diff --git a/workers/api/src/agent-do-tools.test.ts b/workers/api/src/agent-do-tools.test.ts index 053db90f..008f4f82 100644 --- a/workers/api/src/agent-do-tools.test.ts +++ b/workers/api/src/agent-do-tools.test.ts @@ -43,6 +43,20 @@ describe("agent tool definition helpers", () => { } }); + it("passes a registry tool's jsonSchema through verbatim to the LLM-facing definition", () => { + // A creator granting a connector tool: it appears in the built definitions with its + // authored draft-07 schema (properties + required), NOT a rebuilt ad-hoc map. + const declared: AgentCapabilities = { ...caps([]), tools: ["github_read_issue"] }; + const def = buildAgentToolDefinitions({ capabilities: declared }).find( + (t) => t.function.name === "github_read_issue", + ); + expect(def).toBeTruthy(); + expect(def?.function.parameters.type).toBe("object"); + expect(def?.function.parameters.properties).toHaveProperty("repo"); + expect(def?.function.parameters.properties).toHaveProperty("number"); + expect(def?.function.parameters.required).toEqual(["repo", "number"]); + }); + it("prefers storage tool schemas when storage and base tools share a name", () => { const searchKnowledge = buildAgentToolDefinitions().find( (tool) => tool.function.name === "search_knowledge", diff --git a/workers/api/src/agent-do-tools.ts b/workers/api/src/agent-do-tools.ts index 1fb2392e..ffd020fc 100644 --- a/workers/api/src/agent-do-tools.ts +++ b/workers/api/src/agent-do-tools.ts @@ -1,6 +1,6 @@ import { AGENT_TOOLS } from "./lib/tools.js"; import { STORAGE_TOOLS } from "./lib/storage-tools.js"; -import { registryConnectorGroups, registryToolDefs } from "./lib/tool-registry.js"; +import { registryConnectorGroups, registryToolDefs, type JsonSchema } from "./lib/tool-registry.js"; import type { AgentCapabilities } from "./lib/agent-capabilities.js"; // ── Tool groups ────────────────────────────────────────────────────────────── @@ -126,7 +126,13 @@ export function buildAgentToolDefinitions(opts?: { emailEnabled?: boolean; capab // Permission-gated tools are only offered to the model when the user granted them. if (opts?.emailEnabled) enabled.add("find_confirmation_link"); - const toolMap = new Map }>(); + // Two def shapes are merged: the legacy AGENT_TOOLS/STORAGE_TOOLS carry an ad-hoc + // `parameters` map (rebuilt into a JSON Schema below); registry tools already carry a + // draft-07 `jsonSchema` and are passed through verbatim. Both yield the same + // {type,properties,required} object the LLM has always seen — no behaviour change. + type LegacyDef = { name: string; description: string; parameters: Record }; + type SchemaDef = { name: string; description: string; jsonSchema: JsonSchema }; + const toolMap = new Map(); for (const t of [...AGENT_TOOLS, ...STORAGE_TOOLS, ...registryToolDefs()]) { if (enabled.has(t.name)) toolMap.set(t.name, t); } @@ -135,18 +141,21 @@ export function buildAgentToolDefinitions(opts?: { emailEnabled?: boolean; capab function: { name: t.name, description: t.description, - parameters: { - type: "object", - properties: Object.fromEntries( - Object.entries(t.parameters).map(([k, v]) => [ - k, - { type: v.type, description: v.description }, - ]), - ), - required: Object.entries(t.parameters) - .filter(([, v]) => v.required) - .map(([k]) => k), - }, + parameters: + "jsonSchema" in t + ? t.jsonSchema + : { + type: "object", + properties: Object.fromEntries( + Object.entries(t.parameters).map(([k, v]) => [ + k, + { type: v.type, description: v.description }, + ]), + ), + required: Object.entries(t.parameters) + .filter(([, v]) => v.required) + .map(([k]) => k), + }, }, })); } diff --git a/workers/api/src/lib/admin.ts b/workers/api/src/lib/admin.ts index 3827e4e1..a23df16b 100644 --- a/workers/api/src/lib/admin.ts +++ b/workers/api/src/lib/admin.ts @@ -2,8 +2,13 @@ import type { Env, SessionPayload } from "../types.js"; import { agentCapabilities } from "./agent-capabilities.js"; import { registryTools } from "./tool-registry.js"; -// toolName → { connector, scope } from the registry (built once). -const TOOL_META = new Map(registryTools().map((t) => [t.name, { connector: t.connector, scope: t.scope }] as const)); +// toolName → { connector, scope } for connector-provided registry tools (built once). +// Only tools that declare a connector are relevant to the connector admin views. +const TOOL_META = new Map( + registryTools() + .filter((t): t is typeof t & { connector: string; scope: "read" | "write" } => !!t.connector && !!t.scope) + .map((t) => [t.name, { connector: t.connector, scope: t.scope }] as const), +); /** Distinct connector names an agent uses, derived from its declared capability tools. */ function connectorsForTools(tools?: string[]): string[] { diff --git a/workers/api/src/lib/connectors/github.ts b/workers/api/src/lib/connectors/github.ts index abbdc8e2..7c92c722 100644 --- a/workers/api/src/lib/connectors/github.ts +++ b/workers/api/src/lib/connectors/github.ts @@ -3,7 +3,7 @@ // installation token (`installationTokenForOwner`), so access is naturally scoped to // the repos the owner's installation covers. Writes (create issue/PR, trigger) come // later behind consent (#90). These replace Coder's hard-wired GH logic over time. -import type { RegistryTool, RegistryToolCtx } from "../tool-registry.js"; +import type { ToolDef, RegistryToolCtx } from "../tool-registry.js"; import { githubAppConfigured, installationTokenForOwner } from "../github-app.js"; import { listIssues, readIssue } from "../github-issues.js"; @@ -29,15 +29,20 @@ async function resolveRepo(ctx: RegistryToolCtx, repo: string): Promise<{ token: return { token }; } -export const GITHUB_TOOLS: RegistryTool[] = [ +export const GITHUB_TOOLS: ToolDef[] = [ { name: "github_workflow_runs", + tier: "connector", connector: "github", scope: "read", description: "List recent GitHub Actions workflow runs for a repo (status, conclusion, branch, url) — check CI / deploy status.", - parameters: { - repo: { type: "string", description: 'The repository, "owner/name".', required: true }, - per_page: { type: "number", description: "How many recent runs to return (default 5, max 20)." }, + jsonSchema: { + type: "object", + properties: { + repo: { type: "string", description: 'The repository, "owner/name".' }, + per_page: { type: "number", description: "How many recent runs to return (default 5, max 20)." }, + }, + required: ["repo"], }, handler: async (ctx, input) => { const repo = String(input.repo || ""); @@ -62,13 +67,18 @@ export const GITHUB_TOOLS: RegistryTool[] = [ }, { name: "github_list_issues", + tier: "connector", connector: "github", scope: "read", description: "List issues for a repo (excludes pull requests). Filter by state and labels.", - parameters: { - repo: { type: "string", description: 'The repository, "owner/name".', required: true }, - state: { type: "string", description: '"open" | "closed" | "all" (default open).' }, - labels: { type: "string", description: "Comma-separated label filter." }, + jsonSchema: { + type: "object", + properties: { + repo: { type: "string", description: 'The repository, "owner/name".' }, + state: { type: "string", description: '"open" | "closed" | "all" (default open).' }, + labels: { type: "string", description: "Comma-separated label filter." }, + }, + required: ["repo"], }, handler: async (ctx, input) => { const repo = String(input.repo || ""); @@ -81,12 +91,17 @@ export const GITHUB_TOOLS: RegistryTool[] = [ }, { name: "github_read_issue", + tier: "connector", connector: "github", scope: "read", description: "Read one issue (title, body, labels, state) by number.", - parameters: { - repo: { type: "string", description: 'The repository, "owner/name".', required: true }, - number: { type: "number", description: "The issue number.", required: true }, + jsonSchema: { + type: "object", + properties: { + repo: { type: "string", description: 'The repository, "owner/name".' }, + number: { type: "number", description: "The issue number." }, + }, + required: ["repo", "number"], }, handler: async (ctx, input) => { const repo = String(input.repo || ""); @@ -100,14 +115,19 @@ export const GITHUB_TOOLS: RegistryTool[] = [ }, { name: "github_create_issue", + tier: "connector", connector: "github", scope: "write", description: "Open a new GitHub issue in a repo. WRITE — requires the GitHub connector's write consent for this instance.", - parameters: { - repo: { type: "string", description: 'The repository, "owner/name".', required: true }, - title: { type: "string", description: "Issue title.", required: true }, - body: { type: "string", description: "Issue body (markdown)." }, - labels: { type: "string", description: "Comma-separated labels to apply." }, + jsonSchema: { + type: "object", + properties: { + repo: { type: "string", description: 'The repository, "owner/name".' }, + title: { type: "string", description: "Issue title." }, + body: { type: "string", description: "Issue body (markdown)." }, + labels: { type: "string", description: "Comma-separated labels to apply." }, + }, + required: ["repo", "title"], }, handler: async (ctx, input) => { const repo = String(input.repo || ""); diff --git a/workers/api/src/lib/connectors/meta.ts b/workers/api/src/lib/connectors/meta.ts index 312f50a4..b8e58548 100644 --- a/workers/api/src/lib/connectors/meta.ts +++ b/workers/api/src/lib/connectors/meta.ts @@ -10,7 +10,7 @@ // template, not free text. `text` works only inside an open window. // - Instagram: business/creator account linked to a FB Page; messaging windows apply; // no cold-DM. -import type { RegistryTool, RegistryToolCtx } from "../tool-registry.js"; +import type { ToolDef, RegistryToolCtx } from "../tool-registry.js"; const GRAPH = "https://graph.facebook.com/v20.0"; @@ -29,19 +29,24 @@ async function graphPost(token: string, path: string, body: unknown): Promise<{ return { ok: true, data }; } -export const META_TOOLS: RegistryTool[] = [ +export const META_TOOLS: ToolDef[] = [ { name: "whatsapp_send_message", + tier: "connector", connector: "meta", scope: "write", description: "Send a WhatsApp message via the WhatsApp Business Cloud API. Use `text` only inside a 24h reply window; otherwise supply `template_name` (a pre-approved template) with `template_lang` and optional `template_params`. WRITE — requires the meta connector's write consent.", - parameters: { - to: { type: "string", description: "Recipient phone in E.164, e.g. +14155552671.", required: true }, - text: { type: "string", description: "Message text (valid only inside an open 24h window)." }, - template_name: { type: "string", description: "Approved template name (required outside the 24h window)." }, - template_lang: { type: "string", description: "Template language code, e.g. en_US (default en_US)." }, - template_params: { type: "string", description: "Comma-separated body variables for the template, in order." }, + jsonSchema: { + type: "object", + properties: { + to: { type: "string", description: "Recipient phone in E.164, e.g. +14155552671." }, + text: { type: "string", description: "Message text (valid only inside an open 24h window)." }, + template_name: { type: "string", description: "Approved template name (required outside the 24h window)." }, + template_lang: { type: "string", description: "Template language code, e.g. en_US (default en_US)." }, + template_params: { type: "string", description: "Comma-separated body variables for the template, in order." }, + }, + required: ["to"], }, handler: async (ctx, input) => { const token = metaToken(ctx); @@ -74,13 +79,18 @@ export const META_TOOLS: RegistryTool[] = [ }, { name: "instagram_send_dm", + tier: "connector", connector: "meta", scope: "write", description: "Send/reply to an Instagram Direct Message from a connected Instagram Business account (Instagram Messaging API). Messaging-window rules apply — you can reply to people who messaged you; no cold-DM. WRITE — requires the meta connector's write consent.", - parameters: { - recipient_id: { type: "string", description: "The recipient's Instagram-scoped ID (IGSID) from an incoming message.", required: true }, - text: { type: "string", description: "The message text.", required: true }, + jsonSchema: { + type: "object", + properties: { + recipient_id: { type: "string", description: "The recipient's Instagram-scoped ID (IGSID) from an incoming message." }, + text: { type: "string", description: "The message text." }, + }, + required: ["recipient_id", "text"], }, handler: async (ctx, input) => { const token = metaToken(ctx); diff --git a/workers/api/src/lib/connectors/tmux.ts b/workers/api/src/lib/connectors/tmux.ts index 39866577..571434ef 100644 --- a/workers/api/src/lib/connectors/tmux.ts +++ b/workers/api/src/lib/connectors/tmux.ts @@ -9,7 +9,7 @@ // them unless the instance has "tmux" write-consent (instance_connector_consent, 0051). // This is the terminal surface any permitted agent can use to drive shells, git, and // even other CLIs (Claude/Codex) running in the user's own tmux sessions. -import type { RegistryTool, RegistryToolCtx } from "../tool-registry.js"; +import type { ToolDef, RegistryToolCtx } from "../tool-registry.js"; import { callRunner, getBoundRunnerConn, READ_TIMEOUT_MS, type RunnerConn } from "../runner-client.js"; /** Resolve the live runner for this instance, or a helpful error string. */ @@ -26,14 +26,15 @@ function requireSession(input: Record): string { return s; } -export const TMUX_TOOLS: RegistryTool[] = [ +export const TMUX_TOOLS: ToolDef[] = [ { name: "tmux_list_sessions", + tier: "connector", connector: "tmux", scope: "read", description: "List every live tmux session on the connected machine (name, window count, whether it's attached, and the command running in its active pane). Use this first to discover which session to read or drive.", - parameters: {}, + jsonSchema: { type: "object", properties: {} }, handler: async (ctx) => { const r = await resolveRunner(ctx); if ("error" in r) return { content: r.error, success: false }; @@ -43,13 +44,18 @@ export const TMUX_TOOLS: RegistryTool[] = [ }, { name: "tmux_capture_pane", + tier: "connector", connector: "tmux", scope: "read", description: "Read the current output of a tmux session's active pane (ANSI-stripped, with scrollback). Use this to see what a shell, build, server, or CLI is showing right now.", - parameters: { - session: { type: "string", description: "The tmux session name (from tmux_list_sessions).", required: true }, - lines: { type: "number", description: "How many lines of scrollback to include (default 200, max 2000)." }, + jsonSchema: { + type: "object", + properties: { + session: { type: "string", description: "The tmux session name (from tmux_list_sessions)." }, + lines: { type: "number", description: "How many lines of scrollback to include (default 200, max 2000)." }, + }, + required: ["session"], }, handler: async (ctx, input) => { const r = await resolveRunner(ctx); @@ -66,13 +72,18 @@ export const TMUX_TOOLS: RegistryTool[] = [ }, { name: "tmux_run_command", + tier: "connector", connector: "tmux", scope: "write", description: "Type a command line into a tmux session's active pane and press Enter — for shell commands, git, build/test runs, etc. WRITE: runs on the user's machine; requires the tmux connector's write consent. Returns the pane right after sending; capture again after a moment to read the result.", - parameters: { - session: { type: "string", description: "The tmux session name to run the command in.", required: true }, - command: { type: "string", description: "The command line to type and execute (sent literally, then Enter).", required: true }, + jsonSchema: { + type: "object", + properties: { + session: { type: "string", description: "The tmux session name to run the command in." }, + command: { type: "string", description: "The command line to type and execute (sent literally, then Enter)." }, + }, + required: ["session", "command"], }, handler: async (ctx, input) => { const r = await resolveRunner(ctx); @@ -86,14 +97,19 @@ export const TMUX_TOOLS: RegistryTool[] = [ }, { name: "tmux_send_keys", + tier: "connector", connector: "tmux", scope: "write", description: "Send literal text and/or named keys to a tmux session's active pane WITHOUT auto-pressing Enter — for answering a running CLI's prompt (e.g. an interactive Claude/Codex session), pressing Escape/Enter, or sending Ctrl keys. WRITE: requires the tmux connector's write consent. Keys use tmux names like \"Enter\", \"Escape\", \"C-c\", \"Up\".", - parameters: { - session: { type: "string", description: "The tmux session name.", required: true }, - text: { type: "string", description: "Literal text to type (optional)." }, - keys: { type: "string", description: "Comma-separated named keys sent after the text, e.g. \"Enter\" or \"C-c\" (optional)." }, + jsonSchema: { + type: "object", + properties: { + session: { type: "string", description: "The tmux session name." }, + text: { type: "string", description: "Literal text to type (optional)." }, + keys: { type: "string", description: "Comma-separated named keys sent after the text, e.g. \"Enter\" or \"C-c\" (optional)." }, + }, + required: ["session"], }, handler: async (ctx, input) => { const r = await resolveRunner(ctx); @@ -108,14 +124,19 @@ export const TMUX_TOOLS: RegistryTool[] = [ }, { name: "tmux_new_session", + tier: "connector", connector: "tmux", scope: "write", description: "Create a new detached tmux session (optionally running a command in a working directory). WRITE: requires the tmux connector's write consent. No-op if a session with that name already exists.", - parameters: { - session: { type: "string", description: "Name for the new session.", required: true }, - workDir: { type: "string", description: "Working directory to start in (default home; ~ is expanded)." }, - command: { type: "string", description: "Optional command to run on start (e.g. \"claude\")." }, + jsonSchema: { + type: "object", + properties: { + session: { type: "string", description: "Name for the new session." }, + workDir: { type: "string", description: "Working directory to start in (default home; ~ is expanded)." }, + command: { type: "string", description: "Optional command to run on start (e.g. \"claude\")." }, + }, + required: ["session"], }, handler: async (ctx, input) => { const r = await resolveRunner(ctx); @@ -132,12 +153,17 @@ export const TMUX_TOOLS: RegistryTool[] = [ }, { name: "tmux_kill_session", + tier: "connector", connector: "tmux", scope: "write", description: "Kill a tmux session by name. WRITE: requires the tmux connector's write consent. Destroys whatever is running in it — use with care.", - parameters: { - session: { type: "string", description: "The tmux session name to kill.", required: true }, + jsonSchema: { + type: "object", + properties: { + session: { type: "string", description: "The tmux session name to kill." }, + }, + required: ["session"], }, handler: async (ctx, input) => { const r = await resolveRunner(ctx); diff --git a/workers/api/src/lib/tool-registry.test.ts b/workers/api/src/lib/tool-registry.test.ts index 9b7237d8..e90a50e3 100644 --- a/workers/api/src/lib/tool-registry.test.ts +++ b/workers/api/src/lib/tool-registry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getRegistryTool, registryConnectorGroups, registryToolDefs, registryToolNameSet, runRegistryTool } from "./tool-registry.js"; +import { getRegistryTool, registryConnectorGroups, registryToolDefs, registryToolNameSet, registryTools, runRegistryTool } from "./tool-registry.js"; import type { Env } from "../types.js"; const envNoGithub = {} as unknown as Env; // githubAppConfigured() → false @@ -23,10 +23,26 @@ describe("tool registry", () => { expect(names.has("github_read_issue")).toBe(true); }); - it("exposes ToolDef-shaped definitions (name/description/parameters)", () => { + it("exposes ToolDef-shaped definitions (name/description/jsonSchema, verbatim pass-through)", () => { const def = registryToolDefs().find((d) => d.name === "github_workflow_runs"); - expect(def?.parameters.repo.required).toBe(true); + // The registry now carries a draft-07 jsonSchema, passed through verbatim (no + // rebuild from an ad-hoc parameters map). Required fields live in `required`. + expect(def?.jsonSchema.type).toBe("object"); + expect(def?.jsonSchema.properties.repo.type).toBe("string"); + expect(def?.jsonSchema.required).toContain("repo"); expect(typeof def?.description).toBe("string"); + // It's the SAME schema object the tool declares — a true pass-through. + expect(def?.jsonSchema).toBe(getRegistryTool("github_workflow_runs")?.jsonSchema); + }); + + it("every registry tool declares a jsonSchema, a tier, and a connector", () => { + for (const t of registryTools()) { + expect(t.jsonSchema.type).toBe("object"); + expect(t.jsonSchema.properties).toEqual(expect.any(Object)); + expect(["base", "standard", "runtime", "connector"]).toContain(t.tier); + // The current registry is connector-only; all entries name their connector. + expect(typeof t.connector).toBe("string"); + } }); it("groups tools by connector for the catalog", () => { diff --git a/workers/api/src/lib/tool-registry.ts b/workers/api/src/lib/tool-registry.ts index de1eaca1..70aa7b69 100644 --- a/workers/api/src/lib/tool-registry.ts +++ b/workers/api/src/lib/tool-registry.ts @@ -21,24 +21,47 @@ export interface RegistryToolResult { success: boolean; } -export interface RegistryTool { +/** A draft-07 JSON Schema for a tool's input — an object schema with typed properties. */ +export interface JsonSchema { + type: "object"; + properties: Record; + required?: string[]; + [k: string]: unknown; +} + +/** + * The ONE tool definition shape (issue #85). A tool is declared once with its JSON + * Schema and handler; the same def surfaces to the agent runtime (via + * `registryToolDefs` → buildAgentToolDefinitions), the generic tool-call API + * (routes/tools), and — later — MCP. `jsonSchema` is the source of truth for the tool's + * inputs; the runtime passes it through verbatim (no more rebuilding from an ad-hoc map). + */ +export interface ToolDef { name: string; - /** Which connector provides it (e.g. "github"). */ - connector: string; - /** read = safe; write = mutates the external system (gated by consent later, #90). */ - scope: "read" | "write"; description: string; - /** JSON-schema-ish params, same shape as ToolDef.parameters. */ - parameters: Record; + /** Draft-07 object schema for the tool's input. Passed to the LLM + validated on the API. */ + jsonSchema: JsonSchema; + /** base = always granted · standard = creator-selectable · runtime = needs a local runner · connector = external system. */ + tier: "base" | "standard" | "runtime" | "connector"; + /** Which connector provides it (e.g. "github"). Present for connector-tier tools. */ + connector?: string; + /** read = safe; write = mutates the external system (gated by consent, #90). */ + scope?: "read" | "write"; handler: (ctx: RegistryToolCtx, input: Record) => Promise; } +/** + * @deprecated Use {@link ToolDef}. Kept as an alias so existing connector modules that + * annotate their arrays as `RegistryTool[]` keep compiling during the migration. + */ +export type RegistryTool = ToolDef; + // All connectors' tools, keyed by name. Add a connector = add its tools array here. -const REGISTRY: ReadonlyMap = new Map( +const REGISTRY: ReadonlyMap = new Map( [...GITHUB_TOOLS, ...TMUX_TOOLS, ...META_TOOLS].map((t) => [t.name, t] as const), ); -export function getRegistryTool(name: string): RegistryTool | undefined { +export function getRegistryTool(name: string): ToolDef | undefined { return REGISTRY.get(name); } @@ -46,19 +69,25 @@ export function registryToolNameSet(): Set { return new Set(REGISTRY.keys()); } -export function registryTools(): RegistryTool[] { +export function registryTools(): ToolDef[] { return [...REGISTRY.values()]; } -/** ToolDef-shaped entries for buildAgentToolDefinitions (name, description, parameters). */ -export function registryToolDefs(): Array<{ name: string; description: string; parameters: RegistryTool["parameters"] }> { - return registryTools().map((t) => ({ name: t.name, description: t.description, parameters: t.parameters })); +/** + * Runtime tool definitions for buildAgentToolDefinitions. Pass-through of each tool's + * `jsonSchema` (name/description/jsonSchema) — the schema the LLM sees is authored on + * the ToolDef, not rebuilt from an ad-hoc parameters map. Back-compatible: the merged + * output in buildAgentToolDefinitions is identical to before this refactor. + */ +export function registryToolDefs(): Array<{ name: string; description: string; jsonSchema: JsonSchema }> { + return registryTools().map((t) => ({ name: t.name, description: t.description, jsonSchema: t.jsonSchema })); } /** Catalog groups (one per connector) so the tool catalog + creator-selectable set include them. */ export function registryConnectorGroups(): Array<{ connector: string; tools: string[] }> { const byConnector = new Map(); for (const t of REGISTRY.values()) { + if (!t.connector) continue; // only connector-provided tools form catalog groups const arr = byConnector.get(t.connector) ?? []; arr.push(t.name); byConnector.set(t.connector, arr); @@ -75,13 +104,18 @@ export async function runRegistryTool( const tool = REGISTRY.get(name); if (!tool) return { name, content: `Unknown tool: ${name}`, success: false }; // Write-consent gate (issue #90): a write tool needs explicit per-instance consent - // for its connector. Fail-closed — no instance context or no consent → refused. - if (tool.scope === "write" && !(await hasConsent(ctx.env, ctx.instanceId, tool.connector, "write"))) { - return { - name, - content: `Writing via the ${tool.connector} connector isn't permitted for this agent. Enable write access for ${tool.connector} in the instance's Connections settings, then try again.`, - success: false, - }; + // for its connector. Fail-closed — no connector, no instance context, or no consent + // → refused. (A write-scoped tool without a connector can't be consented to, so it's + // unreachable rather than silently ungated.) + if (tool.scope === "write") { + if (!tool.connector || !(await hasConsent(ctx.env, ctx.instanceId, tool.connector, "write"))) { + const label = tool.connector ?? "this"; + return { + name, + content: `Writing via the ${label} connector isn't permitted for this agent. Enable write access for ${label} in the instance's Connections settings, then try again.`, + success: false, + }; + } } try { const r = await tool.handler(ctx, input || {}); diff --git a/workers/api/src/routes/admin.ts b/workers/api/src/routes/admin.ts index 603c75e5..9352e2b2 100644 --- a/workers/api/src/routes/admin.ts +++ b/workers/api/src/routes/admin.ts @@ -97,8 +97,9 @@ adminRoutes.get("/connectors", async (c) => { await requireAdmin(c); const catalog = new Map>(); for (const t of registryTools()) { + if (!t.connector) continue; // non-connector tools don't appear in the connector catalog const arr = catalog.get(t.connector) ?? []; - arr.push({ name: t.name, scope: t.scope }); + arr.push({ name: t.name, scope: t.scope ?? "read" }); catalog.set(t.connector, arr); } const connectors = [...catalog.entries()].map(([connector, tools]) => ({ diff --git a/workers/api/src/routes/tools.test.ts b/workers/api/src/routes/tools.test.ts index e64bde1b..eb310b3b 100644 --- a/workers/api/src/routes/tools.test.ts +++ b/workers/api/src/routes/tools.test.ts @@ -52,6 +52,17 @@ describe("GET /v1/instances/:id/tools", () => { const body = (await res.json()) as any; expect(body.tools.map((t: any) => t.name)).toContain("github_workflow_runs"); }); + it("emits each tool's jsonSchema verbatim (draft-07 object schema)", async () => { + const { app, env } = testApp(); + const res = await req(app, env, "/v1/instances/i1/tools", {}, await tok("u1")); + const body = (await res.json()) as any; + const tool = body.tools.find((t: any) => t.name === "github_workflow_runs"); + expect(tool.jsonSchema.type).toBe("object"); + expect(tool.jsonSchema.properties.repo.type).toBe("string"); + expect(tool.jsonSchema.required).toContain("repo"); + // The old ad-hoc `parameters` map is gone from the wire shape. + expect(tool.parameters).toBeUndefined(); + }); }); describe("POST /v1/instances/:id/tools/:name", () => { @@ -69,4 +80,40 @@ describe("POST /v1/instances/:id/tools/:name", () => { expect(body.success).toBe(false); expect(body.content).toMatch(/not connected|not configured/i); }); + it("400s when a required field is missing (validated against jsonSchema before dispatch)", async () => { + const { app, env } = testApp(); + // github_workflow_runs requires `repo`; omit it. + const res = await req(app, env, "/v1/instances/i1/tools/github_workflow_runs", { method: "POST", body: "{}" }, await tok("u1")); + expect(res.status).toBe(400); + const body = (await res.json()) as any; + expect(body.error).toMatch(/required field: repo/i); + }); + it("400s when a field has the wrong basic type", async () => { + const { app, env } = testApp(); + // github_read_issue: `number` must be a number. + const res = await req( + app, + env, + "/v1/instances/i1/tools/github_read_issue", + { method: "POST", body: JSON.stringify({ repo: "owner/name", number: "not-a-number" }) }, + await tok("u1"), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as any; + expect(body.error).toMatch(/"number" must be a number/i); + }); + it("passes validation when required fields are present + well-typed (reaches the handler)", async () => { + const { app, env } = testApp(); + const res = await req( + app, + env, + "/v1/instances/i1/tools/github_read_issue", + { method: "POST", body: JSON.stringify({ repo: "owner/name", number: 7 }) }, + await tok("u1"), + ); + // Validation passed → handler ran → GitHub not configured → 200 with success:false. + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.content).toMatch(/not connected|not configured/i); + }); }); diff --git a/workers/api/src/routes/tools.ts b/workers/api/src/routes/tools.ts index eb6bc0c0..db5d1d75 100644 --- a/workers/api/src/routes/tools.ts +++ b/workers/api/src/routes/tools.ts @@ -1,10 +1,47 @@ import { Hono } from "hono"; import { HttpError, requireUser } from "../lib/auth.js"; import { requireOwnedInstance } from "./instances-runtime.js"; -import { getRegistryTool, registryTools, runRegistryTool } from "../lib/tool-registry.js"; +import { getRegistryTool, registryTools, runRegistryTool, type JsonSchema } from "../lib/tool-registry.js"; import { listConsents, revokeConsent, setConsent } from "../lib/connector-consent.js"; import type { Env } from "../types.js"; +/** + * Minimal draft-07 object-schema validator — required-fields + basic JSON types only, + * deliberately dependency-free (the repo has no ajv). Returns an error string on the + * first violation, or null when the input satisfies the schema. Unknown/extra keys are + * allowed (schemas here don't set additionalProperties); properties without a matching + * schema entry are skipped, matching the tools' permissive handlers. + */ +function validateAgainstSchema(schema: JsonSchema, input: Record): string | null { + for (const req of schema.required ?? []) { + if (input[req] === undefined || input[req] === null) return `Missing required field: ${req}`; + } + for (const [key, spec] of Object.entries(schema.properties)) { + const val = input[key]; + if (val === undefined || val === null) continue; // absent optional (or absent required already caught) + if (!matchesType(val, spec.type)) return `Field "${key}" must be a ${spec.type}`; + } + return null; +} + +function matchesType(val: unknown, type: string): boolean { + switch (type) { + case "string": + return typeof val === "string"; + case "number": + case "integer": + return typeof val === "number" && Number.isFinite(val) && (type === "number" || Number.isInteger(val)); + case "boolean": + return typeof val === "boolean"; + case "array": + return Array.isArray(val); + case "object": + return typeof val === "object" && !Array.isArray(val); + default: + return true; // unknown type spec → don't block + } +} + /** * Generic connector/registry tool surface (issue #87). The SAME tools the agent * runtime dispatches (agent-think → runRegistryTool) are callable directly here and, @@ -22,7 +59,7 @@ toolRoutes.get("/:id/tools", async (c) => { connector: t.connector, scope: t.scope, description: t.description, - parameters: t.parameters, + jsonSchema: t.jsonSchema, })); return c.json({ tools }); }); @@ -33,8 +70,11 @@ toolRoutes.post("/:id/tools/:name", async (c) => { const instanceId = c.req.param("id"); await requireOwnedInstance(c.env, instanceId, session.uid); const name = c.req.param("name"); - if (!getRegistryTool(name)) throw new HttpError(404, `Unknown tool: ${name}`); + const tool = getRegistryTool(name); + if (!tool) throw new HttpError(404, `Unknown tool: ${name}`); const input = (await c.req.json().catch(() => ({}))) as Record; + const invalid = validateAgainstSchema(tool.jsonSchema, input); + if (invalid) throw new HttpError(400, invalid); const result = await runRegistryTool(name, { env: c.env, userId: session.uid, instanceId }, input); return c.json(result); });