From d975052c379b116dd8b26384cbffcbf338ddc7fc Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Sun, 30 Aug 2026 10:52:43 -0700 Subject: [PATCH 01/15] Give a Bot template a format that refuses what it must not carry --- shared/bot-template.test.ts | 555 +++++++++++++++++++ shared/bot-template.ts | 1035 +++++++++++++++++++++++++++++++++++ 2 files changed, 1590 insertions(+) create mode 100644 shared/bot-template.test.ts create mode 100644 shared/bot-template.ts diff --git a/shared/bot-template.test.ts b/shared/bot-template.test.ts new file mode 100644 index 00000000..de9cdb37 --- /dev/null +++ b/shared/bot-template.test.ts @@ -0,0 +1,555 @@ +import { describe, expect, test } from "bun:test"; +import { + BOT_TEMPLATE_FORMAT, + type BotTemplate, + botTemplateDigest, + parseBotTemplate, + serializeBotTemplate, + STRICT_BOUNDARY, + TEMPLATE_LIMITS, + TemplateRefusedError, + templateGrantMark, +} from "./bot-template"; + +/** The sequence under test, built rather than written, for the reason the module states. */ +const INTERPOLATION = `${"$"}{KEY_ENCRYPTION_KEY}`; + +/** A minimal document every test can start from, so each one varies exactly one thing. */ +const MINIMAL = ` +openbot_template: 1 +template: + slug: renewal-desk + summary: Chases overdue invoices. +bot: + name: Renewal Desk + title: Accounts Receivable + role_description: Chase overdue invoices and draft the follow-up. + runtime: managed +`.trimStart(); + +function withRoot(extra: string): string { + return `${MINIMAL}${extra}`; +} + +/** Asserts the refusal code as well as the fact of refusal: a refusal for the wrong reason is a bug. */ +function refusalOf(source: string): string { + try { + parseBotTemplate(source); + } catch (error) { + if (error instanceof TemplateRefusedError) return error.reason; + throw error; + } + throw new Error("the document was accepted, and the test expected a refusal"); +} + +describe("a document the format accepts", () => { + test("reads the minimum a template can say", () => { + const template = parseBotTemplate(MINIMAL); + expect(template.format).toBe(BOT_TEMPLATE_FORMAT); + expect(template.template.slug).toBe("renewal-desk"); + expect(template.bot.name).toBe("Renewal Desk"); + expect(template.bot.runtime).toBe("managed"); + expect(template.skills).toEqual([]); + expect(template.requests).toEqual({ connectors: [], components: [] }); + }); + + test("an absent boundary is the strictest one, not the most permissive", () => { + expect(parseBotTemplate(MINIMAL).boundary).toEqual(STRICT_BOUNDARY); + }); + + test("a partial boundary fills the rest from the strict default", () => { + const template = parseBotTemplate( + withRoot(` +boundary: + browser: read_only +`), + ); + expect(template.boundary.browser).toBe("read_only"); + expect(template.boundary.shell).toBe("never"); + expect(template.boundary.files).toBe("none"); + }); + + test("hostnames are lower-cased and de-duplicated", () => { + const template = parseBotTemplate( + withRoot(` +boundary: + browser: read_only + navigate_hosts: [Billing.ACME.example, billing.acme.example] +`), + ); + expect(template.boundary.navigateHosts).toEqual(["billing.acme.example"]); + }); + + test("a skill and its tool declarations survive intact", () => { + const template = parseBotTemplate( + withRoot(` +skills: + - slug: check-renewal-risk + title: Check renewal risk + summary: Pull the contract and the recent tickets. + instructions: Find the contract before answering anything about a renewal. + tools: + - google-drive/search_files + - google-drive/search_files +`), + ); + expect(template.skills).toHaveLength(1); + expect(template.skills[0]?.tools).toEqual(["google-drive/search_files"]); + }); + + test("a tool ref is not checked against anything that exists", () => { + // The whole point: a template names tools for connectors nobody has added yet, and they sit + // inert until somebody does. Refusing here would mean a template could only ship refs for + // connectors it could guarantee, which is none of them. + const template = parseBotTemplate( + withRoot(` +skills: + - slug: find-a-thing + title: Find a thing + summary: Look somewhere nobody has connected. + instructions: Search before answering. + tools: [nobody-has-connected-this/search_everything] +`), + ); + expect(template.skills[0]?.tools).toEqual([ + "nobody-has-connected-this/search_everything", + ]); + }); +}); + +describe("the refusals that read bytes rather than a document", () => { + test("an environment reference anywhere is refused", () => { + expect(refusalOf(withRoot(`notes: the key is ${INTERPOLATION}\n`))).toBe( + "interpolation", + ); + }); + + test("an environment reference in a comment is refused too", () => { + // Checked before parse precisely so a YAML parser cannot drop it out from under the check. + const comment = `# ${INTERPOLATION} is expanded by the package loader, not here\n`; + expect(refusalOf(comment + MINIMAL)).toBe("interpolation"); + }); + + // Written as escapes, not as the characters themselves: a source file carrying these has the + // same problem the check exists to solve, and a reviewer cannot tell a test fixture from a payload. + test.each([ + ["zero-width space", "\u200B"], + ["right-to-left override", "\u202E"], + ["private use area", "\uE000"], + ["soft hyphen", "\u00AD"], + ["byte order mark inside the text", "\uFEFF"], + ["word joiner", "\u2060"], + ["a tag character", "\u{E0041}"], + ["a C0 control", "\u0007"], + ["a C1 control", "\u0085"], + ] as const)("an invisible codepoint is refused: %s", (_name, character) => { + expect(refusalOf(withRoot(`notes: hello${character}world\n`))).toBe( + "invisible_character", + ); + }); + + test("tab, newline and carriage return are not invisible characters", () => { + const template = parseBotTemplate( + `${MINIMAL.replace(/\n/g, "\r\n")}notes: "a\\tb"\r\n`, + ); + expect(template.notes).toBe("a\tb"); + }); + + test("a document larger than the limit is refused before it is parsed", () => { + const padding = "x".repeat(TEMPLATE_LIMITS.DOCUMENT_BYTES); + expect(refusalOf(withRoot(`notes: ${padding}\n`))).toBe("too_large"); + }); +}); + +describe("the refusals that make parsing strict", () => { + test("an unknown key at the root is refused rather than ignored", () => { + expect(refusalOf(withRoot("channels: [general]\n"))).toBe("unknown_key"); + }); + + test("an unknown key inside a block is refused just as loudly", () => { + expect( + refusalOf(` +openbot_template: 1 +template: + slug: renewal-desk + summary: Chases overdue invoices. +bot: + name: Renewal Desk + title: Accounts Receivable + role_description: Chase overdue invoices. + runtime: managed + model: gpt-4o +`), + ).toBe("unknown_key"); + }); + + test.each([ + ["a credential value", "auth_value: sk-live-not-a-real-key"], + ["a credential reference", "credential_secret_ref: model-key"], + ["an endpoint", "endpoint: https://evil.example/agui"], + ["a url", "url: https://evil.example/agui"], + ["a system prompt", "system_prompt: You are helpful."], + ["a package id", "package_id: 0000-1111"], + ["a visibility", "visibility: public"], + ["a policy rule", "deny: [true]"], + ["component source", "components: []"], + ])( + "a forbidden field is named rather than quietly dropped: %s", + (_name, line) => { + expect(refusalOf(withRoot(`${line}\n`))).toBe("forbidden_field"); + }, + ); + + test("an unreadable format version is refused", () => { + expect( + refusalOf(MINIMAL.replace("openbot_template: 1", "openbot_template: 2")), + ).toBe("format_version"); + }); + + test("an absent format version is refused", () => { + expect(refusalOf(MINIMAL.replace("openbot_template: 1\n", ""))).toBe( + "format_version", + ); + }); + + test("malformed YAML is refused as malformed YAML", () => { + expect(refusalOf("openbot_template: 1\n bad: [indent\n")).toBe( + "malformed_yaml", + ); + }); +}); + +describe("the refusals that keep an imported Bot editable", () => { + test.each([ + ["one character", "x"], + ["a trailing hyphen", "find-"], + ["a leading hyphen", "-find"], + ["upper case", "Find-A-Document"], + ["an underscore", "find_a_document"], + ])( + "a slug the Skills API would refuse is refused here: %s", + (_name, slug) => { + expect( + refusalOf( + withRoot(` +skills: + - slug: ${JSON.stringify(slug)} + title: A skill + summary: A summary. + instructions: Some instructions. +`), + ), + ).toBe("bad_slug"); + }, + ); + + test.each([ + ["name", "name", TEMPLATE_LIMITS.NAME], + ["title", "title", TEMPLATE_LIMITS.TITLE], + ["role_description", "role_description", TEMPLATE_LIMITS.ROLE_DESCRIPTION], + ])( + "a field longer than the edit form allows is refused: %s", + (_name, key, limit) => { + const long = "a".repeat(limit + 1); + expect( + refusalOf(MINIMAL.replace(new RegExp(`${key}: .*`), `${key}: ${long}`)), + ).toBe("too_long"); + }, + ); + + test("the same slug twice in one file is refused", () => { + expect( + refusalOf( + withRoot(` +skills: + - slug: a-skill + title: One + summary: One. + instructions: One. + - slug: a-skill + title: Two + summary: Two. + instructions: Two. +`), + ), + ).toBe("bad_slug"); + }); +}); + +describe("what a template may say about where its Bot runs", () => { + test("a remote block belongs only on a remote template", () => { + expect( + refusalOf( + withRoot(` +bot_extra: ignored +`), + ), + ).toBe("unknown_key"); + expect( + refusalOf(` +openbot_template: 1 +template: + slug: renewal-desk + summary: Chases overdue invoices. +bot: + name: Renewal Desk + title: Accounts Receivable + role_description: Chase overdue invoices. + runtime: managed + remote: + requires_key: true +`), + ).toBe("bad_type"); + }); + + test("a remote template describes the ask and never the address", () => { + const template = parseBotTemplate(` +openbot_template: 1 +template: + slug: renewal-desk + summary: Chases overdue invoices. +bot: + name: Renewal Desk + title: Accounts Receivable + role_description: Chase overdue invoices. + runtime: remote + remote: + auth_header: Authorization + requires_key: true + example_url: https://renewals.example.com/agui + sends_conversation_to: RENEWALS.example.com +`); + expect(template.bot.remote?.authHeader).toBe("Authorization"); + expect(template.bot.remote?.requiresKey).toBe(true); + expect(template.bot.remote?.sendsConversationTo).toBe( + "renewals.example.com", + ); + // There is no field that could hold the real address, which is what makes the + // attacker-endpoint attack unrepresentable rather than merely gated. + expect(Object.keys(template.bot)).not.toContain("endpoint"); + }); + + test.each([ + ["a scheme", "https://billing.acme.example"], + ["a port", "billing.acme.example:443"], + ["a path", "billing.acme.example/invoices"], + ["a wildcard", "*.acme.example"], + ])( + "a navigate host that is not a plain hostname is refused: %s", + (_name, host) => { + expect( + refusalOf( + withRoot(` +boundary: + browser: read_only + navigate_hosts: [${JSON.stringify(host)}] +`), + ), + ).toBe("bad_hostname"); + }, + ); + + test.each([ + ["plain http", "http://example.com/x"], + ["a javascript link", "javascript:alert(1)"], + ["a credential in the address", "https://user:pass@example.com/x"], + ])( + "a link shown beside a Bot's name is held to https and a plain host: %s", + (_name, url) => { + // Nothing fetches these. They are attacker-controlled text rendered next to a Bot's name while + // somebody decides whether to trust it, which is the one moment a clickable javascript: or a + // credential-carrying address would be worth the most. + expect( + refusalOf( + MINIMAL.replace( + " summary:", + ` source: ${JSON.stringify(url)}\n summary:`, + ), + ), + ).toBe("bad_url"); + }, + ); +}); + +describe("what a template may ask for", () => { + test("a tool must belong to the connector it is filed under", () => { + expect( + refusalOf( + withRoot(` +requests: + connectors: + - id: google-drive + why: The ledger lives in Drive. + tools: + - ref: notion/notion-search + why: Sneaking this in under a familiar heading. +`), + ), + ).toBe("bad_tool_ref"); + }); + + test("an ask is read as an ask, with the author's reason attached", () => { + const template = parseBotTemplate( + withRoot(` +requests: + connectors: + - id: google-drive + why: The invoice ledger export lives in Drive. + tools: + - ref: google-drive/search_files + why: Find the ledger for one customer. + components: + - name: showBarChart + why: Ageing buckets. +`), + ); + expect(template.requests.connectors[0]?.id).toBe("google-drive"); + expect(template.requests.connectors[0]?.tools[0]?.why).toBe( + "Find the ledger for one customer.", + ); + expect(template.requests.components[0]?.name).toBe("showBarChart"); + }); + + test("a Bot may only be given skills the same file defines", () => { + expect( + refusalOf( + withRoot(` +skills: + - slug: a-skill + title: One + summary: One. + instructions: One. +`).replace( + " runtime: managed", + " runtime: managed\n skills: [somebody-elses-skill]", + ), + ), + ).toBe("unknown_skill"); + }); +}); + +describe("the digest a preview and an install agree on", () => { + test("does not move when the same document is written differently", async () => { + const one = parseBotTemplate(MINIMAL); + const other = parseBotTemplate(` +openbot_template: 1 +bot: + runtime: managed + role_description: >- + Chase overdue invoices and draft the follow-up. + title: Accounts Receivable + name: Renewal Desk +template: + summary: Chases overdue invoices. + slug: renewal-desk +`); + expect(await botTemplateDigest(one)).toBe(await botTemplateDigest(other)); + }); + + test("moves when a single character of anybody's prose changes", async () => { + const one = parseBotTemplate(MINIMAL); + const other = parseBotTemplate( + MINIMAL.replace("Chase overdue", "Chase all overdue"), + ); + expect(await botTemplateDigest(one)).not.toBe( + await botTemplateDigest(other), + ); + }); + + test("is stable across Unicode forms, so the digest read is the digest installed", async () => { + // The same name written two ways: precomposed U+00E9, and e followed by a combining acute. A + // reviewer sees one string; without normalisation the install would recompute a different digest + // and refuse a document nobody had changed. + const composed = parseBotTemplate( + MINIMAL.replace("Renewal Desk", "Renewal D\u00E9sk"), + ); + const decomposed = parseBotTemplate( + MINIMAL.replace("Renewal Desk", "Renewal De\u0301sk"), + ); + expect(composed.bot.name).toBe(decomposed.bot.name); + expect(await botTemplateDigest(composed)).toBe( + await botTemplateDigest(decomposed), + ); + }); + + test("marks a grant with a short form of itself", () => { + expect(templateGrantMark("abcdef0123456789")).toBe("template:abcdef012345"); + }); +}); + +describe("serialising a template back to a file", () => { + test("round-trips through parse unchanged", async () => { + const source = ` +openbot_template: 1 +template: + slug: renewal-desk + version: "1.3" + author: acme-revops + source: https://github.com/acme/openbot-templates + summary: Chases overdue invoices and drafts the follow-up. + license: Apache-2.0 +bot: + name: Renewal Desk + title: Accounts Receivable + role_description: Chase overdue invoices and draft the follow-up. + avatar_seed: renewal-desk + runtime: remote + skills: [check-renewal-risk] + remote: + auth_header: Authorization + requires_key: true + example_url: https://renewals.example.com/agui + sends_conversation_to: renewals.example.com +skills: + - slug: check-renewal-risk + title: Check renewal risk + summary: Pull the contract and the recent tickets. + instructions: Find the contract before answering anything about a renewal. + tools: [google-drive/search_files] +requests: + connectors: + - id: google-drive + why: The ledger lives in Drive. + tools: + - ref: google-drive/search_files + why: Find the ledger. + components: + - name: showBarChart + why: Ageing buckets. +boundary: + shell: never + files: none + browser: read_only + navigate_hosts: [billing.acme.example] + mcp: read_only +notes: Point this at whichever Drive folder holds your contracts. +`; + const first = parseBotTemplate(source); + const written = serializeBotTemplate(first); + const second = parseBotTemplate(written); + expect(second).toEqual(first); + expect(await botTemplateDigest(second)).toBe( + await botTemplateDigest(first), + ); + }); + + test("omits absent optional keys rather than writing them as null", () => { + const written = serializeBotTemplate(parseBotTemplate(MINIMAL)); + expect(written).not.toContain("null"); + expect(written).not.toContain("license"); + expect(written).not.toContain("remote"); + }); + + test("always writes the boundary out, so the author sees the ceiling they are shipping", () => { + const written = serializeBotTemplate(parseBotTemplate(MINIMAL)); + expect(written).toContain("boundary:"); + expect(written).toContain("shell: never"); + }); + + test("what it writes never trips the byte refusals it will be read back through", () => { + const template: BotTemplate = parseBotTemplate(MINIMAL); + expect(() => + parseBotTemplate(serializeBotTemplate(template)), + ).not.toThrow(); + }); +}); diff --git a/shared/bot-template.ts b/shared/bot-template.ts new file mode 100644 index 00000000..07ba3891 --- /dev/null +++ b/shared/bot-template.ts @@ -0,0 +1,1035 @@ +/** + * The Bot template format: one file describing one coworker. + * + * Configuration travels; capability does not. A template carries a coworker's identity and its + * prose, the skills it depends on, the capabilities it *asks* for, and a ceiling on what it may do. + * It carries no id, no endpoint URL, no credential, no MCP grant, no component source and no policy + * rule, because none of those are fields here — a document containing one fails to parse rather than + * being quietly stripped, so an author who tried to ship a key is told, and a reviewer reading the + * file is not reading a redacted copy of something larger. + * + * The vocabulary is deliberately the tenant package's (`role_description`, `avatar_seed`, and a + * skill's `slug`/`title`/`summary`/`instructions`/`tools`), so anybody who has read + * `examples/fintech/` can read a template. It diverges on three points, and each is a security + * decision rather than a preference: + * + * 1. STRICT PARSING. `validateTenantPackage` reads the keys it knows and ignores the rest, which is + * right for an operator's own directory: a stale key from an older product version should not + * stop a deployment booting. It is wrong for a stranger's file, where an ignored key is a key + * the reviewer's eye slid over and the parser agreed to. Here an unrecognised key anywhere is a + * refusal naming it. + * + * 2. NO ENVIRONMENT INTERPOLATION, AT ALL. `expandEnvironment` substitutes textually, before the + * YAML is parsed, out of the server's own environment. In a package that is how one file serves + * a laptop, a staging stack and production. In a stranger's file it is an exfiltration + * primitive: a `role_description` naming the deployment's key-encryption key or computer token + * would be expanded, stored, shown to a model and readable afterwards. There is no allowlist of + * variable names and no escaping — the opening sequence is refused wherever it appears, and it + * is checked against the raw bytes rather than the parsed document, so a comment cannot carry + * it either. + * + * 3. THE API'S SLUG RULE, not the package's. The package's admits `x` and `find-`, both of which + * install cleanly and are then permanently uneditable through the product, because the Skills + * API refuses to save what the package was allowed to create. + * + * Nothing in this file reads the database, the environment or the network. It is the whole of what a + * template *is*, so the refusals can be tested as pure functions and the same parse runs at preview + * and again at install. + */ +import { parse, stringify } from "yaml"; + +/** + * The format version, and a hard gate rather than a hint. + * + * A future format that means something different by the same key names must not be read leniently by + * an older deployment. There is one accepted value; anything else is refused and names itself. + */ +export const BOT_TEMPLATE_FORMAT = 1; + +/** Why a document was refused. The wire carries the code; the message is for the person. */ +export type TemplateRefusal = + | "format_version" + | "unknown_key" + | "missing_field" + | "bad_type" + | "interpolation" + | "invisible_character" + | "too_large" + | "too_many" + | "too_long" + | "bad_slug" + | "bad_tool_ref" + | "bad_hostname" + | "bad_url" + | "unknown_skill" + | "forbidden_field" + | "malformed_yaml"; + +export class TemplateRefusedError extends Error { + readonly reason: TemplateRefusal; + constructor(reason: TemplateRefusal, message: string) { + super(message); + this.name = "TemplateRefusedError"; + this.reason = reason; + } +} + +export type TemplateRuntime = "managed" | "remote"; +export type TemplateShell = "never" | "permitted"; +export type TemplateFiles = "none" | "read_only" | "read_write"; +export type TemplateBrowser = "none" | "read_only" | "full"; +export type TemplateMcp = "none" | "read_only" | "read_write"; + +export type BotTemplateRemote = { + /** + * The header NAME only. `auth-header.ts` already keeps it in unencrypted metadata because a header + * name is not a secret; the value never travels and is typed by the importer into their own vault. + */ + authHeader?: string; + /** Whether the importer will be asked for a key. A claim, not a capability. */ + requiresKey: boolean; + /** Documentation for the person typing the address. Never dialled by anything here. */ + exampleUrl?: string; + /** Where the author says conversations go. Shown on the consent screen, and compared with what was typed. */ + sendsConversationTo?: string; +}; + +export type BotTemplateBot = { + name: string; + title: string; + roleDescription: string; + avatarSeed?: string; + runtime: TemplateRuntime; + /** Slugs, every one of which this same file must define. */ + skills: string[]; + remote?: BotTemplateRemote; +}; + +export type BotTemplateSkill = { + slug: string; + title: string; + summary: string; + instructions: string; + /** `/` declarations. Not grants, and deliberately not checked against anything. */ + tools: string[]; +}; + +export type BotTemplateToolRequest = { ref: string; why: string }; +export type BotTemplateConnectorRequest = { + id: string; + why: string; + tools: BotTemplateToolRequest[]; +}; +export type BotTemplateComponentRequest = { name: string; why: string }; + +export type BotTemplateRequests = { + connectors: BotTemplateConnectorRequest[]; + components: BotTemplateComponentRequest[]; +}; + +export type BotTemplateBoundary = { + shell: TemplateShell; + files: TemplateFiles; + browser: TemplateBrowser; + /** Exact hostnames. Compiled to equality, never to a pattern. */ + navigateHosts: string[]; + mcp: TemplateMcp; +}; + +export type BotTemplateMeta = { + slug: string; + version?: string; + /** A CLAIM. Rendered as one, never verified, and never used to decide anything. */ + author?: string; + source?: string; + summary: string; + license?: string; +}; + +export type BotTemplate = { + format: typeof BOT_TEMPLATE_FORMAT; + template: BotTemplateMeta; + bot: BotTemplateBot; + skills: BotTemplateSkill[]; + requests: BotTemplateRequests; + boundary: BotTemplateBoundary; + notes?: string; +}; + +/** + * What an absent `boundary:` block means. + * + * The strictest thing the vocabulary can say, rather than the most permissive. An author who did not + * write a boundary did not decide one, and the safe reading of "did not decide" is not "may do + * anything" — that is the reading the shipped action policy already gives every Bot, and the whole + * point of this block is to be able to say less than that for one Bot. + * + * `mcp` is the exception and is `read_only` rather than `none`, because an MCP grant is refused by + * absence anyway: a ceiling of `none` over a floor of nothing-granted expresses the same thing twice + * and would have to be widened by hand on every template that names a connector, which is most of + * them. The export path writes this block out explicitly so the author sees it and widens what the + * Bot actually needs, which is the reason export produces a draft rather than a download. + */ +export const STRICT_BOUNDARY: BotTemplateBoundary = { + shell: "never", + files: "none", + browser: "none", + navigateHosts: [], + mcp: "read_only", +}; + +/** + * Ceilings, so one file cannot be a denial of service against the person reading it. + * + * `INSTRUCTIONS` is bounded here because the Skills API checks that instructions are present and + * nothing more. A template is the first path by which somebody else's unbounded text reaches that + * column, so the bound is introduced rather than assumed to exist. + */ +export const TEMPLATE_LIMITS = { + DOCUMENT_BYTES: 128 * 1024, + SKILLS: 25, + TOOL_REFS: 40, + REQUEST_ENTRIES: 40, + NAME: 80, + TITLE: 120, + ROLE_DESCRIPTION: 1000, + SKILL_TITLE: 120, + SUMMARY: 300, + INSTRUCTIONS: 8000, + WHY: 300, + NOTES: 4000, + SLUG: 40, + HOSTS: 20, + URL: 200, +} as const; + +/** The Skills API's rule, not the tenant package's looser one. */ +const SLUG = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/; +/** The one shape a declaration and a grant share. Anything else could never match a grant. */ +const TOOL_REF = /^[^/\s]+\/[^/\s]+$/; +/** A plain hostname. No scheme, no path, no port, no wildcard — it compiles to an equality test. */ +const HOSTNAME = + /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/; + +/** + * Codepoints that can hide a payload from every surface a human reviews with. + * + * Format characters, private-use areas, bidirectional overrides, zero-width joiners and tag + * characters render as nothing in an editor, a terminal and a diff alike, so a consent screen + * showing a role description "verbatim" would be showing text the reader cannot see all of. This is + * the GlassWorm vector, and a review control that can be made invisible is not a control. + * + * Built from escapes rather than written literally, because a source file containing these + * characters has the same problem it is here to solve. Checked against the raw bytes, so it covers + * keys, values and comments together. Tab, newline and carriage return are the three controls a YAML + * file legitimately contains and the only ones permitted. + */ +const INVISIBLE = new RegExp( + [ + "[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F]", + "[\\u00AD\\u061C\\u180E]", + "[\\u200B-\\u200F]", + "[\\u202A-\\u202E]", + "[\\u2060-\\u2064]", + "[\\u2066-\\u2069]", + "[\\uFEFF\\uFFF9-\\uFFFB]", + "[\\uE000-\\uF8FF]", + "[\\uDB80-\\uDBBF][\\uDC00-\\uDFFF]", + "\\uDB40[\\uDC00-\\uDFFF]", + ].join("|"), +); + +/** + * The two characters that open an environment reference in a tenant package file. + * + * A plain string rather than a template literal, where the sequence has no meaning and is simply the + * two characters it looks like. + */ +const INTERPOLATION_OPEN = "${"; + +/** + * Key names a template may never carry, checked by name before anything else looks at the document. + * + * These are all refused by the unknown-key rule anyway. They are named separately so that a document + * carrying one is told *why*: "a template never carries a credential" is a sentence an author can + * act on, where "unknown key: credential_secret_ref" reads like a typo and invites them to try + * another spelling. + */ +const FORBIDDEN_KEYS = new Map([ + [ + "auth_value", + "a template never carries a key; the importer types it into their own vault", + ], + ["credential", "a template never carries a credential"], + [ + "credential_id", + "a credential id from another deployment points at nothing here", + ], + ["credential_secret_ref", "a template never carries a credential reference"], + ["callback_token", "a callback token is per-deployment credential material"], + ["endpoint", "a template never names a host; the importer types the address"], + ["url", "a template never names a host; the importer types the address"], + ["package_id", "carrying a package id would forge a system-owned Bot"], + ["owner_user_id", "an imported Bot is owned by whoever imported it"], + ["visibility", "an imported Bot is private until its owner says otherwise"], + [ + "system_prompt", + "behaviour goes in role_description and skill instructions", + ], + ["deny", "a template never writes a policy rule"], + ["allow", "a template never writes a policy rule"], + ["components", "a template never carries component source"], +]); + +function refuse(reason: TemplateRefusal, message: string): never { + throw new TemplateRefusedError(reason, message); +} + +function record(value: unknown, where: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + refuse("bad_type", `${where} must be a block of keys`); + } + return value as Record; +} + +function list(value: unknown, where: string): unknown[] { + if (!Array.isArray(value)) refuse("bad_type", `${where} must be a list`); + return value; +} + +/** + * Every key in this block has to be one we know. + * + * The check that makes strict parsing strict, applied at each level rather than once at the top, + * because a stranger's unknown key three levels down is exactly as unread as one at the root. + * + * A key legitimate HERE is accepted before the forbidden-name table is consulted, and the order + * matters: those names are only meaningful as a better message for a key that was going to be + * refused anyway. Consulted first, the table would refuse `requests.components` — the block that + * exists so a template can *ask* for a component by name — on the grounds that a template never + * carries component source, which is true and is about a different key of the same name. + */ +function onlyKnownKeys( + block: Record, + known: readonly string[], + where: string, +): void { + for (const key of Object.keys(block)) { + if (known.includes(key)) continue; + const forbidden = FORBIDDEN_KEYS.get(key); + if (forbidden) { + refuse("forbidden_field", `${where}.${key} is not allowed: ${forbidden}`); + } + refuse( + "unknown_key", + `${where}.${key} is not part of the Bot template format. Known keys here: ${known.join(", ")}.`, + ); + } +} + +function text( + block: Record, + key: string, + where: string, + max: number, +): string { + const value = block[key]; + if (typeof value !== "string" || !value.trim()) { + refuse("missing_field", `${where}.${key} must be a non-empty string`); + } + const normalised = value.normalize("NFC"); + if ([...normalised].length > max) { + refuse("too_long", `${where}.${key} is longer than ${max} characters`); + } + return normalised; +} + +function optionalText( + block: Record, + key: string, + where: string, + max: number, +): string | undefined { + if (block[key] === undefined || block[key] === null) return undefined; + return text(block, key, where, max); +} + +/** + * A link a template may show a person, and the narrow shape it is allowed to take. + * + * `source` and `example_url` are documentation: nothing here or anywhere else fetches them. They are + * still attacker-controlled text that ends up beside a Bot's name on a consent screen, so they are + * held to https and to a plain host, and the surface renders them as text rather than as anchors. A + * template cannot put a clickable `javascript:` or a credential-carrying `https://user:pass@host` in + * front of somebody who is in the middle of deciding whether to trust it. + */ +function optionalHttpsUrl( + block: Record, + key: string, + where: string, +): string | undefined { + const value = optionalText(block, key, where, TEMPLATE_LIMITS.URL); + if (value === undefined) return undefined; + let parsed: URL; + try { + parsed = new URL(value); + } catch { + refuse("bad_url", `${where}.${key} must be an https:// address`); + } + if (parsed.protocol !== "https:") { + refuse( + "bad_url", + `${where}.${key} must be an https:// address, not ${parsed.protocol}`, + ); + } + if (parsed.username || parsed.password) { + refuse( + "bad_url", + `${where}.${key} must not carry a credential in the address`, + ); + } + if (!HOSTNAME.test(parsed.hostname.toLowerCase())) { + refuse("bad_url", `${where}.${key} must name a plain host`); + } + return value; +} + +function choice( + block: Record, + key: string, + where: string, + allowed: readonly T[], + fallback?: T, +): T { + const value = block[key]; + if (value === undefined || value === null) { + if (fallback !== undefined) return fallback; + refuse( + "missing_field", + `${where}.${key} must be one of ${allowed.join(", ")}`, + ); + } + if (typeof value !== "string" || !allowed.includes(value as T)) { + refuse( + "bad_type", + `${where}.${key} must be one of ${allowed.join(", ")}, not ${String(value)}`, + ); + } + return value as T; +} + +function strings(value: unknown, where: string, max: number): string[] { + const entries = list(value, where); + if (entries.length > max) + refuse("too_many", `${where} has more than ${max} entries`); + return entries.map((entry, index) => { + if (typeof entry !== "string" || !entry.trim()) { + refuse("bad_type", `${where}[${index}] must be a non-empty string`); + } + return entry.normalize("NFC"); + }); +} + +/** + * The refusals that read the file as bytes rather than as a document. + * + * Before `parse`, deliberately. Interpolation and invisible characters are properties of the text a + * person reviewed, and a YAML parser would drop comments and normalise escapes out from under both + * checks — the byte the reviewer saw is the byte that has to be checked. + */ +export function refuseHostileBytes(source: string): void { + const bytes = new TextEncoder().encode(source).length; + if (bytes > TEMPLATE_LIMITS.DOCUMENT_BYTES) { + refuse( + "too_large", + `The document is ${bytes} bytes and the limit is ${TEMPLATE_LIMITS.DOCUMENT_BYTES}`, + ); + } + + const interpolation = source.indexOf(INTERPOLATION_OPEN); + if (interpolation >= 0) { + const line = source.slice(0, interpolation).split("\n").length; + refuse( + "interpolation", + `The document contains an environment reference on line ${line}. A Bot template is never expanded against this deployment's environment, and the sequence is refused rather than ignored, so that a file written to be expanded somewhere else cannot arrive here looking harmless.`, + ); + } + + const invisible = INVISIBLE.exec(source); + if (invisible) { + const line = source.slice(0, invisible.index).split("\n").length; + const codepoint = source.codePointAt(invisible.index) ?? 0; + refuse( + "invisible_character", + `The document contains U+${codepoint.toString(16).toUpperCase().padStart(4, "0")} on line ${line}, which renders as nothing in an editor, a terminal and a diff alike. A template you cannot fully see is one you cannot consent to.`, + ); + } +} + +const META_KEYS = [ + "slug", + "version", + "author", + "source", + "summary", + "license", +] as const; +const BOT_KEYS = [ + "name", + "title", + "role_description", + "avatar_seed", + "runtime", + "skills", + "remote", +] as const; +const REMOTE_KEYS = [ + "auth_header", + "requires_key", + "example_url", + "sends_conversation_to", +] as const; +const SKILL_KEYS = [ + "slug", + "title", + "summary", + "instructions", + "tools", +] as const; +const REQUEST_KEYS = ["connectors", "components"] as const; +const CONNECTOR_KEYS = ["id", "why", "tools"] as const; +const CONNECTOR_TOOL_KEYS = ["ref", "why"] as const; +const COMPONENT_KEYS = ["name", "why"] as const; +const BOUNDARY_KEYS = [ + "shell", + "files", + "browser", + "navigate_hosts", + "mcp", +] as const; +const ROOT_KEYS = [ + "openbot_template", + "template", + "bot", + "skills", + "requests", + "boundary", + "notes", +] as const; + +function parseMeta(value: unknown): BotTemplateMeta { + const block = record(value, "template"); + onlyKnownKeys(block, META_KEYS, "template"); + const slug = text(block, "slug", "template", TEMPLATE_LIMITS.SLUG); + if (!SLUG.test(slug)) { + refuse( + "bad_slug", + `template.slug "${slug}" must be lowercase letters, digits and hyphens, at least two characters, and must not start or end with a hyphen`, + ); + } + return { + slug, + version: optionalText(block, "version", "template", 40), + author: optionalText(block, "author", "template", 80), + source: optionalHttpsUrl(block, "source", "template"), + summary: text(block, "summary", "template", TEMPLATE_LIMITS.SUMMARY), + license: optionalText(block, "license", "template", 40), + }; +} + +function parseRemote(value: unknown): BotTemplateRemote { + const block = record(value, "bot.remote"); + onlyKnownKeys(block, REMOTE_KEYS, "bot.remote"); + + const requiresKey = block.requires_key; + if (requiresKey !== undefined && typeof requiresKey !== "boolean") { + refuse("bad_type", "bot.remote.requires_key must be true or false"); + } + + const authHeader = optionalText(block, "auth_header", "bot.remote", 80); + if (authHeader && !/^[A-Za-z0-9-]+$/.test(authHeader)) { + refuse( + "bad_type", + `bot.remote.auth_header "${authHeader}" is not a header name`, + ); + } + + const sendsTo = optionalText( + block, + "sends_conversation_to", + "bot.remote", + 253, + ); + if (sendsTo && !HOSTNAME.test(sendsTo.toLowerCase())) { + refuse( + "bad_hostname", + `bot.remote.sends_conversation_to "${sendsTo}" must be a plain hostname, so the consent screen can compare it with the address that is actually typed`, + ); + } + + return { + authHeader, + requiresKey: requiresKey === true, + exampleUrl: optionalHttpsUrl(block, "example_url", "bot.remote"), + sendsConversationTo: sendsTo?.toLowerCase(), + }; +} + +function parseBot(value: unknown): BotTemplateBot { + const block = record(value, "bot"); + onlyKnownKeys(block, BOT_KEYS, "bot"); + + const runtime = choice(block, "runtime", "bot", [ + "managed", + "remote", + ] as const); + const avatarSeed = optionalText( + block, + "avatar_seed", + "bot", + TEMPLATE_LIMITS.SLUG, + ); + if (avatarSeed && !SLUG.test(avatarSeed)) { + refuse( + "bad_slug", + `bot.avatar_seed "${avatarSeed}" must be lowercase letters, digits and hyphens`, + ); + } + if (runtime === "managed" && block.remote !== undefined) { + refuse( + "bad_type", + "bot.remote only belongs on a template whose runtime is remote", + ); + } + + return { + name: text(block, "name", "bot", TEMPLATE_LIMITS.NAME), + title: text(block, "title", "bot", TEMPLATE_LIMITS.TITLE), + roleDescription: text( + block, + "role_description", + "bot", + TEMPLATE_LIMITS.ROLE_DESCRIPTION, + ), + avatarSeed, + runtime, + skills: + block.skills === undefined || block.skills === null + ? [] + : strings(block.skills, "bot.skills", TEMPLATE_LIMITS.SKILLS), + remote: block.remote === undefined ? undefined : parseRemote(block.remote), + }; +} + +function parseSkills(value: unknown): BotTemplateSkill[] { + if (value === undefined || value === null) return []; + const entries = list(value, "skills"); + if (entries.length > TEMPLATE_LIMITS.SKILLS) { + refuse( + "too_many", + `skills has more than ${TEMPLATE_LIMITS.SKILLS} entries`, + ); + } + + let refs = 0; + const seen = new Set(); + return entries.map((entry, index) => { + const where = `skills[${index}]`; + const block = record(entry, where); + onlyKnownKeys(block, SKILL_KEYS, where); + + const slug = text(block, "slug", where, TEMPLATE_LIMITS.SLUG); + if (!SLUG.test(slug)) { + refuse( + "bad_slug", + `${where}.slug "${slug}" must be lowercase letters, digits and hyphens, at least two characters, and must not start or end with a hyphen`, + ); + } + if (seen.has(slug)) refuse("bad_slug", `skills defines "${slug}" twice`); + seen.add(slug); + + const tools = + block.tools === undefined || block.tools === null + ? [] + : strings(block.tools, `${where}.tools`, TEMPLATE_LIMITS.TOOL_REFS); + for (const ref of tools) { + if (!TOOL_REF.test(ref)) { + refuse( + "bad_tool_ref", + `${where}.tools entry "${ref}" must be written as serverId/toolName`, + ); + } + } + refs += tools.length; + if (refs > TEMPLATE_LIMITS.TOOL_REFS) { + refuse( + "too_many", + `the template declares more than ${TEMPLATE_LIMITS.TOOL_REFS} tools in total`, + ); + } + + return { + slug, + title: text(block, "title", where, TEMPLATE_LIMITS.SKILL_TITLE), + summary: text(block, "summary", where, TEMPLATE_LIMITS.SUMMARY), + instructions: text( + block, + "instructions", + where, + TEMPLATE_LIMITS.INSTRUCTIONS, + ), + tools: [...new Set(tools)], + }; + }); +} + +function parseRequests(value: unknown): BotTemplateRequests { + if (value === undefined || value === null) + return { connectors: [], components: [] }; + const block = record(value, "requests"); + onlyKnownKeys(block, REQUEST_KEYS, "requests"); + + const connectors = ( + block.connectors === undefined || block.connectors === null + ? [] + : list(block.connectors, "requests.connectors") + ).map((entry, index) => { + const where = `requests.connectors[${index}]`; + const connector = record(entry, where); + onlyKnownKeys(connector, CONNECTOR_KEYS, where); + const id = text(connector, "id", where, TEMPLATE_LIMITS.SLUG); + + const tools = ( + connector.tools === undefined || connector.tools === null + ? [] + : list(connector.tools, `${where}.tools`) + ).map((toolEntry, toolIndex) => { + const toolWhere = `${where}.tools[${toolIndex}]`; + const tool = record(toolEntry, toolWhere); + onlyKnownKeys(tool, CONNECTOR_TOOL_KEYS, toolWhere); + const ref = text(tool, "ref", toolWhere, 120); + if (!TOOL_REF.test(ref)) { + refuse( + "bad_tool_ref", + `${toolWhere}.ref "${ref}" must be written as serverId/toolName`, + ); + } + /* + * The ref has to belong to the connector it is filed under. Otherwise a template could list one + * harmless-looking connector and hang another's tools off it, and the consent screen — which + * groups the ask by connector — would render it under the wrong heading, which is the one place + * a person is reading carefully. + */ + if (!ref.startsWith(`${id}/`)) { + refuse( + "bad_tool_ref", + `${toolWhere}.ref "${ref}" is filed under connector "${id}" but does not belong to it`, + ); + } + return { ref, why: text(tool, "why", toolWhere, TEMPLATE_LIMITS.WHY) }; + }); + + return { + id, + why: text(connector, "why", where, TEMPLATE_LIMITS.WHY), + tools, + }; + }); + + const components = ( + block.components === undefined || block.components === null + ? [] + : list(block.components, "requests.components") + ).map((entry, index) => { + const where = `requests.components[${index}]`; + const component = record(entry, where); + onlyKnownKeys(component, COMPONENT_KEYS, where); + return { + name: text(component, "name", where, 80), + why: text(component, "why", where, TEMPLATE_LIMITS.WHY), + }; + }); + + const total = + connectors.length + + components.length + + connectors.reduce((sum, connector) => sum + connector.tools.length, 0); + if (total > TEMPLATE_LIMITS.REQUEST_ENTRIES) { + refuse( + "too_many", + `the template asks for more than ${TEMPLATE_LIMITS.REQUEST_ENTRIES} things`, + ); + } + + return { connectors, components }; +} + +function parseBoundary(value: unknown): BotTemplateBoundary { + if (value === undefined || value === null) return { ...STRICT_BOUNDARY }; + const block = record(value, "boundary"); + onlyKnownKeys(block, BOUNDARY_KEYS, "boundary"); + + const hosts = + block.navigate_hosts === undefined || block.navigate_hosts === null + ? [] + : strings( + block.navigate_hosts, + "boundary.navigate_hosts", + TEMPLATE_LIMITS.HOSTS, + ); + for (const host of hosts) { + if (!HOSTNAME.test(host.toLowerCase())) { + refuse( + "bad_hostname", + `boundary.navigate_hosts entry "${host}" must be a plain hostname: no scheme, no port, no path and no wildcard, because it is compiled to an equality test rather than to a pattern`, + ); + } + } + + return { + shell: choice( + block, + "shell", + "boundary", + ["never", "permitted"] as const, + STRICT_BOUNDARY.shell, + ), + files: choice( + block, + "files", + "boundary", + ["none", "read_only", "read_write"] as const, + STRICT_BOUNDARY.files, + ), + browser: choice( + block, + "browser", + "boundary", + ["none", "read_only", "full"] as const, + STRICT_BOUNDARY.browser, + ), + navigateHosts: [...new Set(hosts.map((host) => host.toLowerCase()))], + mcp: choice( + block, + "mcp", + "boundary", + ["none", "read_only", "read_write"] as const, + STRICT_BOUNDARY.mcp, + ), + }; +} + +/** + * A YAML document into a template, or a refusal naming what is wrong with it. + * + * Every check here runs again at install against this same function, so a preview somebody consented + * to and the install that follows are answering the same question. Nothing about the deployment is + * consulted: whether a connector exists, whether a slug is taken and whether an endpoint is reachable + * are resolution questions, and they belong to the caller that can see the database. + */ +export function parseBotTemplate(source: string): BotTemplate { + refuseHostileBytes(source); + + let document: unknown; + try { + document = parse(source); + } catch (error) { + refuse( + "malformed_yaml", + `The document is not valid YAML: ${error instanceof Error ? error.message : "unknown error"}`, + ); + } + + const root = record(document, "the document"); + onlyKnownKeys(root, ROOT_KEYS, "the document"); + + const format = root.openbot_template; + if (format !== BOT_TEMPLATE_FORMAT) { + refuse( + "format_version", + `openbot_template must be ${BOT_TEMPLATE_FORMAT}. This deployment cannot read format ${ + format === undefined ? "(absent)" : JSON.stringify(format) + }.`, + ); + } + + const meta = parseMeta(root.template); + const bot = parseBot(root.bot); + const skills = parseSkills(root.skills); + const requests = parseRequests(root.requests); + const boundary = parseBoundary(root.boundary); + const notes = optionalText( + root, + "notes", + "the document", + TEMPLATE_LIMITS.NOTES, + ); + + /* + * A Bot may only be given skills this same file defines. + * + * Refused rather than dropped, which is the judgement `validateTenantPackage` already makes: a slug + * matching nothing is a typo, and a typo that silently attaches no skill is the kind nobody finds, + * because the Bot simply never narrows its tools and the deployment looks like it is working. + * + * Deliberately not checked against skills already in the importing deployment: those include ones + * people wrote, and a template must not be able to hand its Bot somebody else's instructions by + * naming their slug. + */ + const defined = new Set(skills.map((skill) => skill.slug)); + for (const slug of bot.skills) { + if (!defined.has(slug)) { + refuse( + "unknown_skill", + `bot.skills names "${slug}", which this template does not define. A template may only give its Bot its own skills.`, + ); + } + } + + return { + format: BOT_TEMPLATE_FORMAT, + template: meta, + bot, + skills, + requests, + boundary, + notes, + }; +} + +/** Key-sorted, so a digest depends on what a document says rather than on how it was written. */ +function ordered(value: unknown): unknown { + if (Array.isArray(value)) return value.map(ordered); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entry]) => [key, ordered(entry)]), + ); + } + return typeof value === "string" ? value.normalize("NFC") : value; +} + +/** + * What a preview and an install agree they are talking about. + * + * Over the PARSED document rather than the source text, so reordering keys, changing quoting style or + * re-wrapping a folded block does not move it, while changing a single character of anybody's prose + * does. Strings are NFC-normalised here and at parse, so the digest a reviewer was shown is the + * digest the install recomputes. + */ +export async function botTemplateDigest( + template: BotTemplate, +): Promise { + const canonical = JSON.stringify(ordered(template)); + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(canonical), + ); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * The mark a grant an import made carries, so a retraction takes back exactly what it gave. + * + * The same mechanism `tenant-package.ts` uses for its own grants, and for the same reason: every + * other value in that column is the id of the person who pressed the button, so this cannot collide + * with one, and a grant an administrator made by hand survives a retraction untouched. + */ +export function templateGrantMark(digest: string): string { + return `template:${digest.slice(0, 12)}`; +} + +/** + * A template back to the file a person edits. + * + * Snake-case on the way out, because the format's vocabulary is the tenant package's and a reader + * moving between `examples/fintech/agents.yaml` and a template should not have to notice which one + * they are in. Absent optional keys are omitted rather than written as null: a template is read by + * people, and a null license is noise that invites somebody to fill it in. + */ +export function serializeBotTemplate(template: BotTemplate): string { + const meta: Record = { slug: template.template.slug }; + if (template.template.version) meta.version = template.template.version; + if (template.template.author) meta.author = template.template.author; + if (template.template.source) meta.source = template.template.source; + meta.summary = template.template.summary; + if (template.template.license) meta.license = template.template.license; + + const bot: Record = { + name: template.bot.name, + title: template.bot.title, + role_description: template.bot.roleDescription, + }; + if (template.bot.avatarSeed) bot.avatar_seed = template.bot.avatarSeed; + bot.runtime = template.bot.runtime; + if (template.bot.skills.length) bot.skills = template.bot.skills; + if (template.bot.remote) { + const remote: Record = {}; + if (template.bot.remote.authHeader) + remote.auth_header = template.bot.remote.authHeader; + remote.requires_key = template.bot.remote.requiresKey; + if (template.bot.remote.exampleUrl) + remote.example_url = template.bot.remote.exampleUrl; + if (template.bot.remote.sendsConversationTo) { + remote.sends_conversation_to = template.bot.remote.sendsConversationTo; + } + bot.remote = remote; + } + + const document: Record = { + openbot_template: template.format, + template: meta, + bot, + }; + + if (template.skills.length) { + document.skills = template.skills.map((skill) => { + const entry: Record = { + slug: skill.slug, + title: skill.title, + summary: skill.summary, + instructions: skill.instructions, + }; + if (skill.tools.length) entry.tools = skill.tools; + return entry; + }); + } + + if ( + template.requests.connectors.length || + template.requests.components.length + ) { + const requests: Record = {}; + if (template.requests.connectors.length) { + requests.connectors = template.requests.connectors.map((connector) => { + const entry: Record = { + id: connector.id, + why: connector.why, + }; + if (connector.tools.length) entry.tools = connector.tools; + return entry; + }); + } + if (template.requests.components.length) + requests.components = template.requests.components; + document.requests = requests; + } + + document.boundary = { + shell: template.boundary.shell, + files: template.boundary.files, + browser: template.boundary.browser, + ...(template.boundary.navigateHosts.length + ? { navigate_hosts: template.boundary.navigateHosts } + : {}), + mcp: template.boundary.mcp, + }; + + if (template.notes) document.notes = template.notes; + + return stringify(document, { lineWidth: 96 }); +} From b2f0e8bd105e7fb6e7f5926863db5cbc892638c1 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Sun, 30 Aug 2026 12:09:04 -0700 Subject: [PATCH 02/15] Keep a template's draft, its provenance and the asks it made in their own tables --- server/drizzle.config.ts | 1 + server/drizzle/0024_bot_templates.sql | 55 + server/drizzle/meta/0024_snapshot.json | 3424 ++++++++++++++++++++++++ server/drizzle/meta/_journal.json | 9 +- server/src/db/schema/index.ts | 1 + server/src/db/schema/templates.ts | 353 +++ 6 files changed, 3842 insertions(+), 1 deletion(-) create mode 100644 server/drizzle/0024_bot_templates.sql create mode 100644 server/drizzle/meta/0024_snapshot.json create mode 100644 server/src/db/schema/templates.ts diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts index a0fdf026..e4d70255 100644 --- a/server/drizzle.config.ts +++ b/server/drizzle.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ "./src/db/schema/coworker.ts", "./src/db/schema/components.ts", "./src/db/schema/plugins.ts", + "./src/db/schema/templates.ts", "./src/db/schema/work.ts", ], out: "./drizzle", diff --git a/server/drizzle/0024_bot_templates.sql b/server/drizzle/0024_bot_templates.sql new file mode 100644 index 00000000..5b8cf348 --- /dev/null +++ b/server/drizzle/0024_bot_templates.sql @@ -0,0 +1,55 @@ +CREATE TABLE "bot_templates" ( + "id" text PRIMARY KEY NOT NULL, + "agent_id" text, + "owner_user_id" text NOT NULL, + "slug" text NOT NULL, + "document" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "template_boundaries" ( + "import_id" uuid NOT NULL, + "agent_id" text NOT NULL, + "expression" text NOT NULL, + "source_key" text NOT NULL, + "applied_at" timestamp with time zone DEFAULT now() NOT NULL, + "removed_at" timestamp with time zone, + CONSTRAINT "template_boundaries_import_id_expression_pk" PRIMARY KEY("import_id","expression") +); +--> statement-breakpoint +CREATE TABLE "template_imports" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "agent_id" text NOT NULL, + "digest" text NOT NULL, + "slug" text NOT NULL, + "template_version" text, + "author_claim" text, + "source" text NOT NULL, + "source_ref" text, + "document" jsonb NOT NULL, + "imported_by" text NOT NULL, + "imported_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "template_requests" ( + "import_id" uuid NOT NULL, + "kind" text NOT NULL, + "ref" text NOT NULL, + "why" text NOT NULL, + "status" text NOT NULL, + "decided_by" text, + "decided_at" timestamp with time zone, + CONSTRAINT "template_requests_import_id_kind_ref_pk" PRIMARY KEY("import_id","kind","ref") +); +--> statement-breakpoint +ALTER TABLE "bot_templates" ADD CONSTRAINT "bot_templates_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bot_templates" ADD CONSTRAINT "bot_templates_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "template_boundaries" ADD CONSTRAINT "template_boundaries_import_id_template_imports_id_fk" FOREIGN KEY ("import_id") REFERENCES "public"."template_imports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "template_boundaries" ADD CONSTRAINT "template_boundaries_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "template_imports" ADD CONSTRAINT "template_imports_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "template_requests" ADD CONSTRAINT "template_requests_import_id_template_imports_id_fk" FOREIGN KEY ("import_id") REFERENCES "public"."template_imports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "bot_templates_owner_slug_key" ON "bot_templates" USING btree ("owner_user_id","slug");--> statement-breakpoint +CREATE INDEX "template_boundaries_agent_idx" ON "template_boundaries" USING btree ("agent_id","removed_at");--> statement-breakpoint +CREATE UNIQUE INDEX "template_imports_agent_key" ON "template_imports" USING btree ("agent_id");--> statement-breakpoint +CREATE INDEX "template_imports_digest_idx" ON "template_imports" USING btree ("digest"); \ No newline at end of file diff --git a/server/drizzle/meta/0024_snapshot.json b/server/drizzle/meta/0024_snapshot.json new file mode 100644 index 00000000..0f73eefe --- /dev/null +++ b/server/drizzle/meta/0024_snapshot.json @@ -0,0 +1,3424 @@ +{ + "id": "b99907cf-7ac5-4f65-8a64-a6aebd05cabb", + "prevId": "a1053424-96eb-4ace-98e4-63ac0ea99060", + "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 + }, + "last_read_at": { + "name": "last_read_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_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "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.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_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": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "public.bot_templates": { + "name": "bot_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document": { + "name": "document", + "type": "jsonb", + "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": { + "bot_templates_owner_slug_key": { + "name": "bot_templates_owner_slug_key", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_templates_agent_id_agents_id_fk": { + "name": "bot_templates_agent_id_agents_id_fk", + "tableFrom": "bot_templates", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "bot_templates_owner_user_id_users_id_fk": { + "name": "bot_templates_owner_user_id_users_id_fk", + "tableFrom": "bot_templates", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_boundaries": { + "name": "template_boundaries", + "schema": "", + "columns": { + "import_id": { + "name": "import_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expression": { + "name": "expression", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "template_boundaries_agent_idx": { + "name": "template_boundaries_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "removed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_boundaries_import_id_template_imports_id_fk": { + "name": "template_boundaries_import_id_template_imports_id_fk", + "tableFrom": "template_boundaries", + "tableTo": "template_imports", + "columnsFrom": [ + "import_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_boundaries_agent_id_agents_id_fk": { + "name": "template_boundaries_agent_id_agents_id_fk", + "tableFrom": "template_boundaries", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "template_boundaries_import_id_expression_pk": { + "name": "template_boundaries_import_id_expression_pk", + "columns": [ + "import_id", + "expression" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_imports": { + "name": "template_imports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "digest": { + "name": "digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_version": { + "name": "template_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_claim": { + "name": "author_claim", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "imported_by": { + "name": "imported_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imported_at": { + "name": "imported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_imports_agent_key": { + "name": "template_imports_agent_key", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_imports_digest_idx": { + "name": "template_imports_digest_idx", + "columns": [ + { + "expression": "digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_imports_agent_id_agents_id_fk": { + "name": "template_imports_agent_id_agents_id_fk", + "tableFrom": "template_imports", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_requests": { + "name": "template_requests", + "schema": "", + "columns": { + "import_id": { + "name": "import_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "why": { + "name": "why", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "template_requests_import_id_template_imports_id_fk": { + "name": "template_requests_import_id_template_imports_id_fk", + "tableFrom": "template_requests", + "tableTo": "template_imports", + "columnsFrom": [ + "import_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "template_requests_import_id_kind_ref_pk": { + "name": "template_requests_import_id_kind_ref_pk", + "columns": [ + "import_id", + "kind", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "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" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index b6330597..c8609094 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1787841174859, "tag": "0023_routines_owner_index", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1788112755991, + "tag": "0024_bot_templates", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/server/src/db/schema/index.ts b/server/src/db/schema/index.ts index 68ee5d83..048cba42 100644 --- a/server/src/db/schema/index.ts +++ b/server/src/db/schema/index.ts @@ -5,4 +5,5 @@ export * from "./computer"; export * from "./core"; export * from "./coworker"; export * from "./plugins"; +export * from "./templates"; export * from "./work"; diff --git a/server/src/db/schema/templates.ts b/server/src/db/schema/templates.ts new file mode 100644 index 00000000..caee7217 --- /dev/null +++ b/server/src/db/schema/templates.ts @@ -0,0 +1,353 @@ +/** + * Schema owned by Bot templates: the drafts this deployment authored, and the provenance of every + * Bot that arrived as somebody else's file. + * + * A new file rather than columns on `agents` or `agent_profiles`, and that is a decision rather than + * housekeeping. An imported Bot must be an ORDINARY Bot — private, owned by the importer, editable + * and deletable by exactly the rules that govern one somebody made by hand, and specifically not + * `systemOwned`. A column on the Bot's own row would eventually be read as a flag, and a flag on a + * Bot is a second thing deciding what that Bot is. `coworker.ts:1-6` states the file rule; this is + * why the rule is worth keeping here rather than merely convenient. + * + * NOTHING IN THESE TABLES IS A PERMISSION. Configuration travels; capability does not. What a Bot + * may call is `plugin_grants` and only `plugin_grants`; what an MCP server is, is `mcp_servers`. + * These tables hold a document somebody wrote, a record of what was consented to, an ask nobody has + * answered yet, and a ceiling. Every one of them is inert until an administrator acts on a screen + * that already exists. + */ +import { + index, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import { agents, users } from "./core"; +import { jsonb } from "./json"; + +const createdAt = () => + timestamp("created_at", { withTimezone: true }).notNull().defaultNow(); +const updatedAt = () => + timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(); + +/** + * A template draft this deployment authored, before it is a file anybody else has. + * + * Export produces a draft rather than a download because the interesting half of packing a Bot is + * what was stripped: the endpoint, the credential, the id, the package it belonged to. An author + * reads the draft, widens the strict boundary block to what the Bot actually needs, and only then + * sends the file. A one-shot download would put a document nobody read into somebody else's paste + * box, and the person who wrote it would be the last to know what it said. + */ +export const botTemplates = pgTable( + "bot_templates", + { + /** + * `tpl_`, minted here rather than derived from the document. + * + * A text id carrying its kind, the same shape agents and skills use, so an id in a URL or an + * audit payload says what it addresses without a lookup. Deliberately not content-addressed: a + * draft is edited, and an id that moved on every save would break every link to it and make the + * audit trail refer to something that no longer exists. + */ + id: text("id").primaryKey(), + /** + * The Bot this was packed from, or null once that Bot is gone. + * + * `set null` rather than `cascade`, because a draft OUTLIVES the Bot it came from. Once the + * document exists it is an artifact in its own right — the thing that was going to be published, + * or the thing already sent to somebody — and deleting the coworker it was taken from is not a + * reason to destroy it. Null here reads as "packed from a Bot that is no longer on this + * deployment", which is a true sentence a screen can say. + */ + agentId: text("agent_id").references(() => agents.id, { + onDelete: "set null", + }), + /** + * Whose draft this is. Cascades, matching `skills.ownerUserId`: an unpublished document belongs + * to the person who wrote it and goes when they do. + */ + ownerUserId: text("owner_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + /** + * The slug the file is named by, unique PER OWNER rather than across the deployment. + * + * A template slug names a file. It is not `skills.slug`, which is unique deployment-wide because + * it IS the shared `/` namespace and two behaviours answering to `/standup` is a real collision. + * A draft reaches nobody until it is sent, so two people packing the same Bot must not race each + * other for a name — first-taker-keeps would be an obstruction with nothing behind it. + */ + slug: text("slug").notNull(), + /** + * The parsed, NFC-normalised template, not the YAML text. + * + * One canonical value, so the digest, the serialiser and the edit path all read the same thing + * and a `PATCH` re-runs the parser instead of editing a string. The cost is real and worth + * stating: `serializeBotTemplate` regenerates the file, so comments an author typed into their + * YAML are not preserved across an edit. The alternative — keeping the text and parsing it on + * every read — means the stored bytes and the stored meaning can disagree, which for a document + * a stranger will later be asked to consent to is the worse of the two. + */ + document: jsonb("document").notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (table) => [ + uniqueIndex("bot_templates_owner_slug_key").on( + table.ownerUserId, + table.slug, + ), + ], +); + +/** + * Where an imported Bot came from: one row per Bot that arrived as somebody's template. + * + * The only record that a Bot was not made here, and it is keyed by the BOT rather than by the + * template because there is no update channel. Re-importing the same file creates a second Bot with + * its own row, and the two are unrelated from that moment on. That is not an omission — auto-update + * is the mechanism behind the Cyberhaven and Coze compromises, and there is no version of it that is + * safe without publisher identity, which this design deliberately does not have. + */ +export const templateImports = pgTable( + "template_imports", + { + id: uuid("id").primaryKey().defaultRandom(), + /** + * The Bot this import produced. One import per Bot, enforced rather than assumed. + * + * Without the unique constraint a retried install that got past the first write would leave two + * provenance rows for one coworker, and every screen asking "where did this Bot come from" would + * answer with whichever the query happened to order first. Cascades: the provenance of a Bot + * that no longer exists is not a record anybody can act on, and the audit trail — which is + * append-only and not in this file — is what preserves that the import happened. + */ + agentId: text("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + /** + * sha256 over the sorted-key JSON of the parsed, NFC-normalised document. + * + * Recorded because it is what the consent screen showed. Install recomputes it and returns 409 + * if it moved, which closes the window where a gallery entry or a pasted buffer changes between + * the screen a person read and the button they pressed. Its first twelve characters are also the + * mark every grant this import made carries (`templateGrantMark`), mirroring the + * `'tenant-package'` sentinel — which is what lets a retraction take back exactly what this + * import gave and leave an administrator's own grant on the same Bot untouched. + */ + digest: text("digest").notNull(), + slug: text("slug").notNull(), + /** + * The author's version string, and nothing reads it. + * + * Stored so a person can be told which version of a file they took, never to compare against + * anything. Comparing is an update channel by another name, and the absence of one is what makes + * a template safe to import from a stranger. + */ + templateVersion: text("template_version"), + /** + * The author, as the file CLAIMS it. The column name says what it is. + * + * Named `author_claim` rather than `author` on purpose: nothing verifies it, and a name like + * `author` invites the next person writing a query in a hurry to treat it as an identity this + * deployment established. There is no publisher namespace, no signing key and no registry, so + * there is nothing this could have been checked against. + */ + authorClaim: text("author_claim"), + /** + * How the file got here: `paste`, `file` or `gallery`. + * + * Plain text with a documented vocabulary rather than a `pgEnum`, matching `mcp_servers.provenance` + * and `plugin_grants.kind`. The vocabulary grows — a registered git source lands in a later phase + * — and a new member should be a code change rather than a migration that rewrites a type across + * every existing row. + */ + source: text("source").notNull(), + /** + * What that source named this file, or null for a paste. + * + * The gallery filename, or an `owner/repo@sha` and a path once git sources exist. A record of + * where to look, never an address anything dials: fetching is server-side and only from a source + * an administrator registered, and this column is not consulted on the way. + */ + sourceRef: text("source_ref"), + /** + * Exactly what was consented to, stored rather than referenced. + * + * The second copy of a document is the point, not redundancy. A gallery entry is a file on disk + * that a redeploy replaces and a git pin is a sha an administrator can move, so a pointer would + * let the record of what somebody agreed to change after they agreed to it. The consent screen + * showed these bytes; this table can still produce them years later. + */ + document: jsonb("document").notNull(), + /** + * Who imported it, as an email, and NOT a foreign key. + * + * The `plugin_grants.granted_by` convention, shared with `skills.installed_by` and + * `mcp_servers.added_by`. A trail records who acted, and removing the person must not rewrite + * what happened: `cascade` would erase the row, and `set null` would leave it claiming nobody + * imported this Bot. Under `OPENBOT_SINGLE_USER` there is frequently no `users` row worth + * pointing at in the first place. + */ + importedBy: text("imported_by").notNull(), + /** + * When. Written out rather than using the shared `createdAt()` helper, which fixes the column + * name to `created_at` — the same choice `mcp_user_credentials.connected_at` made, and for the + * same reason: this row records an act somebody performed, and it is worth the column saying + * which act. + */ + importedAt: timestamp("imported_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("template_imports_agent_key").on(table.agentId), + /** "Has this document been imported here before", asked by the preview screen. */ + index("template_imports_digest_idx").on(table.digest), + ], +); + +/** + * The consent ledger: everything a template asked for, and what an administrator later said about it. + * + * A row here is an ASK. It is not, and can never become, a permission — the import module has no + * code path that writes a `plugin_grants` row with `kind='mcp'`, and that is a grep test over + * `server/src/templates/` rather than a promise in a comment. An unmet ask never blocks an install + * either: blocking would make "grant everything" the fastest route to a working Bot, which inverts + * the feature it was meant to protect. + * + * THERE IS DELIBERATELY NO `satisfied` COLUMN, and the omission is the design rather than an + * oversight. Whether a capability exists is answered live, at read time, by `plugin_grants`, + * `mcp_servers` and `components`. A column here saying "yes, this one is satisfied" would be a + * second source of truth for a permission, and this codebase already carries the bill for exactly + * that: `server/src/components/sandboxed.ts:288-293` records a `component_exclusions` row that went + * missing and released a component to every Bot, because a governance table whose rows are consulted + * separately from the thing they govern fails open the moment the two disagree. A stale `satisfied` + * here would do the same shape of damage in reverse — a grant retracted through the Plugins page + * would leave this table still saying yes, and a screen reading this table would show a person a + * capability their Bot no longer has. + */ +export const templateRequests = pgTable( + "template_requests", + { + importId: uuid("import_id") + .notNull() + .references(() => templateImports.id, { onDelete: "cascade" }), + /** `mcp`, `component` or `endpoint`. Which screen answers this ask. */ + kind: text("kind").notNull(), + /** + * What is being asked for: `/` for an MCP tool, the component's name, or the + * slot an endpoint gets typed into. The same shape `plugin_grants.ref` holds, so the two can be + * compared without either side parsing the other's format. + */ + ref: text("ref").notNull(), + /** + * The author's sentence explaining the ask, shown to the importer verbatim. + * + * Stored because it is the only thing on the grant screen that says WHY, and because an + * administrator deciding this next month was not in the room when the consent screen was read. + * It is a stranger's prose and is rendered as such — never interpreted, never given to a model. + */ + why: text("why").notNull(), + /** + * `requested`, `unavailable`, `not_in_build`, `granted` or `declined`. + * + * `requested` is the day-one state of everything a template asked for. `unavailable` and + * `not_in_build` record that this deployment could not satisfy the ask at all when the plan was + * resolved — there is no `mcp_servers` row, or the build ships no such component. `granted` and + * `declined` record that a person decided. + * + * `granted` means an administrator pressed the button on this row. It does NOT mean the grant is + * currently in force; that question belongs to `plugin_grants` and is asked there every time. See + * the note above about the second source of truth — nothing decides anything from this column. + */ + status: text("status").notNull(), + /** The administrator's email, same convention and same reasoning as `imported_by`. */ + decidedBy: text("decided_by"), + decidedAt: timestamp("decided_at", { withTimezone: true }), + }, + (table) => [ + /** + * One ask per capability per import. A surrogate id with no unique constraint would let a + * retried install write the same ask twice, and then the screen shows a stranger's `why` twice + * and a grant answers one of the two while the other sits there still saying `requested`. + */ + primaryKey({ columns: [table.importId, table.kind, table.ref] }), + ], +); + +/** + * The compiled ceiling on one imported Bot, kept deliberately AWAY from `action_policy.deny`. + * + * Written by a later phase. The table lands now so this is one migration rather than two, and so the + * storage decision is recorded next to what enforces it rather than only in a design document. + * + * SEPARATE STORAGE IS THE ENTIRE POINT. `policyStore.set` replaces the whole `deny` array, and the + * `/admin/boundaries` screen posts a snapshot of what it last read, with no version column between + * them. A clause written into `action_policy.deny` by an import is therefore erased by the next + * administrator who saves an unrelated change on that screen — a lost update that silently uncages + * an imported Bot, with nothing anywhere saying it happened. Clauses live here instead and + * `policyStore.get()` composes `stored ++ generated` for evaluation only. Different storage makes + * the lost update unrepresentable rather than merely unlikely. + */ +export const templateBoundaries = pgTable( + "template_boundaries", + { + importId: uuid("import_id") + .notNull() + .references(() => templateImports.id, { onDelete: "cascade" }), + /** + * The Bot the clause binds. Denormalised from the import row on purpose: every policy check asks + * "what is in force for this Bot", and that read must not have to join through provenance to + * find out. + */ + agentId: text("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "cascade" }), + /** + * The compiled CEL clause, validated before the import commits. + * + * A template never writes CEL; it writes a closed vocabulary, and the compiler emits this. Three + * properties this column depends on, all of them required rather than defensive. NO REGEX: a + * host list compiles to `page.host == "a" || page.host == "b"` over validated, JSON-escaped + * hostnames, so there is no backtracking to exploit and no `") || true || matches("` to inject. + * PARAMETERIZED EMISSION, never concatenation of a template's values. And WRITE-TIME VALIDATION: + * every clause is evaluated against a synthetic neutral context first, because a clause that + * throws sits in a deny position and would deny every action for every Bot on the deployment. + */ + expression: text("expression").notNull(), + /** + * Which line of the author's vocabulary produced this clause: `shell`, `files`, `browser`, + * `navigate_hosts` or `mcp`. + * + * Kept so a screen can state the restriction in the words the author wrote rather than in CEL, + * and so retracting one line does not require parsing CEL to work out which rows it owns. + */ + sourceKey: text("source_key").notNull(), + appliedAt: timestamp("applied_at", { withTimezone: true }) + .notNull() + .defaultNow(), + /** + * When the clause stopped applying. Null means in force, and that is the read every evaluation + * makes. + * + * Soft rather than a delete, because "this Bot was never bounded" and "somebody took this Bot's + * bound off" must not be the same database state. One of those is a template that asked for + * nothing; the other is an act a person performed and should be able to be asked about. + */ + removedAt: timestamp("removed_at", { withTimezone: true }), + }, + (table) => [ + /** + * One row per distinct clause per import. Two vocabulary lines that compile to the identical + * clause collapse into one row, which is correct — the same restriction stated twice is the same + * restriction — with the consequence that `source_key` then names only one of the two. + */ + primaryKey({ columns: [table.importId, table.expression] }), + /** The evaluation read: which clauses are in force for this Bot, on every policy check. */ + index("template_boundaries_agent_idx").on(table.agentId, table.removedAt), + ], +); From 0b7471706a2eb02afccd46114f4dd3cd6c76ea4d Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Sun, 30 Aug 2026 12:09:04 -0700 Subject: [PATCH 03/15] Let a skill be installed inside a transaction, and name what a template does on the trail --- server/src/audit.ts | 78 +++++ server/src/plugins/store.ts | 131 +++++++-- server/tests/audit.test.ts | 58 ++++ ...ugin-store-transaction.integration.test.ts | 270 ++++++++++++++++++ 4 files changed, 514 insertions(+), 23 deletions(-) create mode 100644 server/tests/plugin-store-transaction.integration.test.ts diff --git a/server/src/audit.ts b/server/src/audit.ts index c92f89fd..41e67299 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -386,6 +386,84 @@ export const auditEventTypes = [ * withholds; the offered credential never does. */ "routines.dispatch_refused", + + /* + * A coworker's configuration leaving this deployment as a file. + * + * The only record that it happened at all. A template is a document somebody downloads and sends + * by whatever means a file travels, so nothing downstream of the click reports back and nothing + * in the exported Bot is changed by having been copied; without this row an export is invisible. + * `stripped` names the fields the export refused to carry, which is the evidence that the + * endpoint, the key and the grants stayed behind rather than being written into the file. + * + * NEVER THE PROSE AND NEVER A KEY, here or on any row below. A template's whole substance is the + * words its author wrote, and this trail is not where they are kept: the payload carries the + * slug, the digest and the counts, and a reader who wants the document reads the document. The + * key-name redaction below is not what enforces that — it drops `prompt` and `content` and knows + * nothing about a field called `description` — so it is enforced where the payload is built. + */ + "template.exported", + /** + * A document this deployment would not import, and why. + * + * The row an investigator reaches for, and the reason both outcomes are recorded rather than only + * the happy one. An import that succeeded leaves a Bot, its skills and a ledger of what it asked + * for, all of which anybody can go and look at. A refusal leaves nothing whatsoever — no Bot, no + * skill, no ledger row — so the attempt is unaccounted for everywhere else in the product. + * + * Which matters because the interesting case is the repeated one. A single paste that failed to + * parse is somebody's typo and nobody needs to know. Forty of them in an afternoon, each turned + * away for a different reason, is somebody mapping the edges of the parser, and that is only ever + * visible to a reader who can count them. + * + * `reason` is the machine-readable half rather than the sentence shown to the person, so rows can + * be grouped by it. `digest` is present only when there was enough of a document to hash — a file + * refused on its size never got parsed — and its absence is itself the fact. + */ + "template.import_refused", + /* + * A Bot that came out of somebody else's file. + * + * Written after `bot.created` rather than instead of it, so a reader filtering `bot.*` still sees + * every Bot that has ever existed here; this row is the extra thing known about one of them. + * `digest` names exactly which bytes were read, which is what makes the consent screen mean + * anything later: the file on disk may have been edited since. + * + * `authorClaim` is a claim and is named as one. Nothing verifies it, this deployment has no + * identity infrastructure to verify it with, and a field called `author` would be recording an + * assertion as a fact. + */ + "template.imported", + /** + * Something a template asked for that this deployment has not given it. + * + * One row per unmet request, written even though the install SUCCEEDED, and it exists so that a + * Bot which silently cannot work is distinguishable from one nobody asked to work. Configuration + * travels and capability does not, so an imported Bot routinely arrives naming a connector nobody + * here has connected: it installs, it looks finished on the Bots page, and it answers from memory + * because the tool it was written around was never granted. Without this row that is + * indistinguishable from a badly written prompt, and the person debugging it reads the prose over + * and over looking for the fault. + * + * Deliberately not filed as a refusal. Nothing was forbidden and nothing was blocked — the ask + * simply landed unmet, which is the designed behaviour — and putting it among the refusals would + * teach a reader to discount the refusals that are real. + */ + "template.capability_requested", + // The answer to one of those requests, given by an administrator through the grant screens that + // already refuse. Both outcomes, and kept separate from the import row on purpose: whether + // somebody approved a connector must never be inferable from the fact that a template named it. + "template.capability_granted", + "template.capability_declined", + // The ceiling a template shipped with, put on a Bot and taken off it again. The compiled clauses + // go in the payload verbatim, because a boundary nobody can read back is one nobody can check — + // and because the removal is the half somebody asks about after the fact. + "template.boundary_applied", + "template.boundary_removed", + // An import undone, naming what the retraction deleted. The only record of it: retraction removes + // the rows carrying that import's mark, so by the time anybody asks, the evidence is what is here. + // The Bot and its skills are not deleted, which is why the row has to say what was. + "template.retracted", ] as const; export type AuditEventType = (typeof auditEventTypes)[number]; diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 4164c39a..f4c21ddd 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -253,6 +253,24 @@ export const INVALID_CLIENT = "invalid_client"; */ type Transaction = Parameters[0]>[0]; +/** + * Where a plugin write runs: the pool, or a caller's open transaction. + * + * Added for the template import, which creates a Bot, installs the skills the template names and + * grants them in one act. Those are three writes through two stores and they have to commit or roll + * back together — a failure partway leaves an orphan Bot with half a skill set, and the person + * retries into a second orphan Bot rather than into a clean slate. + * + * Defaulted to `database` everywhere it is accepted, so a caller that does not have a transaction + * carries on writing on its own connection exactly as before. What a caller must NOT do is pass a + * transaction and then read on the pool: once every pooled connection is inside one of these, the + * second read is a session queueing behind sessions that cannot finish until it returns. See the + * note on `max` in `db/client.ts`. + */ +export type PluginExecutor = + | Pick + | Pick; + /** * A tool name the model can actually call. * @@ -655,13 +673,16 @@ export function createPluginStore(options: PluginStoreOptions) { * server's tool rows, so a ref can be legitimately absent for a moment, and a run must read that as * "load nothing" rather than as a failure. */ - async function knownToolRefs(refs: string[]) { + async function knownToolRefs( + refs: string[], + executor: PluginExecutor = database, + ) { if (refs.length === 0) return new Set(); // Narrowed in the query to the servers actually named, rather than reading the whole catalogue // and filtering here. A deployment aiming at a thousand tools should not scan all of them to // check three. const servers = [...new Set(refs.map((ref) => ref.split("/")[0] ?? ""))]; - const rows = await database + const rows = await executor .select({ serverId: mcpTools.serverId, name: mcpTools.name }) .from(mcpTools) .where(inArray(mcpTools.serverId, servers)); @@ -2212,21 +2233,54 @@ export function createPluginStore(options: PluginStoreOptions) { return row ? row.ownerUserId : undefined; }, - async installSkill(input: { - slug: string; - title: string; - summary: string; - instructions: string; - origin?: string; - /** Whose it is. Null writes a skill for the whole deployment, which is an admin's to make. */ - ownerUserId: string | null; - /** - * The tools this skill needs, as `/` refs. Absent leaves whatever was - * declared before; an empty array clears it, which is how a skill stops asking for anything. - */ - tools?: string[]; - by: string; - }): Promise { + /** + * Write a skill and what it declares it needs, optionally on a transaction the caller has open. + * + * `executor` defaults to the pool, so the Skills page, the package sync and every test write + * where they wrote before. The template import passes its transaction, because a Bot, its + * skills and their grants are one act: without that a mid-install failure leaves an orphan Bot + * holding half a skill set, and the person retries into a second orphan Bot. + */ + async installSkill( + input: { + slug: string; + title: string; + summary: string; + instructions: string; + origin?: string; + /** Whose it is. Null writes a skill for the whole deployment, which is an admin's to make. */ + ownerUserId: string | null; + /** + * The tools this skill needs, as `/` refs. Absent leaves whatever was + * declared before; an empty array clears it, which is how a skill stops asking for anything. + */ + tools?: string[]; + /** + * Save the declarations without first checking that this deployment has seen the tools. + * + * NOT A SECURITY RELAXATION, and it is worth being exact about why, because the name reads + * like one. A declared ref grants nothing. What a Bot may call is decided at run time by + * intersecting what it was GRANTED with what the skill DECLARED, so a ref naming a tool + * nobody has connected is inert, and a ref naming one that exists but was never granted is + * equally inert. The refusal this skips is a typo guard, not a gate: it exists so somebody + * hand-writing a skill on the Skills page learns immediately that `google-drive/serach_files` + * matches nothing, rather than shipping a skill that quietly selects no tools. + * + * A template import is the other case entirely. A template is written somewhere else, before + * this deployment existed, and it necessarily names the connectors its author had — so every + * template naming `google-drive/search_files` would fail to install on every deployment that + * has not connected Drive, which is every fresh one. Refusing the import over it would mean a + * template could only ship skills for connectors it could guarantee, which is none of them. + * + * `synchronizeTenantPackage` already skips this check for exactly this reason, and says so at + * `tenant-package.ts:731-736`. The flag is how the import path reaches the same behaviour + * through the store rather than by writing the rows itself. + */ + allowUnknownTools?: boolean; + by: string; + }, + executor: PluginExecutor = database, + ): Promise { /* * Checked before anything is written, so a save is all-or-nothing from the caller's side: a * skill is never left saved with half its declarations because the fourth ref was a typo. @@ -2235,8 +2289,12 @@ export function createPluginStore(options: PluginStoreOptions) { input.tools === undefined ? undefined : [...new Set(input.tools.map((ref) => ref.trim()).filter(Boolean))]; - if (declared !== undefined && declared.length > 0) { - const known = await knownToolRefs(declared); + if ( + declared !== undefined && + declared.length > 0 && + !input.allowUnknownTools + ) { + const known = await knownToolRefs(declared, executor); const unknown = declared.filter((ref) => !known.has(ref)); if (unknown.length > 0) { throw new PluginRefusedError( @@ -2249,7 +2307,7 @@ export function createPluginStore(options: PluginStoreOptions) { } } - await database + await executor .insert(skills) .values({ id: input.slug, @@ -2278,11 +2336,11 @@ export function createPluginStore(options: PluginStoreOptions) { * a save says what it is now; merging would make removing one a thing with no gesture for it. */ if (declared !== undefined) { - await database + await executor .delete(skillTools) .where(eq(skillTools.skillId, input.slug)); if (declared.length > 0) { - await database.insert(skillTools).values( + await executor.insert(skillTools).values( declared.map((ref) => ({ skillId: input.slug, ref, @@ -2292,6 +2350,21 @@ export function createPluginStore(options: PluginStoreOptions) { } } + /* + * On the audit store's own handle, deliberately NOT on `executor`. + * + * Same trade `recordClientRegistered` makes and for the same reason: the store is injected, so + * a fork or a test may have given us one that writes somewhere other than this database, and + * quietly bypassing it when a transaction is present would make the trail depend on how the + * caller happened to be wired. + * + * The consequence has to be said out loud, because it is the kind of thing a reader is + * entitled to be misled by otherwise. With an executor this row commits whether or not the + * caller's transaction does, so a `skill_installed` row is evidence that an install was + * ATTEMPTED, not that it stuck. A caller wrapping this in a transaction is expected to write + * its own row after the commit — for the template import that is `template.imported` — and a + * `skill_installed` with no such row following it is precisely how a reader spots the rollback. + */ await recordAuditEvent(auditStore, { eventType: "configuration.changed", targetType: "skill", @@ -2352,13 +2425,25 @@ export function createPluginStore(options: PluginStoreOptions) { return rows.map((row) => row.ref); }, + /** + * Give one Bot one thing, optionally on a transaction the caller already has open. + * + * The executor is what lets a template import be one act rather than three: the Bot, the skills + * it names and the grants that put them on it commit together, or none of them do. Its default + * is the pool, so every existing caller — the grant route, the package sync, the tests — writes + * exactly where it wrote before. + * + * The trail row is on the audit store's own handle either way; see the note in `installSkill` + * for why, and for what that means for a caller whose transaction rolls back. + */ async grant( kind: PluginKind, ref: string, agentId: string, by: string, + executor: PluginExecutor = database, ): Promise { - await database + await executor .insert(pluginGrants) .values({ kind, ref, agentId, grantedBy: by }) .onConflictDoUpdate({ diff --git a/server/tests/audit.test.ts b/server/tests/audit.test.ts index 22a7dedf..e8ebbe32 100644 --- a/server/tests/audit.test.ts +++ b/server/tests/audit.test.ts @@ -49,6 +49,64 @@ describe("audit payload redaction", () => { ); }); + /** + * A template's whole life, both outcomes at every step. + * + * Named one by one rather than by prefix, because the list is closed and the pairs are the point: + * dropping `_declined` while keeping `_granted` would leave a trail on which every administrator + * appeared to have approved everything they were ever asked about. + */ + test("records both ends of a template's life", () => { + expect(auditEventTypes).toEqual( + expect.arrayContaining([ + "template.exported", + "template.import_refused", + "template.imported", + "template.capability_requested", + "template.capability_granted", + "template.capability_declined", + "template.boundary_applied", + "template.boundary_removed", + "template.retracted", + ]), + ); + }); + + /** + * What the redaction below does and does not do for a template payload. + * + * Pinned because the temptation on this surface is to treat the key list as the thing that keeps a + * stranger's prose off the trail, and it is not. It matches key NAMES: `prompt` and `content` are + * dropped wherever they appear, and a field called `description`, `instructions` or `title` sails + * through untouched. So the rule that a template row carries the slug, the digest and the counts + * and never the words is enforced where the payload is BUILT, and this test says which half of + * that is mechanical. + * + * `authorClaim` surviving is the intended half. It is an unverified claim, deliberately named as + * one, and it is exactly the sort of thing a reader needs to see. + */ + test("keeps a template row's identifying fields and drops the prose it must never carry", () => { + expect( + redactAuditPayload({ + templateSlug: "standup-bot", + authorClaim: "someone@example.test", + digest: "sha256:0f0f", + hasKey: false, + stripped: ["endpoint", "key"], + prompt: "You are a helpful assistant with access to…", + content: "the whole document", + }), + ).toEqual({ + templateSlug: "standup-bot", + authorClaim: "someone@example.test", + digest: "sha256:0f0f", + hasKey: false, + stripped: ["endpoint", "key"], + prompt: "[REDACTED]", + content: "[REDACTED]", + }); + }); + test("removes secret values and document content recursively", () => { expect( redactAuditPayload({ diff --git a/server/tests/plugin-store-transaction.integration.test.ts b/server/tests/plugin-store-transaction.integration.test.ts new file mode 100644 index 00000000..3bcc13f6 --- /dev/null +++ b/server/tests/plugin-store-transaction.integration.test.ts @@ -0,0 +1,270 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { and, eq, inArray } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import type { ActionPolicy } from "../src/computer/policy"; +import { createDatabase } from "../src/db/client"; +import { + agents, + mcpServers, + mcpTools, + pluginGrants, + skills, + skillTools, +} from "../src/db/schema"; +import { createPluginStore, PluginRefusedError } from "../src/plugins/store"; +import { TEST_POOL } from "./support/database"; + +/** + * A skill install and its grant made as ONE act, and a ref this deployment has never seen. + * + * Both exist for the template import, which creates a Bot, installs the skills the template names + * and grants them to it. Three writes, and either all of them happened or none did: a failure + * partway leaves an orphan Bot holding half a skill set, the person clicks import again, and the + * deployment now has two. + * + * The second half is the one worth being careful about. `allowUnknownTools` reads like a permission + * flag and is not one — the property asserted hardest below is that a skill installed with it still + * grants nothing, because a declaration and a grant are different things and the run-time offer is + * their intersection. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); + +const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; + +const store = createPluginStore({ + database, + auditStore: createAuditStore(database), + credentials: { readSecret: async () => null }, + encryptionKey: "x".repeat(44), + policy: () => policy, +}); + +const suite = randomUUID().slice(0, 8); +const bot = `agent_${suite}`; +const server = `server_${suite}`; +const atomicSkill = `atomic-${suite}`; +const unknownSkill = `unknown-${suite}`; +const everySkill = [atomicSkill, unknownSkill]; + +/** One tool this deployment has actually seen, and one naming a connector nobody has connected. */ +const seenRef = `${server}/search`; +const unseenRef = "google-drive/search_files"; + +const by = "admin@openbot.local"; +const actor = { id: `user_${suite}`, isAdmin: true }; + +beforeAll(async () => { + await database + .insert(agents) + .values({ id: bot, name: bot, type: "built_in", configuration: {} }) + .onConflictDoNothing(); + await database + .insert(mcpServers) + .values({ + id: server, + title: "A test server", + vendor: "Test", + url: "https://mcp.example.invalid/v1", + }) + .onConflictDoNothing(); + await database + .insert(mcpTools) + .values({ serverId: server, name: "search", description: "Find things." }) + .onConflictDoNothing(); +}); + +afterAll(async () => { + await database.delete(skills).where(inArray(skills.slug, everySkill)); + await database.delete(mcpServers).where(eq(mcpServers.id, server)); + await database.delete(agents).where(eq(agents.id, bot)); +}); + +const skillRow = async (slug: string) => + ( + await database.select().from(skills).where(eq(skills.slug, slug)).limit(1) + ).at(0); + +const grantRow = async (kind: string, ref: string) => + ( + await database + .select() + .from(pluginGrants) + .where(and(eq(pluginGrants.kind, kind), eq(pluginGrants.ref, ref))) + .limit(1) + ).at(0); + +describe("an install that is part of a larger act", () => { + test("a rollback takes the skill and its grant with it", async () => { + /* + * The failure the executor exists for. Without it the two writes commit as they are made, so a + * later step throwing leaves a skill in the `/` menu and a Bot holding it, both belonging to an + * import that never finished and neither reachable from anything that would clean them up. + */ + await expect( + database.transaction(async (transaction) => { + await store.installSkill( + { + slug: atomicSkill, + title: "Part of one act", + summary: "For a test.", + instructions: "Do the thing.", + ownerUserId: null, + tools: [seenRef], + by, + }, + transaction, + ); + await store.grant("skill", atomicSkill, bot, by, transaction); + + // Whatever fails after the skills are in: the Bot's profile, a ledger row, a boundary that + // will not compile. From here the transaction's only correct end is backwards. + throw new Error("the step after the skills failed"); + }), + ).rejects.toThrow("the step after the skills failed"); + + expect(await skillRow(atomicSkill)).toBeUndefined(); + expect(await grantRow("skill", atomicSkill)).toBeUndefined(); + expect( + await database + .select() + .from(skillTools) + .where(eq(skillTools.skillId, atomicSkill)), + ).toHaveLength(0); + }); + + test("a transaction that commits keeps both", async () => { + // The other half: the executor must not quietly discard the writes it is handed. + await database.transaction(async (transaction) => { + await store.installSkill( + { + slug: atomicSkill, + title: "Part of one act", + summary: "For a test.", + instructions: "Do the thing.", + ownerUserId: null, + tools: [seenRef], + by, + }, + transaction, + ); + await store.grant("skill", atomicSkill, bot, by, transaction); + }); + + expect(await skillRow(atomicSkill)).toBeDefined(); + expect(await grantRow("skill", atomicSkill)).toBeDefined(); + }); + + test("a caller passing no executor writes exactly where it wrote before", async () => { + // The default is what keeps every existing caller — the Skills page, the package sync — unchanged. + await store.installSkill({ + slug: atomicSkill, + title: "Written on the pool", + summary: "For a test.", + instructions: "Do the thing.", + ownerUserId: null, + by, + }); + expect((await skillRow(atomicSkill))?.title).toBe("Written on the pool"); + }); +}); + +describe("a template naming a tool nothing here has connected", () => { + test("is refused by default, because that is a typo guard for the hand-authored path", async () => { + await expect( + store.installSkill({ + slug: unknownSkill, + title: "Names a connector nobody has added", + summary: "For a test.", + instructions: "Do the thing.", + ownerUserId: null, + tools: [unseenRef], + by, + }), + ).rejects.toBeInstanceOf(PluginRefusedError); + expect(await skillRow(unknownSkill)).toBeUndefined(); + }); + + test("installs when the caller says the refs may be unknown", async () => { + /* + * Every fresh deployment has connected nothing, so without this a template naming + * `google-drive/search_files` could not be imported anywhere, and a template could only ship + * skills for connectors it could guarantee — which is none of them. + */ + await store.installSkill({ + slug: unknownSkill, + title: "Names a connector nobody has added", + summary: "For a test.", + instructions: "Do the thing.", + ownerUserId: null, + tools: [unseenRef], + allowUnknownTools: true, + by, + }); + + const declared = (await store.listSkills(actor)).find( + (row) => row.slug === unknownSkill, + ); + expect(declared?.tools).toEqual([unseenRef]); + }); + + test("and the ref it stored is inert: declaring is still not granting", async () => { + /* + * THE property, and the reason `allowUnknownTools` is not a security relaxation. The run-time + * offer is granted ∩ declared, so a ref that was never checked is also never callable — the Bot + * holds this skill and is offered nothing from it. + */ + await store.grant("skill", unknownSkill, bot, by); + + const held = await store.listForAgent(bot); + expect(held.skills.map((row) => row.slug)).toContain(unknownSkill); + expect(held.tools.map((tool) => tool.ref)).not.toContain(unseenRef); + }); + + test("skips the check and nothing else: a save with no refs at all still behaves", async () => { + // The flag turns one refusal off. It must not become a general "write whatever" switch, so the + // rest of the save — the replace-wholesale rule for declarations — is unchanged under it. + await store.installSkill({ + slug: unknownSkill, + title: "Names a connector nobody has added", + summary: "For a test.", + instructions: "Do the thing.", + ownerUserId: null, + tools: [], + allowUnknownTools: true, + by, + }); + + const declared = (await store.listSkills(actor)).find( + (row) => row.slug === unknownSkill, + ); + expect(declared?.tools).toEqual([]); + }); +}); + +describe("the HTTP path that anybody signed in may reach", () => { + test("never sets allowUnknownTools", async () => { + /* + * Read from the source rather than exercised through a request, because what is being pinned is + * that nobody adds it later. A behavioural test would pass the moment somebody threaded the flag + * through from the request body and forgot what it was for. + * + * The Skills page is the hand-authored path, and there the refusal is the whole point: somebody + * typing `google-drive/serach_files` should be told immediately rather than shipping a skill + * that silently selects nothing. The flag belongs to the import module, which is reading a + * document written against a deployment other than this one. + */ + const source = await readFile( + new URL("../src/plugins/routes.ts", import.meta.url), + "utf8", + ); + expect(source).toContain("store.installSkill("); + expect(source).not.toContain("allowUnknownTools"); + }); +}); From 823c52073d5c665f7bd2883b77ef91fb2221b0b8 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Sun, 30 Aug 2026 12:09:04 -0700 Subject: [PATCH 04/15] Pack a coworker into a template, resolve one against this deployment, and install it granting nothing --- server/src/agents/routes.ts | 79 ++ server/src/app.ts | 88 ++ server/src/templates/install.ts | 858 +++++++++++++++ server/src/templates/pack.ts | 712 +++++++++++++ server/src/templates/resolve.ts | 409 ++++++++ server/src/templates/routes.ts | 992 ++++++++++++++++++ server/src/templates/store.ts | 674 ++++++++++++ .../template-install.integration.test.ts | 550 ++++++++++ server/tests/template-pack.test.ts | 644 ++++++++++++ .../template-resolve.integration.test.ts | 375 +++++++ .../tests/template-routes.integration.test.ts | 952 +++++++++++++++++ .../tests/template-store.integration.test.ts | 379 +++++++ 12 files changed, 6712 insertions(+) create mode 100644 server/src/templates/install.ts create mode 100644 server/src/templates/pack.ts create mode 100644 server/src/templates/resolve.ts create mode 100644 server/src/templates/routes.ts create mode 100644 server/src/templates/store.ts create mode 100644 server/tests/template-install.integration.test.ts create mode 100644 server/tests/template-pack.test.ts create mode 100644 server/tests/template-resolve.integration.test.ts create mode 100644 server/tests/template-routes.integration.test.ts create mode 100644 server/tests/template-store.integration.test.ts diff --git a/server/src/agents/routes.ts b/server/src/agents/routes.ts index 060e77af..96d7cbd1 100644 --- a/server/src/agents/routes.ts +++ b/server/src/agents/routes.ts @@ -1,8 +1,12 @@ import type { Context, MiddlewareHandler } from "hono"; import { Hono } from "hono"; +import { TemplateRefusedError } from "../../../shared/bot-template"; import type { AuditEventType, AuditStore } from "../audit"; import { recordAuditEvent } from "../audit"; import type { AppVariables } from "../auth/guards"; +import { SecretInTemplateError } from "../templates/pack"; +import type { TemplateExport } from "../templates/routes"; +import { TemplateSlugTakenError } from "../templates/store"; import { testAgentConnection } from "./connection-test"; import { checkAgentEndpoint } from "./endpoint"; import { canManageAgent } from "./profile-policy"; @@ -159,6 +163,19 @@ export function createAgentRoutes( /** The Bots this one may address today, read per call so a revoked grant stops showing. */ reachableFrom: (agentId: string) => Promise; }, + /** + * Packing this coworker into a template draft. + * + * Mounted here rather than under `/api/templates` because it is a thing done TO a Bot, beside + * Duplicate, and the question it has to answer first — may this person manage this coworker — is + * this file's question. Everything below that question lives in `templates/routes.ts`, so the two + * halves are not two copies of the same authorization rule. + * + * Absent leaves the route unmounted rather than mounted and refusing, the same shape every other + * optional capability here takes: a deployment that never built the template store has no door for + * this, not a locked one. + */ + templateExport?: TemplateExport, ) { const routes = new Hono<{ Variables: AppVariables }>(); @@ -374,6 +391,68 @@ export function createAgentRoutes( } }); + /** + * Export this coworker as a template draft. + * + * EXPORTING A PACKAGE BOT IS DELIBERATELY ALLOWED, which is why the check below is not simply + * `canManageAgent`. A system-owned Bot is ownerless and public, it is the most template-worthy + * thing in the product, and `POST /:agentId/duplicate` already lets any signed-in person fork one + * — so refusing here would protect nothing while withholding the only Bots worth writing a + * catalogue from. Nothing about the export changes the Bot it read. + * + * The refusals are the packer's, and they are refusals rather than warnings on purpose: a coworker + * with a skill slug the format does not admit, or prose past a ceiling, cannot be expressed as a + * template, and a silently truncated instruction is an instruction nobody wrote. A secret shape in + * its text is refused for the harder reason — the file is about to be handed to somebody. + */ + routes.post("/:agentId/template", requireUser, async (context) => { + if (!templateExport) { + return context.json( + { error: "This deployment cannot author templates." }, + 503, + ); + } + const agentId = context.req.param("agentId"); + try { + const agent = await store.get(context.var.actor, agentId); + if (!agent) return context.json({ error: "Agent not found." }, 404); + if (!canManageAgent(context.var.actor, agent) && !agent.systemOwned) { + return context.json( + { error: "You do not have permission to manage this agent." }, + 403, + ); + } + return context.json( + await templateExport.exportAgent(context.var.actor, agent), + 201, + ); + } catch (error) { + /* + * Both refusals carry their machine-readable half beside the sentence, because the export + * screen has to tell an author which of the two happened: one is a Bot to rename or shorten, + * the other is a key to take out of somebody's prose. Neither body echoes the offending text. + */ + if (error instanceof TemplateRefusedError) { + return context.json( + { error: error.message, reason: error.reason }, + 400, + ); + } + if (error instanceof SecretInTemplateError) { + return context.json( + { error: error.message, reason: "secret_shape", field: error.field }, + 400, + ); + } + if (error instanceof TemplateSlugTakenError) { + // Never overwritten. An export produces a draft the author edits, and a second export of the + // same coworker landing on top of it would throw those edits away without saying so. + return context.json({ error: error.message }, 409); + } + return mapStoreError(context, error); + } + }); + routes.post("/:agentId/hide", requireUser, async (context) => { try { await store.setHidden( diff --git a/server/src/app.ts b/server/src/app.ts index 20561446..ce0a2068 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -45,6 +45,13 @@ import type { RoutineRunner } from "./routines/runner"; import type { IntentRouter } from "./routing/classify"; import { createRoutingRoutes } from "./routing/routes"; import type { PackageStatusReader } from "./tenant-package"; +import type { TemplateInstaller } from "./templates/install"; +import { + createTemplateExport, + createTemplateRoutes, + type TemplateRoutesDeps, +} from "./templates/routes"; +import type { TemplateReadExecutor, TemplateStore } from "./templates/store"; /** * One row for something an administrator did to somebody's access. @@ -190,6 +197,31 @@ export function createApp( * has no door for this at all, not a locked one. */ routineStore?: RoutineStore, + /** + * Bot templates: the drafts this deployment authored, and the one act that installs somebody's. + * + * Appended last, like everything above it: these are positional, so inserting one anywhere else + * silently shifts every existing call site's arguments by one. + * + * Only the three things this module cannot build for itself. The trail, the grant stores and + * whether there is a Bot in the box are all already in scope here, and passing them in again would + * be a second place for them to disagree with the rest of the app. + * + * Absent leaves the routes unmounted rather than mounted and refusing every call, and leaves the + * export button off a coworker's panel: a deployment that never built the template store has no + * door for this at all, not a locked one. + */ + templates?: { + store: TemplateStore; + installer: TemplateInstaller; + /** + * A read handle on this deployment's own tables, for the resolver. + * + * The install path resolves again on its own transaction; this one serves the preview, which + * writes nothing and may read on the pool. + */ + executor: TemplateReadExecutor; + }, ) { const app = new Hono<{ Variables: AppVariables }>(); @@ -779,6 +811,30 @@ export function createApp( pluginStore.botsReachableFrom(agentId), } : undefined, + /* + * Packing a coworker into a template draft, mounted on the Bot rather than under + * /api/templates because that is what it is done to. + * + * Requires the trail as well as the store. An export is the moment a coworker's whole + * configuration becomes a file somebody can send anywhere, and `template.exported` is the + * only record that it happened — so a deployment that cannot write the row does not get the + * button, rather than getting a button that leaves no trace. + */ + templates && auditStore + ? createTemplateExport({ + executor: templates.executor, + templateStore: templates.store, + auditStore, + ...(pluginStore ? { plugins: pluginStore } : {}), + ...(componentStore ? { components: componentStore } : {}), + // What tells `managed` from `remote`: a coworker that runs in the box carries this + // deployment's own address in its configuration, so having an endpoint is not the + // distinction. See templates/pack.ts. + ...(config.managedAgent + ? { managedAgentAgUiUrl: config.managedAgent.endpoint } + : {}), + }) + : undefined, ), ); // Choosing a coworker for an untagged message needs the same permission-filtered roster the @@ -834,6 +890,38 @@ export function createApp( ); } + /* + * Reading a stranger's file, and turning it into an ordinary Bot. + * + * The trail is a condition of mounting rather than an optional extra, which is not the shape the + * other surfaces here take. Every act on these routes is either somebody consenting to text a + * stranger wrote or an administrator answering what that text asked for, and both are only + * accountable afterwards through `template.import_refused`, `template.imported` and + * `template.capability_granted`. A template surface with no trail is the one shape of this feature + * that must not exist, so a deployment that cannot write one gets no import at all. + * + * The grant stores are passed as the narrow `grant` seam each of them already has. There is no new + * grant route anywhere in this feature: satisfying a capability goes through the code that already + * refuses, and handing these routes anything wider would be an invitation to grow a second path. + */ + if (templates && auditStore) { + const templateDeps: TemplateRoutesDeps = { + templateStore: templates.store, + installer: templates.installer, + auditStore, + executor: templates.executor, + // Whether a coworker with `runtime: managed` has anywhere to run. False on the recommended + // one-container image, which is why an import of a managed template asks for an address. + managedAgent: Boolean(config.managedAgent), + ...(pluginStore ? { grants: pluginStore } : {}), + ...(componentStore ? { components: componentStore } : {}), + }; + app.route( + "/api/templates", + createTemplateRoutes(templateDeps, requireUser, canUseBot), + ); + } + if (pluginStore) { app.route( "/api/plugins", diff --git a/server/src/templates/install.ts b/server/src/templates/install.ts new file mode 100644 index 00000000..fd818fb7 --- /dev/null +++ b/server/src/templates/install.ts @@ -0,0 +1,858 @@ +/** + * Turning a template somebody consented to into an ordinary Bot, in one act. + * + * ONE TRANSACTION IS THE WHOLE POINT OF THIS FILE. An import is a Bot, its skills, the grants that + * pair them, a provenance row and a ledger — five writes across three stores — and either all of + * them happened or none did. Without that, a failure partway leaves an orphan Bot holding half a + * skill set, the person presses import again, and the deployment now has two coworkers with the same + * name and no way to tell which one is the wreckage. `pluginStore.installSkill` and + * `pluginStore.grant` take the executor for exactly this, and the profile store is built over the + * transaction rather than the pool for the same reason. + * + * CONFIGURATION TRAVELS; CAPABILITY DOES NOT. There is no code path here that writes a + * `plugin_grants` row with kind `mcp` — not a conditional one, not one behind a flag. The one grant + * an import makes is the Bot-to-skill pairing, which is what switches per-run narrowing on and + * confers nothing by itself. Everything else a template asked for lands in `template_requests` as an + * ask, and is satisfied later by an administrator on a screen that already refuses. `store.grant` + * performs no existence check and `listServers` computes `withdrawn` only for servers that exist, so + * an optimistic grant for an absent connector would be invisible on every screen and would go live + * the day somebody added that connector, with nobody deciding. + * + * NOTHING THE CLIENT PARSED IS TRUSTED. The document is serialised and parsed again here, so the + * refusals that ran at preview run again at install; the digest is recomputed and a move is refused, + * which closes the window where a gallery file or a pasted buffer changes between the screen a + * person read and the button they pressed; and the plan is resolved again on this transaction rather + * than taken from the preview, because a preview is a screen somebody read and not evidence about + * the database a second later. + */ +import { and, eq } from "drizzle-orm"; +import { + type BotTemplate, + botTemplateDigest, + parseBotTemplate, + serializeBotTemplate, + templateGrantMark, +} from "../../../shared/bot-template"; +import { checkAgentEndpoint } from "../agents/endpoint"; +import { createAgentProfileStore } from "../agents/profile-store"; +import type { AgentActor } from "../agents/profile-types"; +import { + type AuditEventType, + type AuditStore, + recordAuditEvent, +} from "../audit"; +import type { CredentialStore } from "../credentials"; +import type { Database } from "../db/client"; +import { agentProfiles, pluginGrants, skills } from "../db/schema"; +import type { PluginStore } from "../plugins/store"; +import { + MAX_SUFFIX, + resolveBotTemplate, + type SlugResolution, + suffixedSlug, + type TemplatePlan, +} from "./resolve"; +import type { + TemplateExecutor, + TemplateImportRow, + TemplateImportSource, + TemplateRequestRow, + TemplateRequestSeed, + TemplateStore, +} from "./store"; + +/** + * The local development actor, which is not a row in `users`. + * + * The audit table has a foreign key to that table, so writing this id there fails the constraint and + * loses the entire row. Who it was goes in the payload either way — the convention + * `agents/routes.ts:123-129` already follows. + */ +const DEV_ACTOR_EMAIL = "dev@openbot.local"; + +export type TemplateActor = AgentActor & { email?: string }; + +export type InstallBotTemplateInput = { + /** The parsed document. Re-serialised and re-parsed here rather than believed. */ + template: BotTemplate; + /** What the consent screen showed. A mismatch is refused rather than reconciled. */ + digest: string; + actor: TemplateActor; + source: TemplateImportSource; + sourceRef?: string; + /** + * The address the importer typed, when the plan asked for one. + * + * Never from the file — the format has no url field — and re-checked here against this + * deployment's allowlist rather than trusted from the route, because a caller that forgot is a + * caller that registered an SSRF target. + */ + endpoint?: string; + /** The key the importer typed, header name and value. The value goes to the vault and nowhere else. */ + auth?: { header: string; value: string }; + /** What to do about each colliding skill slug, keyed by the slug the TEMPLATE names. */ + slugDecisions?: Record; +}; + +export type InstallBotTemplateResult = { + agentId: string; + imported: TemplateImportRow; + ledger: TemplateRequestRow[]; + /** The plan as it was resolved server-side, which may differ from the preview the person saw. */ + plan: TemplatePlan; + skillsCreated: string[]; + skillsReused: string[]; + skillsSuffixed: string[]; + skillsSkipped: string[]; +}; + +export type RetractTemplateImportInput = { + actor: TemplateActor; + agentId: string; +}; + +export type RetractTemplateImportResult = { + agentId: string; + importId: string; + /** Exactly the grants this import made, and nothing an administrator made by hand. */ + revoked: { kind: string; ref: string }[]; + /** The compiled clauses that stopped applying. */ + boundaries: string[]; +}; + +/** + * The file moved between the screen and the click. + * + * A distinguishable type because the route turns it into a 409 rather than a 400: nothing is wrong + * with the document, and the honest thing to tell the person is that what they are about to install + * is not what they read. + */ +export class TemplateDigestMovedError extends Error { + readonly expected: string; + readonly actual: string; + constructor(expected: string, actual: string) { + super( + "This template has changed since you read it. Look at it again before installing.", + ); + this.name = "TemplateDigestMovedError"; + this.expected = expected; + this.actual = actual; + } +} + +/** A coworker with nowhere to run. The importer has to type an address. */ +export class TemplateEndpointRequiredError extends Error { + readonly reason: "remote" | "no_managed_agent"; + constructor(reason: "remote" | "no_managed_agent") { + super( + reason === "remote" + ? "This template's coworker runs somewhere else. Type the address it runs at." + : "This deployment has no Bot in the box, so this coworker needs an address of its own.", + ); + this.name = "TemplateEndpointRequiredError"; + this.reason = reason; + } +} + +/** An address this deployment will not dial, named so the person typing it sees which one. */ +export class TemplateEndpointRefusedError extends Error { + constructor(reason: string) { + super(reason); + this.name = "TemplateEndpointRefusedError"; + } +} + +/** + * A key with nowhere safe to put it. + * + * Refused rather than dropped. `store.create` only writes an agent's key when a vault was + * configured, so without this the import would succeed, report success, and produce a Bot that + * silently authenticates with nothing — which is the failure people spend an afternoon on. + */ +export class TemplateVaultUnavailableError extends Error { + constructor() { + super( + "This deployment has no vault, so a coworker's key cannot be stored. Import it without a key.", + ); + this.name = "TemplateVaultUnavailableError"; + } +} + +/** + * A decision that no longer describes the deployment. + * + * `reuse` means "the skill already here is the same skill", and that has to be true at the moment of + * writing rather than at the moment of the preview. If somebody edited that skill in between, quietly + * pairing the Bot to it anyway would give an imported coworker instructions nobody consented to, and + * quietly suffixing instead would give them a skill they thought they were reusing. Refused, so the + * person reads the plan again. + */ +export class TemplateSlugDecisionError extends Error { + readonly slug: string; + constructor(slug: string, message: string) { + super(message); + this.name = "TemplateSlugDecisionError"; + this.slug = slug; + } +} + +/** No free name left for a skill this template ships. */ +export class TemplateSlugUnavailableError extends Error { + readonly slug: string; + constructor(slug: string) { + super( + `Every name near "${slug}" is taken on this deployment. Skip that skill or free a name.`, + ); + this.name = "TemplateSlugUnavailableError"; + this.slug = slug; + } +} + +export class TemplateImportNotFoundError extends Error { + readonly agentId: string; + constructor(agentId: string) { + super(`No template import is recorded for ${agentId}.`); + this.name = "TemplateImportNotFoundError"; + this.agentId = agentId; + } +} + +/** Retraction is the owner's or an administrator's. Nobody else's. */ +export class TemplateRetractionRefusedError extends Error { + readonly agentId: string; + constructor(agentId: string) { + super( + `Only the owner of ${agentId} or an administrator may retract its import.`, + ); + this.name = "TemplateRetractionRefusedError"; + this.agentId = agentId; + } +} + +export type TemplateInstallerDeps = { + database: Database; + templateStore: TemplateStore; + /** Only these two, and both of them take this module's transaction. */ + pluginStore: Pick; + auditStore: AuditStore; + /** + * The Bot in the box, if this deployment has one. `config.managedAgent?.endpoint`. + * + * Absent is the recommended one-container image, and it is why an import of a `managed` template + * still asks the importer for an address: `store.create` throws `ManagedAgentUnavailableError` + * when there is neither. + */ + managedAgentAgUiUrl?: URL; + vault?: { store: CredentialStore; encryptionKey: string }; + /** + * What this deployment will let a coworker live at, checked here as well as at the route. + * + * Defaulted to the strictest reading — no private hosts, no named ones — because the failure mode + * of forgetting to pass it is a registered SSRF target, and the failure mode of passing it too + * strictly is a refusal somebody can read. + */ + endpointPolicy?: { + allowPrivateHosts?: boolean; + allowedHosts?: ReadonlySet; + }; +}; + +export type TemplateInstaller = { + installBotTemplate( + input: InstallBotTemplateInput, + ): Promise; + retractTemplateImport( + input: RetractTemplateImportInput, + ): Promise; +}; + +export function createTemplateInstaller( + deps: TemplateInstallerDeps, +): TemplateInstaller { + const { + database, + templateStore, + pluginStore, + auditStore, + managedAgentAgUiUrl, + vault, + } = deps; + const endpointPolicy = deps.endpointPolicy ?? {}; + + /** + * A trail row, never fatal. + * + * The change is already committed and the caller has been told so; a trail that is briefly + * unavailable is not a reason to report a failure that did not happen. The same judgement + * `agents/routes.ts` makes for `bot.created`, and it matters more here because a partial audit is + * still readable while a thrown error after a commit is a lie. + */ + const record = async ( + eventType: AuditEventType, + actor: TemplateActor, + agentId: string, + payload: Record, + ): Promise => { + try { + await recordAuditEvent(auditStore, { + eventType, + targetType: "agent", + targetId: agentId, + ...(actor.id && actor.email && actor.email !== DEV_ACTOR_EMAIL + ? { actorUserId: actor.id } + : {}), + payload: { bot: agentId, actor: actor.email ?? actor.id, ...payload }, + }); + } catch (error) { + console.error( + JSON.stringify({ + type: "template-audit-write-failed", + eventType, + agentId, + error: String(error), + }), + ); + } + }; + + /** + * Take a slug, or find out somebody else already has. + * + * THE UNIQUE INDEX DECIDES, NOT A READ. `installSkill` upserts on `skills.slug`, so handing it a + * name that was free when the plan was resolved and taken by the time it runs would silently + * rewrite somebody's `/` command with a stranger's instructions — the one outcome this whole + * feature may never produce. A read-then-write cannot close that: the gap is the bug. So the row + * is claimed first with `on conflict do nothing`, and an empty result means the name is not ours. + * + * The claim writes the real values rather than a placeholder, and `installSkill` then upserts the + * same row a moment later, taking its conflict branch and leaving `owner_user_id`, `origin` and + * `installed_by` exactly as claimed while it writes the declarations and the trail. Duplicating + * the insert is the price of letting the constraint be the arbiter, and it is a smaller price than + * a lost `/` command. + */ + async function claimSlug( + executor: TemplateExecutor, + slug: string, + values: { + ownerUserId: string; + title: string; + summary: string; + instructions: string; + installedBy: string; + }, + ): Promise { + const claimed = await executor + .insert(skills) + .values({ + id: slug, + slug, + ownerUserId: values.ownerUserId, + title: values.title, + summary: values.summary, + instructions: values.instructions, + origin: "template", + installedBy: values.installedBy, + }) + .onConflictDoNothing({ target: skills.slug }) + .returning({ slug: skills.slug }); + return claimed.length > 0; + } + + return { + async installBotTemplate(input) { + /* + * Re-parsed from its own serialisation, not read from the object the caller handed over. The + * client's parse decided what a person was shown; this one decides what is written, and the + * two must be the same function run twice rather than one run trusted twice. + */ + const template = parseBotTemplate(serializeBotTemplate(input.template)); + const digest = await botTemplateDigest(template); + if (digest !== input.digest) { + throw new TemplateDigestMovedError(input.digest, digest); + } + + const actor = input.actor; + /* + * An email where there is one, and the actor id where there is not. `imported_by` and + * `installed_by` are trails rather than foreign keys, and under `OPENBOT_SINGLE_USER` there is + * frequently no `users` row worth naming. + */ + const actorLabel = actor.email ?? actor.id; + const mark = templateGrantMark(digest); + + if (input.auth && !vault) throw new TemplateVaultUnavailableError(); + if (input.auth && !/^[A-Za-z0-9-]+$/.test(input.auth.header)) { + throw new TemplateEndpointRefusedError( + "That is not a valid header name.", + ); + } + + const endpointRequired = + template.bot.runtime === "remote" || !managedAgentAgUiUrl; + if (endpointRequired && !input.endpoint?.trim()) { + throw new TemplateEndpointRequiredError( + template.bot.runtime === "remote" ? "remote" : "no_managed_agent", + ); + } + + /* + * Checked here even though the route checks it too. The rule this enforces is that a URL which + * must never be fetched never reaches the database, and a second caller of this module — a + * gallery installer, a CLI, a test — is exactly how the first check gets skipped. + */ + let endpoint: string | undefined; + if (input.endpoint?.trim()) { + const verdict = checkAgentEndpoint( + input.endpoint.trim(), + endpointPolicy, + ); + if (!verdict.allowed) { + throw new TemplateEndpointRefusedError(verdict.reason); + } + endpoint = verdict.url; + } + + const outcome = await database.transaction( + async (transaction) => { + /* + * The profile store, built over this transaction rather than the pool. + * + * The cast is the same one `work/queue.ts:217` makes and for the same reason: a drizzle + * transaction is a database handle for every purpose this store has — its own + * `transaction` call opens a savepoint inside ours — but the two are not the same + * nominal type. Going through the store rather than writing `agents` and `agent_profiles` + * here is what keeps the vault write, the endpoint handling and the id minting identical + * to what `POST /api/agents` does, so an imported Bot is an ordinary Bot in the only sense + * that matters: the same code made it. + */ + const profileStore = createAgentProfileStore( + transaction as unknown as Database, + managedAgentAgUiUrl, + vault, + ); + + const profile = await profileStore.create(actor, { + name: template.bot.name, + title: template.bot.title, + roleDescription: template.bot.roleDescription, + // Forced, both of them. Ownership and visibility are facts about this deployment, and a + // template has no field that could carry either. Making it public is an ordinary later + // PATCH the owner makes on a Bot they can already see. + visibility: "private", + ...(endpoint ? { endpoint } : {}), + ...(input.auth ? { auth: input.auth } : {}), + }); + const agentId = profile.id; + + /* + * Resolved again, on this transaction. The preview read a database that has since had a + * skill added to it, a connector connected, or a component published, and the decisions + * below are about the one being written. + */ + const plan = await resolveBotTemplate(transaction, template, { + managedAgent: Boolean(managedAgentAgUiUrl), + digest, + }); + + const decisions = input.slugDecisions ?? {}; + const skillsCreated: string[] = []; + const skillsReused: string[] = []; + const skillsSuffixed: string[] = []; + const skillsSkipped: string[] = []; + /** The slug each template skill actually ended up as, for the pairing below. */ + const installedAs = new Map(); + + for (const skill of template.skills) { + const resolved = plan.skills.find( + (entry) => entry.slug === skill.slug, + ); + /* Every template skill is in the plan; this is a type narrowing, not a case. */ + if (!resolved) continue; + + const asked = decisions[skill.slug]; + /* + * The importer's decision only governs a slug that actually collides. A free name has + * nothing to reuse and nothing to suffix, so the only decision worth honouring there is + * skipping the skill entirely. + */ + const decision: SlugResolution = resolved.collides + ? (asked ?? resolved.resolution) + : asked === "skip" + ? "skip" + : "suffix"; + + if (decision === "skip") { + skillsSkipped.push(skill.slug); + continue; + } + + if (decision === "reuse") { + if (!resolved.identical) { + throw new TemplateSlugDecisionError( + skill.slug, + `The skill already called "${skill.slug}" here is not the one this template ships, so it cannot be reused. Read the plan again.`, + ); + } + // Nothing is written. The skill already here is paired to the Bot as it stands, which + // is what reuse means and why it is the default when the two are identical. + installedAs.set(skill.slug, skill.slug); + skillsReused.push(skill.slug); + continue; + } + + /* + * `installAs` is what the plan would do on its own, and it names the colliding slug when + * the plan said reuse. An importer who overrode reuse to suffix is asking for the other + * name, so the suffix candidate is taken directly rather than letting the claim below + * discover the collision and walk to it — which would work, and would make the reason a + * name was chosen unreadable from the code. + */ + const wanted = resolved.collides + ? resolved.suffixCandidate + : resolved.installAs; + if (!wanted) throw new TemplateSlugUnavailableError(skill.slug); + + const values = { + ownerUserId: actor.id, + title: skill.title, + summary: skill.summary, + instructions: skill.instructions, + installedBy: actorLabel, + }; + let written: string | null = null; + if (await claimSlug(transaction, wanted, values)) { + written = wanted; + } else { + /* + * Somebody took the name between the plan and the claim — another import in flight, or + * the Skills page. Walk on rather than fail: the constraint has already told us the + * truth, and the next free suffix is as good a name as the one we asked for. + */ + for (let index = 2; index <= MAX_SUFFIX; index += 1) { + const candidate = suffixedSlug(skill.slug, index); + if (!candidate) continue; + if (await claimSlug(transaction, candidate, values)) { + written = candidate; + break; + } + } + } + if (!written) throw new TemplateSlugUnavailableError(skill.slug); + + await pluginStore.installSkill( + { + slug: written, + title: skill.title, + summary: skill.summary, + instructions: skill.instructions, + // The importer's own, as `duplicate` already makes a forked Bot theirs. The source + // deployment's owner, installer and declarer are identities that mean nothing here. + ownerUserId: actor.id, + origin: "template", + tools: skill.tools, + /* + * A template names the connectors its author had, so every template naming + * `google-drive/search_files` would fail to install on every deployment that has not + * connected Drive — which is every fresh one. A declaration grants nothing; the + * run-time offer is granted ∩ declared, so an unknown ref is inert. + */ + allowUnknownTools: true, + by: actorLabel, + }, + transaction, + ); + installedAs.set(skill.slug, written); + // Disjoint lists, so a reader of the trail can count them. A skill written under the + // name the template asked for is created; one written under another name is suffixed, + // and the name it took is the interesting half of that. + if (written === skill.slug) skillsCreated.push(written); + else skillsSuffixed.push(written); + } + + /* + * The one grant an import makes. + * + * Without it the Bot boots with skills attached to nobody and per-run narrowing never + * switches on — the skills exist, the Bot cannot see them, and nothing anywhere says so. + * Marked with the digest so a retraction takes back exactly this, and leaves an + * administrator's own grant on the same Bot untouched. + */ + for (const slug of template.bot.skills) { + const written = installedAs.get(slug); + if (!written) continue; + await pluginStore.grant( + "skill", + written, + agentId, + mark, + transaction, + ); + } + + const imported = await templateStore.recordImport( + { + agentId, + digest, + slug: template.template.slug, + ...(template.template.version + ? { templateVersion: template.template.version } + : {}), + ...(template.template.author + ? { authorClaim: template.template.author } + : {}), + source: input.source, + ...(input.sourceRef ? { sourceRef: input.sourceRef } : {}), + document: template, + importedBy: actorLabel, + }, + transaction, + ); + + await templateStore.recordRequests( + ledgerFor(imported.id, plan, endpoint, actorLabel), + transaction, + ); + /* + * Read back rather than returned from what was written. `recordRequests` does nothing on a + * row that is already there, so the rows in the database are the ones that count and the + * caller should be handed those. + */ + const ledger = await templateStore.listRequests( + imported.id, + transaction, + ); + + return { + agentId, + imported, + plan, + ledger, + skillsCreated, + skillsReused, + skillsSuffixed, + skillsSkipped, + }; + }, + { isolationLevel: "read committed" }, + ); + + /* + * The trail, after the commit and in this order. + * + * After, because the audit store holds its own handle and writes on the pool: a row written + * inside the transaction would survive a rollback and claim a Bot that does not exist. + * `bot.created` first, so a reader filtering `bot.*` still sees every Bot that ever existed on + * this deployment rather than only the hand-made ones. + * + * NEVER THE PROSE AND NEVER A KEY. `redactAuditPayload` is a key-NAME filter and would pass a + * field called `roleDescription` or `instructions` through verbatim, so the rule is kept here + * rather than downstream: what goes in is a slug, a digest, a count and a host. + */ + const endpointHost = endpoint ? new URL(endpoint).host : undefined; + await record("bot.created", actor, outcome.agentId, { + name: template.bot.name, + ...(endpoint ? { endpoint } : {}), + hasKey: Boolean(input.auth), + }); + await record("template.imported", actor, outcome.agentId, { + templateSlug: template.template.slug, + ...(template.template.version + ? { templateVersion: template.template.version } + : {}), + // A claim, and the payload says so in the field name. Nothing verified it. + ...(template.template.author + ? { authorClaim: template.template.author } + : {}), + digest, + source: input.source, + ...(input.sourceRef ? { sourceRef: input.sourceRef } : {}), + skillsCreated: outcome.skillsCreated, + skillsReused: outcome.skillsReused, + skillsSuffixed: outcome.skillsSuffixed, + skillsSkipped: outcome.skillsSkipped, + ...(endpointHost ? { endpointHost } : {}), + hasKey: Boolean(input.auth), + }); + /* + * One row per unmet ask, written even though the install SUCCEEDED. A Bot that silently cannot + * work has to be distinguishable from one nobody asked to work: an imported coworker routinely + * arrives naming a connector nobody here has connected, installs, looks finished on the Bots + * page, and answers from memory. Without these rows that is indistinguishable from a badly + * written prompt, and the person debugging it reads the prose over and over looking for a fault + * that is not there. The author's `why` is deliberately NOT in the payload; it is a stranger's + * prose and it lives in the ledger, which is where it is rendered as one. + */ + for (const row of outcome.ledger) { + if (row.status === "granted") continue; + await record("template.capability_requested", actor, outcome.agentId, { + kind: row.kind, + ref: row.ref, + status: row.status, + }); + } + + return outcome; + }, + + async retractTemplateImport(input) { + const actor = input.actor; + const outcome = await database.transaction( + async (transaction) => { + const imported = await templateStore.importForAgent( + input.agentId, + transaction, + ); + if (!imported) throw new TemplateImportNotFoundError(input.agentId); + + const [profile] = await transaction + .select({ ownerUserId: agentProfiles.ownerUserId }) + .from(agentProfiles) + .where(eq(agentProfiles.agentId, input.agentId)) + .limit(1); + if ( + !profile || + (actor.role !== "admin" && profile.ownerUserId !== actor.id) + ) { + throw new TemplateRetractionRefusedError(input.agentId); + } + + /* + * Only what this import gave. + * + * Both predicates, and the pairing is what makes the mark safe. The mark is derived from + * the document, so two imports of the same file share it; the agent id is what separates + * them. Every other value in `granted_by` is the id of a person who pressed a button, so a + * grant an administrator made by hand on this same Bot cannot match and survives untouched + * — which is the property the whole sentinel exists for, and the one the test asserts. + */ + const mark = templateGrantMark(imported.digest); + const revoked = await transaction + .delete(pluginGrants) + .where( + and( + eq(pluginGrants.agentId, input.agentId), + eq(pluginGrants.grantedBy, mark), + ), + ) + .returning({ kind: pluginGrants.kind, ref: pluginGrants.ref }); + + const boundaries = await templateStore.retractBoundaries( + imported.id, + transaction, + ); + + /* + * The Bot stays, and so does every skill. Retracting an import takes back what the import + * GAVE; it does not delete a coworker somebody has been using or a skill that is now in + * somebody's `/` menu. Those are ordinary things with ordinary delete gestures of their + * own, and an import undoing them would be a stranger's file reaching further on the way + * out than it did on the way in. The provenance row stays too: it is the record of what + * was consented to, and a retraction is not a reason to forget that it happened. + */ + return { + agentId: input.agentId, + importId: imported.id, + revoked: revoked.map((row) => ({ kind: row.kind, ref: row.ref })), + boundaries: boundaries.map((row) => row.expression), + }; + }, + { isolationLevel: "read committed" }, + ); + + await record("template.retracted", actor, outcome.agentId, { + importId: outcome.importId, + revoked: outcome.revoked, + boundaries: outcome.boundaries.length, + }); + if (outcome.boundaries.length > 0) { + await record("template.boundary_removed", actor, outcome.agentId, { + importId: outcome.importId, + clauses: outcome.boundaries, + }); + } + + return outcome; + }, + }; +} + +/** + * The consent ledger, derived from the plan rather than from the file. + * + * A row per tool a connector asked for, because a tool ref is the thing an administrator can + * actually grant; a row for a connector that named no tools, so an ask with nothing grantable behind + * it is still recorded rather than lost; a row per component name; and a row for the endpoint slot + * when there was one. Nothing here is a permission — see `db/schema/templates.ts` for why there is + * no `satisfied` column and why `granted` means a person pressed a button rather than that a grant + * is in force. + */ +function ledgerFor( + importId: string, + plan: TemplatePlan, + endpoint: string | undefined, + importedBy: string, +): TemplateRequestSeed[] { + const rows: TemplateRequestSeed[] = []; + + for (const connector of plan.connectors) { + if (connector.tools.length === 0) { + rows.push({ + importId, + kind: "mcp", + ref: connector.id, + why: connector.why, + status: connector.verdict === "available" ? "requested" : "unavailable", + }); + continue; + } + for (const tool of connector.tools) { + rows.push({ + importId, + kind: "mcp", + ref: tool.ref, + why: tool.why, + /* + * `available` is a statement about the deployment, never about this Bot, so it lands as + * `requested` — the ask is recorded and nothing was granted. `unavailable` says this + * deployment could not have satisfied it at all when the plan was resolved. + */ + status: tool.verdict === "available" ? "requested" : "unavailable", + }); + } + } + + for (const component of plan.components) { + rows.push({ + importId, + kind: "component", + ref: component.name, + why: component.why, + status: component.verdict === "available" ? "requested" : "not_in_build", + }); + } + + if (plan.endpoint.required && endpoint) { + /* + * The one ask an import answers on the spot, because the importer answered it: they typed the + * address. Recorded as decided by them rather than left `requested`, or the profile's amber + * "requested, not granted" list would forever show a slot that is filled. + * + * The ref is the host rather than the whole address. A ledger row is rendered on a screen and + * read back out of the database by people, and the path and query of an AG-UI endpoint are + * neither interesting nor always free of a token somebody put there. + */ + rows.push({ + importId, + kind: "endpoint", + ref: new URL(endpoint).host, + why: + plan.endpoint.sendsConversationTo ?? + "The address this coworker runs at, typed by whoever imported it.", + status: "granted", + decidedBy: importedBy, + decidedAt: new Date(), + }); + } + + return rows; +} diff --git a/server/src/templates/pack.ts b/server/src/templates/pack.ts new file mode 100644 index 00000000..677b0865 --- /dev/null +++ b/server/src/templates/pack.ts @@ -0,0 +1,712 @@ +/** + * A configured coworker, packed into a template somebody else can read. + * + * The judgement in this file is almost entirely about what does NOT come out the other side. A Bot on + * a running deployment is a row with an id that decides which routes it answers on, an owner, a + * visibility, an address, and a credential id naming a vault row that exists on this machine and + * nowhere else. Every one of those is a fact about the deployment it was packed from rather than + * about the coworker, so none of them travel, and the format has no field that could carry them + * anyway: a template that named a host or a credential would fail to parse rather than be quietly + * redacted. + * + * The stripping is REPORTED rather than performed quietly. `PackResult.stripped` is the interesting + * half of an export — an author about to send this file to a stranger should be told in sentences + * that the endpoint and the key reference were left behind, because otherwise the first thing they + * learn about it is that the imported Bot does not work and they cannot tell why. + * + * Nothing here reads the database, the environment or the network. Packing is a pure function of a + * profile and its attachments, so every refusal can be tested as one, and the same secret scanner + * runs again on a draft an author has since edited by hand. + */ +import type { AgentProfile } from "../agents/profile-types"; +import { + BOT_TEMPLATE_FORMAT, + type BotTemplate, + type BotTemplateComponentRequest, + type BotTemplateConnectorRequest, + type BotTemplateRemote, + type BotTemplateSkill, + refuseHostileBytes, + serializeBotTemplate, + STRICT_BOUNDARY, + TEMPLATE_LIMITS, + TemplateRefusedError, + type TemplateRuntime, +} from "../../../shared/bot-template"; + +/** A skill this coworker holds, as `skills` and `skill_tools` record it. */ +export type PackSkill = { + slug: string; + title: string; + summary: string; + instructions: string; + /** `/` declarations. Declarations only, exactly as they are on the skill. */ + tools: string[]; +}; + +/** + * One of the Bot's MCP `plugin_grants` rows, read here to derive an ask and never to make one. + * + * The kind is written in prose rather than as the literal string the grant store uses, because the + * property this module has to keep is that nothing under `server/src/templates/` ever writes an MCP + * grant, and that property is guarded by a grep over this directory rather than by an argument. + */ +export type PackGrant = { ref: string }; + +export type PackInput = { + profile: AgentProfile; + /** The `agents.configuration` jsonb. Read for the endpoint and the auth record; neither travels. */ + configuration: Record; + skills: PackSkill[]; + /** + * The Bot's MCP grants, which become the `requests` block and nothing else. + * + * A grant is a capability and capability does not travel. What a template may say is that this + * coworker was working against Drive when it was packed, so an importer knows what to grant it if + * they decide to. + */ + grants: PackGrant[]; + /** Component names this Bot may use, which become requests for the same reason. */ + components: string[]; + /** + * The auth header NAME, from `configuration.auth.header`. Never the value, which lives in the + * vault and is not readable here at all. + */ + authHeaderName?: string; + /** + * The address this deployment's own managed Bot answers on, when it has one. + * + * Needed because a managed coworker's configuration carries an endpoint too: `create` writes the + * deployment's own AG-UI URL into `configuration.endpoint` for a Bot that runs in the box + * (`profile-store.ts:277-312`), so the presence of an endpoint alone does not distinguish "runs on + * this deployment" from "runs on somebody's own server". Without this, every Bot on a deployment + * that has a managed agent would pack as `remote`, and every importer would be asked to type an + * address for a coworker that should simply run in their box. The packer cannot look the value up + * itself, because it reads nothing. + */ + managedEndpoint?: string; +}; + +export type PackResult = { + template: BotTemplate; + /** What was left behind, in sentences, one per fact. */ + stripped: string[]; +}; + +/** + * The `why` a request carries when nobody has written one yet. + * + * THE AUTHOR EDITS THIS. `why` is the sentence an importer reads beside an ask, and on the consent + * screen it is the only thing that can explain why a stranger's coworker wants access to their Drive. + * The packer cannot know the reason: a grant row records that somebody granted a tool, never what + * for. So the draft carries the one sentence that is true of every request it derives and leaves the + * author to replace it. This is the whole reason export produces a draft to edit rather than a file + * to download. + */ +const DRAFT_WHY = "Granted to this Bot on the deployment it was packed from."; + +/** + * The slug for a Bot whose name yields nothing a slug can be made of. + * + * A name written entirely in a script with no ASCII letters reduces to an empty string, and an empty + * slug is a document the parser refuses. The fallback is deliberately one an author will notice and + * change rather than one that looks finished. + */ +const UNNAMED_SLUG = "unnamed-bot"; + +/** + * The rules `shared/bot-template.ts` enforces on the way back in, restated because it does not export + * them. + * + * Duplicated constants are a place two files can drift apart, so the round-trip test — pack, + * serialize, parse — is what keeps these honest: a copy that disagreed with the parser would produce + * a draft that fails to import, and that test would fail before anybody shipped one. + */ +const TEMPLATE_SLUG = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/; +const TOOL_REF = /^[^/\s]+\/[^/\s]+$/; +const HEADER_NAME = /^[A-Za-z0-9-]+$/; + +/** Sorted output, so packing the same Bot twice produces the same document and the same digest. */ +function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +/** + * A carried string, normalised and held to the limit the parser will hold it to. + * + * Checked here rather than left to the parser so the refusal names the Bot's own field while the + * author is looking at the Bot. The alternative is a draft that serializes cleanly, travels, and is + * refused on somebody else's deployment for a length nobody here was told about. + */ +function carried(value: string, field: string, max: number): string { + const normalised = value.normalize("NFC").trim(); + if (!normalised) { + throw new TemplateRefusedError( + "missing_field", + `${field} is empty, and a template cannot describe a coworker without it.`, + ); + } + if ([...normalised].length > max) { + throw new TemplateRefusedError( + "too_long", + `${field} is longer than ${max} characters, which is the ceiling the template parser enforces. Shorten it on the Bot rather than exporting a draft no deployment can import.`, + ); + } + return normalised; +} + +/** How the endpoint is recorded. `agents/profile-store.ts` owns the shape; this only reads it. */ +function endpointIn(configuration: Record): string | null { + const endpoint = configuration.endpoint; + return typeof endpoint === "string" && endpoint ? endpoint : null; +} + +/** How a key is recorded. `agents/auth-header.ts` owns the shape: a header name and a vault row id. */ +function authIn( + configuration: Record, +): { header: string; credentialId: string } | null { + const auth = configuration.auth; + if (!auth || typeof auth !== "object" || Array.isArray(auth)) return null; + const { header, credentialId } = auth as { + header?: unknown; + credentialId?: unknown; + }; + return typeof header === "string" && typeof credentialId === "string" + ? { header, credentialId } + : null; +} + +/** + * A Bot's name into the slug that names the file. + * + * Accents are decomposed and their marks dropped before anything else, so "Über Desk" becomes + * "uber-desk" rather than "ber-desk". Everything else outside the alphabet becomes a hyphen, runs + * collapse, and the result is cut to the format's ceiling and re-trimmed, because a cut can land on a + * hyphen and a slug may not end on one. + */ +function deriveSlug(name: string): string { + const folded = name.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase(); + const slug = folded + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, TEMPLATE_LIMITS.SLUG) + .replace(/-+$/g, ""); + return TEMPLATE_SLUG.test(slug) ? slug : UNNAMED_SLUG; +} + +/** + * The gallery's one-line description, drafted from what the Bot already says about itself. + * + * The first sentence of the role description is the closest thing a configured Bot has to a summary, + * and it is the author's own words rather than something invented. A first sentence longer than the + * ceiling is not truncated — a sentence cut in half reads as a mistake nobody made — so the title + * stands in, and either way the author is expected to write a better one. + */ +function draftSummary(roleDescription: string, title: string): string { + const [first] = roleDescription + .normalize("NFC") + .trim() + .split(/(?<=[.!?])\s+/); + const sentence = first?.trim() ?? ""; + return sentence && [...sentence].length <= TEMPLATE_LIMITS.SUMMARY + ? sentence + : title; +} + +function packSkills(skills: PackSkill[]): BotTemplateSkill[] { + const seen = new Set(); + let refs = 0; + + return [...skills] + .sort((left, right) => compare(left.slug, right.slug)) + .map((skill) => { + const slug = carried(skill.slug, "a skill's slug", TEMPLATE_LIMITS.SLUG); + /* + * The tenant package's slug rule is looser than the API's and admits `x` and `find-`, so a + * deployment can genuinely hold a skill whose slug a template may not carry. Refused by name + * rather than dropped: a Bot silently exported without one of its skills is a Bot that imports + * without the instructions it was working from. + */ + if (!TEMPLATE_SLUG.test(slug)) { + throw new TemplateRefusedError( + "bad_slug", + `The skill "${slug}" cannot travel: a template's slug rule is the Skills API's, which this slug predates. Rename the skill and export again.`, + ); + } + if (seen.has(slug)) { + throw new TemplateRefusedError( + "bad_slug", + `The skill "${slug}" was given to this Bot twice, and a template may only define it once.`, + ); + } + seen.add(slug); + + const tools = [ + ...new Set(skill.tools.map((ref) => ref.normalize("NFC").trim())), + ].sort(compare); + for (const ref of tools) { + if (!TOOL_REF.test(ref)) { + throw new TemplateRefusedError( + "bad_tool_ref", + `The skill "${slug}" declares "${ref}", which is not written as serverId/toolName and could therefore never match a grant.`, + ); + } + } + refs += tools.length; + if (refs > TEMPLATE_LIMITS.TOOL_REFS) { + throw new TemplateRefusedError( + "too_many", + `This Bot's skills declare more than ${TEMPLATE_LIMITS.TOOL_REFS} tools between them, which is more than a template may carry.`, + ); + } + + return { + slug, + title: carried( + skill.title, + `skills.${slug}.title`, + TEMPLATE_LIMITS.SKILL_TITLE, + ), + summary: carried( + skill.summary, + `skills.${slug}.summary`, + TEMPLATE_LIMITS.SUMMARY, + ), + instructions: carried( + skill.instructions, + `skills.${slug}.instructions`, + TEMPLATE_LIMITS.INSTRUCTIONS, + ), + tools, + }; + }); +} + +/** + * Grants and component names into the ask. + * + * Grouped by the `` half of each ref, because that is how the consent screen reads them and + * how an administrator satisfies them: one connector at a time, on the screen that already decides + * connectors. Nothing here is written as a permission, and nothing downstream may treat it as one. + */ +function packRequests(input: PackInput): { + connectors: BotTemplateConnectorRequest[]; + components: BotTemplateComponentRequest[]; +} { + const byServer = new Map>(); + for (const grant of input.grants) { + const ref = grant.ref.normalize("NFC").trim(); + if (!TOOL_REF.test(ref)) { + throw new TemplateRefusedError( + "bad_tool_ref", + `This Bot holds a grant for "${ref}", which is not written as serverId/toolName and cannot be expressed as a request.`, + ); + } + const serverId = ref.slice(0, ref.indexOf("/")); + const refs = byServer.get(serverId) ?? new Set(); + refs.add(ref); + byServer.set(serverId, refs); + } + + const connectors = [...byServer.entries()] + .sort(([left], [right]) => compare(left, right)) + .map(([serverId, refs]) => ({ + id: carried( + serverId, + `the connector "${serverId}"`, + TEMPLATE_LIMITS.SLUG, + ), + why: DRAFT_WHY, + tools: [...refs].sort(compare).map((ref) => ({ ref, why: DRAFT_WHY })), + })); + + const components = [ + ...new Set(input.components.map((name) => name.normalize("NFC").trim())), + ] + .sort(compare) + .map((name) => ({ + name: carried(name, `the component "${name}"`, 80), + why: DRAFT_WHY, + })); + + const total = + connectors.length + + components.length + + connectors.reduce((sum, connector) => sum + connector.tools.length, 0); + if (total > TEMPLATE_LIMITS.REQUEST_ENTRIES) { + throw new TemplateRefusedError( + "too_many", + `This Bot asks for ${total} things and a template may ask for ${TEMPLATE_LIMITS.REQUEST_ENTRIES}. A request list nobody reads to the end is not consent.`, + ); + } + + return { connectors, components }; +} + +/** + * What was left behind, said out loud. + * + * Two kinds of entry, and the difference is deliberate. A fact that is true of every export — an id + * is minted, an owner becomes the importer, a visibility becomes private — is listed unconditionally, + * because it states the rule. A fact about something this Bot actually holds — a key, an address, a + * package mark — is listed only when it holds one, because telling an author that their Bot's key was + * stripped when their Bot has no key teaches them the wrong thing about what a template carries. + */ +function strippedFrom( + input: PackInput, + endpoint: string | null, + hasAuth: boolean, + seedReplaced: boolean, +): string[] { + const stripped: string[] = [ + "agents.id: this Bot's id, and an import mints a fresh one. An id decides which deployment routes a Bot answers on, so a template that carried one could name a route it is not entitled to.", + "agent_profiles.visibility: an imported Bot is private, and making it public is an ordinary later edit by its new owner.", + "agent_profiles.deleted_at: this deployment's lifecycle state for this Bot, which says nothing about the coworker.", + "agent_preferences: whether a person here hid this Bot. Per-person state about the people on this deployment.", + ]; + + if (input.profile.systemOwned) { + stripped.push( + "agents.package_id: the mark that makes this Bot system-owned. Carried, it would forge a package Bot on the importing deployment and leave it unmanageable there.", + ); + } + if (input.profile.ownerUserId) { + stripped.push( + "agent_profiles.owner_user_id: who owns this Bot here. An imported Bot is owned by whoever imported it.", + ); + } + if (input.profile.hasCallbackToken) { + stripped.push( + "agents.callback_token_hash: the credential this Bot calls tools back with. An imported Bot arrives with none, which is what stops it calling anything back until somebody issues one.", + ); + } + if (endpoint) { + stripped.push( + "configuration.endpoint: the address this Bot runs on. The format has no field for one: the importer types an address, and it is checked against their deployment's allowlist rather than against this one's.", + ); + } + if (hasAuth) { + stripped.push( + "configuration.auth.credentialId: the vault row holding this Bot's key. It is an id from this deployment pointing at a row that is not on the importing one, and the key itself is never readable here at all.", + ); + } + if (seedReplaced) { + stripped.push( + "agent_profiles.avatar_seed: this Bot's seed is its own id, which is what create writes today. A style token derived from the name travels in its place, because an avatar seed is a style token and an id is not something a template may carry.", + ); + } + /* + * A package Bot's standing prompt, which the format has no field for in v1 and which is therefore + * the one carried thing that goes missing rather than being replaced. Exporting a shipped Bot is + * deliberately allowed — they are the most template-worthy things in the product — so the author + * has to be told plainly that its behaviour did not come with it and has to be written into the + * draft's own prose by hand. + */ + if ( + typeof input.configuration.systemPrompt === "string" && + input.configuration.systemPrompt.trim() + ) { + stripped.push( + "configuration.systemPrompt: the standing prompt this Bot runs on. A template says what a coworker is in role_description and skill instructions, so a Bot built on a system prompt is not a faithful round trip and the draft needs that behaviour written into its prose.", + ); + } + if (input.skills.length) { + stripped.push( + "skills.owner_user_id, skills.installed_by and skills.declared_by: who wrote and installed each skill here. Imported skills belong to whoever imports them.", + ); + } + + return stripped; +} + +/** + * A coworker into the draft of a template. + * + * Throws `TemplateRefusedError` when this Bot cannot be expressed as one — a skill slug the format + * does not admit, prose past a ceiling, a role description carrying an environment reference — and + * `SecretInTemplateError` when its text carries something shaped like a credential. + */ +export function packBotTemplate(input: PackInput): PackResult { + const name = carried( + input.profile.name, + "the Bot's name", + TEMPLATE_LIMITS.NAME, + ); + const title = carried( + input.profile.title, + "the Bot's title", + TEMPLATE_LIMITS.TITLE, + ); + const roleDescription = carried( + input.profile.roleDescription, + "the Bot's role description", + TEMPLATE_LIMITS.ROLE_DESCRIPTION, + ); + + const endpoint = input.profile.endpoint ?? endpointIn(input.configuration); + const auth = authIn(input.configuration); + const hasAuth = input.profile.hasAuth || auth !== null; + + /* + * Remote means this coworker runs somewhere the importing deployment has never heard of, and the + * only honest signal for that is an endpoint that is not this deployment's own. See + * `PackInput.managedEndpoint` for why the presence of an endpoint is not the signal by itself. + */ + const runtime: TemplateRuntime = + endpoint !== null && endpoint !== input.managedEndpoint + ? "remote" + : "managed"; + + const headerName = input.authHeaderName ?? auth?.header; + if (headerName !== undefined && !HEADER_NAME.test(headerName)) { + throw new TemplateRefusedError( + "bad_type", + `"${headerName}" is stored as this Bot's auth header name and is not a header name, so it cannot travel as one.`, + ); + } + + /* + * A remote template carries the header NAME and the fact that a key is wanted, and nothing else + * about where it runs. Not `example_url`, and not `sends_conversation_to`: both are hostnames, and + * this Bot's hostname is the address of a server on the deployment being packed. Stripping the + * endpoint and then writing its host into a documentation field beside it would put back exactly + * what the strip was for. An author who means the address to be public adds it by hand, which is a + * deliberate act, and the consent screen renders it as the claim it is. + */ + const remote: BotTemplateRemote | undefined = + runtime === "remote" + ? { authHeader: headerName, requiresKey: hasAuth } + : undefined; + + const skills = packSkills(input.skills); + const requests = packRequests(input); + + const slug = deriveSlug(name); + const storedSeed = input.profile.avatarSeed.normalize("NFC").trim(); + /* + * `create` sets a new Bot's avatar seed to its own agent id (`profile-store.ts:343`), which is both + * an id and a string the format's slug rule refuses. Where the stored seed is a usable style token + * it travels unchanged, so a Bot keeps its face; where it is an id, the slug stands in and the + * substitution is reported rather than made silently. + */ + const seedTravels = + storedSeed.length <= TEMPLATE_LIMITS.SLUG && TEMPLATE_SLUG.test(storedSeed); + + const template: BotTemplate = { + format: BOT_TEMPLATE_FORMAT, + template: { + slug, + // The author's string, which nothing reads. A draft carries one so there is a field to edit + // rather than a key to discover. + version: "1.0", + summary: draftSummary(roleDescription, title), + /* + * No `author`, `source` or `license`. `template.author` is a claim, and the packer is not the + * one entitled to make it: filling it from the owner would put a person's name into a file that + * travels, on their behalf, because they pressed Export. A licence is a decision about somebody + * else's words. Both are keys the author adds to the draft. + */ + }, + bot: { + name, + title, + roleDescription, + avatarSeed: seedTravels ? storedSeed : slug, + runtime, + skills: skills.map((skill) => skill.slug), + remote, + }, + skills, + requests, + /* + * The strictest ceiling the vocabulary can express, every time, whatever this Bot could do here. + * + * The packer cannot know what the Bot actually used: nothing records that a coworker ever ran a + * shell command or read a file, and the action policy it ran under is one row for this whole + * deployment rather than a fact about this coworker. Deriving a boundary from what it was ALLOWED + * would export a stock deployment's `allow: ["true"]` as a coworker's requirements, and that + * permissiveness would then travel to everyone who imports the file. So the draft says the least + * the format can say, and widening it is a deliberate edit by an author who knows what the Bot + * needs. Spread rather than shared, so nothing downstream can mutate the constant every other + * template also reads. + */ + boundary: { + ...STRICT_BOUNDARY, + navigateHosts: [...STRICT_BOUNDARY.navigateHosts], + }, + }; + + /* + * The draft has to be a document this same deployment could import, checked by running the byte + * refusals over it rather than by arguing that it is. A role description somebody typed `${` into + * is otherwise exported cleanly and refused on every deployment it reaches, including this one, and + * the author hears about it from a stranger. + * + * Before the secret scan, deliberately: an invisible codepoint sitting inside a key is exactly what + * would carry a credential past a scanner reading it as text. + */ + refuseHostileBytes(serializeBotTemplate(template)); + + /* + * The scan is not the caller's to remember. A warning at pack time is a warning an author clicks + * through, and an export route that forgot to call the scanner would ship keys with no error at + * all; calling it here means the only way to get a template out of this module is to get one that + * has been scanned. It stays exported because a draft edited by hand afterwards has to be scanned + * again. + */ + refuseSecrets(template); + + return { + template, + stripped: strippedFrom(input, endpoint, hasAuth, !seedTravels), + }; +} + +/** A template that carries something shaped like a credential, and the field it is in. */ +export class SecretInTemplateError extends Error { + readonly field: string; + constructor(field: string, message: string) { + super(message); + this.name = "SecretInTemplateError"; + this.field = field; + } +} + +/** + * The shapes a credential takes, each recognised by its issuer's own prefix. + * + * Prefixes are anchored against a preceding alphanumeric so that ordinary prose is not a match: the + * three characters that open an OpenAI key also sit in the middle of "task-management-system", and a + * scanner that refused that would be a scanner authors learn to work around. + */ +const SECRET_SHAPES: { what: string; pattern: RegExp }[] = [ + { + what: "an API key of the sk- family", + pattern: /(? `_${letter.toLowerCase()}`); +} + +/** + * Every string in the document, with the path it is written at. + * + * Walked structurally rather than field by field, so a key added to the format later is scanned + * because it is there rather than because somebody remembered to add it to a list. A scanner that + * fails open on a new field is the failure this shape exists to prevent. + */ +function* everyString( + value: unknown, + path: string, +): Generator<[string, string]> { + if (typeof value === "string") { + yield [path, value]; + return; + } + if (Array.isArray(value)) { + for (const [index, entry] of value.entries()) { + yield* everyString(entry, `${path}[${index}]`); + } + return; + } + if (value && typeof value === "object") { + for (const [key, entry] of Object.entries(value)) { + const name = fileKey(key); + yield* everyString(entry, path ? `${path}.${name}` : name); + } + } +} + +/** + * Refuse a template that carries something shaped like a credential. + * + * REFUSES RATHER THAN WARNS. A warning on an export screen is a sentence between an author and the + * thing they are trying to do, and it is clicked through; a key that reaches a file reaches everyone + * the file reaches, and there is no taking it back once it is in somebody's paste buffer. A false + * positive costs an author one edit, which is the trade being made deliberately. + * + * The value is never repeated in the message. The refusal is rendered on a screen, put in a log and + * carried by an audit row, and a scanner that quoted the secret it found would put the secret in all + * three. + */ +export function refuseSecrets(template: BotTemplate): void { + for (const [field, value] of everyString(template, "")) { + const shape = secretShapeIn(value); + if (shape) { + throw new SecretInTemplateError( + field, + `${field} carries something shaped like ${shape}, so this Bot was not packed. A template is a file that travels, and nothing in it is ever a secret. Take the value out of the Bot and export again. It is deliberately not quoted here, or this refusal would be the next place it leaks.`, + ); + } + } +} diff --git a/server/src/templates/resolve.ts b/server/src/templates/resolve.ts new file mode 100644 index 00000000..37d3fa77 --- /dev/null +++ b/server/src/templates/resolve.ts @@ -0,0 +1,409 @@ +/** + * The plan a person consents to, worked out against this deployment and WRITING NOTHING. + * + * Parsing answers what a template says. Resolving answers what would happen if it were installed + * here, which is a different question and the only one worth putting on a consent screen: the same + * file lands as a working coworker on a deployment that has Drive connected and as a cold one on a + * deployment that does not, and the person clicking the button is entitled to know which they are + * about to get. + * + * NOTHING HERE IS A GRANT AND NOTHING HERE IS A WRITE. `available` means an `mcp_servers` row and an + * `mcp_tools` row both exist, which is a statement about the deployment and not about this Bot — it + * is still rendered as a request, there is still no checkbox, and satisfying it is still a separate + * act on a screen that already refuses. The verdict exists so the screen can say "Drive is connected + * here, an administrator can grant this in one click" rather than making the importer discover that + * for themselves. + * + * The install path re-runs this on its own transaction rather than trusting the plan it was handed. + * A preview is a screen a person read; it is not evidence about the database a second later. + */ +import { inArray } from "drizzle-orm"; +import type { BotTemplate } from "../../../shared/bot-template"; +import { + components, + mcpServers, + mcpTools, + skills, + skillTools, +} from "../db/schema"; +import type { TemplateReadExecutor } from "./store"; + +/** + * The Skills API's slug rule, restated. + * + * `shared/bot-template.ts` does not export its regex, so this is a second copy of a rule and + * therefore a place two things can drift. It is here rather than imported because a suffixed slug + * has to satisfy the format as well as the database — a suffix that produced `renewal-desk-` would + * install cleanly and then be permanently uneditable through the product, which is the exact bug the + * format's stricter rule exists to prevent. The integration test for suffixing puts the slug it + * chose back through `parseBotTemplate`, so the two copies cannot silently disagree. + */ +const TEMPLATE_SLUG = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/; + +/** The format's own ceiling on a slug. Restated for the same reason and pinned by the same test. */ +const SLUG_LIMIT = 40; + +/** + * How far a suffix search goes before it gives up and says skip. + * + * Twenty, which is far past any real deployment and short enough that a pathological template cannot + * turn one install into a hundred index probes per skill. Running out is not an error: it lands on + * `skip`, which is a resolution the screen can already render and the importer can already see. + */ +export const MAX_SUFFIX = 20; + +/** What happens to a skill slug this deployment has already given to somebody. */ +export type SlugResolution = "reuse" | "suffix" | "skip"; + +export type ConnectorToolVerdict = "available" | "unavailable"; + +export type ResolvedTool = { + ref: string; + /** The author's sentence. A stranger's prose, rendered as such. */ + why: string; + verdict: ConnectorToolVerdict; +}; + +export type ResolvedConnector = { + id: string; + why: string; + /** `available` only when this deployment has an `mcp_servers` row for it. */ + verdict: ConnectorToolVerdict; + tools: ResolvedTool[]; +}; + +export type ResolvedComponent = { + name: string; + why: string; + /** `not_in_build` when this build ships no component by that name. No row is created either way. */ + verdict: "available" | "not_in_build"; + /** + * Whether the component is published, when it exists at all. + * + * Reported rather than folded into the verdict. An unpublished component is never offered to a + * model, so a template asking for one is asking for something inert today — but it is in the + * build, and telling the importer "this build has no such component" would be false. + */ + published: boolean; +}; + +export type ResolvedSkill = { + /** The slug the template names. */ + slug: string; + title: string; + /** Whether this deployment already has a skill by that slug. */ + collides: boolean; + /** + * Whether the skill already here is byte-identical: the same instructions AND the same declared + * tools. Title and summary are deliberately not compared — they are how a skill is listed, not + * what it does, and a difference in either is not a reason to fork somebody's `/` command. + */ + identical: boolean; + /** What will happen unless the importer says otherwise. */ + resolution: SlugResolution; + /** + * The slug that would actually be written, or null when nothing will be. + * + * For `reuse` this is the colliding slug and no skill is written at all — the existing one is + * paired to the Bot. For `suffix` it is the first free `slug-2`, `slug-3`, … that still satisfies + * the format's rule. For `skip` it is null and the Bot arrives without that skill. + */ + installAs: string | null; + /** Free suffixes this deployment has, in order, so the screen can offer the radio a real value. */ + suffixCandidate: string | null; + /** Whether the template's own Bot is paired to this skill, or it is merely defined in the file. */ + paired: boolean; +}; + +export type ResolvedEndpoint = { + /** Whether the importer has to type an address for this coworker to exist at all. */ + required: boolean; + /** + * Why a slot is being shown. + * + * `remote` is the ordinary case. `no_managed_agent` is the one that matters: `store.create` throws + * `ManagedAgentUnavailableError` when there is neither an endpoint nor a managed agent, and the + * recommended one-container image carries no managed agent, so routing `runtime: managed` straight + * through `create` would 400 on the default install after a preview that reported nothing to + * rebind. That is the same coupling that makes `duplicate` unusable on that image today, and the + * import path must not inherit it. + */ + reason: "remote" | "no_managed_agent" | null; + /** The author says the importer will be asked for a key. A claim, not a capability. */ + requiresKey: boolean; + /** The header NAME the author uses, if any. A header name is not a secret. */ + authHeader?: string; + /** Documentation. Never dialled by anything, here or anywhere. */ + exampleUrl?: string; + /** Where the author says conversations go, for the screen to compare against what is typed. */ + sendsConversationTo?: string; +}; + +export type TemplatePlan = { + /** What a preview and an install agree they are talking about. */ + digest: string; + connectors: ResolvedConnector[]; + components: ResolvedComponent[]; + skills: ResolvedSkill[]; + endpoint: ResolvedEndpoint; + /** + * The defaults, keyed by the slug the template names, ready to be handed straight back to + * `installBotTemplate` as `slugDecisions`. A screen that changes one radio changes one entry. + */ + slugDecisions: Record; +}; + +/** + * `slug-2`, `slug-3`, … and still a slug the product can save. + * + * The base is trimmed rather than the suffix dropped when the two together would pass forty + * characters, and the trim re-cuts a trailing hyphen, because `renewal-desk-…-` fails the format's + * rule and a skill that fails it installs and is then uneditable through every screen. + */ +export function suffixedSlug(base: string, index: number): string | null { + const tail = `-${index}`; + const room = SLUG_LIMIT - tail.length; + const trimmed = base.slice(0, Math.max(room, 0)).replace(/-+$/, ""); + if (!trimmed) return null; + const candidate = `${trimmed}${tail}`; + return TEMPLATE_SLUG.test(candidate) ? candidate : null; +} + +/** + * Resolve a template against this deployment. Reads only. + * + * The executor is the caller's, so an install can resolve on the transaction it is about to write + * in rather than reading a snapshot on a second pooled connection — which would both deadlock under + * a small pool and answer about a database a moment older than the one being written. + */ +export async function resolveBotTemplate( + executor: TemplateReadExecutor, + template: BotTemplate, + options: { + /** Whether this deployment has a Bot in the box. `config.managedAgent` decides. */ + managedAgent: boolean; + /** The digest the caller already computed, so a preview and an install agree on one value. */ + digest: string; + }, +): Promise { + const connectorIds = [ + ...new Set(template.requests.connectors.map((connector) => connector.id)), + ]; + const requestedRefs = [ + ...new Set( + template.requests.connectors.flatMap((connector) => + connector.tools.map((tool) => tool.ref), + ), + ), + ]; + const componentNames = [ + ...new Set(template.requests.components.map((component) => component.name)), + ]; + const templateSlugs = template.skills.map((skill) => skill.slug); + + const serverRows = + connectorIds.length === 0 + ? [] + : await executor + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(inArray(mcpServers.id, connectorIds)); + const presentServers = new Set(serverRows.map((row) => row.id)); + + /* + * Narrowed to the servers actually named rather than reading the catalogue and filtering here, the + * same shape `knownToolRefs` uses and for the same reason: a deployment aiming at a thousand tools + * should not scan all of them to answer three. + */ + const toolServers = [ + ...new Set(requestedRefs.map((ref) => ref.split("/")[0] ?? "")), + ].filter(Boolean); + const toolRows = + toolServers.length === 0 + ? [] + : await executor + .select({ serverId: mcpTools.serverId, name: mcpTools.name }) + .from(mcpTools) + .where(inArray(mcpTools.serverId, toolServers)); + const presentTools = new Set( + toolRows.map((row) => `${row.serverId}/${row.name}`), + ); + + const componentRows = + componentNames.length === 0 + ? [] + : await executor + .select({ name: components.name, published: components.published }) + .from(components) + .where(inArray(components.name, componentNames)); + const presentComponents = new Map( + componentRows.map((row) => [row.name, row.published]), + ); + + /* + * Every slug this deployment already has among the ones the template names, with what each one + * says. Read together rather than one query per skill, and read here rather than trusted from a + * preview, because the whole point of the comparison is that it is about the database as it is. + */ + const existingRows = + templateSlugs.length === 0 + ? [] + : await executor + .select({ + slug: skills.slug, + instructions: skills.instructions, + }) + .from(skills) + .where(inArray(skills.slug, templateSlugs)); + const existing = new Map( + existingRows.map((row) => [row.slug, row.instructions]), + ); + const existingToolRows = + existing.size === 0 + ? [] + : await executor + .select({ skillId: skillTools.skillId, ref: skillTools.ref }) + .from(skillTools) + .where(inArray(skillTools.skillId, [...existing.keys()])); + const existingTools = new Map(); + for (const row of existingToolRows) { + existingTools.set(row.skillId, [ + ...(existingTools.get(row.skillId) ?? []), + row.ref, + ]); + } + + /* + * Every slug the deployment holds, not only the ones the template names, because a suffix search + * probes names the template never mentioned. Read once for the whole plan, and only when something + * actually collided — the ordinary import collides with nothing and should not read the table. + */ + const allSlugRows = + existing.size === 0 + ? [] + : await executor.select({ slug: skills.slug }).from(skills); + /* + * A working set rather than a fixed one. Two skills in the same template can suffix into the same + * name — `desk` and `desk-2` both colliding gives `desk-2` twice — so each choice is added as it is + * made, and the second skill walks past it. + */ + const taken = new Set(allSlugRows.map((row) => row.slug)); + + const paired = new Set(template.bot.skills); + const resolvedSkills: ResolvedSkill[] = []; + const slugDecisions: Record = {}; + + for (const skill of template.skills) { + const collides = existing.has(skill.slug); + const identical = + collides && + existing.get(skill.slug) === skill.instructions && + sameRefs(existingTools.get(skill.slug) ?? [], skill.tools); + + let suffixCandidate: string | null = null; + if (collides) { + for (let index = 2; index <= MAX_SUFFIX; index += 1) { + const candidate = suffixedSlug(skill.slug, index); + if (candidate && !taken.has(candidate)) { + suffixCandidate = candidate; + break; + } + } + } + + const resolution: SlugResolution = !collides + ? "suffix" + : identical + ? "reuse" + : suffixCandidate + ? "suffix" + : "skip"; + + /* + * `suffix` is also what a slug nobody has taken resolves to, which reads oddly for a moment and + * is the right shape: the resolution names what the installer does with the name, and for a free + * name that is "write it as it stands". `installAs` is the value that matters, and a screen only + * offers the radio when `collides` is true. + */ + const installAs = + resolution === "reuse" + ? skill.slug + : resolution === "skip" + ? null + : collides + ? suffixCandidate + : skill.slug; + if (installAs) taken.add(installAs); + + resolvedSkills.push({ + slug: skill.slug, + title: skill.title, + collides, + identical, + resolution, + installAs, + suffixCandidate, + paired: paired.has(skill.slug), + }); + slugDecisions[skill.slug] = resolution; + } + + const remote = template.bot.remote; + const endpointRequired = + template.bot.runtime === "remote" || !options.managedAgent; + + return { + digest: options.digest, + connectors: template.requests.connectors.map((connector) => ({ + id: connector.id, + why: connector.why, + verdict: presentServers.has(connector.id) + ? ("available" as const) + : ("unavailable" as const), + tools: connector.tools.map((tool) => ({ + ref: tool.ref, + why: tool.why, + /* + * Both rows, not either. A server that is connected but has never been refreshed advertises + * no tools, and reporting its refs as available would tell the importer a grant is one click + * away when the grant screen has nothing to list. + */ + verdict: + presentServers.has(connector.id) && presentTools.has(tool.ref) + ? ("available" as const) + : ("unavailable" as const), + })), + })), + components: template.requests.components.map((component) => ({ + name: component.name, + why: component.why, + verdict: presentComponents.has(component.name) + ? ("available" as const) + : ("not_in_build" as const), + published: presentComponents.get(component.name) ?? false, + })), + skills: resolvedSkills, + endpoint: { + required: endpointRequired, + reason: !endpointRequired + ? null + : template.bot.runtime === "remote" + ? ("remote" as const) + : ("no_managed_agent" as const), + requiresKey: remote?.requiresKey ?? false, + ...(remote?.authHeader ? { authHeader: remote.authHeader } : {}), + ...(remote?.exampleUrl ? { exampleUrl: remote.exampleUrl } : {}), + ...(remote?.sendsConversationTo + ? { sendsConversationTo: remote.sendsConversationTo } + : {}), + }, + slugDecisions, + }; +} + +/** Two declaration sets are the same set, regardless of the order either was written in. */ +function sameRefs(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) return false; + const held = new Set(left); + return right.every((ref) => held.has(ref)); +} diff --git a/server/src/templates/routes.ts b/server/src/templates/routes.ts new file mode 100644 index 00000000..b27cb17a --- /dev/null +++ b/server/src/templates/routes.ts @@ -0,0 +1,992 @@ +/** + * The HTTP surface for templates: authoring a draft, reading a stranger's file, and installing it. + * + * THIS FILE DECIDES ALMOST NOTHING. The parser refuses a document, the packer refuses a coworker it + * cannot express, the resolver reports what this deployment can satisfy and the installer writes the + * one transaction. What is left here is the part that is genuinely the API's: who may ask, what an + * error becomes on the wire, and which refusals reach the trail. Everything else is delegated, and + * deliberately so — a route that re-implemented one of those rules would be a second copy of it that + * drifts. + * + * ONE RULE THIS FILE DOES OWN, and it is the whole feature's: satisfying a capability goes through + * the grant stores that already refuse. The grant route below acts on the LEDGER and hands the + * decision to `pluginStore.grant` or `componentStore.grant`; it never re-reads the document, so the + * artifact a person consented to cannot change what is being approved a week later, and there is no + * second grant path with a second set of checks. + * + * WHICH MEANS THIS FILE HOLDS THE ONLY `grant("mcp", …)` UNDER `server/src/templates/`, and anybody + * writing the grep test that guards the import path has to know that. The property is about the + * IMPORT: `install.ts` has no code path that writes an MCP grant, not a conditional one and not one + * behind a flag, because `store.grant` performs no existence check and an optimistic row for an + * absent connector would be invisible on every screen and would go live the day somebody added that + * connector, with nobody deciding. The call on line ~610 is the opposite of that in every respect: + * it is behind `requireAdmin`, it acts on a ledger row a person already consented to, it names a + * tool that exists, and it is exactly the act the grant screen performs. Scope the grep to the + * import path rather than to the directory, or it will forbid the thing the feature is for. + */ +import { eq } from "drizzle-orm"; +import type { Context, MiddlewareHandler } from "hono"; +import { Hono } from "hono"; +import { + type BotTemplate, + botTemplateDigest, + parseBotTemplate, + serializeBotTemplate, + TemplateRefusedError, +} from "../../../shared/bot-template"; +import { authFromConfiguration } from "../agents/auth-header"; +import type { BotAccessCheck } from "../agents/profile-policy"; +import type { AgentActor, AgentProfile } from "../agents/profile-types"; +import { + type AuditEventType, + type AuditStore, + recordAuditEvent, +} from "../audit"; +import { type AppVariables, requireAdmin } from "../auth/guards"; +import type { ComponentStore } from "../components/store"; +import { agents } from "../db/schema"; +import type { PluginStore } from "../plugins/store"; +import { + type TemplateActor, + TemplateDigestMovedError, + TemplateEndpointRefusedError, + TemplateEndpointRequiredError, + TemplateImportNotFoundError, + type TemplateInstaller, + TemplateRetractionRefusedError, + TemplateSlugDecisionError, + TemplateSlugUnavailableError, + TemplateVaultUnavailableError, +} from "./install"; +import { packBotTemplate, refuseSecrets, SecretInTemplateError } from "./pack"; +import { resolveBotTemplate, type SlugResolution } from "./resolve"; +import { + type TemplateDraft, + type TemplateImportSource, + TemplateNotFoundError, + type TemplateReadExecutor, + type TemplateRequestKind, + TemplateSlugTakenError, + type TemplateStore, +} from "./store"; + +/** + * The local development actor, which is not a row in `users`. + * + * The audit table has a foreign key to that table, so writing this id would fail the constraint and + * lose the row entirely. Who it was is in the payload either way — the convention + * `agents/routes.ts:123-129` already follows, restated here rather than imported because importing + * it would make this module depend on the agents API for a constant. + */ +const DEV_ACTOR_EMAIL = "dev@openbot.local"; + +/** + * Why a document was turned away, in the machine-readable half. + * + * The parser's own `TemplateRefusal` codes travel through unchanged; the rest are added here because + * they name refusals that happen AFTER a document parsed cleanly. That distinction is the reason + * `template.import_refused` carries a `digest` at all: a file refused by the parser never got as far + * as being hashed, and one refused for an address this deployment will not dial did. + */ +type RefusalCode = + | "secret_shape" + | "digest_moved" + | "endpoint_required" + | "endpoint_refused" + | "vault_unavailable" + | "slug_decision" + | "slug_unavailable"; + +/** What a template surface needs that it cannot build for itself. */ +export type TemplateRoutesDeps = { + templateStore: TemplateStore; + installer: TemplateInstaller; + auditStore: AuditStore; + /** + * A read handle for the resolver, which is a pure function over the deployment's own tables. + * + * The install path resolves again on its own transaction; this one is the preview, which writes + * nothing and may read on the pool. + */ + executor: TemplateReadExecutor; + /** + * Whether this deployment has a Bot in the box. + * + * A boolean rather than the URL, because the only question the plan asks is whether a coworker + * with `runtime: managed` has anywhere to run. The address itself is the installer's business. + */ + managedAgent: boolean; + /** + * The existing MCP grant path, and the only one this file will use. + * + * `Pick<…, "grant">` rather than the whole store, so this module cannot grow a second way to + * write a permission by reaching for a method that happens to be in scope. Absent on a deployment + * with no plugin store, which is a deployment where an MCP ask cannot be satisfied at all — said + * plainly rather than recorded as decided. + */ + grants?: Pick; + /** The existing component path, on the same terms and for the same reason. */ + components?: Pick; +}; + +/** + * Packing one coworker into a draft, as one act with its trail. + * + * A seam rather than a route, because the export lives in `createAgentRoutes` — it is a thing done + * to a Bot, beside Duplicate, and giving it its own mount would put the same authorization question + * in two files. `createAgentRoutes` asks whether this person may manage this Bot and then calls + * this; everything below the question is here, where the rest of the template code is. + */ +export type TemplateExport = { + /** + * Pack, store the draft, and record `template.exported`. + * + * Throws `TemplateRefusedError` when the coworker cannot be expressed in the format, + * `SecretInTemplateError` when its prose carries something shaped like a credential, and + * `TemplateSlugTakenError` when this person already has a draft by that name. + */ + exportAgent( + actor: TemplateActor, + profile: AgentProfile, + ): Promise; +}; + +export type ExportedTemplate = { + templateId: string; + /** The file itself, so the author can read what left the building before anything else does. */ + yaml: string; + digest: string; + /** What was left behind, in sentences. The interesting half of an export. */ + stripped: string[]; +}; + +export type TemplateExportDeps = { + executor: TemplateReadExecutor; + templateStore: TemplateStore; + auditStore: AuditStore; + /** + * What this Bot holds, read to derive the ASK and never to make one. + * + * `listForAgent` rather than the grant rows, deliberately: it answers with the skills in full — + * slug, title, summary, instructions and declarations — which is exactly what travels, and it + * reads the MCP grants against the live tool list. The cost of that second half is worth naming: a + * grant for a tool the vendor has stopped advertising does not become a request, so a template + * packed while a connector was misbehaving asks for less than the Bot was given. Under-asking is + * the safe direction, and the author edits the draft anyway. + */ + plugins?: Pick; + components?: Pick; + /** + * This deployment's own AG-UI address, when it has a Bot in the box. + * + * The packer needs it to tell `managed` from `remote`: `create` writes the deployment's own + * address into `configuration.endpoint` for a coworker that runs here, so having an endpoint is + * not what distinguishes the two. + */ + managedAgentAgUiUrl?: URL; +}; + +export function createTemplateExport(deps: TemplateExportDeps): TemplateExport { + return { + async exportAgent(actor, profile) { + /* + * The configuration row, read straight rather than through the profile store. + * + * `AgentProfile` deliberately does not carry it: it holds the endpoint and the vault pointer, + * and neither is something every screen that lists coworkers should be handed. The packer + * needs both — to decide the runtime and to name the auth header — and neither travels. + */ + const [row] = await deps.executor + .select({ configuration: agents.configuration }) + .from(agents) + .where(eq(agents.id, profile.id)) + .limit(1); + const configuration = isRecord(row?.configuration) + ? row.configuration + : {}; + + const granted = deps.plugins + ? await deps.plugins.listForAgent(profile.id) + : { tools: [], skills: [] }; + const components = deps.components + ? (await deps.components.listForAgent(profile.id)).map( + (component) => component.name, + ) + : []; + const auth = authFromConfiguration(configuration); + + const packed = packBotTemplate({ + profile, + configuration, + skills: granted.skills.map((skill) => ({ + slug: skill.slug, + title: skill.title, + summary: skill.summary, + instructions: skill.instructions, + tools: skill.tools, + })), + grants: granted.tools.map((tool) => ({ ref: tool.ref })), + components, + // The header NAME, which `auth-header.ts` already keeps unencrypted because it is not a + // secret. The value lives in the vault and is not readable from here at all. + ...(auth ? { authHeaderName: auth.header } : {}), + ...(deps.managedAgentAgUiUrl + ? { managedEndpoint: deps.managedAgentAgUiUrl.toString() } + : {}), + }); + + const draft = await deps.templateStore.createDraft(actor, { + agentId: profile.id, + document: packed.template, + }); + const yaml = serializeBotTemplate(packed.template); + const digest = await botTemplateDigest(packed.template); + + /* + * NEVER THE PROSE. `stripped` is a list of sentences this repository wrote about fields, the + * skills are slugs and the requests are connector ids and component names — none of which is + * anybody's text. The role description and the skill instructions are the substance of a + * template and they are not in the trail; a reader who wants them reads the document. + * + * `redactAuditPayload` would not have saved us here. It is a key-NAME filter and knows nothing + * about a field called `summary`, so the rule is kept at the point the payload is built. + */ + await recordTemplateEvent(deps.auditStore, actor, { + eventType: "template.exported", + targetType: "agent", + targetId: profile.id, + payload: { + templateSlug: packed.template.template.slug, + digest, + stripped: packed.stripped, + skills: packed.template.skills.map((skill) => skill.slug), + requests: { + connectors: packed.template.requests.connectors.map( + (connector) => connector.id, + ), + components: packed.template.requests.components.map( + (component) => component.name, + ), + }, + }, + }); + + return { templateId: draft.id, yaml, digest, stripped: packed.stripped }; + }, + }; +} + +export function createTemplateRoutes( + deps: TemplateRoutesDeps, + requireUser: MiddlewareHandler<{ Variables: AppVariables }>, + /** + * Whether the caller may act as the Bot they named. Required rather than optional, the same shape + * `createPluginRoutes` takes it in, so a deployment cannot end up reading somebody else's + * coworker's provenance by leaving an argument off. + */ + canUseBot: BotAccessCheck, +) { + const routes = new Hono<{ Variables: AppVariables }>(); + const { templateStore, installer } = deps; + + const actorEmail = (context: Context<{ Variables: AppVariables }>) => + context.var.actor.email ?? "unknown"; + + /** + * A document this deployment would not take, on the trail and on the wire. + * + * Both, in one place, because they have to agree: the person is shown the sentence and the reader + * of the trail is shown the code, and a route that wrote one without the other would produce + * refusals nobody can count or refusals nobody can read. + * + * Never the document and never a line of it. What went in is the reason, and the digest and slug + * when the document got far enough to have them. + */ + const refuse = async ( + context: Context<{ Variables: AppVariables }>, + error: unknown, + known: { digest?: string; slug?: string } = {}, + ): Promise => { + const refusal = refusalFor(error); + if (!refusal) throw error; + await recordTemplateEvent(deps.auditStore, context.var.actor, { + eventType: "template.import_refused", + targetType: "template", + ...(known.digest ? { targetId: known.digest } : {}), + payload: { + reason: refusal.reason, + ...(known.digest ? { digest: known.digest } : {}), + ...(known.slug ? { slug: known.slug } : {}), + ...(refusal.field ? { field: refusal.field } : {}), + }, + }); + return context.json( + { + error: refusal.message, + reason: refusal.reason, + ...(refusal.field ? { field: refusal.field } : {}), + }, + 400, + ); + }; + + /** Your drafts. An administrator sees the deployment's, which is what the store already decides. */ + routes.get("/", requireUser, async (context) => { + const drafts = await templateStore.listDrafts(context.var.actor); + return context.json({ + templates: drafts.map((draft) => draftDto(context.var.actor, draft)), + }); + }); + + /** + * What a file would do here. Writes nothing, and on success records nothing. + * + * A preview that left a row would make reading a template indistinguishable from installing one, + * and the point of the consent screen is that a person can read a stranger's file without having + * agreed to anything yet. A REFUSAL is recorded, because a refusal leaves no other trace anywhere + * in the product and the interesting case is the repeated one. + */ + routes.post("/preview", requireUser, async (context) => { + const body = (await context.req.json().catch(() => null)) as { + source?: unknown; + } | null; + const source = typeof body?.source === "string" ? body.source : ""; + if (!source.trim()) { + return context.json({ error: "Paste a template file." }, 400); + } + + let template: BotTemplate; + try { + template = parseBotTemplate(source); + } catch (error) { + return refuse(context, error); + } + + const digest = await botTemplateDigest(template); + const plan = await resolveBotTemplate(deps.executor, template, { + managedAgent: deps.managedAgent, + digest, + }); + /* + * The parsed document goes back, not the text that was posted. The consent screen renders every + * word of it, and what it must render is what the parser accepted rather than what the browser + * happens to be holding — those are the same today only because the parser refused everything + * that would have made them differ. + */ + return context.json({ template, digest, plan }); + }); + + routes.post("/install", requireUser, async (context) => { + const body = (await context.req.json().catch(() => null)) as { + source?: unknown; + digest?: unknown; + from?: unknown; + sourceRef?: unknown; + endpoint?: unknown; + auth?: unknown; + slugDecisions?: unknown; + } | null; + + const source = typeof body?.source === "string" ? body.source : ""; + const digest = typeof body?.digest === "string" ? body.digest.trim() : ""; + if (!source.trim() || !digest) { + return context.json( + { error: "A template and the digest you were shown are required." }, + 400, + ); + } + + const auth = readAuth(body?.auth); + if (auth === "invalid") { + return context.json({ error: "That is not a valid header name." }, 400); + } + const slugDecisions = readSlugDecisions(body?.slugDecisions); + if (slugDecisions === "invalid") { + return context.json( + { error: "A skill is reused, suffixed or skipped." }, + 400, + ); + } + + let template: BotTemplate; + try { + template = parseBotTemplate(source); + } catch (error) { + return refuse(context, error); + } + + /* + * Recomputed here as well as inside the installer, so a refusal after a clean parse can say + * WHICH document was turned away. The installer refuses on its own value either way; this one + * exists for the trail. + */ + const actual = await botTemplateDigest(template); + try { + const result = await installer.installBotTemplate({ + template, + digest, + actor: context.var.actor, + source: readSource(body?.from), + ...(typeof body?.sourceRef === "string" && body.sourceRef.trim() + ? { sourceRef: body.sourceRef.trim() } + : {}), + ...(typeof body?.endpoint === "string" && body.endpoint.trim() + ? { endpoint: body.endpoint.trim() } + : {}), + ...(auth ? { auth } : {}), + ...(slugDecisions ? { slugDecisions } : {}), + }); + /* + * The provenance row's `document` is deliberately not echoed. The caller posted it a moment + * ago and the consent screen is still holding it; sending a stranger's whole file back as the + * receipt for having installed it is a second copy of the largest thing in the exchange. + */ + return context.json( + { + agentId: result.agentId, + importId: result.imported.id, + slug: result.imported.slug, + digest: result.imported.digest, + requests: result.ledger, + plan: result.plan, + skillsCreated: result.skillsCreated, + skillsReused: result.skillsReused, + skillsSuffixed: result.skillsSuffixed, + skillsSkipped: result.skillsSkipped, + }, + 201, + ); + } catch (error) { + if (error instanceof TemplateDigestMovedError) { + /* + * 409 rather than 400, and the distinction is the point. Nothing is wrong with the document; + * what is wrong is that it is not the document the person read. A 400 would have the screen + * tell them their file is malformed, and they would go and look at the wrong thing. + */ + await recordTemplateEvent(deps.auditStore, context.var.actor, { + eventType: "template.import_refused", + targetType: "template", + targetId: error.actual, + payload: { + reason: "digest_moved", + digest: error.actual, + expected: error.expected, + slug: template.template.slug, + }, + }); + return context.json( + { + error: error.message, + reason: "digest_moved", + digest: error.actual, + }, + 409, + ); + } + if (error instanceof TemplateSlugDecisionError) { + await recordTemplateEvent(deps.auditStore, context.var.actor, { + eventType: "template.import_refused", + targetType: "template", + targetId: actual, + payload: { + reason: "slug_decision", + digest: actual, + slug: template.template.slug, + }, + }); + return context.json( + { error: error.message, reason: "slug_decision", slug: error.slug }, + 409, + ); + } + return refuse(context, error, { + digest: actual, + slug: template.template.slug, + }); + } + }); + + /** + * Where this Bot came from, and what it asked for. + * + * 404 rather than 403 for a coworker somebody may not see, matching `GET /api/plugins/for/:agentId` + * exactly: a distinguishable "you may not" is an oracle for other people's private Bots, and a + * provenance row would tell a stranger which template somebody imported and what it wanted. + */ + routes.get("/imports/:agentId", requireUser, async (context) => { + const agentId = context.req.param("agentId"); + if (!(await canUseBot(context.var.actor, agentId))) { + return context.json({ error: "There is no such Bot." }, 404); + } + const imported = await templateStore.importForAgent(agentId); + if (!imported) { + return context.json( + { error: "This Bot did not come from a template." }, + 404, + ); + } + return context.json({ + import: imported, + requests: await templateStore.listRequests(imported.id), + boundaries: await templateStore.boundariesFor(imported.id), + }); + }); + + /** + * An administrator answering one ask, through the grant store that already refuses. + * + * `ref` arrives percent-encoded, because an MCP ref is `/` and a slash is a + * path separator. Hono decodes the parameter, so what arrives here is the ref exactly as the + * ledger stores it. + */ + const decide = (verdict: "granted" | "declined") => + async function decision(context: Context<{ Variables: AppVariables }>) { + const denied = requireAdmin(context); + if (denied) return denied; + + /* + * All three read as optional, because this handler is written out rather than declared inline + * and Hono only infers a path's parameters at the call that registers it. Checked rather than + * asserted: a non-null assertion here would be a claim about a router this file does not own. + */ + const agentId = context.req.param("agentId"); + const kind = asRequestKind(context.req.param("kind")); + const ref = context.req.param("ref"); + if (!agentId || !kind || !ref) { + return context.json( + { error: "A Bot, a kind and a ref are required." }, + 400, + ); + } + + const imported = await templateStore.importForAgent(agentId); + if (!imported) { + return context.json( + { error: "This Bot did not come from a template." }, + 404, + ); + } + const ledger = await templateStore.listRequests(imported.id); + const row = ledger.find( + (entry) => entry.kind === kind && entry.ref === ref, + ); + if (!row) { + return context.json( + { error: "This template did not ask for that." }, + 404, + ); + } + + /* + * The address is not a grant. It was answered on the way in by whoever typed it, and the row + * exists so the profile's amber list does not show a slot that is filled. There is nothing + * here for an administrator to approve or refuse; repointing a coworker is an edit of the Bot. + */ + if (kind === "endpoint") { + return context.json( + { + error: + "The address this coworker runs at was answered by whoever imported it. Change it by editing the Bot.", + }, + 400, + ); + } + + if (verdict === "granted") { + if (kind === "mcp") { + /* + * A bare connector id is an ask with nothing grantable behind it — the template named a + * connector that listed no tools — and the answer to it is adding the connector, not + * writing a grant. `store.grant` performs no existence check, so a row written here would + * be invisible on every screen and would go live the day somebody added that connector. + */ + if (!ref.includes("/")) { + return context.json( + { + error: `${ref} is a connector, not a tool. Add it on the Plugins page, then grant the tools this Bot needs.`, + }, + 400, + ); + } + if (!deps.grants) { + return context.json( + { + error: + "This deployment cannot reach its grant table, so nothing can be granted.", + }, + 503, + ); + } + await deps.grants.grant("mcp", ref, agentId, actorEmail(context)); + } else { + if (!deps.components) { + return context.json( + { + error: + "This deployment has no component store, so nothing can be granted.", + }, + 503, + ); + } + await deps.components.grant(ref, agentId); + } + } + + const decided = await templateStore.decideRequest({ + importId: imported.id, + kind, + ref, + status: verdict, + decidedBy: actorEmail(context), + }); + if (!decided) { + return context.json( + { error: "This template did not ask for that." }, + 404, + ); + } + + /* + * Recorded against the Bot, and the author's `why` is deliberately not in it. That sentence is + * a stranger's prose; it lives in the ledger, which is where it is rendered as one. + */ + await recordTemplateEvent(deps.auditStore, context.var.actor, { + eventType: + verdict === "granted" + ? "template.capability_granted" + : "template.capability_declined", + targetType: "agent", + targetId: agentId, + payload: { bot: agentId, importId: imported.id, kind, ref }, + }); + + return context.json({ request: decided }); + }; + + routes.post( + "/imports/:agentId/requests/:kind/:ref/grant", + requireUser, + decide("granted"), + ); + routes.post( + "/imports/:agentId/requests/:kind/:ref/decline", + requireUser, + decide("declined"), + ); + + /** + * Take back what the import gave, and nothing else. + * + * Both refusals answer 404. The owner and an administrator are the only people with any business + * here, and telling anybody else that this Bot has an import to retract is the same oracle the + * read above closes. + */ + routes.delete("/imports/:agentId", requireUser, async (context) => { + try { + const result = await installer.retractTemplateImport({ + actor: context.var.actor, + agentId: context.req.param("agentId"), + }); + return context.json(result); + } catch (error) { + if ( + error instanceof TemplateImportNotFoundError || + error instanceof TemplateRetractionRefusedError + ) { + return context.json({ error: "There is no such Bot." }, 404); + } + throw error; + } + }); + + /** + * Edit a draft, which is editing a file. + * + * The parser and the secret scanner both run again, because this is the only path by which a + * template's text changes after it was packed, and the packer's refusals are properties of the + * document rather than of the coworker it came from. A refusal here writes nothing to the trail: + * an author fixing their own draft is not an import, and filing it among the import refusals would + * teach a reader to discount the ones that are somebody pasting a stranger's file. + */ + routes.patch("/:templateId", requireUser, async (context) => { + const body = (await context.req.json().catch(() => null)) as { + source?: unknown; + } | null; + const source = typeof body?.source === "string" ? body.source : ""; + if (!source.trim()) { + return context.json({ error: "A template file is required." }, 400); + } + + let template: BotTemplate; + try { + template = parseBotTemplate(source); + refuseSecrets(template); + } catch (error) { + const refusal = refusalFor(error); + if (!refusal) throw error; + return context.json( + { + error: refusal.message, + reason: refusal.reason, + ...(refusal.field ? { field: refusal.field } : {}), + }, + 400, + ); + } + + try { + const draft = await templateStore.updateDraft( + context.var.actor, + context.req.param("templateId"), + template, + ); + return context.json({ + template: draftDto(context.var.actor, draft), + yaml: serializeBotTemplate(draft.document), + digest: await botTemplateDigest(draft.document), + }); + } catch (error) { + return mapDraftError(context, error); + } + }); + + routes.delete("/:templateId", requireUser, async (context) => { + try { + await templateStore.deleteDraft( + context.var.actor, + context.req.param("templateId"), + ); + return context.body(null, 204); + } catch (error) { + return mapDraftError(context, error); + } + }); + + /** + * The draft as the file it is. + * + * `text/yaml` and an attachment, because the thing being served is a document somebody sends to + * somebody else, not a page. The filename is built from the slug, which the parser has already + * held to `^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$` — so there is nothing in it that could break out of + * the header, and this is not the place to re-derive that rule. + */ + routes.get("/:templateId/file", requireUser, async (context) => { + try { + const draft = await templateStore.getDraft( + context.var.actor, + context.req.param("templateId"), + ); + return context.body(serializeBotTemplate(draft.document), 200, { + "content-type": "text/yaml; charset=utf-8", + "content-disposition": `attachment; filename="${draft.slug}.openbot.yaml"`, + }); + } catch (error) { + return mapDraftError(context, error); + } + }); + + return routes; +} + +/** + * A draft on the wire. + * + * The document is not in it. A list of drafts is a roster, and every entry carrying a whole template + * would make opening the page cost as much as opening every file on it; `/file` is how one is read. + * `mine` is separate from being allowed to see it, for the reason `agentDto` gives: an administrator + * sees everybody's, and a screen that split "mine" on permission would file other people's work + * under theirs. + */ +function draftDto(actor: AgentActor, draft: TemplateDraft) { + return { + id: draft.id, + agentId: draft.agentId, + slug: draft.slug, + name: draft.document.bot.name, + title: draft.document.bot.title, + summary: draft.document.template.summary, + skills: draft.document.skills.map((skill) => skill.slug), + createdAt: draft.createdAt, + updatedAt: draft.updatedAt, + mine: draft.ownerUserId === actor.id, + }; +} + +/** + * A draft somebody may not see and a name somebody already has. + * + * Not-found rather than forbidden, and that is the store's decision rather than this one's: a draft + * belonging to somebody else is answered as absent so the drafts route is not a way to enumerate + * what other people are working on. + */ +function mapDraftError( + context: Context<{ Variables: AppVariables }>, + error: unknown, +): Response { + if (error instanceof TemplateNotFoundError) { + return context.json({ error: "There is no such template." }, 404); + } + if (error instanceof TemplateSlugTakenError) { + /* + * 409 rather than overwriting. A second export of the same coworker, or an edit that renames a + * draft onto a name this person already used, would otherwise silently replace a file they had + * been editing — and the edits are the whole reason export produces a draft. + */ + return context.json({ error: error.message }, 409); + } + throw error; +} + +/** The refusal a wire response and an audit row are both built from, or nothing if this is a bug. */ +function refusalFor( + error: unknown, +): { reason: string; message: string; field?: string } | null { + if (error instanceof TemplateRefusedError) { + return { reason: error.reason, message: error.message }; + } + if (error instanceof SecretInTemplateError) { + /* + * The field, never the value. The message the scanner writes says what shape was found and + * where; it does not quote what it found, because this string is rendered, logged and audited. + */ + return { + reason: "secret_shape" satisfies RefusalCode, + message: error.message, + field: error.field, + }; + } + if (error instanceof TemplateEndpointRequiredError) { + return { + reason: "endpoint_required" satisfies RefusalCode, + message: error.message, + }; + } + if (error instanceof TemplateEndpointRefusedError) { + return { + reason: "endpoint_refused" satisfies RefusalCode, + message: error.message, + }; + } + if (error instanceof TemplateVaultUnavailableError) { + return { + reason: "vault_unavailable" satisfies RefusalCode, + message: error.message, + }; + } + if (error instanceof TemplateSlugUnavailableError) { + return { + reason: "slug_unavailable" satisfies RefusalCode, + message: error.message, + }; + } + return null; +} + +/** + * One trail row, never fatal. + * + * The act is already done and the caller has been told so, so a trail that is briefly unavailable is + * not a reason to report a failure that did not happen — the judgement `agents/routes.ts` and + * `templates/install.ts` both make. It matters here because the refusal path writes its row before + * answering: a throw would turn a 400 the person can act on into a 500 they cannot. + * + * Under `OPENBOT_SINGLE_USER` the actor is not a row in `users`, so `actorUserId` is left off and + * the identity travels in the payload instead. + */ +async function recordTemplateEvent( + auditStore: AuditStore, + actor: TemplateActor, + event: { + eventType: AuditEventType; + targetType: string; + targetId?: string; + payload: Record; + }, +): Promise { + try { + await recordAuditEvent(auditStore, { + eventType: event.eventType, + targetType: event.targetType, + ...(event.targetId ? { targetId: event.targetId } : {}), + ...(actor.id && actor.email && actor.email !== DEV_ACTOR_EMAIL + ? { actorUserId: actor.id } + : {}), + payload: { actor: actor.email ?? actor.id, ...event.payload }, + }); + } catch (error) { + console.error( + JSON.stringify({ + type: "template-audit-write-failed", + eventType: event.eventType, + error: String(error), + }), + ); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Where the file came from, as the ledger records it. + * + * Narrowed rather than trusted: the column is plain text with a documented vocabulary, and an + * unrecognised value reads as a paste, which is the shape that claims the least about provenance. + */ +function readSource(value: unknown): TemplateImportSource { + return value === "file" || value === "gallery" ? value : "paste"; +} + +const REQUEST_KINDS: readonly TemplateRequestKind[] = [ + "mcp", + "component", + "endpoint", +]; + +/** + * CHECKED AT RUNTIME, not only in the types. `kind` arrives in a path segment, so a type annotation + * on it is a comment — the same reason `asGrantKind` exists in `plugins/routes.ts`. + */ +function asRequestKind(value: string | undefined): TemplateRequestKind | null { + return REQUEST_KINDS.find((kind) => kind === value) ?? null; +} + +/** + * The key the importer typed, if they typed one. + * + * The header name is held to the same rule `parseAgentInput` holds it to, because this is a second + * door into the same column and a template's `auth_header` is a stranger's suggestion. The value is + * write-only from here on: it goes to the vault and is never read back to anybody. + */ +function readAuth( + value: unknown, +): { header: string; value: string } | undefined | "invalid" { + if (value === undefined || value === null) return undefined; + if (!isRecord(value)) return "invalid"; + const secret = typeof value.value === "string" ? value.value.trim() : ""; + if (!secret) return undefined; + const header = + typeof value.header === "string" && value.header.trim() + ? value.header.trim() + : "Authorization"; + if (!/^[A-Za-z0-9-]+$/.test(header)) return "invalid"; + return { header, value: secret }; +} + +const SLUG_RESOLUTIONS: readonly SlugResolution[] = ["reuse", "suffix", "skip"]; + +/** What the person chose about each colliding skill name, or nothing if they chose nothing. */ +function readSlugDecisions( + value: unknown, +): Record | undefined | "invalid" { + if (value === undefined || value === null) return undefined; + if (!isRecord(value)) return "invalid"; + const decisions: Record = {}; + for (const [slug, resolution] of Object.entries(value)) { + const chosen = SLUG_RESOLUTIONS.find((known) => known === resolution); + if (!chosen) return "invalid"; + decisions[slug] = chosen; + } + return decisions; +} diff --git a/server/src/templates/store.ts b/server/src/templates/store.ts new file mode 100644 index 00000000..0dc6eedb --- /dev/null +++ b/server/src/templates/store.ts @@ -0,0 +1,674 @@ +/** + * The rows a Bot template leaves behind: the drafts this deployment authored, the provenance of a + * Bot that arrived as somebody's file, and the ledger of everything that file asked for. + * + * NOTHING THIS FILE WRITES IS A PERMISSION, and that is the property to keep rather than a slogan. + * A draft is a document. An import row is a record of what somebody consented to. A ledger row is an + * ask nobody has answered. What a Bot may actually call stays `plugin_grants`, and the only grant an + * import makes is the Bot-to-skill pairing written in `install.ts` — there is deliberately no code + * path here that touches `plugin_grants` at all. + * + * Shaped as `agents/profile-store.ts` is: a factory over the database returning an object of + * methods, every write inside `database.transaction`, and an actor threaded through the reads that + * belong to somebody. The writes an import makes additionally accept an executor, because an import + * is one act across two stores — the Bot, its skills, their grants, this provenance and this ledger + * commit together or not at all. + */ +import { and, asc, desc, eq, isNull } from "drizzle-orm"; +import { + type BotTemplate, + parseBotTemplate, + serializeBotTemplate, + TemplateRefusedError, +} from "../../../shared/bot-template"; +import type { AgentActor } from "../agents/profile-types"; +import type { Database } from "../db/client"; +import { + botTemplates, + templateBoundaries, + templateImports, + templateRequests, +} from "../db/schema"; + +type Transaction = Parameters[0]>[0]; + +/** + * Where a template write runs: the pool, or a caller's open transaction. + * + * The same shape and the same reasoning as `PluginExecutor` in `plugins/store.ts`. `update` is in + * the set because a ledger decision and a retracted boundary are updates rather than inserts, and a + * caller holding a transaction must be able to make them on it. + */ +export type TemplateExecutor = + | Pick + | Pick; + +/** Reading only, for the callers that have a transaction open and must not borrow a second one. */ +export type TemplateReadExecutor = + | Pick + | Pick; + +export type TemplateDraft = { + id: string; + /** The Bot it was packed from, or null once that Bot is gone. A draft outlives its Bot. */ + agentId: string | null; + ownerUserId: string; + slug: string; + document: BotTemplate; + createdAt: Date; + updatedAt: Date; +}; + +/** How a file got here. The vocabulary grows when registered git sources land. */ +export type TemplateImportSource = "paste" | "file" | "gallery"; + +export type TemplateImportRow = { + id: string; + agentId: string; + digest: string; + slug: string; + templateVersion: string | null; + /** What the file CLAIMS. Never verified, never used to decide anything. */ + authorClaim: string | null; + source: TemplateImportSource; + sourceRef: string | null; + document: BotTemplate; + importedBy: string; + importedAt: Date; +}; + +export type TemplateRequestKind = "mcp" | "component" | "endpoint"; + +/** + * Where an ask stands. + * + * `requested` is the day-one state of everything a template asked for. `unavailable` and + * `not_in_build` say this deployment could not satisfy the ask when the plan was resolved. + * `granted` and `declined` say a person decided — and `granted` means somebody pressed the button, + * never that the grant is in force today. That question is `plugin_grants` and is asked there. + */ +export type TemplateRequestStatus = + | "requested" + | "unavailable" + | "not_in_build" + | "granted" + | "declined"; + +export type TemplateRequestRow = { + importId: string; + kind: TemplateRequestKind; + ref: string; + /** The author's sentence, stored verbatim and rendered as a stranger's prose. */ + why: string; + status: TemplateRequestStatus; + decidedBy: string | null; + decidedAt: Date | null; +}; + +/** + * A ledger row on the way in. + * + * `decidedBy` and `decidedAt` are optional rather than absent because one ask is answered by the + * import itself: the endpoint slot, which the importer filled by typing an address. Everything else + * arrives undecided, and the two columns stay null until somebody presses a button on a screen that + * already refuses. + */ +export type TemplateRequestSeed = Omit< + TemplateRequestRow, + "decidedBy" | "decidedAt" +> & { + decidedBy?: string; + decidedAt?: Date; +}; + +/** Which line of the author's closed vocabulary produced a compiled clause. */ +export type TemplateBoundarySource = + | "shell" + | "files" + | "browser" + | "navigate_hosts" + | "mcp"; + +export type TemplateBoundaryRow = { + importId: string; + agentId: string; + expression: string; + sourceKey: TemplateBoundarySource; + appliedAt: Date; + removedAt: Date | null; +}; + +/** + * A draft, an import or a ledger row this actor may not see. + * + * Not-found rather than not-permitted, deliberately, and the same call + * `GET /api/plugins/for/:agentId` makes: a template slug is chosen by its author and a refusal that + * distinguishes "not yours" from "does not exist" turns the drafts route into a way to enumerate + * what other people are working on. + */ +export class TemplateNotFoundError extends Error { + readonly templateId: string; + constructor(templateId: string) { + super(`Template ${templateId} was not found.`); + this.name = "TemplateNotFoundError"; + this.templateId = templateId; + } +} + +/** Two drafts of one name, for one person. The unique index decides; this names what it decided. */ +export class TemplateSlugTakenError extends Error { + readonly slug: string; + constructor(slug: string) { + super( + `You already have a template draft called "${slug}". Rename one of them.`, + ); + this.name = "TemplateSlugTakenError"; + this.slug = slug; + } +} + +/** The one index a draft can collide on, named once so the two writes that catch it agree. */ +const OWNER_SLUG_INDEX = "bot_templates_owner_slug_key"; + +/** + * Did this failure come from that unique index? + * + * Walked down the `cause` chain rather than matched on one message, because the driver's error is + * wrapped: drizzle raises a `DrizzleQueryError` carrying the SQL, and the PostgreSQL error naming + * the constraint is underneath it. Matching only the outer message reads the query text — which + * happens to contain the table name and not the index — so the check silently never fired and a + * person who already had a draft of that name got a 500 instead of a sentence. + * + * The constraint NAME rather than the SQLSTATE, because this table can only be collided on in one + * way today and a future second index must not be reported as the first. + */ +function isOwnerSlugCollision(error: unknown): boolean { + let current: unknown = error; + for (let depth = 0; depth < 5 && current; depth += 1) { + if (typeof current !== "object") return false; + const row = current as { constraint?: unknown; message?: unknown }; + if (row.constraint === OWNER_SLUG_INDEX) return true; + if ( + typeof row.message === "string" && + row.message.includes(OWNER_SLUG_INDEX) + ) { + return true; + } + current = (current as { cause?: unknown }).cause; + } + return false; +} + +function newTemplateId() { + return `tpl_${crypto.randomUUID()}`; +} + +/** + * A stored document, read back the same way a pasted one is read. + * + * Round-tripped through the serialiser and the parser rather than cast, because a row in a database + * is not a promise about its own shape. This one is a stranger's document that was written months + * ago by an older build, or hand-edited by whoever has `psql`, and every refusal the format makes — + * the unknown key, the `${`, the invisible codepoint, the length ceilings — is a refusal about text + * that is about to be put in front of a person as something to consent to. Casting would mean the + * parse ran once, at the boundary, and never again. + * + * The cost is a serialise and a parse per row, which is a millisecond on documents this size and is + * paid on screens that list a handful of drafts. + */ +function readStoredTemplate(value: unknown, where: string): BotTemplate { + try { + return parseBotTemplate(serializeBotTemplate(value as BotTemplate)); + } catch (error) { + if (error instanceof TemplateRefusedError) throw error; + /* + * A row that is not a template at all lands here — `serializeBotTemplate` reads through + * `template.slug` and throws a TypeError rather than a refusal. Restated as a refusal so a + * caller has one error type to map, and named so the row can be found. + */ + throw new TemplateRefusedError( + "bad_type", + `The stored document for ${where} is not a Bot template.`, + ); + } +} + +function draftFrom(row: { + id: string; + agentId: string | null; + ownerUserId: string; + slug: string; + document: unknown; + createdAt: Date; + updatedAt: Date; +}): TemplateDraft { + return { + id: row.id, + agentId: row.agentId, + ownerUserId: row.ownerUserId, + slug: row.slug, + document: readStoredTemplate(row.document, row.id), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +/** + * Whether this actor may see and change this draft. + * + * Owner or admin, which is what the API table says. Applied in the store rather than left to the + * route, because there are five routes and one of them will be added later by somebody who did not + * read this comment. + */ +function mayReach(actor: AgentActor, ownerUserId: string): boolean { + return actor.role === "admin" || ownerUserId === actor.id; +} + +export type TemplateStore = { + createDraft( + actor: AgentActor, + input: { + agentId?: string | null; + document: BotTemplate; + }, + ): Promise; + /** Yours, or the deployment's if you are an administrator. Newest first. */ + listDrafts(actor: AgentActor): Promise; + getDraft(actor: AgentActor, templateId: string): Promise; + /** Replaces the document wholesale. The caller has already re-run the parser and the scanner. */ + updateDraft( + actor: AgentActor, + templateId: string, + document: BotTemplate, + ): Promise; + deleteDraft(actor: AgentActor, templateId: string): Promise; + + recordImport( + input: { + agentId: string; + digest: string; + slug: string; + templateVersion?: string; + authorClaim?: string; + source: TemplateImportSource; + sourceRef?: string; + document: BotTemplate; + importedBy: string; + }, + executor?: TemplateExecutor, + ): Promise; + /** Where this Bot came from, or null for one somebody made here. */ + importForAgent( + agentId: string, + executor?: TemplateReadExecutor, + ): Promise; + + recordRequests( + rows: readonly TemplateRequestSeed[], + executor?: TemplateExecutor, + ): Promise; + listRequests( + importId: string, + executor?: TemplateReadExecutor, + ): Promise; + /** + * Record that a person answered one ask. Returns null when there is no such row, so a caller can + * 404 rather than report a decision nobody made. + */ + decideRequest(input: { + importId: string; + kind: TemplateRequestKind; + ref: string; + status: Extract; + decidedBy: string; + }): Promise; + + /** The clauses in force for this import. Written by the boundary phase; read by retraction. */ + boundariesFor( + importId: string, + executor?: TemplateReadExecutor, + ): Promise; + /** + * Take this import's ceiling off, softly. + * + * `removed_at` rather than a delete, because "this Bot was never bounded" and "somebody took this + * Bot's bound off" must not be the same database state. Returns the rows it retired, so the trail + * can say what stopped applying. + */ + retractBoundaries( + importId: string, + executor?: TemplateExecutor, + ): Promise; +}; + +export function createTemplateStore(database: Database): TemplateStore { + const draftProjection = { + id: botTemplates.id, + agentId: botTemplates.agentId, + ownerUserId: botTemplates.ownerUserId, + slug: botTemplates.slug, + document: botTemplates.document, + createdAt: botTemplates.createdAt, + updatedAt: botTemplates.updatedAt, + }; + + const importProjection = { + id: templateImports.id, + agentId: templateImports.agentId, + digest: templateImports.digest, + slug: templateImports.slug, + templateVersion: templateImports.templateVersion, + authorClaim: templateImports.authorClaim, + source: templateImports.source, + sourceRef: templateImports.sourceRef, + document: templateImports.document, + importedBy: templateImports.importedBy, + importedAt: templateImports.importedAt, + }; + + function importFrom(row: { + id: string; + agentId: string; + digest: string; + slug: string; + templateVersion: string | null; + authorClaim: string | null; + source: string; + sourceRef: string | null; + document: unknown; + importedBy: string; + importedAt: Date; + }): TemplateImportRow { + return { + id: row.id, + agentId: row.agentId, + digest: row.digest, + slug: row.slug, + templateVersion: row.templateVersion, + authorClaim: row.authorClaim, + /* + * The column is plain text with a documented vocabulary, so nothing at the database level + * stops a value nobody wrote here. Narrowed rather than cast: an unrecognised source reads as + * a paste, which is the shape that claims the least about where the file came from. + */ + source: + row.source === "file" || row.source === "gallery" + ? row.source + : "paste", + sourceRef: row.sourceRef, + document: readStoredTemplate(row.document, row.id), + importedBy: row.importedBy, + importedAt: row.importedAt, + }; + } + + function requestFrom(row: { + importId: string; + kind: string; + ref: string; + why: string; + status: string; + decidedBy: string | null; + decidedAt: Date | null; + }): TemplateRequestRow { + return { + importId: row.importId, + /* + * Same narrowing, and the fallback matters more here. An unknown kind reads as `component`, + * which is the one kind whose satisfaction is not a grant at all, so a row nobody recognises + * can never be routed to the MCP grant screen by accident. + */ + kind: + row.kind === "mcp" || row.kind === "endpoint" ? row.kind : "component", + ref: row.ref, + why: row.why, + /* + * An unknown status reads as `requested`, which is the state that says nothing has been + * decided. The alternatives all assert something — that a person approved, that a person + * refused, that the deployment cannot satisfy it — and a row we cannot read must not assert. + */ + status: + row.status === "unavailable" || + row.status === "not_in_build" || + row.status === "granted" || + row.status === "declined" + ? row.status + : "requested", + decidedBy: row.decidedBy, + decidedAt: row.decidedAt, + }; + } + + function boundaryFrom(row: { + importId: string; + agentId: string; + expression: string; + sourceKey: string; + appliedAt: Date; + removedAt: Date | null; + }): TemplateBoundaryRow { + return { + importId: row.importId, + agentId: row.agentId, + expression: row.expression, + sourceKey: + row.sourceKey === "shell" || + row.sourceKey === "files" || + row.sourceKey === "browser" || + row.sourceKey === "navigate_hosts" + ? row.sourceKey + : "mcp", + appliedAt: row.appliedAt, + removedAt: row.removedAt, + }; + } + + async function draftWithin( + executor: TemplateReadExecutor, + actor: AgentActor, + templateId: string, + ): Promise { + const [row] = await executor + .select(draftProjection) + .from(botTemplates) + .where(eq(botTemplates.id, templateId)) + .limit(1); + if (!row || !mayReach(actor, row.ownerUserId)) { + throw new TemplateNotFoundError(templateId); + } + return draftFrom(row); + } + + return { + createDraft(actor, input) { + return database.transaction(async (transaction) => { + const id = newTemplateId(); + const slug = input.document.template.slug; + /* + * The unique index is what decides, and the read that would have "checked first" is not + * here on purpose: two exports of the same Bot a second apart would both read a free slug + * and the second insert would still fail. Caught and restated instead, so the person is told + * the true reason rather than a constraint name. + */ + try { + await transaction.insert(botTemplates).values({ + id, + agentId: input.agentId ?? null, + ownerUserId: actor.id, + slug, + document: input.document, + }); + } catch (error) { + if (isOwnerSlugCollision(error)) { + throw new TemplateSlugTakenError(slug); + } + throw error; + } + return draftWithin(transaction, actor, id); + }); + }, + + async listDrafts(actor) { + const rows = await database + .select(draftProjection) + .from(botTemplates) + .where( + actor.role === "admin" + ? undefined + : eq(botTemplates.ownerUserId, actor.id), + ) + .orderBy(desc(botTemplates.updatedAt)); + return rows.map(draftFrom); + }, + + getDraft(actor, templateId) { + return draftWithin(database, actor, templateId); + }, + + updateDraft(actor, templateId, document) { + return database.transaction(async (transaction) => { + // Reached through the same gate as a read, so a draft somebody may not see is one they may + // not overwrite either, and both answer with the same not-found. + await draftWithin(transaction, actor, templateId); + const slug = document.template.slug; + try { + await transaction + .update(botTemplates) + .set({ slug, document, updatedAt: new Date() }) + .where(eq(botTemplates.id, templateId)); + } catch (error) { + if (isOwnerSlugCollision(error)) { + throw new TemplateSlugTakenError(slug); + } + throw error; + } + return draftWithin(transaction, actor, templateId); + }); + }, + + deleteDraft(actor, templateId) { + return database.transaction(async (transaction) => { + await draftWithin(transaction, actor, templateId); + await transaction + .delete(botTemplates) + .where(eq(botTemplates.id, templateId)); + }); + }, + + async recordImport(input, executor = database) { + const [row] = await executor + .insert(templateImports) + .values({ + agentId: input.agentId, + digest: input.digest, + slug: input.slug, + templateVersion: input.templateVersion ?? null, + authorClaim: input.authorClaim ?? null, + source: input.source, + sourceRef: input.sourceRef ?? null, + document: input.document, + importedBy: input.importedBy, + }) + .returning(importProjection); + if (!row) { + throw new Error( + `The provenance row for ${input.agentId} was not written.`, + ); + } + return importFrom(row); + }, + + async importForAgent(agentId, executor = database) { + const [row] = await executor + .select(importProjection) + .from(templateImports) + .where(eq(templateImports.agentId, agentId)) + .limit(1); + return row ? importFrom(row) : null; + }, + + async recordRequests(rows, executor = database) { + if (rows.length === 0) return; + await executor + .insert(templateRequests) + .values( + rows.map((row) => ({ + importId: row.importId, + kind: row.kind, + ref: row.ref, + why: row.why, + status: row.status, + decidedBy: row.decidedBy ?? null, + decidedAt: row.decidedAt ?? null, + })), + ) + /* + * A retried install that got past the first write must not write a stranger's `why` twice. + * `doNothing` rather than an update, because a row already here may carry an + * administrator's decision, and an import has no business overwriting one. + */ + .onConflictDoNothing({ + target: [ + templateRequests.importId, + templateRequests.kind, + templateRequests.ref, + ], + }); + }, + + async listRequests(importId, executor = database) { + const rows = await executor + .select() + .from(templateRequests) + .where(eq(templateRequests.importId, importId)) + .orderBy(asc(templateRequests.kind), asc(templateRequests.ref)); + return rows.map(requestFrom); + }, + + decideRequest(input) { + return database.transaction(async (transaction) => { + const [row] = await transaction + .update(templateRequests) + .set({ + status: input.status, + decidedBy: input.decidedBy, + decidedAt: new Date(), + }) + .where( + and( + eq(templateRequests.importId, input.importId), + eq(templateRequests.kind, input.kind), + eq(templateRequests.ref, input.ref), + ), + ) + .returning(); + return row ? requestFrom(row) : null; + }); + }, + + async boundariesFor(importId, executor = database) { + const rows = await executor + .select() + .from(templateBoundaries) + .where(eq(templateBoundaries.importId, importId)) + .orderBy(asc(templateBoundaries.expression)); + return rows.map(boundaryFrom); + }, + + async retractBoundaries(importId, executor = database) { + const rows = await executor + .update(templateBoundaries) + .set({ removedAt: new Date() }) + .where( + and( + eq(templateBoundaries.importId, importId), + // Only what is still in force. Re-stamping a clause somebody already removed would move + // the date of an act that happened at a different time. + isNull(templateBoundaries.removedAt), + ), + ) + .returning(); + return rows.map(boundaryFrom); + }, + }; +} diff --git a/server/tests/template-install.integration.test.ts b/server/tests/template-install.integration.test.ts new file mode 100644 index 00000000..113ecdee --- /dev/null +++ b/server/tests/template-install.integration.test.ts @@ -0,0 +1,550 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq, inArray } from "drizzle-orm"; +import { + type BotTemplate, + botTemplateDigest, + parseBotTemplate, + templateGrantMark, +} from "../../shared/bot-template"; +import { createAuditStore } from "../src/audit"; +import type { ActionPolicy } from "../src/computer/policy"; +import { createDatabase } from "../src/db/client"; +import { + agentProfiles, + agents, + pluginGrants, + skills, + skillTools, + templateImports, + users, +} from "../src/db/schema"; +import { createPluginStore, type PluginStore } from "../src/plugins/store"; +import { + createTemplateInstaller, + TemplateDigestMovedError, + TemplateEndpointRequiredError, +} from "../src/templates/install"; +import { createTemplateStore } from "../src/templates/store"; + +/** + * An import as one act, and the four things it must never do. + * + * It must never write an MCP grant — `store.grant` performs no existence check and `listServers` + * computes `withdrawn` only for servers that exist, so an optimistic grant for an absent connector + * is invisible on every screen and goes live the day an administrator adds that connector, with + * nobody deciding. It must never overwrite a skill somebody else wrote, because `installSkill` + * upserts on `skills.slug`. It must never leave half of itself behind when a later step fails. And a + * retraction must never take back a grant an administrator made by hand. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + { max: 2 }, +); + +const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; +const auditStore = createAuditStore(database); +const pluginStore = createPluginStore({ + database, + auditStore, + credentials: { readSecret: async () => null }, + encryptionKey: "x".repeat(44), + policy: () => policy, +}); +const templateStore = createTemplateStore(database); + +const suite = randomUUID().slice(0, 8); +const importer = { + id: `user_${suite}`, + role: "user" as const, + email: `importer-${suite}@openbot.local`, +}; +const skillSlug = `check-renewal-${suite}`; +const managedUrl = new URL("https://managed.example.com/agui"); + +/** Every Bot this file made, so the teardown can take them and their grants with them. */ +const created: string[] = []; + +function installer( + options: { + managedAgent?: boolean; + pluginStore?: Pick; + } = {}, +) { + return createTemplateInstaller({ + database, + templateStore, + pluginStore: options.pluginStore ?? pluginStore, + auditStore, + ...(options.managedAgent === false + ? {} + : { managedAgentAgUiUrl: managedUrl }), + }); +} + +function yamlFor( + options: { + runtime?: "managed" | "remote"; + skillSlug?: string; + instructions?: string; + } = {}, +) { + const runtime = options.runtime ?? "managed"; + const slug = options.skillSlug ?? skillSlug; + return `openbot_template: 1 + +template: + slug: renewal-desk-${suite} + version: "1.3" + author: acme-revops + summary: Chases overdue invoices and drafts the follow-up. + +bot: + name: Renewal Desk ${suite} + title: Accounts Receivable + role_description: >- + Chase overdue invoices. Draft a follow-up for a person to send, and name every + document you used. + runtime: ${runtime} +${ + runtime === "remote" + ? ` remote: + auth_header: Authorization + requires_key: false + sends_conversation_to: renewals.example.com +` + : "" +} skills: [${slug}] + +skills: + - slug: ${slug} + title: Check renewal risk + summary: Pull the contract and the recent tickets for one account. + instructions: >- + ${options.instructions ?? "Find the contract and read the renewal date from it."} + tools: + - google-drive/search_files + +requests: + connectors: + - id: google-drive + why: The invoice ledger export lives in Drive. + tools: + - ref: google-drive/search_files + why: Find the ledger for one customer. + components: + - name: showBarChart + why: Ageing buckets. + +boundary: + shell: never + files: none + browser: read_only + mcp: read_only +`; +} + +async function digested(template: BotTemplate) { + return botTemplateDigest(template); +} + +async function grantsFor(agentId: string) { + return database + .select() + .from(pluginGrants) + .where(eq(pluginGrants.agentId, agentId)); +} + +beforeAll(async () => { + await database + .insert(users) + .values({ id: importer.id, email: importer.email }) + .onConflictDoNothing(); +}); + +afterAll(async () => { + if (created.length > 0) { + await database.delete(agents).where(inArray(agents.id, created)); + } + await database + .delete(skills) + .where( + inArray(skills.slug, [ + skillSlug, + `${skillSlug}-2`, + `${skillSlug}-3`, + `other-${suite}`, + `hand-made-${suite}`, + ]), + ); + await database.delete(users).where(eq(users.id, importer.id)); +}); + +describe("an import on a deployment that has connected nothing", () => { + test("creates the Bot cold, records the ask, and grants no MCP anything", async () => { + const template = parseBotTemplate(yamlFor()); + const digest = await digested(template); + + const result = await installer().installBotTemplate({ + template, + digest, + actor: importer, + source: "paste", + slugDecisions: {}, + }); + created.push(result.agentId); + + const [profile] = await database + .select() + .from(agentProfiles) + .where(eq(agentProfiles.agentId, result.agentId)) + .limit(1); + // Forced, both of them. A template has no field that could carry an owner or a visibility, and + // making it public is an ordinary later PATCH the owner makes on a Bot they can already see. + expect(profile?.ownerUserId).toBe(importer.id); + expect(profile?.visibility).toBe("private"); + expect(profile?.roleDescription).toBe(template.bot.roleDescription); + + const [skill] = await database + .select() + .from(skills) + .where(eq(skills.slug, skillSlug)) + .limit(1); + // The importer's own, and marked as having come from a template rather than from the catalogue + // or from somebody typing it here. + expect(skill?.ownerUserId).toBe(importer.id); + expect(skill?.origin).toBe("template"); + expect(skill?.installedBy).toBe(importer.email); + + /* + * The declaration survives even though this deployment has never connected Drive. A declared ref + * grants nothing — the run-time offer is granted ∩ declared — so an unknown one is inert, and + * refusing it would mean a template could only ship skills for connectors it could guarantee, + * which is none of them. + */ + const declared = await database + .select() + .from(skillTools) + .where(eq(skillTools.skillId, skillSlug)); + expect(declared.map((row) => row.ref)).toEqual([ + "google-drive/search_files", + ]); + + const held = await grantsFor(result.agentId); + expect(held).toHaveLength(1); + expect(held[0]?.kind).toBe("skill"); + expect(held[0]?.ref).toBe(skillSlug); + // The mark, so a retraction takes back exactly what this import gave. + expect(held[0]?.grantedBy).toBe(templateGrantMark(digest)); + + /* + * THE PROPERTY THIS WHOLE FEATURE RESTS ON. Not "no mcp grant on this Bot" — no mcp grant + * anywhere naming what the template asked for, because such a row would be invisible on every + * screen and would go live the day somebody connected Drive. + */ + const optimistic = await database + .select() + .from(pluginGrants) + .where( + and( + eq(pluginGrants.kind, "mcp"), + eq(pluginGrants.ref, "google-drive/search_files"), + ), + ); + expect(optimistic).toHaveLength(0); + + const ledger = result.ledger; + expect( + ledger.find((row) => row.ref === "google-drive/search_files")?.status, + ).toBe("unavailable"); + expect(ledger.find((row) => row.ref === "showBarChart")?.status).toBe( + "not_in_build", + ); + // The author's sentence is carried into the ledger, because it is the only thing on the grant + // screen that says why. + expect( + ledger.find((row) => row.ref === "google-drive/search_files")?.why, + ).toBe("Find the ledger for one customer."); + expect(ledger.every((row) => row.decidedBy === null)).toBe(true); + + expect(result.imported.authorClaim).toBe("acme-revops"); + expect(result.imported.templateVersion).toBe("1.3"); + expect(result.skillsCreated).toEqual([skillSlug]); + }); +}); + +describe("the window between the consent screen and the click", () => { + test("a digest that moved is refused, and nothing is written", async () => { + const template = parseBotTemplate(yamlFor({ skillSlug: `other-${suite}` })); + + const before = await database + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.name, `Renewal Desk ${suite}`)); + + await expect( + installer().installBotTemplate({ + template, + digest: "b".repeat(64), + actor: importer, + source: "paste", + slugDecisions: {}, + }), + ).rejects.toBeInstanceOf(TemplateDigestMovedError); + + const after = await database + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.name, `Renewal Desk ${suite}`)); + expect(after).toHaveLength(before.length); + expect( + await database + .select() + .from(skills) + .where(eq(skills.slug, `other-${suite}`)), + ).toHaveLength(0); + }); +}); + +describe("a step that fails after the skills are in", () => { + test("takes the Bot, the skills and the grants back with it", async () => { + const template = parseBotTemplate(yamlFor({ skillSlug: `other-${suite}` })); + const digest = await digested(template); + + const before = await database + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.name, `Renewal Desk ${suite}`)); + + /* + * Whatever fails after the skills are in: a ledger row, a boundary that will not compile, a + * network blip on the vault. Without one transaction this leaves an orphan Bot holding half a + * skill set, the person presses import again, and the deployment now has two. + */ + const failing = { + installSkill: pluginStore.installSkill, + grant: async () => { + throw new Error("the step after the skills failed"); + }, + }; + + await expect( + installer({ pluginStore: failing }).installBotTemplate({ + template, + digest, + actor: importer, + source: "paste", + slugDecisions: {}, + }), + ).rejects.toThrow("the step after the skills failed"); + + const after = await database + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.name, `Renewal Desk ${suite}`)); + expect(after).toHaveLength(before.length); + expect( + await database + .select() + .from(skills) + .where(eq(skills.slug, `other-${suite}`)), + ).toHaveLength(0); + expect( + await database + .select() + .from(templateImports) + .where(eq(templateImports.digest, digest)), + ).toHaveLength(0); + }); +}); + +describe("a skill slug this deployment has already given to somebody", () => { + test("is reused when identical, suffixed when not, and never overwritten", async () => { + /* + * The first import took `check-renewal-` and wrote the template's own instructions there. + * A second import of a template shipping DIFFERENT instructions under the same slug must not + * touch it: `installSkill`'s `onConflictDoUpdate` on `skills.slug` would silently replace + * somebody's `/` command with a stranger's text. + */ + const [original] = await database + .select() + .from(skills) + .where(eq(skills.slug, skillSlug)) + .limit(1); + + const different = parseBotTemplate( + yamlFor({ + instructions: "Something else entirely, written by somebody.", + }), + ); + const suffixed = await installer().installBotTemplate({ + template: different, + digest: await digested(different), + actor: importer, + source: "paste", + slugDecisions: {}, + }); + created.push(suffixed.agentId); + + expect(suffixed.skillsSuffixed).toEqual([`${skillSlug}-2`]); + expect(suffixed.skillsCreated).toHaveLength(0); + const [untouched] = await database + .select() + .from(skills) + .where(eq(skills.slug, skillSlug)) + .limit(1); + expect(untouched?.instructions).toBe(original?.instructions); + expect(untouched?.updatedAt).toEqual(original?.updatedAt); + // The Bot is paired to the copy it was given, never to the one that was already here. + expect((await grantsFor(suffixed.agentId))[0]?.ref).toBe(`${skillSlug}-2`); + + // The same file again: byte-identical instructions and the same declarations, so the skill + // already here IS this skill and nothing is written. + const identical = parseBotTemplate(yamlFor()); + const reused = await installer().installBotTemplate({ + template: identical, + digest: await digested(identical), + actor: importer, + source: "paste", + slugDecisions: {}, + }); + created.push(reused.agentId); + expect(reused.skillsReused).toEqual([skillSlug]); + expect(reused.skillsCreated).toHaveLength(0); + expect((await grantsFor(reused.agentId))[0]?.ref).toBe(skillSlug); + }); + + test("is skipped when the importer says so, and the Bot arrives without it", async () => { + const different = parseBotTemplate( + yamlFor({ instructions: "A third set of instructions again." }), + ); + const skipped = await installer().installBotTemplate({ + template: different, + digest: await digested(different), + actor: importer, + source: "paste", + slugDecisions: { [skillSlug]: "skip" }, + }); + created.push(skipped.agentId); + + expect(skipped.skillsSkipped).toEqual([skillSlug]); + // Degrade, never block. An unmet ask does not stop the install; the Bot simply arrives colder. + expect(await grantsFor(skipped.agentId)).toHaveLength(0); + expect( + await database + .select() + .from(skills) + .where(eq(skills.slug, `${skillSlug}-3`)), + ).toHaveLength(0); + }); +}); + +describe("a managed template on a deployment with no Bot in the box", () => { + test("refuses without an address, and installs with the one the importer typed", async () => { + const template = parseBotTemplate( + yamlFor({ skillSlug: `hand-made-${suite}` }), + ); + const digest = await digested(template); + const cold = installer({ managedAgent: false }); + + /* + * `store.create` throws `ManagedAgentUnavailableError` when there is neither an endpoint nor a + * managed agent, and the recommended one-container image carries no managed agent. Said here as + * a slot the importer fills rather than as a 400 after a preview that reported nothing to + * rebind. + */ + await expect( + cold.installBotTemplate({ + template, + digest, + actor: importer, + source: "paste", + slugDecisions: {}, + }), + ).rejects.toBeInstanceOf(TemplateEndpointRequiredError); + + const result = await cold.installBotTemplate({ + template, + digest, + actor: importer, + source: "paste", + endpoint: "https://renewals.example.com/agui", + slugDecisions: {}, + }); + created.push(result.agentId); + + const [agent] = await database + .select({ configuration: agents.configuration }) + .from(agents) + .where(eq(agents.id, result.agentId)) + .limit(1); + expect( + (agent?.configuration as { endpoint?: string } | null)?.endpoint, + ).toBe("https://renewals.example.com/agui"); + + /* + * The one ask an import answers on the spot, because the importer answered it. The ref is the + * host rather than the whole address: a ledger row is read back by people, and the path of an + * AG-UI endpoint is neither interesting nor always free of something somebody put there. + */ + const slot = result.ledger.find((row) => row.kind === "endpoint"); + expect(slot?.ref).toBe("renewals.example.com"); + expect(slot?.status).toBe("granted"); + expect(slot?.decidedBy).toBe(importer.email); + }); +}); + +describe("retracting an import", () => { + test("takes back what it gave and leaves an administrator's own grant alone", async () => { + const template = parseBotTemplate(yamlFor()); + const digest = await digested(template); + const result = await installer().installBotTemplate({ + template, + digest, + actor: importer, + source: "gallery", + sourceRef: "renewal-desk.openbot.yaml", + slugDecisions: {}, + }); + created.push(result.agentId); + + // A grant somebody made by hand on the same Bot, afterwards, through the screen that already + // refuses. Its `granted_by` is a person, which is why the mark cannot collide with it. + await pluginStore.grant( + "skill", + `${skillSlug}-2`, + result.agentId, + "admin@openbot.local", + ); + expect(await grantsFor(result.agentId)).toHaveLength(2); + + const retracted = await installer().retractTemplateImport({ + actor: importer, + agentId: result.agentId, + }); + + expect(retracted.revoked).toEqual([{ kind: "skill", ref: skillSlug }]); + const left = await grantsFor(result.agentId); + expect(left).toHaveLength(1); + expect(left[0]?.ref).toBe(`${skillSlug}-2`); + expect(left[0]?.grantedBy).toBe("admin@openbot.local"); + + /* + * The Bot stays, the skill stays, and so does the provenance. Retracting an import takes back + * what the import GAVE; it does not delete a coworker somebody has been using, a skill that is + * now in somebody's `/` menu, or the record of what was consented to. + */ + expect( + await database + .select() + .from(agentProfiles) + .where(eq(agentProfiles.agentId, result.agentId)), + ).toHaveLength(1); + expect( + await database.select().from(skills).where(eq(skills.slug, skillSlug)), + ).toHaveLength(1); + expect(await templateStore.importForAgent(result.agentId)).not.toBeNull(); + }); +}); diff --git a/server/tests/template-pack.test.ts b/server/tests/template-pack.test.ts new file mode 100644 index 00000000..a804f5ae --- /dev/null +++ b/server/tests/template-pack.test.ts @@ -0,0 +1,644 @@ +import { describe, expect, test } from "bun:test"; +import { + type BotTemplate, + parseBotTemplate, + serializeBotTemplate, + STRICT_BOUNDARY, + TemplateRefusedError, +} from "../../shared/bot-template"; +import type { AgentProfile } from "../src/agents/profile-types"; +import { + type PackInput, + packBotTemplate, + refuseSecrets, + SecretInTemplateError, +} from "../src/templates/pack"; + +/** + * Packing a coworker, tested as the export boundary it is. + * + * Two things are being proved here and they pull in opposite directions. The first is that everything + * a coworker IS survives the trip: its prose, its skills, the pairing between them, and enough of its + * ask that an importer knows what to grant. The second is that nothing about the deployment it was + * packed from goes with it, and that the author is told what was left behind rather than finding out + * later from a Bot that does not work. + * + * The round-trip test is the load-bearing one. `pack` and `parse` live in different files and each + * restates the other's limits, so a draft this module produces that the parser then refuses is a + * shipped file nobody can import — including the deployment that wrote it. + */ + +/** + * The two characters that open an environment reference, kept apart from the name they open. + * + * Written this way so the file itself is not a template literal the linter has to be told about, + * and so a reader can see that the sequence under test is exactly the two characters and nothing + * clever. `shared/bot-template.ts` holds the same pair as a plain string for the same reason. + */ +const INTERPOLATION_OPEN = "${"; + +/** An id in the shape `create` mints, which is also what it writes into `avatar_seed` today. */ +const AGENT_ID = "agent_9f0c4b1e-7d52-4a3f-9c88-1b0d5e6a2f34"; + +function profile(overrides: Partial = {}): AgentProfile { + return { + id: AGENT_ID, + name: "Renewal Desk", + title: "Accounts Receivable", + roleDescription: + "Chase overdue invoices. Work out who is late and by how much, and draft a follow-up for a person to send.", + avatarSeed: AGENT_ID, + visibility: "public", + ownerUserId: "user_7", + systemOwned: false, + hidden: false, + deletedAt: null, + endpoint: null, + hasAuth: false, + hasCallbackToken: false, + ...overrides, + }; +} + +const RENEWAL_SKILL = { + slug: "check-renewal-risk", + title: "Check renewal risk", + summary: + "Pull the contract, the recent tickets and the usage trend for one account.", + instructions: + "Find the contract and read the renewal date and the notice period from it. Name each document you used.", + tools: ["google-drive/search_files", "google-drive/read_file_content"], +}; + +function packInput(overrides: Partial = {}): PackInput { + return { + profile: profile(), + configuration: {}, + skills: [RENEWAL_SKILL], + grants: [ + { ref: "google-drive/search_files" }, + { ref: "google-drive/read_file_content" }, + ], + components: ["showBarChart"], + ...overrides, + }; +} + +/** A Bot with every strippable thing set at once, so the strip list can be checked whole. */ +function loadedInput(): PackInput { + return packInput({ + profile: profile({ + systemOwned: true, + hasCallbackToken: true, + hasAuth: true, + endpoint: "https://renewals.example.com/agui", + }), + configuration: { + endpoint: "https://renewals.example.com/agui", + auth: { header: "Authorization", credentialId: "cred_4471" }, + }, + }); +} + +function names(stripped: string[], field: string): boolean { + return stripped.some((entry) => entry.includes(field)); +} + +describe("what a template carries", () => { + test("the coworker's identity and its prose", () => { + const { template } = packBotTemplate(packInput()); + expect(template.bot.name).toBe("Renewal Desk"); + expect(template.bot.title).toBe("Accounts Receivable"); + expect(template.bot.roleDescription).toBe(profile().roleDescription); + // The gallery line is drafted from the Bot's own first sentence rather than invented. + expect(template.template.summary).toBe("Chase overdue invoices."); + }); + + test("each skill's text and the Bot-to-skill pairing", () => { + const { template } = packBotTemplate(packInput()); + expect(template.skills).toHaveLength(1); + expect(template.skills[0]).toEqual({ + slug: "check-renewal-risk", + title: RENEWAL_SKILL.title, + summary: RENEWAL_SKILL.summary, + instructions: RENEWAL_SKILL.instructions, + // Sorted, so the same Bot packs to the same document and therefore the same digest. + tools: ["google-drive/read_file_content", "google-drive/search_files"], + }); + // Without the pairing the imported Bot boots with skills attached to nobody. + expect(template.bot.skills).toEqual(["check-renewal-risk"]); + }); + + test("the header name, and that a key is wanted, for a remote coworker", () => { + const { template } = packBotTemplate( + packInput({ + profile: profile({ + endpoint: "https://renewals.example.com/agui", + hasAuth: true, + }), + configuration: { + endpoint: "https://renewals.example.com/agui", + auth: { header: "X-Api-Key", credentialId: "cred_4471" }, + }, + }), + ); + expect(template.bot.runtime).toBe("remote"); + expect(template.bot.remote).toEqual({ + authHeader: "X-Api-Key", + requiresKey: true, + }); + }); + + test("no address, in any field, for a remote coworker", () => { + const { template } = packBotTemplate( + packInput({ + profile: profile({ endpoint: "https://renewals.example.com/agui" }), + configuration: { endpoint: "https://renewals.example.com/agui" }, + }), + ); + // Not as a url, not as documentation, and not as the claim about where conversations go: the + // host of the Bot being packed is a server on the deployment being packed. + expect(template.bot.remote?.exampleUrl).toBeUndefined(); + expect(template.bot.remote?.sendsConversationTo).toBeUndefined(); + expect(serializeBotTemplate(template)).not.toContain( + "renewals.example.com", + ); + }); + + test("an author claim is never invented on the author's behalf", () => { + const { template } = packBotTemplate(packInput()); + expect(template.template.author).toBeUndefined(); + expect(template.template.source).toBeUndefined(); + expect(template.template.license).toBeUndefined(); + }); +}); + +describe("where the coworker runs", () => { + test("its own endpoint makes it remote", () => { + const { template } = packBotTemplate( + packInput({ + profile: profile({ endpoint: "https://renewals.example.com/agui" }), + configuration: { endpoint: "https://renewals.example.com/agui" }, + managedEndpoint: "http://agent:8000/", + }), + ); + expect(template.bot.runtime).toBe("remote"); + }); + + test("this deployment's own managed address makes it managed", () => { + // A managed Bot carries an endpoint too — the deployment's own — so the presence of one is not + // the signal. Without this, every Bot on a deployment with a managed agent packs as remote and + // every importer is asked to type an address for a coworker that should run in their box. + const { template } = packBotTemplate( + packInput({ + profile: profile({ endpoint: "http://agent:8000/" }), + configuration: { endpoint: "http://agent:8000/" }, + managedEndpoint: "http://agent:8000/", + }), + ); + expect(template.bot.runtime).toBe("managed"); + expect(template.bot.remote).toBeUndefined(); + }); + + test("no endpoint at all is managed", () => { + const { template } = packBotTemplate(packInput()); + expect(template.bot.runtime).toBe("managed"); + expect(template.bot.remote).toBeUndefined(); + }); +}); + +describe("what is left behind", () => { + test("every strippable field is named", () => { + const { stripped } = packBotTemplate(loadedInput()); + for (const field of [ + "agents.id", + "agents.package_id", + "agent_profiles.owner_user_id", + "agent_profiles.visibility", + "agents.callback_token_hash", + "agent_profiles.deleted_at", + "agent_preferences", + "configuration.endpoint", + "configuration.auth.credentialId", + "agent_profiles.avatar_seed", + "skills.owner_user_id", + ]) { + expect(names(stripped, field)).toBe(true); + } + }); + + test("none of it reaches the document", () => { + const { template } = packBotTemplate(loadedInput()); + const yaml = serializeBotTemplate(template); + for (const value of [ + AGENT_ID, + "cred_4471", + "https://renewals.example.com/agui", + "user_7", + ]) { + expect(yaml).not.toContain(value); + } + }); + + test("it does not claim to strip what the coworker does not have", () => { + // Telling an author their Bot's key was stripped when their Bot has no key teaches them the + // wrong thing about what a template carries. + const { stripped } = packBotTemplate( + packInput({ + profile: profile({ + ownerUserId: null, + avatarSeed: "renewal-desk", + }), + skills: [], + grants: [], + components: [], + }), + ); + expect(names(stripped, "package_id")).toBe(false); + expect(names(stripped, "callback_token_hash")).toBe(false); + expect(names(stripped, "configuration.endpoint")).toBe(false); + expect(names(stripped, "credentialId")).toBe(false); + expect(names(stripped, "avatar_seed")).toBe(false); + expect(names(stripped, "skills.owner_user_id")).toBe(false); + expect(names(stripped, "configuration.systemPrompt")).toBe(false); + // The rules that are true of every export are still stated. + expect(names(stripped, "agents.id")).toBe(true); + expect(names(stripped, "agent_profiles.visibility")).toBe(true); + }); + + test("a package Bot's system prompt is reported as missing, not silently dropped", () => { + // Exporting a shipped Bot is allowed and is not a faithful round trip: the format has no field + // for a system prompt, so the author has to be told the behaviour did not travel with it. + const { stripped } = packBotTemplate( + packInput({ + profile: profile({ systemOwned: true }), + configuration: { + systemPrompt: "You are a careful analyst. Cite every document.", + }, + }), + ); + expect(names(stripped, "configuration.systemPrompt")).toBe(true); + }); + + test("an id-shaped avatar seed is replaced rather than carried", () => { + const { template, stripped } = packBotTemplate(packInput()); + expect(template.bot.avatarSeed).toBe("renewal-desk"); + expect(names(stripped, "agent_profiles.avatar_seed")).toBe(true); + }); + + test("a real style token keeps the Bot's face", () => { + const { template, stripped } = packBotTemplate( + packInput({ profile: profile({ avatarSeed: "amber-fox" }) }), + ); + expect(template.bot.avatarSeed).toBe("amber-fox"); + expect(names(stripped, "agent_profiles.avatar_seed")).toBe(false); + }); +}); + +describe("the ask", () => { + test("grants become requests, grouped by connector", () => { + const { template } = packBotTemplate( + packInput({ + grants: [ + { ref: "notion/notion-search" }, + { ref: "google-drive/search_files" }, + { ref: "google-drive/read_file_content" }, + // A duplicate row must not become a duplicate ask. + { ref: "notion/notion-search" }, + ], + }), + ); + expect(template.requests.connectors.map((entry) => entry.id)).toEqual([ + "google-drive", + "notion", + ]); + expect( + template.requests.connectors[0]?.tools.map((tool) => tool.ref), + ).toEqual(["google-drive/read_file_content", "google-drive/search_files"]); + expect(template.requests.connectors[1]?.tools).toHaveLength(1); + }); + + test("the why is a draft the author is expected to replace", () => { + const { template } = packBotTemplate(packInput()); + expect(template.requests.connectors[0]?.why).toBe( + "Granted to this Bot on the deployment it was packed from.", + ); + expect(template.requests.components).toEqual([ + { + name: "showBarChart", + why: "Granted to this Bot on the deployment it was packed from.", + }, + ]); + }); + + test("nothing in the ask is written as a permission", () => { + const { template } = packBotTemplate(packInput()); + // The whole request block is names and prose. There is no field here that could be read as a + // grant, which is the property the import module depends on. + expect(Object.keys(template.requests).sort()).toEqual([ + "components", + "connectors", + ]); + expect(Object.keys(template.requests.connectors[0] ?? {}).sort()).toEqual([ + "id", + "tools", + "why", + ]); + }); + + test("a grant that is not a serverId/toolName ref is refused, not dropped", () => { + expect(() => + packBotTemplate(packInput({ grants: [{ ref: "google-drive" }] })), + ).toThrow(TemplateRefusedError); + }); + + test("an ask nobody would read to the end is refused", () => { + const grants = Array.from({ length: 30 }, (_, index) => ({ + ref: `server-${index}/tool`, + })); + expect(() => packBotTemplate(packInput({ grants }))).toThrow( + /may ask for 40/, + ); + }); +}); + +describe("the boundary", () => { + test("is the strictest thing the vocabulary can say", () => { + const { template } = packBotTemplate(packInput()); + expect(template.boundary).toEqual(STRICT_BOUNDARY); + }); + + test("is a copy, so a draft cannot edit every other template's ceiling", () => { + const { template } = packBotTemplate(packInput()); + expect(template.boundary).not.toBe(STRICT_BOUNDARY); + template.boundary.navigateHosts.push("billing.acme.example"); + expect(STRICT_BOUNDARY.navigateHosts).toEqual([]); + }); +}); + +describe("the slug", () => { + test("is derived from the Bot's name", () => { + const { template } = packBotTemplate(packInput()); + expect(template.template.slug).toBe("renewal-desk"); + }); + + test("folds accents rather than dropping the letters they sit on", () => { + const { template } = packBotTemplate( + packInput({ profile: profile({ name: "Über Desk" }) }), + ); + expect(template.template.slug).toBe("uber-desk"); + }); + + test("punctuation collapses and a trailing hyphen never survives", () => { + const { template } = packBotTemplate( + packInput({ profile: profile({ name: "Renewal / Desk (v2) — " }) }), + ); + expect(template.template.slug).toBe("renewal-desk-v2"); + }); + + test("a long name is cut to something the parser accepts", () => { + const { template } = packBotTemplate( + packInput({ + profile: profile({ + name: "The Accounts Receivable Renewal Desk For Overdue Invoices", + }), + }), + ); + expect(template.template.slug.length).toBeLessThanOrEqual(40); + expect(template.template.slug.endsWith("-")).toBe(false); + }); + + test("a name with nothing to make a slug of falls back to one that is valid", () => { + const { template } = packBotTemplate( + packInput({ profile: profile({ name: "更新デスク" }) }), + ); + expect(template.template.slug).toBe("unnamed-bot"); + }); +}); + +describe("the round trip", () => { + test("a managed coworker survives serialize and parse", () => { + const { template } = packBotTemplate(packInput()); + expect(parseBotTemplate(serializeBotTemplate(template))).toEqual(template); + }); + + test("a remote coworker survives serialize and parse", () => { + const { template } = packBotTemplate( + packInput({ + profile: profile({ + endpoint: "https://renewals.example.com/agui", + hasAuth: true, + avatarSeed: "amber-fox", + }), + configuration: { + endpoint: "https://renewals.example.com/agui", + auth: { header: "Authorization", credentialId: "cred_4471" }, + }, + }), + ); + expect(parseBotTemplate(serializeBotTemplate(template))).toEqual(template); + }); + + test("a coworker with no skills, no grants and no components survives too", () => { + const { template } = packBotTemplate( + packInput({ skills: [], grants: [], components: [] }), + ); + expect(parseBotTemplate(serializeBotTemplate(template))).toEqual(template); + }); +}); + +describe("what cannot be packed", () => { + test("a skill slug the format does not admit", () => { + // The tenant package's rule admits `find-`; the Skills API's does not, and a template uses the + // stricter one because a slug that installs and cannot then be edited is worse than a refusal. + expect(() => + packBotTemplate( + packInput({ skills: [{ ...RENEWAL_SKILL, slug: "find-" }] }), + ), + ).toThrow(TemplateRefusedError); + }); + + test("prose past a ceiling the parser would refuse anyway", () => { + expect(() => + packBotTemplate( + packInput({ + profile: profile({ roleDescription: "a".repeat(1001) }), + }), + ), + ).toThrow(/1000 characters/); + }); + + test("an environment reference in the Bot's own prose", () => { + // Exported cleanly, this file is refused on every deployment it reaches, including this one, and + // the author hears about it from a stranger. + let refusal: unknown; + try { + packBotTemplate( + packInput({ + profile: profile({ + roleDescription: `Read the ledger at ${INTERPOLATION_OPEN}LEDGER_URL} first.`, + }), + }), + ); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(TemplateRefusedError); + expect((refusal as TemplateRefusedError).reason).toBe("interpolation"); + }); +}); + +/** A packed draft with one field replaced, so the scanner can be aimed at a single string. */ +function templateWithProse(prose: string): BotTemplate { + const { template } = packBotTemplate(packInput()); + return { ...template, bot: { ...template.bot, roleDescription: prose } }; +} + +describe("the secret scanner", () => { + /** + * Assembled at run time rather than written out. + * + * A fixture for a secret scanner is, by construction, a string shaped exactly like a credential, + * and GitHub's push protection reads this file the same way `refuseSecrets` reads a template: it + * blocked a push over the Slack token that used to sit on one of these lines. The tempting fix is + * to click the "allow this secret" link, which teaches everybody that the warning is noise — the + * habit that eventually waves a real one through. + * + * So the recognisable prefix is severed and joined back together here. The bytes exist only in + * memory, the scanner under test sees exactly the string it would see in a real document, and no + * literal in this repository looks like a credential to anything that scans for one. + */ + const join = (...parts: string[]) => parts.join(""); + const SECRETS: [string, string][] = [ + ["an sk- key", join("sk", "-live-9aZk3mQ7bR1tYv2xLp8Nd4Wc")], + [ + "a GitHub personal token", + join("ghp", "_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"), + ], + [ + "a GitHub oauth token", + join("gho", "_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"), + ], + [ + "a GitHub user token", + join("ghu", "_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"), + ], + [ + "a GitHub server token", + join("ghs", "_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"), + ], + [ + "a fine-grained GitHub token", + join("github", "_pat_11ABCDEFG0aBcDeFgHiJkLmNoPqRsTuVwXyZ"), + ], + ["a Slack bot token", join("xox", "b-2345678901-ABCDEFGHIJKLMNOP")], + ["a Slack user token", join("xox", "p-2345678901-ABCDEFGHIJKLMNOP")], + ["an AWS access key id", join("AKIA", "IOSFODNN7EXAMPLE")], + [ + "a JSON web token", + join( + "eyJhbGciOiJIUzI1NiJ9", + ".eyJzdWIiOiIxMjM0NTY3ODkwIn0", + ".dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + ), + ], + [ + "an address carrying a password", + join("https://svc", ":hunter2@renewals.example.com/agui"), + ], + ["a private key", join("-----BEGIN ", "RSA PRIVATE KEY-----")], + [ + "a bare key beside the word that announces it", + "The token is 8f3Kd9Lm2Qp7Rt4Vx6Zc1Bn5Hj0Wq8Ee.", + ], + ]; + + for (const [what, value] of SECRETS) { + test(`refuses ${what}`, () => { + let refusal: unknown; + try { + refuseSecrets(templateWithProse(`Use this when you connect. ${value}`)); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(SecretInTemplateError); + expect((refusal as SecretInTemplateError).field).toBe( + "bot.role_description", + ); + // The refusal is rendered, logged and audited. Quoting the value would leak it into all three. + expect((refusal as Error).message).not.toContain(value); + }); + } + + const BENIGN: [string, string][] = [ + [ + "a company name that opens like an AWS key", + "Ask the AKIA team before you touch the ledger.", + ], + [ + "a long English sentence with no key beside it", + "Check the renewal date and the notice period in the contract before you answer anything.", + ], + [ + "a hyphenated runbook name that mentions keys and secrets", + "Follow the api-key-rotation-and-secret-handling-runbook when a key expires.", + ], + [ + "a hyphenated phrase whose middle looks like an sk- key", + "Use the task-management-system-for-the-whole-team board to file the follow-up.", + ], + [ + "prose about a password that carries none", + "The password is in your own vault, and it is never in this file.", + ], + ]; + + for (const [what, prose] of BENIGN) { + test(`allows ${what}`, () => { + expect(() => refuseSecrets(templateWithProse(prose))).not.toThrow(); + // And the same prose packs, since the scanner runs inside the packer. + expect(() => + packBotTemplate( + packInput({ profile: profile({ roleDescription: prose }) }), + ), + ).not.toThrow(); + }); + } + + test("names the field it found, wherever in the document it is", () => { + const { template } = packBotTemplate(packInput()); + const withSecret: BotTemplate = { + ...template, + skills: [ + { + ...template.skills[0], + instructions: "Authenticate with sk-live-9aZk3mQ7bR1tYv2xLp8Nd4Wc.", + }, + ], + }; + let refusal: unknown; + try { + refuseSecrets(withSecret); + } catch (error) { + refusal = error; + } + expect((refusal as SecretInTemplateError).field).toBe( + "skills[0].instructions", + ); + }); + + test("the packer refuses rather than exporting a warning", () => { + // A warning on an export screen is a sentence an author clicks through, and a key that reaches a + // file reaches everyone the file reaches. + expect(() => + packBotTemplate( + packInput({ + profile: profile({ + roleDescription: + "Call the ledger with sk-live-9aZk3mQ7bR1tYv2xLp8Nd4Wc as the key.", + }), + }), + ), + ).toThrow(SecretInTemplateError); + }); +}); diff --git a/server/tests/template-resolve.integration.test.ts b/server/tests/template-resolve.integration.test.ts new file mode 100644 index 00000000..e291e625 --- /dev/null +++ b/server/tests/template-resolve.integration.test.ts @@ -0,0 +1,375 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray, sql } from "drizzle-orm"; +import { + type BotTemplate, + botTemplateDigest, + parseBotTemplate, +} from "../../shared/bot-template"; +import { createDatabase } from "../src/db/client"; +import { + components, + mcpServers, + mcpTools, + skills, + skillTools, + users, +} from "../src/db/schema"; +import { resolveBotTemplate, suffixedSlug } from "../src/templates/resolve"; + +/** + * The preview, which writes nothing and is the only thing standing between a stranger's file and a + * person's judgement about it. + * + * Two properties are asserted hardest. The first is that `available` is a statement about the + * DEPLOYMENT and never about the Bot — it says a connector is here, not that anything was granted, + * and the install path turns it into a ledger row that still says `requested`. The second is that a + * colliding skill slug is never resolved to an overwrite: `installSkill` upserts on `skills.slug`, + * so "reuse when identical, else suffix, else skip" is the difference between an import and a way to + * take somebody's `/` command. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + { max: 2 }, +); + +const suite = randomUUID().slice(0, 8); +const owner = `user_${suite}`; +const connector = `drive-${suite}`; +const componentName = `showBarChart_${suite}`; +const skillSlug = `check-renewal-${suite}`; +const otherSkill = `already-here-${suite}`; + +function yamlFor(options: { + runtime?: "managed" | "remote"; + skillSlugs?: string[]; + connectorId?: string; + componentName?: string; + instructions?: string; + tools?: string[]; +}) { + const runtime = options.runtime ?? "managed"; + const slugs = options.skillSlugs ?? [skillSlug]; + const tools = options.tools ?? [`${options.connectorId ?? connector}/search`]; + const instructions = + options.instructions ?? + "Find the contract and read the renewal date from it. Name each document you used."; + return `openbot_template: 1 + +template: + slug: renewal-desk-${suite} + summary: Chases overdue invoices and drafts the follow-up. + +bot: + name: Renewal Desk + title: Accounts Receivable + role_description: >- + Chase overdue invoices and draft a follow-up for a person to send. + runtime: ${runtime} +${ + runtime === "remote" + ? ` remote: + auth_header: Authorization + requires_key: true + example_url: https://renewals.example.com/agui + sends_conversation_to: renewals.example.com +` + : "" +} skills: [${slugs.join(", ")}] + +skills: +${slugs + .map( + (slug) => ` - slug: ${slug} + title: Check renewal risk + summary: Pull the contract and the recent tickets for one account. + instructions: >- + ${instructions} + tools: +${tools.map((ref) => ` - ${ref}`).join("\n")} +`, + ) + .join("")} +requests: + connectors: + - id: ${options.connectorId ?? connector} + why: The invoice ledger export lives there. + tools: + - ref: ${options.connectorId ?? connector}/search + why: Find the ledger for one customer. + - ref: ${options.connectorId ?? connector}/never-advertised + why: Read amounts and due dates. + components: + - name: ${options.componentName ?? componentName} + why: Ageing buckets. + +boundary: + shell: never + files: none + browser: read_only + mcp: read_only +`; +} + +async function plan( + template: BotTemplate, + options: { managedAgent: boolean } = { managedAgent: true }, +) { + return resolveBotTemplate(database, template, { + managedAgent: options.managedAgent, + digest: await botTemplateDigest(template), + }); +} + +beforeAll(async () => { + await database + .insert(users) + .values({ id: owner, email: `${owner}@openbot.local` }) + .onConflictDoNothing(); +}); + +afterAll(async () => { + await database + .delete(skills) + .where( + inArray(skills.slug, [ + skillSlug, + `${skillSlug}-2`, + `${skillSlug}-3`, + otherSkill, + ]), + ); + await database.delete(mcpServers).where(eq(mcpServers.id, connector)); + await database.delete(components).where(eq(components.name, componentName)); + await database.delete(users).where(eq(users.id, owner)); +}); + +describe("what a template asks for, against this deployment", () => { + test("a connector nobody has added is unavailable, and nothing is written to find that out", async () => { + const template = parseBotTemplate(yamlFor({})); + + const [before] = await database + .select({ total: sql`count(*)::int` }) + .from(skills); + const resolved = await plan(template); + const [after] = await database + .select({ total: sql`count(*)::int` }) + .from(skills); + + // The preview is a read. A screen a person has not consented to yet must leave no trace of + // having been shown. + expect(after?.total).toBe(before?.total); + + expect(resolved.connectors[0]?.verdict).toBe("unavailable"); + expect( + resolved.connectors[0]?.tools.every( + (tool) => tool.verdict === "unavailable", + ), + ).toBe(true); + expect(resolved.components[0]?.verdict).toBe("not_in_build"); + // The author's sentence travels with the ask, because it is the only thing on the grant screen + // that says why. + expect(resolved.connectors[0]?.why).toBe( + "The invoice ledger export lives there.", + ); + }); + + test("a server row alone is not enough; the tool row has to be there too", async () => { + await database.insert(mcpServers).values({ + id: connector, + title: "A test connector", + vendor: "Test", + url: "https://mcp.example.invalid/v1", + }); + await database + .insert(mcpTools) + .values({ serverId: connector, name: "search", description: "Find." }); + await database.insert(components).values({ + name: componentName, + title: "Bar chart", + kind: "chart", + draftDescription: "Draws bars.", + published: true, + }); + + const resolved = await plan(parseBotTemplate(yamlFor({}))); + + expect(resolved.connectors[0]?.verdict).toBe("available"); + /* + * A connector that is connected but has never been refreshed advertises no tools. Reporting its + * refs as available would tell the importer a grant is one click away when the grant screen has + * nothing to list. + */ + const byRef = new Map( + resolved.connectors[0]?.tools.map((tool) => [tool.ref, tool.verdict]), + ); + expect(byRef.get(`${connector}/search`)).toBe("available"); + expect(byRef.get(`${connector}/never-advertised`)).toBe("unavailable"); + + expect(resolved.components[0]?.verdict).toBe("available"); + expect(resolved.components[0]?.published).toBe(true); + }); +}); + +describe("a skill slug this deployment has already given to somebody", () => { + test("is reused when the skill already here is the same skill", async () => { + const template = parseBotTemplate(yamlFor({})); + const shipped = template.skills[0]; + if (!shipped) throw new Error("the fixture defines a skill"); + + await database.insert(skills).values({ + id: skillSlug, + slug: skillSlug, + ownerUserId: owner, + title: "Somebody else's title", + summary: "And somebody else's summary.", + // Byte-identical instructions and the same declared tools is what "the same skill" means. + // Title and summary are how a skill is listed, not what it does. + instructions: shipped.instructions, + origin: "yours", + installedBy: `${owner}@openbot.local`, + }); + await database.insert(skillTools).values( + shipped.tools.map((ref) => ({ + skillId: skillSlug, + ref, + declaredBy: `${owner}@openbot.local`, + })), + ); + + const resolved = await plan(template); + const entry = resolved.skills[0]; + expect(entry?.collides).toBe(true); + expect(entry?.identical).toBe(true); + expect(entry?.resolution).toBe("reuse"); + expect(entry?.installAs).toBe(skillSlug); + expect(resolved.slugDecisions[skillSlug]).toBe("reuse"); + }); + + test("is suffixed when it is a different skill wearing the same name", async () => { + const template = parseBotTemplate( + yamlFor({ + instructions: "Something else entirely, written by somebody.", + }), + ); + + const resolved = await plan(template); + const entry = resolved.skills[0]; + expect(entry?.collides).toBe(true); + expect(entry?.identical).toBe(false); + expect(entry?.resolution).toBe("suffix"); + expect(entry?.installAs).toBe(`${skillSlug}-2`); + + /* + * The suffixed slug has to satisfy the FORMAT's rule as well as the database's, and this is the + * assertion that keeps the copy of the regex in `resolve.ts` honest against the one in + * `shared/bot-template.ts`: a slug the format would refuse installs cleanly and is then + * permanently uneditable through every screen in the product. + */ + const chosen = entry?.installAs; + if (!chosen) throw new Error("a suffix was expected"); + const round = parseBotTemplate( + yamlFor({ skillSlugs: [chosen], instructions: "Anything." }), + ); + expect(round.skills[0]?.slug).toBe(chosen); + }); + + test("walks past a suffix that is also taken", async () => { + await database.insert(skills).values({ + id: `${skillSlug}-2`, + slug: `${skillSlug}-2`, + ownerUserId: owner, + title: "Also taken", + summary: "Also taken.", + instructions: "Also taken.", + origin: "yours", + installedBy: `${owner}@openbot.local`, + }); + + const resolved = await plan( + parseBotTemplate( + yamlFor({ instructions: "Something else entirely again." }), + ), + ); + expect(resolved.skills[0]?.installAs).toBe(`${skillSlug}-3`); + }); + + test("never resolves to an overwrite, whatever it resolves to", async () => { + const resolved = await plan( + parseBotTemplate(yamlFor({ instructions: "Different again." })), + ); + for (const entry of resolved.skills) { + /* + * The only way `installAs` may equal a colliding slug is `reuse`, which writes nothing at all. + * Anything else naming a taken slug would reach `installSkill`'s `onConflictDoUpdate` and take + * somebody's `/` command with a stranger's instructions. + */ + if (entry.collides && entry.resolution !== "reuse") { + expect(entry.installAs).not.toBe(entry.slug); + } + } + }); + + test("a name nobody has taken is written as it stands", async () => { + const fresh = `fresh-${suite}`; + const resolved = await plan( + parseBotTemplate(yamlFor({ skillSlugs: [fresh] })), + ); + expect(resolved.skills[0]?.collides).toBe(false); + expect(resolved.skills[0]?.installAs).toBe(fresh); + }); +}); + +describe("a suffix that would not fit", () => { + test("trims the base rather than producing a slug the product cannot save", () => { + const long = "a".repeat(40); + const candidate = suffixedSlug(long, 2); + expect(candidate).toBe(`${"a".repeat(38)}-2`); + expect(candidate?.length).toBe(40); + }); + + test("re-cuts a trailing hyphen the trim exposed", () => { + // `…-` then `-2` would be `…--2`, and a slug ending or doubling a hyphen is refused by the + // format, installed by the package rule, and then uneditable through the product forever. + const candidate = suffixedSlug(`${"a".repeat(37)}--`, 2); + expect(candidate).toBe(`${"a".repeat(37)}-2`); + }); +}); + +describe("where the coworker runs", () => { + test("a remote template always asks the importer for the address", async () => { + const resolved = await plan( + parseBotTemplate(yamlFor({ runtime: "remote" })), + ); + expect(resolved.endpoint.required).toBe(true); + expect(resolved.endpoint.reason).toBe("remote"); + expect(resolved.endpoint.requiresKey).toBe(true); + // The header NAME is not a secret and travels; the value never does. + expect(resolved.endpoint.authHeader).toBe("Authorization"); + expect(resolved.endpoint.sendsConversationTo).toBe("renewals.example.com"); + }); + + test("a managed template asks too, when this deployment has no Bot in the box", async () => { + /* + * The row that is not a nicety. `store.create` throws `ManagedAgentUnavailableError` when there + * is neither an endpoint nor a managed agent, and the recommended one-container image carries no + * managed agent — so routing `runtime: managed` straight through `create` would 400 on the + * default install after a preview that reported nothing to rebind. + */ + const resolved = await plan(parseBotTemplate(yamlFor({})), { + managedAgent: false, + }); + expect(resolved.endpoint.required).toBe(true); + expect(resolved.endpoint.reason).toBe("no_managed_agent"); + }); + + test("and does not, when there is one", async () => { + const resolved = await plan(parseBotTemplate(yamlFor({})), { + managedAgent: true, + }); + expect(resolved.endpoint.required).toBe(false); + expect(resolved.endpoint.reason).toBeNull(); + }); +}); diff --git a/server/tests/template-routes.integration.test.ts b/server/tests/template-routes.integration.test.ts new file mode 100644 index 00000000..2e72abbb --- /dev/null +++ b/server/tests/template-routes.integration.test.ts @@ -0,0 +1,952 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq, inArray } from "drizzle-orm"; +import type { MiddlewareHandler } from "hono"; +import { Hono } from "hono"; +import { parseBotTemplate } from "../../shared/bot-template"; +import { createAgentProfileStore } from "../src/agents/profile-store"; +import { createAgentRoutes } from "../src/agents/routes"; +import { createAuditStore } from "../src/audit"; +import type { AppVariables, AuthenticatedActor } from "../src/auth/guards"; +import type { ActionPolicy } from "../src/computer/policy"; +import { createDatabase } from "../src/db/client"; +import { + agentProfiles, + agents, + auditEvents, + botTemplates, + deploymentPackages, + mcpServers, + mcpTools, + pluginGrants, + skills, + templateImports, + users, +} from "../src/db/schema"; +import { createPluginStore } from "../src/plugins/store"; +import { createTemplateInstaller } from "../src/templates/install"; +import { + createTemplateExport, + createTemplateRoutes, +} from "../src/templates/routes"; +import { createTemplateStore } from "../src/templates/store"; + +/** + * The HTTP surface, against the real database and the real stores underneath it. + * + * Faked stores were the alternative and would have tested almost nothing that matters here: every + * interesting property of these routes is a property of what they DELEGATE to — that a refused + * document leaves exactly one row and no Bot, that a grant goes through the plugin store rather than + * a second write, that a draft somebody else owns is answered as absent. A fake would have agreed + * with whatever this file asserted. + * + * What is deliberately NOT re-tested here: the parser's refusal list, the packer's stripping and the + * installer's transaction. Those have their own suites, and duplicating them through HTTP would make + * this file the place they are maintained. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + { max: 2 }, +); + +const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; +const auditStore = createAuditStore(database); +const pluginStore = createPluginStore({ + database, + auditStore, + credentials: { readSecret: async () => null }, + encryptionKey: "x".repeat(44), + policy: () => policy, +}); +const templateStore = createTemplateStore(database); +const managedUrl = new URL("https://managed.example.com/agui"); +const profileStore = createAgentProfileStore(database, managedUrl); +const installer = createTemplateInstaller({ + database, + templateStore, + pluginStore, + auditStore, + managedAgentAgUiUrl: managedUrl, +}); + +const suite = randomUUID().slice(0, 8); +const owner: AuthenticatedActor = { + id: `owner_${suite}`, + email: `owner-${suite}@openbot.test`, + role: "user", +}; +const stranger: AuthenticatedActor = { + id: `stranger_${suite}`, + email: `stranger-${suite}@openbot.test`, + role: "user", +}; +const administrator: AuthenticatedActor = { + id: `admin_${suite}`, + email: `admin-${suite}@openbot.test`, + role: "admin", +}; + +const templateSlug = `renewal-desk-${suite}`; +const skillSlug = `check-renewal-${suite}`; +const connectorId = `acme-ledger-${suite}`; +const toolRef = `${connectorId}/search_files`; + +/** Every Bot and draft this file made, so the teardown takes them and their grants with them. */ +const createdAgents: string[] = []; +const packageIds: string[] = []; + +function actorMiddleware( + actor: AuthenticatedActor, +): MiddlewareHandler<{ Variables: AppVariables }> { + return async (context, next) => { + context.set("actor", actor); + await next(); + }; +} + +/** + * The app as it is mounted, one actor at a time. + * + * Built per actor rather than reading a header, because the guard is what decides who is asking and + * a test that carried the identity in the request would be testing a guard this deployment does not + * have. + */ +function appFor( + actor: AuthenticatedActor, + options: { components?: boolean } = {}, +) { + const app = new Hono<{ Variables: AppVariables }>(); + const requireUser = actorMiddleware(actor); + app.route( + "/api/templates", + createTemplateRoutes( + { + templateStore, + installer, + auditStore, + executor: database, + managedAgent: true, + grants: pluginStore, + ...(options.components + ? { components: { grant: async () => {} } } + : {}), + }, + requireUser, + async (asking, botId) => (await profileStore.get(asking, botId)) !== null, + ), + ); + app.route( + "/api/agents", + createAgentRoutes( + profileStore, + requireUser, + false, + auditStore, + new Set(), + undefined, + createTemplateExport({ + executor: database, + templateStore, + auditStore, + plugins: pluginStore, + managedAgentAgUiUrl: managedUrl, + }), + ), + ); + return app; +} + +function yamlFor( + options: { slug?: string; roleDescription?: string; skill?: string } = {}, +) { + return `openbot_template: 1 + +template: + slug: ${options.slug ?? templateSlug} + version: "1.3" + author: acme-revops + summary: Chases overdue invoices and drafts the follow-up. + +bot: + name: Renewal Desk ${suite} + title: Accounts Receivable + role_description: >- + ${options.roleDescription ?? "Chase overdue invoices and draft a follow-up for a person to send."} + runtime: managed + skills: [${options.skill ?? skillSlug}] + +skills: + - slug: ${options.skill ?? skillSlug} + title: Check renewal risk + summary: Pull the contract and the recent tickets for one account. + instructions: >- + Find the contract and read the renewal date from it. Name every document you used. + tools: + - ${toolRef} + +requests: + connectors: + - id: ${connectorId} + why: The invoice ledger export lives there. + tools: + - ref: ${toolRef} + why: Find the ledger for one customer. + components: + - name: showBarChart + why: Ageing buckets. + +boundary: + shell: never + files: none + browser: read_only + mcp: read_only +`; +} + +/** Trail rows this suite's actors wrote, newest last. Scoped by actor, never by time. */ +async function trail(eventType: string) { + const rows = await database + .select() + .from(auditEvents) + .where(eq(auditEvents.eventType, eventType)); + return rows.filter((row) => { + const actor = (row.payload as { actor?: unknown }).actor; + return ( + actor === owner.email || + actor === stranger.email || + actor === administrator.email + ); + }); +} + +async function makeBot(input: { + id: string; + name: string; + ownerUserId: string | null; + visibility: "public" | "private"; + packaged?: boolean; +}) { + let packageId: string | null = null; + if (input.packaged) { + const [row] = await database + .insert(deploymentPackages) + .values({ + tenantId: `${input.id}-tenant`, + sourcePath: "examples/fintech", + checksum: "0".repeat(64), + }) + .returning({ id: deploymentPackages.id }); + packageId = row?.id ?? null; + if (packageId) packageIds.push(packageId); + } + await database.insert(agents).values({ + id: input.id, + name: input.name, + type: "remote_ag_ui", + // The deployment's own address, which is what `create` writes for a Bot that runs in the box. + // The packer needs it to read this as `managed` rather than as somebody's own server. + configuration: { endpoint: managedUrl.toString() }, + ...(packageId ? { packageId } : {}), + }); + await database.insert(agentProfiles).values({ + agentId: input.id, + ownerUserId: input.ownerUserId, + title: "Accounts Receivable", + roleDescription: + "Chase overdue invoices and draft the follow-up for a person to send.", + avatarSeed: `seed-${suite}`, + visibility: input.visibility, + }); + createdAgents.push(input.id); + return input.id; +} + +const packagedBot = `agent_pkg_${suite}`; +const publicBot = `agent_pub_${suite}`; +const privateBot = `agent_priv_${suite}`; + +beforeAll(async () => { + await database + .insert(users) + .values([ + { id: owner.id, email: owner.email }, + { id: stranger.id, email: stranger.email }, + { id: administrator.id, email: administrator.email }, + ]) + .onConflictDoNothing(); + + // Ownerless and public, which is what a package Bot is. Exporting one is deliberately allowed. + await makeBot({ + id: packagedBot, + name: `Risk Analyst ${suite}`, + ownerUserId: null, + visibility: "public", + packaged: true, + }); + // Somebody else's, and public: visible to everybody, manageable by nobody but its owner. + await makeBot({ + id: publicBot, + name: `Public Desk ${suite}`, + ownerUserId: owner.id, + visibility: "public", + }); + await makeBot({ + id: privateBot, + name: `Private Desk ${suite}`, + ownerUserId: owner.id, + visibility: "private", + }); + + // A connector that exists and advertises a tool, so the plan can report one ask as available and + // the grant route has something real to delegate. + await database + .insert(mcpServers) + .values({ + id: connectorId, + title: "Acme Ledger", + vendor: "Acme", + url: "https://ledger.example.com/mcp", + summary: "The invoice ledger.", + docsUrl: "https://ledger.example.com/docs", + provenance: "custom", + addedBy: administrator.email, + }) + .onConflictDoNothing(); + await database + .insert(mcpTools) + .values({ + serverId: connectorId, + name: "search_files", + description: "Find a ledger export.", + inputSchema: {}, + }) + .onConflictDoNothing(); +}); + +afterAll(async () => { + if (createdAgents.length > 0) { + await database.delete(agents).where(inArray(agents.id, createdAgents)); + } + if (packageIds.length > 0) { + await database + .delete(deploymentPackages) + .where(inArray(deploymentPackages.id, packageIds)); + } + await database.delete(mcpServers).where(eq(mcpServers.id, connectorId)); + await database + .delete(skills) + .where(inArray(skills.slug, [skillSlug, `other-${suite}`])); + await database + .delete(users) + .where(inArray(users.id, [owner.id, stranger.id, administrator.id])); +}); + +describe("exporting a coworker", () => { + test("a Bot you cannot see is not found, and a Bot you cannot manage is refused", async () => { + const hidden = await appFor(stranger).request( + `/api/agents/${privateBot}/template`, + { method: "POST" }, + ); + // The store's read filter answers first, so a private Bot is absent rather than forbidden. + expect(hidden.status).toBe(404); + + const visible = await appFor(stranger).request( + `/api/agents/${publicBot}/template`, + { method: "POST" }, + ); + // Visible to everybody and manageable by its owner: the honest answer is that this is refused, + // not that it does not exist. + expect(visible.status).toBe(403); + }); + + test("a package Bot exports for any signed-in person, and says what stayed behind", async () => { + const response = await appFor(stranger).request( + `/api/agents/${packagedBot}/template`, + { method: "POST" }, + ); + expect(response.status).toBe(201); + const body = (await response.json()) as { + templateId: string; + yaml: string; + digest: string; + stripped: string[]; + }; + + // The draft is the stranger's, not the deployment's: exporting is authoring. + const [draft] = await database + .select() + .from(botTemplates) + .where(eq(botTemplates.id, body.templateId)); + expect(draft?.ownerUserId).toBe(stranger.id); + expect(draft?.agentId).toBe(packagedBot); + + // The stripping is the interesting fact about an export, so the response names it. + expect(body.stripped.some((line) => line.startsWith("agents.id"))).toBe( + true, + ); + expect( + body.stripped.some((line) => line.startsWith("agents.package_id")), + ).toBe(true); + // A file, and one the parser will take back. + expect(parseBotTemplate(body.yaml).bot.name).toBe(`Risk Analyst ${suite}`); + + const rows = await trail("template.exported"); + const recorded = rows.find((row) => row.targetId === packagedBot); + expect(recorded).toBeDefined(); + const payload = recorded?.payload as Record; + expect(payload.digest).toBe(body.digest); + // Never the prose. The role description is the substance of a template and it is not here. + expect(JSON.stringify(payload)).not.toContain("Chase overdue invoices"); + }); + + test("a second export does not overwrite the draft the author has been editing", async () => { + const again = await appFor(stranger).request( + `/api/agents/${packagedBot}/template`, + { method: "POST" }, + ); + expect(again.status).toBe(409); + expect(((await again.json()) as { error: string }).error).toContain( + "already have a template draft", + ); + }); +}); + +describe("a draft belongs to whoever wrote it", () => { + let draftId = ""; + + beforeAll(async () => { + const draft = await templateStore.createDraft(owner, { + agentId: null, + document: parseBotTemplate(yamlFor()), + }); + draftId = draft.id; + }); + + test("the list is yours, and an administrator's is the deployment's", async () => { + const mine = (await ( + await appFor(owner).request("/api/templates") + ).json()) as { templates: { id: string; mine: boolean }[] }; + expect(mine.templates.some((row) => row.id === draftId)).toBe(true); + expect(mine.templates.every((row) => row.mine)).toBe(true); + + const theirs = (await ( + await appFor(stranger).request("/api/templates") + ).json()) as { templates: { id: string }[] }; + expect(theirs.templates.some((row) => row.id === draftId)).toBe(false); + + const all = (await ( + await appFor(administrator).request("/api/templates") + ).json()) as { templates: { id: string; mine: boolean }[] }; + const seen = all.templates.find((row) => row.id === draftId); + expect(seen).toBeDefined(); + // Ownership is reported separately from permission: an administrator sees it and it is not theirs. + expect(seen?.mine).toBe(false); + }); + + test("somebody else's draft is absent rather than forbidden", async () => { + for (const [method, path] of [ + ["PATCH", `/api/templates/${draftId}`], + ["DELETE", `/api/templates/${draftId}`], + ["GET", `/api/templates/${draftId}/file`], + ] as const) { + const response = await appFor(stranger).request(path, { + method, + ...(method === "PATCH" + ? { + body: JSON.stringify({ source: yamlFor() }), + headers: { "content-type": "application/json" }, + } + : {}), + }); + expect(response.status).toBe(404); + } + }); + + test("the file is served as a file", async () => { + const response = await appFor(owner).request( + `/api/templates/${draftId}/file`, + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/yaml"); + expect(response.headers.get("content-disposition")).toBe( + `attachment; filename="${templateSlug}.openbot.yaml"`, + ); + expect(parseBotTemplate(await response.text()).template.slug).toBe( + templateSlug, + ); + }); + + test("an edit re-runs the parser and the secret scanner", async () => { + const app = appFor(owner); + const unparseable = await app.request(`/api/templates/${draftId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: "openbot_template: 2\n" }), + }); + expect(unparseable.status).toBe(400); + expect((await unparseable.json()) as { reason: string }).toMatchObject({ + reason: "format_version", + }); + + const leaking = await app.request(`/api/templates/${draftId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + source: yamlFor({ + roleDescription: + "Reach the ledger with sk-abcdefghijklmnopqrstuvwx and chase overdue invoices.", + }), + }), + }); + expect(leaking.status).toBe(400); + const refusal = (await leaking.json()) as { + reason: string; + field: string; + error: string; + }; + expect(refusal.reason).toBe("secret_shape"); + expect(refusal.field).toBe("bot.role_description"); + // The refusal is rendered, logged and audited, so it never quotes what it found. + expect(refusal.error).not.toContain("sk-abcdefghijklmnopqrstuvwx"); + + const edited = await app.request(`/api/templates/${draftId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + source: yamlFor({ roleDescription: "Chase the overdue ones only." }), + }), + }); + expect(edited.status).toBe(200); + const [stored] = await database + .select() + .from(botTemplates) + .where(eq(botTemplates.id, draftId)); + /* + * Stored parsed rather than as the text somebody posted, which is what the schema chose and what + * makes the digest stable across quoting. The honest cost is recorded there: an author's YAML + * comments do not survive an edit. + */ + const document = stored?.document as + | { bot: { roleDescription: string } } + | undefined; + expect(document?.bot.roleDescription).toBe("Chase the overdue ones only."); + }); + + test("the owner can delete it", async () => { + const response = await appFor(owner).request(`/api/templates/${draftId}`, { + method: "DELETE", + }); + expect(response.status).toBe(204); + expect( + await database + .select() + .from(botTemplates) + .where(eq(botTemplates.id, draftId)), + ).toHaveLength(0); + }); +}); + +describe("reading a stranger's file", () => { + test("a preview writes nothing and records nothing", async () => { + const before = await trail("template.import_refused"); + const response = await appFor(owner).request("/api/templates/preview", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: yamlFor({ slug: `preview-${suite}` }) }), + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + digest: string; + plan: { + connectors: { verdict: string; tools: { verdict: string }[] }[]; + components: { verdict: string }[]; + endpoint: { required: boolean }; + }; + }; + expect(body.digest).toHaveLength(64); + // The connector exists here and advertises the tool, so the plan says the ask is satisfiable — + // which is a statement about the deployment and not a grant. + expect(body.plan.connectors[0]?.tools[0]?.verdict).toBe("available"); + expect(body.plan.components[0]?.verdict).toBe("not_in_build"); + expect(body.plan.endpoint.required).toBe(false); + + // A preview is somebody reading. Nothing is written and nothing is filed as a refusal. + expect(await trail("template.import_refused")).toHaveLength(before.length); + expect( + await database + .select() + .from(botTemplates) + .where(eq(botTemplates.slug, `preview-${suite}`)), + ).toHaveLength(0); + }); + + test("a refused document leaves exactly one row and creates nothing", async () => { + const before = (await trail("template.import_refused")).length; + const response = await appFor(owner).request("/api/templates/preview", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + // The two characters that make a stranger's prose read this deployment's environment. + source: yamlFor({ + slug: `refused-${suite}`, + roleDescription: `Chase invoices using \${KEY_ENCRYPTION_KEY}.`, + }), + }), + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string; reason: string }; + // Both halves: the code a reader groups by, and the sentence the person is shown. + expect(body.reason).toBe("interpolation"); + expect(body.error.length).toBeGreaterThan(0); + + const after = await trail("template.import_refused"); + expect(after).toHaveLength(before + 1); + const payload = after[after.length - 1]?.payload as + | { reason?: string; digest?: string } + | undefined; + expect(payload?.reason).toBe("interpolation"); + // Refused before anything was hashed, so there is no digest — and its absence is the fact. + expect(payload?.digest).toBeUndefined(); + + expect( + await database + .select() + .from(templateImports) + .where(eq(templateImports.slug, `refused-${suite}`)), + ).toHaveLength(0); + }); +}); + +describe("installing", () => { + let agentId = ""; + let importId = ""; + + test("a digest that moved is 409, and nothing is created", async () => { + const before = await database + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.name, `Renewal Desk ${suite}`)); + + const response = await appFor(owner).request("/api/templates/install", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source: yamlFor(), digest: "b".repeat(64) }), + }); + expect(response.status).toBe(409); + expect((await response.json()) as { reason: string }).toMatchObject({ + reason: "digest_moved", + }); + + const after = await database + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.name, `Renewal Desk ${suite}`)); + expect(after).toHaveLength(before.length); + + /* + * Refused after a clean parse, which is the case that gives `template.import_refused` a digest + * at all: a document turned away by the parser never got as far as being hashed, and this one + * did. The digest recorded is the one the file actually has, not the stale one that was sent. + */ + const rows = await trail("template.import_refused"); + const payload = rows[rows.length - 1]?.payload as + | { reason?: string; digest?: string; expected?: string } + | undefined; + expect(payload?.reason).toBe("digest_moved"); + expect(payload?.digest).toHaveLength(64); + expect(payload?.expected).toBe("b".repeat(64)); + }); + + test("the same file installs when the digest agrees", async () => { + const source = yamlFor(); + const preview = (await ( + await appFor(owner).request("/api/templates/preview", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source }), + }) + ).json()) as { digest: string }; + + const response = await appFor(owner).request("/api/templates/install", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source, digest: preview.digest, from: "paste" }), + }); + expect(response.status).toBe(201); + const body = (await response.json()) as { + agentId: string; + importId: string; + requests: { kind: string; ref: string; status: string }[]; + }; + agentId = body.agentId; + importId = body.importId; + createdAgents.push(agentId); + + // The ask is recorded and nothing was granted: `requested` is a statement about a decision + // nobody has made yet. + expect(body.requests.find((row) => row.ref === toolRef)?.status).toBe( + "requested", + ); + expect( + await database + .select() + .from(pluginGrants) + .where( + and(eq(pluginGrants.kind, "mcp"), eq(pluginGrants.agentId, agentId)), + ), + ).toHaveLength(0); + }); + + test("the provenance is readable by somebody who may use the Bot, and absent to anybody else", async () => { + const mine = await appFor(owner).request( + `/api/templates/imports/${agentId}`, + ); + expect(mine.status).toBe(200); + const body = (await mine.json()) as { + import: { digest: string; authorClaim: string }; + requests: unknown[]; + }; + expect(body.import.authorClaim).toBe("acme-revops"); + expect(body.requests.length).toBeGreaterThan(0); + + // 404 rather than 403, matching GET /api/plugins/for/:agentId: an imported Bot is private to the + // importer, and a distinguishable refusal is an oracle for what other people have installed. + const theirs = await appFor(stranger).request( + `/api/templates/imports/${agentId}`, + ); + expect(theirs.status).toBe(404); + expect(((await theirs.json()) as { error: string }).error).toBe( + "There is no such Bot.", + ); + }); + + test("granting is an administrator's, and it goes through the grant store", async () => { + const path = `/api/templates/imports/${agentId}/requests/mcp/${encodeURIComponent(toolRef)}/grant`; + + const refused = await appFor(owner).request(path, { method: "POST" }); + // The importer owns the Bot and still may not grant it a connector: MCP reaches another + // company's system on this deployment's credential. + expect(refused.status).toBe(403); + + const granted = await appFor(administrator).request(path, { + method: "POST", + }); + expect(granted.status).toBe(200); + expect( + ((await granted.json()) as { request: { status: string } }).request + .status, + ).toBe("granted"); + + // Written by the existing store, with the administrator's own name on it rather than the + // import's mark: a retraction must not take back what a person decided by hand. + const [row] = await database + .select() + .from(pluginGrants) + .where( + and( + eq(pluginGrants.agentId, agentId), + eq(pluginGrants.kind, "mcp"), + eq(pluginGrants.ref, toolRef), + ), + ); + expect(row?.grantedBy).toBe(administrator.email); + + const recorded = await trail("template.capability_granted"); + expect(recorded.some((event) => event.targetId === agentId)).toBe(true); + }); + + test("an ask this template never made, and a kind that is not one, are refused", async () => { + const unknown = await appFor(administrator).request( + `/api/templates/imports/${agentId}/requests/mcp/${encodeURIComponent(`${connectorId}/never_asked`)}/grant`, + { method: "POST" }, + ); + // The route acts on the LEDGER. A ref the template never asked for has no row, so there is + // nothing here that was consented to and nothing to approve. + expect(unknown.status).toBe(404); + + const badKind = await appFor(administrator).request( + `/api/templates/imports/${agentId}/requests/policy/anything/grant`, + { method: "POST" }, + ); + // Checked at runtime, not only in the types: `kind` arrives in a path segment. + expect(badKind.status).toBe(400); + }); + + test("a component ask cannot be granted where there is no component store", async () => { + const response = await appFor(administrator).request( + `/api/templates/imports/${agentId}/requests/component/showBarChart/grant`, + { method: "POST" }, + ); + // Fail closed: the deployment cannot make the grant, so it says so rather than recording a + // decision that satisfied nothing. + expect(response.status).toBe(503); + + const withStore = await appFor(administrator, { + components: true, + }).request( + `/api/templates/imports/${agentId}/requests/component/showBarChart/grant`, + { method: "POST" }, + ); + expect(withStore.status).toBe(200); + }); + + test("declining records the no, and is also an administrator's", async () => { + const path = `/api/templates/imports/${agentId}/requests/component/showBarChart/decline`; + expect( + (await appFor(stranger).request(path, { method: "POST" })).status, + ).toBe(403); + + const response = await appFor(administrator).request(path, { + method: "POST", + }); + expect(response.status).toBe(200); + expect( + ((await response.json()) as { request: { status: string } }).request + .status, + ).toBe("declined"); + expect( + (await trail("template.capability_declined")).some( + (event) => event.targetId === agentId, + ), + ).toBe(true); + }); + + test("retraction is the owner's, and takes back only what the import gave", async () => { + expect(importId.length).toBeGreaterThan(0); + const refused = await appFor(stranger).request( + `/api/templates/imports/${agentId}`, + { method: "DELETE" }, + ); + // 404, not 403: telling a stranger that this Bot has an import to retract is the same oracle the + // read above closes. + expect(refused.status).toBe(404); + + const response = await appFor(owner).request( + `/api/templates/imports/${agentId}`, + { method: "DELETE" }, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + revoked: { kind: string; ref: string }[]; + }; + expect(body.revoked.map((row) => row.ref)).toEqual([skillSlug]); + + // The administrator's own grant survives, because it does not carry the import's mark. + const held = await database + .select() + .from(pluginGrants) + .where(eq(pluginGrants.agentId, agentId)); + expect(held.map((row) => row.ref)).toEqual([toolRef]); + }); +}); + +describe("the two asks that have no Grant button", () => { + const otherSkill = `other-${suite}`; + const bareConnector = `bare-ledger-${suite}`; + const endpoint = `https://renewals-${suite}.example.com/agui`; + let agentId = ""; + + /** + * A connector named with no tools under it, and a coworker that runs somewhere else. + * + * Both land in the ledger and neither is something an administrator can approve here: one is an + * ask with nothing grantable behind it, the other was answered on the way in by whoever typed the + * address. Installed for real rather than hand-inserted, so the rows are the shape the installer + * actually writes. + */ + const source = `openbot_template: 1 + +template: + slug: remote-desk-${suite} + summary: Runs somewhere else and names a connector with nothing under it. + +bot: + name: Remote Desk ${suite} + title: Accounts Receivable + role_description: >- + Chase overdue invoices and draft the follow-up for a person to send. + runtime: remote + remote: + auth_header: Authorization + requires_key: false + sends_conversation_to: renewals.example.com + skills: [${otherSkill}] + +skills: + - slug: ${otherSkill} + title: Read the contract + summary: Pull the contract for one account. + instructions: >- + Find the contract and read the renewal date from it. + tools: [] + +requests: + connectors: + - id: ${bareConnector} + why: The ledger lives there, and nobody has written down which tools yet. +`; + + beforeAll(async () => { + const preview = (await ( + await appFor(owner).request("/api/templates/preview", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source }), + }) + ).json()) as { digest: string; plan: { endpoint: { required: boolean } } }; + expect(preview.plan.endpoint.required).toBe(true); + + const installed = await appFor(owner).request("/api/templates/install", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ source, digest: preview.digest, endpoint }), + }); + expect(installed.status).toBe(201); + agentId = ((await installed.json()) as { agentId: string }).agentId; + createdAgents.push(agentId); + }); + + test("a connector with nothing under it says to add the connector", async () => { + const response = await appFor(administrator).request( + `/api/templates/imports/${agentId}/requests/mcp/${bareConnector}/grant`, + { method: "POST" }, + ); + expect(response.status).toBe(400); + expect(((await response.json()) as { error: string }).error).toContain( + "is a connector, not a tool", + ); + /* + * The property the whole feature rests on: no optimistic grant anywhere. `store.grant` performs + * no existence check, so a row written for an absent connector would be invisible on every + * screen and would go live the day somebody added it. + */ + expect( + await database + .select() + .from(pluginGrants) + .where(eq(pluginGrants.ref, bareConnector)), + ).toHaveLength(0); + }); + + test("the address a coworker runs at is not a grant", async () => { + const host = new URL(endpoint).host; + const response = await appFor(administrator).request( + `/api/templates/imports/${agentId}/requests/endpoint/${host}/grant`, + { method: "POST" }, + ); + expect(response.status).toBe(400); + expect(((await response.json()) as { error: string }).error).toContain( + "answered by whoever imported it", + ); + + const declined = await appFor(administrator).request( + `/api/templates/imports/${agentId}/requests/endpoint/${host}/decline`, + { method: "POST" }, + ); + // Declining it is refused for the same reason. The row records that the importer answered, and + // repointing a coworker is an edit of the Bot rather than a decision on this screen. + expect(declined.status).toBe(400); + }); +}); diff --git a/server/tests/template-store.integration.test.ts b/server/tests/template-store.integration.test.ts new file mode 100644 index 00000000..2e994062 --- /dev/null +++ b/server/tests/template-store.integration.test.ts @@ -0,0 +1,379 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray } from "drizzle-orm"; +import { parseBotTemplate } from "../../shared/bot-template"; +import { createDatabase } from "../src/db/client"; +import { + agents, + botTemplates, + templateBoundaries, + templateImports, + users, +} from "../src/db/schema"; +import { + createTemplateStore, + TemplateNotFoundError, + TemplateSlugTakenError, +} from "../src/templates/store"; + +/** + * The rows a template leaves behind, and the two properties worth asserting about them. + * + * The first is that none of them is a permission: a draft is a document, an import is a record of + * what somebody consented to, and a ledger row is an ask. The second is that a draft belongs to + * somebody — a person who is not its owner and not an administrator is told it does not exist rather + * than that it is not theirs, because the alternative turns the drafts route into a way to enumerate + * what other people are working on. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + { max: 2 }, +); + +const store = createTemplateStore(database); + +const suite = randomUUID().slice(0, 8); +const owner = { id: `user_owner_${suite}`, role: "user" as const }; +const stranger = { id: `user_other_${suite}`, role: "user" as const }; +const admin = { id: `user_admin_${suite}`, role: "admin" as const }; +const bot = `agent_${suite}`; + +function yamlFor(slug: string, summary = "Chases overdue invoices.") { + return `openbot_template: 1 + +template: + slug: ${slug} + version: "1.3" + author: acme-revops + summary: ${summary} + +bot: + name: Renewal Desk + title: Accounts Receivable + role_description: >- + Chase overdue invoices. Draft a follow-up for a person to send, and name every + document you used. + runtime: managed + skills: [check-renewal-risk-${suite}] + +skills: + - slug: check-renewal-risk-${suite} + title: Check renewal risk + summary: Pull the contract and the recent tickets for one account. + instructions: >- + Find the contract and read the renewal date from it. Name each document you used. + tools: + - google-drive/search_files + +requests: + connectors: + - id: google-drive + why: The invoice ledger export lives in Drive. + tools: + - ref: google-drive/search_files + why: Find the ledger for one customer. + components: + - name: showBarChart + why: Ageing buckets. + +boundary: + shell: never + files: none + browser: read_only + navigate_hosts: + - billing.acme.example + mcp: read_only +`; +} + +const draftSlug = `renewal-desk-${suite}`; +const template = parseBotTemplate(yamlFor(draftSlug)); + +beforeAll(async () => { + await database + .insert(users) + .values( + [owner, stranger, admin].map((actor) => ({ + id: actor.id, + email: `${actor.id}@openbot.local`, + })), + ) + .onConflictDoNothing(); + await database + .insert(agents) + .values({ id: bot, name: bot, type: "built_in", configuration: {} }) + .onConflictDoNothing(); +}); + +afterAll(async () => { + await database + .delete(templateImports) + .where(eq(templateImports.agentId, bot)); + await database.delete(agents).where(eq(agents.id, bot)); + await database + .delete(users) + .where(inArray(users.id, [owner.id, stranger.id, admin.id])); +}); + +describe("a template draft", () => { + test("is created for its author, keeps the Bot it was packed from, and reads back parsed", async () => { + const draft = await store.createDraft(owner, { + agentId: bot, + document: template, + }); + + expect(draft.id.startsWith("tpl_")).toBe(true); + expect(draft.ownerUserId).toBe(owner.id); + expect(draft.agentId).toBe(bot); + expect(draft.slug).toBe(draftSlug); + // Round-tripped through the format on the way out, so a row nobody can parse is a refusal here + // rather than a document somebody is asked to consent to. + expect(draft.document.bot.name).toBe("Renewal Desk"); + expect(draft.document.skills[0]?.tools).toEqual([ + "google-drive/search_files", + ]); + + await store.deleteDraft(owner, draft.id); + }); + + test("belongs to its author: a stranger is told it does not exist, an administrator sees it", async () => { + const draft = await store.createDraft(owner, { document: template }); + + await expect(store.getDraft(stranger, draft.id)).rejects.toBeInstanceOf( + TemplateNotFoundError, + ); + /* + * The write gate is the read gate. A draft somebody may not see must not be one they can + * overwrite or delete, and both answer with the same not-found so neither confirms the id. + */ + await expect( + store.updateDraft(stranger, draft.id, template), + ).rejects.toBeInstanceOf(TemplateNotFoundError); + await expect(store.deleteDraft(stranger, draft.id)).rejects.toBeInstanceOf( + TemplateNotFoundError, + ); + + expect((await store.getDraft(admin, draft.id)).id).toBe(draft.id); + expect( + (await store.listDrafts(owner)).some((entry) => entry.id === draft.id), + ).toBe(true); + expect( + (await store.listDrafts(stranger)).some((entry) => entry.id === draft.id), + ).toBe(false); + + await store.deleteDraft(admin, draft.id); + }); + + test("is unique per author rather than across the deployment", async () => { + const mine = await store.createDraft(owner, { document: template }); + + // The same person cannot hold two files of one name; the index says so and the store names it. + await expect( + store.createDraft(owner, { document: template }), + ).rejects.toBeInstanceOf(TemplateSlugTakenError); + + /* + * Somebody else can. A template slug names a file and a draft reaches nobody until it is sent, so + * two people packing the same Bot must not race each other for a name — unlike `skills.slug`, + * which is the shared `/` namespace and is deployment-wide first-taker-keeps. + */ + const theirs = await store.createDraft(stranger, { document: template }); + expect(theirs.slug).toBe(mine.slug); + + await store.deleteDraft(owner, mine.id); + await store.deleteDraft(stranger, theirs.id); + }); + + test("an edit replaces the document and moves the slug with it", async () => { + const draft = await store.createDraft(owner, { document: template }); + const renamed = parseBotTemplate( + yamlFor(`renewal-desk-b-${suite}`, "Now it chases renewals instead."), + ); + + const updated = await store.updateDraft(owner, draft.id, renamed); + expect(updated.slug).toBe(`renewal-desk-b-${suite}`); + expect(updated.document.template.summary).toBe( + "Now it chases renewals instead.", + ); + expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual( + draft.createdAt.getTime(), + ); + + await store.deleteDraft(owner, draft.id); + await expect(store.getDraft(owner, draft.id)).rejects.toBeInstanceOf( + TemplateNotFoundError, + ); + }); + + test("outlives the Bot it was packed from", async () => { + const spare = `agent_spare_${suite}`; + await database + .insert(agents) + .values({ id: spare, name: spare, type: "built_in", configuration: {} }); + const draft = await store.createDraft(owner, { + agentId: spare, + document: template, + }); + + await database.delete(agents).where(eq(agents.id, spare)); + + /* + * `set null` rather than `cascade`. Once the document exists it is an artifact in its own right — + * the thing that was going to be published, or the thing already sent to somebody — and deleting + * the coworker it was taken from is not a reason to destroy it. + */ + const after = await store.getDraft(owner, draft.id); + expect(after.agentId).toBeNull(); + + await store.deleteDraft(owner, draft.id); + }); +}); + +describe("the provenance of an imported Bot", () => { + test("records the claim as a claim, and hands back exactly what was consented to", async () => { + const imported = await store.recordImport({ + agentId: bot, + digest: "a".repeat(64), + slug: draftSlug, + templateVersion: "1.3", + authorClaim: "acme-revops", + source: "paste", + document: template, + importedBy: "importer@openbot.local", + }); + + expect(imported.agentId).toBe(bot); + // Named `authorClaim` rather than `author` because nothing verified it and there is nothing it + // could have been verified against. + expect(imported.authorClaim).toBe("acme-revops"); + expect(imported.source).toBe("paste"); + expect(imported.sourceRef).toBeNull(); + expect(imported.document.bot.roleDescription).toBe( + template.bot.roleDescription, + ); + + const read = await store.importForAgent(bot); + expect(read?.id).toBe(imported.id); + expect(await store.importForAgent(`agent_absent_${suite}`)).toBeNull(); + }); + + test("the ledger holds the ask, and an import never overwrites a decision already made", async () => { + const imported = await store.importForAgent(bot); + if (!imported) throw new Error("the provenance row was not written"); + + await store.recordRequests([ + { + importId: imported.id, + kind: "mcp", + ref: "google-drive/search_files", + why: "Find the ledger for one customer.", + status: "unavailable", + }, + { + importId: imported.id, + kind: "component", + ref: "showBarChart", + why: "Ageing buckets.", + status: "not_in_build", + }, + ]); + + const ledger = await store.listRequests(imported.id); + expect(ledger).toHaveLength(2); + expect(ledger.map((row) => row.status).sort()).toEqual([ + "not_in_build", + "unavailable", + ]); + // Nobody has decided anything yet, and the columns say so rather than defaulting to a person. + expect(ledger.every((row) => row.decidedBy === null)).toBe(true); + + const decided = await store.decideRequest({ + importId: imported.id, + kind: "mcp", + ref: "google-drive/search_files", + status: "granted", + decidedBy: "admin@openbot.local", + }); + expect(decided?.status).toBe("granted"); + expect(decided?.decidedBy).toBe("admin@openbot.local"); + expect(decided?.decidedAt).not.toBeNull(); + + /* + * A retried install must not walk over the administrator's answer. `recordRequests` does nothing + * on a row that is already there, so the decision survives the same rows being offered again. + */ + await store.recordRequests([ + { + importId: imported.id, + kind: "mcp", + ref: "google-drive/search_files", + why: "Find the ledger for one customer.", + status: "unavailable", + }, + ]); + const again = await store.listRequests(imported.id); + expect( + again.find((row) => row.ref === "google-drive/search_files")?.status, + ).toBe("granted"); + + expect( + await store.decideRequest({ + importId: imported.id, + kind: "mcp", + ref: "nothing/at-all", + status: "declined", + decidedBy: "admin@openbot.local", + }), + ).toBeNull(); + }); + + test("a boundary is retracted softly, and only once", async () => { + const imported = await store.importForAgent(bot); + if (!imported) throw new Error("the provenance row was not written"); + + await database.insert(templateBoundaries).values({ + importId: imported.id, + agentId: bot, + expression: 'action == "shell"', + sourceKey: "shell", + }); + + expect(await store.boundariesFor(imported.id)).toHaveLength(1); + + const retired = await store.retractBoundaries(imported.id); + expect(retired).toHaveLength(1); + expect(retired[0]?.expression).toBe('action == "shell"'); + + /* + * Soft, so "this Bot was never bounded" and "somebody took this Bot's bound off" are not the same + * database state — and a second retraction re-stamps nothing, or the date of an act would move to + * the time somebody asked about it. + */ + const rows = await store.boundariesFor(imported.id); + expect(rows[0]?.removedAt).not.toBeNull(); + expect(await store.retractBoundaries(imported.id)).toHaveLength(0); + }); +}); + +describe("what the store never touches", () => { + test("a draft is a document and nothing more", async () => { + const draft = await store.createDraft(owner, { document: template }); + const [row] = await database + .select() + .from(botTemplates) + .where(eq(botTemplates.id, draft.id)) + .limit(1); + + /* + * The stored value is the parsed document rather than the YAML text, so the digest, the + * serialiser and the edit path all read one canonical thing. The cost is real and worth stating: + * an author's own comments do not survive an edit. + */ + expect(row?.document).toBeDefined(); + expect(JSON.stringify(row?.document)).not.toContain("openbot_template:"); + + await store.deleteDraft(owner, draft.id); + }); +}); From cdf945c294522890166f1a182cf97695db1f6ca0 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Sun, 30 Aug 2026 12:09:04 -0700 Subject: [PATCH 05/15] Show a person every word a stranger wrote before any of it reaches a model --- app/src/components/agents/agent-profile.tsx | 26 + app/src/components/agents/export-template.tsx | 180 ++++ app/src/components/agents/import-template.tsx | 888 ++++++++++++++++++ .../components/agents/template-requests.tsx | 174 ++++ app/src/components/ui/radio-group.tsx | 47 + app/src/lib/templates/form.ts | 92 ++ app/src/lib/templates/mutations.ts | 209 +++++ app/src/lib/templates/queries.ts | 339 +++++++ app/src/routes/_authed/_app/agents/index.tsx | 78 +- app/tests/import-template.test.tsx | 244 +++++ app/tests/template-import-form.test.ts | 96 ++ 11 files changed, 2359 insertions(+), 14 deletions(-) create mode 100644 app/src/components/agents/export-template.tsx create mode 100644 app/src/components/agents/import-template.tsx create mode 100644 app/src/components/agents/template-requests.tsx create mode 100644 app/src/components/ui/radio-group.tsx create mode 100644 app/src/lib/templates/form.ts create mode 100644 app/src/lib/templates/mutations.ts create mode 100644 app/src/lib/templates/queries.ts create mode 100644 app/tests/import-template.test.tsx create mode 100644 app/tests/template-import-form.test.ts diff --git a/app/src/components/agents/agent-profile.tsx b/app/src/components/agents/agent-profile.tsx index f74555d6..0c52d89e 100644 --- a/app/src/components/agents/agent-profile.tsx +++ b/app/src/components/agents/agent-profile.tsx @@ -4,7 +4,9 @@ import { type ReactNode, useState } from "react"; import { AbstractAvatar } from "@/components/agents/abstract-avatar"; import { AgentFields } from "@/components/agents/agent-fields"; import { CallbackTokenPanel } from "@/components/agents/callback-token-panel"; +import { ExportTemplate } from "@/components/agents/export-template"; import { HandoffPanel } from "@/components/agents/handoff-panel"; +import { TemplateRequests } from "@/components/agents/template-requests"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; @@ -16,6 +18,7 @@ import { updateAgentMutationOptions, } from "@/lib/agents/mutations"; import { agentQueryOptions } from "@/lib/agents/queries"; +import { templateImportQueryOptions } from "@/lib/templates/queries"; function Tag({ children }: { children: ReactNode }) { return ( @@ -65,6 +68,13 @@ export function AgentProfile({ agentId }: { agentId: string }) { const isConfirmingDelete = confirmingDeleteId === agentId; const agent = useQuery(agentQueryOptions(agentId)); + /* + * Where this coworker came from, or nothing — most were made by hand and this answers null for + * them without an error. Read here as well as inside the panel below because the tag belongs in + * the header beside the other things that are true of the Bot rather than in a section further + * down; both reads are the same cache entry. + */ + const imported = useQuery(templateImportQueryOptions(agentId)); const updateAgent = useMutation(updateAgentMutationOptions(queryClient)); const duplicateAgent = useMutation( duplicateAgentMutationOptions(queryClient), @@ -107,6 +117,12 @@ export function AgentProfile({ agentId }: { agentId: string }) {
{profile.visibility === "private" ? "Private" : "Public"} {profile.systemOwned ? System owned : null} + {/* + * Said out loud, permanently. An imported coworker is an ordinary Bot in every other + * respect — owned, editable, deletable — and the one fact that does not follow from + * looking at it is that its instructions were written somewhere else by somebody else. + */} + {imported.data ? Imported : null}
@@ -162,6 +178,8 @@ export function AgentProfile({ agentId }: { agentId: string }) { */} {isEditing ? null : } + {isEditing ? null : } + {actionError ? (

{actionError.message} @@ -194,6 +212,14 @@ export function AgentProfile({ agentId }: { agentId: string }) { {duplicateAgent.isPending ? "Duplicating…" : "Duplicate"} + {/* + * Beside Duplicate, because it is the same verb pointed somewhere else: Duplicate makes + * another copy here, Export makes one that can leave. A system-owned Bot is offered it as + * well as an owned one — those are the most template-worthy things in the product, and + * Duplicate already lets anybody fork them. + */} + + + {/* + * The packer refuses rather than truncating, so this sentence is usually actionable: a + * skill slug the format will not admit, prose past a ceiling, or something in the Bot's own + * text shaped like a key. None of the three is a fault in the export; each is a thing to + * fix on the coworker. + */} + {exportTemplate.error ? ( +

+ {exportTemplate.error.message} +

+ ) : null} + + ); + } + + return ( +
+

+ Template draft +

+ +

+ Read it before you send it. Widen the boundary to what this coworker + actually needs, and cut anything in the requests it does not. +

+ +