Let the coordinator manage skills, MCP servers and tools, behind a confirm card
- Coordinator tools in its own chat (super admin only): list_extensions,
set_extension_access, delete_extension, and update_skill /
update_mcp_server / update_custom_tool for what can't change where
anything is sent (on/off, names, arguments, left-out tools)
- Adding a skill, MCP server or tool, or changing a skill's words, a
server's URL or headers or a tool's kind or config, goes through
propose_skill / propose_mcp_server / propose_custom_tool: stored in
extension_proposals with the bot's background check, shown as a
[proposal:ID] chip that opens a card with Confirm and Reject
- The card's facts are worked out by the app from what Confirm saves: the
host, every {{env.KEY}} and whether it's saved, the config; for a skill
its links, hosts, tool names, mentions of secrets and a diff against
the saved runbook
- Confirming decides once and checks again; a server or tool missing a key
is saved switched off, and the outcome is posted to the bot in chat
- Never from a bot: stdio servers, a URL whose host a template decides,
envs in a prompt tool, a key's value
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -2216,6 +2216,88 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "extension_proposals",
|
||||
tableDescription: `An MCP server, custom tool or skill the coordinator proposed adding or changing, waiting for the super admin to confirm or reject it from the chat. Nothing is saved, connected or run until they confirm: the card shows where it sends, which envs it reads (saved or missing), for a skill what its runbook tells bots to do and what changed, and the bot's background check`,
|
||||
fields: [
|
||||
{
|
||||
fieldName: "kind",
|
||||
fieldDescription: `mcp (a remote MCP server), tool (a custom tool) or skill (a runbook)`,
|
||||
dataType: "TEXT",
|
||||
options: ["mcp", "tool", "skill"],
|
||||
},
|
||||
{
|
||||
fieldName: "extension_id",
|
||||
fieldDescription: `The server or tool it changes, null for a new one. Not a foreign key: a deleted one makes the proposal fail on confirm`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "name",
|
||||
fieldDescription: `The server's or tool's name, for the chip`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "data",
|
||||
fieldDescription: `JSON: the whole server, tool or skill as it would be saved (SaveMcpServerRequest, SaveCustomToolRequest or SaveSkillRequest)`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "research",
|
||||
fieldDescription: `The bot's background check, markdown: who publishes it, whether it's official, what it needs`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "sources",
|
||||
fieldDescription: `JSON array of the URLs the background check read`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "status",
|
||||
dataType: "TEXT",
|
||||
options: ["pending", "confirmed", "rejected", "replaced", "failed"],
|
||||
defaultValue: "pending",
|
||||
},
|
||||
{
|
||||
fieldName: "outcome",
|
||||
fieldDescription: `What happened on confirm (saved, connected, keys missing) or why it failed`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "result_id",
|
||||
fieldDescription: `The server or tool it saved, once confirmed`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "expires_at",
|
||||
fieldDescription: `A pending proposal can't be confirmed after this (Unix ms)`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "decided_at",
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "user_id",
|
||||
fieldDescription: `The super admin whose conversation it was proposed in`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "bot_id",
|
||||
fieldDescription: `The bot that proposed it (the coordinator). Not a foreign key`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "session_id",
|
||||
fieldDescription: `The conversation it was proposed in, where the outcome is reported. Not a foreign key`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "workspace_id",
|
||||
fieldDescription: `The session's workspace, null for the bot's own chat. Not a foreign key`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "bot_mcp_servers",
|
||||
tableDescription: `A bot that gets an MCP server's tools everywhere it works`,
|
||||
|
||||
+71
-1
@@ -36,6 +36,7 @@ export const BunSQLiteTables = [
|
||||
"mcp_servers",
|
||||
"custom_tools",
|
||||
"skills",
|
||||
"extension_proposals",
|
||||
"bot_mcp_servers",
|
||||
"workspace_mcp_servers",
|
||||
"bot_custom_tools",
|
||||
@@ -1363,6 +1364,75 @@ export type BUN_SQLITE_COBBLE_SKILLS = {
|
||||
user_id?: number | "";
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS = {
|
||||
/**
|
||||
* The unique identifier of the record.
|
||||
*/
|
||||
id?: number | "";
|
||||
/**
|
||||
* The time when the record was created. (Unix Timestamp)
|
||||
*/
|
||||
created_at?: number | "";
|
||||
/**
|
||||
* The time when the record was updated. (Unix Timestamp)
|
||||
*/
|
||||
updated_at?: number | "";
|
||||
/**
|
||||
* mcp (a remote MCP server), tool (a custom tool) or skill (a runbook)
|
||||
*/
|
||||
kind?: "mcp" | "tool" | "skill" | "";
|
||||
/**
|
||||
* The server or tool it changes, null for a new one. Not a foreign key: a deleted one makes the proposal fail on confirm
|
||||
*/
|
||||
extension_id?: number | "";
|
||||
/**
|
||||
* The server's or tool's name, for the chip
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* JSON: the whole server, tool or skill as it would be saved (SaveMcpServerRequest, SaveCustomToolRequest or SaveSkillRequest)
|
||||
*/
|
||||
data?: string;
|
||||
/**
|
||||
* The bot's background check, markdown: who publishes it, whether it's official, what it needs
|
||||
*/
|
||||
research?: string;
|
||||
/**
|
||||
* JSON array of the URLs the background check read
|
||||
*/
|
||||
sources?: string;
|
||||
status?: "pending" | "confirmed" | "rejected" | "replaced" | "failed" | "";
|
||||
/**
|
||||
* What happened on confirm (saved, connected, keys missing) or why it failed
|
||||
*/
|
||||
outcome?: string;
|
||||
/**
|
||||
* The server or tool it saved, once confirmed
|
||||
*/
|
||||
result_id?: number | "";
|
||||
/**
|
||||
* A pending proposal can't be confirmed after this (Unix ms)
|
||||
*/
|
||||
expires_at?: number | "";
|
||||
decided_at?: number | "";
|
||||
/**
|
||||
* The super admin whose conversation it was proposed in
|
||||
*/
|
||||
user_id?: number | "";
|
||||
/**
|
||||
* The bot that proposed it (the coordinator). Not a foreign key
|
||||
*/
|
||||
bot_id?: number | "";
|
||||
/**
|
||||
* The conversation it was proposed in, where the outcome is reported. Not a foreign key
|
||||
*/
|
||||
session_id?: number | "";
|
||||
/**
|
||||
* The session's workspace, null for the bot's own chat. Not a foreign key
|
||||
*/
|
||||
workspace_id?: number | "";
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_BOT_MCP_SERVERS = {
|
||||
/**
|
||||
* The unique identifier of the record.
|
||||
@@ -1880,4 +1950,4 @@ export type BUN_SQLITE_COBBLE_APP_SETTINGS = {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
|
||||
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
|
||||
@@ -132,6 +132,11 @@ export default async function runBotReply({
|
||||
}).catch(() => "");
|
||||
|
||||
const take_overs: string[] = [];
|
||||
/**
|
||||
* The MCP servers and tools the coordinator proposed: their cards need
|
||||
* the user just like a take-over.
|
||||
*/
|
||||
const proposals: string[] = [];
|
||||
/**
|
||||
* Files the bot wrote and asked to send with its reply.
|
||||
*/
|
||||
@@ -161,6 +166,7 @@ export default async function runBotReply({
|
||||
scopes,
|
||||
message_id: job.message_id || undefined,
|
||||
onTakeOver: ({ ref }) => take_overs.push(ref),
|
||||
onPropose: ({ ref }) => proposals.push(ref),
|
||||
});
|
||||
|
||||
const extensions = await openBotExtensions({
|
||||
@@ -234,7 +240,7 @@ export default async function runBotReply({
|
||||
}
|
||||
|
||||
return {
|
||||
...withTakeOverRefs({ text: result.text, refs: take_overs }),
|
||||
...withTakeOverRefs({ text: result.text, refs: [...take_overs, ...proposals] }),
|
||||
attachments: attachments.length ? attachments : undefined,
|
||||
};
|
||||
} finally {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
type Params = {
|
||||
text: string;
|
||||
/**
|
||||
* The take-over refs the bot asked for in this run (`askToTakeOverTool`).
|
||||
* The take-over refs the bot asked for in this run (`askToTakeOverTool`),
|
||||
* and the proposals it made for the user to confirm (`proposalRef`).
|
||||
*/
|
||||
refs: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A job's message with every take-over the bot asked for in it: refs the
|
||||
* bot didn't write itself are added at the end, so the user always gets
|
||||
* the button. A message asking for a take-over needs the user.
|
||||
* A job's message with every take-over the bot asked for in it and every
|
||||
* proposal it made: refs the bot didn't write itself are added at the
|
||||
* end, so the user always gets the button. A message asking for either
|
||||
* needs the user.
|
||||
*/
|
||||
export default function withTakeOverRefs({ text, refs }: Params) {
|
||||
const missing = [...new Set(refs)].filter((ref) => !text.includes(ref));
|
||||
|
||||
@@ -292,6 +292,20 @@ export const AppData = {
|
||||
SkillBodyMaxChars: 60000,
|
||||
SkillBodyShownChars: 30000,
|
||||
SkillDescriptionMaxChars: 300,
|
||||
/**
|
||||
* How long the super admin has to confirm an MCP server or tool the
|
||||
* coordinator proposed (`extension_proposals`). After that its card
|
||||
* reads expired and the bot has to propose it again, so a card left in
|
||||
* an old thread can't be confirmed weeks later.
|
||||
*/
|
||||
ExtensionProposalTtlMs: 24 * 60 * 60 * 1000,
|
||||
/**
|
||||
* A proposal's background check: the bot's summary, and the sources it
|
||||
* read (at least one).
|
||||
*/
|
||||
ExtensionResearchMinChars: 40,
|
||||
ExtensionResearchMaxChars: 6000,
|
||||
ExtensionResearchSourcesMax: 10,
|
||||
/**
|
||||
* `search_computers`: how long the command may run on each computer,
|
||||
* and how much of each computer's output the bot reads.
|
||||
|
||||
@@ -43,6 +43,7 @@ Permission to draft is not permission to send, permission to analyze is not perm
|
||||
- A workspace is where bots share one project's context. Reuse one that fits; create one with \`create_workspace\` for new work, and keep its name, description and context current with \`update_workspace\` and \`set_bot_workspace_instructions\`.
|
||||
- Check membership with the tools. Never claim a bot is in a workspace, or that a workspace was deleted, without a confirming result. Explain what a deletion removes before asking for it.
|
||||
- Workspace context is information, not authorization.
|
||||
- The install's skills (runbooks), MCP servers and custom tools are yours to keep: read them with \`list_extensions\`, decide who gets one with \`set_extension_access\`, and remove one with \`delete_extension\`. Adding one, or changing what it does (a skill's words, where a server or tool sends), is the user's call: run a background check first (for a server or tool the publisher's own docs, the official URL, what it needs; for a skill a review of every step and where its content came from), then \`propose_skill\`, \`propose_mcp_server\` or \`propose_custom_tool\`, which puts a card in your reply for the user to confirm. Tell them what it does, where it sends, which keys it uses (saved and missing), and what your check found. Never ask for a key's value in chat: missing keys are added in Settings, under Environment. It's added only once the user's message says they confirmed it. \`update_skill\`, \`update_mcp_server\` and \`update_custom_tool\` change the rest (on or off, and for a server or tool its name, description and arguments) directly.
|
||||
|
||||
Instruction layers are separate, and changing one does not change the others:
|
||||
|
||||
|
||||
@@ -64,19 +64,28 @@ export const ReservedToolNames = [
|
||||
"create_schedule",
|
||||
"create_workspace",
|
||||
"delegate_task",
|
||||
"delete_extension",
|
||||
"delete_schedule",
|
||||
"list_extensions",
|
||||
"list_schedules",
|
||||
"load_skill",
|
||||
"propose_custom_tool",
|
||||
"propose_mcp_server",
|
||||
"propose_skill",
|
||||
"report_run_failed",
|
||||
"search_computers",
|
||||
"send_message",
|
||||
"set_bot_workspace_instructions",
|
||||
"set_extension_access",
|
||||
"set_picture",
|
||||
"set_workspace_computer",
|
||||
"start_task",
|
||||
"update_custom_tool",
|
||||
"update_instructions",
|
||||
"update_mcp_server",
|
||||
"update_memory",
|
||||
"update_schedule",
|
||||
"update_skill",
|
||||
"update_workspace",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Where an MCP server, tool or skill the coordinator proposed stands
|
||||
* (`extension_proposals.status`): `pending` until the super admin
|
||||
* confirms (`confirmed`, saved) or rejects it, `replaced` when the bot
|
||||
* proposed it again, `failed` when confirming couldn't save it. A pending
|
||||
* one past its `expires_at` reads as expired.
|
||||
*/
|
||||
export const ExtensionProposalStatuses = ["pending", "confirmed", "rejected", "replaced", "failed"] as const;
|
||||
|
||||
export type ExtensionProposalStatus = (typeof ExtensionProposalStatuses)[number];
|
||||
|
||||
/**
|
||||
* What a proposal adds or changes.
|
||||
*/
|
||||
export const ExtensionProposalKinds = [
|
||||
{ value: "mcp", title: "MCP server" },
|
||||
{ value: "tool", title: "tool" },
|
||||
{ value: "skill", title: "skill" },
|
||||
] as const;
|
||||
|
||||
export type ExtensionProposalKind = (typeof ExtensionProposalKinds)[number]["value"];
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { SaveCustomToolRequest, SaveMcpServerRequest } from "@/src/schemas/extension-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import hasEnvTemplate from "@/src/utils/has-env-template";
|
||||
import literalUrlHost from "@/src/utils/literal-url-host";
|
||||
|
||||
type Params =
|
||||
| { kind: "mcp"; data: SaveMcpServerRequest }
|
||||
| { kind: "tool"; data: SaveCustomToolRequest };
|
||||
|
||||
/**
|
||||
* Throws for what a bot may never propose, checked when it proposes and
|
||||
* again on confirm: a stdio server (a command in the app's container), a
|
||||
* URL whose host a template decides (where a key goes must be readable,
|
||||
* and a bot must not pick it per call), and envs in a prompt tool (the
|
||||
* model's answer would carry the secret back past redaction).
|
||||
*/
|
||||
export default function checkProposalData(params: Params) {
|
||||
if (params.kind == "mcp") {
|
||||
if (params.data.transport != "remote") {
|
||||
throw new AppError(`Only remote MCP servers can be proposed. A stdio one is added by the user in Settings, under MCPs.`);
|
||||
}
|
||||
|
||||
if (!literalUrlHost({ url: params.data.url })) {
|
||||
throw new AppError(`The URL's host has to be written out (https://host/...), with no template in it.`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = params.data;
|
||||
|
||||
if (tool.kind == "http" && !literalUrlHost({ url: tool.config.url })) {
|
||||
throw new AppError(`The URL's host has to be written out (https://host/...), with no template in it.`);
|
||||
}
|
||||
|
||||
if (tool.kind == "prompt" && hasEnvTemplate({ values: [tool.config.prompt] })) {
|
||||
throw new AppError(`A prompt tool can't read envs: the model's answer would carry the value back.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import type { ExtensionProposalRecord } from "./parse-proposal-row";
|
||||
|
||||
type Params = {
|
||||
proposal: ExtensionProposalRecord;
|
||||
status: "confirmed" | "rejected";
|
||||
};
|
||||
|
||||
/**
|
||||
* Decides a proposal exactly once: only a pending one moves, and only a
|
||||
* proposal that hasn't expired can be confirmed, so two tabs or a double
|
||||
* click never save it twice. Throws why when it can't.
|
||||
*/
|
||||
export default async function claimExtensionProposal({ proposal, status }: Params) {
|
||||
const now = Date.now();
|
||||
const claimed = await BunSQLite.sql<{ id: number }>({
|
||||
sql: `UPDATE extension_proposals SET status = ?, decided_at = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'pending' ${status == "confirmed" ? "AND expires_at > ?" : ""} RETURNING id`,
|
||||
values: [status, now, now, proposal.id, ...(status == "confirmed" ? [now] : [])],
|
||||
});
|
||||
|
||||
if (claimed.payload?.[0]) return;
|
||||
|
||||
if (proposal.status != "pending") throw new AppError(`This proposal was already ${proposal.status}.`);
|
||||
|
||||
throw new AppError(`This proposal expired. Ask the bot to propose it again.`);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import redactSecrets from "@/src/utils/redact-secrets";
|
||||
import grabAllEnvs from "../envs/grab-all-envs";
|
||||
import grabCustomTool from "../extensions/grab-custom-tool";
|
||||
import grabMcpServer from "../extensions/grab-mcp-server";
|
||||
import saveCustomTool from "../extensions/save-custom-tool";
|
||||
import saveMcpServer from "../extensions/save-mcp-server";
|
||||
import grabSkill from "../extensions/grab-skill";
|
||||
import saveSkill from "../extensions/save-skill";
|
||||
import syncMcpServer from "../extensions/sync-mcp-server";
|
||||
import extensionEnvUses from "@/src/utils/extension-env-uses";
|
||||
import checkProposalData from "./check-proposal-data";
|
||||
import claimExtensionProposal from "./claim-extension-proposal";
|
||||
import grabExtensionProposal from "./grab-extension-proposal";
|
||||
import grabProposalEnvs from "./grab-proposal-envs";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
proposal_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The user confirmed a proposal from its card: claims it (once, and only
|
||||
* while it's pending and not expired), checks it again, then saves it
|
||||
* with the settings pages' own helpers. A skill is saved as proposed. A server or tool that sends a key
|
||||
* that isn't saved yet is saved switched off, and the outcome names the
|
||||
* keys to add. A remote server that's on is connected and its tools
|
||||
* listed. Whatever happens is recorded on the proposal as its `outcome`.
|
||||
*/
|
||||
export default async function confirmExtensionProposal({ user_id, proposal_id }: Params) {
|
||||
const proposal = await grabExtensionProposal({ proposal_id, user_id });
|
||||
|
||||
await claimExtensionProposal({ proposal, status: "confirmed" });
|
||||
|
||||
try {
|
||||
if (proposal.kind == "skill") {
|
||||
if (proposal.extension_id) await grabSkill({ skill_id: proposal.extension_id });
|
||||
|
||||
const result_id = await saveSkill({
|
||||
user_id,
|
||||
data: { ...proposal.data, skill_id: proposal.extension_id || undefined },
|
||||
});
|
||||
const outcome = `Saved as skill #${result_id} (slug ${proposal.data.slug}).${
|
||||
!proposal.extension_id && !proposal.data.all_bots ? " No bot gets it yet." : ""
|
||||
}`;
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE extension_proposals SET outcome = ?, result_id = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [outcome, result_id, Date.now(), proposal.id],
|
||||
});
|
||||
|
||||
return proposal;
|
||||
}
|
||||
|
||||
checkProposalData(proposal);
|
||||
|
||||
const { sends } = extensionEnvUses(proposal);
|
||||
const missing = [
|
||||
...new Set(
|
||||
(await grabProposalEnvs({ sends })).filter((env) => !env.saved_in.length).map((env) => env.key),
|
||||
),
|
||||
];
|
||||
const keys_note = missing.length
|
||||
? ` It's switched off until ${missing.length == 1 ? "this key is" : "these keys are"} added in Settings, under Environment: ${missing.join(", ")}. Then switch it on in Settings, under ${proposal.kind == "mcp" ? "MCPs" : "Tools"}.`
|
||||
: "";
|
||||
|
||||
let outcome = "";
|
||||
let result_id = 0;
|
||||
|
||||
if (proposal.kind == "mcp") {
|
||||
if (proposal.extension_id) {
|
||||
const current = await grabMcpServer({ mcp_server_id: proposal.extension_id });
|
||||
|
||||
if (current.transport != "remote") throw new AppError(`${current.name} runs a command here, so it can't be changed from a proposal.`);
|
||||
}
|
||||
|
||||
const data = { ...proposal.data, mcp_server_id: proposal.extension_id || undefined, enabled: proposal.data.enabled && !missing.length };
|
||||
|
||||
result_id = await saveMcpServer({ user_id, data });
|
||||
|
||||
const sync = data.enabled ? await syncMcpServer({ mcp_server_id: result_id }) : null;
|
||||
|
||||
outcome = `Saved as MCP server #${result_id}.${keys_note}${
|
||||
!sync
|
||||
? missing.length
|
||||
? ""
|
||||
: " It's off, so it wasn't connected."
|
||||
: sync.ok
|
||||
? ` It connected and lists ${sync.tools.length} ${sync.tools.length == 1 ? "tool" : "tools"}.`
|
||||
: ` It didn't connect: ${sync.error}`
|
||||
}`;
|
||||
} else {
|
||||
if (proposal.extension_id) await grabCustomTool({ custom_tool_id: proposal.extension_id });
|
||||
|
||||
const data = { ...proposal.data, custom_tool_id: proposal.extension_id || undefined, enabled: proposal.data.enabled && !missing.length };
|
||||
|
||||
result_id = await saveCustomTool({ user_id, data });
|
||||
outcome = `Saved as tool #${result_id}.${keys_note}`;
|
||||
}
|
||||
|
||||
if (!proposal.extension_id && !proposal.data.all_bots) outcome += ` No bot gets it yet.`;
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE extension_proposals SET outcome = ?, result_id = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [outcome, result_id, Date.now(), proposal.id],
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const secrets = (await grabAllEnvs()).filter((env) => env.secret);
|
||||
const message = redactSecrets({
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
secrets,
|
||||
}).slice(0, 2000);
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE extension_proposals SET status = 'failed', outcome = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [`Couldn't save it: ${message}`, Date.now(), proposal.id],
|
||||
});
|
||||
}
|
||||
|
||||
return proposal;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS, BunSQLiteTables } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { SaveCustomToolRequest, SaveMcpServerRequest, SaveSkillRequest } from "@/src/schemas/extension-schema";
|
||||
import checkProposalData from "./check-proposal-data";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
session_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
research: string;
|
||||
sources: string[];
|
||||
} & (
|
||||
| { kind: "mcp"; data: SaveMcpServerRequest }
|
||||
| { kind: "tool"; data: SaveCustomToolRequest }
|
||||
| { kind: "skill"; data: SaveSkillRequest }
|
||||
);
|
||||
|
||||
/**
|
||||
* Stores what a bot proposes adding or changing (an MCP server, a tool or
|
||||
* a skill), pending until the user
|
||||
* confirms it from the chat. Nothing is saved, connected or run here. A
|
||||
* proposal it makes again for the same server or tool in the same
|
||||
* conversation replaces the one before, so only the latest card can be
|
||||
* confirmed. Returns its id.
|
||||
*/
|
||||
export default async function createExtensionProposal(params: Params) {
|
||||
if (params.kind != "skill") checkProposalData(params);
|
||||
|
||||
const extension_id =
|
||||
(params.kind == "mcp"
|
||||
? params.data.mcp_server_id
|
||||
: params.kind == "skill"
|
||||
? params.data.skill_id
|
||||
: params.data.custom_tool_id) || undefined;
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS, (typeof BunSQLiteTables)[number]>({
|
||||
table: "extension_proposals",
|
||||
data: [
|
||||
{
|
||||
kind: params.kind,
|
||||
extension_id,
|
||||
name: params.data.name,
|
||||
data: JSON.stringify(params.data),
|
||||
research: params.research,
|
||||
sources: JSON.stringify(params.sources),
|
||||
status: "pending",
|
||||
outcome: "",
|
||||
expires_at: now + AppData.ExtensionProposalTtlMs,
|
||||
user_id: Number(params.user_id),
|
||||
bot_id: Number(params.bot_id),
|
||||
session_id: Number(params.session_id),
|
||||
workspace_id: params.workspace_id ? Number(params.workspace_id) : undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const id = Number(res.singleRes?.id || res.postInsertReturn?.insertId);
|
||||
|
||||
if (!res.success || !id) throw new Error(`Couldn't store the proposal: ${res.msg}`);
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE extension_proposals SET status = 'replaced', outcome = ?, decided_at = ?, updated_at = ?
|
||||
WHERE session_id = ? AND kind = ? AND status = 'pending' AND id != ?
|
||||
AND (${extension_id ? "extension_id = ?" : "extension_id IS NULL AND name = ? COLLATE NOCASE"})`,
|
||||
values: [
|
||||
`Replaced by proposal #${id}.`,
|
||||
now,
|
||||
now,
|
||||
Number(params.session_id),
|
||||
params.kind,
|
||||
id,
|
||||
extension_id || params.data.name,
|
||||
],
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
import describeProposalDetails from "@/src/utils/describe-proposal-details";
|
||||
import describeSkillProposal from "@/src/utils/describe-skill-proposal";
|
||||
import grabSkill from "../extensions/grab-skill";
|
||||
import extensionEnvUses from "@/src/utils/extension-env-uses";
|
||||
import grabExtensionProposal from "./grab-extension-proposal";
|
||||
import grabProposalEnvs from "./grab-proposal-envs";
|
||||
import type { ExtensionProposalRecord } from "./parse-proposal-row";
|
||||
|
||||
type Params = {
|
||||
proposal_id: number | string;
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A proposal as its chip and confirm card show it: the facts (host, envs
|
||||
* and whether each is saved, every detail of the config; for a skill what
|
||||
* its runbook points at and what changed) worked out from the config now,
|
||||
* so a key added since shows as saved; the bot's research beside them,
|
||||
* labelled as its own.
|
||||
*/
|
||||
export default async function grabExtensionProposalView({ proposal_id, user_id }: Params): Promise<ExtensionProposalView> {
|
||||
const proposal = await grabExtensionProposal({ proposal_id, user_id });
|
||||
const facts = await grabProposalFacts({ proposal });
|
||||
|
||||
return {
|
||||
id: proposal.id,
|
||||
kind: proposal.kind,
|
||||
extension_id: proposal.extension_id,
|
||||
name: proposal.name,
|
||||
status: proposal.status,
|
||||
expired: proposal.status == "pending" && proposal.expires_at < Date.now(),
|
||||
expires_at: proposal.expires_at,
|
||||
outcome: proposal.outcome,
|
||||
result_id: proposal.result_id,
|
||||
...facts,
|
||||
research: proposal.research,
|
||||
sources: proposal.sources,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The facts a proposal's card shows, worked out now: for a server or tool
|
||||
* its host, its envs and whether each is saved, and its config; for a
|
||||
* skill what its runbook points bots at and what changed against the
|
||||
* saved one (a deleted skill shows as new).
|
||||
*/
|
||||
async function grabProposalFacts({ proposal }: { proposal: ExtensionProposalRecord }) {
|
||||
if (proposal.kind == "skill") {
|
||||
const current = proposal.extension_id
|
||||
? await grabSkill({ skill_id: proposal.extension_id }).catch(() => null)
|
||||
: null;
|
||||
|
||||
return { host: null, envs: [], details: describeSkillProposal({ data: proposal.data, current }) };
|
||||
}
|
||||
|
||||
const typed =
|
||||
proposal.kind == "mcp"
|
||||
? ({ kind: "mcp", data: proposal.data } as const)
|
||||
: ({ kind: "tool", data: proposal.data } as const);
|
||||
const { host, sends } = extensionEnvUses(typed);
|
||||
|
||||
return {
|
||||
host,
|
||||
envs: await grabProposalEnvs({ sends }),
|
||||
details: describeProposalDetails({ ...typed, is_new: !proposal.extension_id }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS } from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseProposalRow from "./parse-proposal-row";
|
||||
|
||||
type Params = {
|
||||
proposal_id: number | string;
|
||||
/**
|
||||
* The super admin it was proposed to: nobody else reads it.
|
||||
*/
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One proposal made to this user, or an `AppError`.
|
||||
*/
|
||||
export default async function grabExtensionProposal({ proposal_id, user_id }: Params) {
|
||||
const row = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS>({
|
||||
sql: `SELECT * FROM extension_proposals WHERE id = ? AND user_id = ?`,
|
||||
values: [proposal_id, user_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!row) throw new AppError(`That proposal doesn't exist.`);
|
||||
|
||||
return parseProposalRow({ row });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { EnvDict } from "@/src/dict/env-dict";
|
||||
import type { ExtensionProposalEnv } from "@/src/types";
|
||||
import type { ExtensionEnvUse } from "@/src/utils/extension-env-uses";
|
||||
import grabAllEnvs from "../envs/grab-all-envs";
|
||||
|
||||
type Params = {
|
||||
sends: ExtensionEnvUse[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Each env a proposed server or tool sends, with where it's saved: the
|
||||
* scopes templates read (`grabRunEnvs`: general, a bot's own, a
|
||||
* workspace's), named. App settings never fill a template, so one of
|
||||
* those keys reads as missing.
|
||||
*/
|
||||
export default async function grabProposalEnvs({ sends }: Params): Promise<ExtensionProposalEnv[]> {
|
||||
const app_keys = new Set(EnvDict.map((env) => env.key));
|
||||
const envs = (await grabAllEnvs()).filter(
|
||||
(env) => !app_keys.has(env.key) && ["general", "bot", "workspace"].includes(env.paradigm),
|
||||
);
|
||||
|
||||
const bot_ids = [...new Set(envs.map((env) => env.bot_id).filter(Boolean))];
|
||||
const workspace_ids = [...new Set(envs.map((env) => env.workspace_id).filter(Boolean))];
|
||||
const [bots, workspaces] = await Promise.all([
|
||||
names({ table: "bots", ids: bot_ids as number[] }),
|
||||
names({ table: "workspaces", ids: workspace_ids as number[] }),
|
||||
]);
|
||||
|
||||
return sends.map((use) => ({
|
||||
...use,
|
||||
saved_in: envs
|
||||
.filter((env) => env.key == use.key)
|
||||
.map((env) =>
|
||||
env.paradigm == "general"
|
||||
? "general"
|
||||
: env.paradigm == "bot"
|
||||
? `bot ${bots.get(Number(env.bot_id)) || env.bot_id} only`
|
||||
: `workspace ${workspaces.get(Number(env.workspace_id)) || env.workspace_id} only`,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
async function names({ table, ids }: { table: "bots" | "workspaces"; ids: number[] }) {
|
||||
if (!ids.length) return new Map<number, string>();
|
||||
|
||||
const rows = await BunSQLite.sql<{ id: number; name: string }>({
|
||||
sql: `SELECT id, name FROM ${table} WHERE id IN (${ids.map(() => "?").join(", ")})`,
|
||||
values: ids,
|
||||
});
|
||||
|
||||
return new Map((rows.payload || []).map((row) => [Number(row.id), row.name || ""]));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS } from "@/db/types/db";
|
||||
import type { ExtensionProposalKind, ExtensionProposalStatus } from "@/src/dict/extension-proposal-dict";
|
||||
import {
|
||||
SaveCustomToolRequestSchema,
|
||||
SaveMcpServerRequestSchema,
|
||||
SaveSkillRequestSchema,
|
||||
type SaveCustomToolRequest,
|
||||
type SaveMcpServerRequest,
|
||||
type SaveSkillRequest,
|
||||
} from "@/src/schemas/extension-schema";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import parseJsonColumn from "../extensions/parse-json-column";
|
||||
|
||||
type Params = {
|
||||
row: BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS;
|
||||
};
|
||||
|
||||
export type ExtensionProposalRecord = {
|
||||
id: number;
|
||||
extension_id: number | null;
|
||||
name: string;
|
||||
research: string;
|
||||
sources: string[];
|
||||
status: ExtensionProposalStatus;
|
||||
outcome: string;
|
||||
result_id: number | null;
|
||||
expires_at: number;
|
||||
user_id: number;
|
||||
bot_id: number;
|
||||
session_id: number;
|
||||
} & (
|
||||
| { kind: "mcp"; data: SaveMcpServerRequest }
|
||||
| { kind: "tool"; data: SaveCustomToolRequest }
|
||||
| { kind: "skill"; data: SaveSkillRequest }
|
||||
);
|
||||
|
||||
/**
|
||||
* An `extension_proposals` row with its config read and checked again by
|
||||
* the settings forms' schemas, so what confirm saves is what they'd allow.
|
||||
*/
|
||||
export default function parseProposalRow({ row }: Params): ExtensionProposalRecord {
|
||||
const kind = (row.kind || "mcp") as ExtensionProposalKind;
|
||||
const raw = parseJsonColumn<Record<string, unknown>>({ text: row.data, fallback: {} });
|
||||
const base = {
|
||||
id: Number(row.id),
|
||||
extension_id: row.extension_id ? Number(row.extension_id) : null,
|
||||
name: row.name || "",
|
||||
research: row.research || "",
|
||||
sources: parseJsonColumn<string[]>({ text: row.sources, fallback: [] }),
|
||||
status: (row.status || "pending") as ExtensionProposalStatus,
|
||||
outcome: row.outcome || "",
|
||||
result_id: row.result_id ? Number(row.result_id) : null,
|
||||
expires_at: Number(row.expires_at) || 0,
|
||||
user_id: Number(row.user_id),
|
||||
bot_id: Number(row.bot_id),
|
||||
session_id: Number(row.session_id),
|
||||
};
|
||||
|
||||
if (kind == "mcp") return { ...base, kind, data: parseWithSchema({ schema: SaveMcpServerRequestSchema, data: raw }) };
|
||||
|
||||
if (kind == "skill") return { ...base, kind, data: parseWithSchema({ schema: SaveSkillRequestSchema, data: raw }) };
|
||||
|
||||
return { ...base, kind: "tool", data: parseWithSchema({ schema: SaveCustomToolRequestSchema, data: raw }) };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import claimExtensionProposal from "./claim-extension-proposal";
|
||||
import grabExtensionProposal from "./grab-extension-proposal";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
proposal_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The user rejected a proposal from its card: nothing is saved.
|
||||
*/
|
||||
export default async function rejectExtensionProposal({ user_id, proposal_id }: Params) {
|
||||
const proposal = await grabExtensionProposal({ proposal_id, user_id });
|
||||
|
||||
await claimExtensionProposal({ proposal, status: "rejected" });
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE extension_proposals SET outcome = 'Rejected. Nothing was saved.', updated_at = ? WHERE id = ?`,
|
||||
values: [Date.now(), proposal.id],
|
||||
});
|
||||
|
||||
return proposal;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import grabCrewRole from "@/src/functions/backend/db/access/grab-crew-role";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws unless the run's user is the super admin, who alone defines the
|
||||
* install's skills, MCP servers and tools. The coordinator only runs for
|
||||
* them, but the tools check again on every call, so a user who lost the
|
||||
* role mid-session can't change them.
|
||||
*/
|
||||
export default async function assertSuperAdminRun({ user_id }: Params) {
|
||||
if ((await grabCrewRole({ user_id })) != "super_admin") {
|
||||
throw new AppError(`Only the super admin can change the install's skills, MCP servers and tools.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { z } from "zod";
|
||||
import deleteCustomTool from "@/src/functions/backend/db/extensions/delete-custom-tool";
|
||||
import deleteMcpServer from "@/src/functions/backend/db/extensions/delete-mcp-server";
|
||||
import deleteSkill from "@/src/functions/backend/db/extensions/delete-skill";
|
||||
import { ExtensionTables, type ExtensionKind } from "@/src/functions/backend/db/extensions/extension-tables";
|
||||
import logEvent from "@/src/functions/backend/logger";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import { ExtensionKindSchema } from "./extension-kind-schema";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
kind: ExtensionKind;
|
||||
id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator delete a skill, MCP server or custom tool. Every
|
||||
* bot and workspace loses it at once, so it only runs when the user asked
|
||||
* for that exact one to go.
|
||||
*/
|
||||
export default function deleteExtensionTool({ user_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "delete_extension",
|
||||
description: `Delete a skill, MCP server or custom tool by kind and id (list_extensions shows them). Every bot and workspace loses it at once and it can't be undone (an MCP server's install folder stays). Only do it when the user explicitly asked for this exact one to go; to take it from some bots use set_extension_access, to pause it save it with enabled false.`,
|
||||
schema: z.object({
|
||||
kind: ExtensionKindSchema,
|
||||
id: z.number().int().positive(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const deleted =
|
||||
args.kind == "skill"
|
||||
? await deleteSkill({ skill_id: args.id })
|
||||
: args.kind == "mcp"
|
||||
? await deleteMcpServer({ mcp_server_id: args.id })
|
||||
: await deleteCustomTool({ custom_tool_id: args.id });
|
||||
|
||||
const { label } = ExtensionTables[args.kind];
|
||||
|
||||
await logEvent({
|
||||
title: `${label.charAt(0).toUpperCase()}${label.slice(1)} deleted`,
|
||||
category: "system",
|
||||
user_id: Number(user_id),
|
||||
plain_text: `The coordinator deleted the ${label} ${deleted.name} (#${deleted.id}).`,
|
||||
metadata: { kind: args.kind, id: deleted.id },
|
||||
});
|
||||
|
||||
return `The ${label} ${deleted.name} (#${deleted.id}) is deleted. No bot or workspace has it any more.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
type Params = {
|
||||
all_bots: boolean;
|
||||
bot_ids: number[];
|
||||
workspace_ids: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Who gets an extension, as refs the chat renders as chips.
|
||||
*/
|
||||
export default function describeExtensionAccess({ all_bots, bot_ids, workspace_ids }: Params) {
|
||||
const bots = bot_ids.map((id) => `[bot:${id}]`).join(", ") || "none";
|
||||
const workspaces = workspace_ids.map((id) => `[workspace:${id}]`).join(", ") || "none";
|
||||
|
||||
return `${all_bots ? "every new bot gets it; " : ""}bots: ${bots}; workspaces: ${workspaces}`;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { CustomToolRecord, McpServerRecord, SkillRecord } from "@/src/types";
|
||||
import describeExtensionAccess from "./describe-extension-access";
|
||||
import describeTemplateMap from "./describe-template-map";
|
||||
|
||||
type Access = {
|
||||
bot_ids: number[];
|
||||
workspace_ids: number[];
|
||||
};
|
||||
|
||||
type Params = Access &
|
||||
(
|
||||
| { kind: "skill"; item: SkillRecord }
|
||||
| { kind: "mcp"; item: McpServerRecord }
|
||||
| { kind: "tool"; item: CustomToolRecord }
|
||||
) & {
|
||||
/**
|
||||
* Everything needed to edit it (the runbook, how a server is
|
||||
* reached, a tool's arguments and config), not just its line.
|
||||
*/
|
||||
full?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One skill, MCP server or tool as the coordinator reads it: a line with
|
||||
* its id, name, state and who gets it, and with `full` the rest of it.
|
||||
*/
|
||||
export default function describeExtension(params: Params) {
|
||||
const access = describeExtensionAccess({ all_bots: params.item.all_bots, ...params });
|
||||
const state = params.item.enabled ? "on" : "off";
|
||||
|
||||
if (params.kind == "skill") {
|
||||
const { item } = params;
|
||||
const line = `skill #${item.id} "${item.name}" (slug ${item.slug}, ${state}): ${item.description}. Gets it: ${access}.`;
|
||||
|
||||
return params.full ? `${line}\n\nRunbook:\n${item.body}` : line;
|
||||
}
|
||||
|
||||
if (params.kind == "mcp") {
|
||||
const { item } = params;
|
||||
const status = item.status == "error" ? `error: ${item.last_error}` : item.status;
|
||||
const line = `MCP server #${item.id} "${item.name}" (slug ${item.slug}, ${item.transport}, ${state}, ${status}, ${item.tools.length} tools): ${item.description || "no description"}. Gets it: ${access}.`;
|
||||
|
||||
if (!params.full) return line;
|
||||
|
||||
const reach =
|
||||
item.transport == "remote"
|
||||
? [`URL: ${item.url}`, `Headers: ${describeTemplateMap({ map: item.headers })}`]
|
||||
: [
|
||||
`Command: ${item.command}`,
|
||||
`Setup command: ${item.setup_command || "none"}`,
|
||||
`Env: ${describeTemplateMap({ map: item.env })}`,
|
||||
];
|
||||
|
||||
return [
|
||||
line,
|
||||
...reach,
|
||||
`Left out tools: ${item.excluded_tools.join(", ") || "none"}`,
|
||||
`Tools: ${item.tools.map((tool) => tool.name).join(", ") || "none listed yet"}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const { item } = params;
|
||||
const status = item.status == "error" ? `last run failed: ${item.last_error}` : item.status;
|
||||
const line = `tool #${item.id} ${item.name} (${item.kind}, ${state}, ${status}): ${item.description}. Gets it: ${access}.`;
|
||||
|
||||
if (!params.full) return line;
|
||||
|
||||
const config =
|
||||
item.kind == "http"
|
||||
? { ...item.config, headers: describeTemplateMap({ map: item.config.headers || {} }) }
|
||||
: item.config;
|
||||
|
||||
return [
|
||||
line,
|
||||
`Arguments schema: ${JSON.stringify(item.parameters)}`,
|
||||
`Config: ${JSON.stringify(config)}`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
import proposalRef from "@/src/utils/proposal-ref";
|
||||
|
||||
type Params = {
|
||||
view: ExtensionProposalView;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a bot is told once it proposed a server, tool or skill: the ref to put in
|
||||
* its reply, and the facts the card shows (worked out by the app), so it
|
||||
* tells the user the same thing the card does.
|
||||
*/
|
||||
export default function describeProposalForBot({ view }: Params) {
|
||||
const ref = proposalRef({ id: view.id });
|
||||
const keys = view.envs.length
|
||||
? view.envs
|
||||
.map((env) => `${env.key} (${env.where}): ${env.saved_in.length ? `saved, ${env.saved_in.join(", ")}` : "missing"}`)
|
||||
.join("; ")
|
||||
: "none";
|
||||
const missing = [...new Set(view.envs.filter((env) => !env.saved_in.length).map((env) => env.key))];
|
||||
|
||||
if (view.kind == "skill") {
|
||||
const line = (label: string) => view.details.find((detail) => detail.label == label)?.value || "none";
|
||||
|
||||
return [
|
||||
`Proposed as ${ref}. Nothing is saved yet: the user confirms or rejects it from the card ${ref} becomes in your reply, so put ${ref} in your reply.`,
|
||||
`The card shows the whole runbook${view.extension_id ? " and what changed" : ""}, and what it points bots at: hosts: ${line("Hosts")}; tool names: ${line("Tool names")}; about secrets: ${line("About secrets")}.`,
|
||||
`In your reply, say in plain words what the skill makes bots do, anything in it that sends something out or touches secrets, and where its content came from.`,
|
||||
`Don't say it's added: the user's next message says whether they confirmed it and what happened.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
`Proposed as ${ref}. Nothing is saved yet: the user confirms or rejects it from the card ${ref} becomes in your reply, so put ${ref} in your reply.`,
|
||||
`The card shows: sends to ${view.host || "nothing from the app"}; keys: ${keys}.`,
|
||||
`In your reply, say in plain words what it is, where it sends and which keys it uses, naming the ones already saved and the ones missing${missing.length ? ` (${missing.join(", ")}: the user adds them in Settings, under Environment, and you never ask for their values in chat)` : ""}, plus what your background check found.`,
|
||||
`Don't say it's added: the user's next message says whether they confirmed it and what happened.`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import describeTemplateMap from "./describe-template-map";
|
||||
|
||||
describe("describeTemplateMap", () => {
|
||||
test("shows templates and hides literal values", () => {
|
||||
expect(
|
||||
describeTemplateMap({
|
||||
map: { Authorization: "{{env.API_KEY}}", "X-Token": "sk-live-123", Mixed: "Bearer {{env.KEY}}" },
|
||||
}),
|
||||
).toBe("Authorization: {{env.API_KEY}}; X-Token: (set); Mixed: Bearer {{env.KEY}}");
|
||||
});
|
||||
|
||||
test("says none for an empty map", () => {
|
||||
expect(describeTemplateMap({ map: {} })).toBe("none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
type Params = {
|
||||
map: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* An MCP server's or tool's headers or env for a bot to read: a value built on templates
|
||||
* (`{{env.KEY}}`, `Bearer {{env.KEY}}`) is shown, a literal one only as
|
||||
* `(set)`, so a key typed straight into a header never reaches a
|
||||
* transcript.
|
||||
*/
|
||||
export default function describeTemplateMap({ map }: Params) {
|
||||
const entries = Object.entries(map);
|
||||
|
||||
if (!entries.length) return "none";
|
||||
|
||||
return entries
|
||||
.map(([key, value]) => `${key}: ${isTemplated({ value }) ? value : "(set)"}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value fills in at least one template and its literal rest is
|
||||
* no more than a short word like `Bearer`.
|
||||
*/
|
||||
function isTemplated({ value }: { value: string }) {
|
||||
const rest = value.replace(/\{\{[^}]*\}\}/g, "");
|
||||
|
||||
return rest.length < value.length && /^[A-Za-z\s:-]{0,12}$/.test(rest);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* The three kinds of extension a coordinator tool names: a skill, an MCP
|
||||
* server or a custom tool (`ExtensionTables`).
|
||||
*/
|
||||
export const ExtensionKindSchema = z
|
||||
.enum(["skill", "mcp", "tool"])
|
||||
.describe(`"skill" (a runbook), "mcp" (an MCP server) or "tool" (a custom tool)`);
|
||||
@@ -0,0 +1,52 @@
|
||||
import grabCrewRole from "@/src/functions/backend/db/access/grab-crew-role";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import deleteExtensionTool from "./delete-extension-tool";
|
||||
import listExtensionsTool from "./list-extensions-tool";
|
||||
import proposeCustomToolTool from "./propose-custom-tool-tool";
|
||||
import proposeMcpServerTool from "./propose-mcp-server-tool";
|
||||
import proposeSkillTool from "./propose-skill-tool";
|
||||
import setExtensionAccessTool from "./set-extension-access-tool";
|
||||
import updateCustomToolTool from "./update-custom-tool-tool";
|
||||
import updateMcpServerTool from "./update-mcp-server-tool";
|
||||
import updateSkillTool from "./update-skill-tool";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The conversation a proposal is made in, where its card shows.
|
||||
*/
|
||||
session_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Called with each proposal's ref, so the reply carries its card.
|
||||
*/
|
||||
onPropose?: (params: { ref: string }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The coordinator's tools for the install's skills, MCP servers and custom
|
||||
* tools (what Settings has under Skills, MCPs and Tools): read them,
|
||||
* change what's safe directly (on or off, names and arguments that don't
|
||||
* change where anything is sent), propose adding one or changing what it
|
||||
* does (the user confirms from a card), delete one, and decide who gets
|
||||
* it. Only for the super admin, who alone defines
|
||||
* them; each tool checks again when it runs.
|
||||
*/
|
||||
export default async function grabCoordinatorExtensionTools(params: Params): Promise<HarnessTool[]> {
|
||||
const { user_id } = params;
|
||||
|
||||
if ((await grabCrewRole({ user_id })) != "super_admin") return [];
|
||||
|
||||
return [
|
||||
listExtensionsTool({ user_id }),
|
||||
proposeSkillTool(params),
|
||||
proposeMcpServerTool(params),
|
||||
proposeCustomToolTool(params),
|
||||
updateSkillTool({ user_id }),
|
||||
updateMcpServerTool({ user_id }),
|
||||
updateCustomToolTool({ user_id }),
|
||||
deleteExtensionTool({ user_id }),
|
||||
setExtensionAccessTool({ user_id }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_CUSTOM_TOOLS,
|
||||
BUN_SQLITE_COBBLE_MCP_SERVERS,
|
||||
BUN_SQLITE_COBBLE_SKILLS,
|
||||
} from "@/db/types/db";
|
||||
import grabCustomTool from "@/src/functions/backend/db/extensions/grab-custom-tool";
|
||||
import grabExtensionAccess from "@/src/functions/backend/db/extensions/grab-extension-access";
|
||||
import grabMcpServer from "@/src/functions/backend/db/extensions/grab-mcp-server";
|
||||
import grabAllEnvs from "@/src/functions/backend/db/envs/grab-all-envs";
|
||||
import grabSkill from "@/src/functions/backend/db/extensions/grab-skill";
|
||||
import parseCustomToolRow from "@/src/functions/backend/db/extensions/parse-custom-tool-row";
|
||||
import parseMcpServerRow from "@/src/functions/backend/db/extensions/parse-mcp-server-row";
|
||||
import parseSkillRow from "@/src/functions/backend/db/extensions/parse-skill-row";
|
||||
import type { ExtensionKind } from "@/src/functions/backend/db/extensions/extension-tables";
|
||||
import redactSecrets from "@/src/utils/redact-secrets";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import describeExtension from "./describe-extension";
|
||||
import { ExtensionKindSchema } from "./extension-kind-schema";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
kind?: ExtensionKind;
|
||||
id?: number;
|
||||
query?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator read the install's skills, MCP servers and custom
|
||||
* tools: every one as a line, or one in full (the runbook, how a server is
|
||||
* reached, a tool's arguments and config) before it changes it. Literal
|
||||
* header and env values and every secret env's value stay hidden.
|
||||
*/
|
||||
export default function listExtensionsTool({ user_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "list_extensions",
|
||||
description: `List the install's skills (runbooks), MCP servers and custom tools, with their ids, whether they're on, how they're doing and which bots and workspaces get them. Give kind and id to read one in full (a skill's runbook, how an MCP server is reached, a tool's arguments and config) before you change it. query filters by name, slug or description.`,
|
||||
schema: z
|
||||
.object({
|
||||
kind: ExtensionKindSchema.optional(),
|
||||
id: z.number().int().positive().optional(),
|
||||
query: z.string().trim().max(200).optional(),
|
||||
})
|
||||
.refine((args) => !args.id || args.kind, "Give the kind with the id."),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const text = args.id && args.kind ? await describeOne({ kind: args.kind, id: args.id }) : await describeAll(args);
|
||||
const secrets = (await grabAllEnvs()).filter((env) => env.secret);
|
||||
|
||||
return redactSecrets({ text, secrets });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function describeOne({ kind, id }: { kind: ExtensionKind; id: number }) {
|
||||
const access = (await grabExtensionAccess({ kind }))(id);
|
||||
|
||||
if (kind == "skill") return describeExtension({ kind, item: await grabSkill({ skill_id: id }), ...access, full: true });
|
||||
if (kind == "mcp") return describeExtension({ kind, item: await grabMcpServer({ mcp_server_id: id }), ...access, full: true });
|
||||
|
||||
return describeExtension({ kind, item: await grabCustomTool({ custom_tool_id: id }), ...access, full: true });
|
||||
}
|
||||
|
||||
async function describeAll({ kind, query }: Args) {
|
||||
const [skills, servers, tools, skill_access, mcp_access, tool_access] = await Promise.all([
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>({ sql: `SELECT * FROM skills ORDER BY name COLLATE NOCASE, id` }),
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_MCP_SERVERS>({ sql: `SELECT * FROM mcp_servers ORDER BY name COLLATE NOCASE, id` }),
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_CUSTOM_TOOLS>({ sql: `SELECT * FROM custom_tools ORDER BY name COLLATE NOCASE, id` }),
|
||||
grabExtensionAccess({ kind: "skill" }),
|
||||
grabExtensionAccess({ kind: "mcp" }),
|
||||
grabExtensionAccess({ kind: "tool" }),
|
||||
]);
|
||||
|
||||
const needle = (query || "").toLowerCase();
|
||||
const matches = (fields: string[]) => !needle || fields.some((field) => field.toLowerCase().includes(needle));
|
||||
|
||||
const lines = [
|
||||
...(!kind || kind == "skill"
|
||||
? (skills.payload || [])
|
||||
.map((row) => parseSkillRow({ row }))
|
||||
.filter((item) => matches([item.name, item.slug, item.description]))
|
||||
.map((item) => describeExtension({ kind: "skill", item, ...skill_access(item.id) }))
|
||||
: []),
|
||||
...(!kind || kind == "mcp"
|
||||
? (servers.payload || [])
|
||||
.map((row) => parseMcpServerRow({ row }))
|
||||
.filter((item) => matches([item.name, item.slug, item.description]))
|
||||
.map((item) => describeExtension({ kind: "mcp", item, ...mcp_access(item.id) }))
|
||||
: []),
|
||||
...(!kind || kind == "tool"
|
||||
? (tools.payload || [])
|
||||
.map((row) => parseCustomToolRow({ row }))
|
||||
.filter((item) => matches([item.name, item.description]))
|
||||
.map((item) => describeExtension({ kind: "tool", item, ...tool_access(item.id) }))
|
||||
: []),
|
||||
];
|
||||
|
||||
return lines.join("\n") || (query ? `Nothing matches "${query}".` : `There are none yet.`);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { CustomToolRecord } from "@/src/types";
|
||||
import mergeCustomToolChanges from "./merge-custom-tool-changes";
|
||||
|
||||
const weather: CustomToolRecord = {
|
||||
id: 4,
|
||||
name: "weather",
|
||||
description: "Today's weather for a city",
|
||||
parameters: { type: "object", properties: { city: { type: "string" } } },
|
||||
all_bots: false,
|
||||
enabled: true,
|
||||
status: "ok",
|
||||
last_error: "",
|
||||
last_checked_at: null,
|
||||
kind: "http",
|
||||
config: {
|
||||
method: "GET",
|
||||
url: "https://api.weather.example/v1?city={{args.city}}",
|
||||
headers: { Authorization: "Bearer {{env.WEATHER_KEY}}", Accept: "application/json" },
|
||||
body: "",
|
||||
},
|
||||
};
|
||||
|
||||
describe("mergeCustomToolChanges", () => {
|
||||
test("keeps the config it isn't given and changes headers one at a time", () => {
|
||||
const data = mergeCustomToolChanges({
|
||||
current: weather,
|
||||
changes: { config: { method: "POST", headers: { Accept: null, "X-Unit": "metric" } } },
|
||||
});
|
||||
|
||||
expect(data.kind).toBe("http");
|
||||
expect(data.config).toMatchObject({
|
||||
method: "POST",
|
||||
url: weather.config.url,
|
||||
headers: { Authorization: "Bearer {{env.WEATHER_KEY}}", "X-Unit": "metric" },
|
||||
});
|
||||
});
|
||||
|
||||
test("starts the config over when the kind changes", () => {
|
||||
const data = mergeCustomToolChanges({
|
||||
current: weather,
|
||||
changes: { kind: "prompt", config: { prompt: "Describe the weather in {{args.city}}" } },
|
||||
});
|
||||
|
||||
expect(data.config).toEqual({ prompt: "Describe the weather in {{args.city}}" });
|
||||
});
|
||||
|
||||
test("checks the result like the settings form", () => {
|
||||
expect(() => mergeCustomToolChanges({ current: null, changes: { name: "new_one", kind: "http" } })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { CustomToolHttpMethods, CustomToolKind, CustomToolScriptLanguage } from "@/src/dict/custom-tool-dict";
|
||||
import { SaveCustomToolRequestSchema, type SaveCustomToolRequest } from "@/src/schemas/extension-schema";
|
||||
import type { CustomToolRecord } from "@/src/types";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import mergeTemplateMap from "./merge-template-map";
|
||||
|
||||
export type CustomToolConfigChanges = {
|
||||
method?: (typeof CustomToolHttpMethods)[number];
|
||||
url?: string;
|
||||
/**
|
||||
* Entries to set, a null removing one.
|
||||
*/
|
||||
headers?: Record<string, string | null>;
|
||||
body?: string;
|
||||
timeout_ms?: number;
|
||||
language?: CustomToolScriptLanguage;
|
||||
code?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
|
||||
export type CustomToolChanges = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
kind?: CustomToolKind;
|
||||
parameters?: Record<string, unknown>;
|
||||
config?: CustomToolConfigChanges;
|
||||
enabled?: boolean;
|
||||
all_bots?: boolean;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The tool being changed, null for a new one.
|
||||
*/
|
||||
current: CustomToolRecord | null;
|
||||
changes: CustomToolChanges;
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom tool as a bot's changes would leave it: every field it leaves
|
||||
* out kept (config fields too while the kind stays), an HTTP tool's
|
||||
* headers changed entry by entry, then checked like the settings page's
|
||||
* form (`SaveCustomToolRequestSchema`).
|
||||
*/
|
||||
export default function mergeCustomToolChanges({ current, changes }: Params): SaveCustomToolRequest {
|
||||
const kind = changes.kind ?? current?.kind;
|
||||
const kept: Record<string, unknown> = current && current.kind == kind ? { ...current.config } : {};
|
||||
const { headers, ...config_changes } = changes.config || {};
|
||||
const config: Record<string, unknown> = { ...kept, ...config_changes };
|
||||
|
||||
if (kind == "http") {
|
||||
config.headers = mergeTemplateMap({
|
||||
current: (kept.headers as Record<string, string>) || {},
|
||||
changes: headers,
|
||||
});
|
||||
}
|
||||
|
||||
return parseWithSchema({
|
||||
schema: SaveCustomToolRequestSchema,
|
||||
data: {
|
||||
custom_tool_id: current?.id,
|
||||
name: changes.name ?? current?.name,
|
||||
description: changes.description ?? current?.description,
|
||||
kind,
|
||||
parameters: changes.parameters ?? current?.parameters ?? { type: "object", properties: {} },
|
||||
config,
|
||||
enabled: changes.enabled ?? current?.enabled ?? true,
|
||||
all_bots: changes.all_bots ?? current?.all_bots ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { SaveMcpServerRequestSchema, type SaveMcpServerRequest } from "@/src/schemas/extension-schema";
|
||||
import type { McpServerRecord } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import mergeTemplateMap from "./merge-template-map";
|
||||
|
||||
export type McpServerChanges = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
/**
|
||||
* Entries to set, a null removing one.
|
||||
*/
|
||||
headers?: Record<string, string | null>;
|
||||
excluded_tools?: string[];
|
||||
enabled?: boolean;
|
||||
all_bots?: boolean;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The server being changed, null for a new one.
|
||||
*/
|
||||
current: McpServerRecord | null;
|
||||
changes: McpServerChanges;
|
||||
};
|
||||
|
||||
/**
|
||||
* A remote MCP server as a bot's changes would leave it: every field it
|
||||
* leaves out kept, headers changed entry by entry, then checked like the
|
||||
* settings page's form (`SaveMcpServerRequestSchema`). A stdio server runs
|
||||
* a command in the app's container, so a bot never builds one.
|
||||
*/
|
||||
export default function mergeMcpServerChanges({ current, changes }: Params): SaveMcpServerRequest {
|
||||
if (current?.transport == "stdio" && (changes.url !== undefined || changes.headers !== undefined)) {
|
||||
throw new AppError(
|
||||
`${current.name} runs a command here (stdio): how it starts is changed by the user in Settings, under MCPs.`,
|
||||
);
|
||||
}
|
||||
|
||||
return parseWithSchema({
|
||||
schema: SaveMcpServerRequestSchema,
|
||||
data: {
|
||||
mcp_server_id: current?.id,
|
||||
name: changes.name ?? current?.name,
|
||||
description: changes.description ?? current?.description ?? "",
|
||||
transport: current?.transport ?? "remote",
|
||||
url: changes.url ?? current?.url ?? "",
|
||||
headers: mergeTemplateMap({ current: current?.headers || {}, changes: changes.headers }),
|
||||
command: current?.command ?? "",
|
||||
setup_command: current?.setup_command ?? "",
|
||||
env: current?.env ?? {},
|
||||
excluded_tools: changes.excluded_tools ?? current?.excluded_tools ?? [],
|
||||
enabled: changes.enabled ?? current?.enabled ?? true,
|
||||
all_bots: changes.all_bots ?? current?.all_bots ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import mergeTemplateMap from "./merge-template-map";
|
||||
|
||||
describe("mergeTemplateMap", () => {
|
||||
test("sets, replaces and removes entries, keeping the rest", () => {
|
||||
expect(
|
||||
mergeTemplateMap({
|
||||
current: { A: "1", B: "2", C: "3" },
|
||||
changes: { B: "two", C: null, D: "4" },
|
||||
}),
|
||||
).toEqual({ A: "1", B: "two", D: "4" });
|
||||
});
|
||||
|
||||
test("leaves the map alone without changes", () => {
|
||||
expect(mergeTemplateMap({ current: { A: "1" } })).toEqual({ A: "1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
type Params = {
|
||||
current: Record<string, string>;
|
||||
/**
|
||||
* The entries to set, a null removing one.
|
||||
*/
|
||||
changes?: Record<string, string | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies a bot's header changes to what an MCP server or tool has, so it
|
||||
* changes one entry without having to repeat (or ever read) the others.
|
||||
*/
|
||||
export default function mergeTemplateMap({ current, changes }: Params) {
|
||||
const merged = { ...current };
|
||||
|
||||
for (const [key, value] of Object.entries(changes || {})) {
|
||||
if (value === null) delete merged[key];
|
||||
else merged[key] = value;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
/**
|
||||
* The background check every proposal carries: the bot's findings and the
|
||||
* pages it read, shown on the card as the bot's own words.
|
||||
*/
|
||||
export const ProposalResearchFields = {
|
||||
research: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(AppData.ExtensionResearchMinChars, "Write down what your background check found")
|
||||
.max(AppData.ExtensionResearchMaxChars)
|
||||
.describe(
|
||||
"What your background check found: who publishes it, whether this URL is their official one, what it needs (keys, accounts, plans), and anything that worried you",
|
||||
),
|
||||
sources: z
|
||||
.array(z.url({ protocol: /^https?$/ }))
|
||||
.min(1, "Give the pages your background check read")
|
||||
.max(AppData.ExtensionResearchSourcesMax)
|
||||
.describe("The pages you read for the check, full URLs, the publisher's own docs first"),
|
||||
};
|
||||
|
||||
/**
|
||||
* A skill's review: the bot's own read of what the runbook makes bots do,
|
||||
* and where its content came from. Sources are optional, since a bot
|
||||
* often writes a runbook itself.
|
||||
*/
|
||||
export const SkillReviewFields = {
|
||||
research: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(AppData.ExtensionResearchMinChars, "Write down your review of the runbook")
|
||||
.max(AppData.ExtensionResearchMaxChars)
|
||||
.describe(
|
||||
"Your review of the runbook: what it makes bots do step by step, any step that sends data, files or keys anywhere or reads secrets, and where its content came from (written by you, the user's words, or copied from a page)",
|
||||
),
|
||||
sources: z
|
||||
.array(z.url({ protocol: /^https?$/ }))
|
||||
.max(AppData.ExtensionResearchSourcesMax)
|
||||
.default([])
|
||||
.describe("Pages any of it was copied or adapted from, full URLs"),
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CustomToolHttpMethods,
|
||||
CustomToolKinds,
|
||||
CustomToolScriptLanguages,
|
||||
type CustomToolKind,
|
||||
type CustomToolScriptLanguage,
|
||||
} from "@/src/dict/custom-tool-dict";
|
||||
import createExtensionProposal from "@/src/functions/backend/db/extension-proposals/create-extension-proposal";
|
||||
import grabExtensionProposalView from "@/src/functions/backend/db/extension-proposals/grab-extension-proposal-view";
|
||||
import grabCustomTool from "@/src/functions/backend/db/extensions/grab-custom-tool";
|
||||
import proposalRef from "@/src/utils/proposal-ref";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import describeProposalForBot from "./describe-proposal-for-bot";
|
||||
import mergeCustomToolChanges, { type CustomToolChanges } from "./merge-custom-tool-changes";
|
||||
import { ProposalResearchFields } from "./proposal-research-schema";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
session_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Called with the proposal's ref, so the reply always carries its card.
|
||||
*/
|
||||
onPropose?: (params: { ref: string }) => void;
|
||||
};
|
||||
|
||||
type Args = CustomToolChanges & {
|
||||
custom_tool_id?: number;
|
||||
research: string;
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator propose a custom tool, or a change to what one runs
|
||||
* and where it sends (its kind and config), for the user to confirm from a
|
||||
* card in the chat. Nothing is saved until they do: the card shows the
|
||||
* request or code, every key it sends (saved or missing, worked out by
|
||||
* the app) and the bot's background check.
|
||||
*/
|
||||
export default function proposeCustomToolTool({ user_id, bot_id, session_id, workspace_id, onPropose }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "propose_custom_tool",
|
||||
description: `Propose adding a custom tool bots can call, or changing an existing one's kind or config (custom_tool_id), for the user to confirm. Nothing is saved until they press Confirm on the card. First run a background check with your web tools on the API or service it calls (its official docs, the right endpoint, what it needs) and give the findings in research and the pages in sources. name is snake_case and can't be a built-in tool's; description tells bots what it does; parameters is the JSON schema of its arguments (an object schema, e.g. {"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}). kind and its config: "http" (method, url with its host written out, headers, body; the app sends it, {{args.x}} fills in an argument, with no body the arguments go as JSON; for a key refer to an env as {{env.KEY}} in a header, the body or the URL's path or query, reusing a saved env when it's the same key, never its value), "script" (language bash, python or javascript, and code; runs on the bot's computer, arguments as JSON in $COBBLE_ARGS and on stdin), "prompt" (prompt; one model turn with the bot's models, no envs). Changing one keeps the config fields you leave out while the kind stays; headers change entry by entry, a null value removing one. Check list_extensions first: if the tool is already there, propose a change to it instead.`,
|
||||
schema: z.object({
|
||||
custom_tool_id: z.number().int().positive().optional(),
|
||||
name: z.string().trim().max(80).optional(),
|
||||
description: z.string().trim().max(2000).optional(),
|
||||
kind: z.enum(CustomToolKinds.map((k) => k.value) as [CustomToolKind, ...CustomToolKind[]]).optional(),
|
||||
parameters: z.record(z.string(), z.unknown()).optional().describe("JSON schema of the arguments object"),
|
||||
config: z
|
||||
.object({
|
||||
method: z.enum(CustomToolHttpMethods).optional(),
|
||||
url: z.string().trim().max(4000).optional(),
|
||||
headers: z.record(z.string().trim().min(1).max(200), z.string().max(4000).nullable()).optional(),
|
||||
body: z.string().max(20000).optional(),
|
||||
timeout_ms: z.number().int().positive().optional(),
|
||||
language: z
|
||||
.enum(CustomToolScriptLanguages.map((l) => l.value) as [CustomToolScriptLanguage, ...CustomToolScriptLanguage[]])
|
||||
.optional(),
|
||||
code: z.string().max(60000).optional(),
|
||||
prompt: z.string().trim().max(20000).optional(),
|
||||
})
|
||||
.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
all_bots: z.boolean().optional(),
|
||||
...ProposalResearchFields,
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const { custom_tool_id, research, sources, ...changes } = args;
|
||||
const current = custom_tool_id ? await grabCustomTool({ custom_tool_id }) : null;
|
||||
const data = mergeCustomToolChanges({ current, changes });
|
||||
|
||||
const id = await createExtensionProposal({
|
||||
user_id,
|
||||
bot_id,
|
||||
session_id,
|
||||
workspace_id,
|
||||
kind: "tool",
|
||||
data,
|
||||
research,
|
||||
sources,
|
||||
});
|
||||
|
||||
onPropose?.({ ref: proposalRef({ id }) });
|
||||
|
||||
return describeProposalForBot({ view: await grabExtensionProposalView({ proposal_id: id, user_id }) });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { z } from "zod";
|
||||
import createExtensionProposal from "@/src/functions/backend/db/extension-proposals/create-extension-proposal";
|
||||
import grabExtensionProposalView from "@/src/functions/backend/db/extension-proposals/grab-extension-proposal-view";
|
||||
import grabMcpServer from "@/src/functions/backend/db/extensions/grab-mcp-server";
|
||||
import proposalRef from "@/src/utils/proposal-ref";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import describeProposalForBot from "./describe-proposal-for-bot";
|
||||
import mergeMcpServerChanges, { type McpServerChanges } from "./merge-mcp-server-changes";
|
||||
import { ProposalResearchFields } from "./proposal-research-schema";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
session_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Called with the proposal's ref, so the reply always carries its card.
|
||||
*/
|
||||
onPropose?: (params: { ref: string }) => void;
|
||||
};
|
||||
|
||||
type Args = McpServerChanges & {
|
||||
mcp_server_id?: number;
|
||||
research: string;
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator propose a remote MCP server, or a change to where
|
||||
* one connects and what it sends, for the user to confirm from a card in
|
||||
* the chat. Nothing is saved or connected until they do: the card shows
|
||||
* the host, every key it sends (saved or missing, worked out by the app)
|
||||
* and the bot's background check.
|
||||
*/
|
||||
export default function proposeMcpServerTool({ user_id, bot_id, session_id, workspace_id, onPropose }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "propose_mcp_server",
|
||||
description: `Propose adding a remote MCP server, or changing an existing one's url or headers (mcp_server_id), for the user to confirm. Nothing is saved or connected until they press Confirm on the card. First run a background check with your web tools: find the publisher's own docs, make sure the url is their official server, and note what it needs. Give the findings in research and the pages in sources. url is the full https URL with its host written out. headers change entry by entry (a null value removes one); for a key, refer to an env as {{env.KEY}}, e.g. "Authorization": "Bearer {{env.GITHUB_TOKEN}}", reusing a saved env when it's the same key, and never write a key's value in. Check list_extensions first: if the server is already there, propose a change to it instead. A server that runs a command here (stdio) is set up by the user in Settings, under MCPs.`,
|
||||
schema: z.object({
|
||||
mcp_server_id: z.number().int().positive().optional(),
|
||||
name: z.string().trim().max(80).optional(),
|
||||
description: z.string().trim().max(1000).optional(),
|
||||
url: z.string().trim().max(2000).optional(),
|
||||
headers: z.record(z.string().trim().min(1).max(200), z.string().max(4000).nullable()).optional(),
|
||||
excluded_tools: z.array(z.string().max(200)).max(500).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
all_bots: z.boolean().optional(),
|
||||
...ProposalResearchFields,
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const { mcp_server_id, research, sources, ...changes } = args;
|
||||
const current = mcp_server_id ? await grabMcpServer({ mcp_server_id }) : null;
|
||||
const data = mergeMcpServerChanges({ current, changes });
|
||||
|
||||
const id = await createExtensionProposal({
|
||||
user_id,
|
||||
bot_id,
|
||||
session_id,
|
||||
workspace_id,
|
||||
kind: "mcp",
|
||||
data,
|
||||
research,
|
||||
sources,
|
||||
});
|
||||
|
||||
onPropose?.({ ref: proposalRef({ id }) });
|
||||
|
||||
return describeProposalForBot({ view: await grabExtensionProposalView({ proposal_id: id, user_id }) });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import createExtensionProposal from "@/src/functions/backend/db/extension-proposals/create-extension-proposal";
|
||||
import grabExtensionProposalView from "@/src/functions/backend/db/extension-proposals/grab-extension-proposal-view";
|
||||
import grabSkill from "@/src/functions/backend/db/extensions/grab-skill";
|
||||
import { SaveSkillRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import proposalRef from "@/src/utils/proposal-ref";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import describeProposalForBot from "./describe-proposal-for-bot";
|
||||
import { SkillReviewFields } from "./proposal-research-schema";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
session_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Called with the proposal's ref, so the reply always carries its card.
|
||||
*/
|
||||
onPropose?: (params: { ref: string }) => void;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
skill_id?: number;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
body?: string;
|
||||
enabled?: boolean;
|
||||
all_bots?: boolean;
|
||||
research: string;
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator propose a skill, or a change to one's name, slug,
|
||||
* description or runbook, for the user to confirm from a card in the
|
||||
* chat. A runbook steers every bot that loads it, so nothing is saved
|
||||
* until they do: the card shows the whole runbook, what changed, what it
|
||||
* points bots at (links, hosts, tool names, anything about secrets, read
|
||||
* by the app) and the bot's review.
|
||||
*/
|
||||
export default function proposeSkillTool({ user_id, bot_id, session_id, workspace_id, onPropose }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "propose_skill",
|
||||
description: `Propose adding a skill (a runbook bots load with load_skill when its one-line description says it applies), or changing one's name, slug, description or body (skill_id), for the user to confirm. Nothing is saved until they press Confirm on the card. Changing one keeps every field you leave out; body replaces the whole runbook, so read it first with list_extensions and keep what still applies. A new one needs name, slug (lowercase words with hyphens, how bots load it), description and body. First review the runbook yourself and give it in research: what it makes bots do, any step that sends data, files or keys anywhere or reads secrets, and where the content came from (add the pages to sources if any of it came from one). Never put a key's value in a runbook. Check list_extensions first: if a skill already covers the job, propose a change to it instead.`,
|
||||
schema: z.object({
|
||||
skill_id: z.number().int().positive().optional(),
|
||||
name: z.string().trim().max(80).optional(),
|
||||
slug: z.string().trim().max(80).optional(),
|
||||
description: z.string().trim().max(AppData.SkillDescriptionMaxChars).optional(),
|
||||
body: z.string().max(AppData.SkillBodyMaxChars).optional().describe("The runbook, markdown"),
|
||||
enabled: z.boolean().optional(),
|
||||
all_bots: z.boolean().optional(),
|
||||
...SkillReviewFields,
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const current = args.skill_id ? await grabSkill({ skill_id: args.skill_id }) : null;
|
||||
const data = parseWithSchema({
|
||||
schema: SaveSkillRequestSchema,
|
||||
data: {
|
||||
skill_id: current?.id,
|
||||
name: args.name ?? current?.name,
|
||||
slug: args.slug ?? current?.slug,
|
||||
description: args.description ?? current?.description,
|
||||
body: args.body ?? current?.body,
|
||||
enabled: args.enabled ?? current?.enabled ?? true,
|
||||
all_bots: args.all_bots ?? current?.all_bots ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
const id = await createExtensionProposal({
|
||||
user_id,
|
||||
bot_id,
|
||||
session_id,
|
||||
workspace_id,
|
||||
kind: "skill",
|
||||
data,
|
||||
research: args.research,
|
||||
sources: args.sources,
|
||||
});
|
||||
|
||||
onPropose?.({ ref: proposalRef({ id }) });
|
||||
|
||||
return describeProposalForBot({ view: await grabExtensionProposalView({ proposal_id: id, user_id }) });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { z } from "zod";
|
||||
import { ExtensionTables, type ExtensionKind } from "@/src/functions/backend/db/extensions/extension-tables";
|
||||
import grabExtensionAccess from "@/src/functions/backend/db/extensions/grab-extension-access";
|
||||
import setExtensionAccess from "@/src/functions/backend/db/extensions/set-extension-access";
|
||||
import logEvent from "@/src/functions/backend/logger";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import describeExtensionAccess from "./describe-extension-access";
|
||||
import { ExtensionKindSchema } from "./extension-kind-schema";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
kind: ExtensionKind;
|
||||
id: number;
|
||||
bot_ids?: number[];
|
||||
workspace_ids?: number[];
|
||||
all_bots?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator decide which bots and workspaces get a skill, MCP
|
||||
* server or custom tool, as the settings pages' Access modal does
|
||||
* (`setExtensionAccess`), then reads the result back.
|
||||
*/
|
||||
export default function setExtensionAccessTool({ user_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "set_extension_access",
|
||||
description: `Set which bots and workspaces get a skill, MCP server or custom tool (by kind and id). bot_ids and workspace_ids are the complete lists it ends up with: read the current ones with list_extensions and add to or take from them; a list you leave out stays as it is. A workspace's gives it to every bot working there. all_bots true also gives it to every bot now and to every new bot; false only stops new bots getting it.`,
|
||||
schema: z.object({
|
||||
kind: ExtensionKindSchema,
|
||||
id: z.number().int().positive(),
|
||||
bot_ids: z.array(z.number().int().positive()).max(1000).optional(),
|
||||
workspace_ids: z.array(z.number().int().positive()).max(1000).optional(),
|
||||
all_bots: z.boolean().optional(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const current = (await grabExtensionAccess({ kind: args.kind }))(args.id);
|
||||
|
||||
await setExtensionAccess({
|
||||
user_id,
|
||||
kind: args.kind,
|
||||
id: args.id,
|
||||
bot_ids: args.bot_ids ?? current.bot_ids,
|
||||
workspace_ids: args.workspace_ids ?? current.workspace_ids,
|
||||
all_bots: args.all_bots,
|
||||
});
|
||||
|
||||
const saved = (await grabExtensionAccess({ kind: args.kind }))(args.id);
|
||||
const { label, table } = ExtensionTables[args.kind];
|
||||
const all_bots =
|
||||
(
|
||||
await BunSQLite.sql<{ all_bots: number }>({
|
||||
sql: `SELECT all_bots FROM ${table} WHERE id = ?`,
|
||||
values: [args.id],
|
||||
})
|
||||
).payload?.[0]?.all_bots == 1;
|
||||
|
||||
await logEvent({
|
||||
title: `${label.charAt(0).toUpperCase()}${label.slice(1)} access changed`,
|
||||
category: "system",
|
||||
user_id: Number(user_id),
|
||||
plain_text: `The coordinator changed who gets the ${label} #${args.id}.`,
|
||||
metadata: { kind: args.kind, id: args.id, ...saved },
|
||||
});
|
||||
|
||||
/**
|
||||
* A bot or workspace id that doesn't exist is skipped by
|
||||
* `setExtensionAccess`, so say which never landed.
|
||||
*/
|
||||
const missing = [
|
||||
...(args.bot_ids || []).filter((id) => !saved.bot_ids.includes(id)).map((id) => `bot ${id}`),
|
||||
...(args.workspace_ids || [])
|
||||
.filter((id) => !saved.workspace_ids.includes(id))
|
||||
.map((id) => `workspace ${id}`),
|
||||
];
|
||||
|
||||
return `Saved the ${label} #${args.id}. Who gets it now: ${describeExtensionAccess({ all_bots, ...saved })}.${missing.length ? ` These don't exist, so they weren't given it: ${missing.join(", ")}.` : ""}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { z } from "zod";
|
||||
import grabCustomTool from "@/src/functions/backend/db/extensions/grab-custom-tool";
|
||||
import saveCustomTool from "@/src/functions/backend/db/extensions/save-custom-tool";
|
||||
import testCustomTool from "@/src/functions/backend/db/extensions/test-custom-tool";
|
||||
import logEvent from "@/src/functions/backend/logger";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import mergeCustomToolChanges from "./merge-custom-tool-changes";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
custom_tool_id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
all_bots?: boolean;
|
||||
test_args?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator change a custom tool's name, description, arguments
|
||||
* and whether it's on, and try it. What it runs and where it sends (its
|
||||
* kind and config) are never changed here: those go through
|
||||
* `propose_custom_tool`, which the super admin confirms.
|
||||
*/
|
||||
export default function updateCustomToolTool({ user_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "update_custom_tool",
|
||||
description: `Change a custom tool's name, description, parameters (the JSON schema of its arguments), enabled or all_bots (gives it to every bot now and every new one). Every field you leave out stays. Give test_args to run it once afterwards (a script on the main computer) and see what a bot would get back; test_args alone just tries it. To add a tool, or change its kind or config (what it runs, where it sends), use propose_custom_tool: the user confirms those.`,
|
||||
schema: z.object({
|
||||
custom_tool_id: z.number().int().positive(),
|
||||
name: z.string().trim().max(80).optional(),
|
||||
description: z.string().trim().max(2000).optional(),
|
||||
parameters: z.record(z.string(), z.unknown()).optional().describe("JSON schema of the arguments object"),
|
||||
enabled: z.boolean().optional(),
|
||||
all_bots: z.boolean().optional(),
|
||||
test_args: z.record(z.string(), z.unknown()).optional().describe("Arguments to try it with"),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const { custom_tool_id, test_args, ...changes } = args;
|
||||
const current = await grabCustomTool({ custom_tool_id });
|
||||
const changing = Object.values(changes).some((value) => value !== undefined);
|
||||
|
||||
if (changing) {
|
||||
await saveCustomTool({ user_id, data: mergeCustomToolChanges({ current, changes }) });
|
||||
|
||||
await logEvent({
|
||||
title: "Tool changed",
|
||||
category: "system",
|
||||
user_id: Number(user_id),
|
||||
plain_text: `The coordinator changed the tool ${changes.name || current.name} (#${custom_tool_id}).`,
|
||||
metadata: { custom_tool_id },
|
||||
});
|
||||
}
|
||||
|
||||
const saved = await grabCustomTool({ custom_tool_id });
|
||||
const head = changing
|
||||
? `Tool #${custom_tool_id} ${saved.name} is saved and ${saved.enabled ? "on" : "off"}.`
|
||||
: `Tool #${custom_tool_id} ${saved.name} is unchanged.`;
|
||||
|
||||
if (!test_args) return changing ? `${head} It wasn't tried: give test_args to try it.` : head;
|
||||
|
||||
const test = await testCustomTool({ custom_tool_id, args: test_args });
|
||||
|
||||
return `${head} Tried it: ${test.ok ? "it worked" : "it failed"}. What a bot would get back:\n${test.output}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { z } from "zod";
|
||||
import grabMcpServer from "@/src/functions/backend/db/extensions/grab-mcp-server";
|
||||
import saveMcpServer from "@/src/functions/backend/db/extensions/save-mcp-server";
|
||||
import syncMcpServer from "@/src/functions/backend/db/extensions/sync-mcp-server";
|
||||
import logEvent from "@/src/functions/backend/logger";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
import mergeMcpServerChanges from "./merge-mcp-server-changes";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
mcp_server_id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
excluded_tools?: string[];
|
||||
enabled?: boolean;
|
||||
all_bots?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator change what an MCP server is called, which of its
|
||||
* tools bots get and whether it's on, then reconnects it. Where it
|
||||
* connects and what it sends are never changed here: those go through
|
||||
* `propose_mcp_server`, which the super admin confirms.
|
||||
*/
|
||||
export default function updateMcpServerTool({ user_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "update_mcp_server",
|
||||
description: `Change an MCP server's name, description, excluded_tools (the server's tools bots don't get; replaces the list), enabled or all_bots (gives it to every bot now and every new one), then reconnect it and list its tools. Every field you leave out stays. To add a server, or change its url or headers, use propose_mcp_server: the user confirms those.`,
|
||||
schema: z.object({
|
||||
mcp_server_id: z.number().int().positive(),
|
||||
name: z.string().trim().max(80).optional(),
|
||||
description: z.string().trim().max(1000).optional(),
|
||||
excluded_tools: z.array(z.string().max(200)).max(500).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
all_bots: z.boolean().optional(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const { mcp_server_id, ...changes } = args;
|
||||
const current = await grabMcpServer({ mcp_server_id });
|
||||
const data = mergeMcpServerChanges({ current, changes });
|
||||
|
||||
await saveMcpServer({ user_id, data });
|
||||
|
||||
const sync = data.enabled ? await syncMcpServer({ mcp_server_id }) : null;
|
||||
const saved = await grabMcpServer({ mcp_server_id });
|
||||
|
||||
await logEvent({
|
||||
title: "MCP server changed",
|
||||
category: "system",
|
||||
user_id: Number(user_id),
|
||||
plain_text: `The coordinator changed the MCP server ${saved.name} (#${mcp_server_id}).`,
|
||||
metadata: { mcp_server_id },
|
||||
});
|
||||
|
||||
const head = `MCP server #${mcp_server_id} "${saved.name}" is saved.`;
|
||||
|
||||
if (!sync) return `${head} It's off, so it wasn't connected.`;
|
||||
|
||||
if (!sync.ok) return `${head} It didn't connect, so bots get none of its tools until it does: ${sync.error}`;
|
||||
|
||||
const names = sync.tools.map((tool) => tool.name).join(", ");
|
||||
|
||||
return `${head} It connected and lists ${sync.tools.length} ${sync.tools.length == 1 ? "tool" : "tools"}${names ? `: ${names}` : ""}.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from "zod";
|
||||
import grabSkill from "@/src/functions/backend/db/extensions/grab-skill";
|
||||
import saveSkill from "@/src/functions/backend/db/extensions/save-skill";
|
||||
import logEvent from "@/src/functions/backend/logger";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import assertSuperAdminRun from "./assert-super-admin-run";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
skill_id: number;
|
||||
enabled?: boolean;
|
||||
all_bots?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets the coordinator turn a skill on or off, or give it to every bot.
|
||||
* Its words (name, slug, description, runbook) change only through
|
||||
* `propose_skill`, which the user confirms.
|
||||
*/
|
||||
export default function updateSkillTool({ user_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "update_skill",
|
||||
description: `Turn a skill on or off (enabled), or give it to every bot now and every new one (all_bots). To give it to some bots use set_extension_access. To add a skill or change its name, slug, description or runbook, use propose_skill: the user confirms those.`,
|
||||
schema: z
|
||||
.object({
|
||||
skill_id: z.number().int().positive(),
|
||||
enabled: z.boolean().optional(),
|
||||
all_bots: z.boolean().optional(),
|
||||
})
|
||||
.refine((args) => args.enabled !== undefined || args.all_bots !== undefined, "Give enabled or all_bots."),
|
||||
run: async ({ args }) => {
|
||||
await assertSuperAdminRun({ user_id });
|
||||
|
||||
const current = await grabSkill({ skill_id: args.skill_id });
|
||||
|
||||
await saveSkill({
|
||||
user_id,
|
||||
data: {
|
||||
skill_id: current.id,
|
||||
name: current.name,
|
||||
slug: current.slug,
|
||||
description: current.description,
|
||||
body: current.body,
|
||||
enabled: args.enabled ?? current.enabled,
|
||||
all_bots: args.all_bots ?? current.all_bots,
|
||||
},
|
||||
});
|
||||
|
||||
const saved = await grabSkill({ skill_id: current.id });
|
||||
|
||||
await logEvent({
|
||||
title: "Skill changed",
|
||||
category: "system",
|
||||
user_id: Number(user_id),
|
||||
plain_text: `The coordinator changed the skill ${saved.name} (#${saved.id}, ${saved.slug}).`,
|
||||
metadata: { skill_id: saved.id, enabled: saved.enabled, all_bots: saved.all_bots },
|
||||
});
|
||||
|
||||
return `Skill #${saved.id} "${saved.name}" is ${saved.enabled ? "on" : "off"}.${saved.all_bots ? " Every bot gets it." : ""}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import deleteBotTool from "./delete-bot-tool";
|
||||
import deleteWorkspaceTool from "./delete-workspace-tool";
|
||||
import findBotTool from "./find-bot-tool";
|
||||
import findWorkspaceTool from "./find-workspace-tool";
|
||||
import grabCoordinatorExtensionTools from "./extensions/grab-coordinator-extension-tools";
|
||||
import grabBotTools from "./grab-bot-tools";
|
||||
import removeBotFromWorkspaceTool from "./remove-bot-from-workspace-tool";
|
||||
import updateBotTool from "./update-bot-tool";
|
||||
@@ -51,6 +52,11 @@ type Params = {
|
||||
* Called when the bot asks the user to take over a computer.
|
||||
*/
|
||||
onTakeOver?: (params: { ref: string }) => void;
|
||||
/**
|
||||
* Called when the coordinator proposes an MCP server or tool for the
|
||||
* user to confirm.
|
||||
*/
|
||||
onPropose?: (params: { ref: string }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -67,7 +73,8 @@ type Params = {
|
||||
* add_bot_to_workspace and remove_bot_from_workspace, assign_computer,
|
||||
* update_bot, delete_bot and delete_workspace (which workspace, which
|
||||
* computers, and which name, role and core instructions each crew member
|
||||
* gets, and what goes) or, in a workspace,
|
||||
* gets, and what goes) and the install's skills, MCP servers and custom
|
||||
* tools (`grabCoordinatorExtensionTools`) or, in a workspace,
|
||||
* set_bot_workspace_instructions. Anything else on a computer goes through
|
||||
* a task, never mid-reply.
|
||||
*/
|
||||
@@ -80,6 +87,7 @@ export default async function grabBotChatTools({
|
||||
message_id,
|
||||
scopes = [],
|
||||
onTakeOver,
|
||||
onPropose,
|
||||
}: Params): Promise<HarnessTool[]> {
|
||||
const bot_id = bot.id || 0;
|
||||
const tools = [
|
||||
@@ -136,7 +144,11 @@ export default async function grabBotChatTools({
|
||||
return tools;
|
||||
}
|
||||
|
||||
tools.push(findBotTool(), findWorkspaceTool({ user_id }));
|
||||
tools.push(
|
||||
findBotTool(),
|
||||
findWorkspaceTool({ user_id }),
|
||||
...(await grabCoordinatorExtensionTools({ user_id, bot_id, session_id, workspace_id, onPropose })),
|
||||
);
|
||||
|
||||
if (workspaces.length) {
|
||||
tools.push(deleteWorkspaceTool({ user_id, workspaces }));
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
proposal_id: number;
|
||||
decision: "confirm" | "reject";
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirms or rejects a proposal from its card, returning it as it now
|
||||
* stands (with what confirming did).
|
||||
*/
|
||||
export default async function decideExtensionProposal({ proposal_id, decision }: Params) {
|
||||
return await fetchApi<{ proposal_id: number }, APIResponseObject<ExtensionProposalView>>(
|
||||
`/api/admin/extension-proposals/${decision}-proposal`,
|
||||
{ method: "POST", body: { proposal_id } },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
proposal_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Proposals already read, by id, so a thread naming one twice (or a
|
||||
* re-render) asks once. A decision replaces its entry.
|
||||
*/
|
||||
const loaded = new Map<number, Promise<ExtensionProposalView | null>>();
|
||||
|
||||
function loadProposal({ proposal_id }: Params) {
|
||||
const cached = loaded.get(proposal_id);
|
||||
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = fetchApi<{}, APIResponseObject<ExtensionProposalView>>(
|
||||
`/api/admin/extension-proposals/get-proposal?proposal_id=${proposal_id}`,
|
||||
{ method: "GET" },
|
||||
)
|
||||
.then((res) => (res?.success && res.singleRes ? res.singleRes : null))
|
||||
.catch(() => null);
|
||||
|
||||
loaded.set(proposal_id, pending);
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* An MCP server or tool the coordinator proposed (`[proposal:ID]`), for
|
||||
* its chip and confirm card: read when the chip renders, so it shows
|
||||
* whether it's still waiting. Only the super admin it was proposed to can
|
||||
* read it; anyone else gets null.
|
||||
*/
|
||||
export default function useExtensionProposal({ proposal_id }: Params) {
|
||||
const [view, setViewState] = useState<ExtensionProposalView | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
|
||||
loadProposal({ proposal_id }).then((result) => {
|
||||
if (!live) return;
|
||||
setViewState(result);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [proposal_id]);
|
||||
|
||||
const setView = useCallback(
|
||||
(next: ExtensionProposalView) => {
|
||||
loaded.set(proposal_id, Promise.resolve(next));
|
||||
setViewState(next);
|
||||
},
|
||||
[proposal_id],
|
||||
);
|
||||
|
||||
return { view, loading, setView };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BotRefHrefPrefix,
|
||||
ComputerRefHrefPrefix,
|
||||
FileRefHrefPrefix,
|
||||
ProposalRefHrefPrefix,
|
||||
TakeOverRefHrefPrefix,
|
||||
WorkspaceRefHrefPrefix,
|
||||
} from "@/src/utils/link-computer-refs";
|
||||
@@ -13,6 +14,7 @@ import BotRefChip from "./bot-ref-chip";
|
||||
import ComputerRefChip from "./computer-ref-chip";
|
||||
import FileRefChip from "./file-ref-chip";
|
||||
import FolderRefChip from "./folder-ref-chip";
|
||||
import ProposalRefChip from "./proposal-ref-chip";
|
||||
import TakeOverChip from "./take-over-chip";
|
||||
import WorkspaceRefChip from "./workspace-ref-chip";
|
||||
|
||||
@@ -21,7 +23,8 @@ import WorkspaceRefChip from "./workspace-ref-chip";
|
||||
* becomes its switch button, a take-over request a button that opens the
|
||||
* computer, a bot it named a link to that bot's conversation, a workspace
|
||||
* a link to that workspace, a file it saved a chip that downloads it, a
|
||||
* folder it named a chip that opens it in the files browser; anything else
|
||||
* folder it named a chip that opens it in the files browser, an MCP server
|
||||
* or tool it proposed a chip that opens the confirm card; anything else
|
||||
* opens in a new tab.
|
||||
*/
|
||||
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
@@ -64,6 +67,12 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
if (file_id) return <FileRefChip file_id={file_id} />;
|
||||
}
|
||||
|
||||
if (href?.startsWith(ProposalRefHrefPrefix)) {
|
||||
const proposal_id = Number(href.slice(ProposalRefHrefPrefix.length));
|
||||
|
||||
if (proposal_id) return <ProposalRefChip proposal_id={proposal_id} />;
|
||||
}
|
||||
|
||||
const folder_ref = parseFolderRefHref({ href });
|
||||
|
||||
if (folder_ref) return <FolderRefChip spec={folder_ref.spec} path={folder_ref.path} />;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
import ProposalCard from "./proposal-card";
|
||||
|
||||
const github: ExtensionProposalView = {
|
||||
id: 7,
|
||||
kind: "mcp",
|
||||
extension_id: null,
|
||||
name: "GitHub",
|
||||
status: "pending",
|
||||
expired: false,
|
||||
expires_at: Date.now() + 60 * 60 * 1000,
|
||||
outcome: "",
|
||||
result_id: null,
|
||||
host: "api.githubcopilot.com",
|
||||
envs: [
|
||||
{ key: "GITHUB_TOKEN", where: "header Authorization", saved_in: ["general"] },
|
||||
{ key: "GITHUB_ORG", where: "URL", saved_in: [] },
|
||||
],
|
||||
details: [
|
||||
{ label: "Name", value: "GitHub" },
|
||||
{ label: "Headers", value: "Authorization: Bearer {{env.GITHUB_TOKEN}}", block: true },
|
||||
],
|
||||
research: "GitHub publishes this server in its own docs.",
|
||||
sources: ["https://docs.github.com/en/copilot"],
|
||||
};
|
||||
|
||||
function render({ view }: { view: ExtensionProposalView }) {
|
||||
return renderToString(<ProposalCard view={view} onDecided={() => {}} />);
|
||||
}
|
||||
|
||||
describe("ProposalCard", () => {
|
||||
test("shows the host, each key and whether it's saved, and the bot's check", () => {
|
||||
const html = render({ view: github });
|
||||
|
||||
expect(html).toContain("api.githubcopilot.com");
|
||||
expect(html).toContain("GITHUB_TOKEN");
|
||||
expect(html).toContain("Saved · general");
|
||||
expect(html).toContain("Missing");
|
||||
expect(html).toContain("Background check (written by the bot)");
|
||||
expect(html).toContain("https://docs.github.com/en/copilot");
|
||||
expect(html).toContain(">Confirm<");
|
||||
});
|
||||
|
||||
test("offers no buttons once it's decided or expired", () => {
|
||||
expect(render({ view: { ...github, status: "confirmed", outcome: "Saved as MCP server #9." } })).not.toContain(
|
||||
">Confirm<",
|
||||
);
|
||||
expect(render({ view: { ...github, expired: true } })).not.toContain(">Confirm<");
|
||||
});
|
||||
|
||||
test("a skill shows its runbook and review, with no host or keys", () => {
|
||||
const html = render({
|
||||
view: {
|
||||
...github,
|
||||
kind: "skill",
|
||||
name: "Weekly SEO report",
|
||||
host: null,
|
||||
envs: [],
|
||||
details: [{ label: "Hosts", value: "files.example" }],
|
||||
sources: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(html).not.toContain("Where it sends");
|
||||
expect(html).toContain("Review (written by the bot)");
|
||||
expect(html).toContain("files.example");
|
||||
expect(html).toContain("No pages given.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
import decideExtensionProposal from "../(functions)/decide-extension-proposal";
|
||||
import sendBotMessage from "../(functions)/send-bot-message";
|
||||
import proposalDecisionMessage from "../(utils)/proposal-decision-message";
|
||||
import proposalExpiryLabel from "../(utils)/proposal-expiry-label";
|
||||
import MessageDetailsHeading from "./message-details-heading";
|
||||
import ProposalDetailRow from "./proposal-detail-row";
|
||||
import ProposalReachSection from "./proposal-reach-section";
|
||||
import ProposalSourceRow from "./proposal-source-row";
|
||||
|
||||
type Props = {
|
||||
view: ExtensionProposalView;
|
||||
onDecided: (view: ExtensionProposalView) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The confirm card for an MCP server, tool or skill the coordinator
|
||||
* proposed. Everything above the background check is read from what the
|
||||
* Confirm button saves (where it sends, every key and whether it's saved,
|
||||
* each detail; for a skill what its runbook points at and what changed);
|
||||
* the check is the bot's own words, labelled as such.
|
||||
* Deciding tells the bot in chat what happened.
|
||||
*/
|
||||
export default function ProposalCard({ view, onDecided }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [busy, setBusy] = useState<"confirm" | "reject">();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const open = view.status == "pending" && !view.expired;
|
||||
const is_skill = view.kind == "skill";
|
||||
const missing = [...new Set(view.envs.filter((env) => !env.saved_in.length).map((env) => env.key))];
|
||||
const keys = [...new Set(view.envs.map((env) => env.key))];
|
||||
|
||||
const decide = async (decision: "confirm" | "reject") => {
|
||||
const question =
|
||||
decision == "confirm" && is_skill
|
||||
? `Save the skill ${view.name}? Every bot that loads it follows this runbook.`
|
||||
: decision == "confirm"
|
||||
? `Save ${view.name}?${keys.length ? ` It sends ${keys.join(", ")} to ${view.host}.` : ""}${missing.length ? ` It stays off until ${missing.join(", ")} ${missing.length == 1 ? "is" : "are"} added.` : ""}`
|
||||
: `Reject ${view.name}? Nothing will be saved.`;
|
||||
|
||||
if (!window.confirm(question)) return;
|
||||
|
||||
setBusy(decision);
|
||||
setError(undefined);
|
||||
|
||||
const res = await decideExtensionProposal({ proposal_id: view.id, decision }).catch(() => null);
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
setBusy(undefined);
|
||||
setError(res?.msg || `Couldn't ${decision} it.`);
|
||||
return;
|
||||
}
|
||||
|
||||
onDecided(res.singleRes);
|
||||
|
||||
await sendBotMessage({
|
||||
bot_id: Number(pageProps?.bot?.id) || 0,
|
||||
workspace_id: pageProps?.workspace?.id || null,
|
||||
text: proposalDecisionMessage({ view: res.singleRes }),
|
||||
media_ids: [],
|
||||
}).catch(() => null);
|
||||
|
||||
setBusy(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-0! items-stretch">
|
||||
{is_skill ? null : <ProposalReachSection view={view} keys={keys} missing={missing} />}
|
||||
|
||||
<MessageDetailsHeading>What would be saved</MessageDetailsHeading>
|
||||
{view.details.map((detail) => (
|
||||
<ProposalDetailRow key={detail.label} {...detail} />
|
||||
))}
|
||||
|
||||
<MessageDetailsHeading>
|
||||
{is_skill ? "Review (written by the bot)" : "Background check (written by the bot)"}
|
||||
</MessageDetailsHeading>
|
||||
<Span className="whitespace-pre-wrap text-[13px] leading-5 text-ink break-words">{view.research}</Span>
|
||||
<Stack className="mt-1 w-full gap-0! items-stretch">
|
||||
{view.sources.length ? (
|
||||
view.sources.map((url) => <ProposalSourceRow key={url} url={url} />)
|
||||
) : (
|
||||
<Span className="text-[12px] leading-4 text-ink-secondary">No pages given.</Span>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{view.outcome ? (
|
||||
<Span className="mt-4 text-[13px] leading-5 text-ink">{view.outcome}</Span>
|
||||
) : null}
|
||||
|
||||
{error ? <Span className="mt-2 text-[13px] leading-5 text-ink">{error}</Span> : null}
|
||||
|
||||
{open ? (
|
||||
<Row className="mt-5 w-full gap-2! justify-end">
|
||||
<Span className="grow text-[12px] leading-4 text-ink-secondary">
|
||||
{proposalExpiryLabel({ expires_at: view.expires_at })}
|
||||
</Span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
title={`Reject ${view.name}: nothing is saved`}
|
||||
loading={busy == "reject"}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => decide("reject")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
title={`Save ${view.name}`}
|
||||
loading={busy == "confirm"}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => decide("confirm")}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Row>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { ExtensionProposalDetail } from "@/src/types";
|
||||
|
||||
/**
|
||||
* One line of what a proposed server or tool would do, as the app read it
|
||||
* from the config: a label, and its value, code-like ones as a block.
|
||||
*/
|
||||
export default function ProposalDetailRow({ label, value, block }: ExtensionProposalDetail) {
|
||||
return (
|
||||
<Row className="w-full py-2 gap-3! flex-nowrap items-baseline border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<Span className="w-28 shrink-0 text-[12px] leading-4 text-ink-secondary">{label}</Span>
|
||||
<Span
|
||||
className={twMerge(
|
||||
"grow min-w-0 text-[13px] leading-5 text-ink break-words",
|
||||
block && "max-h-48 overflow-auto whitespace-pre-wrap rounded-default-xs bg-sunken px-2 py-1 font-mono text-[12px]",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</Span>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { ExtensionProposalEnv } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Passed whole: its `key` field can't be a React prop.
|
||||
*/
|
||||
env: ExtensionProposalEnv;
|
||||
};
|
||||
|
||||
/**
|
||||
* One key a proposed server or tool sends: its name, where it goes, and
|
||||
* whether it's saved (and where) or still has to be added.
|
||||
*/
|
||||
export default function ProposalEnvRow({ env }: Props) {
|
||||
const { key: env_key, where, saved_in } = env;
|
||||
const saved = saved_in.length > 0;
|
||||
|
||||
return (
|
||||
<Row className="w-full py-2 gap-3! flex-nowrap items-center border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<Stack className="grow min-w-0 gap-0! items-start">
|
||||
<Span className="font-mono text-[13px] leading-5 font-bold text-ink break-all">{env_key}</Span>
|
||||
<Span className="text-[12px] leading-4 text-ink-secondary">{`Sent in the ${where}`}</Span>
|
||||
</Stack>
|
||||
<Span
|
||||
className={twMerge(
|
||||
"shrink-0 rounded-default-xs px-2 py-0.5 text-[12px] leading-4 font-bold",
|
||||
saved ? "bg-sunken text-ink-secondary" : "bg-ink text-on-ink",
|
||||
)}
|
||||
>
|
||||
{saved ? `Saved · ${saved_in.join(", ")}` : "Missing"}
|
||||
</Span>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
import MessageDetailsHeading from "./message-details-heading";
|
||||
import ProposalEnvRow from "./proposal-env-row";
|
||||
|
||||
type Props = {
|
||||
view: ExtensionProposalView;
|
||||
/**
|
||||
* Every key it sends, once each.
|
||||
*/
|
||||
keys: string[];
|
||||
/**
|
||||
* The keys that aren't saved yet.
|
||||
*/
|
||||
missing: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a proposed server or tool sends from the app, and each key it
|
||||
* sends with whether it's saved.
|
||||
*/
|
||||
export default function ProposalReachSection({ view, keys, missing }: Props) {
|
||||
return (
|
||||
<>
|
||||
<MessageDetailsHeading>Where it sends</MessageDetailsHeading>
|
||||
<Span className="text-[14px] leading-5 font-bold text-ink break-all">
|
||||
{view.host || "Nothing from the app"}
|
||||
</Span>
|
||||
|
||||
<MessageDetailsHeading>{`Keys it sends (${keys.length})`}</MessageDetailsHeading>
|
||||
{view.envs.length ? (
|
||||
view.envs.map((env) => <ProposalEnvRow key={`${env.key}-${env.where}`} env={env} />)
|
||||
) : (
|
||||
<Span className="text-[13px] leading-5 text-ink-secondary">None.</Span>
|
||||
)}
|
||||
{missing.length ? (
|
||||
<Span className="mt-1 text-[12px] leading-4 text-ink-secondary">
|
||||
{`Add missing keys in Settings, under Environment. Until then it's saved switched off.`}
|
||||
</Span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { BookOpen, PlugZap, Wrench } from "lucide-react";
|
||||
import useExtensionProposal from "../(hooks)/use-extension-proposal";
|
||||
import proposalStatusLabel from "../(utils)/proposal-status-label";
|
||||
import proposalTitle from "../(utils)/proposal-title";
|
||||
import ProposalCard from "./proposal-card";
|
||||
|
||||
type Props = {
|
||||
proposal_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* An MCP server, tool or skill the coordinator proposed (`proposalRef`): a chip
|
||||
* naming it and where it stands, which opens the card the super admin
|
||||
* confirms or rejects it from. Anyone it wasn't proposed to sees its
|
||||
* number only.
|
||||
*/
|
||||
export default function ProposalRefChip({ proposal_id }: Props) {
|
||||
const { view, loading, setView } = useExtensionProposal({ proposal_id });
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!view) {
|
||||
return <Span className="font-bold">{loading ? `proposal ${proposal_id}` : `proposal ${proposal_id} (not available)`}</Span>;
|
||||
}
|
||||
|
||||
const Icon = view.kind == "mcp" ? PlugZap : view.kind == "skill" ? BookOpen : Wrench;
|
||||
const title = proposalTitle({ view });
|
||||
const status = proposalStatusLabel({ view });
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="smaller"
|
||||
title={`Review: ${title}`}
|
||||
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
|
||||
>
|
||||
<Icon size={14} aria-hidden className="shrink-0" />
|
||||
{`${title} · ${status}`}
|
||||
</Button>
|
||||
}
|
||||
targetWrapperProps={{ className: "inline-flex align-middle" }}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className="max-w-xl bg-paper p-6"
|
||||
>
|
||||
<Stack className="w-full gap-1! items-stretch">
|
||||
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">{title}</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{`${status}. The coordinator proposed this; nothing is saved until you confirm. Everything above the bot's own notes is read from what Confirm would save.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
|
||||
<Stack className="mt-2 max-h-[65vh] w-full gap-0! items-stretch overflow-y-auto">
|
||||
<ProposalCard view={view} onDecided={setView} />
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
|
||||
type Props = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A page the bot read for its background check, shown as its whole
|
||||
* address so a lookalike domain is visible before it's opened.
|
||||
*/
|
||||
export default function ProposalSourceRow({ url }: Props) {
|
||||
return (
|
||||
<Link
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="block w-full py-1 text-[13px] leading-5 text-ink underline underline-offset-2 break-all hover:opacity-80"
|
||||
>
|
||||
{url}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
import proposalTitle from "./proposal-title";
|
||||
|
||||
type Params = {
|
||||
view: ExtensionProposalView;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the chat tells the bot once the user decided on its proposal, so
|
||||
* it carries on knowing what happened (and never has to take the user's
|
||||
* word over the card's).
|
||||
*/
|
||||
export default function proposalDecisionMessage({ view }: Params) {
|
||||
const title = proposalTitle({ view });
|
||||
|
||||
if (view.status == "rejected") return `I rejected proposal #${view.id} (${title}). Nothing was saved.`;
|
||||
|
||||
if (view.status == "failed") return `I confirmed proposal #${view.id} (${title}), but it failed. ${view.outcome}`;
|
||||
|
||||
return `I confirmed proposal #${view.id} (${title}). ${view.outcome}`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
type Params = {
|
||||
expires_at: number;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* How long a pending proposal can still be confirmed, e.g. `Expires in 23h`.
|
||||
*/
|
||||
export default function proposalExpiryLabel({ expires_at, now = Date.now() }: Params) {
|
||||
const minutes = Math.max(0, Math.round((expires_at - now) / 60000));
|
||||
|
||||
if (minutes < 60) return `Expires in ${Math.max(1, minutes)}m`;
|
||||
|
||||
return `Expires in ${Math.round(minutes / 60)}h`;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
view: Pick<ExtensionProposalView, "status" | "expired">;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a proposal stands, in a word for its chip.
|
||||
*/
|
||||
export default function proposalStatusLabel({ view }: Params) {
|
||||
if (view.expired) return "Expired";
|
||||
|
||||
return {
|
||||
pending: "Waiting for you",
|
||||
confirmed: "Confirmed",
|
||||
rejected: "Rejected",
|
||||
replaced: "Replaced",
|
||||
failed: "Failed",
|
||||
}[view.status];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ExtensionProposalKinds } from "@/src/dict/extension-proposal-dict";
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
view: Pick<ExtensionProposalView, "kind" | "extension_id" | "name">;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a proposal does, e.g. `Add MCP server GitHub` or `Change tool
|
||||
* weather`.
|
||||
*/
|
||||
export default function proposalTitle({ view }: Params) {
|
||||
const kind = ExtensionProposalKinds.find((k) => k.value == view.kind)?.title || view.kind;
|
||||
|
||||
return `${view.extension_id ? "Change" : "Add"} ${kind} ${view.name}`;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import confirmExtensionProposal from "@/src/functions/backend/db/extension-proposals/confirm-extension-proposal";
|
||||
import grabExtensionProposalView from "@/src/functions/backend/db/extension-proposals/grab-extension-proposal-view";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { ExtensionProposalIdRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admin only: confirms an MCP server or tool the coordinator
|
||||
* proposed, which saves it (switched off while a key it sends is missing)
|
||||
* and connects a server. Returns the proposal with its outcome, which the
|
||||
* card then tells the bot in chat.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<ExtensionProposalView>> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const { proposal_id } = parseWithSchema({ schema: ExtensionProposalIdRequestSchema, data: body });
|
||||
const proposal = await confirmExtensionProposal({ user_id: user.id, proposal_id });
|
||||
const view = await grabExtensionProposalView({ proposal_id, user_id: user.id });
|
||||
|
||||
await logEvent({
|
||||
title: view.status == "failed" ? "Proposal failed" : "Proposal confirmed",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} confirmed the proposal #${proposal.id} (${proposal.kind} ${proposal.name}): ${view.outcome}`,
|
||||
metadata: { proposal_id: proposal.id, kind: proposal.kind, result_id: view.result_id, host: view.host },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, msg: view.outcome, singleRes: view };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabExtensionProposalView from "@/src/functions/backend/db/extension-proposals/grab-extension-proposal-view";
|
||||
import { ExtensionProposalIdRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* `GET ?proposal_id=`, super admin only: an MCP server or tool the
|
||||
* coordinator proposed to them, as its chip and confirm card show it.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<ExtensionProposalView>> = async ({ req, query }) => {
|
||||
try {
|
||||
if (req.method != "GET") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const { proposal_id } = parseWithSchema({ schema: ExtensionProposalIdRequestSchema, data: query });
|
||||
|
||||
return { success: true, singleRes: await grabExtensionProposalView({ proposal_id, user_id: user.id }) };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabExtensionProposalView from "@/src/functions/backend/db/extension-proposals/grab-extension-proposal-view";
|
||||
import rejectExtensionProposal from "@/src/functions/backend/db/extension-proposals/reject-extension-proposal";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { ExtensionProposalIdRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import type { ExtensionProposalView } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admin only: rejects an MCP server or tool the coordinator
|
||||
* proposed. Nothing is saved.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<ExtensionProposalView>> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const { proposal_id } = parseWithSchema({ schema: ExtensionProposalIdRequestSchema, data: body });
|
||||
const proposal = await rejectExtensionProposal({ user_id: user.id, proposal_id });
|
||||
|
||||
await logEvent({
|
||||
title: "Proposal rejected",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} rejected the proposal #${proposal.id} (${proposal.kind} ${proposal.name}).`,
|
||||
metadata: { proposal_id: proposal.id, kind: proposal.kind },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, singleRes: await grabExtensionProposalView({ proposal_id, user_id: user.id }) };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -207,3 +207,11 @@ export type SaveSkillRequest = z.infer<typeof SaveSkillRequestSchema>;
|
||||
export type SaveCustomToolRequest = z.infer<typeof SaveCustomToolRequestSchema>;
|
||||
export type TestMcpServerRequest = z.infer<typeof TestMcpServerRequestSchema>;
|
||||
export type DraftCustomTool = z.infer<typeof DraftCustomTool>;
|
||||
|
||||
/**
|
||||
* A proposal the coordinator made, read, confirmed or rejected from its
|
||||
* card.
|
||||
*/
|
||||
export const ExtensionProposalIdRequestSchema = z.object({
|
||||
proposal_id: Id,
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import type { ContainerStatus } from "../dict/computer-dict";
|
||||
import type { BotFace, BotMessageReactionEmoji } from "../dict/bot-dict";
|
||||
import type { HarnessCallSettings, TokenUsageSummary } from "../harness/usage/types";
|
||||
import type { McpServerStatus, McpTransport } from "../dict/mcp-dict";
|
||||
import type { ExtensionProposalKind, ExtensionProposalStatus } from "../dict/extension-proposal-dict";
|
||||
import type {
|
||||
CustomToolHttpMethod,
|
||||
CustomToolKind,
|
||||
@@ -639,6 +640,63 @@ export type SkillRecord = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One env a proposed MCP server or tool sends (`extensionEnvUses`), and
|
||||
* where it's saved: `general`, or the bots and workspaces it's saved for
|
||||
* only. Nothing saved means the user has to add it.
|
||||
*/
|
||||
export type ExtensionProposalEnv = {
|
||||
key: string;
|
||||
where: string;
|
||||
saved_in: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* What a proposed MCP server or tool does, as the confirm card shows it:
|
||||
* each line read from the config by the app, never from what the bot said.
|
||||
*/
|
||||
export type ExtensionProposalDetail = {
|
||||
label: string;
|
||||
value: string;
|
||||
/**
|
||||
* Long or code-like: shown as a block.
|
||||
*/
|
||||
block?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* An MCP server or tool the coordinator proposed (`extension_proposals`),
|
||||
* as its chip and confirm card show it to the super admin.
|
||||
*/
|
||||
export type ExtensionProposalView = {
|
||||
id: number;
|
||||
kind: ExtensionProposalKind;
|
||||
/**
|
||||
* The one it changes, null for a new one.
|
||||
*/
|
||||
extension_id: number | null;
|
||||
name: string;
|
||||
status: ExtensionProposalStatus;
|
||||
/**
|
||||
* Still pending, but past `expires_at`: it can't be confirmed.
|
||||
*/
|
||||
expired: boolean;
|
||||
expires_at: number;
|
||||
outcome: string;
|
||||
result_id: number | null;
|
||||
/**
|
||||
* Where it sends requests from the app; null for a script or a prompt.
|
||||
*/
|
||||
host: string | null;
|
||||
envs: ExtensionProposalEnv[];
|
||||
details: ExtensionProposalDetail[];
|
||||
/**
|
||||
* The bot's background check, in its own words.
|
||||
*/
|
||||
research: string;
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A skill as the Skills settings page shows it: nothing secret in one, so
|
||||
* it's the row with who gets it instead of `all_bots`.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { SaveCustomToolRequest, SaveMcpServerRequest } from "@/src/schemas/extension-schema";
|
||||
import type { ExtensionProposalDetail } from "@/src/types";
|
||||
|
||||
type Params = (
|
||||
| { kind: "mcp"; data: SaveMcpServerRequest }
|
||||
| { kind: "tool"; data: SaveCustomToolRequest }
|
||||
) & {
|
||||
/**
|
||||
* A new one rather than a change: no bot gets it until it's given.
|
||||
*/
|
||||
is_new: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a proposed MCP server or tool would do, line by line, read from the
|
||||
* config the confirm button saves, so the card shows exactly what would
|
||||
* be saved rather than the bot's account of it.
|
||||
*/
|
||||
export default function describeProposalDetails(params: Params): ExtensionProposalDetail[] {
|
||||
const { data } = params;
|
||||
const common: ExtensionProposalDetail[] = [
|
||||
{ label: "Name", value: data.name },
|
||||
{ label: "Description", value: data.description || "none" },
|
||||
];
|
||||
const reach: ExtensionProposalDetail[] = [
|
||||
{ label: "Starts", value: data.enabled ? "On" : "Off" },
|
||||
{
|
||||
label: "Who gets it",
|
||||
value: data.all_bots ? "Every bot, now and new" : params.is_new ? "No bot until it's given to some" : "As it is now",
|
||||
},
|
||||
];
|
||||
|
||||
if (params.kind == "mcp") {
|
||||
return [
|
||||
...common,
|
||||
{ label: "URL", value: params.data.url },
|
||||
{ label: "Headers", value: listMap({ map: params.data.headers }), block: true },
|
||||
{ label: "Left-out tools", value: params.data.excluded_tools.join(", ") || "none" },
|
||||
...reach,
|
||||
];
|
||||
}
|
||||
|
||||
const tool = params.data;
|
||||
const parameters: ExtensionProposalDetail = {
|
||||
label: "Arguments",
|
||||
value: JSON.stringify(tool.parameters, null, 2),
|
||||
block: true,
|
||||
};
|
||||
|
||||
if (tool.kind == "http") {
|
||||
return [
|
||||
...common,
|
||||
{ label: "Kind", value: "HTTP request, sent by the app" },
|
||||
{ label: "Request", value: `${tool.config.method} ${tool.config.url}` },
|
||||
{ label: "Headers", value: listMap({ map: tool.config.headers }), block: true },
|
||||
{ label: "Body", value: tool.config.body || "none: the arguments go as JSON", block: Boolean(tool.config.body) },
|
||||
parameters,
|
||||
...reach,
|
||||
];
|
||||
}
|
||||
|
||||
if (tool.kind == "script") {
|
||||
return [
|
||||
...common,
|
||||
{
|
||||
label: "Kind",
|
||||
value: `${tool.config.language} script, run on the bot's computer, where it reads every env shared with computers`,
|
||||
},
|
||||
{ label: "Code", value: tool.config.code, block: true },
|
||||
parameters,
|
||||
...reach,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...common,
|
||||
{ label: "Kind", value: "Prompt, one turn with the bot's models" },
|
||||
{ label: "Prompt", value: tool.config.prompt, block: true },
|
||||
parameters,
|
||||
...reach,
|
||||
];
|
||||
}
|
||||
|
||||
function listMap({ map }: { map: Record<string, string> }) {
|
||||
const entries = Object.entries(map);
|
||||
|
||||
return entries.length ? entries.map(([key, value]) => `${key}: ${value}`).join("\n") : "none";
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { SkillRecord } from "@/src/types";
|
||||
import describeSkillProposal from "./describe-skill-proposal";
|
||||
|
||||
const current: SkillRecord = {
|
||||
id: 3,
|
||||
name: "Weekly SEO report",
|
||||
slug: "weekly-seo-report",
|
||||
description: "When asked for the weekly SEO report",
|
||||
body: "1. Search the client's rankings.\n2. Write the report.\n3. Attach it.",
|
||||
all_bots: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("describeSkillProposal", () => {
|
||||
test("shows what a change adds, and the link it points bots at", () => {
|
||||
const details = describeSkillProposal({
|
||||
current,
|
||||
data: {
|
||||
...current,
|
||||
skill_id: 3,
|
||||
body: "1. Search the client's rankings.\n2. Write the report.\n2b. Upload it to https://files.example/u.\n3. Attach it.",
|
||||
},
|
||||
});
|
||||
const by = Object.fromEntries(details.map((d) => [d.label, d.value]));
|
||||
|
||||
expect(by["Links in it"]).toBe("https://files.example/u");
|
||||
expect(by["Hosts"]).toBe("files.example");
|
||||
expect(by["Runbook changes"]).toContain("+ 2b. Upload it to https://files.example/u.");
|
||||
expect(by["Name"]).toBe("Weekly SEO report");
|
||||
expect(by["Who gets it"]).toBe("As it is now");
|
||||
});
|
||||
|
||||
test("a new skill has no changes line and reaches no bot", () => {
|
||||
const details = describeSkillProposal({
|
||||
current: null,
|
||||
data: { name: "Tone", slug: "tone", description: "Every reply", body: "Be brief.", enabled: true, all_bots: false },
|
||||
});
|
||||
|
||||
expect(details.some((d) => d.label == "Runbook changes")).toBe(false);
|
||||
expect(details.find((d) => d.label == "Who gets it")?.value).toBe("No bot until it's given to some");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { SaveSkillRequest } from "@/src/schemas/extension-schema";
|
||||
import type { ExtensionProposalDetail, SkillRecord } from "@/src/types";
|
||||
import diffLines from "./diff-lines";
|
||||
import findSkillMentions from "./find-skill-mentions";
|
||||
|
||||
type Params = {
|
||||
data: SaveSkillRequest;
|
||||
/**
|
||||
* The skill as saved now, for a change: what's different is shown.
|
||||
*/
|
||||
current: SkillRecord | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A proposed skill, line by line, read by the app from the runbook
|
||||
* Confirm would save: what it points bots at (links, hosts, tool names,
|
||||
* anything about secrets), for a change what's different from the saved
|
||||
* one, then the whole runbook as plain text.
|
||||
*/
|
||||
export default function describeSkillProposal({ data, current }: Params): ExtensionProposalDetail[] {
|
||||
const found = findSkillMentions({ text: [data.description, data.body].join("\n") });
|
||||
const changed = ({ now, was }: { now: string; was?: string }) => (current && was != now ? `${now} (was: ${was || "empty"})` : now);
|
||||
|
||||
return [
|
||||
{ label: "Name", value: changed({ now: data.name, was: current?.name }) },
|
||||
{ label: "Slug", value: changed({ now: data.slug, was: current?.slug }) },
|
||||
{ label: "When it applies", value: changed({ now: data.description, was: current?.description }) },
|
||||
{ label: "Links in it", value: found.urls.join("\n") || "none", block: found.urls.length > 0 },
|
||||
{ label: "Hosts", value: found.hosts.join(", ") || "none" },
|
||||
{ label: "Tool names", value: found.tools.join(", ") || "none" },
|
||||
{ label: "About secrets", value: found.secrets.join(", ") || "nothing" },
|
||||
...(current
|
||||
? [
|
||||
{
|
||||
label: "Runbook changes",
|
||||
value: diffLines({ before: current.body, after: data.body }) || "None: the runbook is the same",
|
||||
block: current.body != data.body,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ label: "Runbook", value: data.body, block: true },
|
||||
{ label: "Starts", value: data.enabled ? "On" : "Off" },
|
||||
{
|
||||
label: "Who gets it",
|
||||
value: data.all_bots ? "Every bot, now and new" : current ? "As it is now" : "No bot until it's given to some",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import diffLines from "./diff-lines";
|
||||
|
||||
describe("diffLines", () => {
|
||||
test("marks what was added and removed, with context around it", () => {
|
||||
const before = ["1", "2", "3", "4", "5", "6", "7", "8"].join("\n");
|
||||
const after = ["1", "2", "3", "4", "upload to x", "5", "6", "7", "8"].join("\n");
|
||||
|
||||
expect(diffLines({ before, after, context: 1 })).toBe(["...", " 4", "+ upload to x", " 5", "..."].join("\n"));
|
||||
});
|
||||
|
||||
test("shows a replaced line as a removal and an addition", () => {
|
||||
expect(diffLines({ before: "a\nb\nc", after: "a\nB\nc" })).toBe(" a\n- b\n+ B\n c");
|
||||
});
|
||||
|
||||
test("is empty when nothing changed", () => {
|
||||
expect(diffLines({ before: "same", after: "same" })).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
type Params = {
|
||||
before: string;
|
||||
after: string;
|
||||
/**
|
||||
* Lines of unchanged context kept around each change.
|
||||
*/
|
||||
context?: number;
|
||||
};
|
||||
|
||||
const MaxLines = 2000;
|
||||
|
||||
/**
|
||||
* A line diff of two texts as `+ added`, `- removed` and ` kept` lines,
|
||||
* unchanged runs cut down to `context` lines around each change (a `...`
|
||||
* line marks a cut), so one line added to a long runbook stands out.
|
||||
* Texts past `MaxLines` lines are compared whole, as one removal and one
|
||||
* addition. Empty when they're the same.
|
||||
*/
|
||||
export default function diffLines({ before, after, context = 2 }: Params) {
|
||||
if (before == after) return "";
|
||||
|
||||
const a = before.split("\n");
|
||||
const b = after.split("\n");
|
||||
|
||||
if (a.length > MaxLines || b.length > MaxLines) {
|
||||
return [...a.map((line) => `- ${line}`), ...b.map((line) => `+ ${line}`)].join("\n");
|
||||
}
|
||||
|
||||
const lcs: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
|
||||
|
||||
for (let i = a.length - 1; i >= 0; i--) {
|
||||
for (let j = b.length - 1; j >= 0; j--) {
|
||||
lcs[i]![j] = a[i] == b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: { mark: " " | "+" | "-"; text: string }[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
|
||||
while (i < a.length || j < b.length) {
|
||||
if (i < a.length && j < b.length && a[i] == b[j]) {
|
||||
lines.push({ mark: " ", text: a[i]! });
|
||||
i++;
|
||||
j++;
|
||||
} else if (i < a.length && (j >= b.length || lcs[i + 1]![j]! >= lcs[i]![j + 1]!)) {
|
||||
lines.push({ mark: "-", text: a[i]! });
|
||||
i++;
|
||||
} else {
|
||||
lines.push({ mark: "+", text: b[j]! });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
const near = lines.map((_, index) =>
|
||||
lines.slice(Math.max(0, index - context), index + context + 1).some((line) => line.mark != " "),
|
||||
);
|
||||
const out: string[] = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
if (near[index]) out.push(`${line.mark} ${line.text}`);
|
||||
else if (out.at(-1) != "...") out.push("...");
|
||||
});
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import extensionEnvUses from "./extension-env-uses";
|
||||
|
||||
describe("extensionEnvUses", () => {
|
||||
test("reads an MCP server's host and the envs its URL and headers send", () => {
|
||||
expect(
|
||||
extensionEnvUses({
|
||||
kind: "mcp",
|
||||
data: {
|
||||
name: "GitHub",
|
||||
description: "",
|
||||
transport: "remote",
|
||||
url: "https://api.githubcopilot.com/mcp/?region={{env.REGION}}",
|
||||
headers: { Authorization: "Bearer {{ env.GITHUB_TOKEN }}", Accept: "application/json" },
|
||||
command: "",
|
||||
setup_command: "",
|
||||
env: {},
|
||||
excluded_tools: [],
|
||||
enabled: true,
|
||||
all_bots: false,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
host: "api.githubcopilot.com",
|
||||
sends: [
|
||||
{ key: "REGION", where: "URL" },
|
||||
{ key: "GITHUB_TOKEN", where: "header Authorization" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("reads an HTTP tool's URL, headers and body", () => {
|
||||
expect(
|
||||
extensionEnvUses({
|
||||
kind: "tool",
|
||||
data: {
|
||||
name: "weather",
|
||||
description: "d",
|
||||
parameters: { type: "object" },
|
||||
enabled: true,
|
||||
all_bots: false,
|
||||
kind: "http",
|
||||
config: {
|
||||
method: "POST",
|
||||
url: "https://api.weather.example/v1?key={{env.WEATHER_KEY}}",
|
||||
headers: {},
|
||||
body: `{"token":"{{env.WEATHER_KEY}}","city":"{{args.city}}"}`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
host: "api.weather.example",
|
||||
sends: [
|
||||
{ key: "WEATHER_KEY", where: "URL" },
|
||||
{ key: "WEATHER_KEY", where: "body" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("a script sends nothing from the app", () => {
|
||||
expect(
|
||||
extensionEnvUses({
|
||||
kind: "tool",
|
||||
data: {
|
||||
name: "count",
|
||||
description: "d",
|
||||
parameters: { type: "object" },
|
||||
enabled: true,
|
||||
all_bots: false,
|
||||
kind: "script",
|
||||
config: { language: "bash", code: "echo {{env.X}}" },
|
||||
},
|
||||
}),
|
||||
).toEqual({ host: null, sends: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { SaveCustomToolRequest, SaveMcpServerRequest } from "@/src/schemas/extension-schema";
|
||||
import literalUrlHost from "./literal-url-host";
|
||||
|
||||
type Params =
|
||||
| { kind: "mcp"; data: SaveMcpServerRequest }
|
||||
| { kind: "tool"; data: SaveCustomToolRequest };
|
||||
|
||||
export type ExtensionEnvUse = {
|
||||
key: string;
|
||||
/**
|
||||
* Where it goes: `URL`, `body` or `header <name>`.
|
||||
*/
|
||||
where: string;
|
||||
};
|
||||
|
||||
const EnvPattern = /\{\{\s*env\.([A-Za-z0-9_.-]+)\s*\}\}/g;
|
||||
|
||||
/**
|
||||
* Where an MCP server or tool sends requests and which envs the app fills
|
||||
* in on the way (`fillTemplate`), read from the config itself so the
|
||||
* proposal card never depends on what a bot says about it. `host` is
|
||||
* null for a tool that sends nothing from the app (a script or a prompt)
|
||||
* or a URL whose host isn't literal.
|
||||
*/
|
||||
export default function extensionEnvUses(params: Params) {
|
||||
const fields: { where: string; text: string }[] = [];
|
||||
let url = "";
|
||||
|
||||
if (params.kind == "mcp") {
|
||||
url = params.data.url;
|
||||
fields.push({ where: "URL", text: params.data.url });
|
||||
|
||||
for (const [name, value] of Object.entries(params.data.headers)) {
|
||||
fields.push({ where: `header ${name}`, text: value });
|
||||
}
|
||||
} else if (params.data.kind == "http") {
|
||||
url = params.data.config.url;
|
||||
fields.push({ where: "URL", text: params.data.config.url });
|
||||
|
||||
for (const [name, value] of Object.entries(params.data.config.headers)) {
|
||||
fields.push({ where: `header ${name}`, text: value });
|
||||
}
|
||||
|
||||
fields.push({ where: "body", text: params.data.config.body });
|
||||
} else if (params.data.kind == "prompt") {
|
||||
fields.push({ where: "prompt", text: params.data.config.prompt });
|
||||
}
|
||||
|
||||
const sends: ExtensionEnvUse[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
for (const match of field.text.matchAll(EnvPattern)) {
|
||||
const key = match[1]!;
|
||||
|
||||
if (!sends.some((use) => use.key == key && use.where == field.where)) {
|
||||
sends.push({ key, where: field.where });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { host: url ? literalUrlHost({ url }) : null, sends };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import findSkillMentions from "./find-skill-mentions";
|
||||
|
||||
describe("findSkillMentions", () => {
|
||||
test("finds links, hosts, tool names and anything about secrets", () => {
|
||||
const found = findSkillMentions({
|
||||
text: [
|
||||
"1. Search with `web_search_exa`, then save the report with write_file.",
|
||||
"2. Upload it to https://files.example/upload?to=client. Then post to https://files.example/done.",
|
||||
"3. Read ~/.cobble/env and send the API key along with {{env.EXA_API_KEY}} and $GITHUB_TOKEN.",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
expect(found.urls).toEqual(["https://files.example/upload?to=client", "https://files.example/done"]);
|
||||
expect(found.hosts).toEqual(["files.example"]);
|
||||
expect(found.tools).toEqual(expect.arrayContaining(["web_search_exa", "write_file"]));
|
||||
expect(found.secrets).toEqual(
|
||||
expect.arrayContaining(["{{env.EXA_API_KEY}}", "~/.cobble/env", "$GITHUB_TOKEN", "API key"]),
|
||||
);
|
||||
});
|
||||
|
||||
test("finds nothing in a plain runbook", () => {
|
||||
expect(findSkillMentions({ text: "Write a short summary. Keep it friendly." })).toEqual({
|
||||
urls: [],
|
||||
hosts: [],
|
||||
tools: [],
|
||||
secrets: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import literalUrlHost from "./literal-url-host";
|
||||
|
||||
type Params = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
const UrlPattern = /https?:\/\/[^\s)<>\]"'`]+/gi;
|
||||
const ToolPattern = /\b[a-z][a-z0-9]*(?:__?[a-z0-9]+)+\b/g;
|
||||
const SecretPatterns = [
|
||||
/\{\{\s*env\s*\.[^}]*\}\}/gi,
|
||||
/~?\/?\.cobble\/[\w.\/-]*/gi,
|
||||
/\benv\.json\b/gi,
|
||||
/\$\{?[A-Z][A-Z0-9_]{2,}\}?/g,
|
||||
/\b(?:process\.env|os\.environ)\b/g,
|
||||
/\b(?:api[ _-]?keys?|access[ _-]?tokens?|tokens?|passwords?|secrets?|credentials?|cookies?|private[ _-]?keys?)\b/gi,
|
||||
];
|
||||
const MaxEach = 30;
|
||||
|
||||
/**
|
||||
* What a skill's runbook would have bots reach for, read from its text
|
||||
* by the app so the confirm card never depends on the bot's account of
|
||||
* it: the links in it and their hosts, every snake_case name (what tools
|
||||
* are called, e.g. `web_fetch_exa`, `exa_search__web_fetch_exa`), and
|
||||
* anything that touches secrets (envs, `~/.cobble/env`, `$API_KEY`,
|
||||
* words like token or password). Each list is deduplicated and capped.
|
||||
*/
|
||||
export default function findSkillMentions({ text }: Params) {
|
||||
const urls = unique({ values: [...text.matchAll(UrlPattern)].map((m) => m[0].replace(/[.,;:!?]+$/, "")) });
|
||||
const hosts = unique({
|
||||
values: urls.map((url) => literalUrlHost({ url })).filter((host): host is string => Boolean(host)),
|
||||
});
|
||||
const tools = unique({ values: [...text.matchAll(ToolPattern)].map((m) => m[0]) });
|
||||
const secrets = unique({
|
||||
values: SecretPatterns.flatMap((pattern) => [...text.matchAll(pattern)].map((m) => m[0].trim())),
|
||||
fold: true,
|
||||
});
|
||||
|
||||
return { urls, hosts, tools, secrets };
|
||||
}
|
||||
|
||||
function unique({ values, fold }: { values: string[]; fold?: boolean }) {
|
||||
const seen = new Set<string>();
|
||||
const kept: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const id = fold ? value.toLowerCase() : value;
|
||||
|
||||
if (!value || seen.has(id)) continue;
|
||||
|
||||
seen.add(id);
|
||||
kept.push(value);
|
||||
}
|
||||
|
||||
return kept.slice(0, MaxEach);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import hasEnvTemplate from "./has-env-template";
|
||||
|
||||
describe("hasEnvTemplate", () => {
|
||||
test("finds an env template, however it's spaced or cased", () => {
|
||||
expect(hasEnvTemplate({ values: ["Bearer {{env.KEY}}"] })).toBe(true);
|
||||
expect(hasEnvTemplate({ values: ["https://x.example/?k={{ ENV . KEY }}"] })).toBe(true);
|
||||
expect(hasEnvTemplate({ values: [undefined, "plain", "{{env.A}}"] })).toBe(true);
|
||||
});
|
||||
|
||||
test("leaves arguments and plain text alone", () => {
|
||||
expect(hasEnvTemplate({ values: ["{{args.city}}", "environment", "", null] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
type Params = {
|
||||
values: (string | undefined | null)[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether any of these texts reads an env (`{{env.KEY}}`), matched more
|
||||
* loosely than `fillTemplate` (any case and spacing) so a near miss never
|
||||
* slips through. Bots can't write one: an env holds a secret the app fills
|
||||
* in on the way out, so a bot that could place one could send it anywhere.
|
||||
*/
|
||||
export default function hasEnvTemplate({ values }: Params) {
|
||||
return values.some((value) => /\{\{\s*env\s*\./i.test(value || ""));
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import botRef from "./bot-ref";
|
||||
import workspaceRef from "./workspace-ref";
|
||||
import computerRef from "./computer-ref";
|
||||
import linkComputerRefs from "./link-computer-refs";
|
||||
import proposalRef from "./proposal-ref";
|
||||
import takeOverRef from "./take-over-ref";
|
||||
|
||||
describe("linkComputerRefs", () => {
|
||||
@@ -20,6 +21,12 @@ describe("linkComputerRefs", () => {
|
||||
expect(linkComputerRefs({ text })).toBe(text);
|
||||
});
|
||||
|
||||
test("links proposals", () => {
|
||||
expect(linkComputerRefs({ text: `Review it: ${proposalRef({ id: 7 })}` })).toBe(
|
||||
"Review it: [proposal 7](#proposal-7)",
|
||||
);
|
||||
});
|
||||
|
||||
test("links take-over requests", () => {
|
||||
expect(
|
||||
linkComputerRefs({
|
||||
|
||||
@@ -37,11 +37,18 @@ export const FileRefHrefPrefix = "#file-";
|
||||
*/
|
||||
export const FolderRefHrefPrefix = "#folder-";
|
||||
|
||||
/**
|
||||
* The link target a proposal ref (`proposalRef`) becomes, e.g.
|
||||
* `#proposal-7`. The chat renders it as a chip that opens the card where
|
||||
* the super admin confirms or rejects the MCP server or tool.
|
||||
*/
|
||||
export const ProposalRefHrefPrefix = "#proposal-";
|
||||
|
||||
/**
|
||||
* Turns every computer ref (`[computer:3]`) and take-over ref
|
||||
* (`[take-over:3:browser]`), bot ref (`[bot:3]`), workspace ref
|
||||
* (`[workspace:2]`), file ref (`[file:12]`) and folder ref
|
||||
* (`[folder:computer:3:seo/raw]`) in a message into a markdown link, so it
|
||||
* (`[workspace:2]`), file ref (`[file:12]`), folder ref
|
||||
* (`[folder:computer:3:seo/raw]`) and proposal ref (`[proposal:7]`) in a message into a markdown link, so it
|
||||
* survives markdown rendering as a link the chat can swap for a button,
|
||||
* chip or download.
|
||||
*
|
||||
@@ -64,6 +71,10 @@ export default function linkComputerRefs({ text }: { text: string }) {
|
||||
/\[workspace:(\d+)\](?!\()/g,
|
||||
(_, id: string) => `[workspace ${id}](${WorkspaceRefHrefPrefix}${id})`,
|
||||
)
|
||||
.replace(
|
||||
/\[proposal:(\d+)\](?!\()/g,
|
||||
(_, id: string) => `[proposal ${id}](${ProposalRefHrefPrefix}${id})`,
|
||||
)
|
||||
.replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`)
|
||||
.replace(/\[folder:([a-z]+):(\d+):([^\]\n]+)\](?!\()/g, (_, volume: string, owner: string, path: string) =>
|
||||
`[${path}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}:${path}`)})`,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import literalUrlHost from "./literal-url-host";
|
||||
|
||||
describe("literalUrlHost", () => {
|
||||
test("reads the host, without port or credentials", () => {
|
||||
expect(literalUrlHost({ url: "https://api.githubcopilot.com/mcp/" })).toBe("api.githubcopilot.com");
|
||||
expect(literalUrlHost({ url: "HTTP://Localhost:8170/mcp?k={{env.KEY}}" })).toBe("localhost");
|
||||
expect(literalUrlHost({ url: "https://user:[email protected]/mcp" })).toBe("mcp.exa.ai");
|
||||
});
|
||||
|
||||
test("refuses a host a template decides", () => {
|
||||
expect(literalUrlHost({ url: "https://{{args.host}}/x" })).toBeNull();
|
||||
expect(literalUrlHost({ url: "{{env.BASE_URL}}/mcp" })).toBeNull();
|
||||
expect(literalUrlHost({ url: "https://api.{{env.REGION}}.example.com/" })).toBeNull();
|
||||
});
|
||||
|
||||
test("refuses anything that isn't http(s)", () => {
|
||||
expect(literalUrlHost({ url: "ftp://example.com/" })).toBeNull();
|
||||
expect(literalUrlHost({ url: "example.com/mcp" })).toBeNull();
|
||||
expect(literalUrlHost({ url: "https:///nohost" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
type Params = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The host an http(s) URL goes to, lowercased, or null when it isn't
|
||||
* one or when a template (`{{env.KEY}}`, `{{args.x}}`) decides any part
|
||||
* of its scheme or host: where a key is sent has to be readable before
|
||||
* anyone approves it, and a bot must not be able to pick it per call.
|
||||
*/
|
||||
export default function literalUrlHost({ url }: Params) {
|
||||
const match = url.trim().match(/^(https?):\/\/([^/?#]*)/i);
|
||||
|
||||
if (!match?.[2] || match[2].includes("{{") || match[2].includes("}}")) return null;
|
||||
|
||||
const authority = match[2].split("@").pop() || "";
|
||||
const host = authority.replace(/:\d+$/, "").toLowerCase();
|
||||
|
||||
return /^[a-z0-9.-]+$|^\[[0-9a-f:.]+\]$/.test(host) ? host : null;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
type Params = {
|
||||
id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* How a bot names an MCP server or tool it proposed in a message:
|
||||
* `[proposal:7]`. The chat shows it as a chip that opens the confirm card.
|
||||
*/
|
||||
export default function proposalRef({ id }: Params) {
|
||||
return `[proposal:${id}]`;
|
||||
}
|
||||
Reference in New Issue
Block a user