diff --git a/.github/workflows/ob1-gate-v2.yml b/.github/workflows/ob1-gate-v2.yml index c69f519de..aae6e5893 100644 --- a/.github/workflows/ob1-gate-v2.yml +++ b/.github/workflows/ob1-gate-v2.yml @@ -11,16 +11,28 @@ name: OB1 PR Gate # This means: automated agent passes → human admin approves → merge allowed on: - pull_request: - types: [opened, synchronize, reopened] + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] branches: [main] + workflow_dispatch: permissions: contents: read jobs: + event_guard: + name: OB1 Gate Event Guard + if: github.event_name != 'pull_request_target' + runs-on: ubuntu-latest + steps: + - name: Explain non-review gate run + run: | + echo "OB1 PR Gate received a ${GITHUB_EVENT_NAME} event." + echo "The contribution review only runs for pull_request_target events." + review: name: OB1 Review + if: github.event_name == 'pull_request_target' runs-on: ubuntu-latest steps: - name: Checkout PR head safely @@ -30,7 +42,9 @@ jobs: fetch-depth: 0 - name: Fetch base branch - run: git fetch origin "${{ github.event.pull_request.base.ref }}" --depth=1 + run: | + git fetch origin "${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" --depth=1 + git show "origin/${{ github.event.pull_request.base.ref }}:.github/metadata.schema.json" > /tmp/ob1-metadata.schema.json - name: Install metadata schema validator run: python3 -m pip install check-jsonschema @@ -53,11 +67,11 @@ jobs: - name: Run review checks id: review + env: + CHANGED_FILES: ${{ steps.changed.outputs.files }} + CONTRIB_DIRS: ${{ steps.changed.outputs.contrib_dirs }} + PR_TITLE: ${{ github.event.pull_request.title }} run: | - CHANGED_FILES="${{ steps.changed.outputs.files }}" - CONTRIB_DIRS="${{ steps.changed.outputs.contrib_dirs }}" - PR_TITLE="${{ github.event.pull_request.title }}" - pass_count=0 fail_count=0 results="" @@ -144,7 +158,7 @@ jobs: continue fi - if ! schema_output=$(check-jsonschema --schemafile .github/metadata.schema.json "$dir/metadata.json" 2>&1); then + if ! schema_output=$(check-jsonschema --schemafile /tmp/ob1-metadata.schema.json "$dir/metadata.json" 2>&1); then indented_output=$(printf '%s\n' "$schema_output" | sed 's/^/ /') rule3_detail="${rule3_detail} - \`$dir/metadata.json\` failed schema validation\n${indented_output}\n" rule3_pass=false @@ -628,6 +642,15 @@ jobs: REVIEW_COMMENT: ${{ steps.review.outputs.comment }} REVIEW_FAILED: ${{ steps.review.outputs.failed }} SECRET_BLOCKED: ${{ steps.review.outputs.secret_blocked }} + CHANGED_FILES: ${{ steps.changed.outputs.files }} + CONTRIB_DIRS: ${{ steps.changed.outputs.contrib_dirs }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_AUTHOR_LOGIN: ${{ github.event.pull_request.user.login }} + PR_AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} + PR_DRAFT: ${{ github.event.pull_request.draft }} run: | set -euo pipefail @@ -635,17 +658,17 @@ jobs: printf '%s\n' "$REVIEW_COMMENT" > gate-artifact/ob1-review-summary.md printf '%s\n' "$REVIEW_COMMENT" >> "$GITHUB_STEP_SUMMARY" - printf '%s\n' "${{ steps.changed.outputs.files }}" > gate-artifact/changed-files.txt - printf '%s\n' "${{ steps.changed.outputs.contrib_dirs }}" > gate-artifact/contribution-dirs.txt + printf '%s\n' "$CHANGED_FILES" > gate-artifact/changed-files.txt + printf '%s\n' "$CONTRIB_DIRS" > gate-artifact/contribution-dirs.txt jq -n \ - --argjson pr_number "${{ github.event.pull_request.number }}" \ - --arg pr_url "${{ github.event.pull_request.html_url }}" \ - --arg title "${{ github.event.pull_request.title }}" \ - --arg head_sha "${{ github.event.pull_request.head.sha }}" \ - --arg author_login "${{ github.event.pull_request.user.login }}" \ - --arg author_association "${{ github.event.pull_request.author_association }}" \ - --arg is_draft "${{ github.event.pull_request.draft }}" \ + --argjson pr_number "$PR_NUMBER" \ + --arg pr_url "$PR_URL" \ + --arg title "$PR_TITLE" \ + --arg head_sha "$PR_HEAD_SHA" \ + --arg author_login "$PR_AUTHOR_LOGIN" \ + --arg author_association "$PR_AUTHOR_ASSOCIATION" \ + --arg is_draft "$PR_DRAFT" \ --arg failed "$REVIEW_FAILED" \ --arg secret_blocked "$SECRET_BLOCKED" \ '{ diff --git a/.github/workflows/ob1-pr-followups.yml b/.github/workflows/ob1-pr-followups.yml index e22180dc2..073d43215 100644 --- a/.github/workflows/ob1-pr-followups.yml +++ b/.github/workflows/ob1-pr-followups.yml @@ -16,8 +16,18 @@ permissions: id-token: write jobs: + ignore_non_pr_gate: + name: Ignore Non-PR Gate Run + if: github.event.workflow_run.event != 'pull_request' && github.event.workflow_run.event != 'pull_request_target' + runs-on: ubuntu-latest + steps: + - name: Explain skipped follow-up + run: | + echo "OB1 PR Follow-Ups only acts on pull_request or pull_request_target gate runs." + echo "Received upstream event: ${{ github.event.workflow_run.event }}" + followups: - if: github.event.workflow_run.event == 'pull_request' + if: github.event.workflow_run.event == 'pull_request' || github.event.workflow_run.event == 'pull_request_target' runs-on: ubuntu-latest concurrency: group: ob1-pr-followups-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }} diff --git a/dashboards/open-brain-dashboard-next/.env.example b/dashboards/open-brain-dashboard-next/.env.example index ff5222665..cb9bb6531 100644 --- a/dashboards/open-brain-dashboard-next/.env.example +++ b/dashboards/open-brain-dashboard-next/.env.example @@ -1,6 +1,14 @@ # Required: URL of your Open Brain REST API NEXT_PUBLIC_API_URL=https://YOUR-PROJECT-REF.supabase.co/functions/v1/open-brain-rest +# Required: password entered at the dashboard login screen. +DASHBOARD_PASSWORD= + +# Required: server-side Company Memory access key. It is never entered at +# login or stored in the browser session. MCP_ACCESS_KEY remains a legacy fallback. +OPEN_BRAIN_KEY= +# MCP_ACCESS_KEY= + # Optional: URL of your OB1 Agent Memory API. # If omitted, the dashboard derives it from NEXT_PUBLIC_API_URL by replacing open-brain-rest with agent-memory-api. # AGENT_MEMORY_API_URL=https://YOUR-PROJECT-REF.supabase.co/functions/v1/agent-memory-api @@ -9,6 +17,10 @@ NEXT_PUBLIC_API_URL=https://YOUR-PROJECT-REF.supabase.co/functions/v1/open-brain # AGENT_MEMORY_WORKSPACE_ID=ob1-staging # AGENT_MEMORY_PROJECT_ID= +# Optional: use a dedicated Agent Memory access key when its API is deployed. +# If omitted, the dashboard intentionally falls back to MCP_ACCESS_KEY. +# AGENT_MEMORY_ACCESS_KEY= + # Required: 32+ character secret for iron-session cookie encryption # Generate with: openssl rand -hex 32 SESSION_SECRET= @@ -23,6 +35,12 @@ SESSION_SECRET= # OB1_DEMO_AUTH_BYPASS=false # OB1_DASHBOARD_DEMO_KEY=local-screenshot-key +# Optional: allow Mission Control to send retrieved Company Memory snippets to +# OpenRouter for answer synthesis. Default is off; enabling requires privacy review. +# MISSION_CONTROL_AI_ANSWERS_ENABLED=false +# OPENROUTER_API_KEY= +# OPENROUTER_MODEL=anthropic/claude-3.5-haiku + # Optional: SHA-256 hash of passphrase to unlock restricted/sensitive content # Requires the sensitivity-tiers primitive (sensitivity_tier column on thoughts) # Generate with: echo -n "your-passphrase" | shasum -a 256 diff --git a/dashboards/open-brain-dashboard-next/EXTENSIONS.md b/dashboards/open-brain-dashboard-next/EXTENSIONS.md new file mode 100644 index 000000000..7a398c4ea --- /dev/null +++ b/dashboards/open-brain-dashboard-next/EXTENSIONS.md @@ -0,0 +1,84 @@ +# Dashboard Extensions + +The Open Brain dashboard supports drop-in extensions that add a new route +and a sidebar entry **without modifying any core dashboard file**. + +## Anatomy + +An extension is: + +1. A folder under `app//` containing one or more `page.tsx` files + (Next.js App Router conventions apply). The folder name becomes the URL. +2. One entry in `extensions.config.ts` adding a sidebar nav item. + +That's it. Extensions own their own API helpers (live in the extension +folder, not `lib/api.ts`) and their own types. + +## Minimal example + +``` +app/ + hello/ + page.tsx ← extension page + api.ts ← extension's own data layer (optional) +``` + +```ts +// extensions.config.ts +export const EXTENSIONS: ExtensionNavEntry[] = [ + { href: "/hello", label: "Hello", icon: "sparkles" }, +]; +``` + +`page.tsx` imports its own helpers from `./api` (or wherever), and the +extension is live after `npm run build && vercel deploy --prod`. + +## Auth + +Extension pages use the same session helpers as core pages: + +```tsx +import { requireSessionOrRedirect } from "@/lib/auth"; + +export default async function Page() { + const { apiKey } = await requireSessionOrRedirect(); + // ... +} +``` + +`apiKey` is the OB1 access key the user logged in with — pass it as the +`x-brain-key` header when calling Edge Functions. + +## Backend routes + +Extensions that need their own REST endpoints have two clean options: + +- **Sidecar Edge Function.** Deploy a separate function (e.g. + `my-extension-api`). Derive its URL on the dashboard side by string- + replacing `open-brain-rest` in `NEXT_PUBLIC_API_URL` (`agent-memory-api` + does this — see `lib/agent-memory.ts`). +- **Add routes to `open-brain-rest`.** Acceptable when the data lives in a + table that's tightly coupled to OB1's core surface area. + +## Icon registry + +Extensions reference icons by string name because `extensions.config.ts` +is plain TypeScript (no JSX). Supported keys are declared in +`extensions.config.ts` as `ExtensionIcon`. To add a new icon: + +1. Add the key to the `ExtensionIcon` union. +2. Implement the SVG component in `components/Sidebar.tsx`. +3. Map the key in `EXTENSION_ICONS`. + +## Position in the sidebar + +Extensions render in declaration order, between the core nav items +(Dashboard, Thoughts, Workflow, Agent Memory, Search, Audit, Duplicates) +and the trailing "Add" entry. + +## Versioning + +The extension contract is small (one config file, one folder layout +convention) so it's intentionally not versioned. Breaking changes — if +ever — would surface as TypeScript errors in `extensions.config.ts`, +which is the right place to catch them. diff --git a/dashboards/open-brain-dashboard-next/README.md b/dashboards/open-brain-dashboard-next/README.md index a2d0c38da..8c3e6d0a8 100644 --- a/dashboards/open-brain-dashboard-next/README.md +++ b/dashboards/open-brain-dashboard-next/README.md @@ -255,6 +255,16 @@ AGENT_MEMORY_API_URL=http://127.0.0.1:3022 Do not enable `OB1_DEMO_AUTH_BYPASS` in shared previews or production. It exists so repeatable screenshot and video generation can run without putting real API keys in browser automation. +### Agent Memory Read-Only Governance Guard + +Set `OB1_GOVERNANCE_READ_ONLY=true` for local governance pilots. In this mode, Agent Memory list/detail pages render read-only notices, hide review controls, and server actions return before calling `PATCH /memories/:id/review`. + +Verify locally: + +```bash +npm run test:agent-memory +``` + ## Tech Stack - **Next.js 16** (App Router) diff --git a/dashboards/open-brain-dashboard-next/app/agent-memory/[id]/page.tsx b/dashboards/open-brain-dashboard-next/app/agent-memory/[id]/page.tsx index c99c7c748..46c18b7b4 100644 --- a/dashboards/open-brain-dashboard-next/app/agent-memory/[id]/page.tsx +++ b/dashboards/open-brain-dashboard-next/app/agent-memory/[id]/page.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { notFound } from "next/navigation"; import { revalidatePath } from "next/cache"; import { + agentMemoryDefaults, fetchAgentMemory, reviewAgentMemory, } from "@/lib/agent-memory"; @@ -14,26 +15,40 @@ import { } from "@/components/AgentMemoryBadges"; import { FormattedDate } from "@/components/FormattedDate"; import type { AgentMemoryReviewAction } from "@/lib/types"; +import { isGovernanceReadOnly } from "@/lib/governance"; export const dynamic = "force-dynamic"; export default async function AgentMemoryDetailPage({ params, + searchParams, }: { params: Promise<{ id: string }>; + searchParams: Promise>; }) { const { apiKey } = await requireSessionOrRedirect(); const { id } = await params; + const query = await searchParams; + const defaults = agentMemoryDefaults(); + const workspaceId = query.workspace_id || defaults.workspaceId; + const projectId = query.project_id ?? defaults.projectId; + const governanceReadOnly = isGovernanceReadOnly(); let memory; try { - memory = await fetchAgentMemory(apiKey, id); + memory = await fetchAgentMemory(apiKey, id, { + workspace_id: workspaceId, + project_id: projectId, + }); } catch { notFound(); } async function reviewAction(formData: FormData) { "use server"; + if (isGovernanceReadOnly()) { + return; + } const { apiKey } = await requireSessionOrRedirect(); const action = String(formData.get("action") || "") as AgentMemoryReviewAction; await reviewAgentMemory(apiKey, id, action, { @@ -59,26 +74,32 @@ export default async function AgentMemoryDetailPage({ {memory.summary} -
-
- - -
-
- - -
-
- - -
-
+ {governanceReadOnly ? ( +
+ Review actions are unavailable in the read-only governance pilot. +
+ ) : ( +
+
+ + +
+
+ + +
+
+ + +
+
+ )}
diff --git a/dashboards/open-brain-dashboard-next/app/agent-memory/page.tsx b/dashboards/open-brain-dashboard-next/app/agent-memory/page.tsx index c18d05590..3054ad5d4 100644 --- a/dashboards/open-brain-dashboard-next/app/agent-memory/page.tsx +++ b/dashboards/open-brain-dashboard-next/app/agent-memory/page.tsx @@ -13,6 +13,7 @@ import { } from "@/components/AgentMemoryBadges"; import { FormattedDate } from "@/components/FormattedDate"; import type { AgentMemoryReviewAction } from "@/lib/types"; +import { isGovernanceReadOnly } from "@/lib/governance"; export const dynamic = "force-dynamic"; @@ -38,6 +39,7 @@ export default async function AgentMemoryPage({ const projectId = params.project_id ?? defaults.projectId; const status = params.review_status ?? "pending"; const limit = parseInt(params.limit || "50", 10); + const governanceReadOnly = isGovernanceReadOnly(); let data; let error: string | null = null; @@ -55,6 +57,9 @@ export default async function AgentMemoryPage({ async function reviewAction(formData: FormData) { "use server"; + if (isGovernanceReadOnly()) { + return; + } const { apiKey } = await requireSessionOrRedirect(); const memoryId = String(formData.get("memory_id") || ""); const action = String(formData.get("action") || "") as AgentMemoryReviewAction; @@ -73,6 +78,13 @@ export default async function AgentMemoryPage({ return `/agent-memory?${sp.toString()}`; } + function scopedUrl(path: string) { + const sp = new URLSearchParams(); + sp.set("workspace_id", workspaceId); + if (projectId) sp.set("project_id", projectId); + return `${path}?${sp.toString()}`; + } + return (
@@ -110,7 +122,7 @@ export default async function AgentMemoryPage({ {projectId || "all-projects"}

Recall traces @@ -119,6 +131,11 @@ export default async function AgentMemoryPage({
{error &&

{error}

} + {governanceReadOnly && ( +
+ Agent Memory review actions are unavailable in the read-only governance pilot. +
+ )}
@@ -148,7 +165,7 @@ export default async function AgentMemoryPage({ )) diff --git a/dashboards/open-brain-dashboard-next/app/agent-memory/traces/page.tsx b/dashboards/open-brain-dashboard-next/app/agent-memory/traces/page.tsx index 898665dca..510f8f70c 100644 --- a/dashboards/open-brain-dashboard-next/app/agent-memory/traces/page.tsx +++ b/dashboards/open-brain-dashboard-next/app/agent-memory/traces/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { fetchRecallTrace } from "@/lib/agent-memory"; +import { agentMemoryDefaults, fetchRecallTrace } from "@/lib/agent-memory"; import { requireSessionOrRedirect } from "@/lib/auth"; import { PolicyBadges } from "@/components/AgentMemoryBadges"; import { FormattedDate } from "@/components/FormattedDate"; @@ -14,12 +14,18 @@ export default async function RecallTracePage({ const { apiKey } = await requireSessionOrRedirect(); const params = await searchParams; const requestId = params.request_id || ""; + const defaults = agentMemoryDefaults(); + const workspaceId = params.workspace_id || defaults.workspaceId; + const projectId = params.project_id ?? defaults.projectId; let data = null; let error: string | null = null; if (requestId) { try { - data = await fetchRecallTrace(apiKey, requestId); + data = await fetchRecallTrace(apiKey, requestId, { + workspace_id: workspaceId, + project_id: projectId, + }); } catch (err) { error = err instanceof Error ? err.message : "Failed to load recall trace"; } @@ -44,6 +50,8 @@ export default async function RecallTracePage({ + + {projectId && } {item.agent_memories ? ( {item.agent_memories.summary} @@ -162,3 +170,10 @@ export default async function RecallTracePage({ function formatScore(value: number | null) { return value === null || value === undefined ? "n/a" : Number(value).toFixed(3); } + +function scopedParams(workspaceId: string, projectId: string) { + const sp = new URLSearchParams(); + sp.set("workspace_id", workspaceId); + if (projectId) sp.set("project_id", projectId); + return sp.toString(); +} diff --git a/dashboards/open-brain-dashboard-next/app/api/mission-control/ask/route.ts b/dashboards/open-brain-dashboard-next/app/api/mission-control/ask/route.ts new file mode 100644 index 000000000..b0bb1f750 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/app/api/mission-control/ask/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from "next/server"; +import { searchThoughts } from "@/lib/api"; +import { AuthError, requireSession } from "@/lib/auth"; + +/* + * Ask Open Brain — Phase C. + * Read-only natural-language search over Company Memory via semantic search. + * Server-side key only; no writes. (Plain-language synthesis is a later upgrade + * that needs an LLM key — this returns the genuinely-relevant records.) + */ + +export const dynamic = "force-dynamic"; + +const STOP = new Set( + ("a an the of in on at to for and or but with our us we i you me my your this that these those it its " + + "what which who whom whose when where why how is are was were be been being do does did have has had will " + + "would can could should over past about show tell give list any all from as so just please").split(/\s+/) +); + +// Reduce a natural-language question to its content keywords for keyword search. +function keywords(q: string): string { + const words = q + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter((w) => w.length > 2 && !STOP.has(w)); + return words.join(" ") || q; +} + +function summarize(content: string): { title: string; snippet: string } { + const text = (content || "").replace(/\s+/g, " ").trim(); + const project = content.match(/^\s*PROJECT:\s*(.+)$/im); + const titleLine = content.match(/^\s*(?:Title|TITLE):\s*(.+)$/im); + let title = (project?.[1] || titleLine?.[1] || text.slice(0, 64)).trim(); + title = title.split(/ -- | – | — | \/ /)[0].slice(0, 64).trim(); + const done = content.match(/^\s*DONE:\s*(.+)$/im); + const snippet = (done?.[1] || text).replace(/\s+/g, " ").trim().slice(0, 190); + return { title, snippet }; +} + +// Synthesize a plain-English answer from the retrieved records via OpenRouter. +// Returns null when no key is set or the call fails (caller falls back to the list). +async function synthesize( + question: string, + results: { title: string; snippet: string; date?: string }[] +): Promise { + if (process.env.MISSION_CONTROL_AI_ANSWERS_ENABLED !== "true") return null; + const key = process.env.OPENROUTER_API_KEY; + if (!key || results.length === 0) return null; + const context = results + .map((r, i) => `[${i + 1}] ${r.title} (${(r.date || "").slice(0, 10)}): ${r.snippet}`) + .join("\n"); + try { + const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + model: process.env.OPENROUTER_MODEL || "anthropic/claude-3.5-haiku", + max_tokens: 320, + messages: [ + { + role: "system", + content: + "You are Stone, James's AI chief of staff for HumeStone. Answer the question in 2-4 plain-English sentences using ONLY the provided Company Memory records. Be specific and concise; no jargon. If the records don't answer it, say so briefly. Never invent facts.", + }, + { role: "user", content: `Question: ${question}\n\nCompany Memory records:\n${context}` }, + ], + }), + }); + if (!res.ok) return null; + const d = await res.json(); + return d?.choices?.[0]?.message?.content?.trim() || null; + } catch { + return null; + } +} + +export async function POST(req: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + return NextResponse.json( + { error: "Company Memory is not configured." }, + { status: 500 } + ); + } + + let question = ""; + try { + question = String((await req.json())?.question || ""); + } catch { + /* ignore */ + } + question = question.slice(0, 300).trim(); + if (!question) { + return NextResponse.json({ error: "Question required." }, { status: 400 }); + } + + try { + // Prefer semantic search; fall back to text if the embedding service is down. + let data; + try { + data = await searchThoughts(apiKey, question, "semantic", 5); + } catch { + data = await searchThoughts(apiKey, keywords(question), "text", 5); + } + const results = (data.results || []).slice(0, 5).map((r) => { + const { title, snippet } = summarize(r.content || ""); + return { id: r.id, title, snippet, date: r.created_at }; + }); + const synthesized = await synthesize(question, results); + const answer = + synthesized || + (results.length + ? `Here ${results.length === 1 ? "is the" : "are the"} ${results.length} most relevant ${ + results.length === 1 ? "record" : "records" + } in Company Memory for "${question}":` + : `I couldn't find anything in Company Memory matching "${question}". Try rephrasing it.`); + return NextResponse.json({ answer, results, synthesized: !!synthesized }); + } catch { + return NextResponse.json({ answer: "Couldn't reach Company Memory just now.", results: [] }, { status: 502 }); + } +} diff --git a/dashboards/open-brain-dashboard-next/app/login/LoginForm.tsx b/dashboards/open-brain-dashboard-next/app/login/LoginForm.tsx index 3d6176e3c..23582c983 100644 --- a/dashboards/open-brain-dashboard-next/app/login/LoginForm.tsx +++ b/dashboards/open-brain-dashboard-next/app/login/LoginForm.tsx @@ -18,18 +18,18 @@ export function LoginForm({
diff --git a/dashboards/open-brain-dashboard-next/app/login/page.tsx b/dashboards/open-brain-dashboard-next/app/login/page.tsx index 56789593a..152adfcf9 100644 --- a/dashboards/open-brain-dashboard-next/app/login/page.tsx +++ b/dashboards/open-brain-dashboard-next/app/login/page.tsx @@ -1,4 +1,5 @@ import Image from "next/image"; +import { createHash, timingSafeEqual } from "crypto"; import { redirect } from "next/navigation"; import { getSession } from "@/lib/auth"; import { LoginForm } from "./LoginForm"; @@ -6,26 +7,24 @@ import { LoginForm } from "./LoginForm"; async function loginAction(formData: FormData) { "use server"; - const apiKey = formData.get("apiKey") as string; - if (!apiKey?.trim()) { - return { error: "API key is required" }; + const password = formData.get("password") as string; + if (!password?.trim()) { + return { error: "Password is required" }; } - // Validate key against health endpoint - const apiUrl = process.env.NEXT_PUBLIC_API_URL; - try { - const res = await fetch(`${apiUrl}/health`, { - headers: { "x-brain-key": apiKey }, - }); - if (!res.ok) { - return { error: "Invalid API key or service unavailable" }; - } - } catch { - return { error: "Could not reach API. Check your connection." }; + const expected = process.env.DASHBOARD_PASSWORD; + if (!expected) { + return { error: "Login is not configured. Contact the administrator." }; } + const suppliedDigest = createHash("sha256").update(password).digest(); + const expectedDigest = createHash("sha256").update(expected).digest(); + if (!timingSafeEqual(suppliedDigest, expectedDigest)) { + return { error: "Incorrect password" }; + } + + // The Company Memory key remains server-side and never enters the session. const session = await getSession(); - session.apiKey = apiKey; session.loggedIn = true; await session.save(); @@ -34,7 +33,7 @@ async function loginAction(formData: FormData) { export default async function LoginPage() { const session = await getSession(); - if (session.loggedIn && session.apiKey) { + if (session.loggedIn) { redirect("/"); } @@ -57,7 +56,7 @@ export default async function LoginPage() { Open Brain

- Enter your API key to continue + Enter your password to continue

diff --git a/dashboards/open-brain-dashboard-next/app/mission-control/cockpit.css b/dashboards/open-brain-dashboard-next/app/mission-control/cockpit.css new file mode 100644 index 000000000..6e16ec79f --- /dev/null +++ b/dashboards/open-brain-dashboard-next/app/mission-control/cockpit.css @@ -0,0 +1,332 @@ +/* + * Mission Control cockpit — scoped styles. + * + * Phase A: the colour / type / radius / motion DNA now lives in the shared + * token layer (./tokens.css) so the Company Memory surfaces can inherit the + * same aesthetic in Phase C. This file consumes those tokens; every var() + * resolves to the exact value it replaced, so the cockpit is unchanged. + * + * Per-card locals --mc-accent / --mc-tone are still set inline by Cockpit.tsx + * and are deliberately separate from the palette tokens in tokens.css. + */ + +@import "./tokens.css"; + +.mc-root { + position: fixed; + inset: 0; + z-index: 60; + overflow-y: auto; + overflow-x: hidden; + background: var(--mc-canvas-bg); + color: var(--mc-text); + font-family: var(--mc-font); + -webkit-font-smoothing: antialiased; +} + +.mc-shell { + display: flex; + min-height: 100%; +} + +/* ---------- left nav (spine) ---------- */ +.mc-nav { + width: 232px; + flex-shrink: 0; + border-right: 1px solid var(--mc-border-1); + padding: 22px 16px; + display: flex; + flex-direction: column; + gap: 4px; + position: sticky; + top: 0; + height: 100vh; + background: var(--mc-surface-0); +} +.mc-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 6px 20px; +} +.mc-brand-mark { + width: 30px; + height: 30px; + border-radius: var(--mc-radius-1); + display: grid; + place-items: center; + font-weight: 700; + font-size: 13px; + color: var(--mc-brand-ink); + background: var(--mc-brand-mark); + box-shadow: var(--mc-mark-inset), var(--mc-glow-brand); +} +.mc-nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border-radius: var(--mc-radius-1); + font-size: var(--mc-fs-nav); + color: var(--mc-text-3); + cursor: pointer; + position: relative; + transition: color var(--mc-dur-fast) ease, background var(--mc-dur-fast) ease; + background: none; + border: 0; + width: 100%; + text-align: left; +} +.mc-nav-item:hover { + color: var(--mc-text); + background: var(--mc-surface-hover); +} +.mc-nav-item[data-active="true"] { + color: var(--mc-text); + font-weight: 500; + background: var(--mc-brand-bg-soft); +} +.mc-nav-item[data-active="true"]::before { + content: ""; + position: absolute; + left: -16px; + top: 8px; + bottom: 8px; + width: 2px; + border-radius: 2px; + background: var(--mc-brand-active-bar); + box-shadow: var(--mc-glow-active); +} +.mc-nav-group { + font-size: var(--mc-fs-micro); + letter-spacing: var(--mc-track-eyebrow); + text-transform: uppercase; + color: var(--mc-text-6); + padding: 18px 10px 8px; +} + +/* ---------- main column (surface frame) ---------- */ +.mc-main { + flex: 1; + min-width: 0; + padding: 26px 28px 80px; + max-width: 1320px; +} +.mc-topbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 26px; +} +.mc-breadcrumb { + font-size: var(--mc-fs-caption); + color: var(--mc-text-5); +} +.mc-online { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: var(--mc-fs-online); + letter-spacing: var(--mc-track-online); + text-transform: uppercase; + color: var(--mc-text-2); +} + +/* ---------- shared label / text ---------- */ +.mc-eyebrow { + font-size: var(--mc-fs-eyebrow); + letter-spacing: var(--mc-track-eyebrow); + text-transform: uppercase; + color: var(--mc-text-4); + display: flex; + align-items: center; + gap: 7px; +} +.mc-caption { + font-size: var(--mc-fs-caption); + line-height: 1.45; + color: var(--mc-text-4); +} +.mc-hero-greeting { + font-size: var(--mc-fs-hero); + font-weight: 600; + letter-spacing: var(--mc-track-tight); + line-height: 1.12; +} +.mc-hero-greeting .dim { + color: var(--mc-text-4); +} +.mc-section-head { + font-size: var(--mc-fs-title); + font-weight: 600; + letter-spacing: var(--mc-track-tight); + margin-bottom: 2px; +} + +/* ---------- cards ---------- */ +.mc-card { + position: relative; + border-radius: var(--mc-radius-4); + border: 1px solid var(--mc-border-2); + background: var(--mc-surface-2); + overflow: hidden; + box-shadow: var(--mc-card-inset); + transition: transform var(--mc-dur-mid) var(--mc-ease), border-color var(--mc-dur-mid) ease; +} +.mc-card-wash { + --mc-accent: #ff8a4c; +} +.mc-card-wash::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient(120% 100% at 0% 0%, color-mix(in oklab, var(--mc-accent) 18%, transparent), transparent 58%); +} +.mc-glow-orb { + position: absolute; + top: -60px; + right: -56px; + width: 168px; + height: 168px; + border-radius: var(--mc-radius-pill); + filter: blur(48px); + opacity: 0.36; + pointer-events: none; + background: var(--mc-accent, #ff8a4c); + transition: opacity var(--mc-dur-mid) ease; +} +.mc-kpi:hover { + transform: translateY(-2px); + border-color: var(--mc-border-strong); +} +.mc-kpi:hover .mc-glow-orb { + opacity: 0.6; +} +.mc-kpi-num { + font-size: var(--mc-fs-kpi); + font-weight: 600; + line-height: 1.02; + letter-spacing: var(--mc-track-tighter); + font-variant-numeric: tabular-nums; +} + +/* ---------- status pill ---------- */ +.mc-pill { + display: inline-flex; + align-items: center; + gap: 7px; + border-radius: var(--mc-radius-pill); + padding: 5px 12px; + font-size: var(--mc-fs-caption); + font-weight: 500; + font-variant-numeric: tabular-nums; + border: 1px solid color-mix(in oklab, var(--mc-tone, #3ddc97) 45%, transparent); + background: color-mix(in oklab, var(--mc-tone, #3ddc97) 14%, transparent); + color: var(--mc-tone, #3ddc97); +} +.mc-dot { + width: 7px; + height: 7px; + border-radius: var(--mc-radius-pill); + background: var(--mc-tone, #3ddc97); + box-shadow: 0 0 var(--mc-dot-glow) var(--mc-tone, #3ddc97); +} + +/* ---------- approval / run rows ---------- */ +.mc-row { + display: flex; + gap: 14px; + padding: 16px 18px; + border-radius: var(--mc-radius-3); + border: 1px solid var(--mc-border-1); + background: var(--mc-surface-1); + transition: border-color var(--mc-dur-row) ease, background var(--mc-dur-row) ease; +} +.mc-row:hover { + border-color: var(--mc-border-row-hover); + background: var(--mc-surface-3); +} +.mc-row-title { + font-size: var(--mc-fs-row); + font-weight: 600; + letter-spacing: -0.005em; +} + +/* ---------- progress dial ---------- */ +.mc-dial-track { + stroke: var(--mc-border-2); +} +.mc-dial-fill { + stroke-linecap: round; + transition: stroke-dashoffset var(--mc-dur-dial) ease; +} + +/* ---------- ask open brain dock ---------- */ +.mc-ask { + position: relative; + border-radius: var(--mc-radius-4); + border: 1px solid var(--mc-brand-border); + background: + radial-gradient(120% 140% at 100% 0%, var(--mc-ask-wash), transparent 60%), + var(--mc-surface-2); + overflow: hidden; +} +.mc-ask-input { + width: 100%; + background: var(--mc-surface-inset); + border: 1px solid var(--mc-border-3); + border-radius: var(--mc-radius-2); + padding: 13px 15px; + color: var(--mc-text); + font-size: var(--mc-fs-body); + outline: none; + transition: border-color var(--mc-dur-fast) ease, box-shadow var(--mc-dur-fast) ease; +} +.mc-ask-input:focus { + border-color: var(--mc-brand-border-strong); + box-shadow: var(--mc-focus-ring); +} +.mc-chip { + font-size: var(--mc-fs-caption); + padding: 6px 11px; + border-radius: var(--mc-radius-pill); + border: 1px solid var(--mc-border-3); + background: var(--mc-surface-3); + color: var(--mc-text-1); + cursor: pointer; + transition: border-color var(--mc-dur-fast) ease, color var(--mc-dur-fast) ease; +} +.mc-chip:hover { + border-color: var(--mc-brand-border-hover); + color: var(--mc-text); +} + +/* Ask result cards link to the full record (/thoughts/[id]); hover reads as clickable. */ +.mc-ask-result { + transition: background var(--mc-dur-fast) ease, border-color var(--mc-dur-fast) ease; +} +.mc-ask-result:hover { + background: rgba(255, 255, 255, 0.06); + border-color: var(--mc-border-row-hover); +} + +.mc-grid-3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} +.mc-grid-2 { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 16px; +} +@media (max-width: 900px) { + .mc-nav { display: none; } + .mc-grid-3, .mc-grid-2 { grid-template-columns: 1fr; } + .mc-main { padding: 20px 16px 64px; } +} + +@media (prefers-reduced-motion: reduce) { + .mc-card, .mc-glow-orb, .mc-dial-fill, .mc-row, .mc-ask-input, .mc-ask-result { transition: none; } +} diff --git a/dashboards/open-brain-dashboard-next/app/mission-control/page.tsx b/dashboards/open-brain-dashboard-next/app/mission-control/page.tsx new file mode 100644 index 000000000..491fec698 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/app/mission-control/page.tsx @@ -0,0 +1,81 @@ +import "./cockpit.css"; +import Cockpit from "@/components/mission-control/Cockpit"; +import CockpitStatus from "@/components/mission-control/CockpitStatus"; +import { getCockpitLive } from "@/lib/mission-control"; +import { requireSessionOrRedirect } from "@/lib/auth"; + +export const metadata = { + title: "Mission Control | HumeStone", + description: "Stone's operating cockpit", +}; + +// Always read fresh from Company Memory on load. +export const dynamic = "force-dynamic"; + +/** Human-readable UTC stamp for "when we tried to reach Company Memory". */ +function fetchedAtLabel(d: Date): string { + return `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`; +} + +export default async function MissionControlPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + // Validate the session like the data pages do; the cockpit renders live + // company status and must never load without a real logged-in session. + const { apiKey } = await requireSessionOrRedirect(); + const params = await searchParams; + + // Design-iteration escape hatch: the sample cockpit renders ONLY when + // explicitly requested (?preview=1) and labels itself as sample data. + // It is never a fallback: a failed live read must look failed, not like + // a healthy cockpit full of real-looking numbers. + if (params.preview === "1") return ; + + let live; + try { + live = await getCockpitLive(apiKey); + } catch { + // Fetch/API failure: Company Memory could not be reached. Say exactly + // that, with no fabricated section content anywhere. + return ( + + ); + } + + // Reachable but empty: zero STATUS RECORDs to build a cockpit from. + if (!live) { + return ( + + ); + } + + return ( + + ); +} diff --git a/dashboards/open-brain-dashboard-next/app/mission-control/tokens.css b/dashboards/open-brain-dashboard-next/app/mission-control/tokens.css new file mode 100644 index 000000000..7a8d0aa60 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/app/mission-control/tokens.css @@ -0,0 +1,127 @@ +/* + * Mission Control — shared design tokens (Phase A). + * + * Single source of truth for the Mission Control aesthetic, promoted out of + * cockpit.css so BOTH the cockpit and (from Phase C) the Company Memory + * surfaces consume one palette instead of drifting between two. + * + * Vehicle: plain :root custom properties under the `--mc-*` namespace. + * Framework-agnostic — readable from raw CSS (cockpit.css) and from Tailwind v4 + * arbitrary values (e.g. bg-[var(--mc-canvas)]) when memory pages are re-skinned. + * + * Provenance: our own values. The colour direction was approved by James + * (deep blue-black canvas, near-white text, amber/emerald/violet on data only, + * light from off-card). Inspiration only — no copied code or branding. + * + * Promotion contract: every value below is byte-identical to the literal it + * replaced in cockpit.css, so consuming these tokens is a computed no-op. + * The `--mc-*` palette names here are distinct from the per-card locals + * `--mc-accent` / `--mc-tone` that cockpit.css sets inline, so they never clash. + */ + +:root { + /* ---- canvas & off-card light ------------------------------------------ */ + --mc-canvas: #0e1016; + --mc-wash-amber: rgba(255, 138, 76, 0.08); + --mc-wash-blue: rgba(124, 140, 255, 0.07); + --mc-wash-violet: rgba(167, 139, 250, 0.03); + /* composite canvas background (washes + base) */ + --mc-canvas-bg: + radial-gradient(1100px 520px at 100% -8%, var(--mc-wash-amber), transparent 56%), + radial-gradient(900px 520px at -6% 108%, var(--mc-wash-blue), transparent 60%), + radial-gradient(700px 420px at 50% 50%, var(--mc-wash-violet), transparent 70%), + var(--mc-canvas); + + /* ---- surfaces (translucent white steps over canvas) ------------------- */ + --mc-surface-0: rgba(255, 255, 255, 0.012); /* spine background */ + --mc-surface-1: rgba(255, 255, 255, 0.02); /* list row base */ + --mc-surface-2: rgba(255, 255, 255, 0.025); /* card base */ + --mc-surface-3: rgba(255, 255, 255, 0.035); /* row hover / chip */ + --mc-surface-hover: rgba(255, 255, 255, 0.05); /* nav item hover */ + --mc-surface-inset: rgba(0, 0, 0, 0.28); /* inputs / dark result wells */ + + /* ---- text steps (near-white, stepped by alpha) ------------------------ */ + --mc-text: #eef1f7; + --mc-text-1: rgba(238, 241, 247, 0.72); /* chip / secondary body */ + --mc-text-2: rgba(238, 241, 247, 0.6); /* online status */ + --mc-text-3: rgba(238, 241, 247, 0.58); /* nav item idle */ + --mc-text-4: rgba(238, 241, 247, 0.5); /* eyebrow / caption / dim */ + --mc-text-5: rgba(238, 241, 247, 0.42); /* breadcrumb */ + --mc-text-6: rgba(238, 241, 247, 0.34); /* nav group label */ + + /* ---- hairline borders ------------------------------------------------- */ + --mc-border-1: rgba(255, 255, 255, 0.07); /* spine edge / row */ + --mc-border-2: rgba(255, 255, 255, 0.08); /* card */ + --mc-border-3: rgba(255, 255, 255, 0.12); /* input / chip */ + --mc-border-row-hover: rgba(255, 255, 255, 0.14); + --mc-border-strong: rgba(255, 255, 255, 0.16); /* card hover */ + + /* ---- accent tones — colour = concept, only ever on data --------------- */ + --mc-good: #3ddc97; /* healthy / live / in-sync */ + --mc-attention: #ffb454; /* needs you */ + --mc-blocked: #ff7a7a; /* held / blocked */ + --mc-info: #7c8cff; /* informational / workers */ + --mc-neutral: #a78bfa; /* secondary / content queue */ + + /* ---- brand amber (the one warm hue; identity, washes, focus) ---------- */ + --mc-brand: #ff8a4c; + --mc-brand-light: #ffc371; + --mc-brand-dark: #d97757; + --mc-brand-ink: #2a1407; /* text on amber surfaces */ + --mc-brand-mark: linear-gradient(160deg, #ffc371, #ff8a4c 50%, #d97757); + --mc-brand-active-bar: linear-gradient(180deg, #ffc371, #ff8a4c); + --mc-brand-bg-soft: rgba(255, 138, 76, 0.12); /* active nav fill */ + --mc-brand-border: rgba(255, 138, 76, 0.24); /* ask dock edge */ + --mc-brand-border-strong: rgba(255, 138, 76, 0.6); /* input focus edge */ + --mc-brand-border-hover: rgba(255, 138, 76, 0.5); /* chip hover edge */ + --mc-ask-wash: rgba(255, 138, 76, 0.1); /* ask dock wash */ + + /* ---- radius scale ----------------------------------------------------- */ + --mc-radius-1: 9px; /* nav item, brand mark */ + --mc-radius-2: 12px; /* input */ + --mc-radius-3: 14px; /* row, result well */ + --mc-radius-4: 18px; /* card, ask dock */ + --mc-radius-pill: 999px; + + /* ---- type ------------------------------------------------------------- */ + --mc-font: var(--font-geist-sans), system-ui, sans-serif; + --mc-fs-kpi: 46px; /* one big glowing number per card */ + --mc-fs-hero: 27px; + --mc-fs-title: 17px; /* section head */ + --mc-fs-row: 14.5px; /* row title */ + --mc-fs-body: 14px; /* input / nav-ish body */ + --mc-fs-nav: 13.5px; + --mc-fs-caption: 12px; + --mc-fs-online: 11px; + --mc-fs-eyebrow: 10.5px; + --mc-fs-micro: 10px; /* nav group label */ + --mc-track-eyebrow: 0.2em; /* wide-tracked instrument labels */ + --mc-track-online: 0.12em; + --mc-track-tight: -0.01em; + --mc-track-tighter: -0.02em; + + /* ---- spacing scale (4px base) ----------------------------------------- */ + --mc-space-1: 4px; + --mc-space-2: 8px; + --mc-space-3: 12px; + --mc-space-4: 16px; + --mc-space-5: 20px; + --mc-space-6: 24px; + --mc-space-7: 28px; + --mc-space-8: 32px; + + /* ---- motion (calm; always paired with reduced-motion guards) ---------- */ + --mc-ease: cubic-bezier(0.4, 0, 0.2, 1); + --mc-dur-fast: 160ms; + --mc-dur-mid: 240ms; + --mc-dur-row: 200ms; + --mc-dur-dial: 700ms; + + /* ---- glow & shadow ---------------------------------------------------- */ + --mc-glow-brand: 0 6px 18px -8px rgba(255, 138, 76, 0.7); + --mc-glow-active: 0 0 10px rgba(255, 138, 76, 0.85); + --mc-card-inset: inset 0 1px 0 rgba(255, 255, 255, 0.05); + --mc-mark-inset: inset 0 1px 0 rgba(255, 255, 255, 0.4); + --mc-focus-ring: 0 0 0 3px rgba(255, 138, 76, 0.14); + --mc-dot-glow: 7px; /* status dot blur radius */ +} diff --git a/dashboards/open-brain-dashboard-next/components/Sidebar.tsx b/dashboards/open-brain-dashboard-next/components/Sidebar.tsx index a8aeafd62..82b790c4c 100644 --- a/dashboards/open-brain-dashboard-next/components/Sidebar.tsx +++ b/dashboards/open-brain-dashboard-next/components/Sidebar.tsx @@ -1,21 +1,46 @@ "use client"; +import type { ComponentType } from "react"; import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { RestrictedToggle } from "@/components/RestrictedToggle"; +import { EXTENSIONS, type ExtensionIcon } from "@/extensions.config"; -const nav = [ +type IconComponent = ComponentType<{ active: boolean }>; + +const EXTENSION_ICONS: Record = { + clock: ClockIcon, + folder: FolderIcon, + plug: PlugIcon, + sparkles: SparklesIcon, +}; + +const coreNav: { href: string; label: string; icon: IconComponent }[] = [ { href: "/", label: "Dashboard", icon: DashboardIcon }, + { href: "/mission-control", label: "Mission Control", icon: MissionControlIcon }, { href: "/thoughts", label: "Thoughts", icon: ThoughtsIcon }, { href: "/kanban", label: "Workflow", icon: KanbanIcon }, { href: "/agent-memory", label: "Agent Memory", icon: MemoryIcon }, { href: "/search", label: "Search", icon: SearchIcon }, { href: "/audit", label: "Audit", icon: AuditIcon }, { href: "/duplicates", label: "Duplicates", icon: DuplicatesIcon }, +]; + +const trailingNav: { href: string; label: string; icon: IconComponent }[] = [ { href: "/ingest", label: "Add", icon: AddIcon }, ]; +const nav: { href: string; label: string; icon: IconComponent }[] = [ + ...coreNav, + ...EXTENSIONS.map((e) => ({ + href: e.href, + label: e.label, + icon: EXTENSION_ICONS[e.icon], + })), + ...trailingNav, +]; + interface SidebarProps { isOpen?: boolean; onClose?: () => void; @@ -108,6 +133,15 @@ function DashboardIcon({ active }: { active: boolean }) { ); } +function MissionControlIcon({ active }: { active: boolean }) { + return ( + + + + + ); +} + function ThoughtsIcon({ active }: { active: boolean }) { return ( @@ -169,3 +203,37 @@ function AddIcon({ active }: { active: boolean }) { ); } + +function ClockIcon({ active }: { active: boolean }) { + return ( + + + + + ); +} + +function FolderIcon({ active }: { active: boolean }) { + return ( + + + + ); +} + +function PlugIcon({ active }: { active: boolean }) { + return ( + + + + ); +} + +function SparklesIcon({ active }: { active: boolean }) { + return ( + + + + + ); +} diff --git a/dashboards/open-brain-dashboard-next/components/mission-control/Cockpit.tsx b/dashboards/open-brain-dashboard-next/components/mission-control/Cockpit.tsx new file mode 100644 index 000000000..22c7e7fe4 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/components/mission-control/Cockpit.tsx @@ -0,0 +1,469 @@ +"use client"; + +/* + * Mission Control — redesigned cockpit (Phase A). + * ClaudeOS-inspired principles (big glowing numbers, micro-labels, off-card + * light, whitespace, plain-language captions) in HumeStone's own palette. + * + * Live data comes in through props (Phase B). The sample-data defaults are a + * design-iteration preview only: the page renders them solely behind an + * explicit ?preview=1 opt-in, never as a fallback for a failed live read, + * and the preview labels itself loudly (topbar + banner + spine footer). + */ + +import { useState } from "react"; +import Link from "next/link"; +import { Spine, type SpineGroup } from "./shell/Spine"; +import { + HERO, + KPIS, + APPROVALS, + RUNS, + GATES, + HEALTH, + NAV, + ASK_SUGGESTIONS, + TONE_HEX, + type Tone, + type Kpi, + type Approval, + type Run, + type Health, + type Gate, +} from "./sample-data"; + +const SPINE_GROUPS: SpineGroup[] = [ + { items: NAV.primary }, + { label: "Surfaces", items: NAV.surfaces }, +]; + +type CockpitProps = { + hero?: typeof HERO; + kpis?: Kpi[]; + approvals?: Approval[]; + runs?: Run[]; + health?: Health[]; + gates?: Gate[]; + isLive?: boolean; +}; + +type AskResult = { id?: string; title: string; snippet: string; date?: string }; +type AskResponse = { answer: string; results: AskResult[] }; + +function Pill({ tone, children }: { tone: Tone; children: React.ReactNode }) { + return ( + + + {children} + + ); +} + +function Dial({ pct, accent }: { pct: number; accent: string }) { + const r = 30; + const c = 2 * Math.PI * r; + const offset = c - (pct / 100) * c; + return ( + + + + + ); +} + +export default function Cockpit({ + hero = HERO, + kpis = KPIS, + approvals = APPROVALS, + runs = RUNS, + health = HEALTH, + gates = GATES, + isLive = false, +}: CockpitProps = {}) { + const [query, setQuery] = useState(""); + const [asking, setAsking] = useState(false); + const [answer, setAnswer] = useState(null); + + async function ask(q?: string) { + const qq = (q ?? query).trim(); + if (!qq) return; + setQuery(qq); + setAsking(true); + setAnswer(null); + try { + const res = await fetch("/api/mission-control/ask", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ question: qq }), + }); + const d = await res.json(); + setAnswer(res.ok ? d : { answer: "Couldn't reach Company Memory just now.", results: [] }); + } catch { + setAnswer({ answer: "Couldn't reach Company Memory just now.", results: [] }); + } finally { + setAsking(false); + } + } + + return ( +
+
+ {/* ---------------- sidebar ---------------- */} + + + {isLive ? "Live · Company Memory" : "Preview · sample data"} +
+ } + /> + + {/* ---------------- main ---------------- */} +
+
+
Stone · local cockpit
+
+ + {isLive ? "Stone online" : "Sample data"} +
+
+ + {/* Preview banner: the sample cockpit only renders behind an explicit + ?preview=1 opt-in, and it must never be mistakable for live status. */} + {!isLive && ( +
+ +
+ Design preview: every number and item on this screen is sample data, not live status. +
+
+ )} + + {/* hero */} +
+
+
+
+ Good evening, {hero.name}. {hero.greeting} +
+ {hero.status.label} +
+
+ Working on: {hero.workingOn} + · + Updated {hero.updated} +
+
+ + {/* The Today/7d/28d range toggle was removed 2026-07-03 (surface + convergence F5): nothing read it, and a control that does + nothing erodes trust in the ones that do. */} +
+ + {/* KPI row */} +
+ {kpis.map((k) => ( +
+
+
+
+ {k.eyebrow} +
+
+ {k.value} +
+
{k.caption}
+
+
+ ))} +
+ + {/* Ask Open Brain */} +
+
+ Ask Open Brain +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && ask()} + /> + +
+
+ {ASK_SUGGESTIONS.map((s) => ( + + ))} +
+ + {(asking || answer) && ( +
+ {asking && ( +
+ Searching Company Memory… +
+ )} + {!asking && answer && ( + <> +
+ {answer.answer} +
+ {answer.results.length > 0 && ( +
+ {answer.results.map((r, i) => { + const cardStyle: React.CSSProperties = { + display: "block", + padding: "10px 12px", + borderRadius: 10, + background: "rgba(255,255,255,0.03)", + border: "1px solid rgba(255,255,255,0.07)", + color: "inherit", + textDecoration: "none", + }; + const inner = ( + <> +
+
{r.title}
+ {r.date && ( +
+ {new Date(r.date).toLocaleDateString()} +
+ )} +
+
+ {r.snippet} +
+ + ); + // Each result carries the Company Memory record id; open the + // full record at /thoughts/[id]. Cards without an id (rare) + // stay inert. + return r.id ? ( + + {inner} + + ) : ( +
+ {inner} +
+ ); + })} +
+ )} +
+ + Live semantic search over Company Memory +
+ + )} +
+ )} +
+ + {/* two-column body */} +
+ {/* left: approvals + runs */} +
+
+
+
+ Approval inbox +
+
Waiting on you
+
Stone has these ready — it just needs your yes or no.
+
+
+ {approvals.length === 0 && ( +
+
Nothing waiting on you right now — you're all clear.
+
+ )} + {approvals.map((a) => ( +
+
+
{a.title}
+ {a.why} +
+
{a.plain}
+
+ ))} +
+
+ +
+
+
+ Current runs +
+
What's happening now
+
Live work by Stone and its workers.
+
+
+ {runs.map((r) => { + const tone: Tone = r.state === "done" ? "good" : r.state === "building" ? "info" : "neutral"; + const label = r.state === "done" ? "Done" : r.state === "building" ? "Building" : "Healthy"; + return ( +
+
+
{r.title}
+
{r.plain}
+
+ {label} +
+ ); + })} +
+
+
+ + {/* right: gates + health */} +
+
+
+
+ Gate ledger +
+
Held for safety
+
Things Stone will never do without asking you first.
+
+
+ {gates.map((g, i) => ( +
+
+
{g.label}
+
{g.plain}
+
+ {g.state} +
+ ))} +
+
+ +
+
+
+ System health +
+
All systems
+
The brain, the workers, and the content pipeline.
+
+
+ {health.map((h) => ( +
+
+ +
+ {h.pct}% +
+
+
+
{h.label}
+
{h.caption}
+
+
+ ))} +
+
+
+
+
+
+
+ ); +} diff --git a/dashboards/open-brain-dashboard-next/components/mission-control/CockpitStatus.tsx b/dashboards/open-brain-dashboard-next/components/mission-control/CockpitStatus.tsx new file mode 100644 index 000000000..22e5f96cd --- /dev/null +++ b/dashboards/open-brain-dashboard-next/components/mission-control/CockpitStatus.tsx @@ -0,0 +1,108 @@ +/* + * CockpitStatus — the honest no-live-data surface for Mission Control. + * + * Rendered by the page whenever the cockpit cannot show real status: either + * Company Memory could not be reached (fetch/API failure) or it is reachable + * but holds no STATUS RECORDs yet (empty). Keeps the shell (spine + topbar) + * so the surface still reads as Mission Control, but renders NO section + * content: a fake cockpit that looks real is worse than an honest blank one. + * The sample cockpit is design-preview only, behind /mission-control?preview=1. + */ + +import { Spine, type SpineGroup } from "./shell/Spine"; +import { NAV, TONE_HEX, type Tone } from "./sample-data"; + +const SPINE_GROUPS: SpineGroup[] = [ + { items: NAV.primary }, + { label: "Surfaces", items: NAV.surfaces }, +]; + +export type CockpitStatusProps = { + /** Colour family from the shared tone palette (dot, pill, card wash). */ + tone: Tone; + /** Short status pill text, e.g. "Company Memory unreachable". */ + pill: string; + /** Plain-English headline, e.g. "Live status unavailable". */ + headline: string; + /** One- or two-sentence explanation of what happened and what is true. */ + plain: string; + /** Secondary detail line, e.g. when the fetch was attempted. */ + detail?: string; + /** Spine footer status, e.g. "Offline · Company Memory unreachable". */ + footerLabel: string; + /** Topbar status — replaces the live cockpit's "Stone online" claim. */ + topbarLabel: string; +}; + +export default function CockpitStatus({ + tone, + pill, + headline, + plain, + detail, + footerLabel, + topbarLabel, +}: CockpitStatusProps) { + const toneHex = TONE_HEX[tone]; + + return ( +
+
+ + + {footerLabel} +
+ } + /> + +
+
+
Stone · local cockpit
+
+ + {topbarLabel} +
+
+ +
+
+
+
+ + + {pill} + +
+ {headline} +
+
+ {plain} +
+ {detail && ( +
+ {detail} +
+ )} +
+
+
+
+
+ + ); +} diff --git a/dashboards/open-brain-dashboard-next/components/mission-control/sample-data.ts b/dashboards/open-brain-dashboard-next/components/mission-control/sample-data.ts new file mode 100644 index 000000000..ac5ecf1d5 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/components/mission-control/sample-data.ts @@ -0,0 +1,186 @@ +/* + * Mission Control — Phase A sample data. + * Static fixture so the redesign can be reviewed without wiring live data. + * Content mirrors real HumeStone context so the preview feels real. + * Phase B replaces this with live Company Memory reads. + */ + +export type Tone = "good" | "attention" | "blocked" | "info" | "neutral"; + +export const TONE_HEX: Record = { + good: "#3ddc97", + attention: "#ffb454", + blocked: "#ff7a7a", + info: "#7c8cff", + neutral: "#a78bfa", +}; + +export const HERO = { + name: "James", + greeting: "Here's where Stone stands.", + status: { label: "Needs you", tone: "attention" as Tone }, + workingOn: "Mission Control redesign · Phase A", + updated: "8 min ago", +}; + +export type Kpi = { + eyebrow: string; + value: string; + caption: string; + accent: string; + tone: Tone; +}; + +export const KPIS: Kpi[] = [ + { + eyebrow: "Needs your approval", + value: "2", + caption: "Items waiting on your yes / no before Stone can proceed.", + accent: "#ffb454", + tone: "attention", + }, + { + eyebrow: "Active runs", + value: "3", + caption: "What Stone and its workers are doing right now.", + accent: "#3ddc97", + tone: "good", + }, + { + eyebrow: "Last verified", + value: "8m", + caption: "When Company Memory last confirmed everything is in sync.", + accent: "#a78bfa", + tone: "info", + }, +]; + +export type Approval = { + title: string; + why: string; + plain: string; + tone: Tone; +}; + +export const APPROVALS: Approval[] = [ + { + title: "Deploy the dashboard timezone fix", + why: "Production change to brain.humestone.com", + plain: "A small fix so the memory detail page shows its panels correctly instead of breaking. The fix is already written.", + tone: "attention", + }, + { + title: "Confirm the Test Kitchen candidate", + why: "A business decision only you can make", + plain: "Pick the founder/operator candidate and the workflow for the safer-AI-delegation lane so Stone can move it forward.", + tone: "attention", + }, +]; + +export type Run = { + title: string; + state: "building" | "done" | "healthy"; + plain: string; +}; + +export const RUNS: Run[] = [ + { + title: "Mission Control redesign — Phase A", + state: "building", + plain: "Building the new look you're seeing right now.", + }, + { + title: "Portal — security fix + Next 16 upgrade", + state: "done", + plain: "Both shipped and verified live on app.humestone.com today.", + }, + { + title: "Company Memory content pipeline", + state: "healthy", + plain: "Processing new source articles into the brain. 0 failed.", + }, +]; + +export type Gate = { + label: string; + state: string; + plain: string; + tone: Tone; +}; + +export const GATES: Gate[] = [ + { + label: "Hermes provider retry", + state: "Held", + plain: "Paused until you approve a provider/key retry.", + tone: "blocked", + }, + { + label: "Production deploys", + state: "Gated", + plain: "Stone always asks you before anything goes live.", + tone: "attention", + }, + { + label: "Secrets & credentials", + state: "Gated", + plain: "No key or access changes without your explicit OK.", + tone: "attention", + }, +]; + +export type Health = { + label: string; + pct: number; + caption: string; + accent: string; +}; + +export const HEALTH: Health[] = [ + { label: "Company Memory", pct: 100, caption: "Live & in sync", accent: "#3ddc97" }, + { label: "Workers", pct: 100, caption: "All idle / healthy", accent: "#7c8cff" }, + { label: "Content queue", pct: 100, caption: "0 failed items", accent: "#a78bfa" }, +]; + +/* Ask Open Brain — Phase A shows a canned answer to demonstrate the feature. + Phase C wires this to real natural-language queries over Company Memory. */ +export const ASK_SUGGESTIONS = [ + "What repos have we added in the last 30 days?", + "What did we ship this week?", + "What's blocked and waiting on me?", +]; + +export const ASK_DEMO = { + question: "What repos have we added in the last 30 days?", + answer: + "In the last 30 days you've added 3 repositories to HumeStone's GitHub: humestone-portal (the customer Portal — shipped a security fix and Next 16 upgrade today), the OB1 dashboard fork, and the Company Memory bridge. The Portal saw the most activity, with 39 pull requests.", + sources: [ + "STATUS RECORD · Portal #38/#39 deploy · today", + "STATUS RECORD · Tracks A/B sweep · Jun 3", + "Entity · humestone-portal", + ], + note: "Preview answer — in the finished version this is generated live from Company Memory.", +}; + +/* + * Spine navigation. Phase B: real destinations. + * - Cockpit is the surface route (/mission-control). + * - Approvals/Runs/Ask/Gates/System health jump to sections on the cockpit. + * - Company Memory routes into the folded memory app (legacy chrome until Phase C). + * - Evidence was removed 2026-07-03 (surface convergence F5): it never had a + * surface, and an inert nav item reads as a broken one. Re-add with a real + * href if an evidence surface ships. + */ +export const NAV = { + primary: [ + { label: "Cockpit", icon: "◳", href: "/mission-control" }, + { label: "Approvals", icon: "✓", href: "/mission-control#approvals" }, + { label: "Runs", icon: "▷", href: "/mission-control#runs" }, + { label: "Ask Open Brain", icon: "✦", href: "/mission-control#ask" }, + ], + surfaces: [ + { label: "Company Memory", icon: "◇", href: "/" }, + { label: "Gates", icon: "⊘", href: "/mission-control#gates" }, + { label: "System health", icon: "♥", href: "/mission-control#health" }, + ], +}; diff --git a/dashboards/open-brain-dashboard-next/components/mission-control/shell/Spine.tsx b/dashboards/open-brain-dashboard-next/components/mission-control/shell/Spine.tsx new file mode 100644 index 000000000..08f20f9aa --- /dev/null +++ b/dashboards/open-brain-dashboard-next/components/mission-control/shell/Spine.tsx @@ -0,0 +1,125 @@ +"use client"; + +/* + * Spine — the persistent left navigation of the Mission Control shell. + * + * Phase B: routes for real. Items with an `href` render a Next ; the + * active item is derived from the current path via usePathname (an explicit + * `active` flag still wins when provided, e.g. the /design-system reference). + * In-page section jumps (hrefs that carry a `#`) and hrefless scaffold items + * never claim the active state — only a surface route does. + */ + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export type SpineItem = { + label: string; + icon: string; + /** Destination route. Hrefless items render as inert scaffold buttons. */ + href?: string; + /** Explicit active override. When omitted, active is derived from the path. */ + active?: boolean; +}; + +/** Resolve whether an item is the active surface for the current path. */ +function isItemActive(item: SpineItem, pathname: string): boolean { + if (item.active !== undefined) return item.active; // explicit override wins + if (!item.href || item.href.includes("#")) return false; // scaffold or in-page jump + const base = item.href; + return base === "/" + ? pathname === "/" + : pathname === base || pathname.startsWith(`${base}/`); +} + +export type SpineGroup = { + /** Optional uppercase group label, e.g. "Surfaces". */ + label?: string; + items: SpineItem[]; +}; + +export type SpineProps = { + /** Short mark shown in the brand badge, e.g. "MC". */ + mark?: string; + title?: string; + subtitle?: string; + groups: SpineGroup[]; + /** Optional footer node (e.g. a live/preview status dot). */ + footer?: React.ReactNode; +}; + +function SpineLink({ item, active }: { item: SpineItem; active: boolean }) { + const content = ( + <> + + {item.label} + + ); + + // A real Next when we have a destination (client-side routing + hash + // jumps); an inert button for scaffold-only items. + if (item.href) { + return ( + + {content} + + ); + } + + return ( + + ); +} + +export function Spine({ + mark = "MC", + title = "Mission Control", + subtitle = "HumeStone · Stone", + groups, + footer, +}: SpineProps) { + const pathname = usePathname(); + + return ( + + ); +} + +export default Spine; diff --git a/dashboards/open-brain-dashboard-next/extensions.config.ts b/dashboards/open-brain-dashboard-next/extensions.config.ts new file mode 100644 index 000000000..79e2c8fa9 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/extensions.config.ts @@ -0,0 +1,31 @@ +/** + * Dashboard Extension Registry + * ============================ + * + * Drop-in extensions register here. Each entry adds one nav item to the + * sidebar, in declaration order, between the core nav and the trailing + * "Add" entry. Extension pages live under `app//` and may declare + * their own local helpers — no other dashboard file needs to change. + * + * To install an extension: + * 1. Drop its `app//` folder into the dashboard + * 2. Add one entry below + * 3. `npm run build && vercel deploy --prod` + * + * To uninstall, remove both. No other file is touched. + * + * See EXTENSIONS.md for the full convention. + */ + +export type ExtensionIcon = "clock" | "folder" | "plug" | "sparkles"; + +export interface ExtensionNavEntry { + /** Route the extension owns, e.g. "/sessions". */ + href: string; + /** Sidebar label. */ + label: string; + /** Icon key resolved against the registry in Sidebar.tsx. */ + icon: ExtensionIcon; +} + +export const EXTENSIONS: ExtensionNavEntry[] = []; diff --git a/dashboards/open-brain-dashboard-next/lib/agent-memory.test.mjs b/dashboards/open-brain-dashboard-next/lib/agent-memory.test.mjs new file mode 100644 index 000000000..b097d1d17 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/lib/agent-memory.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const root = process.cwd(); + +function source(relativePath) { + return readFileSync(path.join(root, relativePath), "utf8"); +} + +test("Agent Memory list and detail server actions exit before review mutation in read-only mode", () => { + for (const file of [ + "app/agent-memory/page.tsx", + "app/agent-memory/[id]/page.tsx", + ]) { + const text = source(file); + const guardIndex = text.indexOf("if (isGovernanceReadOnly())"); + const mutationIndex = text.indexOf("reviewAgentMemory("); + assert.notEqual(guardIndex, -1, `${file} must check governance read-only mode`); + assert.notEqual(mutationIndex, -1, `${file} must contain reviewAgentMemory call`); + assert.ok(guardIndex < mutationIndex, `${file} must guard before reviewAgentMemory`); + assert.match(text, /governanceReadOnly \? \(/, `${file} must hide review controls in read-only mode`); + } +}); + +test("Agent Memory API URL prefers explicit Agent Memory endpoint before derived fallback", () => { + const text = source("lib/agent-memory.ts"); + assert.ok( + text.indexOf("process.env.AGENT_MEMORY_API_URL") < text.indexOf("deriveAgentMemoryUrl"), + "AGENT_MEMORY_API_URL must be checked before derived fallback", + ); + assert.ok( + text.indexOf("process.env.NEXT_PUBLIC_AGENT_MEMORY_API_URL") < text.indexOf("deriveAgentMemoryUrl"), + "NEXT_PUBLIC_AGENT_MEMORY_API_URL must be checked before derived fallback", + ); +}); + +test("Agent Memory ID reads carry dashboard workspace and project scope", () => { + const library = source("lib/agent-memory.ts"); + assert.match(library, /fetchAgentMemory\(\s*apiKey: string,\s*memoryId: string,\s*scope\?:/s); + assert.match(library, /fetchRecallTrace\(\s*apiKey: string,\s*requestId: string,\s*scope\?:/s); + assert.match(library, /sp\.set\("workspace_id", scope\.workspace_id\)/); + assert.match(library, /sp\.set\("project_id", scope\.project_id\)/); + + const detailPage = source("app/agent-memory/[id]/page.tsx"); + assert.match(detailPage, /const defaults = agentMemoryDefaults\(\)/); + assert.match(detailPage, /const workspaceId = query\.workspace_id \|\| defaults\.workspaceId/); + assert.match(detailPage, /fetchAgentMemory\(apiKey, id, \{\s*workspace_id: workspaceId,\s*project_id: projectId,/s); + + const tracesPage = source("app/agent-memory/traces/page.tsx"); + assert.match(tracesPage, /const defaults = agentMemoryDefaults\(\)/); + assert.match(tracesPage, /const workspaceId = params\.workspace_id \|\| defaults\.workspaceId/); + assert.match(tracesPage, /fetchRecallTrace\(apiKey, requestId, \{\s*workspace_id: workspaceId,\s*project_id: projectId,/s); + assert.match(tracesPage, //); + + const listPage = source("app/agent-memory/page.tsx"); + assert.match(listPage, /function scopedUrl\(path: string\)/); + assert.match(listPage, /href=\{scopedUrl\(`\/agent-memory\/\$\{memory\.memory_id\}`\)\}/); +}); + +test("Agent Memory requests prefer a dedicated key and retain the shared fallback", () => { + const library = source("lib/agent-memory.ts"); + assert.match( + library, + /process\.env\.AGENT_MEMORY_ACCESS_KEY \|\| apiKey/, + ); + assert.match(library, /"x-brain-key": agentMemoryKey/); +}); diff --git a/dashboards/open-brain-dashboard-next/lib/agent-memory.ts b/dashboards/open-brain-dashboard-next/lib/agent-memory.ts index 8dded8a2d..9bb72422d 100644 --- a/dashboards/open-brain-dashboard-next/lib/agent-memory.ts +++ b/dashboards/open-brain-dashboard-next/lib/agent-memory.ts @@ -19,8 +19,9 @@ function deriveAgentMemoryUrl(restUrl?: string) { } function headers(apiKey: string): HeadersInit { + const agentMemoryKey = process.env.AGENT_MEMORY_ACCESS_KEY || apiKey; return { - "x-brain-key": apiKey, + "x-brain-key": agentMemoryKey, "Content-Type": "application/json", }; } @@ -91,11 +92,19 @@ export async function fetchReviewQueue( export async function fetchAgentMemory( apiKey: string, - memoryId: string + memoryId: string, + scope?: { + workspace_id?: string; + project_id?: string; + } ): Promise { + const sp = new URLSearchParams(); + if (scope?.workspace_id) sp.set("workspace_id", scope.workspace_id); + if (scope?.project_id) sp.set("project_id", scope.project_id); + const query = sp.toString(); const data = await agentMemoryFetch<{ memory: AgentMemoryRecord }>( apiKey, - `/memories/${memoryId}` + `/memories/${memoryId}${query ? `?${query}` : ""}` ); return data.memory; } @@ -121,10 +130,18 @@ export async function reviewAgentMemory( export async function fetchRecallTrace( apiKey: string, - requestId: string + requestId: string, + scope?: { + workspace_id?: string; + project_id?: string; + } ): Promise { + const sp = new URLSearchParams(); + if (scope?.workspace_id) sp.set("workspace_id", scope.workspace_id); + if (scope?.project_id) sp.set("project_id", scope.project_id); + const query = sp.toString(); return agentMemoryFetch( apiKey, - `/recall-traces/${requestId}` + `/recall-traces/${requestId}${query ? `?${query}` : ""}` ); } diff --git a/dashboards/open-brain-dashboard-next/lib/api.ts b/dashboards/open-brain-dashboard-next/lib/api.ts index 3bc7c1dc7..137b65591 100644 --- a/dashboards/open-brain-dashboard-next/lib/api.ts +++ b/dashboards/open-brain-dashboard-next/lib/api.ts @@ -23,7 +23,13 @@ function headers(apiKey: string): HeadersInit { }; } -async function apiFetch( +/** + * Authenticated JSON fetch against the open-brain-rest Edge Function. + * + * Exported so dashboard extensions (see EXTENSIONS.md) can reuse the auth + * header + error-translation plumbing without duplicating it. + */ +export async function apiFetch( apiKey: string, path: string, init?: RequestInit diff --git a/dashboards/open-brain-dashboard-next/lib/auth.ts b/dashboards/open-brain-dashboard-next/lib/auth.ts index 0428c2208..910cca526 100644 --- a/dashboards/open-brain-dashboard-next/lib/auth.ts +++ b/dashboards/open-brain-dashboard-next/lib/auth.ts @@ -1,12 +1,9 @@ -import { getIronSession, type SessionOptions } from "iron-session"; +import { getIronSession } from "iron-session"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; +import { sessionOptions, type SessionData } from "./session"; -export interface SessionData { - apiKey?: string; - loggedIn?: boolean; - restrictedUnlocked?: boolean; -} +export { sessionOptions, type SessionData }; export class AuthError extends Error { constructor(message = "Unauthorized") { @@ -15,34 +12,16 @@ export class AuthError extends Error { } } -function shouldUseSecureCookie() { - if (process.env.AUTH_COOKIE_SECURE) { - return process.env.AUTH_COOKIE_SECURE === "true"; +export function getBrainKey(): string { + const key = process.env.OPEN_BRAIN_KEY || process.env.MCP_ACCESS_KEY; + if (!key) { + throw new Error( + "Server brain key is not configured (set OPEN_BRAIN_KEY or MCP_ACCESS_KEY)" + ); } - const appUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || ""; - return appUrl.startsWith("https://") || process.env.VERCEL === "1"; -} - -// Fail fast if SESSION_SECRET is missing or too short -const SESSION_SECRET = process.env.SESSION_SECRET; -if (!SESSION_SECRET || SESSION_SECRET.length < 32) { - throw new Error( - "SESSION_SECRET env var is required and must be at least 32 characters" - ); + return key; } -export const sessionOptions: SessionOptions = { - cookieName: "open_brain_session", - password: SESSION_SECRET, - ttl: 60 * 60 * 24, // 24 hours - cookieOptions: { - httpOnly: true, - secure: shouldUseSecureCookie(), - sameSite: "lax" as const, - path: "/", - }, -}; - function demoAuthBypass() { if (process.env.OB1_DEMO_AUTH_BYPASS !== "true") return null; return { @@ -64,10 +43,10 @@ export async function requireSession(): Promise<{ apiKey: string }> { if (demoSession) return demoSession; const session = await getSession(); - if (!session.loggedIn || !session.apiKey) { + if (!session.loggedIn) { throw new AuthError(); } - return { apiKey: session.apiKey }; + return { apiKey: getBrainKey() }; } /** @@ -80,8 +59,8 @@ export async function requireSessionOrRedirect(): Promise<{ if (demoSession) return demoSession; const session = await getSession(); - if (!session.loggedIn || !session.apiKey) { + if (!session.loggedIn) { redirect("/login"); } - return { apiKey: session.apiKey }; + return { apiKey: getBrainKey() }; } diff --git a/dashboards/open-brain-dashboard-next/lib/governance.ts b/dashboards/open-brain-dashboard-next/lib/governance.ts new file mode 100644 index 000000000..9ba774c08 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/lib/governance.ts @@ -0,0 +1,26 @@ +export const GOVERNANCE_READ_ONLY_ENV = "OB1_GOVERNANCE_READ_ONLY"; + +export const GOVERNANCE_READ_ONLY_NOTICE = + "Read-only governance pilot is active. Write actions are blocked."; + +export const GOVERNANCE_READ_ONLY_ERROR = + "Read-only governance pilot mode is enabled. Write actions are blocked."; + +export const GOVERNANCE_READ_ONLY_CODE = "OB1_GOVERNANCE_READ_ONLY"; + +export function isGovernanceReadOnly(): boolean { + return process.env.OB1_GOVERNANCE_READ_ONLY === "true"; +} + +export function governanceReadOnlyPayload(action?: string) { + return { + error: GOVERNANCE_READ_ONLY_ERROR, + code: GOVERNANCE_READ_ONLY_CODE, + action: action ?? null, + }; +} + +export function readGovernanceReadOnlyFromDom(): boolean { + if (typeof document === "undefined") return false; + return document.body?.dataset.ob1GovernanceReadOnly === "true"; +} diff --git a/dashboards/open-brain-dashboard-next/lib/mission-control.test.mjs b/dashboards/open-brain-dashboard-next/lib/mission-control.test.mjs new file mode 100644 index 000000000..61bdda23b --- /dev/null +++ b/dashboards/open-brain-dashboard-next/lib/mission-control.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const root = process.cwd(); + +function source(relativePath) { + return readFileSync(path.join(root, relativePath), "utf8"); +} + +test("Mission Control page validates session before preview or live reads", () => { + const page = source("app/mission-control/page.tsx"); + const auth = page.indexOf("await requireSessionOrRedirect()"); + const preview = page.indexOf('params.preview === "1"'); + const liveRead = page.indexOf("await getCockpitLive(apiKey)"); + assert.ok(auth >= 0, "page must validate a real session"); + assert.ok(auth < preview, "preview must remain authenticated"); + assert.ok(auth < liveRead, "live data must remain authenticated"); +}); + +test("request proxy validates sealed sessions instead of trusting cookie presence", () => { + const proxy = source("proxy.ts"); + assert.match(proxy, /getIronSession/); + assert.match(proxy, /if \(!session\.loggedIn\)/); + assert.match(proxy, /catch \{\s*return NextResponse\.redirect/s); + assert.doesNotMatch(proxy, /cookies\.get\("open_brain_session"\)/); +}); + +test("Ask rejects unauthenticated requests before parsing or searching", () => { + const route = source("app/api/mission-control/ask/route.ts"); + const auth = route.indexOf("await requireSession()"); + const parse = route.indexOf("await req.json()"); + const search = route.indexOf("await searchThoughts"); + assert.ok(auth >= 0, "Ask must require a session"); + assert.ok(auth < parse, "Ask must authenticate before parsing the body"); + assert.ok(auth < search, "Ask must authenticate before Company Memory search"); + assert.match(route, /error instanceof AuthError/); + assert.match(route, /status: 401/); +}); + +test("external answer synthesis is explicit opt-in and defaults off", () => { + const route = source("app/api/mission-control/ask/route.ts"); + assert.match( + route, + /process\.env\.MISSION_CONTROL_AI_ANSWERS_ENABLED !== "true"/, + ); + assert.ok( + route.indexOf("MISSION_CONTROL_AI_ANSWERS_ENABLED") < + route.indexOf("OPENROUTER_API_KEY"), + "privacy gate must be checked before the OpenRouter key", + ); +}); + +test("Mission Control renders honest preview, unreachable, and empty states", () => { + const page = source("app/mission-control/page.tsx"); + assert.match(page, /params\.preview === "1"/); + assert.match(page, /headline="Live status unavailable"/); + assert.match(page, /headline="No status records yet"/); + assert.match(page, /if \(!live\)/); + assert.doesNotMatch( + page.slice(page.indexOf("catch {"), page.indexOf("if (!live)")), + //, + "failed live reads must not fall back to sample data", + ); +}); + +test("queue and health claims are based on fresh snapshots and degrade honestly", () => { + const library = source("lib/mission-control.ts"); + assert.match(library, /const snapshotFresh = snapshot !== null && snapshotAgeMs <= SNAPSHOT_STALE_MS/); + assert.match(library, /const approvals = snapshotFresh/); + assert.match(library, /const openCount = snapshotFresh \? snapshot!\.open_count : null/); + assert.match(library, /Decision queue snapshot unavailable/); + assert.match(library, /Snapshot is stale/); + assert.match(library, /let aiOk = true;[\s\S]*catch \{\s*aiOk = false;/); + assert.match(library, /pct: aiOk \? 100 : 55/); +}); + +test("sidebar preserves upstream provenance and extensions while adding Mission Control", () => { + const sidebar = source("components/Sidebar.tsx"); + assert.match(sidebar, /href: "\/mission-control"/); + assert.match(sidebar, /\.\.\.EXTENSIONS\.map/); + assert.match(sidebar, /Nate B\. Jones/); + assert.match(sidebar, /NBJ \/ OB1/); +}); diff --git a/dashboards/open-brain-dashboard-next/lib/mission-control.ts b/dashboards/open-brain-dashboard-next/lib/mission-control.ts new file mode 100644 index 000000000..34a8f9b0c --- /dev/null +++ b/dashboards/open-brain-dashboard-next/lib/mission-control.ts @@ -0,0 +1,319 @@ +import "server-only"; +import { fetchThoughts, searchThoughts } from "./api"; +import type { Tone } from "@/components/mission-control/sample-data"; + +/* + * Mission Control — live data layer (Phase B, reworked 2026-07-03 for the + * surface convergence fixes F3+F4). + * Reads from Company Memory (read-only, server-side key). No writes. If this + * throws, the page renders an explicit "live status unavailable" surface; if + * it returns null (no STATUS RECORDs), the page renders an honest empty + * state. Sample data is never a fallback; preview is ?preview=1 only. + * + * Two sources, honestly separated: + * - QUEUE SNAPSHOT records (written by the Mac's hourly queue sync through + * the governed capture path) carry the REAL decision queue and the REAL + * healthcheck state. The Approval Inbox and the health dials read these. + * - STATUS RECORD prose still feeds the hero + "Current runs" narrative, + * which is descriptive, not a control surface. + */ + +export interface CockpitLive { + live: true; + hero: { name: string; greeting: string; status: { label: string; tone: Tone }; workingOn: string; updated: string }; + kpis: { eyebrow: string; value: string; caption: string; accent: string; tone: Tone }[]; + approvals: { title: string; why: string; plain: string; tone: Tone }[]; + runs: { title: string; state: "building" | "done" | "healthy"; plain: string }[]; + gates: { label: string; state: string; plain: string; tone: Tone }[]; + health: { label: string; pct: number; caption: string; accent: string }[]; +} + +interface ParsedRecord { + project: string; + done: string; + next: string; + blockedBy: string; + createdAt: string; +} + +function field(content: string, label: string): string { + const m = content.match(new RegExp(`^\\s*${label}:\\s*(.+)$`, "mi")); + return m ? m[1].trim() : ""; +} + +function clip(s: string, n: number): string { + s = s.replace(/\s+/g, " ").trim(); + return s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s; +} + +function relTime(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return "—"; + const mins = Math.max(0, Math.round((Date.now() - then) / 60000)); + if (mins < 60) return `${mins}m`; + const hrs = Math.round(mins / 60); + if (hrs < 24) return `${hrs}h`; + return `${Math.round(hrs / 24)}d`; +} + +function shortProject(p: string): string { + // Trim the trailing "/ subtitle" noise and over-long phase names. + return clip(p.split(/ -- | – | — /)[0].split(" / ")[0], 52); +} + +/* ---- Queue snapshot (the real decision queue + Mac health) ---- */ + +interface QueueSnapshot { + generated_at: string; + open_count: number; + open_cards: { id: string; title: string; ask: string; opened: string }[]; + health: { + line: string; + healthy: boolean; + checked_at: string; + pending_capture_flags: number; + escalation_inbox: number; + }; +} + +// The Mac healthcheck runs twice daily and its log line timestamps change, so +// a fresh system produces a new snapshot at least every ~12h. Past 26h the +// snapshot pipeline itself is in trouble and the dials must say so. +const SNAPSHOT_STALE_MS = 26 * 3600 * 1000; + +function parseQueueSnapshot(content: string): QueueSnapshot | null { + try { + const jsonLine = content + .split("\n") + .find((l) => l.trim().startsWith("{")); + if (!jsonLine) return null; + const parsed = JSON.parse(jsonLine) as QueueSnapshot; + if (!parsed || typeof parsed.open_count !== "number" || !Array.isArray(parsed.open_cards)) { + return null; + } + return parsed; + } catch { + return null; + } +} + +export async function getCockpitLive(apiKey: string): Promise { + const res = await fetchThoughts(apiKey, { + per_page: 100, + sort: "created_at", + order: "desc", + }); + const rows = (res?.data ?? []) as Array<{ content?: string; created_at?: string; metadata?: { category?: string } }>; + + // Newest queue snapshot in the window (they are captured on change, at + // least twice a day, so 100 newest records is a comfortable window). + const snapshotRow = rows.find((r) => (r.content || "").trim().startsWith("QUEUE SNAPSHOT")); + const snapshot = snapshotRow ? parseQueueSnapshot(snapshotRow.content || "") : null; + const snapshotAgeMs = snapshot ? Date.now() - new Date(snapshot.generated_at).getTime() : Infinity; + const snapshotFresh = snapshot !== null && snapshotAgeMs <= SNAPSHOT_STALE_MS; + + const records: ParsedRecord[] = rows + .filter((r) => (r.content || "").trim().startsWith("STATUS RECORD")) + .map((r) => ({ + project: field(r.content || "", "PROJECT"), + done: field(r.content || "", "DONE"), + next: field(r.content || "", "NEXT"), + blockedBy: field(r.content || "", "BLOCKED BY"), + createdAt: r.created_at || "", + })) + .filter((r) => r.project); + + if (records.length === 0) return null; + + const newest = records[0]; + const now = Date.now(); + const within = (r: ParsedRecord, ms: number) => now - new Date(r.createdAt).getTime() <= ms; + + // Approvals: the REAL decision queue from the newest QUEUE SNAPSHOT (F3). + // No more regex inference over BLOCKED BY prose — if the snapshot is + // missing or stale, say so instead of guessing. + const approvals = snapshotFresh + ? snapshot!.open_cards.slice(0, 5).map((c) => ({ + title: clip(c.title, 80), + why: "In your decision queue", + plain: clip(c.ask, 200), + tone: "attention" as Tone, + })) + : [ + { + title: "Decision queue snapshot unavailable", + why: snapshot ? "Snapshot is stale" : "No snapshot yet", + plain: snapshot + ? `The Mac last published its queue ${relTime(snapshot.generated_at)} ago — the hourly sync may be down. The Telegram digest and the Mac remain the source of truth.` + : "The Mac has not published a queue snapshot to Company Memory yet. The Telegram digest and the Mac remain the source of truth.", + tone: "info" as Tone, + }, + ]; + + // Runs: most recent distinct projects. + const seenR = new Set(); + const runs = records + .filter((r) => { + const k = shortProject(r.project); + if (seenR.has(k)) return false; + seenR.add(k); + return true; + }) + .slice(0, 4) + .map((r) => { + const done = `${r.done} ${r.next}`; + const state: "building" | "done" | "healthy" = /shipp|deploy|live|complete|accepted|merged|verified|locked/i.test(r.done) + ? "done" + : /^nothing|^none/i.test(r.blockedBy.trim()) + ? "healthy" + : "building"; + return { title: shortProject(r.project), state, plain: clip(r.done || done, 130) }; + }); + + const activeProjects = new Set( + records.filter((r) => within(r, 36 * 3600 * 1000)).map((r) => shortProject(r.project)) + ).size; + + const openCount = snapshotFresh ? snapshot!.open_count : null; + + const kpis = [ + { + eyebrow: "Needs your approval", + value: openCount === null ? "—" : String(openCount), + caption: + openCount === null + ? "Queue snapshot unavailable — check the Telegram digest." + : "Open cards in your decision queue, straight from the Mac.", + accent: "#ffb454", + tone: "attention" as Tone, + }, + { + eyebrow: "Active projects", + value: String(activeProjects || runs.length), + caption: "Distinct projects with activity in the last day or so.", + accent: "#3ddc97", + tone: "good" as Tone, + }, + { + eyebrow: "Last verified", + value: relTime(newest.createdAt), + caption: "When Company Memory last recorded a verified status.", + accent: "#a78bfa", + tone: "info" as Tone, + }, + ]; + + // ---- System health (F4: real gauges, no hardcoded 100%) ---- + const total = res?.total ?? records.length; + + // Cheap probe of whether meaning-based (semantic) search is up. + let aiOk = true; + try { + await searchThoughts(apiKey, "status", "semantic", 1); + } catch { + aiOk = false; + } + + // Company Memory: this very page just read it, so "reachable" is a real + // observation, not a hardcoded claim (if it were down we'd be in fallback). + const memoryDial = { + label: "Company Memory", + pct: 100, + caption: `Reachable · ${total.toLocaleString()} records`, + accent: "#3ddc97", + }; + + const searchDial = { + label: "Smart search", + pct: aiOk ? 100 : 55, + caption: aiOk ? "Meaning-based search on" : "Keyword only — AI credits low", + accent: aiOk ? "#7c8cff" : "#ffb454", + }; + + // Mac healthcheck, from the queue snapshot: the real twice-daily check of + // IP drift, VPS reach, Telegram gateway, pipeline, and backups. + const macDial = !snapshot + ? { label: "Mac healthcheck", pct: 20, caption: "No snapshot from the Mac yet", accent: "#ffb454" } + : !snapshotFresh + ? { + label: "Mac healthcheck", + pct: 45, + caption: `Stale — last heard ${relTime(snapshot.generated_at)} ago`, + accent: "#ffb454", + } + : snapshot.health.healthy + ? { + label: "Mac healthcheck", + pct: 100, + // The healthcheck line's own timestamp is Mac-local without a + // timezone, so date the claim by the snapshot (UTC) instead. + caption: `HEALTHY · as of snapshot ${relTime(snapshot.generated_at)} ago`, + accent: "#3ddc97", + } + : { + label: "Mac healthcheck", + pct: 35, + caption: clip(snapshot.health.line.replace(/^\[[^\]]*\]\s*/, ""), 70), + accent: "#ff7a7a", + }; + + // Queue sync heartbeat: how recently the Mac published its queue state. + const syncDial = !snapshot + ? { label: "Queue sync", pct: 20, caption: "Waiting for the first snapshot", accent: "#ffb454" } + : { + label: "Queue sync", + pct: snapshotFresh ? 100 : 45, + caption: `Snapshot ${relTime(snapshot.generated_at)} old · ${snapshot.open_count} open card${snapshot.open_count === 1 ? "" : "s"}`, + accent: snapshotFresh ? "#a78bfa" : "#ffb454", + }; + + const health = [memoryDial, searchDial, macDial, syncDial]; + + // ---- Gate ledger: standing safety policy + live Hermes hold ---- + const hermesHeld = records.some( + (r) => + /hermes/i.test(`${r.project} ${r.blockedBy} ${r.next}`) && + /hold|held|pending|approval/i.test(`${r.blockedBy} ${r.next}`) + ); + const gates = [ + { + label: "Production deploys", + state: "Gated", + plain: "Stone always asks you before anything goes live.", + tone: "attention" as Tone, + }, + { + label: "Secrets & credentials", + state: "Gated", + plain: "No key or access changes without your explicit OK.", + tone: "attention" as Tone, + }, + { + label: "Hermes provider retry", + state: hermesHeld ? "Held" : "Ready", + plain: hermesHeld ? "Paused until you approve a provider/key retry." : "No active hold.", + tone: (hermesHeld ? "blocked" : "good") as Tone, + }, + ]; + + return { + live: true, + hero: { + name: "James", + greeting: "Here's where Stone stands.", + status: + openCount !== null && openCount > 0 + ? { label: "Needs you", tone: "attention" as Tone } + : openCount === 0 + ? { label: "On track", tone: "good" as Tone } + : { label: "Queue unknown", tone: "info" as Tone }, + workingOn: shortProject(newest.project), + updated: `${relTime(newest.createdAt)} ago`, + }, + kpis, + approvals, + runs, + gates, + health, + }; +} diff --git a/dashboards/open-brain-dashboard-next/lib/session.ts b/dashboards/open-brain-dashboard-next/lib/session.ts new file mode 100644 index 000000000..f12bb15ca --- /dev/null +++ b/dashboards/open-brain-dashboard-next/lib/session.ts @@ -0,0 +1,38 @@ +import type { SessionOptions } from "iron-session"; + +/* + * Session config shared by lib/auth.ts and proxy.ts. This file intentionally + * avoids next/headers and next/navigation so the request proxy can bundle it. + */ + +export interface SessionData { + loggedIn?: boolean; + restrictedUnlocked?: boolean; +} + +function shouldUseSecureCookie() { + if (process.env.AUTH_COOKIE_SECURE) { + return process.env.AUTH_COOKIE_SECURE === "true"; + } + const appUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || ""; + return appUrl.startsWith("https://") || process.env.VERCEL === "1"; +} + +const SESSION_SECRET = process.env.SESSION_SECRET; +if (!SESSION_SECRET || SESSION_SECRET.length < 32) { + throw new Error( + "SESSION_SECRET env var is required and must be at least 32 characters" + ); +} + +export const sessionOptions: SessionOptions = { + cookieName: "open_brain_session", + password: SESSION_SECRET, + ttl: 60 * 60 * 24, + cookieOptions: { + httpOnly: true, + secure: shouldUseSecureCookie(), + sameSite: "lax" as const, + path: "/", + }, +}; diff --git a/dashboards/open-brain-dashboard-next/middleware.ts b/dashboards/open-brain-dashboard-next/middleware.ts deleted file mode 100644 index f4f996366..000000000 --- a/dashboards/open-brain-dashboard-next/middleware.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; - -export function middleware(request: NextRequest) { - const { pathname } = request.nextUrl; - - if (process.env.OB1_DEMO_AUTH_BYPASS === "true") { - return NextResponse.next(); - } - - // Allow login page, API routes, and static assets - if ( - pathname === "/login" || - pathname.startsWith("/api") || - pathname.startsWith("/_next") || - pathname.startsWith("/brand") || - pathname.startsWith("/favicon") - ) { - return NextResponse.next(); - } - - // Check for session cookie existence (iron-session encrypts it) - const sessionCookie = request.cookies.get("open_brain_session"); - if (!sessionCookie?.value) { - return NextResponse.redirect(new URL("/login", request.url)); - } - - return NextResponse.next(); -} - -export const config = { - matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], -}; diff --git a/dashboards/open-brain-dashboard-next/package.json b/dashboards/open-brain-dashboard-next/package.json index b9723dbfa..5a4379ac8 100644 --- a/dashboards/open-brain-dashboard-next/package.json +++ b/dashboards/open-brain-dashboard-next/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test:agent-memory": "node --test lib/agent-memory.test.mjs", + "test:mission-control": "node --test lib/mission-control.test.mjs" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/dashboards/open-brain-dashboard-next/proxy.ts b/dashboards/open-brain-dashboard-next/proxy.ts new file mode 100644 index 000000000..356434934 --- /dev/null +++ b/dashboards/open-brain-dashboard-next/proxy.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getIronSession } from "iron-session"; +import { sessionOptions, type SessionData } from "@/lib/session"; + +export async function proxy(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (process.env.OB1_DEMO_AUTH_BYPASS === "true") { + return NextResponse.next(); + } + + // API routes authenticate themselves before reading request bodies. + if ( + pathname === "/login" || + pathname.startsWith("/api") || + pathname.startsWith("/_next") || + pathname.startsWith("/brand") || + pathname.startsWith("/favicon") + ) { + return NextResponse.next(); + } + + // Unseal the session so a forged or garbage cookie cannot pass the gate. + const response = NextResponse.next(); + try { + const session = await getIronSession( + request, + response, + sessionOptions + ); + if (!session.loggedIn) { + return NextResponse.redirect(new URL("/login", request.url)); + } + } catch { + return NextResponse.redirect(new URL("/login", request.url)); + } + + return response; +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +}; diff --git a/docs/humestone-overlay-reconstruction-2026-07-22.md b/docs/humestone-overlay-reconstruction-2026-07-22.md new file mode 100644 index 000000000..a52744d99 --- /dev/null +++ b/docs/humestone-overlay-reconstruction-2026-07-22.md @@ -0,0 +1,50 @@ +# HumeStone Overlay Reconstruction Provenance + +Date: 2026-07-22 + +## Boundary + +- Clean base: upstream `origin/main` at `677910600de98067f61c120d65956b23f360aedb`. +- Local branch: `codex/ob1-humestone-overlay-reconstruction`. +- Scope: reconstruct the required HumeStone Agent Memory hardening and Mission Control overlay without changing production or replaying the stale deployment branch wholesale. +- Compatibility boundary: current upstream pin, the documented Agent Memory API, and the locally verified Mission Control behavior. There are no Git tags or published releases to use as a stronger boundary. +- Deployment package: `supabase/functions/agent-memory-api` is intentionally absent. The integration source remains authoritative; deployment stays blocked until a package is deliberately materialized, sync-checked, approved, and deployed. + +## File provenance + +| Files | Source | Treatment and conflict resolution | Verification | +| --- | --- | --- | --- | +| `integrations/agent-memory-api/auth.ts`, `auth.test.ts`, `policy.ts`, `policy.test.ts`, `read-only.ts`, `read-only.test.ts`, `endpoint-scope.test.ts`, `production-boundary.test.ts`, `smoke/read-only-smoke.mjs`, `smoke/read-only-smoke.test.mjs`; `dashboards/open-brain-dashboard-next/lib/governance.ts`; dashboard Agent Memory read-only guards and tests in `README.md`, `package.json`, `lib/agent-memory.test.mjs`, `app/agent-memory/page.tsx`, and `app/agent-memory/[id]/page.tsx` | HumeStone `097f60f9c1e2933a25b58b005d0ef837350cb610` and `e34361bc1d1e7eb2125df47440eafdcfbaad0910` | Cherry-picked whole because these commits are isolated Agent Memory hardening. Upstream files outside those commits remain unchanged. | Deno auth, policy, scope, read-only, and production-boundary tests; Node dry-run smoke tests; dashboard tests and build. | +| `integrations/agent-memory-api/index.ts`, `dual-key-auth.test.ts`, `smoke/live-smoke.mjs` | HumeStone `3b2705957b52f7b9049aad5cb993f1caeea0ee09` | Cherry-picked whole. Dedicated `AGENT_MEMORY_ACCESS_KEY` is preferred; `MCP_ACCESS_KEY` remains an intentional temporary fallback. Live smoke is preserved but not executed because it can write. | Dual-key Deno tests and production-boundary tests. | +| `dashboards/open-brain-dashboard-next/app/agent-memory/[id]/page.tsx`, `app/agent-memory/page.tsx`, `app/agent-memory/traces/page.tsx`, `lib/agent-memory.ts`, `lib/agent-memory.test.mjs`; `integrations/agent-memory-api/README.md`, `check-supabase-package-sync.mjs`, `check-supabase-package-sync.test.mjs` | Selective Agent Memory hunks from HumeStone `807c7fc126129d3eee9ef65a24f7f9df3c1d2c05` plus local reconciliation | Manually reconciled: preserve workspace/project scope on ID and trace reads, prefer the dedicated dashboard key, and add the deployment-package drift check. HumeStone rebranding, unrelated date formatting, and non-Agent-Memory changes from the composite commit were omitted. | Dashboard Agent Memory tests and Node package-sync tests. | +| `dashboards/open-brain-dashboard-next/app/mission-control/cockpit.css`, `page.tsx`, `tokens.css`; `components/mission-control/Cockpit.tsx`, `CockpitStatus.tsx`, `sample-data.ts`, `shell/Spine.tsx`; `lib/mission-control.ts` | Final Mission Control product state from HumeStone `a8b39b8` (lineage `2339565`, `4428f67`, `541114f`, `60a41ee`, `3332143`, `ca493d8`, `1a62c0e`, `0729ceb`, `a8b39b8`) | Copied as isolated Mission Control files. The stale branch's global reskin, governance workbench, package downgrade, build config, and unrelated product changes were omitted. Preview remains explicit-only; empty, stale, and unreachable states remain truthful. | Mission Control source assertions, TypeScript, lint, build, and fixture-only browser UAT. | +| `dashboards/open-brain-dashboard-next/lib/session.ts`, `lib/auth.ts`, `proxy.ts`, deleted `middleware.ts`, `app/login/page.tsx`, `app/login/LoginForm.tsx`, `.env.example` | HumeStone session-hardening design from `a8b39b8`, reconciled onto upstream branding and routes | Manually reconciled. Server-side Company Memory key and password login replace browser/session key storage. The proxy unseals sessions instead of trusting cookie existence. Nate B. Jones / OB1 branding is preserved. All upstream API routes remain self-authenticating. | Mission Control auth tests, lint, TypeScript, and build. | +| `dashboards/open-brain-dashboard-next/app/api/mission-control/ask/route.ts` | HumeStone `a8b39b8`, security-corrected locally | Copied then corrected: `requireSession()` runs before body parsing or search, unauthorized calls return 401, and external synthesis is default-off behind `MISSION_CONTROL_AI_ANSWERS_ENABLED=true`. This intentionally does not preserve the deployed branch's unauthenticated server-key search or automatic third-party snippet transfer. | Focused negative source test plus build. No production or OpenRouter call. | +| `dashboards/open-brain-dashboard-next/components/Sidebar.tsx` | Upstream pin plus one Mission Control entry | Manually reconciled. Adds only the Mission Control core link/icon and preserves upstream extensions and Nate B. Jones / OB1 provenance. | Focused sidebar assertion, lint, and build. | +| `dashboards/open-brain-dashboard-next/lib/mission-control.test.mjs`, `package.json` | New reconstruction evidence | Adds focused local tests for authenticated page access, forged-cookie resistance, Ask auth ordering, privacy opt-in, truthful state rendering, snapshot freshness, semantic-search degradation, and preserved sidebar provenance. | `node --test lib/*.test.mjs`. | +| `docs/humestone-overlay-reconstruction-2026-07-22.md` | This reconstruction | Records the clean base, source lineage, conflict choices, omissions, test evidence, and remaining gates. | `git diff --check` and final repository inventory. | + +## Intentionally omitted + +- No wholesale cherry-pick of composite commits `807c7fc` or `60a41ee`. +- No replay of the dirty deployed Mission Control branch or policy-only commit `dae05d2`. +- No Next.js downgrade, package-lock replacement, removal of OpenNext/Cloudflare support, global rebrand/reskin, or replacement of upstream extension navigation. +- No materialized Supabase function package, schema/data mutation, live smoke, native OpenClaw smoke, real endpoint call, Vercel change, deployment, push, pull request, merge, branch deletion, or worktree cleanup. +- No claim of byte-for-byte parity with the live Vercel deployment because its recorded source was dirty. The target is documented functional parity with the security and privacy defects corrected. + +## Remaining gates + +1. All local tests, lint, typecheck, build, plugin schema/build, and fixture-only browser checks must pass. +2. A separate review decision is required before push or pull request creation. +3. Production configuration, secrets, materialization, deployment, or data/schema work require explicit production approval. + +## Local verification evidence + +- Dashboard boundary tests: 11 passed, including Agent Memory scope/key behavior and Mission Control auth, privacy, truthfulness, and provenance assertions. +- Agent Memory API: `deno fmt --check` passed; 44 Deno tests passed. +- Agent Memory dry-run harness: 10 Node tests passed; 15 planned checks; output explicitly confirmed `No network calls were made.` +- Dashboard: ESLint passed, TypeScript no-emit passed, and the Next.js 16.2.4 production build passed with all expected routes, including `/mission-control` and `/api/mission-control/ask`. +- Browser fixtures at `127.0.0.1` only: preview loaded with an explicit sample-data banner; connected-empty rendered `No status records yet`; an unavailable loopback API rendered `Live status unavailable`; all three had content and no Next.js error overlay. +- Runtime auth negatives: malformed unauthenticated Ask request returned `401 Unauthorized` before JSON parsing; a garbage `open_brain_session` cookie returned `307` to `/login`; unauthenticated browser navigation landed on the upstream-branded password screen. +- OpenClaw plugin 0.1.6: schema check passed and bundling completed. Rebuilding the untouched upstream pin does not reproduce its committed `dist/index.js`; the generated file was restored to the exact upstream version. This is inherited upstream build-artifact debt and was not folded into the HumeStone overlay. +- Package sync checker correctly failed closed because `supabase/functions/agent-memory-api` is absent, naming all five missing runtime files. This is the intended deployment blocker, not a test failure to bypass. diff --git a/integrations/agent-memory-api/README.md b/integrations/agent-memory-api/README.md index 333374eaf..2de10d84c 100644 --- a/integrations/agent-memory-api/README.md +++ b/integrations/agent-memory-api/README.md @@ -27,7 +27,12 @@ This Edge Function exposes the v1 OB1 Agent Memory contract. OpenClaw is the fir - Working Open Brain setup ([guide](../../docs/01-getting-started.md)) - [`schemas/agent-memory`](../../schemas/agent-memory/) applied - Supabase CLI installed -- `OPENROUTER_API_KEY` and `MCP_ACCESS_KEY` configured as Supabase secrets +- `OPENROUTER_API_KEY` configured as a Supabase secret +- `AGENT_MEMORY_ACCESS_KEY` configured as the preferred dedicated API secret; + `MCP_ACCESS_KEY` remains an intentional temporary fallback during migration +- For shared or production read-only deployments, `AGENT_MEMORY_READ_ONLY=true`, + `AGENT_MEMORY_ALLOWED_WORKSPACE_ID`, and `AGENT_MEMORY_ALLOWED_PROJECT_ID` + configured before endpoint verification. ## Credential Tracker @@ -63,15 +68,34 @@ Copy this folder into your Supabase project: supabase functions new agent-memory-api cp integrations/agent-memory-api/index.ts supabase/functions/agent-memory-api/index.ts cp integrations/agent-memory-api/deno.json supabase/functions/agent-memory-api/deno.json +cp integrations/agent-memory-api/auth.ts supabase/functions/agent-memory-api/auth.ts +cp integrations/agent-memory-api/policy.ts supabase/functions/agent-memory-api/policy.ts +cp integrations/agent-memory-api/read-only.ts supabase/functions/agent-memory-api/read-only.ts supabase functions deploy agent-memory-api --no-verify-jwt ``` +The integration source folder is authoritative. Do not deploy a stale +materialized `supabase/functions/agent-memory-api` copy unless it has been +resynchronized with `index.ts`, `auth.ts`, `policy.ts`, `read-only.ts`, and +`deno.json`. + +Before deploying from a materialized package, run the package sync check: + +```bash +node integrations/agent-memory-api/check-supabase-package-sync.mjs +``` + +The check intentionally compares only runtime files. Tests, docs, smoke +harnesses, metadata, and `deno.lock` stay in this integration folder unless a +separate deployment-snapshot policy says otherwise. + **Done when:** `supabase functions list` shows `agent-memory-api` as active. ![Step 3](https://img.shields.io/badge/Step_3-Test_Health-1E88E5?style=for-the-badge) ```bash -curl "https://YOUR_PROJECT_REF.supabase.co/functions/v1/agent-memory-api/health?key=YOUR_MCP_ACCESS_KEY" +curl "https://YOUR_PROJECT_REF.supabase.co/functions/v1/agent-memory-api/health" \ + -H "x-brain-key: YOUR_MCP_ACCESS_KEY" ``` **Done when:** the response includes `"ok": true`. @@ -99,6 +123,24 @@ The API accepts the runtime-neutral core schema versions and the OpenClaw launch | `/memories/:id/review` | PATCH | Confirm, edit, reject, restrict, stale, dispute, or supersede | | `/recall-traces/:request_id` | GET | Debug what was recalled and how it was used | +## Auth And Scope Boundary + +Requests authenticate with either `x-brain-key: ...` or +`Authorization: Bearer ...`. Query-string key auth is disabled by default +because URLs can be logged by terminals, proxies, browsers, and screenshots. +Only enable `AGENT_MEMORY_ALLOW_QUERY_KEY=true` for local throwaway testing. + +The API prefers `AGENT_MEMORY_ACCESS_KEY` so Agent Memory can rotate separately +from the broader Company Memory surface. `MCP_ACCESS_KEY` remains accepted as a +temporary compatibility fallback until all governed clients have migrated. + +When `AGENT_MEMORY_ALLOWED_WORKSPACE_ID` or +`AGENT_MEMORY_ALLOWED_PROJECT_ID` is set, the API returns `403 +scope_not_allowed` for out-of-scope requests. This app-level check matters +because the Edge Function uses a service-role database client. ID-based reads +also check the returned row's workspace/project, so a known memory ID or recall +trace ID cannot bypass the approved scope. + ## Expected Outcome An agent runtime can recall relevant context, write back compact memories, and leave a trace that explains what happened. Unsafe write-backs are blocked before durable storage. @@ -117,8 +159,31 @@ OB1_AGENT_MEMORY_PROJECT_ID="agent-memory-api-smoke" \ node integrations/agent-memory-api/smoke/live-smoke.mjs ``` +The stock live smoke harness is intentionally write-heavy. Do not use it for read-only staging lanes. If `OB1_AGENT_MEMORY_READ_ONLY=true` or `AGENT_MEMORY_READ_ONLY=true` is present, the harness exits before any endpoint call. + The harness checks health, write-back policy defaults, conservative recall gating, include-unconfirmed recall, usage reporting, review action, memory inspection, recall trace, and unsafe write-back blocking. It prints a JSON summary and never prints the access key. +For read-only staging lanes, use the dedicated read-only smoke harness. It defaults to dry-run mode and does not call endpoints unless `--execute` is set. + +```bash +node integrations/agent-memory-api/smoke/read-only-smoke.mjs +``` + +Live execute mode is approval-gated and requires explicit read-only posture: + +```bash +AGENT_MEMORY_READ_ONLY=true \ +AGENT_MEMORY_ALLOWED_WORKSPACE_ID="humestone-agent-memory-staging" \ +AGENT_MEMORY_ALLOWED_PROJECT_ID="phase-8c-readonly-smoke" \ +OB1_AGENT_MEMORY_ENDPOINT="https://YOUR_PROJECT_REF.supabase.co/functions/v1/agent-memory-api" \ +OB1_AGENT_MEMORY_KEY="YOUR_MCP_ACCESS_KEY" \ +OB1_AGENT_MEMORY_WORKSPACE_ID="humestone-agent-memory-staging" \ +OB1_AGENT_MEMORY_PROJECT_ID="phase-8c-readonly-smoke" \ +node integrations/agent-memory-api/smoke/read-only-smoke.mjs --execute +``` + +The read-only harness checks header and Bearer auth, read-only empty-state endpoints, out-of-scope scope rejection, and blocked write endpoints (`/recall`, `/writeback`, `/recall/:request_id/usage`, `/memories/:id/review`). It uses empty payload probes for write endpoints so the harness still avoids write-capable payloads if the API is misconfigured. + For personal databases, use the cleanup harness to find or reject smoke/test memories without deleting rows: ```bash @@ -131,10 +196,20 @@ node integrations/agent-memory-api/smoke/cleanup-test-memory.mjs The default mode is dry-run. Add `--apply` to mark matching active test memories as `rejected`. The harness refuses project IDs that do not look like smoke/test/sandbox scopes. +## Local Hardening Notes + +- `AGENT_MEMORY_READ_ONLY=true` blocks write-capable routes before payload validation: `POST /recall`, `POST /writeback`, `POST /recall/:request_id/usage`, and `PATCH /memories/:id/review`. +- `AGENT_MEMORY_ALLOWED_WORKSPACE_ID` and `AGENT_MEMORY_ALLOWED_PROJECT_ID` constrain both query-scoped reads and ID-based reads. +- `x-brain-key` and `Authorization: Bearer` are supported. Query-string key auth is opt-in only through `AGENT_MEMORY_ALLOW_QUERY_KEY=true`. +- Recall returns no memories when semantic search returns no candidate thought IDs. It does not fall back to recent workspace memories. +- Visibility rules are explicit: personal memories require personal recall, channel memories require the matching channel, project memories respect `project_only`, workspace memories can appear in project/workspace recall, and organization memories require organization visibility. +- `merge` marks the current memory as merged and relates it to the target with `merged_into`. +- `supersede` treats the current memory as the replacement and marks the related older memory as superseded. + ## Troubleshooting **Issue: `Invalid or missing access key`** -Solution: Confirm the request includes `?key=...` or `x-brain-key`. +Solution: Confirm the request includes `x-brain-key` or `Authorization: Bearer`. Avoid `?key=` URLs except in local throwaway testing with `AGENT_MEMORY_ALLOW_QUERY_KEY=true`. **Issue: recall returns no memories** Solution: Confirm write-back has created `agent_memories`, and that those memories are confirmed or `include_unconfirmed` is true. diff --git a/integrations/agent-memory-api/auth.test.ts b/integrations/agent-memory-api/auth.test.ts new file mode 100644 index 000000000..38521a4dd --- /dev/null +++ b/integrations/agent-memory-api/auth.test.ts @@ -0,0 +1,61 @@ +import { assertEquals } from "jsr:@std/assert@1"; + +import { accessKeyMatches, parseBearerToken, selectAccessKey } from "./auth.ts"; + +function headers(values: Record) { + const normalized = new Map( + Object.entries(values).map(([key, value]) => [key.toLowerCase(), value]), + ); + return { + get(name: string) { + return normalized.get(name.toLowerCase()) ?? null; + }, + }; +} + +Deno.test("parseBearerToken accepts Authorization Bearer keys", () => { + assertEquals(parseBearerToken("Bearer secret-key"), "secret-key"); + assertEquals(parseBearerToken("bearer secret-key"), "secret-key"); +}); + +Deno.test("selectAccessKey prefers x-brain-key over Authorization Bearer", () => { + assertEquals( + selectAccessKey( + headers({ + "x-brain-key": "header-key", + authorization: "Bearer bearer-key", + }), + "https://example.test", + ), + "header-key", + ); +}); + +Deno.test("selectAccessKey accepts Authorization Bearer when x-brain-key is absent", () => { + assertEquals( + selectAccessKey( + headers({ authorization: "Bearer bearer-key" }), + "https://example.test", + ), + "bearer-key", + ); +}); + +Deno.test("selectAccessKey ignores query string keys unless explicitly allowed", () => { + assertEquals( + selectAccessKey(headers({}), "https://example.test?key=query-key"), + undefined, + ); + assertEquals( + selectAccessKey(headers({}), "https://example.test?key=query-key", { + allowQueryKey: true, + }), + "query-key", + ); +}); + +Deno.test("accessKeyMatches requires provided and expected keys to match", () => { + assertEquals(accessKeyMatches("secret-key", "secret-key"), true); + assertEquals(accessKeyMatches("secret-key", "other-key"), false); + assertEquals(accessKeyMatches(undefined, "secret-key"), false); +}); diff --git a/integrations/agent-memory-api/auth.ts b/integrations/agent-memory-api/auth.ts new file mode 100644 index 000000000..57aa17b61 --- /dev/null +++ b/integrations/agent-memory-api/auth.ts @@ -0,0 +1,32 @@ +export function parseBearerToken( + value: string | undefined, +): string | undefined { + if (!value) return undefined; + const match = value.match(/^Bearer\s+(.+)$/i); + return match?.[1]?.trim() || undefined; +} + +export function selectAccessKey( + headers: { get: (name: string) => string | null }, + url: string, + options: { allowQueryKey?: boolean } = {}, +): string | undefined { + const headerKey = headers.get("x-brain-key")?.trim(); + if (headerKey) return headerKey; + + const bearerKey = parseBearerToken(headers.get("authorization") ?? undefined); + if (bearerKey) return bearerKey; + + if (options.allowQueryKey) { + return new URL(url).searchParams.get("key")?.trim() || undefined; + } + + return undefined; +} + +export function accessKeyMatches( + provided: string | undefined, + expected: string | undefined, +): boolean { + return Boolean(provided && expected && provided === expected); +} diff --git a/integrations/agent-memory-api/check-supabase-package-sync.mjs b/integrations/agent-memory-api/check-supabase-package-sync.mjs new file mode 100644 index 000000000..b42c9b28f --- /dev/null +++ b/integrations/agent-memory-api/check-supabase-package-sync.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const RUNTIME_FILES = [ + "deno.json", + "auth.ts", + "index.ts", + "policy.ts", + "read-only.ts", +]; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_SOURCE_DIR = HERE; +const DEFAULT_PACKAGE_DIR = resolve(HERE, "../../supabase/functions/agent-memory-api"); + +async function readMaybe(filePath) { + try { + return await readFile(filePath); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } +} + +export async function collectPackageSyncIssues({ + sourceDir = DEFAULT_SOURCE_DIR, + packageDir = DEFAULT_PACKAGE_DIR, + runtimeFiles = RUNTIME_FILES, +} = {}) { + const issues = []; + + for (const file of runtimeFiles) { + const source = await readMaybe(resolve(sourceDir, file)); + const materialized = await readMaybe(resolve(packageDir, file)); + + if (!source) { + issues.push({ file, reason: "missing_source_file" }); + continue; + } + if (!materialized) { + issues.push({ file, reason: "missing_package_file" }); + continue; + } + if (!source.equals(materialized)) { + issues.push({ file, reason: "content_mismatch" }); + } + } + + return issues; +} + +export function formatPackageSyncReport(issues, { + sourceDir = DEFAULT_SOURCE_DIR, + packageDir = DEFAULT_PACKAGE_DIR, +} = {}) { + const header = [ + "Agent Memory API package sync check", + `Authoritative source: ${sourceDir}`, + `Materialized package: ${packageDir}`, + ]; + + if (issues.length === 0) { + return [ + ...header, + "Result: OK - runtime files match the authoritative source.", + ].join("\n"); + } + + return [ + ...header, + "Result: FAILED - materialized package drift detected.", + ...issues.map((issue) => `- ${issue.file}: ${issue.reason}`), + "", + "Refresh the package from the authoritative source before deploying it.", + ].join("\n"); +} + +function parseArgs(args) { + const options = {}; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "--json") { + options.json = true; + continue; + } + if (arg === "--source-dir" || arg === "--package-dir") { + const value = args[i + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${arg} requires a value`); + } + options[arg.slice(2).replace("-", "_")] = value; + i += 1; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + return options; +} + +export async function runCli(args = process.argv.slice(2), stdout = process.stdout, stderr = process.stderr) { + let options; + try { + options = parseArgs(args); + } catch (error) { + stderr.write(`${error.message}\n`); + return 2; + } + + const sourceDir = options.source_dir + ? resolve(options.source_dir) + : DEFAULT_SOURCE_DIR; + const packageDir = options.package_dir + ? resolve(options.package_dir) + : DEFAULT_PACKAGE_DIR; + const issues = await collectPackageSyncIssues({ sourceDir, packageDir }); + + if (options.json) { + stdout.write(`${JSON.stringify({ sourceDir, packageDir, issues }, null, 2)}\n`); + } else { + stdout.write(`${formatPackageSyncReport(issues, { sourceDir, packageDir })}\n`); + } + + return issues.length === 0 ? 0 : 1; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + runCli().then((code) => { + process.exitCode = code; + }).catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/integrations/agent-memory-api/check-supabase-package-sync.test.mjs b/integrations/agent-memory-api/check-supabase-package-sync.test.mjs new file mode 100644 index 000000000..188382610 --- /dev/null +++ b/integrations/agent-memory-api/check-supabase-package-sync.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + collectPackageSyncIssues, + formatPackageSyncReport, + RUNTIME_FILES, +} from "./check-supabase-package-sync.mjs"; + +async function writeRuntimeFiles(root, values = {}) { + await mkdir(root, { recursive: true }); + for (const file of RUNTIME_FILES) { + await writeFile(join(root, file), values[file] ?? `${file}\n`); + } +} + +test("collectPackageSyncIssues passes when runtime files match", async (t) => { + const tempRoot = await mkdtemp(join(tmpdir(), "agent-memory-package-sync-")); + t.after(() => rm(tempRoot, { recursive: true, force: true })); + const source = join(tempRoot, "source"); + const pkg = join(tempRoot, "package"); + + await writeRuntimeFiles(source); + await writeRuntimeFiles(pkg); + + const issues = await collectPackageSyncIssues({ sourceDir: source, packageDir: pkg }); + + assert.deepEqual(issues, []); +}); + +test("collectPackageSyncIssues reports missing and drifted runtime files", async (t) => { + const tempRoot = await mkdtemp(join(tmpdir(), "agent-memory-package-sync-")); + t.after(() => rm(tempRoot, { recursive: true, force: true })); + const source = join(tempRoot, "source"); + const pkg = join(tempRoot, "package"); + + await writeRuntimeFiles(source); + await writeRuntimeFiles(pkg, { "index.ts": "stale\n" }); + await writeFile(join(pkg, "auth.ts"), "auth.ts\n"); + await rm(join(pkg, "policy.ts")); + + const issues = await collectPackageSyncIssues({ sourceDir: source, packageDir: pkg }); + + assert.deepEqual(issues, [ + { file: "index.ts", reason: "content_mismatch" }, + { file: "policy.ts", reason: "missing_package_file" }, + ]); +}); + +test("formatPackageSyncReport explains source authority", () => { + const report = formatPackageSyncReport([ + { file: "index.ts", reason: "content_mismatch" }, + ]); + + assert.match(report, /authoritative source/); + assert.match(report, /index\.ts/); + assert.match(report, /content_mismatch/); +}); diff --git a/integrations/agent-memory-api/dual-key-auth.test.ts b/integrations/agent-memory-api/dual-key-auth.test.ts new file mode 100644 index 000000000..6ee2982d4 --- /dev/null +++ b/integrations/agent-memory-api/dual-key-auth.test.ts @@ -0,0 +1,56 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { app, configureAgentMemoryAppForTest } from "./index.ts"; + +const NEW_KEY = "test-dedicated-agent-memory-key"; +const OLD_KEY = "test-shared-mcp-key"; + +async function healthStatus(key?: string): Promise { + const headers: Record = {}; + if (key) headers["x-brain-key"] = key; + const res = await app.fetch( + new Request("http://localhost/health", { headers }), + ); + await res.body?.cancel(); + return res.status; +} + +Deno.test("dual-key: new dedicated key accepted when set", async () => { + configureAgentMemoryAppForTest({ + mcpAccessKey: OLD_KEY, + agentMemoryAccessKey: NEW_KEY, + }); + assertEquals(await healthStatus(NEW_KEY), 200); +}); + +Deno.test("dual-key: MCP_ACCESS_KEY fallback still accepted (D1 interim)", async () => { + configureAgentMemoryAppForTest({ + mcpAccessKey: OLD_KEY, + agentMemoryAccessKey: NEW_KEY, + }); + assertEquals(await healthStatus(OLD_KEY), 200); +}); + +Deno.test("dual-key: wrong key rejected 401", async () => { + configureAgentMemoryAppForTest({ + mcpAccessKey: OLD_KEY, + agentMemoryAccessKey: NEW_KEY, + }); + assertEquals(await healthStatus("wrong-key"), 401); +}); + +Deno.test("dual-key: missing key rejected 401", async () => { + configureAgentMemoryAppForTest({ + mcpAccessKey: OLD_KEY, + agentMemoryAccessKey: NEW_KEY, + }); + assertEquals(await healthStatus(undefined), 401); +}); + +Deno.test("dual-key: unset dedicated key preserves MCP-only behavior", async () => { + configureAgentMemoryAppForTest({ + mcpAccessKey: OLD_KEY, + agentMemoryAccessKey: "", + }); + assertEquals(await healthStatus(OLD_KEY), 200); + assertEquals(await healthStatus(NEW_KEY), 401); +}); diff --git a/integrations/agent-memory-api/endpoint-scope.test.ts b/integrations/agent-memory-api/endpoint-scope.test.ts new file mode 100644 index 000000000..65b465510 --- /dev/null +++ b/integrations/agent-memory-api/endpoint-scope.test.ts @@ -0,0 +1,599 @@ +import { assert, assertEquals, assertFalse } from "jsr:@std/assert@1"; + +import { app, configureAgentMemoryAppForTest } from "./index.ts"; + +const ACCESS_KEY = "local-test-access-key"; +const WORKSPACE_ID = "workspace-11111111-1111-4111-8111-111111111111"; +const OTHER_WORKSPACE_ID = "workspace-22222222-2222-4222-8222-222222222222"; +const PROJECT_ID = "project-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const OTHER_PROJECT_ID = "project-bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const MEMORY_ID = "11111111-2222-4333-8444-555555555555"; +const TRACE_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; +const REQUEST_ID = "99999999-8888-4777-8666-555555555555"; + +type Row = Record; + +class FakeQuery { + #rows: Row[]; + #filters: Array<[string, unknown]> = []; + #limit: number | null = null; + + constructor(rows: Row[]) { + this.#rows = rows; + } + + select(_columns?: string) { + return this; + } + + eq(column: string, value: unknown) { + this.#filters.push([column, value]); + return this; + } + + order(_column: string, _options?: Record) { + return this; + } + + limit(limit: number) { + this.#limit = limit; + return this; + } + + insert(_row: unknown) { + return this; + } + + update(_row: unknown) { + return this; + } + + in(_column: string, _values: unknown[]) { + return this; + } + + like(_column: string, _pattern: string) { + return this; + } + + #resultRows() { + let rows = this.#rows.filter((row) => + this.#filters.every(([column, value]) => row[column] === value) + ); + if (this.#limit !== null) rows = rows.slice(0, this.#limit); + return rows; + } + + then( + resolve: (value: { data: Row[]; error: null }) => unknown, + _reject?: (reason?: unknown) => unknown, + ) { + return Promise.resolve(resolve({ data: this.#resultRows(), error: null })); + } + + single() { + const [row] = this.#resultRows(); + if (!row) { + return Promise.resolve({ + data: null, + error: { message: "No rows found" }, + }); + } + return Promise.resolve({ data: row, error: null }); + } + + maybeSingle() { + const [row] = this.#resultRows(); + return Promise.resolve({ data: row ?? null, error: null }); + } +} + +function memory(overrides: Partial = {}): Row { + return { + id: MEMORY_ID, + thought_id: "thought-11111111-1111-4111-8111-111111111111", + workspace_id: WORKSPACE_ID, + project_id: PROJECT_ID, + channel_id: "channel-11111111-1111-4111-8111-111111111111", + visibility: "project", + memory_type: "decision", + summary: "in-scope synthetic summary", + content: "in-scope synthetic content", + lifecycle_status: "active", + provenance_status: "user_confirmed", + confidence: 0.98, + created_by: "agent", + runtime_name: "local-scope-test", + runtime_version: "0.0.0", + provider: "synthetic-provider", + model: "synthetic-model", + task_id: "task-11111111-1111-4111-8111-111111111111", + flow_id: null, + can_use_as_instruction: true, + can_use_as_evidence: true, + requires_user_confirmation: false, + review_status: "confirmed", + last_confirmed_at: "2026-05-22T12:00:00.000Z", + stale_after: null, + created_at: "2026-05-22T12:00:00.000Z", + metadata: { + source_refs: [{ kind: "synthetic-note", uri: "fixture:in-scope-source" }], + artifacts: [{ kind: "synthetic-doc", uri: "fixture:in-scope-artifact" }], + }, + agent_memory_source_refs: [{ + source_kind: "synthetic-note", + uri: "fixture:in-scope-source", + }], + agent_memory_artifacts: [{ + artifact_kind: "synthetic-doc", + uri: "fixture:in-scope-artifact", + }], + ...overrides, + }; +} + +function trace(overrides: Partial = {}): Row { + return { + id: TRACE_ID, + request_id: REQUEST_ID, + workspace_id: WORKSPACE_ID, + project_id: PROJECT_ID, + runtime_name: "local-scope-test", + task_id: "task-11111111-1111-4111-8111-111111111111", + request_payload: { + query: "synthetic in-scope request", + }, + response_policy: { + max_items: 10, + }, + ...overrides, + }; +} + +function makeFakeSupabase(options: { + memories?: Row[]; + traces?: Row[]; + items?: Row[]; +} = {}) { + const calls: string[] = []; + const tables: Record = { + agent_memories: options.memories ?? [], + agent_memory_recall_traces: options.traces ?? [], + agent_memory_recall_items: options.items ?? [], + agent_memory_audit_events: [], + }; + + return { + calls, + client: { + from(table: string) { + calls.push(table); + return new FakeQuery(tables[table] ?? []); + }, + rpc(fn: string) { + calls.push(`rpc:${fn}`); + return Promise.resolve({ data: [], error: null }); + }, + }, + }; +} + +function configure( + fake = makeFakeSupabase(), + options: boolean | { + readOnly?: boolean; + allowQueryKey?: boolean; + allowedScope?: { + workspace_id?: string | null; + project_id?: string | null; + }; + } = {}, +) { + const runtime = typeof options === "boolean" + ? { readOnly: options } + : options; + configureAgentMemoryAppForTest({ + supabase: fake.client, + mcpAccessKey: ACCESS_KEY, + readOnly: runtime.readOnly ?? false, + allowQueryKey: runtime.allowQueryKey ?? false, + allowedScope: runtime.allowedScope ?? { + workspace_id: WORKSPACE_ID, + project_id: PROJECT_ID, + }, + }); + return fake; +} + +function authed(path: string, init: RequestInit = {}) { + const headers = new Headers(init.headers); + headers.set("x-brain-key", ACCESS_KEY); + return app.request( + path, + { ...init, headers } as Parameters[1], + ); +} + +function assertNoLeak(text: string, forbidden: string[]) { + for (const value of forbidden) { + assertFalse( + text.includes(value), + `response leaked forbidden fixture value: ${value}`, + ); + } +} + +Deno.test("route middleware accepts Bearer auth but keeps query-key auth opt-in", async () => { + configure(); + + const bearerResponse = await app.request("/health", { + headers: { authorization: `Bearer ${ACCESS_KEY}` }, + }); + const rejectedQueryResponse = await app.request(`/health?key=${ACCESS_KEY}`); + + configure(makeFakeSupabase(), { allowQueryKey: true }); + const acceptedQueryResponse = await app.request(`/health?key=${ACCESS_KEY}`); + + assertEquals(bearerResponse.status, 200); + assertEquals(rejectedQueryResponse.status, 401); + assertEquals(acceptedQueryResponse.status, 200); +}); + +Deno.test("GET /memories/:id returns in-scope real-ID-shaped memory detail", async () => { + configure(makeFakeSupabase({ memories: [memory()] })); + + const response = await authed( + `/memories/${MEMORY_ID}?workspace_id=${WORKSPACE_ID}&project_id=${PROJECT_ID}`, + ); + const body = await response.json(); + + assertEquals(response.status, 200); + assertEquals(body.memory.id, MEMORY_ID); + assertEquals(body.memory.workspace_id, WORKSPACE_ID); + assertEquals(body.memory.project_id, PROJECT_ID); + assertEquals(body.memory.content, "in-scope synthetic content"); +}); + +Deno.test("GET detail routes apply configured scope when request omits scope query params", async () => { + configure(makeFakeSupabase({ + memories: [memory()], + traces: [trace()], + items: [{ trace_id: TRACE_ID, rank: 1, agent_memories: memory() }], + })); + + const memoryResponse = await authed(`/memories/${MEMORY_ID}`); + const traceResponse = await authed(`/recall-traces/${REQUEST_ID}`); + + assertEquals(memoryResponse.status, 200); + assertEquals(traceResponse.status, 200); + + configure(makeFakeSupabase({ + memories: [ + memory({ + project_id: OTHER_PROJECT_ID, + summary: "default-scope wrong-project summary", + content: "default-scope wrong-project content", + }), + ], + traces: [ + trace({ + project_id: OTHER_PROJECT_ID, + request_payload: { query: "default-scope wrong-project trace" }, + }), + ], + })); + + const deniedMemoryResponse = await authed(`/memories/${MEMORY_ID}`); + const deniedTraceResponse = await authed(`/recall-traces/${REQUEST_ID}`); + const denialText = `${await deniedMemoryResponse + .text()}\n${await deniedTraceResponse + .text()}`; + + assertEquals(deniedMemoryResponse.status, 404); + assertEquals(deniedTraceResponse.status, 404); + assertNoLeak(denialText, [ + "default-scope wrong-project summary", + "default-scope wrong-project content", + "default-scope wrong-project trace", + OTHER_PROJECT_ID, + ]); +}); + +Deno.test("GET /memories/:id hides wrong-workspace memory without content leakage", async () => { + configure(makeFakeSupabase({ + memories: [ + memory({ + workspace_id: OTHER_WORKSPACE_ID, + summary: "out-of-scope workspace summary", + content: "out-of-scope workspace content", + metadata: { source_refs: [{ uri: "fixture:wrong-workspace-source" }] }, + }), + ], + })); + + const response = await authed( + `/memories/${MEMORY_ID}?workspace_id=${WORKSPACE_ID}&project_id=${PROJECT_ID}`, + ); + const text = await response.text(); + + assertEquals(response.status, 404); + assertNoLeak(text, [ + "out-of-scope workspace summary", + "out-of-scope workspace content", + "fixture:wrong-workspace-source", + OTHER_WORKSPACE_ID, + ]); +}); + +Deno.test("GET /memories/:id hides wrong-project memory without content leakage", async () => { + configure(makeFakeSupabase({ + memories: [ + memory({ + project_id: OTHER_PROJECT_ID, + summary: "out-of-scope project summary", + content: "out-of-scope project content", + metadata: { artifacts: [{ uri: "fixture:wrong-project-artifact" }] }, + }), + ], + })); + + const response = await authed( + `/memories/${MEMORY_ID}?workspace_id=${WORKSPACE_ID}&project_id=${PROJECT_ID}`, + ); + const text = await response.text(); + + assertEquals(response.status, 404); + assertNoLeak(text, [ + "out-of-scope project summary", + "out-of-scope project content", + "fixture:wrong-project-artifact", + OTHER_PROJECT_ID, + ]); +}); + +Deno.test("GET /recall-traces/:request_id returns in-scope trace detail", async () => { + configure(makeFakeSupabase({ + traces: [trace()], + items: [{ trace_id: TRACE_ID, rank: 1, agent_memories: memory() }], + })); + + const response = await authed( + `/recall-traces/${REQUEST_ID}?workspace_id=${WORKSPACE_ID}&project_id=${PROJECT_ID}`, + ); + const body = await response.json(); + + assertEquals(response.status, 200); + assertEquals(body.trace.request_id, REQUEST_ID); + assertEquals(body.trace.workspace_id, WORKSPACE_ID); + assertEquals(body.trace.project_id, PROJECT_ID); + assertEquals(body.items.length, 1); +}); + +Deno.test("GET /recall-traces/:request_id hides wrong-workspace trace without payload leakage", async () => { + configure(makeFakeSupabase({ + traces: [ + trace({ + workspace_id: OTHER_WORKSPACE_ID, + request_payload: { query: "out-of-scope workspace trace query" }, + }), + ], + })); + + const response = await authed( + `/recall-traces/${REQUEST_ID}?workspace_id=${WORKSPACE_ID}&project_id=${PROJECT_ID}`, + ); + const text = await response.text(); + + assertEquals(response.status, 404); + assertNoLeak(text, [ + "out-of-scope workspace trace query", + OTHER_WORKSPACE_ID, + ]); +}); + +Deno.test("GET /recall-traces/:request_id hides wrong-project trace without payload leakage", async () => { + configure(makeFakeSupabase({ + traces: [ + trace({ + project_id: OTHER_PROJECT_ID, + request_payload: { query: "out-of-scope project trace query" }, + }), + ], + })); + + const response = await authed( + `/recall-traces/${REQUEST_ID}?workspace_id=${WORKSPACE_ID}&project_id=${PROJECT_ID}`, + ); + const text = await response.text(); + + assertEquals(response.status, 404); + assertNoLeak(text, [ + "out-of-scope project trace query", + OTHER_PROJECT_ID, + ]); +}); + +Deno.test("GET /recall-traces/:request_id filters nested mixed-scope trace items", async () => { + const orphanedMemoryId = "44444444-5555-4666-8777-888888888888"; + configure(makeFakeSupabase({ + traces: [trace()], + items: [ + { + trace_id: TRACE_ID, + rank: 1, + agent_memories: memory({ content: "nested in-scope content" }), + }, + { + trace_id: TRACE_ID, + rank: 2, + agent_memories: memory({ + id: "22222222-3333-4444-8555-666666666666", + project_id: OTHER_PROJECT_ID, + summary: "nested out-of-scope summary", + content: "nested out-of-scope content", + metadata: { + artifacts: [{ uri: "fixture:nested-out-of-scope-artifact" }], + }, + }), + }, + { + trace_id: TRACE_ID, + rank: 3, + agent_memories: memory({ + id: "33333333-4444-4555-8666-777777777777", + workspace_id: OTHER_WORKSPACE_ID, + summary: "nested other-workspace summary", + content: "nested other-workspace content", + metadata: { + source_refs: [{ uri: "fixture:nested-other-workspace-source" }], + }, + }), + }, + { + trace_id: TRACE_ID, + memory_id: orphanedMemoryId, + rank: 4, + agent_memories: null, + }, + ], + })); + + const response = await authed( + `/recall-traces/${REQUEST_ID}?workspace_id=${WORKSPACE_ID}&project_id=${PROJECT_ID}`, + ); + const text = await response.text(); + const body = JSON.parse(text); + + assertEquals(response.status, 200); + assertEquals(body.items.length, 1); + assertEquals(body.items[0].agent_memories.content, "nested in-scope content"); + assertNoLeak(text, [ + "nested out-of-scope summary", + "nested out-of-scope content", + "fixture:nested-out-of-scope-artifact", + "nested other-workspace summary", + "nested other-workspace content", + "fixture:nested-other-workspace-source", + orphanedMemoryId, + OTHER_PROJECT_ID, + OTHER_WORKSPACE_ID, + ]); +}); + +Deno.test("read-only mode blocks write routes before payload validation or database access", async () => { + const fake = configure(makeFakeSupabase(), true); + const writeRoutes: Array<[string, string]> = [ + ["POST", "/recall"], + ["POST", "/writeback"], + ["POST", `/recall/${REQUEST_ID}/usage`], + ["PATCH", `/memories/${MEMORY_ID}/review`], + ]; + + for (const [method, path] of writeRoutes) { + fake.calls.length = 0; + const response = await authed(path, { + method, + body: "{", + headers: { "content-type": "application/json" }, + }); + const text = await response.text(); + + assertEquals(response.status, 403, `${method} ${path}`); + assertEquals(JSON.parse(text).error, "read_only_mode"); + assertEquals( + fake.calls, + [], + `${method} ${path} should not reach database access`, + ); + assertNoLeak(text, [ + "Invalid recall payload", + "Invalid write-back payload", + "Invalid usage payload", + "Invalid review payload", + ]); + } +}); + +Deno.test("PATCH /memories/:id/review rejects out-of-scope merge relation before side effects", async () => { + const relatedMemoryId = "55555555-6666-4777-8888-999999999999"; + const fake = configure(makeFakeSupabase({ + memories: [ + memory(), + memory({ + id: relatedMemoryId, + project_id: OTHER_PROJECT_ID, + summary: "out-of-scope merge target summary", + content: "out-of-scope merge target content", + }), + ], + })); + + const response = await authed(`/memories/${MEMORY_ID}/review`, { + method: "PATCH", + body: JSON.stringify({ + action: "merge", + related_memory_id: relatedMemoryId, + }), + headers: { "content-type": "application/json" }, + }); + const text = await response.text(); + + assertEquals(response.status, 403); + assertFalse(fake.calls.includes("agent_memory_relations")); + assertFalse(fake.calls.includes("agent_memory_review_actions")); + assertNoLeak(text, [ + relatedMemoryId, + "out-of-scope merge target summary", + "out-of-scope merge target content", + ]); +}); + +Deno.test("denial bodies avoid synthetic out-of-scope details and source metadata", async () => { + configure(makeFakeSupabase({ + memories: [ + memory({ + workspace_id: OTHER_WORKSPACE_ID, + project_id: OTHER_PROJECT_ID, + summary: "denied synthetic summary", + content: "denied synthetic content", + metadata: { + source_refs: [{ uri: "fixture:denied-source-ref" }], + artifacts: [{ uri: "fixture:denied-artifact" }], + note: "denied metadata note", + }, + }), + ], + traces: [ + trace({ + workspace_id: OTHER_WORKSPACE_ID, + project_id: OTHER_PROJECT_ID, + request_payload: { query: "denied trace request payload" }, + response_policy: { note: "denied trace metadata" }, + }), + ], + })); + + const memoryResponse = await authed( + `/memories/${MEMORY_ID}?workspace_id=${WORKSPACE_ID}&project_id=${PROJECT_ID}`, + ); + const traceResponse = await authed( + `/recall-traces/${REQUEST_ID}?workspace_id=${WORKSPACE_ID}&project_id=${PROJECT_ID}`, + ); + const denialText = `${await memoryResponse.text()}\n${await traceResponse + .text()}`; + + assertEquals(memoryResponse.status, 404); + assertEquals(traceResponse.status, 404); + assertNoLeak(denialText, [ + "denied synthetic summary", + "denied synthetic content", + "fixture:denied-source-ref", + "fixture:denied-artifact", + "denied metadata note", + "denied trace request payload", + "denied trace metadata", + OTHER_WORKSPACE_ID, + OTHER_PROJECT_ID, + ]); + assert(denialText.includes("No rows found")); +}); diff --git a/integrations/agent-memory-api/index.ts b/integrations/agent-memory-api/index.ts index fd2d09d51..26f564e29 100644 --- a/integrations/agent-memory-api/index.ts +++ b/integrations/agent-memory-api/index.ts @@ -3,18 +3,94 @@ import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { Hono } from "hono"; import { createClient } from "@supabase/supabase-js"; import { z } from "zod"; +import { accessKeyMatches, selectAccessKey } from "./auth.ts"; +import { + allowedScopeViolation, + buildMemoryThoughtFilter, + reviewTransition, + scopeGuardViolation, + scopeMatches, +} from "./policy.ts"; +import { + parseBooleanEnv, + READ_ONLY_ERROR, + shouldBlockWriteEndpoint, +} from "./read-only.ts"; + +function safeEnv(name: string): string | undefined { + try { + return Deno.env.get(name) ?? undefined; + } catch { + return undefined; + } +} -const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; -const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; -const OPENROUTER_API_KEY = Deno.env.get("OPENROUTER_API_KEY")!; -const MCP_ACCESS_KEY = Deno.env.get("MCP_ACCESS_KEY")!; +const SUPABASE_URL = safeEnv("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = safeEnv("SUPABASE_SERVICE_ROLE_KEY")!; +const OPENROUTER_API_KEY = safeEnv("OPENROUTER_API_KEY")!; +let MCP_ACCESS_KEY = safeEnv("MCP_ACCESS_KEY")!; +let AGENT_MEMORY_ACCESS_KEY = safeEnv("AGENT_MEMORY_ACCESS_KEY"); const OPENROUTER_BASE = "https://openrouter.ai/api/v1"; +let AGENT_MEMORY_READ_ONLY = parseBooleanEnv(safeEnv("AGENT_MEMORY_READ_ONLY")); +let AGENT_MEMORY_ALLOW_QUERY_KEY = parseBooleanEnv( + safeEnv("AGENT_MEMORY_ALLOW_QUERY_KEY"), +); +let AGENT_MEMORY_ALLOWED_SCOPE = { + workspace_id: safeEnv("AGENT_MEMORY_ALLOWED_WORKSPACE_ID") || null, + project_id: safeEnv("AGENT_MEMORY_ALLOWED_PROJECT_ID") || null, +}; + +type SupabaseClientLike = ReturnType | { + from: (table: string) => any; + rpc: (fn: string, args?: Record) => any; +}; + +let supabase: SupabaseClientLike | null = + SUPABASE_URL && SUPABASE_SERVICE_ROLE_KEY + ? createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY) + : null; + +function db() { + if (!supabase) throw new Error("Supabase client is not configured."); + return supabase; +} -const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); +type AgentMemoryAppTestRuntime = { + supabase?: SupabaseClientLike; + mcpAccessKey?: string; + agentMemoryAccessKey?: string; + readOnly?: boolean; + allowQueryKey?: boolean; + allowedScope?: { + workspace_id?: string | null; + project_id?: string | null; + }; +}; + +export function configureAgentMemoryAppForTest( + runtime: AgentMemoryAppTestRuntime, +) { + if (runtime.supabase) supabase = runtime.supabase; + if (runtime.mcpAccessKey !== undefined) MCP_ACCESS_KEY = runtime.mcpAccessKey; + if (runtime.agentMemoryAccessKey !== undefined) { + AGENT_MEMORY_ACCESS_KEY = runtime.agentMemoryAccessKey; + } + if (runtime.readOnly !== undefined) AGENT_MEMORY_READ_ONLY = runtime.readOnly; + if (runtime.allowQueryKey !== undefined) { + AGENT_MEMORY_ALLOW_QUERY_KEY = runtime.allowQueryKey; + } + if (runtime.allowedScope) { + AGENT_MEMORY_ALLOWED_SCOPE = { + workspace_id: runtime.allowedScope.workspace_id ?? null, + project_id: runtime.allowedScope.project_id ?? null, + }; + } +} const corsHeaders = { "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-brain-key", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type, x-brain-key", "Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS", }; @@ -59,7 +135,11 @@ const recallSchema = z.object({ project_only: z.boolean().default(true), include_unconfirmed: z.boolean().default(false), include_stale: z.boolean().default(false), - }).default({ project_only: true, include_unconfirmed: false, include_stale: false }), + }).default({ + project_only: true, + include_unconfirmed: false, + include_stale: false, + }), limits: z.object({ max_items: z.number().int().min(1).max(50).default(10), max_tokens: z.number().int().min(256).max(20000).default(4000), @@ -108,10 +188,20 @@ const writebackSchema = z.object({ })).default([]), memory_payload: memoryPayloadSchema, provenance: z.object({ - default_status: z.enum(["observed", "inferred", "user_confirmed", "imported", "generated"]).default("generated"), + default_status: z.enum([ + "observed", + "inferred", + "user_confirmed", + "imported", + "generated", + ]).default("generated"), confidence: z.number().min(0).max(1).default(0.5), requires_review: z.boolean().default(true), - }).default({ default_status: "generated", confidence: 0.5, requires_review: true }), + }).default({ + default_status: "generated", + confidence: 0.5, + requires_review: true, + }), retention: z.object({ ttl_days: z.number().int().positive().nullable().optional(), stale_after_days: z.number().int().positive().nullable().optional(), @@ -132,7 +222,17 @@ const usageSchema = z.object({ }); const reviewSchema = z.object({ - action: z.enum(["confirm", "edit", "evidence_only", "restrict_scope", "mark_stale", "merge", "reject", "dispute", "supersede"]), + action: z.enum([ + "confirm", + "edit", + "evidence_only", + "restrict_scope", + "mark_stale", + "merge", + "reject", + "dispute", + "supersede", + ]), actor_id: z.string().nullable().optional(), actor_label: z.string().nullable().optional(), notes: z.string().nullable().optional(), @@ -176,7 +276,9 @@ type AgentMemory = { async function sha256Hex(text: string): Promise { const data = new TextEncoder().encode(text); const digest = await crypto.subtle.digest("SHA-256", data); - return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join(""); + return Array.from(new Uint8Array(digest)).map((b) => + b.toString(16).padStart(2, "0") + ).join(""); } async function getEmbedding(text: string): Promise { @@ -191,23 +293,114 @@ async function getEmbedding(text: string): Promise { input: text, }), }); - if (!r.ok) throw new Error(`OpenRouter embeddings failed: ${r.status} ${await r.text()}`); + if (!r.ok) { + throw new Error( + `OpenRouter embeddings failed: ${r.status} ${await r.text()}`, + ); + } const d = await r.json(); return d.data[0].embedding; } -function auth(c: { req: { header: (name: string) => string | undefined; url: string } }) { - const provided = c.req.header("x-brain-key") || new URL(c.req.url).searchParams.get("key"); - return provided && provided === MCP_ACCESS_KEY; +function auth(c: { req: { raw: Request; url: string } }) { + const provided = selectAccessKey(c.req.raw.headers, c.req.url, { + allowQueryKey: AGENT_MEMORY_ALLOW_QUERY_KEY, + }); + // Dedicated read-surface key is preferred when set; the shared + // MCP_ACCESS_KEY fallback is retained until Stone-side harnesses + // migrate (design decision D1 — full decouple is a later increment). + if ( + AGENT_MEMORY_ACCESS_KEY && + accessKeyMatches(provided, AGENT_MEMORY_ACCESS_KEY) + ) { + return true; + } + return accessKeyMatches(provided, MCP_ACCESS_KEY); +} + +function readOnlyBlock( + c: { + json: ( + obj: unknown, + status?: number, + headers?: Record, + ) => Response; + }, + method: string, + endpointPattern: string, +): Response | null { + if ( + !shouldBlockWriteEndpoint(method, endpointPattern, AGENT_MEMORY_READ_ONLY) + ) return null; + return c.json(READ_ONLY_ERROR, 403, corsHeaders); +} + +function scopeBlock( + c: { + json: ( + obj: unknown, + status?: number, + headers?: Record, + ) => Response; + }, + record: { workspace_id?: string | null; project_id?: string | null }, + requested?: { workspace_id?: string | null; project_id?: string | null }, +): Response | null { + const reason = scopeGuardViolation(record, { + allowed: AGENT_MEMORY_ALLOWED_SCOPE, + requested, + }); + if (!reason) return null; + return c.json({ error: "scope_not_allowed", reason }, 403, corsHeaders); +} + +function allowedScopeBlock( + c: { + json: ( + obj: unknown, + status?: number, + headers?: Record, + ) => Response; + }, + record: { workspace_id?: string | null; project_id?: string | null }, +): Response | null { + const reason = allowedScopeViolation(record, AGENT_MEMORY_ALLOWED_SCOPE); + if (!reason) return null; + return c.json({ error: "scope_not_allowed", reason }, 403, corsHeaders); +} + +function requestedOrAllowedScope( + c: { req: { query: (name: string) => string | undefined } }, +) { + return { + workspace_id: c.req.query("workspace_id") || + AGENT_MEMORY_ALLOWED_SCOPE.workspace_id, + project_id: c.req.query("project_id") || + AGENT_MEMORY_ALLOWED_SCOPE.project_id, + }; } function unsafeReasons(text: string): string[] { const reasons: string[] = []; - if (/-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----/.test(text)) reasons.push("private_key"); - if (/(?:sk-[A-Za-z0-9_-]{20,}|sk-or-v1-[A-Za-z0-9_-]{20,})/.test(text)) reasons.push("api_key"); - if (/(?:password|passwd|secret|token)\s*[:=]\s*\S{12,}/i.test(text)) reasons.push("credential_like_string"); - if ((text.match(/```/g) || []).length >= 4 || text.split("\n").filter((l) => l.length > 120).length > 20) reasons.push("large_code_block"); - if (text.length > 15000 || text.split("\n").filter((l) => /^(user|assistant|system|agent|human):/i.test(l.trim())).length > 8) reasons.push("raw_transcript_like"); + if (/-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----/.test(text)) { + reasons.push("private_key"); + } + if (/(?:sk-[A-Za-z0-9_-]{20,}|sk-or-v1-[A-Za-z0-9_-]{20,})/.test(text)) { + reasons.push("api_key"); + } + if (/(?:password|passwd|secret|token)\s*[:=]\s*\S{12,}/i.test(text)) { + reasons.push("credential_like_string"); + } + if ( + (text.match(/```/g) || []).length >= 4 || + text.split("\n").filter((l) => l.length > 120).length > 20 + ) reasons.push("large_code_block"); + if ( + text.length > 15000 || + text.split("\n").filter((l) => + /^(user|assistant|system|agent|human):/i.test(l.trim()) + ).length > 8 + ) reasons.push("raw_transcript_like"); return reasons; } @@ -221,43 +414,62 @@ function staleAfter(days?: number | null): string | null { function memoryRows(payload: z.infer) { const p = payload.memory_payload; const rows: { memory_type: string; content: string }[] = []; - for (const content of p.decisions) rows.push({ memory_type: "decision", content }); - for (const content of p.outputs) rows.push({ memory_type: "output", content }); - for (const content of p.lessons) rows.push({ memory_type: "lesson", content }); - for (const content of p.constraints) rows.push({ memory_type: "constraint", content }); - for (const content of p.unresolved_questions) rows.push({ memory_type: "open_question", content }); - for (const content of p.next_steps) rows.push({ memory_type: "work_log", content: `Next step: ${content}` }); - for (const content of p.failures) rows.push({ memory_type: "failure", content }); + for (const content of p.decisions) { + rows.push({ memory_type: "decision", content }); + } + for (const content of p.outputs) { + rows.push({ memory_type: "output", content }); + } + for (const content of p.lessons) { + rows.push({ memory_type: "lesson", content }); + } + for (const content of p.constraints) { + rows.push({ memory_type: "constraint", content }); + } + for (const content of p.unresolved_questions) { + rows.push({ memory_type: "open_question", content }); + } + for (const content of p.next_steps) { + rows.push({ memory_type: "work_log", content: `Next step: ${content}` }); + } + for (const content of p.failures) { + rows.push({ memory_type: "failure", content }); + } for (const artifact of p.artifacts) { rows.push({ memory_type: "artifact_reference", - content: `${artifact.kind}: ${artifact.description || artifact.uri}\n${artifact.uri}`, + content: `${artifact.kind}: ${ + artifact.description || artifact.uri + }\n${artifact.uri}`, }); } return rows; } -function scopeMatches(memory: AgentMemory, req: z.infer): boolean { - if (memory.workspace_id !== req.workspace_id) return false; - if (req.scope.project_only && req.project_id && memory.project_id !== req.project_id) return false; - if (!req.scope.include_stale && ["stale", "superseded", "rejected", "disputed"].includes(memory.lifecycle_status)) return false; - if (!req.scope.include_unconfirmed && memory.requires_user_confirmation && memory.review_status === "pending") return false; - if (memory.visibility === "personal" && req.scope.visibility !== "personal") return false; - return true; -} - function rankMemory(memory: AgentMemory, similarity = 0): number { - const provenance = memory.provenance_status === "user_confirmed" ? 0.3 - : memory.provenance_status === "imported" ? 0.22 - : memory.provenance_status === "observed" ? 0.15 - : memory.provenance_status === "generated" ? 0.05 + const provenance = memory.provenance_status === "user_confirmed" + ? 0.3 + : memory.provenance_status === "imported" + ? 0.22 + : memory.provenance_status === "observed" + ? 0.15 + : memory.provenance_status === "generated" + ? 0.05 : 0; - const policy = memory.can_use_as_instruction ? 0.2 : memory.can_use_as_evidence ? 0.08 : -0.2; - const review = memory.review_status === "confirmed" ? 0.15 - : memory.review_status === "evidence_only" ? 0.05 - : memory.review_status === "pending" ? -0.08 + const policy = memory.can_use_as_instruction + ? 0.2 + : memory.can_use_as_evidence + ? 0.08 + : -0.2; + const review = memory.review_status === "confirmed" + ? 0.15 + : memory.review_status === "evidence_only" + ? 0.05 + : memory.review_status === "pending" + ? -0.08 : -0.25; - return similarity + provenance + policy + review + Number(memory.confidence || 0) * 0.15; + return similarity + provenance + policy + review + + Number(memory.confidence || 0) * 0.15; } function responseMemory(memory: AgentMemory) { @@ -311,7 +523,7 @@ function writebackResponseSchema(reqSchemaVersion: string) { } async function audit(event_type: string, payload: Record) { - await supabase.from("agent_memory_audit_events").insert({ + await db().from("agent_memory_audit_events").insert({ event_type, workspace_id: payload.workspace_id ?? null, project_id: payload.project_id ?? null, @@ -325,47 +537,83 @@ async function audit(event_type: string, payload: Record) { }); } -const app = new Hono(); +export const app = new Hono(); app.options("*", (c) => c.text("ok", 200, corsHeaders)); app.use("*", async (c, next) => { - if (!auth(c)) return c.json({ error: "Invalid or missing access key" }, 401, corsHeaders); + if (!auth(c)) { + return c.json({ error: "Invalid or missing access key" }, 401, corsHeaders); + } await next(); }); -app.get("/health", (c) => c.json({ ok: true, service: "agent-memory-api", version: "0.1.0" }, 200, corsHeaders)); +app.get( + "/health", + (c) => + c.json( + { ok: true, service: "agent-memory-api", version: "0.1.0" }, + 200, + corsHeaders, + ), +); app.post("/recall", async (c) => { + const blocked = readOnlyBlock(c, "POST", "/recall"); + if (blocked) return blocked; + const parsed = recallSchema.safeParse(await c.req.json()); - if (!parsed.success) return c.json({ error: "Invalid recall payload", details: parsed.error.flatten() }, 400, corsHeaders); + if (!parsed.success) { + return c.json( + { error: "Invalid recall payload", details: parsed.error.flatten() }, + 400, + corsHeaders, + ); + } const req = parsed.data; + const scopeDenied = allowedScopeBlock(c, { + workspace_id: req.workspace_id, + project_id: req.project_id ?? null, + }); + if (scopeDenied) return scopeDenied; const embedding = await getEmbedding(req.query); - const { data: matches, error: matchError } = await supabase.rpc("match_thoughts", { - query_embedding: embedding, - match_threshold: 0.25, - match_count: Math.max(req.limits.max_items * 4, 20), - filter: {}, - }); - if (matchError) return c.json({ error: matchError.message }, 500, corsHeaders); + const { data: matches, error: matchError } = await db().rpc( + "match_thoughts", + { + query_embedding: embedding, + match_threshold: 0.25, + match_count: Math.max(req.limits.max_items * 4, 20), + filter: {}, + }, + ); + if (matchError) { + return c.json({ error: matchError.message }, 500, corsHeaders); + } const similarityByThought = new Map(); - for (const item of matches || []) similarityByThought.set(item.id, item.similarity); - const thoughtIds = Array.from(similarityByThought.keys()); - - let memoryQuery = supabase - .from("agent_memories") - .select("*") - .eq("workspace_id", req.workspace_id) - .order("created_at", { ascending: false }) - .limit(100); - if (thoughtIds.length > 0) memoryQuery = memoryQuery.in("thought_id", thoughtIds); - - const { data: rawMemories, error: memoryError } = await memoryQuery; - if (memoryError) return c.json({ error: memoryError.message }, 500, corsHeaders); + for (const item of matches || []) { + similarityByThought.set(item.id, item.similarity); + } + const thoughtFilter = buildMemoryThoughtFilter( + Array.from(similarityByThought.keys()), + ); + let rawMemories: AgentMemory[] = []; + if (thoughtFilter.mode === "thought_ids") { + const { data, error: memoryError } = await db() + .from("agent_memories") + .select("*") + .eq("workspace_id", req.workspace_id) + .in("thought_id", thoughtFilter.thoughtIds) + .order("created_at", { ascending: false }) + .limit(100); + if (memoryError) { + return c.json({ error: memoryError.message }, 500, corsHeaders); + } + rawMemories = (data || []) as AgentMemory[]; + } - const ranked = ((rawMemories || []) as AgentMemory[]) + const ranked = rawMemories .filter((m) => scopeMatches(m, req)) .map((m) => { const similarity = similarityByThought.get(m.thought_id || "") || 0; @@ -374,7 +622,9 @@ app.post("/recall", async (c) => { .sort((a, b) => b.ranking_score - a.ranking_score) .slice(0, req.limits.max_items); - const { data: trace, error: traceError } = await supabase.from("agent_memory_recall_traces").insert({ + const { data: trace, error: traceError } = await db().from( + "agent_memory_recall_traces", + ).insert({ workspace_id: req.workspace_id, project_id: req.project_id ?? null, runtime_name: req.runtime.name, @@ -386,23 +636,30 @@ app.post("/recall", async (c) => { query: req.query, schema_version: req.schema_version, request_payload: req, - response_policy: { max_items: req.limits.max_items, include_unconfirmed: req.scope.include_unconfirmed }, + response_policy: { + max_items: req.limits.max_items, + include_unconfirmed: req.scope.include_unconfirmed, + }, }).select("*").single(); - if (traceError) return c.json({ error: traceError.message }, 500, corsHeaders); + if (traceError) { + return c.json({ error: traceError.message }, 500, corsHeaders); + } if (ranked.length > 0) { - await supabase.from("agent_memory_recall_items").insert(ranked.map((memory, index) => ({ - trace_id: trace.id, - memory_id: memory.id, - rank: index + 1, - similarity: memory.similarity, - ranking_score: memory.ranking_score, - use_policy_snapshot: { - can_use_as_instruction: memory.can_use_as_instruction, - can_use_as_evidence: memory.can_use_as_evidence, - requires_user_confirmation: memory.requires_user_confirmation, - }, - }))); + await db().from("agent_memory_recall_items").insert( + ranked.map((memory, index) => ({ + trace_id: trace.id, + memory_id: memory.id, + rank: index + 1, + similarity: memory.similarity, + ranking_score: memory.ranking_score, + use_policy_snapshot: { + can_use_as_instruction: memory.can_use_as_instruction, + can_use_as_evidence: memory.can_use_as_evidence, + requires_user_confirmation: memory.requires_user_confirmation, + }, + })), + ); } await audit("recall_requested", { @@ -414,21 +671,50 @@ app.post("/recall", async (c) => { returned_count: ranked.length, }); - return c.json({ - schema_version: recallResponseSchema(req.schema_version), - request_id: trace.request_id, - memories: ranked.map(responseMemory), - }, 200, corsHeaders); + return c.json( + { + schema_version: recallResponseSchema(req.schema_version), + request_id: trace.request_id, + memories: ranked.map(responseMemory), + }, + 200, + corsHeaders, + ); }); app.post("/writeback", async (c) => { + const blocked = readOnlyBlock(c, "POST", "/writeback"); + if (blocked) return blocked; + const parsed = writebackSchema.safeParse(await c.req.json()); - if (!parsed.success) return c.json({ error: "Invalid write-back payload", details: parsed.error.flatten() }, 400, corsHeaders); + if (!parsed.success) { + return c.json( + { error: "Invalid write-back payload", details: parsed.error.flatten() }, + 400, + corsHeaders, + ); + } const req = parsed.data; + const scopeDenied = allowedScopeBlock(c, { + workspace_id: req.workspace_id, + project_id: req.project_id ?? null, + }); + if (scopeDenied) return scopeDenied; const rows = memoryRows(req); - if (rows.length === 0) return c.json({ error: "memory_payload produced no memory rows" }, 400, corsHeaders); + if (rows.length === 0) { + return c.json( + { error: "memory_payload produced no memory rows" }, + 400, + corsHeaders, + ); + } - const unsafe = rows.flatMap((row) => unsafeReasons(row.content).map((reason) => ({ reason, memory_type: row.memory_type }))); + const unsafe = rows.flatMap((row) => + unsafeReasons(row.content).map((reason) => ({ + reason, + memory_type: row.memory_type, + })) + ); if (unsafe.length > 0) { await audit("memory_rejected", { workspace_id: req.workspace_id, @@ -439,20 +725,29 @@ app.post("/writeback", async (c) => { reason: "unsafe_writeback", unsafe, }); - return c.json({ error: "Unsafe write-back blocked", unsafe }, 422, corsHeaders); + return c.json( + { error: "Unsafe write-back blocked", unsafe }, + 422, + corsHeaders, + ); } const created = []; const provider = req.models_used[0]?.provider ?? null; const model = req.models_used[0]?.model ?? null; - const defaultInstruction = ["user_confirmed", "imported"].includes(req.provenance.default_status) && !req.provenance.requires_review; + const defaultInstruction = + ["user_confirmed", "imported"].includes(req.provenance.default_status) && + !req.provenance.requires_review; for (const [index, row] of rows.entries()) { const content_hash = await sha256Hex(`${row.memory_type}:${row.content}`); - const baseKey = req.idempotency_key || `${req.workspace_id}:${req.runtime.name}:${req.task_id || "taskless"}:${req.step_id || "step"}:${content_hash}`; + const baseKey = req.idempotency_key || + `${req.workspace_id}:${req.runtime.name}:${req.task_id || "taskless"}:${ + req.step_id || "step" + }:${content_hash}`; const idempotency_key = `${baseKey}:${index}`; - const { data: existing } = await supabase + const { data: existing } = await db() .from("agent_memories") .select("*") .eq("idempotency_key", idempotency_key) @@ -463,30 +758,39 @@ app.post("/writeback", async (c) => { } const embedding = await getEmbedding(row.content); - const { data: upsertResult, error: upsertError } = await supabase.rpc("upsert_thought", { - p_content: row.content, - p_payload: { - metadata: { - source: "agent_memory", - source_type: "agent_memory", - type: row.memory_type, - topics: req.memory_payload.entities.topics || [], - people: req.memory_payload.entities.people || [], - agent_memory: { - runtime: req.runtime.name, - task_id: req.task_id, - flow_id: req.flow_id, - provenance_status: req.provenance.default_status, + const { data: upsertResult, error: upsertError } = await db().rpc( + "upsert_thought", + { + p_content: row.content, + p_payload: { + metadata: { + source: "agent_memory", + source_type: "agent_memory", + type: row.memory_type, + topics: req.memory_payload.entities.topics || [], + people: req.memory_payload.entities.people || [], + agent_memory: { + runtime: req.runtime.name, + task_id: req.task_id, + flow_id: req.flow_id, + provenance_status: req.provenance.default_status, + }, }, }, }, - }); - if (upsertError) return c.json({ error: upsertError.message }, 500, corsHeaders); + ); + if (upsertError) { + return c.json({ error: upsertError.message }, 500, corsHeaders); + } const thoughtId = upsertResult?.id; - if (thoughtId) await supabase.from("thoughts").update({ embedding }).eq("id", thoughtId); + if (thoughtId) { + await db().from("thoughts").update({ embedding }).eq("id", thoughtId); + } - const { data: memory, error: memoryError } = await supabase.from("agent_memories").insert({ + const { data: memory, error: memoryError } = await db().from( + "agent_memories", + ).insert({ thought_id: thoughtId ?? null, workspace_id: req.workspace_id, project_id: req.project_id ?? null, @@ -499,7 +803,9 @@ app.post("/writeback", async (c) => { content: row.content, provenance_status: req.provenance.default_status, confidence: req.provenance.confidence, - created_by: req.provenance.default_status === "imported" ? "import" : "agent", + created_by: req.provenance.default_status === "imported" + ? "import" + : "agent", runtime_name: req.runtime.name, runtime_version: req.runtime.version ?? null, provider, @@ -521,21 +827,25 @@ app.post("/writeback", async (c) => { writeback_schema_version: req.schema_version, }, }).select("*").single(); - if (memoryError) return c.json({ error: memoryError.message }, 500, corsHeaders); + if (memoryError) { + return c.json({ error: memoryError.message }, 500, corsHeaders); + } if (req.source_refs.length > 0) { - await supabase.from("agent_memory_source_refs").insert(req.source_refs.map((source) => ({ - memory_id: memory.id, - source_kind: source.kind, - uri: source.uri ?? null, - title: source.title ?? null, - source_timestamp: source.timestamp ?? null, - }))); + await db().from("agent_memory_source_refs").insert( + req.source_refs.map((source) => ({ + memory_id: memory.id, + source_kind: source.kind, + uri: source.uri ?? null, + title: source.title ?? null, + source_timestamp: source.timestamp ?? null, + })), + ); } if (row.memory_type === "artifact_reference") { for (const artifact of req.memory_payload.artifacts) { - await supabase.from("agent_memory_artifacts").insert({ + await db().from("agent_memory_artifacts").insert({ memory_id: memory.id, artifact_kind: artifact.kind, uri: artifact.uri, @@ -557,24 +867,65 @@ app.post("/writeback", async (c) => { created.push(memory); } - return c.json({ schema_version: writebackResponseSchema(req.schema_version), memories: created.map(responseMemory) }, 200, corsHeaders); + return c.json( + { + schema_version: writebackResponseSchema(req.schema_version), + memories: created.map(responseMemory), + }, + 200, + corsHeaders, + ); }); app.post("/recall/:request_id/usage", async (c) => { + const blocked = readOnlyBlock(c, "POST", "/recall/:request_id/usage"); + if (blocked) return blocked; + const request_id = c.req.param("request_id"); const parsed = usageSchema.safeParse(await c.req.json()); - if (!parsed.success) return c.json({ error: "Invalid usage payload", details: parsed.error.flatten() }, 400, corsHeaders); + if (!parsed.success) { + return c.json( + { error: "Invalid usage payload", details: parsed.error.flatten() }, + 400, + corsHeaders, + ); + } - const { data: trace, error } = await supabase.from("agent_memory_recall_traces").select("*").eq("request_id", request_id).single(); + const { data: trace, error } = await db().from("agent_memory_recall_traces") + .select("*").eq("request_id", request_id).single(); if (error) return c.json({ error: error.message }, 404, corsHeaders); + const scopeDenied = allowedScopeBlock(c, { + workspace_id: trace.workspace_id, + project_id: trace.project_id, + }); + if (scopeDenied) return scopeDenied; for (const memory_id of parsed.data.used_memory_ids) { - await supabase.from("agent_memory_recall_items").update({ used: true }).eq("trace_id", trace.id).eq("memory_id", memory_id); - await audit("memory_used", { workspace_id: trace.workspace_id, project_id: trace.project_id, trace_id: trace.id, memory_id, runtime_name: trace.runtime_name, task_id: trace.task_id }); + await db().from("agent_memory_recall_items").update({ used: true }).eq( + "trace_id", + trace.id, + ).eq("memory_id", memory_id); + await audit("memory_used", { + workspace_id: trace.workspace_id, + project_id: trace.project_id, + trace_id: trace.id, + memory_id, + runtime_name: trace.runtime_name, + task_id: trace.task_id, + }); } for (const ignored of parsed.data.ignored) { - await supabase.from("agent_memory_recall_items").update({ used: false, ignored_reason: ignored.reason ?? null }).eq("trace_id", trace.id).eq("memory_id", ignored.memory_id); - await audit("memory_ignored", { workspace_id: trace.workspace_id, project_id: trace.project_id, trace_id: trace.id, memory_id: ignored.memory_id, reason: ignored.reason }); + await db().from("agent_memory_recall_items").update({ + used: false, + ignored_reason: ignored.reason ?? null, + }).eq("trace_id", trace.id).eq("memory_id", ignored.memory_id); + await audit("memory_ignored", { + workspace_id: trace.workspace_id, + project_id: trace.project_id, + trace_id: trace.id, + memory_id: ignored.memory_id, + reason: ignored.reason, + }); } return c.json({ ok: true }, 200, corsHeaders); @@ -582,28 +933,53 @@ app.post("/recall/:request_id/usage", async (c) => { app.get("/memories/review", async (c) => { const workspace_id = c.req.query("workspace_id"); - if (!workspace_id) return c.json({ error: "workspace_id is required" }, 400, corsHeaders); + if (!workspace_id) { + return c.json({ error: "workspace_id is required" }, 400, corsHeaders); + } const project_id = c.req.query("project_id"); - let q = supabase.from("agent_memories").select("*").eq("workspace_id", workspace_id).eq("review_status", "pending").order("created_at", { ascending: false }).limit(100); + const scopeDenied = allowedScopeBlock(c, { + workspace_id, + project_id: project_id ?? null, + }); + if (scopeDenied) return scopeDenied; + let q = db().from("agent_memories").select("*").eq( + "workspace_id", + workspace_id, + ).eq("review_status", "pending").order("created_at", { ascending: false }) + .limit(100); if (project_id) q = q.eq("project_id", project_id); const { data, error } = await q; if (error) return c.json({ error: error.message }, 500, corsHeaders); - return c.json({ memories: (data || []).map(responseMemory) }, 200, corsHeaders); + return c.json( + { memories: (data || []).map(responseMemory) }, + 200, + corsHeaders, + ); }); app.get("/memories", async (c) => { const workspace_id = c.req.query("workspace_id"); - if (!workspace_id) return c.json({ error: "workspace_id is required" }, 400, corsHeaders); + if (!workspace_id) { + return c.json({ error: "workspace_id is required" }, 400, corsHeaders); + } + const project_id = c.req.query("project_id"); + const scopeDenied = allowedScopeBlock(c, { + workspace_id, + project_id: project_id ?? null, + }); + if (scopeDenied) return scopeDenied; - const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "50", 10), 1), 200); - let q = supabase + const limit = Math.min( + Math.max(parseInt(c.req.query("limit") || "50", 10), 1), + 200, + ); + let q = db() .from("agent_memories") .select("*") .eq("workspace_id", workspace_id) .order("created_at", { ascending: false }) .limit(limit); - const project_id = c.req.query("project_id"); const review_status = c.req.query("review_status"); const lifecycle_status = c.req.query("lifecycle_status"); const runtime_name = c.req.query("runtime_name"); @@ -619,62 +995,93 @@ app.get("/memories", async (c) => { const { data, error } = await q; if (error) return c.json({ error: error.message }, 500, corsHeaders); - return c.json({ memories: (data || []).map(responseMemory), count: data?.length || 0 }, 200, corsHeaders); + return c.json( + { memories: (data || []).map(responseMemory), count: data?.length || 0 }, + 200, + corsHeaders, + ); }); app.get("/memories/:id", async (c) => { const id = c.req.param("id"); - const { data, error } = await supabase.from("agent_memories").select("*, agent_memory_source_refs(*), agent_memory_artifacts(*)").eq("id", id).single(); + const scope = requestedOrAllowedScope(c); + let q = db() + .from("agent_memories") + .select("*, agent_memory_source_refs(*), agent_memory_artifacts(*)") + .eq("id", id); + if (scope.workspace_id) q = q.eq("workspace_id", scope.workspace_id); + if (scope.project_id) q = q.eq("project_id", scope.project_id); + const { data, error } = await q.single(); if (error) return c.json({ error: error.message }, 404, corsHeaders); + const scopeDenied = scopeBlock(c, data, { + workspace_id: scope.workspace_id ?? null, + project_id: scope.project_id ?? null, + }); + if (scopeDenied) return scopeDenied; return c.json({ memory: data }, 200, corsHeaders); }); app.patch("/memories/:id/review", async (c) => { + const blocked = readOnlyBlock(c, "PATCH", "/memories/:id/review"); + if (blocked) return blocked; + const id = c.req.param("id"); const parsed = reviewSchema.safeParse(await c.req.json()); - if (!parsed.success) return c.json({ error: "Invalid review payload", details: parsed.error.flatten() }, 400, corsHeaders); + if (!parsed.success) { + return c.json( + { error: "Invalid review payload", details: parsed.error.flatten() }, + 400, + corsHeaders, + ); + } const req = parsed.data; - const { data: before, error: beforeError } = await supabase.from("agent_memories").select("*").eq("id", id).single(); - if (beforeError) return c.json({ error: beforeError.message }, 404, corsHeaders); + const { data: before, error: beforeError } = await db().from("agent_memories") + .select("*").eq("id", id).single(); + if (beforeError) { + return c.json({ error: beforeError.message }, 404, corsHeaders); + } + const scopeDenied = allowedScopeBlock(c, { + workspace_id: before.workspace_id, + project_id: before.project_id, + }); + if (scopeDenied) return scopeDenied; + + const transition = reviewTransition(req); + if ( + req.related_memory_id && + ( + Object.keys(transition.relatedMemoryUpdates).length > 0 || + transition.relation + ) + ) { + const { data: related, error: relatedReadError } = await db() + .from("agent_memories") + .select("workspace_id, project_id") + .eq("id", req.related_memory_id) + .single(); + if (relatedReadError) { + return c.json({ error: relatedReadError.message }, 404, corsHeaders); + } + const relatedScopeDenied = allowedScopeBlock(c, { + workspace_id: related.workspace_id, + project_id: related.project_id, + }); + if (relatedScopeDenied) return relatedScopeDenied; + } - const updates: Record = {}; + const updates = { ...transition.memoryUpdates }; if (req.action === "confirm") { - updates.review_status = "confirmed"; - updates.provenance_status = "user_confirmed"; - updates.can_use_as_instruction = true; - updates.requires_user_confirmation = false; updates.last_confirmed_at = new Date().toISOString(); - } else if (req.action === "evidence_only") { - updates.review_status = "evidence_only"; - updates.can_use_as_instruction = false; - updates.can_use_as_evidence = true; - updates.requires_user_confirmation = false; - } else if (req.action === "reject") { - updates.review_status = "rejected"; - updates.lifecycle_status = "rejected"; - updates.can_use_as_instruction = false; - updates.can_use_as_evidence = false; - } else if (req.action === "mark_stale") { - updates.review_status = "stale"; - updates.lifecycle_status = "stale"; - updates.can_use_as_instruction = false; - } else if (req.action === "dispute") { - updates.lifecycle_status = "disputed"; - updates.provenance_status = "disputed"; - updates.can_use_as_instruction = false; - } else if (req.action === "restrict_scope") { - updates.review_status = "restricted"; - updates.visibility = req.visibility || "personal"; - } else if (req.action === "edit") { - if (req.content) updates.content = req.content; - if (req.summary) updates.summary = req.summary; - } - - const { data: after, error: updateError } = await supabase.from("agent_memories").update(updates).eq("id", id).select("*").single(); - if (updateError) return c.json({ error: updateError.message }, 500, corsHeaders); - - await supabase.from("agent_memory_review_actions").insert({ + } + + const { data: after, error: updateError } = await db().from("agent_memories") + .update(updates).eq("id", id).select("*").single(); + if (updateError) { + return c.json({ error: updateError.message }, 500, corsHeaders); + } + + await db().from("agent_memory_review_actions").insert({ memory_id: id, action: req.action, actor_id: req.actor_id ?? null, @@ -684,18 +1091,36 @@ app.patch("/memories/:id/review", async (c) => { after, }); - if (req.related_memory_id && ["merge", "supersede"].includes(req.action)) { - await supabase.from("agent_memory_relations").insert({ - from_memory_id: id, - to_memory_id: req.related_memory_id, - relation: req.action === "merge" ? "merged_into" : "supersedes", - confidence: 1, - }); + if ( + req.related_memory_id && + Object.keys(transition.relatedMemoryUpdates).length > 0 + ) { + const { error: relatedUpdateError } = await db() + .from("agent_memories") + .update(transition.relatedMemoryUpdates) + .eq("id", req.related_memory_id); + if (relatedUpdateError) { + return c.json({ error: relatedUpdateError.message }, 500, corsHeaders); + } + } + + if (transition.relation) { + const { error: relationError } = await db().from("agent_memory_relations") + .insert({ + from_memory_id: id, + to_memory_id: transition.relation.to_memory_id, + relation: transition.relation.relation, + confidence: 1, + }); + if (relationError) { + return c.json({ error: relationError.message }, 500, corsHeaders); + } } const eventMap: Record = { confirm: "memory_confirmed", edit: "memory_edited", + merge: "memory_merged", reject: "memory_rejected", supersede: "memory_superseded", dispute: "memory_disputed", @@ -714,14 +1139,51 @@ app.patch("/memories/:id/review", async (c) => { app.get("/recall-traces/:request_id", async (c) => { const request_id = c.req.param("request_id"); - const { data: trace, error } = await supabase.from("agent_memory_recall_traces").select("*").eq("request_id", request_id).single(); + const scope = requestedOrAllowedScope(c); + let q = db() + .from("agent_memory_recall_traces") + .select("*") + .eq("request_id", request_id); + if (scope.workspace_id) q = q.eq("workspace_id", scope.workspace_id); + if (scope.project_id) q = q.eq("project_id", scope.project_id); + const { data: trace, error } = await q.single(); if (error) return c.json({ error: error.message }, 404, corsHeaders); - const { data: items, error: itemError } = await supabase.from("agent_memory_recall_items").select("*, agent_memories(*)").eq("trace_id", trace.id).order("rank"); + const scopeDenied = scopeBlock(c, trace, { + workspace_id: scope.workspace_id ?? null, + project_id: scope.project_id ?? null, + }); + if (scopeDenied) return scopeDenied; + const { data: items, error: itemError } = await db().from( + "agent_memory_recall_items", + ).select("*, agent_memories(*)").eq("trace_id", trace.id).order("rank"); if (itemError) return c.json({ error: itemError.message }, 500, corsHeaders); - return c.json({ trace, items }, 200, corsHeaders); + const scopedItems = (items || []).filter( + ( + item: { + agent_memories?: { + workspace_id?: string | null; + project_id?: string | null; + } | null; + }, + ) => { + const memory = item.agent_memories as { + workspace_id?: string | null; + project_id?: string | null; + } | null; + if (!memory) return false; + return !scopeGuardViolation(memory, { + allowed: AGENT_MEMORY_ALLOWED_SCOPE, + requested: { + workspace_id: scope.workspace_id ?? null, + project_id: scope.project_id ?? null, + }, + }); + }, + ); + return c.json({ trace, items: scopedItems }, 200, corsHeaders); }); -Deno.serve((req) => { +export function fetchAgentMemoryApi(req: Request) { const url = new URL(req.url); if (url.pathname === "/agent-memory-api") { url.pathname = "/"; @@ -729,4 +1191,8 @@ Deno.serve((req) => { url.pathname = url.pathname.slice("/agent-memory-api".length); } return app.fetch(new Request(url, req)); -}); +} + +if (import.meta.main) { + Deno.serve(fetchAgentMemoryApi); +} diff --git a/integrations/agent-memory-api/policy.test.ts b/integrations/agent-memory-api/policy.test.ts new file mode 100644 index 000000000..a955b1a67 --- /dev/null +++ b/integrations/agent-memory-api/policy.test.ts @@ -0,0 +1,210 @@ +import { assertEquals } from "jsr:@std/assert@1"; + +import { + allowedScopeViolation, + buildMemoryThoughtFilter, + type RecallScope, + reviewTransition, + type ScopedMemory, + scopeGuardViolation, + scopeMatches, +} from "./policy.ts"; + +const baseScope: RecallScope = { + visibility: "project", + project_only: true, + include_unconfirmed: false, + include_stale: false, +}; + +function memory(overrides: Partial = {}): ScopedMemory { + return { + workspace_id: "workspace-1", + project_id: "project-1", + channel_id: "channel-1", + visibility: "project", + lifecycle_status: "active", + requires_user_confirmation: false, + review_status: "confirmed", + ...overrides, + }; +} + +Deno.test("buildMemoryThoughtFilter returns none when semantic search has no thought ids", () => { + assertEquals(buildMemoryThoughtFilter([]), { mode: "none", thoughtIds: [] }); +}); + +Deno.test("buildMemoryThoughtFilter returns thought ids when semantic search found candidates", () => { + assertEquals(buildMemoryThoughtFilter(["thought-1", "thought-2"]), { + mode: "thought_ids", + thoughtIds: ["thought-1", "thought-2"], + }); +}); + +Deno.test("allowedScopeViolation enforces workspace and project allowlists", () => { + assertEquals( + allowedScopeViolation( + { workspace_id: "workspace-1", project_id: "project-1" }, + { workspace_id: "workspace-1", project_id: "project-1" }, + ), + null, + ); + assertEquals( + allowedScopeViolation( + { workspace_id: "workspace-2", project_id: "project-1" }, + { workspace_id: "workspace-1", project_id: "project-1" }, + ), + "workspace_not_allowed", + ); + assertEquals( + allowedScopeViolation( + { workspace_id: "workspace-1", project_id: "project-2" }, + { workspace_id: "workspace-1", project_id: "project-1" }, + ), + "project_not_allowed", + ); +}); + +Deno.test("scopeGuardViolation rejects ID read records outside requested scope", () => { + assertEquals( + scopeGuardViolation( + { workspace_id: "workspace-1", project_id: "project-1" }, + { requested: { workspace_id: "workspace-1", project_id: "project-1" } }, + ), + null, + ); + assertEquals( + scopeGuardViolation( + { workspace_id: "workspace-2", project_id: "project-1" }, + { requested: { workspace_id: "workspace-1", project_id: "project-1" } }, + ), + "workspace_mismatch", + ); + assertEquals( + scopeGuardViolation( + { workspace_id: "workspace-1", project_id: "project-2" }, + { requested: { workspace_id: "workspace-1", project_id: "project-1" } }, + ), + "project_mismatch", + ); +}); + +Deno.test("scopeMatches blocks personal memory unless personal visibility is requested", () => { + assertEquals( + scopeMatches(memory({ visibility: "personal" }), { + workspace_id: "workspace-1", + project_id: "project-1", + channel: {}, + scope: baseScope, + }), + false, + ); + assertEquals( + scopeMatches(memory({ visibility: "personal" }), { + workspace_id: "workspace-1", + project_id: "project-1", + channel: {}, + scope: { ...baseScope, visibility: "personal" }, + }), + true, + ); +}); + +Deno.test("scopeMatches respects project_only project filter", () => { + assertEquals( + scopeMatches(memory({ project_id: "other-project" }), { + workspace_id: "workspace-1", + project_id: "project-1", + channel: {}, + scope: baseScope, + }), + false, + ); +}); + +Deno.test("scopeMatches blocks channel memory outside the requested channel", () => { + assertEquals( + scopeMatches(memory({ visibility: "channel", channel_id: "channel-2" }), { + workspace_id: "workspace-1", + project_id: "project-1", + channel: { id: "channel-1" }, + scope: { ...baseScope, visibility: "channel" }, + }), + false, + ); +}); + +Deno.test("scopeMatches allows workspace memory in project recall but blocks organization memory unless requested", () => { + assertEquals( + scopeMatches(memory({ visibility: "workspace", project_id: null }), { + workspace_id: "workspace-1", + project_id: "project-1", + channel: {}, + scope: baseScope, + }), + true, + ); + assertEquals( + scopeMatches(memory({ visibility: "organization", project_id: null }), { + workspace_id: "workspace-1", + project_id: "project-1", + channel: {}, + scope: baseScope, + }), + false, + ); + assertEquals( + scopeMatches(memory({ visibility: "organization", project_id: null }), { + workspace_id: "workspace-1", + project_id: "project-1", + channel: {}, + scope: { ...baseScope, visibility: "organization" }, + }), + true, + ); +}); + +Deno.test("reviewTransition marks a merged memory as merged and non-instructional", () => { + assertEquals( + reviewTransition({ action: "merge", related_memory_id: "target-memory" }), + { + memoryUpdates: { + review_status: "merged", + can_use_as_instruction: false, + requires_user_confirmation: false, + }, + relatedMemoryUpdates: {}, + relation: { + to_memory_id: "target-memory", + relation: "merged_into", + }, + }, + ); +}); + +Deno.test("reviewTransition marks the related memory superseded when current memory supersedes it", () => { + assertEquals( + reviewTransition({ action: "supersede", related_memory_id: "old-memory" }), + { + memoryUpdates: {}, + relatedMemoryUpdates: { + lifecycle_status: "superseded", + review_status: "stale", + can_use_as_instruction: false, + }, + relation: { + to_memory_id: "old-memory", + relation: "supersedes", + }, + }, + ); +}); + +Deno.test("reviewTransition keeps confirm semantics instruction-grade only after confirmation", () => { + assertEquals(reviewTransition({ action: "confirm" }).memoryUpdates, { + review_status: "confirmed", + provenance_status: "user_confirmed", + can_use_as_instruction: true, + requires_user_confirmation: false, + }); +}); diff --git a/integrations/agent-memory-api/policy.ts b/integrations/agent-memory-api/policy.ts new file mode 100644 index 000000000..511eae86f --- /dev/null +++ b/integrations/agent-memory-api/policy.ts @@ -0,0 +1,209 @@ +export type RecallScope = { + visibility?: string | null; + project_only: boolean; + include_unconfirmed: boolean; + include_stale: boolean; +}; + +export type RecallPolicyRequest = { + workspace_id: string; + project_id?: string | null; + channel: { + id?: string | null; + }; + scope: RecallScope; +}; + +export type ScopedMemory = { + workspace_id: string; + project_id: string | null; + channel_id: string | null; + visibility: string; + lifecycle_status: string; + requires_user_confirmation: boolean; + review_status: string; +}; + +export type AllowedAgentMemoryScope = { + workspace_id?: string | null; + project_id?: string | null; +}; + +export type ScopedRecord = { + workspace_id?: string | null; + project_id?: string | null; +}; + +export function allowedScopeViolation( + record: ScopedRecord, + allowed: AllowedAgentMemoryScope, +): string | null { + if (allowed.workspace_id && record.workspace_id !== allowed.workspace_id) { + return "workspace_not_allowed"; + } + if (allowed.project_id && record.project_id !== allowed.project_id) { + return "project_not_allowed"; + } + return null; +} + +export function scopeGuardViolation( + record: ScopedRecord, + options: { + allowed?: AllowedAgentMemoryScope; + requested?: ScopedRecord; + }, +): string | null { + const allowedViolation = allowedScopeViolation(record, options.allowed ?? {}); + if (allowedViolation) return allowedViolation; + + if ( + options.requested?.workspace_id && + record.workspace_id !== options.requested.workspace_id + ) { + return "workspace_mismatch"; + } + if ( + options.requested?.project_id && + record.project_id !== options.requested.project_id + ) { + return "project_mismatch"; + } + return null; +} + +export function buildMemoryThoughtFilter(thoughtIds: string[]) { + const unique = [...new Set(thoughtIds.filter(Boolean))]; + return unique.length === 0 + ? { mode: "none" as const, thoughtIds: [] as string[] } + : { mode: "thought_ids" as const, thoughtIds: unique }; +} + +export function scopeMatches( + memory: ScopedMemory, + req: RecallPolicyRequest, +): boolean { + if (memory.workspace_id !== req.workspace_id) return false; + if ( + req.scope.project_only && req.project_id && memory.project_id && + memory.project_id !== req.project_id + ) return false; + if ( + !req.scope.include_stale && + ["stale", "superseded", "rejected", "disputed"].includes( + memory.lifecycle_status, + ) + ) return false; + if ( + !req.scope.include_unconfirmed && memory.requires_user_confirmation && + memory.review_status === "pending" + ) return false; + + const requestedVisibility = req.scope.visibility || "project"; + if (memory.visibility === "personal") { + return requestedVisibility === "personal"; + } + if (memory.visibility === "channel") { + return requestedVisibility === "channel" && Boolean(req.channel.id) && + memory.channel_id === req.channel.id; + } + if (memory.visibility === "organization") { + return requestedVisibility === "organization"; + } + if (memory.visibility === "workspace") { + return ["project", "workspace", "organization"].includes( + requestedVisibility, + ); + } + return true; +} + +export type ReviewTransitionInput = { + action: + | "confirm" + | "edit" + | "evidence_only" + | "restrict_scope" + | "mark_stale" + | "merge" + | "reject" + | "dispute" + | "supersede"; + visibility?: string; + content?: string; + summary?: string; + related_memory_id?: string; +}; + +export function reviewTransition(input: ReviewTransitionInput) { + const memoryUpdates: Record = {}; + const relatedMemoryUpdates: Record = {}; + let relation: { to_memory_id: string; relation: string } | null = null; + + if (input.action === "confirm") { + Object.assign(memoryUpdates, { + review_status: "confirmed", + provenance_status: "user_confirmed", + can_use_as_instruction: true, + requires_user_confirmation: false, + }); + } else if (input.action === "evidence_only") { + Object.assign(memoryUpdates, { + review_status: "evidence_only", + can_use_as_instruction: false, + can_use_as_evidence: true, + requires_user_confirmation: false, + }); + } else if (input.action === "reject") { + Object.assign(memoryUpdates, { + review_status: "rejected", + lifecycle_status: "rejected", + can_use_as_instruction: false, + can_use_as_evidence: false, + }); + } else if (input.action === "mark_stale") { + Object.assign(memoryUpdates, { + review_status: "stale", + lifecycle_status: "stale", + can_use_as_instruction: false, + }); + } else if (input.action === "dispute") { + Object.assign(memoryUpdates, { + lifecycle_status: "disputed", + provenance_status: "disputed", + can_use_as_instruction: false, + }); + } else if (input.action === "restrict_scope") { + Object.assign(memoryUpdates, { + review_status: "restricted", + visibility: input.visibility || "personal", + }); + } else if (input.action === "edit") { + if (input.content) memoryUpdates.content = input.content; + if (input.summary) memoryUpdates.summary = input.summary; + } else if (input.action === "merge") { + Object.assign(memoryUpdates, { + review_status: "merged", + can_use_as_instruction: false, + requires_user_confirmation: false, + }); + if (input.related_memory_id) { + relation = { + to_memory_id: input.related_memory_id, + relation: "merged_into", + }; + } + } else if (input.action === "supersede" && input.related_memory_id) { + Object.assign(relatedMemoryUpdates, { + lifecycle_status: "superseded", + review_status: "stale", + can_use_as_instruction: false, + }); + relation = { + to_memory_id: input.related_memory_id, + relation: "supersedes", + }; + } + + return { memoryUpdates, relatedMemoryUpdates, relation }; +} diff --git a/integrations/agent-memory-api/production-boundary.test.ts b/integrations/agent-memory-api/production-boundary.test.ts new file mode 100644 index 000000000..6bfd7f589 --- /dev/null +++ b/integrations/agent-memory-api/production-boundary.test.ts @@ -0,0 +1,106 @@ +import { assert, assertStringIncludes } from "jsr:@std/assert@1"; + +const source = await Deno.readTextFile("./index.ts"); + +Deno.test("index wires header/Bearer auth helper and keeps query-key auth opt-in", () => { + assertStringIncludes(source, "selectAccessKey(c.req.raw.headers"); + assertStringIncludes(source, "AGENT_MEMORY_ALLOW_QUERY_KEY"); + assertStringIncludes(source, "accessKeyMatches(provided, MCP_ACCESS_KEY)"); +}); + +Deno.test("index reads production scope allowlist env vars", () => { + assertStringIncludes(source, "AGENT_MEMORY_ALLOWED_WORKSPACE_ID"); + assertStringIncludes(source, "AGENT_MEMORY_ALLOWED_PROJECT_ID"); +}); + +Deno.test("index guards ID-based read routes before returning records", () => { + const memoryRoute = source.indexOf('app.get("/memories/:id"'); + const traceRoute = source.indexOf('app.get("/recall-traces/:request_id"'); + assert(memoryRoute > -1, "memory ID route must exist"); + assert(traceRoute > -1, "recall trace ID route must exist"); + + const memoryRouteBody = source.slice(memoryRoute, traceRoute); + const traceRouteBody = source.slice(traceRoute); + assertStringIncludes(memoryRouteBody, "scopeBlock(c, data"); + assertStringIncludes(traceRouteBody, "scopeBlock(c, trace"); +}); + +Deno.test("index pushes requested or allowed scope into ID-based read queries", () => { + const memoryRoute = source.indexOf('app.get("/memories/:id"'); + const traceRoute = source.indexOf('app.get("/recall-traces/:request_id"'); + const memoryRouteBody = source.slice(memoryRoute, traceRoute); + const traceRouteBody = source.slice(traceRoute); + + assertStringIncludes(source, "function requestedOrAllowedScope"); + assertStringIncludes( + memoryRouteBody, + "const scope = requestedOrAllowedScope(c)", + ); + assertStringIncludes( + memoryRouteBody, + 'q = q.eq("workspace_id", scope.workspace_id)', + ); + assertStringIncludes( + memoryRouteBody, + 'q = q.eq("project_id", scope.project_id)', + ); + assertStringIncludes( + traceRouteBody, + "const scope = requestedOrAllowedScope(c)", + ); + assertStringIncludes( + traceRouteBody, + 'q = q.eq("workspace_id", scope.workspace_id)', + ); + assertStringIncludes( + traceRouteBody, + 'q = q.eq("project_id", scope.project_id)', + ); +}); + +Deno.test("index scope-filters nested trace memories before returning trace items", () => { + const traceRoute = source.indexOf('app.get("/recall-traces/:request_id"'); + const traceRouteBody = source.slice(traceRoute); + + assertStringIncludes( + traceRouteBody, + "const scopedItems = (items || []).filter", + ); + assertStringIncludes(traceRouteBody, "const memory = item.agent_memories"); + assertStringIncludes(traceRouteBody, "scopeGuardViolation(memory"); + assertStringIncludes( + traceRouteBody, + "return c.json({ trace, items: scopedItems }", + ); +}); + +Deno.test("index validates related memory scope before review transition side effects", () => { + const reviewRoute = source.indexOf('app.patch("/memories/:id/review"'); + const traceRoute = source.indexOf('app.get("/recall-traces/:request_id"'); + const reviewRouteBody = source.slice(reviewRoute, traceRoute); + + assertStringIncludes(reviewRouteBody, 'select("workspace_id, project_id")'); + assertStringIncludes( + reviewRouteBody, + "const relatedScopeDenied = allowedScopeBlock", + ); + assertStringIncludes( + reviewRouteBody, + "if (relatedScopeDenied) return relatedScopeDenied", + ); + assert( + reviewRouteBody.indexOf("const relatedScopeDenied = allowedScopeBlock") < + reviewRouteBody.indexOf(".update(updates)"), + "related memory scope must be validated before primary memory updates", + ); + assert( + reviewRouteBody.indexOf("const relatedScopeDenied = allowedScopeBlock") < + reviewRouteBody.indexOf('from("agent_memory_review_actions")'), + "related memory scope must be validated before review action writes", + ); + assert( + reviewRouteBody.indexOf("const relatedScopeDenied = allowedScopeBlock") < + reviewRouteBody.indexOf(".update(transition.relatedMemoryUpdates)"), + "related memory scope must be validated before related memory updates", + ); +}); diff --git a/integrations/agent-memory-api/read-only.test.ts b/integrations/agent-memory-api/read-only.test.ts new file mode 100644 index 000000000..151e0dc4d --- /dev/null +++ b/integrations/agent-memory-api/read-only.test.ts @@ -0,0 +1,57 @@ +import { assertEquals } from "jsr:@std/assert@1"; + +import { + parseBooleanEnv, + READ_ONLY_ERROR, + shouldBlockWriteEndpoint, +} from "./read-only.ts"; + +Deno.test("READ_ONLY_ERROR is runtime-neutral", () => { + assertEquals(READ_ONLY_ERROR, { + error: "read_only_mode", + message: "Agent Memory API is read-only for this environment.", + }); +}); + +Deno.test("parseBooleanEnv recognizes truthy variants", () => { + assertEquals(parseBooleanEnv("true"), true); + assertEquals(parseBooleanEnv("TRUE"), true); + assertEquals(parseBooleanEnv("1"), true); + assertEquals(parseBooleanEnv(" yes "), true); + assertEquals(parseBooleanEnv("on"), true); +}); + +Deno.test("parseBooleanEnv returns false for non-truthy values", () => { + assertEquals(parseBooleanEnv(undefined), false); + assertEquals(parseBooleanEnv(""), false); + assertEquals(parseBooleanEnv("0"), false); + assertEquals(parseBooleanEnv("false"), false); + assertEquals(parseBooleanEnv("no"), false); +}); + +Deno.test("shouldBlockWriteEndpoint blocks all write routes in read-only mode", () => { + assertEquals(shouldBlockWriteEndpoint("POST", "/recall", true), true); + assertEquals(shouldBlockWriteEndpoint("POST", "/writeback", true), true); + assertEquals( + shouldBlockWriteEndpoint("POST", "/recall/:request_id/usage", true), + true, + ); + assertEquals( + shouldBlockWriteEndpoint("PATCH", "/memories/:id/review", true), + true, + ); +}); + +Deno.test("shouldBlockWriteEndpoint leaves read routes available", () => { + assertEquals(shouldBlockWriteEndpoint("GET", "/health", true), false); + assertEquals(shouldBlockWriteEndpoint("GET", "/memories", true), false); + assertEquals( + shouldBlockWriteEndpoint("GET", "/memories/review", true), + false, + ); + assertEquals( + shouldBlockWriteEndpoint("GET", "/recall-traces/:request_id", true), + false, + ); + assertEquals(shouldBlockWriteEndpoint("POST", "/recall", false), false); +}); diff --git a/integrations/agent-memory-api/read-only.ts b/integrations/agent-memory-api/read-only.ts new file mode 100644 index 000000000..bb8b74b43 --- /dev/null +++ b/integrations/agent-memory-api/read-only.ts @@ -0,0 +1,33 @@ +export const READ_ONLY_ERROR = { + error: "read_only_mode", + message: "Agent Memory API is read-only for this environment.", +}; + +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); + +const WRITE_ENDPOINTS = new Set([ + "POST /recall", + "POST /writeback", + "POST /recall/:request_id/usage", + "PATCH /memories/:id/review", +]); + +export function parseBooleanEnv(value: string | undefined): boolean { + if (!value) return false; + return TRUE_VALUES.has(value.trim().toLowerCase()); +} + +export function readOnlyEnabledFromEnv( + value = Deno.env.get("AGENT_MEMORY_READ_ONLY"), +): boolean { + return parseBooleanEnv(value); +} + +export function shouldBlockWriteEndpoint( + method: string, + endpointPattern: string, + readOnlyEnabled: boolean, +): boolean { + if (!readOnlyEnabled) return false; + return WRITE_ENDPOINTS.has(`${method.toUpperCase()} ${endpointPattern}`); +} diff --git a/integrations/agent-memory-api/smoke/live-smoke.mjs b/integrations/agent-memory-api/smoke/live-smoke.mjs index 641c92b7f..36d0270ed 100755 --- a/integrations/agent-memory-api/smoke/live-smoke.mjs +++ b/integrations/agent-memory-api/smoke/live-smoke.mjs @@ -5,6 +5,11 @@ const accessKey = process.env.OB1_AGENT_MEMORY_KEY || process.env.MCP_ACCESS_KEY if (!accessKey) { fail("Set OB1_AGENT_MEMORY_KEY or MCP_ACCESS_KEY."); } +if (isTruthy(process.env.OB1_AGENT_MEMORY_READ_ONLY) || isTruthy(process.env.AGENT_MEMORY_READ_ONLY)) { + fail( + "live-smoke.mjs is write-heavy and cannot run in read-only mode. Use the read-only health/auth smoke flow instead.", + ); +} const workspaceId = process.env.OB1_AGENT_MEMORY_WORKSPACE_ID || "ob1-staging"; const projectId = process.env.OB1_AGENT_MEMORY_PROJECT_ID || "agent-memory-api-smoke"; @@ -233,6 +238,11 @@ function requiredEnv(name) { return value; } +function isTruthy(value) { + if (!value) return false; + return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); +} + function assert(condition, message) { if (!condition) fail(message); } diff --git a/integrations/agent-memory-api/smoke/read-only-smoke.mjs b/integrations/agent-memory-api/smoke/read-only-smoke.mjs new file mode 100755 index 000000000..65fcdf595 --- /dev/null +++ b/integrations/agent-memory-api/smoke/read-only-smoke.mjs @@ -0,0 +1,382 @@ +#!/usr/bin/env node + +import process from "node:process"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export const ZERO_UUID = "00000000-0000-0000-0000-000000000000"; +export const NONEXISTENT_TRACE_ID = "phase-8c-readonly-smoke-nonexistent"; + +export function isTruthy(value) { + if (!value) return false; + return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); +} + +export function parseArgs(argv) { + const args = new Set(argv.slice(2)); + if (args.has("--help") || args.has("-h")) { + return { help: true, execute: false }; + } + return { + help: false, + execute: args.has("--execute"), + }; +} + +export function buildHarnessConfig(options = {}, env = process.env) { + const execute = options.execute === true; + const endpoint = env.OB1_AGENT_MEMORY_ENDPOINT?.replace(/\/$/, "") || + "https://.supabase.co/functions/v1/agent-memory-api"; + const accessKey = env.OB1_AGENT_MEMORY_KEY || env.MCP_ACCESS_KEY || ""; + const workspaceId = env.OB1_AGENT_MEMORY_WORKSPACE_ID || "humestone-agent-memory-staging"; + const projectId = env.OB1_AGENT_MEMORY_PROJECT_ID || "phase-8c-readonly-smoke"; + const outOfScopeWorkspaceId = env.OB1_AGENT_MEMORY_OUT_OF_SCOPE_WORKSPACE_ID || `${workspaceId}-out-of-scope`; + const outOfScopeProjectId = env.OB1_AGENT_MEMORY_OUT_OF_SCOPE_PROJECT_ID || `${projectId}-out-of-scope`; + + if (!execute) { + return { execute, endpoint, accessKey, workspaceId, projectId, outOfScopeWorkspaceId, outOfScopeProjectId }; + } + + if (!isTruthy(env.AGENT_MEMORY_READ_ONLY)) { + throw new Error("Live execution is blocked unless AGENT_MEMORY_READ_ONLY=true."); + } + if (env.AGENT_MEMORY_ALLOW_QUERY_KEY && isTruthy(env.AGENT_MEMORY_ALLOW_QUERY_KEY)) { + throw new Error("Live execution is blocked when AGENT_MEMORY_ALLOW_QUERY_KEY is enabled."); + } + if (env.AGENT_MEMORY_ALLOWED_WORKSPACE_ID !== workspaceId) { + throw new Error("Set AGENT_MEMORY_ALLOWED_WORKSPACE_ID to match OB1_AGENT_MEMORY_WORKSPACE_ID for --execute."); + } + if (env.AGENT_MEMORY_ALLOWED_PROJECT_ID !== projectId) { + throw new Error("Set AGENT_MEMORY_ALLOWED_PROJECT_ID to match OB1_AGENT_MEMORY_PROJECT_ID for --execute."); + } + if (!env.OB1_AGENT_MEMORY_ENDPOINT) { + throw new Error("Set OB1_AGENT_MEMORY_ENDPOINT for --execute."); + } + if (!accessKey) { + throw new Error("Set OB1_AGENT_MEMORY_KEY or MCP_ACCESS_KEY for --execute."); + } + + return { execute, endpoint, accessKey, workspaceId, projectId, outOfScopeWorkspaceId, outOfScopeProjectId }; +} + +export function buildChecks(config) { + const readQuery = new URLSearchParams({ + workspace_id: config.workspaceId, + project_id: config.projectId, + }); + const readQueryWithLimit = new URLSearchParams({ + workspace_id: config.workspaceId, + project_id: config.projectId, + limit: "20", + }); + + return [ + { + id: "health_missing_key", + method: "GET", + path: "/health", + auth: "none", + expectedStatus: 401, + expectedError: "Invalid or missing access key", + }, + { + id: "health_invalid_key", + method: "GET", + path: "/health", + auth: "invalid", + expectedStatus: 401, + expectedError: "Invalid or missing access key", + }, + { + id: "health_valid_key", + method: "GET", + path: "/health", + auth: "valid", + expectedStatus: 200, + expectedFields: { + ok: true, + service: "agent-memory-api", + }, + }, + { + id: "health_valid_bearer", + method: "GET", + path: "/health", + auth: "valid_bearer", + expectedStatus: 200, + expectedFields: { + ok: true, + service: "agent-memory-api", + }, + }, + { + id: "health_query_key_not_used", + method: "GET", + path: "/health", + queryKey: true, + displayPath: "/health?key=", + auth: "none", + expectedStatus: 401, + expectedError: "Invalid or missing access key", + note: "Production verification must use headers, not ?key= URLs.", + }, + { + id: "memories_empty_state", + method: "GET", + path: `/memories?${readQueryWithLimit.toString()}`, + auth: "valid", + expectedStatus: 200, + expectsEmptyMemories: true, + }, + { + id: "memories_workspace_not_allowed", + method: "GET", + path: `/memories?${new URLSearchParams({ + workspace_id: config.outOfScopeWorkspaceId, + project_id: config.projectId, + limit: "20", + }).toString()}`, + auth: "valid", + expectedStatus: 403, + expectedError: "scope_not_allowed", + }, + { + id: "memories_project_not_allowed", + method: "GET", + path: `/memories?${new URLSearchParams({ + workspace_id: config.workspaceId, + project_id: config.outOfScopeProjectId, + limit: "20", + }).toString()}`, + auth: "valid", + expectedStatus: 403, + expectedError: "scope_not_allowed", + }, + { + id: "review_queue_empty_state", + method: "GET", + path: `/memories/review?${readQuery.toString()}`, + auth: "valid", + expectedStatus: 200, + expectsEmptyMemories: true, + }, + { + id: "memory_not_found", + method: "GET", + path: `/memories/${ZERO_UUID}`, + auth: "valid", + expectedStatus: 404, + }, + { + id: "recall_trace_not_found", + method: "GET", + path: `/recall-traces/${NONEXISTENT_TRACE_ID}`, + auth: "valid", + expectedStatus: 404, + }, + { + id: "recall_write_blocked", + method: "POST", + path: "/recall", + auth: "valid", + expectedStatus: 403, + expectedError: "read_only_mode", + body: {}, + }, + { + id: "writeback_write_blocked", + method: "POST", + path: "/writeback", + auth: "valid", + expectedStatus: 403, + expectedError: "read_only_mode", + body: {}, + }, + { + id: "usage_write_blocked", + method: "POST", + path: `/recall/${NONEXISTENT_TRACE_ID}/usage`, + auth: "valid", + expectedStatus: 403, + expectedError: "read_only_mode", + body: {}, + }, + { + id: "review_write_blocked", + method: "PATCH", + path: `/memories/${ZERO_UUID}/review`, + auth: "valid", + expectedStatus: 403, + expectedError: "read_only_mode", + body: {}, + }, + ]; +} + +function makeHeaders(authMode, key) { + const headers = { + "content-type": "application/json", + }; + if (authMode === "valid") { + headers["x-brain-key"] = key; + } else if (authMode === "valid_bearer") { + headers.authorization = `Bearer ${key}`; + } else if (authMode === "invalid") { + headers["x-brain-key"] = "phase-8c-invalid-key"; + } + return headers; +} + +function validateResult(check, payload) { + if (check.expectedError !== undefined) { + if (payload.error !== check.expectedError) { + throw new Error(`${check.id}: expected error=${check.expectedError}, got ${JSON.stringify(payload).slice(0, 400)}`); + } + } + if (check.expectedFields) { + for (const [field, expected] of Object.entries(check.expectedFields)) { + if (payload[field] !== expected) { + throw new Error(`${check.id}: expected ${field}=${JSON.stringify(expected)}, got ${JSON.stringify(payload[field])}`); + } + } + } + if (check.expectsEmptyMemories) { + if (!Array.isArray(payload.memories)) { + throw new Error(`${check.id}: expected memories array in response.`); + } + if (payload.memories.length !== 0) { + throw new Error(`${check.id}: expected empty memories array, got ${payload.memories.length}.`); + } + if (typeof payload.count === "number" && payload.count !== 0) { + throw new Error(`${check.id}: expected count=0, got ${payload.count}.`); + } + } +} + +async function runCheck(config, check) { + const path = check.queryKey + ? `${check.path}?key=${encodeURIComponent(config.accessKey)}` + : check.path; + const displayPath = check.displayPath || check.path; + const response = await fetch(`${config.endpoint}${path}`, { + method: check.method, + headers: makeHeaders(check.auth, config.accessKey), + body: check.body === undefined ? undefined : JSON.stringify(check.body), + }); + const text = await response.text(); + let payload = {}; + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = { raw: text }; + } + } + + if (response.status !== check.expectedStatus) { + throw new Error(`${check.id}: expected status ${check.expectedStatus}, got ${response.status}. body=${JSON.stringify(payload).slice(0, 600)}`); + } + + validateResult(check, payload); + return { + id: check.id, + method: check.method, + path: displayPath, + status: response.status, + ok: true, + }; +} + +function buildDryRunSummary(config, checks) { + return { + ok: true, + mode: "dry-run", + endpoint: config.endpoint, + workspace_id: config.workspaceId, + project_id: config.projectId, + checks: checks.map((check) => ({ + id: check.id, + method: check.method, + path: check.displayPath || check.path, + expected_status: check.expectedStatus, + auth: check.auth, + expected_error: check.expectedError || null, + })), + notes: [ + "No network calls were made.", + "This harness never invokes the stock write-heavy live-smoke script.", + "Live execution is blocked unless read-only mode and workspace/project allowlists are explicit.", + "Live execution uses header auth and refuses AGENT_MEMORY_ALLOW_QUERY_KEY=true.", + "When executed, write probes use empty payloads and must return read_only_mode=403.", + ], + }; +} + +function printHelp() { + console.log(`Usage: + node integrations/agent-memory-api/smoke/read-only-smoke.mjs [--execute] + +Defaults to dry-run mode with no network calls. +Use --execute only after explicit approval for live staging read-only smoke. + +Required for --execute: + AGENT_MEMORY_READ_ONLY=true + AGENT_MEMORY_ALLOWED_WORKSPACE_ID= + AGENT_MEMORY_ALLOWED_PROJECT_ID= + OB1_AGENT_MEMORY_ENDPOINT=https://.supabase.co/functions/v1/agent-memory-api + OB1_AGENT_MEMORY_KEY= (or MCP_ACCESS_KEY) + +Optional: + OB1_AGENT_MEMORY_WORKSPACE_ID=humestone-agent-memory-staging + OB1_AGENT_MEMORY_PROJECT_ID=phase-8c-readonly-smoke + OB1_AGENT_MEMORY_OUT_OF_SCOPE_WORKSPACE_ID= + OB1_AGENT_MEMORY_OUT_OF_SCOPE_PROJECT_ID= +`); +} + +export async function runCli(argv = process.argv, env = process.env) { + const parsed = parseArgs(argv); + if (parsed.help) { + printHelp(); + return { ok: true, mode: "help" }; + } + + const config = buildHarnessConfig(parsed, env); + const checks = buildChecks(config); + + if (!config.execute) { + const summary = buildDryRunSummary(config, checks); + console.log(JSON.stringify(summary, null, 2)); + return summary; + } + + const results = []; + for (const check of checks) { + const result = await runCheck(config, check); + results.push(result); + } + + const summary = { + ok: true, + mode: "execute", + endpoint: config.endpoint, + workspace_id: config.workspaceId, + project_id: config.projectId, + total_checks: results.length, + checks: results, + }; + console.log(JSON.stringify(summary, null, 2)); + return summary; +} + +const isEntrypoint = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href; + +if (isEntrypoint) { + runCli().catch((error) => { + console.error(JSON.stringify({ + ok: false, + error: error?.message || String(error), + }, null, 2)); + process.exit(1); + }); +} diff --git a/integrations/agent-memory-api/smoke/read-only-smoke.test.mjs b/integrations/agent-memory-api/smoke/read-only-smoke.test.mjs new file mode 100644 index 000000000..67c9b4f61 --- /dev/null +++ b/integrations/agent-memory-api/smoke/read-only-smoke.test.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildChecks, + buildHarnessConfig, + parseArgs, +} from "./read-only-smoke.mjs"; + +test("parseArgs defaults to dry-run mode", () => { + const parsed = parseArgs(["node", "read-only-smoke.mjs"]); + assert.equal(parsed.help, false); + assert.equal(parsed.execute, false); +}); + +test("parseArgs enables execute mode with --execute", () => { + const parsed = parseArgs(["node", "read-only-smoke.mjs", "--execute"]); + assert.equal(parsed.help, false); + assert.equal(parsed.execute, true); +}); + +test("buildHarnessConfig allows dry-run without endpoint and key", () => { + const config = buildHarnessConfig({ execute: false }, {}); + assert.equal(config.execute, false); + assert.equal(config.workspaceId, "humestone-agent-memory-staging"); + assert.equal(config.projectId, "phase-8c-readonly-smoke"); + assert.equal(config.outOfScopeWorkspaceId, "humestone-agent-memory-staging-out-of-scope"); + assert.equal(config.outOfScopeProjectId, "phase-8c-readonly-smoke-out-of-scope"); +}); + +test("buildHarnessConfig blocks execute when read-only flag is missing", () => { + assert.throws( + () => buildHarnessConfig({ execute: true }, { + OB1_AGENT_MEMORY_ENDPOINT: "https://example.supabase.co/functions/v1/agent-memory-api", + OB1_AGENT_MEMORY_KEY: "fake-key", + }), + /AGENT_MEMORY_READ_ONLY=true/, + ); +}); + +test("buildHarnessConfig blocks execute unless allowlist env matches the requested scope", () => { + assert.throws( + () => buildHarnessConfig({ execute: true }, { + AGENT_MEMORY_READ_ONLY: "true", + OB1_AGENT_MEMORY_ENDPOINT: "https://example.supabase.co/functions/v1/agent-memory-api", + OB1_AGENT_MEMORY_KEY: "fake-key", + OB1_AGENT_MEMORY_WORKSPACE_ID: "workspace-1", + OB1_AGENT_MEMORY_PROJECT_ID: "project-1", + }), + /AGENT_MEMORY_ALLOWED_WORKSPACE_ID/, + ); + assert.throws( + () => buildHarnessConfig({ execute: true }, { + AGENT_MEMORY_READ_ONLY: "true", + AGENT_MEMORY_ALLOWED_WORKSPACE_ID: "workspace-1", + OB1_AGENT_MEMORY_ENDPOINT: "https://example.supabase.co/functions/v1/agent-memory-api", + OB1_AGENT_MEMORY_KEY: "fake-key", + OB1_AGENT_MEMORY_WORKSPACE_ID: "workspace-1", + OB1_AGENT_MEMORY_PROJECT_ID: "project-1", + }), + /AGENT_MEMORY_ALLOWED_PROJECT_ID/, + ); +}); + +test("buildHarnessConfig blocks execute when query-string keys are enabled", () => { + assert.throws( + () => buildHarnessConfig({ execute: true }, { + AGENT_MEMORY_READ_ONLY: "true", + AGENT_MEMORY_ALLOW_QUERY_KEY: "true", + AGENT_MEMORY_ALLOWED_WORKSPACE_ID: "workspace-1", + AGENT_MEMORY_ALLOWED_PROJECT_ID: "project-1", + OB1_AGENT_MEMORY_ENDPOINT: "https://example.supabase.co/functions/v1/agent-memory-api", + OB1_AGENT_MEMORY_KEY: "fake-key", + OB1_AGENT_MEMORY_WORKSPACE_ID: "workspace-1", + OB1_AGENT_MEMORY_PROJECT_ID: "project-1", + }), + /AGENT_MEMORY_ALLOW_QUERY_KEY/, + ); +}); + +test("buildChecks includes expected health/auth/read/write assertions", () => { + const checks = buildChecks({ + workspaceId: "humestone-agent-memory-staging", + projectId: "phase-8c-readonly-smoke", + }); + + const ids = checks.map((check) => check.id); + assert.deepEqual(ids, [ + "health_missing_key", + "health_invalid_key", + "health_valid_key", + "health_valid_bearer", + "health_query_key_not_used", + "memories_empty_state", + "memories_workspace_not_allowed", + "memories_project_not_allowed", + "review_queue_empty_state", + "memory_not_found", + "recall_trace_not_found", + "recall_write_blocked", + "writeback_write_blocked", + "usage_write_blocked", + "review_write_blocked", + ]); + + const blockedChecks = checks.filter((check) => check.id.endsWith("_write_blocked")); + assert.equal(blockedChecks.length, 4); + for (const blocked of blockedChecks) { + assert.equal(blocked.expectedStatus, 403); + assert.equal(blocked.expectedError, "read_only_mode"); + } + + const queryKeyCheck = checks.find((check) => check.id === "health_query_key_not_used"); + assert.equal(queryKeyCheck.queryKey, true); + assert.equal(queryKeyCheck.displayPath, "/health?key="); +}); diff --git a/integrations/chrome-capture-extension/.gitignore b/integrations/chrome-capture-extension/.gitignore new file mode 100644 index 000000000..2792f6051 --- /dev/null +++ b/integrations/chrome-capture-extension/.gitignore @@ -0,0 +1,20 @@ +# Runtime captures and local dev artifacts +data/captures/ +data/logs/ + +# Chrome Web Store build artifacts +*.zip +*.crx +*.pem +dist/ +build/ + +# Editor and OS noise +.DS_Store +Thumbs.db +.vscode/ +.idea/ + +# Secrets — never commit +.env +.env.local diff --git a/integrations/chrome-capture-extension/README.md b/integrations/chrome-capture-extension/README.md new file mode 100644 index 000000000..e5c258827 --- /dev/null +++ b/integrations/chrome-capture-extension/README.md @@ -0,0 +1,234 @@ +# Chrome Capture Extension + +![Community Contribution](https://img.shields.io/badge/OB1_COMMUNITY-Approved_Contribution-2ea44f?style=for-the-badge&logo=github) + +**Created by [@alanshurafa](https://github.com/alanshurafa)** + +> Chrome MV3 extension that captures conversations from Claude, ChatGPT, and Gemini into your Open Brain via the REST API gateway. + +## What It Does + +A client-side Chrome (or Chromium-based browser) extension that sits on top of Claude.ai, chatgpt.com, and gemini.google.com. When you finish an interesting exchange, click the extension icon and the extension extracts the latest user + assistant turn from the page DOM, runs local sensitivity and duplicate filters, and POSTs the result to your Open Brain REST API gateway. It also supports bulk backfill from Claude and ChatGPT using their internal conversation APIs so you can import your existing chat history in one pass. + +This is a **client-side** integration — unlike the other integrations in this repo (Slack, Discord, email capture) which deploy as Supabase Edge Functions, a Chrome extension runs entirely in the user's browser. It does **not** register as an MCP server. All it does is call the REST API gateway's `/ingest` endpoint with standard `x-brain-key` auth. Every user installs it locally against their own Open Brain. + +## Screenshots + +Placeholder. See [`docs/screenshots/README.md`](docs/screenshots/README.md) for the expected filenames. The four targets are: + +- First-run Configure screen (URL + API key entry) +- Popup on a Claude tab with Capture Current Response visible +- Activity log showing a successful capture plus a duplicate/skipped one +- Sync tab with Claude full/incremental sync controls + +## Prerequisites + +- Working Open Brain setup ([guide](../../docs/01-getting-started.md)) +- The [`integrations/open-brain-rest`](../open-brain-rest/) gateway deployed and reachable — the extension POSTs to `/open-brain-rest/ingest` and pings `/open-brain-rest/health` +- An `MCP_ACCESS_KEY` (or equivalent `x-brain-key` token) issued by your Open Brain for this device +- Chrome 120+, or any Chromium-based browser that supports MV3 (Edge 120+, Brave, Arc, Opera) + +## Credential Tracker + +Copy this block into a text editor and fill it in as you go. + +```text +CHROME CAPTURE EXTENSION -- CREDENTIAL TRACKER +-------------------------------------- + +FROM YOUR OPEN BRAIN SETUP + REST API base URL: ____________ + (Supabase example: https://YOUR_PROJECT_REF.supabase.co/functions/v1 + Self-hosted example: https://brain.example.com) + x-brain-key API key: ____________ + +BROWSER INFO + Browser + version: ____________ + Extension ID (after install): ____________ + +-------------------------------------- +``` + +## Installation + +1. Download or clone this repository to your machine +2. Open your Chromium-based browser and go to `chrome://extensions` +3. Toggle **Developer mode** on (top-right) +4. Click **Load unpacked** and pick the `integrations/chrome-capture-extension/` folder +5. Pin the extension icon to the toolbar so you can reach it quickly +6. A new tab opens automatically on first install — the Configure Open Brain screen (see below) + +## First-Run Config + +The extension ships with **no hardcoded server URLs**. On first install it opens `popup/config.html` and asks for two things: + +1. **Open Brain REST API URL** — the base URL of your REST API gateway. Examples: + - Supabase-hosted: `https://your-project-ref.supabase.co/functions/v1` + - Self-hosted: `https://brain.example.com` +2. **API Key** — the `x-brain-key` (`MCP_ACCESS_KEY`) you configured when deploying the REST API integration + +When you click **Save & Grant Permission**, Chrome shows a native permission prompt asking whether the extension may access the specific origin you entered. Approve it. This is a one-time grant — Chrome remembers it and the extension can now talk to your Open Brain without asking again. You can revoke the grant any time from `chrome://extensions → Open Brain Capture → Details → Site access`. + +**Storage details:** +- API key → `chrome.storage.local` (per-device only, **never** synced across Chrome profiles) +- API URL (`apiEndpoint`) → `chrome.storage.local` (per-device only). Rationale: the URL alone isn't a secret, but combining it with your Google-account-wide synced profiles would let anyone signed into the same Google account on a shared or loaner laptop see a pre-filled target for your Open Brain. Treating the endpoint as per-device avoids that surface, and also sidesteps `chrome.storage.sync`'s 8KB-per-item quota, which could silently reject saves for very long URLs. +- Platform toggles (ChatGPT / Claude / Gemini) → `chrome.storage.sync` (follows your Google account across devices). If `chrome.storage.sync` is unavailable (policy-managed profile, sync disabled, or quota exceeded) the extension transparently falls back to `chrome.storage.local` so saves never silently fail. + +## Usage + +**Manual capture (primary workflow):** + +1. Open a conversation on Claude.ai, chatgpt.com, or gemini.google.com +2. Click the extension icon in the toolbar +3. Click **Capture Current Response** +4. Watch the Activity log on the Overview tab — you should see `captured` and the sent counter tick up +5. Confirm the thought arrived in your Open Brain (query `search_thoughts` or peek at your database's `thoughts` table) + +**Bulk backfill (Claude, ChatGPT, and Gemini):** + +Switch to the Sync tab and click **Sync All** under the platform you want to import. For Claude and ChatGPT the extension walks each platform's internal conversation API using your existing logged-in session; for Gemini it uses a `chrome.debugger`-based history capture (see "Gemini bulk history sync (Phase B/C)" below). Every path funnels through the same ingest pipeline, and dedup is handled via SHA-256 content fingerprints — running Sync All twice is safe. Incremental **Sync New** imports only conversations not yet captured. Optionally turn on **Auto-sync** to keep new conversations flowing in hands-free (15 min cadence for Claude/ChatGPT, 4 h for Gemini). + +## Supported Sites + +| Site | Manual capture | Bulk sync | Notes | +|------|---------------|-----------|-------| +| `claude.ai` | Yes | Yes | Uses Claude's internal `/api/organizations/.../chat_conversations` endpoint for bulk sync. DOM extractor walks open shadow roots to survive UI refactors. | +| `chatgpt.com`, `chat.openai.com` | Yes | Yes | Uses ChatGPT's `/backend-api/conversations` for bulk sync and `data-message-author-role` selectors for manual capture. | +| `gemini.google.com` | Yes (best-effort) | Yes (debugger-based) | Google exposes no public conversation API, so bulk sync uses `chrome.debugger` to observe Gemini's internal `batchexecute` history-load RPC (`rpcids=hNvQHb`). The "Debugging this browser" banner appears while syncing — see the Gemini bulk history sync section below. Manual-capture selectors target `` and `` Web Components and may drift with Google UI refreshes. | + +## Architecture + +``` +┌──────────────────────────┐ +│ claude.ai / chatgpt.com │ +│ / gemini.google.com tab │ +└──────────┬───────────────┘ + │ content script (bridge.js + extractor-.js) + │ extracts last user+assistant turn from DOM + ▼ +┌──────────────────────────┐ +│ background/service- │ +│ worker.js │ +│ - sensitivity filter │ +│ - SHA-256 fingerprint │ +│ - retry queue (5 tries, │ +│ exponential backoff) │ +└──────────┬───────────────┘ + │ fetch() with x-brain-key header + ▼ +┌──────────────────────────┐ +│ Open Brain REST API │ +│ /open-brain-rest/ingest │ +│ (Supabase Edge Function) │ +└──────────────────────────┘ +``` + +The service worker is the only network caller. Content scripts never touch the network — they only extract DOM text and hand it over via `chrome.runtime.sendMessage`. This keeps the API key out of every page's origin and makes the permission model reviewable. + +## Gemini bulk history sync (Phase B/C) + +Google does not expose a public conversation API for Gemini, so bulk backfill uses a two-part flow that observes Gemini's own internal traffic instead of scraping the DOM. + +**Phase B — chrome.debugger history capture.** When a Gemini tab is open, the extension attaches the MV3 debugger protocol (`chrome.debugger.attach`) and watches `Network.requestWillBeSent`/`loadingFinished` for exactly one URL pattern: `batchexecute` requests with `rpcids=hNvQHb` (Gemini's history-load RPC). Other batchexecute rpcids (`MaZiqc`, `ESY5D`, `L5adhe`, and so on — sidebar, settings, status) are ignored. On `loadingFinished` the service worker fetches the response body via `Network.getResponseBody`, parses the framed positional JSON, and funnels every user+assistant turn in the conversation through the existing capture pipeline (retry queue, sensitivity filter, fingerprint dedup, session metrics). No DOM scraping, no parallel `/ingest` path. + +**Phase C — Sync All orchestrator.** The Sync tab exposes three Gemini controls: + +- **Sync All History** — enumerates every conversation link in your Gemini sidebar (scrolling to load the full list), opens a dedicated background tab, and drives it through each conversation one at a time. Phase B observes the history-load RPC that Gemini fires on page load and resolves a per-conversation waiter. Fingerprint dedup guarantees that re-running Sync All is safe — already-captured turns return `duplicate_fingerprint` / `existing`. +- **Sync New** — same enumeration, but filters against a lifetime list of synced conversation IDs so only conversations you've never captured get navigated. Safe for scheduled use. +- **Auto-sync every 4 hours** — optional. When on, a `chrome.alarms`-driven 4h cadence calls Sync New (capped at 20 conversations per cycle) so new Gemini conversations land in your Open Brain hands-free. Off by default. + +A per-conversation jittered throttle (4–12 s plus a longer "reading pause" every 10 conversations) keeps cadence off Google's bot-detection radar. If Gemini does redirect the sync tab to a CAPTCHA/login page mid-run, the orchestrator detects the unhealthy tab, transitions to a `canceled` paused state, and the Sync All button relabels itself to **Resume Sync**. Solve the challenge in the Gemini tab, then click Resume to pick up where the run left off. + +**What this requires at install time:** + +- Extra manifest permissions: `debugger` (to attach to Gemini tabs) and `scripting` (to run the sidebar-enumeration helper). Chrome shows a combined permission prompt on install / update — "Read and change your data on gemini.google.com" plus "Debug" language. That is expected. +- A visible banner while syncing: Chrome shows "Open Brain Capture started debugging this browser" along the top of Chrome whenever `chrome.debugger` is attached. This is mandatory platform UX — dismissing it cancels the debugger session and the extension will flip to the paused state. Leave it open while Sync All is running. +- No external telemetry, no third-party hosts. Every request that leaves your browser still goes only to your configured Open Brain REST API URL. + +**Why use `chrome.debugger` instead of a content script.** Content scripts can't observe cross-origin response bodies. The Gemini history-load payload is a framed positional-array blob that mixes anti-XSSI prefixes with length-prefixed JSON chunks — parsing it from a `fetch()` interceptor in page context would be fragile and require re-implementing half of Google's `batchexecute` protocol in the page. The debugger path gets the raw response bytes exactly as Gemini's own JS receives them. + +**Turn off:** set the Gemini toggle to off in Settings, and the debugger detaches from every open Gemini tab immediately. Uninstalling the extension clears all persisted state (sync state, fingerprint cache, retry queue) with it. + +## Host Permissions Approach + +This extension uses **`optional_host_permissions` + runtime `chrome.permissions.request()`**, not `` at install time. Trade-off analysis: + +| Approach | Pros | Cons | +|----------|------|------| +| `host_permissions: [""]` | One-line manifest, no prompt flow | Chrome Web Store flags it as a high-risk permission, install-time prompt scares users, extension can hit any site | +| `optional_host_permissions` + runtime request (chosen) | Minimum-viable permissions, user sees exactly which origin they're granting, survives Chrome Web Store review | Requires a Configure screen + one extra click during setup | + +The extension declares `optional_host_permissions: ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"]` in the manifest — the HTTPS wildcard covers public deployments, and the two loopback HTTP entries exist so local dev setups (e.g. `http://localhost:54321`) work without dropping TLS requirements for everyone else. On the Configure screen the extension parses the user's URL, derives an origin pattern like `https://your-project-ref.supabase.co/*`, and calls `chrome.permissions.request({ origins: [origin] })`. The user approves once; Chrome persists the grant; the service worker can now `fetch()` that origin. Nothing else. + +The `content_scripts` entries for `claude.ai`, `chatgpt.com`, and `gemini.google.com` remain as normal `host_permissions` because the content scripts inject at `document_idle` on page load — they can't wait for a runtime prompt. Those three origins are scoped narrowly and visible in the install dialog. + +## Security + +- **API key storage.** The `x-brain-key` lives in `chrome.storage.local`. Chrome encrypts local storage on disk with OS-level keys, and the key is **never** written to `chrome.storage.sync` — meaning it does not propagate to your other Chrome profiles on the same Google account. Rotate by reopening the Configure screen and saving a new value. Uninstalling the extension removes the key along with it. +- **API URL storage.** The Open Brain API URL (`apiEndpoint`) also lives in `chrome.storage.local` only, alongside the key. The URL itself isn't a secret, but sync-replicating it would leak your brain's location to any Chrome profile signed into the same Google account (shared laptops, family devices, loaner Chromebooks). Keeping the endpoint per-device avoids that pre-fill attack surface. +- **Transport security.** The Configure screen rejects any API URL that isn't `https://…` or `http://localhost` / `http://127.0.0.1` (with optional port). The manifest's `optional_host_permissions` reflects the same policy: `https://*/*` plus narrow loopback exceptions only. Plaintext `http://` endpoints over the public internet are not accepted — the `x-brain-key` header and captured conversation text would travel in the clear. +- **Client-side sensitivity filtering.** `data/sensitivity-patterns.json` holds regex patterns for SSNs, passports, bank accounts, API keys, credit cards, passwords-in-URLs, and medical/financial markers. Anything matching a `restricted` pattern is blocked locally before the request is even built — the text never leaves the browser, and the activity log shows a `restricted_blocked` entry. `personal` matches pass through silently and are NOT logged — the intent is to capture them alongside the rest of the conversation, not to separately surface them. Patterns compile once per session and are tested with `String.prototype.match` regex semantics. +- **Outbound requests.** Only the service worker calls `fetch()`, and only to the user-configured origin. No telemetry, no analytics, no third-party hosts. +- **Retry queue integrity.** Failed captures live in `chrome.storage.local` with the full payload and a `nextRetryAt` timestamp. Retries honour exponential backoff (1, 2, 4, 8, 16 minutes, capped at 60), max 5 attempts, then a dead-letter entry in the activity log. Fingerprints live across retries so a retry-then-manual-retry doesn't produce duplicates in Open Brain. +- **CSP.** Manifest V3 service workers run under a strict CSP that forbids `eval` and remote script loading. The lib scripts are all local. + +## Publishing to Chrome Web Store + +**Status: future work.** This contribution is currently distributed as an unpacked/developer-mode install. To publish to the Chrome Web Store, a maintainer will need to: + +1. Review the bundled branded icon set (16/32/48/128 PNGs in [`icons/`](icons/)) and refresh the artwork if needed for a 1.0.0 store listing +2. Fill in the store listing: description, category (Productivity), screenshots, privacy policy URL +3. Draft the **permission justifications** — the store review team requires a paragraph per declared permission. Suggested text: + - `storage` — "Persists user-supplied Open Brain API URL, API key, and per-platform capture toggles." + - `alarms` — "Scheduled retry of failed ingests and optional auto-sync from Claude/ChatGPT (15 min) and Gemini (4 hours)." + - `activeTab`, `tabs` — "Resolves the active conversation tab when the user clicks Capture and creates a transient background tab to drive Gemini bulk sync." + - `cookies` — "Reads the `lastActiveOrg` cookie on claude.ai and the session cookie on chatgpt.com to bulk-fetch conversations via each platform's internal API using the user's own session." + - `debugger` — "Attaches the debugger protocol to gemini.google.com tabs only, and only to observe the one internal history-load RPC (`batchexecute` with `rpcids=hNvQHb`) that Gemini itself calls to load conversation turns. No injected code, no DOM modification, no other origins." + - `scripting` — "Runs a single sidebar-enumeration helper in the Gemini tab to collect conversation IDs for bulk sync. The helper only reads `a[href*=\"/app/\"]` anchors; it does not mutate the page." + - Host permissions for `claude.ai`, `chatgpt.com`, `chat.openai.com`, `gemini.google.com` — "Content scripts extract the latest conversation turn from the page DOM when the user clicks Capture." + - `optional_host_permissions` — "Runtime-granted by the user to reach their specific Open Brain API URL." +4. Pay the $5 one-time developer registration fee +5. Submit for review (typically 3–7 business days) + +Alternatively, host the packed `.crx` on a maintainer-owned update URL and let users sideload without going through the store at all. + +## Known Limitations + +- **ChatGPT and Gemini extractors are best-effort and unverified against live pages.** The ChatGPT and Gemini DOM extractors were written from public selector knowledge (`[data-message-author-role]`, `` / `` Web Components, aria-label fallbacks) and have not been exhaustively verified on a live logged-in session at merge time. They may break with any vendor UI refresh — OpenAI and Google both ship Gemini/ChatGPT UI changes on short cadence. When they break, manual capture on those platforms will return "No conversation turns found" until a maintainer updates the selectors. The Claude manual-capture extractor walks open shadow roots and has been exercised against live claude.ai; it is more resilient. Bulk sync (Claude + ChatGPT) uses internal JSON APIs and is far less fragile than any DOM path. +- **Bulk sync depends on vendor-internal APIs that are not publicly supported.** Anthropic's `/api/organizations/.../chat_conversations` and OpenAI's `/backend-api/conversations` endpoints are undocumented and subject to change without notice. Expect periodic maintenance PRs. If you rely on auto-sync, monitor the Sync Log tab for sustained errors. +- **DOM extraction is fragile.** Claude, ChatGPT, and Gemini all ship UI rewrites without notice. When a platform shuffles its selectors, manual capture returns "No conversation turns found" until the extractor is updated. The Gemini extractor is especially exposed — Google ships new Gemini UIs every few months. Expect occasional maintenance PRs. Bulk sync (Claude + ChatGPT) uses stable internal JSON APIs and is far less fragile than DOM extraction. +- **No passive/ambient capture.** The extension only captures when the user explicitly clicks Capture or runs Sync. A previous "observe every turn" design was retired because keeping up with selector churn on every render was not sustainable. The Settings panel has no Auto/Manual capture-mode toggle — that UI was dropped in the initial public release because it controlled only the ambient path. If ambient capture ever ships, the toggle comes back with it. +- **Gemini bulk sync relies on the debugger protocol.** Google does not expose a public conversation history API. The extension observes Gemini's own internal `batchexecute` history-load RPC via `chrome.debugger`, which requires Chrome to show the "Open Brain Capture started debugging this browser" banner while a run is live — dismissing the banner detaches the debugger and pauses the sync. See "Gemini bulk history sync (Phase B/C)" for the full flow. +- **Large conversations.** The REST API `/ingest` endpoint accepts a single payload per request. A 400-turn Claude thread becomes one very large POST. If your gateway has a request size cap (Supabase default is 10MB), Sync All may dead-letter the longest conversations. Check the activity log and trim in your dashboard if that happens. +- **Sensitivity filter is regex-only.** It's deliberately conservative — false negatives are possible. Treat it as a guardrail, not a vault. For truly sensitive content, don't paste it into an AI chat in the first place. + +## Troubleshooting + +**Issue: Extension icon has a yellow `!` badge and captures fail** +Solution: The extension is not configured. Click the icon, then click **Open Configure screen** in the yellow banner, and supply your Open Brain REST API URL + API key. + +**Issue: "Missing x-brain-key API key" error when I click Capture** +Solution: Either the API key was never saved, or Chrome's local storage got cleared (this can happen after a browser profile reset). Open the Settings tab → **Reconfigure API URL & Key** and re-enter. + +**Issue: "Cannot reach the page" error when capturing** +Solution: The content script isn't loaded on this tab. Refresh the tab and retry. If the page is still on the same URL family that the manifest declares (`claude.ai/*`, `chatgpt.com/*`, etc.), the refresh will re-inject the script. If the error persists, disable and re-enable the extension from `chrome://extensions`. + +**Issue: "No conversation turns found" on Claude / ChatGPT / Gemini** +Solution: The site DOM has changed and the extractor selectors are stale. Check the repo for a newer version of the extension; if there isn't one yet, open an issue with a sample of the current DOM and the `chrome://extensions → errors` output. + +**Issue: Sync All reports every conversation as `existing` but your Open Brain is empty** +Solution: The SHA-256 fingerprint cache is populated but the ingest POSTs are silently rejected. Open the Activity log on the Overview tab and look for `queued_retry` or `dead_letter` entries — those will show the actual API error. Common cause: the REST API gateway is deployed but `MCP_ACCESS_KEY` was rotated and you didn't update the extension. + +**Issue: I configured the extension but Test Connection says "fetch failed"** +Solution: Your browser doesn't have host permission for that origin. Open the Configure screen and save again — Chrome will re-prompt. If it still fails, verify the URL is reachable from your browser (paste it directly into the address bar, expect a 401 or similar from the gateway). + +## Tool Surface Area + +This integration is a **capture source**, not an MCP server — it doesn't expose any tools to your AI. It only writes into Open Brain. The AI-facing tool count of your setup is unchanged by installing this extension. + +If you're weighing whether to add more MCP-exposing extensions on top, see the [MCP Tool Audit & Optimization Guide](../../docs/05-tool-audit.md) for how to keep your tool count manageable as your Open Brain grows. + +## Changelog + +- **0.5.1** — Added the branded icon set (16/32/48/128 PNGs); the toolbar button now shows the Open Brain mark instead of Chrome's default puzzle-piece glyph. +- **0.5.0** — Gemini bulk history sync, host-permission hardening, error surfacing, fingerprint dedup, retry caps, and race fixes. diff --git a/integrations/chrome-capture-extension/background/gemini-debugger.js b/integrations/chrome-capture-extension/background/gemini-debugger.js new file mode 100644 index 000000000..60a4f141a --- /dev/null +++ b/integrations/chrome-capture-extension/background/gemini-debugger.js @@ -0,0 +1,566 @@ +/** + * Open Brain Capture — Gemini durable history capture via chrome.debugger + * + * Phase B: attach chrome.debugger to https://gemini.google.com/* tabs, watch + * for the batchexecute `rpcids=hNvQHb` request/response (Gemini's internal + * conversation-history loader), pair the request+response so MV3 service- + * worker suspensions don't lose state mid-response, fetch the response body + * on loadingFinished, and funnel extracted turns through + * `processCaptureRequest`. + * + * What this file does NOT do: + * - It does NOT observe StreamGenerate (the live per-turn stream). That + * path would be ambient capture, which the extension deliberately + * dropped in the initial public release (see service-worker.js notes). + * Only Phase B's history-load path ships here, and it only fires when + * the user (or the Sync All orchestrator on their behalf) opens a + * conversation. + * + * Coordination with the sync orchestrator: + * - When Phase C's gemini-sync.js drives a bulk backfill it navigates a + * hidden tab to `/app/` and waits on a per-conversation + * waiter. The page loads the conversation by firing the hNvQHb RPC; we + * observe the response here, funnel every turn through the capture + * pipeline (retry queue, sensitivity filter, fingerprint dedup), and + * then ping `OBGeminiSync.notifyHistoryCaptured(conversationId, totals)` + * so the orchestrator's waiter resolves and it can drive the next + * conversation. + * + * Respects the user's Gemini toggle: if the user disables Gemini capture in + * the popup settings, this module detaches from all tabs and stops listening + * until re-enabled. No probes, no telemetry, no third-party hosts. + */ + +/* global chrome, OBConfig */ + +(function () { + 'use strict'; + + // Phase B: conversation history is loaded via a batchexecute RPC. + // `rpcids=hNvQHb` is the history-load variant, confirmed via the Gemini + // network research referenced in the README. Other batchexecute rpcids + // (MaZiqc, ESY5D, L5adhe, etc.) handle sidebar/settings/status and are + // ignored by the URL guard below. + const BATCHEXECUTE_PATH = 'batchexecute'; + const HISTORY_RPCID = 'hNvQHb'; + const DEBUGGER_PROTOCOL_VERSION = '1.3'; + const REQUEST_STASH_TTL_MS = 120 * 1000; + const GEMINI_URL_PATTERN = 'https://gemini.google.com/'; + + // chrome.storage.session key prefix for the pending-request stash. + // Full key: `${STASH_KEY_PREFIX}${tabId}:${requestId}`. + const STASH_KEY_PREFIX = 'ob_gemini_stash_'; + + // chrome.storage.local key the popup reads to show the paused indicator. + const PAUSED_STATE_KEY = 'ob_gemini_paused'; + + // Hard cap on batchexecute response-body size before we even try to parse. + // Gemini's hNvQHb payload is dominated by the conversation transcript plus + // candidate metadata; in practice the largest payloads we've seen in + // research fixtures clock in under 2 MB. 8 MB gives us ~4x headroom for + // long-thread outliers while protecting the SW from a pathological body + // (parser bug, wrong url match, Google format change) OOM'ing the worker. + const MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024; + + // In-memory mirror of the persisted stash for speed. Canonical copy lives + // in chrome.storage.session; this map is always re-derivable from there. + const pendingRequests = new Map(); + + const attachedTabs = new Set(); + let capturePausedByUser = false; + let geminiEnabled = true; + let initialized = false; + + const LOG = (msg, ...rest) => console.log(`[OB Gemini] ${msg}`, ...rest); + const ERR = (msg, ...rest) => console.error(`[OB Gemini] ${msg}`, ...rest); + + function isHistoryUrl(url) { + return typeof url === 'string' + && url.includes(BATCHEXECUTE_PATH) + && url.includes(`rpcids=${HISTORY_RPCID}`); + } + + // --------------------------------------------------------------------------- + // Stash — chrome.storage.session-backed, in-memory mirrored + // --------------------------------------------------------------------------- + + function stashKey(tabId, requestId) { + return `${STASH_KEY_PREFIX}${tabId}:${requestId}`; + } + + async function stashSet(tabId, requestId, entry) { + const key = stashKey(tabId, requestId); + pendingRequests.set(key, entry); + try { + await chrome.storage.session.set({ [key]: entry }); + } catch (err) { + ERR(`stashSet failed key=${key}:`, err?.message || err); + } + } + + async function stashDelete(tabId, requestId) { + const key = stashKey(tabId, requestId); + pendingRequests.delete(key); + try { + await chrome.storage.session.remove(key); + } catch (err) { + ERR(`stashDelete failed key=${key}:`, err?.message || err); + } + } + + function stashGet(tabId, requestId) { + const entry = pendingRequests.get(stashKey(tabId, requestId)); + if (!entry) return null; + if (Date.now() - entry.startedAt > REQUEST_STASH_TTL_MS) return null; + return entry; + } + + async function stashRehydrate() { + try { + const all = await chrome.storage.session.get(null); + const now = Date.now(); + const expired = []; + let live = 0; + for (const [key, value] of Object.entries(all)) { + if (!key.startsWith(STASH_KEY_PREFIX)) continue; + if (!value || typeof value !== 'object' || typeof value.startedAt !== 'number') { + expired.push(key); + continue; + } + if (now - value.startedAt > REQUEST_STASH_TTL_MS) { + expired.push(key); + continue; + } + pendingRequests.set(key, value); + live += 1; + } + if (expired.length) { + await chrome.storage.session.remove(expired); + } + LOG(`stash rehydrate live=${live} expired=${expired.length}`); + } catch (err) { + ERR('stashRehydrate failed:', err?.message || err); + } + } + + async function stashDropForTab(tabId) { + const prefix = `${STASH_KEY_PREFIX}${tabId}:`; + const keys = []; + for (const key of pendingRequests.keys()) { + if (key.startsWith(prefix)) keys.push(key); + } + if (!keys.length) return; + for (const key of keys) pendingRequests.delete(key); + try { + await chrome.storage.session.remove(keys); + } catch (err) { + ERR(`stashDropForTab failed tab=${tabId}:`, err?.message || err); + } + } + + // --------------------------------------------------------------------------- + // Paused-state flag — persisted for the popup + // --------------------------------------------------------------------------- + + async function setPausedByUser(paused) { + capturePausedByUser = Boolean(paused); + try { + await chrome.storage.local.set({ [PAUSED_STATE_KEY]: capturePausedByUser }); + } catch (err) { + ERR('setPausedByUser failed:', err?.message || err); + } + } + + function isCapturePausedByUser() { + return capturePausedByUser; + } + + // --------------------------------------------------------------------------- + // Settings — read Gemini toggle from user config + // --------------------------------------------------------------------------- + + async function readGeminiEnabled() { + try { + const config = await OBConfig.getConfig(); + return config?.enabledPlatforms?.gemini !== false; + } catch (err) { + ERR('readGeminiEnabled failed — defaulting to enabled:', err?.message || err); + return true; + } + } + + async function applyEnabledState(nextEnabled) { + const prevEnabled = geminiEnabled; + geminiEnabled = Boolean(nextEnabled); + + if (geminiEnabled && !prevEnabled) { + LOG('gemini capture enabled — attaching to open tabs'); + await attachToOpenGeminiTabs(); + } else if (!geminiEnabled && prevEnabled) { + LOG('gemini capture disabled — detaching all tabs'); + await detachFromAllTabs(); + } + } + + // --------------------------------------------------------------------------- + // Attach lifecycle + // --------------------------------------------------------------------------- + + async function attachToGeminiTab(tabId) { + if (!geminiEnabled) return; + if (attachedTabs.has(tabId)) return; + try { + await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION); + await chrome.debugger.sendCommand({ tabId }, 'Network.enable', {}); + attachedTabs.add(tabId); + LOG(`attached tab=${tabId}`); + // A successful attach clears any prior "user canceled" paused state. + if (capturePausedByUser) await setPausedByUser(false); + } catch (err) { + ERR(`attach failed tab=${tabId}:`, err?.message || String(err)); + } + } + + async function detachFromTab(tabId) { + if (!attachedTabs.has(tabId)) { + await stashDropForTab(tabId); + return; + } + try { + await chrome.debugger.detach({ tabId }); + LOG(`detached tab=${tabId}`); + } catch (err) { + // detach often fails if the tab is already closed; not fatal + ERR(`detach failed tab=${tabId}:`, err?.message || String(err)); + } + attachedTabs.delete(tabId); + await stashDropForTab(tabId); + } + + async function attachToOpenGeminiTabs() { + try { + const tabs = await chrome.tabs.query({ url: 'https://gemini.google.com/*' }); + LOG(`startup scan: ${tabs.length} Gemini tab(s) open`); + for (const tab of tabs) { + if (typeof tab.id === 'number') await attachToGeminiTab(tab.id); + } + } catch (err) { + ERR('attachToOpenGeminiTabs failed:', err?.message || err); + } + } + + async function detachFromAllTabs() { + const snapshot = Array.from(attachedTabs); + for (const tabId of snapshot) await detachFromTab(tabId); + } + + // --------------------------------------------------------------------------- + // Event wiring + // --------------------------------------------------------------------------- + + function wireTabListeners() { + chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => { + if (typeof changeInfo.url !== 'string') return; + if (changeInfo.url.startsWith(GEMINI_URL_PATTERN)) { + await attachToGeminiTab(tabId); + } else if (attachedTabs.has(tabId)) { + await detachFromTab(tabId); + } + }); + + chrome.tabs.onRemoved.addListener(async (tabId) => { + if (attachedTabs.has(tabId)) { + await detachFromTab(tabId); + } else { + await stashDropForTab(tabId); + } + }); + } + + function wireDebuggerListeners() { + chrome.debugger.onDetach.addListener(async (source, reason) => { + const tabId = source.tabId; + if (typeof tabId !== 'number') return; + LOG(`onDetach tab=${tabId} reason=${reason}`); + attachedTabs.delete(tabId); + await stashDropForTab(tabId); + if (reason === 'canceled_by_user') { + await setPausedByUser(true); + } + }); + + chrome.debugger.onEvent.addListener((source, method, params) => { + const tabId = source.tabId; + if (typeof tabId !== 'number' || !attachedTabs.has(tabId)) return; + + if (method === 'Network.requestWillBeSent') { + handleRequestWillBeSent(tabId, params).catch((err) => + ERR(`requestWillBeSent handler failed tab=${tabId}:`, err?.message || err) + ); + } else if (method === 'Network.loadingFinished') { + handleLoadingFinished(tabId, params).catch((err) => + ERR(`loadingFinished handler failed tab=${tabId}:`, err?.message || err) + ); + } + }); + } + + function wireSettingsListener() { + // OBConfig stores non-secret platform toggles in chrome.storage.sync under + // STORAGE_KEYS.settings (and falls back to chrome.storage.local if sync + // is unavailable). Watch both so enabling/disabling Gemini capture takes + // effect regardless of which area currently holds the settings blob. + const settingsKey = OBConfig.STORAGE_KEYS.settings; + chrome.storage.onChanged.addListener(async (changes, areaName) => { + if (areaName !== 'sync' && areaName !== 'local') return; + if (!(settingsKey in changes)) return; + const next = await readGeminiEnabled(); + await applyEnabledState(next); + }); + } + + // --------------------------------------------------------------------------- + // Request/response handlers + // --------------------------------------------------------------------------- + + async function handleRequestWillBeSent(tabId, params) { + const url = params?.request?.url ?? ''; + const requestId = params.requestId; + + // Phase B: history load for a conversation the user (or the sync + // orchestrator) opened. The request body isn't needed — the user prompts + // and assistant turns are all embedded in the response body. + if (isHistoryUrl(url)) { + const entry = { + tabId, + requestId, + url, + kind: 'history', + startedAt: Date.now() + }; + await stashSet(tabId, requestId, entry); + LOG(`requestWillBeSent tab=${tabId} requestId=${requestId} kind=history`); + return; + } + + // Not a URL we care about. + } + + async function handleLoadingFinished(tabId, params) { + const requestId = params.requestId; + const entry = stashGet(tabId, requestId); + if (!entry) return; + + const elapsed = Date.now() - entry.startedAt; + LOG(`loadingFinished tab=${tabId} requestId=${requestId} kind=${entry.kind || 'unknown'} elapsed=${elapsed}ms`); + + let body = null; + try { + const result = await chrome.debugger.sendCommand( + { tabId }, + 'Network.getResponseBody', + { requestId } + ); + const rawBody = typeof result?.body === 'string' ? result.body : null; + const bodyLen = rawBody ? rawBody.length : 0; + const base64Encoded = Boolean(result?.base64Encoded); + + // batchexecute hNvQHb responses are always text/JSON with the anti-XSSI + // prefix — never binary. A base64Encoded=true would mean either Gemini + // changed its content type or we're misinterpreting a different + // request. Drop defensively rather than parse garbage. + if (base64Encoded) { + ERR(`unexpected base64Encoded body tab=${tabId} requestId=${requestId} length=${bodyLen} — dropping`); + await stashDelete(tabId, requestId); + return; + } + + // Bounded parse. See MAX_RESPONSE_BODY_BYTES for the rationale. + if (bodyLen > MAX_RESPONSE_BODY_BYTES) { + ERR(`response body exceeds cap tab=${tabId} requestId=${requestId} length=${bodyLen} cap=${MAX_RESPONSE_BODY_BYTES} — dropping`); + await stashDelete(tabId, requestId); + return; + } + + body = rawBody; + LOG(`body received tab=${tabId} length=${bodyLen} base64=false`); + } catch (err) { + ERR(`getResponseBody failed tab=${tabId} requestId=${requestId}:`, err?.message || err); + await stashDelete(tabId, requestId); + return; + } + + // Phase B is the only request kind we handle here. + if (entry.kind === 'history') { + try { + await routeHistoryThroughCapturePipeline({ tabId, requestId, responseBody: body }); + } finally { + await stashDelete(tabId, requestId); + } + return; + } + + // Unknown kind — drop defensively. + await stashDelete(tabId, requestId); + } + + async function routeHistoryThroughCapturePipeline({ tabId, requestId, responseBody }) { + const extractor = self.OBGeminiHistoryExtractor; + if (!extractor || typeof extractor.extractGeminiHistory !== 'function') { + ERR(`OBGeminiHistoryExtractor unavailable — dropping tab=${tabId} requestId=${requestId}`); + return; + } + + const turns = extractor.extractGeminiHistory({ responseBody }); + if (!Array.isArray(turns) || turns.length === 0) { + LOG(`history extractor returned empty tab=${tabId} requestId=${requestId} — dropping`); + // Let the orchestrator's 15s capture timeout fire naturally so the + // conversation lands in failedIds, not in everSyncedIds. Calling + // notifyHistoryCaptured with zero totals here would mark the + // conversation as completed-with-zero-turns and permanently skip + // it on future incremental syncs even when the payload was just a + // transient parse failure. A natural timeout lets the user retry + // via "Sync All" after we ship an extractor fix. + return; + } + + const captureHandler = self.processCaptureRequest; + if (typeof captureHandler !== 'function') { + ERR(`processCaptureRequest unavailable in SW scope — dropping tab=${tabId} requestId=${requestId}`); + return; + } + + // Loop the turns serially to keep the ingest pipeline's retry queue, + // sensitivity filter, and fingerprint dedup operating predictably per + // turn. Fingerprint dedup guarantees that re-opening the same + // conversation does NOT produce duplicate thoughts; each turn either + // ingests new or returns 'duplicate_fingerprint' / 'existing'. + LOG(`history load tab=${tabId} requestId=${requestId} turns=${turns.length}`); + + let captured = 0; + let skippedDup = 0; + let other = 0; + + for (const turn of turns) { + const combinedText = `User: ${turn.userPrompt}\n\nAssistant: ${turn.assistantText}`; + try { + const result = await captureHandler({ + platform: 'gemini', + captureMode: 'sync', + text: combinedText, + sourceMetadata: { + gemini_conversation_id: turn.conversationId, + gemini_response_id: turn.responseId, + gemini_candidate_id: turn.candidateId, + gemini_language: turn.language, + gemini_model: turn.model, + gemini_user_prompt: turn.userPrompt, + gemini_assistant_text: turn.assistantText, + gemini_captured_at: turn.capturedAt, + gemini_history_order: turn.historyOrder, + gemini_capture_kind: 'history' + }, + assistantLength: turn.assistantText.length, + preview: turn.assistantText + }); + + const status = result?.status || 'unknown'; + if (status === 'duplicate_fingerprint' || status === 'existing') { + skippedDup += 1; + } else if (status === 'complete' || status === 'captured' || status === 'inserted') { + captured += 1; + } else { + other += 1; + LOG(`history turn[${turn.historyOrder}] tab=${tabId} status=${status}`); + } + } catch (err) { + other += 1; + ERR(`history turn[${turn.historyOrder}] threw tab=${tabId}:`, err?.message || err); + } + } + + LOG(`history captured tab=${tabId} requestId=${requestId} captured=${captured} dedup=${skippedDup} other=${other} total=${turns.length}`); + + // Phase C hook: notify the sync orchestrator (if present) so it can + // un-block its per-conversation waiter. Use the first turn's + // conversation ID — all turns in a single hNvQHb response share it. + // + // The sync orchestrator keys its waiters by the BARE conversation hash + // (derived from the /app/ URL it navigates to). Our extractor + // returns the PREFIXED form (c_) straight from Gemini's JSON. + // Strip the prefix at the notify boundary so sync's Map lookup hits. + // The stored metadata on the thought keeps the prefixed form — that's + // canonical for retrieval. This normalization is sync-waiter-only. + // + // Silently no-ops when Phase C isn't loaded or no sync is in flight. + const rawConversationId = turns[0]?.conversationId; + const firstConversationId = + typeof rawConversationId === 'string' && rawConversationId.startsWith('c_') + ? rawConversationId.slice(2) + : rawConversationId; + if ( + typeof firstConversationId === 'string' && + firstConversationId && + self.OBGeminiSync && + typeof self.OBGeminiSync.notifyHistoryCaptured === 'function' + ) { + try { + self.OBGeminiSync.notifyHistoryCaptured(firstConversationId, { + captured, + skippedDup, + other, + total: turns.length + }); + } catch (err) { + ERR(`notifyHistoryCaptured threw tab=${tabId}:`, err?.message || err); + } + } + } + + // --------------------------------------------------------------------------- + // Init + // --------------------------------------------------------------------------- + + async function initGeminiDebugger() { + if (initialized) return; + initialized = true; + + LOG('init'); + + geminiEnabled = await readGeminiEnabled(); + LOG(`gemini capture enabled=${geminiEnabled}`); + + await stashRehydrate(); + + wireDebuggerListeners(); + wireTabListeners(); + wireSettingsListener(); + + if (geminiEnabled) { + await attachToOpenGeminiTabs(); + } + + LOG('event listeners wired'); + } + + // Auto-initialize on SW wake. Idempotent. + initGeminiDebugger().catch((err) => ERR('init failed:', err?.message || err)); + + // Expose to the classic importScripts service-worker global scope. + self.OBGeminiDebugger = { + initGeminiDebugger, + attachToGeminiTab, + detachFromTab, + detachFromAllTabs, + isCapturePausedByUser, + // Constants for tests and later wiring. + DEBUGGER_PROTOCOL_VERSION, + REQUEST_STASH_TTL_MS, + GEMINI_URL_PATTERN, + STASH_KEY_PREFIX, + PAUSED_STATE_KEY, + // Read-only views of internal state. + _attachedTabs: attachedTabs, + _pendingRequests: pendingRequests + }; +})(); diff --git a/integrations/chrome-capture-extension/background/gemini-sync.js b/integrations/chrome-capture-extension/background/gemini-sync.js new file mode 100644 index 000000000..ccede6a1c --- /dev/null +++ b/integrations/chrome-capture-extension/background/gemini-sync.js @@ -0,0 +1,1039 @@ +/** + * Open Brain Capture — Gemini "Sync All History" orchestrator (Phase C) + * + * Drives a one-shot full-history backfill by walking the Gemini sidebar, + * navigating a dedicated background tab to each conversation, and waiting + * for the Phase B debugger capture (gemini-debugger.js → hNvQHb batchexecute) + * to call back through `notifyHistoryCaptured(id, result)`. + * + * Design principles: + * - No DOM scraping for content — Phase B still owns that via chrome.debugger. + * - Resumable across MV3 service-worker restarts via chrome.storage.local. + * - User-cancelable at any time. + * - No per-conversation API calls from this module; it only coordinates. + * - No telemetry, no third-party hosts. + * + * State transitions and bookkeeping live in the pure helper at + * `lib/gemini-sync-state.js` (`OBGeminiSyncState`). + */ + +/* global chrome, self, OBGeminiSyncState */ + +(function () { + 'use strict'; + + // --------------------------------------------------------------------------- + // Constants + // --------------------------------------------------------------------------- + + // Persisted state key. Single object under chrome.storage.local so rehydrate + // on SW wake is a single read. + const STATE_STORAGE_KEY = 'ob_gemini_sync_state'; + + const GEMINI_APP_URL = 'https://gemini.google.com/app'; + + // Hard ceiling to avoid runaway iteration on pathological sidebar DOMs. + const DEFAULT_CAP = 2000; + + // Gentler cap for auto/incremental runs. Keeps total navigations per + // scheduled cycle low so we don't tempt Google's bot detector. If there + // are more than this many new conversations since the last run, the + // remainder waits for the next alarm. + const DEFAULT_AUTO_CAP = 20; + + // Max time to wait between navigating the sync tab and Phase B firing the + // capture callback. A typical hNvQHb round-trip is 0.5s-3s; 15s absorbs + // slow networks without pinning the orchestrator forever. + const CAPTURE_WAIT_TIMEOUT_MS = 15000; + + // Max time to wait for Phase B's debugger to re-attach to the sync tab + // after we navigate. If we don't see OBGeminiDebugger._attachedTabs list + // our tab within this window, we proceed anyway — capture will either + // happen or time out via CAPTURE_WAIT_TIMEOUT_MS. + const ATTACH_WAIT_TIMEOUT_MS = 2000; + const ATTACH_POLL_INTERVAL_MS = 100; + + // Sidebar enumeration: how long to scroll the sidebar for and how many + // scrolls to perform before giving up. + const ENUMERATE_SCROLL_STEPS = 60; + const ENUMERATE_SCROLL_PAUSE_MS = 250; + + // Heartbeat stale threshold — if we see a record in state=syncing whose + // heartbeat is older than this, we assume the previous SW died + // mid-conversation and the user may want to resume manually. + const STALE_HEARTBEAT_MS = 5 * 60 * 1000; + + // Anti-bot throttle. An earlier experiment with a uniform 4s cadence + // triggered Google's bot challenge around conversation 21. Mitigations: + // - Longer base interval (8s average) + // - Randomized jitter (4-12s range) with full-float precision so delays + // never cluster on whole-second ticks (a classic bot signature) + // - Periodic "reading pauses" every N conversations to break cadence + const THROTTLE_MIN_MS = 4000; + const THROTTLE_MAX_MS = 12000; + const READING_PAUSE_EVERY_N = 10; + const READING_PAUSE_MIN_MS = 20000; + const READING_PAUSE_MAX_MS = 35000; + + const LOG = (msg, ...rest) => console.log(`[OB Gemini SYNC] ${msg}`, ...rest); + const ERR = (msg, ...rest) => console.error(`[OB Gemini SYNC] ${msg}`, ...rest); + + // Live waiter registry for notifyHistoryCaptured. Created lazily because + // OBGeminiSyncState may not yet be on the global when this IIFE runs; + // we access it via `getStateModule()` below. + let waiters = null; + + // In-memory flag to short-circuit the main loop when cancel was requested. + // Also mirrored into persisted state for resume-after-wake behavior. + let cancelRequested = false; + + // Guards against concurrent startSync invocations from the popup. This is + // set synchronously by every entry point (startSync, syncIncremental, + // resumeSync, resumeIfInterrupted) BEFORE any await, so a second entry + // that lands during the first entry's first microtask turn still observes + // the lock. Cleared in the finally block of each entry point. + let syncInFlight = false; + + // --------------------------------------------------------------------------- + // Lazy accessor for the state helper module + // --------------------------------------------------------------------------- + + function getStateModule() { + const mod = self.OBGeminiSyncState; + if (!mod) { + throw new Error('OBGeminiSyncState module not loaded'); + } + return mod; + } + + function getWaiters() { + if (!waiters) waiters = getStateModule().createWaiterRegistry(); + return waiters; + } + + // --------------------------------------------------------------------------- + // Persistence + // --------------------------------------------------------------------------- + + async function loadState() { + try { + const stored = await chrome.storage.local.get({ [STATE_STORAGE_KEY]: null }); + const raw = stored[STATE_STORAGE_KEY]; + if (!raw || typeof raw !== 'object') { + return getStateModule().createInitialState(); + } + // Defensive merge — guarantees shape even if stored record is from + // an older extension version. + const fresh = getStateModule().createInitialState(); + const merged = { + ...fresh, + ...raw, + totals: { ...fresh.totals, ...(raw.totals || {}) }, + pendingIds: Array.isArray(raw.pendingIds) ? raw.pendingIds : [], + completedIds: Array.isArray(raw.completedIds) ? raw.completedIds : [], + failedIds: Array.isArray(raw.failedIds) ? raw.failedIds : [] + }; + return merged; + } catch (err) { + ERR('loadState failed — returning initial:', err?.message || err); + return getStateModule().createInitialState(); + } + } + + async function saveState(record) { + try { + await chrome.storage.local.set({ [STATE_STORAGE_KEY]: record }); + } catch (err) { + ERR('saveState failed:', err?.message || err); + } + } + + async function updateState(mutator) { + const record = await loadState(); + const next = mutator(record) || record; + await saveState(next); + return next; + } + + // --------------------------------------------------------------------------- + // Sidebar enumeration — runs in the page via chrome.scripting.executeScript + // --------------------------------------------------------------------------- + + /** + * Page-context function. Scrolls the sidebar conversation list and returns + * every conversation id it can find as hrefs of the form `/app/`. + * + * Gemini's DOM changes frequently. We cast a wide net: any anchor whose + * href matches /app/[a-z0-9]+ is treated as a conversation link. Duplicates + * are collapsed. + */ + function enumerateSidebar(scrollSteps, scrollPauseMs) { + const isValidId = (id) => typeof id === 'string' && /^[a-z0-9]{8,}$/i.test(id); + + const collect = () => { + const ids = new Set(); + const anchors = document.querySelectorAll('a[href*="/app/"]'); + for (const anchor of anchors) { + const href = anchor.getAttribute('href') || ''; + const m = href.match(/\/app\/([a-z0-9]+)/i); + if (m && isValidId(m[1])) ids.add(m[1]); + } + return ids; + }; + + // Find the most likely scroll container. Gemini's sidebar is typically + // a `
{memory.summary} @@ -186,29 +203,35 @@ export default async function AgentMemoryPage({ -
-
- - - -
-
- - - -
-
- - - -
-
+ {governanceReadOnly ? ( +
+ Review unavailable +
+ ) : ( +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ )}