From a1bb1310528e9338043941eb7122d12a89e400c1 Mon Sep 17 00:00:00 2001
From: Ayal Kleinman
Date: Mon, 24 Aug 2026 22:11:53 -0700
Subject: [PATCH 01/18] fix: name the element in a browser refusal, neutral mcp
notwithstanding
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every browser context carries a neutral all-empty `mcp` object so a rule naming
`mcp.effect` evaluates to false instead of throwing on an unbound identifier.
The refusal copy keyed on that object being present rather than on its contents,
so every live browser refusal took the tool-call branch and read ": on is
blocked" — two empty strings where the element and the page belonged.
The tests asserted the right sentence and passed anyway, because their contexts
omitted the field the gateway always attaches. The new cases build the context
the gateway actually builds.
The branch now keys on the server and tool being named, which a real tool call
always has and a browser action never does.
---
server/src/computer/policy.ts | 7 +++-
server/tests/computer-policy.test.ts | 50 ++++++++++++++++++++++++++++
2 files changed, 56 insertions(+), 1 deletion(-)
diff --git a/server/src/computer/policy.ts b/server/src/computer/policy.ts
index bc9df1cc..1766ecc4 100644
--- a/server/src/computer/policy.ts
+++ b/server/src/computer/policy.ts
@@ -318,7 +318,12 @@ function describeRefusal(context: PolicyContext, expression: string): string {
);
}
- if (context.mcp) {
+ // Present is not enough: the gateway attaches a neutral all-empty `mcp` to every browser context
+ // so a rule naming `mcp.effect` evaluates to false instead of throwing. Testing the object rather
+ // than its contents made this branch fire for every browser refusal, and a person whose click was
+ // refused read ": on is blocked" — two empty strings where the element and page belonged. A real
+ // tool call always names its server and its tool, so those are what the branch keys on.
+ if (context.mcp?.server || context.mcp?.tool) {
return (
`This deployment's policy does not allow that: ${context.mcp.tool} on ` +
`${context.mcp.server} is blocked by the rule \`${expression}\`.`
diff --git a/server/tests/computer-policy.test.ts b/server/tests/computer-policy.test.ts
index 2f834927..60c2462d 100644
--- a/server/tests/computer-policy.test.ts
+++ b/server/tests/computer-policy.test.ts
@@ -619,3 +619,53 @@ describe("a rule about one surface does not refuse another", () => {
expect(decision.source).toBe("deny");
});
});
+
+
+describe("refusal wording under the context the gateway actually builds", () => {
+ /*
+ * The gateway attaches a neutral all-empty `mcp` to every browser context so a rule naming
+ * `mcp.effect` evaluates instead of throwing. The refusal copy used to key on the object being
+ * present rather than on its contents, so every live browser refusal read ": on is blocked" —
+ * empty strings where the element and page belonged — while tests that omitted the field passed.
+ */
+ test("a browser refusal names the element, neutral mcp notwithstanding", () => {
+ const decision = evaluateActionPolicy(
+ { mode: "enforce", deny: ['contains(element.name, "Submit")'], allow: ["true"] },
+ {
+ tool: { name: "computer_click" },
+ bot: { id: "general-assistant" },
+ actor: { id: "user:dev" },
+ page: { url: "https://example.com/checkout", host: "example.com" },
+ intent: "activate",
+ key: "",
+ element: { ref: "e1", role: "button", name: "Submit order", type: "" },
+ file: { path: "", name: "", extension: "" },
+ command: "",
+ mcp: { server: "", tool: "", effect: "" },
+ },
+ );
+ expect(decision.allowed).toBe(false);
+ expect(decision.reason).toContain("Submit order");
+ expect(decision.reason).toContain("example.com");
+ expect(decision.reason).not.toContain(" on is blocked");
+ });
+
+ test("a real tool call still reads as its server and tool", () => {
+ const decision = evaluateActionPolicy(
+ { mode: "enforce", deny: ['mcp.server == "notes"'], allow: ["true"] },
+ {
+ tool: { name: "mcp__notes__search_notes" },
+ bot: { id: "general-assistant" },
+ actor: { id: "user:dev" },
+ page: { url: "", host: "" },
+ key: "",
+ element: { ref: "", role: "", name: "", type: "" },
+ file: { path: "", name: "", extension: "" },
+ command: "",
+ mcp: { server: "notes", tool: "search_notes", effect: "read" },
+ },
+ );
+ expect(decision.allowed).toBe(false);
+ expect(decision.reason).toContain("search_notes on notes");
+ });
+});
From 6b026c203abb039f2d6934273556e630a28a5fdd Mon Sep 17 00:00:00 2001
From: Ayal Kleinman
Date: Mon, 24 Aug 2026 22:11:53 -0700
Subject: [PATCH 02/18] feat: test a boundary rule against history before
saving it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A boundary was written blind: an administrator typed a CEL rule, saved it, and
learned what it matches from the refusals it produced in production. The trail
already records every judged computer action with the same facts the gateway
judged it on, so the question "what would this rule have done" had an answer
nobody could ask.
`POST /policy-dry-run` takes a candidate policy, validates it exactly as PUT
does, replays it over recent judged actions, and names each one it would have
decided differently and the rule that would decide it. It writes nothing — not
the policy, and no audit row, because no action is permitted or refused. The
route is a new single segment reserved in DEPLOYMENT_ROUTES, which is the
mechanism that file exists to make a third route think about.
The replay is a reconstruction, not a simulation: `contextFromAuditPayload`
rebuilds the gateway's context field for field from what the gateway recorded,
through the same helpers (`intentOf`, `describeFile`, `hostOf`, now exported),
so a rule behaves in the test as it will behave live. Absent facts become the
same neutral empty strings the gateway uses, for the same reason: a replay that
threw on `command` in a click would report a boundary far stricter than the one
proposed. Rows that predate a field are skipped rather than guessed at.
On the Boundaries page, Test first sits beside Add rule and runs the current
policy plus the draft. The reply leads with the count over everything scanned,
because the list under it is capped and a reader who stops at the rows should
not believe the rows are the whole answer.
---
CHANGELOG.md | 28 ++++
app/src/lib/computers/queries.ts | 41 +++++
app/src/routes/_authed/admin/boundaries.tsx | 107 ++++++++++++
server/src/app.ts | 1 +
server/src/computer/deployment-routes.ts | 2 +-
server/src/computer/gateway.ts | 6 +-
server/src/computer/policy-dry-run.ts | 177 ++++++++++++++++++++
server/src/computer/routes.ts | 62 +++++++
server/tests/policy-dry-run.test.ts | 171 +++++++++++++++++++
9 files changed, 591 insertions(+), 4 deletions(-)
create mode 100644 server/src/computer/policy-dry-run.ts
create mode 100644 server/tests/policy-dry-run.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cbee9e84..7c13c147 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,34 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
## Unreleased
+### A rule can be tested against history before it is saved
+
+A boundary was written blind: an administrator typed a CEL rule, saved it, and learned what it
+actually matches from the refusals it produced in production. The trail already records every judged
+computer action with the same facts the gateway judged it on, so the question "what would this rule
+have done" had an answer nobody could ask.
+
+The Boundaries page now has **Test first** beside **Add rule**. The candidate — the current policy
+plus the drafted rule — is replayed over recent recorded actions, and the reply names each one it
+would have decided differently and the rule that would have decided it. Nothing is saved and nothing
+is decided; no audit row is written, because no action was permitted or refused.
+
+Replay, not simulation: the context is rebuilt from the audit row exactly as the gateway built it at
+decision time, through the same helpers, so a rule behaves here as it will behave live. The scan is
+bounded and biased to recency, and the reply says how many rows it covered.
+
+### A browser refusal names the element again
+
+Every browser context carries a neutral all-empty `mcp` object, so a rule naming `mcp.effect`
+evaluates to false instead of throwing. The refusal copy keyed on that object being present rather
+than on its contents, so every live browser refusal took the tool-call branch and read
+": on is blocked" — two empty strings where the element and the page belonged. The tests passed,
+because their contexts omitted the field the gateway always attaches.
+
+The branch now keys on the server and tool being named, which a real tool call always has. A refused
+click reads "“Submit order” on shop.example is blocked by the rule ..." again, which is what the Bot
+relays to the person asking.
+
### Knowledge searches instead of guessing
A package can say which of its skills each coworker gets, and the fintech example gives Knowledge the
diff --git a/app/src/lib/computers/queries.ts b/app/src/lib/computers/queries.ts
index 84cf9c09..ba632b5e 100644
--- a/app/src/lib/computers/queries.ts
+++ b/app/src/lib/computers/queries.ts
@@ -71,3 +71,44 @@ export function actionPolicyQueryOptions() {
}),
});
}
+
+/** One recorded action a candidate policy would decide differently than what happened. */
+export type DryRunChange = {
+ id: string;
+ createdAt: string;
+ action: string;
+ bot: string;
+ page: string;
+ element: { role: string; name: string } | null;
+ command: string | null;
+ file: string | null;
+ was: "allowed" | "refused";
+ would: "allowed" | "refused";
+ rule: string | null;
+ reason: string;
+};
+
+export type DryRunReport = {
+ scanned: number;
+ wouldRefuse: number;
+ wouldAllow: number;
+ unchanged: number;
+ /** Capped by the server; the counts cover everything scanned. */
+ changes: DryRunChange[];
+};
+
+/**
+ * What would this policy have decided, about recent recorded actions?
+ *
+ * A plain function rather than a query: the answer is about this candidate at this moment, nothing
+ * caches it and nothing invalidates it. It writes nothing — not the policy, and no audit row.
+ */
+export async function dryRunActionPolicy(
+ candidate: ActionPolicy,
+): Promise {
+ return client("/api/computers/policy-dry-run", "report", {
+ method: "POST",
+ body: { policy: candidate },
+ fallback: "The rule could not be tested against history.",
+ });
+}
diff --git a/app/src/routes/_authed/admin/boundaries.tsx b/app/src/routes/_authed/admin/boundaries.tsx
index cb6faca6..a4830562 100644
--- a/app/src/routes/_authed/admin/boundaries.tsx
+++ b/app/src/routes/_authed/admin/boundaries.tsx
@@ -6,6 +6,8 @@ import { saveActionPolicyMutationOptions } from "@/lib/computers/mutations";
import {
type ActionPolicy,
actionPolicyQueryOptions,
+ type DryRunReport,
+ dryRunActionPolicy,
type PolicyMode,
} from "@/lib/computers/queries";
import { queryClient } from "@/query-client";
@@ -51,6 +53,12 @@ function BoundariesPage() {
const [saved, setSaved] = useState(false);
const [draft, setDraft] = useState("");
+ const [tested, setTested] = useState<{
+ rule: string;
+ report: DryRunReport;
+ } | null>(null);
+ const [testing, setTesting] = useState(false);
+
const stored = useQuery(actionPolicyQueryOptions());
const savePolicy = useMutation(saveActionPolicyMutationOptions(queryClient));
@@ -90,6 +98,31 @@ function BoundariesPage() {
if (!trimmed || policy.deny.includes(trimmed)) return;
void save({ ...policy, deny: [...policy.deny, trimmed] });
setDraft("");
+ setTested(null);
+ };
+
+ /*
+ * The rule as it would be in force — the current policy plus this draft — replayed over recent
+ * recorded actions. Nothing is saved and nothing is decided; the reply names the actions the
+ * addition would have decided differently, so the rule's real reach is known before it starts
+ * refusing anybody.
+ */
+ const testRule = async (rule: string) => {
+ const trimmed = rule.trim();
+ if (!trimmed) return;
+ setProblem(null);
+ setTesting(true);
+ try {
+ const report = await dryRunActionPolicy({
+ ...policy,
+ deny: [...policy.deny, trimmed],
+ });
+ setTested({ rule: trimmed, report });
+ } catch (thrown) {
+ setProblem((thrown as Error).message);
+ } finally {
+ setTesting(false);
+ }
};
return (
@@ -191,6 +224,7 @@ function BoundariesPage() {
onChange={(event) => {
setDraft(event.target.value);
setSaved(false);
+ setTested(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter") addRule(draft);
@@ -198,6 +232,14 @@ function BoundariesPage() {
placeholder='tool.name == "computer_click" && contains(element.name, "submit")'
value={draft}
/>
+
+ {tested ? : null}
+
{PRESETS.map((preset) => (
@@ -256,3 +300,66 @@ function BoundariesPage() {
);
}
+
+
+/**
+ * What the tested rule would have done to actions already on the trail.
+ *
+ * Says the number over everything scanned first, because the list below it is capped and a reader
+ * who stops at the rows should not believe the rows are the whole answer.
+ */
+function DryRunResult({ report }: { report: DryRunReport }) {
+ if (report.scanned === 0) {
+ return (
+
+ No recorded computer actions to test against yet. The rule is valid;
+ what it matches will only be known once Bots have acted.
+
+ );
+ }
+
+ return (
+
+
+ {report.wouldRefuse === 0
+ ? `Tested against the last ${report.scanned} recorded actions: this rule would have refused none of them. It may still match future actions.`
+ : `Tested against the last ${report.scanned} recorded actions: this rule would have refused ${report.wouldRefuse}.`}
+
+ Showing the first {report.changes.length}; the count above covers
+ everything scanned.
+
+ ) : null}
+
+ );
+}
diff --git a/server/src/app.ts b/server/src/app.ts
index edd47dff..bdc87261 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -630,6 +630,7 @@ export function createApp(
computerPolicy,
requireUser,
canUseBot,
+ auditReader,
),
);
}
diff --git a/server/src/computer/deployment-routes.ts b/server/src/computer/deployment-routes.ts
index a46daff2..678d7f42 100644
--- a/server/src/computer/deployment-routes.ts
+++ b/server/src/computer/deployment-routes.ts
@@ -11,4 +11,4 @@
* Nothing in the request distinguishes the two, so a Bot that really is called `policy` would be
* indistinguishable from the deployment route and would be served without the guard being asked.
*/
-export const DEPLOYMENT_ROUTES = new Set(["policy", "fleet"]);
+export const DEPLOYMENT_ROUTES = new Set(["policy", "fleet", "policy-dry-run"]);
diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts
index bf6a8920..14ba0f6c 100644
--- a/server/src/computer/gateway.ts
+++ b/server/src/computer/gateway.ts
@@ -903,7 +903,7 @@ export function createComputerGateway(
* Lower-cased, because a rule forbidding `.env` must also catch `.ENV`; the
* operator should have anticipated. Same reasoning as the case-insensitive `contains` in policy.ts.
*/
-function describeFile(path: string): {
+export function describeFile(path: string): {
path: string;
name: string;
extension: string;
@@ -949,7 +949,7 @@ const ACTIVATING_KEYS = new Set(["Enter", "NumpadEnter", "Space", " "]);
*/
const HUMAN_GESTURES = new Set(["click", "type", "key", "scroll"]);
-function intentOf(
+export function intentOf(
toolName: string,
key: string | undefined,
): PolicyContext["intent"] {
@@ -1109,7 +1109,7 @@ async function writeControlEvent(
});
}
-function hostOf(url: string): string {
+export function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
diff --git a/server/src/computer/policy-dry-run.ts b/server/src/computer/policy-dry-run.ts
new file mode 100644
index 00000000..ba291d61
--- /dev/null
+++ b/server/src/computer/policy-dry-run.ts
@@ -0,0 +1,177 @@
+/**
+ * What would this policy have decided, about actions that already happened?
+ *
+ * A boundary is written blind: an administrator types a CEL rule, saves it, and finds out what it
+ * actually matches from the refusals it produces. The trail already holds everything needed to do
+ * better — every computer action is recorded with the same facts the gateway judged it on — so a
+ * candidate policy can be replayed over that history and answer, before it is saved, "these are the
+ * actions you would have decided differently."
+ *
+ * Replay, not simulation: the context handed to `evaluateActionPolicy` here is rebuilt from the
+ * audit row exactly as the gateway built it at decision time, through the same helpers. A rule that
+ * behaves one way here and another way live would make this feature worse than absent.
+ */
+
+import type { AuditEvent } from "../audit";
+import { describeFile, hostOf, intentOf } from "./gateway";
+import {
+ type ActionPolicy,
+ evaluateActionPolicy,
+ type PolicyContext,
+} from "./policy";
+
+/** The event types the gateway writes for a judged computer action. In one place, for the query. */
+export const REPLAYABLE_EVENT_TYPES = [
+ "computer.action_allowed",
+ "computer.action_refused",
+ "computer.action_failed",
+] as const;
+
+/** One action the candidate policy would have decided differently. */
+export type DryRunChange = {
+ id: string;
+ createdAt: string;
+ action: string;
+ bot: string;
+ page: string;
+ /** For a person reading the list; absent on file and command actions. */
+ element: { role: string; name: string } | null;
+ command: string | null;
+ file: string | null;
+ /** What actually happened, from the trail. A failed action was permitted first, so it was allowed. */
+ was: "allowed" | "refused";
+ would: "allowed" | "refused";
+ /** The candidate rule that decided it, or null for the default refusal. */
+ rule: string | null;
+ reason: string;
+};
+
+export type DryRunReport = {
+ /** Rows replayed. Bounded by what the caller asked the trail for, and says so. */
+ scanned: number;
+ wouldRefuse: number;
+ wouldAllow: number;
+ unchanged: number;
+ /** Capped; the counts above are over everything scanned. */
+ changes: DryRunChange[];
+};
+
+/** Changes returned in full detail. The counts still cover every scanned row. */
+const CHANGES_CAP = 50;
+
+/**
+ * The gateway's context, rebuilt from what it recorded.
+ *
+ * Field for field against the context the gateway constructs: absent browser facts become neutral
+ * empty strings rather than missing keys, because cel-js throws on an unbound identifier and a
+ * throw fails closed — a replay that refused everything the moment a rule named `command` would
+ * report a boundary far stricter than the one being proposed. `intent` is not stored on the row; it
+ * is derived from the tool and key here exactly as the gateway derives it at decision time.
+ *
+ * Null when the row does not carry enough to replay — a row from before a field existed, or a
+ * hand-inserted one. Skipped rather than guessed at.
+ */
+export function contextFromAuditPayload(
+ payload: Record,
+): PolicyContext | null {
+ const action = payload.action;
+ const bot = payload.bot;
+ if (typeof action !== "string" || typeof bot !== "string") return null;
+
+ const text = (value: unknown): string =>
+ typeof value === "string" ? value : "";
+ const page = text(payload.page);
+ const key = text(payload.key);
+ const file = text(payload.file);
+
+ // Stored as an object on element actions, absent on file and command actions, and the literal
+ // sentence "not in the current snapshot" when the server could not identify the element. Only the
+ // object shape carries fields a rule can match; the other two replay as the neutral element.
+ const element =
+ payload.element && typeof payload.element === "object"
+ ? (payload.element as Record)
+ : null;
+ const intent = intentOf(action, key || undefined);
+
+ return {
+ tool: { name: action },
+ bot: { id: bot },
+ actor: { id: text(payload.actor) },
+ page: { url: page, host: hostOf(page) },
+ ...(intent ? { intent } : {}),
+ key,
+ element: {
+ // The ref travels beside the element on the row, not inside it.
+ ref: text(payload.ref),
+ role: text(element?.role),
+ name: text(element?.name),
+ type: text(element?.type),
+ },
+ file: file ? describeFile(file) : { path: "", name: "", extension: "" },
+ command: text(payload.command),
+ mcp: { server: "", tool: "", effect: "" },
+ };
+}
+
+/**
+ * Replay judged actions under a candidate policy and report what changes.
+ *
+ * "Was" comes from the row's event type, which records what the policy in force decided — including
+ * a dry-run policy's refusals, which were recorded and then carried out. That is the honest
+ * baseline: the question this answers is "what would decide differently than was decided", not
+ * "what would run differently than ran".
+ */
+export function dryRunAgainstHistory(
+ policy: ActionPolicy,
+ events: AuditEvent[],
+): DryRunReport {
+ const report: DryRunReport = {
+ scanned: 0,
+ wouldRefuse: 0,
+ wouldAllow: 0,
+ unchanged: 0,
+ changes: [],
+ };
+
+ for (const event of events) {
+ const context = contextFromAuditPayload(event.payload);
+ if (!context) continue;
+ report.scanned += 1;
+
+ const was =
+ event.eventType === "computer.action_refused" ? "refused" : "allowed";
+ const decision = evaluateActionPolicy(policy, context);
+ const would = decision.allowed ? "allowed" : "refused";
+
+ if (was === would) {
+ report.unchanged += 1;
+ continue;
+ }
+ if (would === "refused") report.wouldRefuse += 1;
+ else report.wouldAllow += 1;
+
+ if (report.changes.length >= CHANGES_CAP) continue;
+ report.changes.push({
+ id: event.id,
+ createdAt: event.createdAt,
+ action: context.tool.name,
+ bot: context.bot.id,
+ page: context.page.url,
+ element:
+ context.element?.role || context.element?.name
+ ? {
+ role: context.element.role,
+ name: context.element.name,
+ }
+ : null,
+ command: context.command || null,
+ file: context.file?.path || null,
+ was,
+ would,
+ rule: decision.matched,
+ reason: decision.reason,
+ });
+ }
+
+ return report;
+}
diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts
index e8a88be6..d282aeac 100644
--- a/server/src/computer/routes.ts
+++ b/server/src/computer/routes.ts
@@ -16,7 +16,12 @@ import {
WorkspaceRequestError,
} from "./gateway";
import { DEPLOYMENT_ROUTES } from "./deployment-routes";
+import type { AuditReader } from "../audit";
import { type PolicyStore, parseActionPolicy } from "./policy-store";
+import {
+ dryRunAgainstHistory,
+ REPLAYABLE_EVENT_TYPES,
+} from "./policy-dry-run";
/**
* The Bot computer's surface, behind the same session guard as every other API route.
@@ -38,6 +43,12 @@ export function createComputerRoutes(
* deployment cannot be wired up without an answer to it.
*/
canUseBot: BotAccessCheck,
+ /**
+ * For the policy dry-run, which replays the trail. Optional the way `auditReader` is optional in
+ * `createApp`: a deployment wired without one still governs and records actions, and the dry-run
+ * endpoint says it cannot answer rather than answering from nothing.
+ */
+ auditReader?: AuditReader,
) {
const routes = new Hono<{ Variables: AppVariables }>();
@@ -468,6 +479,57 @@ export function createComputerRoutes(
return context.json({ policy: policyStore.get() });
});
+ /**
+ * What would this policy have decided, about actions already on the trail?
+ *
+ * A rule is otherwise written blind: saved first, understood later, from the refusals it produces
+ * in production. This answers before the save — the candidate is validated exactly as PUT
+ * validates it, replayed over recent judged actions, and the reply names each action it would have
+ * decided differently and the rule that would have decided it.
+ *
+ * A POST that writes nothing: not the policy, and no audit row either. Nothing is decided here —
+ * no action is permitted or refused, nothing runs or is stopped — and a trail row for every
+ * what-if would bury the rows that record what actually happened. Deployment-wide and named in
+ * DEPLOYMENT_ROUTES beside `/policy`, and admin-gated the same way, because history is the
+ * administrator's view.
+ */
+ routes.post("/policy-dry-run", requireUser, async (context) => {
+ const denied = requireAdmin(context);
+ if (denied) return denied;
+
+ if (!auditReader) {
+ return context.json(
+ {
+ error:
+ "This deployment records no readable trail, so there is no history to test against.",
+ },
+ 501,
+ );
+ }
+
+ const body = (await context.req.json().catch(() => null)) as {
+ policy?: unknown;
+ limit?: unknown;
+ } | null;
+ const parsed = parseActionPolicy(body?.policy);
+ if (!parsed.ok) {
+ return context.json({ error: parsed.error }, 400);
+ }
+
+ // Bounded, and biased to recency: the question is what this rule does to the traffic the
+ // deployment actually has, and last week's traffic answers that better than a full scan.
+ const requested = typeof body?.limit === "number" ? body.limit : 200;
+ const limit = Math.min(Math.max(Math.trunc(requested), 1), 500);
+
+ const { events } = await auditReader.list({
+ limit,
+ eventType: REPLAYABLE_EVENT_TYPES.join(","),
+ targetType: "computer",
+ });
+
+ return context.json({ report: dryRunAgainstHistory(parsed.policy, events) });
+ });
+
return routes;
}
diff --git a/server/tests/policy-dry-run.test.ts b/server/tests/policy-dry-run.test.ts
new file mode 100644
index 00000000..cb875363
--- /dev/null
+++ b/server/tests/policy-dry-run.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, test } from "bun:test";
+import type { AuditEvent } from "../src/audit";
+import {
+ contextFromAuditPayload,
+ dryRunAgainstHistory,
+} from "../src/computer/policy-dry-run";
+import type { ActionPolicy } from "../src/computer/policy";
+
+/**
+ * The replay must judge a recorded action exactly as the gateway judged it live. Every case here is
+ * a divergence that would make the feature lie: a field stored beside the element instead of inside
+ * it, an intent that is derived rather than stored, a row that predates a field.
+ */
+
+const PERMIT_EVERYTHING: ActionPolicy = {
+ mode: "enforce",
+ deny: [],
+ allow: ["true"],
+};
+
+function event(
+ overrides: Partial & { payload: Record },
+): AuditEvent {
+ return {
+ id: overrides.id ?? "evt-1",
+ actorUserId: null,
+ eventType: overrides.eventType ?? "computer.action_allowed",
+ targetType: "computer",
+ targetId: "general-assistant",
+ createdAt: overrides.createdAt ?? "2026-08-01T00:00:00.000Z",
+ payload: overrides.payload,
+ };
+}
+
+const CLICK_SUBMIT = {
+ action: "computer_click",
+ bot: "general-assistant",
+ actor: "user:dev",
+ page: "https://shop.example/checkout",
+ ref: "e12",
+ element: { role: "button", name: "Submit order" },
+};
+
+describe("contextFromAuditPayload", () => {
+ test("rebuilds the element with the ref that is stored beside it", () => {
+ const context = contextFromAuditPayload(CLICK_SUBMIT);
+ expect(context?.element).toEqual({
+ ref: "e12",
+ role: "button",
+ name: "Submit order",
+ type: "",
+ });
+ expect(context?.page.host).toBe("shop.example");
+ });
+
+ test("derives intent the way the gateway does, including Enter as activation", () => {
+ expect(contextFromAuditPayload(CLICK_SUBMIT)?.intent).toBe("activate");
+ const enter = contextFromAuditPayload({
+ action: "computer_key",
+ bot: "b",
+ key: "Enter",
+ });
+ expect(enter?.intent).toBe("activate");
+ const letter = contextFromAuditPayload({
+ action: "computer_key",
+ bot: "b",
+ key: "a",
+ });
+ expect(letter?.intent).toBe("type");
+ });
+
+ test("an unidentifiable element replays as the neutral element, not a throw", () => {
+ // The gateway records the sentence "not in the current snapshot" for these rows.
+ const context = contextFromAuditPayload({
+ action: "computer_click",
+ bot: "b",
+ element: "not in the current snapshot",
+ });
+ expect(context?.element).toEqual({ ref: "", role: "", name: "", type: "" });
+ });
+
+ test("a row without the facts to replay is skipped, not guessed at", () => {
+ expect(contextFromAuditPayload({ bot: "b" })).toBeNull();
+ expect(contextFromAuditPayload({ action: 7, bot: "b" })).toBeNull();
+ });
+});
+
+describe("dryRunAgainstHistory", () => {
+ test("a new deny reports the allowed actions it would now refuse, with the rule", () => {
+ const candidate: ActionPolicy = {
+ mode: "enforce",
+ deny: ['contains(element.name, "Submit")'],
+ allow: ["true"],
+ };
+ const report = dryRunAgainstHistory(candidate, [
+ event({ id: "a", payload: CLICK_SUBMIT }),
+ event({
+ id: "b",
+ payload: { ...CLICK_SUBMIT, element: { role: "link", name: "Help" } },
+ }),
+ ]);
+ expect(report.scanned).toBe(2);
+ expect(report.wouldRefuse).toBe(1);
+ expect(report.unchanged).toBe(1);
+ expect(report.changes).toHaveLength(1);
+ expect(report.changes[0]?.id).toBe("a");
+ expect(report.changes[0]?.was).toBe("allowed");
+ expect(report.changes[0]?.would).toBe("refused");
+ expect(report.changes[0]?.rule).toBe('contains(element.name, "Submit")');
+ });
+
+ test("a loosened policy reports refusals it would now allow", () => {
+ const report = dryRunAgainstHistory(PERMIT_EVERYTHING, [
+ event({
+ id: "r",
+ eventType: "computer.action_refused",
+ payload: CLICK_SUBMIT,
+ }),
+ ]);
+ expect(report.wouldAllow).toBe(1);
+ expect(report.changes[0]?.was).toBe("refused");
+ expect(report.changes[0]?.would).toBe("allowed");
+ });
+
+ test("a failed action was permitted first, so it counts as allowed", () => {
+ const deny: ActionPolicy = {
+ mode: "enforce",
+ deny: ['tool.name == "computer_click"'],
+ allow: ["true"],
+ };
+ const report = dryRunAgainstHistory(deny, [
+ event({
+ id: "f",
+ eventType: "computer.action_failed",
+ payload: CLICK_SUBMIT,
+ }),
+ ]);
+ expect(report.wouldRefuse).toBe(1);
+ expect(report.changes[0]?.was).toBe("allowed");
+ });
+
+ test("a rule naming a command does not refuse a click, because absent facts are neutral", () => {
+ const candidate: ActionPolicy = {
+ mode: "enforce",
+ deny: ['contains(command, "rm -rf")'],
+ allow: ["true"],
+ };
+ const report = dryRunAgainstHistory(candidate, [
+ event({ payload: CLICK_SUBMIT }),
+ ]);
+ // The honest answer to "is this click running rm -rf" is no. A replay that failed closed here
+ // would report the boundary as far stricter than the one proposed.
+ expect(report.wouldRefuse).toBe(0);
+ expect(report.unchanged).toBe(1);
+ });
+
+ test("counts cover every scanned row even past the detail cap", () => {
+ const candidate: ActionPolicy = {
+ mode: "enforce",
+ deny: ["true"],
+ allow: ["true"],
+ };
+ const events = Array.from({ length: 60 }, (_, index) =>
+ event({ id: `evt-${index}`, payload: CLICK_SUBMIT }),
+ );
+ const report = dryRunAgainstHistory(candidate, events);
+ expect(report.scanned).toBe(60);
+ expect(report.wouldRefuse).toBe(60);
+ expect(report.changes).toHaveLength(50);
+ });
+});
From a6bada954e4a2bc696c6617d3f4c7a1512230990 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Tue, 25 Aug 2026 15:59:48 -0300
Subject: [PATCH 03/18] Channel pin and soft delete, and a Notion connector
over hosted MCP (#242)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Let a member pin a channel and soft-delete it, from a right-click menu
* Land the caret in the composer once a coworker is chosen
* Calm the screen panel down and make the full-size view a card
* Let a Bot's message take the whole transcript column
* Put Notion in the catalogue, and let a vendor register its OAuth client dynamically
* Rotate refresh tokens in place, serialised per connection, and recover an evicted client
* Introduce the deployment to a dynamic vendor on first connect
* Show Notion in the plugin screens, without a client form it does not need
* Say what the Notion connector is, everywhere the catalogue is described
* Grant a batch of tools to Bots from the vendor page
* Hold the vault row while a rotating token is spent, so replicas take turns
* Refuse to mint a second client inside the re-registration window
* Tell every member's roster when a channel is deleted, again
* Leave a who-and-when behind a soft delete, again
* Carry a pin across one person's own tabs
* Say the classification direction right everywhere a person reads it
* Refuse to whisper into a deleted channel
`get` and `list` filter on `deleted_at`; `recordActivity` and `setPinned` did not. Activity POSTed
to a soft-deleted channel returned 204, bumped `last_message`, and announced it to every member,
each of whom then refetched a roster for a row it cannot show; a pin on one succeeded the same way.
Both now join the channel and require it undeleted, throwing ChannelNotFoundError to match `get`,
which also keeps the notify off the refused path since it is written inside the transaction.
The roster's second query repeats the same filter. It selects the page and then joins the agents to
it in a separate statement on a separate snapshot, so a delete committing between the two would hand
back a channel this person can no longer see.
* Hold a pinned channel at the top of the roster, not the page
The roster ordered by recency alone and the client lifted pinned rows at render, so a pin only
reached the top of whatever pages were loaded: a channel somebody pinned and then did not talk to
for a month sat on page three and never appeared above anything. The promise is about the roster, so
the ordering belongs in the query.
The page now orders by the pin first and the cursor carries it as the leading element. Every part of
the sort descends — a pin is 1 and no pin is 0 — which keeps the keyset predicate a single row
comparison rather than a nest of ORs, and a cursor minted before the pin existed reads as the first
page, like any other cursor describing an ordering this query no longer has.
`pinnedFirst` stays in the sidebar as the render-level mirror, for the window between refetches: the
socket patches a pin onto a loaded row without moving it, and re-sorts a page by recency alone. Its
comment now says that is what it is for, rather than claiming to be where the rule lives.
* Read a vendor's garbage as a refusal, not a crash
* Keep the wheel reachable when the screen has nothing to show
Take control and Hand back live in the full-size view, and the only way in was
disabled unless there was a picture to open. So a blank browser, a screenshot
that had not arrived, or a computer that could not be reached left a person with
no way to take the wheel at all - the three states where they most want it.
The frame now opens whatever is in it, and with nothing to draw the full-size
view reserves the same shape and says the same words the card does, with the
wheel underneath them. Somebody already driving keeps the live socket, whatever
is on the page: once a person holds the wheel the stream is the truth about it.
The Bot ASKING for the wheel comes back to the card as its own amber row with
the reason on it, which is what the rework dropped. It is not the persistent
footer that was deliberately removed - it is there only while the request is,
next to the credential form, which is the other thing a stuck Bot needs.
* Answer pin and delete failures where they happened
Three things this row did quietly. A refused delete stayed on the mutation, so
reopening the confirm showed a stale 409 about an attempt nobody had made yet;
the menu resets it on the way in. A failed pin said nothing at all - the menu
closed, the pin did not move, and that reads as the app ignoring the click - so
the sentence now lands on the row, there being no toast in this app.
And a delete of the channel on screen navigated home after the write. The roster
invalidates the moment it lands, which unmounts this row and the dialog inside
it, so the navigate belonged to a component that was already gone. Leaving first
is safe in the other direction: a refusal puts them on the roster with the
channel still in it, and says why.
* Grant a batch with one refetch and a progress count
Two Bots and twelve tools is twenty-four writes, and every one of them went
through the grant mutation - which invalidates every plugin query and waits for
the refetch. Most of the wait was re-reading a list hidden behind the dialog.
The write is now its own function with no refetch attached, and the dialog
invalidates once when the loop is done, including after a refusal, because the
grants before it landed.
The button says which of the N is in flight rather than only "Granting", so a
slow batch can be told from a stuck one, and each set of tickboxes is a fieldset
named by the heading already above it - "Changes things" is the whole warning on
those tools, and a listener would otherwise never hear it.
* Sweep the code the screen rework orphaned
`hasBrowsed` had no callers left once the screen and the activity log stopped
being tabs that had to guess which one to open, and the placeholder artwork went
with the blank-browser strip it decorated. The note itself stays: the tool
handler is the only place the fact exists, and a screenshot cannot answer it.
The composer's autofocus is a mount-time courtesy, claimed once. Keyed off the
editor becoming interactive, it re-fired on every disabled or busy transition,
so a completed turn yanked the caret back from wherever the person had moved it.
A send of their own still returns it - that one they asked for.
* Stop pretending a new client can spend an old grant
* Let two first connects race to one client
* Cap, revoke and say what refresh saw
* Seal the consent state, not just sign it
* Refuse a consent that outlived the person's access
---
CHANGELOG.md | 69 +-
README.md | 3 +-
.../components/app-sidebar/app-sidebar.tsx | 17 +-
app/src/components/app-sidebar/channel.tsx | 277 +-
.../components/channels/chat-transcript.tsx | 13 +-
.../components/channels/composer/composer.tsx | 28 +-
.../components/channels/conversation-view.tsx | 4 +
app/src/components/computer/activity-log.tsx | 13 +-
app/src/components/computer/computer-view.tsx | 312 ++-
app/src/components/computer/placeholder.tsx | 162 --
app/src/components/ui/alert-dialog.tsx | 150 --
app/src/components/ui/checkbox.tsx | 26 +
app/src/components/ui/context-menu.tsx | 269 ++
app/src/lib/channels/mutations.ts | 61 +-
app/src/lib/channels/queries.ts | 2 +
app/src/lib/channels/use-channel-events.ts | 157 +-
app/src/lib/computers/activity.ts | 20 +-
app/src/lib/markdown.tsx | 19 +-
app/src/lib/plugins/mutations.ts | 42 +-
app/src/lib/plugins/queries.ts | 5 +
.../_authed/_app/channel/$channelId.tsx | 97 +-
app/src/routes/_authed/_app/channel/new.tsx | 3 +
app/src/routes/_authed/admin/plugins/$key.tsx | 388 ++-
.../routes/_authed/admin/plugins/index.tsx | 2 +
.../settings/connected-accounts/index.tsx | 2 +
app/tests/channel-event-patch.test.ts | 171 ++
app/tests/channel-menu-mutations.test.ts | 88 +
app/tests/channel-order.test.ts | 52 +
app/tests/markdown-chips.test.ts | 41 +
app/tests/plugin-grants.test.ts | 111 +
docs/README.md | 1 +
docs/architecture.md | 4 +-
docs/plugins/notion.md | 79 +
examples/fintech/skills.yaml | 16 +-
.../0016_pin_and_soft_delete_channels.sql | 2 +
server/drizzle/meta/0016_snapshot.json | 2395 +++++++++++++++++
server/drizzle/meta/_journal.json | 7 +
server/src/app.ts | 39 +-
server/src/audit.ts | 9 +-
server/src/auth/signed-value.ts | 69 +
server/src/channels/events.ts | 9 +-
server/src/channels/routes.ts | 424 +--
server/src/credentials.ts | 38 +
server/src/db/schema/core.ts | 11 +
server/src/index.ts | 10 +
server/src/plugins/catalogue.ts | 99 +-
server/src/plugins/oauth.ts | 218 +-
server/src/plugins/routes.ts | 102 +-
server/src/plugins/store.ts | 1227 +++++++--
.../channel-activity.integration.test.ts | 97 +
.../tests/channel-events.integration.test.ts | 276 +-
server/tests/channel-routes.test.ts | 584 +++-
server/tests/credentials.test.ts | 100 +
server/tests/plugin-catalogue.test.ts | 62 +-
server/tests/plugin-connect-route.test.ts | 161 ++
server/tests/plugin-oauth-callback.test.ts | 231 ++
server/tests/plugin-oauth.test.ts | 488 +++-
server/tests/plugin-store.integration.test.ts | 1987 +++++++++++++-
...plugin-user-credential.integration.test.ts | 5 +
59 files changed, 9796 insertions(+), 1558 deletions(-)
delete mode 100644 app/src/components/computer/placeholder.tsx
delete mode 100644 app/src/components/ui/alert-dialog.tsx
create mode 100644 app/src/components/ui/checkbox.tsx
create mode 100644 app/src/components/ui/context-menu.tsx
create mode 100644 app/tests/channel-event-patch.test.ts
create mode 100644 app/tests/channel-menu-mutations.test.ts
create mode 100644 app/tests/channel-order.test.ts
create mode 100644 app/tests/markdown-chips.test.ts
create mode 100644 app/tests/plugin-grants.test.ts
create mode 100644 docs/plugins/notion.md
create mode 100644 server/drizzle/0016_pin_and_soft_delete_channels.sql
create mode 100644 server/drizzle/meta/0016_snapshot.json
create mode 100644 server/tests/plugin-connect-route.test.ts
create mode 100644 server/tests/plugin-oauth-callback.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cbee9e84..02c58967 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,34 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
## Unreleased
+### Notion joins the connector catalogue
+
+Notion is now a governed MCP connector, reached through Notion's own hosted server on the
+catalogue's default transport, as the person asking — the same grant, policy and audit machinery
+Google Drive already runs through. Unlike Drive, it ships both read and write tools from the start;
+the writing ones are named in the catalogue, and an advertised tool absent from that list classifies
+as a read — so reconciling the write-tool names against what Notion's hosted server actually calls
+them, on the first Refresh tools, is required, not cosmetic. A tool the server never advertised at
+all still classifies as a write, same as any other connector.
+
+There is no client to register: this deployment introduces itself to Notion on first connect. That
+shortens setup but does not finish it — unlike Drive, whose tool list is this codebase's own code,
+Notion's tool list is an answer from Notion's hosted server, so a deployment has recorded none of it
+until Refresh tools has run at least once; and, like every other connector, a Bot gets nothing until
+its tools are granted to it. Setup is enable at `/admin/plugins/notion`, connect an account at
+`/settings/connected-accounts`, refresh tools, then grant — a bulk **Grant tools…** dialog on
+`/admin/plugins/notion` grants a batch of tools to a batch of Bots in one pass, one grant and one
+audit row per Bot per tool. No migration.
+
+### Refresh tokens rotate in place, and replicas take turns spending them
+
+A vendor that rotates refresh tokens invalidates the one it just handed out, so two replicas racing
+to use a stale token would have the loser refused, or worse: a rotating vendor's reuse detection can
+read that as a stolen token and revoke the whole connection. Every plugin call that mints an access
+token now locks the credential's vault row for the length of the exchange, so a second replica waits
+rather than races, and the rotated token is written back in the same transaction that held the lock.
+Nothing to configure; a connection just stops going stale under concurrent traffic.
+
### Knowledge searches instead of guessing
A package can say which of its skills each coworker gets, and the fintech example gives Knowledge the
@@ -233,6 +261,24 @@ without a word, because the input path looks for a viewer before it looks for an
A close now stops casting only when the socket closing is the one that was casting.
+### A sidebar channel row can be pinned or deleted
+
+Right-click on a channel in the sidebar and a menu opens with two entries: Pin channel and Delete
+channel.
+
+Pin is held per member rather than per channel, so pinning one holds it at the top of your own
+roster — newest first among pinned channels — and leaves every other member's roster unaffected.
+
+Delete is confirmed in a dialog first, and it is soft. The channel disappears from every member's
+roster and from a direct fetch of it, while the row, its transcript, and its Intelligence thread all
+survive. That disappearance is live, not just on next load: every member's open tabs drop the row as
+the delete lands, and a tab parked on the channel itself is sent home. The deletion is audited as its
+own `channel.deleted` row. A channel the deployment package defines is refused, with the reason
+named. Recovery today is clearing `channels.deleted_at` in the database directly; there is no restore
+control in the product.
+
+The deployment gains two nullable columns, via migration `0016`.
+
### An MCP server address that points inside the deployment is refused in three more spellings
Adding an MCP server by URL is checked before the address is stored, because that form is otherwise a
@@ -287,29 +333,6 @@ one they are, so those match too.
No configuration changes and nothing is stored differently; a deployment that was already on the
light theme sees no difference at all.
-### A conversation can be deleted
-
-Nothing removed a channel. Starting one was the only lever the product gave a person, and every
-conversation with every coworker sat in the roster forever, growing on every message the way
-`DEFAULT_CHANNEL_PAGE`'s own note already described: a page that was instant in a demo returns
-thousands of rows for anybody who has actually been using the product a while, one that never shrinks
-again.
-
-Deleting a channel now removes it for good. The channel row goes, and its memberships, its linked
-coworkers, and its Intelligence thread mapping go with it through the same foreign-key cascades that
-already existed for them — no migration needed, only a query that finally uses them. The deployment
-also asks Intelligence to permanently delete the thread itself, so the message history is not just
-unlisted, it is gone from the platform too.
-
-A thread the platform refuses to delete does not hold the channel hostage. The local removal already
-committed by the time that call runs, so a rejected or unreachable upstream delete leaves the channel
-gone from the roster regardless, with an audit row (`channel.deleted`) naming the thread and whether
-Intelligence actually forgot it. A channel that is gone locally with an orphaned thread still on the
-platform is a smaller, more honest failure than a channel sitting in the roster with its history
-silently wiped out from under it, and the audit trail is where an administrator finds the one that
-did not clean up completely. `DELETE /api/channels/:channelId` answers with `historyLeftBehind`, so a
-screen showing the outcome does not have to guess which of the two happened.
-
## 0.0.4
### A click citing a ref this deployment cannot resolve is refused
diff --git a/README.md b/README.md
index 63b1b726..f71a6a5f 100644
--- a/README.md
+++ b/README.md
@@ -127,7 +127,6 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you
| `/bot` | Direct chat with a Bot; `?agent=` selects one. |
| `/skills` | Create and enable personal skills. |
| `/settings` | User preferences. |
-| `/admin/connectors` | Configure deployment knowledge sources. |
| `/admin/credentials` | Store write-only encrypted credentials. |
| `/admin/computers` | View, stop, and reset Bot computers. |
| `/admin/boundaries` | Configure browser/file/MCP action policy. |
@@ -147,7 +146,7 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you
- **Secrets never enter the transcript**: the trail records that a secret was requested and how long it was, not what it said.
- **Bring your own agent**: any AG-UI endpoint is a Bot, on a framework or hand-written. Endpoints are validated with the same target checks used for browser navigation, and an auth header is stored write-only.
- **Components instead of prose**: compiled React components live in `app/src/components/gallery/`, sandboxed ones are authored in `/admin/playground` and published with no deployment. Every call asks the server whether the component exists, is published, and is not withheld from that Bot. Data functions are granted per component.
-- **Governed MCP**: Google Drive ships in the catalogue, reached as the person asking. The catalogue carries only vendors this deployment stands behind, so adding one is a review of that vendor. Custom servers must pass URL checks, and any tool not positively classified as a read is treated as a write. A Bot is told which connectors exist here and which it holds, so it says it has not been granted one rather than browsing to the vendor's website.
+- **Governed MCP**: Google Drive and Notion ship in the catalogue, reached as the person asking. The catalogue carries only vendors this deployment stands behind, so adding one is a review of that vendor. Custom servers must pass URL checks; unknown tools and custom-server tools are treated as writes, and a catalogue tool the server advertises but does not name as a write classifies as a read. A Bot is told which connectors exist here and which it holds, so it says it has not been granted one rather than browsing to the vendor's website.
- **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer.
- **Sign in with what your company already has**: Google, Microsoft or Okta from the environment, or a company's own SAML or OpenID Connect provider registered while the deployment runs and routed by email domain. Any one turns sign-in on; several may be configured at once.
- **Decide who gets in**: `/admin/people` lists everybody who has signed in, promotes and demotes them, and removes access, which ends the session they are using and stops the next sign-in. Every change is on the audit trail.
diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx
index b6e8d2dc..68c0676d 100644
--- a/app/src/components/app-sidebar/app-sidebar.tsx
+++ b/app/src/components/app-sidebar/app-sidebar.tsx
@@ -109,6 +109,20 @@ function matchingChannels(
);
}
+/**
+ * Pinned channels first, everything else after, newest activity first within each group.
+ *
+ * The mirror of a server rule, not the rule itself: the roster query orders pinned-first and its
+ * cursor carries the pin, so a pinned channel arrives on page one however long ago it was last
+ * spoken in. Sorting here as well is for what happens between refetches — the socket patches a pin
+ * onto a loaded row without moving it, and re-sorts a page by recency alone — which is the same
+ * reason `byRecency` in use-channel-events.ts mirrors the recency rule. A stable partition, so the
+ * recency order inside each group is whatever arrived.
+ */
+export function pinnedFirst(channels: ChannelSummary[]): ChannelSummary[] {
+ return [...channels].sort((a, b) => Number(b.pinned) - Number(a.pinned));
+}
+
/**
* A roster row that can animate.
*
@@ -145,6 +159,7 @@ function ChannelRow({
? relativeTime(channel.lastMessageAt)
: undefined
}
+ pinned={channel.pinned}
/>
);
@@ -160,7 +175,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
useChannelEvents();
const [search, setSearch] = useState("");
const searching = search.trim().length > 0;
- const visibleChannels = matchingChannels(channels.data, search);
+ const visibleChannels = pinnedFirst(matchingChannels(channels.data, search));
/*
* FILTERING DOES NOT ANIMATE. Rows exit and relayout on every keystroke otherwise, which is a
* list thrashing under somebody who is still typing — and the moving target is the very thing
diff --git a/app/src/components/app-sidebar/channel.tsx b/app/src/components/app-sidebar/channel.tsx
index 9b15b799..f4fa2edf 100644
--- a/app/src/components/app-sidebar/channel.tsx
+++ b/app/src/components/app-sidebar/channel.tsx
@@ -1,34 +1,39 @@
-import { IconDots } from "@tabler/icons-react";
+import {
+ IconPin,
+ IconPinFilled,
+ IconPinnedOff,
+ IconTrash,
+} from "@tabler/icons-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { memo, useState } from "react";
-import { deleteChannelMutationOptions } from "@/lib/channels/mutations";
-import { ChannelAvatar } from "../channels/avatar";
+import { Button } from "@/components/ui/button";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
import {
- AlertDialog,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "../ui/alert-dialog";
-import { Button } from "../ui/button";
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "../ui/dropdown-menu";
+ deleteChannelMutationOptions,
+ setChannelPinnedMutationOptions,
+} from "@/lib/channels/mutations";
+import { ChannelAvatar } from "../channels/avatar";
/**
* Memoized roster row. `use-channel-events` preserves unchanged row identity, and
* `content-visibility` keeps off-screen rows cheap without virtualization.
*
- * State inside a row is no reason to drop the memo: `memo` compares the props it is handed and has
- * nothing to say about a hook. Dropping it re-renders every row in the roster whenever the sidebar
- * renders, which is the cost the identity-preserving patch in `use-channel-events` exists to avoid.
+ * Right-click opens Pin and Delete. Deleting is confirmed in a dialog that names the channel,
+ * because the row it was invoked on is one of several identical-looking rows.
*/
export const Channel = memo(function Channel({
channelId,
@@ -36,139 +41,165 @@ export const Channel = memo(function Channel({
name,
lastMessage,
lastMessageAt,
+ pinned,
}: {
channelId: string;
participantIds: string[];
name: string;
lastMessage?: string;
lastMessageAt?: string;
+ pinned: boolean;
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
- // `strict: false`: this row renders in the sidebar on every screen, not only while its own
- // channel is open, so there may be no `channelId` route param to read at all.
- const { channelId: openChannelId } = useParams({ strict: false });
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ // Whether this row's channel is the one on screen, as a boolean, so navigating between
+ // channels re-renders the two rows whose answer changed rather than the whole roster.
+ const isOpen = useParams({
+ strict: false,
+ select: (params) =>
+ (params as { channelId?: string }).channelId === channelId,
+ });
+ const setPinned = useMutation(setChannelPinnedMutationOptions(queryClient));
const deleteChannel = useMutation(deleteChannelMutationOptions(queryClient));
+ const [confirming, setConfirming] = useState(false);
+ /**
+ * Why a pin did not take, said on the row it was asked of.
+ *
+ * Pinning used to fail in total silence: the menu closed, the pin did not move, and nothing on
+ * screen accounted for it — which reads as the app ignoring the click. There is no toast in this
+ * app, and the row is where the person was looking, so the sentence goes here and is replaced by
+ * the next attempt.
+ */
+ const [pinProblem, setPinProblem] = useState(null);
- const handleDelete = async () => {
- // Navigate away first: the row this menu lives on unmounts the moment the list invalidates,
- // and a screen still pointed at a channel id that no longer resolves is worse than a screen
- // that moved on a beat early.
- if (openChannelId === channelId) {
+ const confirmDelete = async () => {
+ /*
+ * Away first when this row's channel is the one on screen.
+ *
+ * The roster invalidates the moment the delete lands, so this row — and the dialog living inside
+ * it — unmounts while the rest of this function is still owed. Navigating after the mutation
+ * therefore ran in a component that was already gone, leaving somebody looking at a conversation
+ * that no longer exists. Leaving before asking is safe in the other direction: a refused delete
+ * puts them on the roster with the channel still in it, and says why in the dialog.
+ */
+ if (isOpen) {
await navigate({ to: "/" });
}
try {
await deleteChannel.mutateAsync(channelId);
- /*
- * Closed on success rather than left to the unmount.
- *
- * The row does go away when the roster invalidates, taking this dialog with it, but that is a
- * side effect of a cache write and not something this component controls.
- */
- setDeleteDialogOpen(false);
} catch {
- /*
- * Left open, deliberately. A delete that failed leaves the row exactly where it was, so
- * closing would return the person to a roster that still lists the conversation they just
- * asked to be rid of, with nothing anywhere saying why. The message is rendered below;
- * `mutateAsync` rejects rather than swallowing, which is why this catch exists at all.
- */
+ // The error is on the mutation and rendered in the dialog; leaving it open says "not done".
+ return;
}
+ setConfirming(false);
};
return (
-
+
+
+
+ >
);
});
diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx
index 84edf602..f5044c1c 100644
--- a/app/src/components/channels/chat-transcript.tsx
+++ b/app/src/components/channels/chat-transcript.tsx
@@ -409,8 +409,17 @@ const TranscriptMessage = memo(function TranscriptMessage({
-
-
+ {/*
+ A Bot's message takes the whole column, not the width of its words: block content
+ inside it — a fenced code block, a table — should span the transcript rather than
+ shrink to its own text. A person's bubble keeps fitting what they said.
+ */}
+
+
{isUser ? (
// A person's own message is shown exactly as they typed it. Rendering it as markdown
// would silently reformat what they said, and an asterisk in a sentence is not
diff --git a/app/src/components/channels/composer/composer.tsx b/app/src/components/channels/composer/composer.tsx
index 0c4781b2..dc8ef92b 100644
--- a/app/src/components/channels/composer/composer.tsx
+++ b/app/src/components/channels/composer/composer.tsx
@@ -70,6 +70,17 @@ export type ComposerProps = {
* connecting and restoring its history, and the composer is on screen throughout.
*/
pending?: boolean;
+ /**
+ * Put the caret in the editor the first moment it can take one, and then leave it alone. For the
+ * screens where typing is the next thing a person does — choosing a coworker answers the "to"
+ * field, and the message is what remains.
+ *
+ * Once, not on every change: it used to re-claim the caret whenever the editor became interactive
+ * again, so a person who had clicked into something else — a search box, another channel's row —
+ * had the cursor yanked back the moment a turn finished. A send of their own still returns the
+ * caret, because that one they asked for.
+ */
+ autoFocus?: boolean;
/**
* There is a run on the wire for Stop to reach.
*
@@ -95,6 +106,7 @@ export function Composer({
onStop,
disabled = false,
pending = false,
+ autoFocus = false,
stoppable,
}: ComposerProps) {
const [value, setValue] = useState([]);
@@ -103,6 +115,8 @@ export function Composer({
const promptAreaRef = useRef(null);
/** A send has completed and the caret is owed back, as soon as the editor will take it. */
const wantsFocus = useRef(false);
+ /** `autoFocus` has been honoured once, and is not owed again for the life of this composer. */
+ const claimedAutoFocus = useRef(false);
const isBusy = pending || isSubmitting;
const triggers = useMemo(
@@ -191,14 +205,24 @@ export function Composer({
* Keyed off the editor becoming interactive rather than off the send resolving, so it survives
* whatever the parent does with `pending` in between — and it runs after the commit, which is the
* only point at which the element is enabled and focusable.
+ *
+ * Two different debts, and only one of them recurs. A finished send owes the caret back every
+ * time. `autoFocus` owes it exactly once, at the start: it used to be re-owed on every
+ * disabled/busy transition, so every completed turn stole the caret back from wherever the person
+ * had moved it, and a composer that had never been sent from would grab focus mid-conversation.
*/
useEffect(() => {
- if (!wantsFocus.current || disabled || isBusy) {
+ if (disabled || isBusy) {
+ return;
+ }
+ const owed = wantsFocus.current || (autoFocus && !claimedAutoFocus.current);
+ if (!owed) {
return;
}
wantsFocus.current = false;
+ claimedAutoFocus.current = true;
promptAreaRef.current?.focus();
- }, [disabled, isBusy]);
+ }, [autoFocus, disabled, isBusy]);
const handleFormSubmit = (event: FormEvent) => {
event.preventDefault();
diff --git a/app/src/components/channels/conversation-view.tsx b/app/src/components/channels/conversation-view.tsx
index 420e33ca..dd28b866 100644
--- a/app/src/components/channels/conversation-view.tsx
+++ b/app/src/components/channels/conversation-view.tsx
@@ -26,6 +26,7 @@ export function ConversationView({
commands,
disabled = false,
pending = false,
+ autoFocus = false,
stopped,
stoppable,
queueWhileBusy = false,
@@ -53,6 +54,8 @@ export function ConversationView({
* drains on this falling.
*/
pending?: boolean;
+ /** Focus the composer the moment it can take a caret; forwarded to the composer. */
+ autoFocus?: boolean;
/** Why the last turn ended without an answer. Drawn at the end of the transcript, not here. */
stopped?: string;
/**
@@ -224,6 +227,7 @@ export function ConversationView({
{notice}
- Nothing yet. Commands the Bot runs, and files it reads, appear here as
- they happen.
-
+
+
+
+ Nothing yet. Commands the Bot runs, and files it reads, appear here
+ as they happen.
+
+
+
);
}
diff --git a/app/src/components/computer/computer-view.tsx b/app/src/components/computer/computer-view.tsx
index 9ba91baa..cfb7c00c 100644
--- a/app/src/components/computer/computer-view.tsx
+++ b/app/src/components/computer/computer-view.tsx
@@ -8,8 +8,8 @@ import {
takeControl,
} from "@/lib/computers/control";
import { readScreenshot, type Screenshot } from "@/lib/computers/screen";
+import { ChannelAvatar } from "../channels/avatar";
import { LiveScreen } from "./live-screen";
-import { ComputerPlaceholder } from "./placeholder";
/** Explicit blank-browser URLs use placeholder artwork; missing URL fields are treated as real pages. */
function isBlankBrowser(shot: Screenshot): boolean {
@@ -45,6 +45,43 @@ const SETTLE_TIMEOUT_MS = 30_000;
/** Short confirmation window after a secret is sent to the page. */
const SECRET_CONFIRM_MS = 6_000;
+/**
+ * What the frame says when there is no picture in it.
+ *
+ * Shared by the card and the full-size view because it is the same fact at either size, and because
+ * the full-size view is now reachable with nothing to draw: the wheel lives down there, so a person
+ * whose Bot is looking at a blank browser — or whose screen cannot be read at all — has to be able
+ * to open it and be told why it is empty, rather than find a disabled frame and no way in.
+ */
+function NothingToSee({
+ problem,
+ blankBrowser,
+}: {
+ problem: string | null;
+ blankBrowser: boolean;
+}) {
+ return (
+
+ {problem ? (
+ <>
+
+ You cannot see the screen right now
+
+ {problem}
+
+ The assistant may still be working. An administrator can check
+ whether its computer is running.
+
+ >
+ ) : blankBrowser ? (
+ The assistant has not opened a page yet.
+ ) : (
+ Waiting for the assistant's screen…
+ )}
+
+ );
+}
+
type Props = {
/** Which computer to watch. One shared computer unless each Bot has been given its own. */
computerId: string;
@@ -55,6 +92,8 @@ type Props = {
aspectRatio?: number;
minWidth?: number;
minHeight?: number;
+ /** Whose screen this is, drawn as a small badge over the frame. Absent, no badge is drawn. */
+ name?: string;
};
export function ComputerView({
@@ -64,6 +103,7 @@ export function ComputerView({
aspectRatio = DEFAULT_ASPECT_RATIO,
minWidth = DEFAULT_MIN_WIDTH,
minHeight = DEFAULT_MIN_HEIGHT,
+ name,
}: Props) {
const [shot, setShot] = useState(null);
const [problem, setProblem] = useState(null);
@@ -176,19 +216,21 @@ export function ComputerView({
/*
* Sized from the ratio, never from the payload, so the frame is identical while a screen is
- * loading and once it arrives.
- *
- * A browser that has opened nothing is the exception. Reserving a screen-sized frame for it put a
- * placeholder the height of a browser window into the middle of a conversation, above an answer
- * that never involved the browser at all: a Bot asked about Google Drive rendered a full-size
- * empty panel saying it had not opened a page. Nothing is loading there and nothing is coming, so
- * there is no layout jump to protect against and no reason to take the room.
+ * loading, once it arrives, and while the browser has nothing open. A blank browser used to
+ * collapse to a strip of text; that made the panel change shape the moment a page opened, and a
+ * surface whose whole job is showing a screen kept surprising the layout around it.
*/
- const frameStyle = blankBrowser
- ? { minWidth }
- : { aspectRatio, minWidth, minHeight };
- /** Blank browser placeholders should not be opened as readable screens. */
+ const frameStyle = { aspectRatio, minWidth, minHeight };
+ /** Whether there is a page to draw. A blank browser and an unreadable screen are both "no". */
const showScreen = shot !== null && !blankBrowser;
+ /**
+ * Whether the full-size view has a stream worth opening.
+ *
+ * Nothing to draw, and it says so in the same words the card does — but somebody holding the wheel
+ * gets the live socket whatever is on it, because once a person is driving the stream is the truth
+ * about the page and a placeholder over it would be the view arguing with them.
+ */
+ const showLiveScreen = showScreen || driving;
const polledScreen = showScreen ? (
setExpanded(true)}
- // Disabled while blank/waiting but still reserves the frame.
- disabled={!showScreen}
- className="relative block w-full bg-muted enabled:cursor-zoom-in"
+ /*
+ * Opens whether or not there is a picture in it. It used to be disabled without one, and
+ * the wheel is down there: a blank browser, a screen that had not arrived yet, or a
+ * computer that could not be reached left a person with no way to take control at all —
+ * the states where they most want it. With nothing to draw the full-size view shows these
+ * same words, and the wheel below them.
+ */
+ className="relative block w-full cursor-pointer bg-muted"
style={frameStyle}
aria-label="Open the assistant's screen full size"
>
{polledScreen}
- {blankBrowser ? (
-
+ {/* Whose computer this is — and whose hands are on it — said on the picture itself. */}
+ {name || driving ? (
+
+ {name ? (
+
+
+ {name}
+
+ ) : null}
+ {driving ? (
+
+ You have control
+
+ ) : null}
+
) : null}
- {/* The blank state is a line of text, so it needs its own height rather than the frame's. */}
- {blankBrowser ? : null}
{showScreen ? null : (
-
- {problem ? (
- <>
-
- You cannot see the screen right now
-
- {problem}
-
- The assistant may still be working. An administrator can
- check whether its computer is running.
-
- >
- ) : blankBrowser ? (
- The assistant has not opened a page yet.
- ) : (
- Waiting for the assistant's screen…
- )}
-
+
)}
+ {/*
+ * The Bot ASKING for the wheel, which is not the same thing as a person wanting it.
+ *
+ * The standing "who is driving" prose and the everyday Take control button live in the
+ * full-size view, where there is a page big enough to drive. This row is the exception: a
+ * request is an exceptional state with a reason attached, it is the one moment the screen is
+ * waiting on a person rather than the other way round, and making them open the full-size
+ * view to find out what was wanted would hide the reason behind a click. Taking the wheel
+ * from here opens that view, because driving is what they are being asked to do.
+ */}
+ {!driving && control?.requested ? (
+
+ ) : null}
+
{/*
Secret values go directly to the page path and are never included in the conversation.
Audit records that a secret was supplied, not the value.
@@ -303,77 +368,12 @@ export function ComputerView({
) : null}
- {driving ? (
-
- You have control of this browser.
-
-
-
-
-
- ) : null}
-
{/*
- * The wheel is offered whether or not the Bot asked for it.
- *
- * It only used to appear once the Bot called `computer_request_help`, which made the button
- * depend on the Bot getting one instruction right. It does not always: asked to open a page
- * behind a sign-in, a Bot answered "If you'd like, I can prompt you to take control … would
- * you like to proceed with signing in?" and called nothing. The person was told to take
- * control, and there was no control to take. The prompt already forbids that sentence in as
- * many words, so the answer is not more prose: it is that a person who wants their own
- * browser should not have to be offered it first.
- *
- * The amber row stays the Bot ASKING, which is a different thing and still worth its own
- * colour and its reason. Without a request this is a quiet control that says who is driving.
+ * The inline card carries no persistent footer: taking the wheel, handing it back, and the
+ * standing "who is driving" prose all live in the full-size view, where there is a page big
+ * enough to drive. The two rows above appear only while the Bot is stuck — waiting on a
+ * credential, or asking for the wheel — and go again when it is not.
*/}
- {!driving ? (
-
-
- {control?.requested ? (
- <>
-
- The assistant needs you.
- {" "}
- {control.reason}
- >
- ) : (
- "The assistant is driving. You can take over whenever you want."
- )}
-
-
-
- ) : null}
{/*
@@ -385,7 +385,7 @@ export function ComputerView({
role="dialog"
aria-modal="true"
aria-label="The assistant's screen"
- className="fixed inset-0 z-50 flex flex-col p-4 sm:p-8"
+ className="fixed inset-0 z-50 flex flex-col items-center justify-center p-4 sm:p-8"
>
{/* Backdrop closes only while read-only; during driving, Escape remains the exit. */}
-
-
- {driving ? (
- <>
- You have control.{" "}
- Click and type on the page as you normally would.
- {control?.reason ? ` ${control.reason}` : null}
- >
+ {/* A card holding the screen, with who and the wheel centered beneath it. */}
+
+ {/*
+ Overlay uses the live socket; the inline card keeps low-cost polling. With no page
+ to draw it reserves the same frame and says the same thing the card does — the
+ wheel below is the reason this view opens at all in that state.
+ */}
+
+
+ {name ? (
+
+
+ {name}
+
+ ) : null}
+ {driving ? (
+
+ You have control — click and type on the page.
+ {control?.reason ? ` ${control.reason}` : null}
+
+ ) : control?.requested ? (
+
+
+ The assistant needs you.
+ {" "}
+ {control.reason}
+
+ ) : null}
+
{driving ? (
) : (
- /* Offered here too, and for the same reason: see the inline card above. */
)}
-
- {driving
- ? "Press Escape to close"
- : "Click anywhere or press Escape to close"}
-
-
-
- {/* Overlay uses the live socket; the inline card keeps low-cost polling. */}
-
-
+
,
document.body,
diff --git a/app/src/components/computer/placeholder.tsx b/app/src/components/computer/placeholder.tsx
deleted file mode 100644
index 57dfab79..00000000
--- a/app/src/components/computer/placeholder.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-import type { SVGProps } from "react";
-
-/**
- * Decorative waiting artwork for the fixed-size computer frame.
- */
-export function ComputerPlaceholder(props: SVGProps) {
- return (
-
- );
-}
diff --git a/app/src/components/ui/alert-dialog.tsx b/app/src/components/ui/alert-dialog.tsx
deleted file mode 100644
index b30e42a2..00000000
--- a/app/src/components/ui/alert-dialog.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
-import type * as React from "react";
-
-import { Button } from "@/components/ui/button";
-import { cn } from "@/lib/utils";
-
-/**
- * A modal for an action a click cannot undo. Built on `AlertDialogRoot` rather than `Dialog`'s
- * `DialogRoot`: it carries `role="alertdialog"` and is announced immediately, and has no corner
- * close button — the only way out is one of the footer's own buttons.
- */
-function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
- return ;
-}
-
-function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
- return (
-
- );
-}
-
-function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
- return (
-
- );
-}
-
-function AlertDialogOverlay({
- className,
- ...props
-}: AlertDialogPrimitive.Backdrop.Props) {
- return (
-
- );
-}
-
-function AlertDialogContent({
- className,
- ...props
-}: AlertDialogPrimitive.Popup.Props) {
- return (
-
-
-
-
- );
-}
-
-function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function AlertDialogTitle({
- className,
- ...props
-}: AlertDialogPrimitive.Title.Props) {
- return (
-
- );
-}
-
-function AlertDialogDescription({
- className,
- ...props
-}: AlertDialogPrimitive.Description.Props) {
- return (
-
- );
-}
-
-/** The button that answers "no" or "not now." Closes without running anything else. */
-function AlertDialogCancel({
- className,
- ...props
-}: React.ComponentProps) {
- return (
- }
- />
- );
-}
-
-/**
- * The button that carries out the action, styled destructive by default since that is the only
- * reason this component exists rather than the ordinary `Dialog`. Pass `onClick` to run the action;
- * closing is automatic, the same as `AlertDialogCancel`.
- */
-function AlertDialogAction({
- className,
- variant = "destructive",
- ...props
-}: React.ComponentProps) {
- return (
- }
- />
- );
-}
-
-export {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogOverlay,
- AlertDialogPortal,
- AlertDialogTitle,
- AlertDialogTrigger,
-};
diff --git a/app/src/components/ui/checkbox.tsx b/app/src/components/ui/checkbox.tsx
new file mode 100644
index 00000000..a863a705
--- /dev/null
+++ b/app/src/components/ui/checkbox.tsx
@@ -0,0 +1,26 @@
+import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
+import { IconCheck } from "@tabler/icons-react"
+
+import { cn } from "@/lib/utils"
+
+function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/app/src/components/ui/context-menu.tsx b/app/src/components/ui/context-menu.tsx
new file mode 100644
index 00000000..3b23d181
--- /dev/null
+++ b/app/src/components/ui/context-menu.tsx
@@ -0,0 +1,269 @@
+import * as React from "react"
+import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
+
+import { cn } from "@/lib/utils"
+import { IconChevronRight, IconCheck } from "@tabler/icons-react"
+
+function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
+ return
+}
+
+function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
+ return (
+
+ )
+}
+
+function ContextMenuTrigger({
+ className,
+ ...props
+}: ContextMenuPrimitive.Trigger.Props) {
+ return (
+
+ )
+}
+
+function ContextMenuContent({
+ className,
+ align = "start",
+ alignOffset = 4,
+ side = "right",
+ sideOffset = 0,
+ ...props
+}: ContextMenuPrimitive.Popup.Props &
+ Pick<
+ ContextMenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
+ return (
+
+ )
+}
+
+function ContextMenuLabel({
+ className,
+ inset,
+ ...props
+}: ContextMenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function ContextMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: ContextMenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
+ return (
+
+ )
+}
+
+function ContextMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: ContextMenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function ContextMenuSubContent({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function ContextMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: ContextMenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function ContextMenuRadioGroup({
+ ...props
+}: ContextMenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function ContextMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: ContextMenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function ContextMenuSeparator({
+ className,
+ ...props
+}: ContextMenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function ContextMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ ContextMenu,
+ ContextMenuTrigger,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuCheckboxItem,
+ ContextMenuRadioItem,
+ ContextMenuLabel,
+ ContextMenuSeparator,
+ ContextMenuShortcut,
+ ContextMenuGroup,
+ ContextMenuPortal,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuRadioGroup,
+}
diff --git a/app/src/lib/channels/mutations.ts b/app/src/lib/channels/mutations.ts
index 1fb3f76c..cf116ed8 100644
--- a/app/src/lib/channels/mutations.ts
+++ b/app/src/lib/channels/mutations.ts
@@ -22,35 +22,6 @@ export function createChannelMutationOptions(queryClient: QueryClient) {
});
}
-/**
- * Delete a channel, and ask the platform to forget the thread behind it.
- *
- * Other tabs learn a channel is gone from the socket event in `use-channel-events.ts`; this tab
- * issued the delete itself and never receives its own event, so it clears the roster and detail
- * cache directly on success.
- *
- * Resolves to whether the message history outlived the channel. The local delete commits first and
- * the thread deletion can fail on its own, so this is not a failure to throw on: the conversation
- * is gone either way, and the caller shows the residue rather than reporting an error that did not
- * happen.
- */
-export function deleteChannelMutationOptions(queryClient: QueryClient) {
- return mutationOptions({
- mutationFn: async (channelId: string): Promise => {
- const response = await client(`/api/channels/${channelId}`, {
- method: "DELETE",
- fallback: "Could not delete this conversation",
- });
- const body = (await response.json()) as { historyLeftBehind?: boolean };
- return body.historyLeftBehind === true;
- },
- onSuccess: (_data, channelId) => {
- queryClient.invalidateQueries({ queryKey: channelKeys.all });
- queryClient.removeQueries({ queryKey: channelKeys.detail(channelId) });
- },
- });
-}
-
/**
* Report the last thing said in a channel.
*
@@ -79,3 +50,35 @@ export function recordChannelActivityMutationOptions() {
},
});
}
+
+/** Pin or unpin a channel for this member. A marker, not a reorder, so no optimistic sort. */
+export function setChannelPinnedMutationOptions(queryClient: QueryClient) {
+ return mutationOptions({
+ mutationFn: async (variables: { channelId: string; pinned: boolean }) => {
+ await client(`/api/channels/${variables.channelId}/pin`, {
+ method: "PUT",
+ body: { pinned: variables.pinned },
+ fallback: "Could not pin this channel",
+ });
+ },
+ onSuccess: () =>
+ queryClient.invalidateQueries({ queryKey: channelKeys.all }),
+ });
+}
+
+/** Soft-delete a channel for everyone in it. The server keeps the transcript; the roster forgets. */
+export function deleteChannelMutationOptions(queryClient: QueryClient) {
+ return mutationOptions({
+ mutationFn: async (channelId: string) => {
+ await client(`/api/channels/${channelId}`, {
+ method: "DELETE",
+ fallback: "Could not delete this channel",
+ });
+ },
+ // The roster only. The open channel's detail query would refetch into the fresh 404 and
+ // flash an error before the navigate-home lands; left alone, it keeps its cache and the
+ // navigation happens with nothing to complain about.
+ onSuccess: () =>
+ queryClient.invalidateQueries({ queryKey: channelKeys.list() }),
+ });
+}
diff --git a/app/src/lib/channels/queries.ts b/app/src/lib/channels/queries.ts
index 21ecbce7..2c292946 100644
--- a/app/src/lib/channels/queries.ts
+++ b/app/src/lib/channels/queries.ts
@@ -24,6 +24,8 @@ export type ChannelSummary = AgentChannel & {
lastMessageAgentId: string | null;
/** ISO-8601. Ordering falls back to this, so a channel just created sorts to the top. */
createdAt: string;
+ /** Whether this member pinned the channel. Pinned channels sort first in the roster. */
+ pinned: boolean;
};
export const channelKeys = {
diff --git a/app/src/lib/channels/use-channel-events.ts b/app/src/lib/channels/use-channel-events.ts
index 63459dd4..70ffab50 100644
--- a/app/src/lib/channels/use-channel-events.ts
+++ b/app/src/lib/channels/use-channel-events.ts
@@ -10,15 +10,98 @@ import { type ChannelPage, type ChannelSummary, channelKeys } from "./queries";
* list to recover events missed while disconnected.
*/
-type ChannelActivityEvent = {
+export type ChannelActivityEvent = {
channelId: string;
lastMessage: string | null;
lastMessageAt: string | null;
lastMessageAgentId: string | null;
- /** The channel is gone. Absent on an ordinary activity event. */
+ /** The channel is gone from every member's roster. Absent on an ordinary activity event. */
deleted?: true;
+ /**
+ * This member's pin, changed. Absent on an ordinary activity event.
+ *
+ * The server scopes a pin to the member who made it, so one arriving here is the reader's own,
+ * made in another tab or on another replica.
+ */
+ pinned?: boolean;
};
+/** The infinite query's cache, which holds pages rather than one array. */
+type ChannelCache = { pages: ChannelPage[]; pageParams: unknown[] };
+
+/**
+ * Apply one event to the cached pages.
+ *
+ * Pure, and exported, because the patching rules are the whole of what a socket event does to the
+ * screen and they should be provable without a socket. Returns the cache it was given when nothing
+ * changed, so React re-renders nothing, and `"unknown"` when the event names a channel no page
+ * holds — which the caller answers with a refetch rather than a patch.
+ */
+export function applyChannelEvent(
+ data: ChannelCache,
+ activity: ChannelActivityEvent,
+): ChannelCache | "unknown" {
+ const holdingPage = data.pages.findIndex((page) =>
+ page.channels.some((channel) => channel.id === activity.channelId),
+ );
+
+ // Must run before the patch below, which spreads the event onto the existing row — reaching that
+ // first would stamp `deleted: true` on the row instead of removing it. An unknown channel here is
+ // already gone from this cache, so there is nothing to patch or invalidate for, unlike the
+ // "unknown channel" case below for an ordinary event.
+ if (activity.deleted) {
+ if (holdingPage === -1) return data;
+ const page = data.pages[holdingPage] as ChannelPage;
+ const pages = data.pages.slice();
+ pages[holdingPage] = {
+ ...page,
+ channels: page.channels.filter(
+ (channel) => channel.id !== activity.channelId,
+ ),
+ };
+ return { ...data, pages };
+ }
+
+ // An unknown channel id means the roster is stale; refetch rather than patch.
+ if (holdingPage === -1) return "unknown";
+
+ const page = data.pages[holdingPage] as ChannelPage;
+ const index = page.channels.findIndex(
+ (channel) => channel.id === activity.channelId,
+ );
+ const previous = page.channels[index];
+ if (!previous) return data;
+
+ /*
+ * A pin patches the one field it is about.
+ *
+ * The spread below would carry this event's null message onto the row and wipe the preview the
+ * roster renders. No re-sort either: a pin is not activity, and pinned rows are lifted at render
+ * time by `pinnedFirst`, not by the order they sit in here.
+ */
+ if (activity.pinned !== undefined) {
+ if (previous.pinned === activity.pinned) return data;
+ const channels = page.channels.slice();
+ channels[index] = { ...previous, pinned: activity.pinned };
+ const pages = data.pages.slice();
+ pages[holdingPage] = { ...page, channels };
+ return { ...data, pages };
+ }
+
+ // Preserve object identity for unchanged rows so memoized rows do not re-render.
+ const next = page.channels.slice();
+ next[index] = { ...previous, ...activity };
+ next.sort(byRecency);
+
+ // An event that changes nothing visible, a duplicate, or a report the server ignored as stale,
+ // returns the original object, so React re-renders nothing at all.
+ if (next.every((channel, at) => channel === page.channels[at])) return data;
+
+ const pages = data.pages.slice();
+ pages[holdingPage] = { ...page, channels: next };
+ return { ...data, pages };
+}
+
const FIRST_RETRY_MS = 500;
const MAX_RETRY_MS = 30_000;
@@ -66,73 +149,25 @@ export function useChannelEvents() {
*/
queryClient.setQueryData(
channelKeys.list(),
- (
- data: { pages: ChannelPage[]; pageParams: unknown[] } | undefined,
- ) => {
+ (data: ChannelCache | undefined) => {
if (!data) return data;
-
- const holdingPage = data.pages.findIndex((page) =>
- page.channels.some(
- (channel) => channel.id === activity.channelId,
- ),
- );
-
- // Must run before the patch below, which spreads the event onto the existing row —
- // reaching that first would stamp `deleted: true` on the row instead of removing it.
- // An unknown channel here is already gone from this cache, so there is nothing to patch
- // or invalidate for, unlike the "unknown channel" case below for an ordinary event.
- if (activity.deleted) {
- if (holdingPage === -1) return data;
- const page = data.pages[holdingPage] as ChannelPage;
- const pages = data.pages.slice();
- pages[holdingPage] = {
- ...page,
- channels: page.channels.filter(
- (channel) => channel.id !== activity.channelId,
- ),
- };
- return { ...data, pages };
- }
-
+ const patched = applyChannelEvent(data, activity);
+ if (patched !== "unknown") return patched;
// An unknown channel id means the roster is stale; refetch rather than patch.
- if (holdingPage === -1) {
- void queryClient.invalidateQueries({
- queryKey: channelKeys.list(),
- });
- return data;
- }
-
- const page = data.pages[holdingPage] as ChannelPage;
- const index = page.channels.findIndex(
- (channel) => channel.id === activity.channelId,
- );
- const previous = page.channels[index];
- if (!previous) return data;
-
- // Preserve object identity for unchanged rows so memoized rows do not re-render.
- const next = page.channels.slice();
- next[index] = { ...previous, ...activity };
- next.sort(byRecency);
-
- // An event that changes nothing visible, a duplicate, or a report the server ignored as
- // stale, returns the original object, so React re-renders nothing at all.
- if (next.every((channel, at) => channel === page.channels[at])) {
- return data;
- }
-
- const pages = data.pages.slice();
- pages[holdingPage] = { ...page, channels: next };
- return { ...data, pages };
+ void queryClient.invalidateQueries({
+ queryKey: channelKeys.list(),
+ });
+ return data;
},
);
/*
* A tab looking at the channel somebody just deleted in another tab.
*
- * The tab that issued the delete moves itself before it fires the request. Every other tab
- * only ever hears about it here, and dropping the row without moving leaves that tab on a
- * route whose channel no longer resolves: an error, or an empty conversation, depending on
- * which query answers first.
+ * The tab that issued the delete moves itself once the request returns. Every other tab only
+ * ever hears about it here, and dropping the row without moving leaves that tab on a route
+ * whose channel no longer resolves: an error, or an empty conversation, depending on which
+ * query answers first.
*
* Read off the router at event time rather than through `useParams`, so the effect does not
* have to be torn down and reconnected on every navigation just to keep this value fresh.
diff --git a/app/src/lib/computers/activity.ts b/app/src/lib/computers/activity.ts
index f6c29330..c9535034 100644
--- a/app/src/lib/computers/activity.ts
+++ b/app/src/lib/computers/activity.ts
@@ -70,16 +70,16 @@ export function activityFor(computerId: string): ComputerActivity[] {
}
/**
- * Whether this Bot has opened a page since the surface was loaded.
- *
- * The screen is a property of the computer and outlives a conversation: several Bots share one, and
- * the profile keeps whatever page was last open. So a Bot that spends a whole conversation in a
- * terminal still has a screen, showing somebody else's order form from an hour ago, and defaulting
- * the pane to it captions a stale page as what this Bot is doing right now. That is worse than
- * showing nothing, because it is confidently wrong.
+ * Which Bots have opened a page since the surface was loaded.
*
* Recorded rather than inferred from the screenshot: a screenshot always succeeds, and "the browser
- * has a page loaded" is a different fact from "this Bot opened one".
+ * has a page loaded" is a different fact from "this Bot opened one" — the screen belongs to the
+ * computer and outlives a conversation, so a Bot that has browsed nothing still has a page on it.
+ *
+ * Nothing reads this today. The screen and the activity log are now stacked rather than tabbed, so
+ * no view has to guess which of the two to open, and the caption that hedged about whose page it
+ * was is gone with it. The note stays because the tool handler is the only place the fact exists and
+ * losing it would mean re-deriving it from a screenshot, which cannot answer it.
*/
const browsed = new Set();
@@ -89,10 +89,6 @@ export function noteBrowsed(computerId: string): void {
for (const listener of listeners) listener();
}
-export function hasBrowsed(computerId: string): boolean {
- return browsed.has(computerId);
-}
-
export function subscribeToActivity(listener: () => void): () => void {
listeners.add(listener);
return () => {
diff --git a/app/src/lib/markdown.tsx b/app/src/lib/markdown.tsx
index 1dae79d0..6ed4be50 100644
--- a/app/src/lib/markdown.tsx
+++ b/app/src/lib/markdown.tsx
@@ -22,9 +22,9 @@ import type { ComponentProps } from "react";
* a connector that answers from a live system. It also survives the model's phrasing: whether it
* writes "I found it in X" or lists three files, each one is drawn the same way.
*
- * Recognition is by URL, and only Google's own document hosts. Anything else is an ordinary link,
- * because a chip asserts "this is a file in a system you have connected" and that is not something
- * to claim about a URL a model wrote.
+ * Recognition is by URL, and only document hosts this deployment knows about. Anything else is an
+ * ordinary link, because a chip asserts "this is a file in a system you have connected" and that
+ * is not something to claim about a URL a model wrote.
*/
const DRIVE_KINDS = [
{ match: "/document/", icon: IconFileText, label: "Doc" },
@@ -32,14 +32,14 @@ const DRIVE_KINDS = [
{ match: "/presentation/", icon: IconPresentation, label: "Slides" },
] as const;
-function driveKind(href: string | undefined) {
+export function documentChipKind(href: string | undefined) {
if (!href) return null;
let url: URL;
try {
url = new URL(href);
} catch {
- // A relative or malformed href is not a Drive document, and is not worth throwing over.
+ // A relative or malformed href is not a recognised document, and is not worth throwing over.
return null;
}
@@ -59,12 +59,19 @@ function driveKind(href: string | undefined) {
if (url.hostname === "drive.google.com") {
return { match: "", icon: IconFile, label: "Drive" };
}
+ /*
+ * Same exact-host rule as Drive: a chip asserts "this is a document in a system you have
+ * connected", and notion.so.evil.test is somebody else's domain wearing the name.
+ */
+ if (url.hostname === "notion.so" || url.hostname === "www.notion.so") {
+ return { match: "", icon: IconFileText, label: "Notion" };
+ }
return null;
}
export const markdownComponents = {
a: ({ href, children, ...rest }: ComponentProps<"a">) => {
- const kind = driveKind(href);
+ const kind = documentChipKind(href);
if (kind) {
const Icon = kind.icon;
diff --git a/app/src/lib/plugins/mutations.ts b/app/src/lib/plugins/mutations.ts
index 7ead8df7..e8bee1ff 100644
--- a/app/src/lib/plugins/mutations.ts
+++ b/app/src/lib/plugins/mutations.ts
@@ -53,10 +53,40 @@ export type PluginKind = "mcp" | "skill";
const FALLBACK = "That did not work.";
-function invalidatePlugins(queryClient: QueryClient) {
+/**
+ * Refetch everything the plugin screens read.
+ *
+ * Exported because a bulk grant has to say when: N of these in a row, each awaiting its own
+ * refetch, is a dialog that spends most of a batch re-reading a list nobody has looked at yet.
+ */
+export function invalidatePlugins(queryClient: QueryClient) {
return queryClient.invalidateQueries({ queryKey: pluginKeys.all });
}
+/**
+ * Grant one plugin to one Bot, and refetch nothing.
+ *
+ * The write on its own, for the caller granting a batch of them: the server still records a row per
+ * grant, so the audit trail is unchanged, but the reader is refreshed once at the end rather than
+ * between every pair. Anything granting a single one should use the mutation below instead, which
+ * carries the refetch with it.
+ */
+export function grantPlugin(variables: {
+ kind: PluginKind;
+ ref: string;
+ agentId: string;
+}): Promise {
+ return client("/api/plugins/grants", {
+ method: "POST",
+ body: {
+ kind: variables.kind,
+ ref: variables.ref,
+ agentId: variables.agentId,
+ },
+ fallback: "That Agent could not be changed.",
+ });
+}
+
/**
* Whether one Bot carries one plugin.
*
@@ -72,15 +102,7 @@ export function setPluginGrantMutationOptions(queryClient: QueryClient) {
granted: boolean;
}) => {
if (variables.granted) {
- await client("/api/plugins/grants", {
- method: "POST",
- body: {
- kind: variables.kind,
- ref: variables.ref,
- agentId: variables.agentId,
- },
- fallback: "That Agent could not be changed.",
- });
+ await grantPlugin(variables);
return;
}
await client(
diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts
index 19dc6008..9530b33b 100644
--- a/app/src/lib/plugins/queries.ts
+++ b/app/src/lib/plugins/queries.ts
@@ -42,6 +42,11 @@ export type PluginServer = {
toolsRefreshedAt: string | null;
lastError: string | null;
addedBy: string | null;
+ /**
+ * Whether this server registers its own OAuth client (RFC 7591) rather than waiting on an
+ * administrator to paste one in.
+ */
+ dynamicClient: boolean;
tools: PluginTool[];
/** Empty for a healthy connector. See {@link WithdrawnGrant}. */
withdrawn: WithdrawnGrant[];
diff --git a/app/src/routes/_authed/_app/channel/$channelId.tsx b/app/src/routes/_authed/_app/channel/$channelId.tsx
index 8040d2c4..d9030a43 100644
--- a/app/src/routes/_authed/_app/channel/$channelId.tsx
+++ b/app/src/routes/_authed/_app/channel/$channelId.tsx
@@ -2,7 +2,7 @@ import { IconDeviceDesktop, IconSettings } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { motion, useReducedMotion } from "motion/react";
-import { useEffect, useRef, useState, useSyncExternalStore } from "react";
+import { useEffect, useRef } from "react";
import { z } from "zod";
import { AgentProfile } from "@/components/agents/agent-profile";
import { ChannelAvatar } from "@/components/channels/avatar";
@@ -13,11 +13,6 @@ import { useNeedsYou } from "@/components/computer/needs-you";
import { DetailPanel } from "@/components/layout/detail-panel";
import { Button } from "@/components/ui/button";
import { type AgentChannel, channelQueryOptions } from "@/lib/channels/queries";
-import {
- activityFor,
- hasBrowsed,
- subscribeToActivity,
-} from "@/lib/computers/activity";
import { onComputerActivity } from "@/lib/copilot/computer-activity";
const chatSearchSchema = z.object({
@@ -42,13 +37,11 @@ export const Route = createFileRoute("/_authed/_app/channel/$channelId")({
/**
* What the Bot is looking at, and what it is doing.
*
- * Two surfaces rather than one. The screen was the only window into a Bot's computer, so a Bot that
- * spent two minutes in a terminal showed a blank browser and nothing else: the honest answer to
- * "what is it doing" was "something, on a machine holding your logins". The second tab is the shell
- * and the workspace, and it fills up while the screen sits still.
- *
- * The screen stays the default, because most work is browsing and it is the surface somebody has to
- * take the wheel on. The count on the other tab is what says the Bot is busy somewhere else.
+ * Two surfaces, stacked rather than tabbed. The screen was the only window into a Bot's computer,
+ * so a Bot that spent two minutes in a terminal showed a blank browser and nothing else: the honest
+ * answer to "what is it doing" was "something, on a machine holding your logins". The activity —
+ * the shell and the workspace — sits below the screen, so watching one never costs the other and
+ * nothing about what the Bot is doing hides behind a tab nobody clicked.
*/
function ComputerViewPanel({
agentId,
@@ -57,77 +50,13 @@ function ComputerViewPanel({
agentId: string;
name?: string;
}) {
- const activity = useSyncExternalStore(
- subscribeToActivity,
- () => activityFor(agentId),
- () => activityFor(agentId),
- );
- const browsed = useSyncExternalStore(
- subscribeToActivity,
- () => hasBrowsed(agentId),
- () => hasBrowsed(agentId),
- );
-
- /*
- * Which surface opens, decided by what the Bot is actually doing.
- *
- * The screen belongs to the computer rather than to the conversation: Bots share one and the
- * profile keeps whatever page was open last. A Bot that spends a whole conversation in a terminal
- * therefore has a screen showing somebody else's page from an hour ago, and defaulting to it
- * captions that as what this Bot is doing now.
- *
- * So the screen is the default until there is a reason to think otherwise, and work away from the
- * browser with no page opened is that reason. Once somebody picks a tab, their choice stands.
- */
- const [chosen, setChosen] = useState<"screen" | "activity" | null>(null);
- const showing =
- chosen ?? (!browsed && activity.length > 0 ? "activity" : "screen");
-
return (
-
-
-
-
-
- {/*
- Both mounted, one hidden. Unmounting the screen would drop its socket and its polling, so
- looking at the terminal for a moment would cost the live view and the take-the-wheel prompt
- that rides on it.
- */}
-
-
- {/*
- The caption says whose page this is, and it is only this Bot's once it has opened one.
- Before that the browser still shows whatever was last open on the shared computer, and
- calling that "General Assistant's screen" states something untrue with confidence.
- */}
-
- {browsed
- ? `${name || "Agent"}'s screen`
- : `${name || "Agent"} has not opened a page in this conversation. This is whatever its computer had open last.`}
-
-
+
-
+
+
Activity
@@ -145,10 +74,14 @@ function RouteComponent() {
const isWatching = watch === true;
/** Channel routing currently supports one coworker. */
const agentId = channel.data?.agentIds[0];
- /** Needs-you state is rendered by the screen when the screen is already open. */
+ /** Only polled while the screen is closed; the screen panel polls control itself. */
const needsYou = useNeedsYou(agentId, !isWatching);
- // Needs-you prompts auto-open the screen because the actionable prompt is rendered there.
+ /*
+ * Needs-you prompts auto-open the screen panel, because the prompt with the reason on it — the
+ * amber "the assistant needs you" row, and the masked field for a credential — is drawn on the
+ * screen card in that panel. Nothing about a stuck Bot is actionable until this pane is open.
+ */
useEffect(() => {
if (!needsYou) return;
show("watch");
diff --git a/app/src/routes/_authed/_app/channel/new.tsx b/app/src/routes/_authed/_app/channel/new.tsx
index 520b5f89..100f7b3e 100644
--- a/app/src/routes/_authed/_app/channel/new.tsx
+++ b/app/src/routes/_authed/_app/channel/new.tsx
@@ -107,6 +107,9 @@ function RouteComponent() {
,
+ member: string,
+): ReadonlySet {
+ const next = new Set(set);
+ if (!next.delete(member)) next.add(member);
+ return next;
+}
/**
* How widely a tool is granted, in words rather than a fraction.
@@ -101,6 +114,24 @@ function RouteComponent() {
const [token, setToken] = useState("");
const [instanceHost, setInstanceHost] = useState("");
const [client, setClient] = useState({ clientId: "", clientSecret: "" });
+ /** Who gets the tools, and which, while the grant dialog is open. */
+ const [selectedBots, setSelectedBots] = useState>(
+ new Set(),
+ );
+ const [selectedRefs, setSelectedRefs] = useState>(
+ new Set(),
+ );
+ /**
+ * How far through a batch of grants we are, or null when none is running.
+ *
+ * A count rather than a boolean because a bulk grant is honestly N writes: a Bot times twelve
+ * tools is twelve requests, and a button that says only "Granting…" for the length of them gives
+ * an administrator no way to tell a slow batch from a stuck one.
+ */
+ const [granting, setGranting] = useState<{
+ done: number;
+ total: number;
+ } | null>(null);
/* Every write reports into one banner rather than each growing its own handler. */
const report = { onError: (thrown: Error) => setError(thrown.message) };
@@ -173,6 +204,39 @@ function RouteComponent() {
}
};
+ /*
+ * One write per grant, in selection order. The server records each grant as its own audit row, so
+ * a bulk action here is honestly N decisions; a refusal stops the rest and leaves the dialog open
+ * with the banner saying why.
+ *
+ * One refetch for the batch, at the end. Going through the grant mutation invalidated every plugin
+ * query after each write and awaited it, so a batch of twenty grants was twenty round trips
+ * interleaved with twenty refetches of a list nobody could see behind the dialog — most of the
+ * wait, for nothing anybody read. It is invalidated even when a grant is refused, because the ones
+ * before it landed and the screen behind is now stale about them.
+ */
+ const grantSelected = async () => {
+ setError(null);
+ const total = selectedBots.size * selectedRefs.size;
+ setGranting({ done: 0, total });
+ let done = 0;
+ try {
+ for (const agentId of selectedBots) {
+ for (const ref of selectedRefs) {
+ await grantPlugin({ agentId, kind: "mcp", ref });
+ done += 1;
+ setGranting({ done, total });
+ }
+ }
+ setDialog(null);
+ } catch (thrown) {
+ setError((thrown as Error).message);
+ } finally {
+ await invalidatePlugins(queryClient);
+ setGranting(null);
+ }
+ };
+
/* Nothing rather than a placeholder, so no sentence asserts anything while the fetch is open. */
if (plugins.isPending) {
return {null};
@@ -189,6 +253,16 @@ function RouteComponent() {
);
}
+ /* The grant dialog's two halves of the tool list, split by what a boundary would see. */
+ const reads = server?.tools.filter((tool) => tool.effect !== "write") ?? [];
+ const writes = server?.tools.filter((tool) => tool.effect === "write") ?? [];
+ const chosenWrites = writes.filter((tool) =>
+ selectedRefs.has(tool.ref),
+ ).length;
+ const chosenNames = bots
+ .filter((bot) => selectedBots.has(bot.id))
+ .map((bot) => bot.name);
+
return (
{/*
- * Rows that DO something, and nothing else. The layout skill's third row kind — a value
- * with no chevron and nothing to click — earns its place on a screen full of them, but
- * among four actionable rows a dead one reads as a control that has stopped working. The
- * redirect URI is prose under the card instead.
+ * Rows that DO something, and nothing else — with one admitted exception. The layout
+ * skill's third row kind — a value with no chevron and nothing to click — earns its
+ * place on a screen full of them, but among four actionable rows a dead one reads as a
+ * control that has stopped working. The redirect URI is prose under the card instead.
+ *
+ * The exception is the OAuth client row for a vendor with a dynamic client: there is a
+ * real fact to state — this deployment registers itself, nobody configures it — right
+ * where the actionable client row would otherwise sit. Leaving that slot empty would
+ * read as a missing setup step, not as nothing to do.
*/}
{auth === "deployment-bearer" ? (
@@ -279,7 +358,29 @@ function RouteComponent() {
) : null}
- {auth === "user-oauth" ? (
+ {auth === "user-oauth" && server?.dynamicClient ? (
+ /*
+ * Nothing to click. This deployment registers its own OAuth client with the
+ * vendor (RFC 7591) the first time anybody connects, so there is no client id
+ * or secret for an administrator to hold, let alone paste.
+ */
+
+
+ OAuth client
+
+ This deployment registers itself with the vendor on first
+ connect. There is nothing to paste.
+
+
+
+
+ Self-registered
+
+
+
+ ) : null}
+
+ {auth === "user-oauth" && !server?.dynamicClient ? (
setDialog("client")} type="button" />
@@ -314,10 +415,13 @@ function RouteComponent() {
* It is NOT part of setup. The connector is fully configured without it, which is why it
* sits below the client and says so rather than reading as the next required step.
*
- * Shown only once a client exists, because there is nothing to consent against before
- * that: a Connect button with no OAuth client behind it can only fail.
+ * Shown once a client exists, because there is nothing to consent against before
+ * that: a Connect button with no OAuth client behind it can only fail. A vendor with a
+ * dynamic client is the exception — there is no client to register in advance, so
+ * Connect is shown right away and is itself what creates one.
*/}
- {auth === "user-oauth" && server?.hasCredential ? (
+ {auth === "user-oauth" &&
+ (server?.hasCredential || server?.dynamicClient) ? (
<>
@@ -325,7 +429,7 @@ function RouteComponent() {
Your account
{youConnected
- ? "Connected, so a Bot granted these tools reads your Drive as you. Everybody else connects their own."
+ ? `Connected, so a Bot granted these tools uses your ${title} as you. Everybody else connects their own.`
: "Connect your own account to try this connector. Setup is complete without it, and it reaches your documents only."}
@@ -416,21 +520,28 @@ function RouteComponent() {
{auth === "user-oauth" ? (
-
- Add this to the client's authorised redirect URIs at the vendor,
- exactly as written. A single wrong character fails there, with a
- message that does not mention OpenBot.
-
- {plugins.data?.redirectUri ? (
- /* Selectable and monospaced: it is copied by hand into somebody else's console. */
-
- {plugins.data.redirectUri}
-
+ {server?.dynamicClient ? (
+
+ The deployment registers its redirect URI itself, so there is
+ nothing to add at the vendor.
+
) : (
+
+ Add this to the client's authorised redirect URIs at the
+ vendor, exactly as written. A single wrong character fails
+ there, with a message that does not mention OpenBot.
+
+ )}
+ {!plugins.data?.redirectUri ? (
This deployment has no public URL, so nobody can complete a
consent flow. Set OPENBOT_PUBLIC_URL.
+ ) : server?.dynamicClient ? null : (
+ /* Selectable and monospaced: it is copied by hand into somebody else's console. */
+
+ {plugins.data.redirectUri}
+
)}
) : null}
@@ -446,14 +557,35 @@ function RouteComponent() {
* an administrator came here to do.
*/
action={
-
+
+
+ {/*
+ * Outline where refresh is ghost: granting is the thing an administrator came to
+ * this section to do. Hidden rather than disabled with nothing to grant — a dialog
+ * over an empty list could only explain its own emptiness.
+ */}
+ {server.tools.length > 0 && bots.length > 0 ? (
+
+ ) : null}
+
}
description="A Bot is told about a tool only when it holds it. Every call is decided again when it happens, so removing a grant takes effect on the next one."
title="Tools"
@@ -561,7 +693,7 @@ function RouteComponent() {
+
+ {/*
+ * Who first, then what: the decision arrives as "set this Bot up", not as a list of tools
+ * looking for an owner. Both groups get a select-all; the amber heading and the footer's
+ * "N of which change things" are what keep a bulk write grant a read decision, not a blind one.
+ */}
+ {server ? (
+
+ ) : null}
);
}
diff --git a/app/src/routes/_authed/admin/plugins/index.tsx b/app/src/routes/_authed/admin/plugins/index.tsx
index 69b6ee44..17d6db1d 100644
--- a/app/src/routes/_authed/admin/plugins/index.tsx
+++ b/app/src/routes/_authed/admin/plugins/index.tsx
@@ -1,5 +1,6 @@
import {
IconBrandGoogleDrive,
+ IconBrandNotion,
IconChevronRight,
IconPlug,
} from "@tabler/icons-react";
@@ -54,6 +55,7 @@ export const Route = createFileRoute("/_authed/admin/plugins/")({
*/
const MARKS: Record> = {
"google-drive": IconBrandGoogleDrive,
+ notion: IconBrandNotion,
};
const markFor = (key: string) => MARKS[key] ?? IconPlug;
diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx
index 07e2bf6a..04a7f96b 100644
--- a/app/src/routes/_authed/settings/connected-accounts/index.tsx
+++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx
@@ -1,5 +1,6 @@
import {
IconBrandGoogleDrive,
+ IconBrandNotion,
IconChevronRight,
IconPlug,
} from "@tabler/icons-react";
@@ -52,6 +53,7 @@ export const Route = createFileRoute("/_authed/settings/connected-accounts/")({
/** The same marks the admin connector list uses: these are the same vendors seen from your side. */
const MARKS: Record> = {
"google-drive": IconBrandGoogleDrive,
+ notion: IconBrandNotion,
};
const markFor = (key: string) => MARKS[key] ?? IconPlug;
diff --git a/app/tests/channel-event-patch.test.ts b/app/tests/channel-event-patch.test.ts
new file mode 100644
index 00000000..62efb793
--- /dev/null
+++ b/app/tests/channel-event-patch.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, test } from "bun:test";
+import type { ChannelPage, ChannelSummary } from "../src/lib/channels/queries";
+import {
+ applyChannelEvent,
+ type ChannelActivityEvent,
+} from "../src/lib/channels/use-channel-events";
+
+/** A minimal but fully-typed channel summary, so tests build real objects rather than casts. */
+function channel(
+ id: string,
+ overrides: Partial = {},
+): ChannelSummary {
+ return {
+ id,
+ name: id,
+ agentIds: [],
+ threadId: `thread-${id}`,
+ active: true,
+ lastMessage: null,
+ lastMessageAt: null,
+ lastMessageAgentId: null,
+ createdAt: "2024-01-01T00:00:00.000Z",
+ pinned: false,
+ ...overrides,
+ };
+}
+
+function cache(...pages: ChannelSummary[][]) {
+ return {
+ pages: pages.map(
+ (channels): ChannelPage => ({ channels, nextCursor: null }),
+ ),
+ pageParams: pages.map(() => ""),
+ };
+}
+
+function event(
+ overrides: Partial & { channelId: string },
+): ChannelActivityEvent {
+ return {
+ lastMessage: null,
+ lastMessageAt: null,
+ lastMessageAgentId: null,
+ ...overrides,
+ };
+}
+
+describe("an ordinary activity event", () => {
+ test("patches the row inside the page that holds it and re-sorts that page", () => {
+ const data = cache([
+ channel("a", { lastMessageAt: "2024-03-01T00:00:00.000Z" }),
+ channel("b"),
+ ]);
+
+ const patched = applyChannelEvent(
+ data,
+ event({
+ channelId: "b",
+ lastMessage: "Said something.",
+ lastMessageAt: "2024-04-01T00:00:00.000Z",
+ }),
+ );
+
+ expect(patched).not.toBe("unknown");
+ if (patched === "unknown") return;
+ expect(patched.pages[0]?.channels.map((row) => row.id)).toEqual(["b", "a"]);
+ expect(patched.pages[0]?.channels[0]?.lastMessage).toBe("Said something.");
+ });
+
+ test("is unknown when no page holds the channel, so the caller refetches", () => {
+ expect(
+ applyChannelEvent(cache([channel("a")]), event({ channelId: "z" })),
+ ).toBe("unknown");
+ });
+});
+
+/**
+ * A channel somebody deleted in another tab, or on another replica.
+ *
+ * The tab that issued the delete moves itself; every other tab only ever hears about it here, so
+ * without this the row stays on their roster until something else makes them refetch.
+ */
+describe("a deleted channel", () => {
+ test("is removed from the page that held it", () => {
+ const data = cache([channel("a"), channel("b")], [channel("c")]);
+
+ const patched = applyChannelEvent(
+ data,
+ event({ channelId: "b", deleted: true }),
+ );
+
+ expect(patched).not.toBe("unknown");
+ if (patched === "unknown") return;
+ expect(patched.pages[0]?.channels.map((row) => row.id)).toEqual(["a"]);
+ // The other page is untouched, object identity included, so its rows do not re-render.
+ expect(patched.pages[1]).toBe(data.pages[1]);
+ });
+
+ test("is never spread onto the row instead of removing it", () => {
+ const patched = applyChannelEvent(
+ cache([channel("a")]),
+ event({ channelId: "a", deleted: true }),
+ );
+
+ expect(patched).not.toBe("unknown");
+ if (patched === "unknown") return;
+ // The failure this guards is a row left on the roster carrying `deleted: true`, which renders
+ // as an ordinary channel whose every query now 404s.
+ expect(patched.pages[0]?.channels).toEqual([]);
+ });
+
+ test("changes nothing when this cache never had the channel", () => {
+ const data = cache([channel("a")]);
+
+ // Unlike an ordinary event, an unknown id here is not a stale roster: the channel is already
+ // gone from this cache, so there is nothing to patch and nothing to refetch for.
+ expect(
+ applyChannelEvent(data, event({ channelId: "z", deleted: true })),
+ ).toBe(data);
+ });
+});
+
+/**
+ * A pin this person made in one of their own tabs.
+ *
+ * Scoped to them by the server, so arriving here means it is the reader's own pin.
+ */
+describe("a pin", () => {
+ test("patches only the pinned flag, leaving the last message alone", () => {
+ const data = cache([
+ channel("a", {
+ lastMessage: "Said something.",
+ lastMessageAt: "2024-04-01T00:00:00.000Z",
+ lastMessageAgentId: "agent-1",
+ }),
+ ]);
+
+ const patched = applyChannelEvent(
+ data,
+ event({ channelId: "a", pinned: true }),
+ );
+
+ expect(patched).not.toBe("unknown");
+ if (patched === "unknown") return;
+ expect(patched.pages[0]?.channels[0]).toEqual({
+ ...(data.pages[0]?.channels[0] as ChannelSummary),
+ pinned: true,
+ });
+ });
+
+ test("unpins the same way", () => {
+ const patched = applyChannelEvent(
+ cache([channel("a", { pinned: true })]),
+ event({ channelId: "a", pinned: false }),
+ );
+
+ expect(patched).not.toBe("unknown");
+ if (patched === "unknown") return;
+ expect(patched.pages[0]?.channels[0]?.pinned).toBe(false);
+ });
+
+ test("returns the same cache when the row already says so", () => {
+ const data = cache([channel("a", { pinned: true })]);
+
+ // A duplicate, or the tab that made the pin hearing its own event back. Identity preserved, so
+ // React re-renders nothing at all.
+ expect(
+ applyChannelEvent(data, event({ channelId: "a", pinned: true })),
+ ).toBe(data);
+ });
+});
diff --git a/app/tests/channel-menu-mutations.test.ts b/app/tests/channel-menu-mutations.test.ts
new file mode 100644
index 00000000..5ccad1e1
--- /dev/null
+++ b/app/tests/channel-menu-mutations.test.ts
@@ -0,0 +1,88 @@
+import { afterEach, expect, test } from "bun:test";
+import type { QueryClient } from "@tanstack/react-query";
+import {
+ deleteChannelMutationOptions,
+ setChannelPinnedMutationOptions,
+} from "../src/lib/channels/mutations";
+
+const realFetch = globalThis.fetch;
+
+afterEach(() => {
+ globalThis.fetch = realFetch;
+});
+
+type SeenRequest = { url: string; init: RequestInit | undefined };
+
+function capturingFetch(status: number, body: unknown) {
+ const seen: SeenRequest[] = [];
+ globalThis.fetch = (async (url: unknown, init?: RequestInit) => {
+ seen.push({ url: String(url), init });
+ return new Response(body === undefined ? null : JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+ }) as unknown as typeof fetch;
+ return seen;
+}
+
+function invalidationRecorder() {
+ const invalidated: unknown[] = [];
+ const queryClient = {
+ invalidateQueries: async (filter: unknown) => {
+ invalidated.push(filter);
+ },
+ } as unknown as QueryClient;
+ return { queryClient, invalidated };
+}
+
+test("pinning PUTs the flag to the channel's pin route and invalidates the roster", async () => {
+ const seen = capturingFetch(200, { pinned: true });
+ const { queryClient, invalidated } = invalidationRecorder();
+ const options = setChannelPinnedMutationOptions(queryClient);
+
+ await options.mutationFn?.({ channelId: "channel-1", pinned: true });
+ await options.onSuccess?.(
+ undefined as never,
+ { channelId: "channel-1", pinned: true },
+ undefined as never,
+ undefined as never,
+ );
+
+ expect(seen).toHaveLength(1);
+ expect(seen[0]?.url).toBe("/api/channels/channel-1/pin");
+ expect(seen[0]?.init?.method).toBe("PUT");
+ expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ pinned: true });
+ expect(invalidated).toEqual([{ queryKey: ["channels"] }]);
+});
+
+test("deleting sends DELETE to the channel route and invalidates the roster", async () => {
+ const seen = capturingFetch(204, undefined);
+ const { queryClient, invalidated } = invalidationRecorder();
+ const options = deleteChannelMutationOptions(queryClient);
+
+ await options.mutationFn?.("channel-1");
+ await options.onSuccess?.(
+ undefined as never,
+ "channel-1",
+ undefined as never,
+ undefined as never,
+ );
+
+ expect(seen).toHaveLength(1);
+ expect(seen[0]?.url).toBe("/api/channels/channel-1");
+ expect(seen[0]?.init?.method).toBe("DELETE");
+ expect(invalidated).toEqual([{ queryKey: ["channels", "list"] }]);
+});
+
+test("a refused delete surfaces the server's sentence", async () => {
+ capturingFetch(409, {
+ error:
+ "This channel is defined by the deployment package, so it cannot be deleted here.",
+ });
+ const { queryClient } = invalidationRecorder();
+ const options = deleteChannelMutationOptions(queryClient);
+
+ await expect(options.mutationFn?.("channel-1")).rejects.toThrow(
+ "This channel is defined by the deployment package, so it cannot be deleted here.",
+ );
+});
diff --git a/app/tests/channel-order.test.ts b/app/tests/channel-order.test.ts
new file mode 100644
index 00000000..3544a81a
--- /dev/null
+++ b/app/tests/channel-order.test.ts
@@ -0,0 +1,52 @@
+import { expect, test } from "bun:test";
+import { pinnedFirst } from "../src/components/app-sidebar/app-sidebar";
+import type { ChannelSummary } from "../src/lib/channels/queries";
+
+/** A minimal but fully-typed channel summary, so tests build real objects rather than casts. */
+function channel(id: string, pinned: boolean): ChannelSummary {
+ return {
+ id,
+ name: id,
+ agentIds: [],
+ threadId: `thread-${id}`,
+ active: true,
+ lastMessage: null,
+ lastMessageAt: null,
+ lastMessageAgentId: null,
+ createdAt: "2024-01-01T00:00:00.000Z",
+ pinned,
+ };
+}
+
+test("holds pinned channels at the top, newest-activity order preserved within each group", () => {
+ /*
+ * Interleaved, which is what the cache can hold between refetches: the server hands back
+ * pinned-first, and then the socket patches a pin onto a loaded row without moving it, or re-sorts
+ * a page by recency alone. This function is the render-level mirror that closes that window.
+ */
+ const channels = [
+ channel("a", false),
+ channel("b", true),
+ channel("c", false),
+ channel("d", true),
+ channel("e", false),
+ ];
+
+ expect(pinnedFirst(channels).map((c) => c.id)).toEqual([
+ "b",
+ "d",
+ "a",
+ "c",
+ "e",
+ ]);
+});
+
+test("leaves an all-unpinned roster in its original order", () => {
+ const channels = [
+ channel("a", false),
+ channel("b", false),
+ channel("c", false),
+ ];
+
+ expect(pinnedFirst(channels).map((c) => c.id)).toEqual(["a", "b", "c"]);
+});
diff --git a/app/tests/markdown-chips.test.ts b/app/tests/markdown-chips.test.ts
new file mode 100644
index 00000000..31d86466
--- /dev/null
+++ b/app/tests/markdown-chips.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, test } from "bun:test";
+import { documentChipKind } from "../src/lib/markdown";
+
+/**
+ * Which links get drawn as a document chip.
+ *
+ * Recognition is by exact host and https only, because a chip asserts "this is a document in a
+ * system you have connected" — a claim that must not be honored for an impostor domain or a
+ * downgraded scheme.
+ */
+describe("recognising a document link", () => {
+ test("a Google Doc URL is drawn as a Doc chip", () => {
+ expect(documentChipKind("https://docs.google.com/document/d/x")).toEqual(
+ expect.objectContaining({ label: "Doc" }),
+ );
+ });
+
+ test("a Notion workspace page URL is drawn as a Notion chip", () => {
+ expect(documentChipKind("https://www.notion.so/ws/Page-abc")).toEqual(
+ expect.objectContaining({ label: "Notion" }),
+ );
+ });
+
+ test("a bare notion.so URL is drawn as a Notion chip", () => {
+ expect(documentChipKind("https://notion.so/abc")).toEqual(
+ expect.objectContaining({ label: "Notion" }),
+ );
+ });
+
+ test("a lookalike host is not a Notion document, even though it ends the same way", () => {
+ expect(documentChipKind("https://notion.so.evil.test/x")).toBeNull();
+ });
+
+ test("a Notion URL over plain http is not a document chip", () => {
+ expect(documentChipKind("http://www.notion.so/x")).toBeNull();
+ });
+
+ test("a relative href is not a document chip", () => {
+ expect(documentChipKind("/some/relative/path")).toBeNull();
+ });
+});
diff --git a/app/tests/plugin-grants.test.ts b/app/tests/plugin-grants.test.ts
new file mode 100644
index 00000000..f18ee1aa
--- /dev/null
+++ b/app/tests/plugin-grants.test.ts
@@ -0,0 +1,111 @@
+import { afterEach, expect, test } from "bun:test";
+import type { QueryClient } from "@tanstack/react-query";
+import {
+ grantPlugin,
+ setPluginGrantMutationOptions,
+} from "../src/lib/plugins/mutations";
+
+/**
+ * Granting a batch of tools, and what a batch is allowed to cost.
+ *
+ * The admin dialog grants a tool per Bot per tick, so choosing two Bots and twelve tools is
+ * twenty-four writes. Every one of them used to go through the grant mutation, which invalidates
+ * every plugin query and waits for the refetch — so most of the wait was re-reading a list hidden
+ * behind the dialog. `grantPlugin` is the write on its own, and the caller refetches once at the
+ * end. These pin that the write is unchanged and that the refetch is not attached to it.
+ */
+
+const realFetch = globalThis.fetch;
+
+afterEach(() => {
+ globalThis.fetch = realFetch;
+});
+
+type SeenRequest = { url: string; init: RequestInit | undefined };
+
+function capturingFetch(status: number, body: unknown) {
+ const seen: SeenRequest[] = [];
+ globalThis.fetch = (async (url: unknown, init?: RequestInit) => {
+ seen.push({ url: String(url), init });
+ return new Response(body === undefined ? null : JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+ }) as unknown as typeof fetch;
+ return seen;
+}
+
+function invalidationRecorder() {
+ const invalidated: unknown[] = [];
+ const queryClient = {
+ invalidateQueries: async (filter: unknown) => {
+ invalidated.push(filter);
+ },
+ } as unknown as QueryClient;
+ return { queryClient, invalidated };
+}
+
+test("one grant is one POST of the three things it joins", async () => {
+ const seen = capturingFetch(200, {});
+
+ await grantPlugin({ agentId: "agent-1", kind: "mcp", ref: "notion/search" });
+
+ expect(seen).toHaveLength(1);
+ expect(seen[0]?.url).toBe("/api/plugins/grants");
+ expect(seen[0]?.init?.method).toBe("POST");
+ expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({
+ agentId: "agent-1",
+ kind: "mcp",
+ ref: "notion/search",
+ });
+});
+
+test("a batch of grants is N grant requests and nothing else", async () => {
+ const seen = capturingFetch(200, {});
+
+ for (const ref of ["notion/search", "notion/fetch", "notion/create"]) {
+ await grantPlugin({ agentId: "agent-1", kind: "mcp", ref });
+ }
+
+ // Three writes, and no read in between: the refetch belongs to the caller, once, at the end.
+ expect(seen.map((request) => request.init?.method)).toEqual([
+ "POST",
+ "POST",
+ "POST",
+ ]);
+});
+
+test("a refused grant carries the server's own sentence", async () => {
+ capturingFetch(403, { error: "That Agent is defined by the package." });
+
+ await expect(
+ grantPlugin({ agentId: "agent-1", kind: "mcp", ref: "notion/search" }),
+ ).rejects.toThrow("That Agent is defined by the package.");
+});
+
+test("granting one on its own still carries its refetch", async () => {
+ const seen = capturingFetch(200, {});
+ const { queryClient, invalidated } = invalidationRecorder();
+ const options = setPluginGrantMutationOptions(queryClient);
+
+ await options.mutationFn?.({
+ agentId: "agent-1",
+ granted: true,
+ kind: "mcp",
+ ref: "notion/search",
+ });
+ await options.onSuccess?.(
+ undefined as never,
+ {
+ agentId: "agent-1",
+ granted: true,
+ kind: "mcp",
+ ref: "notion/search",
+ },
+ undefined as never,
+ undefined as never,
+ );
+
+ expect(seen).toHaveLength(1);
+ expect(invalidated).toEqual([{ queryKey: ["plugins"] }]);
+});
diff --git a/docs/README.md b/docs/README.md
index 7392c968..e850f9ee 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -8,6 +8,7 @@ Start with the root [README](../README.md), then use these references:
- [Coworkers](coworkers.md): durable Bot profiles, channels, visibility, deletion, and external AG-UI registration.
- Plugins, one connector per page — what an administrator registers, what each person consents to, and what the failures mean:
- [Google Drive](plugins/google-drive.md)
+ - [Notion](plugins/notion.md)
- [Deployment](deployment.md): the container, what is in the image, minimum sizes, and the platform notes.
- [Releasing](releasing.md): how a release is proposed, reviewed and published.
diff --git a/docs/architecture.md b/docs/architecture.md
index e8dc5a5c..46072940 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -138,9 +138,9 @@ MCP servers and skills share the plugin grant table, but they have different own
- MCP tools are admin-governed because they can reach external systems with stored credentials.
- Skills are reusable instructions. A person can create personal skills and attach them only to Bots they own. Administrators create deployment skills.
-The curated MCP catalogue contains Google Drive. Custom MCP servers must pass URL checks; unknown tools and custom-server tools are treated as writes unless positively classified as reads.
+The curated MCP catalogue contains Google Drive and Notion. Custom MCP servers must pass URL checks; unknown tools and custom-server tools are treated as writes unless positively classified as reads.
-A catalogue entry says whose credential a Bot reaches it with, which is a different question from whether it is reachable at all. A deployment-wide token answers the same for everybody; Google Drive is `user-oauth`, so a Bot reads it as the person asking and sees only what that person can see. An administrator enabling the connector and a person connecting their own account are two decisions, and neither can be made for the other. See [Google Drive](plugins/google-drive.md).
+A catalogue entry says whose credential a Bot reaches it with, which is a different question from whether it is reachable at all. A deployment-wide token answers the same for everybody; Google Drive and Notion are both `user-oauth`, so a Bot reaches them as the person asking and sees only what that person can see. An administrator enabling the connector and a person connecting their own account are two decisions, and neither can be made for the other. See [Google Drive](plugins/google-drive.md) and [Notion](plugins/notion.md).
Every MCP call checks the grant first, then evaluates the same action policy engine with MCP context, then audits the result.
diff --git a/docs/plugins/notion.md b/docs/plugins/notion.md
new file mode 100644
index 00000000..44bb1331
--- /dev/null
+++ b/docs/plugins/notion.md
@@ -0,0 +1,79 @@
+# Notion
+
+A Bot with this connector granted reaches Notion **as the person asking**, through the hosted MCP
+server Notion runs at `mcp.notion.com`, on the catalogue's default MCP transport. Two people asking
+the same question get the pages their own accounts can see, and neither sees anything they could not
+open themselves. Unlike Google Drive, this connector ships both read and write tools: the writing
+tools are named in the catalogue, and the action policy governs every call the same as any other
+plugin tool.
+
+Setting it up takes two people, and neither can do the other's half:
+
+| Who | Does | Where |
+| ----------------- | ------------------------------------------- | ----------------------------- |
+| An administrator | Enables the connector | `/admin/plugins/notion` |
+| Each person | Consents with their own Notion account | `/settings/connected-accounts` |
+
+There is deliberately no endpoint for an administrator to connect an account on somebody's behalf.
+
+## What an administrator does
+
+### 1. Enable the connector in OpenBot
+
+At `/admin/plugins/notion`, turn on **Enable for this deployment**. There is no client to register
+and no secret to paste: this deployment introduces itself to Notion on first connect, over RFC 7591
+dynamic client registration. The prerequisite is a public URL the redirect URI can be derived from —
+`OPENBOT_PUBLIC_URL` if it is set, or the auth base URL it falls back to otherwise — nothing needs to
+be registered at Notion ahead of time.
+
+### 2. Connect your own account
+
+On the same page, use **Your account** to connect your own Notion account before doing anything
+else here. Unlike Google Drive's tool list, which is OpenBot's own code and needs no credential to
+read, this connector's tool list is an answer from Notion's hosted server: refreshing it takes a
+credential, and the refresh in the next step mints one from the connection belonging to whoever
+presses the button — not whichever account happens to be connected here. That is a personal grant
+like anybody else's, reaching only what your own account can see — not deployment configuration —
+but it has to come first, because an administrator who presses Refresh tools without having
+connected their own account is refused, not lent someone else's.
+
+### 3. Press Refresh tools
+
+This records the tool list Notion's hosted server advertises today, both reads and writes.
+
+Notion has **no read-only scope**. Access is granted per page, at the moment somebody consents, not
+by a scope string the way Google's `drive.readonly` is — so there is nothing at the vendor standing
+behind a tool's read-or-write classification. The catalogue's write-tool list, plus the action
+policy, is the **entire** write barrier for this connector.
+
+That makes reconciling the catalogue's write-tool names against what the live list actually calls
+them, on this first refresh, required rather than cosmetic. A name that has changed at the vendor is
+the dangerous direction, not a safe one: `classifyTool` reads a tool Notion advertises but that no
+longer matches an entry in the write list as a **read**, so an uncorrected rename quietly turns a
+write into something the policy will pass through. The safe direction runs the other way — a tool
+name the server never advertised at all still classifies as a write — but that is not the case this
+refresh exists to catch.
+
+### 4. Grant tools to a Bot
+
+Enabling the connector does not give any Bot access to it. Each tool is granted per Bot, the same as
+every other plugin tool. Every call then checks the grant, evaluates the action policy, and writes an
+audit row.
+
+## What each person does
+
+At `/settings/connected-accounts`, Notion appears once an administrator has enabled it. Open it and
+press **Connect**. That leaves OpenBot for Notion's own consent screen — the arrow on the button says
+so — where the pages and databases to share are chosen, and returns to the same page, which then
+reads **Connected**.
+
+Nothing is cached. OpenBot stores the refresh token and mints a short-lived access token for each
+call, so withdrawing access at Notion takes effect on the next call rather than whenever a cache
+expires.
+
+## See also
+
+- [Architecture](../architecture.md) — where plugins, grants, policy and audit sit.
+- [Configuration](../configuration.md) — `OPENBOT_PUBLIC_URL`, `OPENBOT_APP_URL`, `KEY_ENCRYPTION_KEY`.
+- [Notion's own guide](https://developers.notion.com/guides/mcp/build-mcp-client) to building an MCP
+ client against its hosted server.
diff --git a/examples/fintech/skills.yaml b/examples/fintech/skills.yaml
index b651e589..24a1cca4 100644
--- a/examples/fintech/skills.yaml
+++ b/examples/fintech/skills.yaml
@@ -17,8 +17,8 @@
# file may name tools for connectors this deployment has not added, and should — that is what makes
# them work the moment somebody does.
#
-# Refs are `/`, the same form a grant is written in. `google-drive` is the
-# catalogue connector; a server an administrator adds by URL uses the id it was given.
+# Refs are `/`, the same form a grant is written in. `google-drive` and `notion`
+# are catalogue connectors; a server an administrator adds by URL uses the id it was given.
skills:
- slug: find-a-document
title: Find a document
@@ -33,6 +33,18 @@ skills:
- google-drive/read_file_content
- google-drive/get_file_metadata
+ - slug: find-a-notion-page
+ title: Find a Notion page
+ summary: Search Notion for a page and read what it says.
+ instructions: >-
+ Find the Notion page the person is asking about before answering anything about its contents.
+ Search first, then fetch the page you found rather than answering from its title. If the search
+ returns nothing, say so and say what you searched for, rather than guessing at what the page
+ might contain. Name the page you used.
+ tools:
+ - notion/notion-search
+ - notion/notion-fetch
+
- slug: whats-changed
title: What changed recently
summary: List recently changed documents and say who changed them and when.
diff --git a/server/drizzle/0016_pin_and_soft_delete_channels.sql b/server/drizzle/0016_pin_and_soft_delete_channels.sql
new file mode 100644
index 00000000..f1ca6d46
--- /dev/null
+++ b/server/drizzle/0016_pin_and_soft_delete_channels.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "channel_memberships" ADD COLUMN "pinned_at" timestamp with time zone;--> statement-breakpoint
+ALTER TABLE "channels" ADD COLUMN "deleted_at" timestamp with time zone;
\ No newline at end of file
diff --git a/server/drizzle/meta/0016_snapshot.json b/server/drizzle/meta/0016_snapshot.json
new file mode 100644
index 00000000..714e5982
--- /dev/null
+++ b/server/drizzle/meta/0016_snapshot.json
@@ -0,0 +1,2395 @@
+{
+ "id": "8e5982ba-c8e3-4634-a05c-68a90e066dac",
+ "prevId": "8f1f91d5-9cb1-490a-a28f-a0a3f15b9614",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.accounts": {
+ "name": "accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "accounts_provider_account_idx": {
+ "name": "accounts_provider_account_idx",
+ "columns": [
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "accounts_user_id_users_id_fk": {
+ "name": "accounts_user_id_users_id_fk",
+ "tableFrom": "accounts",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agents": {
+ "name": "agents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "agent_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "configuration": {
+ "name": "configuration",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "package_id": {
+ "name": "package_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override": {
+ "name": "override",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "agents_package_id_deployment_packages_id_fk": {
+ "name": "agents_package_id_deployment_packages_id_fk",
+ "tableFrom": "agents",
+ "tableTo": "deployment_packages",
+ "columnsFrom": ["package_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_events": {
+ "name": "audit_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "audit_events_created_at_idx": {
+ "name": "audit_events_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_events_type_time_idx": {
+ "name": "audit_events_type_time_idx",
+ "columns": [
+ {
+ "expression": "event_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_events_actor_time_idx": {
+ "name": "audit_events_actor_time_idx",
+ "columns": [
+ {
+ "expression": "actor_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_events_target_time_idx": {
+ "name": "audit_events_target_time_idx",
+ "columns": [
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channel_agents": {
+ "name": "channel_agents",
+ "schema": "",
+ "columns": {
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "channel_agents_channel_id_channels_id_fk": {
+ "name": "channel_agents_channel_id_channels_id_fk",
+ "tableFrom": "channel_agents",
+ "tableTo": "channels",
+ "columnsFrom": ["channel_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "channel_agents_agent_id_agents_id_fk": {
+ "name": "channel_agents_agent_id_agents_id_fk",
+ "tableFrom": "channel_agents",
+ "tableTo": "agents",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "channel_agents_channel_id_agent_id_pk": {
+ "name": "channel_agents_channel_id_agent_id_pk",
+ "columns": ["channel_id", "agent_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channel_memberships": {
+ "name": "channel_memberships",
+ "schema": "",
+ "columns": {
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pinned_at": {
+ "name": "pinned_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "channel_memberships_channel_id_channels_id_fk": {
+ "name": "channel_memberships_channel_id_channels_id_fk",
+ "tableFrom": "channel_memberships",
+ "tableTo": "channels",
+ "columnsFrom": ["channel_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "channel_memberships_user_id_users_id_fk": {
+ "name": "channel_memberships_user_id_users_id_fk",
+ "tableFrom": "channel_memberships",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "channel_memberships_channel_id_user_id_pk": {
+ "name": "channel_memberships_channel_id_user_id_pk",
+ "columns": ["channel_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channels": {
+ "name": "channels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "suggested_prompts": {
+ "name": "suggested_prompts",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "allowed_groups": {
+ "name": "allowed_groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "package_id": {
+ "name": "package_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override": {
+ "name": "override",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message": {
+ "name": "last_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message_at": {
+ "name": "last_message_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message_agent_id": {
+ "name": "last_message_agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "channels_recent_activity_idx": {
+ "name": "channels_recent_activity_idx",
+ "columns": [
+ {
+ "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "channels_package_id_deployment_packages_id_fk": {
+ "name": "channels_package_id_deployment_packages_id_fk",
+ "tableFrom": "channels",
+ "tableTo": "deployment_packages",
+ "columnsFrom": ["package_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "channels_last_message_agent_id_agents_id_fk": {
+ "name": "channels_last_message_agent_id_agents_id_fk",
+ "tableFrom": "channels",
+ "tableTo": "agents",
+ "columnsFrom": ["last_message_agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.credentials": {
+ "name": "credentials",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "kind": {
+ "name": "kind",
+ "type": "credential_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_id": {
+ "name": "key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "credentials_active_key_idx": {
+ "name": "credentials_active_key_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"credentials\".\"revoked_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.deployment_packages": {
+ "name": "deployment_packages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_path": {
+ "name": "source_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "loaded_at": {
+ "name": "loaded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "deployment_packages_tenant_id_unique": {
+ "name": "deployment_packages_tenant_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["tenant_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.intelligence_channel_mappings": {
+ "name": "intelligence_channel_mappings",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "intelligence_channel_mappings_thread_idx": {
+ "name": "intelligence_channel_mappings_thread_idx",
+ "columns": [
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "intelligence_channel_mappings_user_id_users_id_fk": {
+ "name": "intelligence_channel_mappings_user_id_users_id_fk",
+ "tableFrom": "intelligence_channel_mappings",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "intelligence_channel_mappings_channel_id_channels_id_fk": {
+ "name": "intelligence_channel_mappings_channel_id_channels_id_fk",
+ "tableFrom": "intelligence_channel_mappings",
+ "tableTo": "channels",
+ "columnsFrom": ["channel_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "intelligence_channel_mappings_user_id_channel_id_pk": {
+ "name": "intelligence_channel_mappings_user_id_channel_id_pk",
+ "columns": ["user_id", "channel_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.revoked_access": {
+ "name": "revoked_access",
+ "schema": "",
+ "columns": {
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "revoked_by": {
+ "name": "revoked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sso_providers": {
+ "name": "sso_providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_config": {
+ "name": "oidc_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "saml_config": {
+ "name": "saml_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sso_providers_user_id_users_id_fk": {
+ "name": "sso_providers_user_id_users_id_fk",
+ "tableFrom": "sso_providers",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sso_providers_provider_id_unique": {
+ "name": "sso_providers_provider_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["provider_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_roles": {
+ "name": "user_roles",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_roles_user_id_users_id_fk": {
+ "name": "user_roles_user_id_users_id_fk",
+ "tableFrom": "user_roles",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_roles_user_id_role_pk": {
+ "name": "user_roles_user_id_role_pk",
+ "columns": ["user_id", "role"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "groups": {
+ "name": "groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verifications": {
+ "name": "verifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.action_policy": {
+ "name": "action_policy",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deny": {
+ "name": "deny",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "allow": {
+ "name": "allow",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.computer_snapshot": {
+ "name": "computer_snapshot",
+ "schema": "",
+ "columns": {
+ "computer_id": {
+ "name": "computer_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "snapshot_id": {
+ "name": "snapshot_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "elements": {
+ "name": "elements",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "taken_at": {
+ "name": "taken_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "session": {
+ "name": "session",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_preferences": {
+ "name": "agent_preferences",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hidden_at": {
+ "name": "hidden_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "agent_preferences_user_id_users_id_fk": {
+ "name": "agent_preferences_user_id_users_id_fk",
+ "tableFrom": "agent_preferences",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_preferences_agent_id_agents_id_fk": {
+ "name": "agent_preferences_agent_id_agents_id_fk",
+ "tableFrom": "agent_preferences",
+ "tableTo": "agents",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "agent_preferences_user_id_agent_id_pk": {
+ "name": "agent_preferences_user_id_agent_id_pk",
+ "columns": ["user_id", "agent_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_profiles": {
+ "name": "agent_profiles",
+ "schema": "",
+ "columns": {
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role_description": {
+ "name": "role_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "avatar_seed": {
+ "name": "avatar_seed",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "agent_visibility",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "callback_token_hash": {
+ "name": "callback_token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "callback_token_issued_at": {
+ "name": "callback_token_issued_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "agent_profiles_visibility_deleted_idx": {
+ "name": "agent_profiles_visibility_deleted_idx",
+ "columns": [
+ {
+ "expression": "visibility",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_profiles_agent_id_agents_id_fk": {
+ "name": "agent_profiles_agent_id_agents_id_fk",
+ "tableFrom": "agent_profiles",
+ "tableTo": "agents",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_profiles_owner_user_id_users_id_fk": {
+ "name": "agent_profiles_owner_user_id_users_id_fk",
+ "tableFrom": "agent_profiles",
+ "tableTo": "users",
+ "columnsFrom": ["owner_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.component_exclusions": {
+ "name": "component_exclusions",
+ "schema": "",
+ "columns": {
+ "component_name": {
+ "name": "component_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "withheld_by": {
+ "name": "withheld_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "component_exclusions_component_name_components_name_fk": {
+ "name": "component_exclusions_component_name_components_name_fk",
+ "tableFrom": "component_exclusions",
+ "tableTo": "components",
+ "columnsFrom": ["component_name"],
+ "columnsTo": ["name"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "component_exclusions_agent_id_agents_id_fk": {
+ "name": "component_exclusions_agent_id_agents_id_fk",
+ "tableFrom": "component_exclusions",
+ "tableTo": "agents",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "component_exclusions_component_name_agent_id_pk": {
+ "name": "component_exclusions_component_name_agent_id_pk",
+ "columns": ["component_name", "agent_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.component_functions": {
+ "name": "component_functions",
+ "schema": "",
+ "columns": {
+ "component_name": {
+ "name": "component_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "function_name": {
+ "name": "function_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "granted_by": {
+ "name": "granted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "component_functions_component_name_components_name_fk": {
+ "name": "component_functions_component_name_components_name_fk",
+ "tableFrom": "component_functions",
+ "tableTo": "components",
+ "columnsFrom": ["component_name"],
+ "columnsTo": ["name"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "component_functions_component_name_function_name_pk": {
+ "name": "component_functions_component_name_function_name_pk",
+ "columns": ["component_name", "function_name"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.components": {
+ "name": "components",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "draft_description": {
+ "name": "draft_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "published_description": {
+ "name": "published_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published": {
+ "name": "published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_servers": {
+ "name": "mcp_servers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "vendor": {
+ "name": "vendor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provenance": {
+ "name": "provenance",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'first-party'"
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tools_refreshed_at": {
+ "name": "tools_refreshed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mcp_servers_credential_id_credentials_id_fk": {
+ "name": "mcp_servers_credential_id_credentials_id_fk",
+ "tableFrom": "mcp_servers",
+ "tableTo": "credentials",
+ "columnsFrom": ["credential_id"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_tools": {
+ "name": "mcp_tools",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "input_schema": {
+ "name": "input_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mcp_tools_server_id_mcp_servers_id_fk": {
+ "name": "mcp_tools_server_id_mcp_servers_id_fk",
+ "tableFrom": "mcp_tools",
+ "tableTo": "mcp_servers",
+ "columnsFrom": ["server_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "mcp_tools_server_id_name_pk": {
+ "name": "mcp_tools_server_id_name_pk",
+ "columns": ["server_id", "name"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_user_credentials": {
+ "name": "mcp_user_credentials",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "mcp_user_credentials_user_idx": {
+ "name": "mcp_user_credentials_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "mcp_user_credentials_server_id_mcp_servers_id_fk": {
+ "name": "mcp_user_credentials_server_id_mcp_servers_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "tableTo": "mcp_servers",
+ "columnsFrom": ["server_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mcp_user_credentials_user_id_users_id_fk": {
+ "name": "mcp_user_credentials_user_id_users_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mcp_user_credentials_credential_id_credentials_id_fk": {
+ "name": "mcp_user_credentials_credential_id_credentials_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "tableTo": "credentials",
+ "columnsFrom": ["credential_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "mcp_user_credentials_server_id_user_id_pk": {
+ "name": "mcp_user_credentials_server_id_user_id_pk",
+ "columns": ["server_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_grants": {
+ "name": "plugin_grants",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ref": {
+ "name": "ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "granted_by": {
+ "name": "granted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "plugin_grants_agent_idx": {
+ "name": "plugin_grants_agent_idx",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_grants_agent_id_agents_id_fk": {
+ "name": "plugin_grants_agent_id_agents_id_fk",
+ "tableFrom": "plugin_grants",
+ "tableTo": "agents",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "plugin_grants_kind_ref_agent_id_pk": {
+ "name": "plugin_grants_kind_ref_agent_id_pk",
+ "columns": ["kind", "ref", "agent_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sandboxed_components": {
+ "name": "sandboxed_components",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "draft_description": {
+ "name": "draft_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_html": {
+ "name": "draft_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_css": {
+ "name": "draft_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_js_functions": {
+ "name": "draft_js_functions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_argument_schema": {
+ "name": "draft_argument_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "published_description": {
+ "name": "published_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_html": {
+ "name": "published_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_css": {
+ "name": "published_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_js_functions": {
+ "name": "published_js_functions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_argument_schema": {
+ "name": "published_argument_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sample_arguments": {
+ "name": "sample_arguments",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "published": {
+ "name": "published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "authored_by": {
+ "name": "authored_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.skill_tools": {
+ "name": "skill_tools",
+ "schema": "",
+ "columns": {
+ "skill_id": {
+ "name": "skill_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ref": {
+ "name": "ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "declared_by": {
+ "name": "declared_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "skill_tools_ref_idx": {
+ "name": "skill_tools_ref_idx",
+ "columns": [
+ {
+ "expression": "ref",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "skill_tools_skill_id_skills_id_fk": {
+ "name": "skill_tools_skill_id_skills_id_fk",
+ "tableFrom": "skill_tools",
+ "tableTo": "skills",
+ "columnsFrom": ["skill_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "skill_tools_skill_id_ref_pk": {
+ "name": "skill_tools_skill_id_ref_pk",
+ "columns": ["skill_id", "ref"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.skills": {
+ "name": "skills",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instructions": {
+ "name": "instructions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin": {
+ "name": "origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'yours'"
+ },
+ "installed_by": {
+ "name": "installed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "skills_slug_key": {
+ "name": "skills_slug_key",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "skills_owner_idx": {
+ "name": "skills_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "skills_owner_user_id_users_id_fk": {
+ "name": "skills_owner_user_id_users_id_fk",
+ "tableFrom": "skills",
+ "tableTo": "users",
+ "columnsFrom": ["owner_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.agent_type": {
+ "name": "agent_type",
+ "schema": "public",
+ "values": ["built_in", "remote_ag_ui"]
+ },
+ "public.credential_kind": {
+ "name": "credential_kind",
+ "schema": "public",
+ "values": [
+ "model",
+ "connector",
+ "agent",
+ "mcp",
+ "mcp_oauth_client",
+ "mcp_user_token"
+ ]
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "public",
+ "values": ["admin", "user"]
+ },
+ "public.agent_visibility": {
+ "name": "agent_visibility",
+ "schema": "public",
+ "values": ["public", "private"]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json
index 1162cae9..aa1db8f5 100644
--- a/server/drizzle/meta/_journal.json
+++ b/server/drizzle/meta/_journal.json
@@ -113,6 +113,13 @@
"when": 1787525879804,
"tag": "0015_credentials_one_live_key",
"breakpoints": true
+ },
+ {
+ "idx": 16,
+ "version": "7",
+ "when": 1787581516968,
+ "tag": "0016_pin_and_soft_delete_channels",
+ "breakpoints": true
}
]
}
diff --git a/server/src/app.ts b/server/src/app.ts
index edd47dff..fa8f1722 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -686,19 +686,10 @@ export function createApp(
}
}
- // Shared by the thread-existence check below and a deleted channel's request to forget its thread.
- const intelligence = createIntelligenceClient(config.runtime.intelligence);
-
if (channelStore) {
app.route(
"/api/channels",
- createChannelRoutes(
- channelStore,
- requireUser,
- channelEvents,
- auditStore,
- (params) => intelligence.deleteThread(params),
- ),
+ createChannelRoutes(channelStore, requireUser, channelEvents, auditStore),
);
}
@@ -714,6 +705,24 @@ export function createApp(
"/api/plugins",
createPluginRoutes(pluginStore, requireUser, canUseBot, {
encryptionKey: config.keyEncryptionKey,
+ /*
+ * Whether the person a consent was started for still has access, asked when the callback
+ * lands rather than when the flow began.
+ *
+ * The callback carries no session — identity comes from the state — so this is where the
+ * question gets asked at all. `find` answers both halves of it: no row means a user id that
+ * names nobody, and `revoked` means an administrator removed them while they were away at
+ * the vendor. Either way there is no live person for a fresh refresh token to belong to.
+ *
+ * No people store means this deployment cannot answer the question, so it refuses rather
+ * than assuming yes. It also cannot remove anybody, which is exactly why guessing here
+ * would be a hole nothing else closes.
+ */
+ personHasAccess: async (userId) => {
+ if (!peopleStore) return false;
+ const person = await peopleStore.find(userId);
+ return person !== undefined && !person.revoked;
+ },
// The deployment-wide fallback a Bot may present, as a yes or no. The secret itself stays
// in config and is checked in `/api/agent-tools/call`; the surface only needs to know
// whether a Bot without its own credential has any way to call back.
@@ -837,9 +846,13 @@ export function createApp(
threadIdentity,
requireUser,
// config.ts refuses to boot without the full Intelligence contract (see copilot.ts's
- // header comment), so `config.runtime.intelligence` is never missing here, and the shared
- // `intelligence` client built above is never missing either.
- createThreadReader(intelligence),
+ // header comment), so `config.runtime.intelligence` is never missing here. Built from it
+ // rather than assumed, though: this is the one place besides the runtime mount itself that
+ // needs to reach Intelligence, and it should keep working unmodified if that guarantee ever
+ // loosens and a deployment can legitimately have no reader to build.
+ createThreadReader(
+ createIntelligenceClient(config.runtime.intelligence),
+ ),
),
);
}
diff --git a/server/src/audit.ts b/server/src/audit.ts
index 3b738346..b7100e12 100644
--- a/server/src/audit.ts
+++ b/server/src/audit.ts
@@ -60,9 +60,12 @@ export const auditEventTypes = [
*/
"channel.routed",
/**
- * A channel was deleted, taking its memberships, agent links, and thread mapping with it. The
- * channel row is already gone by the time this is written, so this is the only place left that
- * says it ever existed. `payload.threadForgotten: false` marks a thread that outlived it.
+ * A channel was removed from every member's roster, and by whom.
+ *
+ * The removal is soft, so the row and its thread survive and `channels.deleted_at` records that it
+ * happened. What it cannot record is who did it: a timestamp answers "when did that conversation
+ * disappear" and not "who ended it for everybody in it", which is the half somebody asks about.
+ * `payload.mechanism` names how, so a later hard delete is distinguishable from this one.
*/
"channel.deleted",
"agent.invoked",
diff --git a/server/src/auth/signed-value.ts b/server/src/auth/signed-value.ts
index e6acc31a..026ea80a 100644
--- a/server/src/auth/signed-value.ts
+++ b/server/src/auth/signed-value.ts
@@ -1,4 +1,5 @@
import { createHmac, timingSafeEqual } from "node:crypto";
+import { decryptSecret, encryptSecret } from "../credentials";
/**
* A value this deployment can hand out and later recognise as its own.
@@ -7,6 +8,13 @@ import { createHmac, timingSafeEqual } from "node:crypto";
* carried by a customer's own agent process, for instance. The alternative is a row per statement,
* which buys nothing here because these are short-lived and single-purpose, and costs a write and a
* read on a path that already has both.
+ *
+ * Two shapes, and the difference between them is the whole point of this module. A SIGNED value is
+ * authenticated and readable: anybody holding it can read what it says, and only this deployment can
+ * make another one. A SEALED value is authenticated and unreadable: nobody without the key learns
+ * anything from holding it. Which one a statement needs is decided by what is inside it — a signed
+ * value is right for a claim that is public anyway, and is wrong the moment the statement carries a
+ * secret of its own.
*/
/**
@@ -59,3 +67,64 @@ export function verify(
if (given.length !== wanted.length) return null;
return timingSafeEqual(given, wanted) ? value : null;
}
+
+/**
+ * The key a value is sealed with.
+ *
+ * Derived like a signing key and under a distinguished prefix, so the same deployment secret gives
+ * this use its own material: a sealing key is never a signing key, and neither is the key the
+ * credential vault encrypts with. AES-256 wants 32 bytes and an HMAC-SHA256 digest is exactly that,
+ * base64 because {@link encryptSecret} takes its key that way.
+ */
+function sealingKey(encryptionKey: string, label: string): string {
+ return createHmac("sha256", encryptionKey)
+ .update(`seal:${label}`)
+ .digest("base64");
+}
+
+/**
+ * A value nobody but this deployment can read, in one URL-safe string.
+ *
+ * AES-256-GCM, through the same helper the credential vault uses rather than a second crypto
+ * implementation to keep right. GCM authenticates as well as encrypts, so a sealed value needs no
+ * signature around it: a tampered one fails to decrypt rather than decrypting to something else.
+ *
+ * The label separates uses exactly as it does for a signature, but here it does so through the key —
+ * a value sealed for one purpose is not merely rejected by another, it cannot be opened by it at all.
+ *
+ * base64url over the envelope, because this is for values that travel as a query parameter and the
+ * envelope itself is JSON with base64 inside it. Sealing says nothing about freshness: a caller that
+ * needs an expiry puts one INSIDE the value and checks it after opening.
+ */
+export async function seal(
+ value: string,
+ encryptionKey: string,
+ label: string,
+): Promise {
+ const envelope = await encryptSecret(sealingKey(encryptionKey, label), value);
+ return Buffer.from(envelope, "utf8").toString("base64url");
+}
+
+/**
+ * The value a sealed string carries, or nothing.
+ *
+ * One answer for every way of being unopenable — not base64url, not an envelope, sealed under
+ * another label, sealed by somebody else, altered by a byte — because there is exactly one thing to
+ * do with a value this deployment cannot read, and a caller that has to tell those apart is a caller
+ * that can get one of them wrong.
+ */
+export async function unseal(
+ sealed: string | undefined,
+ encryptionKey: string,
+ label: string,
+): Promise {
+ if (!sealed) return null;
+ try {
+ return await decryptSecret(
+ sealingKey(encryptionKey, label),
+ Buffer.from(sealed, "base64url").toString("utf8"),
+ );
+ } catch {
+ return null;
+ }
+}
diff --git a/server/src/channels/events.ts b/server/src/channels/events.ts
index b0682a41..b0bced6f 100644
--- a/server/src/channels/events.ts
+++ b/server/src/channels/events.ts
@@ -22,8 +22,15 @@ export type ChannelActivityEvent = {
lastMessage: string | null;
lastMessageAt: string | null;
lastMessageAgentId: string | null;
- /** The channel is gone. Absent on an ordinary activity event. */
+ /** The channel is hidden from every member's roster. Absent on an ordinary activity event. */
deleted?: true;
+ /**
+ * One member's pin, changed. Absent on an ordinary activity event.
+ *
+ * A pin lives on one membership row, so the writer names that member alone in `memberIds` and the
+ * hub's delivery rule does the rest: nobody else in the channel hears a pin they did not make.
+ */
+ pinned?: boolean;
};
type Send = (payload: string) => void;
diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts
index a912aa68..0a1061d7 100644
--- a/server/src/channels/routes.ts
+++ b/server/src/channels/routes.ts
@@ -1,4 +1,15 @@
-import { and, asc, desc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm";
+import {
+ and,
+ asc,
+ desc,
+ eq,
+ exists,
+ inArray,
+ isNull,
+ lt,
+ or,
+ sql,
+} from "drizzle-orm";
import type { Context, MiddlewareHandler } from "hono";
import { Hono } from "hono";
import {
@@ -38,6 +49,8 @@ export type ChannelSummary = AgentChannel & {
lastMessageAt: Date | null;
lastMessageAgentId: string | null;
createdAt: Date;
+ /** Whether the caller pinned this channel. A pin is per-member, so this is the caller's, only. */
+ pinned: boolean;
};
/** What a client that ran an agent reports back about the message it just saw. */
@@ -70,14 +83,26 @@ const DEFAULT_CHANNEL_PAGE = 50;
/** The most a caller may ask for, so the endpoint cannot be talked back into reading everything. */
const MAX_CHANNEL_PAGE = 200;
-/** Where a page stopped: both halves of the sort, since two channels can share a timestamp. */
-type ChannelCursor = { recency: string; id: string };
+/**
+ * Where a page stopped: every part of the sort, in sort order.
+ *
+ * `pinned` leads, because the ordering does: a keyset cursor has to name the whole sort key or the
+ * next page is selected by a different rule than the page it follows, which serves some channels
+ * twice and others never. `recency` and `id` are both here for the same reason — two channels can
+ * share a timestamp.
+ */
+type ChannelCursor = { pinned: boolean; recency: string; id: string };
function encodeChannelCursor(cursor: ChannelCursor): string {
return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
}
-/** A malformed cursor reads as the first page, which is the honest answer to a stale link. */
+/**
+ * A malformed cursor reads as the first page, which is the honest answer to a stale link.
+ *
+ * A cursor minted before `pinned` existed is malformed by this definition, and deliberately: it
+ * describes a position in an ordering this query no longer has.
+ */
function decodeChannelCursor(
value: string | undefined,
): ChannelCursor | undefined {
@@ -86,7 +111,9 @@ function decodeChannelCursor(
const parsed = JSON.parse(
Buffer.from(value, "base64url").toString("utf8"),
) as ChannelCursor;
- return typeof parsed?.id === "string" && typeof parsed?.recency === "string"
+ return typeof parsed?.id === "string" &&
+ typeof parsed?.recency === "string" &&
+ typeof parsed?.pinned === "boolean"
? parsed
: undefined;
} catch {
@@ -94,17 +121,48 @@ function decodeChannelCursor(
}
}
+/**
+ * The roster's sort key, as SQL, in the order it sorts.
+ *
+ * Every part descends, which is what lets the cursor be one row comparison rather than a nest of
+ * ORs: a pin is 1 and no pin is 0, so `desc` puts pinned channels first, and both remaining parts
+ * already wanted `desc`. Starting a conversation counts as activity — a channel somebody just made
+ * has nothing said in it yet and is the one they are about to type in, so ordering on the message
+ * alone would bury it under every channel that has one.
+ *
+ * The browser repeats the recency half when the socket patches a row, and lifts pinned rows at
+ * render; both must agree with this, or the list reorders itself on the next event. See `byRecency`
+ * in use-channel-events.ts and `pinnedFirst` in app-sidebar.tsx.
+ */
+const PINNED_RANK = sql`case when ${channelMemberships.pinnedAt} is not null then 1 else 0 end`;
+const RECENCY = sql`coalesce(${channels.lastMessageAt}, ${channels.createdAt})`;
+const ROSTER_ORDER = [
+ sql`${PINNED_RANK} desc`,
+ sql`${RECENCY} desc`,
+ desc(channels.id),
+];
+
export type ChannelStore = {
create(actor: AgentActor, agentIds: string[]): Promise;
get(actor: AgentActor, channelId: string): Promise;
list(actor: AgentActor, query?: ChannelQuery): Promise;
+ /** Pin or unpin the caller's own membership. Throws ChannelNotFoundError for a non-member. */
+ setPinned(
+ actor: AgentActor,
+ channelId: string,
+ pinned: boolean,
+ ): Promise;
+ /**
+ * Hide the channel for every member. Soft: the row and the thread survive, every read filters.
+ * Throws ChannelNotFoundError for a non-member and ChannelPackageOwnedError for a channel the
+ * tenant package defines, which configuration owns rather than any member.
+ */
+ softDelete(actor: AgentActor, channelId: string): Promise;
recordActivity(
actor: AgentActor,
channelId: string,
activity: ChannelActivity,
): Promise;
- /** Deletes the channel for everyone in it. Returns the thread it owned, so the caller can forget it upstream. */
- remove(actor: AgentActor, channelId: string): Promise;
};
const PRIVATE_AGENT_CHANNEL_DESCRIPTION = "Private agent channel.";
@@ -226,7 +284,7 @@ export function createChannelStore(
agentProfiles,
eq(agentProfiles.agentId, channelAgents.agentId),
)
- .where(eq(channels.id, channelId))
+ .where(and(eq(channels.id, channelId), isNull(channels.deletedAt)))
.orderBy(asc(channelAgents.agentId));
const first = rows[0];
@@ -258,7 +316,8 @@ export function createChannelStore(
const page = await database
.select({
id: channels.id,
- recency: sql`coalesce(${channels.lastMessageAt}, ${channels.createdAt})`,
+ recency: sql`${RECENCY}`,
+ pinned: sql`${channelMemberships.pinnedAt} is not null`,
})
.from(channels)
.innerJoin(
@@ -269,14 +328,16 @@ export function createChannelStore(
),
)
.where(
- cursor
- ? sql`(coalesce(${channels.lastMessageAt}, ${channels.createdAt}), ${channels.id}) < (${cursor.recency}::timestamptz, ${cursor.id})`
- : undefined,
- )
- .orderBy(
- sql`coalesce(${channels.lastMessageAt}, ${channels.createdAt}) desc`,
- desc(channels.id),
+ and(
+ isNull(channels.deletedAt),
+ // One row comparison over the whole sort key, which only reads as "everything after the
+ // cursor" because every part of that key descends. See ROSTER_ORDER.
+ cursor
+ ? sql`(${PINNED_RANK}, ${RECENCY}, ${channels.id}) < (${cursor.pinned ? 1 : 0}::int, ${cursor.recency}::timestamptz, ${cursor.id})`
+ : undefined,
+ ),
)
+ .orderBy(...ROSTER_ORDER)
// One more than asked for, so "is there another page" needs no second count query.
.limit(limit + 1);
@@ -285,6 +346,7 @@ export function createChannelStore(
const nextCursor =
page.length > limit && last
? encodeChannelCursor({
+ pinned: last.pinned,
recency: new Date(last.recency).toISOString(),
id: last.id,
})
@@ -303,6 +365,7 @@ export function createChannelStore(
lastMessageAt: channels.lastMessageAt,
lastMessageAgentId: channels.lastMessageAgentId,
createdAt: channels.createdAt,
+ pinnedAt: channelMemberships.pinnedAt,
})
.from(channels)
.innerJoin(
@@ -324,22 +387,20 @@ export function createChannelStore(
agentProfiles,
eq(agentProfiles.agentId, channelAgents.agentId),
)
- // Most recent first, where starting a conversation counts as activity. A channel somebody
- // just created has nothing said in it yet, and is also the one they are about to type in, // ordering on the message alone would bury it under every channel that has one.
- //
- // The browser repeats this when the socket patches a row. Both must agree, or the list
- // reorders itself on the next event; see `byRecency` in use-channel-events.ts.
.where(
- inArray(
- channels.id,
- wanted.map((row) => row.id),
+ and(
+ inArray(
+ channels.id,
+ wanted.map((row) => row.id),
+ ),
+ // Repeated, not inherited from the query that chose the page: these are two statements
+ // on two snapshots, so a delete that commits between them would otherwise hand back a
+ // channel this person can no longer see.
+ isNull(channels.deletedAt),
),
)
- .orderBy(
- sql`coalesce(${channels.lastMessageAt}, ${channels.createdAt}) desc`,
- desc(channels.id),
- asc(channelAgents.agentId),
- );
+ // The same order the page was chosen in, since the rows below are read in order.
+ .orderBy(...ROSTER_ORDER, asc(channelAgents.agentId));
// One row per channel-agent pair; the ordering above keeps each channel's rows together and
// its agents in the same lexicographic order `get` returns.
@@ -361,25 +422,149 @@ export function createChannelStore(
lastMessageAt: row.lastMessageAt,
lastMessageAgentId: row.lastMessageAgentId,
createdAt: row.createdAt,
+ pinned: row.pinnedAt !== null,
});
}
return { channels: [...summaries.values()], nextCursor };
},
+ async setPinned(actor, channelId, pinned) {
+ await database.transaction(
+ async (transaction) => {
+ const updated = await transaction
+ .update(channelMemberships)
+ .set({ pinnedAt: pinned ? new Date() : null })
+ .where(
+ and(
+ eq(channelMemberships.channelId, channelId),
+ eq(channelMemberships.userId, actor.id),
+ // A deleted channel is not there to pin. Without this, pinning one succeeds and
+ // announces, and the announcement sends this person's tabs to refetch a roster that
+ // cannot show the row. `get` and `list` filter the same way.
+ exists(
+ transaction
+ .select({ one: sql`1` })
+ .from(channels)
+ .where(
+ and(
+ eq(channels.id, channelId),
+ isNull(channels.deletedAt),
+ ),
+ ),
+ ),
+ ),
+ )
+ .returning({ channelId: channelMemberships.channelId });
+ // Not a member, no such channel, or a deleted one: the same answer every way, matching
+ // recordActivity and `get`.
+ if (updated.length === 0) throw new ChannelNotFoundError(channelId);
+
+ /*
+ * Announced to this member alone.
+ *
+ * A pin is a fact about one membership row, so `memberIds` holds the person who made it
+ * and nobody else. The hub delivers by that list, which is what carries the pin across
+ * this person's own tabs and replicas without putting it on anybody else's roster.
+ */
+ const event: ChannelActivityEvent = {
+ channelId,
+ memberIds: [actor.id],
+ lastMessage: null,
+ lastMessageAt: null,
+ lastMessageAgentId: null,
+ pinned,
+ };
+ await transaction.execute(
+ sql`select pg_notify(${CHANNEL_ACTIVITY_TOPIC}, ${JSON.stringify(event)})`,
+ );
+ },
+ { isolationLevel: "read committed" },
+ );
+ },
+
+ async softDelete(actor, channelId) {
+ await database.transaction(
+ async (transaction) => {
+ const [row] = await transaction
+ .select({ packageId: channels.packageId })
+ .from(channels)
+ .innerJoin(
+ channelMemberships,
+ and(
+ eq(channelMemberships.channelId, channels.id),
+ eq(channelMemberships.userId, actor.id),
+ ),
+ )
+ .where(eq(channels.id, channelId));
+ // Not a member, or no such channel: the same answer either way.
+ if (!row) throw new ChannelNotFoundError(channelId);
+ // Package channels are configuration; the sync that wrote them owns them.
+ if (row.packageId !== null) {
+ throw new ChannelPackageOwnedError(channelId);
+ }
+ // The guard on deletedAt is what makes a repeat call a no-op rather than a new stamp.
+ await transaction
+ .update(channels)
+ .set({ deletedAt: new Date(), updatedAt: new Date() })
+ .where(and(eq(channels.id, channelId), isNull(channels.deletedAt)));
+
+ // Read on this transaction, so the members told are the ones the channel had when it was
+ // hidden. Soft leaves the membership rows in place, so this reads the same list a repeat
+ // call would.
+ const members = await transaction
+ .select({ userId: channelMemberships.userId })
+ .from(channelMemberships)
+ .where(eq(channelMemberships.channelId, channelId));
+
+ /*
+ * Announced inside the transaction, so it is delivered on commit and a refused delete —
+ * a channel the package owns, or one the caller is not in — announces nothing at all.
+ *
+ * Every member is told, because the deletion hides the channel for all of them: without
+ * this, a second tab and a second replica keep rendering a row whose channel no longer
+ * resolves until something else makes them refetch.
+ */
+ const event: ChannelActivityEvent = {
+ channelId,
+ memberIds: members.map((member) => member.userId),
+ lastMessage: null,
+ lastMessageAt: null,
+ lastMessageAgentId: null,
+ deleted: true,
+ };
+ await transaction.execute(
+ sql`select pg_notify(${CHANNEL_ACTIVITY_TOPIC}, ${JSON.stringify(event)})`,
+ );
+ },
+ { isolationLevel: "read committed" },
+ );
+ },
+
recordActivity(actor, channelId, activity) {
return database.transaction(
async (transaction) => {
const [membership] = await transaction
.select({ channelId: channelMemberships.channelId })
.from(channelMemberships)
+ // Joined rather than checked on the membership alone, so a deleted channel is refused
+ // too. `get` and `list` filter on `deleted_at`, so without this a client holding a stale
+ // roster row can bump `last_message` on a channel nobody can see and announce it to
+ // every member, each of whom refetches their roster for an invisible row.
+ .innerJoin(
+ channels,
+ and(
+ eq(channels.id, channelMemberships.channelId),
+ isNull(channels.deletedAt),
+ ),
+ )
.where(
and(
eq(channelMemberships.channelId, channelId),
eq(channelMemberships.userId, actor.id),
),
);
- // Not a member, or no such channel: the same answer either way, so belonging to a channel
- // is not something an outsider can probe for.
+ // Not a member, no such channel, or a deleted one: the same answer every way, so belonging
+ // to a channel is not something an outsider can probe for.
if (!membership) throw new ChannelNotFoundError(channelId);
if (activity.agentId !== null) {
@@ -441,64 +626,6 @@ export function createChannelStore(
{ isolationLevel: "read committed" },
);
},
-
- // `create` inserts exactly one membership row, so "delete" and "leave" are the same act while a
- // channel has exactly one member. Named `remove` for the multi-member split this becomes later.
- remove(actor, channelId) {
- return database.transaction(
- async (transaction) => {
- const [membership] = await transaction
- .select({ channelId: channelMemberships.channelId })
- .from(channelMemberships)
- .where(
- and(
- eq(channelMemberships.channelId, channelId),
- eq(channelMemberships.userId, actor.id),
- ),
- );
- // Not a member, or no such channel: the same answer either way, so belonging to a channel
- // is not something an outsider can probe for. Same reasoning as `recordActivity` above.
- if (!membership) throw new ChannelNotFoundError(channelId);
-
- // Read before the delete, not after: the cascade below wipes both of these tables, and the
- // notify payload needs the members it is telling, while the caller needs the thread id to
- // ask Intelligence to forget it.
- const members = await transaction
- .select({ userId: channelMemberships.userId })
- .from(channelMemberships)
- .where(eq(channelMemberships.channelId, channelId));
- const [mapping] = await transaction
- .select({ threadId: intelligenceChannelMappings.threadId })
- .from(intelligenceChannelMappings)
- .where(
- and(
- eq(intelligenceChannelMappings.channelId, channelId),
- eq(intelligenceChannelMappings.userId, actor.id),
- ),
- );
-
- // Cascades `channel_memberships`, `channel_agents`, and `intelligence_channel_mappings`:
- // see the `onDelete: "cascade"` on each in db/schema/core.ts. Nothing else references a
- // channel, so this one delete is the whole local removal.
- await transaction.delete(channels).where(eq(channels.id, channelId));
-
- const event: ChannelActivityEvent = {
- channelId,
- memberIds: members.map((member) => member.userId),
- lastMessage: null,
- lastMessageAt: null,
- lastMessageAgentId: null,
- deleted: true,
- };
- await transaction.execute(
- sql`select pg_notify(${CHANNEL_ACTIVITY_TOPIC}, ${JSON.stringify(event)})`,
- );
-
- return mapping?.threadId ?? null;
- },
- { isolationLevel: "read committed" },
- );
- },
};
}
@@ -509,6 +636,13 @@ export class ChannelNotFoundError extends Error {
}
}
+export class ChannelPackageOwnedError extends Error {
+ constructor(id: string) {
+ super(`Channel ${id} is defined by the deployment package.`);
+ this.name = "ChannelPackageOwnedError";
+ }
+}
+
type ChannelInputParseResult =
| { ok: true; value: { agentIds: string[] } }
| { ok: false; error: string };
@@ -589,31 +723,24 @@ export function createChannelRoutes(
requireUser: MiddlewareHandler<{ Variables: AppVariables }>,
/** Absent in tests and wherever live updates are not wanted; the routes still work without it. */
events?: ChannelEventHub,
- /** Where a channel's deletion is written. Absent in tests that do not care about the trail. */
+ /** Where a channel's removal is written. Absent in tests that do not care about the trail. */
auditStore?: AuditStore,
- /**
- * Ask Intelligence to permanently delete a thread. Absent leaves a channel deletable and its
- * thread left behind on the platform: the local removal below does not depend on this existing.
- */
- forgetThread?: (params: {
- threadId: string;
- userId: string;
- agentId: string;
- }) => Promise,
) {
const routes = new Hono<{ Variables: AppVariables }>();
/**
* Write the one audit row this file ever writes, tolerantly.
*
- * Mirrors `record` in agents/routes.ts: never fatal, because the channel is already gone and the
+ * Mirrors `record` in agents/routes.ts: never fatal, because the channel is already hidden and the
* caller has already been told so by the time this runs. A trail that is briefly unavailable is
* not a reason to report a failure that did not happen.
+ *
+ * Reached only after `softDelete` resolves, so a refused delete — a channel the package owns, or
+ * one the caller is not in — writes nothing. The trail records acts, not attempts.
*/
const recordDeleted = async (
context: Context<{ Variables: AppVariables }>,
channelId: string,
- payload: { threadId: string | null; threadForgotten: boolean },
): Promise => {
if (!auditStore) return;
const actor = context.var.actor;
@@ -633,7 +760,9 @@ export function createChannelRoutes(
* by default, and "somebody deleted this conversation" is the whole point of the row.
*/
actorUserId: actor.id,
- payload,
+ // Named rather than implied: the channel row and its thread are still there, and a later
+ // hard delete would be a different fact about the same channel.
+ payload: { mechanism: "soft" },
});
} catch (error) {
console.error(
@@ -723,72 +852,49 @@ export function createChannelRoutes(
}
});
- routes.get("/:channelId", requireUser, async (context) => {
+ routes.put("/:channelId/pin", requireUser, async (context) => {
+ const body = await context.req.json().catch(() => null);
+ if (!isChannelInputObject(body)) {
+ return context.json({ error: "Pin input must be a JSON object." }, 400);
+ }
+ const { pinned } = body as { pinned?: unknown };
+ if (typeof pinned !== "boolean") {
+ return context.json({ error: "Pinned must be true or false." }, 400);
+ }
+
try {
- const channel = await store.get(
+ await store.setPinned(
context.var.actor,
context.req.param("channelId"),
+ pinned,
);
- if (!channel) {
- return context.json({ error: "Channel not found." }, 404);
- }
- return context.json({ channel: channelDto(channel) });
+ return context.json({ pinned });
} catch (error) {
return mapStoreError(context, error);
}
});
- // Registered unconditionally, unlike `GET /:threadId` in thread-routes.ts: removing your own
- // channel from your own roster can always succeed locally, whether or not Intelligence is reachable.
routes.delete("/:channelId", requireUser, async (context) => {
const channelId = context.req.param("channelId");
try {
- const threadId = await store.remove(context.var.actor, channelId);
-
- // The local delete already committed above, so a failed thread deletion is non-fatal: a
- // channel gone locally with an orphaned thread beats one still on screen with its history wiped.
- let threadForgotten = false;
- if (threadId && forgetThread) {
- try {
- await forgetThread({
- threadId,
- userId: context.var.actor.id,
- // Derived here, never accepted from the caller: this is the same string
- // channel-chat.tsx builds for the same channel, and trusting a client-supplied agentId
- // would let a request name any thread it likes and ask the platform to delete it.
- agentId: `channel:${channelId}`,
- });
- threadForgotten = true;
- } catch {
- console.error(
- JSON.stringify({
- type: "channel-thread-forget-failed",
- note: "Could not delete the Intelligence thread for a removed channel.",
- channelId,
- threadId,
- }),
- );
- }
- }
-
- await recordDeleted(context, channelId, { threadId, threadForgotten });
+ await store.softDelete(context.var.actor, channelId);
+ await recordDeleted(context, channelId);
+ return context.body(null, 204);
+ } catch (error) {
+ return mapStoreError(context, error);
+ }
+ });
- /*
- * 200 with the outcome, not a bare 204.
- *
- * The thread deletion is the half that can fail on its own, and 204 says the whole act
- * succeeded whichever way it went. The screen then tells somebody their message history is
- * gone while it is still sitting on the platform, which is the one thing a person deleting a
- * conversation is asking about.
- *
- * Reported as the question the caller has rather than the two facts it is derived from: no
- * thread and a forgotten thread both mean nothing was left behind, and only a thread that
- * survived is worth putting on a screen.
- */
- return context.json(
- { historyLeftBehind: threadId !== null && !threadForgotten },
- 200,
+ routes.get("/:channelId", requireUser, async (context) => {
+ try {
+ const channel = await store.get(
+ context.var.actor,
+ context.req.param("channelId"),
);
+ if (!channel) {
+ return context.json({ error: "Channel not found." }, 404);
+ }
+ return context.json({ channel: channelDto(channel) });
} catch (error) {
return mapStoreError(context, error);
}
@@ -815,6 +921,7 @@ function channelSummaryDto(channel: ChannelSummary) {
lastMessageAt: channel.lastMessageAt?.toISOString() ?? null,
lastMessageAgentId: channel.lastMessageAgentId,
createdAt: channel.createdAt.toISOString(),
+ pinned: channel.pinned,
};
}
@@ -825,5 +932,14 @@ function mapStoreError(context: Context, error: unknown): Response {
if (error instanceof ChannelNotFoundError) {
return context.json({ error: "Channel not found." }, 404);
}
+ if (error instanceof ChannelPackageOwnedError) {
+ return context.json(
+ {
+ error:
+ "This channel is defined by the deployment package, so it cannot be deleted here.",
+ },
+ 409,
+ );
+ }
throw error;
}
diff --git a/server/src/credentials.ts b/server/src/credentials.ts
index ea14fd3a..b95d7517 100644
--- a/server/src/credentials.ts
+++ b/server/src/credentials.ts
@@ -64,6 +64,27 @@ export type CredentialStore = {
value: CredentialStoreValue,
executor?: CredentialExecutor,
) => Promise;
+ /**
+ * Re-encrypt a live row in place, keeping the id everything already points at.
+ *
+ * A vendor whose refresh token ROTATES is the one caller. It has already killed the old token at
+ * its end by the time it answers, so there is no second grant left to withdraw and nothing to
+ * learn from a new row — only a row per tool call, forever. A person RECONNECTING still goes
+ * through `rotate`, because that is the act that leaves a live grant behind for us to withdraw at
+ * our side too.
+ *
+ * A revoked or missing row is refused rather than written through: a grant somebody withdrew must
+ * not come back to life by being handed a fresh secret.
+ *
+ * Without an executor this writes on its own connection. With one it joins the caller's
+ * transaction, which is how the caller that has locked this row spends the token under that lock:
+ * the write has to commit with the lock rather than beside it.
+ */
+ updateSecret: (
+ id: string,
+ encryptedValue: string,
+ executor?: CredentialExecutor,
+ ) => Promise;
/**
* Replace one credential with another, atomically.
*
@@ -224,6 +245,23 @@ export function createCredentialStore(
}
return credential;
},
+ updateSecret: async (id, encryptedValue, executor = database) => {
+ const [credential] = await executor
+ .update(credentials)
+ .set({ encryptedValue, updatedAt: new Date() })
+ .where(and(eq(credentials.id, id), isNull(credentials.revokedAt)))
+ .returning({ id: credentials.id });
+
+ /*
+ * One statement, so nothing can revoke the row between a check and the write.
+ *
+ * The cost is that "no such row" and "revoked" arrive as the same answer, hence the one
+ * message naming both. The caller acts identically on either: it refuses its call.
+ */
+ if (!credential) {
+ throw new Error("Credential was not found or is revoked");
+ }
+ },
rotate: async (input, executor) => {
const write = async (transaction: CredentialExecutor) => {
/**
diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts
index 35dc9ac1..617ba97f 100644
--- a/server/src/db/schema/core.ts
+++ b/server/src/db/schema/core.ts
@@ -267,6 +267,12 @@ export const channels = pgTable(
onDelete: "set null",
},
),
+ /**
+ * When this channel was deleted, or null. Soft: the row, the transcript, and the Intelligence
+ * thread stay intact, and every read path filters on this instead. Channel grain, because
+ * deleting is for everyone — per-member hiding would be a membership fact instead.
+ */
+ deletedAt: timestamp("deleted_at", { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
@@ -297,6 +303,11 @@ export const channelMemberships = pgTable(
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
+ /**
+ * When this member pinned the channel, or null. On the membership, not the channel: a pin is
+ * one person's marker, and the membership row is already the per-member half of a channel.
+ */
+ pinnedAt: timestamp("pinned_at", { withTimezone: true }),
createdAt: createdAt(),
},
(table) => [primaryKey({ columns: [table.channelId, table.userId] })],
diff --git a/server/src/index.ts b/server/src/index.ts
index 4b7f2bf6..7e2c87bb 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -45,6 +45,7 @@ import {
} from "./credentials";
import { createDatabase } from "./db/client";
import { createPeopleStore } from "./people/store";
+import { redirectUriFor } from "./plugins/oauth";
import { createPluginStore } from "./plugins/store";
import { grantedSkills, grantedTools } from "./plugins/tools";
import { createIntentRouter } from "./routing/classify";
@@ -274,6 +275,15 @@ const pluginStore = createPluginStore({
credentials: credentialStore,
encryptionKey: config.keyEncryptionKey,
policy: () => policyStore.get(),
+ /*
+ * Where a vendor sends people back, for a vendor whose client this deployment registers itself.
+ *
+ * The same value the connect and callback routes build, from the same config field, because it has
+ * to match what was registered character for character. Undefined without a public URL, which is
+ * the honest state: there is nowhere for a consent flow to come back to, so there is nothing worth
+ * registering.
+ */
+ redirectUri: config.publicUrl ? redirectUriFor(config.publicUrl) : undefined,
});
void recordAuditEvent(bootAuditStore, {
diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts
index 3c81ef4d..420148a5 100644
--- a/server/src/plugins/catalogue.ts
+++ b/server/src/plugins/catalogue.ts
@@ -46,9 +46,25 @@ export type CatalogueAuth =
revokeUrl: string;
/**
* What to ask a person to consent to. Narrow on purpose: a scope granted by everybody who
- * connects and used by nothing is a permission nobody remembers agreeing to.
+ * connects and used by nothing is a permission nobody remembers agreeing to. Empty for a
+ * vendor whose consent screen itself is the scoping (Notion), where scope strings would
+ * assert a control that does not exist.
*/
scopes: readonly string[];
+ /**
+ * How the deployment gets its OAuth client. Absent means an administrator registers one at
+ * the vendor and pastes it in. `dynamic` means the deployment registers ITSELF (RFC 7591)
+ * on first connect — no admin step, no client secret; PKCE carries the proof instead.
+ */
+ clientRegistration?: "dynamic";
+ /** The RFC 7591 endpoint. Pinned https, required when `clientRegistration` is `dynamic`. */
+ registrationUrl?: string;
+ /**
+ * Vendor-specific consent-URL parameters. Google's offline/consent pair lives HERE rather
+ * than in `authorizationUrlFor`, so one vendor's requirements are never sent to another —
+ * an unknown parameter is a thing a strict vendor may refuse the whole request over.
+ */
+ authorizationParams?: Readonly>;
};
export type CatalogueEntry = {
@@ -88,8 +104,11 @@ export type CatalogueEntry = {
*
* Kept so the policy can be written about effect rather than about tool names a rule author would
* have to look up. Known-incomplete for some vendors, which is why {@link classifyTool} treats an
- * unknown tool as a write rather than as a read: a missed write that gets extra scrutiny is safer
- * than a write classified as a read.
+ * unknown tool as a write rather than as a read: a tool the server never advertised, so nothing
+ * here could have named it, is safe to over-scrutinize as a write. The opposite direction is the
+ * one that matters for this list: a tool the server DOES advertise but that is missing from here
+ * classifies as a read, so an incomplete list is the failure mode, not a safe default — this list
+ * has to lean over-inclusive.
*/
writeTools: readonly string[];
/**
@@ -105,7 +124,7 @@ export type CatalogueEntry = {
};
/**
- * One entry, deliberately.
+ * A short list, deliberately.
*
* Atlassian, Box, Slack, Salesforce and ServiceNow were here and were removed: each was a reviewed
* source contract for a vendor nobody had connected, and a screen offering five untried connectors
@@ -157,6 +176,15 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([
revokeUrl: "https://oauth2.googleapis.com/revoke",
// Read-only, because nothing in this slice writes to anybody's Drive.
scopes: Object.freeze(["https://www.googleapis.com/auth/drive.readonly"]),
+ /*
+ * `offline` and `consent` are both load bearing FOR GOOGLE. Without `access_type=offline`
+ * Google returns no refresh token; without `prompt=consent` a reconnect returns none either.
+ * They are Google parameters, so they live on Google's entry.
+ */
+ authorizationParams: Object.freeze({
+ access_type: "offline",
+ prompt: "consent",
+ }),
},
/*
* Named writes even though the scope above makes Google refuse them.
@@ -169,6 +197,60 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([
docsUrl:
"https://developers.google.com/workspace/guides/configure-mcp-servers",
},
+ {
+ key: "notion",
+ title: "Notion",
+ vendor: "Notion",
+ summary: "Pages and databases of whoever is asking.",
+ /*
+ * The hosted MCP server Notion runs, on the default MCP transport — the first entry to use
+ * it. Drive's REST adapter is a workaround for a preview-gated vendor; Notion's server is
+ * generally available, so this entry is the shape the catalogue was designed for.
+ */
+ host: "https://mcp.notion.com",
+ path: "/mcp",
+ auth: {
+ kind: "user-oauth",
+ // From https://mcp.notion.com/.well-known/oauth-authorization-server, verified live.
+ authorizationUrl: "https://mcp.notion.com/authorize",
+ tokenUrl: "https://mcp.notion.com/token",
+ // Notion's published revocation_endpoint IS its token endpoint — not a copy-paste mistake.
+ revokeUrl: "https://mcp.notion.com/token",
+ /*
+ * Notion has no scope strings and no read-only scope: access is per-page, chosen on the
+ * consent screen. `writeTools` below plus the action policy are the ENTIRE write barrier —
+ * there is no vendor-side scope backing them up.
+ */
+ scopes: Object.freeze([]),
+ clientRegistration: "dynamic",
+ registrationUrl: "https://mcp.notion.com/register",
+ },
+ /*
+ * The writing tools as the hosted server advertises them today. The hosted server advertises
+ * its tools, so a name here that does not match an advertised tool is not the risk — an
+ * advertised tool that is missing from this list is: {@link classifyTool} reads an unlisted
+ * but advertised name as a read, never as a write. That makes under-inclusion the failure
+ * mode, so this list has to lean over-inclusive rather than minimal, and reconciling it
+ * against the live tool list on the first Refresh tools is required, not cosmetic.
+ */
+ writeTools: Object.freeze([
+ "notion-convert-page-to-skill",
+ "notion-create-attachment",
+ "notion-create-comment",
+ "notion-create-database",
+ "notion-create-file-upload",
+ "notion-create-folder",
+ "notion-create-pages",
+ "notion-create-view",
+ "notion-duplicate-page",
+ "notion-move-pages",
+ "notion-update-data-source",
+ "notion-update-folder",
+ "notion-update-page",
+ "notion-update-view",
+ ]),
+ docsUrl: "https://developers.notion.com/guides/mcp/build-mcp-client",
+ },
]);
const BY_KEY = new Map(CATALOGUE.map((entry) => [entry.key, entry]));
@@ -227,11 +309,10 @@ export function resolveServerUrl(
/**
* What this tool does, in the only two categories a policy author cares about.
*
- * Unknown counts as a write, in both directions it can be unknown. A tool named in
- * {@link CatalogueEntry.writeTools} is a write. A tool the server advertised and this file has never
- * heard of is a write, because these lists are a verified subset for several vendors rather than the
- * complete surface. A tool the server never advertised at all is a write, because the only thing
- * that produced the name was a model.
+ * Unknown counts as a write. A tool named in {@link CatalogueEntry.writeTools} is a write. A tool
+ * the server never advertised at all is a write, because the only thing that produced the name was
+ * a model. A server with no catalogue entry behind it is a write throughout, because nothing
+ * reviewed says any tool of theirs only reads.
*
* Only a tool the server itself listed AND that is absent from the write list is treated as a read.
* That is the one case where both sources agree, and it is the only one where guessing permissively
diff --git a/server/src/plugins/oauth.ts b/server/src/plugins/oauth.ts
index 04a461de..5d9c2b70 100644
--- a/server/src/plugins/oauth.ts
+++ b/server/src/plugins/oauth.ts
@@ -1,5 +1,5 @@
import { createHash, randomBytes } from "node:crypto";
-import { sign, verify } from "../auth/signed-value";
+import { seal, unseal } from "../auth/signed-value";
import type { CatalogueAuth } from "./catalogue";
/**
@@ -8,19 +8,27 @@ import type { CatalogueAuth } from "./catalogue";
* The browser is in the middle of this, which is the whole difficulty. An authorization code arrives
* on a request that somebody else's server sent the person to, so nothing on it can be believed on
* its own — not who is connecting, not which server they meant, not that they ever asked. Two things
- * carry the truth across: a signed state, which is this deployment's own statement about the request
+ * carry the truth across: a sealed state, which is this deployment's own statement about the request
* it started, and a PKCE verifier, which proves the code being redeemed belongs to that request.
*
+ * SEALED, not signed, and that distinction is the reason this comment exists. The state carries the
+ * PKCE verifier, and the callback URL carries the verifier's state and the authorization code
+ * TOGETHER. A dynamically registered client is public — it proves itself with PKCE and no secret —
+ * so the verifier is the only thing binding that code to this deployment. A signed state is readable
+ * by anybody holding it, and a callback URL is held by every CDN log, proxy log, browser history and
+ * vendor log it passes through: any one of those readers could redeem the code. Encrypted, the state
+ * says nothing to anybody but this deployment.
+ *
* Everything here fails closed. A state that was tampered with, replayed after it expired, or minted
* for some other purpose reads back as nothing, because the alternative is attaching one person's
* Google account to another person's row.
*/
/**
- * The label this deployment's connect states are signed under.
+ * The label this deployment's connect states are sealed under.
*
- * Its own, so a signature valid here can never be replayed as a run assertion and vice versa. Every
- * signed value the deployment hands out would otherwise be a candidate state.
+ * Its own, so a state cannot be opened as a run assertion and vice versa. Every value the deployment
+ * hands out under this key would otherwise be a candidate state.
*/
const CONNECT_LABEL = "mcp-oauth-connect";
@@ -46,7 +54,7 @@ const CALLBACK_PATH = "/api/plugins/oauth/callback";
* one falls back instead of being followed, so the worst a tampered state achieves is the wrong page
* of this app.
*
- * It lives in the SIGNED state rather than on the callback URL because the callback is a request
+ * It lives in the SEALED state rather than on the callback URL because the callback is a request
* somebody else's server sent the browser on. Nothing on it is believable by itself.
*/
export type ConnectOrigin = "settings" | "admin";
@@ -56,13 +64,24 @@ export type ConnectState = {
userId: string;
/** Which server they are connecting. Prevents a code for one vendor landing on another's row. */
serverId: string;
- /** The PKCE verifier, held here rather than in a table because it is single-use and short-lived. */
+ /**
+ * The PKCE verifier, held here rather than in a table because it is single-use and short-lived.
+ *
+ * It is also why the state is sealed rather than signed: this is a secret travelling beside the
+ * code it unlocks, so a state anybody could read would be a code anybody could redeem.
+ */
verifier: string;
/** Where to go back to. Absent reads as `settings`, which is where every flow used to end. */
returnTo?: ConnectOrigin;
};
-type SignedState = ConnectState & { exp: number };
+/**
+ * What is actually sealed: the state, plus when it stops being one.
+ *
+ * The expiry travels inside the sealed value because sealing says nothing about freshness. Nobody
+ * can move it without the key, and this deployment checks it on the way out.
+ */
+type SealedState = ConnectState & { exp: number };
/**
* Where the vendor sends somebody back to.
@@ -148,35 +167,39 @@ export function challengeFor(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url");
}
-export function signConnectState(
+/**
+ * The state to send a person to the vendor with.
+ *
+ * Async because sealing is: the encryption goes through WebCrypto, like every other secret this
+ * deployment writes down. It is one call on a path that already makes a network request or two.
+ */
+export async function sealConnectState(
state: ConnectState,
encryptionKey: string,
now: number = Date.now(),
-): string {
- const payload: SignedState = { ...state, exp: now + STATE_TTL_MS };
- const value = Buffer.from(JSON.stringify(payload)).toString("base64url");
- return sign(value, encryptionKey, CONNECT_LABEL);
+): Promise {
+ const payload: SealedState = { ...state, exp: now + STATE_TTL_MS };
+ return seal(JSON.stringify(payload), encryptionKey, CONNECT_LABEL);
}
/**
* What a state says, or nothing at all.
*
- * One return for every way of being unacceptable — bad signature, wrong label, expired, malformed,
- * missing a field — because a caller that has to tell those apart is a caller that can get one of
- * them wrong. There is exactly one thing to do with an unusable state, so there is one answer.
+ * One return for every way of being unacceptable — altered, sealed by another key, sealed for
+ * another purpose, expired, malformed, missing a field — because a caller that has to tell those
+ * apart is a caller that can get one of them wrong. There is exactly one thing to do with an
+ * unusable state, so there is one answer.
*/
-export function readConnectState(
- signed: string,
+export async function readConnectState(
+ sealed: string,
encryptionKey: string,
now: number = Date.now(),
-): ConnectState | null {
- const value = verify(signed, encryptionKey, CONNECT_LABEL);
+): Promise {
+ const value = await unseal(sealed, encryptionKey, CONNECT_LABEL);
if (!value) return null;
try {
- const payload = JSON.parse(
- Buffer.from(value, "base64url").toString("utf8"),
- ) as Partial;
+ const payload = JSON.parse(value) as Partial;
if (
typeof payload.userId !== "string" ||
@@ -203,14 +226,31 @@ export function readConnectState(
}
}
+/**
+ * The six keys that carry this flow's own security: who is asking (`client_id`), where the vendor
+ * answers (`redirect_uri`), the grant shape (`response_type`), the sealed state (`state`), and the
+ * PKCE proof (`code_challenge`, `code_challenge_method`). A catalogue entry's `authorizationParams`
+ * must never rewrite one of these — an entry setting `code_challenge_method: "plain"` would defeat
+ * PKCE with nothing here to catch it. The catalogue is frozen, reviewed code today, so nothing can
+ * reach this, but a future entry that tried would fail at first connect rather than quietly winning.
+ */
+const RESERVED_AUTHORIZATION_PARAMS: ReadonlySet = new Set([
+ "client_id",
+ "redirect_uri",
+ "response_type",
+ "state",
+ "code_challenge",
+ "code_challenge_method",
+]);
+
/**
* The vendor's consent screen, as a URL to send somebody to.
*
- * `offline` and `consent` are both load bearing. Without `access_type=offline` Google returns an
- * access token and no refresh token, so the connection would appear to work and then stop about an
- * hour later with nothing to renew it. Without `prompt=consent` a second connect returns no refresh
- * token at all, because the person already agreed once — which turns reconnecting after a disconnect
- * into a silent no-op.
+ * Vendor-specific parameters — Google's `offline`/`consent` pair, or nothing at all for a vendor
+ * like Notion whose consent screen is itself the scoping — come from the catalogue entry's own
+ * `authorizationParams`, never hardcoded here. The rationale for any one vendor's requirements lives
+ * on that vendor's entry, because a parameter this function adds for everybody is a parameter an
+ * unrelated vendor never asked for and may refuse the whole request over.
*/
export function authorizationUrlFor(input: {
auth: Extract;
@@ -220,17 +260,34 @@ export function authorizationUrlFor(input: {
codeChallenge: string;
}): string {
const url = new URL(input.auth.authorizationUrl);
- url.search = new URLSearchParams({
+ const params = new URLSearchParams({
client_id: input.clientId,
redirect_uri: input.redirectUri,
response_type: "code",
- scope: input.auth.scopes.join(" "),
- access_type: "offline",
- prompt: "consent",
state: input.state,
code_challenge: input.codeChallenge,
code_challenge_method: "S256",
- }).toString();
+ });
+ // Empty means the consent screen itself is the scoping; an empty scope= is not "no scope".
+ if (input.auth.scopes.length > 0) {
+ params.set("scope", input.auth.scopes.join(" "));
+ }
+ // The vendor's own requirements, from its reviewed entry — never another vendor's.
+ for (const [name, value] of Object.entries(
+ input.auth.authorizationParams ?? {},
+ )) {
+ // Applied last, so a reserved name here would quietly rewrite the flow's own security instead
+ // of the vendor's. That is a bad entry, and it must fail at first connect, not win silently.
+ if (RESERVED_AUTHORIZATION_PARAMS.has(name)) {
+ throw new Error(
+ `authorizationParams may not set "${name}": it is one of the flow's own security ` +
+ "parameters (client_id, redirect_uri, response_type, state, code_challenge, " +
+ "code_challenge_method) and must never be rewritten by a catalogue entry.",
+ );
+ }
+ params.set(name, value);
+ }
+ url.search = params.toString();
return url.toString();
}
@@ -256,39 +313,108 @@ export async function redeemAuthorizationCode(input: {
redirectUri: string;
verifier: string;
}): Promise {
+ const params = new URLSearchParams({
+ grant_type: "authorization_code",
+ code: input.code,
+ client_id: input.clientId,
+ redirect_uri: input.redirectUri,
+ code_verifier: input.verifier,
+ });
+ // A public (DCR) client proves itself with PKCE, and some vendors refuse an unexpected empty field.
+ if (input.clientSecret) params.set("client_secret", input.clientSecret);
+
const response = await fetch(input.tokenUrl, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- grant_type: "authorization_code",
- code: input.code,
- client_id: input.clientId,
- client_secret: input.clientSecret,
- redirect_uri: input.redirectUri,
- code_verifier: input.verifier,
- }),
+ body: params,
+ /*
+ * A redirect is a refusal, not a detour to be followed.
+ *
+ * `tokenUrl` is pinned in the catalogue because this request carries a client secret and an
+ * authorization code, and following a 302 would hand both to whatever address the answer named.
+ * Manual leaves the 3xx as the response, which is not `ok`, so it falls into the refusal below.
+ */
+ redirect: "manual",
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) return null;
- const body = (await response.json()) as {
+ /*
+ * Read defensively, because a 200 is not a promise of JSON.
+ *
+ * A CDN interstitial, a captive portal or a maintenance page answers 200 with HTML, and an
+ * unguarded parse would throw a SyntaxError out of a function whose whole contract is to refuse
+ * quietly — escaping the callback as a 500 instead of the redirect-with-a-notice a person who has
+ * just consented should get, and quoting the vendor's body into whatever logged the throw.
+ */
+ const body = (await response.json().catch(() => null)) as {
refresh_token?: unknown;
scope?: unknown;
- };
+ } | null;
/*
* No refresh token is a failure, not a partial success.
*
* It is what a vendor returns when it believes this person already consented, and storing the
* access token instead would produce a connection that works for an hour and then cannot be
- * renewed — the worst of the three outcomes, because it looks like success.
+ * renewed — the worst of the three outcomes, because it looks like success. A body that was not
+ * JSON at all arrives here as nothing, which is the same answer: the vendor said something other
+ * than a token.
*/
- if (typeof body.refresh_token !== "string" || !body.refresh_token) {
+ if (typeof body?.refresh_token !== "string" || !body.refresh_token) {
return null;
}
return {
refreshToken: body.refresh_token,
- scope: typeof body.scope === "string" ? body.scope : "",
+ /*
+ * Capped where it is read. It is a short string in the protocol and vendor-controlled in fact,
+ * and everything downstream shows it to somebody — the connected-accounts page, the
+ * `mcp.account_connected` payload, the `scope` column — none of which is a promise about length.
+ */
+ scope: typeof body.scope === "string" ? body.scope.slice(0, 512) : "",
+ };
+}
+
+/**
+ * Register this deployment as an OAuth client, at the vendor's own registration endpoint.
+ *
+ * RFC 7591, the shape Notion's hosted MCP expects: a public client (`token_endpoint_auth_method:
+ * "none"`) whose proof is PKCE rather than a secret. Null on refusal rather than a throw, for the
+ * same reason `redeemAuthorizationCode` refuses quietly: the vendor's error body is written for a
+ * developer console and can be surfaced by the caller that knows who is listening.
+ */
+export async function registerDynamicClient(input: {
+ registrationUrl: string;
+ redirectUri: string;
+}): Promise<{ clientId: string; clientSecret: string } | null> {
+ const response = await fetch(input.registrationUrl, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ redirect_uris: [input.redirectUri],
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ token_endpoint_auth_method: "none",
+ client_name: "OpenBot",
+ }),
+ // The registration endpoint is pinned in the catalogue, so a redirect is somebody else deciding
+ // where this deployment introduces itself. Left as the response, which is not `ok`.
+ redirect: "manual",
+ signal: AbortSignal.timeout(15_000),
+ });
+ if (!response.ok) return null;
+ // A 200 is not a promise of JSON — see `redeemAuthorizationCode`. A body that will not parse is
+ // the vendor answering with something other than a client, which is this function's null.
+ const body = (await response.json().catch(() => null)) as {
+ client_id?: unknown;
+ client_secret?: unknown;
+ } | null;
+ if (typeof body?.client_id !== "string" || !body.client_id) return null;
+ return {
+ clientId: body.client_id,
+ // A public client has none; a vendor that issues one anyway gets it stored and sent back.
+ clientSecret:
+ typeof body.client_secret === "string" ? body.client_secret : "",
};
}
diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts
index 164242b7..0c62c9ee 100644
--- a/server/src/plugins/routes.ts
+++ b/server/src/plugins/routes.ts
@@ -12,15 +12,29 @@ import {
redeemAuthorizationCode,
redirectUriFor,
connectedAccountsUrlFor,
- signConnectState,
+ sealConnectState,
} from "./oauth";
import {
CatalogueEntryUnknownError,
CustomServerRefusedError,
+ type OAuthClient,
PluginRefusedError,
type PluginStore,
} from "./store";
+/**
+ * Whether the person a consent was started for still has access to this deployment.
+ *
+ * A seam rather than an import, because these routes have no business knowing what a person is or
+ * where the deny list lives — and because the answer has to come from the deployment as it is when
+ * the callback lands, not from what was true when the flow started.
+ *
+ * False for somebody who was removed while they were away at the vendor's consent screen, and false
+ * for a user id that names nobody at all. Both are the same refusal: there is no live person for
+ * this grant to belong to.
+ */
+export type ConnectingPersonCheck = (userId: string) => Promise;
+
/**
* The Plugins surface: what this deployment has added, and which Bots may use it.
*
@@ -47,8 +61,8 @@ export function createPluginRoutes(
*/
canUseBot: BotAccessCheck,
/**
- * What the connect flow needs that the store does not hold: the key its state is signed with, and
- * the address a vendor sends people back to.
+ * What the connect flow needs that the store does not hold: the key its state is sealed with, the
+ * address a vendor sends people back to, and who still has access when they come back.
*
* Optional, so a deployment with no public URL configured simply cannot start a connect flow and
* says so, rather than building a redirect URI out of a request header and failing at the vendor.
@@ -60,6 +74,16 @@ export function createPluginRoutes(
*/
connect?: {
encryptionKey: string;
+ /**
+ * Whether the person a state names may still connect an account here.
+ *
+ * Required rather than optional, unlike everything else that arrived on this object as "one more
+ * parameter". The callback is sessionless on purpose, so this is the ONLY thing asking whether
+ * the identity in the state is still one this deployment recognises — and a deployment that
+ * forgot to pass it would complete a consent for somebody who was removed ten minutes ago and
+ * write a live refresh token nothing will ever revoke.
+ */
+ personHasAccess: ConnectingPersonCheck;
/**
* Whether this deployment holds a shared secret a Bot may present when calling a tool back.
*
@@ -348,8 +372,48 @@ export function createPluginRoutes(
);
}
- const client = await store.oauthClientFor(serverId);
+ /*
+ * A dynamic entry introduces the deployment itself on first use; a manual one still waits
+ * for an administrator. Registration lives here, on the one handler that already refuses
+ * without OPENBOT_PUBLIC_URL — the redirect URI it registers is guaranteed to exist.
+ */
+ /*
+ * A vendor in the catalogue that nobody has added to this deployment reaches here, gets past
+ * every check above — the entry is real — and then asks the store for a client it cannot have,
+ * because there is no server row to hold one. `ensureOAuthClient` says so by throwing, and
+ * unhandled that was a 500 on the one path where a person is trying to connect their account.
+ *
+ * The same 409 as a vendor whose client an administrator has not pasted in yet, because it is
+ * the same situation: the person pressing Connect has no step to take, and an administrator has
+ * one. The sentence names the step rather than the exception.
+ */
+ let client: OAuthClient | null;
+ try {
+ client =
+ (await store.oauthClientFor(serverId)) ??
+ (entry.auth.clientRegistration === "dynamic"
+ ? await store.ensureOAuthClient(serverId, actorEmail(context))
+ : null);
+ } catch (error) {
+ if (error instanceof CatalogueEntryUnknownError) {
+ return context.json(
+ {
+ error: `${entry.title} has not been added to this deployment yet. An administrator has to add it first.`,
+ },
+ 409,
+ );
+ }
+ throw error;
+ }
if (!client) {
+ if (entry.auth.clientRegistration === "dynamic") {
+ return context.json(
+ {
+ error: `${entry.title} refused this deployment's registration. Try again, and check the vendor's status if it persists.`,
+ },
+ 502,
+ );
+ }
return context.json(
{
error: `${entry.title} has no OAuth client registered yet. An administrator has to add one first.`,
@@ -362,7 +426,7 @@ export function createPluginRoutes(
* Where to come back to, as one of two names rather than a URL the caller chose.
*
* Read from the query and narrowed immediately, so an unrecognised value is the default rather
- * than something carried into a signed state. See {@link ConnectOrigin}: a destination that could
+ * than something carried into a sealed state. See {@link ConnectOrigin}: a destination that could
* name another origin is an open redirect with a consent screen in front of it.
*/
const returnTo =
@@ -374,7 +438,7 @@ export function createPluginRoutes(
auth: entry.auth,
clientId: client.clientId,
redirectUri: redirectUriFor(connect.publicUrl),
- state: signConnectState(
+ state: await sealConnectState(
{ userId: context.var.actor.id, serverId, verifier, returnTo },
connect.encryptionKey,
),
@@ -387,10 +451,13 @@ export function createPluginRoutes(
* Where the vendor sends somebody back.
*
* Deliberately not behind `requireUser`. The person arrives on a redirect from another company's
- * server, and whose connection this is comes from the signed state rather than from whatever
+ * server, and whose connection this is comes from the sealed state rather than from whatever
* session the browser happens to be carrying — which is what stops a callback delivered to the
* wrong browser from attaching one person's Google account to another person's row.
*
+ * Having no session is what makes the access check below necessary. Every other route asks the
+ * question by being behind a guard; this one has to ask it out loud.
+ *
* Every failure ends the same way: back at Settings with a word about what happened, and nothing
* written. There is no useful distinction here for the person between a forged state and an expired
* one, and spelling out which is which tells anybody probing this endpoint how far they got.
@@ -402,12 +469,29 @@ export function createPluginRoutes(
if (!connect?.publicUrl) return context.redirect(failed);
const code = context.req.query("code");
- const state = readConnectState(
+ const state = await readConnectState(
context.req.query("state") ?? "",
connect.encryptionKey,
);
if (!code || !state) return context.redirect(failed);
+ /*
+ * Is the person in the state still somebody here?
+ *
+ * Asked here, before the code is redeemed and before anything is written, because a state is
+ * good for ten minutes and access can end inside them. Removing somebody deny-lists their
+ * address, deletes their sessions and retires the credentials they had already granted — and
+ * none of that reaches a consent already in flight at the vendor. Without this, that consent
+ * comes back and writes a fresh, live refresh token belonging to somebody who no longer has
+ * access, which nothing downstream will ever revoke because nothing knows it was created.
+ *
+ * The same anonymous failure as an unreadable state. Whether an address is deny-listed is not a
+ * fact this endpoint owes an unauthenticated caller.
+ */
+ if (!(await connect.personHasAccess(state.userId))) {
+ return context.redirect(failed);
+ }
+
const entry = catalogueEntry(state.serverId);
if (entry?.auth.kind !== "user-oauth") return context.redirect(failed);
@@ -435,7 +519,7 @@ export function createPluginRoutes(
connectedAccountsUrlFor(
connect.appUrl,
{ serverId: state.serverId },
- // From the signed state, so the destination is one this deployment chose, not the browser.
+ // From the sealed state, so the destination is one this deployment chose, not the browser.
state.returnTo,
),
);
diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts
index c54d775f..61a27974 100644
--- a/server/src/plugins/store.ts
+++ b/server/src/plugins/store.ts
@@ -6,9 +6,11 @@ import {
type PolicyContext,
} from "../computer/policy";
import {
+ type CredentialExecutor,
type CredentialSecretReader,
type CredentialStore,
decryptCredentialForUse,
+ decryptSecret,
encryptSecret,
} from "../credentials";
import type { Database } from "../db/client";
@@ -32,6 +34,7 @@ import {
resolveServerUrl,
} from "./catalogue";
import { McpServerError } from "./mcp";
+import { registerDynamicClient } from "./oauth";
import { transportFor } from "./transport";
/**
@@ -92,6 +95,12 @@ export type ServerRecord = {
toolsRefreshedAt: string | null;
lastError: string | null;
addedBy: string | null;
+ /**
+ * Whether the catalogue entry registers its own OAuth client (RFC 7591) rather than waiting on
+ * an administrator to paste one in. So the admin screen can hide the paste-a-client form where
+ * there is nothing for it to collect.
+ */
+ dynamicClient: boolean;
tools: ToolRecord[];
/**
* Grants on tools this server no longer advertises.
@@ -182,6 +191,43 @@ export class CustomServerRefusedError extends Error {
}
}
+/**
+ * The vendor's `error` code, when a token endpoint refuses an exchange.
+ *
+ * {@link INVALID_CLIENT} is the one code this module ACTS on rather than reports, so it has to
+ * survive as a value. It used to travel inside the sentence, which meant the recovery in
+ * {@link createPluginStore}'s `refuseAndReplaceEvictedClient` hung on a substring of prose written for a
+ * person to read: rewording the sentence — translating it, dropping the parenthesis — would have
+ * turned self-registration off with every test still green. A field cannot be reworded by accident.
+ */
+export class TokenRefusedError extends McpServerError {
+ constructor(
+ message: string,
+ readonly code: string | null,
+ ) {
+ super(message);
+ this.name = "TokenRefusedError";
+ }
+}
+
+/**
+ * The vendor saying the CLIENT is the problem, rather than the grant. RFC 6749 §5.2.
+ *
+ * Told apart from every other refusal because it is the only one a deployment can do anything about
+ * on its own: a client it issued to itself, it can issue again.
+ */
+export const INVALID_CLIENT = "invalid_client";
+
+/**
+ * A transaction, as the writes in this module hand one to each other.
+ *
+ * Named because two writes here are one decision — a secret in the vault, and the pointer that says
+ * what it is for — and the only way to say that is to run both on the same executor. `select`,
+ * `insert` and `update` alone would do for {@link CredentialExecutor}; this needs `execute` too, for
+ * the advisory lock that serialises the client path.
+ */
+type Transaction = Parameters[0]>[0];
+
/**
* A tool name the model can actually call.
*
@@ -199,6 +245,39 @@ export function refFromToolName(toolName: string): string | null {
return `${rest.slice(0, separator)}/${rest.slice(separator + 2)}`;
}
+/**
+ * Advertised tool names this deployment's write list does not name, where that list is the whole
+ * barrier.
+ *
+ * WHY THIS IS WORTH A ROW. {@link classifyTool} reads an advertised name absent from `writeTools` as
+ * a READ. So under-inclusion is the failure mode of that list, and it is silent: a write the vendor
+ * offers and the entry forgot is offered to a model as a read, and nothing anywhere says so. Notion's
+ * entry says reconciling its list against the live tool list "is required, not cosmetic" — this is
+ * the mechanical half of that, so the reconciliation is somebody reading a trail rather than somebody
+ * remembering.
+ *
+ * Only where the list stands alone. A vendor that expresses SCOPES has something behind the list: a
+ * tool missing from Drive's `writeTools` still cannot write, because `drive.readonly` refuses it at
+ * the vendor. Naming those would be noise in front of the one case that has no second barrier at all
+ * — Notion, whose access is per-page on a consent screen and whose `scopes` are therefore empty.
+ *
+ * A server with no catalogue entry is not reconciled either, and for the opposite reason: nothing
+ * reviewed says any tool of theirs only reads, so all of them are already writes.
+ *
+ * Sorted, so two readings of the same listing produce the same row.
+ */
+export function unlistedAdvertisedTools(
+ entry: CatalogueEntry | null,
+ advertised: readonly string[],
+): string[] {
+ if (!entry || entry.writeTools.length === 0) return [];
+ if (entry.auth.kind !== "user-oauth" || entry.auth.scopes.length > 0) {
+ return [];
+ }
+ const writes = new Set(entry.writeTools);
+ return advertised.filter((name) => !writes.has(name)).sort();
+}
+
const iso = (value: Date | string | null): string | null =>
value === null ? null : value instanceof Date ? value.toISOString() : value;
@@ -247,36 +326,83 @@ function effectiveUrl(
* host does not: this request carries the deployment's client secret and somebody's refresh token,
* so where it goes is a reviewed decision rather than a runtime one.
*
- * The vendor's error body is deliberately not passed through. It is written for whoever registered
- * the client, not for the person who asked a Bot a question, and it can name the client id.
+ * The vendor's error body is deliberately not passed through — it is written for whoever registered
+ * the client, not for the person who asked a Bot a question, and it can name the client id. Its
+ * `error` CODE is, though, and only that: `invalid_client` is what tells a client the vendor has
+ * forgotten apart from a grant somebody withdrew, and those two have entirely different answers.
+ * It goes out as a field on {@link TokenRefusedError} as well as in the sentence, because the field
+ * is the copy the recovery reads.
+ *
+ * Exported for its own tests rather than for a caller. Every path through the store reaches it as
+ * the default `exchangeRefreshToken`, and the store's own suites inject a stub in its place — which
+ * leaves what this function does with a REAL vendor reply, honest or garbled, untested unless it can
+ * be called directly.
*/
-async function exchangeRefreshTokenOverHttp(input: {
+export async function exchangeRefreshTokenOverHttp(input: {
tokenUrl: string;
client: OAuthClient;
refreshToken: string;
}): Promise {
+ const params = new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: input.refreshToken,
+ client_id: input.client.clientId,
+ });
+ // A public (DCR) client proves itself without one, and some vendors refuse an unexpected empty
+ // field outright. The same guard the authorization-code redemption in `oauth.ts` uses.
+ if (input.client.clientSecret) {
+ params.set("client_secret", input.client.clientSecret);
+ }
+
const response = await fetch(input.tokenUrl, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- grant_type: "refresh_token",
- refresh_token: input.refreshToken,
- client_id: input.client.clientId,
- client_secret: input.client.clientSecret,
- }),
+ body: params,
signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
});
if (!response.ok) {
- throw new McpServerError(
- `The vendor would not renew this access (${response.status}).`,
+ /*
+ * The code, when the refusal is JSON and carries one. Read defensively: a token endpoint that
+ * is refusing may be refusing with an HTML error page, and a parse failure here would replace
+ * the vendor's status — the one fact we do have — with a syntax error.
+ */
+ const refusal = (await response.json().catch(() => null)) as {
+ error?: unknown;
+ } | null;
+ /*
+ * Capped where it is read, because everything downstream of here shows it to somebody: the
+ * person who asked, the model, the connector's `lastError` on the admin page, and an audit
+ * payload. It is a short token in the protocol and vendor-controlled in fact, and nothing on
+ * those paths is a promise about length.
+ */
+ const code =
+ typeof refusal?.error === "string" ? refusal.error.slice(0, 64) : null;
+ throw new TokenRefusedError(
+ `The vendor would not renew this access (${response.status}).${code ? ` (${code})` : ""}`,
+ code,
);
}
- const body = (await response.json()) as {
+ /*
+ * A 200 is not a promise of JSON, and this branch used to read it as one.
+ *
+ * The refusal above already parses defensively; the success branch did not, so a CDN interstitial
+ * or a maintenance page answering 200 with HTML threw a SyntaxError from here — out through
+ * `callTool`, which records the failure with the thrower's message, and the parser's message
+ * quotes the body it choked on. So a vendor's HTML reached an audit payload and the person who
+ * asked, as a crash rather than as the refusal every other unusable reply produces.
+ */
+ const body = (await response.json().catch(() => null)) as {
access_token?: unknown;
expires_in?: unknown;
- };
+ refresh_token?: unknown;
+ } | null;
+ if (!body) {
+ throw new McpServerError(
+ "The vendor answered this renewal with something other than a token.",
+ );
+ }
if (typeof body.access_token !== "string" || !body.access_token) {
throw new McpServerError("The vendor renewed this access with no token.");
}
@@ -284,6 +410,17 @@ async function exchangeRefreshTokenOverHttp(input: {
accessToken: body.access_token,
expiresInSeconds:
typeof body.expires_in === "number" ? body.expires_in : undefined,
+ /*
+ * Only when the vendor sent one, and only a non-empty one.
+ *
+ * A rotating vendor replies with a new refresh token and invalidates the one it was shown; a
+ * vendor that does not rotate sends none. Reading an absent or empty field as a rotation would
+ * repoint a working connection at nothing.
+ */
+ refreshToken:
+ typeof body.refresh_token === "string" && body.refresh_token
+ ? body.refresh_token
+ : undefined,
};
}
@@ -301,8 +438,39 @@ const TOKEN_TIMEOUT_MS = 10_000;
*/
export type OAuthClient = { clientId: string; clientSecret: string };
+/**
+ * The client and when the vault row holding it was written.
+ *
+ * The date is not about the client: it is how long ago this deployment last introduced itself, which
+ * is the one thing that distinguishes a client the vendor has evicted from one a re-registration
+ * minted moments ago. Null only for a row that has since disappeared, which the read refuses first.
+ */
+type StoredClient = { client: OAuthClient; registeredAt: Date | null };
+
+/**
+ * How long a freshly stored OAuth client is left alone after `invalid_client`.
+ *
+ * Re-registering once per refusal is right for one call and wrong for a deployment: a vendor that is
+ * simply down answers every exchange `invalid_client`, and every tool call anywhere then mints a
+ * client of its own, because each of them is the first refusal IT has seen. A client younger than
+ * this was already the product of a re-registration, so registering again inside the window is
+ * amplification rather than recovery — the honest answer is the vendor's refusal, unedited.
+ */
+const CLIENT_REREGISTRATION_BACKOFF_MS = 5 * 60_000;
+
/** What a vendor's token endpoint gave back for a refresh token. */
-export type AccessToken = { accessToken: string; expiresInSeconds?: number };
+export type AccessToken = {
+ accessToken: string;
+ expiresInSeconds?: number;
+ /**
+ * The refresh token to present next time, from a vendor that rotates.
+ *
+ * Absent from Google's replies and present in every one of Notion's. When it is here it is the
+ * only one that still works — the token just spent is dead at the vendor — so it has to be
+ * persisted before the access token beside it is used for anything.
+ */
+ refreshToken?: string;
+};
export type PluginStoreOptions = {
database: Database;
@@ -342,6 +510,13 @@ export type PluginStoreOptions = {
client: OAuthClient;
refreshToken: string;
}) => Promise;
+ /** RFC 7591 self-registration, for entries whose clientRegistration is dynamic. */
+ registerClient?: (input: {
+ registrationUrl: string;
+ redirectUri: string;
+ }) => Promise;
+ /** Where the vendor sends people back; needed to (re)register a dynamic client. */
+ redirectUri?: string;
};
export function createPluginStore(options: PluginStoreOptions) {
@@ -354,6 +529,32 @@ export function createPluginStore(options: PluginStoreOptions) {
const injectedVendor = options.callVendor;
const exchangeRefreshToken =
options.exchangeRefreshToken ?? exchangeRefreshTokenOverHttp;
+ const registerClient = options.registerClient ?? registerDynamicClient;
+
+ /*
+ * One exchange at a time per (server, person). A rotating vendor invalidates the refresh
+ * token it was shown, so two concurrent calls that both present the old one would have the
+ * second refused through no fault of anybody's. The chain serialises them; the map entry is
+ * removed when the chain drains so the map cannot grow past the set of active connections.
+ */
+ const exchangeChains = new Map>();
+ function serialized(key: string, work: () => Promise): Promise {
+ const previous = exchangeChains.get(key) ?? Promise.resolve();
+ const next = previous.catch(() => {}).then(work);
+ exchangeChains.set(key, next);
+ /*
+ * The refusal belongs to the caller, who is holding `next` and will see it. This branch exists
+ * only to forget the key, so it swallows before it cleans up: `next.finally(…)` on its own
+ * derives a SECOND rejected promise that nobody is holding, and a refused call — a withdrawn
+ * credential, say — then surfaces as an unhandled rejection somewhere unrelated.
+ */
+ void next
+ .catch(() => {})
+ .finally(() => {
+ if (exchangeChains.get(key) === next) exchangeChains.delete(key);
+ });
+ return next;
+ }
async function grantsFor(kind: PluginKind, refs: string[]) {
if (refs.length === 0) return new Map();
@@ -514,6 +715,12 @@ export function createPluginStore(options: PluginStoreOptions) {
);
}
+ /*
+ * Whether this person has connected at all, which is a refusal worth reaching before anything
+ * queues behind another call. WHICH credential they hold is read again inside the critical
+ * section below, because a reconnection can move it while this call waits its turn — and even
+ * when the row stays put, the secret inside it does not.
+ */
const [held] = await database
.select({ credentialId: mcpUserCredentials.credentialId })
.from(mcpUserCredentials)
@@ -532,32 +739,638 @@ export function createPluginStore(options: PluginStoreOptions) {
);
}
- const refreshToken = await secretFor(
- held.credentialId,
- `Your ${entry.title} access was withdrawn. Connect it again in Settings.`,
- );
+ // Held before the critical section, because narrowing does not survive into a closure and this
+ // is where the entry is known to be a `user-oauth` one.
+ const { tokenUrl } = entry.auth;
+ const { title } = entry;
+ /*
+ * Where to register again, for a vendor that issues its own clients — and undefined for one an
+ * administrator registered with by hand, where there is nothing this deployment could do about a
+ * client the vendor no longer honours.
+ */
+ const registrationUrl =
+ entry.auth.clientRegistration === "dynamic"
+ ? entry.auth.registrationUrl
+ : undefined;
+
+ /*
+ * What to tell the person when the deployment holds no client they can be called on, in the
+ * words of whoever can actually change that.
+ *
+ * A hand-registered client is an administrator's paperwork — they pasted it in from the vendor's
+ * console, and only they can paste one in again. A self-registered one is nobody's paperwork:
+ * there is no console entry to re-create, and the deployment introduces itself again the next
+ * time somebody connects. Naming an administrator there would send the person to somebody with
+ * no step to take, which is worse than saying nothing.
+ */
+ const noClient = registrationUrl
+ ? `${title} has no OAuth client for this deployment, so this cannot be called. Connect ${title} again in Settings: the deployment registers itself with the vendor when somebody connects.`
+ : `${title} has no OAuth client registered for this deployment, so this cannot be called. An administrator has to add one.`;
+ const unusableClient = registrationUrl
+ ? `${title} has no usable OAuth client for this deployment. Connect ${title} again in Settings: the deployment registers itself with the vendor on the next connect.`
+ : `${title} has no usable OAuth client for this deployment. An administrator has to add one again.`;
+ /**
+ * What to say when the vendor has forgotten the client this person's grant was issued under.
+ *
+ * The same register as the two above, and the same instruction, because it is the same situation
+ * from the person's side: nothing they can be called on. What is different is that the
+ * deployment CAN do its half — introduce itself again — and has, by the time this is thrown. So
+ * the sentence says that too, otherwise "connect again" reads as a thing to keep trying.
+ *
+ * Their refresh token is not carried across. A grant belongs to the client it was issued to (RFC
+ * 6749 §6, §10.4), so re-presenting it under the new client is a request a conforming vendor
+ * refuses — and one that only ever appears to work against a vendor whose acceptance would
+ * itself be the vulnerability. A new consent is the only thing that produces a usable grant.
+ */
+ const clientReplaced = `${title} no longer recognises this deployment's OAuth client, so this cannot be called. The deployment has registered itself again — connect ${title} again in Settings.`;
if (!row.credentialId) {
- // The person did their part; the deployment has not. Said plainly, because the person cannot
- // fix it and should not be told to try.
- throw new PluginRefusedError(
- `${entry.title} has no OAuth client registered for this deployment, so this cannot be called. An administrator has to add one.`,
- null,
+ // The person did their part; the deployment has not. Refused before anything queues, because
+ // a deployment holding no client has the same answer for everybody asking.
+ throw new PluginRefusedError(noClient, null);
+ }
+
+ /**
+ * The client as the deployment holds it right now, or the refusal for holding none.
+ *
+ * Read from the server row each time rather than from the row this call came in with: a retry
+ * that registered again — this connection's own, a moment ago — replaced it, and the pointer
+ * carried in from before the queue names the evicted one.
+ */
+ async function currentClient(): Promise {
+ /*
+ * When the client was stored comes back with it, from the vault row itself rather than from a
+ * column of our own. It is what the retry below measures its backoff against, and a left join
+ * keeps it one query: a server pointing at nothing is the refusal on the next line, and a
+ * pointer to a row that is no longer there is `secretFor`'s to refuse.
+ */
+ const [server] = await database
+ .select({
+ credentialId: mcpServers.credentialId,
+ registeredAt: credentialRows.createdAt,
+ })
+ .from(mcpServers)
+ .leftJoin(
+ credentialRows,
+ eq(credentialRows.id, mcpServers.credentialId),
+ )
+ .where(eq(mcpServers.id, row.id))
+ .limit(1);
+ if (!server?.credentialId) {
+ throw new PluginRefusedError(noClient, null);
+ }
+ return {
+ client: JSON.parse(
+ await secretFor(server.credentialId, unusableClient),
+ ) as OAuthClient,
+ registeredAt: server.registeredAt,
+ };
+ }
+
+ /*
+ * The exchange, one call at a time for this connection, reading the connection fresh inside.
+ *
+ * Both halves of what goes out are read in here rather than carried in from above, because both
+ * can move while this call waits its turn. The refresh token rotates, and the token read a
+ * moment ago is then already spent — presenting it would be refused by the vendor for no reason
+ * the person could act on. The client is replaced by a re-registration, and presenting the
+ * evicted one would have every queued call discover that separately and register around it.
+ *
+ * TWO things serialise this, and they are not redundant. The map above queues calls made in THIS
+ * process; the row lock below serialises the whole deployment. Only the lock is a correctness
+ * property — a second replica has a map of its own and is not in ours — and the map is what
+ * keeps a burst of calls on one connection from piling N transactions onto that one row lock,
+ * each of them holding a pooled connection while it waits its turn.
+ *
+ * An evicted client is handled AFTER the transaction rather than inside it. Nothing about
+ * re-registering needs this person's lock — the client is per server — and doing it inside would
+ * have a second pooled connection opened while this one holds a row lock, which is the shape the
+ * pool note in `db/client.ts` is about.
+ */
+ return await serialized(`${row.id}:${actorId}`, async () => {
+ /*
+ * The client, read before the transaction opens rather than inside it.
+ *
+ * It is per SERVER and is not what the lock protects, and reading it here keeps the vault read
+ * off a second pooled connection while this call holds one open for the whole exchange. Still
+ * inside the critical section, so the ordering that matters is unchanged: a queued call reads
+ * the client after whatever ran before it replaced it.
+ */
+ const stored = await currentClient();
+
+ /*
+ * The vault row, locked for as long as the token it holds is being spent.
+ *
+ * A rotating vendor kills the refresh token it was shown, so two replicas that both read the
+ * stored token and both present it do not merely race: the second presentation looks to the
+ * vendor like a stolen token being replayed, and refresh-token-reuse detection answers it by
+ * revoking the whole token family. The connection is then bricked, and nobody did anything
+ * wrong. `SELECT … FOR UPDATE` is what makes the second replica wait for the first, exactly as
+ * a person reconnecting already waits (`credentials.rotate`).
+ *
+ * Yes, the lock is held across an HTTP call to the vendor — bounded by the exchange's own
+ * timeout. That is the point rather than an oversight: the lock IS the cross-replica
+ * serialisation, and a lock released before the exchange would serialise nothing.
+ *
+ * The read comes AFTER the lock, never before. A replica that woke from the lock and used a
+ * token it had read on the way in would present the one the first replica just spent, which is
+ * the very double-spend this exists to prevent.
+ */
+ try {
+ return await database.transaction(async (transaction) => {
+ const [current] = await transaction
+ .select({
+ credentialId: mcpUserCredentials.credentialId,
+ scope: mcpUserCredentials.scope,
+ })
+ .from(mcpUserCredentials)
+ .where(
+ and(
+ eq(mcpUserCredentials.serverId, row.id),
+ eq(mcpUserCredentials.userId, actorId),
+ ),
+ )
+ .limit(1);
+
+ // Disconnected while this call was queued. The same sentence as above: nothing is broken,
+ // and connecting again is the thing to do.
+ if (!current) {
+ throw new PluginRefusedError(
+ `You have not connected your ${title} account. Connect it in Settings and ask again.`,
+ null,
+ );
+ }
+
+ const [locked] = await transaction
+ .select({
+ encryptedValue: credentialRows.encryptedValue,
+ revokedAt: credentialRows.revokedAt,
+ })
+ .from(credentialRows)
+ .where(eq(credentialRows.id, current.credentialId))
+ .for("update");
+ /*
+ * A row that is gone or revoked, said the way `secretFor` says it: withdrawn access is
+ * nobody's fault and connecting again is the step. Reached by a replica that waited here
+ * while somebody disconnected, as well as by one that was told after the fact.
+ */
+ if (!locked || locked.revokedAt) {
+ throw new PluginRefusedError(
+ `Your ${title} access was withdrawn. Connect it again in Settings.`,
+ null,
+ );
+ }
+ const refreshToken = await decryptSecret(
+ encryptionKey,
+ locked.encryptedValue,
+ );
+
+ const minted = await exchangeRefreshToken({
+ tokenUrl,
+ client: stored.client,
+ refreshToken,
+ });
+
+ /*
+ * A vendor that sent nothing back, or sent back the token we presented, rotated nothing —
+ * and writing either would be inventing a rotation, at the cost of a needless
+ * re-encryption of every connection on every call.
+ */
+ if (minted.refreshToken && minted.refreshToken !== refreshToken) {
+ /*
+ * The vendor rotated the grant: the token we were just shown is now the only valid one.
+ * Persisting it is not optional bookkeeping — failing to would strand the connection on
+ * the next call — so a failure here refuses THIS call rather than returning an access
+ * token whose refresh token is already spent.
+ *
+ * In this transaction, so it commits with the lock that made the exchange ours: written
+ * outside it, the next replica in line would wake to the token this one just spent.
+ */
+ await rotateConnectionToken(
+ {
+ credentialId: current.credentialId,
+ refreshToken: minted.refreshToken,
+ },
+ transaction,
+ );
+ }
+
+ return { token: minted.accessToken };
+ });
+ } catch (error) {
+ /*
+ * Outside the transaction, so the row lock is already released and the vault read this does
+ * is not a second connection held behind this one's.
+ *
+ * Rethrows anything that is not the vendor disowning our client, which is every ordinary
+ * failure: a withdrawn grant, a vendor being down, a disconnect mid-queue.
+ */
+ return await refuseAndReplaceEvictedClient({
+ error,
+ clientRegisteredAt: stored.registeredAt,
+ registrationUrl,
+ serverId: row.id,
+ refusal: clientReplaced,
+ });
+ }
+ });
+ }
+
+ /**
+ * The vendor has disowned this deployment's client: fix the deployment, refuse the call.
+ *
+ * `invalid_client` is the vendor saying the CLIENT is the problem, and for a client the deployment
+ * issued to itself there is nobody to tell — no console entry an administrator could re-create, so
+ * every connection to that server would otherwise sit behind a refusal nothing here can act on.
+ * Introducing itself again is the same act as the first registration, and it is worth doing: it is
+ * what makes the next CONSENT possible.
+ *
+ * IT DOES NOT MAKE THIS CALL POSSIBLE, and this function used to pretend otherwise. It registered a
+ * new client and re-presented the same refresh token under it. A refresh token is bound to the
+ * client it was issued to — RFC 6749 §6 has the token endpoint verify exactly that, and §10.4 is
+ * why — so a conforming vendor refuses the retry, and the only vendor it can work against is one
+ * whose acceptance would itself be the vulnerability. So the grant is never carried across, and the
+ * person is told the one thing that helps: connect again.
+ *
+ * The re-registration is still bounded by {@link CLIENT_REREGISTRATION_BACKOFF_MS}, and that bound
+ * is the whole protection here rather than a nicety. This runs for any non-admin's tool call, and
+ * it REPLACES the client every other connection in the deployment is bound to; a vendor that is
+ * simply down answers every exchange `invalid_client`, so without the window one outage would have
+ * each call in turn rotate the deployment-wide client. A client younger than the window is the
+ * product of the last refusal's re-registration, and is left exactly alone.
+ *
+ * Always throws. The refusal it raises when it did register is the caller's answer; anything it
+ * cannot act on is rethrown untouched, because the vendor's own words are better than ours.
+ */
+ async function refuseAndReplaceEvictedClient(input: {
+ error: unknown;
+ /** When the client that was just refused was stored. */
+ clientRegisteredAt: Date | null;
+ registrationUrl: string | undefined;
+ serverId: string;
+ /** What to tell the person once the deployment has registered itself again. */
+ refusal: string;
+ }): Promise {
+ const { error, registrationUrl, serverId } = input;
+ /*
+ * The code, off the error itself. Never the sentence: that is written for a person, and a
+ * recovery that read it would be one rewording away from silently never running again.
+ */
+ const code = error instanceof TokenRefusedError ? error.code : null;
+ const { redirectUri } = options;
+ if (code !== INVALID_CLIENT || !registrationUrl || !redirectUri) {
+ throw error;
+ }
+
+ const registeredAt = input.clientRegisteredAt;
+ if (
+ registeredAt &&
+ Date.now() - registeredAt.getTime() < CLIENT_REREGISTRATION_BACKOFF_MS
+ ) {
+ throw error;
+ }
+
+ const fresh = await registerClient({ registrationUrl, redirectUri });
+ // The vendor would not have us either. The first refusal is the one worth reporting: it says
+ // what actually stopped the call, where this one says what stopped the recovery.
+ if (!fresh) throw error;
+
+ await persistOAuthClient({ serverId, client: fresh, by: "deployment" });
+ throw new PluginRefusedError(input.refusal, null);
+ }
+
+ /**
+ * Point one person's connection at a new refresh token, revoking the one it replaces.
+ *
+ * For a person connecting, which is where a new row earns its keep: what they held before this is
+ * still a live grant at the vendor, and the revocation is how it stops being one. A vendor's own
+ * rotation is the other case entirely, and goes through {@link rotateConnectionToken}.
+ *
+ * Upserted on the pair, so it is the same act whether they are connecting or reconnecting. The
+ * credential the row used to point at is revoked in the same breath: a refresh token nothing
+ * points at is still a live grant at the vendor, and leaving it behind would mean somebody had two
+ * valid grants and could only ever see one of them to withdraw it.
+ *
+ * Says whether it replaced something, which is the one fact the caller writing the trail needs
+ * and cannot recover afterwards.
+ *
+ * ONE TRANSACTION, because these are two writes and one decision. The secret goes into the vault
+ * and the connection row is pointed at it; separately, a failure between them leaves the pointer
+ * naming the credential the rotation had just revoked — a connection that reads as live on the
+ * settings page and refuses every call, with the person's actual grant retired and no way back to
+ * it. `credentials.rotate` and `credentials.create` already accept the caller's executor for
+ * exactly this, and the pointer write runs on the same one.
+ */
+ async function swapUserCredential(input: {
+ serverId: string;
+ userId: string;
+ refreshToken: string;
+ scope: string;
+ }): Promise<{ replaced: boolean }> {
+ const key = {
+ kind: "mcp_user_token" as const,
+ provider: input.serverId,
+ keyId: input.userId,
+ };
+ const value = {
+ ...key,
+ metadata: { server: input.serverId, scope: input.scope },
+ // Encrypted before the transaction opens: it is arithmetic, and it has no business happening
+ // while a pooled connection is held open behind row locks.
+ encryptedValue: await encryptSecret(encryptionKey, input.refreshToken),
+ };
+
+ return await database.transaction(async (transaction) => {
+ /*
+ * `credentials_active_key_idx` holds one live credential per key, so a second insert for the
+ * same person and server would be refused. Asked of the key rather than of the connection row,
+ * because the row can name a credential that has already been revoked while the key itself is
+ * free, and it is the key the index constrains.
+ */
+ const live = await credentials.findLiveByKey(key, transaction);
+ const stored = live
+ ? await credentials.rotate(
+ { ...value, previousCredentialId: live.id },
+ transaction,
+ )
+ : await credentials.create(value, transaction);
+
+ await transaction
+ .insert(mcpUserCredentials)
+ .values({
+ serverId: input.serverId,
+ userId: input.userId,
+ credentialId: stored.id,
+ scope: input.scope,
+ })
+ .onConflictDoUpdate({
+ target: [mcpUserCredentials.serverId, mcpUserCredentials.userId],
+ set: {
+ credentialId: stored.id,
+ scope: input.scope,
+ updatedAt: new Date(),
+ },
+ });
+
+ return { replaced: live !== null };
+ });
+ }
+
+ /**
+ * Carry a connection over to the refresh token the vendor rotated to.
+ *
+ * In place, in the vault row the connection already points at — deliberately NOT the swap
+ * {@link swapUserCredential} performs. A rotating vendor issues a new refresh token on every
+ * exchange, so a swap here would mint a row and revoke a row per tool call, forever, on the
+ * hottest path there is. And the revocation would have nothing to withdraw: the token just spent
+ * was dead at the vendor the moment it answered, so the only live grant is the one being written.
+ *
+ * Deliberately WITHOUT the `mcp.account_connected` row, for the same reason as ever: rotation is
+ * the vendor's plumbing, not a person's act, and a trail that records it as one reads as a
+ * re-consent that nobody performed.
+ *
+ * The scope and the row are left alone. Nothing about what the vendor granted has changed — only
+ * which token presents it.
+ */
+ async function rotateConnectionToken(
+ input: {
+ credentialId: string;
+ refreshToken: string;
+ },
+ /**
+ * The transaction the caller spent the token in, and this write belongs to it.
+ *
+ * The caller holds a `FOR UPDATE` lock on the very row being written. On its own pooled
+ * connection this write would be a second session waiting for a lock only the caller can
+ * release, and the caller cannot release it while awaiting this — so it would hang to the
+ * statement timeout rather than rotate.
+ */
+ executor?: CredentialExecutor,
+ ): Promise {
+ await credentials.updateSecret(
+ input.credentialId,
+ await encryptSecret(encryptionKey, input.refreshToken),
+ executor,
+ );
+ }
+
+ /** The one vault key a server's OAuth client is ever stored under. */
+ const oauthClientKey = (serverId: string) => ({
+ kind: "mcp_oauth_client" as const,
+ provider: serverId,
+ keyId: `oauth-client-${serverId}`,
+ });
+
+ /**
+ * One writer at a time for one server's OAuth client, across the whole deployment.
+ *
+ * Everything that stores a client reads "is there a live one" and then writes accordingly, and the
+ * gap between those two used to be unserialised. `POST /connect` is `requireUser`, so two people
+ * pressing Connect on a fresh connector is not a rare interleaving: both read no live client, and
+ * then the second `create` meets the first on `credentials_active_key_idx` as a raw 23505 — a 500
+ * where a consent URL belonged — or, when there was a client to replace, the second `rotate` finds
+ * its own predecessor already revoked and says so.
+ *
+ * An ADVISORY lock rather than a row lock, because the thing being protected is the ABSENCE of a
+ * row as much as a row: there is nothing to lock `FOR UPDATE` on a first registration. Held for
+ * the transaction, so it is released by the commit or the rollback and never by us forgetting.
+ *
+ * `hashtext` collisions are harmless here. Two servers sharing a hash would take turns registering
+ * clients, which is slower and not wrong.
+ */
+ async function withOAuthClientLock(
+ serverId: string,
+ work: (transaction: Transaction) => Promise,
+ ): Promise {
+ return await database.transaction(async (transaction) => {
+ await transaction.execute(
+ sql`select pg_advisory_xact_lock(hashtext(${`oauth-client-${serverId}`}))`,
);
+ return await work(transaction);
+ });
+ }
+
+ /**
+ * {@link storedOAuthClient}'s question, asked on the caller's own transaction.
+ *
+ * The same question deliberately — the server row's pointer, and the row it names being live —
+ * because the callback redeems against `oauthClientFor`, which reads exactly that. A read that
+ * accepted a live client the server row does NOT name would hand somebody a consent screen for a
+ * client the callback then cannot find, and the connect would fail after the vendor said yes.
+ *
+ * On the transaction rather than through `storedOAuthClient` because the caller is inside
+ * {@link withOAuthClientLock} and holding a pooled connection: a read on a second connection would
+ * be a session queueing behind sessions that cannot finish until it returns.
+ */
+ async function heldOAuthClient(
+ transaction: Transaction,
+ serverId: string,
+ ): Promise {
+ const [server] = await transaction
+ .select({ credentialId: mcpServers.credentialId })
+ .from(mcpServers)
+ .where(eq(mcpServers.id, serverId))
+ .limit(1);
+ if (!server?.credentialId) return null;
+
+ const [held] = await transaction
+ .select({
+ encryptedValue: credentialRows.encryptedValue,
+ revokedAt: credentialRows.revokedAt,
+ })
+ .from(credentialRows)
+ .where(eq(credentialRows.id, server.credentialId))
+ .limit(1);
+ if (!held || held.revokedAt) return null;
+
+ try {
+ return JSON.parse(
+ await decryptSecret(encryptionKey, held.encryptedValue),
+ ) as OAuthClient;
+ } catch {
+ // Unreadable is the same as none: there is nothing to send anybody to consent with.
+ return null;
}
- const client = JSON.parse(
- await secretFor(
- row.credentialId,
- `${entry.title} has no usable OAuth client for this deployment. An administrator has to add one again.`,
+ }
+
+ /**
+ * The two writes that store a client, on one transaction: the vault row, and the pointer to it.
+ *
+ * One transaction because they are one decision. Separately, a failure between them leaves
+ * `mcp_servers.credential_id` naming the credential the rotation had just revoked — a connector
+ * that looks configured on every screen and cannot complete a consent flow.
+ *
+ * The caller is expected to hold {@link withOAuthClientLock}, which is what makes the read below
+ * safe to act on.
+ */
+ async function writeOAuthClient(
+ input: { serverId: string; client: OAuthClient },
+ transaction: Transaction,
+ ): Promise<{ replaced: boolean }> {
+ const key = oauthClientKey(input.serverId);
+ const value = {
+ ...key,
+ metadata: { server: input.serverId, clientId: input.client.clientId },
+ encryptedValue: await encryptSecret(
+ encryptionKey,
+ JSON.stringify(input.client),
),
- ) as OAuthClient;
+ };
- const minted = await exchangeRefreshToken({
- tokenUrl: entry.auth.tokenUrl,
- client,
- refreshToken,
+ const live = await credentials.findLiveByKey(key, transaction);
+ const stored = live
+ ? await credentials.rotate(
+ { ...value, previousCredentialId: live.id },
+ transaction,
+ )
+ : await credentials.create(value, transaction);
+
+ await transaction
+ .update(mcpServers)
+ .set({ credentialId: stored.id, updatedAt: new Date() })
+ .where(eq(mcpServers.id, input.serverId));
+
+ return { replaced: live !== null };
+ }
+
+ /**
+ * The trail row for a client this deployment now holds.
+ *
+ * Written AFTER the transaction that stored it, not inside. The audit store has its own handle on
+ * the database, so writing from inside would open a second pooled connection while the first is
+ * held — the shape the pool note in `db/client.ts` warns about, and the one that turns a busy
+ * deployment into a hang. A trail row lost to a crash in that window is a worse trade than a
+ * deadlock, but only just, and this way round the client is at least the thing that is certain.
+ */
+ async function recordClientRegistered(
+ input: { serverId: string; client: OAuthClient; by: string },
+ replaced: boolean,
+ ): Promise {
+ await recordAuditEvent(auditStore, {
+ eventType: "mcp.oauth_client_registered",
+ targetType: "mcp_server",
+ targetId: input.serverId,
+ payload: {
+ actor: input.by,
+ server: input.serverId,
+ // The id, never the secret. It identifies the client that was registered, which is what
+ // somebody reading the trail needs in order to check it against the vendor's console.
+ clientId: input.client.clientId,
+ replaced,
+ },
});
- return { token: minted.accessToken };
+ }
+
+ /**
+ * Store the deployment's OAuth client for a `user-oauth` server, whoever obtained it.
+ *
+ * Both halves go into one encrypted value, so a single vault read yields a usable client. The id is
+ * copied into `metadata` as well — it is not a secret, and a page listing what the deployment holds
+ * should be able to name it without decrypting anything.
+ *
+ * Replacing a client revokes the previous one rather than orphaning it, so "what does this
+ * deployment hold" keeps having one answer per server. Nobody's connection breaks in the sense that
+ * matters here — a refresh token is the person's — but nobody's connection SURVIVES either: a grant
+ * belongs to the client it was issued to, so replacing the client is asking everybody to connect
+ * again. That is why the two callers that replace one both say so to whoever is listening.
+ *
+ * Shared by an administrator pasting one in and by the deployment registering its own, so `by` is
+ * the only difference between the two in the trail — which is the honest one.
+ */
+ async function persistOAuthClient(input: {
+ serverId: string;
+ client: OAuthClient;
+ by: string;
+ }): Promise {
+ const { entry } = await requireServer(input.serverId);
+ if (entry?.auth.kind !== "user-oauth") {
+ throw new CustomServerRefusedError(
+ `${input.serverId} is not reached with an OAuth client.`,
+ );
+ }
+
+ const { replaced } = await withOAuthClientLock(
+ input.serverId,
+ (transaction) =>
+ writeOAuthClient(
+ { serverId: input.serverId, client: input.client },
+ transaction,
+ ),
+ );
+
+ await recordClientRegistered(input, replaced);
+ }
+
+ /**
+ * The deployment's OAuth client for a server as it stands, or null if there is none to read.
+ *
+ * Decrypted, because both halves are needed: the id to build a consent URL and the secret to
+ * redeem the code it comes back with. Held for the length of one request, like every other secret
+ * this module reads.
+ */
+ async function storedOAuthClient(
+ serverId: string,
+ ): Promise {
+ const [row] = await database
+ .select({ credentialId: mcpServers.credentialId })
+ .from(mcpServers)
+ .where(eq(mcpServers.id, serverId))
+ .limit(1);
+ if (!row?.credentialId) return null;
+
+ try {
+ return JSON.parse(
+ await decryptCredentialForUse(
+ encryptionKey,
+ credentials,
+ row.credentialId,
+ ),
+ ) as OAuthClient;
+ } catch {
+ // A revoked, missing or unreadable client is the same as none for every caller: there is
+ // nothing to send anybody to consent with, and the answer is to obtain one again.
+ return null;
+ }
}
async function requireServer(serverId: string) {
@@ -763,19 +1576,26 @@ export function createPluginStore(options: PluginStoreOptions) {
},
/**
- * Remove a server, and stop its token being live.
+ * Remove a server, and stop every secret it was reached with being live.
*
- * The token is keyed `mcp-` and nothing else revokes it, so
- * leaving it behind means re-adding the same server meets its own
- * abandoned row on `credentials_active_key_idx`. It is revoked rather
- * than deleted, because the vault keeps revoked rows for audit.
+ * TWO KINDS OF SECRET, and both have to go. The server's own credential is whatever
+ * `mcp_servers.credential_id` names — a `mcp` bearer token an administrator added, or, for a
+ * `user-oauth` vendor, the deployment's OAuth client, keyed `oauth-client-`. Nothing
+ * else revokes it, so leaving it behind means re-adding the same server meets its own abandoned
+ * row on `credentials_active_key_idx`.
*
- * The revoke goes first. These are two writes on two tables and the
- * store exposes no transaction that spans both, so the order decides
- * what a failure between them leaves: revoke-then-delete leaves a server
- * whose token no longer works and which removing again will finish off,
- * while delete-then-revoke leaves a live token no server references and
- * no operation can reach.
+ * The other kind is every PERSON'S grant for this server, keyed `mcp_user_token` on the server id.
+ * `mcp_user_credentials` cascades on the server row, so removing the connector used to delete
+ * every pointer and leave every refresh token live and unreferenced: reachable from no screen,
+ * revoked by no operation, and still a usable grant at the vendor. "We removed the connector" has
+ * to be true of the thing that matters, which is the token sitting at the vendor.
+ *
+ * Revoked rather than deleted, because the vault keeps revoked rows for audit.
+ *
+ * The revokes go first. These are writes on two tables and the store exposes no transaction that
+ * spans both, so the order decides what a failure between them leaves: revoke-then-delete leaves
+ * a server whose secrets no longer work and which removing again will finish off, while
+ * delete-then-revoke leaves live secrets no server references and no operation can reach.
*/
async removeServer(serverId: string, by: string): Promise {
const [existing] = await database
@@ -819,6 +1639,50 @@ export function createPluginStore(options: PluginStoreOptions) {
});
}
+ /*
+ * Every person's grant for this server, read out of the VAULT rather than through the join
+ * table.
+ *
+ * `credentials.provider` holds the server id for an `mcp_user_token`, so the vault can be asked
+ * directly — which matters because the join row is the thing about to be cascaded away, and a
+ * grant whose pointer has already gone (a person removed earlier) would otherwise be invisible
+ * here too. The same argument `retireConnectionsFor` makes from the other direction.
+ */
+ const held = await database
+ .select({ id: credentialRows.id, keyId: credentialRows.keyId })
+ .from(credentialRows)
+ .where(
+ and(
+ eq(credentialRows.kind, "mcp_user_token"),
+ eq(credentialRows.provider, serverId),
+ isNull(credentialRows.revokedAt),
+ ),
+ )
+ .orderBy(asc(credentialRows.keyId));
+
+ for (const grant of held) {
+ await credentials.revoke(grant.id);
+ await recordAuditEvent(auditStore, {
+ eventType: "mcp.account_disconnected",
+ targetType: "mcp_server",
+ targetId: serverId,
+ payload: {
+ actor: by,
+ server: serverId,
+ // Whose it was. `key_id` holds the user id for this kind, and it is the only place left
+ // to read it from once the join row has been cascaded away.
+ owner: grant.keyId,
+ /*
+ * Not "they disconnected" and not "they were removed": an administrator took the whole
+ * connector away, and the person did nothing. An auditor asking what happened to their
+ * access should see which of the three this was.
+ */
+ reason: "mcp_server_removed",
+ vendorRevoked: false,
+ },
+ });
+ }
+
await database.delete(mcpServers).where(eq(mcpServers.id, serverId));
await recordAuditEvent(auditStore, {
eventType: "configuration.changed",
@@ -938,6 +1802,34 @@ export function createPluginStore(options: PluginStoreOptions) {
});
}
+ /*
+ * Tools the vendor advertises that this deployment's write list does not name.
+ *
+ * The mechanical half of the reconciliation Notion's catalogue entry says is required. See
+ * {@link unlistedAdvertisedTools} for why only that shape of vendor is named here: an
+ * advertised tool absent from `writeTools` classifies as a READ, so an under-inclusive list
+ * is silent, and for a vendor with no scope strings there is nothing else standing behind it.
+ *
+ * `configuration.changed` rather than a type of its own, the same as the stranded grants
+ * above and for the same reason: nothing was denied and the refresh succeeded. What changed
+ * is that the deployment now knows a name it had not classified.
+ */
+ const unlisted = unlistedAdvertisedTools(entry, [...advertised]);
+ if (unlisted.length > 0) {
+ await recordAuditEvent(auditStore, {
+ eventType: "configuration.changed",
+ targetType: "mcp_server",
+ targetId: serverId,
+ payload: {
+ actor: actorId,
+ change: "unlisted_tools_advertised",
+ server: serverId,
+ tools: unlisted,
+ note: "Advertised by this server and not named in its reviewed write list, so each is offered to models as a read. This vendor has no read-only scope behind that list, so anything here that writes should be added to the entry.",
+ },
+ });
+ }
+
return { tools: tools.length };
} catch (error) {
const message =
@@ -950,7 +1842,17 @@ export function createPluginStore(options: PluginStoreOptions) {
// unreachable is not a reason to revoke what Bots are using.
await database
.update(mcpServers)
- .set({ lastError: message, updatedAt: new Date() })
+ .set({
+ /*
+ * Capped at the same 400 characters `callTool` caps its recorded failure at.
+ *
+ * Parts of this sentence come from a vendor, and it is drawn on the admin page — neither
+ * is a promise about length, and the two paths that show a vendor's words to an operator
+ * should not disagree about how much of them to keep.
+ */
+ lastError: message.slice(0, 400),
+ updatedAt: new Date(),
+ })
.where(eq(mcpServers.id, serverId));
return { tools: 0 };
}
@@ -998,6 +1900,9 @@ export function createPluginStore(options: PluginStoreOptions) {
toolsRefreshedAt: iso(row.toolsRefreshedAt),
lastError: row.lastError,
addedBy: row.addedBy,
+ dynamicClient:
+ entry?.auth.kind === "user-oauth" &&
+ entry.auth.clientRegistration === "dynamic",
tools: tools
.filter((tool) => tool.serverId === row.id)
.map((tool) => {
@@ -1339,81 +2244,99 @@ export function createPluginStore(options: PluginStoreOptions) {
/**
* Register the deployment's OAuth client for a `user-oauth` server.
*
- * Both halves go into one encrypted value, so a single vault read yields a usable client. The id
- * is copied into `metadata` as well — it is not a secret, and a page listing what the deployment
- * holds should be able to name it without decrypting anything.
+ * An administrator pasting in what they created at the vendor. The work itself is
+ * {@link persistOAuthClient}, which self-registration goes through too — one path, so a client
+ * this deployment issued itself is stored, revoked and recorded exactly like a pasted one.
+ */
+ registerOAuthClient: persistOAuthClient,
+
+ /**
+ * The client to send somebody to the vendor with, registering one first if that is this
+ * vendor's way of getting one.
+ *
+ * A dynamically registered client is not paperwork anybody did: there is no console entry to
+ * paste, so "none yet" is the ordinary state of a server nobody has connected — and the answer
+ * is for the deployment to introduce itself, which is what it would have to do eventually
+ * anyway. Where an administrator registers by hand instead, and where the deployment has no
+ * public URL to be sent back to, the answer stays null: inventing a client at a vendor that
+ * never offered to issue one, or registering a redirect URI that resolves to nothing, would
+ * both leave behind a client that can never complete a consent flow.
+ *
+ * ONE CLIENT PER DEPLOYMENT EVEN WHEN TWO PEOPLE ASK AT ONCE. This is `requireUser`'s handler, so
+ * two first connects racing is the ordinary first hour of a connector. Registering twice is not
+ * merely wasteful: the loser's consent screen names a client the vault no longer holds, so that
+ * person consents and their callback then redeems the code against the client that replaced it —
+ * a connect that fails after the vendor already said yes. So the registration happens under
+ * {@link withOAuthClientLock}, with the "do we hold one" question asked AGAIN inside it, and the
+ * second caller finds the first one's client and is handed the same one.
*
- * Replacing a client revokes the previous one rather than orphaning it, so "what does this
- * deployment hold" keeps having one answer per server. Nobody's connection breaks: a refresh
- * token is the person's, and it is the client that is being rotated underneath it.
+ * The lock is held across the registration request to the vendor, deliberately. It is one round
+ * trip with its own timeout, and a lock released before it would serialise nothing.
*/
- async registerOAuthClient(input: {
- serverId: string;
- client: OAuthClient;
- by: string;
- }): Promise {
- const { row, entry } = await requireServer(input.serverId);
- if (entry?.auth.kind !== "user-oauth") {
- throw new CustomServerRefusedError(
- `${input.serverId} is not reached with an OAuth client.`,
- );
+ async ensureOAuthClient(
+ serverId: string,
+ by: string,
+ ): Promise {
+ const stored = await storedOAuthClient(serverId);
+ if (stored) return stored;
+
+ const { entry } = await requireServer(serverId);
+ if (
+ entry?.auth.kind !== "user-oauth" ||
+ entry.auth.clientRegistration !== "dynamic" ||
+ !entry.auth.registrationUrl ||
+ !options.redirectUri
+ ) {
+ return null;
}
+ // Held before the lock, because narrowing does not survive into the closure below.
+ const { registrationUrl } = entry.auth;
+ const { redirectUri } = options;
- const key = {
- kind: "mcp_oauth_client" as const,
- provider: input.serverId,
- keyId: `oauth-client-${input.serverId}`,
- };
- const value = {
- ...key,
- metadata: { server: input.serverId, clientId: input.client.clientId },
- encryptedValue: await encryptSecret(
- encryptionKey,
- JSON.stringify(input.client),
- ),
- };
-
- /*
- * Re-registering a client replaces the one before it, in one transaction.
- *
- * A key holds at most one live credential, so inserting a second for this server would be
- * refused by `credentials_active_key_idx` rather than leaving the orphan it used to leave.
- * The question is asked of the key and not of `row.credentialId`, because the server row keeps
- * naming a credential an administrator has revoked from the Credentials page: the pointer can
- * be stale where the key is not, and it is the key the index constrains.
- */
- const live = await credentials.findLiveByKey(key);
- const stored = live
- ? await credentials.rotate({ ...value, previousCredentialId: live.id })
- : await credentials.create(value);
+ const outcome = await withOAuthClientLock(
+ serverId,
+ async (transaction) => {
+ /*
+ * Asked again, under the lock. The read above was a fast path taken without one, and by now
+ * the caller we were racing has committed a client of its own — which is the one this
+ * deployment holds, so it is the one to consent against.
+ */
+ const held = await heldOAuthClient(transaction, serverId);
+ if (held) return { client: held, registered: false, replaced: false };
- await database
- .update(mcpServers)
- .set({ credentialId: stored.id, updatedAt: new Date() })
- .where(eq(mcpServers.id, input.serverId));
+ const registered = await registerClient({
+ registrationUrl,
+ redirectUri,
+ });
+ if (!registered) return null;
- await recordAuditEvent(auditStore, {
- eventType: "mcp.oauth_client_registered",
- targetType: "mcp_server",
- targetId: input.serverId,
- payload: {
- actor: input.by,
- server: input.serverId,
- // The id, never the secret. It identifies the client an administrator registered, which is
- // what somebody reading the trail needs in order to check it against the vendor's console.
- clientId: input.client.clientId,
- replaced: row.credentialId !== null,
+ const { replaced } = await writeOAuthClient(
+ { serverId, client: registered },
+ transaction,
+ );
+ return { client: registered, registered: true, replaced };
},
- });
+ );
+
+ if (!outcome) return null;
+ // Only what this call actually did. A trail row for handing back somebody else's client would
+ // claim a registration that never happened.
+ if (outcome.registered) {
+ await recordClientRegistered(
+ { serverId, client: outcome.client, by },
+ outcome.replaced,
+ );
+ }
+ return outcome.client;
},
/**
* Record that one person connected their own account to one server.
*
- * Upserted on the pair, so reconnecting replaces rather than accumulating. The credential the row
- * used to point at is revoked in the same breath: a refresh token nothing points at is still a
- * live grant at the vendor, and leaving it behind would mean a person who reconnected had two
- * valid grants and could only ever see one of them to disconnect it.
+ * The credential swap is {@link swapUserCredential}, and this is its only caller: a person
+ * connecting or reconnecting is exactly when there is an older grant to revoke. Rotation writes
+ * the same connection in place instead ({@link rotateConnectionToken}). What is only here is the
+ * audit row: this one IS somebody's act, and the trail should say so.
*/
async recordConnection(input: {
serverId: string;
@@ -1421,56 +2344,7 @@ export function createPluginStore(options: PluginStoreOptions) {
refreshToken: string;
scope: string;
}): Promise {
- const [previous] = await database
- .select({ credentialId: mcpUserCredentials.credentialId })
- .from(mcpUserCredentials)
- .where(
- and(
- eq(mcpUserCredentials.serverId, input.serverId),
- eq(mcpUserCredentials.userId, input.userId),
- ),
- )
- .limit(1);
-
- const key = {
- kind: "mcp_user_token" as const,
- provider: input.serverId,
- keyId: input.userId,
- };
- /*
- * Reconnecting replaces this person's token for this server, in one transaction.
- *
- * `credentials_active_key_idx` holds one live credential per key, so a second insert for the
- * same person and server would be refused. Asked of the key rather than of `previous`, because
- * the connection row can name a credential that has already been revoked while the key itself
- * is free, and it is the key the index constrains.
- */
- const live = await credentials.findLiveByKey(key);
- const value = {
- ...key,
- metadata: { server: input.serverId, scope: input.scope },
- encryptedValue: await encryptSecret(encryptionKey, input.refreshToken),
- };
- const stored = live
- ? await credentials.rotate({ ...value, previousCredentialId: live.id })
- : await credentials.create(value);
-
- await database
- .insert(mcpUserCredentials)
- .values({
- serverId: input.serverId,
- userId: input.userId,
- credentialId: stored.id,
- scope: input.scope,
- })
- .onConflictDoUpdate({
- target: [mcpUserCredentials.serverId, mcpUserCredentials.userId],
- set: {
- credentialId: stored.id,
- scope: input.scope,
- updatedAt: new Date(),
- },
- });
+ const { replaced } = await swapUserCredential(input);
await recordAuditEvent(auditStore, {
eventType: "mcp.account_connected",
@@ -1481,7 +2355,7 @@ export function createPluginStore(options: PluginStoreOptions) {
server: input.serverId,
// What the vendor granted, so a later refusal for want of a scope can be explained.
scope: input.scope,
- reconnected: previous !== undefined,
+ reconnected: replaced,
},
});
},
@@ -1489,32 +2363,9 @@ export function createPluginStore(options: PluginStoreOptions) {
/**
* The deployment's OAuth client for a server, or null if none is registered.
*
- * Decrypted, because both halves are needed: the id to build a consent URL and the secret to
- * redeem the code it comes back with. Held for the length of one request, like every other
- * secret this module reads.
+ * Reads only. {@link ensureOAuthClient} is the one that will go and get one.
*/
- async oauthClientFor(serverId: string): Promise {
- const [row] = await database
- .select({ credentialId: mcpServers.credentialId })
- .from(mcpServers)
- .where(eq(mcpServers.id, serverId))
- .limit(1);
- if (!row?.credentialId) return null;
-
- try {
- return JSON.parse(
- await decryptCredentialForUse(
- encryptionKey,
- credentials,
- row.credentialId,
- ),
- ) as OAuthClient;
- } catch {
- // A revoked, missing or unreadable client is the same as none for every caller: there is
- // nothing to send anybody to consent with, and the answer is for an administrator to add one.
- return null;
- }
- },
+ oauthClientFor: storedOAuthClient,
/** Which `user-oauth` servers this person has connected, for their own settings page. */
async connectionsFor(
@@ -1841,6 +2692,9 @@ export function createPluginStore(options: PluginStoreOptions) {
* that the failure now exists in the trail, which is where somebody asking "is this connector
* working" looks. The vendor's own sentence is kept, since for a 403 that is the sentence
* naming which API is not enabled.
+ *
+ * Capped like the `isError` branch above, and for the same reason: parts of this sentence
+ * came from the vendor, and a failure is not a promise about length.
*/
await recordAuditEvent(auditStore, {
eventType: "mcp.call_failed",
@@ -1848,7 +2702,10 @@ export function createPluginStore(options: PluginStoreOptions) {
targetId: input.ref,
payload: {
...decided,
- failure: error instanceof Error ? error.message : String(error),
+ failure: (error instanceof Error
+ ? error.message
+ : String(error)
+ ).slice(0, 400),
},
});
throw error;
diff --git a/server/tests/channel-activity.integration.test.ts b/server/tests/channel-activity.integration.test.ts
index 06698aca..5b0b155c 100644
--- a/server/tests/channel-activity.integration.test.ts
+++ b/server/tests/channel-activity.integration.test.ts
@@ -227,6 +227,7 @@ describe("channel activity", () => {
lastMessageAgentId: agentId,
lastMessageAt: at,
createdAt: expect.any(Date),
+ pinned: false,
},
]);
});
@@ -345,3 +346,99 @@ describe("channel activity", () => {
).toEqual([busy.id, quiet.id]);
});
});
+
+/**
+ * A pin holds a channel at the top of the roster, which is a claim about the roster and not about
+ * whichever page happens to be loaded.
+ *
+ * Ordering pinned-first only in the browser sorts the rows already fetched, so a channel somebody
+ * pinned and then did not talk to for a month sits on page three and never appears at the top at
+ * all — the roster the person sees contradicts the pin they made. The order therefore belongs in the
+ * query, and the cursor has to carry the pin flag as its leading element or paging walks the same
+ * channel twice.
+ */
+describe("a pinned channel in a paged roster", () => {
+ /** Channels with explicit, minute-apart activity, newest last, so recency order is not a clock race. */
+ async function channelsWithActivity(owner: AgentActor, count: number) {
+ const agentId = await createAgent(owner);
+ const ids: string[] = [];
+ for (let index = 0; index < count; index += 1) {
+ ids.push((await createChannel(owner, [agentId])).id);
+ }
+ const base = Date.now() - count * 60_000;
+ for (const [index, id] of ids.entries()) {
+ await store.recordActivity(owner, id, {
+ agentId,
+ at: new Date(base + index * 60_000),
+ text: `Message ${index}`,
+ });
+ }
+ return ids;
+ }
+
+ /** Every channel the cursor reaches, in the order the pages hand them over. */
+ async function walk(owner: AgentActor, limit: number) {
+ const seen: string[] = [];
+ let cursor: string | undefined;
+ for (let page = 0; page < 20; page += 1) {
+ const result = await store.list(owner, {
+ limit,
+ ...(cursor ? { cursor } : {}),
+ });
+ seen.push(...result.channels.map((channel) => channel.id));
+ if (!result.nextCursor) break;
+ cursor = result.nextCursor;
+ }
+ return seen;
+ }
+
+ test("lifts a pinned channel onto the first page, however old it is", async () => {
+ const owner = await createUser();
+ const ids = await channelsWithActivity(owner, 6);
+ const oldest = ids[0] as string;
+
+ await store.setPinned(owner, oldest, true);
+
+ // Two per page, six channels: on recency alone this one is the last row of the last page.
+ const page = await store.list(owner, { limit: 2 });
+ expect(page.channels.map((channel) => channel.id)[0]).toBe(oldest);
+ });
+
+ test("walks every channel exactly once across pinned and unpinned", async () => {
+ const owner = await createUser();
+ const ids = await channelsWithActivity(owner, 6);
+ // Two pins, chosen so the group boundary does not line up with a page boundary.
+ await store.setPinned(owner, ids[0] as string, true);
+ await store.setPinned(owner, ids[3] as string, true);
+
+ const seen = await walk(owner, 2);
+
+ // Pinned first, and recency within each group: the cursor has to order pages the same way the
+ // first page is ordered, or a channel is served twice and another never at all.
+ expect(seen).toEqual([
+ ids[3] as string,
+ ids[0] as string,
+ ids[5] as string,
+ ids[4] as string,
+ ids[2] as string,
+ ids[1] as string,
+ ]);
+ expect(new Set(seen).size).toBe(seen.length);
+ });
+
+ test("unpinning puts the channel back where recency alone would have it", async () => {
+ const owner = await createUser();
+ const ids = await channelsWithActivity(owner, 4);
+ const oldest = ids[0] as string;
+
+ await store.setPinned(owner, oldest, true);
+ await store.setPinned(owner, oldest, false);
+
+ expect(await walk(owner, 2)).toEqual([
+ ids[3] as string,
+ ids[2] as string,
+ ids[1] as string,
+ oldest,
+ ]);
+ });
+});
diff --git a/server/tests/channel-events.integration.test.ts b/server/tests/channel-events.integration.test.ts
index 98d93258..697217a9 100644
--- a/server/tests/channel-events.integration.test.ts
+++ b/server/tests/channel-events.integration.test.ts
@@ -5,17 +5,24 @@ import { createAgentProfileStore } from "../src/agents/profile-store";
import type { AgentActor } from "../src/agents/profile-types";
import {
type ChannelActivityEvent,
+ type ChannelEventHub,
createChannelEventHub,
startChannelActivityListener,
} from "../src/channels/events";
-import { createChannelStore } from "../src/channels/routes";
+import {
+ ChannelNotFoundError,
+ ChannelPackageOwnedError,
+ createChannelStore,
+} from "../src/channels/routes";
import { createThreadIdentity } from "../src/channels/thread-identity";
import { createDatabase } from "../src/db/client";
import { TEST_POOL } from "./support/database";
import {
agentProfiles,
agents,
+ channelMemberships,
channels,
+ deploymentPackages,
intelligenceChannelMappings,
users,
} from "../src/db/schema";
@@ -103,6 +110,7 @@ const testPrefix = `channel-events-${randomUUID()}`;
const createdUserIds: string[] = [];
const createdAgentIds: string[] = [];
const createdChannelIds: string[] = [];
+const createdPackageIds: string[] = [];
afterEach(async () => {
for (const channelId of createdChannelIds.splice(0)) {
@@ -111,6 +119,11 @@ afterEach(async () => {
.where(eq(intelligenceChannelMappings.channelId, channelId));
await database.delete(channels).where(eq(channels.id, channelId));
}
+ for (const packageId of createdPackageIds.splice(0)) {
+ await database
+ .delete(deploymentPackages)
+ .where(eq(deploymentPackages.id, packageId));
+ }
for (const agentId of createdAgentIds.splice(0)) {
await database
.delete(agentProfiles)
@@ -187,3 +200,264 @@ describe("channel activity delivery", () => {
});
});
});
+
+async function createTestUser(name: string): Promise {
+ const id = `${testPrefix}-user-${randomUUID()}`;
+ await database.insert(users).values({
+ id,
+ email: `${id}@example.test`,
+ name,
+ });
+ createdUserIds.push(id);
+ return { id, role: "user" };
+}
+
+/** A channel with two members, which is what makes "who hears this" a question worth asking. */
+async function createSharedChannel(owner: AgentActor, other: AgentActor) {
+ const profile = await profileStore.create(owner, {
+ name: "Expense Manager",
+ title: "Finance Operations",
+ roleDescription: "Review receipts.",
+ visibility: "public",
+ });
+ createdAgentIds.push(profile.id);
+ const channel = await store.create(owner, [profile.id]);
+ createdChannelIds.push(channel.id);
+ // `create` writes the creator's membership only; the second member is added directly, with the
+ // thread mapping the roster join requires.
+ await database.insert(channelMemberships).values({
+ channelId: channel.id,
+ userId: other.id,
+ });
+ await database.insert(intelligenceChannelMappings).values({
+ userId: other.id,
+ channelId: channel.id,
+ // thread_id is globally unique, so the second member's mapping needs one of its own.
+ threadId: randomUUID(),
+ });
+ return channel;
+}
+
+/** Collect what each person's connection hears, and a promise that settles when one of them does. */
+function watch(hub: ChannelEventHub, userIds: string[]) {
+ const heard = new Map();
+ let announce = () => {};
+ const anything = new Promise((resolve) => {
+ announce = resolve;
+ });
+ for (const userId of userIds) {
+ heard.set(userId, []);
+ hub.register(userId, (payload) => {
+ heard.get(userId)?.push(JSON.parse(payload));
+ announce();
+ });
+ }
+ const of = (userId: string) => heard.get(userId) ?? [];
+ return { of, anything };
+}
+
+function within5s(arrived: Promise) {
+ return Promise.race([
+ arrived,
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("no event within 5s")), 5000),
+ ),
+ ]);
+}
+
+/**
+ * Two changes a roster has to hear about that are not a message: a channel that is gone, and a pin.
+ *
+ * They differ in who is owed the news. A deletion hides the channel for everybody in it, so every
+ * member's tabs need telling; a pin belongs to one member's own membership row, so telling anybody
+ * else would show them a pin they did not make.
+ */
+describe("channel change delivery", () => {
+ test("announces a deleted channel to every member, exactly once", async () => {
+ const owner = await createTestUser("Deleting Member");
+ const other = await createTestUser("Other Member");
+ const channel = await createSharedChannel(owner, other);
+
+ const hub = createChannelEventHub();
+ const watched = watch(hub, [owner.id, other.id]);
+ const listener = await startChannelActivityListener(databaseUrl, hub);
+
+ try {
+ await store.softDelete(owner, channel.id);
+ await within5s(watched.anything);
+ } finally {
+ await listener.stop();
+ }
+
+ // One announcement, heard by both members: a soft delete hides the row for everyone in it.
+ expect(watched.of(owner.id)).toHaveLength(1);
+ expect(watched.of(other.id)).toHaveLength(1);
+ expect(watched.of(owner.id)[0]).toMatchObject({
+ channelId: channel.id,
+ deleted: true,
+ });
+ expect(watched.of(owner.id)[0]?.memberIds?.sort()).toEqual(
+ [owner.id, other.id].sort(),
+ );
+ });
+
+ test("announces nothing for a delete the deployment package refuses", async () => {
+ const owner = await createTestUser("Refused Member");
+ const [deploymentPackage] = await database
+ .insert(deploymentPackages)
+ .values({
+ tenantId: `${testPrefix}-tenant-${randomUUID()}`,
+ sourcePath: "/tmp/none",
+ checksum: "0",
+ })
+ .returning({ id: deploymentPackages.id });
+ if (!deploymentPackage) throw new Error("package row was not created");
+ createdPackageIds.push(deploymentPackage.id);
+ const channelId = `${testPrefix}-package-channel-${randomUUID()}`;
+ await database.insert(channels).values({
+ id: channelId,
+ name: "Package channel",
+ description: "Defined by the tenant package.",
+ packageId: deploymentPackage.id,
+ });
+ createdChannelIds.push(channelId);
+ await database
+ .insert(channelMemberships)
+ .values({ channelId, userId: owner.id });
+
+ const hub = createChannelEventHub();
+ const watched = watch(hub, [owner.id]);
+ const listener = await startChannelActivityListener(databaseUrl, hub);
+
+ try {
+ await expect(store.softDelete(owner, channelId)).rejects.toBeInstanceOf(
+ ChannelPackageOwnedError,
+ );
+ // The refusal rolls the transaction back, so there is nothing to wait for. A window long
+ // enough for a notify that did happen to arrive is what makes the empty assertion mean
+ // something.
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ } finally {
+ await listener.stop();
+ }
+
+ // The channel is still there for everybody, so telling a roster it is gone would be a lie.
+ expect(watched.of(owner.id)).toEqual([]);
+ });
+
+ test("tells the pinning member's own tabs and nobody else's", async () => {
+ const owner = await createTestUser("Pinning Member");
+ const other = await createTestUser("Other Member");
+ const channel = await createSharedChannel(owner, other);
+
+ const hub = createChannelEventHub();
+ const watched = watch(hub, [owner.id, other.id]);
+ const listener = await startChannelActivityListener(databaseUrl, hub);
+
+ try {
+ await store.setPinned(owner, channel.id, true);
+ await within5s(watched.anything);
+ } finally {
+ await listener.stop();
+ }
+
+ expect(watched.of(owner.id)).toHaveLength(1);
+ expect(watched.of(owner.id)[0]).toMatchObject({
+ channelId: channel.id,
+ pinned: true,
+ memberIds: [owner.id],
+ });
+ /*
+ * The half worth having a test for. A pin lives on one membership row, and the hub delivers by
+ * `memberIds`, so naming anybody else here would put a pin on their roster that they did not
+ * make. Both members are watching the same hub through the same notify, so an event that
+ * included the other member would already be in this array.
+ */
+ expect(watched.of(other.id)).toEqual([]);
+ });
+
+ /*
+ * A write refused because the channel is deleted announces nothing either.
+ *
+ * The listener is attached after the delete, so the delete's own announcement is not what these
+ * observe: what is being asserted is that a later report about a hidden channel is silent. A notify
+ * here would send every member's browser off to refetch a roster for a row it cannot show.
+ */
+ test("announces nothing for activity reported on a deleted channel", async () => {
+ const owner = await createTestUser("Deleted Channel Member");
+ const other = await createTestUser("Other Member");
+ const channel = await createSharedChannel(owner, other);
+ await store.softDelete(owner, channel.id);
+
+ const hub = createChannelEventHub();
+ const watched = watch(hub, [owner.id, other.id]);
+ const listener = await startChannelActivityListener(databaseUrl, hub);
+
+ try {
+ await expect(
+ store.recordActivity(owner, channel.id, {
+ agentId: null,
+ at: new Date(),
+ text: "Said into a channel that is gone.",
+ }),
+ ).rejects.toBeInstanceOf(ChannelNotFoundError);
+ // The refusal rolls back, so there is nothing to wait for; the window is what makes an empty
+ // assertion mean something.
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ } finally {
+ await listener.stop();
+ }
+
+ expect(watched.of(owner.id)).toEqual([]);
+ expect(watched.of(other.id)).toEqual([]);
+ });
+
+ test("announces nothing for a pin on a deleted channel", async () => {
+ const owner = await createTestUser("Pinning Member");
+ const channel = await createSharedChannel(
+ owner,
+ await createTestUser("Other Member"),
+ );
+ await store.softDelete(owner, channel.id);
+
+ const hub = createChannelEventHub();
+ const watched = watch(hub, [owner.id]);
+ const listener = await startChannelActivityListener(databaseUrl, hub);
+
+ try {
+ await expect(
+ store.setPinned(owner, channel.id, true),
+ ).rejects.toBeInstanceOf(ChannelNotFoundError);
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ } finally {
+ await listener.stop();
+ }
+
+ expect(watched.of(owner.id)).toEqual([]);
+ });
+
+ test("announces an unpin the same way", async () => {
+ const owner = await createTestUser("Unpinning Member");
+ const other = await createTestUser("Other Member");
+ const channel = await createSharedChannel(owner, other);
+ await store.setPinned(owner, channel.id, true);
+
+ const hub = createChannelEventHub();
+ const watched = watch(hub, [owner.id, other.id]);
+ const listener = await startChannelActivityListener(databaseUrl, hub);
+
+ try {
+ await store.setPinned(owner, channel.id, false);
+ await within5s(watched.anything);
+ } finally {
+ await listener.stop();
+ }
+
+ expect(watched.of(owner.id)).toHaveLength(1);
+ expect(watched.of(owner.id)[0]).toMatchObject({
+ channelId: channel.id,
+ pinned: false,
+ });
+ expect(watched.of(other.id)).toEqual([]);
+ });
+});
diff --git a/server/tests/channel-routes.test.ts b/server/tests/channel-routes.test.ts
index 17906cc7..a43275b0 100644
--- a/server/tests/channel-routes.test.ts
+++ b/server/tests/channel-routes.test.ts
@@ -7,7 +7,7 @@ import {
test,
} from "bun:test";
import { randomUUID } from "node:crypto";
-import { eq } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import type { MiddlewareHandler } from "hono";
import { Hono } from "hono";
import {
@@ -22,6 +22,7 @@ import type { AppVariables } from "../src/auth/guards";
import {
type AgentChannel,
ChannelNotFoundError,
+ ChannelPackageOwnedError,
type ChannelStore,
createChannelRoutes,
createChannelStore,
@@ -30,16 +31,17 @@ import {
import { createThreadIdentity } from "../src/channels/thread-identity";
import { loadConfig } from "../src/config";
import { createDatabase } from "../src/db/client";
+import { TEST_POOL } from "./support/database";
import {
agentProfiles,
agents,
channelAgents,
channelMemberships,
channels,
+ deploymentPackages,
intelligenceChannelMappings,
users,
} from "../src/db/schema";
-import { TEST_POOL } from "./support/database";
import { testEnvironment } from "./support/environment";
const actor = {
@@ -74,9 +76,11 @@ function fakeStore(
calls.push(["get", receivedActor, id]);
return channel({ id });
},
- async remove(receivedActor, id) {
- calls.push(["remove", receivedActor, id]);
- return "thread-1";
+ async setPinned(receivedActor, id, pinned) {
+ calls.push(["setPinned", receivedActor, id, pinned]);
+ },
+ async softDelete(receivedActor, id) {
+ calls.push(["softDelete", receivedActor, id]);
},
};
@@ -303,155 +307,181 @@ describe("channel routes", () => {
expect(response.status).toBe(599);
expect(await json(response)).toEqual({ sentinel: "database disconnected" });
});
-});
-describe("channel delete route", () => {
- /** Rows written by the route under test, in order. */
- let audited: AuditEventInput[] = [];
+ test("pins through the authenticated actor and reports the new state", async () => {
+ const store = fakeStore();
+ const app = appFor(store);
- beforeEach(() => {
- audited = [];
+ const response = await app.request("http://openbot.test/channel-1/pin", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ pinned: true }),
+ });
+
+ expect(response.status).toBe(200);
+ expect(await json(response)).toEqual({ pinned: true });
+ expect(store.calls).toEqual([["setPinned", actor, "channel-1", true]]);
});
- function appWithForget(
- store: ChannelStore,
- forgetThread?: (params: {
- threadId: string;
- userId: string;
- agentId: string;
- }) => Promise,
- auditStore: AuditStore = {
- insert: async (event) => void audited.push(event),
- },
- ) {
- const app = new Hono<{ Variables: AppVariables }>();
- app.route(
- "/",
- createChannelRoutes(
- store,
- requireUser,
- undefined,
- auditStore,
- forgetThread,
- ),
+ test.each([
+ ["{", "Pin input must be a JSON object."],
+ [JSON.stringify([]), "Pin input must be a JSON object."],
+ [JSON.stringify({}), "Pinned must be true or false."],
+ [JSON.stringify({ pinned: "yes" }), "Pinned must be true or false."],
+ ])("rejects malformed pin bodies: %p", async (body, error) => {
+ const store = fakeStore();
+ const response = await appFor(store).request(
+ "http://openbot.test/channel-1/pin",
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body,
+ },
);
- return app;
- }
- test("returns 200 and calls store.remove with the actor and channel id", async () => {
+ expect(response.status).toBe(400);
+ expect(await json(response)).toEqual({ error });
+ expect(store.calls).toEqual([]);
+ });
+
+ test("keeps authentication in front of pinning", async () => {
+ const store = fakeStore();
+ const denied: MiddlewareHandler<{ Variables: AppVariables }> = (context) =>
+ Promise.resolve(context.json({ error: "denied" }, 401));
+ const app = appFor(store, denied);
+
+ const response = await app.request("http://openbot.test/channel-1/pin", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ pinned: true }),
+ });
+
+ expect(response.status).toBe(401);
+ expect(store.calls).toEqual([]);
+ });
+
+ test("deletes through the authenticated actor and answers 204", async () => {
const store = fakeStore();
- const response = await appWithForget(store, async () => {}).request(
+ const response = await appFor(store).request(
"http://openbot.test/channel-1",
{ method: "DELETE" },
);
- expect(response.status).toBe(200);
- expect(await json(response)).toEqual({ historyLeftBehind: false });
- expect(store.calls).toEqual([["remove", actor, "channel-1"]]);
+ expect(response.status).toBe(204);
+ expect(store.calls).toEqual([["softDelete", actor, "channel-1"]]);
});
- test("maps ChannelNotFoundError to 404", async () => {
+ test("maps a package-owned refusal to 409", async () => {
const store = fakeStore({
- remove: async () => {
- throw new ChannelNotFoundError("channel-1");
+ softDelete: async () => {
+ throw new ChannelPackageOwnedError("channel-1");
},
});
- const response = await appWithForget(store).request(
+ const response = await appFor(store).request(
"http://openbot.test/channel-1",
{ method: "DELETE" },
);
- expect(response.status).toBe(404);
- expect(await json(response)).toEqual({ error: "Channel not found." });
+ expect(response.status).toBe(409);
+ expect(await json(response)).toEqual({
+ error:
+ "This channel is defined by the deployment package, so it cannot be deleted here.",
+ });
});
- test("calls forgetThread with the derived channel-scoped agent id", async () => {
+ test("keeps authentication in front of deleting", async () => {
const store = fakeStore();
- const calls: unknown[] = [];
- const response = await appWithForget(store, async (params) => {
- calls.push(params);
- }).request("http://openbot.test/channel-1", { method: "DELETE" });
-
- expect(response.status).toBe(200);
- expect(calls).toEqual([
- { threadId: "thread-1", userId: actor.id, agentId: "channel:channel-1" },
- ]);
- });
+ const denied: MiddlewareHandler<{ Variables: AppVariables }> = (context) =>
+ Promise.resolve(context.json({ error: "denied" }, 401));
+ const app = appFor(store, denied);
- /*
- * The critical ordering-decision regression test: a rejected upstream delete must not become a
- * failure response. The local removal already committed by the time `forgetThread` runs, so this
- * MUST fail if a later change propagates that rejection as an error status instead of swallowing
- * it and still answering 204.
- */
- test("still succeeds when forgetThread rejects, and says the history survived", async () => {
- const store = fakeStore();
- const response = await appWithForget(store, async () => {
- throw new Error("Intelligence is unreachable");
- }).request("http://openbot.test/channel-1", { method: "DELETE" });
+ const response = await app.request("http://openbot.test/channel-1", {
+ method: "DELETE",
+ });
- expect(response.status).toBe(200);
- // The half that failed is the half a person deleting a conversation is asking about, so it has
- // to reach them rather than being swallowed into an indistinguishable success.
- expect(await json(response)).toEqual({ historyLeftBehind: true });
+ expect(response.status).toBe(401);
+ expect(store.calls).toEqual([]);
});
+});
- test("does not call forgetThread when remove found no thread to forget", async () => {
- const store = fakeStore({
- remove: async (receivedActor, id) => {
- store.calls.push(["remove", receivedActor, id]);
- return null;
- },
- });
- let called = false;
- const response = await appWithForget(store, async () => {
- called = true;
- }).request("http://openbot.test/channel-1", { method: "DELETE" });
+/**
+ * The channel row survives a soft delete, but nothing on it says who hid it or when.
+ *
+ * "Where did that conversation go" is the question this answers, and the row is the only thing that
+ * can: `deleted_at` is a timestamp with no actor. Untested, it is also the easiest thing to drop in
+ * a later refactor without anything going red — which is exactly how it was lost once already.
+ */
+describe("channel delete audit", () => {
+ /** Rows written by the route under test, in order. */
+ let audited: AuditEventInput[] = [];
- expect(response.status).toBe(200);
- // Nothing to forget is not something left behind.
- expect(await json(response)).toEqual({ historyLeftBehind: false });
- expect(called).toBe(false);
+ beforeEach(() => {
+ audited = [];
});
- /*
- * The channel row is gone by the time this runs, so this row is the only thing left that says the
- * conversation ever existed or who ended it. Untested, it is also the easiest thing to drop in a
- * later refactor without anything going red.
- */
- test("writes an attributed audit row naming the thread it forgot", async () => {
- const store = fakeStore();
- await appWithForget(store, async () => {}).request(
+ function appWithAudit(
+ store: ChannelStore,
+ auditStore: AuditStore = {
+ insert: async (event) => void audited.push(event),
+ },
+ ) {
+ const app = new Hono<{ Variables: AppVariables }>();
+ app.route(
+ "/",
+ createChannelRoutes(store, requireUser, undefined, auditStore),
+ );
+ return app;
+ }
+
+ test("writes an attributed row naming the mechanism", async () => {
+ const response = await appWithAudit(fakeStore()).request(
"http://openbot.test/channel-1",
{ method: "DELETE" },
);
+ expect(response.status).toBe(204);
expect(audited).toEqual([
{
eventType: "channel.deleted",
targetType: "channel",
targetId: "channel-1",
actorUserId: actor.id,
- payload: { threadId: "thread-1", threadForgotten: true },
+ payload: { mechanism: "soft" },
},
]);
});
- test("records a thread that outlived the channel", async () => {
- const store = fakeStore();
- await appWithForget(store, async () => {
- throw new Error("Intelligence is unreachable");
- }).request("http://openbot.test/channel-1", { method: "DELETE" });
+ /* Same discipline as bot-lifecycle-audit.test.ts: the trail records acts, not attempts. */
+ test("a refused change writes nothing", async () => {
+ const store = fakeStore({
+ softDelete: async () => {
+ throw new ChannelPackageOwnedError("channel-1");
+ },
+ });
- expect(audited).toEqual([
- {
- eventType: "channel.deleted",
- targetType: "channel",
- targetId: "channel-1",
- actorUserId: actor.id,
- payload: { threadId: "thread-1", threadForgotten: false },
+ const response = await appWithAudit(store).request(
+ "http://openbot.test/channel-1",
+ { method: "DELETE" },
+ );
+
+ expect(response.status).toBe(409);
+ expect(audited).toEqual([]);
+ });
+
+ test("a delete of somebody else's channel writes nothing", async () => {
+ const store = fakeStore({
+ softDelete: async () => {
+ throw new ChannelNotFoundError("channel-1");
},
- ]);
+ });
+
+ const response = await appWithAudit(store).request(
+ "http://openbot.test/channel-1",
+ { method: "DELETE" },
+ );
+
+ expect(response.status).toBe(404);
+ expect(audited).toEqual([]);
});
/*
@@ -462,19 +492,17 @@ describe("channel delete route", () => {
* "by whom", which is the half worth keeping.
*/
test("attributes the local development actor rather than dropping it", async () => {
- const store = fakeStore();
const app = new Hono<{ Variables: AppVariables }>();
app.route(
"/",
createChannelRoutes(
- store,
+ fakeStore(),
async (context, next) => {
context.set("actor", DEV_ACTOR);
await next();
},
undefined,
{ insert: async (event) => void audited.push(event) },
- async () => {},
),
);
@@ -484,18 +512,30 @@ describe("channel delete route", () => {
});
/*
- * The channel is already gone and the caller has already been told so. A trail that is briefly
- * unavailable is not a reason to report a failure that did not happen.
+ * The channel is already hidden and the caller has already been told so by the time this runs. A
+ * trail that is briefly unavailable is not a reason to report a failure that did not happen.
*/
test("still answers when the audit write throws", async () => {
- const store = fakeStore();
- const response = await appWithForget(store, async () => {}, {
+ const response = await appWithAudit(fakeStore(), {
insert: async () => {
throw new Error("audit table is unreachable");
},
}).request("http://openbot.test/channel-1", { method: "DELETE" });
- expect(response.status).toBe(200);
+ expect(response.status).toBe(204);
+ });
+
+ test("deletes without a trail when the deployment keeps none", async () => {
+ const store = fakeStore();
+ const app = new Hono<{ Variables: AppVariables }>();
+ app.route("/", createChannelRoutes(store, requireUser));
+
+ const response = await app.request("http://openbot.test/channel-1", {
+ method: "DELETE",
+ });
+
+ expect(response.status).toBe(204);
+ expect(store.calls).toEqual([["softDelete", actor, "channel-1"]]);
});
});
@@ -585,6 +625,7 @@ const testPrefix = `channel-store-${randomUUID()}`;
const createdUserIds: string[] = [];
const createdAgentIds: string[] = [];
const createdChannelIds: string[] = [];
+const createdPackageIds: string[] = [];
afterEach(async () => {
for (const channelId of createdChannelIds.splice(0)) {
@@ -593,6 +634,11 @@ afterEach(async () => {
.where(eq(intelligenceChannelMappings.channelId, channelId));
await database.delete(channels).where(eq(channels.id, channelId));
}
+ for (const packageId of createdPackageIds.splice(0)) {
+ await database
+ .delete(deploymentPackages)
+ .where(eq(deploymentPackages.id, packageId));
+ }
for (const agentId of createdAgentIds.splice(0)) {
await database
.delete(agentProfiles)
@@ -946,44 +992,314 @@ describe("channel store integration", () => {
expect(await channelTableSnapshot()).toEqual(before);
},
);
+});
- test("deletes the channel row and cascades memberships, agents, and the thread mapping", async () => {
+describe("channel pinning", () => {
+ test("stamps and clears pinned_at on the caller's own membership", async () => {
const actor = await createPersistentUser();
const agentId = await createPersistentAgent({
- name: "Removable agent",
+ name: "Pinnable agent",
owner: actor,
});
const created = await persistentStore.create(actor, [agentId]);
- // Not pushed to createdChannelIds: `remove` is the thing under test, and afterEach's cleanup
- // deleting an already-deleted row is a no-op either way.
+ createdChannelIds.push(created.id);
- const threadId = await persistentStore.remove(actor, created.id);
+ await persistentStore.setPinned(actor, created.id, true);
+ let [row] = await database
+ .select({ pinnedAt: channelMemberships.pinnedAt })
+ .from(channelMemberships)
+ .where(
+ and(
+ eq(channelMemberships.channelId, created.id),
+ eq(channelMemberships.userId, actor.id),
+ ),
+ );
+ expect(row?.pinnedAt).not.toBeNull();
+
+ await persistentStore.setPinned(actor, created.id, false);
+ [row] = await database
+ .select({ pinnedAt: channelMemberships.pinnedAt })
+ .from(channelMemberships)
+ .where(
+ and(
+ eq(channelMemberships.channelId, created.id),
+ eq(channelMemberships.userId, actor.id),
+ ),
+ );
+ expect(row?.pinnedAt).toBeNull();
+ });
- expect(threadId).toBe(created.threadId);
- const persisted = await persistedChannel(created.id);
- expect(persisted.channelRow).toBeUndefined();
- expect(persisted.memberships).toEqual([]);
- expect(persisted.linkedAgents).toEqual([]);
- expect(persisted.mappings).toEqual([]);
+ test("refuses to pin a channel the caller is not a member of", async () => {
+ const member = await createPersistentUser();
+ const outsider = await createPersistentUser();
+ const agentId = await createPersistentAgent({
+ name: "Members-only agent",
+ owner: member,
+ });
+ const created = await persistentStore.create(member, [agentId]);
+ createdChannelIds.push(created.id);
+
+ await expect(
+ persistentStore.setPinned(outsider, created.id, true),
+ ).rejects.toBeInstanceOf(ChannelNotFoundError);
});
- test("refuses a non-member's remove and leaves the channel row untouched", async () => {
- const owner = await createPersistentUser();
+ test("pins and unpins through the caller's own membership", async () => {
+ const actor = await createPersistentUser();
+ const agentId = await createPersistentAgent({
+ name: "Pinnable agent",
+ owner: actor,
+ });
+ const created = await persistentStore.create(actor, [agentId]);
+ createdChannelIds.push(created.id);
+
+ await persistentStore.setPinned(actor, created.id, true);
+ let page = await persistentStore.list(actor);
+ expect(
+ page.channels.find((channel) => channel.id === created.id)?.pinned,
+ ).toBe(true);
+
+ await persistentStore.setPinned(actor, created.id, false);
+ page = await persistentStore.list(actor);
+ expect(
+ page.channels.find((channel) => channel.id === created.id)?.pinned,
+ ).toBe(false);
+ });
+
+ test("one member's pin is invisible to another member", async () => {
+ const pinner = await createPersistentUser();
+ const other = await createPersistentUser();
+ const agentId = await createPersistentAgent({
+ name: "Shared pinnable agent",
+ owner: pinner,
+ visibility: "public",
+ });
+ const created = await persistentStore.create(pinner, [agentId]);
+ createdChannelIds.push(created.id);
+ // The store only creates the creator's membership; give the other user one directly,
+ // plus the thread mapping the list join requires.
+ await database.insert(channelMemberships).values({
+ channelId: created.id,
+ userId: other.id,
+ });
+ await database.insert(intelligenceChannelMappings).values({
+ userId: other.id,
+ channelId: created.id,
+ // thread_id is globally unique; the pinner's own mapping row already claimed
+ // created.threadId, so the other member's row needs one of its own.
+ threadId: randomUUID(),
+ });
+
+ await persistentStore.setPinned(pinner, created.id, true);
+
+ const otherPage = await persistentStore.list(other);
+ expect(
+ otherPage.channels.find((channel) => channel.id === created.id)?.pinned,
+ ).toBe(false);
+ });
+
+ test("reports pinned false for a channel nobody pinned", async () => {
+ const actor = await createPersistentUser();
+ const agentId = await createPersistentAgent({
+ name: "Unpinned agent",
+ owner: actor,
+ });
+ const created = await persistentStore.create(actor, [agentId]);
+ createdChannelIds.push(created.id);
+
+ expect(
+ (await persistentStore.list(actor)).channels.find(
+ (channel) => channel.id === created.id,
+ )?.pinned,
+ ).toBe(false);
+ });
+});
+
+describe("channel soft delete", () => {
+ test("hides a deleted channel from list and get", async () => {
+ const actor = await createPersistentUser();
+ const other = await createPersistentUser();
+ const agentId = await createPersistentAgent({
+ name: "Deletable agent",
+ owner: actor,
+ visibility: "public",
+ });
+ const created = await persistentStore.create(actor, [agentId]);
+ createdChannelIds.push(created.id);
+ // The store only creates the creator's membership; give the other user one directly,
+ // plus the thread mapping the list join requires.
+ await database.insert(channelMemberships).values({
+ channelId: created.id,
+ userId: other.id,
+ });
+ await database.insert(intelligenceChannelMappings).values({
+ userId: other.id,
+ channelId: created.id,
+ // thread_id is globally unique; the actor's own mapping row already claimed
+ // created.threadId, so the other member's row needs one of its own.
+ threadId: randomUUID(),
+ });
+
+ await persistentStore.softDelete(actor, created.id);
+
+ expect(await persistentStore.get(actor, created.id)).toBeNull();
+ const page = await persistentStore.list(actor);
+ expect(
+ page.channels.find((channel) => channel.id === created.id),
+ ).toBeUndefined();
+
+ expect(await persistentStore.get(other, created.id)).toBeNull();
+ const otherPage = await persistentStore.list(other);
+ expect(
+ otherPage.channels.find((channel) => channel.id === created.id),
+ ).toBeUndefined();
+ });
+
+ test("stamps deleted_at on the channel", async () => {
+ const actor = await createPersistentUser();
+ const agentId = await createPersistentAgent({
+ name: "Deletable agent",
+ owner: actor,
+ });
+ const created = await persistentStore.create(actor, [agentId]);
+ createdChannelIds.push(created.id);
+
+ await persistentStore.softDelete(actor, created.id);
+
+ // Soft: the row is still there, stamped rather than gone.
+ const [row] = await database
+ .select({ deletedAt: channels.deletedAt })
+ .from(channels)
+ .where(eq(channels.id, created.id));
+ expect(row?.deletedAt).not.toBeNull();
+ });
+
+ test("deleting again is a no-op, not an error", async () => {
+ const actor = await createPersistentUser();
+ const agentId = await createPersistentAgent({
+ name: "Twice-deleted agent",
+ owner: actor,
+ });
+ const created = await persistentStore.create(actor, [agentId]);
+ createdChannelIds.push(created.id);
+
+ await persistentStore.softDelete(actor, created.id);
+ await expect(
+ persistentStore.softDelete(actor, created.id),
+ ).resolves.toBeUndefined();
+ });
+
+ test("refuses to delete a channel the caller is not a member of", async () => {
+ const member = await createPersistentUser();
const outsider = await createPersistentUser();
const agentId = await createPersistentAgent({
name: "Guarded agent",
- owner,
+ owner: member,
});
- const created = await persistentStore.create(owner, [agentId]);
+ const created = await persistentStore.create(member, [agentId]);
createdChannelIds.push(created.id);
await expect(
- persistentStore.remove(outsider, created.id),
+ persistentStore.softDelete(outsider, created.id),
).rejects.toBeInstanceOf(ChannelNotFoundError);
+ });
- expect((await persistedChannel(created.id)).channelRow).toMatchObject({
- id: created.id,
+ /*
+ * A deleted channel is gone as far as every other path is concerned.
+ *
+ * `get` and `list` filter on `deleted_at`, so a member who still has a stale roster row, or a
+ * client that reports the reply to a message sent moments before the delete, would otherwise be
+ * writing to and announcing a channel nobody can see: every member's browser refetches its roster
+ * for a row that resolves to nothing.
+ */
+ test("refuses activity on a deleted channel and leaves the last message alone", async () => {
+ const actor = await createPersistentUser();
+ const agentId = await createPersistentAgent({
+ name: "Silenced agent",
+ owner: actor,
+ });
+ const created = await persistentStore.create(actor, [agentId]);
+ createdChannelIds.push(created.id);
+ await persistentStore.recordActivity(actor, created.id, {
+ agentId,
+ at: new Date(Date.now() - 60_000),
+ text: "Said before the delete.",
});
+ await persistentStore.softDelete(actor, created.id);
+
+ await expect(
+ persistentStore.recordActivity(actor, created.id, {
+ agentId,
+ at: new Date(),
+ text: "Said after the delete.",
+ }),
+ ).rejects.toBeInstanceOf(ChannelNotFoundError);
+
+ // Same answer `get` gives, and the row the roster would have shown is untouched.
+ const [row] = await database
+ .select({
+ lastMessage: channels.lastMessage,
+ lastMessageAt: channels.lastMessageAt,
+ })
+ .from(channels)
+ .where(eq(channels.id, created.id));
+ expect(row?.lastMessage).toBe("Said before the delete.");
+ });
+
+ test("refuses to pin a deleted channel and leaves the membership alone", async () => {
+ const actor = await createPersistentUser();
+ const agentId = await createPersistentAgent({
+ name: "Unpinnable agent",
+ owner: actor,
+ });
+ const created = await persistentStore.create(actor, [agentId]);
+ createdChannelIds.push(created.id);
+ await persistentStore.softDelete(actor, created.id);
+
+ await expect(
+ persistentStore.setPinned(actor, created.id, true),
+ ).rejects.toBeInstanceOf(ChannelNotFoundError);
+
+ const [row] = await database
+ .select({ pinnedAt: channelMemberships.pinnedAt })
+ .from(channelMemberships)
+ .where(
+ and(
+ eq(channelMemberships.channelId, created.id),
+ eq(channelMemberships.userId, actor.id),
+ ),
+ );
+ expect(row?.pinnedAt).toBeNull();
+ });
+
+ test("refuses to delete a package-defined channel", async () => {
+ const actor = await createPersistentUser();
+ const [pkg] = await database
+ .insert(deploymentPackages)
+ .values({
+ tenantId: persistentId("tenant"),
+ sourcePath: "/tmp/none",
+ checksum: "0",
+ })
+ .returning({ id: deploymentPackages.id });
+ if (!pkg) throw new Error("package row was not created");
+ createdPackageIds.push(pkg.id);
+ const channelId = persistentId("package-channel");
+ await database.insert(channels).values({
+ id: channelId,
+ name: "Package channel",
+ description: "Defined by the tenant package.",
+ packageId: pkg.id,
+ });
+ createdChannelIds.push(channelId);
+ await database.insert(channelMemberships).values({
+ channelId,
+ userId: actor.id,
+ });
+
+ await expect(
+ persistentStore.softDelete(actor, channelId),
+ ).rejects.toBeInstanceOf(ChannelPackageOwnedError);
});
});
diff --git a/server/tests/credentials.test.ts b/server/tests/credentials.test.ts
index b343f969..32970be7 100644
--- a/server/tests/credentials.test.ts
+++ b/server/tests/credentials.test.ts
@@ -59,6 +59,13 @@ describe("credential encryption", () => {
stored.push(value);
return { id: "credential-1", revokedAt: null };
},
+ // An administrator's credential is replaced by a new row, never edited in place, so a
+ // call here would mean this path had changed shape.
+ updateSecret: async () => {
+ throw new Error(
+ "administrator credentials are not updated in place",
+ );
+ },
rotate: async () => {
throw new Error("nothing live to replace, so create is the path");
},
@@ -114,6 +121,9 @@ describe("credential encryption", () => {
encryptionKey: key,
store: {
create: async () => ({ id: "credential-unused", revokedAt: null }),
+ updateSecret: async () => {
+ throw new Error("administrator credentials are not updated in place");
+ },
rotate: async (input: { previousCredentialId: string }) => {
rotated.push(input);
return { id: "credential-new", revokedAt: null };
@@ -587,6 +597,96 @@ describe("one live credential per key", () => {
});
});
+/**
+ * The in-place write, which exists for one caller: a connector whose vendor rotates its refresh
+ * token on every exchange. Everything else replaces a credential by writing a new row and revoking
+ * the old one, because that is the act that has an old grant to withdraw.
+ */
+describe("credential secret update", () => {
+ /** A live `mcp_user_token` row, which is the only kind of row this write is for. */
+ async function liveCredential(plaintext: string, revoked = false) {
+ const id = randomUUID();
+ credentialIds.push(id);
+ await database.insert(credentials).values({
+ id,
+ kind: "mcp_user_token",
+ provider: "notion",
+ keyId: `user-${id}`,
+ encryptedValue: await encryptSecret(key, plaintext),
+ metadata: {},
+ revokedAt: revoked ? new Date("2026-03-01T00:00:00.000Z") : null,
+ });
+ return id;
+ }
+
+ test("re-encrypts a live row without moving it", async () => {
+ const store = createCredentialStore(database);
+ const id = await liveCredential("rt-1");
+
+ await store.updateSecret(id, await encryptSecret(key, "rt-2"));
+
+ // The same row, addressed by the same id everything else already holds, now carrying the new
+ // token — and still live, because nothing about the grant changed.
+ const stored = await store.readSecret(id);
+ expect(stored?.revokedAt).toBeNull();
+ await expect(
+ decryptSecret(key, stored?.encryptedValue ?? ""),
+ ).resolves.toBe("rt-2");
+ });
+
+ test("refuses a revoked row rather than bringing it back to life", async () => {
+ const store = createCredentialStore(database);
+ const id = await liveCredential("rt-1", true);
+
+ await expect(
+ store.updateSecret(id, await encryptSecret(key, "rt-2")),
+ ).rejects.toThrow("Credential was not found or is revoked");
+ // Untouched: a withdrawn grant must not become usable again by being written through.
+ const stored = await store.readSecret(id);
+ await expect(
+ decryptSecret(key, stored?.encryptedValue ?? ""),
+ ).resolves.toBe("rt-1");
+ });
+
+ test("refuses a row that is not there", async () => {
+ await expect(
+ createCredentialStore(database).updateSecret(
+ randomUUID(),
+ await encryptSecret(key, "rt-2"),
+ ),
+ ).rejects.toThrow("Credential was not found or is revoked");
+ });
+
+ /**
+ * The write joins the caller's transaction when it is handed one.
+ *
+ * A rotating vendor is spent under a row lock the caller took, and the re-encryption has to be
+ * part of that transaction: on its own connection it would commit whether or not the caller's
+ * transaction did, and — with every pooled connection inside such a transaction — would wait for a
+ * connection that only the caller could release.
+ */
+ test("joins a caller's transaction, so a rollback takes the new secret with it", async () => {
+ const store = createCredentialStore(database);
+ const id = await liveCredential("rt-1");
+
+ await expect(
+ database.transaction(async (transaction) => {
+ await store.updateSecret(
+ id,
+ await encryptSecret(key, "rt-2"),
+ transaction,
+ );
+ throw new Error("the caller changed its mind");
+ }),
+ ).rejects.toThrow("the caller changed its mind");
+
+ const stored = await store.readSecret(id);
+ await expect(
+ decryptSecret(key, stored?.encryptedValue ?? ""),
+ ).resolves.toBe("rt-1");
+ });
+});
+
describe("admin credential API", () => {
test("returns only credential status and metadata", async () => {
const app = createApp(
diff --git a/server/tests/plugin-catalogue.test.ts b/server/tests/plugin-catalogue.test.ts
index a122a5a7..e98a6213 100644
--- a/server/tests/plugin-catalogue.test.ts
+++ b/server/tests/plugin-catalogue.test.ts
@@ -118,8 +118,15 @@ describe("whose credential a server uses", () => {
expect(entry.auth.tokenUrl.startsWith("https://")).toBe(true);
expect(entry.auth.revokeUrl.startsWith("https://")).toBe(true);
// No scopes means consent to nothing, which would fail at the vendor with a message that
- // does not name us.
- expect(entry.auth.scopes.length).toBeGreaterThan(0);
+ // does not name us — except for a vendor whose consent screen itself is the scoping
+ // (Notion, with dynamic client registration), where a scope string would assert a control
+ // that does not exist.
+ if (entry.auth.clientRegistration !== "dynamic") {
+ expect(entry.auth.scopes.length).toBeGreaterThan(0);
+ }
+ if (entry.auth.clientRegistration === "dynamic") {
+ expect(entry.auth.registrationUrl?.startsWith("https://")).toBe(true);
+ }
}
});
@@ -178,6 +185,57 @@ describe("Google Drive", () => {
});
});
+describe("Notion", () => {
+ const entry = catalogueEntry("notion");
+
+ test("is in the catalogue with the MCP transport", () => {
+ expect(entry).not.toBeNull();
+ // Transport omitted means MCP, which is the point: Drive's REST adapter is the exception.
+ expect(entry?.transport).toBeUndefined();
+ expect(entry?.host).toBe("https://mcp.notion.com");
+ expect(entry?.path).toBe("/mcp");
+ });
+
+ test("registers its client dynamically, with every endpoint pinned to https", () => {
+ if (entry?.auth.kind !== "user-oauth") throw new Error("wrong auth kind");
+ expect(entry.auth.clientRegistration).toBe("dynamic");
+ expect(entry.auth.registrationUrl?.startsWith("https://")).toBe(true);
+ expect(entry.auth.authorizationUrl.startsWith("https://")).toBe(true);
+ expect(entry.auth.tokenUrl.startsWith("https://")).toBe(true);
+ // Notion MCP scoping is the consent screen; scope strings would assert control that
+ // does not exist.
+ expect(entry.auth.scopes).toEqual([]);
+ });
+
+ test("pins the exact write list, so a dropped or renamed entry fails here", () => {
+ // Copied from the catalogue's Notion entry, in its declared order. This list is the
+ // entire write barrier for Notion (see the comment above writeTools in catalogue.ts) —
+ // asserting membership against itself would never catch a silently dropped or renamed
+ // tool, so the fix is to pin the literal names.
+ expect(entry?.writeTools).toEqual([
+ "notion-convert-page-to-skill",
+ "notion-create-attachment",
+ "notion-create-comment",
+ "notion-create-database",
+ "notion-create-file-upload",
+ "notion-create-folder",
+ "notion-create-pages",
+ "notion-create-view",
+ "notion-duplicate-page",
+ "notion-move-pages",
+ "notion-update-data-source",
+ "notion-update-folder",
+ "notion-update-page",
+ "notion-update-view",
+ ]);
+ for (const name of entry?.writeTools ?? []) {
+ expect(classifyTool(entry, name, true)).toBe("write");
+ }
+ expect(classifyTool(entry, "notion-search", true)).toBe("read");
+ expect(classifyTool(entry, "brand-new-tool", false)).toBe("write");
+ });
+});
+
describe("what a tool does", () => {
const drive = catalogueEntry("google-drive")!;
diff --git a/server/tests/plugin-connect-route.test.ts b/server/tests/plugin-connect-route.test.ts
new file mode 100644
index 00000000..ccdcf46b
--- /dev/null
+++ b/server/tests/plugin-connect-route.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, test } from "bun:test";
+import type { MiddlewareHandler } from "hono";
+import { Hono } from "hono";
+import type { AppVariables } from "../src/auth/guards";
+import { createPluginRoutes } from "../src/plugins/routes";
+import {
+ CatalogueEntryUnknownError,
+ type OAuthClient,
+} from "../src/plugins/store";
+
+/**
+ * `POST /servers/:id/connect`, for a dynamically registered vendor.
+ *
+ * Notion has no administrator step: nobody pastes a client id, so the first person to connect is
+ * the one who makes the deployment introduce itself (RFC 7591) to the vendor. Google Drive is the
+ * regression pin for the OLD behaviour, which must survive unchanged for a manually registered
+ * vendor: no stored client is still a 409 telling an administrator to add one, and registration is
+ * never attempted for it.
+ */
+
+/** A real key shape: base64 over 32 bytes, which is what the deployment's own check demands. */
+const ENCRYPTION_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
+
+function signedIn(): MiddlewareHandler<{ Variables: AppVariables }> {
+ return async (context, next) => {
+ context.set("actor", {
+ id: "user-1",
+ email: "person@openbot.test",
+ role: "user",
+ } as never);
+ await next();
+ };
+}
+
+function app(store: {
+ oauthClientFor: (serverId: string) => Promise;
+ ensureOAuthClient: (
+ serverId: string,
+ by: string,
+ ) => Promise;
+}) {
+ const routes = createPluginRoutes(
+ store as never,
+ signedIn(),
+ async () => true,
+ {
+ publicUrl: "https://openbot.example",
+ appUrl: "https://app.example",
+ encryptionKey: ENCRYPTION_KEY,
+ // Only the callback asks this. Every test here stops at the authorization URL.
+ personHasAccess: async () => true,
+ },
+ );
+ return new Hono().route("/api/plugins", routes);
+}
+
+describe("connecting a dynamically registered vendor", () => {
+ test("registers a client on first connect and mints an authorization URL with it", async () => {
+ const ensureCalls: { serverId: string; by: string }[] = [];
+ const hono = app({
+ oauthClientFor: async () => null,
+ ensureOAuthClient: async (serverId, by) => {
+ ensureCalls.push({ serverId, by });
+ return { clientId: "dyn-1", clientSecret: "" };
+ },
+ });
+
+ const response = await hono.request(
+ "http://t/api/plugins/servers/notion/connect",
+ { method: "POST" },
+ );
+
+ expect(response.status).toBe(200);
+ expect(ensureCalls).toEqual([
+ { serverId: "notion", by: "person@openbot.test" },
+ ]);
+
+ const body = (await response.json()) as { authorizationUrl: string };
+ const url = new URL(body.authorizationUrl);
+ expect(url.host).toBe("mcp.notion.com");
+ expect(url.pathname).toBe("/authorize");
+ expect(url.searchParams.get("client_id")).toBe("dyn-1");
+ });
+
+ test("a refused registration answers 502, naming the vendor", async () => {
+ const ensureCalls: { serverId: string; by: string }[] = [];
+ const hono = app({
+ oauthClientFor: async () => null,
+ ensureOAuthClient: async (serverId, by) => {
+ ensureCalls.push({ serverId, by });
+ return null;
+ },
+ });
+
+ const response = await hono.request(
+ "http://t/api/plugins/servers/notion/connect",
+ { method: "POST" },
+ );
+
+ expect(response.status).toBe(502);
+ expect(ensureCalls.length).toBe(1);
+ const body = (await response.json()) as { error: string };
+ expect(body.error).toBe(
+ "Notion refused this deployment's registration. Try again, and check the vendor's status if it persists.",
+ );
+ });
+});
+
+describe("connecting a manually registered vendor (regression pin)", () => {
+ test("still 409s with no client registered, and never attempts self-registration", async () => {
+ const ensureCalls: { serverId: string; by: string }[] = [];
+ const hono = app({
+ oauthClientFor: async () => null,
+ ensureOAuthClient: async (serverId, by) => {
+ ensureCalls.push({ serverId, by });
+ return { clientId: "should-not-happen", clientSecret: "x" };
+ },
+ });
+
+ const response = await hono.request(
+ "http://t/api/plugins/servers/google-drive/connect",
+ { method: "POST" },
+ );
+
+ expect(response.status).toBe(409);
+ expect(ensureCalls).toEqual([]);
+ const body = (await response.json()) as { error: string };
+ expect(body.error).toContain("no OAuth client registered");
+ });
+});
+
+/**
+ * Connecting a catalogue vendor that nobody has added to this deployment.
+ *
+ * The entry exists, so the handler gets past every check it makes about the vendor, and then asks the
+ * store for a client — which cannot answer, because there is no server row to hold one. That is an
+ * administrator's missing step and the person pressing Connect can do nothing about it, so it is the
+ * same 409 as a vendor whose client an administrator has not pasted in yet. It used to be a 500: an
+ * unhandled `CatalogueEntryUnknownError` out of `ensureOAuthClient`.
+ */
+describe("connecting a vendor this deployment has not added", () => {
+ test("is the 409 an administrator can act on, not a 500", async () => {
+ const hono = app({
+ oauthClientFor: async () => null,
+ ensureOAuthClient: async (serverId) => {
+ throw new CatalogueEntryUnknownError(serverId);
+ },
+ });
+
+ const response = await hono.request(
+ "http://t/api/plugins/servers/notion/connect",
+ { method: "POST" },
+ );
+
+ expect(response.status).toBe(409);
+ const body = (await response.json()) as { error: string };
+ expect(body.error).toBe(
+ "Notion has not been added to this deployment yet. An administrator has to add it first.",
+ );
+ });
+});
diff --git a/server/tests/plugin-oauth-callback.test.ts b/server/tests/plugin-oauth-callback.test.ts
new file mode 100644
index 00000000..757d84a7
--- /dev/null
+++ b/server/tests/plugin-oauth-callback.test.ts
@@ -0,0 +1,231 @@
+import { describe, expect, test } from "bun:test";
+import type { MiddlewareHandler } from "hono";
+import { Hono } from "hono";
+import type { AppVariables } from "../src/auth/guards";
+import { challengeFor, sealConnectState } from "../src/plugins/oauth";
+import { createPluginRoutes } from "../src/plugins/routes";
+
+/**
+ * `GET /oauth/callback`: the request the vendor sends somebody back on.
+ *
+ * It has no session by design — whose connection this is comes from the state, not from whatever
+ * cookie the browser happens to be carrying. That is what makes the state the only thing standing
+ * between a consent screen and a live refresh token in this deployment's vault, and it is why what
+ * the state says has to be checked against the deployment as it is when the callback LANDS rather
+ * than as it was when the flow started ten minutes earlier.
+ *
+ * So these tests are mostly about what must not be written: a grant for a state this deployment did
+ * not seal, for a state old enough to have expired, or for somebody who no longer has access.
+ */
+
+const KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
+
+const CALLBACK = "http://t/api/plugins/oauth/callback";
+
+const FAILED =
+ "https://app.example/settings/connected-accounts?connected=failed";
+
+function signedIn(): MiddlewareHandler<{ Variables: AppVariables }> {
+ return async (context, next) => {
+ context.set("actor", {
+ id: "user-1",
+ email: "person@openbot.test",
+ role: "user",
+ } as never);
+ await next();
+ };
+}
+
+/** What `recordConnection` was asked to write, which is the row this endpoint can create. */
+type Recorded = {
+ serverId: string;
+ userId: string;
+ refreshToken: string;
+ scope: string;
+};
+
+function app(input: {
+ recorded: Recorded[];
+ /** Whether the person named by the state still has access. Present by default. */
+ personHasAccess?: (userId: string) => Promise;
+}) {
+ const store = {
+ oauthClientFor: async () => ({ clientId: "dyn-1", clientSecret: "" }),
+ ensureOAuthClient: async () => ({ clientId: "dyn-1", clientSecret: "" }),
+ recordConnection: async (connection: Recorded) => {
+ input.recorded.push(connection);
+ },
+ };
+ const routes = createPluginRoutes(
+ store as never,
+ signedIn(),
+ async () => true,
+ {
+ publicUrl: "https://openbot.example",
+ appUrl: "https://app.example",
+ encryptionKey: KEY,
+ personHasAccess: input.personHasAccess ?? (async () => true),
+ },
+ );
+ return new Hono().route("/api/plugins", routes);
+}
+
+/** A vendor that would happily hand over a refresh token, so only our own checks can refuse. */
+async function withWillingVendor(
+ run: (asked: { params: URLSearchParams }[]) => Promise,
+): Promise {
+ const asked: { params: URLSearchParams }[] = [];
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
+ asked.push({ params: new URLSearchParams(String(init?.body)) });
+ return new Response(
+ JSON.stringify({ refresh_token: "rt-1", scope: "read" }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ );
+ }) as unknown as typeof fetch;
+ try {
+ return await run(asked);
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+}
+
+function callbackUrl(state: string): string {
+ return `${CALLBACK}?code=code-1&state=${encodeURIComponent(state)}`;
+}
+
+describe("a consent that came back the way it left", () => {
+ test("the state minted by connect is the state the callback reads", async () => {
+ const recorded: Recorded[] = [];
+ const hono = app({ recorded });
+
+ const started = await hono.request(
+ "http://t/api/plugins/servers/notion/connect",
+ { method: "POST" },
+ );
+ const { authorizationUrl } = (await started.json()) as {
+ authorizationUrl: string;
+ };
+ const authorization = new URL(authorizationUrl);
+ const state = authorization.searchParams.get("state") ?? "";
+
+ const asked = await withWillingVendor(async (asked) => {
+ const response = await hono.request(callbackUrl(state));
+ expect(response.headers.get("location")).toBe(
+ "https://app.example/settings/connected-accounts/notion",
+ );
+ return asked;
+ });
+
+ expect(recorded).toEqual([
+ {
+ serverId: "notion",
+ userId: "user-1",
+ refreshToken: "rt-1",
+ scope: "read",
+ },
+ ]);
+ /*
+ * The verifier survived the round trip, and it survived it INSIDE the state rather than beside
+ * it: the code challenge the vendor was shown is the S256 of the verifier the callback redeemed
+ * with. That is the property the sealed state has to keep — it is unreadable, not lossy.
+ */
+ const verifier = asked[0]?.params.get("code_verifier") ?? "";
+ expect(verifier.length).toBeGreaterThanOrEqual(43);
+ expect(challengeFor(verifier)).toBe(
+ authorization.searchParams.get("code_challenge"),
+ );
+ // And it was never on the callback URL in a form anybody reading that URL could use.
+ expect(state).not.toContain(verifier);
+ });
+});
+
+describe("a consent that outlived the person's access", () => {
+ /*
+ * THE HOLE THIS CLOSES. Removing somebody deny-lists their address, deletes their sessions and
+ * retires the credentials they had already granted — and none of that reaches a consent already in
+ * flight at the vendor, because a state is good for ten minutes and the callback has no session to
+ * check. Completed, that consent used to write a fresh, live refresh token belonging to somebody
+ * who no longer has access, which nothing downstream would ever revoke because nothing knew it
+ * existed.
+ */
+ test("writes nothing, and does not even ask the vendor", async () => {
+ const recorded: Recorded[] = [];
+ const asked: string[] = [];
+ const hono = app({
+ recorded,
+ personHasAccess: async (userId) => {
+ asked.push(userId);
+ return false;
+ },
+ });
+ const state = await sealConnectState(
+ { userId: "removed-user", serverId: "notion", verifier: "v-1" },
+ KEY,
+ );
+
+ const requests = await withWillingVendor(async (requests) => {
+ const response = await hono.request(callbackUrl(state));
+ expect(response.headers.get("location")).toBe(FAILED);
+ return requests;
+ });
+
+ expect(recorded).toEqual([]);
+ expect(asked).toEqual(["removed-user"]);
+ // Refused before the code was redeemed, so the deployment never even holds the token it would
+ // have had to throw away.
+ expect(requests).toEqual([]);
+ });
+
+ test("a user id that names nobody is the same refusal", async () => {
+ const recorded: Recorded[] = [];
+ const hono = app({ recorded, personHasAccess: async () => false });
+ const state = await sealConnectState(
+ { userId: "never-existed", serverId: "notion", verifier: "v-1" },
+ KEY,
+ );
+
+ await withWillingVendor(async () => {
+ const response = await hono.request(callbackUrl(state));
+ expect(response.headers.get("location")).toBe(FAILED);
+ });
+ expect(recorded).toEqual([]);
+ });
+});
+
+describe("a consent this deployment did not start", () => {
+ test("a state altered on the way back is refused, and nothing is written", async () => {
+ const recorded: Recorded[] = [];
+ const hono = app({ recorded });
+ const sealed = await sealConnectState(
+ { userId: "user-1", serverId: "notion", verifier: "v-1" },
+ KEY,
+ );
+ const at = Math.floor(sealed.length / 2);
+ const tampered = `${sealed.slice(0, at)}${sealed[at] === "A" ? "B" : "A"}${sealed.slice(at + 1)}`;
+
+ await withWillingVendor(async () => {
+ const response = await hono.request(callbackUrl(tampered));
+ expect(response.headers.get("location")).toBe(FAILED);
+ });
+ expect(recorded).toEqual([]);
+ });
+
+ test("a state left in a tab too long is refused, and nothing is written", async () => {
+ const recorded: Recorded[] = [];
+ const hono = app({ recorded });
+ // Sealed as if the flow had started half an hour ago: the expiry rides inside the state, so this
+ // is the same value the browser would still be holding.
+ const stale = await sealConnectState(
+ { userId: "user-1", serverId: "notion", verifier: "v-1" },
+ KEY,
+ Date.now() - 30 * 60_000,
+ );
+
+ await withWillingVendor(async () => {
+ const response = await hono.request(callbackUrl(stale));
+ expect(response.headers.get("location")).toBe(FAILED);
+ });
+ expect(recorded).toEqual([]);
+ });
+});
diff --git a/server/tests/plugin-oauth.test.ts b/server/tests/plugin-oauth.test.ts
index 570cde21..853adc53 100644
--- a/server/tests/plugin-oauth.test.ts
+++ b/server/tests/plugin-oauth.test.ts
@@ -1,13 +1,16 @@
import { describe, expect, test } from "bun:test";
-import { catalogueEntry } from "../src/plugins/catalogue";
+import { seal } from "../src/auth/signed-value";
+import type { CatalogueAuth } from "../src/plugins/catalogue";
import {
authorizationUrlFor,
challengeFor,
createVerifier,
readConnectState,
+ redeemAuthorizationCode,
redirectUriFor,
+ registerDynamicClient,
connectedAccountsUrlFor,
- signConnectState,
+ sealConnectState,
} from "../src/plugins/oauth";
/**
@@ -16,7 +19,7 @@ import {
* Everything here exists because the browser is in the middle of it. An authorization code arrives
* on a URL somebody else's server sent the person to, so nothing on that request can be believed on
* its own: not who is connecting, not which server they meant, and not that they ever asked. The
- * signed state is what carries those facts across, and the PKCE verifier is what proves the code
+ * sealed state is what carries those facts across, and the PKCE verifier is what proves the code
* being redeemed belongs to the request that started it.
*
* So these tests are almost entirely about refusal. A state that was tampered with, replayed after
@@ -25,22 +28,17 @@ import {
*/
const KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
-const drive = catalogueEntry("google-drive");
-if (drive?.auth.kind !== "user-oauth") {
- throw new Error("google-drive must be a user-oauth entry for these tests");
-}
-const driveAuth = drive.auth;
const NOW = 1_770_000_000_000;
describe("the state that travels through the vendor", () => {
- test("carries who, which server, and the verifier, and reads back exactly", () => {
- const signed = signConnectState(
+ test("carries who, which server, and the verifier, and reads back exactly", async () => {
+ const sealed = await sealConnectState(
{ userId: "user-1", serverId: "google-drive", verifier: "v-1" },
KEY,
NOW,
);
- expect(readConnectState(signed, KEY, NOW)).toEqual({
+ expect(await readConnectState(sealed, KEY, NOW)).toEqual({
userId: "user-1",
serverId: "google-drive",
verifier: "v-1",
@@ -50,8 +48,8 @@ describe("the state that travels through the vendor", () => {
});
});
- test("the screen to return to survives the round trip", () => {
- const signed = signConnectState(
+ test("the screen to return to survives the round trip", async () => {
+ const sealed = await sealConnectState(
{
userId: "user-1",
serverId: "google-drive",
@@ -61,7 +59,7 @@ describe("the state that travels through the vendor", () => {
KEY,
NOW,
);
- expect(readConnectState(signed, KEY, NOW)?.returnTo).toBe("admin");
+ expect((await readConnectState(sealed, KEY, NOW))?.returnTo).toBe("admin");
});
/*
@@ -71,17 +69,17 @@ describe("the state that travels through the vendor", () => {
*
* The defence is that the field cannot express another origin at all. Only "admin" is recognised;
* everything else — a URL, a protocol-relative host, a path traversal — reads back as the default.
- * Asserted through a SIGNED state, because a valid signature is exactly what an attacker would not
- * have, and the point is that the narrowing does not depend on the signature to hold.
+ * Asserted through a state this deployment actually sealed, because a state it will open at all is
+ * exactly what an attacker does not have, and the point is that the narrowing holds regardless.
*/
- test("a destination that names anywhere else reads back as the default", () => {
+ test("a destination that names anywhere else reads back as the default", async () => {
for (const hostile of [
"https://evil.test",
"//evil.test",
"/admin/plugins/../../evil",
"ADMIN",
]) {
- const signed = signConnectState(
+ const sealed = await sealConnectState(
{
userId: "user-1",
serverId: "google-drive",
@@ -91,57 +89,123 @@ describe("the state that travels through the vendor", () => {
KEY,
NOW,
);
- expect(readConnectState(signed, KEY, NOW)?.returnTo).toBe("settings");
+ expect((await readConnectState(sealed, KEY, NOW))?.returnTo).toBe(
+ "settings",
+ );
}
});
- test("is refused once a character of it changes", () => {
- const signed = signConnectState(
+ test("is refused once a character of it changes", async () => {
+ const sealed = await sealConnectState(
{ userId: "user-1", serverId: "google-drive", verifier: "v-1" },
KEY,
NOW,
);
- // The payload is base64url, so flipping a character inside it is the realistic tamper: somebody
- // trying to have the callback attach their Google account to another person's row.
- const tampered = `${signed.slice(0, 4)}${signed[4] === "A" ? "B" : "A"}${signed.slice(5)}`;
- expect(readConnectState(tampered, KEY, NOW)).toBeNull();
+ /*
+ * Every character of a sealed state is the envelope, the nonce or the ciphertext, so one flipped
+ * character anywhere is the realistic tamper: somebody trying to have the callback attach their
+ * account to another person's row. AES-GCM authenticates as well as encrypts, so an altered
+ * state fails to open rather than opening as something else — no separate signature catches it.
+ */
+ for (const at of [0, Math.floor(sealed.length / 2), sealed.length - 1]) {
+ const tampered = `${sealed.slice(0, at)}${sealed[at] === "A" ? "B" : "A"}${sealed.slice(at + 1)}`;
+ expect(await readConnectState(tampered, KEY, NOW)).toBeNull();
+ }
});
- test("is refused when signed with a different key", () => {
- const signed = signConnectState(
+ test("is refused when sealed with a different key", async () => {
+ const sealed = await sealConnectState(
{ userId: "user-1", serverId: "google-drive", verifier: "v-1" },
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
NOW,
);
- expect(readConnectState(signed, KEY, NOW)).toBeNull();
+ expect(await readConnectState(sealed, KEY, NOW)).toBeNull();
});
- test("expires, so a stale consent screen cannot be redeemed later", () => {
- const signed = signConnectState(
+ test("expires, so a stale consent screen cannot be redeemed later", async () => {
+ // The expiry rides INSIDE the sealed value, where nobody can move it — but it still has to be
+ // checked on the way out, which is the half that encrypting does not do for you.
+ const sealed = await sealConnectState(
{ userId: "user-1", serverId: "google-drive", verifier: "v-1" },
KEY,
NOW,
);
- expect(readConnectState(signed, KEY, NOW + 60_000)).not.toBeNull();
- expect(readConnectState(signed, KEY, NOW + 60 * 60_000)).toBeNull();
+ expect(await readConnectState(sealed, KEY, NOW + 60_000)).not.toBeNull();
+ expect(await readConnectState(sealed, KEY, NOW + 60 * 60_000)).toBeNull();
});
- test("cannot be a run assertion wearing a different hat", () => {
- // Signed under its own label, so a signature valid for one kind of statement is not valid as
- // another. Without that, any signed value this deployment ever hands out is a candidate state.
- const signed = signConnectState(
- { userId: "user-1", serverId: "google-drive", verifier: "v-1" },
+ test("cannot be a run assertion wearing a different hat", async () => {
+ /*
+ * Sealed under its own label, and the label derives the key — so a value this deployment sealed
+ * for another purpose is not merely rejected here, it cannot be opened here at all. Without
+ * that, anything the deployment ever sealed under this key would be a candidate state.
+ */
+ const elsewhere = await seal(
+ JSON.stringify({
+ userId: "user-1",
+ serverId: "google-drive",
+ verifier: "v-1",
+ exp: NOW + 60_000,
+ }),
+ KEY,
+ "agent-callback",
+ );
+ expect(await readConnectState(elsewhere, KEY, NOW)).toBeNull();
+ });
+
+ /**
+ * THE PROPERTY THIS FORMAT EXISTS FOR: the state says nothing to anybody without the key.
+ *
+ * The state and the authorization code travel on the SAME callback URL, and a dynamically
+ * registered client is public — PKCE is the only thing binding that code to this deployment. So
+ * every reader of that URL, and there are several nobody chose (a CDN log, a proxy log, browser
+ * history, the vendor's own logs), used to hold everything needed to redeem the code: a state that
+ * was base64url JSON with an HMAC after it is authenticated, and perfectly readable.
+ *
+ * Asserted by decoding rather than by eye, because the failure being guarded against is a value
+ * that LOOKS opaque and is not.
+ */
+ test("does not carry the PKCE verifier where a reader without the key can find it", async () => {
+ const verifier = createVerifier();
+ const state = await sealConnectState(
+ { userId: "user-1", serverId: "notion", verifier },
KEY,
NOW,
);
- const [payload] = signed.split(".");
- expect(readConnectState(payload ?? "", KEY, NOW)).toBeNull();
+ expect(state).not.toContain(verifier);
+ // Opaque as far as a URL is concerned, too: one token, nothing in it to escape.
+ expect(state).toMatch(/^[A-Za-z0-9\-_]+$/);
+ for (const segment of state.split(".")) {
+ for (const encoding of ["base64url", "base64", "hex"] as const) {
+ expect(Buffer.from(segment, encoding).toString("utf8")).not.toContain(
+ verifier,
+ );
+ }
+ }
});
- test("is refused when it is not a state at all", () => {
- expect(readConnectState("", KEY, NOW)).toBeNull();
- expect(readConnectState("nonsense", KEY, NOW)).toBeNull();
- expect(readConnectState("a.b", KEY, NOW)).toBeNull();
+ test("still fits in a query parameter", async () => {
+ // Sealing costs size, and the state has to survive a round trip through somebody else's URL
+ // handling. A real-shaped one — a UUID for the person, a live verifier — is well inside it.
+ const state = await sealConnectState(
+ {
+ userId: crypto.randomUUID(),
+ serverId: "google-drive",
+ verifier: createVerifier(),
+ },
+ KEY,
+ NOW,
+ );
+ expect(state.length).toBeLessThan(1_024);
+ });
+
+ test("is refused when it is not a state at all", async () => {
+ expect(await readConnectState("", KEY, NOW)).toBeNull();
+ expect(await readConnectState("nonsense", KEY, NOW)).toBeNull();
+ expect(await readConnectState("a.b", KEY, NOW)).toBeNull();
+ // A real envelope, sealed with the right key under the right label, carrying no state at all.
+ const empty = await seal("{}", KEY, "mcp-oauth-connect");
+ expect(await readConnectState(empty, KEY, NOW)).toBeNull();
});
});
@@ -169,9 +233,20 @@ describe("PKCE", () => {
});
describe("the address the person is sent to", () => {
+ // A literal fixture, not the live Google Drive entry: this pins Google's behavior through the
+ // params-from-the-entry refactor without the test depending on the catalogue's own shape.
+ const googleAuth: Extract = {
+ kind: "user-oauth",
+ authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
+ tokenUrl: "https://oauth2.googleapis.com/token",
+ revokeUrl: "https://oauth2.googleapis.com/revoke",
+ scopes: ["https://www.googleapis.com/auth/drive.readonly"],
+ authorizationParams: { access_type: "offline", prompt: "consent" },
+ };
+
const url = new URL(
authorizationUrlFor({
- auth: driveAuth,
+ auth: googleAuth,
clientId: "client-id",
redirectUri: "https://openbot.example/api/plugins/oauth/callback",
state: "signed-state",
@@ -180,7 +255,7 @@ describe("the address the person is sent to", () => {
);
test("is the vendor's own, from the catalogue", () => {
- expect(`${url.origin}${url.pathname}`).toBe(driveAuth.authorizationUrl);
+ expect(`${url.origin}${url.pathname}`).toBe(googleAuth.authorizationUrl);
});
test("asks for a refresh token that consent is granted for once", () => {
@@ -193,7 +268,7 @@ describe("the address the person is sent to", () => {
});
test("asks only for the scopes the entry pins", () => {
- expect(url.searchParams.get("scope")).toBe(driveAuth.scopes.join(" "));
+ expect(url.searchParams.get("scope")).toBe(googleAuth.scopes.join(" "));
});
test("carries the state and the challenge, and names the method", () => {
@@ -201,6 +276,80 @@ describe("the address the person is sent to", () => {
expect(url.searchParams.get("code_challenge")).toBe("challenge");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
});
+
+ test("a vendor with no authorization params and no scopes sends neither, nor an empty scope", () => {
+ // Notion's shape: no authorizationParams, and scopes: [] because the consent screen itself is
+ // the scoping. An empty `scope=` would be a malformed request to some vendors, so the key must
+ // be entirely absent, not present-and-empty.
+ const notionAuth: Extract = {
+ kind: "user-oauth",
+ authorizationUrl: "https://mcp.notion.com/authorize",
+ tokenUrl: "https://mcp.notion.com/token",
+ revokeUrl: "https://mcp.notion.com/revoke",
+ scopes: [],
+ };
+ const bare = new URL(
+ authorizationUrlFor({
+ auth: notionAuth,
+ clientId: "client-id",
+ redirectUri: "https://openbot.example/api/plugins/oauth/callback",
+ state: "signed-state",
+ codeChallenge: "challenge",
+ }),
+ );
+ expect(bare.searchParams.has("access_type")).toBe(false);
+ expect(bare.searchParams.has("prompt")).toBe(false);
+ expect(bare.searchParams.has("scope")).toBe(false);
+ });
+
+ /*
+ * DEFENSE IN DEPTH, NOT REACHABILITY TODAY. The catalogue is frozen, reviewed code, so nothing in
+ * it can set this now — but `authorizationParams` is applied LAST, after the six keys that carry
+ * this flow's own security (who is asking, where the vendor answers, and the PKCE proof). An entry
+ * that names one of them would quietly win, and a future entry setting `code_challenge_method:
+ * "plain"` would defeat PKCE with no test catching it. Throwing at URL-build time turns that into a
+ * fail-at-first-connect instead of a silent downgrade.
+ */
+ test("an entry that names one of the flow's own keys throws rather than winning", () => {
+ const hostileAuth: Extract = {
+ kind: "user-oauth",
+ authorizationUrl: "https://vendor.example/authorize",
+ tokenUrl: "https://vendor.example/token",
+ revokeUrl: "https://vendor.example/revoke",
+ scopes: [],
+ authorizationParams: { code_challenge_method: "plain" },
+ };
+ expect(() =>
+ authorizationUrlFor({
+ auth: hostileAuth,
+ clientId: "client-id",
+ redirectUri: "https://openbot.example/api/plugins/oauth/callback",
+ state: "signed-state",
+ codeChallenge: "challenge",
+ }),
+ ).toThrow(/code_challenge_method/);
+ });
+
+ test("an entry's harmless extra parameter still passes through", () => {
+ const audienceAuth: Extract = {
+ kind: "user-oauth",
+ authorizationUrl: "https://vendor.example/authorize",
+ tokenUrl: "https://vendor.example/token",
+ revokeUrl: "https://vendor.example/revoke",
+ scopes: [],
+ authorizationParams: { audience: "x" },
+ };
+ const withAudience = new URL(
+ authorizationUrlFor({
+ auth: audienceAuth,
+ clientId: "client-id",
+ redirectUri: "https://openbot.example/api/plugins/oauth/callback",
+ state: "signed-state",
+ codeChallenge: "challenge",
+ }),
+ );
+ expect(withAudience.searchParams.get("audience")).toBe("x");
+ });
});
describe("the address the vendor sends them back to", () => {
@@ -304,3 +453,250 @@ describe("where the callback sends somebody afterwards", () => {
);
});
});
+
+describe("registering this deployment as an OAuth client", () => {
+ test("registers a dynamic client with the redirect URI and no secret", async () => {
+ const seen: { url: string; body: unknown }[] = [];
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = (async (url: unknown, init?: RequestInit) => {
+ seen.push({ url: String(url), body: JSON.parse(String(init?.body)) });
+ return new Response(JSON.stringify({ client_id: "dyn-123" }), {
+ status: 201,
+ headers: { "content-type": "application/json" },
+ });
+ }) as unknown as typeof fetch;
+ try {
+ const client = await registerDynamicClient({
+ registrationUrl: "https://vendor.example/register",
+ redirectUri: "https://openbot.example/api/plugins/oauth/callback",
+ });
+ expect(client).toEqual({ clientId: "dyn-123", clientSecret: "" });
+ expect(seen[0]?.url).toBe("https://vendor.example/register");
+ expect(seen[0]?.body).toEqual({
+ redirect_uris: ["https://openbot.example/api/plugins/oauth/callback"],
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ token_endpoint_auth_method: "none",
+ client_name: "OpenBot",
+ });
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+ });
+
+ test("a refused registration returns null rather than a client", async () => {
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = (async () =>
+ new Response("no", { status: 400 })) as unknown as typeof fetch;
+ try {
+ expect(
+ await registerDynamicClient({
+ registrationUrl: "https://vendor.example/register",
+ redirectUri: "https://openbot.example/cb",
+ }),
+ ).toBeNull();
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+ });
+
+ /**
+ * A 200 carrying something that is not JSON.
+ *
+ * A CDN interstitial, a captive portal, a load balancer's maintenance page: all of them answer 200
+ * with HTML, and this function's contract is that a vendor which will not register us reads back
+ * as null. An unguarded `response.json()` breaks that contract in the worst available way — it
+ * throws a SyntaxError, which escapes the whole request as a 500, and the parser's message quotes
+ * the vendor's body into whatever logs it.
+ */
+ test("a 200 that is not JSON is a refusal, not a thrown parse error", async () => {
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = (async () =>
+ new Response("Attention Required!", {
+ status: 200,
+ headers: { "content-type": "text/html" },
+ })) as unknown as typeof fetch;
+ try {
+ expect(
+ await registerDynamicClient({
+ registrationUrl: "https://vendor.example/register",
+ redirectUri: "https://openbot.example/cb",
+ }),
+ ).toBeNull();
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+ });
+
+ /**
+ * A redirect is a refusal, and is never followed.
+ *
+ * This request carries nothing secret, but where it goes is a reviewed decision: the registration
+ * endpoint is pinned in the catalogue, and a 302 is somebody else deciding for us. Followed, it
+ * would have this deployment register itself at whatever address the answer named — and believe
+ * the client id that came back.
+ */
+ test("a redirect is not followed, and reads back as a refusal", async () => {
+ const seen: { redirect: RequestRedirect | undefined }[] = [];
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
+ seen.push({ redirect: init?.redirect });
+ return new Response(null, {
+ status: 302,
+ headers: { location: "https://elsewhere.example/register" },
+ });
+ }) as unknown as typeof fetch;
+ try {
+ expect(
+ await registerDynamicClient({
+ registrationUrl: "https://vendor.example/register",
+ redirectUri: "https://openbot.example/cb",
+ }),
+ ).toBeNull();
+ expect(seen[0]?.redirect).toBe("manual");
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+ });
+});
+
+describe("redeeming an authorization code", () => {
+ test("an empty client secret sends no client_secret field", async () => {
+ const seen: { params: URLSearchParams }[] = [];
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
+ seen.push({ params: new URLSearchParams(String(init?.body)) });
+ return new Response(
+ JSON.stringify({ refresh_token: "rt-1", scope: "" }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ );
+ }) as unknown as typeof fetch;
+ try {
+ await redeemAuthorizationCode({
+ tokenUrl: "https://vendor.example/token",
+ clientId: "client-id",
+ clientSecret: "",
+ code: "code-1",
+ redirectUri: "https://openbot.example/api/plugins/oauth/callback",
+ verifier: "verifier-1",
+ });
+ expect(seen[0]?.params.has("client_secret")).toBe(false);
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+ });
+
+ test("a non-empty client secret still sends the field", async () => {
+ const seen: { params: URLSearchParams }[] = [];
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
+ seen.push({ params: new URLSearchParams(String(init?.body)) });
+ return new Response(
+ JSON.stringify({ refresh_token: "rt-1", scope: "" }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ );
+ }) as unknown as typeof fetch;
+ try {
+ await redeemAuthorizationCode({
+ tokenUrl: "https://vendor.example/token",
+ clientId: "client-id",
+ clientSecret: "secret-1",
+ code: "code-1",
+ redirectUri: "https://openbot.example/api/plugins/oauth/callback",
+ verifier: "verifier-1",
+ });
+ expect(seen[0]?.params.get("client_secret")).toBe("secret-1");
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+ });
+
+ /**
+ * A 200 carrying something that is not JSON.
+ *
+ * The documented contract is a refusal — "a refusal rather than an exception when the vendor
+ * declines" — and a CDN interstitial answering 200 with HTML is exactly the case where the
+ * unguarded parse turned that refusal into a 500. The person had consented by then, so the failure
+ * lands on the callback: it must redirect them back to Settings with a notice, not crash.
+ */
+ test("a 200 that is not JSON is a refusal, not a thrown parse error", async () => {
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = (async () =>
+ new Response("checking your browser", {
+ status: 200,
+ headers: { "content-type": "text/html" },
+ })) as unknown as typeof fetch;
+ try {
+ expect(
+ await redeemAuthorizationCode({
+ tokenUrl: "https://vendor.example/token",
+ clientId: "client-id",
+ clientSecret: "",
+ code: "code-1",
+ redirectUri: "https://openbot.example/api/plugins/oauth/callback",
+ verifier: "verifier-1",
+ }),
+ ).toBeNull();
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+ });
+
+ /** A redirect from the token endpoint is a refusal too, and is never followed. */
+ test("a redirect is not followed, and reads back as a refusal", async () => {
+ const seen: { redirect: RequestRedirect | undefined }[] = [];
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
+ seen.push({ redirect: init?.redirect });
+ return new Response(null, {
+ status: 307,
+ headers: { location: "https://elsewhere.example/token" },
+ });
+ }) as unknown as typeof fetch;
+ try {
+ expect(
+ await redeemAuthorizationCode({
+ tokenUrl: "https://vendor.example/token",
+ clientId: "client-id",
+ clientSecret: "",
+ code: "code-1",
+ redirectUri: "https://openbot.example/api/plugins/oauth/callback",
+ verifier: "verifier-1",
+ }),
+ ).toBeNull();
+ expect(seen[0]?.redirect).toBe("manual");
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+ });
+
+ /**
+ * The scope is capped where it is read.
+ *
+ * It is a short string in the protocol and vendor-controlled in fact, and everything downstream
+ * shows it to somebody: the connected-accounts page, an `mcp.account_connected` payload, the
+ * `mcp_user_credentials.scope` column. None of those is a promise about length, and a vendor
+ * answering with a megabyte of it should cost a truncated line rather than a stored megabyte.
+ */
+ test("a vendor's scope is capped rather than stored whole", async () => {
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = (async () =>
+ new Response(
+ JSON.stringify({ refresh_token: "rt-1", scope: "s".repeat(2_000) }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ )) as unknown as typeof fetch;
+ try {
+ const grant = await redeemAuthorizationCode({
+ tokenUrl: "https://vendor.example/token",
+ clientId: "client-id",
+ clientSecret: "",
+ code: "code-1",
+ redirectUri: "https://openbot.example/api/plugins/oauth/callback",
+ verifier: "verifier-1",
+ });
+ expect(grant?.scope.length).toBe(512);
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+ });
+});
diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts
index bddede09..00fe637b 100644
--- a/server/tests/plugin-store.integration.test.ts
+++ b/server/tests/plugin-store.integration.test.ts
@@ -1,23 +1,47 @@
-import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ test,
+} from "bun:test";
import { randomUUID } from "node:crypto";
+import { MCPMock } from "@copilotkit/aimock/mcp";
import { and, eq, inArray, like, sql } from "drizzle-orm";
import { createAuditStore } from "../src/audit";
-import { encryptSecret } from "../src/credentials";
import type { ActionPolicy } from "../src/computer/policy";
+import {
+ createCredentialStore,
+ type CredentialStoreValue,
+ decryptSecret,
+ encryptSecret,
+} from "../src/credentials";
import { createDatabase } from "../src/db/client";
import { TEST_POOL } from "./support/database";
import {
agents,
auditEvents,
+ credentials,
credentials as credentialRows,
mcpServers,
mcpTools,
+ mcpUserCredentials,
pluginGrants,
+ users,
} from "../src/db/schema";
+import { catalogueEntry } from "../src/plugins/catalogue";
+import { redirectUriFor } from "../src/plugins/oauth";
import {
+ type AccessToken,
createPluginStore,
CustomServerRefusedError,
+ exchangeRefreshTokenOverHttp,
+ INVALID_CLIENT,
+ type OAuthClient,
PluginRefusedError,
+ TokenRefusedError,
+ unlistedAdvertisedTools,
} from "../src/plugins/store";
/**
@@ -71,11 +95,15 @@ const store = createPluginStore({
credentials: {
// No credential is ever read in these tests, because every call is refused before the vault.
readSecret: async () => null,
- // Nor created. Loud rather than absent: a call reaching this would mean this file had started
- // exercising something it does not claim to, and a silent no-op would hide that.
+ // Nor written in place. Loud rather than absent: a call reaching either of these would mean
+ // this file had started exercising something it does not claim to, and a silent no-op would
+ // hide that.
create: async () => {
throw new Error("this suite does not write credentials");
},
+ updateSecret: async () => {
+ throw new Error("this suite does not write credentials");
+ },
// `removeServer` does revoke: it retires the token the server was configured with so a re-add
// does not collide on `credentials_active_key_idx`. The stamp goes to the real row, because
// `removeServer` reads liveness from the table before deciding whether to revoke at all.
@@ -497,6 +525,90 @@ describe("removing an MCP server", () => {
// `credentialId` is suite-scoped, so re-runs never collide.
});
+ /**
+ * The people's grants go too, not only the server's own token.
+ *
+ * `mcp_user_credentials` cascades on the server row, so removing a `user-oauth` connector used to
+ * delete every pointer and leave every refresh token in the vault live and unreferenced: reachable
+ * from no screen, revoked by no operation, and still a usable grant at the vendor. "We removed the
+ * connector" has to be true of the thing that matters, which is the token sitting at Notion.
+ */
+ test("revokes every person's grant for the server it removes", async () => {
+ const removalServerId = `removal-target-people-${suite}`;
+ const connectedUserId = `user_removal_${suite}`;
+ revokedCredentialIds.length = 0;
+
+ await database
+ .insert(users)
+ .values({
+ id: connectedUserId,
+ email: `${connectedUserId}@openbot.test`,
+ name: connectedUserId,
+ emailVerified: false,
+ })
+ .onConflictDoNothing();
+
+ const [grant] = await database
+ .insert(credentialRows)
+ .values({
+ kind: "mcp_user_token",
+ provider: removalServerId,
+ keyId: connectedUserId,
+ encryptedValue: "{}",
+ metadata: {},
+ })
+ .returning({ id: credentialRows.id });
+ const grantId = grant?.id;
+ if (!grantId) throw new Error("grant row was not created");
+ issuedCredentialIds.push(grantId);
+
+ await database.insert(mcpServers).values({
+ id: removalServerId,
+ title: "removal target with people",
+ vendor: "test",
+ url: "https://example.invalid/mcp",
+ provenance: "custom",
+ });
+ await database.insert(mcpUserCredentials).values({
+ serverId: removalServerId,
+ userId: connectedUserId,
+ credentialId: grantId,
+ scope: "",
+ });
+
+ try {
+ await store.removeServer(removalServerId, "admin@openbot.local");
+
+ expect(revokedCredentialIds).toEqual([grantId]);
+ const [row] = await database
+ .select({ revokedAt: credentialRows.revokedAt })
+ .from(credentialRows)
+ .where(eq(credentialRows.id, grantId));
+ expect(row?.revokedAt).not.toBeNull();
+
+ // And the trail says whose access ended and why, which is the row an auditor reaches for.
+ const trail = await database
+ .select({
+ eventType: auditEvents.eventType,
+ owner: sql`payload ->> 'owner'`,
+ reason: sql`payload ->> 'reason'`,
+ })
+ .from(auditEvents)
+ .where(
+ and(
+ eq(auditEvents.targetType, "mcp_server"),
+ eq(auditEvents.targetId, removalServerId),
+ eq(auditEvents.eventType, "mcp.account_disconnected"),
+ ),
+ );
+ expect(trail).toHaveLength(1);
+ expect(trail[0]?.owner).toBe(connectedUserId);
+ expect(trail[0]?.reason).toBe("mcp_server_removed");
+ } finally {
+ await database.delete(users).where(eq(users.id, connectedUserId));
+ }
+ });
+
test("does not call revoke when the server had no credential", async () => {
const removalServerId = `removal-target-nocred-${suite}`;
revokedCredentialIds.length = 0;
@@ -617,130 +729,1663 @@ describe("a grant on a tool the vendor no longer lists", () => {
});
/**
- * Which credential a custom server is allowed to be pointed at.
+ * A vendor that hands back a new refresh token every time it is asked for access.
*
- * `addCustomServer` takes the pointer from the request body, and the add itself dereferences it: the
- * refresh that follows decrypts whatever it names and sends it to the URL from the same request. So
- * the pointer is the whole control. An administrator naming somebody's `mcp_user_token` was enough
- * to have that person's decrypted token delivered to an address the administrator chose, before any
- * grant, policy check or Bot existed.
+ * Notion does. The token it was shown is dead the moment it answers, so a deployment that keeps the
+ * old one has spent somebody's connection on a single call: the next one presents a token the vendor
+ * has already invalidated, and the person is told to connect again for no reason they can see. That
+ * makes persisting the new token part of the exchange rather than bookkeeping after it, and it makes
+ * two concurrent calls a problem — both would present the same token, and one of them would lose.
*
- * `POST /api/admin/credentials` already refuses to *mint* a `mcp_user_token` by hand, and says why:
- * it would be "creating a credential attributed to a person who never agreed to it". Pointing at one
- * spends that credential on the same person's behalf, which is the same objection.
+ * This suite needs a REAL vault, unlike the store fixture above: rotation re-encrypts the row the
+ * connection already points at, and a stub that throws cannot show that happening — nor show that
+ * nothing else was written. So it builds its own store, with the vendor and its token endpoint
+ * injected and everything else genuine.
*/
-describe("a custom server may only be pointed at its own kind of credential", () => {
- const suffix = randomUUID().slice(0, 8);
- const deploymentCredentialId = randomUUID();
- const personalCredentialId = randomUUID();
- const oauthClientCredentialId = randomUUID();
- const customServerId = `custom-cred-${suffix}`;
- const madeServerIds: string[] = [];
+describe("refresh token rotation", () => {
+ const rotationBotId = `agent_rotation_bot_${suite}`;
+ const rotationUserId = `user_rotation_${suite}`;
+ /** Notion, because it is the entry whose vendor actually rotates. */
+ const rotationServerId = "notion";
+ /** Suite-scoped, so it cannot collide with a name Notion really advertises. */
+ const rotationToolName = `search_${suite}`;
+ const rotationRef = `${rotationServerId}/${rotationToolName}`;
+ /**
+ * 32 zero bytes in base64.
+ *
+ * A real AES-256 key length, unlike the `"x".repeat(44)` the fixture above gets away with: every
+ * call there is refused before the vault is opened, and every call here goes through it.
+ */
+ const ROTATION_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
+ const CLIENT = { clientId: "notion-client", clientSecret: "notion-secret" };
+ /** Notion has no scope strings; the connection stores what the vendor said, which is nothing. */
+ const SCOPE = "";
+
+ /** Every vault row this suite created, so the cleanup can take exactly those. */
+ const vaultRows: string[] = [];
+ /** Every access token the store was about to send to the vendor, in order. */
+ const sent: string[] = [];
+ /**
+ * The exchange, as a sequence of the moments it entered and left.
+ *
+ * Recorded as a log rather than as a count because the property under test is an ORDERING: two
+ * exchanges for one connection must not overlap. A log makes an overlap visible without the test
+ * having to guess when to look.
+ */
+ const log: string[] = [];
+ /** What each exchange received and what it rotated to, which is the pairing rotation is about. */
+ const exchanges: { received: string; returned?: string }[] = [];
+ /** What the vendor's token endpoint does, installed per test. */
+ let mint: (refreshToken: string) => Promise = async () => {
+ throw new Error("no exchange was installed for this test");
+ };
+
+ /**
+ * The vault, wired once and shared by every store this describe builds.
+ *
+ * Shared deliberately: a second replica of this deployment reads and writes the same rows through
+ * the same code, and a per-store copy of the wiring would be a second place for the fixture to
+ * drift from what production does.
+ */
+ const vault = {
+ readSecret: async (id: string) => {
+ const [row] = await database
+ .select({
+ encryptedValue: credentials.encryptedValue,
+ revokedAt: credentials.revokedAt,
+ })
+ .from(credentials)
+ .where(eq(credentials.id, id));
+ return row ?? null;
+ },
+ create: async (value: CredentialStoreValue) => {
+ const [row] = await database
+ .insert(credentials)
+ .values(value)
+ .returning({ id: credentials.id, revokedAt: credentials.revokedAt });
+ if (!row) throw new Error("credential was not stored");
+ vaultRows.push(row.id);
+ return row;
+ },
+ /*
+ * The vault's own in-place update, not a stand-in for it.
+ *
+ * This is the write rotation now performs, and the suite asserts the ROW it leaves behind: the
+ * same id, re-encrypted, nothing added. A hand-rolled copy of the statement here would assert
+ * the copy rather than the vault — and would not join the caller's transaction, which is the
+ * whole of what keeps two replicas from spending one refresh token twice.
+ */
+ updateSecret: createCredentialStore(database).updateSecret,
+ /*
+ * The real swap and the real key lookup too: `credentials_active_key_idx` holds one live row
+ * per key, so a reconnect in this suite replaces its previous token through the same
+ * transaction production uses. A stand-in would dodge the index the test data must obey.
+ */
+ rotate: async (
+ value: CredentialStoreValue & { previousCredentialId: string },
+ ) => {
+ const stored = await createCredentialStore(database).rotate(value);
+ vaultRows.push(stored.id);
+ return stored;
+ },
+ findLiveByKey: createCredentialStore(database).findLiveByKey,
+ isLive: createCredentialStore(database).isLive,
+ revoke: async (id: string) => {
+ const [row] = await database
+ .update(credentials)
+ .set({ revokedAt: new Date() })
+ .where(eq(credentials.id, id))
+ .returning({ revokedAt: credentials.revokedAt });
+ if (!row?.revokedAt) throw new Error("credential was not revoked");
+ return row.revokedAt;
+ },
+ };
+
+ const rotationStore = createPluginStore({
+ database,
+ auditStore: createAuditStore(database),
+ credentials: vault,
+ encryptionKey: ROTATION_KEY,
+ policy: () => policy,
+ // Stops before the network, and records what the call would have gone out with.
+ callVendor: async (connection) => {
+ sent.push(connection.token ?? "");
+ return { text: "[vendor not reached in tests]", isError: false };
+ },
+ exchangeRefreshToken: async ({ client, refreshToken }) => {
+ expect(client).toEqual(CLIENT);
+ log.push(`start:${refreshToken}`);
+ const minted = await mint(refreshToken);
+ log.push(`end:${refreshToken}`);
+ exchanges.push({ received: refreshToken, returned: minted.refreshToken });
+ return minted;
+ },
+ });
+
+ /**
+ * A second replica of this deployment, over the same database.
+ *
+ * The point of building the store again rather than calling the same one twice is what is NOT
+ * shared: the in-process map that queues one connection's exchanges belongs to a store instance,
+ * so two instances are as unserialised as two containers behind a load balancer. Whatever keeps
+ * them from spending one refresh token twice has to live in the database.
+ */
+ function replica(exchange: (refreshToken: string) => Promise) {
+ return createPluginStore({
+ database,
+ auditStore: createAuditStore(database),
+ credentials: vault,
+ encryptionKey: ROTATION_KEY,
+ policy: () => policy,
+ callVendor: async () => ({
+ text: "[vendor not reached in tests]",
+ isError: false,
+ }),
+ exchangeRefreshToken: async ({ refreshToken }) => exchange(refreshToken),
+ });
+ }
+
+ /** The deployment's OAuth client, which is what `mcp_servers.credential_id` holds. */
+ async function registerClient() {
+ const [credential] = await database
+ .insert(credentials)
+ .values({
+ kind: "mcp_oauth_client",
+ provider: rotationServerId,
+ keyId: "oauth-client",
+ metadata: { clientId: CLIENT.clientId },
+ encryptedValue: await encryptSecret(
+ ROTATION_KEY,
+ JSON.stringify(CLIENT),
+ ),
+ })
+ .returning({ id: credentials.id });
+ if (!credential) throw new Error("client was not stored");
+ vaultRows.push(credential.id);
+ await database
+ .update(mcpServers)
+ .set({ credentialId: credential.id })
+ .where(eq(mcpServers.id, rotationServerId));
+ }
+
+ /** Which vault row this person's connection points at, so a swap is observable. */
+ async function connectionCredential() {
+ const [row] = await database
+ .select({ credentialId: mcpUserCredentials.credentialId })
+ .from(mcpUserCredentials)
+ .where(
+ and(
+ eq(mcpUserCredentials.serverId, rotationServerId),
+ eq(mcpUserCredentials.userId, rotationUserId),
+ ),
+ );
+ return row?.credentialId ?? null;
+ }
+
+ /**
+ * Every vault row this person's connection has ever had, live or revoked.
+ *
+ * The count is the point. A rotating vendor issues a new refresh token on every exchange, so a
+ * rotation that minted a row would leave one row per tool call here — which is invisible to any
+ * assertion that only looks at where the connection currently points.
+ */
+ async function connectionVaultRows() {
+ return (
+ database
+ .select({
+ id: credentials.id,
+ encryptedValue: credentials.encryptedValue,
+ revokedAt: credentials.revokedAt,
+ })
+ .from(credentials)
+ .where(
+ and(
+ eq(credentials.kind, "mcp_user_token"),
+ eq(credentials.provider, rotationServerId),
+ eq(credentials.keyId, rotationUserId),
+ ),
+ )
+ // Ordered, so that comparing the whole list before and after is comparing the rows rather
+ // than whatever order the database felt like returning them in.
+ .orderBy(credentials.id)
+ );
+ }
+
+ /** How many times this person is recorded as having connected their account. */
+ async function connectedRows() {
+ return (
+ await database
+ .select({ actor: sql`payload ->> 'actor'` })
+ .from(auditEvents)
+ .where(
+ and(
+ eq(auditEvents.eventType, "mcp.account_connected"),
+ eq(auditEvents.targetId, rotationServerId),
+ sql`payload ->> 'actor' = ${rotationUserId}`,
+ ),
+ )
+ ).length;
+ }
+
+ /** Waiting for something the other call does, rather than for a duration. */
+ async function waitUntil(condition: () => boolean, what: string) {
+ for (let attempt = 0; attempt < 300; attempt += 1) {
+ if (condition()) return;
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ throw new Error(`timed out waiting for ${what}`);
+ }
+
+ /** A connection holding `rt-1`, written through the store so the vault is exercised. */
+ async function connect() {
+ await rotationStore.recordConnection({
+ serverId: rotationServerId,
+ userId: rotationUserId,
+ refreshToken: "rt-1",
+ scope: SCOPE,
+ });
+ log.length = 0;
+ exchanges.length = 0;
+ sent.length = 0;
+ }
+
+ let notionWasAlreadyConfigured = false;
+ /**
+ * The OAuth client this deployment had before the suite ran, restored afterwards.
+ *
+ * `mcp_servers.credential_id` is live configuration, and this suite repoints it. Restored
+ * unconditionally, because the delete below removes the row it would otherwise still address.
+ */
+ let clientBefore: string | null = null;
beforeAll(async () => {
- const encrypted = await encryptSecret(
- `${"A".repeat(43)}=`,
- "not-read-here",
+ await database
+ .insert(agents)
+ .values({
+ id: rotationBotId,
+ name: rotationBotId,
+ type: "remote_ag_ui",
+ configuration: {},
+ })
+ .onConflictDoNothing();
+ await database
+ .insert(users)
+ .values({
+ id: rotationUserId,
+ email: `${rotationUserId}@openbot.test`,
+ name: rotationUserId,
+ emailVerified: false,
+ })
+ .onConflictDoNothing();
+
+ const [existing] = await database
+ .select({ id: mcpServers.id, credentialId: mcpServers.credentialId })
+ .from(mcpServers)
+ .where(eq(mcpServers.id, rotationServerId));
+ notionWasAlreadyConfigured = existing !== undefined;
+ clientBefore = existing?.credentialId ?? null;
+
+ // Written directly, so the test needs no vendor to be reachable. What is under test is which
+ // refresh token the next exchange presents, not the listing.
+ await database
+ .insert(mcpServers)
+ .values({
+ id: rotationServerId,
+ title: "Notion",
+ vendor: "Notion",
+ url: "https://mcp.notion.com/mcp",
+ provenance: "first-party",
+ })
+ .onConflictDoNothing();
+ await database
+ .insert(mcpTools)
+ .values({
+ serverId: rotationServerId,
+ name: rotationToolName,
+ description: "Search pages.",
+ })
+ .onConflictDoNothing();
+ await rotationStore.grant(
+ "mcp",
+ rotationRef,
+ rotationBotId,
+ "admin@openbot.local",
);
- await database.insert(credentialRows).values([
- {
- id: deploymentCredentialId,
- kind: "mcp",
- provider: customServerId,
- keyId: customServerId,
- encryptedValue: encrypted,
- metadata: {},
- },
- {
- id: personalCredentialId,
- kind: "mcp_user_token",
- provider: "google-drive",
- // For a user token the key is the person, which is what makes one pickable by name from the
- // administrator's own credential list.
- keyId: `user_someone_else_${suffix}`,
- encryptedValue: encrypted,
- metadata: {},
- },
- {
- id: oauthClientCredentialId,
- kind: "mcp_oauth_client",
- provider: "google-drive",
- keyId: "google-drive",
- encryptedValue: encrypted,
- metadata: {},
- },
- ]);
+ await registerClient();
});
afterAll(async () => {
- // By prefix, not by the ids this suite meant to make: before the fix the refused adds succeed,
- // and a row left behind holds a foreign key onto the credentials deleted just below.
+ // This suite's own person, never every row for this vendor: the id carries the run's suffix.
await database
- .delete(mcpServers)
- .where(like(mcpServers.id, `${customServerId}%`));
+ .delete(mcpUserCredentials)
+ .where(
+ and(
+ eq(mcpUserCredentials.serverId, rotationServerId),
+ eq(mcpUserCredentials.userId, rotationUserId),
+ ),
+ );
+ // Before the deletes, because the column addresses one of the rows they remove.
await database
- .delete(credentialRows)
+ .update(mcpServers)
+ .set({ credentialId: clientBefore })
+ .where(eq(mcpServers.id, rotationServerId));
+ for (const id of vaultRows) {
+ await database.delete(credentials).where(eq(credentials.id, id));
+ }
+ await database
+ .delete(pluginGrants)
.where(
- inArray(credentialRows.id, [
- deploymentCredentialId,
- personalCredentialId,
- oauthClientCredentialId,
- ]),
+ and(
+ eq(pluginGrants.ref, rotationRef),
+ eq(pluginGrants.agentId, rotationBotId),
+ ),
+ );
+ await database
+ .delete(mcpTools)
+ .where(
+ and(
+ eq(mcpTools.serverId, rotationServerId),
+ eq(mcpTools.name, rotationToolName),
+ ),
);
+ // A server row is deployment configuration, so it goes only if this suite is what added it.
+ if (!notionWasAlreadyConfigured) {
+ await database
+ .delete(mcpTools)
+ .where(eq(mcpTools.serverId, rotationServerId));
+ await database
+ .delete(mcpServers)
+ .where(eq(mcpServers.id, rotationServerId));
+ }
+ await database.delete(agents).where(eq(agents.id, rotationBotId));
+ await database.delete(users).where(eq(users.id, rotationUserId));
});
- test("somebody else's connector token is refused, and no server is written", async () => {
- const id = `${customServerId}-personal`;
- await expect(
- store.addCustomServer({
- id,
- title: "Collector",
- url: "https://collector.example/mcp",
- credentialId: personalCredentialId,
- by: "admin@example.com",
- }),
- ).rejects.toBeInstanceOf(CustomServerRefusedError);
+ test("the list says which servers register their own OAuth client", async () => {
+ // Notion is dynamic (RFC 7591, registered by this deployment on first connect); Google Drive
+ // is not — an administrator pastes its client in, so the paste-a-client form still has a job
+ // to do there. The field distinguishes the two so the admin screen can hide the form only
+ // where it would otherwise be filled in with nothing to type.
+ const servers = await store.listServers();
+ const notion = servers.find((server) => server.id === rotationServerId);
+ const drive = servers.find((server) => server.id === serverId);
+ expect(notion?.dynamicClient).toBe(true);
+ expect(drive?.dynamicClient).toBe(false);
+ });
- // The refusal has to stop the write, not merely report on it: a row here is a pointer the next
- // refresh would dereference.
- const rows = await database
- .select({ id: mcpServers.id })
- .from(mcpServers)
- .where(eq(mcpServers.id, id));
- expect(rows).toHaveLength(0);
+ test("the token the vendor rotated to is the one the next call presents", async () => {
+ await connect();
+ const before = await connectionCredential();
+ const rowsBefore = await connectionVaultRows();
+ const connectedBefore = await connectedRows();
+ mint = async () => ({ accessToken: "at-1", refreshToken: "rt-2" });
+
+ await rotationStore.callTool({
+ ref: rotationRef,
+ args: {},
+ botId: rotationBotId,
+ actorId: rotationUserId,
+ });
+ await rotationStore.callTool({
+ ref: rotationRef,
+ args: {},
+ botId: rotationBotId,
+ actorId: rotationUserId,
+ });
+
+ // The whole property: the second exchange presented what the first was given back.
+ expect(exchanges.map((exchange) => exchange.received)).toEqual([
+ "rt-1",
+ "rt-2",
+ ]);
+ // Both calls went out with an access token, so neither was refused on the way.
+ expect(sent).toEqual(["at-1", "at-1"]);
+
+ /*
+ * Two rotations, and the vault holds exactly what it held before: the same row, still live,
+ * carrying the latest token.
+ *
+ * This is the whole reason rotation is in place rather than a swap. Every single call to a
+ * rotating vendor rotates, so minting a row per rotation would grow the vault without bound on
+ * the hottest path there is — and would revoke a grant the vendor had already killed itself the
+ * moment it handed the new token back.
+ */
+ const after = await connectionCredential();
+ expect(after).toBe(before);
+ const rowsAfter = await connectionVaultRows();
+ expect(rowsAfter.map((row) => row.id)).toEqual(
+ rowsBefore.map((row) => row.id),
+ );
+ const live = rowsAfter.filter((row) => row.revokedAt === null);
+ expect(live.map((row) => row.id)).toEqual([before]);
+ // And the row that stayed is the one the vendor rotated to, not the one it replaced.
+ expect(
+ await decryptSecret(ROTATION_KEY, live[0]?.encryptedValue ?? ""),
+ ).toBe("rt-2");
+
+ // And nothing claims the person connected an account again. Rotation is the vendor's plumbing,
+ // not somebody's act, and a trail that says otherwise is read as a re-consent that never
+ // happened.
+ expect(await connectedRows()).toBe(connectedBefore);
});
- test("the deployment's OAuth client is refused too", async () => {
- // Not a per-person secret, but not this server's token either, and handing a vendor its own
- // client secret as a bearer token is the mistake `refreshTools` was already changed to avoid.
- const id = `${customServerId}-client`;
- await expect(
- store.addCustomServer({
- id,
- title: "Collector",
- url: "https://collector.example/mcp",
- credentialId: oauthClientCredentialId,
- by: "admin@example.com",
- }),
- ).rejects.toBeInstanceOf(CustomServerRefusedError);
+ test("a vendor that does not rotate leaves the connection alone", async () => {
+ await connect();
+ const before = await connectionCredential();
+ // Google's reply: an access token and nothing else. Repointing anything here would be inventing
+ // a rotation the vendor did not perform.
+ mint = async () => ({ accessToken: "at-1" });
+
+ await rotationStore.callTool({
+ ref: rotationRef,
+ args: {},
+ botId: rotationBotId,
+ actorId: rotationUserId,
+ });
+ await rotationStore.callTool({
+ ref: rotationRef,
+ args: {},
+ botId: rotationBotId,
+ actorId: rotationUserId,
+ });
+
+ expect(exchanges.map((exchange) => exchange.received)).toEqual([
+ "rt-1",
+ "rt-1",
+ ]);
+ // Said explicitly, because it is the condition the store branches on: no refresh token came
+ // back at all. A test that only checked the connection was untouched would pass just as well
+ // against a store that rotated to the token it already held.
+ expect(exchanges.map((exchange) => exchange.returned)).toEqual([
+ undefined,
+ undefined,
+ ]);
+ expect(await connectionCredential()).toBe(before);
});
- test("a credential that does not exist is refused the same way", async () => {
- // Same message as the wrong-kind refusal on purpose. A caller who can tell "wrong kind" from
- // "no such row" can ask this endpoint which ids are real, which is a vault oracle.
- const id = `${customServerId}-missing`;
- const missing = store.addCustomServer({
- id,
- title: "Collector",
- url: "https://collector.example/mcp",
- credentialId: randomUUID(),
- by: "admin@example.com",
+ test("a vendor that hands the same token back writes nothing", async () => {
+ await connect();
+ const before = await connectionVaultRows();
+ // Notion's reply when the grant did not move: a fresh access token and the refresh token we
+ // presented. Nothing rotated, so there is nothing to persist.
+ mint = async () => ({ accessToken: "at-1", refreshToken: "rt-1" });
+
+ await rotationStore.callTool({
+ ref: rotationRef,
+ args: {},
+ botId: rotationBotId,
+ actorId: rotationUserId,
});
- await expect(missing).rejects.toBeInstanceOf(CustomServerRefusedError);
- const wrongKind = store
- .addCustomServer({
- id: `${customServerId}-kind-message`,
+ /*
+ * Byte-identical, which is a stronger claim than "same row".
+ *
+ * Encryption draws a fresh IV every time, so re-encrypting the very same token would leave a
+ * different envelope in the same row. An untouched envelope is the only evidence that the write
+ * did not happen at all.
+ */
+ expect(await connectionVaultRows()).toEqual(before);
+ expect(sent).toEqual(["at-1"]);
+ });
+
+ test("two calls at once take turns, and the second spends what the first was given", async () => {
+ await connect();
+ let release: (minted: AccessToken) => void = () => {};
+ const parked = new Promise((resolve) => {
+ release = resolve;
+ });
+ let asked = 0;
+ mint = async () => {
+ asked += 1;
+ // The first exchange hangs until this test lets it finish. The second must not have started.
+ return asked === 1
+ ? parked
+ : { accessToken: "at-2", refreshToken: "rt-3" };
+ };
+
+ const both = Promise.allSettled([
+ rotationStore.callTool({
+ ref: rotationRef,
+ args: {},
+ botId: rotationBotId,
+ actorId: rotationUserId,
+ }),
+ rotationStore.callTool({
+ ref: rotationRef,
+ args: {},
+ botId: rotationBotId,
+ actorId: rotationUserId,
+ }),
+ ]);
+
+ try {
+ await waitUntil(() => log.length > 0, "the first exchange to start");
+ /*
+ * Long enough for a second, unserialised call to reach the vendor on its own. Its queries are
+ * a few milliseconds against a local database, so an overlapping exchange would be in the log
+ * by now — and with the exchanges serialised, waiting changes nothing at all.
+ */
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ expect(log).toEqual(["start:rt-1"]);
+ } finally {
+ release({ accessToken: "at-1", refreshToken: "rt-2" });
+ }
+
+ const results = await both;
+ expect(results.map((result) => result.status)).toEqual([
+ "fulfilled",
+ "fulfilled",
+ ]);
+ // One after another, never interleaved, and the second presented the first's rotated token —
+ // which is only possible because the first persisted it before answering.
+ expect(log).toEqual(["start:rt-1", "end:rt-1", "start:rt-2", "end:rt-2"]);
+ expect(exchanges).toEqual([
+ { received: "rt-1", returned: "rt-2" },
+ { received: "rt-2", returned: "rt-3" },
+ ]);
+ });
+
+ /**
+ * Two replicas, one connection, and the vendor shown each refresh token exactly once.
+ *
+ * This is the case the in-process queue cannot reach. Each replica has its own map, so both read
+ * the stored token, both present it, and a vendor with refresh-token-reuse detection reads the
+ * second presentation as a stolen token and revokes the whole family — bricking a connection that
+ * nobody did anything wrong with. The row lock is what makes the second replica wait and then read
+ * what the first rotated to.
+ */
+ test("two replicas take turns at the row, and neither spends a token twice", async () => {
+ await connect();
+ /** Every refresh token the vendor was shown, by either replica, in order. */
+ const presented: string[] = [];
+ let issued = 1;
+ const exchange = async (refreshToken: string) => {
+ presented.push(refreshToken);
+ /*
+ * Long enough that an unlocked second replica has read the vault and presented what it found
+ * there before this exchange answers. With the lock held it changes nothing except how long
+ * the other replica waits for its turn.
+ */
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ issued += 1;
+ return { accessToken: `at-${issued}`, refreshToken: `rt-${issued}` };
+ };
+ const first = replica(exchange);
+ const second = replica(exchange);
+
+ const call = (store: ReturnType) =>
+ store.callTool({
+ ref: rotationRef,
+ args: {},
+ botId: rotationBotId,
+ actorId: rotationUserId,
+ });
+ const results = await Promise.all([call(first), call(second)]);
+
+ expect(results.map((result) => result.isError)).toEqual([false, false]);
+ // The whole property. `["rt-1", "rt-1"]` is the double-spend: two replicas presenting one token.
+ expect(presented).toEqual(["rt-1", "rt-2"]);
+ // And the row the connection points at carries the last token issued, so a third call would
+ // present that rather than something either replica had already spent.
+ const live = (await connectionVaultRows()).filter(
+ (row) => row.revokedAt === null,
+ );
+ expect(
+ await decryptSecret(ROTATION_KEY, live[0]?.encryptedValue ?? ""),
+ ).toBe("rt-3");
+ });
+});
+
+/**
+ * A client this deployment registered for itself, which the vendor has since forgotten.
+ *
+ * A dynamically registered client is nobody's paperwork: there is no console entry an administrator
+ * could go and re-create, so a vendor that evicts one — a pruned test client, an expired
+ * registration — would otherwise strand every connection to that server behind a refusal nobody in
+ * the deployment can act on. The one thing the deployment CAN do is introduce itself again, which is
+ * exactly what it did the first time, so it does that once and retries.
+ *
+ * Once, and only once. A retry that re-registered on every refusal would answer a vendor outage by
+ * minting clients in a loop, and the second refusal is the honest signal that the problem is not the
+ * client at all.
+ */
+describe("a dynamic client the vendor has evicted", () => {
+ const dynamicBotId = `agent_dynamic_bot_${suite}`;
+ const dynamicUserId = `user_dynamic_${suite}`;
+ /** Notion, because it is the entry that registers itself. */
+ const dynamicServerId = "notion";
+ /** Suite-scoped, so it cannot collide with a name Notion really advertises. */
+ const dynamicToolName = `search_dyn_${suite}`;
+ const dynamicRef = `${dynamicServerId}/${dynamicToolName}`;
+ /** 32 zero bytes in base64: a real AES-256 key, because every call here opens the vault. */
+ const DYNAMIC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
+ /** The client the deployment registered once and the vendor has stopped honouring. */
+ const EVICTED: OAuthClient = { clientId: "dyn-1", clientSecret: "" };
+ /** What registering again gets. No secret: a DCR client proves itself with PKCE. */
+ const FRESH: OAuthClient = { clientId: "dyn-2", clientSecret: "" };
+ /** Built the way the callback route builds it, so the vendor is offered the real thing. */
+ const REDIRECT_URI = redirectUriFor("https://openbot.test");
+ /** The pinned endpoint, read from the entry rather than copied, so the two cannot drift. */
+ const REGISTRATION_URL = (() => {
+ const entry = catalogueEntry(dynamicServerId);
+ if (entry?.auth.kind !== "user-oauth" || !entry.auth.registrationUrl) {
+ throw new Error(
+ "notion is not a dynamically registered user-oauth entry",
+ );
+ }
+ return entry.auth.registrationUrl;
+ })();
+ const SCOPE = "";
+
+ /** Every vault row this suite created, so the cleanup can take exactly those. */
+ const vaultRows: string[] = [];
+ /** Which client each exchange was offered, in order. One entry per call, never two. */
+ const offered: string[] = [];
+ /**
+ * Every exchange as the pair it really is: which client presented which grant.
+ *
+ * The pair is the property, not either half. A refresh token belongs to the client it was issued
+ * to — RFC 6749 §6 has the token endpoint check exactly that, and §10.4 says why — so a pair
+ * naming a token under a client it was never issued to is this deployment attempting to spend one
+ * client's grant as another's. No call may ever produce one.
+ */
+ const exchanges: { clientId: string; refreshToken: string }[] = [];
+ /** Which client the vendor issued each refresh token to, so the stub can enforce the binding. */
+ const issuedTo = new Map();
+ /** Every registration the store asked the vendor for, with what it asked with. */
+ const registrations: { registrationUrl: string; redirectUri: string }[] = [];
+ /** Which client ids the vendor still honours. Anything else is answered `invalid_client`. */
+ let accepted = new Set();
+ /** What the vendor's registration endpoint hands back, installed per test. */
+ let issue: () => OAuthClient | null = () => {
+ throw new Error("no registration was installed for this test");
+ };
+
+ /*
+ * The real vault, with every row it mints written down.
+ *
+ * Genuine rather than stubbed, because what this suite asserts is that a re-registered client is
+ * KEPT — which is a write and a read back through the encryption, not a call that was made. The
+ * one wrapper is the bookkeeping that lets the cleanup take exactly this suite's rows.
+ */
+ const realVault = createCredentialStore(database);
+ const vault = {
+ ...realVault,
+ /*
+ * The executor is FORWARDED, and dropping it is not a detail.
+ *
+ * The store hands its own transaction to the vault so that a secret and the pointer that names it
+ * commit together. A wrapper that swallows it has the insert run on a second pooled connection
+ * instead — which, with the caller holding the first and a sibling holding the second, is not a
+ * slower write but a deadlock: the insert waits for a connection only a transaction that is
+ * waiting for the insert can release.
+ */
+ create: async (
+ value: Parameters[0],
+ executor?: Parameters[1],
+ ) => {
+ const row = await realVault.create(value, executor);
+ vaultRows.push(row.id);
+ return row;
+ },
+ };
+
+ /**
+ * How the vendor refuses a client it no longer honours.
+ *
+ * Both halves of what `exchangeRefreshTokenOverHttp` builds for a reply carrying an `error` code:
+ * the sentence a person reads, and the code as a FIELD. The field is the half the retry
+ * reads, which is why it is set structurally here rather than spelled into the prose — two tests
+ * below vary each half independently to prove which one is load-bearing.
+ */
+ const evictionRefusal = () =>
+ new TokenRefusedError(
+ "The vendor would not renew this access (401). (invalid_client)",
+ INVALID_CLIENT,
+ );
+ /** The refusal in force, so a test can vary the sentence or the code. Reset before each. */
+ let refuse: () => Error = evictionRefusal;
+
+ /*
+ * The exchange, standing in for the vendor's token endpoint.
+ */
+ const seams = {
+ exchangeRefreshToken: async ({
+ client,
+ refreshToken,
+ }: {
+ tokenUrl: string;
+ client: OAuthClient;
+ refreshToken: string;
+ }): Promise => {
+ offered.push(client.clientId);
+ exchanges.push({ clientId: client.clientId, refreshToken });
+ if (!accepted.has(client.clientId)) {
+ throw refuse();
+ }
+ /*
+ * A grant belongs to one client, and this stub enforces it.
+ *
+ * It did not, and that omission is what made the old "register again and re-present the same
+ * refresh token" retry look like it worked. It only ever worked against a vendor that skipped
+ * the check RFC 6749 §6 requires — so the mechanism was pinned by a fixture whose behaviour
+ * would itself have been the vulnerability.
+ */
+ const owner = issuedTo.get(refreshToken);
+ if (owner !== undefined && owner !== client.clientId) {
+ throw new TokenRefusedError(
+ "The vendor would not renew this access (400). (invalid_grant)",
+ "invalid_grant",
+ );
+ }
+ // The same token back, so nothing rotates: what this suite is about is the client.
+ return { accessToken: `at-${client.clientId}`, refreshToken };
+ },
+ registerClient: async (input: {
+ registrationUrl: string;
+ redirectUri: string;
+ }) => {
+ registrations.push(input);
+ return issue();
+ },
+ };
+
+ const dynamicStore = createPluginStore({
+ database,
+ auditStore: createAuditStore(database),
+ credentials: vault,
+ encryptionKey: DYNAMIC_KEY,
+ policy: () => policy,
+ callVendor: async () => ({
+ text: "[vendor not reached in tests]",
+ isError: false,
+ }),
+ ...seams,
+ redirectUri: REDIRECT_URI,
+ });
+
+ /**
+ * The same store, for a deployment with no public URL.
+ *
+ * There is nowhere for the vendor to send anybody back to, so there is nothing honest to register
+ * — and registering a redirect URI that does not resolve would leave a client that can never
+ * complete a consent flow.
+ */
+ const storeWithNoRedirect = createPluginStore({
+ database,
+ auditStore: createAuditStore(database),
+ credentials: vault,
+ encryptionKey: DYNAMIC_KEY,
+ policy: () => policy,
+ ...seams,
+ });
+
+ /**
+ * Point the server at a client, the way a registration does, without going through one.
+ *
+ * Aged an hour by default, because that is the client these tests are about: one the deployment
+ * has been using for a while and the vendor has since evicted. A row written a moment ago is
+ * inside the re-registration window and is deliberately not registered around, which is its own
+ * test below rather than the state every other test starts from.
+ */
+ async function putClient(
+ client: OAuthClient,
+ registeredAt = new Date(Date.now() - 60 * 60 * 1000),
+ ) {
+ /*
+ * One live client per key is law (`credentials_active_key_idx`), so planting a client the way a
+ * registration would means retiring whatever live row the key still holds from an earlier test.
+ */
+ await database
+ .update(credentials)
+ .set({ revokedAt: new Date(), updatedAt: new Date() })
+ .where(
+ and(
+ eq(credentials.kind, "mcp_oauth_client"),
+ eq(credentials.provider, dynamicServerId),
+ eq(credentials.keyId, `oauth-client-${dynamicServerId}`),
+ sql`${credentials.revokedAt} IS NULL`,
+ ),
+ );
+ const [row] = await database
+ .insert(credentials)
+ .values({
+ kind: "mcp_oauth_client",
+ provider: dynamicServerId,
+ keyId: `oauth-client-${dynamicServerId}`,
+ metadata: { clientId: client.clientId },
+ encryptedValue: await encryptSecret(
+ DYNAMIC_KEY,
+ JSON.stringify(client),
+ ),
+ createdAt: registeredAt,
+ })
+ .returning({ id: credentials.id });
+ if (!row) throw new Error("client was not stored");
+ vaultRows.push(row.id);
+ await database
+ .update(mcpServers)
+ .set({ credentialId: row.id })
+ .where(eq(mcpServers.id, dynamicServerId));
+ }
+
+ /** A deployment that holds no client for this server at all. */
+ async function clearClient(serverId = dynamicServerId) {
+ await database
+ .update(mcpServers)
+ .set({ credentialId: null })
+ .where(eq(mcpServers.id, serverId));
+ }
+
+ /**
+ * How many of those rows say a particular actor registered a particular client.
+ *
+ * Counted rather than "the most recent row", because these rows have no ordering finer than the
+ * second they were written in and this suite writes several of them.
+ */
+ const registeredBy = (
+ rows: { actor: string; clientId: string }[],
+ actor: string,
+ clientId: string,
+ ) =>
+ rows.filter((row) => row.actor === actor && row.clientId === clientId)
+ .length;
+
+ /** What the trail says about clients registered for this server, and by whom. */
+ async function registeredRows() {
+ return database
+ .select({
+ actor: sql`payload ->> 'actor'`,
+ clientId: sql`payload ->> 'clientId'`,
+ })
+ .from(auditEvents)
+ .where(
+ and(
+ eq(auditEvents.eventType, "mcp.oauth_client_registered"),
+ eq(auditEvents.targetId, dynamicServerId),
+ ),
+ );
+ }
+
+ /**
+ * A connection holding `rt-1`, written through the store so the vault is exercised.
+ *
+ * `issuedBy` is which client the vendor issued that grant to, which is the fact the stub above
+ * enforces. It is the evicted one in every test here, because that is what an eviction means: the
+ * grant somebody holds was obtained under the client the vendor has since stopped honouring.
+ */
+ async function connect(issuedBy: OAuthClient = EVICTED) {
+ await dynamicStore.recordConnection({
+ serverId: dynamicServerId,
+ userId: dynamicUserId,
+ refreshToken: "rt-1",
+ scope: SCOPE,
+ });
+ issuedTo.set("rt-1", issuedBy.clientId);
+ offered.length = 0;
+ exchanges.length = 0;
+ registrations.length = 0;
+ }
+
+ /** One tool call by the connected person, which is every call this suite makes. */
+ const call = () =>
+ dynamicStore.callTool({
+ ref: dynamicRef,
+ args: {},
+ botId: dynamicBotId,
+ actorId: dynamicUserId,
+ });
+
+ let notionWasAlreadyConfigured = false;
+ /** This deployment's own client, restored afterwards: the column is live configuration. */
+ let clientBefore: string | null = null;
+
+ // The vendor refuses the ordinary way unless a test says otherwise, so a test that varies the
+ // refusal cannot leave the next one asserting against somebody else's setup.
+ beforeEach(() => {
+ refuse = evictionRefusal;
+ });
+
+ beforeAll(async () => {
+ await database
+ .insert(agents)
+ .values({
+ id: dynamicBotId,
+ name: dynamicBotId,
+ type: "remote_ag_ui",
+ configuration: {},
+ })
+ .onConflictDoNothing();
+ await database
+ .insert(users)
+ .values({
+ id: dynamicUserId,
+ email: `${dynamicUserId}@openbot.test`,
+ name: dynamicUserId,
+ emailVerified: false,
+ })
+ .onConflictDoNothing();
+
+ const [existing] = await database
+ .select({ id: mcpServers.id, credentialId: mcpServers.credentialId })
+ .from(mcpServers)
+ .where(eq(mcpServers.id, dynamicServerId));
+ notionWasAlreadyConfigured = existing !== undefined;
+ clientBefore = existing?.credentialId ?? null;
+
+ await database
+ .insert(mcpServers)
+ .values({
+ id: dynamicServerId,
+ title: "Notion",
+ vendor: "Notion",
+ url: "https://mcp.notion.com/mcp",
+ provenance: "first-party",
+ })
+ .onConflictDoNothing();
+ await database
+ .insert(mcpTools)
+ .values({
+ serverId: dynamicServerId,
+ name: dynamicToolName,
+ description: "Search pages.",
+ })
+ .onConflictDoNothing();
+ await dynamicStore.grant(
+ "mcp",
+ dynamicRef,
+ dynamicBotId,
+ "admin@openbot.local",
+ );
+ });
+
+ afterAll(async () => {
+ await database
+ .delete(mcpUserCredentials)
+ .where(
+ and(
+ eq(mcpUserCredentials.serverId, dynamicServerId),
+ eq(mcpUserCredentials.userId, dynamicUserId),
+ ),
+ );
+ // Before the deletes, because the column addresses one of the rows they remove.
+ await database
+ .update(mcpServers)
+ .set({ credentialId: clientBefore })
+ .where(eq(mcpServers.id, dynamicServerId));
+ for (const id of vaultRows) {
+ await database.delete(credentials).where(eq(credentials.id, id));
+ }
+ await database
+ .delete(pluginGrants)
+ .where(
+ and(
+ eq(pluginGrants.ref, dynamicRef),
+ eq(pluginGrants.agentId, dynamicBotId),
+ ),
+ );
+ await database
+ .delete(mcpTools)
+ .where(
+ and(
+ eq(mcpTools.serverId, dynamicServerId),
+ eq(mcpTools.name, dynamicToolName),
+ ),
+ );
+ if (!notionWasAlreadyConfigured) {
+ await database
+ .delete(mcpTools)
+ .where(eq(mcpTools.serverId, dynamicServerId));
+ await database
+ .delete(mcpServers)
+ .where(eq(mcpServers.id, dynamicServerId));
+ }
+ await database.delete(agents).where(eq(agents.id, dynamicBotId));
+ await database.delete(users).where(eq(users.id, dynamicUserId));
+ });
+
+ test("the deployment registers again, once, and refuses this call", async () => {
+ await putClient(EVICTED);
+ await connect();
+ const registeredBefore = await registeredRows();
+ // The vendor honours the fresh client, so a retry under it is exactly what would have LOOKED
+ // like a recovery. The point of this test is that it is not attempted.
+ accepted = new Set([FRESH.clientId]);
+ issue = () => FRESH;
+
+ /*
+ * The person is told to connect again, because that is the only thing that can help them.
+ *
+ * Their refresh token was issued to the client the vendor has forgotten, and a grant belongs to
+ * the client it was issued to. There is no arrangement of stored secrets that turns it into a
+ * usable one — only a new consent under the client that now exists.
+ */
+ await expect(call()).rejects.toThrow(
+ "Notion no longer recognises this deployment's OAuth client",
+ );
+
+ /*
+ * One exchange, on the client the deployment held. The old grant is never presented to the new
+ * client: a conforming vendor refuses that (RFC 6749 §6), so the retry that used to be here
+ * could only ever have succeeded against a vendor whose acceptance was itself the bug.
+ */
+ expect(offered).toEqual([EVICTED.clientId]);
+ expect(exchanges).toEqual([
+ { clientId: EVICTED.clientId, refreshToken: "rt-1" },
+ ]);
+
+ // Registered exactly once, with the pinned endpoint and the deployment's own redirect URI —
+ // never a URL from the request, which is the property `redirectUriFor` exists for.
+ expect(registrations).toEqual([
+ { registrationUrl: REGISTRATION_URL, redirectUri: REDIRECT_URI },
+ ]);
+
+ // And kept, so the connect this refusal sends somebody to uses the client that works.
+ expect(await dynamicStore.oauthClientFor(dynamicServerId)).toEqual(FRESH);
+
+ /*
+ * With a row in the trail saying the deployment did it to itself.
+ *
+ * `deployment` rather than the person whose call triggered it: they consented to nothing here,
+ * and a trail naming them would read as an administrator having registered a client.
+ */
+ const registered = await registeredRows();
+ expect(registered.length).toBe(registeredBefore.length + 1);
+ expect(registeredBy(registered, "deployment", FRESH.clientId)).toBe(
+ registeredBy(registeredBefore, "deployment", FRESH.clientId) + 1,
+ );
+ });
+
+ test("a vendor refusing everything costs one registration, not one per call", async () => {
+ await putClient(EVICTED);
+ await connect();
+ // The vendor honours nothing, which is what an outage looks like from here.
+ accepted = new Set();
+ issue = () => ({ clientId: "dyn-3", clientSecret: "" });
+
+ await expect(call()).rejects.toThrow(
+ "Notion no longer recognises this deployment's OAuth client",
+ );
+ expect(registrations.length).toBe(1);
+ expect(exchanges).toEqual([
+ { clientId: EVICTED.clientId, refreshToken: "rt-1" },
+ ]);
+
+ /*
+ * The next call is a NEW call, not a retry: it reads the client the deployment now holds, offers
+ * it once, and is refused. What it must not do is register a second one — dyn-3 was stored
+ * moments ago, so it is inside the re-registration window and is left alone. That is the
+ * difference between an outage costing one client and costing one per tool call.
+ */
+ exchanges.length = 0;
+ await expect(call()).rejects.toThrow("invalid_client");
+ expect(registrations.length).toBe(1);
+ expect(exchanges).toEqual([{ clientId: "dyn-3", refreshToken: "rt-1" }]);
+ });
+
+ /**
+ * A client minted moments ago is not registered around again.
+ *
+ * Once per call is right for one call and wrong for a deployment: a vendor answering every
+ * exchange `invalid_client` — an outage, not an eviction — has every tool call anywhere in the
+ * deployment mint a client of its own, because each of them is the first refusal it has seen.
+ * The age of the stored client is the one piece of shared state that says otherwise, and a client
+ * younger than the window is already the product of somebody's re-registration.
+ */
+ test("a client registered moments ago is refused rather than replaced", async () => {
+ // Written now, the way a re-registration would have written it a moment ago.
+ await putClient(EVICTED, new Date());
+ await connect();
+ accepted = new Set();
+ issue = () => FRESH;
+
+ // The vendor's own refusal, surfaced as it stands: nothing here can improve on it.
+ await expect(call()).rejects.toThrow("invalid_client");
+
+ expect(registrations).toEqual([]);
+ expect(offered).toEqual([EVICTED.clientId]);
+ expect(exchanges).toEqual([
+ { clientId: EVICTED.clientId, refreshToken: "rt-1" },
+ ]);
+ });
+
+ /**
+ * The code decides, not the sentence.
+ *
+ * The sentence is written for a person and will be reworded — shortened, translated, given a
+ * different parenthesis. When the recovery hung on a substring of it, any of those edits would
+ * have switched self-registration off with every test in this file still passing, and the symptom
+ * would have been every Notion connection in the deployment stranded behind a refusal.
+ */
+ test("a refusal that words it differently still re-registers", async () => {
+ await putClient(EVICTED);
+ await connect();
+ accepted = new Set([FRESH.clientId]);
+ issue = () => FRESH;
+ // Not one character of the code anywhere in the prose.
+ refuse = () =>
+ new TokenRefusedError(
+ "Le fournisseur a refusé de renouveler cet accès (401).",
+ INVALID_CLIENT,
+ );
+
+ await expect(call()).rejects.toThrow(
+ "Notion no longer recognises this deployment's OAuth client",
+ );
+
+ expect(offered).toEqual([EVICTED.clientId]);
+ expect(registrations.length).toBe(1);
+ expect(await dynamicStore.oauthClientFor(dynamicServerId)).toEqual(FRESH);
+ });
+
+ /** And the other way round: prose that says the word, over a code that does not. */
+ test("a refusal whose code is another one is not registered around", async () => {
+ await putClient(EVICTED);
+ await connect();
+ accepted = new Set();
+ issue = () => FRESH;
+ refuse = () =>
+ new TokenRefusedError(
+ "The vendor would not renew this access (400). (invalid_grant, not an invalid_client problem)",
+ "invalid_grant",
+ );
+
+ await expect(call()).rejects.toThrow("invalid_grant");
+
+ // A withdrawn grant is the person's to fix by connecting again. Minting a client for it would
+ // leave a spare client behind and still refuse.
+ expect(registrations).toEqual([]);
+ expect(offered).toEqual([EVICTED.clientId]);
+ });
+
+ /**
+ * Two calls queued on one connection, and one registration between them.
+ *
+ * The client is read INSIDE the per-connection critical section, so the second call reads it after
+ * the first has replaced it. Read before the queue instead, both calls would carry the evicted
+ * client in, both would be refused, and both would register — a client minted per queued call, on
+ * a deployment whose client the first call already replaced.
+ *
+ * Both calls fail, and they fail differently, which is the honest outcome. The first found the
+ * client evicted; the second offered the client that now exists and was refused because the grant
+ * it holds was issued to the old one. Only a new consent fixes that, and both refusals say so.
+ */
+ test("two calls queued on one connection register once between them", async () => {
+ await putClient(EVICTED);
+ await connect();
+ accepted = new Set([FRESH.clientId]);
+ issue = () => FRESH;
+
+ const results = await Promise.allSettled([call(), call()]);
+
+ expect(results.map((result) => result.status)).toEqual([
+ "rejected",
+ "rejected",
+ ]);
+ // One exchange per call, and never a token offered twice inside one of them.
+ expect(offered).toEqual([EVICTED.clientId, FRESH.clientId]);
+ expect(registrations.length).toBe(1);
+ });
+
+ test("a client the deployment already holds is handed back untouched", async () => {
+ await putClient(EVICTED);
+ registrations.length = 0;
+
+ expect(
+ await dynamicStore.ensureOAuthClient(
+ dynamicServerId,
+ "someone@openbot.test",
+ ),
+ ).toEqual(EVICTED);
+ // Nothing was asked of the vendor: this is the path every connect takes once, and it must not
+ // mint a client on top of the working one.
+ expect(registrations).toEqual([]);
+ });
+
+ test("a dynamic entry with no client gets one, kept and recorded", async () => {
+ await clearClient();
+ registrations.length = 0;
+ const registeredBefore = await registeredRows();
+ issue = () => FRESH;
+
+ expect(
+ await dynamicStore.ensureOAuthClient(
+ dynamicServerId,
+ "someone@openbot.test",
+ ),
+ ).toEqual(FRESH);
+ expect(registrations).toEqual([
+ { registrationUrl: REGISTRATION_URL, redirectUri: REDIRECT_URI },
+ ]);
+ expect(await dynamicStore.oauthClientFor(dynamicServerId)).toEqual(FRESH);
+
+ const registered = await registeredRows();
+ expect(registered.length).toBe(registeredBefore.length + 1);
+ // Whoever pressed Connect, because for a first registration that IS the act that caused it.
+ expect(
+ registeredBy(registered, "someone@openbot.test", FRESH.clientId),
+ ).toBe(
+ registeredBy(registeredBefore, "someone@openbot.test", FRESH.clientId) +
+ 1,
+ );
+ });
+
+ test("an entry an administrator registers by hand is left alone", async () => {
+ /*
+ * Drive, whose client is pasted in from Google's console. Registering one for it would be
+ * inventing a client at a vendor that never offered to issue one — the honest answer is none,
+ * and the 409 an administrator sees is the instruction to go and paste one.
+ */
+ const [before] = await database
+ .select({ credentialId: mcpServers.credentialId })
+ .from(mcpServers)
+ .where(eq(mcpServers.id, serverId));
+ await clearClient(serverId);
+ registrations.length = 0;
+
+ try {
+ expect(
+ await dynamicStore.ensureOAuthClient(serverId, "someone@openbot.test"),
+ ).toBeNull();
+ expect(registrations).toEqual([]);
+ } finally {
+ await database
+ .update(mcpServers)
+ .set({ credentialId: before?.credentialId ?? null })
+ .where(eq(mcpServers.id, serverId));
+ }
+ });
+
+ test("a deployment with no public URL registers nothing", async () => {
+ await clearClient();
+ registrations.length = 0;
+
+ expect(
+ await storeWithNoRedirect.ensureOAuthClient(
+ dynamicServerId,
+ "someone@openbot.test",
+ ),
+ ).toBeNull();
+ expect(registrations).toEqual([]);
+ });
+
+ /**
+ * Two people pressing Connect at the same moment, on a deployment holding no client yet.
+ *
+ * `POST /connect` is `requireUser`, not `requireAdmin`, so this is not a rare interleaving — it is
+ * the ordinary first hour of a connector nobody has used. Unserialised, the two runs read "no live
+ * client" and then both write one: the second `create` meets the first on
+ * `credentials_active_key_idx` as a raw 23505, which reaches the person as a 500 where a consent
+ * URL belonged, and a `rotate` racing the same way fails with "Previous credential is already
+ * revoked" instead.
+ *
+ * One client, not two, and that part is not only about the error. Two clients means one of the two
+ * consent screens names a client the vault no longer holds, so that person consents and their
+ * callback then redeems the code against the other client — a connect that fails after the vendor
+ * said yes, which is the hardest possible place to fail.
+ */
+ test("two first connects race to one client, and both callers get it", async () => {
+ await clearClient();
+ // No live row for the key either, so this really is a deployment holding nothing: `clearClient`
+ // only drops the pointer, and it is the KEY the index constrains.
+ await database
+ .update(credentials)
+ .set({ revokedAt: new Date(), updatedAt: new Date() })
+ .where(
+ and(
+ eq(credentials.kind, "mcp_oauth_client"),
+ eq(credentials.provider, dynamicServerId),
+ eq(credentials.keyId, `oauth-client-${dynamicServerId}`),
+ sql`${credentials.revokedAt} IS NULL`,
+ ),
+ );
+ registrations.length = 0;
+ // A distinct client per registration, so two registrations cannot be mistaken for one.
+ let issued = 0;
+ issue = () => {
+ issued += 1;
+ return { clientId: `dyn-race-${issued}`, clientSecret: "" };
+ };
+
+ const [first, second] = await Promise.all([
+ dynamicStore.ensureOAuthClient(dynamicServerId, "one@openbot.test"),
+ dynamicStore.ensureOAuthClient(dynamicServerId, "two@openbot.test"),
+ ]);
+
+ // Neither raised, and neither got null: both people can be sent to consent.
+ expect(first).not.toBeNull();
+ expect(second).not.toBeNull();
+ // The same client, so both consent screens name the client the deployment actually holds.
+ expect(first).toEqual(second);
+ expect(registrations.length).toBe(1);
+
+ /*
+ * One live row for the key, and the server row naming exactly it.
+ *
+ * The pair is the assertion, not either half: the vault write and the pointer write are one
+ * transaction now, so a reader can never see a live client the server row does not name, nor a
+ * server row naming a client the vault retired.
+ */
+ const live = await database
+ .select({ id: credentials.id })
+ .from(credentials)
+ .where(
+ and(
+ eq(credentials.kind, "mcp_oauth_client"),
+ eq(credentials.provider, dynamicServerId),
+ eq(credentials.keyId, `oauth-client-${dynamicServerId}`),
+ sql`${credentials.revokedAt} IS NULL`,
+ ),
+ );
+ expect(live.length).toBe(1);
+ const [server] = await database
+ .select({ credentialId: mcpServers.credentialId })
+ .from(mcpServers)
+ .where(eq(mcpServers.id, dynamicServerId));
+ expect(server?.credentialId).toBe(live[0]?.id);
+ });
+
+ /**
+ * A refresh naming the advertised tools this deployment's write list does not cover.
+ *
+ * Notion has no scope strings and no read-only scope: access is per-page, chosen on the consent
+ * screen, so `writeTools` plus the action policy are the ENTIRE write barrier. The entry's own
+ * comment says reconciling that list against the live tool list "is required, not cosmetic" — and
+ * until this row existed, nothing mechanical did it. An advertised tool missing from the list
+ * classifies as a READ ({@link classifyTool}), so under-inclusion is the failure mode and it is
+ * silent.
+ *
+ * The vendor here is a real MCP server on localhost, reached by pointing the pinned host at it for
+ * the length of this test. The host is pinned for good reasons and nothing in the store will take a
+ * URL from a caller, so the seam is fetch — which is also the honest one: what is under test is
+ * what a real listing over the real protocol produces.
+ */
+ test("a refresh names the advertised tools no write list covers", async () => {
+ await putClient(EVICTED);
+ await connect();
+ accepted = new Set([EVICTED.clientId]);
+
+ /** Suite-scoped, so it cannot be a name Notion really advertises, nor a name in `writeTools`. */
+ const unlistedName = `notion-invent-${suite}`;
+ const mock = new MCPMock();
+ mock
+ .addTool({
+ name: "notion-create-pages",
+ description: "A write the list already names.",
+ inputSchema: { type: "object", properties: {} },
+ })
+ .addTool({
+ name: unlistedName,
+ description: "Advertised, and named by no write list.",
+ inputSchema: { type: "object", properties: {} },
+ });
+ const mockUrl = await mock.start();
+
+ // What the deployment currently advertises for this server, because a refresh replaces the list
+ // wholesale and this one is pointing the vendor at a mock.
+ const advertisedBefore = await database
+ .select()
+ .from(mcpTools)
+ .where(eq(mcpTools.serverId, dynamicServerId));
+ const [stampBefore] = await database
+ .select({
+ toolsRefreshedAt: mcpServers.toolsRefreshedAt,
+ lastError: mcpServers.lastError,
+ })
+ .from(mcpServers)
+ .where(eq(mcpServers.id, dynamicServerId));
+
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
+ const target = String(input instanceof Request ? input.url : input);
+ return realFetch(
+ target.startsWith("https://mcp.notion.com") ? mockUrl : input,
+ init,
+ );
+ }) as typeof fetch;
+
+ try {
+ expect(
+ await dynamicStore.refreshTools(dynamicServerId, dynamicUserId),
+ ).toEqual({ tools: 2 });
+ } finally {
+ globalThis.fetch = realFetch;
+ await mock.stop?.();
+ await database
+ .delete(mcpTools)
+ .where(eq(mcpTools.serverId, dynamicServerId));
+ if (advertisedBefore.length > 0) {
+ await database.insert(mcpTools).values(advertisedBefore);
+ }
+ await database
+ .update(mcpServers)
+ .set({
+ toolsRefreshedAt: stampBefore?.toolsRefreshedAt ?? null,
+ lastError: stampBefore?.lastError ?? null,
+ })
+ .where(eq(mcpServers.id, dynamicServerId));
+ }
+
+ const named = (
+ await database
+ .select({ payload: auditEvents.payload })
+ .from(auditEvents)
+ .where(
+ and(
+ eq(auditEvents.eventType, "configuration.changed"),
+ eq(auditEvents.targetId, dynamicServerId),
+ sql`payload ->> 'change' = 'unlisted_tools_advertised'`,
+ ),
+ )
+ ).flatMap((row) => (row.payload as { tools?: string[] }).tools ?? []);
+
+ // The one the list does not name, and never the one it does.
+ expect(named).toContain(unlistedName);
+ expect(named).not.toContain("notion-create-pages");
+ });
+
+ /**
+ * What a failed refresh writes into `lastError`, and how much of it.
+ *
+ * The column is drawn on the admin page and parts of the sentence come from a vendor, so it is not
+ * a promise about length — the same reasoning `callTool` already applies to the failure it records.
+ * Capped at the same 400 characters, so the two agree.
+ */
+ test("a refusal written to lastError is capped like every other vendor sentence", async () => {
+ await putClient(EVICTED);
+ await connect();
+ // A refusal the retry cannot act on — no code at all — so it arrives unedited and long.
+ accepted = new Set();
+ refuse = () => new Error(`vendor said: ${"y".repeat(1_000)}`);
+
+ const [before] = await database
+ .select({ lastError: mcpServers.lastError })
+ .from(mcpServers)
+ .where(eq(mcpServers.id, dynamicServerId));
+
+ try {
+ // Zero tools and no throw: a refresh records its failure rather than raising it.
+ expect(
+ await dynamicStore.refreshTools(dynamicServerId, dynamicUserId),
+ ).toEqual({ tools: 0 });
+
+ const [row] = await database
+ .select({ lastError: mcpServers.lastError })
+ .from(mcpServers)
+ .where(eq(mcpServers.id, dynamicServerId));
+ expect(row?.lastError?.length).toBe(400);
+ expect(row?.lastError?.startsWith("vendor said: ")).toBe(true);
+ } finally {
+ // The column is live configuration an operator reads, so this suite puts back what it found.
+ await database
+ .update(mcpServers)
+ .set({ lastError: before?.lastError ?? null })
+ .where(eq(mcpServers.id, dynamicServerId));
+ }
+ });
+
+ /**
+ * The vault row and the pointer that names it commit together, or neither does.
+ *
+ * They were two transactions, so a failure between them left `mcp_user_credentials` naming a
+ * credential that had just been revoked — a connection that reads as live on the settings page and
+ * refuses every call. The pointer write is the one that can fail on its own: `user_id` is a real
+ * foreign key, so a person who is no longer in `users` is a genuine 23503 at exactly that
+ * statement, which is the injection this test uses rather than a spy.
+ */
+ test("a pointer write that fails leaves no live grant behind", async () => {
+ const ghost = `user_ghost_${suite}`;
+
+ await expect(
+ dynamicStore.recordConnection({
+ serverId: dynamicServerId,
+ userId: ghost,
+ refreshToken: "rt-ghost",
+ scope: SCOPE,
+ }),
+ ).rejects.toThrow();
+
+ // Nothing in the vault, live or otherwise: the insert that minted it was rolled back with the
+ // pointer write that failed. Asked of the key, because that is what a later connect collides on.
+ const rows = await database
+ .select({ id: credentials.id })
+ .from(credentials)
+ .where(
+ and(
+ eq(credentials.kind, "mcp_user_token"),
+ eq(credentials.provider, dynamicServerId),
+ eq(credentials.keyId, ghost),
+ ),
+ );
+ expect(rows).toEqual([]);
+ });
+});
+
+/**
+ * Which credential a custom server is allowed to be pointed at.
+ *
+ * `addCustomServer` takes the pointer from the request body, and the add itself dereferences it: the
+ * refresh that follows decrypts whatever it names and sends it to the URL from the same request. So
+ * the pointer is the whole control. An administrator naming somebody's `mcp_user_token` was enough
+ * to have that person's decrypted token delivered to an address the administrator chose, before any
+ * grant, policy check or Bot existed.
+ *
+ * `POST /api/admin/credentials` already refuses to *mint* a `mcp_user_token` by hand, and says why:
+ * it would be "creating a credential attributed to a person who never agreed to it". Pointing at one
+ * spends that credential on the same person's behalf, which is the same objection.
+ */
+describe("a custom server may only be pointed at its own kind of credential", () => {
+ const suffix = randomUUID().slice(0, 8);
+ const deploymentCredentialId = randomUUID();
+ const personalCredentialId = randomUUID();
+ const oauthClientCredentialId = randomUUID();
+ const customServerId = `custom-cred-${suffix}`;
+ const madeServerIds: string[] = [];
+
+ beforeAll(async () => {
+ const encrypted = await encryptSecret(
+ `${"A".repeat(43)}=`,
+ "not-read-here",
+ );
+ await database.insert(credentialRows).values([
+ {
+ id: deploymentCredentialId,
+ kind: "mcp",
+ provider: customServerId,
+ keyId: customServerId,
+ encryptedValue: encrypted,
+ metadata: {},
+ },
+ {
+ id: personalCredentialId,
+ kind: "mcp_user_token",
+ provider: "google-drive",
+ // For a user token the key is the person, which is what makes one pickable by name from the
+ // administrator's own credential list.
+ keyId: `user_someone_else_${suffix}`,
+ encryptedValue: encrypted,
+ metadata: {},
+ },
+ {
+ id: oauthClientCredentialId,
+ kind: "mcp_oauth_client",
+ provider: "google-drive",
+ keyId: "google-drive",
+ encryptedValue: encrypted,
+ metadata: {},
+ },
+ ]);
+ });
+
+ afterAll(async () => {
+ // By prefix, not by the ids this suite meant to make: before the fix the refused adds succeed,
+ // and a row left behind holds a foreign key onto the credentials deleted just below.
+ await database
+ .delete(mcpServers)
+ .where(like(mcpServers.id, `${customServerId}%`));
+ await database
+ .delete(credentialRows)
+ .where(
+ inArray(credentialRows.id, [
+ deploymentCredentialId,
+ personalCredentialId,
+ oauthClientCredentialId,
+ ]),
+ );
+ });
+
+ test("somebody else's connector token is refused, and no server is written", async () => {
+ const id = `${customServerId}-personal`;
+ await expect(
+ store.addCustomServer({
+ id,
+ title: "Collector",
+ url: "https://collector.example/mcp",
+ credentialId: personalCredentialId,
+ by: "admin@example.com",
+ }),
+ ).rejects.toBeInstanceOf(CustomServerRefusedError);
+
+ // The refusal has to stop the write, not merely report on it: a row here is a pointer the next
+ // refresh would dereference.
+ const rows = await database
+ .select({ id: mcpServers.id })
+ .from(mcpServers)
+ .where(eq(mcpServers.id, id));
+ expect(rows).toHaveLength(0);
+ });
+
+ test("the deployment's OAuth client is refused too", async () => {
+ // Not a per-person secret, but not this server's token either, and handing a vendor its own
+ // client secret as a bearer token is the mistake `refreshTools` was already changed to avoid.
+ const id = `${customServerId}-client`;
+ await expect(
+ store.addCustomServer({
+ id,
+ title: "Collector",
+ url: "https://collector.example/mcp",
+ credentialId: oauthClientCredentialId,
+ by: "admin@example.com",
+ }),
+ ).rejects.toBeInstanceOf(CustomServerRefusedError);
+ });
+
+ test("a credential that does not exist is refused the same way", async () => {
+ // Same message as the wrong-kind refusal on purpose. A caller who can tell "wrong kind" from
+ // "no such row" can ask this endpoint which ids are real, which is a vault oracle.
+ const id = `${customServerId}-missing`;
+ const missing = store.addCustomServer({
+ id,
+ title: "Collector",
+ url: "https://collector.example/mcp",
+ credentialId: randomUUID(),
+ by: "admin@example.com",
+ });
+ await expect(missing).rejects.toBeInstanceOf(CustomServerRefusedError);
+
+ const wrongKind = store
+ .addCustomServer({
+ id: `${customServerId}-kind-message`,
title: "Collector",
url: "https://collector.example/mcp",
credentialId: personalCredentialId,
@@ -851,3 +2496,133 @@ describe("a custom server may only be pointed at its own kind of credential", ()
expect(added.id).toBe(id);
});
});
+
+/**
+ * Which advertised names a vendor's write list does not cover, as a rule on its own.
+ *
+ * Unit-tested here as well as through a refresh, because the rule is the part that decides whether
+ * anybody ever hears about an under-inclusive write list, and it has two branches a live listing
+ * cannot show side by side: a vendor whose consent screen is the whole barrier, and one whose own
+ * scope is read-only. The entries are the real ones, so a catalogue edit that removed Drive's
+ * read-only scope would fail here rather than start filing rows about Drive.
+ */
+describe("advertised tools a write list does not name", () => {
+ test("a Notion tool absent from the write list is named, sorted", () => {
+ expect(
+ unlistedAdvertisedTools(catalogueEntry("notion"), [
+ "notion-search",
+ "notion-create-pages",
+ "notion-fetch",
+ ]),
+ ).toEqual(["notion-fetch", "notion-search"]);
+ });
+
+ test("a write the list already names is not", () => {
+ expect(
+ unlistedAdvertisedTools(catalogueEntry("notion"), [
+ "notion-create-pages",
+ ]),
+ ).toEqual([]);
+ });
+
+ /*
+ * Drive's grant is `drive.readonly`, so a tool missing from its write list cannot write whatever
+ * this deployment believes about it — the vendor refuses. Filing rows about it would be noise
+ * standing between somebody and the vendor where it is the only barrier.
+ */
+ test("a vendor whose own scope is read-only is not reconciled here", () => {
+ expect(
+ unlistedAdvertisedTools(catalogueEntry("google-drive"), [
+ "search_files",
+ "made_up_tool",
+ ]),
+ ).toEqual([]);
+ });
+
+ /** A server an administrator added by URL: every tool of theirs is already a write. */
+ test("a server nobody reviewed is not reconciled here either", () => {
+ expect(unlistedAdvertisedTools(null, ["anything"])).toEqual([]);
+ });
+});
+
+/**
+ * What the real token endpoint said, read by the real exchange.
+ *
+ * Every other suite in this file injects `exchangeRefreshToken`, because what they are about is which
+ * credential a call goes out with rather than how a reply is parsed. That leaves the parsing itself —
+ * the one part that meets a vendor's actual bytes — with nothing exercising it, and the interesting
+ * bytes are the dishonest ones: a 200 carrying a CDN interstitial rather than a token.
+ */
+describe("a vendor reply that is not a token", () => {
+ const replyClient: OAuthClient = { clientId: "c-1", clientSecret: "" };
+
+ test("a 200 that is not JSON is a refusal, not a thrown parse error", async () => {
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = (async () =>
+ new Response("checking your browser", {
+ status: 200,
+ headers: { "content-type": "text/html" },
+ })) as unknown as typeof fetch;
+ try {
+ /*
+ * The refusal this module already knows how to carry, rather than a SyntaxError.
+ *
+ * An unguarded parse throws out of here into `callTool`, which records it as `mcp.call_failed`
+ * with the parser's message — and that message quotes the vendor's body, so an interstitial's
+ * HTML ends up in an audit payload and in front of the person who asked.
+ */
+ await expect(
+ exchangeRefreshTokenOverHttp({
+ tokenUrl: "https://vendor.example/token",
+ client: replyClient,
+ refreshToken: "rt-1",
+ }),
+ ).rejects.toThrow(
+ "The vendor answered this renewal with something other than a token.",
+ );
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+ });
+
+ test("a 200 with JSON and no access token is still the refusal it always was", async () => {
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = (async () =>
+ new Response(JSON.stringify({ token_type: "bearer" }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ })) as unknown as typeof fetch;
+ try {
+ await expect(
+ exchangeRefreshTokenOverHttp({
+ tokenUrl: "https://vendor.example/token",
+ client: replyClient,
+ refreshToken: "rt-1",
+ }),
+ ).rejects.toThrow("The vendor renewed this access with no token.");
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+ });
+
+ /** The error branch, which already read defensively: the status survives an unparseable body. */
+ test("a refusal that is not JSON keeps the status, which is the one fact there is", async () => {
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = (async () =>
+ new Response("502", {
+ status: 502,
+ headers: { "content-type": "text/html" },
+ })) as unknown as typeof fetch;
+ try {
+ await expect(
+ exchangeRefreshTokenOverHttp({
+ tokenUrl: "https://vendor.example/token",
+ client: replyClient,
+ refreshToken: "rt-1",
+ }),
+ ).rejects.toThrow("The vendor would not renew this access (502).");
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+ });
+});
diff --git a/server/tests/plugin-user-credential.integration.test.ts b/server/tests/plugin-user-credential.integration.test.ts
index 08ca92b4..c097ec19 100644
--- a/server/tests/plugin-user-credential.integration.test.ts
+++ b/server/tests/plugin-user-credential.integration.test.ts
@@ -114,6 +114,11 @@ const store = createPluginStore({
create: async () => {
throw new Error("this suite writes credentials directly");
},
+ // Google does not rotate, so no exchange here ever reaches the in-place update. Loud, so that
+ // one starting to would show up rather than pass quietly.
+ updateSecret: async () => {
+ throw new Error("this suite writes credentials directly");
+ },
/*
* A real revocation, against this suite's own rows.
*
From 88078a412c52d5e86ee009e4ed1690ecd6c30562 Mon Sep 17 00:00:00 2001
From: David McKay
Date: Tue, 25 Aug 2026 18:18:06 -0700
Subject: [PATCH 04/18] Run OpenBot on Kubernetes: Bots and all, proven on EKS
(#235)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Run OpenBot on Kubernetes: a Helm chart, and what installing it found
One chart for EKS, GKE, AKS and somebody's own cluster, with nothing but
values between them. No cloud branching in any template: every place the
clouds differ is a value whose default is what a plain self-hosted
cluster does. Identity is one annotations map, because that is all IRSA,
Workload Identity and AKS workload identity are. Secrets are a plain
Secret by default and an ExternalSecret against any backend when asked.
Two replicas by default, because horizontal is the point and one hides
every bug that is not. A bad install is refused at helm install naming
the value to change, rather than found in a crash loop.
Three things only a real install could find:
drizzle-kit cannot migrate in the shipped image. It reads a TypeScript
config, which needs the esbuild that bun install --production leaves out,
so it printed one line, exited 1 and said nothing. EMBEDDED_POSTGRES=on
was starting containers whose database was never migrated. The migrator
inside drizzle-orm is a runtime dependency already and keeps the same
journal.
sessionOf answered from a map in the process that started the computer,
which is right until there are two of them. The replica taking a snapshot
is usually not the one handling the click, and an unknown session skips
the generation check rather than failing it, so the check that stops a
ref from a replaced computer resolving against a live one was silently
absent on the shape it was written for. It now asks by listing, never by
ensuring, so asking cannot start a computer that had stopped.
A browser in an API pod cannot be replicated, so the image's computer
gets the same switch its database has.
* Give Bots computers on Kubernetes, and suspend them when idle
The chart had no computer, so no Bot could do anything on a cluster. It
has one now, and a Bot has driven a real browser on real EKS with the
decision in the audit trail.
computers.mode picks the shape. shared runs one browser for every Bot and
needs nothing installed. sandbox gives each Bot its own as a Sandbox from
kubernetes-sigs/agent-sandbox, which is built for exactly this: an
isolated stateful singleton with a stable identity and persistent
storage, where suspending is a field that keeps the volumes, so a
computer comes back with its logins rather than signed out.
What decides a computer is idle is the audit trail, not the browser.
Asking the browser wakes it, so every computer anything asked about would
come back up and the bill would never fall.
The work is claimed and leased out of Postgres with for update skip
locked. Three features need that one mechanism, so it is written once
with all three in view: the culler here, routines, and a hop from one Bot
to another. A CronJob runs the sweep rather than a timer in the API,
because a timer fires in every replica and suspending a browser somebody
just started using is not something to do five times.
Also: a fresh EKS cluster very often has no default StorageClass. eksctl
creates gp2, unmarked and on the in-tree provisioner current Kubernetes
no longer has, so a volume asking for the default never binds and nothing
says why. Found on a real 1.34 cluster and written down where somebody
configuring one will read it.
* Refuse a sandbox install on a cluster that cannot make one
computers.mode: sandbox creates Sandbox objects, which exist only once
the agent-sandbox controller is installed. Without it the install
succeeds, every pod is healthy, and the deployment looks finished right
up until the first Bot asks for a browser and the API server answers 404.
That is the worst moment to learn it.
The check reads the cluster rather than a value somebody has to remember
to set, and the message carries the one command that fixes it. Proven
both ways: refused on a cluster with no CRD, installs on the EKS cluster
that has one.
Also from driving it on real EKS: lost+found was listed as a Bot, because
an EBS volume is ext4 and arrives with that directory, which a bind mount
never does. The allow-list that stops a hostile id becoming a path
answers the other half of the question too.
The migration Job named a ServiceAccount that does not exist yet, since a
pre-install hook runs before the chart's own resources. It talks to a
database and never to the cluster, so it needs no account at all.
The API pod gets a cluster token only in sandbox mode, the pods roll when
the computer template changes, the Sandbox asks for a Service so it has
an address that survives a resume, and the cluster CA is actually used
when talking to the API server.
* Tell one run of a computer from the next across a suspend
A resumed browser counts snapshot generations from one again, so a ref
the model still holds from before the suspend matches a row nothing has
overwritten, and the boundary decides about an element on a page that no
longer exists.
The first answer used the node and the pod address. Resuming a real
computer on EKS disproved it: a suspended sandbox is very often
rescheduled onto the same node and handed the same address back, and both
were identical across the cycle, so the check would have said same run
for the exact case it exists to catch. The Ready condition's transition
time moves whenever a computer starts serving again, needs no permission
beyond the sandbox already read, and is precisely the question.
Driven on EKS: a ref taken before a suspend is refused after the resume,
naming why, and a fresh ref from a new snapshot clicks through.
* Let the policy reach the computers, and refuse one that fences off the database
The NetworkPolicy allowed DNS and the bundled database. Nothing let the
API reach a Bot's computer, which it does for every browser action, and
nothing let it reach a managed database, whose address this chart cannot
know. On a cluster that enforces policy both are outages that read as
something else: the API looks broken rather than fenced.
The computers and the API server are allowed now, and turning the policy
on with an external database and no rule for it is refused with the shape
of the rule to add.
None of this showed up by installing it, because EKS runs its CNI with
--enable-network-policy=false and the policy is inert there. That is
worth knowing on its own, so it is written down: a policy that installs,
looks right, and does nothing is worse than one that is off.
Also driven on EKS: reset takes the volumes with it and the Bot gets a
clean profile afterwards, and the HPA reads real metrics.
* Keep the browsing that produced an answer
Every turn in which a Bot used a tool vanished from the transcript on
reload. The sentence the Bot wrote stayed, the browsing that produced it
did not, the inline screen went with it, and the footer said some
messages could not be read.
The history store writes a tool call as {id, name, args}; AG-UI describes
{id, type: function, function: {name, arguments}}. The reader validated
against the second and treated the first as damage from an interrupted
run. It is not damage, it is how every tool call is stored, so a guard
written against one bad turn was deleting all the real ones.
Found by driving a real conversation on the EKS deployment rather than by
reading: two browsing turns, both counted unreadable, both well formed in
the store's own dialect.
Both spellings now read as the same thing. A mixed or unrecognised array
is still refused rather than half-translated, because a reader that
rewrites what it does not recognise is worse than one that refuses it.
* Show the page a finished turn opened, not the one open now
Reopening a conversation made every past turn fetch the screen as it is
now, so an answer about Hacker News from an hour ago sat under a picture
of whatever the Bot had open since. The frame was live and the caption
was not, and the turn read as though it had browsed somewhere it never
went.
A turn that has finished is history, and history is not polled. It names
the page that turn actually left open, which the tool result already
carried. Nothing changes while a turn runs: those frames are its own and
freeze where it left them.
It names the page rather than showing it, because nothing stored the
picture and fetching one now would show a different page. Naming it stays
true however many times the Bot has browsed since.
Driven on EKS: three turns, three different pages, each holding its own
across a reload.
* Keep the frame a browsing turn ended on
Reopening a conversation made every past turn fetch the screen as it is
now, so an answer about one page sat under a picture of whatever the Bot
had open since.
A browsing turn keeps its last frame in computer_turn_frame, filed under
the tool call and written once, because a turn that has happened does not
happen differently later.
Three things had to be true together and each was wrong on its own first.
The frame is read at the moment the turn ends, since a short turn
finishes before the tile has polled anything. Restoring a kept frame must
not make the turn look live again, which the first version did: it
counted a turn as history only while it had no picture, so restoring one
restarted the polling that then replaced it. And a turn is over when it
has a result rather than when its status says so, because a restored tool
call arrives with its result in hand and a status that is briefly
something else.
Found by watching the network on the deployed cluster rather than by
reading: two live screenshot reads before the restore, on every reload.
* Keep a turn's frame only when it is a frame of that turn's page
The capture ran at the end of a turn and took whatever the screen showed
then. That is usually right and sometimes badly wrong: the same computer
is driven by other conversations, a resumed one starts blank, and a short
turn finishes before the tile has polled anything. So an answer about one
page could be filed with a picture of another, which is worse than having
no picture at all.
A frame is now kept only when its own url is the page the turn opened.
Unknown counts as no match, because storing on unknown is how the wrong
picture gets kept.
Also folds the restore and the capture into one effect asked in order:
what is stored first, the live screen only if nothing is. Two effects
racing is what made a reopened turn restore the right frame and then
overwrite it with a fresh screenshot one render later, which the console
showed plainly once I stopped guessing and logged it.
* Photograph the page where it is opened, not where it is read back
The transcript's inline screen used to capture its own frame after the turn ended, and file it under
the tool call. That is a race it cannot win. A reopened turn and one that has just finished look
identical from inside the component, the same computer is driven by other conversations in between,
and a resumed computer starts blank, so the picture filed was routinely of somewhere the turn never
went, or of nothing.
The frame is now taken on the server the moment a navigation succeeds, which is the one moment the
screen is certainly showing the page that was asked for, and kept per computer and page rather than
per tool call. The surface only reads. Failing to take the picture never fails the navigation.
* Open the Bot screen on a Bot this deployment has
Three things a fork trips over.
The Bot screen defaulted to a coworker named risk-analyst, which is a name from one tenant package
and a crash on every other. OpenBot exists to be forked, so a Bot id written into a route is a
defect on all but the deployment it came from: the screen took the whole page down to an unstyled
error boundary. It now opens on whatever Bot this deployment actually has, and answers a mistyped
name in a sentence.
The audit trail wrote "not in the current snapshot" against every navigation, file read and command.
That sentence is about a ref the server could not resolve, and deciding it by elimination put it on
actions that never named an element at all, sending a reader looking for a snapshot nobody took. It
is keyed on the ref now.
The chart had no way to point at anyone's own AG-UI Bot, which is the seam the whole product is
about. config.managedAgent.url and secrets.managedAgentToken, refused at install if one is set
without the other.
* Format the regenerated migration snapshot
* Fix what the review found, and make CI able to find it next time
Every claim driven against the real thing rather than read, and all but two held.
THE QUEUE. Leases were computed on the replica's clock and compared against the database's, which is
two clocks pretending to be one: a node ninety seconds behind wrote a sixty-second lease that arrived
already expired, and the next replica took the item out from under it. Both ran it. `finish` and
`release` matched on the key alone, so a replica whose lease had quietly gone deleted or rescheduled
work another was executing. Nothing ever renewed, and the culler took twenty items on one lease.
`finish` deleted the row, destroying the idempotence this table's own comment promises: the insert a
re-offer was meant to collide with had nothing left to collide with. And a permanently failing item
retried until somebody noticed, which on a queue with no dashboard is never.
Every moment is named in SQL now, all three lease calls ask the same question, the culler renews
between items, a finished row stays until a retention sweep takes it, and an item that runs out of
attempts stops with its count and its reason where a person can query them. The probe that
reproduced the first two comes back clean.
THE CHART could not start a server on any of its four shipped targets: each configured sign-in and
none supplied the secret sessions are signed with, and one carried the public example encryption key.
Driven on a real cluster, watched to crash-loop, fixed, watched to come up ready. Both states are
refused at install now, including the one hiding behind an external secret store, where the value is
unreadable but the list of keys is not.
THE DATABASE WAS REACHABLE FROM THE BOT'S BROWSER. Compose has kept those apart since the beginning;
the chart dropped it, and the bundled database shipped a policy admitting any pod in any namespace on
5432. Proven by opening a socket from the browser pod. Pinned, plus a policy for the computer itself,
which had none. That pod also carried a cluster credential it has no use for, which it no longer
does: verified on a recreated per-Bot computer.
The service account token was read once and held for the life of the process. Projected tokens rotate
on a schedule the cluster picks, so sandbox calls work until the first rotation and then all return
401, which reads like the cluster broke.
THE TRANSCRIPT still lied in two places. A finished turn holding a stale live frame fell through to
"Waiting for the assistant's screen…" and waited there for ever, because the poll that would end the
wait stops when a turn settles. And zooming a past turn mounted the live stream and offered Take
control, so the one gesture for looking closer at what a turn did replaced it with whatever the Bot
has open now. The kept frame exists to stop exactly that.
TWO TESTS passed a pool-options object where a connection string belongs and were green for a reason
unrelated to what they check, because the test tree is not type-checked. That is its own sweep; the
misuse throws now. One of them also deleted every real queued suspension in the database.
NOTHING HAS EVER RENDERED THIS CHART, which is how four broken targets shipped and stayed shipped. CI
lints, renders and checks five targets now, including the per-Bot mode nothing rendered before, and a
script that asks whether every secret key a container demands is one the chart writes.
* Give the chart job the runtime its check needs
* Address the second review: the frame goes back on turn identity, and the fixes stop breaking things
Most of round two is consequences of round one, which is the honest summary.
THE QUEUE WEDGED ITS OWN KEYS. An item at the attempt cap is not finished, so `claim` skipped it,
`purge` did not match it, and `offer` cannot replace a row that is still there. The culler keys on
the Bot id: five failed suspends and that Bot never scaled to zero again, silently and for good. Both
kinds of done are reaped now, on the same window, which is also how long it waits before anything
tries again. Giving up is logged rather than simply ceasing.
PINNING THE DATABASE POLICY BROKE THE THINGS THAT USE IT. Only the API server carried the client
label; the migration Job and the culler both open the database and neither did, so any cluster that
actually enforces would have failed the install. My own probe could not have caught it, because that
cluster ships enforcement switched off.
THE FRAME GOES BACK ON THE TURN. Keying it on the page was a mistake with a plausible reason: two
visits to one address collided, and letting the newer win made a past turn's picture change under the
person reading it, which is the mutability this whole change exists to remove. It was chosen because
the navigate handler seemed not to know its tool call. It does, on `context.toolCall.id`, which I
assumed rather than checked. The row is written once and never updated.
That leaves the race the old client-side guard used to cover: the screenshot is a second round trip,
and with one computer shared by every Bot another Bot's navigation lands in the gap. The guard is
back, on the side that now does the capturing. The capture also refuses to resume a suspended
computer, so a convenience picture cannot undo a cull or hold a navigation open for a pod schedule.
A TURN IS OVER WHETHER OR NOT IT GOT ANYWHERE. Settling on "do I have a page" left refused, failed
and stopped navigations polling the live screen for ever under a finished answer, which are the turns
where what is on screen has least to do with what is being read. And the control pill was the one
affordance the merge did not teach: take the wheel mid-navigation, the turn settles, and a frozen
picture from an hour ago asserted "You have control" with no way to hand it back.
RESET NOW MEANS RESET. "Every login the Bot had is gone" was said while screenshots of the signed-in
pages stayed in the database, readable from the transcript by anyone who could reach that Bot. The
frames go with the profile, and a reaper takes the rest on a retention window, because a page is a
row and nothing ever took anything out of that table.
A REJECTED PROMISE WAS REMEMBERED FOR EVER. One unreadable token file at the wrong moment and every
computer request for the pod's life failed with the same stale error, with no probe failing.
And the chart's own gates were softer than they looked. `helm lint` reports a template `fail` as an
INFO line and exits 0 even under `--strict`, which I drove rather than assumed, so it can never gate
a refusal. Rendering can, and now does: CI asserts three refusals actually fire. The render check can
no longer pass by matching nothing. `better-auth-secret` is optional only when it truly is, which
also makes the existing-Secret path visible to that check. The policies render in a CI target for the
first time. The subchart, its image and the lock are all pinned, and the lock is committed rather
than ignored beside the tarballs it exists to pin.
* Assert the example-key refusal only where it is armed
* Arm the Bot-endpoint refusal under an external secret store too
* Close the round-one gates: one dialect, one predicate, one shipped rule
Four things that were reported as still open, and all four were.
THE BOT SIDE HAD THE SAME DIALECT BUG AS THE SURFACE. A call read back from the thread store arrives
as `{id, name, args}`, so `call.function` is empty: agent-bot defaulted every restored call to a tool
named `tool` with no arguments, which is a call the model cannot recognise as the one it made, so it
makes it again. That is the repetition the default was written to prevent, caused by the default. The
LangGraph twin did not degrade at all, it dereferenced straight through and threw. Both read either
spelling now, and the fallback is the last resort it was meant to be.
THE SURFACE STILL PASSED ARGUMENTS THROUGH UNTOUCHED. AG-UI types them as a string and the store is
under no such obligation, so a tool called with structured input produced a call that looked
translated and failed validation anyway. Strings are passed through exactly, down to their
whitespace, because a fragment of a stream that was never valid JSON is what the model actually said.
AND IT DROPPED A TURN THAT CALLED A TOOL AND SAID NOTHING. The schema makes an assistant's content
optional and does not allow null, so the two mean the same thing and only one parsed: the same loss
as the dialect bug, by a different route. A person's turn is not the same case, and #207's decision
to refuse and count that one stands. The tests that pinned multimodal content, null content and
ordering are back, and the shapes were driven against the reader rather than assumed: the one I was
most confident about, that a list of parts is refused, turned out to be wrong.
THE PROFILE TEST PROVED ITS OWN COPY. It reimplemented the filter it was checking, so deleting the
real one left the suite green and the fleet page listing `lost+found` as a Bot again. The rule is its
own module now, imported by both, and removing the filter fails the test.
* Run the reaper that was written, and keep frames through a rollout
Two things asked for before merge, both mine, and the second was worse than reported.
THE REAPER HAD NO CALLER. `computer_page_frame` had a purge, an index to serve it and a test proving
it works, and nothing ever invoked it: written on every navigation, taken out by a profile wipe and
by nothing else. The culler calls it, because that is already the sweep that runs on a schedule with
a claim under it and a second timer would be a second thing to get wrong. Kept a month, which is long
after anybody reads a conversation back.
Deleting a Bot still leaves them, and that is left alone deliberately: a delete is soft and touches
no computer state at all today, not the profile, not the browser, not the snapshots. Clearing only
the screenshots would be the one half-measure that reads as though the rest had been handled.
A SCREENSHOT THAT DOES NOT SAY WHAT IT IS OF IS THE ORDINARY CASE ON AN OLD COMPUTER. That field
arrived after the first computers shipped. Refusing on a missing url therefore did not fail safe, it
failed silently and completely: a fleet part-way through a rollout kept no frames at all and said
nothing about why. The question is now asked where it means something. With a computer each there is
nobody to race with and the picture can only be this turn's. On one shared browser another Bot's
navigation lands in exactly that gap, so an unlabelled frame is still refused, and the rollout order
that matters is written down where somebody upgrading will read it.
And every refusal says so now. Two of the three returned quietly, under a docstring promising the
opposite, which is how a deployment ends up keeping no frames with nothing in its logs to explain it.
---
.github/workflows/ci.yml | 79 +-
.gitignore | 5 +
CHANGELOG.md | 129 +-
agent-bot/src/history.ts | 44 +-
agent-bot/tests/history.test.ts | 44 +
agent-computer/src/profile-listing.ts | 38 +
agent-computer/src/profiles.ts | 12 +-
agent-computer/tests/profile-listing.test.ts | 49 +
agent-langgraph/src/history.ts | 31 +-
app/src/components/computer/computer-view.tsx | 369 ++-
app/src/lib/computers/screen.ts | 28 +
app/src/lib/copilot/computer-tools.tsx | 67 +-
app/src/lib/copilot/thread-messages.ts | 109 +-
app/src/routes/_authed/_app/bot.tsx | 39 +-
app/tests/thread-messages.test.ts | 313 +-
charts/openbot/.helmignore | 5 +
charts/openbot/Chart.lock | 6 +
charts/openbot/Chart.yaml | 31 +
charts/openbot/README.md | 225 ++
charts/openbot/ci/aks-values.yaml | 56 +
charts/openbot/ci/eks-sandbox-values.yaml | 106 +
charts/openbot/ci/eks-values.yaml | 84 +
charts/openbot/ci/gke-values.yaml | 55 +
charts/openbot/ci/self-hosted-values.yaml | 48 +
charts/openbot/templates/_helpers.tpl | 387 +++
.../templates/computer/culler-cronjob.yaml | 77 +
.../templates/computer/pod-template.yaml | 24 +
.../templates/computer/sandbox-rbac.yaml | 47 +
.../templates/computer/sandbox-template.yaml | 23 +
.../openbot/templates/computer/service.yaml | 19 +
.../templates/computer/statefulset.yaml | 170 ++
.../openbot/templates/computer/warmpool.yaml | 22 +
charts/openbot/templates/configmap.yaml | 17 +
charts/openbot/templates/externalsecret.yaml | 24 +
charts/openbot/templates/httproute.yaml | 26 +
charts/openbot/templates/ingress.yaml | 44 +
charts/openbot/templates/migrations/job.yaml | 105 +
charts/openbot/templates/networkpolicy.yaml | 140 +
charts/openbot/templates/secret.yaml | 50 +
.../openbot/templates/server/deployment.yaml | 155 +
charts/openbot/templates/server/hpa.yaml | 37 +
charts/openbot/templates/server/pdb.yaml | 30 +
charts/openbot/templates/server/service.yaml | 24 +
.../templates/server/serviceaccount.yaml | 17 +
charts/openbot/templates/validation.yaml | 249 ++
charts/openbot/values.yaml | 381 +++
docker/s6/s6-rc.d/computer/run | 13 +
docker/s6/scripts/migrate.sh | 5 +-
scripts/check-rendered-chart.ts | 140 +
server/drizzle.config.ts | 1 +
server/drizzle/0017_durable_work.sql | 16 +
server/drizzle/0018_page_frames.sql | 11 +
server/drizzle/meta/0017_snapshot.json | 2506 ++++++++++++++++
server/drizzle/meta/0018_snapshot.json | 2577 +++++++++++++++++
server/drizzle/meta/_journal.json | 14 +
server/scripts/cull-idle-computers.ts | 100 +
server/scripts/migrate.ts | 43 +
server/src/app.ts | 17 +-
server/src/computer/gateway.ts | 39 +-
server/src/computer/page-frames.ts | 139 +
server/src/computer/provider.ts | 70 +-
server/src/computer/routes.ts | 171 +-
server/src/computer/sandbox.ts | 416 +++
server/src/computer/supervisor.ts | 41 +-
server/src/config.ts | 75 +-
server/src/db/client.ts | 14 +
server/src/db/schema/computer.ts | 53 +-
server/src/db/schema/index.ts | 3 +-
server/src/db/schema/work.ts | 98 +
server/src/index.ts | 6 +
server/src/work/culler.ts | 229 ++
server/src/work/queue.ts | 286 ++
.../tests/computer-culler.integration.test.ts | 222 ++
server/tests/computer-gateway.test.ts | 16 +-
.../tests/computer-page-frame-route.test.ts | 308 ++
...puter-page-frame-store.integration.test.ts | 214 ++
server/tests/computer-provider.test.ts | 44 +-
server/tests/computer-sandbox.test.ts | 159 +
server/tests/computer-supervisor.test.ts | 76 +
server/tests/work-queue.integration.test.ts | 326 +++
server/tsconfig.json | 9 +-
81 files changed, 12252 insertions(+), 245 deletions(-)
create mode 100644 agent-computer/src/profile-listing.ts
create mode 100644 agent-computer/tests/profile-listing.test.ts
create mode 100644 charts/openbot/.helmignore
create mode 100644 charts/openbot/Chart.lock
create mode 100644 charts/openbot/Chart.yaml
create mode 100644 charts/openbot/README.md
create mode 100644 charts/openbot/ci/aks-values.yaml
create mode 100644 charts/openbot/ci/eks-sandbox-values.yaml
create mode 100644 charts/openbot/ci/eks-values.yaml
create mode 100644 charts/openbot/ci/gke-values.yaml
create mode 100644 charts/openbot/ci/self-hosted-values.yaml
create mode 100644 charts/openbot/templates/_helpers.tpl
create mode 100644 charts/openbot/templates/computer/culler-cronjob.yaml
create mode 100644 charts/openbot/templates/computer/pod-template.yaml
create mode 100644 charts/openbot/templates/computer/sandbox-rbac.yaml
create mode 100644 charts/openbot/templates/computer/sandbox-template.yaml
create mode 100644 charts/openbot/templates/computer/service.yaml
create mode 100644 charts/openbot/templates/computer/statefulset.yaml
create mode 100644 charts/openbot/templates/computer/warmpool.yaml
create mode 100644 charts/openbot/templates/configmap.yaml
create mode 100644 charts/openbot/templates/externalsecret.yaml
create mode 100644 charts/openbot/templates/httproute.yaml
create mode 100644 charts/openbot/templates/ingress.yaml
create mode 100644 charts/openbot/templates/migrations/job.yaml
create mode 100644 charts/openbot/templates/networkpolicy.yaml
create mode 100644 charts/openbot/templates/secret.yaml
create mode 100644 charts/openbot/templates/server/deployment.yaml
create mode 100644 charts/openbot/templates/server/hpa.yaml
create mode 100644 charts/openbot/templates/server/pdb.yaml
create mode 100644 charts/openbot/templates/server/service.yaml
create mode 100644 charts/openbot/templates/server/serviceaccount.yaml
create mode 100644 charts/openbot/templates/validation.yaml
create mode 100644 charts/openbot/values.yaml
create mode 100644 scripts/check-rendered-chart.ts
create mode 100644 server/drizzle/0017_durable_work.sql
create mode 100644 server/drizzle/0018_page_frames.sql
create mode 100644 server/drizzle/meta/0017_snapshot.json
create mode 100644 server/drizzle/meta/0018_snapshot.json
create mode 100644 server/scripts/cull-idle-computers.ts
create mode 100644 server/scripts/migrate.ts
create mode 100644 server/src/computer/page-frames.ts
create mode 100644 server/src/computer/sandbox.ts
create mode 100644 server/src/db/schema/work.ts
create mode 100644 server/src/work/culler.ts
create mode 100644 server/src/work/queue.ts
create mode 100644 server/tests/computer-culler.integration.test.ts
create mode 100644 server/tests/computer-page-frame-route.test.ts
create mode 100644 server/tests/computer-page-frame-store.integration.test.ts
create mode 100644 server/tests/computer-sandbox.test.ts
create mode 100644 server/tests/work-queue.integration.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a5f0ec41..6c79e8f1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -73,6 +73,83 @@ jobs:
- run: bun run typecheck
working-directory: ${{ matrix.package }}
+ chart:
+ name: chart (${{ matrix.target }})
+ runs-on: ubuntu-latest
+ strategy:
+ # One red target must not hide whether another is red too.
+ fail-fast: false
+ matrix:
+ target: [self-hosted, eks, eks-sandbox, gke, aks]
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
+ with:
+ version: v3.19.0
+ # For the coherence check below, which is a Bun script like everything else here.
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ bun-version: 1.3.14
+ # Nothing rendered this chart until now, which is how four values files that produce a server
+ # unable to start were shipped and stayed shipped. Rendering is the cheap half; the refusals in
+ # validation.yaml are the half that catches a missing value before a cluster does.
+ - run: helm dependency build charts/openbot
+ # Structure only. `helm lint` reports a template `fail` as an INFO line and exits 0 even under
+ # `--strict`, which was driven and confirmed, so it cannot gate the refusals below. Rendering
+ # does: `helm template` exits non-zero on one.
+ - run: helm lint charts/openbot --values charts/openbot/ci/${{ matrix.target }}-values.yaml
+ # A key encryption key is a real 32 bytes rather than a placeholder, because the chart checks
+ # its shape. Generated here so no example key is ever a literal in this repository.
+ - name: Render
+ run: |
+ helm template ci charts/openbot \
+ --values charts/openbot/ci/${{ matrix.target }}-values.yaml \
+ --set-string secrets.keyEncryptionKey="$(openssl rand -base64 32)" \
+ --api-versions agents.x-k8s.io/v1beta1/Sandbox \
+ --api-versions extensions.agents.x-k8s.io/v1beta1/SandboxTemplate \
+ > rendered.yaml
+ # Rendering proves the templates run. This proves the result is coherent, which is a different
+ # question: every secret key a container demands has to be one the chart actually writes.
+ # Getting that wrong is invisible until a pod starts, and every shipped target had it wrong.
+ - run: bun scripts/check-rendered-chart.ts rendered.yaml
+ # And that the refusals are load-bearing rather than decorative. A chart full of `fail`
+ # messages nothing ever triggers is a chart that has never been shown to refuse anything, and
+ # every one of these describes a state that shipped in a values file at some point.
+ - name: Refusals fire
+ run: |
+ set -uo pipefail
+ refuses() {
+ local why="$1"; shift
+ if helm template ci charts/openbot \
+ --values charts/openbot/ci/${{ matrix.target }}-values.yaml \
+ --set-string secrets.keyEncryptionKey="$(openssl rand -base64 32)" \
+ --api-versions agents.x-k8s.io/v1beta1/Sandbox \
+ --api-versions extensions.agents.x-k8s.io/v1beta1/SandboxTemplate \
+ "$@" >/dev/null 2>&1; then
+ echo "::error::The chart rendered $why, which it is supposed to refuse."
+ return 1
+ fi
+ echo "refused: $why"
+ }
+ # The public example key, which the server will not start with. Only where this chart
+ # holds the secret: with a store, the value is not readable at template time, so the
+ # refusal is deliberately not armed and asserting it here would be asserting a bug.
+ if ! grep -qE '^ *enabled: true' <(sed -n '/^externalSecrets:/,/^[a-z]/p' charts/openbot/ci/${{ matrix.target }}-values.yaml); then
+ refuses "the public example encryption key" \
+ --set-string secrets.keyEncryptionKey="$(head -c 32 /dev/zero | base64)"
+ else
+ echo "skipped: the example-key refusal is not armed when the secret comes from a store"
+ fi
+ # A Bot endpoint with nothing on the request that says who is calling. Armed whether the
+ # secret is this chart's or a store's, because the key list is readable either way.
+ refuses "a managed agent URL with no token" \
+ --set-string config.managedAgent.url=http://agent.default:8000/ag-ui
+ # A browser inside every replica of a replicated API.
+ refuses "an embedded browser across several replicas" \
+ --set server.embeddedComputer=true --set server.replicaCount=2
+
test:
name: tests
runs-on: ubuntu-latest
@@ -261,7 +338,7 @@ jobs:
name: verify
runs-on: ubuntu-latest
if: always()
- needs: [static, deployables, test, build, migrations, image]
+ needs: [static, deployables, chart, test, build, migrations, image]
steps:
- name: Require every check
env:
diff --git a/.gitignore b/.gitignore
index f5d48e06..f1e6ae03 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,8 @@ app/src/lib/generated/application-config.ts
# TanStack Router scratch output
app/.tanstack/
+
+# Helm subchart tarballs, fetched by `helm dependency build`. The lock beside them is NOT ignored:
+# it is what makes that fetch reproducible, and ignoring it meant every build resolved the dependency
+# afresh, so CI and a customer install could take different subchart versions with no diff to show it.
+charts/*/charts/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 02c58967..b90ea999 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,134 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
## Unreleased
+### A finished turn shows the page it opened, not the one open now
+
+Reopening a conversation made every past turn fetch the screen as it is now, so an answer about
+Hacker News from an hour ago sat under a picture of whatever the Bot had open since.
+
+A page is now photographed where it is opened. The server takes the frame the moment a navigation
+succeeds and keeps it in `computer_page_frame` under the computer and the address, which is the one
+moment the screen is certainly showing the page that was asked for. Reopening the conversation shows
+that frame rather than the live screen, and a turn with nothing kept names the page instead of
+drawing the wrong one.
+
+The surface used to capture it itself once the turn went quiet, and that is a race it cannot win: a
+reopened turn and one that has just finished are indistinguishable from inside the component, the
+same computer is driven by other conversations in between, and a resumed computer starts blank. It
+filed pictures of pages the turn never opened, or none at all. It only reads now.
+
+**Redeploy the computers with the server.** A screenshot only says which page it is of on an
+`agent-computer` built after that field was added, and this is what decides whether a frame is kept.
+Where each Bot has a computer of its own there is nobody to race with, so an old computer's picture is
+accepted and the feature works through a rollout. On ONE SHARED COMPUTER it cannot be: another Bot's
+navigation lands between the navigation and the picture, and a frame that cannot be told apart from
+theirs is refused. So a shared-computer deployment that updates the server and not the computer keeps
+no frames until it does, and says so in the server log each time rather than leaving somebody to
+wonder.
+
+Two things followed from making a past turn a record. Its placeholder is decided by the turn being
+over rather than by whether a live frame happens to be in hand, because a tile that was live a moment
+ago keeps its last screenshot and used to fall through to "Waiting for the assistant's screen…" and
+wait there for ever. And opening one full size shows that same kept frame, with no live stream and no
+wheel: zooming a past turn used to mount the socket and offer Take control, so the one gesture for
+looking closer at what a turn did replaced it with whatever the Bot has open now.
+
+### A conversation keeps the browsing that produced its answers
+
+Every turn in which a Bot used a tool was disappearing from the transcript on reload. The sentence
+the Bot wrote stayed; the browsing that produced it did not, the inline screen went with it, and the
+footer said some messages could not be read.
+
+The history store writes a tool call as `{id, name, args}`. AG-UI describes
+`{id, type: "function", function: {name, arguments}}`. The reader validated against the second,
+treated the first as damage from an interrupted run, and dropped it. It is not damage: it is how
+every tool call is stored, so what looked like a guard against one bad turn was deleting all of the
+real ones. Observed on a live thread where every browsing turn was counted unreadable and every one
+of them was well formed in the store's own dialect.
+
+The two spellings are now read as the same thing. The check stays for turns that really are
+malformed, and a mixed or unrecognised array is still refused rather than half-translated, because a
+reader that rewrites what it does not recognise is worse than one that refuses it.
+
+
+### Run this on Kubernetes
+
+A Helm chart under `charts/openbot`, Bots and all, and the fixes that installing it for real turned
+up. Proven on a real EKS cluster: five workloads, replicas across two nodes, EBS volumes bound, and a
+Bot opening a real page from inside AWS with the decision in the audit trail.
+
+One chart, four targets: EKS, GKE, AKS and somebody's own cluster, with nothing but values between
+them. There is no cloud branching in any template. Every place the clouds genuinely differ is a
+value whose default is what a plain self-hosted cluster does: the cluster's own default StorageClass,
+no RuntimeClass, a plain Kubernetes Secret, an Ingress. Identity is one `serviceAccount.annotations`
+map, which is all IRSA, Workload Identity and AKS workload identity are. Secrets are a plain Secret
+by default and an ExternalSecret against any backend when asked, so Secrets Manager, Secret Manager
+and Key Vault are a values block rather than three code paths. Gateway API is supported beside
+Ingress rather than instead of it. `charts/openbot/ci` holds a values file per target.
+
+Two replicas by default, because horizontal is the point and one replica hides every bug that is
+not. A bad install is refused at `helm install`, naming the value to change, rather than discovered
+in a crash loop: no database or two of them, nobody who could sign in, nobody who would be an
+administrator, a key of the wrong shape, both routers enabled, or a browser asked for inside more
+than one replica.
+
+**A Bot's computer is not in an API pod.** The image runs one beside the API so that a single
+container works on its own, and `EMBEDDED_COMPUTER=off` turns it off. A replica must not carry a
+browser: it is a few hundred megabytes holding one Bot's logins, so scaling the API would scale
+those with it.
+
+**Migrations no longer need a development tool.** `bun x drizzle-kit migrate` cannot run in the
+shipped image at all. The CLI reads a TypeScript config, which needs the esbuild that
+`bun install --production` correctly leaves out, so it printed "Reading config file", exited 1 and
+said nothing else. `EMBEDDED_POSTGRES=on` was therefore starting a container whose database was
+never migrated, and the first symptom was the API reporting that `users` does not exist.
+`server/scripts/migrate.ts` uses the migrator inside `drizzle-orm`, which is a runtime dependency
+already, and keeps the same journal, so a database migrated by either tool is migrated.
+
+**A computer for each Bot, suspended when idle.** `computers.mode: sandbox` gives every Bot its own
+browser as a `Sandbox` from `kubernetes-sigs/agent-sandbox`, which is built for this workload: an
+isolated, stateful, singleton pod with a stable identity and persistent storage. Suspending is one
+field, and it keeps the volumes, so a computer comes back with its logins rather than signed out of
+everything. `shared` stays the default and needs nothing installed in the cluster.
+
+**The NetworkPolicy would have fenced the API off from its own work.** Its egress named DNS and the
+bundled database and nothing else, so on a cluster that enforces policy the API could not have
+reached a Bot's computer or, with a managed database, the database. Both are allowed now, and turning
+the policy on with an external database and no rule for it is refused rather than shipped. Worth
+knowing either way: EKS runs its CNI with `--enable-network-policy=false`, so a policy there installs,
+looks right, and does nothing at all.
+
+**A cluster with no controller is refused at install.** `computers.mode: sandbox` needs the
+agent-sandbox CRD, and without it the install succeeds, every pod is healthy, and the deployment
+looks finished until the first Bot asks for a browser. The chart reads the cluster and refuses,
+naming the one command that fixes it.
+
+**What decides a computer is idle is the audit trail, not the browser.** Asking the browser would
+wake it, so every computer anything asked about would come back up and the bill would never fall.
+
+**Durable work, claimed by whichever replica gets there first.** `work_items` plus
+`select ... for update skip locked` and a lease: no coordinator, no leader election, and a replica
+added is throughput added. The idle-computer culler is its first user; scheduled routines and
+hand-offs between Bots are the other two, which is why it is written once rather than three times
+slightly differently. A CronJob runs the sweep, because a timer in the API fires in every replica and
+suspending a browser somebody just started using is not something to do five times.
+
+**Which run of a computer this is, across a suspend.** A resumed browser counts snapshot
+generations from one again, so a ref the model still holds from before the suspend would match a row
+nothing has overwritten and the boundary would decide about an element on a page that no longer
+exists. The first answer here used the node and the pod address, and resuming a real computer
+disproved it: a suspended sandbox is very often rescheduled onto the same node and handed the same
+address back, so both were identical across a suspend and resume and the check would have said "same
+run" for the exact case it exists to catch. It reads the `Ready` condition's transition time instead,
+which moves every time a computer starts serving again.
+
+**Which run of a computer this is, on more than one replica.** `sessionOf` answered from a map in
+the process that started the computer, which is right until there are two: the replica that took a
+snapshot is usually not the one handling the click, and the second had nothing to answer with. An
+unknown session means "no opinion" and skips the generation check, so on exactly the deployment
+shape it was written for, the check that stops a ref from a replaced computer resolving against a
+live one was silently absent. It now asks the supervisor when it does not know, by listing rather
+than by ensuring, so asking never starts a computer that had stopped.
### Notion joins the connector catalogue
Notion is now a governed MCP connector, reached through Notion's own hosted server on the
@@ -35,7 +163,6 @@ read that as a stolen token and revoke the whole connection. Every plugin call t
token now locks the credential's vault row for the length of the exchange, so a second replica waits
rather than races, and the rotated token is written back in the same transaction that held the lock.
Nothing to configure; a connection just stops going stale under concurrent traffic.
-
### Knowledge searches instead of guessing
A package can say which of its skills each coworker gets, and the fintech example gives Knowledge the
diff --git a/agent-bot/src/history.ts b/agent-bot/src/history.ts
index b4f09a68..1c7165bb 100644
--- a/agent-bot/src/history.ts
+++ b/agent-bot/src/history.ts
@@ -63,16 +63,7 @@ export function toProviderMessages(
const toolCalls = message.toolCalls?.map((call) => ({
id: call.id,
type: "function" as const,
- function: {
- /*
- * A name is required by the provider and is not always present: read back from the thread
- * store these arrive undefined, and a payload carrying `"name": undefined` is rejected
- * outright. The call still has to be shown, or the model repeats an action it already
- * took, so it keeps its id and is named as something the model can read.
- */
- name: call.function?.name ?? "tool",
- arguments: call.function?.arguments ?? "{}",
- },
+ function: callDetails(call),
}));
messages.push({
role: "assistant",
@@ -109,3 +100,36 @@ export function toProviderMessages(
return messages;
}
+
+/**
+ * A tool call's name and arguments, in whichever dialect it arrived in.
+ *
+ * TWO SPELLINGS, ONE CALL. AG-UI describes `{id, type: "function", function: {name, arguments}}` and
+ * the history store writes `{id, name, args}`. Read back from a thread, every call arrives in the
+ * second, so code reaching straight for `call.function` finds nothing there.
+ *
+ * That was diagnosed here as "the name is not always present" and papered over with a default, which
+ * turned every restored call into a tool named `tool` with no arguments. The model is then shown a
+ * call it cannot recognise as the one it made, so it makes it again: the exact repetition the
+ * fallback was written to prevent.
+ */
+function callDetails(call: {
+ function?: { name?: unknown; arguments?: unknown };
+ name?: unknown;
+ args?: unknown;
+}): { name: string; arguments: string } {
+ const name = call.function?.name ?? call.name;
+ const args = call.function?.arguments ?? call.args;
+ return {
+ // Still defaulted, because a call with no name at all is rejected outright by the provider and
+ // showing the model something is better than losing the turn. It is now the last resort it was
+ // meant to be rather than the ordinary path.
+ name: typeof name === "string" && name ? name : "tool",
+ arguments:
+ typeof args === "string"
+ ? args
+ : args === undefined || args === null
+ ? "{}"
+ : JSON.stringify(args),
+ };
+}
diff --git a/agent-bot/tests/history.test.ts b/agent-bot/tests/history.test.ts
index 540d29d8..9aae3b0b 100644
--- a/agent-bot/tests/history.test.ts
+++ b/agent-bot/tests/history.test.ts
@@ -207,3 +207,47 @@ describe("a history that arrives out of order", () => {
expect(assistant.tool_calls?.[0]?.function.name).toBe("tool");
});
});
+
+/**
+ * A call read back from the thread store arrives in the store's dialect, not AG-UI's.
+ *
+ * `{id, name, args}` rather than `{id, type, function: {name, arguments}}`. Reaching straight for
+ * `call.function` finds nothing there, and the default underneath turned every restored call into a
+ * tool named `tool` with no arguments: the model is shown a call it cannot recognise as the one it
+ * made, so it makes it again. That is the repetition the default was written to prevent.
+ */
+describe("a tool call restored from the thread store", () => {
+ test("keeps the name and arguments it was made with", () => {
+ const messages = toProviderMessages({
+ messages: [
+ {
+ id: "m1",
+ role: "assistant",
+ content: null,
+ toolCalls: [
+ {
+ id: "call_1",
+ name: "computer_navigate",
+ args: '{"url":"https://news.ycombinator.com"}',
+ },
+ ],
+ },
+ {
+ id: "m2",
+ role: "tool",
+ toolCallId: "call_1",
+ content: '{"ok":true}',
+ },
+ ],
+ } as never);
+
+ const withCalls = messages.find(
+ (message: Record) => message.tool_calls,
+ ) as Record;
+ const call = (withCalls.tool_calls as Array>)[0];
+ const fn = call.function as Record;
+
+ expect(fn.name).toBe("computer_navigate");
+ expect(fn.arguments).toBe('{"url":"https://news.ycombinator.com"}');
+ });
+});
diff --git a/agent-computer/src/profile-listing.ts b/agent-computer/src/profile-listing.ts
new file mode 100644
index 00000000..cd00047c
--- /dev/null
+++ b/agent-computer/src/profile-listing.ts
@@ -0,0 +1,38 @@
+/**
+ * Which entries under the profiles root are Bots.
+ *
+ * ITS OWN MODULE SO ONE ANSWER SERVES BOTH SIDES. The rule lived inline in `profiles.ts`, and the
+ * test that covered it had a second copy: delete the production filter and the suite stayed green,
+ * because it was checking its own copy rather than the shipped one. A predicate worth testing is
+ * worth importing.
+ *
+ * Free of Playwright on purpose. `profiles.ts` launches browsers, so a test that wanted this rule had
+ * to drag a browser runtime in with it, which is most of why the copy existed in the first place.
+ */
+import { isPlainBotId } from "./bot-id";
+
+/** The shape of a directory entry, as both `readdir` and a test can supply it. */
+export type ProfileEntry = { name: string; isDirectory: () => boolean };
+
+/**
+ * The Bot ids among a directory listing, sorted and deduplicated.
+ *
+ * ONLY ENTRIES THIS CODE COULD HAVE MADE. The root is a mounted volume, and a volume is not an empty
+ * directory: a real disk formatted ext4 arrives with `lost+found` already in it, so on a cloud the
+ * fleet page listed a Bot by that name, offered to reset it, and nobody could say where it came from.
+ * Never seen locally, because a bind mount and kind's local-path volumes have no such directory,
+ * which is exactly the shape of bug that ships.
+ *
+ * `isPlainBotId` is the same allow-list that stops a hostile id becoming a path, used here for the
+ * other half of the question: an entry it would refuse to create is not one of ours to list.
+ */
+export function botIdsIn(entries: readonly ProfileEntry[]): string[] {
+ return [
+ ...new Set(
+ entries
+ .filter((entry) => entry.isDirectory())
+ .filter((entry) => isPlainBotId(entry.name))
+ .map((entry) => entry.name),
+ ),
+ ].sort();
+}
diff --git a/agent-computer/src/profiles.ts b/agent-computer/src/profiles.ts
index 420931ee..28f6547f 100644
--- a/agent-computer/src/profiles.ts
+++ b/agent-computer/src/profiles.ts
@@ -40,6 +40,7 @@ import { profileDirectoryFor } from "./bot-id";
import { chooseEvictions, chooseIdle } from "./browser-eviction";
import { egressFor, egressLabel } from "./egress";
import { numberFromEnv } from "./env";
+import { botIdsIn } from "./profile-listing";
// Re-exported so callers that already import it from here do not change, while the test imports it
// from the playwright-free `./env` instead of pulling this module's browser driver in with it.
@@ -368,12 +369,11 @@ export function createProfiles(root: string) {
const onDisk = await readdir(root, { withFileTypes: true }).catch(
() => [],
);
- return [
- ...new Set([
- ...onDisk.filter((e) => e.isDirectory()).map((e) => e.name),
- ...live.keys(),
- ]),
- ].sort();
+ /*
+ * The rule lives in its own module, so the test that covers it imports the same one this uses.
+ * It had a copy before, which meant deleting this filter left the suite green.
+ */
+ return [...new Set([...botIdsIn(onDisk), ...live.keys()])].sort();
},
/** What the admin surface lists. Running or not, because a Bot that has a profile has a computer. */
diff --git a/agent-computer/tests/profile-listing.test.ts b/agent-computer/tests/profile-listing.test.ts
new file mode 100644
index 00000000..e790a80c
--- /dev/null
+++ b/agent-computer/tests/profile-listing.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, test } from "bun:test";
+import { mkdir, mkdtemp, readdir, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { isPlainBotId } from "../src/bot-id";
+import { botIdsIn } from "../src/profile-listing";
+
+/**
+ * Which directories under the profiles root are Bots.
+ *
+ * The root is a mounted volume, and a volume is not an empty directory. A real disk formatted ext4
+ * arrives with `lost+found` already in it, so on a cloud the fleet page listed a Bot by that name and
+ * offered to reset it. Never seen locally, because a bind mount and kind's local-path volumes have no
+ * such directory: the bug appears only on the deployment shape the feature is for.
+ *
+ * The rule is the one that already exists. `isPlainBotId` decides what may become a profile path, so
+ * an entry it would refuse to create is not one of ours to list.
+ */
+async function knownIn(root: string): Promise {
+ const onDisk = await readdir(root, { withFileTypes: true }).catch(() => []);
+ /*
+ * THE SHIPPED PREDICATE, imported rather than restated.
+ *
+ * This test used to carry its own copy of the filter, which meant it proved the copy rather than
+ * the product: deleting the real one left the suite green and the fleet page listing `lost+found`
+ * as a Bot again.
+ */
+ return botIdsIn(onDisk);
+}
+
+describe("listing the Bots that have a computer", () => {
+ test("lists Bot profiles and ignores what the filesystem put there", async () => {
+ const root = await mkdtemp(join(tmpdir(), "profiles-"));
+ await mkdir(join(root, "knowledge"));
+ await mkdir(join(root, "risk-analyst"));
+ // What an ext4 volume brings with it, which is the whole reason this test exists.
+ await mkdir(join(root, "lost+found"));
+ // A file is not a computer either.
+ await writeFile(join(root, "notes.txt"), "");
+
+ expect(await knownIn(root)).toEqual(["knowledge", "risk-analyst"]);
+ });
+
+ test("the name a real volume arrives with is not a usable Bot id", () => {
+ // Stated directly, because this is the property the filter leans on.
+ expect(isPlainBotId("lost+found")).toBe(false);
+ expect(isPlainBotId("knowledge")).toBe(true);
+ });
+});
diff --git a/agent-langgraph/src/history.ts b/agent-langgraph/src/history.ts
index e95f376a..ce16acf4 100644
--- a/agent-langgraph/src/history.ts
+++ b/agent-langgraph/src/history.ts
@@ -73,11 +73,11 @@ export function toLangChainMessages(input: RunAgentInput): BaseMessage[] {
tool_calls:
message.toolCalls?.map((call) => ({
id: call.id,
- name: call.function.name,
+ name: callDetails(call).name,
// LangChain wants parsed arguments where AG-UI carries the raw string. A call whose
// arguments did not parse is passed as empty rather than dropped: the model needs to
// see that it made the call, or it makes it again.
- args: parseArguments(call.function.arguments),
+ args: parseArguments(callDetails(call).arguments),
})) ?? [],
}),
);
@@ -95,7 +95,7 @@ export function toLangChainMessages(input: RunAgentInput): BaseMessage[] {
new ToolMessage({
tool_call_id: call.id,
content: NO_ANSWER_CAME,
- name: call.function.name,
+ name: callDetails(call).name,
}),
);
}
@@ -116,3 +116,28 @@ function parseArguments(raw: string): Record {
return {};
}
}
+
+/**
+ * A tool call's name and arguments, in whichever dialect it arrived in.
+ *
+ * TWO SPELLINGS, ONE CALL. AG-UI describes `{id, type: "function", function: {name, arguments}}` and
+ * the history store writes `{id, name, args}`. Read back from a thread, every call arrives in the
+ * second, so `call.function.name` here did not merely degrade: it threw, and took the run with it.
+ */
+function callDetails(call: {
+ function?: { name?: unknown; arguments?: unknown };
+ name?: unknown;
+ args?: unknown;
+}): { name: string; arguments: string } {
+ const name = call.function?.name ?? call.name;
+ const args = call.function?.arguments ?? call.args;
+ return {
+ name: typeof name === "string" && name ? name : "tool",
+ arguments:
+ typeof args === "string"
+ ? args
+ : args === undefined || args === null
+ ? "{}"
+ : JSON.stringify(args),
+ };
+}
diff --git a/app/src/components/computer/computer-view.tsx b/app/src/components/computer/computer-view.tsx
index cfb7c00c..532c85b1 100644
--- a/app/src/components/computer/computer-view.tsx
+++ b/app/src/components/computer/computer-view.tsx
@@ -7,7 +7,11 @@ import {
supplySecret,
takeControl,
} from "@/lib/computers/control";
-import { readScreenshot, type Screenshot } from "@/lib/computers/screen";
+import {
+ readPageFrame,
+ readScreenshot,
+ type Screenshot,
+} from "@/lib/computers/screen";
import { ChannelAvatar } from "../channels/avatar";
import { LiveScreen } from "./live-screen";
@@ -18,6 +22,53 @@ function isBlankBrowser(shot: Screenshot): boolean {
return url === "" || url === "about:blank";
}
+/** The part of a URL worth putting on screen; the whole thing is rarely readable at this size. */
+function hostOf(url: string): string {
+ try {
+ return new URL(url).host;
+ } catch {
+ return url;
+ }
+}
+
+/**
+ * What each finished turn opened, and the frame it ended on, kept outside any component.
+ *
+ * MODULE SCOPE, BECAUSE THE TILE DOES NOT SURVIVE. A transcript re-renders freely and remounts the
+ * tiles in it, and anything held in component state goes with it: the fresh mount has no page yet,
+ * behaves for one render like a live turn, and reaches for the live screen. Keyed on the tool call,
+ * which is the identity of the turn rather than of the component drawing it.
+ *
+ * Bounded, because a long conversation is a lot of screenshots. Oldest out first, and a turn whose
+ * frame has been dropped falls back to naming its page.
+ */
+type RememberedTurn = {
+ page?: { url?: string; title?: string };
+ frame?: { base64: string; url: string };
+ /** Whether the server has already been asked, so a turn with no frame is not asked again. */
+ asked?: boolean;
+};
+const REMEMBERED_TURNS = new Map();
+const MAX_REMEMBERED_TURNS = 40;
+
+function rememberTurn(toolCallId: string, patch: RememberedTurn): void {
+ const existing = REMEMBERED_TURNS.get(toolCallId) ?? {};
+ /*
+ * A FRAME IS WRITTEN ONCE, which is what the server's own insert says and what this has to agree
+ * with. Letting a later write win is exactly what went wrong: the tile restored the right frame and
+ * then replaced it, one render later, with a screenshot of whatever the Bot had open by then.
+ */
+ const merged: RememberedTurn = { ...existing, ...patch };
+ if (existing.frame) merged.frame = existing.frame;
+ REMEMBERED_TURNS.delete(toolCallId);
+ REMEMBERED_TURNS.set(toolCallId, merged);
+ while (REMEMBERED_TURNS.size > MAX_REMEMBERED_TURNS) {
+ const oldest = REMEMBERED_TURNS.keys().next().value;
+ if (oldest === undefined) break;
+ REMEMBERED_TURNS.delete(oldest);
+ }
+}
+
/** Default browser viewport ratio, reserved before the first screenshot arrives. */
const DEFAULT_ASPECT_RATIO = 1280 / 800;
@@ -56,13 +107,50 @@ const SECRET_CONFIRM_MS = 6_000;
function NothingToSee({
problem,
blankBrowser,
+ settled,
+ page,
}: {
problem: string | null;
blankBrowser: boolean;
+ /** Whether this is a turn that has finished, rather than the browser as it is now. */
+ settled?: boolean;
+ /** The page that turn opened, named when there is no picture of it. */
+ page?: { url?: string; title?: string } | undefined;
}) {
return (
- {problem ? (
+ {settled ? (
+ <>
+ {/*
+ What this turn had open, named rather than drawn.
+
+ The picture is gone: nothing stored it, and fetching one now would show a different page.
+ Naming the page is the honest version of the same sentence, and it stays true however
+ many times the Bot has browsed since.
+
+ GATED ON THE TURN BEING OVER, not on whether a live frame happens to be in hand. A tile
+ that was live a moment ago keeps its last screenshot in state after it settles, and this
+ used to check for that: with one held and no frame stored, it fell through to "Waiting
+ for the assistant's screen…" and waited there for ever, because the poll that would have
+ ended the wait stops the moment a turn settles.
+ */}
+ {page?.url ? (
+ <>
+ {page.title || "A page"}
+ {hostOf(page.url)}
+
+ Opened during this turn. The screen has moved on since.
+
+ >
+ ) : (
+ /*
+ * A turn that ended without getting anywhere: refused by a boundary, stopped, or failed.
+ * Saying "opened during this turn" here would describe something that did not happen.
+ */
+ This turn did not open a page.
+ )}
+ >
+ ) : problem ? (
<>
You cannot see the screen right now
@@ -94,6 +182,30 @@ type Props = {
minHeight?: number;
/** Whose screen this is, drawn as a small badge over the frame. Absent, no badge is drawn. */
name?: string;
+ /**
+ * The page this turn left the browser on, for a turn that has finished.
+ *
+ * A conversation is a record, and a record must not change its mind. Without this, reopening a
+ * conversation made every past turn fetch the screen as it is now, so an answer about Hacker News
+ * from an hour ago sat under a picture of whatever the Bot has open today. The frame was live, the
+ * caption was not, and the turn read as though it had browsed somewhere it never went.
+ */
+ page?: { url?: string; title?: string };
+ /**
+ * Whether the turn this tile belongs to has ended.
+ *
+ * SEPARATE FROM HAVING A PAGE. A navigation that was refused, failed or stopped ends without one,
+ * and a tile that decided history by "do I have a page" left exactly those turns polling the live
+ * screen for ever, under an answer that had nothing to do with what was on it.
+ */
+ finished?: boolean;
+ /**
+ * The tool call this tile belongs to, which is what a kept frame is filed under.
+ *
+ * Without it the tile can still name the page; with it, it can show the page. Optional because the
+ * side panel is not a turn and has nothing to remember.
+ */
+ toolCallId?: string;
};
export function ComputerView({
@@ -104,6 +216,9 @@ export function ComputerView({
minWidth = DEFAULT_MIN_WIDTH,
minHeight = DEFAULT_MIN_HEIGHT,
name,
+ page,
+ finished,
+ toolCallId,
}: Props) {
const [shot, setShot] = useState(null);
const [problem, setProblem] = useState(null);
@@ -132,8 +247,76 @@ export function ComputerView({
/** Force a short watch window after non-Bot actions such as secret entry. */
const watchUntil = useRef(0);
+ /**
+ * A finished turn is history, and history is not polled.
+ *
+ * While a turn runs, the frames are that turn's own and freeze where it left them, which is right.
+ * Reopening the conversation later is the case this guards: the component mounts with no frame,
+ * and fetching one would put today's page under yesterday's answer. It shows the page that turn
+ * actually left open instead, which is the thing being remembered.
+ *
+ * `page` is what marks a turn as settled history rather than one still going, so a caller that
+ * knows nothing about the page keeps the old behaviour and nothing regresses.
+ *
+ * DELIBERATELY NOT "AND WE HAVE NO FRAME YET". That is what this said first, and it undid itself:
+ * restoring the kept frame set the frame, which made the turn stop counting as history, which
+ * restarted the polling this exists to prevent, which replaced the restored picture with the live
+ * one. The turn being over is the fact; whether a picture has arrived yet is not.
+ */
+ if (toolCallId && page?.url) rememberTurn(toolCallId, { page });
+ const knownPage =
+ page?.url !== undefined
+ ? page
+ : toolCallId
+ ? REMEMBERED_TURNS.get(toolCallId)?.page
+ : undefined;
+ const keptFrame = toolCallId
+ ? (REMEMBERED_TURNS.get(toolCallId)?.frame ?? null)
+ : null;
+ /** Bumped when a frame arrives, because the store it lands in is not React state. */
+ const [, setFrameArrived] = useState(0);
+
+ const settled = !active && (finished || Boolean(knownPage));
+
+ /*
+ * The frame this turn's page was showing, fetched once and then kept.
+ *
+ * A READ, AND ONLY A READ. The tile used to capture the frame itself once the turn went inactive,
+ * and it kept filing the wrong picture: a reopened turn and one that has just finished look
+ * identical from in here, the same computer is driven by other conversations between the two, and
+ * a resumed computer starts blank. The frame is now taken on the server the moment the navigation
+ * succeeds, which is the one moment the screen is certainly showing the page that was asked for,
+ * so there is nothing left here to race.
+ */
+ useEffect(() => {
+ if (!toolCallId || !settled) return;
+ const remembered = REMEMBERED_TURNS.get(toolCallId);
+ /*
+ * Asked once per turn, answer or not. Without remembering the empty answer, every turn from
+ * before this shipped refetched nothing on every remount, which on a long transcript is one
+ * pointless request per turn per scroll.
+ */
+ if (remembered?.frame || remembered?.asked) return;
+ let current = true;
+
+ void (async () => {
+ const stored = await readPageFrame(computerId, toolCallId);
+ if (!current) return;
+ rememberTurn(toolCallId, {
+ asked: true,
+ ...(stored ? { frame: { base64: stored.frame, url: stored.url } } : {}),
+ });
+ if (stored) setFrameArrived((n) => n + 1);
+ })();
+
+ return () => {
+ current = false;
+ };
+ }, [computerId, toolCallId, settled]);
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `secretPending` intentionally restarts settled polling.
useEffect(() => {
+ if (settled) return;
const mine = ++generation.current;
let timer: ReturnType;
// Consecutive identical frames observed during post-action settling.
@@ -181,10 +364,11 @@ export function ComputerView({
generation.current++;
clearTimeout(timer);
};
- }, [computerId, active, intervalMs, secretPending]);
+ }, [computerId, active, intervalMs, secretPending, settled]);
/** Poll control state independently from screenshot polling so help/secret prompts surface. */
useEffect(() => {
+ if (settled) return;
let live = true;
let timer: ReturnType;
const tick = async () => {
@@ -198,7 +382,7 @@ export function ComputerView({
live = false;
clearTimeout(timer);
};
- }, [computerId]);
+ }, [computerId, settled]);
// Input forwarding lives in LiveScreen on the socket.
// Escape is bound to the window so it works regardless of overlay focus.
@@ -212,7 +396,11 @@ export function ComputerView({
}, [expanded]);
// Always render the card frame; help/secret controls live below the conditional picture.
- const blankBrowser = shot ? isBlankBrowser(shot) : false;
+ /*
+ * A finished turn is never "blank": it opened a page, and that is what it shows or names. Only a
+ * live browser can be sitting on about:blank.
+ */
+ const blankBrowser = !settled && shot ? isBlankBrowser(shot) : false;
/*
* Sized from the ratio, never from the payload, so the frame is identical while a screen is
@@ -221,8 +409,19 @@ export function ComputerView({
* surface whose whole job is showing a screen kept surprising the layout around it.
*/
const frameStyle = { aspectRatio, minWidth, minHeight };
+ /**
+ * What this tile draws: the kept frame for a turn that is over, the live one while it runs.
+ *
+ * A finished turn never draws `shot`. It may hold one, caught in the render between mounting and
+ * its result arriving, and that frame is of whatever the Bot has open now rather than of this turn.
+ */
+ const drawn = settled
+ ? keptFrame
+ : shot
+ ? { base64: shot.base64, url: shot.url ?? "" }
+ : null;
/** Whether there is a page to draw. A blank browser and an unreadable screen are both "no". */
- const showScreen = shot !== null && !blankBrowser;
+ const showScreen = drawn !== null && !blankBrowser;
/**
* Whether the full-size view has a stream worth opening.
*
@@ -230,11 +429,21 @@ export function ComputerView({
* gets the live socket whatever is on it, because once a person is driving the stream is the truth
* about the page and a placeholder over it would be the view arguing with them.
*/
- const showLiveScreen = showScreen || driving;
+ const showLiveScreen = !settled && (showScreen || driving);
+ /**
+ * Whether the wheel in somebody's hands is the wheel THIS tile is showing.
+ *
+ * A person can take control mid-navigation, and the turn then settles under them. `driving` stays
+ * true, because it is true: they are driving the browser. It is just not the browser in this
+ * picture any more. Left ungated, the frozen tile asserted "You have control" over a page from an
+ * hour ago, with the hand-back footer already gone and the backdrop refusing to close because it
+ * believed somebody was driving it.
+ */
+ const wheelHere = driving && !settled;
const polledScreen = showScreen ? (
{name ? (
@@ -270,7 +479,7 @@ export function ComputerView({
{name}
) : null}
- {driving ? (
+ {wheelHere ? (
You have control
@@ -279,7 +488,12 @@ export function ComputerView({
) : null}
{showScreen ? null : (
-
+
)}
@@ -293,7 +507,7 @@ export function ComputerView({
* view to find out what was wanted would hide the reason behind a click. Taking the wheel
* from here opens that view, because driving is what they are being asked to do.
*/}
- {!driving && control?.requested ? (
+ {!driving && !settled && control?.requested ? (
The assistant needs you.{" "}
@@ -387,14 +601,17 @@ export function ComputerView({
aria-label="The assistant's screen"
className="fixed inset-0 z-50 flex flex-col items-center justify-center p-4 sm:p-8"
>
- {/* Backdrop closes only while read-only; during driving, Escape remains the exit. */}
+ {/*
+ Backdrop closes only while read-only; during driving, Escape remains the exit. A
+ turn that is over is always read-only, whoever is holding the live browser.
+ */}
,
document.body,
diff --git a/app/src/lib/computers/screen.ts b/app/src/lib/computers/screen.ts
index 874a5438..df3025e2 100644
--- a/app/src/lib/computers/screen.ts
+++ b/app/src/lib/computers/screen.ts
@@ -40,3 +40,31 @@ export async function readScreenshot(
return { error: unavailable };
}
}
+
+/** The frame a page was showing when a Bot opened it. */
+export type PageFrame = { url: string; title: string | null; frame: string };
+
+/**
+ * What this turn had on screen when it opened its page, or nothing if it was never kept.
+ *
+ * Nothing here writes. The frame is taken on the server at the moment the navigation succeeds, which
+ * is the only moment the screen is certainly showing the page that was asked for. Capturing it here
+ * instead meant capturing it after the turn, from a computer other conversations are also driving,
+ * and filing whatever it happened to show.
+ */
+export async function readPageFrame(
+ computerId: string,
+ toolCallId: string,
+): Promise {
+ try {
+ const response = await tryClient(
+ `/api/computers/${computerId}/page-frame/${encodeURIComponent(toolCallId)}`,
+ );
+ if (!response.ok) return null;
+ const body = (await response.json()) as { frame?: PageFrame | null };
+ return body.frame ?? null;
+ } catch {
+ // A missing picture is a smaller sentence, not a broken conversation.
+ return null;
+ }
+}
diff --git a/app/src/lib/copilot/computer-tools.tsx b/app/src/lib/copilot/computer-tools.tsx
index c4087b49..20d9f75d 100644
--- a/app/src/lib/copilot/computer-tools.tsx
+++ b/app/src/lib/copilot/computer-tools.tsx
@@ -126,6 +126,9 @@ type ComputerOutcome = {
bytes?: number;
/** A file read. Named `text` on the way back and `contents` on the way in. */
text?: string;
+ /** Where a navigation landed, which is what a finished turn's screen tile remembers. */
+ url?: string;
+ title?: string;
};
/**
@@ -249,7 +252,10 @@ export function ComputerTools() {
handler: async (
{ url }: { url: string },
// Context is optional in the SDK.
- { signal }: { signal?: AbortSignal } = {},
+ {
+ signal,
+ toolCall,
+ }: { signal?: AbortSignal; toolCall?: { id?: string } } = {},
) => {
const computerId = bot.current;
const result = await callComputer(
@@ -257,7 +263,15 @@ export function ComputerTools() {
"/navigate",
{
method: "POST",
- body: { url },
+ /*
+ * Which turn is asking, so the server can file the picture under it.
+ *
+ * The handler's context carries the tool call, which is worth saying because assuming it
+ * did not is how the frame ended up keyed on the page instead: two visits to one address
+ * then collided, and resolving that by letting the newer win made a past turn's picture
+ * change under the person reading it.
+ */
+ body: { url, ...(toolCall?.id ? { toolCallId: toolCall.id } : {}) },
},
signal,
);
@@ -279,11 +293,50 @@ export function ComputerTools() {
}
: result;
},
- render: ({ status }) => (
-
-
-
- ),
+ render: ({ result, status, toolCallId }) => {
+ /*
+ * The page this turn left open, so reopening the conversation shows what it browsed rather
+ * than what the Bot has open now. Only once the turn is finished: while it runs, the live
+ * frames are its own.
+ */
+ /*
+ * A RESULT IS WHAT MAKES A TURN OVER, not the status.
+ *
+ * A restored tool call arrives with its result already in hand and a status that is briefly
+ * something other than complete, so keying on the status alone made every reopened turn look
+ * like one still running: the tile polled the live screen, put today's page under yesterday's
+ * answer, and only then restored the frame it should have shown from the start.
+ */
+ const finished = status === "complete" || result !== undefined;
+ const outcome = finished ? outcomeOf(result) : {};
+ const page =
+ typeof outcome.url === "string"
+ ? {
+ url: outcome.url,
+ ...(typeof outcome.title === "string"
+ ? { title: outcome.title }
+ : {}),
+ }
+ : undefined;
+ return (
+
+
+
+ );
+ },
});
useFrontendTool({
diff --git a/app/src/lib/copilot/thread-messages.ts b/app/src/lib/copilot/thread-messages.ts
index 887ecec3..d6d35e97 100644
--- a/app/src/lib/copilot/thread-messages.ts
+++ b/app/src/lib/copilot/thread-messages.ts
@@ -11,14 +11,25 @@ import { tryClient } from "@/lib/client";
*
* WHAT ARRIVES HERE IS NOT TRUSTED. This used to end `stored as Message[]`, which is a cast rather
* than a check: whatever the history store held was handed to `setMessages` and then to every
- * projection that reads a transcript. A turn shaped differently — a tool call persisted as
- * `{id, name, args}` instead of AG-UI's `{id, type: "function", function: {…}}`, which interrupted
- * runs have produced — reached a renderer that dereferenced `toolCall.function.arguments` and took
- * the whole conversation down with it. One bad turn made a thread unreadable.
+ * projection that reads a transcript. A turn shaped differently reached a renderer that dereferenced
+ * `toolCall.function.arguments` and took the whole conversation down with it. One bad turn made a
+ * thread unreadable.
*
* So each turn is parsed against the schema AG-UI ships, and one that does not parse is left out.
* Checked here rather than in a projection because there are several projections and one history:
* fixing it in the reader that is closest to the wire is what makes every consumer safe at once.
+ *
+ * BUT `{id, name, args}` IS NOT A CORRUPTION, AND TREATING IT AS ONE DELETED REAL WORK. That shape
+ * was read as damage from an interrupted run and dropped. It is how the runtime persists every tool
+ * call it stores, so dropping it meant every turn in which a Bot used a tool vanished on reload: the
+ * transcript kept the sentence the Bot wrote and lost the browsing that produced it, the inline
+ * screen went with it, and the footer said some messages could not be read. Observed against a live
+ * thread, where every browsing turn was counted unreadable and every one of them was well formed in
+ * the store's own dialect.
+ *
+ * So it is translated rather than refused. The check stays for turns that really are malformed; a
+ * reader is entitled to insist on one shape, but not to throw away the history because the writer
+ * spells it another way.
*/
/**
@@ -52,8 +63,11 @@ export function readableTurns(stored: readonly unknown[]): StoredThread {
let unreadable = 0;
for (const turn of stored) {
- if (MessageSchema.safeParse(turn).success) {
- messages.push(turn as Message);
+ const candidate = withNormalisedToolCalls(
+ withoutNullAssistantContent(turn),
+ );
+ if (MessageSchema.safeParse(candidate).success) {
+ messages.push(candidate as Message);
} else {
unreadable += 1;
}
@@ -62,6 +76,89 @@ export function readableTurns(stored: readonly unknown[]): StoredThread {
return { messages, unreadable };
}
+/**
+ * An assistant turn whose content is `null`, read as one that simply has no content.
+ *
+ * The schema makes an assistant's content optional and does not allow it to be null, so the two say
+ * the same thing and only one parses. A turn that called a tool and said nothing alongside it is
+ * written exactly that way, so this dropped the browsing and kept nothing in its place: the same
+ * loss the tool-call dialect caused, arriving by a different route.
+ *
+ * ASSISTANT ONLY. A user turn's content is required, and `content: null` there is not a message
+ * somebody sent; it used to reach a projection and draw as a blank line, which is why it is refused
+ * and counted rather than quietly shown. That decision stands.
+ */
+function withoutNullAssistantContent(turn: unknown): unknown {
+ if (typeof turn !== "object" || turn === null) return turn;
+ const record = turn as Record;
+ if (record.role !== "assistant" || record.content !== null) return turn;
+ const { content: _dropped, ...rest } = record;
+ return rest;
+}
+
+/** A tool call as the history store writes one. */
+type StoredToolCall = { id?: unknown; name?: unknown; args?: unknown };
+
+/**
+ * The store's dialect for a tool call, in the shape AG-UI describes.
+ *
+ * `{id, name, args}` becomes `{id, type: "function", function: {name, arguments}}`. Only the array is
+ * rebuilt and only when every entry is in that dialect: a turn already in AG-UI's shape is returned
+ * untouched, and a mixed or unrecognised array is left exactly as it came so the parse below still
+ * refuses it rather than this quietly inventing something.
+ *
+ * The rest of the message is spread through unchanged, for the same reason `parsed.data` is not used
+ * anywhere here: a reader that rewrites what it does not recognise is worse than one that refuses it.
+ */
+function withNormalisedToolCalls(turn: unknown): unknown {
+ if (typeof turn !== "object" || turn === null) return turn;
+ const calls = (turn as { toolCalls?: unknown }).toolCalls;
+ if (!Array.isArray(calls) || calls.length === 0) return turn;
+
+ const isStoredDialect = (call: unknown): call is StoredToolCall =>
+ typeof call === "object" &&
+ call !== null &&
+ "name" in call &&
+ "args" in call &&
+ !("function" in call);
+ if (!calls.every(isStoredDialect)) return turn;
+
+ return {
+ ...(turn as Record),
+ toolCalls: calls.map((call) => ({
+ id: call.id,
+ type: "function",
+ function: { name: call.name, arguments: argumentsOf(call.args) },
+ })),
+ };
+}
+
+/**
+ * The arguments, as a string, because that is what the protocol says they are.
+ *
+ * AG-UI types `arguments` as a string and the store is under no such obligation: it holds whatever
+ * the run put there, which for a tool called with structured input is an object. Passing that through
+ * produced a call that looked translated and still failed validation, so the turn was dropped anyway.
+ * That is this whole function's bug one layer down, which is a good reason to be explicit here rather
+ * than to trust the shapes to line up.
+ *
+ * A string is already right and is left exactly as it is, down to its whitespace: it may be a
+ * fragment of a stream that was never valid JSON, and re-encoding it would change what the model
+ * actually said. Anything else is encoded. `undefined` becomes `"{}"`, which is what a call with no
+ * arguments means and what every reader of this field expects to parse.
+ */
+function argumentsOf(args: unknown): string {
+ if (typeof args === "string") return args;
+ if (args === undefined || args === null) return "{}";
+ try {
+ return JSON.stringify(args);
+ } catch {
+ // Circular, or something else that cannot be encoded. An empty object is a call the reader can
+ // parse; a throw here would lose the whole conversation over one malformed argument list.
+ return "{}";
+ }
+}
+
export async function readThreadMessages(
threadId: string,
agentId: string,
diff --git a/app/src/routes/_authed/_app/bot.tsx b/app/src/routes/_authed/_app/bot.tsx
index 620b2e96..b29f1322 100644
--- a/app/src/routes/_authed/_app/bot.tsx
+++ b/app/src/routes/_authed/_app/bot.tsx
@@ -1,7 +1,9 @@
import { CopilotChat } from "@copilotkit/react-core/v2";
import { IconPlus } from "@tabler/icons-react";
+import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
+import { agentListQueryOptions } from "@/lib/agents/queries";
import { useActiveBot } from "@/lib/copilot/active-bot";
import { useBotThread } from "@/lib/copilot/bot-thread";
import { useStoppedTurn } from "@/lib/copilot/stopped-turn";
@@ -13,10 +15,45 @@ export const Route = createFileRoute("/_authed/_app/bot")({
}),
});
+/**
+ * Which Bot this screen is for.
+ *
+ * WHATEVER THIS DEPLOYMENT ACTUALLY HAS. The default used to be a hardcoded `risk-analyst`, a name
+ * from a tenant package this one is not: on a clone that ships anything else, opening this screen
+ * without naming a Bot took the whole page down to an unstyled error boundary, because the chat
+ * throws when asked for an agent the runtime never synced. OpenBot exists to be forked, so a Bot
+ * name written into a route is a defect on every fork but the one it came from.
+ *
+ * A named Bot that this deployment does not have is answered in a sentence rather than thrown,
+ * for the same reason: a mistyped link is not a crash.
+ */
function RouteComponent() {
const { agent } = Route.useSearch();
- const agentId = agent ?? "risk-analyst";
+ const { data: agents, isPending } = useQuery(agentListQueryOptions());
+ const agentId = agent ?? agents?.[0]?.id;
+ const known = agents?.some((candidate) => candidate.id === agentId) ?? false;
+ if (isPending) return null;
+ if (!agentId || !known) {
+ return (
+
+
+ {agent
+ ? `This deployment has no Bot called "${agent}".`
+ : "This deployment has no Bots yet."}
+