diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c4dd6bbf9..478c79a2a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,52 @@ entry. See `CONTRIBUTING.md` § Releases & changelog. ## [Unreleased] +### Added — chat-context memory ACL: per-team/channel/user agent memory (#860 W5, design #870) + +2026-08-27 — Agent memory was isolated per AGENT but not per CHAT CONTEXT. What an agent +learned in Teams team A landed in one agent-global tree and was quotable in team B on the +next turn. This wave partitions that tree by chat context, fail-closed. + +- **Scope grammar.** `ScopedMemoryStore` gains `team::*`, `channel::*` and + `user::*`, plus an `ro:` access modifier. The context trees live under a + NEW top-level segment `/memories/contexts/`, deliberately not under + `/memories/orchestrators//`: `orchestrator::*` matches only the agent tree, so + no legacy scope reaches a context tree and no context scope reaches the agent tree. `ro:` + is a veto rather than a weak grant — an overlapping pattern cannot silently re-grant write + to a path it protects. +- **Context key.** `memoryContextKey(channelType, nativeId)` is the single sanitiser behind + every ``, every physical path, every purge selector and the promote route. A + lossless id passes through byte-identically; anything else keeps a readable stem plus a + 64-bit digest of the RAW input, and the two output spaces are kept disjoint so an id + spelled like a digest cannot pre-image another context's tree. The axes derivation keys on + an injective tuple of the scope's structural parts, not on its wire form — `group:x` as a + group ref and as a conversation id are two contexts, not one. +- **Per-turn binding.** `MemoryBinder.forOrigin()` resolves one stack per chat context + (LRU-cached) at the start of each turn, and the orchestrator threads it to `dispatchTool` + as an explicit parameter — never through AsyncLocalStorage, where a generator resumed in + its caller's context would lose it silently and widen the scope rather than fail. +- **What a context turn may do.** Write its own tier; read the agent tier (`ro:`); read but + NOT write the shared trees (`core`, `sessions`, `chat-sessions`, `_*`), which are the one + model-facing surface two contexts address by the same path. New knowledge leaves a context + only through the operator promote action. +- **Operator surfaces.** The Danger-Zone purge axes `user`/`team`/`channel` get a scratch + footprint for the first time and delete the named context tree across every agent; a + selector without a `~` half is now refused with `invalid_selector` instead of + silently matching nothing. New `POST|GET /api/v1/admin/memory/promotions/:slug` copies or + moves knowledge between an agent's tiers, on the same auth gate as purge, audited three + ways (JSONL log, provenance frontmatter, `[security-audit]` line). The memory browser gains + a context dimension, a promote dialog and an audit tab. +- **No flag day.** Per-agent `agents.context_memory` (`off` | `enforce` | `enforce-strict`, + migration 0050) defaults to `off`, and `ChatTurnInput.origin` is optional. Every + combination of old/new middleware and old/new channel plugin behaves exactly as it does + today until an operator switches an agent over; unknown and NULL flag values read as `off`. + +Two properties are worth remembering because they cost a rewrite each. `formatSessionScope` +is injective only over the strings `parseSessionScope` emits, while the design has channel +adapters build scopes directly — so it cannot be used to key a security boundary. And a +sanitise-or-hash key function is not injective unless the two branches have disjoint output +spaces; without that, the hash branch is pre-imageable by anyone who can name their own id. + ### Fixed — plugin ingest rejected the Teams app-package template (#860 W1a) 2026-08-26 — A store update to `@omadia/channel-teams` 0.21.0 failed on the live diff --git a/docs/middleware-agent-handoff.md b/docs/middleware-agent-handoff.md index d8dbb6e94..136e70169 100644 --- a/docs/middleware-agent-handoff.md +++ b/docs/middleware-agent-handoff.md @@ -2407,3 +2407,168 @@ Teams-Identity-Routen aus `operatorAgents.ts` (Datei > 800 Zeilen) bei nächster (22), `test/operatorAgentsRouter.test.ts` (42), `test/agentTeamsIdentityStore.pg.test.ts` (9, gegen echtes Postgres, wendet Migration 0049 doppelt an), `coreMigrations.pg.test.ts` (Double-Apply aller 49 Files). + +## Chat-Kontext-Memory-ACL (W5, #860 / #870, 2026-08-27) + +**Das Problem.** Agent-Memory war pro AGENT isoliert (`ScopedMemoryStore` über +`['core', 'orchestrator::*']`), nicht pro CHAT-KONTEXT. Was ein Agent in Teams-Team A +lernte, landete im agent-globalen Baum und war im nächsten Turn in Team B zitierbar. W5 +partitioniert diesen Baum nach Chat-Kontext. + +### Scope-Grammatik + +`ScopedMemoryStore` versteht drei neue Tokens plus einen Modifier. Physische Wurzeln kommen +ausschliesslich aus `contextTierRoot(agentSlug, axis, ctxKey)` — eine zweite Schreibweise +wäre eine Partition, die der kompilierte Scope nicht gewährt: + +| Token | matcht | +|---|---| +| `team::*` | `/memories/contexts//team//…` | +| `channel::*` | `/memories/contexts//channel//…` | +| `user::*` | `/memories/contexts//user//…` | +| `ro:` | Access-Modifier: read/list/exists ja, write/delete/rename → `MemoryScopeViolation` | + +`/memories/contexts/` ist ein **neues Top-Level-Segment**, nicht ein Unterbaum von +`/memories/orchestrators/`. Das ist das strukturelle Kollisionsfreiheits-Argument: +`orchestrator::*` matcht ausschliesslich den Agent-Baum, also erreicht kein Alt-Scope +einen Kontextbaum und kein Kontext-Scope den Agent-Baum. **Nicht "aufräumen".** + +`ro:` ist ein **Veto**, kein schwaches Grant: matcht ein `ro:`-Pattern den Pfad, wird der +Write abgelehnt, auch wenn ein zweites Pattern ihn gewähren würde. Sonst re-öffnet jedes +überlappende Pattern still das Tier, das `ro:` quarantänisieren soll. + +`/memories/core/audit/` ist für **jeden** Agent unbeschreibbar (Deny-Prefix vor jeder +positiven Prüfung). Dort liegt das Promote-Audit-Log; `core` ist ein Read/Write-Grant, das +jeder Agent hält, also könnte ein Agent ohne diesen Ausschnitt das Protokoll dessen +überschreiben, was ein Operator mit seinem Memory gemacht hat. + +### Kontext-Key + +`memoryContextKey(channelType, nativeId)` (`harness-channel-sdk/src/scopeId.ts`) ist der +**einzige** Sanitizer: `${channelType}~${safeKey(nativeId)}`. Jeder `` in der +Grammatik, jeder physische Pfad, jeder Purge-Selector und die Promote-Route gehen da durch. +Ein Ad-hoc-`replace(/[^a-z0-9]/g,'-')` irgendwo anders reisst das Loch wieder auf, das +`scopeGraphKey` geschlossen hat: eine Teams-Conversation-Id ist `19:abc@thread.tacv2`, und +plain sanitisiert kollidiert sie mit dem Literal `19-abc-thread-tacv2`. + +Zwei Eigenschaften sind sicherheitstragend: + +- **Injektiv.** Ein bereits verlustfreier Id (`/^[a-z0-9_-]{1,64}$/`) geht byte-identisch + durch, alles andere bekommt Stem + 64-Bit-sha256-Digest des ROHEN Strings. Die beiden + Ausgaberäume sind **disjunkt** — ein Id, der wie ein Digest aussieht (`…-<16 hex>`), wird + selbst gehasht. Ohne das könnte jemand, der seine eigene Conversation-Id benennen kann, + den Key eines gehashten Kontexts vorbilden und in dessen Baum landen. +- **`~` liegt ausserhalb des Safe-Alphabets** → die Zerlegung ist eindeutig und ein Key kann + nie ein `:` tragen, das das `team::*`-Format bräche. + +`memoryAxesForOrigin` keyt **nicht** auf `formatSessionScope(scope)`, sondern auf eine +injektive JSON-Tupel-Kodierung der strukturellen Scope-Teile. Die Wire-Form ist nur über der +Teilmenge injektiv, die `parseSessionScope` emittiert — und Adapter bauen Scopes direkt. +Sonst teilen sich `{kind:'group',groupRef:'x'}` und +`{kind:'conversation',conversationId:'group:x'}` ein Tier. + +### Effective Scope (statisch ∩ dynamisch) + +``` +scope = axes.isContextFree + ? ['core', `orchestrator:${slug}:*`] // exakt heute + : ['ro:core', `ro:orchestrator:${slug}:*`, …axes.patterns] // enforce + : ['ro:core', …axes.patterns] // enforce-strict +``` + +- **Fail-closed.** Fehlender `origin`, `unscoped`, `system`, unbekannter `channelType`, + unbrauchbare Patterns → Zeile 1 der Tabelle, byte-identisch zu heute, kein Kontextbaum + erreichbar. `axes.patterns` ist eine **Allowlist**: alles ausserhalb der drei Tier-Tokens + wird verworfen und geloggt, denn diese Liste kommt über eine Paketgrenze aus einem + unabhängig versionierten Channel-Plugin. +- **Agent-Tier ist read-only.** Sonst wäre "notiere das global" ein permanenter Leak-Kanal + von Team A nach Team B. +- **`ro:core`, nicht `core`.** Die Shared-Bäume (`core`, `sessions`, `chat-sessions`, + Top-Level `_*`) reicht der Namespacer unverändert durch — sie sind die EINE modellseitige + Fläche, die zwei Kontexte unter demselben Pfad ansprechen. Schreibbar wäre + `/memories/core/notes.md` ein Einzeiler-Bypass der ganzen ACL. +- **Nie ein Throw auf dem Message-Pfad.** Kaputte Axes degradieren auf den Agent-Privat-Scope + und loggen laut (`[security-audit]`) — in BEIDEN Modi, weil ein Plugin-Bug sonst unsichtbar + bleibt. + +### Turn-Bindung + +`MemoryBinder.forOrigin(origin)` liefert synchron und LRU-gecacht (Cap 256) den Stack +`DurableRulesMemoryStore?( ContextMemoryNamespacer( ScopedMemoryStore(scope, rootStore) ) )`. +Der Orchestrator ruft das **einmal am Turn-Anfang** und reicht das Ergebnis als **expliziten +Parameter** bis `dispatchToolInner` durch — ausdrücklich **nicht** über `turnContext` +(AsyncLocalStorage). Ein Generator wird im Async-Kontext seines Aufrufers fortgesetzt; genau +so hat `turnContext.enter` vor W3-A auf jedem Streaming-Turn den Kontext still verloren. Eine +so verlorene Bindung würde nicht fehlschlagen, sie würde leise den Scope weiten. + +Modellseitig (nur im Kontext-Modus, sonst byte-identischer Prompt): + +``` +/memories/… → engstes Tier des Turns (Kanal bzw. User) +/memories/~team/… → Team-Tier (rw, nur wenn eine Team-Achse existiert) +/memories/~agent/… → Agent-Baum (ro; Enforcement macht der Store, nicht der Mapper) +``` + +`~` ist kollisionsfrei, weil der bestehende Namespacer nie `~`-Segmente nach aussen emittiert. + +### Rollout + +`agents.context_memory` (Migration `0050_agent_context_memory_flag.sql`), `off` | `enforce` | +`enforce-strict`, **Default `off`**. `off` plus optionales `origin` ⇒ jede Kombination aus +alter/neuer Middleware und altem/neuem Channel-Plugin verhält sich wie heute, bis ein +Operator umschaltet. Kein Flag-Day. Unbekannte/NULL-Werte lesen sich als `off` +(deny-default), damit ein Rollback das Memory-Routing nicht ändert. + +`buildOrchestrator` baut den Binder **unbedingt** und gated per Modus — `off` und der heutige +Stack sind ein Codepfad, damit der Schalter nicht von dem wegdriftet, was er schaltet. +`ChatSessionStore`/`SessionLogger` bleiben auf dem statischen `scopedStore`: Session- +Transkripte bleiben geteilt unter `core/sessions` (Entscheidung A3a). + +HTTP/API-Turns emittieren **kein** `origin` (Koordinator-Entscheidung 1). Deren +Scope-Strings (`http-`, client-gewählte `sessionId`, das geteilte `'http-default'`) +sind vom Caller gelieferte Transkript-Labels — daraus eine Memory-Partition abzuleiten hiesse, +jedem API-Client das Tier eines anderen benennbar zu machen. + +### Purge & Promote + +**Purge** (`/api/v1/admin/memory/purge`): `axis:'team'|'channel'|'user'` hat erstmals einen +Scratch-Footprint und löscht den Kontextbaum über ALLE Agenten (Enumeration via +`store.list('/memories/contexts')`, nur list+delete, also backend-agnostisch). `axis:'agent'` +nimmt `/memories/contexts/` mit, `axis:'all'` erfasst `contexts` gratis (nicht in +`PROTECTED_SEED_ENTRIES`). Selector-Semantik: **immer** `~`; ohne `~` → +400 `invalid_selector`, denn eine Danger-Zone-Geste, die nichts löscht und Erfolg meldet, ist +schlimmer als ein Fehler. Beide Lesarten (verbatim Key / roher Native-Id) werden aufgelöst +und die Vereinigung der real existierenden Bäume gelöscht — `memoryContextKey` ist auf seiner +eigenen Digest-Form bewusst nicht idempotent. Das server-seitige Type-to-confirm prüft +weiterhin gegen den **getippten** Selector, nie gegen den abgeleiteten `ctxKey`. + +**Promote** (`POST|GET /api/v1/admin/memory/promotions/:slug`, gleiches `requireAuth`-Gate +und gleicher Prefix wie Purge): kopiert/verschiebt Files und Subtrees zwischen den Tiers +EINES Agenten. Das ist der einzige Weg, auf dem Wissen eine Kontextgrenze überschreitet. +Audit dreifach: JSONL-Zeile in `/memories/core/audit/memory-promotions.jsonl`, +Provenance-Frontmatter (`promoted-from`/`-by`/`-at`) im Ziel-File, `[security-audit]`-Logzeile. +Läuft auf dem ROOT-Store (undekoriert), Präzedenz `memoryPurge`. + +Zwei Fallen, die real waren: `move` löscht nur die Files, die es auch geschrieben hat — der +rekursive `delete(sourceRoot)` hätte Dotfiles vernichtet, die `store.list()` gar nicht +aufzählt (der Walk überspringt `.`-Namen, in-memory wie Postgres). Und ein Ziel, das im +Quellbaum liegt (oder umgekehrt), wird abgelehnt: `move` hätte das frisch geschriebene Ziel +mit der Quelle zusammen gelöscht und Erfolg gemeldet. + +### Tests (Store-Level, kein LLM-Output; Per-Test-Fixtures) + +`test/memoryContextKey.test.ts` (Injektivität, Pre-Image-Schutz), +`test/memoryAxesForOrigin.test.ts` (§2-Tabelle als Cases + Cross-Kind-Kollisionen), +`test/scopedMemoryStore.contexts.test.ts` (Token-Matrix × read/write, `ro:`, +Kollisionsfreiheit), `test/effectiveMemoryScope.test.ts` (fail-closed + Golden gegen +`orchestratorMemoryScope`), `test/contextMemoryNamespacer.test.ts` (Bijektion), +`test/memoryContextIsolation.test.ts` (**der Abnahmetest**: Team A ↮ Team B, Kanal ↮ Kanal, +User ↮ User, Shared-Namespace als Seitenkanal, Audit-Log, `off`-Golden, `enforce-strict`), +`test/memoryBinder.cache.test.ts` (LRU + Key-Kollisionsfreiheit), +`test/memoryPurge*.test.ts`, `test/memoryPromote*.test.ts`. + +**Offene Follow-ups:** Der Memory-Browser im web-ui liest die Kontext-Bäume noch über den +dev-only `GET /bot-api/dev/memory/list` — in Produktion nicht gemountet, also dort inert. Eine +operator-authentifizierte Listing-Route (gleiches Gate wie Purge) ist der nächste Schritt. +Die Channel-Plugins (`omadia-channel-teams`, `omadia-channel-telegram`) bauen den `TurnOrigin` +in ihren EIGENEN Repos; die können erst nach Release des SDK mit `TurnOrigin` gebaut werden. diff --git a/middleware/migrations/0050_agent_context_memory_flag.sql b/middleware/migrations/0050_agent_context_memory_flag.sql new file mode 100644 index 000000000..0c5dbc475 --- /dev/null +++ b/middleware/migrations/0050_agent_context_memory_flag.sql @@ -0,0 +1,42 @@ +-- Epic #860 / W5 — per-Agent rollout switch for chat-context-scoped memory. +-- +-- Agent memory is isolated per AGENT today: what an Agent learns in Teams team +-- A lands in one agent-global tree and is quotable in team B on the next turn. +-- W5 partitions that tree by chat context. This column is the switch that turns +-- the partitioning on, per Agent. +-- +-- 'off' — DEFAULT. Byte-identical to today: every turn gets the +-- agent-private memory stack, whether or not its channel +-- plugin sends a TurnOrigin. +-- 'enforce' — a context turn writes into its own tier and reads the +-- agent tier READ-ONLY, so existing knowledge stays +-- quotable but "note this globally" stops being a leak +-- channel from team A into team B. +-- 'enforce-strict' — full quarantine: a context turn cannot even read the +-- agent tier. +-- +-- Default 'off' is the no-flag-day guarantee: every existing row reports 'off' +-- the moment this lands, so no deployment changes behaviour until an operator +-- flips an Agent deliberately. NOT NULL + DEFAULT rather than a nullable +-- column, so a NULL can never be read as "some other mode". +-- +-- The CHECK constraint is created separately and guarded, because +-- `ADD CONSTRAINT` has no IF NOT EXISTS in PostgreSQL and this migration must +-- be applicable twice (schema CI gate). +ALTER TABLE agents + ADD COLUMN IF NOT EXISTS context_memory TEXT NOT NULL DEFAULT 'off'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'agents_context_memory_check' + ) THEN + ALTER TABLE agents + ADD CONSTRAINT agents_context_memory_check + CHECK (context_memory IN ('off', 'enforce', 'enforce-strict')); + END IF; +END +$$; + +COMMENT ON COLUMN agents.context_memory IS + 'W5 memory-ACL rollout switch: off | enforce | enforce-strict. Default off = today''s agent-global memory.'; diff --git a/middleware/packages/harness-channel-api/src/chatRouter.ts b/middleware/packages/harness-channel-api/src/chatRouter.ts index 930029d9a..cee87b9aa 100644 --- a/middleware/packages/harness-channel-api/src/chatRouter.ts +++ b/middleware/packages/harness-channel-api/src/chatRouter.ts @@ -170,6 +170,23 @@ export function createApiChatRouter(deps: ApiChatRouterDeps): Router { }; try { + // W5 memory-ACL (#860), coordinator decision 1 — this router emits NO + // `metadata.origin`, so an API turn resolves context-free and gets the + // agent-private memory stack, byte-identical to today. Deliberate: + // + // - An API key is its own identity, not a delegate for a human in a + // team or a channel (issue #438, see below), so there is no team or + // channel this turn could honestly be said to belong to. + // - `conversationId` is caller-supplied and only becomes safe after + // the `internalConversationId` hash below. Deriving a memory + // partition from the pre-hash value would let one caller name + // another's tier; deriving it from the post-hash value would create + // a per-key tier that no operator surface can list or purge by any + // name a human knows. + // + // Giving API callers context memory means resolving a real tenant from + // the key and emitting an explicit `origin` — a deliberate change, not + // something to inherit by accident. const turn: IncomingTurn = { channelId: deps.channelId, // Namespaced by key identity: CoreApi derives its scope as diff --git a/middleware/packages/harness-channel-sdk/src/chatAgent.ts b/middleware/packages/harness-channel-sdk/src/chatAgent.ts index 783482702..cd718fd37 100644 --- a/middleware/packages/harness-channel-sdk/src/chatAgent.ts +++ b/middleware/packages/harness-channel-sdk/src/chatAgent.ts @@ -9,6 +9,7 @@ import type { } from './outgoing.js'; import type { SurfaceStreamEvent, PendingCanvasSurface } from './surface.js'; import type { EnvelopeProvenance } from './provenance.js'; +import type { TurnOrigin } from './turnOrigin.js'; /** * Orchestrator surface contract — the duck-typed interface every chat-handling @@ -407,6 +408,20 @@ export interface ChatTurnInput { * Set only by the canvas channel; absent → the skeleton path is unchanged. */ canvasState?: { basedOnRevision: string; currentTree: unknown }; + /** + * Kontext-Herkunft des Turns. Fehlt → kontextfreier Memory-Scope (fail-closed). + * + * W5 memory-ACL (design #870 §4/§5): the one contract extension the + * chat-context memory ACL needs. `ChatAgent.chat()` is the only surface every + * channel adapter shares, so this is where "which team / channel / user is + * this turn from" can be stated once instead of per connector. The + * orchestrator resolves it to memory axes at the start of the turn + * (`memoryAxesForOrigin`) and never lets the model see it. + * + * Optional on purpose — an older channel plugin sends nothing, resolves to + * the context-free axes and behaves exactly as it does today. + */ + origin?: TurnOrigin; } /** diff --git a/middleware/packages/harness-channel-sdk/src/index.ts b/middleware/packages/harness-channel-sdk/src/index.ts index 3fb85c9ad..c472de947 100644 --- a/middleware/packages/harness-channel-sdk/src/index.ts +++ b/middleware/packages/harness-channel-sdk/src/index.ts @@ -274,6 +274,7 @@ export { SYSTEM_SCOPE_ORIGINS, formatSessionScope, isAddressableScope, + memoryContextKey, parseSessionScope, scopeGraphKey, unsharedConversationScope, @@ -282,6 +283,21 @@ export { type UnscopedReason, } from './scopeId.js'; +// W5 memory-ACL — where a turn came from, and the memory axes that follow. +// Re-exported from the package root because the PRODUCERS live outside this +// repository: `omadia-channel-teams` and `omadia-channel-telegram` resolve +// `@omadia/channel-sdk` to this package's built `dist/index.d.ts`, so a type +// that is not named here cannot be named by a channel plugin at all. +export { + CONTEXT_FREE_MEMORY_AXES, + CONTEXT_MEMORY_CHANNEL_TYPES, + memoryAxesForOrigin, + teamAxisKey, + type MemoryAxes, + type MemoryAxis, + type TurnOrigin, +} from './turnOrigin.js'; + // #333 Phase 1 — the typed principal. Same home as `ScopeId` and for the same // reason: Conductor (`middleware/src`), the orchestrator and the kernel all // depend on this package and it depends on none of them. `Principal` says WHO; diff --git a/middleware/packages/harness-channel-sdk/src/scopeId.ts b/middleware/packages/harness-channel-sdk/src/scopeId.ts index bc6974027..d971abd04 100644 --- a/middleware/packages/harness-channel-sdk/src/scopeId.ts +++ b/middleware/packages/harness-channel-sdk/src/scopeId.ts @@ -287,3 +287,101 @@ export function scopeGraphKey(rawScope: string): string { // keeps the key well-formed without collapsing it onto `'unscoped'`. return `${stem.length > 0 ? stem : 'scope'}-${digest}`; } + +/** + * Context-key constraints. Same construction as `scopeGraphKey` above, a + * tighter budget: a context key is a PATH SEGMENT under + * `/memories/contexts///`, and it is embedded in the scope pattern + * `` `team::*` `` — so it must stay short and must never carry a `:`. + */ +const CONTEXT_KEY_MAX_LEN = 64; +const CONTEXT_KEY_SAFE = /^[a-z0-9_-]{1,64}$/; + +/** + * The channel-type / id separator. Deliberately OUTSIDE the safe alphabet, so + * splitting a key at its first `~` recovers exactly the two parts that made it. + */ +const CONTEXT_KEY_SEPARATOR = '~'; + +/** + * Placeholder stem for an id that sanitizes to nothing (punctuation-only, or + * empty). Keeps the segment well-formed; the digest still carries the identity. + */ +const CONTEXT_KEY_EMPTY_STEM = 'id'; + +/** + * The shape the digest branch below always produces: `…-<16 lowercase hex>`. + * + * Load-bearing for injectivity, not cosmetic. The two branches of + * {@link safeContextSegment} would otherwise share one output space: an + * already-safe id spelled `x-61d6ea9c6d461bda` is carried through verbatim by + * the passthrough branch and is ALSO what the digest branch emits for the + * unsafe id `X!` (sha256('X!').slice(0,16) === '61d6ea9c6d461bda'). A caller + * who can name their own conversation id could then pre-image another + * context's key and land in its memory tree. Forcing every raw id of this + * shape down the digest branch makes the two output spaces disjoint, so the + * segment function is injective wherever sha256-16 is collision-free. + */ +const CONTEXT_KEY_DIGEST_SHAPE = new RegExp(`-[0-9a-f]{${DIGEST_LEN}}$`); + +/** + * One key segment: byte-identical when it is already lossless AND cannot be + * confused with a digest, stem + digest otherwise. This is `scopeGraphKey`'s + * body with the context budget — kept as its own function rather than a + * parameter on `scopeGraphKey` because the two keys must be free to diverge + * without silently moving graph partitions. + */ +function safeContextSegment(raw: string): string { + if (CONTEXT_KEY_SAFE.test(raw) && !CONTEXT_KEY_DIGEST_SHAPE.test(raw)) return raw; + + const digest = createHash('sha256').update(raw, 'utf8').digest('hex').slice(0, DIGEST_LEN); + const stem = raw + .replace(/[^a-zA-Z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase() + .slice(0, CONTEXT_KEY_MAX_LEN - DIGEST_LEN - 1); + return `${stem.length > 0 ? stem : CONTEXT_KEY_EMPTY_STEM}-${digest}`; +} + +/** + * W5 memory-ACL — the partition key for a chat context's memory tree. + * + * Every `` in the store grammar (`team::*`, `channel::*`, + * `user::*`), every physical path under + * `/memories/contexts////`, every purge selector and the + * promote route go through THIS function. It is the single choke point on + * purpose: an ad-hoc `replace(/[^a-z0-9]/g, '-')` anywhere else re-opens the + * hole `scopeGraphKey` was written to close. + * + * Why a digest at all: the old `sanitizeScope` collapse is not injective, and + * that stops being a recall nuisance the moment the key is a SECURITY boundary. + * A Teams conversation id is `19:abc@thread.tacv2` — it carries `:` and `@`, so + * plain sanitizing maps it onto the same key as the literal `19-abc-thread-tacv2` + * and onto every sibling that differs only in punctuation. Two teams that must + * not see each other's notes would then share one memory tree while every + * equality check still passes. + * + * The shape is `` `${lower(channelType)}~${safeKey(nativeId)}` ``: + * + * - `channelType` is a TYPE TOKEN, so it is case- and whitespace-normalised + * before hashing — `'Teams'`, `'teams'` and `' teams '` are the same channel. + * - `nativeId` is IDENTITY, so it is hashed byte-exact: `' c1'` and `'c1'` are + * two ids and get two partitions. Over-partitioning is the safe direction. + * - `~` is outside the safe alphabet, so the split back into channel type and + * id is unambiguous, and the result can never contain a `:` that would break + * the `` /^team:([^:]+):\*$/ `` pattern format. + * + * Injective wherever a 64-bit sha256 prefix is collision-free: an already-safe + * id is carried through byte-identically, anything else keeps a 64-bit digest + * of the RAW input beside its readable stem, and the two output spaces are kept + * DISJOINT by {@link CONTEXT_KEY_DIGEST_SHAPE} — without that a safe id spelled + * in the digest branch's own shape would pre-image a hashed context's key. + * + * Examples: `teams~19-abc-thread-tacv2-a1b2c3d4e5f60718`, + * `telegram~-1001234567890`, `api~tenant-acme`. + */ +export function memoryContextKey(channelType: string, nativeId: string): string { + const type = safeContextSegment((channelType ?? '').trim().toLowerCase()); + const id = safeContextSegment(nativeId ?? ''); + return `${type}${CONTEXT_KEY_SEPARATOR}${id}`; +} diff --git a/middleware/packages/harness-channel-sdk/src/turnOrigin.ts b/middleware/packages/harness-channel-sdk/src/turnOrigin.ts new file mode 100644 index 000000000..62f465624 --- /dev/null +++ b/middleware/packages/harness-channel-sdk/src/turnOrigin.ts @@ -0,0 +1,282 @@ +/** + * W5 memory-ACL — `TurnOrigin`: where a chat turn came from, and the memory + * axes that follow from it (design: issue #870 §2, §5). + * + * Agent memory is isolated per AGENT today (`ScopedMemoryStore` over + * `['core', 'orchestrator::*']`) but not per CHAT CONTEXT: what an agent + * learns in Teams team A lands in the agent-global tree and is quotable in team + * B on the next turn. Closing that hole needs one thing the kernel does not have + * yet — a typed statement of WHERE a turn came from, carried on the single + * contract every channel adapter shares. + * + * `ChatAgent.chat(input)` is that contract. Teams, Telegram, the HTTP dev route + * and the CLI all go through it, so `ChatTurnInput.origin` is the only place the + * injection can live without becoming channel-specific. It is optional: an old + * channel plugin that sends no `origin` resolves to the context-free axes, which + * are byte-identical to today's behaviour. No flag day. + * + * `memoryAxesForOrigin` is the pure translation from that origin to the scope + * patterns of §3. It decides NOTHING about enforcement — `effectiveMemoryScope` + * intersects these axes with the static agent scope, and `ScopedMemoryStore` + * remains the backstop that turns a mapping bug into a `MemoryScopeViolation` + * rather than a leak. Keeping this function pure and synchronous is what makes + * the security decision testable as a table instead of as an integration. + * + * **Fail-closed is the whole design.** Every path that cannot name a context + * with confidence returns {@link CONTEXT_FREE_MEMORY_AXES} — row 1 of the §2 + * table, exactly what a turn does today. Note which direction that fails in: + * context-free grants read/write on the agent-private tier and reaches NO + * context tree, so an unrecognised turn can never see team A's notes. Guessing + * a context would be the unsafe direction; refusing one is not. + */ + +import type { Principal } from './principal.js'; +import { memoryContextKey, type ScopeId } from './scopeId.js'; + +/** + * The three context tiers a turn can reach, narrowest-first in the sense that + * matters here: `channel` and `user` are per-conversation, `team` spans the + * conversations of one container. + */ +export type MemoryAxis = 'team' | 'channel' | 'user'; + +/** + * Where a turn came from, in the platform-agnostic terms the memory ACL needs. + * + * Built by the channel adapter from the platform-native event, immediately + * beside the `sessionScope` it already builds — see §4 of the design for the + * per-channel recipes (Teams: `parseSessionScope(sessionScope)` plus + * `channelData.team?.id`; Telegram: `chat.type` decides personal vs. + * conversation and never yields a container). + */ +export interface TurnOrigin { + /** Plugin-/Channel-Typ, z.B. 'teams' | 'telegram' | 'http' | 'api'. */ + readonly channelType: string; + /** Conversation-Scope des Turns — bestehender #575-Typ. */ + readonly scope: ScopeId; + /** Umgebender Container, wenn die Plattform einen liefert (Teams-Team, API-Mandant). */ + readonly container?: { readonly kind: 'team' | 'tenant'; readonly id: string }; + /** + * Sprechende Person — bestehender #333-Typ. + * + * Carried for audit and for the promote action's actor, NOT for the axis + * derivation: on a personal chat the scope's own `userId` is the authority on + * whose tier this is, and deriving the user key from a second field would let + * the two disagree. See {@link memoryAxesForOrigin}. + */ + readonly principal?: Principal; +} + +/** The memory axes a turn may reach, in the scope grammar of design §3. */ +export interface MemoryAxes { + readonly isContextFree: boolean; + /** Scope-Patterns in Grammatik aus Abschnitt 3, z.B. ['channel:teams~…:*', 'team:teams~…:*']. */ + readonly patterns: readonly string[]; + /** Engstes Tier — bestimmt das privateRoot des Namespacers. */ + readonly narrowest?: { readonly axis: MemoryAxis; readonly ctxKey: string }; +} + +/** + * Row 1 of the §2 table: no context tree is reachable, the agent-private tier + * stays read/write. Frozen and shared because every fail-closed path returns + * the same value and a caller must not be able to mutate it into a wider one. + */ +export const CONTEXT_FREE_MEMORY_AXES: MemoryAxes = Object.freeze({ + isContextFree: true, + patterns: Object.freeze([]) as readonly string[], +}); + +/** + * The channel types whose turns may reach a context tree. + * + * An allowlist rather than "any non-empty string" because the §2 table is a + * per-channel statement: it says what a Teams team channel means, what a + * Telegram private chat means, what an API turn with a tenant means. A channel + * nobody has reasoned about has no row, and inventing one for it would be + * exactly the guess this design refuses. A new channel therefore behaves like + * today (context-free) until someone adds it here together with its recipe in + * §4 — a deliberate act, reviewable as a one-line diff. + * + * Matching is on the trimmed, lower-cased type token, the same normalisation + * `memoryContextKey` applies to its type segment. + */ +export const CONTEXT_MEMORY_CHANNEL_TYPES: ReadonlySet = Object.freeze( + new Set(['teams', 'telegram', 'http', 'api']), +); + +/** + * A context key for `nativeId`, or `undefined` when `identity` names nothing. + * + * The blank check runs on the IDENTITY-bearing field rather than on the string + * that gets keyed, because the keyed string is often a composed one + * (`` `org:${orgId}` ``, `` `group:${groupRef}` ``): a blank id composes into a + * perfectly non-blank `'org:'` that every such turn would then share — the + * shared-bucket hole in a new place. + * + * The check itself runs on a trimmed copy while the RAW string is what gets + * keyed: `memoryContextKey` hashes identity byte-exact on purpose (`' c1'` and + * `'c1'` are two ids), and trimming here would produce a key that no purge + * selector or promote route computing the same id could reproduce. + */ +function contextKeyFor( + channelType: string, + identity: string, + nativeId = identity, +): string | undefined { + if ((identity ?? '').trim().length === 0) return undefined; + return memoryContextKey(channelType, nativeId); +} + +/** + * An injective string for a tuple of scope parts. + * + * `JSON.stringify` of an array of strings is unambiguous — every `"` and `\` + * inside a part is escaped, so no two different tuples can produce one string. + * That is the whole reason it is used here instead of joining with a + * separator. + * + * The alternative, `formatSessionScope(scope)`, is NOT safe for this job even + * though it is the canonical wire form. It is injective only over the string + * subset `parseSessionScope` emits, while §4 has the Telegram adapter build + * conversation scopes DIRECTLY, so that precondition is not guaranteed — + * `{kind:'group', groupRef:'x'}` and `{kind:'conversation', + * conversationId:'group:x'}` both format to `group:x`, and its + * `CONVERSATION_SEPARATOR` is not escaped either, so + * `{channelId:'msteams', conversationId:'c'}` and + * `{conversationId:'msteams::c'}` also collapse. Each collapse is two + * structurally different chats sharing one memory tier. + * + * The readable stem survives: `["conversation","msteams","c1"]` sanitises to + * the stem `conversation-msteams-c1`, so the operator still recognises the + * tree — it just carries a digest of the exact tuple beside it. + */ +function scopeTuple(...parts: ReadonlyArray): string { + // Absent optional parts are OMITTED rather than encoded as null, purely so + // the sanitised stem reads as `conversation-c1` instead of + // `conversation-null-c1`. Injectivity is unaffected: JSON arrays of different + // length are different strings, and only trailing optionals are ever absent. + return JSON.stringify(parts.filter((p): p is string => p !== undefined)); +} + +/** + * The per-conversation axis of a scope: `channel` for conversation/group scopes, + * `user` for personal ones, none for anything else. + * + * A personal scope keys on `userId` alone rather than on a tuple, because the + * user tier is about the PERSON: the same human's private chat should land in + * one tree whatever the scope spelling, and `userId` is the whole identity of a + * personal scope anyway. That cannot collide with a conversation key that + * happens to read the same, since the axis is part of both the pattern + * (`user:` vs `channel:`) and the physical path (`…/user/` vs + * `…/channel/`). + */ +function narrowAxisFor( + channelType: string, + scope: ScopeId, +): { readonly axis: MemoryAxis; readonly ctxKey: string } | undefined { + if (scope.kind === 'personal') { + const ctxKey = contextKeyFor(channelType, scope.userId); + return ctxKey === undefined ? undefined : { axis: 'user', ctxKey }; + } + + if (scope.kind === 'conversation') { + const ctxKey = contextKeyFor( + channelType, + scope.conversationId, + scopeTuple(scope.kind, scope.channelId, scope.conversationId), + ); + return ctxKey === undefined ? undefined : { axis: 'channel', ctxKey }; + } + + if (scope.kind === 'group') { + const ctxKey = contextKeyFor( + channelType, + scope.groupRef, + scopeTuple(scope.kind, scope.groupRef), + ); + return ctxKey === undefined ? undefined : { axis: 'channel', ctxKey }; + } + + // `org` has no conversation of its own — it is handled as a container below. + return undefined; +} + +/** + * The team-tier key of a turn, or `undefined` when it has no container. + * + * Two sources, in precedence order: + * + * 1. An explicit `container` — a Teams team or an API tenant. Both land in the + * SAME tier, so the container kind is part of the keyed identity: a team + * `acme` and a tenant `acme` on one channel type must not share a tree. + * 2. An `org` scope with no container. An org scope names a tenant-wide + * audience rather than a conversation, so the team tier is where it + * belongs — and note this is the SAFE direction: mapping it to a team tier + * is strictly narrower than the context-free row it would otherwise take. + */ +function teamKeyFor(channelType: string, origin: TurnOrigin): string | undefined { + const container = origin.container; + if (container !== undefined) { + // Defensive: `container.kind` is typed, but this value crosses a plugin + // boundary from an independently versioned channel package. + if (container.kind !== 'team' && container.kind !== 'tenant') return undefined; + return contextKeyFor(channelType, container.id, scopeTuple(container.kind, container.id)); + } + + if (origin.scope?.kind === 'org') { + const orgId = origin.scope.orgId; + return contextKeyFor(channelType, orgId, scopeTuple('org', orgId)); + } + + return undefined; +} + +/** Pure. unscoped/system/unbekannt → { isContextFree: true, patterns: [] }. */ +export function memoryAxesForOrigin(origin: TurnOrigin | undefined): MemoryAxes { + if (origin === undefined) return CONTEXT_FREE_MEMORY_AXES; + + const channelType = (origin.channelType ?? '').trim().toLowerCase(); + if (!CONTEXT_MEMORY_CHANNEL_TYPES.has(channelType)) return CONTEXT_FREE_MEMORY_AXES; + + const scope = origin.scope; + // `unscoped` is a shared bucket or nothing at all, and a `system` scope has no + // audience by construction (`isAddressableScope`) — neither names a context. + if (scope === undefined || scope.kind === 'unscoped' || scope.kind === 'system') { + return CONTEXT_FREE_MEMORY_AXES; + } + + const narrow = narrowAxisFor(channelType, scope); + const teamKey = teamKeyFor(channelType, origin); + if (narrow === undefined && teamKey === undefined) return CONTEXT_FREE_MEMORY_AXES; + + // Narrowest first: the default write target is the first pattern, and the + // order is what the namespacer reads its `privateRoot` from. + const patterns: string[] = []; + if (narrow !== undefined) patterns.push(`${narrow.axis}:${narrow.ctxKey}:*`); + if (teamKey !== undefined) patterns.push(`team:${teamKey}:*`); + + return Object.freeze({ + isContextFree: false, + patterns: Object.freeze(patterns) as readonly string[], + narrowest: narrow ?? ({ axis: 'team', ctxKey: teamKey as string } as const), + }); +} + +/** `team::*` — the pattern token for one context tier. */ +const TEAM_PATTERN = /^team:([^:]+):\*$/; + +/** + * The team-tier key these axes grant, if any. + * + * Read back off the emitted patterns rather than recomputed, so the `~team` + * alias the namespacer exposes can never point at a tier the compiled scope + * does not actually grant. + */ +export function teamAxisKey(axes: MemoryAxes | undefined): string | undefined { + if (axes === undefined) return undefined; + for (const p of axes.patterns) { + const m = TEAM_PATTERN.exec(p); + if (m) return m[1]!; + } + return undefined; +} diff --git a/middleware/packages/harness-orchestrator/src/buildOrchestrator.ts b/middleware/packages/harness-orchestrator/src/buildOrchestrator.ts index d20f3537f..b2fbc3a49 100644 --- a/middleware/packages/harness-orchestrator/src/buildOrchestrator.ts +++ b/middleware/packages/harness-orchestrator/src/buildOrchestrator.ts @@ -65,6 +65,7 @@ import { CliChatAgent } from './cliChatAgent.js'; import { ToolDispatchService } from './toolDispatchService.js'; import { OrchestratorMemoryNamespacer } from './orchestratorMemoryNamespacer.js'; import { DurableRulesMemoryStore } from './durableRulesMemoryStore.js'; +import { MemoryBinder, type ContextMemoryMode } from './memoryBinder.js'; import { ScopedMemoryStore, orchestratorMemoryScope, @@ -103,6 +104,23 @@ export interface AgentRuntimeConfig { * by the caller from `agent_persona_skills` (see {@link OrchestratorOptions}). * Per-agent, unlike the platform-shared `OrchestratorDeps` fields below. */ readonly personaSkills?: readonly OrchestratorPersonaSkill[]; + /** + * W5 memory-ACL — per-Agent rollout switch for chat-context-scoped memory. + * Read from the `agents.context_memory` column (migration 0050). + * + * - `'off'` (DEFAULT, and what every existing row reports) — byte-identical + * to today: every turn gets the agent-private memory stack, whether or not + * its channel plugin sends a `TurnOrigin`. + * - `'enforce'` — context turns write into their own tier and READ the + * agent tier read-only, so existing knowledge stays quotable. + * - `'enforce-strict'` — full quarantine: a context turn cannot even read + * the agent tier. + * + * Off-by-default plus an optional `origin` is what makes this a no-flag-day + * change: every combination of old/new middleware and old/new channel plugin + * behaves exactly as it does today until an operator flips this. + */ + readonly contextMemory?: ContextMemoryMode; } /** @@ -302,6 +320,50 @@ export function buildOrchestratorForAgent( : namespacedStore; const memoryToolHandler = new MemoryToolHandler(memoryToolStore); + // W5 memory-ACL — the per-CHAT-CONTEXT binder. `memoryToolHandler` above is + // resolved once, here, for the whole process lifetime; the binder resolves a + // stack per turn from the turn's `TurnOrigin` instead, so what an Agent + // learns in team A is not quotable in team B. + // + // It is built unconditionally and gated by MODE, not by presence: with + // `contextMemory: 'off'` — the default, and what every existing agent row + // reports until an operator changes it — `forOrigin` ignores the origin and + // returns the context-free stack, which is the same ScopedMemoryStore + + // OrchestratorMemoryNamespacer + DurableRulesMemoryStore composition the + // lines above build. One code path, so the rollout switch cannot drift away + // from the thing it is switching. + // + // The binder takes `deps.memoryStore` UNDECORATED on purpose: it owns the + // whole decorator chain, because the scope it enforces is what decides which + // wrappers apply. `chatSessionStore` / `sessionLogger` keep the static + // `scopedStore` — session transcripts stay shared under `core/sessions` + // (decision A3a) and must not be partitioned by chat context. + const memoryBinder = new MemoryBinder({ + agentSlug: config.agentId, + root: deps.memoryStore, + mode: config.contextMemory ?? 'off', + ...(durableRulesHookEnabled + ? { + durableRules: { + pool: deps.graphPool!, + kg: deps.knowledgeGraph, + tenantId: deps.graphTenantId ?? 'default', + ...(deps.embeddingClient + ? { embeddingClient: deps.embeddingClient } + : {}), + log: (msg: string): void => { + console.error(msg); + }, + }, + } + : {}), + log: (msg, fields): void => { + console.error( + `[security-audit] ${msg}${fields ? ` ${JSON.stringify(fields)}` : ''}`, + ); + }, + }); + // Native-tool instances (channel-coupled UI cards + calendar). The calendar // tools are present only when the Microsoft 365 accessor is available. const chatParticipantsTool = new ChatParticipantsTool(); @@ -373,6 +435,7 @@ export function buildOrchestratorForAgent( domainTools: [], nativeToolRegistry: deps.nativeToolRegistry, memoryToolHandler, + memoryBinder, sessionLogger, entityRefBus: deps.entityRefBus, knowledgeGraph: deps.knowledgeGraph, diff --git a/middleware/packages/harness-orchestrator/src/index.ts b/middleware/packages/harness-orchestrator/src/index.ts index b932bd2fc..a349be2ad 100644 --- a/middleware/packages/harness-orchestrator/src/index.ts +++ b/middleware/packages/harness-orchestrator/src/index.ts @@ -78,6 +78,36 @@ export { ScopedMemoryStore, } from './registry/scopedMemoryStore.js'; export type { ScopedMemoryStoreOptions } from './registry/scopedMemoryStore.js'; +// W5 (#860) — chat-context memory ACL. The scope resolver and the tier-root +// helper are exported because they are the contract the promote service, the +// purge service and the tests all have to agree on: a second spelling of +// `/memories/contexts///` anywhere would be a partition the +// compiled scope does not grant. +export { + CONTEXT_AXES, + contextTierRoot, + effectiveMemoryScope, + orchestratorMemoryScope, +} from './registry/scopedMemoryStore.js'; +export type { + ContextAxis, + ContextMemoryEnforcement, + EffectiveMemoryScopeOptions, + MemoryAxes, + MemoryAxis, +} from './registry/scopedMemoryStore.js'; +export { ContextMemoryNamespacer } from './orchestratorMemoryNamespacer.js'; +export type { ContextMemoryNamespacerOptions } from './orchestratorMemoryNamespacer.js'; +export { + DEFAULT_BINDER_CACHE_CAP, + MemoryBinder, + memoryBindingCacheKey, +} from './memoryBinder.js'; +export type { + BoundTurnMemory, + ContextMemoryMode, + MemoryBinderOptions, +} from './memoryBinder.js'; export { ConfigStore, ConfigValidationError, diff --git a/middleware/packages/harness-orchestrator/src/memoryBinder.ts b/middleware/packages/harness-orchestrator/src/memoryBinder.ts new file mode 100644 index 000000000..2fb949b03 --- /dev/null +++ b/middleware/packages/harness-orchestrator/src/memoryBinder.ts @@ -0,0 +1,239 @@ +import { + CONTEXT_FREE_MEMORY_AXES, + memoryAxesForOrigin, + teamAxisKey, + type MemoryAxes, + type TurnOrigin, +} from '@omadia/channel-sdk'; +import { MemoryToolHandler } from '@omadia/memory'; +import type { MemoryStore } from '@omadia/plugin-api'; + +import { + DurableRulesMemoryStore, + type DurableRulesHookDeps, +} from './durableRulesMemoryStore.js'; +import { + ContextMemoryNamespacer, + OrchestratorMemoryNamespacer, +} from './orchestratorMemoryNamespacer.js'; +import { + contextTierRoot, + effectiveMemoryScope, + ScopedMemoryStore, + type ContextMemoryEnforcement, +} from './registry/scopedMemoryStore.js'; + +/** + * `MemoryBinder` (W5, design spec #870 §4/§5). + * + * Today an Agent gets ONE memory stack at build time, so everything it notes + * through the `memory` tool lands in one agent-global tree — and is quotable in + * every other chat the same Agent serves. The binder replaces that single stack + * with one stack PER CHAT CONTEXT, resolved at the start of each turn from the + * turn's `TurnOrigin`: + * + * forOrigin(origin) + * → axes = memoryAxesForOrigin(origin) (pure, §2 table) + * → scope = effectiveMemoryScope(agentSlug, axes) (static ∩ dynamic) + * → stack = DurableRulesMemoryStore?( ContextMemoryNamespacer( + * ScopedMemoryStore(scope, rootStore) ) ) + * + * Four properties are load-bearing: + * + * - **Synchronous.** The result is handed through the turn state explicitly + * rather than carried in async-local storage, so losing the context is a + * structural impossibility rather than an unlikely accident. + * - **Fail-closed.** No origin, a machine scope or an unresolvable one yields + * `['core', 'orchestrator::*']` — byte-identical to today's stack, via + * today's `OrchestratorMemoryNamespacer`. + * - **Rollout-gated.** `mode: 'off'` short-circuits to the context-free stack + * for EVERY origin, so an operator can ship this wave dark. `'off'` is a + * routing decision and lives here, not as a second fail-open branch inside + * `effectiveMemoryScope`. + * - **Cached.** A stack is pure configuration over the shared root store, so + * identical axes reuse one instance. The cache is an LRU keyed by the + * canonical scope string: a busy Agent in hundreds of channels keeps a + * bounded number of wrappers alive, and eviction is never observable + * (evicting only drops the wrapper, never the data underneath). + * + * The root store is passed UNDECORATED (`deps.memoryStore`): the binder owns + * the whole decorator chain. `ChatSessionStore`/`SessionLogger` deliberately + * keep using the static agent-scoped store — session transcripts stay shared + * under `core/sessions` (decision A3a). + */ + +/** Default LRU capacity — see `MemoryBinderOptions.cacheCap`. */ +export const DEFAULT_BINDER_CACHE_CAP = 256; + +/** + * Per-agent rollout switch for context-scoped memory. + * + * `'off'` is the first-release default and means byte-identical-to-today: every + * turn, with or without an `origin`, gets the agent-private stack. + */ +export type ContextMemoryMode = 'off' | ContextMemoryEnforcement; + +/** One turn's bound memory stack. */ +export interface BoundTurnMemory { + /** The model-facing `memory` tool handler for this turn. */ + readonly handler: MemoryToolHandler; + /** + * The store the handler runs on. Additive to the design's shape so callers + * that need store-level access (tests, the promote service, purge previews) + * do not have to re-derive the stack. + */ + readonly store: MemoryStore; + /** The compiled scope this stack enforces. */ + readonly scope: readonly string[]; + /** The axes this stack was built from. */ + readonly axes: MemoryAxes; +} + +export interface MemoryBinderOptions { + readonly agentSlug: string; + /** The kernel `MemoryStore`, undecorated. */ + readonly root: MemoryStore; + /** Durable-rules live hook, when the graph pool is available. */ + readonly durableRules?: DurableRulesHookDeps; + /** LRU capacity; defaults to {@link DEFAULT_BINDER_CACHE_CAP}. */ + readonly cacheCap?: number; + /** Rollout switch; defaults to `'off'` (today's behaviour). */ + readonly mode?: ContextMemoryMode; + /** Warn sink, forwarded to every `ScopedMemoryStore` this binder builds. */ + readonly log?: (msg: string, fields?: Record) => void; +} + +/** + * Separator for the binder's LRU key. + * + * U+001F (UNIT SEPARATOR) rather than a raw NUL: it cannot occur in a slug, an + * axis name or a context key either — `memoryContextKey` emits only + * `[a-z0-9_~-]` — but a NUL byte in the source would make git classify this + * file as binary, which is exactly the wrong property for the most + * security-critical file in the wave. + */ +const CACHE_KEY_SEP = '\u001f'; + +/** + * The binder's LRU key. Exported because "the key does not collide between + * agents or axes" is a property worth asserting directly rather than inferring + * from cache hit counts. + */ +export function memoryBindingCacheKey( + agentSlug: string, + axes: MemoryAxes, + mode: ContextMemoryMode = 'off', +): string { + const narrowest = axes.narrowest + ? `${axes.narrowest.axis}${CACHE_KEY_SEP}${axes.narrowest.ctxKey}` + : ''; + const scope = + mode === 'off' + ? [] + : effectiveMemoryScope(agentSlug, axes, { mode }); + return [agentSlug, mode, narrowest, ...scope].join(CACHE_KEY_SEP); +} + +export class MemoryBinder { + private readonly cacheCap: number; + private readonly mode: ContextMemoryMode; + /** Insertion-ordered — `Map` iteration order is the LRU order. */ + private readonly cache = new Map(); + + constructor(private readonly options: MemoryBinderOptions) { + const cap = options.cacheCap ?? DEFAULT_BINDER_CACHE_CAP; + this.cacheCap = cap > 0 ? Math.floor(cap) : DEFAULT_BINDER_CACHE_CAP; + this.mode = options.mode ?? 'off'; + } + + /** Live cache size. Bounded by `cacheCap`. */ + get cacheSize(): number { + return this.cache.size; + } + + /** + * Resolve the memory stack for one turn. Synchronous and cached; the same + * origin always yields the same instance until it is evicted. + */ + forOrigin(origin: TurnOrigin | undefined): BoundTurnMemory { + // `'off'` discards the origin entirely rather than resolving axes and then + // ignoring them — one branch, so there is no path on which a half-applied + // rollout could still open a context tree. + const axes = + this.mode === 'off' + ? CONTEXT_FREE_MEMORY_AXES + : memoryAxesForOrigin(origin); + const key = memoryBindingCacheKey(this.options.agentSlug, axes, this.mode); + + const hit = this.cache.get(key); + if (hit) { + // Refresh recency: delete + set moves the entry to the end of the + // insertion order, which is what makes eviction least-recently-USED + // rather than least-recently-added. + this.cache.delete(key); + this.cache.set(key, hit); + return hit; + } + + const bound = this.build(axes); + this.cache.set(key, bound); + this.evictOverflow(); + return bound; + } + + private evictOverflow(): void { + while (this.cache.size > this.cacheCap) { + const oldest = this.cache.keys().next(); + if (oldest.done) return; + this.cache.delete(oldest.value); + } + } + + private build(axes: MemoryAxes): BoundTurnMemory { + const { agentSlug, root, durableRules, log } = this.options; + const scope = + this.mode === 'off' + ? effectiveMemoryScope(agentSlug, CONTEXT_FREE_MEMORY_AXES) + : effectiveMemoryScope(agentSlug, axes, { + mode: this.mode, + ...(log ? { log } : {}), + }); + + const scoped = new ScopedMemoryStore({ + agentSlug, + scope, + inner: root, + ...(log ? { log } : {}), + }); + + const namespaced: MemoryStore = + axes.isContextFree || !axes.narrowest + ? new OrchestratorMemoryNamespacer(agentSlug, scoped) + : new ContextMemoryNamespacer( + { + privateRoot: contextTierRoot( + agentSlug, + axes.narrowest.axis, + axes.narrowest.ctxKey, + ), + agentRoot: `/memories/orchestrators/${agentSlug}`, + ...this.teamRootFor(axes), + }, + scoped, + ); + + // Durable-rules decorator stays OUTSIDE the namespacer so it still sees the + // model-facing `_rules/` path (both namespacers pass `_` segments through). + const store: MemoryStore = durableRules + ? new DurableRulesMemoryStore(namespaced, durableRules) + : namespaced; + + return { handler: new MemoryToolHandler(store), store, scope, axes }; + } + + private teamRootFor(axes: MemoryAxes): { teamRoot?: string } { + const key = teamAxisKey(axes); + if (key === undefined) return {}; + return { teamRoot: contextTierRoot(this.options.agentSlug, 'team', key) }; + } +} diff --git a/middleware/packages/harness-orchestrator/src/orchestrator.ts b/middleware/packages/harness-orchestrator/src/orchestrator.ts index a1d654778..67e1cc121 100644 --- a/middleware/packages/harness-orchestrator/src/orchestrator.ts +++ b/middleware/packages/harness-orchestrator/src/orchestrator.ts @@ -67,6 +67,7 @@ import { knowledgeGraphToolSpec, } from './knowledgeGraphTool.js'; import type { MemoryToolHandler } from '@omadia/memory'; +import type { MemoryBinder } from './memoryBinder.js'; import type { ChatParticipantsTool } from './tools/chatParticipantsTool.js'; import { CHAT_PARTICIPANTS_TOOL_NAME, @@ -426,6 +427,24 @@ export interface OrchestratorOptions { * handler is used exactly as before. Wired by `buildOrchestratorForAgent`. */ memoryToolHandler?: MemoryToolHandler; + /** + * W5 memory-ACL — per-CHAT-CONTEXT memory isolation, one level narrower than + * `memoryToolHandler`. + * + * `memoryToolHandler` is resolved once at build time, so everything an Agent + * notes lands in one agent-global tree and is quotable in every other chat + * that Agent serves. When a binder is set, the handler is resolved instead at + * the START OF EACH TURN from `ChatTurnInput.origin` + * (`MemoryBinder.forOrigin`) and threaded down to `dispatchTool` as an + * explicit turn parameter — deliberately NOT through `turnContext` + * (AsyncLocalStorage), because a security decision that can be silently lost + * at an await boundary is not a security decision. + * + * Absent → `memoryToolHandler` is used exactly as before, and so it is for + * every turn whose origin resolves context-free. Wired by + * `buildOrchestratorForAgent`. + */ + memoryBinder?: MemoryBinder; /** * Optional. When set, retrieves conversational context (verbatim tail of * the active chat + entity-anchored and full-text hits from other chats of @@ -1293,6 +1312,33 @@ function withUnscreenedMarker(input: ChatTurnInput): ChatTurnInput { const DEFAULT_ASSISTANT_IDENTITY = 'Du bist ein KI-Assistent, der Anfragen beantwortet, indem er an spezialisierte Fach-Agenten delegiert und Lernpunkte über Sessions hinweg persistent merkt.'; +/** + * W5 memory-ACL — one turn's resolved memory binding. + * + * Threaded as an explicit parameter from the turn entry point down to + * `dispatchToolInner`, never read back out of `turnContext`. `contextBound` + * says whether the turn actually landed in a chat-context tier: it selects the + * matching system-prompt convention, so the model is never told about + * `/memories/~team/` on a turn where that path is not mapped. + */ +interface TurnMemoryBinding { + readonly handler: MemoryToolHandler | undefined; + readonly contextBound: boolean; +} + +/** + * The memory-namespace convention a CONTEXT-BOUND turn gets, replacing the + * "global for this agent" sentence of the default prompt — which is false the + * moment the binder is active and would invite the model to expect notes from + * another chat. + */ +const CONTEXT_MEMORY_PROMPT_BLOCK = `**Memory-Kontext (dieser Chat):** +- Deine Notizen unter \`/memories/\` gelten für DIESEN Chat-Kontext — was du hier schreibst, ist in anderen Teams/Kanälen nicht sichtbar, und umgekehrt. +- Team-weites Wissen liegt unter \`/memories/~team/\` (lesen und schreiben) — nur dorthin schreiben, wenn es für das ganze Team gilt. +- Agent-weites Alt-Wissen liegt **read-only** unter \`/memories/~agent/\`. Schreibversuche dorthin schlagen fehl; wenn etwas dauerhaft agent-weit gelten soll, sag es dem Nutzer, statt es zu erzwingen — ein Operator hebt es dann bewusst hoch. + +`; + function buildSystemPrompt( assistantIdentity: string, domainTools: DomainTool[], @@ -1304,7 +1350,11 @@ function buildSystemPrompt( hasCalendar: boolean, hasPrivacyV4: boolean, extraToolDocs: readonly string[] = [], + contextBoundMemory = false, ): string { + // W5 — off by default, so a turn that is not context-bound produces a + // byte-identical prompt (and therefore a byte-identical prompt-cache key). + const contextMemoryBlock = contextBoundMemory ? CONTEXT_MEMORY_PROMPT_BLOCK : ''; const domainList = domainTools.length ? domainTools.map((t) => `- \`${t.name}\`: ${t.spec.description}`).join('\n') : '- (keine Fach-Agenten konfiguriert)'; @@ -1379,7 +1429,7 @@ Memory-Namensräume (Konvention): - /memories/observations/… → Zeitstempelbezogene Beobachtungen für Rück-Vergleiche. - /memories/sessions//YYYY-MM-DD.md → **chronologische Q&A-Transkripte**, von der Middleware geschrieben (nicht von dir). Diese enthalten echte vorangegangene Konversationen. Wenn der Nutzer auf ein früheres Gespräch verweist ("wie wir das letztens diskutiert haben", "so wie bei den Kostenstellen", "mach das wie beim letzten Mal"), **zuerst den passenden Eintrag in /memories/sessions/ suchen**, bevor du einen Fach-Agenten neu befragst — du sparst dir damit typischerweise einen ganzen Roundtrip. Aber: lies nicht standardmäßig alle Sessions, das wäre Token-Verschwendung. Nur auf Rückbezug gezielt nachschlagen. -**Regel für /memories/_rules/ lesen:** +${contextMemoryBlock}**Regel für /memories/_rules/ lesen:** - Bei einer **neuen fachlichen Frage** (Erstfrage zu einer Domäne in dieser Session, oder Wechsel der Domäne) zuerst die relevanten Regel-Dateien unter /memories/_rules/ lesen und die Konventionen strikt befolgen. - Bei einem **Follow-up** im selben Chat (Variante, Bereinigung, Klarifikation, Nachfrage zum letzten Turn wie "und das Ganze nochmal ohne X", "und für Q4?", "zeig das als Line-Chart") **NICHT erneut** die Regeln lesen — der Verbatim-Tail im Gesprächskontext hat bereits den relevanten Stand. Direkt antworten (ggf. mit \`render_diagram\` für Chart-Varianten). Regel erneut lesen nur, wenn die Follow-up eine fachlich neue Dimension einführt (z. B. "jetzt das Gleiche auf HR-Ebene"). - Heuristik: enthält der Kontext-Block einen \`## Letzte Turns in diesem Chat\`-Abschnitt und bezieht sich die aktuelle Frage auf einen dieser Turns → Memory-Read überspringen. @@ -1763,6 +1813,8 @@ export class Orchestrator { private readonly maxTurnMs: number; /** Per-Agent scoped memory-tool handler; overrides the global one. */ private readonly memoryToolHandler: MemoryToolHandler | undefined; + /** W5 — per-chat-context binder; overrides `memoryToolHandler` per turn. */ + private readonly memoryBinder: MemoryBinder | undefined; private readonly domainToolsByName: Map; /** #332 Layer 2 — Direct Line delivery policy (default `'strict'`). */ private readonly directLineMode: DirectLineMode; @@ -1881,6 +1933,7 @@ export class Orchestrator { ? Math.trunc(options.maxTurnSeconds * 1000) : 0; this.memoryToolHandler = options.memoryToolHandler; + this.memoryBinder = options.memoryBinder; this.domainToolsByName = new Map(options.domainTools.map((t) => [t.name, t])); this.directLineMode = options.directLineMode ?? 'strict'; this.directLinePrefix = options.directLinePrefix ?? '#'; @@ -3196,6 +3249,43 @@ export class Orchestrator { return aiDisclosure ? { ...result, aiDisclosure } : result; } + /** + * W5 memory-ACL — resolve the memory-tool handler for ONE turn. + * + * Called exactly once per turn, at the turn's start, from `runTurnCore` and + * from the streaming mirror. The result is passed down the dispatch chain as + * an explicit parameter, never read back out of ambient state. + * + * Three fallbacks, all in the same direction: + * + * - no binder configured → the build-time `memoryToolHandler` (today); + * - a binder, but an origin that resolves context-free (no `origin` at all, + * an unknown channel type, an `unscoped`/`system` scope, or the per-agent + * rollout flag still `'off'`) → `forOrigin` itself returns the + * agent-private stack, byte-identical to today; + * - a binder that throws → today's handler, plus a loud log. A binding is + * configuration over a store, so a throw here means a programming error, + * not a hostile input — and dropping the user's turn over it would be the + * wrong trade. The fallback is the NARROWER scope, never a wider one. + */ + private bindTurnMemory(input: ChatTurnInput): TurnMemoryBinding { + const fallback: TurnMemoryBinding = { + handler: this.memoryToolHandler, + contextBound: false, + }; + if (!this.memoryBinder) return fallback; + try { + const bound = this.memoryBinder.forOrigin(input.origin); + return { handler: bound.handler, contextBound: !bound.axes.isContextFree }; + } catch (err) { + console.error( + '[security-audit] orchestrator: MemoryBinder.forOrigin threw — falling back to the agent-private memory stack:', + err, + ); + return fallback; + } + } + private async runTurnCore(input: ChatTurnInput): Promise { const turnId = randomUUID(); // W2-1 (#544) — an MCP input card's answer arrives as a machine envelope in @@ -3359,10 +3449,16 @@ export class Orchestrator { // by the harness; the orchestrator LLM never runs. Still flows through // the privacy-finalize block below so the verbatim answer is PII-masked // (Pitfall 3) and a receipt is attached. - const direct = await this.executeDirectLine(input, turnId); + // W5 memory-ACL — resolve THIS turn's memory binding exactly once, at + // the turn's start, and hand the result down as an explicit parameter. + // `input` is final here: the MCP-envelope normalisation and the + // inbound-screening gate above have both already re-bound it, so the + // origin the binding is derived from is the origin the turn ran with. + const turnMemory = this.bindTurnMemory(input); + const direct = await this.executeDirectLine(input, turnId, turnMemory); let result: ChatTurnResult; try { - result = direct ?? (await this.chatInContext(input, turnId)); + result = direct ?? (await this.chatInContext(input, turnId, turnMemory)); // #445 — an ordinary turn is by definition an UNBOUND turn (a live // binding would have produced a sticky dispatch), so stamp the // negative. Without it a client could never learn a binding ended. @@ -3484,6 +3580,7 @@ export class Orchestrator { private async executeDirectLine( input: ChatTurnInput, turnId: string, + turnMemory: TurnMemoryBinding | undefined, ): Promise { // Candidates = THIS orchestrator's whitelisted sub-agents (OB-29-1 gating). const candidates: DirectLineCandidate[] = Array.from( @@ -3643,6 +3740,7 @@ export class Orchestrator { tool.name, { question: wirePayload }, handle.observer, + turnMemory, ); // `createDomainTool.handle` does not throw on a sub-agent failure — it // returns an `Error …` string. Treat that as a faithful failure too. @@ -4023,10 +4121,11 @@ export class Orchestrator { private async chatInContext( input: ChatTurnInput, turnId: string, + turnMemory: TurnMemoryBinding | undefined, ): Promise { this.applyTurnAuthContext(input); try { - const result = await this.chatInContextInner(input, turnId); + const result = await this.chatInContextInner(input, turnId, turnMemory); await this.fireTurnHook( 'onAfterTurn', turnId, @@ -4132,6 +4231,7 @@ export class Orchestrator { private async chatInContextInner( input: ChatTurnInput, turnId: string, + turnMemory: TurnMemoryBinding | undefined, ): Promise { await this.fireTurnHook('onBeforeTurn', turnId, input, { userMessage: input.userMessage, @@ -4312,7 +4412,7 @@ export class Orchestrator { model: turnModel, max_tokens: this.maxTokens, system: buildSystemBlocks( - this.composeStableSystemPrompt(prependRules, turnPersonaBody), + this.composeStableSystemPrompt(prependRules, turnPersonaBody, turnMemory?.contextBound === true), priorContext, withFinalizeHint( effectiveExtraSystemHint, @@ -4561,7 +4661,7 @@ export class Orchestrator { }); const settled = await Promise.allSettled( toolUses.map((use: ContentBlock, i: number) => - this.dispatchTool(use.name, use.input, invocations[i]?.observer), + this.dispatchTool(use.name, use.input, invocations[i]?.observer, turnMemory), ), ); const toolResults: ContentBlock[] = toolUses.map((use: ContentBlock, i: number) => { @@ -4996,13 +5096,21 @@ export class Orchestrator { // inject extra user messages keyed by the same session scope. The inner // loop drains them at each iteration boundary; `endTurn` clears the buffer. steeringBus.beginTurn(sessionId); + // W5 memory-ACL — the streaming mirror of `runTurnCore`: bind once, thread + // explicitly. The streaming path is exactly why this is a parameter and not + // an AsyncLocalStorage lookup — a generator is resumed in the async context + // of whoever calls `.next()`, which is how `turnContext.enter` was silently + // losing the turn context on every streaming turn before W3-A (see the + // comment at the top of `chatStream`). A binding lost that way would not + // fail; it would quietly fall back to the agent-global tree. + const turnMemory = this.bindTurnMemory(input); try { // #332 Layer 2 — Direct Line short-circuit (streaming / web-ui path). // A user-directed specialist turn is dispatched deterministically by the // harness; the orchestrator LLM never runs. We synthesize the `done` // event and decorate it with the privacy receipt + onAfterTurn hook, // exactly like the normal done branch below. - const direct = await this.executeDirectLine(input, turnId); + const direct = await this.executeDirectLine(input, turnId, turnMemory); if (direct) { const directAgentsConsulted = deriveAgentsConsulted(direct.runTrace); let doneEvent: Extract = { @@ -5059,7 +5167,7 @@ export class Orchestrator { // generator throws before any model call when masking cannot be // guaranteed; convert that into a graceful privacy-error `done` event // instead of tearing the stream down with a raw 500. - const inner = this.chatStreamInner(input, turnId, observer); + const inner = this.chatStreamInner(input, turnId, observer, turnMemory); const guardedInner = (async function* () { try { yield* inner; @@ -5186,6 +5294,7 @@ export class Orchestrator { input: ChatTurnInput, turnId: string, observer: AskObserver | undefined, + turnMemory: TurnMemoryBinding | undefined, ): AsyncGenerator { // #361 — wire-bound prompt masking; see chatInContextInner for the full // rationale. Same seam, streaming path. @@ -5427,7 +5536,7 @@ export class Orchestrator { model: turnModel, max_tokens: this.maxTokens, system: buildSystemBlocks( - this.composeStableSystemPrompt(prependRules, turnPersonaBody), + this.composeStableSystemPrompt(prependRules, turnPersonaBody, turnMemory?.contextBound === true), priorContext, withFinalizeHint( effectiveExtraSystemHint, @@ -5677,7 +5786,7 @@ export class Orchestrator { const HEARTBEAT_MS = 5_000; const TICK_MS = 1_000; const slots: ParallelSlot[] = toolUses.map((use: ContentBlock, idx: number) => - this.prepareStreamSlot(use, idx, traceCollector), + this.prepareStreamSlot(use, idx, traceCollector, turnMemory), ); while (slots.some((s: ParallelSlot) => !s.settled)) { @@ -6091,6 +6200,7 @@ export class Orchestrator { use: ContentBlock, idx: number, traceCollector: RunTraceCollector | undefined, + turnMemory: TurnMemoryBinding | undefined, ): ParallelSlot { const subEvents: ChatStreamEvent[] = []; const isNative = this.nativeTools.has(use.name); @@ -6103,7 +6213,7 @@ export class Orchestrator { : undefined; const observer = this.makeSlotObserver(use.id, subEvents, invocation); const started = Date.now(); - const promise = this.dispatchTool(use.name, use.input, observer); + const promise = this.dispatchTool(use.name, use.input, observer, turnMemory); return { idx, use, @@ -6207,6 +6317,7 @@ export class Orchestrator { name: string, input: unknown, observer?: AskObserver, + turnMemory?: TurnMemoryBinding, ): Promise { // #575 — the audience floor's egress guard, at the ONE choke point every // tool dispatch passes through. Placed before the deadline machinery so a @@ -6229,7 +6340,7 @@ export class Orchestrator { const timeoutMs = resolveToolDispatchTimeoutMs(); if (timeoutMs === 0) { // Deadline explicitly disabled by the operator — legacy behaviour. - return this.dispatchToolDeadlined(name, input, observer); + return this.dispatchToolDeadlined(name, input, observer, undefined, turnMemory); } const controller = new AbortController(); const work = this.dispatchToolDeadlined( @@ -6237,6 +6348,7 @@ export class Orchestrator { input, abortGuardedObserver(observer, controller.signal), controller.signal, + turnMemory, ); // A dispatch that rejects AFTER the deadline already resolved the race // would otherwise surface as an unhandled rejection and kill the process. @@ -6265,6 +6377,7 @@ export class Orchestrator { input: unknown, observer?: AskObserver, deadlineSignal?: AbortSignal, + turnMemory?: TurnMemoryBinding, ): Promise { // Privacy Shield v4 — Data-Plane Boundary. The privacy handle is // threaded through `turnContext.privacyHandle`; absent ⇒ no privacy @@ -6320,7 +6433,7 @@ export class Orchestrator { ? { subAgentOwnerPluginId: domainToolAgentId } : {}), }, - () => this.dispatchToolInner(name, input, observer), + () => this.dispatchToolInner(name, input, observer, turnMemory), ); } else if (privacy !== undefined && ctx !== undefined) { // #570 — MCP tools reach dispatch as NATIVE tools (`mcpNativeHandler`), @@ -6330,10 +6443,10 @@ export class Orchestrator { // context plus the receipt. result = await turnContext.run( { ...ctx, mcpInputSentinelMint }, - () => this.dispatchToolInner(name, input, observer), + () => this.dispatchToolInner(name, input, observer, turnMemory), ); } else { - result = await this.dispatchToolInner(name, input, observer); + result = await this.dispatchToolInner(name, input, observer, turnMemory); } // W0-2 — late-result firewall. The deadline already fired for this slot: // the turn took `toolDeadlineError` and moved on. Everything below this @@ -6641,6 +6754,7 @@ export class Orchestrator { name: string, input: unknown, observer?: AskObserver, + turnMemory?: TurnMemoryBinding, ): Promise { // Per-orchestrator memory isolation: when this Agent has a scoped // memory-tool handler, it MUST shadow the globally-registered `memory` @@ -6658,12 +6772,20 @@ export class Orchestrator { // registered `memory` via a plugin) keeps its `agentId === undefined ⇒ // always-available` default, so the two current always-ready memory // plugins are unaffected as long as they haven't reported not-ready. - if (name === MEMORY_TOOL_NAME && this.memoryToolHandler) { + // W5 — `turnMemory` is the handler `MemoryBinder.forOrigin` produced for + // THIS turn, handed down as an explicit parameter from the turn entry + // point. It wins over the build-time `memoryToolHandler` whenever it is + // present. It is a parameter and not an ambient lookup on purpose: a + // context binding that can be lost at an await boundary is a leak, and + // "unlikely" is not the standard for the axis that keeps team A's notes + // out of team B. + const memoryHandler = turnMemory ? turnMemory.handler : this.memoryToolHandler; + if (name === MEMORY_TOOL_NAME && memoryHandler) { const memoryAgentId = this.nativeTools.get(MEMORY_TOOL_NAME)?.agentId; if (!this.isToolAvailable(memoryAgentId)) { return `Error: tool \`${name}\` is unavailable — plugin \`${memoryAgentId}\` has not completed its connection/auth setup.`; } - const result = await this.memoryToolHandler.handle(input); + const result = await memoryHandler.handle(input); // Arm the Fresh-Check gate only on a read that actually DELIVERED a file. // Checked after the handler so a `view` of a missing/invalid path — which // contributed nothing — does not mark the answer as memory-fed. @@ -6741,7 +6863,10 @@ export class Orchestrator { * rules, Fach-Agent routing) is unaffected, so a persona skill can change * *who* answers but never *how* tools/privacy rules are enforced. */ - private getSystemPrompt(personaOverride?: string): string { + private getSystemPrompt( + personaOverride?: string, + contextBoundMemory = false, + ): string { // Plugin-contributed prompt docs, collected from the registry. The // kernel's hardcoded blocks (graph/diagram/…) remain in buildSystemPrompt // for their tools; plugin docs land in a separate bullet list so both @@ -6775,6 +6900,7 @@ export class Orchestrator { this.findFreeSlotsTool !== undefined && this.bookMeetingTool !== undefined, this.privacyGuard?.() !== undefined, extraDocs, + contextBoundMemory, ); } @@ -6826,8 +6952,9 @@ export class Orchestrator { private composeStableSystemPrompt( prependRules: string, personaOverride?: string, + contextBoundMemory = false, ): string { - const body = this.getSystemPrompt(personaOverride); + const body = this.getSystemPrompt(personaOverride, contextBoundMemory); if (prependRules.length === 0) return body; return `${prependRules}\n\n---\n\n${body}`; } diff --git a/middleware/packages/harness-orchestrator/src/orchestratorMemoryNamespacer.ts b/middleware/packages/harness-orchestrator/src/orchestratorMemoryNamespacer.ts index 41c7228cd..808d1725a 100644 --- a/middleware/packages/harness-orchestrator/src/orchestratorMemoryNamespacer.ts +++ b/middleware/packages/harness-orchestrator/src/orchestratorMemoryNamespacer.ts @@ -23,6 +23,9 @@ import type { MemoryEntry, MemoryStore } from '@omadia/plugin-api'; * → FilesystemMemoryStore (physical I/O) * The ScopedMemoryStore is the hard backstop: a rewrite bug surfaces as a * `MemoryScopeViolation` rather than a cross-agent leak. + * + * `ContextMemoryNamespacer` (below) is the same bijection with a per-CONTEXT + * private root — see its doc comment. */ const MEMORIES_ROOT = '/memories'; @@ -31,9 +34,20 @@ const MEMORIES_ROOT = '/memories'; * First-segment names under `/memories` that are SHARED across Agents and * therefore pass through the namespacer unchanged. Mirrors the `core` * pattern in `ScopedMemoryStore` (which also permits top-level `_*` dirs). + * + * `contexts` is deliberately NOT in here: the per-context trees are private + * per Agent × context, never shared. */ const SHARED_SEGMENTS = new Set(['core', 'sessions', 'chat-sessions']); +/** + * Reserved model-facing first segments, recognised ONLY in context mode. + * The `~` prefix is safe by construction: the namespacer never emits a `~` + * segment outward, so no pre-existing physical path can collide with them. + */ +const TEAM_SEGMENT = '~team'; +const AGENT_SEGMENT = '~agent'; + function firstSegment(rest: string): string { // `rest` starts with '/', e.g. '/core/x.md' → 'core'. const trimmed = rest.replace(/^\/+/, ''); @@ -46,30 +60,72 @@ function isShared(rest: string): boolean { return seg.startsWith('_') || SHARED_SEGMENTS.has(seg); } -export class OrchestratorMemoryNamespacer implements MemoryStore { - private readonly privateRoot: string; +/** One model-facing prefix ↔ one physical root. */ +interface RootBinding { + /** Model-facing prefix, e.g. `/memories` or `/memories/~team`. */ + readonly outer: string; + /** Physical root it is backed by. */ + readonly inner: string; +} - constructor( - private readonly agentSlug: string, +/** + * Shared machinery for both namespacers: a bijection between the model-facing + * `/memories` namespace and one or more physical roots. + * + * `reservedSegments` names the model-facing first segments that are resolved + * through a binding instead of through the private root. A reserved segment + * WITHOUT a bound root (e.g. `~team` on a turn that has no team axis) is left + * in the outer namespace untouched — it then matches no compiled pattern, so + * the `ScopedMemoryStore` soft-denies the read and raises + * `MemoryScopeViolation` on the write. Enforcement stays in the store; this + * mapper never throws. + */ +abstract class MemoryNamespacerBase implements MemoryStore { + /** Physical roots, longest first, so nested roots resolve unambiguously. */ + private readonly bindings: readonly RootBinding[]; + + protected constructor( private readonly inner: MemoryStore, + private readonly privateRoot: string, + /** Model-facing segment (without slashes) → physical root. */ + private readonly reservedRoots: ReadonlyMap, + /** Segments recognised as reserved, bound or not. */ + private readonly reservedSegments: ReadonlySet, ) { - this.privateRoot = `${MEMORIES_ROOT}/orchestrators/${agentSlug}`; + const bindings: RootBinding[] = [ + { outer: MEMORIES_ROOT, inner: privateRoot }, + ]; + for (const [segment, root] of reservedRoots) { + bindings.push({ outer: `${MEMORIES_ROOT}/${segment}`, inner: root }); + } + this.bindings = bindings.sort((a, b) => b.inner.length - a.inner.length); } - /** Model-facing `/memories/...` → physical path in the private tree. */ - private toInner(path: string): string { + /** Model-facing `/memories/...` → physical path. */ + protected toInner(path: string): string { if (path === MEMORIES_ROOT) return this.privateRoot; if (!path.startsWith(`${MEMORIES_ROOT}/`)) return path; // not ours; leave it const rest = path.slice(MEMORIES_ROOT.length); // '/...' + + const seg = firstSegment(rest); + if (this.reservedSegments.has(seg)) { + const root = this.reservedRoots.get(seg); + // Unbound reserved segment → leave it outside every compiled scope. + if (root === undefined) return path; + return `${root}${rest.slice(rest.indexOf(seg) + seg.length)}`; + } + if (isShared(rest)) return path; // shared namespace — passthrough return `${this.privateRoot}${rest}`; } /** Physical path → model-facing `/memories/...` (inverse of `toInner`). */ - private toOuter(path: string): string { - if (path === this.privateRoot) return MEMORIES_ROOT; - if (path.startsWith(`${this.privateRoot}/`)) { - return `${MEMORIES_ROOT}${path.slice(this.privateRoot.length)}`; + protected toOuter(path: string): string { + for (const binding of this.bindings) { + if (path === binding.inner) return binding.outer; + if (path.startsWith(`${binding.inner}/`)) { + return `${binding.outer}${path.slice(binding.inner.length)}`; + } } return path; // shared / unmapped — already in the outer namespace } @@ -111,3 +167,93 @@ export class OrchestratorMemoryNamespacer implements MemoryStore { ); } } + +const NO_RESERVED_ROOTS: ReadonlyMap = new Map(); +const NO_RESERVED_SEGMENTS: ReadonlySet = new Set(); + +export class OrchestratorMemoryNamespacer extends MemoryNamespacerBase { + constructor(agentSlug: string, inner: MemoryStore) { + super( + inner, + `${MEMORIES_ROOT}/orchestrators/${agentSlug}`, + NO_RESERVED_ROOTS, + NO_RESERVED_SEGMENTS, + ); + } +} + +/** + * Physical roots for one context-scoped turn. PLAIN STRINGS by design: the + * axes → roots translation belongs to the `MemoryBinder`, so this mapper + * carries no channel-SDK dependency and stays testable in isolation. + */ +export interface ContextMemoryNamespacerOptions { + /** + * Physical root the model's bare `/memories/...` maps to — the NARROWEST + * tier of the turn, e.g. `/memories/contexts//channel/` or + * `/memories/contexts//user/`. + * + * Passing the Agent tree (`/memories/orchestrators/`) here reproduces + * `OrchestratorMemoryNamespacer` for every path EXCEPT a literal `~team` / + * `~agent` first segment: this class always reserves those two segments, so + * an unbound one is left in the outer namespace (and denied by + * `ScopedMemoryStore`) where the legacy class would have privatised it. That + * divergence is why `MemoryBinder` routes context-free turns through + * `OrchestratorMemoryNamespacer` itself rather than through this class with + * the agent root — an agent holding a top-level `~team` entry keeps it. + */ + readonly privateRoot: string; + /** + * Physical root of the team tier, e.g. + * `/memories/contexts//team/`. Omitted when the turn has no + * team axis — `/memories/~team/...` is then left unmapped and the + * `ScopedMemoryStore` denies it. + */ + readonly teamRoot?: string; + /** + * Physical root of the Agent tier, i.e. `/memories/orchestrators/`. + * Read-only from a context turn — enforced by the `ro:` pattern in the + * `ScopedMemoryStore`, NOT by this mapper. + */ + readonly agentRoot?: string; +} + +/** + * Context-scoped memory namespacer — the same bijection as + * `OrchestratorMemoryNamespacer`, but with a per-CONTEXT private root plus two + * reserved model-facing segments: + * + * ``` + * /memories/... → /... (narrowest tier: channel | user) + * /memories/~team/... → /... (only when a team axis exists) + * /memories/~agent/... → /... (read-only via the `ro:` pattern) + * /memories/core/... → unchanged (shared passthrough) + * /memories/_rules/... → unchanged (shared passthrough) + * ``` + * + * `list` never leaks a physical `contexts/...` path outward: every entry is + * mapped back through `toOuter`. + * + * A rewrite bug here yields a path outside the compiled scope, which the + * `ScopedMemoryStore` turns into a `MemoryScopeViolation` — the backstop + * guarantee of the layering is preserved. + */ +export class ContextMemoryNamespacer extends MemoryNamespacerBase { + constructor(options: ContextMemoryNamespacerOptions, inner: MemoryStore) { + const roots = new Map(); + // A wider root identical to the private root would break the bijection + // (two outer paths for one physical path) — the private root wins. + if (options.teamRoot && options.teamRoot !== options.privateRoot) { + roots.set(TEAM_SEGMENT, options.teamRoot); + } + if (options.agentRoot && options.agentRoot !== options.privateRoot) { + roots.set(AGENT_SEGMENT, options.agentRoot); + } + super( + inner, + options.privateRoot, + roots, + new Set([TEAM_SEGMENT, AGENT_SEGMENT]), + ); + } +} diff --git a/middleware/packages/harness-orchestrator/src/registry/configStore.ts b/middleware/packages/harness-orchestrator/src/registry/configStore.ts index 5d26db025..761a9547a 100644 --- a/middleware/packages/harness-orchestrator/src/registry/configStore.ts +++ b/middleware/packages/harness-orchestrator/src/registry/configStore.ts @@ -2,6 +2,8 @@ import type { Pool } from 'pg'; import { resolveModelRef } from '@omadia/llm-provider'; +import type { ContextMemoryMode } from '../memoryBinder.js'; + import { AgentGraphStore, type PersonaSkillRow, @@ -48,6 +50,17 @@ export interface AgentRow { readonly modelRouting?: Record | null; /** Cosmetic canvas coordinate; `null`/absent until first laid out. */ readonly canvasPosition?: CanvasPosition | null; + /** + * W5 memory-ACL — per-agent rollout switch for chat-context-scoped memory + * (`agents.context_memory`, migration 0050). Lifted into + * `AgentRuntimeConfig.contextMemory`, where the `MemoryBinder` consumes it. + * + * Optional on the row type so pre-existing `AgentRow` fixtures stay valid; + * absent and every unrecognised value both resolve to `'off'` — today's + * behaviour — in {@link parseContextMemoryMode}. Fail-closed applies to the + * flag itself, not just to the scope it controls. + */ + readonly contextMemory?: ContextMemoryMode; readonly createdAt: Date; readonly updatedAt: Date; } @@ -207,10 +220,26 @@ interface AgentDbRow { status: AgentStatus; model_routing: Record | null; canvas_position: CanvasPosition | null; + /** W5 — `agents.context_memory`; absent on a DB that predates migration 0050. */ + context_memory?: string | null; created_at: Date; updated_at: Date; } +/** + * Narrow the persisted `context_memory` text to the typed rollout mode. + * + * Deny-default: anything the running code does not recognise — a NULL from a + * pre-0050 database, a value written by a NEWER middleware during a rolling + * deploy, a hand-edited row — resolves to `'off'`, which is today's + * agent-global behaviour. The alternative failure direction (treating an + * unknown value as `'enforce'`) would change memory routing on a rollback, and + * the safe direction here is the one that changes nothing. + */ +function parseContextMemoryMode(raw: unknown): ContextMemoryMode { + return raw === 'enforce' || raw === 'enforce-strict' ? raw : 'off'; +} + interface AgentPluginDbRow { agent_id: string; plugin_id: string; @@ -241,6 +270,7 @@ function mapAgent(row: AgentDbRow): AgentRow { status: row.status, modelRouting: row.model_routing ?? null, canvasPosition: row.canvas_position ?? null, + contextMemory: parseContextMemoryMode(row.context_memory), createdAt: row.created_at, updatedAt: row.updated_at, }; diff --git a/middleware/packages/harness-orchestrator/src/registry/scopedMemoryStore.ts b/middleware/packages/harness-orchestrator/src/registry/scopedMemoryStore.ts index cdf03287b..2145086d6 100644 --- a/middleware/packages/harness-orchestrator/src/registry/scopedMemoryStore.ts +++ b/middleware/packages/harness-orchestrator/src/registry/scopedMemoryStore.ts @@ -1,3 +1,4 @@ +import type { MemoryAxes } from '@omadia/channel-sdk'; import type { MemoryEntry, MemoryStore } from '@omadia/plugin-api'; /** @@ -28,12 +29,31 @@ import type { MemoryEntry, MemoryStore } from '@omadia/plugin-api'; * Agent's own model-notes + its per-plugin * sub-trees under `.../plugins//`). * - `session:*` — matches `/memories/sessions/...`. + * - `team::*` — matches `/memories/contexts//team//...`. + * - `channel::*` — matches `/memories/contexts//channel//...`. + * - `user::*` — matches `/memories/contexts//user//...`. + * - `ro:` — access modifier: `` counts for reads + * (read / list / exists) only; write, delete and + * rename against it raise `MemoryScopeViolation`. * - `/memories/foo` — exact path match. * - `/memories/foo/*` — prefix match (everything under `/memories/foo/`). * * Unknown patterns are conservative: they match nothing (deny by default) * and the constructor surfaces them as a warning so a typo in a manifest * shows up in the log without breaking the boot. + * + * Chat-context tiers (`team:` / `channel:` / `user:`) deliberately live under + * the NEW top-level segment `/memories/contexts/` and NOT under + * `/memories/orchestrators//`: the already-compiled `orchestrator::*` + * pattern would otherwise match every context tree of that agent, so a + * legacy agent scope would silently unlock all chat contexts. Keeping the + * trees in disjoint top-level segments is what makes the two grammars + * collision-free: no legacy scope reaches a context tree and no context + * scope reaches the agent tree. Do not "tidy" the two trees together. + * + * `` never contains `:` (guaranteed by the key derivation in the + * channel SDK), which is what lets the token be parsed with a plain + * `/^team:([^:]+):\*$/`-style regex. */ /** @@ -48,6 +68,171 @@ export function orchestratorMemoryScope(agentSlug: string): readonly string[] { return ['core', `orchestrator:${agentSlug}:*`]; } +/** + * The axes a turn may reach come from `@omadia/channel-sdk`: they are derived + * from a `TurnOrigin` that only a channel adapter can build, so the canonical + * type and its only producer (`memoryAxesForOrigin`) live there and this module + * CONSUMES them. Re-exported so orchestrator-side callers do not have to reach + * into the SDK for a type they only ever hand to `effectiveMemoryScope`. + */ +export type { MemoryAxes, MemoryAxis } from '@omadia/channel-sdk'; + +/** + * How strictly a context turn is quarantined from the agent-global tree. + * + * - `'enforce'` — the default. A context turn READS the agent tier + * (`ro:orchestrator::*`) so existing knowledge stays quotable, but + * cannot write to it. + * - `'enforce-strict'` — design §10 Q3: full quarantine of legacy knowledge. + * A context turn cannot even read the agent tier. + * + * `'off'` is deliberately absent: it is a *routing* decision made one layer up + * (the binder hands over the context-free axes and never calls this with a + * mode), not a second fail-open branch inside the scope resolver. + */ +export type ContextMemoryEnforcement = 'enforce' | 'enforce-strict'; + +export interface EffectiveMemoryScopeOptions { + /** Default `'enforce'`. */ + readonly mode?: ContextMemoryEnforcement; + /** Structured warn-level sink. Never throws; see {@link effectiveMemoryScope}. */ + readonly log?: (msg: string, fields?: Record) => void; +} + +/** + * The only patterns {@link effectiveMemoryScope} will accept from `MemoryAxes`. + * + * An allowlist rather than a passthrough, because `axes.patterns` crosses a + * package boundary from an independently versioned channel plugin, and a + * passthrough would make that boundary scope-granting: a plugin that emitted + * `'core'`, `'orchestrator::*'` or `'/memories/*'` would widen the + * turn's scope instead of narrowing it. Everything outside the three context + * tiers is dropped and logged. + * + * `[^:]+` on the key mirrors the tier patterns' own regexes and is what makes + * `memoryContextKey`'s "never a `:` in a key" guarantee load-bearing here: a + * key that smuggled a `:` in could otherwise re-parse as a different tier. + */ +const CONTEXT_AXIS_PATTERN = /^(?:team|channel|user):[^:]+:\*$/; + +/** + * The effective memory scope for one turn: the static agent scope intersected + * with the dynamic context axes, fail-closed (design #870 §2, §4 step 7). + * + * ``` + * scope = axes.isContextFree + * ? ['core', `orchestrator:${slug}:*`] // exactly today + * : ['ro:core', `ro:orchestrator:${slug}:*`, …axes.patterns] // enforce + * : ['ro:core', …axes.patterns] // enforce-strict + * ``` + * + * Four properties are the whole point, and each fails in the safe direction: + * + * 1. **Fail-closed.** A missing `origin`, an `unscoped` scope, an unknown + * `channelType` — every one of them reaches this function as + * `isContextFree: true` and gets row 1 of the §2 table: byte-identical to + * what a turn does today, with NO context tree reachable. So does a + * context turn whose patterns are all unusable. The context-free branch + * delegates to {@link orchestratorMemoryScope} rather than re-spelling it, + * which is what keeps the golden comparison true by construction instead + * of by test. + * 2. **The agent tier is read-only from context turns.** Without the `ro:` + * modifier, "note this globally" in team A would be a permanent leak + * channel into team B — the exact hole this design closes. New knowledge + * leaves a context only through the operator promote action. + * 3. **The shared trees are read-only from context turns.** `ro:core`, not + * `core`: the namespacer passes `/memories/core`, `sessions`, + * `chat-sessions` and top-level `_*` through untouched, so they are the ONE + * model-facing surface that two different chat contexts address by the same + * path. Writable, `/memories/core/notes.md` would be a one-line bypass of + * every tier boundary below it. They stay writable from a context-FREE turn + * (operator, CLI, pre-W5 plugin). + * 4. **Never a throw on the message path.** A malformed `axes` is a bug in a + * channel plugin, not a reason to drop a user's turn; it degrades to the + * agent-private scope and says so in the log. Never a throw, never a wider + * scope. + * + * Note what this function does NOT do: it decides no path, it only names + * scopes. `ScopedMemoryStore` compiles the emitted tokens and stays the + * backstop that turns a mapping bug into a `MemoryScopeViolation` rather than a + * leak — including for a token it does not recognise, which it soft-denies. + * That is also why emitting `ro:orchestrator::*` before the grammar knows + * `ro:` is safe: an unknown token matches nothing, so the interim behaviour is + * *narrower* than the target, not wider. + * + * Pure and synchronous, so the security decision is testable as a table. + */ +export function effectiveMemoryScope( + agentSlug: string, + axes: MemoryAxes, + options: EffectiveMemoryScopeOptions = {}, +): readonly string[] { + const strict = options.mode === 'enforce-strict'; + + // `agentSlug` is interpolated into `orchestrator::*`, whose compiled + // regex is `[^:]+`. A slug carrying a `:` therefore produces a token that + // matches nothing — fail-closed, and identical to how + // `orchestratorMemoryScope` already behaves for such a slug. + const contextFree = (reason: string): readonly string[] => { + // `context-free` is a legitimate turn shape — the operator UI, the CLI and + // every pre-W5 channel plugin land here by design, so auditing it in the + // default mode would be pure noise. `axes-missing` and + // `no-usable-context-pattern` are NOT legitimate: they only happen when a + // channel plugin emits a broken axes object, and this log line is the sole + // signal an operator gets that context memory silently stopped working. + // Those two are therefore audited in BOTH modes. + if (strict || reason !== 'context-free') { + options.log?.( + '[security-audit] effectiveMemoryScope: no resolvable turn context — agent-private scope', + { agentSlug, reason, mode: strict ? 'enforce-strict' : 'enforce' }, + ); + } + return orchestratorMemoryScope(agentSlug); + }; + + // Defensive on the whole object: it crosses a plugin boundary, and a missing + // one must not become a TypeError on the message path. + if (axes === undefined || axes === null) return contextFree('axes-missing'); + if (axes.isContextFree !== false) { + // Anything but an explicit `false` is treated as context-free, so a + // half-built axes object cannot open a context tree by omission. Stray + // patterns on a context-free axes are ignored by construction. + return contextFree('context-free'); + } + + const patterns: string[] = []; + const seen = new Set(); + for (const raw of axes.patterns ?? []) { + if (typeof raw !== 'string' || !CONTEXT_AXIS_PATTERN.test(raw)) { + options.log?.('effectiveMemoryScope: dropping non-context axis pattern — deny-default', { + agentSlug, + pattern: raw, + }); + continue; + } + if (seen.has(raw)) continue; + seen.add(raw); + patterns.push(raw); + } + + // A context turn that named no usable tier is indistinguishable from one that + // named no tier at all. Both take row 1. + if (patterns.length === 0) return contextFree('no-usable-context-pattern'); + + // `ro:core`, NOT `core`. The shared trees (`/memories/core`, `sessions`, + // `chat-sessions`, top-level `_*`) are passed through untouched by the + // namespacer, so they are the ONE model-facing surface that two different + // chat contexts address by the same path. Leaving them writable would make + // `/memories/core/notes.md` a one-line bypass of the entire ACL: team A + // writes, team B reads, and every tier boundary below is irrelevant. Shared + // trees stay writable from a context-FREE turn (operator, CLI, pre-W5 + // plugin); new knowledge leaves a context only through the operator promote + // action (coordinator decision 2). + return strict + ? ['ro:core', ...patterns] + : ['ro:core', `ro:orchestrator:${agentSlug}:*`, ...patterns]; +} + export class MemoryScopeViolation extends Error { readonly agentSlug: string; readonly virtualPath: string; @@ -69,15 +254,93 @@ const CORE_PREFIXES = [ '/memories/chat-sessions/', ]; +/** + * Subtrees inside `core` that no agent may ever write, whatever else its scope + * grants — a deny list, evaluated before any positive pattern. + * + * `/memories/core/audit/` holds the tamper-evident record of privileged + * OPERATOR actions (currently the promotion log written by + * `services/memoryPromote.ts`). It was placed under `core` so agents can READ + * it, but `core` is a read/write grant every agent holds, so without this + * carve-out any agent could overwrite or delete the log that records what an + * operator did to its memory — which is precisely the property an audit trail + * must not have. It is written by kernel services on the ROOT store, which + * never passes through this wrapper, so nothing legitimate loses a write. + */ +const AGENT_UNWRITABLE_PREFIXES = ['/memories/core/audit/']; + +function isAgentUnwritable(path: string): boolean { + return AGENT_UNWRITABLE_PREFIXES.some( + (pre) => path === pre.slice(0, -1) || path.startsWith(pre), + ); +} + +/** Access modifier prefix: `ro:` — read/list/exists only. */ +const READ_ONLY_PREFIX = 'ro:'; + +/** `team::*` / `channel::*` / `user::*`. */ +const CONTEXT_TOKEN = /^(team|channel|user):([^:]+):\*$/; + +/** Root of the chat-context trees — a top-level segment of its own. */ +const CONTEXTS_ROOT = '/memories/contexts'; + +/** The three chat-context tiers, in the order the design spec lists them. */ +export const CONTEXT_AXES = Object.freeze(['team', 'channel', 'user'] as const); +export type ContextAxis = (typeof CONTEXT_AXES)[number]; + +/** + * Physical root of one context tier for one Agent — the SINGLE source of truth + * shared by the pattern compiler here and by `ContextMemoryNamespacer`, which + * rewrites the model-facing `/memories/…` namespace into it. Two spellings of + * this path would mean the namespacer could emit a path the compiler does not + * grant (or, worse, one it grants for a different context). + */ +export function contextTierRoot( + agentSlug: string, + axis: ContextAxis, + ctxKey: string, +): string { + return `${CONTEXTS_ROOT}/${agentSlug}/${axis}/${ctxKey}`; +} + interface CompiledPattern { match(path: string): boolean; source: string; + /** `true` for `ro:`-prefixed patterns — they grant reads but never writes. */ + readOnly: boolean; +} + +/** Matches `prefix` itself (without its trailing slash) and everything below it. */ +function prefixMatcher(prefix: string): (path: string) => boolean { + const root = prefix.slice(0, -1); + return (p) => p === root || p.startsWith(prefix); +} + +function compilePattern( + pattern: string, + agentSlug: string, +): CompiledPattern | undefined { + if (pattern.startsWith(READ_ONLY_PREFIX)) { + // Single level only: `ro:ro:` is not a pattern, it is a typo — falls + // through to the unknown-pattern soft-deny below. + const inner = compileAccessPattern( + pattern.slice(READ_ONLY_PREFIX.length), + agentSlug, + ); + if (!inner) return undefined; + return { source: pattern, match: inner.match, readOnly: true }; + } + return compileAccessPattern(pattern, agentSlug); } -function compilePattern(pattern: string): CompiledPattern | undefined { +function compileAccessPattern( + pattern: string, + agentSlug: string, +): CompiledPattern | undefined { if (pattern === 'core') { return { source: pattern, + readOnly: false, match: (p) => { for (const pre of CORE_PREFIXES) { if (p === pre.slice(0, -1) || p.startsWith(pre)) return true; @@ -92,10 +355,22 @@ function compilePattern(pattern: string): CompiledPattern | undefined { const agentMatch = /^agent:([^:]+):\*$/.exec(pattern); if (agentMatch) { const id = agentMatch[1]!; - const prefix = `/memories/agents/${id}/`; return { source: pattern, - match: (p) => p === prefix.slice(0, -1) || p.startsWith(prefix), + readOnly: false, + match: prefixMatcher(`/memories/agents/${id}/`), + }; + } + // Chat-context tiers — always relative to THIS agent's slug, so a context + // key alone can never address another agent's tree. + const ctxMatch = CONTEXT_TOKEN.exec(pattern); + if (ctxMatch) { + const axis = ctxMatch[1] as ContextAxis; + const ctxKey = ctxMatch[2]!; + return { + source: pattern, + readOnly: false, + match: prefixMatcher(`${contextTierRoot(agentSlug, axis, ctxKey)}/`), }; } // Per-orchestrator isolation (strict): an Agent's own private tree — @@ -104,30 +379,31 @@ function compilePattern(pattern: string): CompiledPattern | undefined { const orchMatch = /^orchestrator:([^:]+):\*$/.exec(pattern); if (orchMatch) { const slug = orchMatch[1]!; - const prefix = `/memories/orchestrators/${slug}/`; return { source: pattern, - match: (p) => p === prefix.slice(0, -1) || p.startsWith(prefix), + readOnly: false, + match: prefixMatcher(`/memories/orchestrators/${slug}/`), }; } if (pattern === 'session:*') { - const prefix = '/memories/sessions/'; return { source: pattern, - match: (p) => p === prefix.slice(0, -1) || p.startsWith(prefix), + readOnly: false, + match: prefixMatcher('/memories/sessions/'), }; } if (pattern.startsWith('/')) { if (pattern.endsWith('/*')) { - const prefix = pattern.slice(0, -1); return { source: pattern, - match: (p) => p === prefix.slice(0, -1) || p.startsWith(prefix), + readOnly: false, + match: prefixMatcher(pattern.slice(0, -1)), }; } const exact = pattern; return { source: pattern, + readOnly: false, match: (p) => p === exact, }; } @@ -149,7 +425,7 @@ export class ScopedMemoryStore implements MemoryStore { constructor(private readonly options: ScopedMemoryStoreOptions) { const compiled: CompiledPattern[] = []; for (const raw of options.scope) { - const c = compilePattern(raw); + const c = compilePattern(raw, options.agentSlug); if (c) { compiled.push(c); } else { @@ -162,62 +438,91 @@ export class ScopedMemoryStore implements MemoryStore { this.patterns = compiled; } - private allowed(virtualPath: string): boolean { + /** + * Read access — every compiled pattern counts, `ro:` ones included. + * Denial stays SOFT on the read paths that have a "not there" answer + * (`list` filters, `*Exists` returns false); an explicit `readFile` still + * throws so a caller cannot mistake a denial for an empty file. + */ + private allowedRead(virtualPath: string): boolean { for (const p of this.patterns) if (p.match(virtualPath)) return true; return false; } + /** + * Write access — `ro:` is a VETO, not a weak grant. A matching read-only + * pattern rejects the write even when another pattern in the same scope + * would have granted it; without that precedence a scope such as + * `['ro:orchestrator:hr:*', '/memories/orchestrators/hr/notes.md']` would + * silently re-open the tier the `ro:` token exists to quarantine, and the + * grammar is an exported primitive other scopes compose against. + * Denial is always HARD here. + */ + private allowedWrite(virtualPath: string): boolean { + if (isAgentUnwritable(virtualPath)) return false; + let granted = false; + for (const p of this.patterns) { + if (!p.match(virtualPath)) continue; + if (p.readOnly) return false; + granted = true; + } + return granted; + } + list(virtualPath: string): Promise { - if (!this.allowed(virtualPath)) { + if (!this.allowedRead(virtualPath)) { // Soft-deny — listing a directory the agent can't see returns empty // rather than throwing, so UI surfaces stay stable. return Promise.resolve([]); } return this.options.inner .list(virtualPath) - .then((entries) => entries.filter((e) => this.allowed(e.virtualPath))); + .then((entries) => entries.filter((e) => this.allowedRead(e.virtualPath))); } fileExists(virtualPath: string): Promise { - if (!this.allowed(virtualPath)) return Promise.resolve(false); + if (!this.allowedRead(virtualPath)) return Promise.resolve(false); return this.options.inner.fileExists(virtualPath); } directoryExists(virtualPath: string): Promise { - if (!this.allowed(virtualPath)) return Promise.resolve(false); + if (!this.allowedRead(virtualPath)) return Promise.resolve(false); return this.options.inner.directoryExists(virtualPath); } async readFile(virtualPath: string): Promise { - if (!this.allowed(virtualPath)) { + if (!this.allowedRead(virtualPath)) { throw new MemoryScopeViolation(this.options.agentSlug, 'read', virtualPath); } return this.options.inner.readFile(virtualPath); } async createFile(virtualPath: string, content: string): Promise { - if (!this.allowed(virtualPath)) { + if (!this.allowedWrite(virtualPath)) { throw new MemoryScopeViolation(this.options.agentSlug, 'write', virtualPath); } return this.options.inner.createFile(virtualPath, content); } async writeFile(virtualPath: string, content: string): Promise { - if (!this.allowed(virtualPath)) { + if (!this.allowedWrite(virtualPath)) { throw new MemoryScopeViolation(this.options.agentSlug, 'write', virtualPath); } return this.options.inner.writeFile(virtualPath, content); } async delete(virtualPath: string): Promise { - if (!this.allowed(virtualPath)) { + if (!this.allowedWrite(virtualPath)) { throw new MemoryScopeViolation(this.options.agentSlug, 'delete', virtualPath); } return this.options.inner.delete(virtualPath); } async rename(fromVirtualPath: string, toVirtualPath: string): Promise { - if (!this.allowed(fromVirtualPath) || !this.allowed(toVirtualPath)) { + if ( + !this.allowedWrite(fromVirtualPath) || + !this.allowedWrite(toVirtualPath) + ) { throw new MemoryScopeViolation( this.options.agentSlug, 'rename', diff --git a/middleware/src/channels/orchestratorDispatcher.ts b/middleware/src/channels/orchestratorDispatcher.ts index 8eeb5d004..93ce9f17d 100644 --- a/middleware/src/channels/orchestratorDispatcher.ts +++ b/middleware/src/channels/orchestratorDispatcher.ts @@ -1,7 +1,71 @@ import { CHAT_AGENT_SERVICE } from '@omadia/channel-sdk'; -import type { ChatAgent, ChatAgentBundle, ChannelUserKind } from '@omadia/channel-sdk'; +import type { + ChatAgent, + ChatAgentBundle, + ChannelUserKind, + TurnOrigin, +} from '@omadia/channel-sdk'; import type { ChannelKind } from '@omadia/plugin-api'; +/** + * W5 memory-ACL (#860) — validate a `TurnOrigin` arriving from an + * independently-versioned channel plugin. + * + * This is a TRUST BOUNDARY, not a cast. The origin decides which memory tier a + * turn reaches, so a malformed one must never become a partially-populated + * object that resolves to *some* tier: it resolves to NONE. Every rejection + * below returns `undefined`, which `memoryAxesForOrigin` reads as context-free + * — the agent-private stack every turn gets today. + * + * Note the deliberate shallowness: `scope` and `principal` are handed on + * unvalidated beyond "is an object", because `memoryAxesForOrigin` already + * switches on `scope.kind` with a default that falls through to context-free, + * and re-implementing that discrimination here would be a second, drifting + * copy of the §2 table. What this function guarantees is only that the SHAPE + * cannot throw and that `channelType`/`container` cannot be smuggled through + * as non-strings. + */ +function readTurnOrigin(raw: unknown): TurnOrigin | undefined { + if (raw === null || typeof raw !== 'object') return undefined; + const candidate = raw as Record; + + const channelType = candidate['channelType']; + if (typeof channelType !== 'string' || channelType.trim().length === 0) { + return undefined; + } + const scope = candidate['scope']; + if (scope === null || typeof scope !== 'object') return undefined; + + const rawContainer = candidate['container']; + let container: TurnOrigin['container']; + if (rawContainer !== undefined) { + if (rawContainer === null || typeof rawContainer !== 'object') return undefined; + const kind = (rawContainer as Record)['kind']; + const id = (rawContainer as Record)['id']; + // An unknown container kind is dropped rather than rejected: the turn is + // still a legitimate conversation, it simply has no team tier. Dropping is + // the narrower answer; rejecting the whole origin would be wider only in + // the sense of losing the channel tier too, so both are safe — but keeping + // the narrow tier is the more useful of the two. + if ((kind === 'team' || kind === 'tenant') && typeof id === 'string' && id.length > 0) { + container = { kind, id }; + } + } + + const rawPrincipal = candidate['principal']; + const principal = + rawPrincipal !== null && typeof rawPrincipal === 'object' + ? (rawPrincipal as TurnOrigin['principal']) + : undefined; + + return { + channelType, + scope: scope as TurnOrigin['scope'], + ...(container ? { container } : {}), + ...(principal ? { principal } : {}), + }; +} + /** * #430 fixup — map the channel-plugin-facing {@link ChannelUserKind} * namespace to the KG-facing {@link ChannelKind} the ACL/identity model @@ -160,10 +224,20 @@ export function createOrchestratorDispatcher( const channelIdentity = channelKind ? { channelKind, channelUserId: input.userRef.id } : undefined; + // W5 memory-ACL (#860) — forward the channel plugin's `TurnOrigin` when + // it sent one. This is the ONE seam between an independently-versioned + // channel package and the memory partitioning, so it is validated here + // rather than trusted: a shape this code does not recognise is DROPPED, + // which resolves the turn context-free (today's agent-private memory). + // Fail-closed applies to the transport too — an old plugin sends no + // `origin` at all and gets exactly the same answer, so there is no flag + // day in either direction. + const origin = readTurnOrigin(input.metadata?.['origin']); yield* agent.chatStream({ userMessage: input.text, sessionScope: input.scope, userId: input.userRef.id, + ...(origin ? { origin } : {}), ...(channelIdentity ? { channelIdentity } : {}), ...(canvasSessionId ? { canvasSessionId } : {}), ...(action ? { action } : {}), diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 47f07127f..5a94b2c63 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -28,6 +28,7 @@ import { createTigrisStore } from '@omadia/diagrams'; import type { MemoryStore } from '@omadia/plugin-api'; import { createAdminRouter } from './routes/admin.js'; import { createMemoryPurgeRouter } from './routes/memoryPurge.js'; +import { createMemoryPromoteRouter } from './routes/memoryPromote.js'; import { createAdminUpdateRouter } from './routes/adminUpdate.js'; import { createUpdateAuditStore } from './update/auditStore.js'; import { createReleaseLookup } from './update/releaseLookup.js'; @@ -3160,6 +3161,24 @@ async function main(): Promise { '[middleware] memory-purge endpoint ready at /api/v1/admin/memory/purge', ); + // W5 (#860) — operator memory promotion: the ONE way knowledge crosses a + // chat-context boundary, since a context turn can no longer write into the + // agent tier itself. Deliberately on the SAME gate and the SAME prefix as + // the purge router above (`requireAuth`, cookie session JWT), not on the + // machine-to-machine ADMIN_TOKEN surface in `admin.ts`: promotion is an + // operator judgement call that has to be attributable to a person, and the + // audit line records that person as its actor. The spec's + // `/api/agents/:slug/memory/promotions` would have been a third auth surface + // for a Danger-Zone-class action; the deviation is deliberate. + app.use( + '/api/v1/admin/memory/promotions', + requireAuth, + createMemoryPromoteRouter({ store: memoryStore }), + ); + console.log( + '[middleware] memory-promote endpoint ready at /api/v1/admin/memory/promotions/:slug', + ); + // #575 — audience-floor grants. Cookie-auth admin surface like the routers // above. Mounted whenever Postgres is present, INDEPENDENTLY of whether the // floor is enforcing: an operator has to be able to seed and review the grant diff --git a/middleware/src/routes/chat.ts b/middleware/src/routes/chat.ts index 00b8c4ae2..1368158a5 100644 --- a/middleware/src/routes/chat.ts +++ b/middleware/src/routes/chat.ts @@ -48,6 +48,25 @@ const ChatRequestSchema = z.object({ .optional(), }); +/** + * The orchestrator `sessionScope` for an HTTP turn. + * + * W5 memory-ACL (#860), coordinator decision 1 — note what this route does NOT + * do: it never builds a `TurnOrigin`, so every HTTP turn resolves context-free + * and gets the agent-private memory stack, byte-identical to today. That is a + * decision, not an omission. + * + * The scopes this function returns (`http-`, a client-chosen + * `sessionId`, or the shared literal `'http-default'`) are transcript-bucketing + * labels supplied by the CALLER. Deriving a memory partition from them would + * hand any API client the ability to name — and therefore to read — another + * caller's memory tier by sending its scope string, and `'http-default'` would + * make one shared tier out of every unlabelled turn. An API caller gets no + * implicit team or channel memory; when a genuine tenant identity exists on + * this surface it has to be resolved from the authenticated principal and + * passed as an explicit `origin`, which is a change to make deliberately, not + * to inherit from a debug label. + */ function resolveScope(parsed: z.infer): string { if (parsed.scope) return `http-${parsed.scope}`; if (parsed.sessionId) return parsed.sessionId; diff --git a/middleware/src/routes/memoryPromote.ts b/middleware/src/routes/memoryPromote.ts new file mode 100644 index 000000000..765eb00ca --- /dev/null +++ b/middleware/src/routes/memoryPromote.ts @@ -0,0 +1,355 @@ +import { Router } from 'express'; +import type { Request, Response } from 'express'; +import { z } from 'zod'; +import type { MemoryStore } from '@omadia/plugin-api'; + +import { + promoteMemory, + PROMOTION_AUDIT_PATH, + type PromoteReceipt, +} from '../services/memoryPromote.js'; + +/** + * Memory promotion — the operator-facing surface for the one explicit act + * that moves knowledge across an agent's context boundaries (design spec + * #870 §6, epic #860). + * + * POST /:slug/memory/promotions run a copy/move between two tiers + * GET /:slug/memory/promotions read that agent's promotion audit log + * + * MOUNT + GATE + * ------------ + * Mounted at `/api/v1/operator/agents` behind `requireAuth` (cookie session + * JWT) — the SAME gate `routes/memoryPurge.ts` documents for the Danger + * Zone, and the prefix `routes/operatorAgents.ts` already owns for every + * other per-agent operator action. The full URLs are therefore + * `/api/v1/operator/agents/:slug/memory/promotions`. Like purge, this is + * intentionally NOT on the machine-to-machine `ADMIN_TOKEN` surface in + * `admin.ts`: the operator authenticates as a logged-in admin user via the + * browser session. + * + * This is a deliberate reconciliation of the spec, which writes the path as + * `/api/agents/:slug/memory/promotions` "gleiche Gate wie Purge" — a surface + * that does not exist in this repo. Keeping the spec's resource shape while + * reusing the existing `requireAuth`-gated operator prefix satisfies both + * halves of that sentence without inventing a third top-level API surface. + * The router is a separate module (not folded into `operatorAgents.ts`) so + * the two can be developed and mounted independently; Express consults both + * routers at the same prefix in mount order. + * + * ACTOR + * ----- + * `PromoteRequest.actor` is "die Operator-Identität aus der Session" (§6) and + * is written into all three audit surfaces, so it is read from the session + * here rather than hardcoded the way `memoryPurge` writes `'admin-ui'` — an + * audit trail that always names the UI instead of the human is worthless. + * The `omadia_user_id ?? sub` fallback is the `uiPrefs.ts` idiom: + * `omadia_user_id` is optional on a live session (first-login / KG-degraded + * window), and a genuinely session-less request is the only 401. + * + * STORE + * ----- + * Runs on the ROOT (undecorated) `MemoryStore` — promotion crosses exactly + * the scopes a `ScopedMemoryStore` enforces, so it cannot run inside one. + * Same precedent as `memoryPurge`. + */ + +const PromoteSourceSchema = z.object({ + axis: z.enum(['team', 'channel', 'user']), + ctxKey: z.string().min(1).max(256), + path: z.string().min(1).max(1024), +}); + +const PromoteTargetSchema = z.object({ + tier: z.enum(['agent', 'team']), + ctxKey: z.string().min(1).max(256).optional(), + path: z.string().min(1).max(1024).optional(), +}); + +/** Body of a promotion. `agentSlug` comes from the path, `actor` from the + * session — neither is accepted from the client. */ +const PromoteBodySchema = z.object({ + source: PromoteSourceSchema, + target: PromoteTargetSchema, + mode: z.enum(['copy', 'move']), + reason: z.string().max(2000).optional(), + overwrite: z.boolean().optional(), +}); + +const AuditQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(1000).optional(), +}); + +const DEFAULT_AUDIT_LIMIT = 100; + +export interface MemoryPromoteDeps { + /** ROOT MemoryStore — undecorated, exactly like `memoryPurge`. */ + store: MemoryStore; + /** Injectable log sink. Defaults to `console.error`. */ + log?: (message: string) => void; +} + +/** Service errors carry a machine-readable `code`; map it to a status. */ +function statusForCode(code: string): number { + switch (code) { + case 'source_not_found': + return 404; + case 'target_exists': + case 'target_is_directory': + return 409; + case 'invalid_agent_slug': + case 'invalid_axis': + case 'invalid_tier': + case 'invalid_mode': + case 'invalid_ctx_key': + case 'invalid_path': + case 'actor_required': + case 'source_escapes_agent': + case 'target_escapes_agent': + case 'target_equals_source': + case 'target_overlaps_source': + case 'source_empty': + return 400; + default: + return 500; + } +} + +/** + * The error codes the service raises BEFORE it writes anything. Only these + * license the claim "both tiers are untouched"; every other failure may have + * landed some of the files. Kept as one list so the promise and the status + * table cannot drift apart. + */ +const PRE_WRITE_CODES: ReadonlySet = new Set([ + 'source_not_found', + 'target_exists', + 'target_is_directory', + 'invalid_agent_slug', + 'invalid_axis', + 'invalid_tier', + 'invalid_mode', + 'invalid_ctx_key', + 'invalid_path', + 'actor_required', + 'source_escapes_agent', + 'target_escapes_agent', + 'target_equals_source', + 'target_overlaps_source', + 'source_empty', +]); + +function errorCode(err: unknown): string | undefined { + if (err !== null && typeof err === 'object' && 'code' in err) { + const code = (err as { code: unknown }).code; + if (typeof code === 'string') return code; + } + return undefined; +} + +/** `audit_write_failed` carries the receipt of the promotion that DID run. */ +function attachedReceipt(err: unknown): PromoteReceipt | undefined { + if (err !== null && typeof err === 'object' && 'receipt' in err) { + return (err as { receipt: PromoteReceipt }).receipt; + } + return undefined; +} + +function messageOf(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** Express 5 types `req.params` values as `string | string[]`. A wildcard + * cannot reach `:slug`, but narrow honestly rather than casting — an empty + * string falls through to the service's own `invalid_agent_slug`. */ +function slugParam(req: Request): string { + const raw = req.params.slug; + return typeof raw === 'string' ? raw : ''; +} + +/** Session identity of the operator, or `null` after a 401 was sent. */ +function requireActor(req: Request, res: Response): string | null { + const actor = req.session?.omadia_user_id ?? req.session?.sub; + if (!actor) { + res.status(401).json({ error: 'auth.required', message: 'login required' }); + return null; + } + return actor; +} + +interface AuditEntry { + readonly agentSlug?: unknown; + readonly [key: string]: unknown; +} + +/** + * Read the append-only JSONL the service writes (§6a) and return this + * agent's entries, newest first. A malformed line is counted, never thrown + * on: the log is the audit record of promotions that already happened, so a + * single unparseable line must not hide the rest. + */ +async function readAuditEntries( + store: MemoryStore, + agentSlug: string, + limit: number, +): Promise<{ entries: AuditEntry[]; malformed: number }> { + if (!(await store.fileExists(PROMOTION_AUDIT_PATH))) { + return { entries: [], malformed: 0 }; + } + const raw = await store.readFile(PROMOTION_AUDIT_PATH); + const entries: AuditEntry[] = []; + let malformed = 0; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + malformed += 1; + continue; + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + malformed += 1; + continue; + } + const entry = parsed as AuditEntry; + if (entry.agentSlug !== agentSlug) continue; + entries.push(entry); + } + // The file is append-only chronological; the operator wants the latest hop + // first, and `limit` must cut the OLDEST entries, not the newest. + entries.reverse(); + return { entries: entries.slice(0, limit), malformed }; +} + +export function createMemoryPromoteRouter(deps: MemoryPromoteDeps): Router { + const router = Router(); + const log = + deps.log ?? + ((message: string): void => { + console.error(message); + }); + + // Run a promotion. + // + // What IS guaranteed: every VALIDATION rejection — a bad request body, an + // escaping path, an overlapping source/target, a pre-existing target — is + // decided before the first byte lands, so those codes mean both tiers are + // untouched. + // + // What is NOT guaranteed: the service writes the planned files in an + // unguarded loop and, on `mode: 'move'`, deletes the source files after + // them. A store failure part-way (quota, transient Postgres error, EACCES) + // leaves the promotion HALF APPLIED. Such an error carries no `code`, so it + // would otherwise be indistinguishable from a clean rejection — the operator + // retries, hits `409 target_exists` on the files that did land, and is told + // there is a conflict on a promotion the API twice reported as never having + // started. Any error outside the pre-write validation set is therefore + // answered with `partial: true`, which is the honest statement: inspect the + // target before retrying. + // + // `audit_write_failed` is the one completed-but-incomplete case with a + // receipt, and stays a 200 + `warning`, never a failure. + router.post('/:slug', async (req: Request, res: Response) => { + const actor = requireActor(req, res); + if (actor === null) return; + + const parsed = PromoteBodySchema.safeParse(req.body); + if (!parsed.success) { + res + .status(400) + .json({ error: 'invalid_request', issues: parsed.error.issues }); + return; + } + const { source, target, mode, reason, overwrite } = parsed.data; + const agentSlug = slugParam(req); + + try { + const receipt = await promoteMemory(deps.store, { + agentSlug, + source, + target, + mode, + actor, + ...(reason !== undefined ? { reason } : {}), + ...(overwrite !== undefined ? { overwrite } : {}), + }); + res.json({ receipt }); + } catch (err) { + const code = errorCode(err); + const receipt = code === 'audit_write_failed' ? attachedReceipt(err) : undefined; + if (receipt) { + // The promotion completed — an audit gap must not mask it (same rule + // `memoryPurge` applies to its `memory_purge_audit` row). + log( + `[memory-promote] audit line failed for ${receipt.sourcePath} → ${receipt.targetPath}: ${messageOf(err)}`, + ); + res.json({ + receipt, + warning: `The promotion was applied but its audit line could not be written to ${PROMOTION_AUDIT_PATH}.`, + }); + return; + } + const status = statusForCode(code ?? 'memory_promote_failed'); + // A failure outside the pre-write set may have written some files and, + // on `move`, deleted some sources. Say so — the operator's next action + // (retry vs. inspect) depends entirely on which of the two it was. + const partial = code === undefined || !PRE_WRITE_CODES.has(code); + if (status >= 500) { + log(`[memory-promote] promotion failed: ${messageOf(err)}`); + } + res.status(status).json({ + error: code ?? 'memory_promote_failed', + message: messageOf(err), + ...(partial + ? { + partial: true, + warning: + 'This failure happened after the write loop had started, so the promotion may be partially applied. Inspect the target tier before retrying.', + } + : {}), + }); + } + }); + + // Read the promotion audit log for this agent (§6a). + router.get('/:slug', async (req: Request, res: Response) => { + if (requireActor(req, res) === null) return; + + const parsed = AuditQuerySchema.safeParse(req.query); + if (!parsed.success) { + res + .status(400) + .json({ error: 'invalid_request', issues: parsed.error.issues }); + return; + } + const limit = parsed.data.limit ?? DEFAULT_AUDIT_LIMIT; + const agentSlug = slugParam(req); + + try { + const { entries, malformed } = await readAuditEntries( + deps.store, + agentSlug, + limit, + ); + if (malformed > 0) { + log( + `[memory-promote] ${String(malformed)} unparseable line(s) in ${PROMOTION_AUDIT_PATH}`, + ); + } + res.json({ + auditPath: PROMOTION_AUDIT_PATH, + entries, + ...(malformed > 0 ? { malformed } : {}), + }); + } catch (err) { + log(`[memory-promote] audit read failed: ${messageOf(err)}`); + res + .status(500) + .json({ error: 'audit_read_failed', message: messageOf(err) }); + } + }); + + return router; +} diff --git a/middleware/src/routes/memoryPurge.ts b/middleware/src/routes/memoryPurge.ts index 842af40f9..11c61daab 100644 --- a/middleware/src/routes/memoryPurge.ts +++ b/middleware/src/routes/memoryPurge.ts @@ -22,7 +22,10 @@ import { * is no client-side `ADMIN_TOKEN`. * * Bulk-deletes memory across both layers: - * - scratch (`MemoryStore`) via `previewMemoryPurge` / `purgeMemory` + * - scratch (`MemoryStore`) via `previewMemoryPurge` / `purgeMemory` — + * including, since the chat-context memory ACL, the per-context trees under + * `/memories/contexts`, which is what gives the team/channel/user axes a + * scratch footprint at all * - Knowledge-Graph `MemorableKnowledge` via `count/purgeMemorableKnowledge` * Type-to-confirm is enforced server-side; a single `memory_purge_audit` * row is written per executed purge. @@ -56,9 +59,66 @@ export interface MemoryPurgeDeps { tenantId?: string; } +/** + * Warning surfaced for an axis whose Knowledge-Graph half is not modelled. + * + * This used to read "only scratch memory is affected", which was misleading in + * BOTH directions: at the time a team/channel purge touched no scratch memory + * either (the axes had no `/memories` footprint at all), so the sentence + * promised an effect that did not happen. Now that the context trees exist the + * scratch half is real, and the part that needs saying is the OTHER half: the + * Knowledge-Graph is deliberately left alone. Modelling a team/channel KG + * partition is a follow-up, not something to fake with an invented filter. + */ +function kgUnmodelledWarning( + axis: MemoryPurgeAxis, + past: boolean, + scratchTargets: number, +): string { + const kgHalf = + `${axis}-scoped Knowledge-Graph purge is not yet modeled (no KG column), ` + + `so the Knowledge-Graph ${past ? 'was' : 'is'} left untouched. `; + // Never claim an effect the purge did not have: a selector that resolves to + // zero trees is the single most likely operator mistake on this surface, and + // reporting "the scratch trees were affected" would hide it behind a 200. + const scratchHalf = + scratchTargets === 0 + ? `No matching context tree ${past ? 'was found' : 'exists'} under /memories/contexts either — nothing ${past ? 'was' : 'will be'} deleted.` + : `Only the /memories/contexts scratch trees ${past ? 'were' : 'are'} affected.`; + return kgHalf + scratchHalf; +} + +/** + * Warning for the `user` axis, whose two halves consume the selector in + * INCOMPATIBLE spellings: the KG matches it raw as `aclOwner`, while the purge + * service resolves it as a `~` context key. At most one half + * can match any given selector, and without this the operator gets a 200 and a + * half-done purge with nothing saying which leg was a no-op. + * + * Deliberately a warning and not a fix: reconciling the two spellings means + * modelling a KG context column, which design §7 puts outside this wave. What + * is fixable here is the silence. + */ +function userAxisSplitWarning( + scratchTargets: number, + kgDeleted: number, + past: boolean, +): string | undefined { + if (scratchTargets > 0 && kgDeleted > 0) return undefined; + if (scratchTargets === 0 && kgDeleted === 0) return undefined; + return scratchTargets === 0 + ? `The Knowledge-Graph half matched, but no context tree under /memories/contexts ${past ? 'did' : 'does'}: the KG matches the selector raw as an ACL owner, while a context tree is named "~". The user's scratch memory ${past ? 'was' : 'will be'} NOT purged.` + : `The context trees matched, but no Knowledge-Graph row did: the KG matches the selector raw as an ACL owner, not as a "~" context key. The user's Knowledge-Graph rows ${past ? 'were' : 'will be'} NOT purged.`; +} + /** Map a purge axis+selector to the KG MemorableKnowledge filter. Returns * null for axes that have no KG column yet (team/channel) so the caller can - * surface a warning instead of fabricating a filter. */ + * surface a warning instead of fabricating a filter. + * + * NOTE: for `user` the selector doubles as the KG `aclOwner` AND — via + * `memoryContextKey` inside the purge service — as the scratch context key. + * Reconciling those two spellings belongs to the KG team-axis follow-up; this + * router deliberately does not invent a mapping between them. */ function axisToKgFilter( axis: MemoryPurgeAxis, selector: string | undefined, @@ -78,6 +138,17 @@ function axisToKgFilter( } } +/** + * The service's typed error code, when it carried one. Surfacing it instead of + * a blanket `memory_purge_failed` is what lets the Danger-Zone UI tell a + * mistyped selector (`invalid_selector`) apart from a store failure. + */ +function errorCode(err: unknown): string | undefined { + if (err === null || typeof err !== 'object' || !('code' in err)) return undefined; + const code = (err as { code: unknown }).code; + return typeof code === 'string' ? code : undefined; +} + let auditTableReady: Promise | null = null; /** Lazily create the `memory_purge_audit` table on first use. Idempotent. */ @@ -145,15 +216,19 @@ export function createMemoryPurgeRouter(deps: MemoryPurgeDeps): Router { let warning: string | undefined; const filter = axisToKgFilter(axis, selector, tenantId); if (filter === null) { - warning = `${axis}-scoped Knowledge-Graph purge is not yet modeled (no KG column); only scratch memory is affected.`; + warning = kgUnmodelledWarning(axis, false, scratchCount); } else if (deps.knowledgeGraph) { kgCount = (await deps.knowledgeGraph.countMemorableKnowledge(filter)) .count; } + if (axis === 'user') { + warning = userAxisSplitWarning(scratchCount, kgCount, false) ?? warning; + } res.json({ scratchCount, kgCount, ...(warning ? { warning } : {}) }); } catch (err) { const message = err instanceof Error ? err.message : String(err); - res.status(400).json({ error: 'memory_purge_preview_failed', message }); + const code = errorCode(err) ?? 'memory_purge_preview_failed'; + res.status(400).json({ error: code, message }); } }); @@ -170,6 +245,12 @@ export function createMemoryPurgeRouter(deps: MemoryPurgeDeps): Router { // Server-side type-to-confirm: 'all' demands the fixed phrase; every // other axis demands the selector be re-typed verbatim. + // + // "Verbatim" means the string the OPERATOR typed, never the `ctxKey` the + // purge service derives from it. Confirming against the derived key would + // make the gesture unperformable (the operator cannot type a sha256 stem) + // and would silently accept two different selectors that normalise to one + // key — the confirmation must guard the input, not the normalisation. const expected = axis === 'all' ? CONFIRM_ALL : (selector ?? ''); if (confirm !== expected || (axis !== 'all' && expected.length === 0)) { res.status(400).json({ error: 'confirmation_mismatch' }); @@ -185,11 +266,14 @@ export function createMemoryPurgeRouter(deps: MemoryPurgeDeps): Router { let warning: string | undefined; const filter = axisToKgFilter(axis, selector, tenantId); if (filter === null) { - warning = `${axis}-scoped Knowledge-Graph purge is not yet modeled (no KG column); only scratch memory was affected.`; + warning = kgUnmodelledWarning(axis, true, scratchDeleted); } else if (deps.knowledgeGraph) { kgDeleted = (await deps.knowledgeGraph.purgeMemorableKnowledge(filter)) .deletedNodes; } + if (axis === 'user') { + warning = userAxisSplitWarning(scratchDeleted, kgDeleted, true) ?? warning; + } if (deps.graphPool) { try { @@ -211,7 +295,8 @@ export function createMemoryPurgeRouter(deps: MemoryPurgeDeps): Router { res.json({ scratchDeleted, kgDeleted, ...(warning ? { warning } : {}) }); } catch (err) { const message = err instanceof Error ? err.message : String(err); - res.status(400).json({ error: 'memory_purge_failed', message }); + const code = errorCode(err) ?? 'memory_purge_failed'; + res.status(400).json({ error: code, message }); } }); diff --git a/middleware/src/services/memoryPromote.ts b/middleware/src/services/memoryPromote.ts new file mode 100644 index 000000000..2929d2045 --- /dev/null +++ b/middleware/src/services/memoryPromote.ts @@ -0,0 +1,501 @@ +import type { MemoryStore } from '@omadia/plugin-api'; + +/** + * W5 — `promoteMemory`: the explicit operator act that moves knowledge between + * an agent's memory tiers (design spec #870 §6, epic #860). + * + * Context-scoped memory keeps what an agent learns in team A out of team B. + * Sharing across that line is therefore never implicit — it is this one + * operator action, and it is audited three ways (§6): + * + * (a) an append-only JSONL line in {@link PROMOTION_AUDIT_PATH} — inside the + * shared `core` namespace, so agents can READ it while the operator has + * it in one central place. Read-only for agents is not a property of + * `core` (which is a read/write grant every agent holds): it is enforced + * by the `/memories/core/audit/` deny prefix in `ScopedMemoryStore`, + * without which any agent could rewrite the record of what an operator + * did to its memory. This service writes it on the ROOT store, which + * never passes through that wrapper; + * (b) provenance frontmatter (`promoted-from` / `promoted-by` / + * `promoted-at`) in every promoted markdown file; + * (c) a structured `[security-audit]` log line, the idiom + * `buildOrchestrator.ts` already uses (there is no central audit bus). + * + * Like {@link file://./memoryPurge.ts}, this runs on the ROOT (undecorated) + * `MemoryStore`: promotion crosses the scopes a `ScopedMemoryStore` enforces, + * so it cannot run inside one. It stays backend-agnostic for the same reason + * purge does — only the existing `list` / `fileExists` / `directoryExists` / + * `readFile` / `writeFile` / `delete` surface is used, so filesystem and + * Postgres stores work unchanged (spec §7: no schema change). + * + * Physical layout: + * + * /memories/orchestrators//... — agent tier + * /memories/contexts//team//... — team tier + * /memories/contexts//channel//... — channel tier + * /memories/contexts//user//... — user tier + * + * Both roots are built from the SAME `agentSlug`, so promotion is structurally + * per-agent (spec §9: never cross-agent). Anything that would escape that + * agent's two roots — a `..` segment, a `/` inside a context key, an absolute + * path — is REJECTED, never clamped. + * + * `` is derived by `memoryContextKey` (channel SDK) at the caller / + * route boundary and never re-derived here; this service validates the shape + * it must have (see {@link CTX_KEY_RE}). + */ + +/** Copy leaves the source in place; move removes it after every write lands. */ +export type PromoteMode = 'copy' | 'move'; + +/** Context tiers a promotion can read from. */ +export type PromoteSourceAxis = 'team' | 'channel' | 'user'; + +/** Tiers a promotion can write to (spec §6: upward, plus downward "seed"). */ +export type PromoteTargetTier = 'agent' | 'team'; + +export interface PromoteSource { + readonly axis: PromoteSourceAxis; + /** Context key as produced by `memoryContextKey`. Never contains `:` or `/`. */ + readonly ctxKey: string; + /** File or directory, RELATIVE to the tier root. */ + readonly path: string; +} + +export interface PromoteTarget { + readonly tier: PromoteTargetTier; + /** Required for `tier: 'team'`; rejected for `tier: 'agent'`. */ + readonly ctxKey?: string; + /** Relative target path. Defaults to the source path. */ + readonly path?: string; +} + +export interface PromoteRequest { + readonly agentSlug: string; + readonly source: PromoteSource; + readonly target: PromoteTarget; + readonly mode: PromoteMode; + /** Operator identity from the session. Recorded in every audit surface. */ + readonly actor: string; + readonly reason?: string; + /** + * Allow overwriting files that already exist at the target. Default `false`: + * a promotion refuses rather than silently clobbering existing knowledge. + */ + readonly overwrite?: boolean; +} + +export interface PromotedFile { + readonly sourcePath: string; + readonly targetPath: string; + /** UTF-8 byte length of the content written to the target (frontmatter included). */ + readonly bytes: number; + /** Whether provenance frontmatter was added (markdown-ish files only). */ + readonly provenance: boolean; +} + +export interface PromoteReceipt { + readonly ts: string; + readonly agentSlug: string; + readonly actor: string; + readonly mode: PromoteMode; + /** Absolute source root — the file or subtree that was promoted. */ + readonly sourcePath: string; + /** Absolute target root. */ + readonly targetPath: string; + readonly reason?: string; + /** Sum of {@link PromotedFile.bytes}. */ + readonly bytes: number; + readonly files: readonly PromotedFile[]; + readonly auditPath: string; +} + +export interface PromoteOptions { + /** Injectable clock — tests pin the timestamp. */ + readonly now?: () => Date; + /** Injectable `[security-audit]` sink. Defaults to `console.warn`. */ + readonly securityAuditSink?: (event: Record) => void; +} + +const MEMORIES_ROOT = '/memories'; +const AGENT_TIER_ROOT = `${MEMORIES_ROOT}/orchestrators`; +const CONTEXTS_ROOT = `${MEMORIES_ROOT}/contexts`; + +/** Central promotion audit log (spec §6a). Shared `core` namespace. */ +export const PROMOTION_AUDIT_PATH = `${MEMORIES_ROOT}/core/audit/memory-promotions.jsonl`; + +/** Same shape `chat.ts` already enforces for an agent slug. */ +const AGENT_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; + +/** + * Shape of a `memoryContextKey` result: `~`, where the + * safe alphabet excludes `:` (which would break the scope-pattern grammar) and + * `/` (which would leave the tier root). + */ +const CTX_KEY_RE = /^[a-z0-9_-]{1,64}~[a-z0-9_-]{1,128}$/i; + +/** Extensions that carry YAML frontmatter without being corrupted by it. */ +const FRONTMATTER_EXTENSIONS: readonly string[] = ['.md', '.markdown', '.mdx', '.txt']; + +/** Error carrying a machine-readable `code`, matching the `memoryPurge` idiom. */ +function fail(code: string, message: string): Error & { code: string } { + return Object.assign(new Error(message), { code }); +} + +function assertRelativePath(raw: string, label: string): string { + const path = raw.trim(); + if (path.length === 0) throw fail('invalid_path', `${label} path must not be empty`); + if (path.startsWith('/')) throw fail('invalid_path', `${label} path must be relative: ${raw}`); + if (path.includes('\\')) throw fail('invalid_path', `${label} path contains a backslash: ${raw}`); + if (/\s/.test(path)) throw fail('invalid_path', `${label} path contains whitespace: ${raw}`); + const segments = path.split('/'); + for (const segment of segments) { + if (segment.length === 0) throw fail('invalid_path', `${label} path has an empty segment: ${raw}`); + if (segment === '.' || segment === '..') { + throw fail('invalid_path', `${label} path contains a traversal segment: ${raw}`); + } + } + return segments.join('/'); +} + +function assertCtxKey(raw: string, label: string): string { + const key = raw.trim(); + if (!CTX_KEY_RE.test(key)) { + throw fail('invalid_ctx_key', `${label} context key is not a memoryContextKey: ${raw}`); + } + return key; +} + +function assertAgentSlug(raw: string): string { + const slug = raw.trim(); + if (!AGENT_SLUG_RE.test(slug)) throw fail('invalid_agent_slug', `Invalid agent slug: ${raw}`); + return slug; +} + +/** Defence in depth: the built path must live under the agent's own root. */ +function assertInside(root: string, absolute: string, code: string): string { + if (absolute !== root && !absolute.startsWith(`${root}/`)) { + throw fail(code, `Path leaves the agent's tier root: ${absolute}`); + } + return absolute; +} + +function contextTierRoot(agentSlug: string, axis: PromoteSourceAxis, ctxKey: string): string { + return `${CONTEXTS_ROOT}/${agentSlug}/${axis}/${ctxKey}`; +} + +function agentTierRoot(agentSlug: string): string { + return `${AGENT_TIER_ROOT}/${agentSlug}`; +} + +/** Every root this agent's promotions may write to — nothing else is reachable. */ +function agentOwnedRoots(agentSlug: string): readonly string[] { + return [agentTierRoot(agentSlug), `${CONTEXTS_ROOT}/${agentSlug}`]; +} + +interface ResolvedRequest { + readonly agentSlug: string; + readonly actor: string; + readonly mode: PromoteMode; + readonly reason?: string; + readonly overwrite: boolean; + readonly sourceRoot: string; + readonly targetRoot: string; +} + +/** Validate the request and build the two absolute roots. Rejects, never clamps. */ +function resolveRequest(req: PromoteRequest): ResolvedRequest { + if (req.mode !== 'copy' && req.mode !== 'move') { + throw fail('invalid_mode', `Unknown promote mode: ${String(req.mode)}`); + } + const actor = (req.actor ?? '').trim(); + if (actor.length === 0) throw fail('actor_required', 'actor is required'); + + const agentSlug = assertAgentSlug(req.agentSlug ?? ''); + + const axis = req.source?.axis; + if (axis !== 'team' && axis !== 'channel' && axis !== 'user') { + throw fail('invalid_axis', `Unknown source axis: ${String(axis)}`); + } + const sourceCtxKey = assertCtxKey(req.source.ctxKey ?? '', 'source'); + const sourcePath = assertRelativePath(req.source.path ?? '', 'source'); + const sourceRoot = assertInside( + contextTierRoot(agentSlug, axis, sourceCtxKey), + `${contextTierRoot(agentSlug, axis, sourceCtxKey)}/${sourcePath}`, + 'source_escapes_agent', + ); + + const tier = req.target?.tier; + if (tier !== 'agent' && tier !== 'team') { + throw fail('invalid_tier', `Unknown target tier: ${String(tier)}`); + } + const targetPath = assertRelativePath(req.target.path ?? sourcePath, 'target'); + + let targetTierRoot: string; + if (tier === 'agent') { + if (req.target.ctxKey !== undefined) { + throw fail('invalid_ctx_key', "target tier 'agent' does not take a context key"); + } + targetTierRoot = agentTierRoot(agentSlug); + } else { + const targetCtxKey = assertCtxKey(req.target.ctxKey ?? '', 'target'); + targetTierRoot = contextTierRoot(agentSlug, 'team', targetCtxKey); + } + const targetRoot = assertInside( + targetTierRoot, + `${targetTierRoot}/${targetPath}`, + 'target_escapes_agent', + ); + + // Structural belt-and-braces: both roots must live under THIS agent. + const owned = agentOwnedRoots(agentSlug); + for (const [path, code] of [ + [sourceRoot, 'source_escapes_agent'], + [targetRoot, 'target_escapes_agent'], + ] as const) { + if (!owned.some((root) => path.startsWith(`${root}/`))) { + throw fail(code, `Path is outside agent '${agentSlug}': ${path}`); + } + } + + // Equality is not enough. A target NESTED INSIDE the source passes every + // guard above (it is legitimately under the agent), and on `mode: 'move'` the + // recursive `store.delete(sourceRoot)` that runs after the copy then wipes + // the freshly written target along with the source — net knowledge destroyed, + // with a success receipt and a success audit line. The reverse nesting + // (source inside target) is refused for the mirror reason. Reachable with + // perfectly valid typed input: `source {axis:'team', ctxKey:K, path:'notes'}` + // + `target {tier:'team', ctxKey:K, path:'notes/archive'}`. + if ( + targetRoot === sourceRoot || + targetRoot.startsWith(`${sourceRoot}/`) || + sourceRoot.startsWith(`${targetRoot}/`) + ) { + throw fail( + 'target_overlaps_source', + `Target and source overlap — one contains the other: source=${sourceRoot} target=${targetRoot}`, + ); + } + + return { + agentSlug, + actor, + mode: req.mode, + ...(req.reason !== undefined ? { reason: req.reason } : {}), + overwrite: req.overwrite === true, + sourceRoot, + targetRoot, + }; +} + +/** + * Every file under `root` (or `root` itself when it is a file). Walks with the + * 2-levels-deep `list` contract, so arbitrarily deep subtrees are covered. + */ +async function collectFiles(store: MemoryStore, root: string): Promise { + if (await store.fileExists(root)) return [root]; + if (!(await store.directoryExists(root))) { + throw fail('source_not_found', `Source does not exist: ${root}`); + } + + const files = new Set(); + const visited = new Set(); + const queue: string[] = [root]; + while (queue.length > 0) { + const dir = queue.shift(); + if (dir === undefined || visited.has(dir)) continue; + visited.add(dir); + for (const entry of await store.list(dir)) { + if (entry.virtualPath === dir) continue; + if (entry.isDirectory) { + if (!visited.has(entry.virtualPath)) queue.push(entry.virtualPath); + } else { + files.add(entry.virtualPath); + } + } + } + if (files.size === 0) throw fail('source_empty', `Source holds no files: ${root}`); + return [...files].sort(); +} + +function hasFrontmatterExtension(path: string): boolean { + const name = path.slice(path.lastIndexOf('/') + 1); + const dot = name.lastIndexOf('.'); + if (dot === -1) return true; // extensionless memory notes are markdown by convention + if (dot === 0) return false; // dotfile without an extension — keep it byte-identical + return FRONTMATTER_EXTENSIONS.includes(name.slice(dot).toLowerCase()); +} + +interface Provenance { + readonly from: string; + readonly by: string; + readonly at: string; +} + +const PROVENANCE_KEYS: readonly string[] = ['promoted-from', 'promoted-by', 'promoted-at']; + +/** YAML double-quoted scalar — JSON string escaping is a valid subset. */ +function provenanceLines(p: Provenance): string[] { + return [ + `promoted-from: ${JSON.stringify(p.from)}`, + `promoted-by: ${JSON.stringify(p.by)}`, + `promoted-at: ${JSON.stringify(p.at)}`, + ]; +} + +/** + * Add provenance frontmatter (spec §6b). An existing frontmatter block is + * extended in place (its own `promoted-*` keys are replaced, so a two-hop + * promotion records the latest hop); otherwise a block is prepended. + */ +function withProvenance(content: string, p: Provenance): string { + const lines = provenanceLines(p); + const normalised = content.replace(/\r\n/g, '\n'); + if (normalised.startsWith('---\n')) { + const end = normalised.indexOf('\n---', 3); + if (end !== -1) { + const block = normalised.slice(4, end + 1); + const rest = normalised.slice(end + 1); + const kept = block + .split('\n') + .filter((line) => !PROVENANCE_KEYS.some((key) => line.startsWith(`${key}:`))) + .filter((line, index, all) => !(line.length === 0 && index === all.length - 1)); + return `---\n${[...kept, ...lines].join('\n')}\n${rest}`; + } + } + return `---\n${lines.join('\n')}\n---\n\n${normalised}`; +} + +function byteLength(content: string): number { + return Buffer.byteLength(content, 'utf8'); +} + +/** Append one JSONL line. `MemoryStore` has no append, so read-modify-write. */ +async function appendAuditLine(store: MemoryStore, line: string): Promise { + const existing = (await store.fileExists(PROMOTION_AUDIT_PATH)) + ? await store.readFile(PROMOTION_AUDIT_PATH) + : ''; + const prefix = existing.length === 0 || existing.endsWith('\n') ? existing : `${existing}\n`; + await store.writeFile(PROMOTION_AUDIT_PATH, `${prefix}${line}\n`); +} + +/** + * Copy or move a file / subtree between tiers of ONE agent. + * + * Fails before writing anything when the source is missing or a target file + * already exists (unless `overwrite`), so a rejected promotion leaves both + * tiers untouched. A `move` deletes the source only after every write landed. + * + * Throws `Error & { code }`: + * `invalid_agent_slug` · `invalid_axis` · `invalid_tier` · `invalid_mode` · + * `invalid_ctx_key` · `invalid_path` · `actor_required` · + * `source_escapes_agent` · `target_escapes_agent` · `target_equals_source` · + * `source_not_found` · `source_empty` · `target_exists` · + * `target_is_directory` · `audit_write_failed` (carries the `receipt`). + */ +export async function promoteMemory( + store: MemoryStore, + req: PromoteRequest, + options: PromoteOptions = {}, +): Promise { + const resolved = resolveRequest(req); + const ts = (options.now?.() ?? new Date()).toISOString(); + + const sourceFiles = await collectFiles(store, resolved.sourceRoot); + const sourceIsFile = sourceFiles.length === 1 && sourceFiles[0] === resolved.sourceRoot; + + // Plan every write first — a conflict must abort before the first byte lands. + const planned: Array<{ source: string; target: string }> = []; + for (const source of sourceFiles) { + const target = sourceIsFile + ? resolved.targetRoot + : `${resolved.targetRoot}/${source.slice(resolved.sourceRoot.length + 1)}`; + assertInside(resolved.targetRoot, target, 'target_escapes_agent'); + if (await store.directoryExists(target)) { + throw fail('target_is_directory', `Target is a directory: ${target}`); + } + if (!resolved.overwrite && (await store.fileExists(target))) { + throw fail('target_exists', `Target already exists: ${target}`); + } + planned.push({ source, target }); + } + + const files: PromotedFile[] = []; + for (const { source, target } of planned) { + const raw = await store.readFile(source); + const provenance = hasFrontmatterExtension(target); + const content = provenance + ? withProvenance(raw, { from: source, by: resolved.actor, at: ts }) + : raw; + await store.writeFile(target, content); + files.push({ sourcePath: source, targetPath: target, bytes: byteLength(content), provenance }); + } + + if (resolved.mode === 'move') { + // Delete exactly what was copied, file by file — NEVER + // `store.delete(sourceRoot)`. + // + // The recursive delete removes descendants by raw path prefix, while the + // enumeration that produced `planned` came from `store.list()`, whose walk + // skips every entry whose name starts with `.` (identically in the + // in-memory and Postgres stores). A dotfile under the source was therefore + // never read, never written to the target — and would have been destroyed + // by the recursive delete, with the receipt and the audit line both + // reporting success and never mentioning it. + // + // The cost is that an emptied source DIRECTORY survives the move. That is + // the safe direction: a leftover empty directory is visible and harmless, + // silent data loss is neither. Anything the walk could not see stays where + // it is, still reachable. + for (const { source } of planned) { + await store.delete(source); + } + } + + const bytes = files.reduce((sum, file) => sum + file.bytes, 0); + const receipt: PromoteReceipt = { + ts, + agentSlug: resolved.agentSlug, + actor: resolved.actor, + mode: resolved.mode, + sourcePath: resolved.sourceRoot, + targetPath: resolved.targetRoot, + ...(resolved.reason !== undefined ? { reason: resolved.reason } : {}), + bytes, + files, + auditPath: PROMOTION_AUDIT_PATH, + }; + + const auditEvent: Record = { + event: 'memory.promote', + ts, + agentSlug: receipt.agentSlug, + actor: receipt.actor, + mode: receipt.mode, + sourcePath: receipt.sourcePath, + targetPath: receipt.targetPath, + ...(receipt.reason !== undefined ? { reason: receipt.reason } : {}), + bytes, + files: files.length, + }; + const sink = + options.securityAuditSink ?? + ((event: Record): void => { + console.warn(`[security-audit] ${JSON.stringify(event)}`); + }); + sink(auditEvent); + + const { event: _event, ...auditLine } = auditEvent; + try { + await appendAuditLine(store, JSON.stringify(auditLine)); + } catch (err) { + // The promotion already happened — surface the audit gap loudly rather + // than pretending the write was clean. + throw Object.assign( + fail('audit_write_failed', `Promotion applied but the audit line failed: ${String(err)}`), + { receipt }, + ); + } + + return receipt; +} diff --git a/middleware/src/services/memoryPurge.ts b/middleware/src/services/memoryPurge.ts index 83d466044..262b128c3 100644 --- a/middleware/src/services/memoryPurge.ts +++ b/middleware/src/services/memoryPurge.ts @@ -1,3 +1,4 @@ +import { memoryContextKey } from '@omadia/channel-sdk'; import type { MemoryStore } from '@omadia/plugin-api'; /** @@ -12,14 +13,18 @@ import type { MemoryStore } from '@omadia/plugin-api'; * Physical layout (see harness-orchestrator `scopedMemoryStore` / * `orchestratorMemoryNamespacer`): * - * /memories/orchestrators//... — per-agent private tree - * /memories/_rules, /memories/_brand — shared seed (brand/conventions) - * /memories/core — shared kernel namespace - * /memories/sessions, /chat-sessions — shared session scratch + * /memories/orchestrators//... — per-agent private tree + * /memories/contexts////... — per-agent × chat-context + * tree (axis = team | + * channel | user) + * /memories/_rules, /memories/_brand — shared seed + * /memories/core — shared kernel namespace + * /memories/sessions, /chat-sessions — shared session scratch * * The seed prefixes below are PROTECTED from `axis: 'all'` purges unless the * caller explicitly opts into `reseed` (in which case the caller is expected - * to re-seed them afterwards). + * to re-seed them afterwards). `contexts` is deliberately NOT among them: it + * is ordinary scratch, so an `axis: 'all'` purge takes it along for free. */ export type MemoryPurgeAxis = 'all' | 'agent' | 'user' | 'team' | 'channel'; @@ -45,30 +50,132 @@ export const PROTECTED_SEED_ENTRIES: readonly string[] = [ const MEMORIES_ROOT = '/memories'; +/** Root of the per-agent × chat-context scratch trees. A top-level `/memories` + * entry like any other — NOT protected, so `axis: 'all'` clears it. */ +const CONTEXTS_ROOT = `${MEMORIES_ROOT}/contexts`; + +/** The purge axes that address a chat context rather than an agent. */ +const CONTEXT_AXES = ['team', 'channel', 'user'] as const; + +type ContextPurgeAxis = (typeof CONTEXT_AXES)[number]; + +function isContextAxis(axis: MemoryPurgeAxis): axis is ContextPurgeAxis { + return (CONTEXT_AXES as readonly string[]).includes(axis); +} + +function selectorRequired(): Error { + return Object.assign(new Error('selector_required'), { + code: 'selector_required', + }); +} + interface PurgeMemoryOptions { /** When true, an `axis: 'all'` purge ALSO removes the protected seed * prefixes (caller re-seeds afterwards). Ignored for non-'all' axes. */ reseed?: boolean; } -/** Leaf name of a top-level `/memories/` entry, or null if the entry is - * not a direct child of `/memories`. */ -function topLevelName(virtualPath: string): string | null { - if (!virtualPath.startsWith(`${MEMORIES_ROOT}/`)) return null; - const rest = virtualPath.slice(MEMORIES_ROOT.length + 1); +/** First path segment of `virtualPath` below `parent`, or null when the entry + * is not inside `parent`. `list` walks two levels deep, so a caller that only + * wants the DIRECT children has to fold the grandchildren back up. */ +function childName(parent: string, virtualPath: string): string | null { + if (!virtualPath.startsWith(`${parent}/`)) return null; + const rest = virtualPath.slice(parent.length + 1); if (rest.length === 0) return null; const slash = rest.indexOf('/'); return slash === -1 ? rest : rest.slice(0, slash); } +/** Leaf name of a top-level `/memories/` entry, or null if the entry is + * not a direct child of `/memories`. */ +function topLevelName(virtualPath: string): string | null { + return childName(MEMORIES_ROOT, virtualPath); +} + +/** Distinct direct children of `parent`, or `[]` when `parent` does not exist. + * `list` throws `MemoryPathNotFoundError` on a missing directory, so the + * existence probe is load-bearing: an installation that has never written a + * context tree has no `/memories/contexts` at all. */ +async function directChildren( + store: MemoryStore, + parent: string, +): Promise { + if (!(await store.directoryExists(parent))) return []; + const entries = await store.list(parent); + const names: string[] = []; + const seen = new Set(); + for (const entry of entries) { + const name = childName(parent, entry.virtualPath); + if (name === null || seen.has(name)) continue; + seen.add(name); + names.push(name); + } + return names; +} + +function invalidSelector(): Error { + return Object.assign( + new Error( + 'a context selector must be spelled "~", e.g. "teams~19:abc@thread.tacv2" (raw native id) or "teams~19-abc-thread-tacv2-a1b2c3d4e5f60718" (the key shown in the memory browser)', + ), + { code: 'invalid_selector' }, + ); +} + +/** + * The `ctxKey` candidates an operator-typed context selector may name, as path + * segments under `/memories/contexts///`. + * + * A context key is `${channelType}~${safeKey(nativeId)}` (see + * {@link memoryContextKey}). The operator may legitimately type either + * spelling, and the two are NOT interchangeable through one derivation: + * + * - the RAW native id (`teams~19:abc@thread.tacv2`) has to be derived, and + * - the DERIVED key copied out of the memory browser must NOT be derived a + * second time. `memoryContextKey` is deliberately not idempotent on its own + * digest shape — that would make a hashed context pre-imageable, which is + * the hole this key exists to close. + * + * So both readings are resolved and the union of the trees they actually name + * is purged. The candidate set is at most two, both are keys of the requesting + * axis, and the preview counts exactly the trees the delete will remove — the + * operator sees the real number before confirming. + * + * A selector with no `~` cannot name a context at all: the channel type is + * missing, so nothing could ever match. It is REJECTED rather than passed + * through, because a Danger-Zone gesture that silently deletes nothing while + * reporting success is worse than an error — the shipped placeholder used to + * invite exactly that spelling. + */ +function contextKeyCandidates(selector: string | undefined): string[] { + const raw = (selector ?? '').trim(); + if (raw.length === 0) throw selectorRequired(); + + const separator = raw.indexOf('~'); + if (separator <= 0 || separator === raw.length - 1) throw invalidSelector(); + + const derived = memoryContextKey(raw.slice(0, separator), raw.slice(separator + 1)); + return derived === raw ? [raw] : [raw, derived]; +} + /** * Compute the set of top-level `/memories/` entries that a purge would * delete, given the axis + selector. Returns absolute virtual paths. * * - 'all' → every top-level entry except the protected seed prefixes - * (unless `reseed`, which includes them). - * - 'agent' → the single `/memories/orchestrators/` subtree. - * - others → [] (scratch is agent-scoped; user/team/channel act on KG only). + * (unless `reseed`, which includes them). `contexts` is not + * protected, so it is included. + * - 'agent' → everything that belongs to one agent: its + * `/memories/orchestrators/` tree AND its whole + * `/memories/contexts/` context forest. + * - 'team' | 'channel' | 'user' → one chat context ACROSS every agent: + * `/memories/contexts///`. The + * isolation axis is agent × context (context trees live under the + * agent slug because agent memory is never shared between + * agents), so purging a context means enumerating the agents. + * + * Returned paths are always the DEEPEST node that may be removed wholesale; + * `delete` is recursive, so no descendant needs to be listed. */ async function resolvePurgeTargets( store: MemoryStore, @@ -78,38 +185,58 @@ async function resolvePurgeTargets( ): Promise { if (axis === 'agent') { const slug = (selector ?? '').trim(); - if (slug.length === 0) { - throw Object.assign(new Error('selector_required'), { - code: 'selector_required', - }); + if (slug.length === 0) throw selectorRequired(); + + const candidates = [ + `${MEMORIES_ROOT}/orchestrators/${slug}`, + `${CONTEXTS_ROOT}/${slug}`, + ]; + const targets: string[] = []; + for (const candidate of candidates) { + if (await store.directoryExists(candidate)) targets.push(candidate); } - const target = `${MEMORIES_ROOT}/orchestrators/${slug}`; - return (await store.directoryExists(target)) ? [target] : []; + return targets; } - if (axis === 'all') { - const entries = await store.list(MEMORIES_ROOT); - const seen = new Set(); + if (isContextAxis(axis)) { + const ctxKeys = contextKeyCandidates(selector); const targets: string[] = []; - for (const entry of entries) { - const name = topLevelName(entry.virtualPath); - if (name === null || seen.has(name)) continue; - seen.add(name); - if (!reseed && PROTECTED_SEED_ENTRIES.includes(name)) continue; - targets.push(`${MEMORIES_ROOT}/${name}`); + const seen = new Set(); + for (const agentSlug of await directChildren(store, CONTEXTS_ROOT)) { + for (const ctxKey of ctxKeys) { + const target = `${CONTEXTS_ROOT}/${agentSlug}/${axis}/${ctxKey}`; + if (seen.has(target)) continue; + seen.add(target); + if (await store.directoryExists(target)) targets.push(target); + } } return targets; } - // 'user' | 'team' | 'channel' — scratch memory is agent-scoped, so these - // axes have no scratch footprint. They act only on the Knowledge-Graph. - return []; + // axis === 'all' — every top-level entry the seed guard lets through. + // `contexts` is one of them (it is not in PROTECTED_SEED_ENTRIES), so a full + // purge clears the context forest without naming it here. + const entries = await store.list(MEMORIES_ROOT); + const seen = new Set(); + const targets: string[] = []; + for (const entry of entries) { + const name = topLevelName(entry.virtualPath); + if (name === null || seen.has(name)) continue; + seen.add(name); + if (!reseed && PROTECTED_SEED_ENTRIES.includes(name)) continue; + targets.push(`${MEMORIES_ROOT}/${name}`); + } + return targets; } /** * Count the scratch entries a purge WOULD delete — dry-run preview. Never - * mutates. Returns the number of top-level `/memories/...` entries removed - * (NOT a recursive file count): one per agent subtree / seed prefix. + * mutates. Returns the number of TARGETS removed, not a recursive file count: + * one per agent subtree / seed prefix, and — for a context axis — one per AGENT + * that holds the named context. A team present in three agents therefore + * previews as 3, which is the honest number of trees the operator is about to + * lose. Preview and execute share `resolvePurgeTargets`, so the number the UI + * shows is by construction the number the delete acts on. */ export async function previewMemoryPurge( store: MemoryStore, @@ -127,15 +254,21 @@ export async function previewMemoryPurge( } /** - * Execute the scratch purge. Deletes the resolved top-level entries and - * returns how many were removed. `delete` is recursive (per the MemoryStore - * contract), so deleting `/memories/orchestrators/` removes the whole - * subtree. + * Execute the scratch purge. Deletes the resolved targets and returns how many + * were removed. `delete` is recursive (per the MemoryStore contract), so + * deleting `/memories/orchestrators/` or + * `/memories/contexts//team/` removes the whole subtree. */ export async function purgeMemory( store: MemoryStore, axis: MemoryPurgeAxis, - selector: string | undefined, + // Optional, matching `previewMemoryPurge` — the two share + // `resolvePurgeTargets`, so a caller that may omit the selector for one + // (`axis: 'all'` ignores it) must be able to omit it for the other. The + // asymmetry made `purgeMemory(store, 'all')` a type error while + // `previewMemoryPurge(store, 'all')` was fine, which is how the test tree + // accumulated the errors the ratchet was tracking. + selector?: string, options: PurgeMemoryOptions = {}, ): Promise { const targets = await resolvePurgeTargets( diff --git a/middleware/test-typecheck-baseline.json b/middleware/test-typecheck-baseline.json index ae1a901d0..b8209687b 100644 --- a/middleware/test-typecheck-baseline.json +++ b/middleware/test-typecheck-baseline.json @@ -85,7 +85,6 @@ "test/mcpRescan.test.ts": 1, "test/mcpStructuredContent.test.ts": 1, "test/mcpTransportDeprecation.test.ts": 1, - "test/memoryPurge.test.ts": 1, "test/memoryScoping.test.ts": 1, "test/migrationRunner.test.ts": 7, "test/oauth/brokerService.test.ts": 1, diff --git a/middleware/test/contextMemoryNamespacer.test.ts b/middleware/test/contextMemoryNamespacer.test.ts new file mode 100644 index 000000000..76e1e4d38 --- /dev/null +++ b/middleware/test/contextMemoryNamespacer.test.ts @@ -0,0 +1,291 @@ +/** + * `ContextMemoryNamespacer` — the per-context variant of the orchestrator + * memory bijection. The model still only ever sees `/memories/...`, but the + * bare root is physically backed by the NARROWEST tier of the turn + * (`/memories/contexts//channel/` or `.../user/`), while two + * reserved model-facing segments address the wider tiers: + * + * /memories/~team/... → /memories/contexts//team//... + * /memories/~agent/... → /memories/orchestrators//... + * + * Shared namespaces (`core`, `sessions`, `chat-sessions`, `_*`) pass through + * untouched, and `list` never emits a physical `contexts/...` path. + * + * Pollution guard: every test builds its own `InMemoryMemoryStore` — no + * module-level fixtures, no store shared between tests. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { InMemoryMemoryStore } from '@omadia/memory'; + +import { + ContextMemoryNamespacer, + OrchestratorMemoryNamespacer, +} from '../packages/harness-orchestrator/src/orchestratorMemoryNamespacer.js'; + +const SLUG = 'public'; +const CHANNEL_KEY = 'teams~19-abc-thread-tacv2-a1b2c3d4e5f60718'; +const TEAM_KEY = 'teams~team-a-0011223344556677'; + +const AGENT_ROOT = `/memories/orchestrators/${SLUG}`; +const CHANNEL_ROOT = `/memories/contexts/${SLUG}/channel/${CHANNEL_KEY}`; +const TEAM_ROOT = `/memories/contexts/${SLUG}/team/${TEAM_KEY}`; +const USER_ROOT = `/memories/contexts/${SLUG}/user/teams~aad-1234`; + +/** Full context turn: channel tier narrowest, team + agent tiers reachable. */ +function channelTurn(): { + store: InMemoryMemoryStore; + nsr: ContextMemoryNamespacer; +} { + const store = new InMemoryMemoryStore(); + const nsr = new ContextMemoryNamespacer( + { privateRoot: CHANNEL_ROOT, teamRoot: TEAM_ROOT, agentRoot: AGENT_ROOT }, + store, + ); + return { store, nsr }; +} + +/** Personal chat: user tier narrowest, no team axis. */ +function personalTurn(): { + store: InMemoryMemoryStore; + nsr: ContextMemoryNamespacer; +} { + const store = new InMemoryMemoryStore(); + const nsr = new ContextMemoryNamespacer( + { privateRoot: USER_ROOT, agentRoot: AGENT_ROOT }, + store, + ); + return { store, nsr }; +} + +test('bare /memories writes land in the narrowest (channel) tier', async () => { + const { store, nsr } = channelTurn(); + await nsr.createFile('/memories/notes.md', 'hi'); + assert.equal(await store.readFile(`${CHANNEL_ROOT}/notes.md`), 'hi'); + // The model still addresses it at the un-namespaced path. + assert.equal(await nsr.readFile('/memories/notes.md'), 'hi'); + assert.equal(await nsr.fileExists('/memories/notes.md'), true); +}); + +test('the user tier behaves exactly like the channel tier', async () => { + const { store, nsr } = personalTurn(); + await nsr.writeFile('/memories/prefs.md', 'dark-mode'); + assert.equal(await store.readFile(`${USER_ROOT}/prefs.md`), 'dark-mode'); + assert.equal(await nsr.readFile('/memories/prefs.md'), 'dark-mode'); +}); + +test('/memories/~team maps to the team tier when a team axis exists', async () => { + const { store, nsr } = channelTurn(); + await nsr.createFile('/memories/~team/glossary.md', 'team-wide'); + assert.equal(await store.readFile(`${TEAM_ROOT}/glossary.md`), 'team-wide'); + assert.equal(await nsr.readFile('/memories/~team/glossary.md'), 'team-wide'); + // The channel tier is untouched by a `~team` write. + assert.equal(await store.fileExists(`${CHANNEL_ROOT}/glossary.md`), false); +}); + +test('/memories/~agent maps to the agent tier (mapper does not enforce ro)', async () => { + const { store, nsr } = channelTurn(); + await store.writeFile(`${AGENT_ROOT}/legacy.md`, 'old-knowledge'); + assert.equal( + await nsr.readFile('/memories/~agent/legacy.md'), + 'old-knowledge', + ); + assert.equal(await nsr.fileExists('/memories/~agent/legacy.md'), true); + // Read-only is the ScopedMemoryStore's job — the mapper only rewrites. + await nsr.writeFile('/memories/~agent/written.md', 'x'); + assert.equal(await store.readFile(`${AGENT_ROOT}/written.md`), 'x'); +}); + +test('a reserved root is addressable bare and round-trips through list', async () => { + const { nsr } = channelTurn(); + await nsr.createFile('/memories/~team/a.md', '1'); + const paths = (await nsr.list('/memories/~team')) + .map((e) => e.virtualPath) + .sort(); + assert.deepEqual(paths, ['/memories/~team', '/memories/~team/a.md']); +}); + +test('an unbound ~team stays outside every context root (fail-closed)', async () => { + const { store, nsr } = personalTurn(); + await nsr.writeFile('/memories/~team/leak.md', 'nope'); + // NOT silently redirected into the private tier — it stays at the outer + // path, where no compiled pattern matches it and the ScopedMemoryStore + // (layered underneath in production) raises a MemoryScopeViolation. + assert.equal(await store.readFile('/memories/~team/leak.md'), 'nope'); + assert.equal(await store.fileExists(`${USER_ROOT}/~team/leak.md`), false); +}); + +test('shared namespaces (core, _rules, chat-sessions) pass through', async () => { + const { store, nsr } = channelTurn(); + await nsr.writeFile('/memories/core/rules.md', 'shared'); + await nsr.writeFile('/memories/_rules/durable.md', 'rules'); + await nsr.writeFile('/memories/chat-sessions/s1.md', 'transcript'); + // Physically NOT under any context tree. + assert.equal(await store.readFile('/memories/core/rules.md'), 'shared'); + assert.equal(await store.readFile('/memories/_rules/durable.md'), 'rules'); + assert.equal( + await store.readFile('/memories/chat-sessions/s1.md'), + 'transcript', + ); + // …and readable back through the mapper at the same outer path. + assert.equal(await nsr.readFile('/memories/core/rules.md'), 'shared'); + assert.equal(await nsr.readFile('/memories/_rules/durable.md'), 'rules'); +}); + +test('toInner/toOuter round-trip for every model-facing prefix', async () => { + const { store, nsr } = channelTurn(); + const outerPaths = [ + '/memories/notes.md', + '/memories/sub/deep.md', + '/memories/~team/glossary.md', + '/memories/~agent/legacy.md', + '/memories/core/rules.md', + '/memories/_rules/durable.md', + ]; + for (const [i, p] of outerPaths.entries()) { + await nsr.writeFile(p, `v${i}`); + } + // Every write reads back at exactly the path it was written to… + for (const [i, p] of outerPaths.entries()) { + assert.equal(await nsr.readFile(p), `v${i}`); + } + // …and each outer prefix has its own distinct physical home (injective). + const expected = [ + `${CHANNEL_ROOT}/notes.md`, + `${CHANNEL_ROOT}/sub/deep.md`, + `${TEAM_ROOT}/glossary.md`, + `${AGENT_ROOT}/legacy.md`, + '/memories/core/rules.md', + '/memories/_rules/durable.md', + ]; + assert.deepEqual((await collectFiles(store)).sort(), expected.sort()); +}); + +test('list never emits a physical contexts/... path', async () => { + const { nsr } = channelTurn(); + await nsr.createFile('/memories/a.md', '1'); + await nsr.createFile('/memories/sub/b.md', '2'); + const paths = (await nsr.list('/memories')).map((e) => e.virtualPath).sort(); + assert.ok(paths.every((p) => p === '/memories' || p.startsWith('/memories/'))); + assert.ok(!paths.some((p) => p.includes('/contexts/'))); + assert.ok(!paths.some((p) => p.includes('/orchestrators/'))); + assert.ok(paths.includes('/memories/a.md')); + assert.ok(paths.includes('/memories/sub')); +}); + +test('list of the agent tier round-trips through the ~agent prefix', async () => { + const { store, nsr } = channelTurn(); + await store.writeFile(`${AGENT_ROOT}/legacy.md`, 'old'); + const paths = (await nsr.list('/memories/~agent')) + .map((e) => e.virtualPath) + .sort(); + assert.deepEqual(paths, ['/memories/~agent', '/memories/~agent/legacy.md']); +}); + +test('two contexts of one agent do not collide at the same model path', async () => { + const store = new InMemoryMemoryStore(); + const teamA = new ContextMemoryNamespacer( + { privateRoot: `/memories/contexts/${SLUG}/team/teams~a` }, + store, + ); + const teamB = new ContextMemoryNamespacer( + { privateRoot: `/memories/contexts/${SLUG}/team/teams~b` }, + store, + ); + await teamA.createFile('/memories/secret.md', 'from-a'); + await teamB.createFile('/memories/secret.md', 'from-b'); + assert.equal(await teamA.readFile('/memories/secret.md'), 'from-a'); + assert.equal(await teamB.readFile('/memories/secret.md'), 'from-b'); +}); + +test('context-free construction matches the legacy namespacer on non-reserved paths', async () => { + const legacyStore = new InMemoryMemoryStore(); + const legacy = new OrchestratorMemoryNamespacer(SLUG, legacyStore); + const ctxStore = new InMemoryMemoryStore(); + // No team/agent root → no reserved segment is bound, so every NON-reserved + // path behaves like today: privatized into the Agent tree, shared segments + // pass through. `~team` / `~agent` are the documented exception, asserted + // separately below. + const ctx = new ContextMemoryNamespacer({ privateRoot: AGENT_ROOT }, ctxStore); + + for (const p of ['/memories/notes.md', '/memories/core/rules.md']) { + await legacy.writeFile(p, 'v'); + await ctx.writeFile(p, 'v'); + } + assert.deepEqual( + (await collectFiles(ctxStore)).sort(), + (await collectFiles(legacyStore)).sort(), + ); + assert.deepEqual( + (await ctx.list('/memories')).map((e) => e.virtualPath).sort(), + (await legacy.list('/memories')).map((e) => e.virtualPath).sort(), + ); +}); + +test('the reserved segments are where the two namespacers deliberately diverge', async () => { + // The legacy class privatizes `~team` like any other segment; the context + // class always reserves it, so an UNBOUND `~team` stays in the outer + // namespace and is denied by the ScopedMemoryStore underneath. This is why + // MemoryBinder routes context-free turns through OrchestratorMemoryNamespacer + // rather than through ContextMemoryNamespacer with the agent root — an agent + // holding a top-level `~team` entry must not silently lose access to it. + const legacyStore = new InMemoryMemoryStore(); + const legacy = new OrchestratorMemoryNamespacer(SLUG, legacyStore); + const ctxStore = new InMemoryMemoryStore(); + const ctx = new ContextMemoryNamespacer({ privateRoot: AGENT_ROOT }, ctxStore); + + for (const p of ['/memories/~team/g.md', '/memories/~agent/h.md']) { + await legacy.writeFile(p, 'v'); + await ctx.writeFile(p, 'v'); + } + + assert.deepEqual((await collectFiles(legacyStore)).sort(), [ + `${AGENT_ROOT}/~agent/h.md`, + `${AGENT_ROOT}/~team/g.md`, + ]); + // Unmapped — outside any compiled context scope, so the store below denies it. + assert.deepEqual((await collectFiles(ctxStore)).sort(), [ + '/memories/~agent/h.md', + '/memories/~team/g.md', + ]); +}); + +test('rename maps both sides and can move across tiers', async () => { + const { store, nsr } = channelTurn(); + await nsr.createFile('/memories/draft.md', 'body'); + await nsr.rename('/memories/draft.md', '/memories/~team/final.md'); + assert.equal(await store.readFile(`${TEAM_ROOT}/final.md`), 'body'); + assert.equal(await store.fileExists(`${CHANNEL_ROOT}/draft.md`), false); +}); + +test('delete and directoryExists go through the same mapping', async () => { + const { store, nsr } = channelTurn(); + await nsr.createFile('/memories/~team/dir/x.md', '1'); + assert.equal(await nsr.directoryExists('/memories/~team/dir'), true); + assert.equal(await nsr.directoryExists('/memories/dir'), false); + await nsr.delete('/memories/~team/dir/x.md'); + assert.equal(await store.fileExists(`${TEAM_ROOT}/dir/x.md`), false); +}); + +/** + * Enumerates every physical file path held by an in-memory store. `list` + * walks two levels per call, so the overlapping walks are de-duplicated. + */ +async function collectFiles(store: InMemoryMemoryStore): Promise { + const files = new Set(); + const visited = new Set(); + const stack = ['/memories']; + while (stack.length > 0) { + const dir = stack.pop(); + if (dir === undefined || visited.has(dir)) continue; + visited.add(dir); + for (const entry of await store.list(dir)) { + if (entry.virtualPath === dir) continue; + if (entry.isDirectory) stack.push(entry.virtualPath); + else files.add(entry.virtualPath); + } + } + return [...files]; +} diff --git a/middleware/test/effectiveMemoryScope.test.ts b/middleware/test/effectiveMemoryScope.test.ts new file mode 100644 index 000000000..8f8a4846b --- /dev/null +++ b/middleware/test/effectiveMemoryScope.test.ts @@ -0,0 +1,501 @@ +/** + * W5 memory-ACL — context-scoped agent memory (design #870). + * + * This file is the §8.1 unit matrix. It currently covers + * `effectiveMemoryScope`: the per-turn intersection of the static agent scope + * with the dynamic context axes. + * + * The function decides no path — it only names scopes — so every case here is a + * pure table assertion on the emitted tokens. What is under test is the + * SECURITY property, not a formatting one: an unrecognised turn must land on + * row 1 of the §2 table (agent-private, no context tree), a recognised one must + * lose write access to the agent tier, and neither may ever emit a token wider + * than `core` + this agent's own tiers. + * + * Pollution guard (§8): every case builds its own inputs and its own log sink. + * No module-level fixtures, no shared state, no env mutation. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + effectiveMemoryScope, + orchestratorMemoryScope, + type MemoryAxes, +} from '../packages/harness-orchestrator/src/registry/scopedMemoryStore.js'; + +interface LogLine { + readonly msg: string; + readonly fields?: Record; +} + +function sink(): { lines: LogLine[]; log: (msg: string, fields?: Record) => void } { + const lines: LogLine[] = []; + return { lines, log: (msg, fields) => void lines.push({ msg, fields }) }; +} + +/** Row 1 of the §2 table — the fail-closed answer, in every shape it arrives in. */ +function contextFreeAxes(): MemoryAxes { + return { isContextFree: true, patterns: [] }; +} + +function channelAxes(ctxKey: string): MemoryAxes { + return { + isContextFree: false, + patterns: [`channel:${ctxKey}:*`], + narrowest: { axis: 'channel', ctxKey }, + }; +} + +// --------------------------------------------------------------------------- +// Golden comparison: the context-free branch is exactly today's behaviour. +// --------------------------------------------------------------------------- + +test('context-free axes return byte-identical output to orchestratorMemoryScope', () => { + // Arrange — the compat anchor is the whole point of the fail-closed branch: + // a turn that names no context must behave as it does today, not merely + // "similarly". + for (const slug of ['public', 'hr-agent', 'a', 'agent_with_underscores']) { + // Act + const effective = effectiveMemoryScope(slug, contextFreeAxes()); + + // Assert + assert.deepStrictEqual(effective, orchestratorMemoryScope(slug)); + assert.deepStrictEqual(effective, ['core', `orchestrator:${slug}:*`]); + } +}); + +test('context-free branch grants the agent tier read-WRITE (no ro: modifier)', () => { + // Arrange / Act + const scope = effectiveMemoryScope('public', contextFreeAxes()); + + // Assert — operator UI / CLI turns must keep writing where they write today. + assert.ok(scope.includes('orchestrator:public:*')); + assert.ok(!scope.some((p) => p.startsWith('ro:'))); +}); + +test('a context-free axes object cannot smuggle context patterns in', () => { + // Arrange — a half-built or hostile axes: the flag says "no context", the + // patterns say otherwise. The flag wins, because trusting the patterns here + // would let a plugin open a tier it just declared unreachable. + const axes: MemoryAxes = { + isContextFree: true, + patterns: ['team:teams~acme:*', 'channel:teams~c1:*'], + }; + + // Act + const scope = effectiveMemoryScope('public', axes); + + // Assert + assert.deepStrictEqual(scope, orchestratorMemoryScope('public')); +}); + +// --------------------------------------------------------------------------- +// Context turns: static ∩ dynamic. +// --------------------------------------------------------------------------- + +test('a channel-context turn adds its tier and downgrades the agent tier to ro:', () => { + // Arrange / Act + const scope = effectiveMemoryScope('public', channelAxes('teams~c1')); + + // Assert — §4 step 7, verbatim. + assert.deepStrictEqual(scope, [ + 'ro:core', + 'ro:orchestrator:public:*', + 'channel:teams~c1:*', + ]); +}); + +test('the agent tier is read-only from context turns — the bare token never appears', () => { + // Arrange — this is the leak channel the design exists to close: if + // `orchestrator::*` survived without `ro:`, "note this globally" in + // team A would be readable in team B on the next turn. + const axes: MemoryAxes = { + isContextFree: false, + patterns: ['channel:teams~c1:*', 'team:teams~acme:*'], + narrowest: { axis: 'channel', ctxKey: 'teams~c1' }, + }; + + // Act + const scope = effectiveMemoryScope('public', axes); + + // Assert + assert.ok(!scope.includes('orchestrator:public:*')); + assert.ok(scope.includes('ro:orchestrator:public:*')); +}); + +test('team and channel tiers keep the narrowest-first order the axes gave them', () => { + // Arrange — the binder reads the default write target off the first context + // pattern, so the order is load-bearing rather than cosmetic. + const axes: MemoryAxes = { + isContextFree: false, + patterns: ['channel:teams~c1:*', 'team:teams~acme:*'], + narrowest: { axis: 'channel', ctxKey: 'teams~c1' }, + }; + + // Act + const scope = effectiveMemoryScope('hr', axes); + + // Assert + assert.deepStrictEqual(scope, [ + 'ro:core', + 'ro:orchestrator:hr:*', + 'channel:teams~c1:*', + 'team:teams~acme:*', + ]); +}); + +test('a personal-chat turn reaches the user tier and nothing else', () => { + // Arrange + const axes: MemoryAxes = { + isContextFree: false, + patterns: ['user:telegram~4711:*'], + narrowest: { axis: 'user', ctxKey: 'telegram~4711' }, + }; + + // Act + const scope = effectiveMemoryScope('public', axes); + + // Assert + assert.deepStrictEqual(scope, [ + 'ro:core', + 'ro:orchestrator:public:*', + 'user:telegram~4711:*', + ]); +}); + +test('core stays READABLE in every branch, but writable only context-free', () => { + // Shared kernel/seed content stays reachable across the context split (§2, + // §7) — but only for reading from a context turn. The shared trees are the + // one model-facing surface two contexts address by the same path, so a + // writable `core` would be a one-line bypass of the whole ACL: A writes + // `/memories/core/notes.md`, B reads it. Writes to the shared trees stay a + // context-FREE privilege; knowledge leaves a context via promote (decision 2). + assert.ok(effectiveMemoryScope('public', contextFreeAxes()).includes('core')); + assert.ok(effectiveMemoryScope('public', channelAxes('teams~c1')).includes('ro:core')); + assert.ok( + effectiveMemoryScope('public', channelAxes('teams~c1'), { + mode: 'enforce-strict', + }).includes('ro:core'), + ); + // …and the bare, writable token never appears on a context turn. + for (const mode of ['enforce', 'enforce-strict'] as const) { + assert.ok( + !effectiveMemoryScope('public', channelAxes('teams~c1'), { mode }).includes('core'), + ); + } +}); + +test('duplicate context patterns collapse so the scope string stays canonical', () => { + // Arrange — the binder caches its per-context stack under the canonical + // scope string; two spellings of one scope would be two cache entries. + const axes: MemoryAxes = { + isContextFree: false, + patterns: ['channel:teams~c1:*', 'channel:teams~c1:*', 'team:teams~acme:*'], + }; + + // Act + const scope = effectiveMemoryScope('public', axes); + + // Assert + assert.deepStrictEqual(scope, [ + 'ro:core', + 'ro:orchestrator:public:*', + 'channel:teams~c1:*', + 'team:teams~acme:*', + ]); +}); + +// --------------------------------------------------------------------------- +// Fail-closed: nothing a plugin sends may widen the scope. +// --------------------------------------------------------------------------- + +test('patterns outside the three context tiers are dropped and logged', () => { + // Arrange — `axes.patterns` crosses a package boundary from an independently + // versioned channel plugin. Each of these would WIDEN the turn if it were + // passed through: another agent's tree, the shared namespace as a write + // target, a raw path, a key smuggling a `:` to re-parse as another tier. + const widening = [ + 'core', + 'orchestrator:other-agent:*', + 'agent:other:*', + 'session:*', + '/memories/*', + '/memories/core/rules.md', + 'team:teams:acme:*', + 'team::*', + 'channel:teams~c1:**', + 'channel:teams~c1', + '', + ]; + + for (const bad of widening) { + const { lines, log } = sink(); + const axes: MemoryAxes = { + isContextFree: false, + patterns: ['channel:teams~c1:*', bad], + }; + + // Act + const scope = effectiveMemoryScope('public', axes, { log }); + + // Assert — the exact shape is the real check. (Note `'core'` is in the + // output either way: it is granted by the STATIC scope, never by an axis. + // That is precisely why an axis may not be trusted to name it.) + assert.deepStrictEqual( + scope, + ['ro:core', 'ro:orchestrator:public:*', 'channel:teams~c1:*'], + `pattern "${bad}" must not survive`, + ); + assert.equal(lines.length, 1, `pattern "${bad}" must be logged`); + assert.match(lines[0]!.msg, /dropping non-context axis pattern/); + assert.equal(lines[0]!.fields?.pattern, bad); + } +}); + +test('a context turn whose patterns are all unusable falls back to row 1', () => { + // Arrange — indistinguishable from a turn that named no tier at all, so it + // must get the same answer rather than an empty (or partial) context scope. + const axes: MemoryAxes = { isContextFree: false, patterns: ['core', '/memories/*'] }; + + // Act + const scope = effectiveMemoryScope('public', axes); + + // Assert + assert.deepStrictEqual(scope, orchestratorMemoryScope('public')); +}); + +test('a context turn with an empty pattern list falls back to row 1', () => { + // Arrange / Act + const scope = effectiveMemoryScope('public', { isContextFree: false, patterns: [] }); + + // Assert + assert.deepStrictEqual(scope, orchestratorMemoryScope('public')); +}); + +test('malformed axes never throw on the message path', () => { + // Arrange — a bug in a channel plugin must not drop a user's turn. Each of + // these is a shape the type system forbids but the wire allows. + const malformed: unknown[] = [ + undefined, + null, + {}, + { isContextFree: false }, + { isContextFree: false, patterns: null }, + { isContextFree: false, patterns: ['channel:teams~c1:*', 42, undefined, null, {}] }, + { patterns: ['channel:teams~c1:*'] }, + { isContextFree: 'no', patterns: ['channel:teams~c1:*'] }, + ]; + + for (const axes of malformed) { + const { log } = sink(); + + // Act — must not throw. + const scope = effectiveMemoryScope('public', axes as MemoryAxes, { log }); + + // Assert — and must never be wider than the agent's own scope. + assert.ok(Array.isArray(scope)); + assert.ok(!scope.some((p) => p.includes('other'))); + for (const token of scope) { + assert.ok( + token === 'core' || + token === 'ro:core' || + token === 'orchestrator:public:*' || + token === 'ro:orchestrator:public:*' || + /^(?:team|channel|user):[^:]+:\*$/.test(token), + `unexpected token "${token}"`, + ); + } + } +}); + +test('a truthy-but-not-false isContextFree is treated as context-free', () => { + // Arrange — omission must fail closed: only an explicit `false` opens a tier. + const axes = { patterns: ['channel:teams~c1:*'] } as unknown as MemoryAxes; + + // Act + const scope = effectiveMemoryScope('public', axes); + + // Assert + assert.deepStrictEqual(scope, orchestratorMemoryScope('public')); +}); + +test('one agent never receives another agent slug in its scope', () => { + // Arrange + const axes: MemoryAxes = { + isContextFree: false, + patterns: ['team:teams~acme:*'], + narrowest: { axis: 'team', ctxKey: 'teams~acme' }, + }; + + // Act + const a = effectiveMemoryScope('agent-a', axes); + const b = effectiveMemoryScope('agent-b', axes); + + // Assert — the isolation axis is agent × context: the same team on two + // agents yields two scopes that share only `core` and the team tier. + assert.ok(a.includes('ro:orchestrator:agent-a:*')); + assert.ok(!a.some((p) => p.includes('agent-b'))); + assert.ok(b.includes('ro:orchestrator:agent-b:*')); + assert.ok(!b.some((p) => p.includes('agent-a'))); +}); + +test('the returned scope is a fresh array — callers cannot mutate a shared one', () => { + // Arrange / Act + const first = effectiveMemoryScope('public', channelAxes('teams~c1')) as string[]; + first.push('core'); + + // Assert + assert.deepStrictEqual(effectiveMemoryScope('public', channelAxes('teams~c1')), [ + 'ro:core', + 'ro:orchestrator:public:*', + 'channel:teams~c1:*', + ]); +}); + +// --------------------------------------------------------------------------- +// enforce-strict (design §10 Q3, settled): full quarantine of legacy knowledge. +// --------------------------------------------------------------------------- + +test('enforce-strict drops the agent tier from context turns entirely', () => { + // Arrange + const axes: MemoryAxes = { + isContextFree: false, + patterns: ['channel:teams~c1:*', 'team:teams~acme:*'], + }; + + // Act + const scope = effectiveMemoryScope('public', axes, { mode: 'enforce-strict' }); + + // Assert — not even read-only. + assert.deepStrictEqual(scope, ['ro:core', 'channel:teams~c1:*', 'team:teams~acme:*']); + assert.ok(!scope.some((p) => p.includes('orchestrator:'))); +}); + +test('enforce-strict with an unresolvable origin yields the agent-private scope and logs loudly', () => { + // Arrange — under strict enforcement a context-free turn is an anomaly, so + // it is audited. It is still answered, and still answered narrowly. + const { lines, log } = sink(); + + // Act + const scope = effectiveMemoryScope('public', contextFreeAxes(), { + mode: 'enforce-strict', + log, + }); + + // Assert + assert.deepStrictEqual(scope, orchestratorMemoryScope('public')); + assert.equal(lines.length, 1); + assert.match(lines[0]!.msg, /\[security-audit\]/); + assert.match(lines[0]!.msg, /no resolvable turn context/); + assert.deepStrictEqual(lines[0]!.fields, { + agentSlug: 'public', + reason: 'context-free', + mode: 'enforce-strict', + }); +}); + +test('enforce-strict names WHY the context was refused', () => { + // Arrange — 'axes-missing' and 'no-usable-context-pattern' are different + // bugs in different places; collapsing them would make the audit line + // useless for finding the producer at fault. + const cases: ReadonlyArray = [ + [undefined, 'axes-missing'], + [null, 'axes-missing'], + [{ isContextFree: true, patterns: [] }, 'context-free'], + [{ isContextFree: false, patterns: ['core'] }, 'no-usable-context-pattern'], + ]; + + for (const [axes, reason] of cases) { + const { lines, log } = sink(); + + // Act + effectiveMemoryScope('public', axes as MemoryAxes, { mode: 'enforce-strict', log }); + + // Assert + const audit = lines.filter((l) => l.msg.includes('[security-audit]')); + assert.equal(audit.length, 1); + assert.equal(audit[0]!.fields?.reason, reason); + } +}); + +test('the default mode does not audit context-free turns', () => { + // Arrange — operator UI and CLI turns are legitimately context-free; logging + // each one at security-audit level would bury the strict-mode signal. + const { lines, log } = sink(); + + // Act + effectiveMemoryScope('public', contextFreeAxes(), { log }); + + // Assert + assert.deepStrictEqual(lines, []); +}); + +test('the default mode DOES audit a broken axes object', () => { + // `context-free` is a legitimate turn shape and stays quiet above. These two + // are not: they only happen when a channel plugin emits a broken axes object, + // and the audit line is the only signal an operator gets that context memory + // silently stopped working for that plugin. Suppressing them outside strict + // mode — the mode production does NOT run — made the JSDoc's promise false + // and left the failure invisible. + const cases: ReadonlyArray = [ + [undefined, 'axes-missing'], + [null, 'axes-missing'], + [{ isContextFree: 'no', patterns: ['channel:teams~c1:*'] }, 'context-free'], + [{ isContextFree: false, patterns: [] }, 'no-usable-context-pattern'], + [{ isContextFree: false, patterns: ['core'] }, 'no-usable-context-pattern'], + ]; + + for (const [axes, reason] of cases) { + const { lines, log } = sink(); + + const scope = effectiveMemoryScope('public', axes as MemoryAxes, { log }); + + // Fail-closed either way — the fix is diagnosability, not the scope. + assert.deepStrictEqual(scope, orchestratorMemoryScope('public')); + + const audit = lines.filter((l) => l.msg.includes('[security-audit]')); + if (reason === 'context-free') { + // A wrong-typed flag is read as context-free by design (deny-default), + // and the default mode stays quiet about context-free turns. + assert.deepStrictEqual(audit, []); + continue; + } + assert.equal(audit.length, 1, `expected one audit line for ${reason}`); + assert.equal(audit[0]!.fields?.reason, reason); + assert.equal(audit[0]!.fields?.mode, 'enforce'); + } +}); + +test('effectiveMemoryScope is pure — it never mutates the axes it is given', () => { + // Arrange + const patterns = ['channel:teams~c1:*', 'core', 'channel:teams~c1:*']; + const axes: MemoryAxes = { isContextFree: false, patterns }; + + // Act + effectiveMemoryScope('public', axes); + + // Assert + assert.deepStrictEqual(patterns, ['channel:teams~c1:*', 'core', 'channel:teams~c1:*']); + assert.deepStrictEqual(axes, { isContextFree: false, patterns }); +}); + +test('a frozen axes object is accepted (memoryAxesForOrigin freezes its result)', () => { + // Arrange — the SDK-side producer returns frozen axes; touching them would + // throw in strict mode, which ESM always is. + const axes = Object.freeze({ + isContextFree: false, + patterns: Object.freeze(['channel:teams~c1:*']) as readonly string[], + }) as MemoryAxes; + + // Act + const scope = effectiveMemoryScope('public', axes); + + // Assert + assert.deepStrictEqual(scope, [ + 'ro:core', + 'ro:orchestrator:public:*', + 'channel:teams~c1:*', + ]); +}); diff --git a/middleware/test/memoryAxesForOrigin.test.ts b/middleware/test/memoryAxesForOrigin.test.ts new file mode 100644 index 000000000..b9e5970eb --- /dev/null +++ b/middleware/test/memoryAxesForOrigin.test.ts @@ -0,0 +1,477 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { + CONTEXT_MEMORY_CHANNEL_TYPES, + memoryAxesForOrigin, + type MemoryAxes, + type TurnOrigin, +} from '../packages/harness-channel-sdk/src/turnOrigin.js'; +import { + memoryContextKey, + parseSessionScope, +} from '../packages/harness-channel-sdk/src/scopeId.js'; + +/** + * W5 memory-ACL — `memoryAxesForOrigin`, the pure translation from "where did + * this turn come from" to the scope patterns a turn may reach (design #870 §2 + * table, §3 grammar). + * + * The suite is written as that table: one describe per row, plus a fail-closed + * block for every way an origin can fail to name a context. What is under test + * is not prettiness of keys but WHICH TIERS a turn reaches — the function is the + * single place where a turn's audience is decided, so a wrong row here is a + * cross-team leak that no downstream test would catch. + * + * Pollution guard (known full-suite bug): the function is pure and this file + * holds no module-level fixtures — every case builds its own `TurnOrigin` + * inline, and no test mutates env or shared state. + */ + +/** + * The identity string a tier is keyed on: a JSON array of the STRUCTURAL parts + * of the scope, not its wire form. + * + * Spelled out here as a second, independent implementation rather than + * imported from the SDK — importing the production helper would make every + * assertion below circular. What it must agree with is documented at + * `scopeTuple` in `turnOrigin.ts`: JSON array encoding, absent optional parts + * omitted, no separator that a part could contain. + */ +const tuple = (...parts: ReadonlyArray): string => + JSON.stringify(parts.filter((p): p is string => p !== undefined)); + +/** + * Recompute a context key through the real key derivation, but from the + * structural parts this test spells itself. + * + * Deliberately NOT `formatSessionScope(scope)`: that is injective only over + * the strings `parseSessionScope` emits, so keying on it lets a `group` scope + * and a `conversation` scope whose id spells `group:` share one tier. + */ +const conversationPattern = ( + channelType: string, + conversationId: string, + channelId?: string, +): string => + `channel:${memoryContextKey(channelType, tuple('conversation', channelId, conversationId))}:*`; + +const groupPattern = (channelType: string, groupRef: string): string => + `channel:${memoryContextKey(channelType, tuple('group', groupRef))}:*`; + +const containerTeamPattern = ( + channelType: string, + kind: 'team' | 'tenant', + id: string, +): string => `team:${memoryContextKey(channelType, tuple(kind, id))}:*`; + +const orgTeamPattern = (channelType: string, orgId: string): string => + `team:${memoryContextKey(channelType, tuple('org', orgId))}:*`; + +/** A personal scope is keyed on the PERSON alone — see `narrowAxisFor`. */ +const userPattern = (channelType: string, userId: string): string => + `user:${memoryContextKey(channelType, userId)}:*`; + +/** Row 1 of the §2 table — the shape every fail-closed path must return. */ +const assertContextFree = (axes: MemoryAxes, because: string): void => { + assert.equal(axes.isContextFree, true, because); + assert.deepEqual([...axes.patterns], [], because); + assert.equal(axes.narrowest, undefined, because); +}; + +describe('W5 memoryAxesForOrigin — §2 row 2: Teams team channel (container present)', () => { + it('reaches its own channel tier and its team tier, narrowest first', () => { + // The literal shapes Teams produces: `teams-` as the session + // scope, `channelData.team.id` as the container. + const sessionScope = 'teams-19:abc@thread.tacv2'; + const origin: TurnOrigin = { + channelType: 'teams', + scope: parseSessionScope(sessionScope), + container: { kind: 'team', id: '19:team-a@thread.tacv2' }, + principal: { kind: 'user', userId: 'aad-oid-1' }, + }; + + const axes = memoryAxesForOrigin(origin); + + assert.equal(axes.isContextFree, false); + assert.deepEqual( + [...axes.patterns], + [ + conversationPattern('teams', sessionScope), + containerTeamPattern('teams', 'team', '19:team-a@thread.tacv2'), + ], + ); + assert.deepEqual(axes.narrowest, { + axis: 'channel', + ctxKey: memoryContextKey('teams', tuple('conversation', sessionScope)), + }); + }); + + it('keeps two channels of ONE team apart while both reach the same team tier', () => { + const container = { kind: 'team', id: '19:team-a@thread.tacv2' } as const; + const general = memoryAxesForOrigin({ + channelType: 'teams', + scope: parseSessionScope('teams-19:general@thread.tacv2'), + container, + }); + const random = memoryAxesForOrigin({ + channelType: 'teams', + scope: parseSessionScope('teams-19:random@thread.tacv2'), + container, + }); + + assert.notEqual(general.patterns[0], random.patterns[0]); + assert.equal(general.patterns[1], random.patterns[1]); + }); + + it('keeps team A and team B apart — the property the whole design exists for', () => { + const scope = parseSessionScope('teams-19:shared-name@thread.tacv2'); + const teamA = memoryAxesForOrigin({ + channelType: 'teams', + scope, + container: { kind: 'team', id: '19:team-a@thread.tacv2' }, + }); + const teamB = memoryAxesForOrigin({ + channelType: 'teams', + scope, + container: { kind: 'team', id: '19:team-b@thread.tacv2' }, + }); + + assert.notEqual(teamA.patterns[1], teamB.patterns[1]); + }); + + it('does not let a team container collide with a tenant container of the same id', () => { + const scope = parseSessionScope('teams-19:c1@thread.tacv2'); + const asTeam = memoryAxesForOrigin({ + channelType: 'teams', + scope, + container: { kind: 'team', id: 'acme' }, + }); + const asTenant = memoryAxesForOrigin({ + channelType: 'teams', + scope, + container: { kind: 'tenant', id: 'acme' }, + }); + + assert.notEqual(asTeam.patterns[1], asTenant.patterns[1]); + }); +}); + +describe('W5 memoryAxesForOrigin — §2 row 3: group chat without a container', () => { + it('gives a Telegram group its channel tier and no team tier', () => { + const scope = parseSessionScope('telegram::-1001234567890'); + const axes = memoryAxesForOrigin({ channelType: 'telegram', scope }); + + assert.equal(axes.isContextFree, false); + assert.deepEqual([...axes.patterns], [conversationPattern('telegram', '-1001234567890', 'telegram')]); + assert.deepEqual(axes.narrowest, { + axis: 'channel', + ctxKey: memoryContextKey('telegram', tuple('conversation', 'telegram', '-1001234567890')), + }); + }); + + it('treats an explicit `group:` scope as a channel tier too', () => { + const axes = memoryAxesForOrigin({ + channelType: 'teams', + scope: parseSessionScope('group:19:groupchat@thread.v2'), + }); + + assert.deepEqual([...axes.patterns], [groupPattern('teams', '19:groupchat@thread.v2')]); + assert.equal(axes.narrowest?.axis, 'channel'); + }); + + it('keeps a channel-qualified conversation apart from the bare conversation id', () => { + // The channelId is part of the keyed tuple, so `a::c1` and `c1` are two + // partitions — collapsing them would merge two platforms' conversations. + const qualified = memoryAxesForOrigin({ + channelType: 'telegram', + scope: parseSessionScope('a::c1'), + }); + const bare = memoryAxesForOrigin({ + channelType: 'telegram', + scope: parseSessionScope('c1'), + }); + + assert.notEqual(qualified.patterns[0], bare.patterns[0]); + }); + + it('never lets two structurally different scopes share one tier', () => { + // Every pair below formats to the SAME `formatSessionScope` string, which + // is exactly why the key is derived from the structural tuple instead. + // Design §4 has adapters construct scopes DIRECTLY, so `parseSessionScope` + // is not on the path that would have made the wire form injective. + const collidingPairs: ReadonlyArray = [ + // group 'x' vs conversation 'group:x' → both format to `group:x` + [ + { kind: 'group', groupRef: 'x' }, + { kind: 'conversation', conversationId: 'group:x' }, + ], + // channelId 'msteams' + 'c' vs bare 'msteams::c' → the separator is + // not escaped in the wire form, so both format to `msteams::c`. + [ + { kind: 'conversation', channelId: 'msteams', conversationId: 'c' }, + { kind: 'conversation', conversationId: 'msteams::c' }, + ], + // A conversation id that spells another kind's wire form. + [ + { kind: 'conversation', conversationId: 'personal:u1' }, + { kind: 'group', groupRef: 'personal:u1' }, + ], + ]; + + for (const [left, right] of collidingPairs) { + const a = memoryAxesForOrigin({ channelType: 'teams', scope: left }); + const b = memoryAxesForOrigin({ channelType: 'teams', scope: right }); + assert.equal(a.isContextFree, false); + assert.equal(b.isContextFree, false); + assert.notEqual( + a.narrowest?.ctxKey, + b.narrowest?.ctxKey, + `${JSON.stringify(left)} and ${JSON.stringify(right)} must not share a tier`, + ); + } + }); +}); + +describe('W5 memoryAxesForOrigin — §2 row 4: personal chat', () => { + it('gives a Teams 1:1 turn only its user tier', () => { + const axes = memoryAxesForOrigin({ + channelType: 'teams', + scope: parseSessionScope('personal:aad-oid-1'), + principal: { kind: 'user', userId: 'aad-oid-1' }, + }); + + assert.equal(axes.isContextFree, false); + assert.deepEqual([...axes.patterns], [userPattern('teams', 'aad-oid-1')]); + assert.deepEqual(axes.narrowest, { + axis: 'user', + ctxKey: memoryContextKey('teams', 'aad-oid-1'), + }); + }); + + it('keys the user tier on the scope, not on the principal', () => { + // A principal that disagrees with the scope must not move the tier: the + // scope is what the platform authenticated the turn into. + const axes = memoryAxesForOrigin({ + channelType: 'telegram', + scope: parseSessionScope('personal:12345'), + principal: { kind: 'user', userId: 'someone-else' }, + }); + + assert.deepEqual([...axes.patterns], [userPattern('telegram', '12345')]); + }); + + it('separates two people in private chats on the same channel', () => { + const alice = memoryAxesForOrigin({ + channelType: 'telegram', + scope: parseSessionScope('personal:111'), + }); + const bob = memoryAxesForOrigin({ + channelType: 'telegram', + scope: parseSessionScope('personal:222'), + }); + + assert.notEqual(alice.patterns[0], bob.patterns[0]); + }); + + it('still adds a team tier when a personal turn carries a container', () => { + const axes = memoryAxesForOrigin({ + channelType: 'teams', + scope: parseSessionScope('personal:aad-oid-1'), + container: { kind: 'tenant', id: 'acme' }, + }); + + assert.deepEqual( + [...axes.patterns], + [userPattern('teams', 'aad-oid-1'), containerTeamPattern('teams', 'tenant', 'acme')], + ); + assert.equal(axes.narrowest?.axis, 'user'); + }); +}); + +describe('W5 memoryAxesForOrigin — §2 row 5: API turn with a tenant', () => { + it('reaches the tenant team tier and its own conversation tier', () => { + const axes = memoryAxesForOrigin({ + channelType: 'api', + scope: parseSessionScope('api::conv-7'), + container: { kind: 'tenant', id: 'acme' }, + }); + + assert.deepEqual( + [...axes.patterns], + [ + conversationPattern('api', 'conv-7', 'api'), + containerTeamPattern('api', 'tenant', 'acme'), + ], + ); + assert.equal(axes.narrowest?.axis, 'channel'); + }); + + it('maps an org scope with no container onto the team tier alone', () => { + // An org scope names a tenant-wide audience, not a conversation. Landing it + // on the team tier is strictly NARROWER than the context-free row it would + // otherwise take, so this is the safe direction. + const axes = memoryAxesForOrigin({ + channelType: 'api', + scope: parseSessionScope('org:acme'), + }); + + assert.equal(axes.isContextFree, false); + assert.deepEqual([...axes.patterns], [orgTeamPattern('api', 'acme')]); + assert.deepEqual(axes.narrowest, { + axis: 'team', + ctxKey: memoryContextKey('api', tuple('org', 'acme')), + }); + }); + + it('lets an explicit container win over the org scope', () => { + const axes = memoryAxesForOrigin({ + channelType: 'api', + scope: parseSessionScope('org:acme'), + container: { kind: 'tenant', id: 'globex' }, + }); + + assert.deepEqual([...axes.patterns], [containerTeamPattern('api', 'tenant', 'globex')]); + }); +}); + +describe('W5 memoryAxesForOrigin — §2 row 1: fail-closed', () => { + it('returns the context-free axes when no origin was supplied at all', () => { + // The no-flag-day case: a channel plugin that predates `origin`. + assertContextFree(memoryAxesForOrigin(undefined), 'missing origin'); + }); + + it('refuses an absent scope', () => { + assertContextFree( + memoryAxesForOrigin({ channelType: 'teams', scope: parseSessionScope(undefined) }), + 'unscoped: absent', + ); + }); + + it('refuses every shared bucket token', () => { + // These are the measured multi-caller buckets from #575. A context tree keyed + // on one of them would be shared by unrelated callers — the exact hole. + for (const token of ['http-default', 'teams-unknown', 'unknown']) { + assertContextFree( + memoryAxesForOrigin({ channelType: 'http', scope: parseSessionScope(token) }), + `unscoped: shared token ${token}`, + ); + } + }); + + it('refuses machine scopes — they have no audience by construction', () => { + for (const raw of [ + 'routine:nightly', + 'schedule:cron-1', + 'conductor:run-1', + 'conductor-builder:draft-1', + ]) { + assertContextFree( + memoryAxesForOrigin({ channelType: 'api', scope: parseSessionScope(raw) }), + `system scope ${raw}`, + ); + } + }); + + it('refuses a channel type nobody has written a §2 row for', () => { + for (const channelType of ['discord', 'canvas', 'cli', '', ' ']) { + assertContextFree( + memoryAxesForOrigin({ + channelType, + scope: parseSessionScope('c1'), + container: { kind: 'team', id: 'team-a' }, + }), + `unknown channelType ${JSON.stringify(channelType)}`, + ); + } + }); + + it('accepts a known channel type regardless of case or padding', () => { + for (const channelType of ['Teams', ' teams ', 'TEAMS']) { + const axes = memoryAxesForOrigin({ channelType, scope: parseSessionScope('c1') }); + assert.equal(axes.isContextFree, false, channelType); + assert.deepEqual([...axes.patterns], [conversationPattern('teams', 'c1')], channelType); + } + }); + + it('refuses a personal scope whose user id is blank', () => { + // `parseSessionScope('personal:')` yields an EMPTY userId — a bucket every + // such turn would share. + assertContextFree( + memoryAxesForOrigin({ channelType: 'teams', scope: parseSessionScope('personal:') }), + 'blank personal userId', + ); + }); + + it('drops a blank container instead of keying a shared team tier on it', () => { + const axes = memoryAxesForOrigin({ + channelType: 'teams', + scope: parseSessionScope('teams-19:c1@thread.tacv2'), + container: { kind: 'team', id: ' ' }, + }); + + assert.deepEqual([...axes.patterns], [conversationPattern('teams', 'teams-19:c1@thread.tacv2')]); + assert.equal(axes.narrowest?.axis, 'channel'); + }); + + it('refuses an org scope with a blank org id', () => { + assertContextFree( + memoryAxesForOrigin({ channelType: 'api', scope: parseSessionScope('org:') }), + 'blank orgId', + ); + }); + + it('refuses a container kind outside the contract', () => { + // Crosses a plugin boundary from an independently versioned package, so the + // type alone is not the guarantee. + const axes = memoryAxesForOrigin({ + channelType: 'teams', + scope: parseSessionScope('personal:u1'), + container: { kind: 'workspace', id: 'w1' } as unknown as TurnOrigin['container'], + }); + + assert.deepEqual([...axes.patterns], [userPattern('teams', 'u1')]); + }); +}); + +describe('W5 memoryAxesForOrigin — grammar and purity', () => { + it('emits only patterns the §3 grammar can compile', () => { + const axes = memoryAxesForOrigin({ + channelType: 'teams', + scope: parseSessionScope('teams-19:abc@thread.tacv2'), + container: { kind: 'team', id: '19:team-a@thread.tacv2' }, + }); + + // `` must never contain a `:` — the store matches `/^team:([^:]+):\*$/`. + for (const pattern of axes.patterns) { + assert.match(pattern, /^(team|channel|user):[a-z0-9_~-]+:\*$/, pattern); + } + }); + + it('is pure — the same origin yields an equal result and no shared mutable state', () => { + const build = (): TurnOrigin => ({ + channelType: 'teams', + scope: parseSessionScope('teams-19:abc@thread.tacv2'), + container: { kind: 'team', id: '19:team-a@thread.tacv2' }, + }); + + assert.deepEqual(memoryAxesForOrigin(build()), memoryAxesForOrigin(build())); + }); + + it('hands out a frozen context-free value that a caller cannot widen', () => { + const axes = memoryAxesForOrigin(undefined); + assert.throws(() => { + (axes.patterns as string[]).push('team:evil:*'); + }); + assertContextFree(memoryAxesForOrigin(undefined), 'still context-free after a push attempt'); + }); + + it('documents its allowlist as data the §4 recipes can be checked against', () => { + assert.deepEqual([...CONTEXT_MEMORY_CHANNEL_TYPES].sort(), [ + 'api', + 'http', + 'teams', + 'telegram', + ]); + }); +}); diff --git a/middleware/test/memoryBinder.cache.test.ts b/middleware/test/memoryBinder.cache.test.ts new file mode 100644 index 000000000..61d936ca8 --- /dev/null +++ b/middleware/test/memoryBinder.cache.test.ts @@ -0,0 +1,192 @@ +/** + * W5 — `MemoryBinder` cache behaviour (design #870 §8.9). + * + * The binder builds one memory stack per chat context and caches it, because a + * stack is pure configuration over the shared root store. Two properties have + * to hold for that to be safe: + * + * 1. The cache is a bounded LRU. A busy Agent serving hundreds of channels + * keeps a fixed number of wrappers alive, eviction is least-recently-USED + * (not least-recently-added), and evicting a binding never loses data — + * it drops a wrapper, not the tree underneath. + * 2. The key never collides. Different agents, different axes and different + * context keys must never share a binding, or the cache would become the + * leak the wave exists to close. + * + * Pollution guard: every test builds its own store and binder. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { InMemoryMemoryStore } from '@omadia/memory'; + +import { + DEFAULT_BINDER_CACHE_CAP, + MemoryBinder, + memoryBindingCacheKey, +} from '../packages/harness-orchestrator/src/memoryBinder.js'; +import { + memoryAxesForOrigin, + type TurnOrigin, +} from '../packages/harness-channel-sdk/src/turnOrigin.js'; + +const AGENT = 'acme-bot'; + +/** The separator `memoryBindingCacheKey` joins its parts with (U+001F). */ +const SEP = '\u001f'; + +/** `mode: 'enforce'` — with the shipped `'off'` default every origin would + * collapse onto ONE binding and the cache properties would be vacuous. */ +function binder(cacheCap?: number): MemoryBinder { + return new MemoryBinder({ + agentSlug: AGENT, + root: new InMemoryMemoryStore(), + mode: 'enforce', + ...(cacheCap === undefined ? {} : { cacheCap }), + }); +} + +function channel(conversationId: string, teamId?: string): TurnOrigin { + return { + channelType: 'teams', + scope: { kind: 'conversation', channelId: 'msteams', conversationId }, + ...(teamId ? { container: { kind: 'team' as const, id: teamId } } : {}), + }; +} + +test('the same origin resolves to the same binding instance', () => { + const b = binder(); + const origin = channel('19:chan-a@thread.tacv2', 'team-a'); + + assert.equal(b.forOrigin(origin), b.forOrigin(origin)); + assert.equal(b.cacheSize, 1); + // A structurally equal but distinct origin object hits the same entry — the + // key is derived from the scope, not from object identity. + assert.equal(b.forOrigin(origin), b.forOrigin(channel('19:chan-a@thread.tacv2', 'team-a'))); + assert.equal(b.cacheSize, 1); +}); + +test('different origins resolve to different bindings', () => { + const b = binder(); + const a = b.forOrigin(channel('19:chan-a@thread.tacv2', 'team-a')); + const other = b.forOrigin(channel('19:chan-b@thread.tacv2', 'team-b')); + const free = b.forOrigin(undefined); + + assert.notEqual(a, other); + assert.notEqual(a, free); + assert.equal(b.cacheSize, 3); +}); + +test('the cache never grows past its cap', () => { + const b = binder(2); + b.forOrigin(channel('19:one@thread.tacv2')); + b.forOrigin(channel('19:two@thread.tacv2')); + assert.equal(b.cacheSize, 2); + + b.forOrigin(channel('19:three@thread.tacv2')); + assert.equal(b.cacheSize, 2); + + for (let i = 0; i < 50; i += 1) b.forOrigin(channel(`19:bulk-${i}@thread.tacv2`)); + assert.equal(b.cacheSize, 2); +}); + +test('eviction is least-recently-used, not least-recently-added', () => { + const b = binder(2); + const one = b.forOrigin(channel('19:one@thread.tacv2')); + b.forOrigin(channel('19:two@thread.tacv2')); + + // Touch `one` so `two` becomes the least recently used entry. + assert.equal(b.forOrigin(channel('19:one@thread.tacv2')), one); + b.forOrigin(channel('19:three@thread.tacv2')); + + assert.equal(b.forOrigin(channel('19:one@thread.tacv2')), one, 'one should survive'); + assert.notEqual( + b.forOrigin(channel('19:two@thread.tacv2')), + undefined, + 'two is rebuilt after eviction', + ); +}); + +test('an evicted binding loses its wrapper, never its data', async () => { + const b = binder(1); + const origin = channel('19:one@thread.tacv2'); + + const first = b.forOrigin(origin); + await first.handler.handle({ command: 'create', path: '/memories/n.md', file_text: 'kept' }); + + b.forOrigin(channel('19:evictor@thread.tacv2')); + const second = b.forOrigin(origin); + + assert.notEqual(second, first, 'the wrapper was rebuilt'); + assert.equal(await second.store.readFile('/memories/n.md'), 'kept'); +}); + +test('a non-positive cap falls back to the default rather than disabling the cache', () => { + assert.equal(DEFAULT_BINDER_CACHE_CAP, 256); + const b = binder(0); + const origin = channel('19:one@thread.tacv2'); + assert.equal(b.forOrigin(origin), b.forOrigin(origin)); +}); + +test('the cache key does not collide across agents, axes or context keys', () => { + const conversation = memoryAxesForOrigin(channel('19:chan-a@thread.tacv2', 'team-a')); + const otherConversation = memoryAxesForOrigin(channel('19:chan-b@thread.tacv2', 'team-a')); + const sameConversationOtherTeam = memoryAxesForOrigin( + channel('19:chan-a@thread.tacv2', 'team-b'), + ); + const personal = memoryAxesForOrigin({ + channelType: 'teams', + scope: { kind: 'personal', userId: '19:chan-a@thread.tacv2' }, + }); + const contextFree = memoryAxesForOrigin(undefined); + + const keys = [ + memoryBindingCacheKey(AGENT, conversation, 'enforce'), + memoryBindingCacheKey(AGENT, otherConversation, 'enforce'), + memoryBindingCacheKey(AGENT, sameConversationOtherTeam, 'enforce'), + memoryBindingCacheKey(AGENT, personal, 'enforce'), + memoryBindingCacheKey(AGENT, contextFree, 'enforce'), + // Same axes, different agent — the agent slug is part of the key. + memoryBindingCacheKey('other-bot', conversation, 'enforce'), + memoryBindingCacheKey('other-bot', contextFree, 'enforce'), + // Same axes, different MODE. 'enforce' and 'enforce-strict' compile + // DIFFERENT scopes from one axes object, so sharing a cache entry between + // them would hand a strict-mode turn a stack that reads the agent tier. + memoryBindingCacheKey(AGENT, conversation, 'enforce-strict'), + memoryBindingCacheKey(AGENT, conversation, 'off'), + ]; + + assert.equal(new Set(keys).size, keys.length, `keys collided: ${keys.join('\n')}`); +}); + +test('the cache key survives a separator-shaped context key', () => { + // The key is joined with U+001F. `memoryContextKey` emits only + // `[a-z0-9_~-]`, so the separator cannot occur inside a part — but assert it + // rather than assume it, because the whole no-collision argument rests on it. + const axes = memoryAxesForOrigin(channel('19:chan-a@thread.tacv2', 'team-a')); + const key = memoryBindingCacheKey(AGENT, axes, 'enforce'); + for (const part of [AGENT, axes.narrowest?.ctxKey ?? '', ...axes.patterns]) { + assert.ok(!part.includes(SEP), `separator leaked into a key part: ${part}`); + } + assert.ok(key.includes(SEP)); +}); + +test('two binders for different agents never share a physical tree', async () => { + const root = new InMemoryMemoryStore(); + const one = new MemoryBinder({ agentSlug: 'agent-one', root, mode: 'enforce' }); + const two = new MemoryBinder({ agentSlug: 'agent-two', root, mode: 'enforce' }); + const origin = channel('19:shared-chan@thread.tacv2', 'team-a'); + + await one + .forOrigin(origin) + .handler.handle({ command: 'create', path: '/memories/n.md', file_text: 'from one' }); + + assert.equal(await two.forOrigin(origin).store.fileExists('/memories/n.md'), false); + const paths = (await root.list('/memories/contexts')).map((e) => e.virtualPath); + assert.ok(paths.includes('/memories/contexts/agent-one'), paths.join(', ')); + assert.deepEqual( + paths.filter((p) => p.includes('agent-two')), + [], + ); +}); diff --git a/middleware/test/memoryContextIsolation.test.ts b/middleware/test/memoryContextIsolation.test.ts new file mode 100644 index 000000000..54eea8901 --- /dev/null +++ b/middleware/test/memoryContextIsolation.test.ts @@ -0,0 +1,394 @@ +/** + * W5 — chat-context memory ACL: the isolation acceptance test (design #870 §8.4). + * + * One `MemoryBinder`, several turn origins. What must hold: + * + * 1. Team A ↮ Team B — a note written in team A's channel is invisible in + * team B (soft-deny through the model-facing surface) and unreadable at the + * store level (hard `MemoryScopeViolation` on an explicit read). + * 2. The agent tier is READ-ONLY from a context turn: pre-existing notes stay + * readable via `/memories/~agent/…`, writing there throws. + * 3. A context-free turn behaves exactly as today: it reads the agent tier at + * the plain `/memories` root and cannot see the context trees through that + * surface at all — they exist only physically, in the root store. + * 4. The same holds channel↔channel (with the team tier shared inside one + * team) and user↔user. + * + * Every assertion is store-level; nothing here depends on an LLM. Per the + * design's pollution guard, each test builds its own `InMemoryMemoryStore` and + * its own binder — there are no module-level fixtures. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { InMemoryMemoryStore } from '@omadia/memory'; + +import { + MemoryBinder, + type BoundTurnMemory, +} from '../packages/harness-orchestrator/src/memoryBinder.js'; +import { + MemoryScopeViolation, + ScopedMemoryStore, +} from '../packages/harness-orchestrator/src/registry/scopedMemoryStore.js'; +import type { TurnOrigin } from '../packages/harness-channel-sdk/src/turnOrigin.js'; + +const AGENT = 'acme-bot'; + +/** + * A binder in the mode an operator has switched ON. The shipped default is + * `'off'` (no flag day), which is a separate property with its own test at the + * bottom of this file — every isolation assertion here is about what `enforce` + * actually enforces, so it must not be able to pass by accident because the + * feature was inert. + */ +function fixture(): { root: InMemoryMemoryStore; binder: MemoryBinder } { + const root = new InMemoryMemoryStore(); + const binder = new MemoryBinder({ agentSlug: AGENT, root, mode: 'enforce' }); + return { root, binder }; +} + +function teamsChannel(conversationId: string, teamId?: string): TurnOrigin { + return { + channelType: 'teams', + scope: { kind: 'conversation', channelId: 'msteams', conversationId }, + ...(teamId ? { container: { kind: 'team' as const, id: teamId } } : {}), + }; +} + +function teamsPersonal(userId: string): TurnOrigin { + return { channelType: 'teams', scope: { kind: 'personal', userId } }; +} + +/** The physical root the narrowest tier of a binding writes into. */ +function narrowestRoot(bound: BoundTurnMemory): string { + const narrowest = bound.axes.narrowest; + assert.ok(narrowest, 'expected a context binding, got the context-free one'); + return `/memories/contexts/${AGENT}/${narrowest.axis}/${narrowest.ctxKey}`; +} + +async function createNote(bound: BoundTurnMemory, path: string, body: string): Promise { + return bound.handler.handle({ command: 'create', path, file_text: body }); +} + +test('a note written in team A is physically confined to team A\'s channel tier', async () => { + const { root, binder } = fixture(); + const a = binder.forOrigin(teamsChannel('19:chan-a@thread.tacv2', 'team-a')); + + const reply = await createNote(a, '/memories/secret.md', 'team A only'); + assert.match(reply, /File created at \/memories\/secret\.md\./); + + const physical = `${narrowestRoot(a)}/secret.md`; + assert.equal(await root.readFile(physical), 'team A only'); + assert.match(physical, new RegExp(`^/memories/contexts/${AGENT}/channel/teams~`)); + // The legacy agent tree is untouched — this is the whole point of the wave. + assert.equal(await root.fileExists(`/memories/orchestrators/${AGENT}/secret.md`), false); +}); + +test('team B sees nothing of team A: soft-deny through the tool, hard violation at the store', async () => { + const { root, binder } = fixture(); + const a = binder.forOrigin(teamsChannel('19:chan-a@thread.tacv2', 'team-a')); + const b = binder.forOrigin(teamsChannel('19:chan-b@thread.tacv2', 'team-b')); + + await createNote(a, '/memories/secret.md', 'team A only'); + + // Soft: through the model-facing surface team B simply has no such file. + const view = await b.handler.handle({ command: 'view', path: '/memories/secret.md' }); + assert.match(view, /does not exist/); + assert.equal(await b.store.fileExists('/memories/secret.md'), false); + // Listing team B's own root shows team B's own notes and nothing else. + await createNote(b, '/memories/own.md', 'team B only'); + const listing = await b.store.list('/memories'); + assert.deepEqual( + listing.map((e) => e.virtualPath), + ['/memories', '/memories/own.md'], + ); + + // Hard: an explicit read of team A's physical path from team B's compiled + // scope is a scope violation, not an empty result. + const bScoped = new ScopedMemoryStore({ agentSlug: AGENT, scope: b.scope, inner: root }); + await assert.rejects( + () => bScoped.readFile(`${narrowestRoot(a)}/secret.md`), + MemoryScopeViolation, + ); + await assert.rejects( + () => bScoped.createFile(`${narrowestRoot(a)}/planted.md`, 'x'), + MemoryScopeViolation, + ); +}); + +test('the agent tier is readable but not writable from a context turn', async () => { + const { root, binder } = fixture(); + await root.createFile(`/memories/orchestrators/${AGENT}/legacy.md`, 'pre-existing'); + + const a = binder.forOrigin(teamsChannel('19:chan-a@thread.tacv2', 'team-a')); + + assert.equal(await a.store.readFile('/memories/~agent/legacy.md'), 'pre-existing'); + + await assert.rejects( + () => a.store.createFile('/memories/~agent/global.md', 'leak'), + MemoryScopeViolation, + ); + const reply = await createNote(a, '/memories/~agent/global.md', 'leak'); + assert.match(reply, /is not permitted to write/); + assert.equal(await root.fileExists(`/memories/orchestrators/${AGENT}/global.md`), false); +}); + +test('a context-free turn keeps today\'s stack and cannot reach the context trees', async () => { + const { root, binder } = fixture(); + await root.createFile(`/memories/orchestrators/${AGENT}/legacy.md`, 'pre-existing'); + + const a = binder.forOrigin(teamsChannel('19:chan-a@thread.tacv2', 'team-a')); + await createNote(a, '/memories/secret.md', 'team A only'); + + const free = binder.forOrigin(undefined); + assert.deepEqual(free.scope, ['core', `orchestrator:${AGENT}:*`]); + assert.equal(free.axes.isContextFree, true); + + // Reads the agent tier at the plain root, exactly as before the wave. + assert.equal(await free.store.readFile('/memories/legacy.md'), 'pre-existing'); + // Cannot see the context tree through the model-facing surface… + const physical = `${narrowestRoot(a)}/secret.md`; + assert.equal(await free.store.fileExists(physical), false); + assert.equal(await free.store.fileExists('/memories/secret.md'), false); + // …while it does exist physically in the root store. + assert.equal(await root.readFile(physical), 'team A only'); +}); + +test('a system scope and an unscoped turn fail closed onto the context-free stack', async () => { + const { binder } = fixture(); + + const system = binder.forOrigin({ + channelType: 'teams', + scope: { kind: 'system', origin: 'routine', id: 'nightly' }, + }); + const unscoped = binder.forOrigin({ + channelType: 'http', + scope: { kind: 'unscoped', reason: 'shared', token: 'http-default' }, + }); + + for (const bound of [system, unscoped]) { + assert.equal(bound.axes.isContextFree, true); + assert.deepEqual(bound.scope, ['core', `orchestrator:${AGENT}:*`]); + } +}); + +test('two channels of the same team are isolated but share the team tier', async () => { + const { root, binder } = fixture(); + const one = binder.forOrigin(teamsChannel('19:chan-one@thread.tacv2', 'team-a')); + const two = binder.forOrigin(teamsChannel('19:chan-two@thread.tacv2', 'team-a')); + + await createNote(one, '/memories/local.md', 'channel one only'); + assert.equal(await two.store.fileExists('/memories/local.md'), false); + assert.equal(await one.store.readFile('/memories/local.md'), 'channel one only'); + + // The team tier is read-write from a matching team context (coordinator + // decision 2) and is the same tree for both channels. + await createNote(one, '/memories/~team/policy.md', 'team-wide'); + assert.equal(await two.store.readFile('/memories/~team/policy.md'), 'team-wide'); + assert.equal( + await root.readFile(`/memories/contexts/${AGENT}/team/${teamKeyOf(one)}/policy.md`), + 'team-wide', + ); +}); + +test('a channel without a team container cannot write the team tier', async () => { + const { binder } = fixture(); + const loose = binder.forOrigin(teamsChannel('19:groupchat@thread.tacv2')); + + assert.deepEqual(loose.axes.patterns.filter((p) => p.startsWith('team:')), []); + await assert.rejects( + () => loose.store.createFile('/memories/~team/policy.md', 'nope'), + MemoryScopeViolation, + ); +}); + +test('two personal chats are isolated from each other', async () => { + const { binder } = fixture(); + const alice = binder.forOrigin(teamsPersonal('aad-alice')); + const bob = binder.forOrigin(teamsPersonal('aad-bob')); + + assert.equal(alice.axes.narrowest?.axis, 'user'); + await createNote(alice, '/memories/preferences.md', 'alice likes tables'); + + assert.equal(await bob.store.fileExists('/memories/preferences.md'), false); + assert.equal(await alice.store.readFile('/memories/preferences.md'), 'alice likes tables'); +}); + +test('a context turn READS the shared core namespace but cannot write it', async () => { + // The shared trees (`core`, `sessions`, `chat-sessions`, top-level `_*`) are + // passed through by the namespacer untouched, so they are the ONE model-facing + // surface two different contexts address by the same path. Writable, they + // would be a one-line bypass of the whole ACL — hence `ro:core`. + const { root, binder } = fixture(); + await root.createFile('/memories/core/brand.md', 'shared'); + + const a = binder.forOrigin(teamsChannel('19:chan-a@thread.tacv2', 'team-a')); + assert.equal(await a.store.readFile('/memories/core/brand.md'), 'shared'); + assert.ok(a.scope.includes('ro:core'), `expected ro:core, got ${a.scope.join(', ')}`); + + await assert.rejects( + () => a.store.createFile('/memories/core/planted.md', 'x'), + MemoryScopeViolation, + ); + await assert.rejects( + () => a.store.writeFile('/memories/core/brand.md', 'overwritten'), + MemoryScopeViolation, + ); + assert.equal(await root.readFile('/memories/core/brand.md'), 'shared'); +}); + +test('the shared namespace is not a side channel between two contexts', async () => { + // The regression the reviewer asked for by name: A writes into the shared + // trees, and a cross-tier rename OUT of its private tree into `core`. Both + // must fail, and B must see neither. + const { root, binder } = fixture(); + const a = binder.forOrigin(teamsChannel('19:chan-a@thread.tacv2', 'team-a')); + const b = binder.forOrigin(teamsChannel('19:chan-b@thread.tacv2', 'team-b')); + + // (1) A top-level `_*` directory is covered by `core` and would otherwise be + // writable — the widest half of the shared surface. + await assert.rejects( + () => a.store.createFile('/memories/_x/leak.md', 'from A'), + MemoryScopeViolation, + ); + assert.equal(await b.store.fileExists('/memories/_x/leak.md'), false); + assert.equal(await root.fileExists('/memories/_x/leak.md'), false); + + // (2) A rename is a write on BOTH endpoints, so smuggling a private note out + // into the shared tree is refused at the destination. + await createNote(a, '/memories/private.md', 'from A'); + await assert.rejects( + () => a.store.rename('/memories/private.md', '/memories/core/leaked.md'), + MemoryScopeViolation, + ); + assert.equal(await b.store.fileExists('/memories/core/leaked.md'), false); + assert.equal(await root.fileExists('/memories/core/leaked.md'), false); + // The source survives the refused rename — a denial is not a delete. + assert.equal(await a.store.readFile('/memories/private.md'), 'from A'); + + // (3) And the note itself never became visible to B by any spelling. + assert.equal(await b.store.fileExists('/memories/private.md'), false); +}); + +test('the promotion audit log is readable by an agent but never writable', async () => { + // `/memories/core/audit/` records privileged OPERATOR actions. It sits under + // `core` so agents can read it — but `core` is a read/write grant on a + // context-free turn, so without an explicit deny prefix any agent could + // rewrite the record of what an operator did to its memory. + const { root, binder } = fixture(); + const auditPath = '/memories/core/audit/memory-promotions.jsonl'; + await root.createFile(auditPath, '{"event":"memory.promote"}\n'); + + for (const bound of [ + binder.forOrigin(teamsChannel('19:chan-a@thread.tacv2', 'team-a')), + binder.forOrigin(undefined), // the context-FREE turn, which holds plain `core` + ]) { + assert.equal(await bound.store.readFile(auditPath), '{"event":"memory.promote"}\n'); + await assert.rejects( + () => bound.store.writeFile(auditPath, 'tampered'), + MemoryScopeViolation, + ); + await assert.rejects(() => bound.store.delete(auditPath), MemoryScopeViolation); + } + assert.equal(await root.readFile(auditPath), '{"event":"memory.promote"}\n'); +}); + +test('two structurally different scopes that format alike do not share a tier', async () => { + // `formatSessionScope` is injective only over the strings `parseSessionScope` + // emits, and §4 has adapters build conversation scopes directly. Without the + // kind discriminator in the key, `{kind:'group', groupRef:'x'}` and + // `{kind:'conversation', conversationId:'group:x'}` both format to `group:x` + // and land in ONE memory tier — two different chats reading each other. + const { binder } = fixture(); + const group = binder.forOrigin({ + channelType: 'teams', + scope: { kind: 'group', groupRef: 'x' }, + }); + const conversation = binder.forOrigin({ + channelType: 'teams', + scope: { kind: 'conversation', conversationId: 'group:x' }, + }); + + assert.notEqual(group.axes.narrowest?.ctxKey, conversation.axes.narrowest?.ctxKey); + assert.notEqual(group, conversation); + + await createNote(group, '/memories/note.md', 'group only'); + assert.equal(await conversation.store.fileExists('/memories/note.md'), false); + assert.equal(await group.store.readFile('/memories/note.md'), 'group only'); +}); + +test('a digest-shaped conversation id cannot pre-image another context tier', async () => { + // The other half of the same property, one layer down: an id that LOOKS like + // a produced key must not be carried through verbatim, or a caller who can + // name their own conversation could address a hashed context's tree. + const { binder } = fixture(); + const hashed = binder.forOrigin(teamsChannel('19:chan-a@thread.tacv2', 'team-a')); + const hashedKey = hashed.axes.narrowest?.ctxKey; + assert.ok(hashedKey); + + // Feed the produced key back in as if it were a raw conversation id. + const idHalf = hashedKey.slice(hashedKey.indexOf('~') + 1); + const impostor = binder.forOrigin(teamsChannel(idHalf, 'team-a')); + + assert.notEqual(impostor.axes.narrowest?.ctxKey, hashedKey); + await createNote(hashed, '/memories/secret.md', 'the real context'); + assert.equal(await impostor.store.fileExists('/memories/secret.md'), false); +}); + +test("mode 'off' is byte-identical to today for every origin", async () => { + // The no-flag-day guarantee, and the shipped default. An operator who has not + // switched an Agent over gets exactly the stack it has today — with or + // without a channel plugin that sends an origin. + const root = new InMemoryMemoryStore(); + const off = new MemoryBinder({ agentSlug: AGENT, root, mode: 'off' }); + const defaulted = new MemoryBinder({ agentSlug: AGENT, root }); + + const origins: Array = [ + undefined, + teamsChannel('19:chan-a@thread.tacv2', 'team-a'), + teamsPersonal('aad-alice'), + ]; + for (const binder of [off, defaulted]) { + for (const origin of origins) { + const bound = binder.forOrigin(origin); + assert.equal(bound.axes.isContextFree, true); + assert.deepEqual(bound.scope, ['core', `orchestrator:${AGENT}:*`]); + } + } + + // And the notes land where they land today: the agent tree, not a context one. + const bound = off.forOrigin(teamsChannel('19:chan-a@thread.tacv2', 'team-a')); + await createNote(bound, '/memories/note.md', 'as before'); + assert.equal(await root.readFile(`/memories/orchestrators/${AGENT}/note.md`), 'as before'); + assert.equal(await root.directoryExists('/memories/contexts'), false); +}); + +test("enforce-strict quarantines the agent tier entirely", async () => { + // Coordinator decision 3 / design §10 Q3: in strict mode a context turn + // cannot even READ the agent tier, so no pre-W5 note is quotable in a chat. + const root = new InMemoryMemoryStore(); + await root.createFile(`/memories/orchestrators/${AGENT}/legacy.md`, 'pre-existing'); + const binder = new MemoryBinder({ agentSlug: AGENT, root, mode: 'enforce-strict' }); + + const a = binder.forOrigin(teamsChannel('19:chan-a@thread.tacv2', 'team-a')); + assert.ok(!a.scope.some((p) => p.includes(`orchestrator:${AGENT}`)), a.scope.join(', ')); + assert.equal(await a.store.fileExists('/memories/~agent/legacy.md'), false); + await assert.rejects( + () => a.store.readFile('/memories/~agent/legacy.md'), + MemoryScopeViolation, + ); + // Its own tier still works — strict narrows, it does not break the turn. + await createNote(a, '/memories/note.md', 'strict'); + assert.equal(await a.store.readFile('/memories/note.md'), 'strict'); +}); + +/** The team-tier key granted to a binding, read back off its compiled scope. */ +function teamKeyOf(bound: BoundTurnMemory): string { + for (const p of bound.scope) { + const m = /^team:([^:]+):\*$/.exec(p); + if (m) return m[1]!; + } + assert.fail(`binding has no team axis: ${bound.scope.join(', ')}`); +} diff --git a/middleware/test/memoryContextKey.test.ts b/middleware/test/memoryContextKey.test.ts new file mode 100644 index 000000000..0c8901e10 --- /dev/null +++ b/middleware/test/memoryContextKey.test.ts @@ -0,0 +1,210 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { createHash } from 'node:crypto'; + +import { memoryContextKey } from '../packages/harness-channel-sdk/src/scopeId.js'; + +/** + * W5 memory-ACL — `memoryContextKey`, the single choke point every tier path + * goes through (design: issue #870 §3). + * + * The property under test is INJECTIVITY, not prettiness. Once the context key + * partitions memory it is a security boundary: two distinct chat contexts that + * map to one key share one memory tree while every equality check in the store + * still passes. `scopeGraphKey` (#575 D3) measured that exact failure for the + * old `sanitizeScope` collapse; this suite is the guard that the replacement + * does not reintroduce it. + * + * Pollution guard: this function is pure and this file holds no fixtures — + * every case builds its own inputs inline (no module-level shared state). + */ + +/** The digest half of the construction, recomputed independently of the impl. */ +const digestOf = (raw: string): string => + createHash('sha256').update(raw, 'utf8').digest('hex').slice(0, 16); + +/** The alphabet the store grammar and the physical path layout can accept. */ +const KEY_SHAPE = /^[a-z0-9_-]{1,64}~[a-z0-9_-]{1,64}$/; + +describe('W5 memoryContextKey — documented shapes', () => { + it('digests a Teams conversation id, which carries the `:` and `@` that break sanitizing', () => { + // The literal shape of a Teams channel conversation id. + const nativeId = '19:abc@thread.tacv2'; + assert.equal( + memoryContextKey('teams', nativeId), + `teams~19-abc-thread-tacv2-${digestOf(nativeId)}`, + ); + }); + + it('carries a Telegram group id through byte-identically — it is already lossless', () => { + // Negative chat ids are digits and a leading '-': inside the safe alphabet, + // so no digest is added and no existing partition would ever move. + assert.equal(memoryContextKey('telegram', '-1001234567890'), 'telegram~-1001234567890'); + }); + + it('carries an API tenant id through byte-identically', () => { + assert.equal(memoryContextKey('api', 'tenant-acme'), 'api~tenant-acme'); + }); +}); + +describe('W5 memoryContextKey — injectivity (the security property)', () => { + it('separates a punctuated Teams id from the literal that sanitizing collapses it onto', () => { + // `sanitizeScope` mapped BOTH of these to `19-abc-thread-tacv2`. + const punctuated = memoryContextKey('teams', '19:abc@thread.tacv2'); + const literal = memoryContextKey('teams', '19-abc-thread-tacv2'); + assert.notEqual(punctuated, literal); + assert.equal(literal, 'teams~19-abc-thread-tacv2'); + }); + + it('separates a whole family of ids that share one sanitized stem', () => { + // Every one of these sanitizes to the stem `teams-c1`. + const family = ['teams:c1', 'teams::c1', 'teams@c1', 'teams.c1', 'teams/c1', 'teams c1']; + const keys = family.map((id) => memoryContextKey('teams', id)); + assert.equal(new Set(keys).size, family.length, keys.join(' | ')); + // The safe spelling of the same stem is the seventh distinct partition. + assert.ok(!keys.includes(memoryContextKey('teams', 'teams-c1'))); + }); + + it('separates 200-character ids that agree on their first 100 characters', () => { + const shared = 'x'.repeat(100); + const a = `${shared}${'a'.repeat(100)}`; + const b = `${shared}${'b'.repeat(100)}`; + assert.equal(a.length, 200); + assert.notEqual(memoryContextKey('teams', a), memoryContextKey('teams', b)); + }); + + it('separates ids that differ only in case — the safe alphabet is lowercase', () => { + assert.notEqual(memoryContextKey('teams', 'C1'), memoryContextKey('teams', 'c1')); + }); + + it('separates ids that differ only in surrounding whitespace — an id is identity, not a token', () => { + assert.notEqual(memoryContextKey('teams', ' c1'), memoryContextKey('teams', 'c1')); + }); + + it('separates the same native id across channel types', () => { + const keys = ['teams', 'telegram', 'api', 'http'].map((type) => memoryContextKey(type, 'c1')); + assert.equal(new Set(keys).size, keys.length); + }); + + it('keeps every adversarial input in its own partition', () => { + const inputs = [ + '', + ' ', + '-', + '@@@', + ':', + '::', + 'c1', + 'C1', + ' c1', + 'c1 ', + 'teams:c1', + 'teams-c1', + '19:abc@thread.tacv2', + '19-abc-thread-tacv2', + '-1001234567890', + '1001234567890', + 'x'.repeat(64), + 'x'.repeat(65), + 'x'.repeat(200), + `${'x'.repeat(100)}a`, + `${'x'.repeat(100)}b`, + 'tenant-acme', + 'tenant.acme', + ]; + const keys = inputs.map((id) => memoryContextKey('teams', id)); + assert.equal(new Set(keys).size, inputs.length, keys.join('\n')); + }); +}); + +describe('W5 memoryContextKey — idempotence for already-safe ids', () => { + it('returns a safe id byte-identically, so no partition that was never at risk moves', () => { + for (const id of ['c1', 'tenant-acme', 'a_b-c9', '-1001234567890', 'x'.repeat(64)]) { + assert.equal(memoryContextKey('teams', id), `teams~${id}`, `id=${id}`); + } + }); + + it('does NOT let a digest-shaped id pre-image a hashed context', () => { + // The security property that replaces naive idempotence. If a produced + // `-<16 hex>` segment were carried through verbatim, anyone able to + // name their own conversation id could spell another context's key and be + // routed into its memory tree. The two branches must stay disjoint, so a + // digest-shaped id is itself hashed and lands somewhere else. + const once = memoryContextKey('teams', '19:abc@thread.tacv2'); + const idSegment = once.slice(once.indexOf('~') + 1); + assert.match(idSegment, /-[0-9a-f]{16}$/); + assert.notEqual(memoryContextKey('teams', idSegment), once); + + // The reviewer's concrete pair: sha256('X!').slice(0,16) === '61d6ea9c…'. + assert.notEqual( + memoryContextKey('teams', 'X!'), + memoryContextKey('teams', 'x-61d6ea9c6d461bda'), + ); + }); + + it('stays byte-identical for an ordinary already-safe id', () => { + // Idempotence is kept where it costs nothing: an id that is already a lossless + // path segment AND cannot be mistaken for a digest is carried through as-is. + for (const id of ['c1', 'tenant-acme', 'a_b-9', '-1001234567890']) { + assert.equal(memoryContextKey('teams', id), `teams~${id}`); + } + }); + + it('normalises the channel type by case and whitespace — it is a type token, not identity', () => { + const canonical = memoryContextKey('teams', 'c1'); + for (const type of ['teams', 'Teams', 'TEAMS', ' teams ']) { + assert.equal(memoryContextKey(type, 'c1'), canonical, `type=${type}`); + } + }); +}); + +describe('W5 memoryContextKey — the key can never break the pattern format', () => { + const HOSTILE = [ + '19:abc@thread.tacv2', + 'team:x:*', + 'a~b', + '../../etc/passwd', + 'c1/../../other', + 'ümläut', + '', + '@@@', + 'x'.repeat(300), + 'a\nb', + 'a b', + ]; + + it('never emits a `:` — `team::*` stays parseable', () => { + for (const id of HOSTILE) { + for (const type of ['teams', 'a:b', 'a~b', '']) { + assert.ok( + !memoryContextKey(type, id).includes(':'), + `type=${JSON.stringify(type)} id=${JSON.stringify(id)}`, + ); + } + } + }); + + it('always matches the safe key shape and stays inside the segment budget', () => { + for (const id of HOSTILE) { + for (const type of ['teams', 'a:b', 'a~b', '', ' ']) { + const key = memoryContextKey(type, id); + assert.match(key, KEY_SHAPE, `type=${JSON.stringify(type)} id=${JSON.stringify(id)}`); + } + } + }); + + it('splits back into channel type and id at exactly one `~`', () => { + for (const id of HOSTILE) { + const key = memoryContextKey('teams', id); + assert.equal(key.split('~').length, 2, key); + assert.equal(key.split('~')[0], 'teams'); + } + }); + + it('never throws on the message path, whatever the producer hands over', () => { + // Coordinator decision 3: an unparseable origin narrows the scope, it never + // throws mid-turn. This function is on that path. + assert.doesNotThrow(() => memoryContextKey('', '')); + assert.doesNotThrow(() => memoryContextKey('teams', '\u0000\uFFFD')); + }); +}); diff --git a/middleware/test/memoryPromote.test.ts b/middleware/test/memoryPromote.test.ts new file mode 100644 index 000000000..9fb89a31e --- /dev/null +++ b/middleware/test/memoryPromote.test.ts @@ -0,0 +1,446 @@ +import { strict as assert } from 'node:assert'; +import { beforeEach, describe, it } from 'node:test'; + +import { InMemoryMemoryStore } from '@omadia/memory'; + +import { + PROMOTION_AUDIT_PATH, + promoteMemory, + type PromoteRequest, +} from '../src/services/memoryPromote.js'; + +// --------------------------------------------------------------------------- +// W5 — `promoteMemory` (design spec #870 §6, test plan §8 item 8). +// +// Runs against an InMemoryMemoryStore so the list/delete semantics (recursive +// delete, two-levels-deep list, implicit directories) match production. +// Pollution guard (§8): every fixture is built per test in `beforeEach`; no +// module-level store, no shared state between files. +// --------------------------------------------------------------------------- + +const SLUG = 'atlas'; +const CHANNEL_KEY = 'teams~19-chan-a-aaaa1111'; +const OTHER_CHANNEL_KEY = 'teams~19-chan-b-cccc3333'; +const TEAM_KEY = 'teams~team-alpha-bbbb2222'; +const USER_KEY = 'teams~user-marcel-dddd4444'; + +const CHANNEL_ROOT = `/memories/contexts/${SLUG}/channel/${CHANNEL_KEY}`; +const TEAM_ROOT = `/memories/contexts/${SLUG}/team/${TEAM_KEY}`; +const USER_ROOT = `/memories/contexts/${SLUG}/user/${USER_KEY}`; +const AGENT_ROOT = `/memories/orchestrators/${SLUG}`; + +const AT = new Date('2026-08-26T10:00:00.000Z'); + +function baseRequest(overrides: Partial = {}): PromoteRequest { + return { + agentSlug: SLUG, + source: { axis: 'channel', ctxKey: CHANNEL_KEY, path: 'notes/deploy.md' }, + target: { tier: 'team', ctxKey: TEAM_KEY }, + mode: 'copy', + actor: 'operator@byte5.de', + reason: 'team-wide runbook', + ...overrides, + }; +} + +function hasCode(code: string) { + return (err: unknown): boolean => + !!err && typeof err === 'object' && (err as { code?: string }).code === code; +} + +async function auditLines(store: InMemoryMemoryStore): Promise>> { + const raw = await store.readFile(PROMOTION_AUDIT_PATH); + return raw + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +describe('memoryPromote (operator tier promotion)', () => { + let store: InMemoryMemoryStore; + let events: Array>; + let options: { now: () => Date; securityAuditSink: (e: Record) => void }; + + beforeEach(async () => { + store = new InMemoryMemoryStore(); + events = []; + options = { + now: () => AT, + securityAuditSink: (event) => { + events.push(event); + }, + }; + await store.createFile(`${CHANNEL_ROOT}/notes/deploy.md`, '# Deploy\n\nrun-the-thing\n'); + }); + + it('copies a channel file into the team tier with provenance, audit and receipt', async () => { + const receipt = await promoteMemory(store, baseRequest(), options); + + const target = `${TEAM_ROOT}/notes/deploy.md`; + assert.equal(receipt.sourcePath, `${CHANNEL_ROOT}/notes/deploy.md`); + assert.equal(receipt.targetPath, target); + assert.equal(receipt.mode, 'copy'); + assert.equal(receipt.ts, AT.toISOString()); + assert.equal(receipt.files.length, 1); + assert.equal(receipt.files[0]?.provenance, true); + + // Copy leaves the source in place. + assert.equal(await store.fileExists(`${CHANNEL_ROOT}/notes/deploy.md`), true); + + // (b) provenance frontmatter in the target file. + const content = await store.readFile(target); + assert.match(content, /^---\n/); + assert.match(content, /promoted-from: "\/memories\/contexts\/atlas\/channel\//); + assert.match(content, /promoted-by: "operator@byte5\.de"/); + assert.match(content, /promoted-at: "2026-08-26T10:00:00\.000Z"/); + assert.match(content, /run-the-thing/); + + // (a) one JSONL audit line in the shared core namespace. + const lines = await auditLines(store); + assert.equal(lines.length, 1); + assert.deepEqual(lines[0], { + ts: AT.toISOString(), + agentSlug: SLUG, + actor: 'operator@byte5.de', + mode: 'copy', + sourcePath: `${CHANNEL_ROOT}/notes/deploy.md`, + targetPath: target, + reason: 'team-wide runbook', + bytes: receipt.bytes, + files: 1, + }); + + // (c) structured [security-audit] event. + assert.equal(events.length, 1); + assert.equal(events[0]?.event, 'memory.promote'); + assert.equal(events[0]?.targetPath, target); + }); + + it('lands the promoted file exactly under the target tier root the scope grammar compiles', async () => { + const receipt = await promoteMemory(store, baseRequest(), options); + // `team::*` compiles to `/memories/contexts//team//`, + // so a turn bound to that team reads the promoted file (spec §3). + assert.ok(receipt.targetPath.startsWith(`${TEAM_ROOT}/`)); + assert.equal(await store.fileExists(receipt.targetPath), true); + }); + + it('move removes the source and keeps the target', async () => { + await promoteMemory(store, baseRequest({ mode: 'move' }), options); + + assert.equal(await store.fileExists(`${CHANNEL_ROOT}/notes/deploy.md`), false); + assert.equal(await store.fileExists(`${TEAM_ROOT}/notes/deploy.md`), true); + const lines = await auditLines(store); + assert.equal(lines[0]?.mode, 'move'); + }); + + it('copies a subtree deeper than the two-level list walk, preserving structure', async () => { + await store.createFile(`${CHANNEL_ROOT}/runbooks/db/restore/steps.md`, 'restore\n'); + await store.createFile(`${CHANNEL_ROOT}/runbooks/db/backup.md`, 'backup\n'); + await store.createFile(`${CHANNEL_ROOT}/runbooks/index.md`, 'index\n'); + + const receipt = await promoteMemory( + store, + baseRequest({ + source: { axis: 'channel', ctxKey: CHANNEL_KEY, path: 'runbooks' }, + target: { tier: 'agent' }, + }), + options, + ); + + assert.equal(receipt.files.length, 3); + assert.equal(await store.fileExists(`${AGENT_ROOT}/runbooks/db/restore/steps.md`), true); + assert.equal(await store.fileExists(`${AGENT_ROOT}/runbooks/db/backup.md`), true); + assert.equal(await store.fileExists(`${AGENT_ROOT}/runbooks/index.md`), true); + assert.match(await store.readFile(`${AGENT_ROOT}/runbooks/db/backup.md`), /promoted-from:/); + // Untouched neighbour file stays in the channel tier. + assert.equal(await store.fileExists(`${CHANNEL_ROOT}/notes/deploy.md`), true); + }); + + it('move of a subtree deletes the whole source tree', async () => { + await store.createFile(`${CHANNEL_ROOT}/runbooks/db/backup.md`, 'backup\n'); + await store.createFile(`${CHANNEL_ROOT}/runbooks/index.md`, 'index\n'); + + await promoteMemory( + store, + baseRequest({ + source: { axis: 'channel', ctxKey: CHANNEL_KEY, path: 'runbooks' }, + target: { tier: 'agent' }, + mode: 'move', + }), + options, + ); + + assert.equal(await store.directoryExists(`${CHANNEL_ROOT}/runbooks`), false); + assert.equal(await store.fileExists(`${AGENT_ROOT}/runbooks/db/backup.md`), true); + assert.equal(await store.fileExists(`${AGENT_ROOT}/runbooks/index.md`), true); + }); + + it('promotes team and user tiers into the agent tier', async () => { + await store.createFile(`${TEAM_ROOT}/conventions.md`, 'team-convention\n'); + await store.createFile(`${USER_ROOT}/prefs.md`, 'user-pref\n'); + + await promoteMemory( + store, + baseRequest({ + source: { axis: 'team', ctxKey: TEAM_KEY, path: 'conventions.md' }, + target: { tier: 'agent' }, + }), + options, + ); + await promoteMemory( + store, + baseRequest({ + source: { axis: 'user', ctxKey: USER_KEY, path: 'prefs.md' }, + target: { tier: 'agent', path: 'people/marcel.md' }, + }), + options, + ); + + assert.equal(await store.fileExists(`${AGENT_ROOT}/conventions.md`), true); + assert.equal(await store.fileExists(`${AGENT_ROOT}/people/marcel.md`), true); + assert.equal((await auditLines(store)).length, 2); + }); + + it('rejects every target that would land outside the requesting agent', async () => { + const cases: Array<{ req: PromoteRequest; code: string }> = [ + { + req: baseRequest({ target: { tier: 'team', ctxKey: '../../other-agent/team/x' } }), + code: 'invalid_ctx_key', + }, + { + req: baseRequest({ + target: { tier: 'agent', path: '../../orchestrators/other-agent/stolen.md' }, + }), + code: 'invalid_path', + }, + { + req: baseRequest({ target: { tier: 'agent', path: '/memories/orchestrators/other/x.md' } }), + code: 'invalid_path', + }, + { + req: baseRequest({ target: { tier: 'agent', ctxKey: TEAM_KEY } }), + code: 'invalid_ctx_key', + }, + { + req: baseRequest({ agentSlug: '../other-agent' }), + code: 'invalid_agent_slug', + }, + { + req: baseRequest({ + source: { axis: 'channel', ctxKey: CHANNEL_KEY, path: '../../../orchestrators/other/x.md' }, + }), + code: 'invalid_path', + }, + { + req: baseRequest({ target: { tier: 'nowhere' as 'agent' } }), + code: 'invalid_tier', + }, + { + req: baseRequest({ actor: ' ' }), + code: 'actor_required', + }, + ]; + + for (const { req, code } of cases) { + await assert.rejects(() => promoteMemory(store, req, options), hasCode(code), code); + } + + // Nothing was written, nothing was audited. + assert.equal(await store.directoryExists(TEAM_ROOT), false); + assert.equal(await store.directoryExists(AGENT_ROOT), false); + assert.equal(await store.fileExists(PROMOTION_AUDIT_PATH), false); + assert.equal(events.length, 0); + }); + + it('refuses a promotion onto its own source and a missing source', async () => { + await assert.rejects( + () => + promoteMemory( + store, + baseRequest({ + source: { axis: 'team', ctxKey: TEAM_KEY, path: 'x.md' }, + target: { tier: 'team', ctxKey: TEAM_KEY, path: 'x.md' }, + }), + options, + ), + hasCode('target_overlaps_source'), + ); + + await assert.rejects( + () => + promoteMemory( + store, + baseRequest({ + source: { axis: 'channel', ctxKey: OTHER_CHANNEL_KEY, path: 'nothing.md' }, + }), + options, + ), + hasCode('source_not_found'), + ); + }); + + it('refuses a target NESTED inside the source, which move would destroy', async () => { + // Equality is not enough. A nested target passes every other guard — it is + // legitimately under the same agent — and then `move`'s recursive delete of + // the source wipes the freshly written target with it: net knowledge + // destroyed, reported as a success. Reachable with valid typed input. + await store.createFile(`${TEAM_ROOT}/notes/a.md`, 'A'); + await store.createFile(`${TEAM_ROOT}/notes/b.md`, 'B'); + + for (const mode of ['copy', 'move'] as const) { + await assert.rejects( + () => + promoteMemory( + store, + baseRequest({ + mode, + source: { axis: 'team', ctxKey: TEAM_KEY, path: 'notes' }, + target: { tier: 'team', ctxKey: TEAM_KEY, path: 'notes/archive' }, + }), + options, + ), + hasCode('target_overlaps_source'), + `nested target must be refused for mode=${mode}`, + ); + // And the mirror nesting: source inside target. + await assert.rejects( + () => + promoteMemory( + store, + baseRequest({ + mode, + source: { axis: 'team', ctxKey: TEAM_KEY, path: 'notes/a.md' }, + target: { tier: 'team', ctxKey: TEAM_KEY, path: 'notes' }, + }), + options, + ), + hasCode('target_overlaps_source'), + `nested source must be refused for mode=${mode}`, + ); + } + + assert.equal(await store.readFile(`${TEAM_ROOT}/notes/a.md`), 'A'); + assert.equal(await store.readFile(`${TEAM_ROOT}/notes/b.md`), 'B'); + }); + + it('a move never destroys a file it did not copy', async () => { + // `collectFiles` enumerates via `store.list()`, whose walk skips entries + // whose name starts with `.` — identically in the in-memory and Postgres + // stores. A recursive `delete(sourceRoot)` has no such filter, so a dotfile + // was deleted from the source having never been written to the target, + // with the receipt and the audit line both reporting success. + await store.createFile(`${CHANNEL_ROOT}/runbooks/index.md`, 'visible'); + await store.createFile(`${CHANNEL_ROOT}/runbooks/.secrets.md`, 'invisible to list()'); + + const receipt = await promoteMemory( + store, + baseRequest({ + mode: 'move', + source: { axis: 'channel', ctxKey: CHANNEL_KEY, path: 'runbooks' }, + target: { tier: 'agent', path: 'runbooks' }, + }), + options, + ); + + // What WAS planned moved. + assert.deepEqual( + receipt.files.map((f) => f.sourcePath), + [`${CHANNEL_ROOT}/runbooks/index.md`], + ); + assert.match(await store.readFile(`${AGENT_ROOT}/runbooks/index.md`), /\nvisible$/); + assert.equal(await store.fileExists(`${CHANNEL_ROOT}/runbooks/index.md`), false); + + // What was NOT planned is still exactly where it was — never silently gone. + assert.equal( + await store.readFile(`${CHANNEL_ROOT}/runbooks/.secrets.md`), + 'invisible to list()', + ); + }); + + it('refuses to overwrite an existing target and writes nothing at all', async () => { + await store.createFile(`${CHANNEL_ROOT}/runbooks/a.md`, 'a\n'); + await store.createFile(`${CHANNEL_ROOT}/runbooks/b.md`, 'b\n'); + await store.createFile(`${AGENT_ROOT}/runbooks/b.md`, 'existing-b\n'); + + const req = baseRequest({ + source: { axis: 'channel', ctxKey: CHANNEL_KEY, path: 'runbooks' }, + target: { tier: 'agent' }, + mode: 'move', + }); + + await assert.rejects(() => promoteMemory(store, req, options), hasCode('target_exists')); + + // Pre-flight conflict check: no sibling was written, no source was moved. + assert.equal(await store.fileExists(`${AGENT_ROOT}/runbooks/a.md`), false); + assert.equal(await store.readFile(`${AGENT_ROOT}/runbooks/b.md`), 'existing-b\n'); + assert.equal(await store.fileExists(`${CHANNEL_ROOT}/runbooks/a.md`), true); + assert.equal(await store.fileExists(PROMOTION_AUDIT_PATH), false); + + // Explicit opt-in overwrites. + const receipt = await promoteMemory(store, { ...req, overwrite: true }, options); + assert.equal(receipt.files.length, 2); + assert.match(await store.readFile(`${AGENT_ROOT}/runbooks/b.md`), /^---\npromoted-from:/); + }); + + it('appends one audit line per promotion', async () => { + await store.createFile(`${CHANNEL_ROOT}/notes/second.md`, 'second\n'); + + await promoteMemory(store, baseRequest(), options); + await promoteMemory( + store, + baseRequest({ + source: { axis: 'channel', ctxKey: CHANNEL_KEY, path: 'notes/second.md' }, + mode: 'move', + reason: undefined, + }), + options, + ); + + const lines = await auditLines(store); + assert.equal(lines.length, 2); + assert.equal(lines[0]?.mode, 'copy'); + assert.equal(lines[1]?.mode, 'move'); + assert.equal('reason' in (lines[1] ?? {}), false); + }); + + it('merges provenance into an existing frontmatter block instead of stacking one', async () => { + await store.createFile( + `${CHANNEL_ROOT}/notes/tagged.md`, + '---\ntitle: "Runbook"\npromoted-from: "/memories/contexts/atlas/user/old"\n---\n\nbody\n', + ); + + await promoteMemory( + store, + baseRequest({ + source: { axis: 'channel', ctxKey: CHANNEL_KEY, path: 'notes/tagged.md' }, + }), + options, + ); + + const content = await store.readFile(`${TEAM_ROOT}/notes/tagged.md`); + assert.equal(content.split('---\n').length - 1, 2, 'exactly one frontmatter block'); + assert.match(content, /title: "Runbook"/); + assert.equal(content.includes('/memories/contexts/atlas/user/old'), false); + assert.match(content, /promoted-from: "\/memories\/contexts\/atlas\/channel\/[^"]+\/notes\/tagged\.md"/); + assert.match(content, /\nbody\n$/); + }); + + it('leaves non-markdown payloads byte-identical (no frontmatter injection)', async () => { + const json = '{"threshold":3}\n'; + await store.createFile(`${CHANNEL_ROOT}/config/limits.json`, json); + + const receipt = await promoteMemory( + store, + baseRequest({ + source: { axis: 'channel', ctxKey: CHANNEL_KEY, path: 'config/limits.json' }, + target: { tier: 'agent' }, + }), + options, + ); + + assert.equal(receipt.files[0]?.provenance, false); + assert.equal(await store.readFile(`${AGENT_ROOT}/config/limits.json`), json); + // The JSONL audit line still records it. + assert.equal((await auditLines(store))[0]?.targetPath, `${AGENT_ROOT}/config/limits.json`); + }); +}); diff --git a/middleware/test/memoryPromoteRoute.test.ts b/middleware/test/memoryPromoteRoute.test.ts new file mode 100644 index 000000000..86c07945f --- /dev/null +++ b/middleware/test/memoryPromoteRoute.test.ts @@ -0,0 +1,454 @@ +import { strict as assert } from 'node:assert'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { describe, it } from 'node:test'; + +import express from 'express'; +import type { NextFunction, Request, Response } from 'express'; + +import { InMemoryMemoryStore } from '@omadia/memory'; +import type { MemoryStore } from '@omadia/plugin-api'; + +import { PROMOTION_AUDIT_PATH } from '../src/services/memoryPromote.js'; +import { createMemoryPromoteRouter } from '../src/routes/memoryPromote.js'; +import { listenLoopback } from './_helpers/listenLoopback.js'; + +/** + * HTTP integration test for the memory-promotion router + * (`createMemoryPromoteRouter`), mounted in prod at + * `/api/v1/admin/memory/promotions` behind `requireAuth` so the live URLs are + * `POST|GET /api/v1/admin/memory/promotions/:slug`. + * + * That prefix and that gate are deliberately the SAME as the Danger-Zone purge + * router's (`/api/v1/admin/memory/purge`, cookie session JWT — NOT the + * machine-to-machine ADMIN_TOKEN surface). Promotion is the one way knowledge + * crosses a chat-context boundary, so it is an operator judgement call that has + * to be attributable to a person; the audit line records that person as its + * actor. The design spec's `/api/agents/:slug/memory/promotions` would have + * introduced a third auth surface for a Danger-Zone-class action. + * + * Drives the REAL router end-to-end over an express `listen(0)` server with a + * real `InMemoryMemoryStore` (same MemoryStore contract as prod) and the REAL + * `promoteMemory` service — nothing about the promotion is faked. + * + * `requireAuth` runs at MOUNT time in prod, not inside the router (same as + * `memoryPurgeRoute.test.ts`), so the harness injects a `req.session` — or + * omits it, to exercise the router's own 401 and the fact that the audited + * `actor` comes from that session and never from the request body. + * + * Pollution guard (design spec §8): every test builds its own store, its own + * server and its own log buffer — no module-level fixtures, no shared state. + */ + +const MOUNT = '/api/v1/admin/memory/promotions'; +const SLUG = 'atlas'; +const OTHER_SLUG = 'borea'; +const ACTOR = 'operator-user-1'; + +const CHANNEL_KEY = 'teams~19-chan-a-aaaa1111'; +const TEAM_KEY = 'teams~team-alpha-bbbb2222'; + +const CHANNEL_ROOT = `/memories/contexts/${SLUG}/channel/${CHANNEL_KEY}`; +const TEAM_ROOT = `/memories/contexts/${SLUG}/team/${TEAM_KEY}`; +const AGENT_ROOT = `/memories/orchestrators/${SLUG}`; + +interface Harness { + url: (slug?: string) => string; + store: InMemoryMemoryStore; + logs: string[]; + close: () => Promise; +} + +function copyBody(overrides: Record = {}): Record { + return { + source: { axis: 'channel', ctxKey: CHANNEL_KEY, path: 'notes/deploy.md' }, + target: { tier: 'team', ctxKey: TEAM_KEY }, + mode: 'copy', + reason: 'team-wide runbook', + ...overrides, + }; +} + +/** Stand up a fresh server + a freshly-seeded store. `actor === null` omits + * the session entirely so the router's own 401 guard fires. */ +async function makeHarness(options: { + actor?: string | null; + /** Wrap the store so writing the audit JSONL fails — proves an audit gap + * never masks a promotion that already landed. */ + breakAuditWrite?: boolean; +} = {}): Promise { + const store = new InMemoryMemoryStore(); + await store.createFile(`${CHANNEL_ROOT}/notes/deploy.md`, '# Deploy\n\nrun-it\n'); + + const logs: string[] = []; + const actor = options.actor === undefined ? ACTOR : options.actor; + + const app = express(); + app.use(express.json()); + if (actor !== null) { + app.use((req: Request, _res: Response, next: NextFunction) => { + (req as unknown as { session: Record }).session = { + omadia_user_id: actor, + }; + next(); + }); + } + app.use( + MOUNT, + createMemoryPromoteRouter({ + store: options.breakAuditWrite ? withBrokenAuditWrite(store) : store, + log: (message) => logs.push(message), + }), + ); + + const server: Server = await listenLoopback(app); + const { port } = server.address() as AddressInfo; + const base = `http://127.0.0.1:${String(port)}${MOUNT}`; + + return { + url: (slug = SLUG) => `${base}/${slug}`, + store, + logs, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +/** Delegating MemoryStore whose only difference is that the audit-line write + * throws. The promotion's own writes still land, which is the point. */ +function withBrokenAuditWrite(store: InMemoryMemoryStore): MemoryStore { + const proxied = Object.create(store) as MemoryStore; + proxied.writeFile = async (path: string, content: string): Promise => { + if (path === PROMOTION_AUDIT_PATH) throw new Error('disk on fire'); + await store.writeFile(path, content); + }; + return proxied; +} + +async function send( + url: string, + method: 'GET' | 'POST', + body?: unknown, +): Promise<{ status: number; body: Record }> { + const res = await fetch(url, { + method, + ...(body === undefined + ? {} + : { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }), + }); + const text = await res.text(); + let parsed: Record; + try { + parsed = text ? (JSON.parse(text) as Record) : {}; + } catch { + parsed = { raw: text }; + } + return { status: res.status, body: parsed }; +} + +function receiptOf(body: Record): Record { + const receipt = body['receipt']; + assert.ok(receipt && typeof receipt === 'object', `no receipt in ${JSON.stringify(body)}`); + return receipt as Record; +} + +async function auditLines( + store: InMemoryMemoryStore, +): Promise>> { + const raw = await store.readFile(PROMOTION_AUDIT_PATH); + return raw + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +describe('memory-promote router (HTTP, end-to-end)', () => { + it('1. POST without a session → 401, nothing promoted', async () => { + const h = await makeHarness({ actor: null }); + try { + const res = await send(h.url(), 'POST', copyBody()); + assert.equal(res.status, 401); + assert.equal(res.body['error'], 'auth.required'); + assert.equal(await h.store.fileExists(`${TEAM_ROOT}/notes/deploy.md`), false); + } finally { + await h.close(); + } + }); + + it('2. GET without a session → 401', async () => { + const h = await makeHarness({ actor: null }); + try { + const res = await send(h.url(), 'GET'); + assert.equal(res.status, 401); + assert.equal(res.body['error'], 'auth.required'); + } finally { + await h.close(); + } + }); + + it('3. POST copy → 200 receipt, provenance in the target, audit line written', async () => { + const h = await makeHarness(); + try { + const res = await send(h.url(), 'POST', copyBody()); + assert.equal(res.status, 200, JSON.stringify(res.body)); + + const receipt = receiptOf(res.body); + assert.equal(receipt['agentSlug'], SLUG); + assert.equal(receipt['actor'], ACTOR); + assert.equal(receipt['mode'], 'copy'); + assert.equal(receipt['sourcePath'], `${CHANNEL_ROOT}/notes/deploy.md`); + assert.equal(receipt['targetPath'], `${TEAM_ROOT}/notes/deploy.md`); + assert.equal(receipt['auditPath'], PROMOTION_AUDIT_PATH); + + // Copy leaves the source in place and stamps provenance on the target. + assert.equal(await h.store.fileExists(`${CHANNEL_ROOT}/notes/deploy.md`), true); + const written = await h.store.readFile(`${TEAM_ROOT}/notes/deploy.md`); + assert.match(written, /^---\n/); + assert.match(written, /promoted-by: "operator-user-1"/); + assert.match(written, /run-it/); + + const lines = await auditLines(h.store); + assert.equal(lines.length, 1); + assert.equal(lines[0]?.['actor'], ACTOR); + assert.equal(lines[0]?.['agentSlug'], SLUG); + assert.equal(lines[0]?.['reason'], 'team-wide runbook'); + } finally { + await h.close(); + } + }); + + it('4. the audited actor comes from the session — a body `actor` is ignored', async () => { + const h = await makeHarness(); + try { + const res = await send( + h.url(), + 'POST', + copyBody({ actor: 'mallory', agentSlug: 'someone-else' }), + ); + assert.equal(res.status, 200, JSON.stringify(res.body)); + assert.equal(receiptOf(res.body)['actor'], ACTOR); + assert.equal(receiptOf(res.body)['agentSlug'], SLUG); + } finally { + await h.close(); + } + }); + + it('5. POST with an unknown mode → 400 invalid_request carrying zod issues', async () => { + const h = await makeHarness(); + try { + const res = await send(h.url(), 'POST', copyBody({ mode: 'teleport' })); + assert.equal(res.status, 400); + assert.equal(res.body['error'], 'invalid_request'); + assert.ok(Array.isArray(res.body['issues'])); + assert.ok((res.body['issues'] as unknown[]).length > 0); + } finally { + await h.close(); + } + }); + + it('6. POST with a traversal source path → 400 invalid_path, nothing written', async () => { + const h = await makeHarness(); + try { + const res = await send( + h.url(), + 'POST', + copyBody({ source: { axis: 'channel', ctxKey: CHANNEL_KEY, path: '../../escape.md' } }), + ); + assert.equal(res.status, 400); + assert.equal(res.body['error'], 'invalid_path'); + assert.equal(await h.store.fileExists(PROMOTION_AUDIT_PATH), false); + } finally { + await h.close(); + } + }); + + it('7. POST for a missing source → 404 source_not_found, and NOT flagged partial', async () => { + const h = await makeHarness(); + try { + const res = await send( + h.url(), + 'POST', + copyBody({ source: { axis: 'channel', ctxKey: CHANNEL_KEY, path: 'nope.md' } }), + ); + assert.equal(res.status, 404); + assert.equal(res.body['error'], 'source_not_found'); + // A pre-write validation rejection genuinely means both tiers are + // untouched, so it must NOT tell the operator to go inspect the target. + assert.equal(res.body['partial'], undefined); + } finally { + await h.close(); + } + }); + + it('7b. a store failure mid-write is reported as PARTIAL, not as "nothing happened"', async () => { + // `promoteMemory` writes the planned files in an unguarded loop with no + // rollback. A store failure part-way leaves the promotion half applied — + // but such an error carries no `code`, so it used to be indistinguishable + // from a clean rejection. The operator would retry, hit 409 target_exists + // on the files that DID land, and be told there is a conflict on a + // promotion the API twice reported as never having started. + const h = await makeHarness(); + try { + await h.store.createFile(`${CHANNEL_ROOT}/runbooks/one.md`, 'first\n'); + await h.store.createFile(`${CHANNEL_ROOT}/runbooks/two.md`, 'second\n'); + + // Fail the SECOND payload write; the audit write is a different path. + let payloadWrites = 0; + const original = h.store.writeFile.bind(h.store); + h.store.writeFile = async (path: string, content: string): Promise => { + if (path !== PROMOTION_AUDIT_PATH) { + payloadWrites += 1; + if (payloadWrites === 2) throw new Error('quota exceeded'); + } + await original(path, content); + }; + + const res = await send( + h.url(), + 'POST', + copyBody({ + source: { axis: 'channel', ctxKey: CHANNEL_KEY, path: 'runbooks' }, + target: { tier: 'agent', path: 'runbooks' }, + }), + ); + + assert.equal(res.status, 500, JSON.stringify(res.body)); + assert.equal(res.body['error'], 'memory_promote_failed'); + assert.equal(res.body['partial'], true, 'the ambiguity must be surfaced'); + assert.match(String(res.body['warning']), /partially applied/); + + // And the state really is half-applied — the flag is not decoration. + assert.equal(await h.store.fileExists(`${AGENT_ROOT}/runbooks/one.md`), true); + assert.equal(await h.store.fileExists(`${AGENT_ROOT}/runbooks/two.md`), false); + } finally { + await h.close(); + } + }); + + it('8. POST onto an existing target → 409, and 200 with overwrite:true', async () => { + const h = await makeHarness(); + try { + await h.store.createFile(`${TEAM_ROOT}/notes/deploy.md`, 'older knowledge\n'); + + const conflict = await send(h.url(), 'POST', copyBody()); + assert.equal(conflict.status, 409); + assert.equal(conflict.body['error'], 'target_exists'); + assert.equal( + await h.store.readFile(`${TEAM_ROOT}/notes/deploy.md`), + 'older knowledge\n', + ); + + const forced = await send(h.url(), 'POST', copyBody({ overwrite: true })); + assert.equal(forced.status, 200, JSON.stringify(forced.body)); + assert.match(await h.store.readFile(`${TEAM_ROOT}/notes/deploy.md`), /run-it/); + } finally { + await h.close(); + } + }); + + it('9. POST mode:move to the agent tier → source is gone, target exists', async () => { + const h = await makeHarness(); + try { + const res = await send( + h.url(), + 'POST', + copyBody({ mode: 'move', target: { tier: 'agent' } }), + ); + assert.equal(res.status, 200, JSON.stringify(res.body)); + assert.equal(receiptOf(res.body)['targetPath'], `${AGENT_ROOT}/notes/deploy.md`); + assert.equal(await h.store.fileExists(`${CHANNEL_ROOT}/notes/deploy.md`), false); + assert.equal(await h.store.fileExists(`${AGENT_ROOT}/notes/deploy.md`), true); + } finally { + await h.close(); + } + }); + + it('10. an audit-write failure is logged but never masks the applied promotion', async () => { + const h = await makeHarness({ breakAuditWrite: true }); + try { + const res = await send(h.url(), 'POST', copyBody()); + assert.equal(res.status, 200, JSON.stringify(res.body)); + assert.equal(receiptOf(res.body)['targetPath'], `${TEAM_ROOT}/notes/deploy.md`); + assert.match(String(res.body['warning']), /audit line/i); + // The promotion itself really landed. + assert.equal(await h.store.fileExists(`${TEAM_ROOT}/notes/deploy.md`), true); + assert.ok(h.logs.some((line) => line.includes('[memory-promote]'))); + } finally { + await h.close(); + } + }); + + it('11. GET with no audit file yet → 200 with an empty entry list', async () => { + const h = await makeHarness(); + try { + const res = await send(h.url(), 'GET'); + assert.equal(res.status, 200); + assert.equal(res.body['auditPath'], PROMOTION_AUDIT_PATH); + assert.deepEqual(res.body['entries'], []); + } finally { + await h.close(); + } + }); + + it('12. GET returns this agent\'s entries newest-first and honours ?limit', async () => { + const h = await makeHarness(); + try { + await send(h.url(), 'POST', copyBody()); + await send( + h.url(), + 'POST', + copyBody({ source: { axis: 'channel', ctxKey: CHANNEL_KEY, path: 'notes/deploy.md' }, target: { tier: 'agent' }, reason: 'second hop' }), + ); + // A foreign agent's line and an unparseable line must not leak/throw. + const raw = await h.store.readFile(PROMOTION_AUDIT_PATH); + await h.store.writeFile( + PROMOTION_AUDIT_PATH, + `${raw}${JSON.stringify({ agentSlug: OTHER_SLUG, actor: 'someone' })}\nnot-json\n`, + ); + + const all = await send(h.url(), 'GET'); + assert.equal(all.status, 200, JSON.stringify(all.body)); + const entries = all.body['entries'] as Array>; + assert.equal(entries.length, 2); + assert.equal(entries[0]?.['reason'], 'second hop', 'newest first'); + assert.equal(entries[1]?.['reason'], 'team-wide runbook'); + assert.ok(entries.every((e) => e['agentSlug'] === SLUG)); + assert.equal(all.body['malformed'], 1); + + const limited = await send(`${h.url()}?limit=1`, 'GET'); + assert.equal(limited.status, 200); + const one = limited.body['entries'] as Array>; + assert.equal(one.length, 1); + assert.equal(one[0]?.['reason'], 'second hop', 'limit cuts the oldest'); + } finally { + await h.close(); + } + }); + + it('13. GET with an out-of-range limit → 400 invalid_request', async () => { + const h = await makeHarness(); + try { + const res = await send(`${h.url()}?limit=0`, 'GET'); + assert.equal(res.status, 400); + assert.equal(res.body['error'], 'invalid_request'); + assert.ok(Array.isArray(res.body['issues'])); + } finally { + await h.close(); + } + }); + + it('14. an invalid agent slug in the path → 400 invalid_agent_slug', async () => { + const h = await makeHarness(); + try { + const res = await send(h.url('Not A Slug'), 'POST', copyBody()); + assert.equal(res.status, 400); + assert.equal(res.body['error'], 'invalid_agent_slug'); + } finally { + await h.close(); + } + }); +}); diff --git a/middleware/test/memoryPurge.test.ts b/middleware/test/memoryPurge.test.ts index 1dadbee2f..33fb8c57b 100644 --- a/middleware/test/memoryPurge.test.ts +++ b/middleware/test/memoryPurge.test.ts @@ -2,6 +2,7 @@ import { strict as assert } from 'node:assert'; import { beforeEach, describe, it } from 'node:test'; import { InMemoryMemoryStore } from '@omadia/memory'; +import { memoryContextKey } from '@omadia/channel-sdk'; import { previewMemoryPurge, purgeMemory } from '../src/services/memoryPurge.js'; @@ -9,8 +10,18 @@ import { previewMemoryPurge, purgeMemory } from '../src/services/memoryPurge.js' // WS3 — Danger-Zone scratch purge helpers. Exercised against an // InMemoryMemoryStore so the list/delete semantics (recursive delete, // two-levels-deep list) match production behaviour. +// +// Pollution guard (known full-suite bug): every store is built per-test in +// `beforeEach`. No module-level fixtures, nothing shared with another file. // --------------------------------------------------------------------------- +/** The Teams team id used across the context cases — deliberately a REAL-shaped + * id with `:` and `@` in it, so the selector genuinely has to be normalised. */ +const TEAM_NATIVE_ID = '19:team-alpha@thread.tacv2'; +const TEAM_SELECTOR = `teams~${TEAM_NATIVE_ID}`; +const TEAM_KEY = memoryContextKey('teams', TEAM_NATIVE_ID); +const OTHER_TEAM_KEY = memoryContextKey('teams', '19:team-beta@thread.tacv2'); + describe('memoryPurge (scratch helpers)', () => { let store: InMemoryMemoryStore; @@ -21,6 +32,32 @@ describe('memoryPurge (scratch helpers)', () => { await store.createFile('/memories/_rules/r.md', 'rule-content'); }); + /** Seed the context forest: team ALPHA is shared by agents a + b, team BETA + * and a channel/user tree exist alongside it as the "must survive" control + * group. */ + async function seedContexts(): Promise { + await store.createFile( + `/memories/contexts/a/team/${TEAM_KEY}/note.md`, + 'a-team-alpha', + ); + await store.createFile( + `/memories/contexts/b/team/${TEAM_KEY}/note.md`, + 'b-team-alpha', + ); + await store.createFile( + `/memories/contexts/a/team/${OTHER_TEAM_KEY}/note.md`, + 'a-team-beta', + ); + await store.createFile( + `/memories/contexts/a/channel/${TEAM_KEY}/note.md`, + 'a-channel-same-key', + ); + await store.createFile( + `/memories/contexts/a/user/teams~u-1/note.md`, + 'a-user', + ); + } + it("axis 'agent' selector 'a' removes only a's subtree", async () => { const preview = await previewMemoryPurge(store, 'agent', 'a'); assert.equal(preview, 1); @@ -68,16 +105,147 @@ describe('memoryPurge (scratch helpers)', () => { assert.equal(await store.fileExists('/memories/_rules/r.md'), false); }); - it("axis 'user' is a scratch no-op", async () => { - const preview = await previewMemoryPurge(store, 'user', 'someone'); + it("axis 'user' with no context forest at all deletes nothing", async () => { + // No `/memories/contexts` exists in this fixture. `list` throws on a + // missing directory, so this asserts the existence probe, not just a count. + const preview = await previewMemoryPurge(store, 'user', 'teams~someone'); assert.equal(preview, 0); - const deleted = await purgeMemory(store, 'user', 'someone'); + const deleted = await purgeMemory(store, 'user', 'teams~someone'); assert.equal(deleted, 0); - // Everything intact — user purge only touches the Knowledge-Graph. assert.equal(await store.fileExists('/memories/orchestrators/a/x.md'), true); assert.equal(await store.fileExists('/memories/orchestrators/b/y.md'), true); assert.equal(await store.fileExists('/memories/_rules/r.md'), true); }); + + // ------------------------------------------------------------------------- + // Chat-context axes (design §7 / test plan item 7). The isolation axis is + // agent × context, so a context purge crosses agents and a context key is + // never allowed to leak into a neighbouring axis or a neighbouring key. + // ------------------------------------------------------------------------- + + it("axis 'team' deletes the context tree across EVERY agent", async () => { + await seedContexts(); + + // Two agents hold team ALPHA → two targets, not one. + const preview = await previewMemoryPurge(store, 'team', TEAM_SELECTOR); + assert.equal(preview, 2); + + const deleted = await purgeMemory(store, 'team', TEAM_SELECTOR); + assert.equal(deleted, 2, 'preview and execute agree by construction'); + + assert.equal( + await store.directoryExists(`/memories/contexts/a/team/${TEAM_KEY}`), + false, + ); + assert.equal( + await store.directoryExists(`/memories/contexts/b/team/${TEAM_KEY}`), + false, + ); + + // Neighbour key, neighbour axis, agent tree and seed all survive. + assert.equal( + await store.fileExists( + `/memories/contexts/a/team/${OTHER_TEAM_KEY}/note.md`, + ), + true, + 'a different team is a different partition', + ); + assert.equal( + await store.fileExists(`/memories/contexts/a/channel/${TEAM_KEY}/note.md`), + true, + 'the same key under a different axis is a different partition', + ); + assert.equal(await store.fileExists('/memories/orchestrators/a/x.md'), true); + assert.equal(await store.fileExists('/memories/_rules/r.md'), true); + }); + + it("axis 'team' resolves a raw native id and an already-derived ctxKey alike", async () => { + await seedContexts(); + + // A double-prefixed key is NOT the same partition — normalising is + // injective, so a mistyped selector cannot land on someone else's tree. + assert.equal( + await previewMemoryPurge(store, 'team', `teams~${TEAM_KEY}`), + 0, + 'teams~teams~… is a different key, not a lenient match', + ); + + // The key an operator copies out of the memory browser round-trips: + // `memoryContextKey` is idempotent on an already-safe id. + assert.equal(await previewMemoryPurge(store, 'team', TEAM_KEY), 2); + assert.equal(await previewMemoryPurge(store, 'team', TEAM_SELECTOR), 2); + + assert.equal(await purgeMemory(store, 'team', TEAM_KEY), 2); + assert.equal( + await store.directoryExists(`/memories/contexts/a/team/${TEAM_KEY}`), + false, + ); + }); + + it("axis 'channel' and 'user' address their own axis only", async () => { + await seedContexts(); + + assert.equal(await purgeMemory(store, 'channel', TEAM_SELECTOR), 1); + assert.equal( + await store.fileExists(`/memories/contexts/a/team/${TEAM_KEY}/note.md`), + true, + "the team tree survives a channel purge with the same key", + ); + + assert.equal(await purgeMemory(store, 'user', 'teams~u-1'), 1); + assert.equal( + await store.directoryExists('/memories/contexts/a/user/teams~u-1'), + false, + ); + }); + + it('context axes refuse an empty selector', async () => { + await seedContexts(); + for (const axis of ['team', 'channel', 'user'] as const) { + await assert.rejects( + () => purgeMemory(store, axis, ' '), + (err: unknown) => + !!err && + typeof err === 'object' && + (err as { code?: string }).code === 'selector_required', + `${axis} must not purge on an empty selector`, + ); + } + }); + + it("axis 'agent' takes the agent's whole context forest with it", async () => { + await seedContexts(); + + // /memories/orchestrators/a + /memories/contexts/a. + const preview = await previewMemoryPurge(store, 'agent', 'a'); + assert.equal(preview, 2); + assert.equal(await purgeMemory(store, 'agent', 'a'), 2); + + assert.equal(await store.directoryExists('/memories/contexts/a'), false); + assert.equal( + await store.directoryExists('/memories/orchestrators/a'), + false, + ); + // Agent b keeps both of its trees — including its half of team ALPHA. + assert.equal( + await store.fileExists(`/memories/contexts/b/team/${TEAM_KEY}/note.md`), + true, + ); + assert.equal(await store.fileExists('/memories/orchestrators/b/y.md'), true); + }); + + it("axis 'all' sweeps contexts up without naming it", async () => { + await seedContexts(); + + // orchestrators + contexts; _rules still protected. + const preview = await previewMemoryPurge(store, 'all'); + assert.equal(preview, 2); + assert.equal(await purgeMemory(store, 'all'), 2); + + assert.equal(await store.directoryExists('/memories/contexts'), false); + assert.equal(await store.directoryExists('/memories/orchestrators'), false); + assert.equal(await store.fileExists('/memories/_rules/r.md'), true); + }); }); diff --git a/middleware/test/memoryPurgeRoute.test.ts b/middleware/test/memoryPurgeRoute.test.ts index b2b3e0c9f..bf91bfec7 100644 --- a/middleware/test/memoryPurgeRoute.test.ts +++ b/middleware/test/memoryPurgeRoute.test.ts @@ -8,6 +8,7 @@ import { Pool } from 'pg'; import { resolvePgTestUrl } from './_helpers/pgTestDb.js'; import { InMemoryMemoryStore } from '@omadia/memory'; +import { memoryContextKey } from '@omadia/channel-sdk'; import { InMemoryKnowledgeGraph } from '@omadia/knowledge-graph-inmemory'; import type { GraphNode, @@ -117,11 +118,21 @@ interface Harness { const MOUNT = '/api/v1/admin/memory/purge'; +/** A real-shaped Teams team id (`:` + `@`) and the two spellings an operator + * may type for it. The route must confirm against the TYPED one. */ +const TEAM_NATIVE_ID = '19:team-alpha@thread.tacv2'; +const TEAM_SELECTOR = `teams~${TEAM_NATIVE_ID}`; +const TEAM_KEY = memoryContextKey('teams', TEAM_NATIVE_ID); + async function seedScratch(store: InMemoryMemoryStore): Promise { await store.createFile('/memories/orchestrators/a/x.md', 'ax'); await store.createFile('/memories/orchestrators/a/notes/deep.md', 'deep'); await store.createFile('/memories/orchestrators/b/y.md', 'by'); await store.createFile('/memories/_rules/r.md', 'rule'); + // Team ALPHA lives in BOTH agents — a context purge has to cross them. + await store.createFile(`/memories/contexts/a/team/${TEAM_KEY}/n.md`, 'a-team'); + await store.createFile(`/memories/contexts/b/team/${TEAM_KEY}/n.md`, 'b-team'); + await store.createFile(`/memories/contexts/a/user/teams~u-1/n.md`, 'a-user'); } async function seedKg(kg: InMemoryKnowledgeGraph): Promise { @@ -234,7 +245,7 @@ describe('memory-purge router (HTTP, end-to-end)', () => { if (pgPool) await pgPool.end().catch(() => undefined); }); - it('1. POST /preview {axis:agent, selector:a} → scratchCount 2, kgCount 1', async () => { + it('1. POST /preview {axis:agent, selector:a} → scratchCount 2 (agent tree + context forest), kgCount 1', async () => { const h = await makeHarness(); try { const res = await postJson(`${h.baseUrl}/preview`, 'POST', { @@ -243,14 +254,13 @@ describe('memory-purge router (HTTP, end-to-end)', () => { }); assert.equal(res.status, 200, JSON.stringify(res.body)); assert.equal(res.body['kgCount'], 1); - // DEVIATION from the prompt's "scratchCount = 2": previewMemoryPurge - // intentionally returns the count of top-level `/memories/...` ENTRIES - // a purge removes (one agent subtree = 1), NOT a recursive file count. - // See services/memoryPurge.ts doc: "Returns the number of top-level - // entries removed (NOT a recursive file count)". We assert that real - // contract (1) and separately prove a's 2-file footprint is intact - // pre-delete and (test 2) fully removed post-delete. - assert.equal(res.body['scratchCount'], 1); + // previewMemoryPurge counts TARGETS, not files: agent 'a' owns two + // subtrees — `/memories/orchestrators/a` and its context forest + // `/memories/contexts/a` — so the honest preview is 2. (Before the + // chat-context ACL this was 1; the second target is the new context + // forest, not a recursive file count.) We separately prove a's file + // footprint is intact pre-delete and (test 2) fully removed post-delete. + assert.equal(res.body['scratchCount'], 2); assert.equal( await h.store.fileExists('/memories/orchestrators/a/x.md'), true, @@ -273,9 +283,19 @@ describe('memory-purge router (HTTP, end-to-end)', () => { confirm: 'a', }); assert.equal(res.status, 200, JSON.stringify(res.body)); - assert.equal(res.body['scratchDeleted'], 1); + assert.equal(res.body['scratchDeleted'], 2); assert.equal(res.body['kgDeleted'], 1); + assert.equal( + await h.store.directoryExists('/memories/contexts/a'), + false, + "a's context forest goes with the agent", + ); + assert.equal( + await h.store.fileExists(`/memories/contexts/b/team/${TEAM_KEY}/n.md`), + true, + "b's half of the shared team survives an agent purge", + ); assert.equal( await h.store.fileExists('/memories/orchestrators/b/y.md'), true, @@ -345,6 +365,11 @@ describe('memory-purge router (HTTP, end-to-end)', () => { false, 'orchestrators purged', ); + assert.equal( + await h.store.directoryExists('/memories/contexts'), + false, + 'contexts is ordinary scratch — axis:all takes it without naming it', + ); } finally { await h.close(); } @@ -369,7 +394,14 @@ describe('memory-purge router (HTTP, end-to-end)', () => { } }); - it('6. DELETE / {axis:user, selector:user-2} → scratch no-op, kgDeleted 1, no warning', async () => { + it('6. DELETE / {axis:user, selector:user-2} → 400 invalid_selector, nothing deleted', async () => { + // `user-2` is a KG acl-owner id, not a context key: it has no + // `~` half, so it can never name a context tree. This used to + // answer 200 / {scratchDeleted: 0} with a warning claiming the scratch + // trees WERE affected — a Danger-Zone gesture reporting success for a + // delete that could not possibly have matched. It is now refused loudly, + // and the KG leg does not run either: a selector this route cannot resolve + // must not half-execute. const h = await makeHarness(); try { const res = await postJson(h.baseUrl, 'DELETE', { @@ -377,25 +409,179 @@ describe('memory-purge router (HTTP, end-to-end)', () => { selector: 'user-2', confirm: 'user-2', }); + assert.equal(res.status, 400, JSON.stringify(res.body)); + assert.equal(res.body['error'], 'invalid_selector'); + assert.match(String(res.body['message']), /~/); + assert.equal( + await h.store.fileExists('/memories/contexts/a/user/teams~u-1/n.md'), + true, + 'nothing was deleted', + ); + assert.equal((await h.kg.countMemorableKnowledge({ tenantId: 'default' })).count, 2); + } finally { + await h.close(); + } + }); + + it("6b. DELETE / {axis:user, selector:teams~u-1} → the scratch tree goes, and the KG/scratch seam is named", async () => { + // The two legs of the user axis consume the selector in INCOMPATIBLE + // spellings: the KG matches it raw as an `aclOwner`, the purge service as a + // `~` context key. At most one can ever match, and the + // operator has to be told which half was a no-op instead of reading a 200 + // as "the user was purged". + const h = await makeHarness(); + try { + const res = await postJson(h.baseUrl, 'DELETE', { + axis: 'user', + selector: 'teams~u-1', + confirm: 'teams~u-1', + }); assert.equal(res.status, 200, JSON.stringify(res.body)); - assert.equal(res.body['scratchDeleted'], 0); - assert.equal(res.body['kgDeleted'], 1); - assert.equal(res.body['warning'], undefined, 'user IS modeled — no warning'); + assert.equal(res.body['scratchDeleted'], 1); + assert.equal(res.body['kgDeleted'], 0); + assert.match( + String(res.body['warning']), + /Knowledge-Graph rows .* NOT purged/, + 'the no-op half must be named', + ); + assert.equal( + await h.store.fileExists('/memories/contexts/a/user/teams~u-1/n.md'), + false, + ); } finally { await h.close(); } }); - it('7. POST /preview {axis:team, selector:t1} → warning + kgCount 0', async () => { + it('7. POST /preview {axis:team} → warning names the KG as the untouched half', async () => { const h = await makeHarness(); try { const res = await postJson(`${h.baseUrl}/preview`, 'POST', { axis: 'team', - selector: 't1', + selector: TEAM_KEY, }); assert.equal(res.status, 200, JSON.stringify(res.body)); assert.equal(res.body['kgCount'], 0); - assert.equal(typeof res.body['warning'], 'string', 'team not modeled → warning'); + // Team ALPHA lives in both agents, so the honest preview is 2 trees. + assert.equal(res.body['scratchCount'], 2); + const warning = res.body['warning']; + assert.equal(typeof warning, 'string', 'team not modeled → warning'); + // The warning used to claim "only scratch memory is affected", which read + // backwards once the context trees existed. It must now say the KG is the + // untouched half — and must not imply an invented KG filter. + assert.match(String(warning), /Knowledge-Graph is left untouched/); + assert.doesNotMatch(String(warning), /only scratch memory is affected/); + assert.match(String(warning), /scratch trees are affected/); + } finally { + await h.close(); + } + }); + + it('7b. POST /preview {axis:team} that matches nothing does NOT claim an effect', async () => { + // The same defect class the warning above was written to fix: promising an + // effect that did not happen. A well-formed selector that resolves to zero + // trees is the likeliest operator mistake on this surface, and it must not + // be reported as "the scratch trees were affected". + const h = await makeHarness(); + try { + const res = await postJson(`${h.baseUrl}/preview`, 'POST', { + axis: 'team', + selector: 'teams~does-not-exist', + }); + assert.equal(res.status, 200, JSON.stringify(res.body)); + assert.equal(res.body['scratchCount'], 0); + const warning = String(res.body['warning']); + assert.match(warning, /No matching context tree exists/); + assert.doesNotMatch(warning, /scratch trees are affected/); + } finally { + await h.close(); + } + }); + + it('7c. a context selector with no channel-type half is refused, not silently ignored', async () => { + const h = await makeHarness(); + try { + for (const axis of ['team', 'channel', 'user'] as const) { + const res = await postJson(`${h.baseUrl}/preview`, 'POST', { + axis, + selector: '19:team-alpha@thread.tacv2', + }); + assert.equal(res.status, 400, `${axis}: ${JSON.stringify(res.body)}`); + assert.equal(res.body['error'], 'invalid_selector'); + } + } finally { + await h.close(); + } + }); + + it('9. DELETE / {axis:team} purges the context tree across agents, KG untouched', async () => { + const h = await makeHarness(); + try { + const preview = await postJson(`${h.baseUrl}/preview`, 'POST', { + axis: 'team', + selector: TEAM_SELECTOR, + }); + assert.equal(preview.status, 200, JSON.stringify(preview.body)); + assert.equal(preview.body['scratchCount'], 2, 'one target per agent'); + + const res = await postJson(h.baseUrl, 'DELETE', { + axis: 'team', + selector: TEAM_SELECTOR, + confirm: TEAM_SELECTOR, + }); + assert.equal(res.status, 200, JSON.stringify(res.body)); + assert.equal(res.body['scratchDeleted'], 2); + assert.equal(res.body['kgDeleted'], 0, 'no KG filter is fabricated'); + assert.match(String(res.body['warning']), /Knowledge-Graph was left untouched/); + + assert.equal( + await h.store.directoryExists(`/memories/contexts/a/team/${TEAM_KEY}`), + false, + ); + assert.equal( + await h.store.directoryExists(`/memories/contexts/b/team/${TEAM_KEY}`), + false, + ); + // Agent trees and the other context axis survive. + assert.equal(await h.store.fileExists('/memories/orchestrators/a/x.md'), true); + assert.equal( + await h.store.fileExists('/memories/contexts/a/user/teams~u-1/n.md'), + true, + ); + // The KG kept both MKs — the team axis has no KG column. + const all = await h.kg.countMemorableKnowledge({ tenantId: 'default' }); + assert.equal(all.count, 2); + } finally { + await h.close(); + } + }); + + it('10. type-to-confirm guards the TYPED selector, not the derived ctxKey', async () => { + const h = await makeHarness(); + try { + // Confirming with the normalised key while having typed the raw selector + // must be rejected: the gesture guards the input, not the normalisation. + const mismatch = await postJson(h.baseUrl, 'DELETE', { + axis: 'team', + selector: TEAM_SELECTOR, + confirm: TEAM_KEY, + }); + assert.equal(mismatch.status, 400, JSON.stringify(mismatch.body)); + assert.equal(mismatch.body['error'], 'confirmation_mismatch'); + assert.equal( + await h.store.directoryExists(`/memories/contexts/a/team/${TEAM_KEY}`), + true, + 'nothing deleted on a mismatch', + ); + + // Re-typing the selector verbatim is what unlocks it. + const ok = await postJson(h.baseUrl, 'DELETE', { + axis: 'team', + selector: TEAM_SELECTOR, + confirm: TEAM_SELECTOR, + }); + assert.equal(ok.status, 200, JSON.stringify(ok.body)); + assert.equal(ok.body['scratchDeleted'], 2); } finally { await h.close(); } diff --git a/middleware/test/scopedMemoryStore.contexts.test.ts b/middleware/test/scopedMemoryStore.contexts.test.ts new file mode 100644 index 000000000..622f82779 --- /dev/null +++ b/middleware/test/scopedMemoryStore.contexts.test.ts @@ -0,0 +1,440 @@ +/** + * W5 — chat-context memory ACL: scope grammar of `ScopedMemoryStore`. + * + * Covers the design spec (#870 §3) as implemented by #871: + * + * 1. Token matrix for `team:` / `channel:` / `user:` — exact root, child + * path, neighbour key, neighbour axis, neighbour agent and the legacy + * `/memories/orchestrators/…` tree, each for read and for write. + * 2. The `ro:` access modifier — reads and filtered lists pass, + * write / create / delete / rename raise `MemoryScopeViolation`. + * 3. Collision-freedom: `/memories/contexts/…` is a top-level segment of its + * own, so no legacy `orchestrator::*` scope reaches a context tree + * and no context scope reaches the agent tree. + * 4. Compatibility: unknown tokens stay soft-deny + warning, and + * `orchestratorMemoryScope` returns byte-identically what it did before. + * + * Pollution guard (known full-suite bug): every test builds its own + * `InMemoryMemoryStore` + `ScopedMemoryStore` through `harness()`. There are + * no module-level fixtures and no environment mutation. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { InMemoryMemoryStore } from '@omadia/memory'; + +import { + MemoryScopeViolation, + orchestratorMemoryScope, + ScopedMemoryStore, +} from '../packages/harness-orchestrator/src/registry/scopedMemoryStore.js'; + +const AGENT = 'alpha'; +const OTHER_AGENT = 'beta'; +const KEY = 'teams~ctx-1'; +const OTHER_KEY = 'teams~ctx-2'; + +type ContextAxis = 'team' | 'channel' | 'user'; +const AXES: readonly ContextAxis[] = ['team', 'channel', 'user']; + +interface Warning { + readonly msg: string; + readonly fields?: Record; +} + +interface Harness { + readonly root: InMemoryMemoryStore; + readonly scoped: ScopedMemoryStore; + readonly warnings: Warning[]; +} + +function harness(scope: readonly string[], agentSlug = AGENT): Harness { + const root = new InMemoryMemoryStore(); + const warnings: Warning[] = []; + const scoped = new ScopedMemoryStore({ + agentSlug, + scope, + inner: root, + log: (msg, fields) => warnings.push({ msg, fields }), + }); + return { root, scoped, warnings }; +} + +function contextRoot( + axis: ContextAxis, + ctxKey = KEY, + agentSlug = AGENT, +): string { + return `/memories/contexts/${agentSlug}/${axis}/${ctxKey}`; +} + +function parentOf(path: string): string { + return path.slice(0, path.lastIndexOf('/')); +} + +/* ------------------------------------------------------------------ * + * Read / write expectations, expressed against the public MemoryStore + * surface — `allowedRead` / `allowedWrite` stay private by design. + * ------------------------------------------------------------------ */ + +/** + * `viaList` / `viaRename` are off for patterns whose grant does not extend to + * the parent directory or to sibling names — an exact-path pattern such as + * `/memories/exact.md` grants that one path and nothing else. + */ +interface AccessOptions { + readonly viaList?: boolean; + readonly viaRename?: boolean; +} + +async function assertFileReadable( + h: Harness, + path: string, + opts: AccessOptions = {}, +): Promise { + await h.root.writeFile(path, 'payload'); + assert.equal(await h.scoped.fileExists(path), true, `fileExists ${path}`); + assert.equal(await h.scoped.readFile(path), 'payload', `readFile ${path}`); + if (opts.viaList === false) return; + const entries = await h.scoped.list(parentOf(path)); + assert.ok( + entries.some((e) => e.virtualPath === path), + `list should surface ${path}`, + ); +} + +async function assertFileNotReadable(h: Harness, path: string): Promise { + await h.root.writeFile(path, 'payload'); + assert.equal(await h.scoped.fileExists(path), false, `fileExists ${path}`); + await assert.rejects( + () => h.scoped.readFile(path), + MemoryScopeViolation, + `readFile ${path}`, + ); + const entries = await h.scoped.list(parentOf(path)); + assert.equal( + entries.some((e) => e.virtualPath === path), + false, + `list must not surface ${path}`, + ); + // Soft denial never touches the underlying data. + assert.equal(await h.root.readFile(path), 'payload'); +} + +async function assertFileWritable( + h: Harness, + path: string, + opts: AccessOptions = {}, +): Promise { + await h.scoped.writeFile(path, 'v1'); + assert.equal(await h.root.readFile(path), 'v1', `writeFile ${path}`); + let last = path; + if (opts.viaRename !== false) { + await h.scoped.rename(path, `${path}.bak`); + assert.equal(await h.root.readFile(`${path}.bak`), 'v1', `rename ${path}`); + last = `${path}.bak`; + } + await h.scoped.delete(last); + assert.equal(await h.root.fileExists(last), false, `delete ${path}`); +} + +async function assertFileNotWritable(h: Harness, path: string): Promise { + await assert.rejects( + () => h.scoped.writeFile(path, 'v1'), + MemoryScopeViolation, + `writeFile ${path}`, + ); + await assert.rejects( + () => h.scoped.createFile(path, 'v1'), + MemoryScopeViolation, + `createFile ${path}`, + ); + await assert.rejects( + () => h.scoped.delete(path), + MemoryScopeViolation, + `delete ${path}`, + ); + await assert.rejects( + () => h.scoped.rename(path, `${path}.bak`), + MemoryScopeViolation, + `rename ${path}`, + ); +} + +async function assertRootAllowed(h: Harness, root: string): Promise { + await h.root.writeFile(`${root}/seed.md`, 'seed'); + assert.equal(await h.scoped.directoryExists(root), true, `dirExists ${root}`); + assert.ok((await h.scoped.list(root)).length > 0, `list ${root}`); + // A subtree delete is a write operation against the exact root path. + await h.scoped.delete(root); + assert.equal(await h.root.directoryExists(root), false, `delete ${root}`); +} + +async function assertRootDenied(h: Harness, root: string): Promise { + await h.root.writeFile(`${root}/seed.md`, 'seed'); + assert.equal(await h.scoped.directoryExists(root), false, `dirExists ${root}`); + assert.deepEqual(await h.scoped.list(root), [], `list ${root}`); + await assert.rejects( + () => h.scoped.delete(root), + MemoryScopeViolation, + `delete ${root}`, + ); + assert.equal(await h.root.fileExists(`${root}/seed.md`), true); +} + +/* ------------------------------------------------------------------ * + * 1. Token matrix + * ------------------------------------------------------------------ */ + +for (const axis of AXES) { + const pattern = `${axis}:${KEY}:*`; + const own = contextRoot(axis); + const neighbourAxis: ContextAxis = axis === 'team' ? 'channel' : 'team'; + + const denied: ReadonlyArray = [ + ['neighbour key', `${contextRoot(axis, OTHER_KEY)}/notes.md`], + ['neighbour axis', `${contextRoot(neighbourAxis)}/notes.md`], + ['neighbour agent', `${contextRoot(axis, KEY, OTHER_AGENT)}/notes.md`], + ['legacy agent tree', `/memories/orchestrators/${AGENT}/notes.md`], + ]; + + test(`${pattern} — exact root is readable and writable`, async () => { + await assertRootAllowed(harness([pattern]), own); + }); + + test(`${pattern} — child paths are readable and writable`, async () => { + for (const child of [`${own}/notes.md`, `${own}/sub/deep/notes.md`]) { + await assertFileReadable(harness([pattern]), child); + await assertFileWritable(harness([pattern]), child); + } + }); + + for (const [label, path] of denied) { + test(`${pattern} — ${label} is soft-denied for reads`, async () => { + await assertFileNotReadable(harness([pattern]), path); + }); + + test(`${pattern} — ${label} raises a violation on writes`, async () => { + await assertFileNotWritable(harness([pattern]), path); + }); + } + + test(`${pattern} — neighbour roots are denied wholesale`, async () => { + await assertRootDenied(harness([pattern]), contextRoot(axis, OTHER_KEY)); + await assertRootDenied(harness([pattern]), contextRoot(neighbourAxis)); + await assertRootDenied( + harness([pattern]), + contextRoot(axis, KEY, OTHER_AGENT), + ); + }); + + test(`${pattern} — a sibling key sharing a prefix is not matched`, async () => { + // `teams~ctx-1` must not unlock `teams~ctx-10`: the compiled prefix ends + // in a path separator. + const sibling = `${contextRoot(axis, `${KEY}0`)}/notes.md`; + await assertFileNotReadable(harness([pattern]), sibling); + await assertFileNotWritable(harness([pattern]), sibling); + }); +} + +test('context tokens are bound to the store’s own agent slug', async () => { + // The very same pattern resolves to a different physical tree per agent — + // a context key alone can never address another agent's memory. + const mine = harness([`team:${KEY}:*`], AGENT); + await assertFileReadable(mine, `${contextRoot('team', KEY, AGENT)}/n.md`); + + const theirs = harness([`team:${KEY}:*`], OTHER_AGENT); + await assertFileNotReadable( + theirs, + `${contextRoot('team', KEY, AGENT)}/n.md`, + ); + await assertFileReadable( + harness([`team:${KEY}:*`], OTHER_AGENT), + `${contextRoot('team', KEY, OTHER_AGENT)}/n.md`, + ); +}); + +/* ------------------------------------------------------------------ * + * 2. `ro:` access modifier + * ------------------------------------------------------------------ */ + +test('ro: grants reads on the legacy agent tree', async () => { + const path = `/memories/orchestrators/${AGENT}/notes.md`; + await assertFileReadable(harness([`ro:orchestrator:${AGENT}:*`]), path); +}); + +test('ro: refuses every write on the legacy agent tree', async () => { + const h = harness([`ro:orchestrator:${AGENT}:*`]); + const path = `/memories/orchestrators/${AGENT}/notes.md`; + await h.root.writeFile(path, 'existing'); + await assertFileNotWritable(h, path); + assert.equal(await h.root.readFile(path), 'existing'); +}); + +test('ro: applies to context tokens as well', async () => { + const path = `${contextRoot('team')}/notes.md`; + await assertFileReadable(harness([`ro:team:${KEY}:*`]), path); + const h = harness([`ro:team:${KEY}:*`]); + await h.root.writeFile(path, 'existing'); + await assertFileNotWritable(h, path); +}); + +test('ro: still filters list output to the readable subset', async () => { + const dir = `/memories/orchestrators/${AGENT}`; + const h = harness([`ro:${dir}`, `ro:${dir}/keep.md`]); + await h.root.writeFile(`${dir}/keep.md`, 'keep'); + await h.root.writeFile(`${dir}/drop.md`, 'drop'); + const entries = await h.scoped.list(dir); + assert.deepEqual( + entries.filter((e) => !e.isDirectory).map((e) => e.virtualPath), + [`${dir}/keep.md`], + ); +}); + +test('a read-only tier combines with a writable context tier', async () => { + const agentPath = `/memories/orchestrators/${AGENT}/legacy.md`; + const ctxPath = `${contextRoot('channel')}/notes.md`; + const h = harness([ + 'core', + `ro:orchestrator:${AGENT}:*`, + `channel:${KEY}:*`, + ]); + await h.root.writeFile(agentPath, 'legacy'); + + assert.equal(await h.scoped.readFile(agentPath), 'legacy'); + await assert.rejects( + () => h.scoped.writeFile(agentPath, 'mutated'), + MemoryScopeViolation, + ); + + await h.scoped.writeFile(ctxPath, 'fresh'); + assert.equal(await h.root.readFile(ctxPath), 'fresh'); +}); + +test('rename across the read-only boundary is refused in both directions', async () => { + const agentPath = `/memories/orchestrators/${AGENT}/legacy.md`; + const ctxPath = `${contextRoot('channel')}/promoted.md`; + const h = harness([`ro:orchestrator:${AGENT}:*`, `channel:${KEY}:*`]); + await h.root.writeFile(agentPath, 'legacy'); + await h.root.writeFile(ctxPath, 'fresh'); + + await assert.rejects( + () => h.scoped.rename(agentPath, ctxPath.replace('.md', '-2.md')), + MemoryScopeViolation, + ); + await assert.rejects( + () => h.scoped.rename(ctxPath, agentPath.replace('.md', '-2.md')), + MemoryScopeViolation, + ); +}); + +test('nested ro: is not a pattern — soft-deny plus warning', async () => { + const h = harness([`ro:ro:orchestrator:${AGENT}:*`]); + assert.equal(h.warnings.length, 1); + assert.match(h.warnings[0]!.msg, /unknown scope pattern/); + await assertFileNotReadable(h, `/memories/orchestrators/${AGENT}/notes.md`); +}); + +/* ------------------------------------------------------------------ * + * 3. Collision-freedom between the two trees + * ------------------------------------------------------------------ */ + +test('a legacy agent scope reaches no context tree', async () => { + for (const axis of AXES) { + const h = harness(orchestratorMemoryScope(AGENT)); + await assertFileNotReadable(h, `${contextRoot(axis)}/notes.md`); + await assertFileNotWritable(h, `${contextRoot(axis)}/notes.md`); + } + const h = harness(orchestratorMemoryScope(AGENT)); + await assertRootDenied(h, `/memories/contexts/${AGENT}`); +}); + +test('a context scope reaches neither the agent tree nor foreign contexts', async () => { + const h = harness([ + `team:${KEY}:*`, + `channel:${KEY}:*`, + `user:${KEY}:*`, + ]); + await assertFileNotReadable(h, `/memories/orchestrators/${AGENT}/notes.md`); + await assertFileNotWritable(h, `/memories/orchestrators/${AGENT}/notes.md`); + await assertRootDenied( + harness([`team:${KEY}:*`]), + `/memories/orchestrators/${AGENT}`, + ); +}); + +test('the contexts root itself is never granted by a tier token', async () => { + const h = harness([`team:${KEY}:*`]); + await assertRootDenied(h, `/memories/contexts/${AGENT}/team`); +}); + +/* ------------------------------------------------------------------ * + * 4. Compatibility of the pre-existing grammar + * ------------------------------------------------------------------ */ + +test('orchestratorMemoryScope is byte-identical to the legacy contract', () => { + assert.deepEqual(orchestratorMemoryScope(AGENT), [ + 'core', + `orchestrator:${AGENT}:*`, + ]); + assert.deepEqual(orchestratorMemoryScope('svc-1'), [ + 'core', + 'orchestrator:svc-1:*', + ]); +}); + +test('legacy tokens keep their exact meaning', async () => { + const cases: ReadonlyArray = [ + ['core', '/memories/core/brand.md'], + ['core', '/memories/sessions/s1/turn.md'], + ['core', '/memories/chat-sessions/c1/turn.md'], + ['core', '/memories/_rules/hr.md'], + ['agent:hr:*', '/memories/agents/hr/notes.md'], + [`orchestrator:${AGENT}:*`, `/memories/orchestrators/${AGENT}/notes.md`], + ['session:*', '/memories/sessions/s1/turn.md'], + ['/memories/prefixed/*', '/memories/prefixed/deep/notes.md'], + ]; + for (const [pattern, path] of cases) { + await assertFileReadable(harness([pattern]), path); + await assertFileWritable(harness([pattern]), path); + } +}); + +test('an exact-path token grants that path and nothing around it', async () => { + const exact = '/memories/exact.md'; + const opts = { viaList: false, viaRename: false }; + await assertFileReadable(harness([exact]), exact, opts); + await assertFileWritable(harness([exact]), exact, opts); + + const h = harness([exact]); + await assertFileNotReadable(h, '/memories/exact.md.bak'); + await assertFileNotWritable(h, '/memories/exact.md.bak'); +}); + +test('an unknown token stays soft-deny and is warned about', async () => { + const h = harness(['team:has:colons:*', 'nonsense']); + assert.equal(h.warnings.length, 2); + for (const w of h.warnings) { + assert.match(w.msg, /unknown scope pattern/); + assert.equal(w.fields?.agentSlug, AGENT); + } + assert.deepEqual( + h.warnings.map((w) => w.fields?.pattern), + ['team:has:colons:*', 'nonsense'], + ); + // A context key carrying a `:` cannot be spelled — so it can never widen + // the grammar by accident. + await assertFileNotReadable( + h, + `/memories/contexts/${AGENT}/team/has:colons/notes.md`, + ); + await assertRootDenied(h, `/memories/contexts/${AGENT}`); +}); + +test('an empty scope denies everything', async () => { + const h = harness([]); + assert.equal(h.warnings.length, 0); + await assertFileNotReadable(h, `${contextRoot('team')}/notes.md`); + await assertFileNotWritable(h, `/memories/core/brand.md`); +}); diff --git a/web-ui/app/_lib/api.ts b/web-ui/app/_lib/api.ts index d5666137e..08a1706e5 100644 --- a/web-ui/app/_lib/api.ts +++ b/web-ui/app/_lib/api.ts @@ -3866,10 +3866,31 @@ export async function resetChatSession( // - DELETE / → irreversible purge, gated by a confirm phrase // // Axis semantics: 'all' wipes both the agent-scratch (per-agent Turn store) -// and the Knowledge-Graph. The scoped axes (agent/user/team/channel) only -// touch the Knowledge-Graph — the agent-scratch is agent-scoped and is not -// reachable by a user/team/channel selector. The backend surfaces that as a -// `warning` on the response, which the UI renders verbatim. +// and the Knowledge-Graph. +// +// Since the chat-context memory ACL (design #870 §7) the scoped axes reach the +// agent-scratch too: `agent` additionally removes /memories/contexts/, +// and `user`/`team`/`channel` delete /memories/contexts/// +// across every agent. The old "scratch is agent-scoped" caveat therefore no +// longer holds for those axes — the backend still surfaces whatever caveat +// applies as a `warning` on the response, which the UI renders verbatim. +// +// Selector semantics for user/team/channel: the value MUST carry a channel-type +// half. Two spellings are accepted, and the backend resolves both: +// +// - the derived context key copied out of the memory browser +// (`teams~19-abc-thread-tacv2-a1b2c3d4e5f60718`), or +// - the channel type plus the platform's RAW native id +// (`teams~19:abc@thread.tacv2`), which is the form an operator can copy out +// of a chat client. +// +// A selector with NO `~` is rejected with `invalid_selector` (400) rather than +// silently matching nothing: a Danger-Zone gesture that deletes nothing while +// reporting success is the worst possible answer here. The two spellings are +// NOT interchangeable through one derivation — `memoryContextKey` is +// deliberately not idempotent on its own digest shape, since that would make a +// hashed context pre-imageable — so the backend resolves both readings and +// purges the union of the trees they actually name. // ----------------------------------------------------------------------------- export type MemoryPurgeAxis = 'all' | 'agent' | 'user' | 'team' | 'channel'; @@ -3933,6 +3954,114 @@ export async function purgeMemory(body: { return JSON.parse(text) as MemoryPurgeResult; } +// ----------------------------------------------------------------------------- +// Memory promote — the explicit, audited operator act that moves knowledge +// across context tiers of ONE agent (design #870 §6). Never agent-crossing. +// +// POST /memory/promotions copy|move a file/subtree upwards +// GET /memory/promotions read the audit log +// +// `` is the operator agent route this repo already exposes +// (/api/v1/operator/agents/:slug) — the design sketch wrote +// `/api/agents/:slug`, which is not a mount point that exists here. Kept in +// ONE helper so the path is a single edit if the backend lands elsewhere. +// ----------------------------------------------------------------------------- + +export type MemoryContextAxis = 'team' | 'channel' | 'user'; +export type MemoryPromoteTier = 'agent' | 'team'; +export type MemoryPromoteMode = 'copy' | 'move'; + +export interface MemoryPromoteRequest { + /** Source is always a context tier; `path` is relative to that tier root. */ + source: { axis: MemoryContextAxis; ctxKey: string; path: string }; + /** Target tier; `path` defaults to the source path server-side. */ + target: { tier: MemoryPromoteTier; ctxKey?: string; path?: string }; + mode: MemoryPromoteMode; + /** Mandatory in the UI — an unexplained promote is not auditable. */ + reason: string; +} + +/** One line of /memories/core/audit/memory-promotions.jsonl. */ +export interface MemoryPromotionReceipt { + ts: string; + agentSlug: string; + actor: string; + mode: MemoryPromoteMode; + sourcePath: string; + targetPath: string; + reason?: string; + bytes: number; +} + +/** A context tree with its display name, when something could resolve one. */ +export interface MemoryContextLabel { + axis: MemoryContextAxis; + ctxKey: string; + displayName?: string; +} + +/** + * The promotion endpoint, on the SAME prefix and gate as the Danger-Zone purge + * (`/api/v1/admin/memory/purge`, cookie session JWT). Promotion is the one way + * knowledge crosses a chat-context boundary, so it is a Danger-Zone-class + * operator action and shares that surface rather than introducing a third one. + */ +function memoryPromotionsPath(agentSlug: string): string { + return `/v1/admin/memory/promotions/${encodeURIComponent(agentSlug)}`; +} + +/** Copy or move a memory file/subtree into a wider tier of the same agent. */ +export async function promoteMemory( + agentSlug: string, + req: MemoryPromoteRequest, +): Promise { + const res = await postJson<{ receipt?: MemoryPromotionReceipt }>( + memoryPromotionsPath(agentSlug), + req, + ); + // The route answers `{ receipt }`, not a bare receipt. `postJson` is an + // unchecked cast, so validate at the boundary: without this a shape change + // surfaces as a render-time TypeError on `receipt.targetPath` rather than as + // a handled error. + const receipt = res?.receipt; + if (!receipt || typeof receipt !== 'object') { + throw new Error('memory_promote_unexpected_response'); + } + return receipt; +} + +/** Read the promote audit log, newest first. */ +export async function listMemoryPromotions( + agentSlug: string, + opts: { limit?: number } = {}, +): Promise<{ entries: MemoryPromotionReceipt[] }> { + const qs = + opts.limit === undefined ? '' : `?limit=${encodeURIComponent(String(opts.limit))}`; + const res = await getJson<{ entries?: unknown }>( + `${memoryPromotionsPath(agentSlug)}${qs}`, + ); + // Same reason as above, and the failure is worse here: `setEntries(undefined)` + // followed by `entries.length` throws during render and white-screens the + // whole /memory page, which the panel's own error state cannot catch. + return { entries: Array.isArray(res?.entries) ? (res.entries as MemoryPromotionReceipt[]) : [] }; +} + +/** + * Best-effort display names for an agent's context keys ("aufgelöste + * Display-Namen aus dem KG, wo vorhanden", design §6). A context key is a + * digest by construction, so it is unreadable on purpose — but the KG only + * knows a name for contexts it has actually seen. Callers MUST tolerate a + * rejection (404 while the resolver is not deployed, 403 for a non-operator) + * and fall back to the decoded key. + */ +export async function listMemoryContextLabels( + agentSlug: string, +): Promise<{ contexts: MemoryContextLabel[] }> { + return getJson<{ contexts: MemoryContextLabel[] }>( + `/v1/operator/agents/${encodeURIComponent(agentSlug)}/memory/contexts`, + ); +} + // ----------------------------------------------------------------------------- // Memory storage backend switch (postgres ↔ inmemory). Backed by the admin // router at /api/v1/admin/memory/backend, surfaced to the browser as diff --git a/web-ui/app/admin/danger-zone/page.tsx b/web-ui/app/admin/danger-zone/page.tsx index 18c780c22..7c90dc915 100644 --- a/web-ui/app/admin/danger-zone/page.tsx +++ b/web-ui/app/admin/danger-zone/page.tsx @@ -20,8 +20,21 @@ import { * Admin → Danger Zone (memory purge). * * Two-stage destructive surface for wiping memory along an axis: - * - 'all' → Agent-Scratch + Knowledge-Graph - * - 'agent' | 'user' | 'team' | 'channel' → Knowledge-Graph only + * - 'all' → Agent-Scratch + Knowledge-Graph + * - 'agent' → Knowledge-Graph + /memories/contexts/ + * - 'user' | 'team' | 'channel' + * → Knowledge-Graph + /memories/contexts/// + * + * Selector semantics for the three context axes changed with the chat-context + * memory ACL (design #870 §7). The value must ALWAYS carry a channel-type half: + * either the context key copied out of the memory browser + * (`~`) or the channel type plus the platform's raw + * native id. A selector without a `~` is refused by the backend with + * `invalid_selector` rather than quietly matching nothing — which is why the + * placeholders and hints spell the format out and say "never a bare id". + * + * It is also no longer a KG-only selector: these axes now delete scratch memory + * too, across every agent that holds the named context. * * Flow: pick axis (+ selector) → Vorschau (POST /preview, dry-run counts) * → type the confirm phrase → Löschen (DELETE /, irreversible). The delete @@ -186,6 +199,11 @@ export default function DangerZonePage(): React.ReactElement {

{t.rich('note', { strong: (chunks) => {chunks}, + code: (chunks) => ( + + {chunks} + + ), })}

{warning !== null && ( @@ -229,8 +247,11 @@ export default function DangerZonePage(): React.ReactElement { onChange={(e) => { onSelectorChange(e.target.value); }} placeholder={t(`selectorPlaceholder.${axis}`)} disabled={deleting} - className="rounded border border-[color:var(--border)] px-2 py-1 text-sm" + className="rounded border border-[color:var(--border)] px-2 py-1 font-mono text-sm" /> + + {t(`selectorHint.${axis}`)} + )} diff --git a/web-ui/app/memory/__tests__/page.contexts.test.tsx b/web-ui/app/memory/__tests__/page.contexts.test.tsx new file mode 100644 index 000000000..0977d0f89 --- /dev/null +++ b/web-ui/app/memory/__tests__/page.contexts.test.tsx @@ -0,0 +1,393 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithIntl } from '../../_lib/test-utils'; +import DangerZonePage from '../../admin/danger-zone/page'; +import MemoryPage from '../page'; + +/** + * Operator surface for the chat-context memory ACL (design #870 §6/§7). + * + * What these tests pin down: + * - the memory browser has a CONTEXT dimension, derived from the store's own + * `/memories/contexts///` layout — not from a registry, + * so it can never claim a context tree that does not exist, + * - a context key renders as a readable label (KG display name when one + * resolves, the verbatim `channelType~safeKey` context key otherwise) + * while still + * addressing the raw key, + * - "Promote…" is offered ONLY for a file inside a context tree, refuses to + * submit without a reason, and posts source/target/mode/reason exactly as + * the promote service expects, + * - the audit tab reads the promotions log for the agent in hand, + * - the Danger-Zone selector now documents the context-key semantics, because + * the user/team/channel axes gained a scratch footprint. + * + * Pollution guard: every test builds its own store fixture and its own fetch + * stub in `beforeEach`; nothing is shared at module level. + */ + +const AGENT = 'de.byte5.agent.hr'; +const TEAM_KEY = 'teams~19-abc-thread-tacv2-a1b2c3d4'; +const CHANNEL_KEY = 'teams~19-chan-thread-tacv2-c3d4e5f6'; +const CHANNEL_ROOT = `/memories/contexts/${AGENT}/channel/${CHANNEL_KEY}`; + +const { + MockApiError, + mockGetMemoryBackend, + mockListMemoryContextLabels, + mockListMemoryPromotions, + mockPromoteMemory, + mockPreviewMemoryPurge, + mockPurgeMemory, +} = vi.hoisted(() => ({ + MockApiError: class MockApiError extends Error { + constructor( + public status: number, + message: string, + public body = '', + ) { + super(message); + } + }, + mockGetMemoryBackend: vi.fn(), + mockListMemoryContextLabels: vi.fn(), + mockListMemoryPromotions: vi.fn(), + mockPromoteMemory: vi.fn(), + mockPreviewMemoryPurge: vi.fn(), + mockPurgeMemory: vi.fn(), +})); + +vi.mock('@/app/_lib/api', () => ({ + ApiError: MockApiError, + getMemoryBackend: mockGetMemoryBackend, + listMemoryContextLabels: mockListMemoryContextLabels, + listMemoryPromotions: mockListMemoryPromotions, + promoteMemory: mockPromoteMemory, + previewMemoryPurge: mockPreviewMemoryPurge, + purgeMemory: mockPurgeMemory, +})); + +/** Directory fixture: path → child names, `+` prefix marks a file. */ +function buildStore(): Record { + return { + '/memories': ['contexts', 'orchestrators'], + '/memories/orchestrators': [AGENT], + [`/memories/orchestrators/${AGENT}`]: ['+global.md'], + '/memories/contexts': [AGENT], + [`/memories/contexts/${AGENT}`]: ['team', 'channel', 'user'], + [`/memories/contexts/${AGENT}/team`]: [TEAM_KEY], + [`/memories/contexts/${AGENT}/channel`]: [CHANNEL_KEY], + [`/memories/contexts/${AGENT}/user`]: [], + [`/memories/contexts/${AGENT}/team/${TEAM_KEY}`]: [], + [CHANNEL_ROOT]: ['+vacation-policy.md'], + }; +} + +function installFetch(store: Record): void { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://localhost'); + const path = url.searchParams.get('path') ?? ''; + if (url.pathname === '/bot-api/dev/memory/file') { + return Promise.resolve( + new Response('# Vacation policy\n', { status: 200 }), + ); + } + const children = store[path]; + if (children === undefined) { + return Promise.resolve(new Response('not found', { status: 404 })); + } + // The real endpoint includes the listed directory itself; keep it so the + // "self" filter stays exercised. + const entries = [ + { virtualPath: path, isDirectory: true, sizeBytes: 0 }, + ...children.map((name) => ({ + virtualPath: `${path}/${name.replace(/^\+/, '')}`, + isDirectory: !name.startsWith('+'), + sizeBytes: name.startsWith('+') ? 128 : 0, + })), + ]; + return Promise.resolve( + new Response(JSON.stringify({ path, entries }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }), + ); +} + +/** Walk from the tree to a selected file inside the channel context. */ +async function selectContextFile(user: ReturnType): Promise { + // Deliberately NOT anchored on the agent node: once the browser has walked + // into the agent tier, the slug also appears as a breadcrumb button. + const channelContext = await screen.findByRole('button', { + name: /teams~19-chan-thread-tacv2-c3d4e5f6/i, + }); + await user.click(channelContext); + const file = await screen.findByRole('button', { name: /vacation-policy\.md/ }); + await user.click(file); +} + +describe('memory browser — context dimension', () => { + beforeEach(() => { + mockGetMemoryBackend.mockResolvedValue({ current: 'inmemory' }); + mockListMemoryContextLabels.mockRejectedValue(new MockApiError(404, 'nope')); + mockListMemoryPromotions.mockResolvedValue({ entries: [] }); + mockPromoteMemory.mockReset(); + installFetch(buildStore()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('renders the agent tier and one branch per context axis', async () => { + renderWithIntl(); + + await screen.findByRole('button', { name: AGENT }); + expect( + await screen.findByRole('button', { name: /agent tier/i }), + ).toBeInTheDocument(); + expect(await screen.findByText('Teams (1)')).toBeInTheDocument(); + expect(await screen.findByText('Channels (1)')).toBeInTheDocument(); + // The empty axis is still shown — "no user context" is information. + expect(await screen.findByText('Users (0)')).toBeInTheDocument(); + }); + + it('falls back to the VERBATIM context key when no display name resolves', async () => { + renderWithIntl(); + + const label = await screen.findByRole('button', { + name: /teams~19-abc-thread-tacv2-a1b2c3d4/i, + }); + // The raw key stays addressable via the physical path in the tooltip. + expect(label).toHaveAttribute( + 'title', + `/memories/contexts/${AGENT}/team/${TEAM_KEY}`, + ); + }); + + it('uses the KG display name for a context when one resolves', async () => { + mockListMemoryContextLabels.mockResolvedValue({ + contexts: [{ axis: 'team', ctxKey: TEAM_KEY, displayName: 'byte5 GmbH' }], + }); + renderWithIntl(); + + expect( + await screen.findByRole('button', { name: 'byte5 GmbH' }), + ).toBeInTheDocument(); + }); + + it('browses into a context tier when its node is selected', async () => { + const user = userEvent.setup(); + renderWithIntl(); + + const channelContext = await screen.findByRole('button', { + name: /teams~19-chan-thread-tacv2-c3d4e5f6/i, + }); + await user.click(channelContext); + + expect( + await screen.findByRole('button', { name: /vacation-policy\.md/ }), + ).toBeInTheDocument(); + }); +}); + +describe('memory browser — promote', () => { + beforeEach(() => { + mockGetMemoryBackend.mockResolvedValue({ current: 'inmemory' }); + mockListMemoryContextLabels.mockRejectedValue(new MockApiError(404, 'nope')); + mockListMemoryPromotions.mockResolvedValue({ entries: [] }); + mockPromoteMemory.mockReset(); + installFetch(buildStore()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('offers Promote only for a file inside a context tree', async () => { + const user = userEvent.setup(); + renderWithIntl(); + + // Agent-tier file: promoting it would have no source context. + await user.click(await screen.findByRole('button', { name: /agent tier/i })); + await user.click(await screen.findByRole('button', { name: /global\.md/ })); + expect( + screen.queryByRole('button', { name: /promote…/i }), + ).not.toBeInTheDocument(); + + await selectContextFile(user); + expect( + await screen.findByRole('button', { name: /promote…/i }), + ).toBeInTheDocument(); + }); + + it('refuses to submit without a reason', async () => { + const user = userEvent.setup(); + renderWithIntl(); + await selectContextFile(user); + await user.click(await screen.findByRole('button', { name: /promote…/i })); + + const dialog = await screen.findByRole('dialog'); + const submit = within(dialog).getByRole('button', { name: 'Promote' }); + expect(submit).toBeDisabled(); + + await user.type( + within(dialog).getByRole('textbox', { name: /reason/i }), + 'Policy applies to the whole team', + ); + expect(submit).toBeEnabled(); + }); + + it('posts source, target, mode and reason, then reports the target path', async () => { + mockPromoteMemory.mockResolvedValue({ + ts: '2026-08-25T10:00:00.000Z', + agentSlug: AGENT, + actor: 'operator@byte5.de', + mode: 'copy', + sourcePath: `${CHANNEL_ROOT}/vacation-policy.md`, + targetPath: `/memories/contexts/${AGENT}/team/${TEAM_KEY}/vacation-policy.md`, + reason: 'Policy applies to the whole team', + bytes: 128, + }); + const user = userEvent.setup(); + renderWithIntl(); + await selectContextFile(user); + await user.click(await screen.findByRole('button', { name: /promote…/i })); + + const dialog = await screen.findByRole('dialog'); + await user.type( + within(dialog).getByRole('textbox', { name: /reason/i }), + 'Policy applies to the whole team', + ); + await user.click(within(dialog).getByRole('button', { name: 'Promote' })); + + await waitFor(() => { + expect(mockPromoteMemory).toHaveBeenCalledTimes(1); + }); + expect(mockPromoteMemory).toHaveBeenCalledWith(AGENT, { + source: { + axis: 'channel', + ctxKey: CHANNEL_KEY, + path: 'vacation-policy.md', + }, + // Channel sources default to the team tier, pre-filled with the agent's + // existing team key — the natural channel→team promotion. + target: { tier: 'team', ctxKey: TEAM_KEY }, + mode: 'copy', + reason: 'Policy applies to the whole team', + }); + expect( + await screen.findByText( + `Promoted to /memories/contexts/${AGENT}/team/${TEAM_KEY}/vacation-policy.md.`, + ), + ).toBeInTheDocument(); + }); + + it('surfaces a 403 from the promote route as an authorization error', async () => { + mockPromoteMemory.mockRejectedValue(new MockApiError(403, 'forbidden')); + const user = userEvent.setup(); + renderWithIntl(); + await selectContextFile(user); + await user.click(await screen.findByRole('button', { name: /promote…/i })); + + const dialog = await screen.findByRole('dialog'); + await user.type( + within(dialog).getByRole('textbox', { name: /reason/i }), + 'because', + ); + await user.click(within(dialog).getByRole('button', { name: 'Promote' })); + + expect( + await within(dialog).findByText(/requires operator rights/i), + ).toBeInTheDocument(); + }); +}); + +describe('memory browser — audit tab', () => { + beforeEach(() => { + mockGetMemoryBackend.mockResolvedValue({ current: 'inmemory' }); + mockListMemoryContextLabels.mockRejectedValue(new MockApiError(404, 'nope')); + installFetch(buildStore()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('reads the promotion log of the agent whose context is open', async () => { + mockListMemoryPromotions.mockResolvedValue({ + entries: [ + { + ts: '2026-08-25T10:00:00.000Z', + agentSlug: AGENT, + actor: 'operator@byte5.de', + mode: 'move', + sourcePath: `${CHANNEL_ROOT}/vacation-policy.md`, + targetPath: `/memories/orchestrators/${AGENT}/vacation-policy.md`, + reason: 'Applies company-wide', + bytes: 128, + }, + ], + }); + const user = userEvent.setup(); + renderWithIntl(); + await selectContextFile(user); + + await user.click(screen.getByRole('tab', { name: 'Audit' })); + + await waitFor(() => { + expect(mockListMemoryPromotions).toHaveBeenCalledWith(AGENT, { + limit: 100, + }); + }); + expect(await screen.findByText('Applies company-wide')).toBeInTheDocument(); + expect(screen.getByText('operator@byte5.de')).toBeInTheDocument(); + }); + + it('explains a middleware without the audit endpoint instead of a raw 404', async () => { + mockListMemoryPromotions.mockRejectedValue(new MockApiError(404, 'nope')); + const user = userEvent.setup(); + renderWithIntl(); + await selectContextFile(user); + + await user.click(screen.getByRole('tab', { name: 'Audit' })); + + expect( + await screen.findByText(/audit endpoint is not available/i), + ).toBeInTheDocument(); + }); +}); + +describe('danger zone — context-key selector semantics', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('tells the operator the channel-type half is mandatory', async () => { + // The backend refuses a `~`-less selector with 400 invalid_selector, so the + // copy must not invite a bare native id. Before the fix it did exactly + // that, and the bare id it invited silently matched nothing while the + // response reported the scratch trees as affected. + const user = userEvent.setup(); + renderWithIntl(); + + await user.selectOptions( + screen.getByRole('combobox', { name: /axis/i }), + 'channel', + ); + + expect(screen.getByText(/always `channelType~id`/i)).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('telegram~-1001234567890 (never a bare id)'), + ).toBeInTheDocument(); + }); +}); diff --git a/web-ui/app/memory/_components/MemoryContextTree.tsx b/web-ui/app/memory/_components/MemoryContextTree.tsx new file mode 100644 index 000000000..75bdd0631 --- /dev/null +++ b/web-ui/app/memory/_components/MemoryContextTree.tsx @@ -0,0 +1,270 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; + +import { listMemoryContextLabels, type MemoryContextAxis } from '@/app/_lib/api'; + +import { + CONTEXTS_ROOT, + MEMORY_CONTEXT_AXES, + ORCHESTRATORS_ROOT, + agentTierRoot, + basename, + contextAxisRoot, + contextTierRoot, + type MemoryContextRef, +} from '../_lib/memoryPaths'; + +/** + * Context dimension of the memory browser (design #870 §6). + * + * The tree is derived from the store itself — `/memories/orchestrators/*` for + * the agent tier, `/memories/contexts///*` for the context tiers — + * so it shows exactly what exists rather than what a registry believes exists. + * Display names are an OPTIONAL enrichment: when nothing resolves a key, the + * `~` context key is shown, which is the form the purge + * selector accepts. + * + * KNOWN LIMITATION — this surface is DEV-ONLY today. `listDir` is backed by + * `GET /bot-api/dev/memory/list` (`createDevMemoryRouter`), which is + * unauthenticated and mounted only when the plugin's + * `dev_memory_endpoints_enabled` flag resolves truthy; the kernel enforces that + * the flag is never set in production. §9 of the design puts the operator UI + * outside this wave, so no operator-authenticated listing endpoint exists yet. + * What this component guarantees in the meantime is that the absence is + * VISIBLE: a non-404 failure reaches the error state instead of rendering as an + * empty tree. Backing it with an operator-gated listing (matching the purge + * gate) is the follow-up. + */ + +export interface DirEntry { + virtualPath: string; + isDirectory: boolean; +} + +export type ListDir = (path: string) => Promise; + +export interface MemoryContextTreeProps { + listDir: ListDir; + /** Agent whose agent-tier root is currently browsed, if any. */ + activeAgentTier: string | null; + activeContext: MemoryContextRef | null; + onSelectAgentTier: (agentSlug: string) => void; + onSelectContext: (ref: MemoryContextRef) => void; +} + +type AxisKeys = Partial>; + +function dirNames(entries: DirEntry[], parent: string): string[] { + return entries + .filter((e) => e.isDirectory && e.virtualPath !== parent) + .map((e) => basename(e.virtualPath)) + .sort((a, b) => a.localeCompare(b)); +} + +export function MemoryContextTree({ + listDir, + activeAgentTier, + activeContext, + onSelectAgentTier, + onSelectContext, +}: MemoryContextTreeProps): React.ReactElement { + const t = useTranslations('memory.contexts'); + const [agents, setAgents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState(null); + const [axisKeys, setAxisKeys] = useState>({}); + const [labels, setLabels] = useState>>({}); + + useEffect(() => { + let cancelled = false; + async function loadAgents(): Promise { + try { + // Both roots are optional — a store with no context trees yet has no + // `contexts` directory at all — but that "optional" is the CALLER's + // 404-to-empty rule, not a blanket catch here. Swallowing every + // rejection would make the error state below unreachable, so a + // middleware that is down or a 401 from an expired session would + // render as "No agent memory yet" and an operator would conclude the + // context trees do not exist. Let a real failure through. + const [ctx, orch] = await Promise.all([ + listDir(CONTEXTS_ROOT), + listDir(ORCHESTRATORS_ROOT), + ]); + if (cancelled) return; + const merged = [ + ...new Set([ + ...dirNames(ctx, CONTEXTS_ROOT), + ...dirNames(orch, ORCHESTRATORS_ROOT), + ]), + ].sort((a, b) => a.localeCompare(b)); + setAgents(merged); + setExpanded(merged[0] ?? null); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (!cancelled) setLoading(false); + } + } + void loadAgents(); + return () => { + cancelled = true; + }; + }, [listDir]); + + const loadAgentAxes = useCallback( + async (slug: string): Promise => { + const perAxis = await Promise.all( + MEMORY_CONTEXT_AXES.map(async (axis) => { + const root = contextAxisRoot(slug, axis); + const entries = await listDir(root).catch(() => [] as DirEntry[]); + return [axis, dirNames(entries, root)] as const; + }), + ); + setAxisKeys((prev) => ({ + ...prev, + [slug]: Object.fromEntries(perAxis) as AxisKeys, + })); + // Optional enrichment — a store without a name resolver stays usable. + try { + const res = await listMemoryContextLabels(slug); + const map: Record = {}; + for (const c of res.contexts) { + if (c.displayName !== undefined && c.displayName.length > 0) { + map[`${c.axis}/${c.ctxKey}`] = c.displayName; + } + } + setLabels((prev) => ({ ...prev, [slug]: map })); + } catch { + setLabels((prev) => ({ ...prev, [slug]: {} })); + } + }, + [listDir], + ); + + useEffect(() => { + if (expanded === null) return; + if (expanded in axisKeys) return; + // Lazy load-on-expand: the axes of an agent are only fetched once, and the + // first state write happens after the awaits — not a cascading render. + // eslint-disable-next-line react-hooks/set-state-in-effect + void loadAgentAxes(expanded); + }, [expanded, axisKeys, loadAgentAxes]); + + const labelFor = (slug: string, axis: MemoryContextAxis, key: string): string => { + const resolved = labels[slug]?.[`${axis}/${key}`]; + if (resolved !== undefined) return resolved; + // Fall back to the key VERBATIM, not to a prettified half of it. The half + // after `~` is a sanitised stem plus a digest, so rendering it alone reads + // like a native id an operator could paste into the Danger-Zone selector — + // where it would derive a different key and silently match nothing on a + // destructive action. The full `channelType~safeKey` is exactly the form + // that selector accepts, so showing it is both honest and directly usable. + return key; + }; + + return ( +
+
+ {t('title')} +
+ {loading && ( +

{t('loading')}

+ )} + {error !== null && ( +

{t('error')}

+ )} + {!loading && error === null && agents.length === 0 && ( +

{t('empty')}

+ )} +
    + {agents.map((slug) => { + const isOpen = expanded === slug; + const axes = axisKeys[slug]; + return ( +
  • + {/* eslint-disable-next-line no-restricted-syntax -- tree disclosure row, not a text CTA */} + + {isOpen && ( +
    + {/* eslint-disable-next-line no-restricted-syntax -- tree selection row, not a text CTA */} + + {axes === undefined ? ( + + {t('loading')} + + ) : ( + MEMORY_CONTEXT_AXES.map((axis) => { + const keys = axes[axis] ?? []; + return ( +
    +
    + {t(`axis.${axis}`, { count: keys.length })} +
    + {keys.map((key) => { + const isActive = + activeContext !== null && + activeContext.agentSlug === slug && + activeContext.axis === axis && + activeContext.ctxKey === key; + return ( + // eslint-disable-next-line no-restricted-syntax -- tree selection row, not a text CTA + + ); + })} +
    + ); + }) + )} +
    + )} +
  • + ); + })} +
+
+ ); +} diff --git a/web-ui/app/memory/_components/PromoteDialog.tsx b/web-ui/app/memory/_components/PromoteDialog.tsx new file mode 100644 index 000000000..6a62f1b9f --- /dev/null +++ b/web-ui/app/memory/_components/PromoteDialog.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; + +import { Button } from '@/app/_components/ui/Button'; +import { + ApiError, + promoteMemory, + type MemoryPromoteMode, + type MemoryPromoteTier, + type MemoryPromotionReceipt, +} from '@/app/_lib/api'; + +import { + agentTierRoot, + contextTierRoot, + type MemoryContextLocation, +} from '../_lib/memoryPaths'; + +/** + * "Promote…" — the explicit operator act that lifts one memory file out of a + * chat context into a wider tier of the SAME agent (design #870 §6). + * + * Direction is constrained by the source axis, because those are the only + * directions the design allows: channel→team, channel→agent, team→agent, + * user→agent. Nothing here can cross agents; that is a non-goal, not a + * missing feature. + * + * The reason is mandatory in the UI even though the service takes it as + * optional: an unexplained cross-context copy is exactly the event the audit + * log exists to explain. + */ + +const MODES: readonly MemoryPromoteMode[] = ['copy', 'move']; + +function tiersForAxis(axis: MemoryContextLocation['axis']): MemoryPromoteTier[] { + return axis === 'channel' ? ['team', 'agent'] : ['agent']; +} + +export interface PromoteDialogProps { + source: MemoryContextLocation; + /** Known team context keys of the same agent, offered as suggestions. */ + teamKeys: readonly string[]; + onClose: () => void; + onPromoted: (receipt: MemoryPromotionReceipt) => void; +} + +export function PromoteDialog({ + source, + teamKeys, + onClose, + onPromoted, +}: PromoteDialogProps): React.ReactElement { + const t = useTranslations('memory.promote'); + const tiers = useMemo(() => tiersForAxis(source.axis), [source.axis]); + const [tier, setTier] = useState(tiers[0] ?? 'agent'); + const [targetCtxKey, setTargetCtxKey] = useState(teamKeys[0] ?? ''); + const [targetPath, setTargetPath] = useState(source.relPath); + const [mode, setMode] = useState('copy'); + const [reason, setReason] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const trimmedReason = reason.trim(); + const trimmedCtxKey = targetCtxKey.trim(); + const trimmedPath = targetPath.trim(); + const needsCtxKey = tier === 'team'; + const canSubmit = + trimmedReason.length > 0 && + trimmedPath.length > 0 && + (!needsCtxKey || trimmedCtxKey.length > 0) && + !submitting; + + const sourcePath = `${contextTierRoot(source)}/${source.relPath}`; + const targetPreview = + tier === 'agent' + ? `${agentTierRoot(source.agentSlug)}/${trimmedPath}` + : `${contextTierRoot({ + agentSlug: source.agentSlug, + axis: 'team', + ctxKey: trimmedCtxKey || '…', + })}/${trimmedPath}`; + + const submit = useCallback(async (): Promise => { + if (!canSubmit) return; + setSubmitting(true); + setError(null); + try { + const receipt = await promoteMemory(source.agentSlug, { + source: { + axis: source.axis, + ctxKey: source.ctxKey, + path: source.relPath, + }, + target: { + tier, + ...(needsCtxKey ? { ctxKey: trimmedCtxKey } : {}), + ...(trimmedPath === source.relPath ? {} : { path: trimmedPath }), + }, + mode, + reason: trimmedReason, + }); + onPromoted(receipt); + } catch (err) { + if (err instanceof ApiError && err.status === 403) { + setError(t('forbidden')); + } else if (err instanceof ApiError && err.status === 404) { + setError(t('unavailable')); + } else { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + setSubmitting(false); + } + }, [ + canSubmit, + mode, + needsCtxKey, + onPromoted, + source, + t, + tier, + trimmedCtxKey, + trimmedPath, + trimmedReason, + ]); + + return ( +
+
+

+ {t('title')} +

+

+ {t('intro')} +

+ +
+
{t('sourceLabel')}
+
{sourcePath}
+
{t('targetPreviewLabel')}
+
{targetPreview}
+
+ +
+ + + + + {needsCtxKey && ( + + )} + + +
+ +