diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index 8f9efddb9b..8c2fb32f8b 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { gunzipSync } from "node:zlib"; import { afterEach, describe, expect, it } from "vitest"; import postgres from "postgres"; import { createBufferedTextFileWriter, runDatabaseBackup, runDatabaseRestore } from "./backup-lib.js"; @@ -410,6 +411,79 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { 60_000, ); + it( + "restores fallback COPY data when child tables are dumped before parent tables", + async () => { + const sourceConnectionString = await createTempDatabase(); + const restoreConnectionString = await createSiblingDatabase( + sourceConnectionString, + "paperclip_copy_fk_restore_target", + ); + const backupDir = createTempDir("paperclip-db-copy-fk-backup-"); + const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} }); + const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} }); + const originalPgDumpPath = process.env.PAPERCLIP_PG_DUMP_PATH; + process.env.PAPERCLIP_PG_DUMP_PATH = "/bin/false"; + + try { + await sourceSql.unsafe(` + CREATE TABLE "public"."zzz_parent_records" ( + "id" uuid PRIMARY KEY, + "name" text NOT NULL + ); + CREATE TABLE "public"."aaa_child_records" ( + "id" uuid PRIMARY KEY, + "parent_id" uuid NOT NULL REFERENCES "public"."zzz_parent_records"("id") ON DELETE CASCADE, + "note" text NOT NULL + ); + INSERT INTO "public"."zzz_parent_records" ("id", "name") + VALUES ('11111111-1111-4111-8111-111111111111', 'parent'); + INSERT INTO "public"."aaa_child_records" ("id", "parent_id", "note") + VALUES ( + '22222222-2222-4222-8222-222222222222', + '11111111-1111-4111-8111-111111111111', + 'child emitted before parent' + ); + `); + + const result = await runDatabaseBackup({ + connectionString: sourceConnectionString, + backupDir, + retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, + filenamePrefix: "paperclip-copy-fk-test", + backupEngine: "auto", + }); + + const backupSql = gunzipSync(await fs.promises.readFile(result.backupFile)).toString("utf8"); + expect(backupSql.indexOf("-- Data for: public.aaa_child_records")).toBeGreaterThan(-1); + expect(backupSql.indexOf("-- Data for: public.aaa_child_records")).toBeLessThan( + backupSql.indexOf("-- Data for: public.zzz_parent_records"), + ); + + await runDatabaseRestore({ + connectionString: restoreConnectionString, + backupFile: result.backupFile, + }); + + const rows = await restoreSql.unsafe<{ note: string; name: string }[]>(` + SELECT child."note", parent."name" + FROM "public"."aaa_child_records" child + JOIN "public"."zzz_parent_records" parent ON parent."id" = child."parent_id" + `); + expect(rows).toEqual([{ note: "child emitted before parent", name: "parent" }]); + } finally { + if (originalPgDumpPath === undefined) { + delete process.env.PAPERCLIP_PG_DUMP_PATH; + } else { + process.env.PAPERCLIP_PG_DUMP_PATH = originalPgDumpPath; + } + await sourceSql.end(); + await restoreSql.end(); + } + }, + 60_000, + ); + it( "restores legacy public-only backups without migration history", async () => { diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 02dc168bdb..41750e77ea 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -985,10 +985,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise { const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); + let psqlRestoreError: unknown = null; try { await restoreWithPsql(opts, connectTimeout); return; } catch (error) { + psqlRestoreError = error; if (!(await hasStatementBreakpoints(opts.backupFile))) { throw new Error( `Failed to restore ${basename(opts.backupFile)} with psql: ${sanitizeRestoreErrorMessage(error)}`, @@ -1010,8 +1012,9 @@ export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promi .map((line) => line.trim()) .find((line) => line.length > 0 && !line.startsWith("--")) : null; + const psqlMessage = psqlRestoreError === null ? "" : `; psql error: ${sanitizeRestoreErrorMessage(psqlRestoreError)}`; throw new Error( - `Failed to restore ${basename(opts.backupFile)}: ${sanitizeRestoreErrorMessage(error)}${statementPreview ? ` [statement: ${statementPreview.slice(0, 120)}]` : ""}`, + `Failed to restore ${basename(opts.backupFile)}: ${sanitizeRestoreErrorMessage(error)}${statementPreview ? ` [statement: ${statementPreview.slice(0, 120)}]` : ""}${psqlMessage}`, ); } finally { await sql.end(); diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index a9616ec91b..2e9621ddb7 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -5,6 +5,7 @@ import postgres from "postgres"; import { applyPendingMigrations, inspectMigrations, + resetPostgresDatabase, } from "./client.js"; import { getEmbeddedPostgresTestSupport, @@ -88,6 +89,34 @@ if (!embeddedPostgresSupport.supported) { ); } +describeEmbeddedPostgres("resetPostgresDatabase", () => { + it("recreates an existing database so stale tables are removed", async () => { + const connectionString = await createTempDatabase(); + const adminUrl = new URL(connectionString); + const databaseName = adminUrl.pathname.replace(/^\//, ""); + adminUrl.pathname = "/postgres"; + + const setupSql = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + await setupSql.unsafe(`CREATE TABLE stale_reseed_target_only (id integer PRIMARY KEY)`); + } finally { + await setupSql.end(); + } + + await resetPostgresDatabase(adminUrl.toString(), databaseName); + + const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + const rows = await verifySql.unsafe<{ stale_table: string | null }[]>( + `SELECT to_regclass('public.stale_reseed_target_only')::text AS stale_table`, + ); + expect(rows[0]?.stale_table).toBeNull(); + } finally { + await verifySql.end(); + } + }); +}); + describeEmbeddedPostgres("applyPendingMigrations", () => { it("rejects unallowlisted migration backfills that bump updated_at on user-visible tables", async () => { const entries = await fs.promises.readdir(new URL("./migrations", import.meta.url), { diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 2b1949ab9b..f2ec3fa32c 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -776,4 +776,25 @@ export async function ensurePostgresDatabase( } } +export async function resetPostgresDatabase( + url: string, + databaseName: string, +): Promise<"reset"> { + const quotedDatabaseName = quoteIdentifier(databaseName); + const sql = createUtilitySql(url); + try { + await sql` + select pg_terminate_backend(pid) + from pg_stat_activity + where datname = ${databaseName} + and pid <> pg_backend_pid() + `; + await sql.unsafe(`drop database if exists ${quotedDatabaseName}`); + await sql.unsafe(`create database ${quotedDatabaseName} encoding 'UTF8' lc_collate 'C' lc_ctype 'C' template template0`); + return "reset"; + } finally { + await sql.end(); + } +} + export type Db = ReturnType; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index a1c5bce8b2..ff1a803dba 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -2,6 +2,7 @@ export { createDb, getPostgresDataDirectory, ensurePostgresDatabase, + resetPostgresDatabase, inspectMigrations, applyPendingMigrations, reconcilePendingMigrationHistory, diff --git a/packages/db/src/migrations/0148_tool_access_mcp_connections.sql b/packages/db/src/migrations/0148_tool_access_mcp_connections.sql new file mode 100644 index 0000000000..6892d6d871 --- /dev/null +++ b/packages/db/src/migrations/0148_tool_access_mcp_connections.sql @@ -0,0 +1,126 @@ +CREATE TABLE IF NOT EXISTS "tool_applications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "name" text NOT NULL, + "type" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "tool_connections" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "application_id" uuid NOT NULL, + "name" text NOT NULL, + "transport" text NOT NULL, + "status" text DEFAULT 'draft' NOT NULL, + "enabled" boolean DEFAULT false NOT NULL, + "config" jsonb DEFAULT '{}'::jsonb NOT NULL, + "credential_refs" jsonb DEFAULT '[]'::jsonb NOT NULL, + "health_status" text DEFAULT 'unchecked' NOT NULL, + "health_message" text, + "last_health_at" timestamp with time zone, + "last_catalog_refresh_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "tool_catalog_entries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "connection_id" uuid NOT NULL, + "name" text NOT NULL, + "title" text, + "description" text, + "input_schema" jsonb DEFAULT '{}'::jsonb NOT NULL, + "annotations" jsonb DEFAULT '{}'::jsonb NOT NULL, + "risk_level" text DEFAULT 'read' NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "version_hash" text NOT NULL, + "first_seen_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_seen_at" timestamp with time zone DEFAULT now() NOT NULL, + "quarantined_at" timestamp with time zone, + "quarantine_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "tool_runtime_slots" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "connection_id" uuid NOT NULL, + "slot_key" text NOT NULL, + "status" text DEFAULT 'stopped' NOT NULL, + "provider_ref" text, + "health_status" text DEFAULT 'unchecked' NOT NULL, + "health_message" text, + "last_started_at" timestamp with time zone, + "last_used_at" timestamp with time zone, + "idle_deadline_at" timestamp with time zone, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "tool_access_audit_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "connection_id" uuid, + "catalog_entry_id" uuid, + "actor_type" text DEFAULT 'system' NOT NULL, + "actor_id" text, + "action" text NOT NULL, + "outcome" text NOT NULL, + "reason_code" text, + "details" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_applications_company_id_companies_id_fk') THEN + ALTER TABLE "tool_applications" ADD CONSTRAINT "tool_applications_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_connections_company_id_companies_id_fk') THEN + ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_connections_application_id_tool_applications_id_fk') THEN + ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_catalog_entries_company_id_companies_id_fk') THEN + ALTER TABLE "tool_catalog_entries" ADD CONSTRAINT "tool_catalog_entries_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_catalog_entries_connection_id_tool_connections_id_fk') THEN + ALTER TABLE "tool_catalog_entries" ADD CONSTRAINT "tool_catalog_entries_connection_id_tool_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_runtime_slots_company_id_companies_id_fk') THEN + ALTER TABLE "tool_runtime_slots" ADD CONSTRAINT "tool_runtime_slots_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_runtime_slots_connection_id_tool_connections_id_fk') THEN + ALTER TABLE "tool_runtime_slots" ADD CONSTRAINT "tool_runtime_slots_connection_id_tool_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_access_audit_events_company_id_companies_id_fk') THEN + ALTER TABLE "tool_access_audit_events" ADD CONSTRAINT "tool_access_audit_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_access_audit_events_connection_id_tool_connections_id_fk') THEN + ALTER TABLE "tool_access_audit_events" ADD CONSTRAINT "tool_access_audit_events_connection_id_tool_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_access_audit_events_catalog_entry_id_tool_catalog_entries_id_fk') THEN + ALTER TABLE "tool_access_audit_events" ADD CONSTRAINT "tool_access_audit_events_catalog_entry_id_tool_catalog_entries_id_fk" FOREIGN KEY ("catalog_entry_id") REFERENCES "public"."tool_catalog_entries"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "tool_applications_company_idx" ON "tool_applications" USING btree ("company_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_applications_company_name_uq" ON "tool_applications" USING btree ("company_id", "name");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_connections_company_idx" ON "tool_connections" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_connections_application_idx" ON "tool_connections" USING btree ("application_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_connections_company_name_uq" ON "tool_connections" USING btree ("company_id", "name");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_catalog_entries_company_idx" ON "tool_catalog_entries" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_catalog_entries_connection_idx" ON "tool_catalog_entries" USING btree ("connection_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_catalog_entries_connection_name_uq" ON "tool_catalog_entries" USING btree ("connection_id", "name");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_runtime_slots_company_idx" ON "tool_runtime_slots" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_runtime_slots_connection_idx" ON "tool_runtime_slots" USING btree ("connection_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_runtime_slots_slot_key_uq" ON "tool_runtime_slots" USING btree ("company_id", "slot_key");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_access_audit_company_created_idx" ON "tool_access_audit_events" USING btree ("company_id", "created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_access_audit_connection_idx" ON "tool_access_audit_events" USING btree ("connection_id"); diff --git a/packages/db/src/migrations/0149_agent_access_phase2_contracts.sql b/packages/db/src/migrations/0149_agent_access_phase2_contracts.sql new file mode 100644 index 0000000000..39db6be7cc --- /dev/null +++ b/packages/db/src/migrations/0149_agent_access_phase2_contracts.sql @@ -0,0 +1,612 @@ +ALTER TABLE "tool_applications" ADD COLUMN IF NOT EXISTS "application_key" text;--> statement-breakpoint +ALTER TABLE "tool_applications" ADD COLUMN IF NOT EXISTS "description" text;--> statement-breakpoint +ALTER TABLE "tool_applications" ADD COLUMN IF NOT EXISTS "plugin_id" uuid;--> statement-breakpoint +ALTER TABLE "tool_applications" ADD COLUMN IF NOT EXISTS "owner_agent_id" uuid;--> statement-breakpoint +ALTER TABLE "tool_applications" ADD COLUMN IF NOT EXISTS "owner_user_id" text;--> statement-breakpoint +ALTER TABLE "tool_applications" ADD COLUMN IF NOT EXISTS "archived_at" timestamp with time zone;--> statement-breakpoint + +UPDATE "tool_applications" +SET "application_key" = concat( + coalesce(nullif(lower(regexp_replace("name", '[^a-zA-Z0-9._:-]+', '-', 'g')), ''), 'app'), + '-', + "id"::text +) +WHERE "application_key" IS NULL;--> statement-breakpoint + +ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "connection_kind" text DEFAULT 'managed' NOT NULL;--> statement-breakpoint +ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "transport_config" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "credential_secret_refs" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "health_checked_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "last_error" text;--> statement-breakpoint +ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "created_by_agent_id" uuid;--> statement-breakpoint +ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "created_by_user_id" text;--> statement-breakpoint + +UPDATE "tool_connections" +SET "transport_config" = coalesce(nullif("config", '{}'::jsonb), '{}'::jsonb) +WHERE "transport_config" = '{}'::jsonb + AND "config" <> '{}'::jsonb;--> statement-breakpoint + +ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "application_id" uuid;--> statement-breakpoint +ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "entry_kind" text DEFAULT 'tool' NOT NULL;--> statement-breakpoint +ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "tool_name" text;--> statement-breakpoint +ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "output_schema" jsonb;--> statement-breakpoint +ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "is_read_only" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "is_write" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "is_destructive" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "version" text;--> statement-breakpoint +ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "schema_hash" text;--> statement-breakpoint +ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "reviewed_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "reviewed_by_agent_id" uuid;--> statement-breakpoint +ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "reviewed_by_user_id" text;--> statement-breakpoint + +UPDATE "tool_catalog_entries" AS e +SET + "application_id" = c."application_id", + "tool_name" = coalesce(e."tool_name", e."name"), + "is_read_only" = CASE WHEN e."risk_level" = 'read' THEN true ELSE false END, + "is_write" = CASE WHEN e."risk_level" IN ('write', 'destructive') THEN true ELSE false END, + "is_destructive" = CASE WHEN e."risk_level" = 'destructive' THEN true ELSE false END, + "schema_hash" = coalesce(e."schema_hash", e."version_hash") +FROM "tool_connections" c +WHERE c."id" = e."connection_id";--> statement-breakpoint + +UPDATE "tool_catalog_entries" +SET "tool_name" = "name" +WHERE "tool_name" IS NULL;--> statement-breakpoint + +ALTER TABLE "tool_catalog_entries" ALTER COLUMN "tool_name" SET NOT NULL;--> statement-breakpoint + +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "application_id" uuid;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "project_workspace_id" uuid;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "execution_workspace_id" uuid;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "issue_id" uuid;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "owner_scope_type" text DEFAULT 'connection' NOT NULL;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "owner_scope_id" text;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "runtime_kind" text DEFAULT 'local_stdio' NOT NULL;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "reuse_key" text;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "workspace_scope" text;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "credential_scope_hash" text;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "provider" text;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "process_id" integer;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "command_template_key" text;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "last_health_check_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "started_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "stopped_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "idle_expires_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "last_error" text;--> statement-breakpoint +ALTER TABLE "tool_runtime_slots" ALTER COLUMN "connection_id" DROP NOT NULL;--> statement-breakpoint + +UPDATE "tool_runtime_slots" AS s +SET "application_id" = c."application_id" +FROM "tool_connections" c +WHERE c."id" = s."connection_id" + AND s."application_id" IS NULL;--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "tool_profiles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "profile_key" text NOT NULL, + "name" text NOT NULL, + "description" text, + "status" text DEFAULT 'active' NOT NULL, + "default_action" text DEFAULT 'deny' NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "tool_profile_entries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "profile_id" uuid NOT NULL, + "selector_type" text NOT NULL, + "effect" text DEFAULT 'include' NOT NULL, + "application_id" uuid, + "connection_id" uuid, + "catalog_entry_id" uuid, + "tool_name" text, + "risk_level" text, + "conditions" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "tool_profile_bindings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "profile_id" uuid NOT NULL, + "target_type" text NOT NULL, + "target_id" text NOT NULL, + "priority" integer DEFAULT 100 NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "tool_policies" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "name" text NOT NULL, + "description" text, + "policy_type" text NOT NULL, + "priority" integer DEFAULT 100 NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "selectors" jsonb DEFAULT '{}'::jsonb NOT NULL, + "conditions" jsonb, + "config" jsonb, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "tool_invocations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "idempotency_key" text, + "actor_type" text DEFAULT 'system' NOT NULL, + "actor_id" text, + "agent_id" uuid, + "issue_id" uuid, + "run_id" uuid, + "application_id" uuid, + "connection_id" uuid, + "catalog_entry_id" uuid, + "tool_name" text NOT NULL, + "arguments_hash" text, + "arguments_summary" jsonb, + "policy_decision" text, + "matched_policy_ids" jsonb DEFAULT '[]'::jsonb NOT NULL, + "approval_state" text DEFAULT 'not_required' NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "upstream_request_id" text, + "result_hash" text, + "result_summary" jsonb, + "result_size_bytes" integer, + "result_artifact_id" uuid, + "error_code" text, + "error_message" text, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "tool_action_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "invocation_id" uuid NOT NULL, + "issue_id" uuid, + "interaction_id" uuid, + "approval_id" uuid, + "status" text DEFAULT 'pending' NOT NULL, + "canonical_arguments_hash" text NOT NULL, + "canonical_arguments_summary" jsonb NOT NULL, + "signed_arguments" text, + "preview_markdown" text, + "requested_by_agent_id" uuid, + "requested_by_user_id" text, + "resolved_by_agent_id" uuid, + "resolved_by_user_id" text, + "decided_by_agent_id" uuid, + "decided_by_user_id" text, + "decided_at" timestamp with time zone, + "expires_at" timestamp with time zone, + "resolved_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "tool_call_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "event_type" text NOT NULL, + "actor_type" text DEFAULT 'system' NOT NULL, + "actor_id" text, + "agent_id" uuid, + "run_id" uuid, + "issue_id" uuid, + "application_id" uuid, + "connection_id" uuid, + "catalog_entry_id" uuid, + "invocation_id" uuid, + "action_request_id" uuid, + "runtime_slot_id" uuid, + "tool_name" text, + "decision" text, + "matched_policy_ids" jsonb DEFAULT '[]'::jsonb NOT NULL, + "reason_code" text, + "outcome" text DEFAULT 'pending' NOT NULL, + "latency_ms" integer, + "arguments_summary" jsonb, + "request_hash" text, + "request_summary" jsonb, + "result_hash" text, + "result_summary" jsonb, + "result_size_bytes" integer, + "redaction_plan" jsonb, + "rate_limit_state" jsonb, + "metadata" jsonb, + "error_code" text, + "error_message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "tool_rate_limit_counters" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "policy_id" uuid NOT NULL, + "counter_key" text NOT NULL, + "scope_type" text NOT NULL, + "scope_id" text NOT NULL, + "window_kind" text NOT NULL, + "window_start_at" timestamp with time zone NOT NULL, + "limit" integer NOT NULL, + "remaining" integer NOT NULL, + "reset_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_applications_plugin_id_plugins_id_fk') THEN + ALTER TABLE "tool_applications" ADD CONSTRAINT "tool_applications_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_applications_owner_agent_id_agents_id_fk') THEN + ALTER TABLE "tool_applications" ADD CONSTRAINT "tool_applications_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_connections_created_by_agent_id_agents_id_fk') THEN + ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_catalog_entries_application_id_tool_applications_id_fk') THEN + ALTER TABLE "tool_catalog_entries" ADD CONSTRAINT "tool_catalog_entries_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_catalog_entries_reviewed_by_agent_id_agents_id_fk') THEN + ALTER TABLE "tool_catalog_entries" ADD CONSTRAINT "tool_catalog_entries_reviewed_by_agent_id_agents_id_fk" FOREIGN KEY ("reviewed_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profiles_company_id_companies_id_fk') THEN + ALTER TABLE "tool_profiles" ADD CONSTRAINT "tool_profiles_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_entries_company_id_companies_id_fk') THEN + ALTER TABLE "tool_profile_entries" ADD CONSTRAINT "tool_profile_entries_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_entries_profile_id_tool_profiles_id_fk') THEN + ALTER TABLE "tool_profile_entries" ADD CONSTRAINT "tool_profile_entries_profile_id_tool_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."tool_profiles"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_entries_application_id_tool_applications_id_fk') THEN + ALTER TABLE "tool_profile_entries" ADD CONSTRAINT "tool_profile_entries_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_entries_connection_id_tool_connections_id_fk') THEN + ALTER TABLE "tool_profile_entries" ADD CONSTRAINT "tool_profile_entries_connection_id_tool_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_entries_catalog_entry_id_tool_catalog_entries_id_fk') THEN + ALTER TABLE "tool_profile_entries" ADD CONSTRAINT "tool_profile_entries_catalog_entry_id_tool_catalog_entries_id_fk" FOREIGN KEY ("catalog_entry_id") REFERENCES "public"."tool_catalog_entries"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_bindings_company_id_companies_id_fk') THEN + ALTER TABLE "tool_profile_bindings" ADD CONSTRAINT "tool_profile_bindings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_bindings_profile_id_tool_profiles_id_fk') THEN + ALTER TABLE "tool_profile_bindings" ADD CONSTRAINT "tool_profile_bindings_profile_id_tool_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."tool_profiles"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_bindings_created_by_agent_id_agents_id_fk') THEN + ALTER TABLE "tool_profile_bindings" ADD CONSTRAINT "tool_profile_bindings_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_policies_company_id_companies_id_fk') THEN + ALTER TABLE "tool_policies" ADD CONSTRAINT "tool_policies_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_policies_created_by_agent_id_agents_id_fk') THEN + ALTER TABLE "tool_policies" ADD CONSTRAINT "tool_policies_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_runtime_slots_application_id_tool_applications_id_fk') THEN + ALTER TABLE "tool_runtime_slots" ADD CONSTRAINT "tool_runtime_slots_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_runtime_slots_project_workspace_id_project_workspaces_id_fk') THEN + ALTER TABLE "tool_runtime_slots" ADD CONSTRAINT "tool_runtime_slots_project_workspace_id_project_workspaces_id_fk" FOREIGN KEY ("project_workspace_id") REFERENCES "public"."project_workspaces"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_runtime_slots_execution_workspace_id_execution_workspaces_id_fk') THEN + ALTER TABLE "tool_runtime_slots" ADD CONSTRAINT "tool_runtime_slots_execution_workspace_id_execution_workspaces_id_fk" FOREIGN KEY ("execution_workspace_id") REFERENCES "public"."execution_workspaces"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_runtime_slots_issue_id_issues_id_fk') THEN + ALTER TABLE "tool_runtime_slots" ADD CONSTRAINT "tool_runtime_slots_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_company_id_companies_id_fk') THEN + ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_agent_id_agents_id_fk') THEN + ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_issue_id_issues_id_fk') THEN + ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_run_id_heartbeat_runs_id_fk') THEN + ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_application_id_tool_applications_id_fk') THEN + ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_connection_id_tool_connections_id_fk') THEN + ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_connection_id_tool_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_catalog_entry_id_tool_catalog_entries_id_fk') THEN + ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_catalog_entry_id_tool_catalog_entries_id_fk" FOREIGN KEY ("catalog_entry_id") REFERENCES "public"."tool_catalog_entries"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_company_id_companies_id_fk') THEN + ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_invocation_id_tool_invocations_id_fk') THEN + ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_invocation_id_tool_invocations_id_fk" FOREIGN KEY ("invocation_id") REFERENCES "public"."tool_invocations"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_issue_id_issues_id_fk') THEN + ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_interaction_id_issue_thread_interactions_id_fk') THEN + ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_interaction_id_issue_thread_interactions_id_fk" FOREIGN KEY ("interaction_id") REFERENCES "public"."issue_thread_interactions"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_approval_id_approvals_id_fk') THEN + ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_approval_id_approvals_id_fk" FOREIGN KEY ("approval_id") REFERENCES "public"."approvals"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_requested_by_agent_id_agents_id_fk') THEN + ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_requested_by_agent_id_agents_id_fk" FOREIGN KEY ("requested_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_resolved_by_agent_id_agents_id_fk') THEN + ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_resolved_by_agent_id_agents_id_fk" FOREIGN KEY ("resolved_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_decided_by_agent_id_agents_id_fk') THEN + ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_decided_by_agent_id_agents_id_fk" FOREIGN KEY ("decided_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_company_id_companies_id_fk') THEN + ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_agent_id_agents_id_fk') THEN + ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_run_id_heartbeat_runs_id_fk') THEN + ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_issue_id_issues_id_fk') THEN + ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_application_id_tool_applications_id_fk') THEN + ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_connection_id_tool_connections_id_fk') THEN + ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_connection_id_tool_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_catalog_entry_id_tool_catalog_entries_id_fk') THEN + ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_catalog_entry_id_tool_catalog_entries_id_fk" FOREIGN KEY ("catalog_entry_id") REFERENCES "public"."tool_catalog_entries"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_invocation_id_tool_invocations_id_fk') THEN + ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_invocation_id_tool_invocations_id_fk" FOREIGN KEY ("invocation_id") REFERENCES "public"."tool_invocations"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_action_request_id_tool_action_requests_id_fk') THEN + ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_action_request_id_tool_action_requests_id_fk" FOREIGN KEY ("action_request_id") REFERENCES "public"."tool_action_requests"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_runtime_slot_id_tool_runtime_slots_id_fk') THEN + ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_runtime_slot_id_tool_runtime_slots_id_fk" FOREIGN KEY ("runtime_slot_id") REFERENCES "public"."tool_runtime_slots"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_rate_limit_counters_company_id_companies_id_fk') THEN + ALTER TABLE "tool_rate_limit_counters" ADD CONSTRAINT "tool_rate_limit_counters_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_rate_limit_counters_policy_id_tool_policies_id_fk') THEN + ALTER TABLE "tool_rate_limit_counters" ADD CONSTRAINT "tool_rate_limit_counters_policy_id_tool_policies_id_fk" FOREIGN KEY ("policy_id") REFERENCES "public"."tool_policies"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "tool_applications_company_status_idx" ON "tool_applications" USING btree ("company_id", "status");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_applications_company_key_uq" ON "tool_applications" USING btree ("company_id", "application_key");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_connections_company_enabled_idx" ON "tool_connections" USING btree ("company_id", "enabled");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_catalog_entries_application_idx" ON "tool_catalog_entries" USING btree ("application_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_catalog_entries_company_status_idx" ON "tool_catalog_entries" USING btree ("company_id", "status");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_profiles_company_status_idx" ON "tool_profiles" USING btree ("company_id", "status");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_profiles_company_key_uq" ON "tool_profiles" USING btree ("company_id", "profile_key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_profiles_company_name_uq" ON "tool_profiles" USING btree ("company_id", "name");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_profile_entries_company_profile_idx" ON "tool_profile_entries" USING btree ("company_id", "profile_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_profile_entries_application_idx" ON "tool_profile_entries" USING btree ("company_id", "application_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_profile_entries_connection_idx" ON "tool_profile_entries" USING btree ("company_id", "connection_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_profile_entries_catalog_entry_idx" ON "tool_profile_entries" USING btree ("company_id", "catalog_entry_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_profile_bindings_company_target_idx" ON "tool_profile_bindings" USING btree ("company_id", "target_type", "target_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_profile_bindings_target_profile_uq" ON "tool_profile_bindings" USING btree ("company_id", "target_type", "target_id", "profile_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_policies_company_enabled_idx" ON "tool_policies" USING btree ("company_id", "enabled");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_policies_company_type_idx" ON "tool_policies" USING btree ("company_id", "policy_type");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_policies_company_name_uq" ON "tool_policies" USING btree ("company_id", "name");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_runtime_slots_execution_workspace_idx" ON "tool_runtime_slots" USING btree ("company_id", "execution_workspace_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_invocations_company_created_idx" ON "tool_invocations" USING btree ("company_id", "created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_invocations_run_idx" ON "tool_invocations" USING btree ("company_id", "run_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_invocations_issue_idx" ON "tool_invocations" USING btree ("company_id", "issue_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_invocations_company_idempotency_uq" ON "tool_invocations" USING btree ("company_id", "idempotency_key");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_action_requests_company_status_idx" ON "tool_action_requests" USING btree ("company_id", "status");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_action_requests_invocation_idx" ON "tool_action_requests" USING btree ("invocation_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_action_requests_issue_idx" ON "tool_action_requests" USING btree ("company_id", "issue_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_call_events_company_created_idx" ON "tool_call_events" USING btree ("company_id", "created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_call_events_run_idx" ON "tool_call_events" USING btree ("company_id", "run_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_call_events_issue_idx" ON "tool_call_events" USING btree ("company_id", "issue_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_call_events_invocation_idx" ON "tool_call_events" USING btree ("invocation_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_rate_limit_counters_company_idx" ON "tool_rate_limit_counters" USING btree ("company_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_rate_limit_counters_window_uq" ON "tool_rate_limit_counters" USING btree ("company_id", "policy_id", "counter_key", "window_kind", "window_start_at");--> statement-breakpoint + +INSERT INTO "tool_applications" ( + "company_id", + "application_key", + "name", + "type", + "status", + "plugin_id", + "metadata", + "created_at", + "updated_at" +) +SELECT + c."id", + 'paperclip_plugin:' || p."plugin_key", + coalesce(p."manifest_json"->>'name', p."plugin_key"), + 'paperclip_plugin', + 'active', + p."id", + jsonb_build_object('source', 'plugin_backfill', 'pluginKey', p."plugin_key"), + now(), + now() +FROM "companies" c +CROSS JOIN "plugins" p +WHERE jsonb_array_length(coalesce(p."manifest_json"->'tools', '[]'::jsonb)) > 0 +ON CONFLICT ("company_id", "name") DO NOTHING;--> statement-breakpoint + +INSERT INTO "tool_connections" ( + "company_id", + "application_id", + "name", + "connection_kind", + "transport", + "status", + "enabled", + "config", + "transport_config", + "credential_refs", + "credential_secret_refs", + "health_status", + "created_at", + "updated_at" +) +SELECT + a."company_id", + a."id", + 'Plugin: ' || coalesce(p."manifest_json"->>'name', p."plugin_key"), + 'managed', + 'remote_http', + 'active', + true, + jsonb_build_object('pluginKey', p."plugin_key", 'type', 'paperclip_plugin'), + jsonb_build_object('pluginKey', p."plugin_key", 'type', 'paperclip_plugin'), + '[]'::jsonb, + '[]'::jsonb, + 'ok', + now(), + now() +FROM "tool_applications" a +JOIN "plugins" p ON p."id" = a."plugin_id" +WHERE a."type" = 'paperclip_plugin' + AND NOT EXISTS ( + SELECT 1 FROM "tool_connections" existing + WHERE existing."company_id" = a."company_id" + AND existing."application_id" = a."id" + ) +ON CONFLICT ("company_id", "name") DO NOTHING;--> statement-breakpoint + +INSERT INTO "tool_catalog_entries" ( + "company_id", + "application_id", + "connection_id", + "entry_kind", + "name", + "tool_name", + "title", + "description", + "input_schema", + "annotations", + "risk_level", + "is_read_only", + "is_write", + "is_destructive", + "status", + "version_hash", + "schema_hash", + "first_seen_at", + "last_seen_at", + "created_at", + "updated_at" +) +SELECT + c."company_id", + c."application_id", + c."id", + 'tool', + tool.value->>'name', + tool.value->>'name', + coalesce(tool.value->>'displayName', tool.value->>'title'), + tool.value->>'description', + coalesce(tool.value->'parametersSchema', '{}'::jsonb), + '{}'::jsonb, + 'read', + true, + false, + false, + 'active', + md5(tool.value::text), + md5(tool.value::text), + now(), + now(), + now(), + now() +FROM "tool_connections" c +JOIN "tool_applications" a ON a."id" = c."application_id" +JOIN "plugins" p ON p."id" = a."plugin_id" +CROSS JOIN LATERAL jsonb_array_elements(coalesce(p."manifest_json"->'tools', '[]'::jsonb)) AS tool(value) +WHERE a."type" = 'paperclip_plugin' + AND tool.value ? 'name' +ON CONFLICT ("connection_id", "name") DO NOTHING;--> statement-breakpoint + +INSERT INTO "principal_permission_grants" ( + "company_id", + "principal_type", + "principal_id", + "permission_key", + "scope", + "granted_by_user_id", + "created_at", + "updated_at" +) +SELECT + memberships."company_id", + memberships."principal_type", + memberships."principal_id", + permissions."permission_key", + NULL, + NULL, + now(), + now() +FROM "company_memberships" memberships +JOIN ( + VALUES + ('tools:admin'), + ('tools:manage_connections'), + ('tools:manage_profiles'), + ('tools:view_audit'), + ('tools:manage_runtime') +) AS permissions("permission_key") ON true +WHERE memberships."principal_type" = 'user' + AND memberships."status" = 'active' + AND memberships."membership_role" IN ('owner', 'admin') +ON CONFLICT ("company_id", "principal_type", "principal_id", "permission_key") DO NOTHING;--> statement-breakpoint + +INSERT INTO "principal_permission_grants" ( + "company_id", + "principal_type", + "principal_id", + "permission_key", + "scope", + "granted_by_user_id", + "created_at", + "updated_at" +) +SELECT + agents."company_id", + 'agent', + agents."id", + permissions."permission_key", + NULL, + NULL, + now(), + now() +FROM "agents" +JOIN ( + VALUES + ('tools:admin'), + ('tools:manage_connections'), + ('tools:manage_profiles'), + ('tools:view_audit'), + ('tools:manage_runtime') +) AS permissions("permission_key") ON true +WHERE agents."role" IN ('ceo', 'cto') + AND agents."status" NOT IN ('pending_approval', 'terminated') +ON CONFLICT ("company_id", "principal_type", "principal_id", "permission_key") DO NOTHING; diff --git a/packages/db/src/migrations/0150_tool_invocation_catalog_snapshots.sql b/packages/db/src/migrations/0150_tool_invocation_catalog_snapshots.sql new file mode 100644 index 0000000000..d827b61c04 --- /dev/null +++ b/packages/db/src/migrations/0150_tool_invocation_catalog_snapshots.sql @@ -0,0 +1,2 @@ +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "catalog_version_hash" text;--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "catalog_schema_hash" text;--> statement-breakpoint diff --git a/packages/db/src/migrations/0151_tool_gateway_sessions.sql b/packages/db/src/migrations/0151_tool_gateway_sessions.sql new file mode 100644 index 0000000000..9d20d97073 --- /dev/null +++ b/packages/db/src/migrations/0151_tool_gateway_sessions.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS "tool_gateway_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "agent_id" uuid NOT NULL, + "run_id" uuid NOT NULL, + "issue_id" uuid, + "project_id" uuid, + "token_hash" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "last_used_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_gateway_sessions_company_id_companies_id_fk') THEN + ALTER TABLE "tool_gateway_sessions" ADD CONSTRAINT "tool_gateway_sessions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_gateway_sessions_agent_id_agents_id_fk') THEN + ALTER TABLE "tool_gateway_sessions" ADD CONSTRAINT "tool_gateway_sessions_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_gateway_sessions_run_id_heartbeat_runs_id_fk') THEN + ALTER TABLE "tool_gateway_sessions" ADD CONSTRAINT "tool_gateway_sessions_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_gateway_sessions_issue_id_issues_id_fk') THEN + ALTER TABLE "tool_gateway_sessions" ADD CONSTRAINT "tool_gateway_sessions_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_gateway_sessions_project_id_projects_id_fk') THEN + ALTER TABLE "tool_gateway_sessions" ADD CONSTRAINT "tool_gateway_sessions_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint + +CREATE UNIQUE INDEX IF NOT EXISTS "tool_gateway_sessions_token_hash_uq" ON "tool_gateway_sessions" USING btree ("token_hash");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_gateway_sessions_company_agent_idx" ON "tool_gateway_sessions" USING btree ("company_id", "agent_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_gateway_sessions_company_expires_idx" ON "tool_gateway_sessions" USING btree ("company_id", "expires_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_gateway_sessions_run_idx" ON "tool_gateway_sessions" USING btree ("company_id", "run_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_gateway_sessions_issue_idx" ON "tool_gateway_sessions" USING btree ("company_id", "issue_id"); diff --git a/packages/db/src/migrations/0152_tool_connection_application_no_cascade.sql b/packages/db/src/migrations/0152_tool_connection_application_no_cascade.sql new file mode 100644 index 0000000000..6250cfe0a8 --- /dev/null +++ b/packages/db/src/migrations/0152_tool_connection_application_no_cascade.sql @@ -0,0 +1,18 @@ +-- Harden DELETE /tool-applications/:applicationId against concurrent connection creation. +-- The tool_connections.application_id FK was ON DELETE CASCADE, so a connection created in +-- the gap between the endpoint's "any connections?" pre-check and its DELETE could be silently +-- removed by the cascade instead of forcing the promised 409. Switch the FK to ON DELETE +-- NO ACTION so the database fails closed: an application with connections can never be deleted, +-- and a concurrently-inserted connection (which holds a FOR KEY SHARE lock on the parent row) +-- forces the delete to raise a foreign_key_violation rather than cascade. +-- +-- NO ACTION (end-of-statement check) rather than RESTRICT (immediate check) is deliberate so a +-- company delete still cascades cleanly: companies -> tool_applications and companies -> +-- tool_connections both fire within the one DELETE statement, and the connections are already +-- gone by the time this constraint is verified. +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_connections_application_id_tool_applications_id_fk') THEN + ALTER TABLE "tool_connections" DROP CONSTRAINT "tool_connections_application_id_tool_applications_id_fk"; + END IF; + ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE no action ON UPDATE no action; +END $$; diff --git a/packages/db/src/migrations/0153_tool_stdio_command_templates.sql b/packages/db/src/migrations/0153_tool_stdio_command_templates.sql new file mode 100644 index 0000000000..810d512348 --- /dev/null +++ b/packages/db/src/migrations/0153_tool_stdio_command_templates.sql @@ -0,0 +1,32 @@ +CREATE TABLE IF NOT EXISTS "tool_stdio_command_templates" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "template_key" text NOT NULL, + "name" text NOT NULL, + "description" text, + "status" text DEFAULT 'active' NOT NULL, + "command" text NOT NULL, + "args" jsonb DEFAULT '[]'::jsonb NOT NULL, + "env_keys" jsonb DEFAULT '[]'::jsonb NOT NULL, + "tools" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "disabled_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_stdio_command_templates_company_id_companies_id_fk') THEN + ALTER TABLE "tool_stdio_command_templates" ADD CONSTRAINT "tool_stdio_command_templates_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_stdio_command_templates_created_by_agent_id_agents_id_fk') THEN + ALTER TABLE "tool_stdio_command_templates" ADD CONSTRAINT "tool_stdio_command_templates_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_stdio_command_templates_company_idx" ON "tool_stdio_command_templates" USING btree ("company_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_stdio_command_templates_company_status_idx" ON "tool_stdio_command_templates" USING btree ("company_id", "status"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_stdio_command_templates_company_key_uq" ON "tool_stdio_command_templates" USING btree ("company_id", "template_key"); diff --git a/packages/db/src/migrations/0154_tool_oauth_states.sql b/packages/db/src/migrations/0154_tool_oauth_states.sql new file mode 100644 index 0000000000..2faa939b93 --- /dev/null +++ b/packages/db/src/migrations/0154_tool_oauth_states.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS "tool_oauth_states" ( + "state" text PRIMARY KEY NOT NULL, + "company_id" uuid NOT NULL, + "connection_id" uuid NOT NULL, + "code_verifier" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'tool_oauth_states_company_id_companies_id_fk' + ) THEN + ALTER TABLE "tool_oauth_states" + ADD CONSTRAINT "tool_oauth_states_company_id_companies_id_fk" + FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") + ON DELETE cascade ON UPDATE no action; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'tool_oauth_states_connection_id_tool_connections_id_fk' + ) THEN + ALTER TABLE "tool_oauth_states" + ADD CONSTRAINT "tool_oauth_states_connection_id_tool_connections_id_fk" + FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") + ON DELETE cascade ON UPDATE no action; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS "tool_oauth_states_company_idx" ON "tool_oauth_states" USING btree ("company_id"); +CREATE INDEX IF NOT EXISTS "tool_oauth_states_connection_idx" ON "tool_oauth_states" USING btree ("connection_id"); +CREATE INDEX IF NOT EXISTS "tool_oauth_states_expires_at_idx" ON "tool_oauth_states" USING btree ("expires_at"); diff --git a/packages/db/src/migrations/0155_tool_oauth_state_actor_binding.sql b/packages/db/src/migrations/0155_tool_oauth_state_actor_binding.sql new file mode 100644 index 0000000000..37e755f4e8 --- /dev/null +++ b/packages/db/src/migrations/0155_tool_oauth_state_actor_binding.sql @@ -0,0 +1,5 @@ +ALTER TABLE "tool_oauth_states" ADD COLUMN IF NOT EXISTS "created_by_actor_type" text; +ALTER TABLE "tool_oauth_states" ADD COLUMN IF NOT EXISTS "created_by_actor_id" text; + +CREATE INDEX IF NOT EXISTS "tool_oauth_states_actor_idx" + ON "tool_oauth_states" USING btree ("created_by_actor_type", "created_by_actor_id"); diff --git a/packages/db/src/migrations/0156_tool_oauth_state_session_binding.sql b/packages/db/src/migrations/0156_tool_oauth_state_session_binding.sql new file mode 100644 index 0000000000..e89f8f590f --- /dev/null +++ b/packages/db/src/migrations/0156_tool_oauth_state_session_binding.sql @@ -0,0 +1 @@ +ALTER TABLE "tool_oauth_states" ADD COLUMN IF NOT EXISTS "created_by_session_id" text; diff --git a/packages/db/src/migrations/0157_tool_profile_new_tools_review.sql b/packages/db/src/migrations/0157_tool_profile_new_tools_review.sql new file mode 100644 index 0000000000..1c0c3fd6c6 --- /dev/null +++ b/packages/db/src/migrations/0157_tool_profile_new_tools_review.sql @@ -0,0 +1 @@ +ALTER TABLE "tool_profiles" ADD COLUMN IF NOT EXISTS "new_tools_reviewed_at" timestamp with time zone; diff --git a/packages/db/src/migrations/0158_tool_invocation_connected_mcp_metadata.sql b/packages/db/src/migrations/0158_tool_invocation_connected_mcp_metadata.sql new file mode 100644 index 0000000000..52d0ef267d --- /dev/null +++ b/packages/db/src/migrations/0158_tool_invocation_connected_mcp_metadata.sql @@ -0,0 +1,4 @@ +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "provider_type" text;--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "application_key" text;--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "upstream_tool_name" text;--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "risk_level" text; diff --git a/packages/db/src/migrations/0159_named_mcp_gateways.sql b/packages/db/src/migrations/0159_named_mcp_gateways.sql new file mode 100644 index 0000000000..33ab930cf1 --- /dev/null +++ b/packages/db/src/migrations/0159_named_mcp_gateways.sql @@ -0,0 +1,72 @@ +CREATE TABLE IF NOT EXISTS "tool_mcp_gateways" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "description" text, + "status" text DEFAULT 'active' NOT NULL, + "profile_id" uuid NOT NULL, + "agent_id" uuid, + "project_id" uuid, + "issue_id" uuid, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "tool_mcp_gateway_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "gateway_id" uuid NOT NULL, + "name" text NOT NULL, + "token_hash" text NOT NULL, + "expires_at" timestamp with time zone, + "last_used_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateways_company_id_companies_id_fk') THEN + ALTER TABLE "tool_mcp_gateways" ADD CONSTRAINT "tool_mcp_gateways_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateways_profile_id_tool_profiles_id_fk') THEN + ALTER TABLE "tool_mcp_gateways" ADD CONSTRAINT "tool_mcp_gateways_profile_id_tool_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."tool_profiles"("id") ON DELETE restrict ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateways_agent_id_agents_id_fk') THEN + ALTER TABLE "tool_mcp_gateways" ADD CONSTRAINT "tool_mcp_gateways_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateways_project_id_projects_id_fk') THEN + ALTER TABLE "tool_mcp_gateways" ADD CONSTRAINT "tool_mcp_gateways_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateways_issue_id_issues_id_fk') THEN + ALTER TABLE "tool_mcp_gateways" ADD CONSTRAINT "tool_mcp_gateways_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateway_tokens_company_id_companies_id_fk') THEN + ALTER TABLE "tool_mcp_gateway_tokens" ADD CONSTRAINT "tool_mcp_gateway_tokens_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateway_tokens_gateway_id_tool_mcp_gateways_id_fk') THEN + ALTER TABLE "tool_mcp_gateway_tokens" ADD CONSTRAINT "tool_mcp_gateway_tokens_gateway_id_tool_mcp_gateways_id_fk" FOREIGN KEY ("gateway_id") REFERENCES "public"."tool_mcp_gateways"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_mcp_gateways_company_idx" ON "tool_mcp_gateways" USING btree ("company_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_mcp_gateways_company_status_idx" ON "tool_mcp_gateways" USING btree ("company_id", "status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_mcp_gateways_profile_idx" ON "tool_mcp_gateways" USING btree ("company_id", "profile_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_mcp_gateways_company_slug_uq" ON "tool_mcp_gateways" USING btree ("company_id", "slug"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_mcp_gateways_company_name_uq" ON "tool_mcp_gateways" USING btree ("company_id", "name"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_mcp_gateway_tokens_token_hash_uq" ON "tool_mcp_gateway_tokens" USING btree ("token_hash"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_mcp_gateway_tokens_gateway_idx" ON "tool_mcp_gateway_tokens" USING btree ("company_id", "gateway_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_mcp_gateway_tokens_company_expires_idx" ON "tool_mcp_gateway_tokens" USING btree ("company_id", "expires_at"); diff --git a/packages/db/src/migrations/0160_mcp_gateway_contract_expansion.sql b/packages/db/src/migrations/0160_mcp_gateway_contract_expansion.sql new file mode 100644 index 0000000000..d74cc8b0e5 --- /dev/null +++ b/packages/db/src/migrations/0160_mcp_gateway_contract_expansion.sql @@ -0,0 +1,176 @@ +ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "gateway_public_id" text; +--> statement-breakpoint +UPDATE "tool_mcp_gateways" +SET "gateway_public_id" = 'gw_' || replace("id"::text, '-', '') +WHERE "gateway_public_id" IS NULL; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateways" ALTER COLUMN "gateway_public_id" SET DEFAULT ('gw_' || replace(gen_random_uuid()::text, '-', '')); +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateways" ALTER COLUMN "gateway_public_id" SET NOT NULL; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "display_slug" text; +--> statement-breakpoint +UPDATE "tool_mcp_gateways" +SET "display_slug" = "slug" +WHERE "display_slug" IS NULL OR "display_slug" = ''; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateways" ALTER COLUMN "display_slug" SET DEFAULT ''; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateways" ALTER COLUMN "display_slug" SET NOT NULL; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "default_profile_mode" text DEFAULT 'gateway_only' NOT NULL; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "context_scope_type" text DEFAULT 'none' NOT NULL; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "context_scope_id" text; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "approval_issue_id" uuid; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "auth_config" jsonb DEFAULT '{"version":1,"bearer":{"enabled":true,"tokenPrefix":"pcgw","defaultTtlSeconds":7776000,"requireFiniteExpiry":true,"longLivedTokenRequiresOverride":true},"oauth":{"enabled":false,"reservedFor":"v1_5","dynamicClientRegistration":false,"authorizationCodePkce":false}}'::jsonb NOT NULL; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "header_policy" jsonb DEFAULT '{"version":1,"callerPassthrough":{"enabled":false,"allowedHeaders":[]},"staticHeaders":[],"generatedMetadata":{"enabled":false,"allowedHeaders":[]},"responseHeaders":{"forwardMcpRequiredHeaders":true,"forwardSafeCacheHeaders":true}}'::jsonb NOT NULL; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "metadata_policy" jsonb DEFAULT '{"version":1,"forwardCompanyId":false,"forwardGatewayId":false,"forwardProjectId":false,"forwardIssueId":false,"forwardAgentId":false,"forwardRunId":false,"forwardCorrelationId":true}'::jsonb NOT NULL; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "on_demand_tools_config" jsonb DEFAULT '{"enabled":false,"searchToolName":"search_tools","runToolName":"run_tool"}'::jsonb NOT NULL; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "archived_at" timestamp with time zone; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "token_prefix" text DEFAULT '' NOT NULL; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "subject_type" text DEFAULT 'gateway_client' NOT NULL; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "subject_id" text; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "client_label" text DEFAULT '' NOT NULL; +--> statement-breakpoint +UPDATE "tool_mcp_gateway_tokens" +SET "client_label" = "name" +WHERE "client_label" = ''; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "owner_note" text DEFAULT '' NOT NULL; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "allowed_actions" jsonb DEFAULT '["tools/list","tools/call"]'::jsonb NOT NULL; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "expiry_override_reason" text; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "expiry_override_by_user_id" text; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "expiry_override_by_agent_id" uuid; +--> statement-breakpoint +ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "expiry_override_at" timestamp with time zone; +--> statement-breakpoint +ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "gateway_id" uuid; +--> statement-breakpoint +ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "gateway_token_id" uuid; +--> statement-breakpoint +ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "gateway_public_id" text; +--> statement-breakpoint +ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "client_subject_type" text; +--> statement-breakpoint +ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "client_subject_id" text; +--> statement-breakpoint +ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "client_name" text; +--> statement-breakpoint +ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "mcp_session_id" text; +--> statement-breakpoint +ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "correlation_id" text; +--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "gateway_id" uuid; +--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "gateway_token_id" uuid; +--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "gateway_public_id" text; +--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "client_subject_type" text; +--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "client_subject_id" text; +--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "client_name" text; +--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "mcp_session_id" text; +--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "correlation_id" text; +--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "policy_explanation" jsonb; +--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "credential_scope_summary" jsonb; +--> statement-breakpoint +ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "header_policy_summary" jsonb; +--> statement-breakpoint +ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "gateway_id" uuid; +--> statement-breakpoint +ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "gateway_token_id" uuid; +--> statement-breakpoint +ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "gateway_public_id" text; +--> statement-breakpoint +ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "client_subject_type" text; +--> statement-breakpoint +ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "client_subject_id" text; +--> statement-breakpoint +ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "client_name" text; +--> statement-breakpoint +ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "mcp_session_id" text; +--> statement-breakpoint +ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "correlation_id" text; +--> statement-breakpoint +ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "policy_explanation" jsonb; +--> statement-breakpoint +ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "credential_scope_summary" jsonb; +--> statement-breakpoint +ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "header_policy_summary" jsonb; +--> statement-breakpoint +ALTER TABLE "tool_access_audit_events" ADD COLUMN IF NOT EXISTS "gateway_id" uuid; +--> statement-breakpoint +ALTER TABLE "tool_access_audit_events" ADD COLUMN IF NOT EXISTS "gateway_token_id" uuid; +--> statement-breakpoint +ALTER TABLE "tool_access_audit_events" ADD COLUMN IF NOT EXISTS "gateway_public_id" text; +--> statement-breakpoint +ALTER TABLE "tool_access_audit_events" ADD COLUMN IF NOT EXISTS "client_name" text; +--> statement-breakpoint +ALTER TABLE "tool_access_audit_events" ADD COLUMN IF NOT EXISTS "correlation_id" text; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateways_approval_issue_id_issues_id_fk') THEN + ALTER TABLE "tool_mcp_gateways" ADD CONSTRAINT "tool_mcp_gateways_approval_issue_id_issues_id_fk" FOREIGN KEY ("approval_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateway_tokens_expiry_override_by_agent_id_agents_id_fk') THEN + ALTER TABLE "tool_mcp_gateway_tokens" ADD CONSTRAINT "tool_mcp_gateway_tokens_expiry_override_by_agent_id_agents_id_fk" FOREIGN KEY ("expiry_override_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_gateway_sessions_gateway_id_tool_mcp_gateways_id_fk') THEN + ALTER TABLE "tool_gateway_sessions" ADD CONSTRAINT "tool_gateway_sessions_gateway_id_tool_mcp_gateways_id_fk" FOREIGN KEY ("gateway_id") REFERENCES "public"."tool_mcp_gateways"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_gateway_sessions_gateway_token_id_tool_mcp_gateway_tokens_id_fk') THEN + ALTER TABLE "tool_gateway_sessions" ADD CONSTRAINT "tool_gateway_sessions_gateway_token_id_tool_mcp_gateway_tokens_id_fk" FOREIGN KEY ("gateway_token_id") REFERENCES "public"."tool_mcp_gateway_tokens"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_gateway_id_tool_mcp_gateways_id_fk') THEN + ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_gateway_id_tool_mcp_gateways_id_fk" FOREIGN KEY ("gateway_id") REFERENCES "public"."tool_mcp_gateways"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_gateway_token_id_tool_mcp_gateway_tokens_id_fk') THEN + ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_gateway_token_id_tool_mcp_gateway_tokens_id_fk" FOREIGN KEY ("gateway_token_id") REFERENCES "public"."tool_mcp_gateway_tokens"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_gateway_id_tool_mcp_gateways_id_fk') THEN + ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_gateway_id_tool_mcp_gateways_id_fk" FOREIGN KEY ("gateway_id") REFERENCES "public"."tool_mcp_gateways"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk') THEN + ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk" FOREIGN KEY ("gateway_token_id") REFERENCES "public"."tool_mcp_gateway_tokens"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_access_audit_events_gateway_id_tool_mcp_gateways_id_fk') THEN + ALTER TABLE "tool_access_audit_events" ADD CONSTRAINT "tool_access_audit_events_gateway_id_tool_mcp_gateways_id_fk" FOREIGN KEY ("gateway_id") REFERENCES "public"."tool_mcp_gateways"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_access_audit_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk') THEN + ALTER TABLE "tool_access_audit_events" ADD CONSTRAINT "tool_access_audit_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk" FOREIGN KEY ("gateway_token_id") REFERENCES "public"."tool_mcp_gateway_tokens"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_mcp_gateways_public_id_uq" ON "tool_mcp_gateways" USING btree ("gateway_public_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_mcp_gateway_tokens_subject_idx" ON "tool_mcp_gateway_tokens" USING btree ("company_id", "subject_type", "subject_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_gateway_sessions_gateway_idx" ON "tool_gateway_sessions" USING btree ("company_id", "gateway_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_invocations_gateway_idx" ON "tool_invocations" USING btree ("company_id", "gateway_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_call_events_gateway_idx" ON "tool_call_events" USING btree ("company_id", "gateway_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_access_audit_gateway_idx" ON "tool_access_audit_events" USING btree ("company_id", "gateway_id"); diff --git a/packages/db/src/migrations/0161_environment_custom_image_company_scope_repair.sql b/packages/db/src/migrations/0161_environment_custom_image_company_scope_repair.sql new file mode 100644 index 0000000000..f1faa2470a --- /dev/null +++ b/packages/db/src/migrations/0161_environment_custom_image_company_scope_repair.sql @@ -0,0 +1,8 @@ +-- No-op retained for migration ordering compatibility. +-- +-- This branch previously carried a company-scope repair for environment custom +-- images, but origin/master intentionally restored custom images to the +-- instance-scoped environment model in 0127. Reapplying company scope here +-- contradicts the checked-in Drizzle schema and can fail against cloned +-- multi-company dev databases. +SELECT 1; diff --git a/packages/db/src/migrations/0162_tool_runtime_metric_counters.sql b/packages/db/src/migrations/0162_tool_runtime_metric_counters.sql new file mode 100644 index 0000000000..0ba11cc327 --- /dev/null +++ b/packages/db/src/migrations/0162_tool_runtime_metric_counters.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS "tool_runtime_metric_counters" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "metric" text NOT NULL, + "bucket_start_at" timestamp with time zone NOT NULL, + "count" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "tool_runtime_metric_counters_count_nonnegative" CHECK ("count" >= 0) +); + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'tool_runtime_metric_counters_company_id_companies_id_fk' + ) THEN + ALTER TABLE "tool_runtime_metric_counters" + ADD CONSTRAINT "tool_runtime_metric_counters_company_id_companies_id_fk" + FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS "tool_runtime_metric_counters_company_metric_idx" + ON "tool_runtime_metric_counters" USING btree ("company_id", "metric", "bucket_start_at"); + +CREATE UNIQUE INDEX IF NOT EXISTS "tool_runtime_metric_counters_bucket_uq" + ON "tool_runtime_metric_counters" USING btree ("company_id", "metric", "bucket_start_at"); diff --git a/packages/db/src/migrations/0163_secret_binding_projection_class.sql b/packages/db/src/migrations/0163_secret_binding_projection_class.sql new file mode 100644 index 0000000000..b953a31a01 --- /dev/null +++ b/packages/db/src/migrations/0163_secret_binding_projection_class.sql @@ -0,0 +1,2 @@ +ALTER TABLE "company_secret_bindings" ADD COLUMN IF NOT EXISTS "projection_class" text DEFAULT 'unclassified' NOT NULL;--> statement-breakpoint +ALTER TABLE "company_secret_bindings" ADD COLUMN IF NOT EXISTS "projection_allowlist_key" text; diff --git a/packages/db/src/migrations/0164_plugin_config_company_scope.sql b/packages/db/src/migrations/0164_plugin_config_company_scope.sql new file mode 100644 index 0000000000..c79f8f0aa5 --- /dev/null +++ b/packages/db/src/migrations/0164_plugin_config_company_scope.sql @@ -0,0 +1,117 @@ +-- Scope plugin configuration rows by company before re-enabling plugin secret +-- refs. Legacy rows were instance-global; preserve only rows with an +-- unambiguous company owner and fail closed when a row is ambiguous. + +ALTER TABLE "plugin_config" + ADD COLUMN IF NOT EXISTS "company_id" uuid;--> statement-breakpoint + +WITH binding_owner AS ( + SELECT + pc."id" AS config_id, + min(csb."company_id"::text)::uuid AS company_id, + count(DISTINCT csb."company_id") AS company_count + FROM "plugin_config" pc + JOIN "company_secret_bindings" csb + ON csb."target_type" = 'plugin' + AND csb."target_id" = pc."plugin_id"::text + GROUP BY pc."id" +) +UPDATE "plugin_config" pc +SET "company_id" = bo."company_id" +FROM binding_owner bo +WHERE pc."company_id" IS NULL + AND bo."config_id" = pc."id" + AND bo."company_count" = 1;--> statement-breakpoint + +WITH single_company AS ( + SELECT min("id"::text)::uuid AS company_id, count(*) AS company_count + FROM "companies" +) +UPDATE "plugin_config" pc +SET "company_id" = sc."company_id" +FROM single_company sc +WHERE pc."company_id" IS NULL + AND sc."company_count" = 1;--> statement-breakpoint + +DROP INDEX IF EXISTS "plugin_config_plugin_id_idx";--> statement-breakpoint + +WITH unbound_config AS ( + SELECT pc.* + FROM "plugin_config" pc + WHERE pc."company_id" IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM "company_secret_bindings" csb + WHERE csb."target_type" = 'plugin' + AND csb."target_id" = pc."plugin_id"::text + ) +), primary_company AS ( + SELECT min("id"::text)::uuid AS company_id + FROM "companies" +) +INSERT INTO "plugin_config" ( + "plugin_id", + "company_id", + "config_json", + "last_error", + "created_at", + "updated_at" +) +SELECT + uc."plugin_id", + c."id", + uc."config_json", + uc."last_error", + uc."created_at", + uc."updated_at" +FROM unbound_config uc +CROSS JOIN "companies" c +CROSS JOIN primary_company pc +WHERE c."id" <> pc."company_id";--> statement-breakpoint + +WITH primary_company AS ( + SELECT min("id"::text)::uuid AS company_id + FROM "companies" +) +UPDATE "plugin_config" pc +SET "company_id" = primary_company."company_id" +FROM primary_company +WHERE pc."company_id" IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM "company_secret_bindings" csb + WHERE csb."target_type" = 'plugin' + AND csb."target_id" = pc."plugin_id"::text + );--> statement-breakpoint + +DO $$ +DECLARE + unresolved_count integer; +BEGIN + SELECT count(*) INTO unresolved_count + FROM "plugin_config" + WHERE "company_id" IS NULL; + + IF unresolved_count > 0 THEN + RAISE EXCEPTION 'Cannot assign company_id for % plugin_config row(s); resolve ambiguous plugin secret bindings before applying migration 0164', unresolved_count; + END IF; +END $$;--> statement-breakpoint + +ALTER TABLE "plugin_config" + ALTER COLUMN "company_id" SET NOT NULL;--> statement-breakpoint + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'plugin_config_company_id_companies_id_fk' + ) THEN + ALTER TABLE "plugin_config" + ADD CONSTRAINT "plugin_config_company_id_companies_id_fk" + FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") + ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint + +CREATE UNIQUE INDEX IF NOT EXISTS "plugin_config_plugin_company_idx" + ON "plugin_config" USING btree ("plugin_id", "company_id"); diff --git a/packages/db/src/migrations/0165_connection_token_issuances.sql b/packages/db/src/migrations/0165_connection_token_issuances.sql new file mode 100644 index 0000000000..8edf6c3662 --- /dev/null +++ b/packages/db/src/migrations/0165_connection_token_issuances.sql @@ -0,0 +1,64 @@ +CREATE TABLE IF NOT EXISTS "connection_token_issuances" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "application_id" uuid, + "connection_id" uuid NOT NULL, + "agent_id" uuid NOT NULL, + "run_id" uuid, + "issue_id" uuid, + "project_id" uuid, + "responsible_user_id" text, + "path" text NOT NULL, + "requested_scope" jsonb DEFAULT '[]'::jsonb NOT NULL, + "issued_scope" jsonb DEFAULT '[]'::jsonb NOT NULL, + "ttl_seconds" integer, + "expires_at" timestamp with time zone, + "token_hash" text, + "outcome" text NOT NULL, + "error_code" text, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "connection_token_issuances_path_check" CHECK ("connection_token_issuances"."path" IN ('exchange', 'oauth_access', 'static')), + CONSTRAINT "connection_token_issuances_outcome_check" CHECK ("connection_token_issuances"."outcome" IN ('success', 'denied', 'rate_limited', 'use_env_lease', 'upstream_error', 'failure')), + CONSTRAINT "connection_token_issuances_ttl_bounds" CHECK ("connection_token_issuances"."ttl_seconds" IS NULL OR ("connection_token_issuances"."ttl_seconds" >= 1 AND "connection_token_issuances"."ttl_seconds" <= 900)), + CONSTRAINT "connection_token_issuances_token_hash_format" CHECK ("connection_token_issuances"."token_hash" IS NULL OR "connection_token_issuances"."token_hash" ~ '^[a-f0-9]{64}$') +);--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "connection_token_issuances" ADD CONSTRAINT "connection_token_issuances_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "connection_token_issuances" ADD CONSTRAINT "connection_token_issuances_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "connection_token_issuances" ADD CONSTRAINT "connection_token_issuances_connection_id_tool_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "connection_token_issuances" ADD CONSTRAINT "connection_token_issuances_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "connection_token_issuances" ADD CONSTRAINT "connection_token_issuances_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "connection_token_issuances" ADD CONSTRAINT "connection_token_issuances_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "connection_token_issuances" ADD CONSTRAINT "connection_token_issuances_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connection_token_issuances_company_created_idx" ON "connection_token_issuances" USING btree ("company_id", "created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connection_token_issuances_connection_created_idx" ON "connection_token_issuances" USING btree ("company_id", "connection_id", "created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connection_token_issuances_agent_connection_idx" ON "connection_token_issuances" USING btree ("company_id", "agent_id", "connection_id", "created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "connection_token_issuances_run_idx" ON "connection_token_issuances" USING btree ("company_id", "run_id"); diff --git a/packages/db/src/migrations/0166_smoke_lab_results.sql b/packages/db/src/migrations/0166_smoke_lab_results.sql new file mode 100644 index 0000000000..5bf0ac880c --- /dev/null +++ b/packages/db/src/migrations/0166_smoke_lab_results.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS "smoke_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "trigger" text NOT NULL, + "status" text DEFAULT 'running' NOT NULL, + "started_at" timestamp with time zone DEFAULT now() NOT NULL, + "finished_at" timestamp with time zone, + "summary" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "smoke_run_steps" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "run_id" uuid NOT NULL, + "path" text NOT NULL, + "scenario_step" text NOT NULL, + "status" text NOT NULL, + "detail" text, + "screenshot_artifact_ref" jsonb, + "duration_ms" integer, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "smoke_runs" ADD CONSTRAINT "smoke_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "smoke_run_steps" ADD CONSTRAINT "smoke_run_steps_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "smoke_run_steps" ADD CONSTRAINT "smoke_run_steps_run_id_smoke_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."smoke_runs"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "smoke_runs_company_started_idx" ON "smoke_runs" USING btree ("company_id", "started_at"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "smoke_runs_company_status_idx" ON "smoke_runs" USING btree ("company_id", "status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "smoke_run_steps_company_run_idx" ON "smoke_run_steps" USING btree ("company_id", "run_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "smoke_run_steps_company_path_idx" ON "smoke_run_steps" USING btree ("company_id", "path"); diff --git a/packages/db/src/migrations/0167_environment_custom_image_instance_scope_cleanup.sql b/packages/db/src/migrations/0167_environment_custom_image_instance_scope_cleanup.sql new file mode 100644 index 0000000000..a81aeeb6c4 --- /dev/null +++ b/packages/db/src/migrations/0167_environment_custom_image_instance_scope_cleanup.sql @@ -0,0 +1,32 @@ +-- Keep environment custom image tables aligned with the instance-scoped schema. +-- This is safe for databases that never ran the stale 0158 company-scope repair +-- and repairs databases that did run it before 0158 was neutralized. +DROP INDEX IF EXISTS "environment_custom_image_templates_company_environment_status_idx"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_templates_company_provider_status_idx"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_templates_company_environment_active_uq"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_templates_company_last_used_idx"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_environment_status_idx"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_environment_active_uq"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_template_idx"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_promoted_template_idx"; +--> statement-breakpoint +DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_expires_idx"; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_templates" + DROP CONSTRAINT IF EXISTS "environment_custom_image_templates_company_id_companies_id_fk"; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_setup_sessions" + DROP CONSTRAINT IF EXISTS "environment_custom_image_setup_sessions_company_id_companies_id_fk"; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_templates" + DROP COLUMN IF EXISTS "company_id"; +--> statement-breakpoint +ALTER TABLE "environment_custom_image_setup_sessions" + DROP COLUMN IF EXISTS "company_id"; diff --git a/packages/db/src/migrations/0168_tool_connection_installs.sql b/packages/db/src/migrations/0168_tool_connection_installs.sql new file mode 100644 index 0000000000..fb2d18d898 --- /dev/null +++ b/packages/db/src/migrations/0168_tool_connection_installs.sql @@ -0,0 +1,56 @@ +CREATE TABLE IF NOT EXISTS "tool_connection_installs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "connection_id" uuid NOT NULL, + "target_type" text NOT NULL, + "target_id" text NOT NULL, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "tool_connection_installs_target_type_check" CHECK ("target_type" in ('company', 'agent')) +); +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'tool_connection_installs_company_id_companies_id_fk' + ) THEN + ALTER TABLE "tool_connection_installs" + ADD CONSTRAINT "tool_connection_installs_company_id_companies_id_fk" + FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") + ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'tool_connection_installs_connection_id_tool_connections_id_fk' + ) THEN + ALTER TABLE "tool_connection_installs" + ADD CONSTRAINT "tool_connection_installs_connection_id_tool_connections_id_fk" + FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") + ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'tool_connection_installs_created_by_agent_id_agents_id_fk' + ) THEN + ALTER TABLE "tool_connection_installs" + ADD CONSTRAINT "tool_connection_installs_created_by_agent_id_agents_id_fk" + FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") + ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_connection_installs_company_target_idx" ON "tool_connection_installs" USING btree ("company_id","target_type","target_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_connection_installs_connection_idx" ON "tool_connection_installs" USING btree ("company_id","connection_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_connection_installs_target_uq" ON "tool_connection_installs" USING btree ("company_id","connection_id","target_type","target_id"); diff --git a/packages/db/src/migrations/0169_tool_gateway_protocol_rate_limit_counters.sql b/packages/db/src/migrations/0169_tool_gateway_protocol_rate_limit_counters.sql new file mode 100644 index 0000000000..baff6fa87e --- /dev/null +++ b/packages/db/src/migrations/0169_tool_gateway_protocol_rate_limit_counters.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS "tool_gateway_rate_limit_counters" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "counter_key" text NOT NULL, + "window_start_at" timestamp with time zone NOT NULL, + "window_ms" integer NOT NULL, + "limit" integer NOT NULL, + "count" integer DEFAULT 0 NOT NULL, + "reset_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'tool_gateway_rate_limit_counters_company_id_companies_id_fk' + ) THEN + ALTER TABLE "tool_gateway_rate_limit_counters" + ADD CONSTRAINT "tool_gateway_rate_limit_counters_company_id_companies_id_fk" + FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") + ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'tool_gateway_rate_limit_counters_window_bounds' + ) THEN + ALTER TABLE "tool_gateway_rate_limit_counters" + ADD CONSTRAINT "tool_gateway_rate_limit_counters_window_bounds" + CHECK ("window_ms" > 0 AND "limit" > 0 AND "count" >= 0); + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tool_gateway_rate_limit_counters_company_idx" + ON "tool_gateway_rate_limit_counters" USING btree ("company_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_gateway_rate_limit_counters_window_uq" + ON "tool_gateway_rate_limit_counters" USING btree ("company_id","counter_key","window_start_at"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index a28906ec31..b5fae44d7a 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1023,6 +1023,160 @@ "when": 1783953514660, "tag": "0147_cost_event_status", "breakpoints": true + }, + { + "idx": 148, + "version": "7", + "when": 1783953515660, + "tag": "0148_tool_access_mcp_connections", + "breakpoints": true + }, + { + "idx": 149, + "version": "7", + "when": 1783953516660, + "tag": "0149_agent_access_phase2_contracts", + "breakpoints": true + }, + { + "idx": 150, + "version": "7", + "when": 1783953517660, + "tag": "0150_tool_invocation_catalog_snapshots", + "breakpoints": true + }, + { + "idx": 151, + "version": "7", + "when": 1783953518660, + "tag": "0151_tool_gateway_sessions", + "breakpoints": true + }, + { + "idx": 152, + "version": "7", + "when": 1783953519660, + "tag": "0152_tool_connection_application_no_cascade", + "breakpoints": true + }, + { + "idx": 153, + "version": "7", + "when": 1783953520660, + "tag": "0153_tool_stdio_command_templates", + "breakpoints": true + }, + { + "idx": 154, + "version": "7", + "when": 1783953521660, + "tag": "0154_tool_oauth_states", + "breakpoints": true + }, + { + "idx": 155, + "version": "7", + "when": 1783953522660, + "tag": "0155_tool_oauth_state_actor_binding", + "breakpoints": true + }, + { + "idx": 156, + "version": "7", + "when": 1783953523660, + "tag": "0156_tool_oauth_state_session_binding", + "breakpoints": true + }, + { + "idx": 157, + "version": "7", + "when": 1783953524660, + "tag": "0157_tool_profile_new_tools_review", + "breakpoints": true + }, + { + "idx": 158, + "version": "7", + "when": 1783953525660, + "tag": "0158_tool_invocation_connected_mcp_metadata", + "breakpoints": true + }, + { + "idx": 159, + "version": "7", + "when": 1783953526660, + "tag": "0159_named_mcp_gateways", + "breakpoints": true + }, + { + "idx": 160, + "version": "7", + "when": 1783953527660, + "tag": "0160_mcp_gateway_contract_expansion", + "breakpoints": true + }, + { + "idx": 161, + "version": "7", + "when": 1783953528660, + "tag": "0161_environment_custom_image_company_scope_repair", + "breakpoints": true + }, + { + "idx": 162, + "version": "7", + "when": 1783953529660, + "tag": "0162_tool_runtime_metric_counters", + "breakpoints": true + }, + { + "idx": 163, + "version": "7", + "when": 1783953530660, + "tag": "0163_secret_binding_projection_class", + "breakpoints": true + }, + { + "idx": 164, + "version": "7", + "when": 1783953531660, + "tag": "0164_plugin_config_company_scope", + "breakpoints": true + }, + { + "idx": 165, + "version": "7", + "when": 1783953532660, + "tag": "0165_connection_token_issuances", + "breakpoints": true + }, + { + "idx": 166, + "version": "7", + "when": 1783953533660, + "tag": "0166_smoke_lab_results", + "breakpoints": true + }, + { + "idx": 167, + "version": "7", + "when": 1783953534660, + "tag": "0167_environment_custom_image_instance_scope_cleanup", + "breakpoints": true + }, + { + "idx": 168, + "version": "7", + "when": 1783953535660, + "tag": "0168_tool_connection_installs", + "breakpoints": true + }, + { + "idx": 169, + "version": "7", + "when": 1783953536660, + "tag": "0169_tool_gateway_protocol_rate_limit_counters", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/company_secret_bindings.ts b/packages/db/src/schema/company_secret_bindings.ts index 06f926914a..fbd8bf28fe 100644 --- a/packages/db/src/schema/company_secret_bindings.ts +++ b/packages/db/src/schema/company_secret_bindings.ts @@ -14,6 +14,8 @@ export const companySecretBindings = pgTable( versionSelector: text("version_selector").notNull().default("latest"), required: boolean("required").notNull().default(true), label: text("label"), + projectionClass: text("projection_class").notNull().default("unclassified"), + projectionAllowlistKey: text("projection_allowlist_key"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 735fd4e621..545131409c 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -87,6 +87,7 @@ export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_ export { heartbeatRuns } from "./heartbeat_runs.js"; export { heartbeatRunEvents } from "./heartbeat_run_events.js"; export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js"; +export { smokeRuns, smokeRunSteps } from "./smoke_lab.js"; export { costEvents } from "./cost_events.js"; export { financeEvents } from "./finance_events.js"; export { approvals } from "./approvals.js"; @@ -99,6 +100,30 @@ export { companySecretVersions } from "./company_secret_versions.js"; export { companySecretBindings } from "./company_secret_bindings.js"; export { userSecretDeclarations } from "./user_secret_declarations.js"; export { secretAccessEvents } from "./secret_access_events.js"; +export { + toolApplications, + toolConnections, + toolConnectionInstalls, + toolOauthStates, + toolCatalogEntries, + toolProfiles, + toolProfileEntries, + toolProfileBindings, + toolMcpGateways, + toolMcpGatewayTokens, + toolPolicies, + toolRuntimeSlots, + toolRuntimeMetricCounters, + toolStdioCommandTemplates, + toolGatewaySessions, + connectionTokenIssuances, + toolInvocations, + toolActionRequests, + toolCallEvents, + toolRateLimitCounters, + toolGatewayRateLimitCounters, + toolAccessAuditEvents, +} from "./tool_access.js"; export { companySkills, companySkillVersions, diff --git a/packages/db/src/schema/plugin_company_settings.ts b/packages/db/src/schema/plugin_company_settings.ts index 87d4b4af08..5feefe60c3 100644 --- a/packages/db/src/schema/plugin_company_settings.ts +++ b/packages/db/src/schema/plugin_company_settings.ts @@ -6,8 +6,9 @@ import { plugins } from "./plugins.js"; * `plugin_company_settings` table — stores operator-managed plugin settings * scoped to a specific company. * - * This is distinct from `plugin_config`, which stores instance-wide plugin - * configuration. Each company can have at most one settings row per plugin. + * This is distinct from `plugin_config`, which stores the plugin's declared + * operator configuration. Each company can have at most one settings row per + * plugin. * * Rows represent explicit overrides from the default company behavior: * - no row => plugin is enabled for the company by default diff --git a/packages/db/src/schema/plugin_config.ts b/packages/db/src/schema/plugin_config.ts index 24407b9780..15aa216f5b 100644 --- a/packages/db/src/schema/plugin_config.ts +++ b/packages/db/src/schema/plugin_config.ts @@ -1,10 +1,10 @@ import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; import { plugins } from "./plugins.js"; /** - * `plugin_config` table — stores operator-provided instance configuration - * for each plugin (one row per plugin, enforced by a unique index on - * `plugin_id`). + * `plugin_config` table — stores operator-provided configuration for each + * plugin within a company (one row per plugin/company pair). * * The `config_json` column holds the values that the operator enters in the * plugin settings UI. These values are validated at runtime against the @@ -19,12 +19,18 @@ export const pluginConfig = pgTable( pluginId: uuid("plugin_id") .notNull() .references(() => plugins.id, { onDelete: "cascade" }), + companyId: uuid("company_id") + .notNull() + .references(() => companies.id, { onDelete: "cascade" }), configJson: jsonb("config_json").$type>().notNull().default({}), lastError: text("last_error"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ - pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId), + pluginCompanyIdx: uniqueIndex("plugin_config_plugin_company_idx").on( + table.pluginId, + table.companyId, + ), }), ); diff --git a/packages/db/src/schema/smoke_lab.ts b/packages/db/src/schema/smoke_lab.ts new file mode 100644 index 0000000000..b096435c89 --- /dev/null +++ b/packages/db/src/schema/smoke_lab.ts @@ -0,0 +1,48 @@ +import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import type { + SmokeRunStatus, + SmokeRunStepPath, + SmokeRunStepStatus, + SmokeRunTrigger, +} from "@paperclipai/shared"; +import { companies } from "./companies.js"; + +export const smokeRuns = pgTable( + "smoke_runs", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + trigger: text("trigger").$type().notNull(), + status: text("status").$type().notNull().default("running"), + startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(), + finishedAt: timestamp("finished_at", { withTimezone: true }), + summary: jsonb("summary").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("smoke_runs_company_started_idx").on(table.companyId, table.startedAt), + index("smoke_runs_company_status_idx").on(table.companyId, table.status), + ], +); + +export const smokeRunSteps = pgTable( + "smoke_run_steps", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + runId: uuid("run_id").notNull().references(() => smokeRuns.id, { onDelete: "cascade" }), + path: text("path").$type().notNull(), + scenarioStep: text("scenario_step").notNull(), + status: text("status").$type().notNull(), + detail: text("detail"), + screenshotArtifactRef: jsonb("screenshot_artifact_ref").$type>(), + durationMs: integer("duration_ms"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("smoke_run_steps_company_run_idx").on(table.companyId, table.runId), + index("smoke_run_steps_company_path_idx").on(table.companyId, table.path), + ], +); diff --git a/packages/db/src/schema/tool_access.ts b/packages/db/src/schema/tool_access.ts new file mode 100644 index 0000000000..91d2355836 --- /dev/null +++ b/packages/db/src/schema/tool_access.ts @@ -0,0 +1,813 @@ +import { + sql, +} from "drizzle-orm"; +import { + boolean, + check, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import type { + ConnectionTokenIssuanceOutcome, + ConnectionTokenIssuancePath, + McpConnectionCredentialRef, + ToolActionRequestStatus, + ToolApplicationStatus, + ToolApplicationType, + ToolAuditEventType, + ToolAuditOutcome, + ToolCatalogEntryKind, + ToolCatalogEntryStatus, + ToolConnectionHealthStatus, + ToolConnectionKind, + ToolConnectionInstallTargetType, + ToolConnectionStatus, + ToolConnectionTransport, + ToolCredentialSecretRef, + ToolInvocationApprovalState, + ToolInvocationStatus, + ToolMcpGatewayAuthConfig, + ToolMcpGatewayContextScopeType, + ToolMcpGatewayDefaultProfileMode, + ToolMcpGatewayHeaderPolicy, + ToolMcpGatewayMetadataPolicy, + ToolMcpGatewayOnDemandToolsConfig, + ToolMcpGatewayStatus, + ToolMcpGatewayTokenAction, + ToolMcpGatewayTokenSubjectType, + ToolPolicyDecision, + ToolPolicyType, + ToolProfileBindingTargetType, + ToolProfileDefaultAction, + ToolProfileEntryEffect, + ToolProfileEntrySelectorType, + ToolProfileStatus, + ToolRateLimitWindowKind, + ToolRedactedValueSummary, + ToolRiskLevel, + ToolRuntimeKind, + ToolRuntimeSlotStatus, +} from "@paperclipai/shared"; +import { agents } from "./agents.js"; +import { approvals } from "./approvals.js"; +import { companies } from "./companies.js"; +import { executionWorkspaces } from "./execution_workspaces.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; +import { issueThreadInteractions } from "./issue_thread_interactions.js"; +import { issues } from "./issues.js"; +import { plugins } from "./plugins.js"; +import { projects } from "./projects.js"; +import { projectWorkspaces } from "./project_workspaces.js"; + +export const toolApplications = pgTable( + "tool_applications", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + applicationKey: text("application_key"), + name: text("name").notNull(), + description: text("description"), + type: text("type").$type().notNull(), + status: text("status").$type().notNull().default("active"), + pluginId: uuid("plugin_id").references(() => plugins.id, { onDelete: "set null" }), + ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }), + ownerUserId: text("owner_user_id"), + metadata: jsonb("metadata").$type>().notNull().default({}), + archivedAt: timestamp("archived_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_applications_company_idx").on(table.companyId), + index("tool_applications_company_status_idx").on(table.companyId, table.status), + uniqueIndex("tool_applications_company_name_uq").on(table.companyId, table.name), + uniqueIndex("tool_applications_company_key_uq").on(table.companyId, table.applicationKey), + ], +); + +export const toolConnections = pgTable( + "tool_connections", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + // NO ACTION (not CASCADE) so the database itself refuses to delete an application that still + // has connections. This closes the delete-vs-create race in DELETE + // /tool-applications/:applicationId: a connection inserted concurrently takes a FOR KEY SHARE + // lock on the parent row, so the delete fails closed with a foreign-key violation instead of + // silently cascading the new connection away. NO ACTION (checked at end-of-statement) rather + // than RESTRICT (checked immediately) is deliberate: it lets a company delete still cascade — + // companies → tool_applications and companies → tool_connections both fire in one statement, + // and the connections are gone by the time this constraint is checked. + applicationId: uuid("application_id").notNull().references(() => toolApplications.id, { onDelete: "no action" }), + name: text("name").notNull(), + connectionKind: text("connection_kind").$type().notNull().default("managed"), + transport: text("transport").$type().notNull(), + status: text("status").$type().notNull().default("draft"), + enabled: boolean("enabled").notNull().default(false), + config: jsonb("config").$type>().notNull().default({}), + transportConfig: jsonb("transport_config").$type>().notNull().default({}), + credentialRefs: jsonb("credential_refs").$type().notNull().default([]), + credentialSecretRefs: jsonb("credential_secret_refs").$type().notNull().default([]), + healthStatus: text("health_status").$type().notNull().default("unchecked"), + healthMessage: text("health_message"), + healthCheckedAt: timestamp("health_checked_at", { withTimezone: true }), + lastHealthAt: timestamp("last_health_at", { withTimezone: true }), + lastCatalogRefreshAt: timestamp("last_catalog_refresh_at", { withTimezone: true }), + lastError: text("last_error"), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_connections_company_idx").on(table.companyId), + index("tool_connections_application_idx").on(table.applicationId), + index("tool_connections_company_enabled_idx").on(table.companyId, table.enabled), + uniqueIndex("tool_connections_company_name_uq").on(table.companyId, table.name), + ], +); + +export const toolConnectionInstalls = pgTable( + "tool_connection_installs", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + connectionId: uuid("connection_id").notNull().references(() => toolConnections.id, { onDelete: "cascade" }), + targetType: text("target_type").$type().notNull(), + targetId: text("target_id").notNull(), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + check("tool_connection_installs_target_type_check", sql`${table.targetType} in ('company', 'agent')`), + index("tool_connection_installs_company_target_idx").on(table.companyId, table.targetType, table.targetId), + index("tool_connection_installs_connection_idx").on(table.companyId, table.connectionId), + uniqueIndex("tool_connection_installs_target_uq").on( + table.companyId, + table.connectionId, + table.targetType, + table.targetId, + ), + ], +); + +export const toolOauthStates = pgTable( + "tool_oauth_states", + { + state: text("state").primaryKey(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + connectionId: uuid("connection_id").notNull().references(() => toolConnections.id, { onDelete: "cascade" }), + codeVerifier: text("code_verifier").notNull(), + createdByActorType: text("created_by_actor_type"), + createdByActorId: text("created_by_actor_id"), + createdBySessionId: text("created_by_session_id"), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_oauth_states_company_idx").on(table.companyId), + index("tool_oauth_states_connection_idx").on(table.connectionId), + index("tool_oauth_states_actor_idx").on(table.createdByActorType, table.createdByActorId), + index("tool_oauth_states_expires_at_idx").on(table.expiresAt), + ], +); + +export const toolCatalogEntries = pgTable( + "tool_catalog_entries", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + applicationId: uuid("application_id").references(() => toolApplications.id, { onDelete: "cascade" }), + connectionId: uuid("connection_id").notNull().references(() => toolConnections.id, { onDelete: "cascade" }), + entryKind: text("entry_kind").$type().notNull().default("tool"), + name: text("name").notNull(), + toolName: text("tool_name").notNull(), + title: text("title"), + description: text("description"), + inputSchema: jsonb("input_schema").$type>().notNull().default({}), + outputSchema: jsonb("output_schema").$type>(), + annotations: jsonb("annotations").$type>().notNull().default({}), + riskLevel: text("risk_level").$type().notNull().default("read"), + isReadOnly: boolean("is_read_only").notNull().default(true), + isWrite: boolean("is_write").notNull().default(false), + isDestructive: boolean("is_destructive").notNull().default(false), + status: text("status").$type().notNull().default("active"), + version: text("version"), + versionHash: text("version_hash").notNull(), + schemaHash: text("schema_hash"), + firstSeenAt: timestamp("first_seen_at", { withTimezone: true }).notNull().defaultNow(), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(), + reviewedAt: timestamp("reviewed_at", { withTimezone: true }), + reviewedByAgentId: uuid("reviewed_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + reviewedByUserId: text("reviewed_by_user_id"), + quarantinedAt: timestamp("quarantined_at", { withTimezone: true }), + quarantineReason: text("quarantine_reason"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_catalog_entries_company_idx").on(table.companyId), + index("tool_catalog_entries_application_idx").on(table.applicationId), + index("tool_catalog_entries_connection_idx").on(table.connectionId), + index("tool_catalog_entries_company_status_idx").on(table.companyId, table.status), + uniqueIndex("tool_catalog_entries_connection_name_uq").on(table.connectionId, table.name), + ], +); + +export const toolProfiles = pgTable( + "tool_profiles", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + profileKey: text("profile_key").notNull(), + name: text("name").notNull(), + description: text("description"), + status: text("status").$type().notNull().default("active"), + defaultAction: text("default_action").$type().notNull().default("deny"), + newToolsReviewedAt: timestamp("new_tools_reviewed_at", { withTimezone: true }), + metadata: jsonb("metadata").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_profiles_company_status_idx").on(table.companyId, table.status), + uniqueIndex("tool_profiles_company_key_uq").on(table.companyId, table.profileKey), + uniqueIndex("tool_profiles_company_name_uq").on(table.companyId, table.name), + ], +); + +export const toolProfileEntries = pgTable( + "tool_profile_entries", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + profileId: uuid("profile_id").notNull().references(() => toolProfiles.id, { onDelete: "cascade" }), + selectorType: text("selector_type").$type().notNull(), + effect: text("effect").$type().notNull().default("include"), + applicationId: uuid("application_id").references(() => toolApplications.id, { onDelete: "cascade" }), + connectionId: uuid("connection_id").references(() => toolConnections.id, { onDelete: "cascade" }), + catalogEntryId: uuid("catalog_entry_id").references(() => toolCatalogEntries.id, { onDelete: "cascade" }), + toolName: text("tool_name"), + riskLevel: text("risk_level").$type(), + conditions: jsonb("conditions").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_profile_entries_company_profile_idx").on(table.companyId, table.profileId), + index("tool_profile_entries_application_idx").on(table.companyId, table.applicationId), + index("tool_profile_entries_connection_idx").on(table.companyId, table.connectionId), + index("tool_profile_entries_catalog_entry_idx").on(table.companyId, table.catalogEntryId), + ], +); + +export const toolProfileBindings = pgTable( + "tool_profile_bindings", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + profileId: uuid("profile_id").notNull().references(() => toolProfiles.id, { onDelete: "cascade" }), + targetType: text("target_type").$type().notNull(), + targetId: text("target_id").notNull(), + priority: integer("priority").notNull().default(100), + metadata: jsonb("metadata").$type>().notNull().default({}), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_profile_bindings_company_target_idx").on(table.companyId, table.targetType, table.targetId), + uniqueIndex("tool_profile_bindings_target_profile_uq").on( + table.companyId, + table.targetType, + table.targetId, + table.profileId, + ), + ], +); + +export const toolMcpGateways = pgTable( + "tool_mcp_gateways", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + gatewayPublicId: text("gateway_public_id").notNull().default(sql`'gw_' || replace(gen_random_uuid()::text, '-', '')`), + name: text("name").notNull(), + slug: text("slug").notNull(), + displaySlug: text("display_slug").notNull().default(""), + description: text("description"), + status: text("status").$type().notNull().default("active"), + profileId: uuid("profile_id").notNull().references(() => toolProfiles.id, { onDelete: "restrict" }), + defaultProfileMode: text("default_profile_mode").$type().notNull().default("gateway_only"), + contextScopeType: text("context_scope_type").$type().notNull().default("none"), + contextScopeId: text("context_scope_id"), + agentId: uuid("agent_id").references(() => agents.id, { onDelete: "set null" }), + projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }), + issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }), + approvalIssueId: uuid("approval_issue_id").references(() => issues.id, { onDelete: "set null" }), + authConfig: jsonb("auth_config").$type().notNull().default({ + version: 1, + bearer: { + enabled: true, + tokenPrefix: "pcgw", + defaultTtlSeconds: 7_776_000, + requireFiniteExpiry: true, + longLivedTokenRequiresOverride: true, + }, + oauth: { + enabled: false, + reservedFor: "v1_5", + dynamicClientRegistration: false, + authorizationCodePkce: false, + }, + }), + headerPolicy: jsonb("header_policy").$type().notNull().default({ + version: 1, + callerPassthrough: { enabled: false, allowedHeaders: [] }, + staticHeaders: [], + generatedMetadata: { enabled: false, allowedHeaders: [] }, + responseHeaders: { forwardMcpRequiredHeaders: true, forwardSafeCacheHeaders: true }, + }), + metadataPolicy: jsonb("metadata_policy").$type().notNull().default({ + version: 1, + forwardCompanyId: false, + forwardGatewayId: false, + forwardProjectId: false, + forwardIssueId: false, + forwardAgentId: false, + forwardRunId: false, + forwardCorrelationId: true, + }), + onDemandToolsConfig: jsonb("on_demand_tools_config").$type().notNull().default({ + enabled: false, + searchToolName: "search_tools", + runToolName: "run_tool", + }), + metadata: jsonb("metadata").$type>().notNull().default({}), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + archivedAt: timestamp("archived_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_mcp_gateways_company_idx").on(table.companyId), + index("tool_mcp_gateways_company_status_idx").on(table.companyId, table.status), + index("tool_mcp_gateways_profile_idx").on(table.companyId, table.profileId), + uniqueIndex("tool_mcp_gateways_public_id_uq").on(table.gatewayPublicId), + uniqueIndex("tool_mcp_gateways_company_slug_uq").on(table.companyId, table.slug), + uniqueIndex("tool_mcp_gateways_company_name_uq").on(table.companyId, table.name), + ], +); + +export const toolMcpGatewayTokens = pgTable( + "tool_mcp_gateway_tokens", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + gatewayId: uuid("gateway_id").notNull().references(() => toolMcpGateways.id, { onDelete: "cascade" }), + name: text("name").notNull(), + tokenHash: text("token_hash").notNull(), + tokenPrefix: text("token_prefix").notNull().default(""), + subjectType: text("subject_type").$type().notNull().default("gateway_client"), + subjectId: text("subject_id"), + clientLabel: text("client_label").notNull().default(""), + ownerNote: text("owner_note").notNull().default(""), + allowedActions: jsonb("allowed_actions").$type().notNull().default(["tools/list", "tools/call"]), + expiresAt: timestamp("expires_at", { withTimezone: true }), + expiryOverrideReason: text("expiry_override_reason"), + expiryOverrideByUserId: text("expiry_override_by_user_id"), + expiryOverrideByAgentId: uuid("expiry_override_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + expiryOverrideAt: timestamp("expiry_override_at", { withTimezone: true }), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("tool_mcp_gateway_tokens_token_hash_uq").on(table.tokenHash), + index("tool_mcp_gateway_tokens_gateway_idx").on(table.companyId, table.gatewayId), + index("tool_mcp_gateway_tokens_subject_idx").on(table.companyId, table.subjectType, table.subjectId), + index("tool_mcp_gateway_tokens_company_expires_idx").on(table.companyId, table.expiresAt), + ], +); + +export const toolPolicies = pgTable( + "tool_policies", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + name: text("name").notNull(), + description: text("description"), + policyType: text("policy_type").$type().notNull(), + priority: integer("priority").notNull().default(100), + enabled: boolean("enabled").notNull().default(true), + selectors: jsonb("selectors").$type>().notNull().default({}), + conditions: jsonb("conditions").$type>(), + config: jsonb("config").$type>(), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_policies_company_enabled_idx").on(table.companyId, table.enabled), + index("tool_policies_company_type_idx").on(table.companyId, table.policyType), + uniqueIndex("tool_policies_company_name_uq").on(table.companyId, table.name), + ], +); + +export const toolRuntimeSlots = pgTable( + "tool_runtime_slots", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + applicationId: uuid("application_id").references(() => toolApplications.id, { onDelete: "set null" }), + connectionId: uuid("connection_id").references(() => toolConnections.id, { onDelete: "cascade" }), + projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }), + executionWorkspaceId: uuid("execution_workspace_id").references(() => executionWorkspaces.id, { onDelete: "set null" }), + issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }), + ownerScopeType: text("owner_scope_type").notNull().default("connection"), + ownerScopeId: text("owner_scope_id"), + runtimeKind: text("runtime_kind").$type().notNull().default("local_stdio"), + slotKey: text("slot_key").notNull(), + status: text("status").$type().notNull().default("stopped"), + reuseKey: text("reuse_key"), + workspaceScope: text("workspace_scope"), + credentialScopeHash: text("credential_scope_hash"), + provider: text("provider"), + providerRef: text("provider_ref"), + processId: integer("process_id"), + commandTemplateKey: text("command_template_key"), + healthStatus: text("health_status").$type().notNull().default("unchecked"), + healthMessage: text("health_message"), + lastHealthCheckAt: timestamp("last_health_check_at", { withTimezone: true }), + lastStartedAt: timestamp("last_started_at", { withTimezone: true }), + startedAt: timestamp("started_at", { withTimezone: true }), + stoppedAt: timestamp("stopped_at", { withTimezone: true }), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + idleExpiresAt: timestamp("idle_expires_at", { withTimezone: true }), + idleDeadlineAt: timestamp("idle_deadline_at", { withTimezone: true }), + lastError: text("last_error"), + metadata: jsonb("metadata").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_runtime_slots_company_idx").on(table.companyId), + index("tool_runtime_slots_connection_idx").on(table.connectionId), + index("tool_runtime_slots_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId), + uniqueIndex("tool_runtime_slots_slot_key_uq").on(table.companyId, table.slotKey), + ], +); + +export const toolStdioCommandTemplates = pgTable( + "tool_stdio_command_templates", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + templateKey: text("template_key").notNull(), + name: text("name").notNull(), + description: text("description"), + status: text("status").$type<"active" | "disabled">().notNull().default("active"), + command: text("command").notNull(), + args: jsonb("args").$type().notNull().default([]), + envKeys: jsonb("env_keys").$type().notNull().default([]), + tools: jsonb("tools").$type>>().notNull().default([]), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + disabledAt: timestamp("disabled_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_stdio_command_templates_company_idx").on(table.companyId), + index("tool_stdio_command_templates_company_status_idx").on(table.companyId, table.status), + uniqueIndex("tool_stdio_command_templates_company_key_uq").on(table.companyId, table.templateKey), + ], +); + +export const toolGatewaySessions = pgTable( + "tool_gateway_sessions", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }), + runId: uuid("run_id").notNull().references(() => heartbeatRuns.id, { onDelete: "cascade" }), + issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }), + projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }), + gatewayId: uuid("gateway_id").references(() => toolMcpGateways.id, { onDelete: "set null" }), + gatewayTokenId: uuid("gateway_token_id").references(() => toolMcpGatewayTokens.id, { onDelete: "set null" }), + gatewayPublicId: text("gateway_public_id"), + clientSubjectType: text("client_subject_type").$type(), + clientSubjectId: text("client_subject_id"), + clientName: text("client_name"), + mcpSessionId: text("mcp_session_id"), + correlationId: text("correlation_id"), + tokenHash: text("token_hash").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("tool_gateway_sessions_token_hash_uq").on(table.tokenHash), + index("tool_gateway_sessions_company_agent_idx").on(table.companyId, table.agentId), + index("tool_gateway_sessions_company_expires_idx").on(table.companyId, table.expiresAt), + index("tool_gateway_sessions_run_idx").on(table.companyId, table.runId), + index("tool_gateway_sessions_issue_idx").on(table.companyId, table.issueId), + index("tool_gateway_sessions_gateway_idx").on(table.companyId, table.gatewayId), + ], +); + +export const toolGatewayRateLimitCounters = pgTable( + "tool_gateway_rate_limit_counters", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + counterKey: text("counter_key").notNull(), + windowStartAt: timestamp("window_start_at", { withTimezone: true }).notNull(), + windowMs: integer("window_ms").notNull(), + limit: integer("limit").notNull(), + count: integer("count").notNull().default(0), + resetAt: timestamp("reset_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_gateway_rate_limit_counters_company_idx").on(table.companyId), + uniqueIndex("tool_gateway_rate_limit_counters_window_uq").on( + table.companyId, + table.counterKey, + table.windowStartAt, + ), + ], +); + +export const toolInvocations = pgTable( + "tool_invocations", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + idempotencyKey: text("idempotency_key"), + actorType: text("actor_type").notNull().default("system"), + actorId: text("actor_id"), + agentId: uuid("agent_id").references(() => agents.id, { onDelete: "set null" }), + issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }), + runId: uuid("run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + gatewayId: uuid("gateway_id").references(() => toolMcpGateways.id, { onDelete: "set null" }), + gatewayTokenId: uuid("gateway_token_id").references(() => toolMcpGatewayTokens.id, { onDelete: "set null" }), + gatewayPublicId: text("gateway_public_id"), + clientSubjectType: text("client_subject_type").$type(), + clientSubjectId: text("client_subject_id"), + clientName: text("client_name"), + mcpSessionId: text("mcp_session_id"), + correlationId: text("correlation_id"), + applicationId: uuid("application_id").references(() => toolApplications.id, { onDelete: "set null" }), + connectionId: uuid("connection_id").references(() => toolConnections.id, { onDelete: "set null" }), + catalogEntryId: uuid("catalog_entry_id").references(() => toolCatalogEntries.id, { onDelete: "set null" }), + catalogVersionHash: text("catalog_version_hash"), + catalogSchemaHash: text("catalog_schema_hash"), + providerType: text("provider_type"), + applicationKey: text("application_key"), + upstreamToolName: text("upstream_tool_name"), + riskLevel: text("risk_level").$type(), + toolName: text("tool_name").notNull(), + argumentsHash: text("arguments_hash"), + argumentsSummary: jsonb("arguments_summary").$type(), + policyDecision: text("policy_decision").$type(), + matchedPolicyIds: jsonb("matched_policy_ids").$type().notNull().default([]), + policyExplanation: jsonb("policy_explanation").$type>(), + credentialScopeSummary: jsonb("credential_scope_summary").$type>(), + headerPolicySummary: jsonb("header_policy_summary").$type>(), + approvalState: text("approval_state").$type().notNull().default("not_required"), + status: text("status").$type().notNull().default("pending"), + upstreamRequestId: text("upstream_request_id"), + resultHash: text("result_hash"), + resultSummary: jsonb("result_summary").$type(), + resultSizeBytes: integer("result_size_bytes"), + resultArtifactId: uuid("result_artifact_id"), + errorCode: text("error_code"), + errorMessage: text("error_message"), + startedAt: timestamp("started_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_invocations_company_created_idx").on(table.companyId, table.createdAt), + index("tool_invocations_run_idx").on(table.companyId, table.runId), + index("tool_invocations_issue_idx").on(table.companyId, table.issueId), + index("tool_invocations_gateway_idx").on(table.companyId, table.gatewayId), + uniqueIndex("tool_invocations_company_idempotency_uq").on(table.companyId, table.idempotencyKey), + ], +); + +export const toolActionRequests = pgTable( + "tool_action_requests", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + invocationId: uuid("invocation_id").notNull().references(() => toolInvocations.id, { onDelete: "cascade" }), + issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }), + interactionId: uuid("interaction_id").references(() => issueThreadInteractions.id, { onDelete: "set null" }), + approvalId: uuid("approval_id").references(() => approvals.id, { onDelete: "set null" }), + status: text("status").$type().notNull().default("pending"), + canonicalArgumentsHash: text("canonical_arguments_hash").notNull(), + canonicalArgumentsSummary: jsonb("canonical_arguments_summary").$type().notNull(), + signedArguments: text("signed_arguments"), + previewMarkdown: text("preview_markdown"), + requestedByAgentId: uuid("requested_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + requestedByUserId: text("requested_by_user_id"), + resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + resolvedByUserId: text("resolved_by_user_id"), + decidedByAgentId: uuid("decided_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + decidedByUserId: text("decided_by_user_id"), + decidedAt: timestamp("decided_at", { withTimezone: true }), + expiresAt: timestamp("expires_at", { withTimezone: true }), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_action_requests_company_status_idx").on(table.companyId, table.status), + index("tool_action_requests_invocation_idx").on(table.invocationId), + index("tool_action_requests_issue_idx").on(table.companyId, table.issueId), + ], +); + +export const toolCallEvents = pgTable( + "tool_call_events", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + eventType: text("event_type").$type().notNull(), + actorType: text("actor_type").notNull().default("system"), + actorId: text("actor_id"), + agentId: uuid("agent_id").references(() => agents.id, { onDelete: "set null" }), + runId: uuid("run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }), + gatewayId: uuid("gateway_id").references(() => toolMcpGateways.id, { onDelete: "set null" }), + gatewayTokenId: uuid("gateway_token_id").references(() => toolMcpGatewayTokens.id, { onDelete: "set null" }), + gatewayPublicId: text("gateway_public_id"), + clientSubjectType: text("client_subject_type").$type(), + clientSubjectId: text("client_subject_id"), + clientName: text("client_name"), + mcpSessionId: text("mcp_session_id"), + correlationId: text("correlation_id"), + applicationId: uuid("application_id").references(() => toolApplications.id, { onDelete: "set null" }), + connectionId: uuid("connection_id").references(() => toolConnections.id, { onDelete: "set null" }), + catalogEntryId: uuid("catalog_entry_id").references(() => toolCatalogEntries.id, { onDelete: "set null" }), + invocationId: uuid("invocation_id").references(() => toolInvocations.id, { onDelete: "set null" }), + actionRequestId: uuid("action_request_id").references(() => toolActionRequests.id, { onDelete: "set null" }), + runtimeSlotId: uuid("runtime_slot_id").references(() => toolRuntimeSlots.id, { onDelete: "set null" }), + toolName: text("tool_name"), + decision: text("decision").$type(), + matchedPolicyIds: jsonb("matched_policy_ids").$type().notNull().default([]), + reasonCode: text("reason_code"), + policyExplanation: jsonb("policy_explanation").$type>(), + credentialScopeSummary: jsonb("credential_scope_summary").$type>(), + headerPolicySummary: jsonb("header_policy_summary").$type>(), + outcome: text("outcome").$type().notNull().default("pending"), + latencyMs: integer("latency_ms"), + argumentsSummary: jsonb("arguments_summary").$type(), + requestHash: text("request_hash"), + requestSummary: jsonb("request_summary").$type(), + resultHash: text("result_hash"), + resultSummary: jsonb("result_summary").$type(), + resultSizeBytes: integer("result_size_bytes"), + redactionPlan: jsonb("redaction_plan").$type>(), + rateLimitState: jsonb("rate_limit_state").$type>(), + metadata: jsonb("metadata").$type>(), + errorCode: text("error_code"), + errorMessage: text("error_message"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_call_events_company_created_idx").on(table.companyId, table.createdAt), + index("tool_call_events_run_idx").on(table.companyId, table.runId), + index("tool_call_events_issue_idx").on(table.companyId, table.issueId), + index("tool_call_events_invocation_idx").on(table.invocationId), + index("tool_call_events_gateway_idx").on(table.companyId, table.gatewayId), + ], +); + +export const connectionTokenIssuances = pgTable( + "connection_token_issuances", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + applicationId: uuid("application_id").references(() => toolApplications.id, { onDelete: "set null" }), + connectionId: uuid("connection_id").notNull().references(() => toolConnections.id, { onDelete: "cascade" }), + agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }), + runId: uuid("run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }), + projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }), + responsibleUserId: text("responsible_user_id"), + path: text("path").$type().notNull(), + requestedScope: jsonb("requested_scope").$type().notNull().default([]), + issuedScope: jsonb("issued_scope").$type().notNull().default([]), + ttlSeconds: integer("ttl_seconds"), + expiresAt: timestamp("expires_at", { withTimezone: true }), + tokenHash: text("token_hash"), + outcome: text("outcome").$type().notNull(), + errorCode: text("error_code"), + metadata: jsonb("metadata").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("connection_token_issuances_company_created_idx").on(table.companyId, table.createdAt), + index("connection_token_issuances_connection_created_idx").on(table.companyId, table.connectionId, table.createdAt), + index("connection_token_issuances_agent_connection_idx").on(table.companyId, table.agentId, table.connectionId, table.createdAt), + index("connection_token_issuances_run_idx").on(table.companyId, table.runId), + sql`CONSTRAINT connection_token_issuances_path_check CHECK (${table.path} IN ('exchange', 'oauth_access', 'static'))`, + sql`CONSTRAINT connection_token_issuances_outcome_check CHECK (${table.outcome} IN ('success', 'denied', 'rate_limited', 'use_env_lease', 'upstream_error', 'failure'))`, + sql`CONSTRAINT connection_token_issuances_ttl_bounds CHECK (${table.ttlSeconds} IS NULL OR (${table.ttlSeconds} >= 1 AND ${table.ttlSeconds} <= 900))`, + sql`CONSTRAINT connection_token_issuances_token_hash_format CHECK (${table.tokenHash} IS NULL OR ${table.tokenHash} ~ '^[a-f0-9]{64}$')`, + ], +); + +export const toolRateLimitCounters = pgTable( + "tool_rate_limit_counters", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + policyId: uuid("policy_id").notNull().references(() => toolPolicies.id, { onDelete: "cascade" }), + counterKey: text("counter_key").notNull(), + scopeType: text("scope_type").notNull(), + scopeId: text("scope_id").notNull(), + windowKind: text("window_kind").$type().notNull(), + windowStartAt: timestamp("window_start_at", { withTimezone: true }).notNull(), + limit: integer("limit").notNull(), + remaining: integer("remaining").notNull(), + resetAt: timestamp("reset_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_rate_limit_counters_company_idx").on(table.companyId), + uniqueIndex("tool_rate_limit_counters_window_uq").on( + table.companyId, + table.policyId, + table.counterKey, + table.windowKind, + table.windowStartAt, + ), + ], +); + +export const toolRuntimeMetricCounters = pgTable( + "tool_runtime_metric_counters", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + metric: text("metric").notNull(), + bucketStartAt: timestamp("bucket_start_at", { withTimezone: true }).notNull(), + count: integer("count").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_runtime_metric_counters_company_metric_idx").on(table.companyId, table.metric, table.bucketStartAt), + uniqueIndex("tool_runtime_metric_counters_bucket_uq").on(table.companyId, table.metric, table.bucketStartAt), + sql`CONSTRAINT tool_runtime_metric_counters_count_nonnegative CHECK (${table.count} >= 0)`, + ], +); + +export const toolAccessAuditEvents = pgTable( + "tool_access_audit_events", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + gatewayId: uuid("gateway_id").references(() => toolMcpGateways.id, { onDelete: "set null" }), + gatewayTokenId: uuid("gateway_token_id").references(() => toolMcpGatewayTokens.id, { onDelete: "set null" }), + gatewayPublicId: text("gateway_public_id"), + clientName: text("client_name"), + correlationId: text("correlation_id"), + connectionId: uuid("connection_id").references(() => toolConnections.id, { onDelete: "set null" }), + catalogEntryId: uuid("catalog_entry_id").references(() => toolCatalogEntries.id, { onDelete: "set null" }), + actorType: text("actor_type").notNull().default("system"), + actorId: text("actor_id"), + action: text("action").notNull(), + outcome: text("outcome").notNull(), + reasonCode: text("reason_code"), + details: jsonb("details").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("tool_access_audit_company_created_idx").on(table.companyId, table.createdAt), + index("tool_access_audit_connection_idx").on(table.connectionId), + index("tool_access_audit_gateway_idx").on(table.companyId, table.gatewayId), + ], +); diff --git a/packages/plugins/examples/plugin-file-browser-example/src/worker.ts b/packages/plugins/examples/plugin-file-browser-example/src/worker.ts index cf038cf017..6d042c0cb9 100644 --- a/packages/plugins/examples/plugin-file-browser-example/src/worker.ts +++ b/packages/plugins/examples/plugin-file-browser-example/src/worker.ts @@ -65,10 +65,11 @@ const plugin = definePlugin({ async setup(ctx) { ctx.logger.info(`${PLUGIN_NAME} plugin setup`); - // Expose the current plugin config so UI components can read operator - // settings from the canonical instance config store. - ctx.data.register("plugin-config", async () => { - const config = await ctx.config.get(); + // Expose the current company-scoped plugin config so UI components can read + // operator settings from the canonical config store. + ctx.data.register("plugin-config", async (params) => { + const companyId = typeof params.companyId === "string" ? params.companyId : ""; + const config = companyId ? await ctx.config.get(companyId) : null; return { showFilesInSidebar: config?.showFilesInSidebar === true, commentAnnotationMode: config?.commentAnnotationMode ?? "both", diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx index 95ce4c14c1..0350867be8 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx @@ -422,7 +422,7 @@ function hostFetchJson(path: string, init?: RequestInit): Promise { }); } -function useSettingsConfig() { +function useSettingsConfig(companyId: string | null) { const [configJson, setConfigJson] = useState>({ ...DEFAULT_CONFIG }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -430,8 +430,15 @@ function useSettingsConfig() { useEffect(() => { let cancelled = false; + if (!companyId) { + setLoading(false); + setError("Select a company before loading plugin config."); + return () => { + cancelled = true; + }; + } setLoading(true); - hostFetchJson<{ configJson?: Record | null } | null>(`/api/plugins/${PLUGIN_ID}/config`) + hostFetchJson<{ configJson?: Record | null } | null>(`/api/plugins/${PLUGIN_ID}/config?companyId=${encodeURIComponent(companyId)}`) .then((result) => { if (cancelled) return; setConfigJson({ ...DEFAULT_CONFIG, ...(result?.configJson ?? {}) }); @@ -447,14 +454,15 @@ function useSettingsConfig() { return () => { cancelled = true; }; - }, []); + }, [companyId]); async function save(nextConfig: Record) { + if (!companyId) throw new Error("Select a company before saving plugin config."); setSaving(true); try { await hostFetchJson(`/api/plugins/${PLUGIN_ID}/config`, { method: "POST", - body: JSON.stringify({ configJson: nextConfig }), + body: JSON.stringify({ companyId, configJson: nextConfig }), }); setConfigJson(nextConfig); setError(null); @@ -2103,7 +2111,7 @@ export function KitchenSinkPage({ context }: PluginPageProps) { } export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) { - const { configJson, setConfigJson, loading, saving, error, save } = useSettingsConfig(); + const { configJson, setConfigJson, loading, saving, error, save } = useSettingsConfig(context.companyId); const [savedMessage, setSavedMessage] = useState(null); function setField(key: string, value: unknown) { diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts index a20b21deaf..cff71c5376 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts @@ -8,6 +8,7 @@ import { definePlugin, runWorker, type PaperclipPlugin, + type EnvSecretRefBinding, type PluginContext, type PluginEntityQuery, type PluginEvent, @@ -41,7 +42,7 @@ type KitchenSinkConfig = { showCommentContextMenuItem?: boolean; enableWorkspaceDemos?: boolean; enableProcessDemos?: boolean; - secretRefExample?: string; + secretRefExample?: string | EnvSecretRefBinding; httpDemoUrl?: string; allowedCommands?: string[]; workspaceScratchFile?: string; @@ -91,14 +92,20 @@ function pushRecord(record: Omit): DemoRecord { return next; } -async function getConfig(ctx: PluginContext): Promise { - const config = await ctx.config.get(); +async function getConfig(ctx: PluginContext, companyId?: string): Promise { + const config = await ctx.config.get(companyId); return { ...DEFAULT_CONFIG, ...(config as KitchenSinkConfig), }; } +function isSecretRefBinding(value: unknown): value is EnvSecretRefBinding { + return typeof value === "object" && value !== null && !Array.isArray(value) + && (value as { type?: unknown }).type === "secret_ref" + && typeof (value as { secretId?: unknown }).secretId === "string"; +} + async function writeInstanceState(ctx: PluginContext, stateKey: string, value: unknown): Promise { await ctx.state.set({ scopeKind: "instance", stateKey }, value); } @@ -248,13 +255,14 @@ function runtimeLaunchersSnapshot(): PluginLauncherRegistration[] { } async function registerDataHandlers(ctx: PluginContext): Promise { - ctx.data.register("plugin-config", async () => { - return await getConfig(ctx); + ctx.data.register("plugin-config", async (params) => { + const companyId = typeof params.companyId === "string" ? params.companyId : undefined; + return await getConfig(ctx, companyId); }); ctx.data.register("overview", async (params) => { const companyId = typeof params.companyId === "string" ? params.companyId : ""; - const config = await getConfig(ctx); + const config = companyId ? await getConfig(ctx, companyId) : DEFAULT_CONFIG; const companies = await ctx.companies.list({ limit: 200, offset: 0 }); const projects = companyId ? await ctx.projects.list({ companyId, limit: 200, offset: 0 }) : []; const issues = companyId ? await listIssuesForCompany(ctx, companyId, 200) : []; @@ -583,7 +591,8 @@ async function registerActionHandlers(ctx: PluginContext): Promise { }); ctx.actions.register("http-fetch", async (params) => { - const config = await getConfig(ctx); + const companyId = getCurrentCompanyId(params); + const config = await getConfig(ctx, companyId); const url = typeof params.url === "string" && params.url.length > 0 ? params.url : config.httpDemoUrl || DEFAULT_CONFIG.httpDemoUrl; @@ -607,29 +616,32 @@ async function registerActionHandlers(ctx: PluginContext): Promise { }); ctx.actions.register("resolve-secret", async (params) => { - const config = await getConfig(ctx); - const secretRef = typeof params.secretRef === "string" && params.secretRef.length > 0 + const companyId = getCurrentCompanyId(params); + const config = await getConfig(ctx, companyId); + const secretRef = isSecretRefBinding(params.secretRef) ? params.secretRef - : config.secretRefExample || ""; + : isSecretRefBinding(config.secretRefExample) + ? config.secretRefExample + : null; if (!secretRef) { throw new Error("No secret reference configured"); } - const resolved = await ctx.secrets.resolve(secretRef); + const resolved = await ctx.secrets.resolve(secretRef, { companyId, configPath: "secretRefExample" }); pushRecord({ level: "info", source: "secrets", - message: `Resolved secret reference ${secretRef}`, + message: `Resolved secret reference ${secretRef.secretId}`, }); return { - secretRef, + secretRef: secretRef.secretId, resolvedLength: resolved.length, preview: resolved.length > 0 ? `${resolved.slice(0, 2)}***` : "", }; }); ctx.actions.register("run-process", async (params) => { - const config = await getConfig(ctx); const companyId = getCurrentCompanyId(params); + const config = await getConfig(ctx, companyId); const projectId = typeof params.projectId === "string" ? params.projectId : ""; const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined; const commandKey = typeof params.commandKey === "string" ? params.commandKey : "pwd"; @@ -638,11 +650,11 @@ async function registerActionHandlers(ctx: PluginContext): Promise { }); ctx.actions.register("read-workspace-file", async (params) => { - const config = await getConfig(ctx); + const companyId = getCurrentCompanyId(params); + const config = await getConfig(ctx, companyId); if (!config.enableWorkspaceDemos) { throw new Error("Workspace demos are disabled in plugin settings"); } - const companyId = getCurrentCompanyId(params); const projectId = typeof params.projectId === "string" ? params.projectId : ""; const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined; const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0 @@ -660,11 +672,11 @@ async function registerActionHandlers(ctx: PluginContext): Promise { }); ctx.actions.register("write-workspace-scratch", async (params) => { - const config = await getConfig(ctx); + const companyId = getCurrentCompanyId(params); + const config = await getConfig(ctx, companyId); if (!config.enableWorkspaceDemos) { throw new Error("Workspace demos are disabled in plugin settings"); } - const companyId = getCurrentCompanyId(params); const projectId = typeof params.projectId === "string" ? params.projectId : ""; const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined; const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0 @@ -965,8 +977,7 @@ const plugin: PaperclipPlugin = definePlugin({ }, async onHealth(): Promise { - const ctx = currentContext; - const config = ctx ? await getConfig(ctx) : DEFAULT_CONFIG; + const config: KitchenSinkConfig = DEFAULT_CONFIG; return { status: "ok", message: "Kitchen Sink plugin ready", diff --git a/packages/plugins/plugin-llm-wiki/src/wiki/core.ts b/packages/plugins/plugin-llm-wiki/src/wiki/core.ts index f4496fd297..a0033f8c29 100644 --- a/packages/plugins/plugin-llm-wiki/src/wiki/core.ts +++ b/packages/plugins/plugin-llm-wiki/src/wiki/core.ts @@ -639,11 +639,11 @@ function protectDistillationSourceBody(input: { async function resolvePaperclipDistillationLimits( ctx: PluginContext, - input: Pick, + input: Pick, ): Promise { assertRequestedCharacterLimit("maxCharacters", input.maxCharacters, DEFAULT_MAX_PAPERCLIP_CURSOR_WINDOW_CHARS); assertRequestedCharacterLimit("maxCharactersPerSource", input.maxCharactersPerSource, DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS); - const config = await ctx.config.get() as Record; + const config = await ctx.config.get(input.companyId) as Record; const maxCharactersPerSource = Math.min( normalizeBundleLimit(input.maxCharactersPerSource, DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS), normalizeBundleLimit(config.maxPaperclipIssueSourceCharacters, DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS), @@ -686,8 +686,8 @@ function estimateSourceCostCents(characters: number, costCentsPerThousandSourceC return Math.ceil((characters / 1000) * costCentsPerThousandSourceCharacters); } -async function assertSourceWithinConfiguredLimit(ctx: PluginContext, contents: string) { - const config = await ctx.config.get(); +async function assertSourceWithinConfiguredLimit(ctx: PluginContext, companyId: string, contents: string) { + const config = await ctx.config.get(companyId); const maxSourceBytes = normalizeMaxSourceBytes(config.maxSourceBytes); const sourceBytes = byteLength(contents); if (sourceBytes > maxSourceBytes) { @@ -1866,7 +1866,7 @@ export async function captureWikiSource(ctx: PluginContext, input: CaptureSource const wikiId = normalizeWikiId(input.wikiId); const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug }); const title = stringField(input.title) ?? "Untitled source"; - await assertSourceWithinConfiguredLimit(ctx, input.contents); + await assertSourceWithinConfiguredLimit(ctx, input.companyId, input.contents); const hash = contentHash(input.contents); const rawPath = input.rawPath ? assertRawPath(input.rawPath) @@ -3308,11 +3308,11 @@ async function upsertPageBinding(ctx: PluginContext, input: { ); } -async function autoApplyEnabled(ctx: PluginContext, requested: boolean | undefined): Promise { +async function autoApplyEnabled(ctx: PluginContext, companyId: string, requested: boolean | undefined): Promise { if (getDistillationAutoApplyRestriction().autoApplyRestriction) { return false; } - const config = await ctx.config.get(); + const config = await ctx.config.get(companyId); const configured = (config as { autoApplyIngestPatches?: unknown }).autoApplyIngestPatches !== false; return configured && requested !== false; } @@ -3462,7 +3462,7 @@ export async function distillPaperclipProjectPage(ctx: PluginContext, input: Pap } const autoApplyRestriction = getDistillationAutoApplyRestriction(); - const canAutoApply = await autoApplyEnabled(ctx, input.autoApply); + const canAutoApply = await autoApplyEnabled(ctx, input.companyId, input.autoApply); if (!canAutoApply || reviewRequired) { const autoApplyWarning = autoApplyRestriction.autoApplyRestriction diff --git a/packages/plugins/sdk/src/define-plugin.ts b/packages/plugins/sdk/src/define-plugin.ts index 4359dd65cd..668551633b 100644 --- a/packages/plugins/sdk/src/define-plugin.ts +++ b/packages/plugins/sdk/src/define-plugin.ts @@ -19,10 +19,12 @@ * * // Subscribe to events * ctx.events.on("issue.created", async (event) => { - * const config = await ctx.config.get(); + * const companyId = event.companyId; + * const config = await ctx.config.get(companyId); + * const apiKey = await ctx.secrets.resolve(config.apiKeyRef, { companyId, configPath: "apiKeyRef" }); * await ctx.http.fetch(`https://api.linear.app/...`, { * method: "POST", - * headers: { Authorization: `Bearer ${await ctx.secrets.resolve(config.apiKeyRef as string)}` }, + * headers: { Authorization: `Bearer ${apiKey}` }, * body: JSON.stringify({ title: event.payload.title }), * }); * }); @@ -203,8 +205,8 @@ export interface PluginDefinition { onHealth?(): Promise; /** - * Called when the operator updates the plugin's instance configuration at - * runtime, without restarting the worker. + * Called when the operator updates this plugin's company-scoped configuration + * at runtime, without restarting the worker. * * If not implemented, the host restarts the worker to apply the new config. * diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index 8fc8fdfa09..027d988060 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -100,7 +100,10 @@ export class InvocationScopeDeniedError extends Error { export interface HostServices { /** Provides `config.get`. */ config: { - get(): Promise>; + get( + params: WorkerToHostMethods["config.get"][0], + context?: WorkerHostCallContext, + ): Promise>; }; /** Provides trusted company-scoped local folder helpers. */ @@ -147,7 +150,10 @@ export interface HostServices { /** Provides `secrets.resolve`. */ secrets: { - resolve(params: WorkerToHostMethods["secrets.resolve"][0]): Promise; + resolve( + params: WorkerToHostMethods["secrets.resolve"][0], + context?: WorkerHostCallContext, + ): Promise; }; /** Provides `activity.log`. */ @@ -570,10 +576,12 @@ export function createHostClientHandlers( } const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId); - if (!allowedCompanyId) return; if (requested.kind === "all") { if (method === "companies.list") return; + if (!allowedCompanyId) { + throw new InvocationScopeDeniedError(pluginId, method, "company context is required"); + } throw new InvocationScopeDeniedError( pluginId, method, @@ -581,6 +589,10 @@ export function createHostClientHandlers( ); } + if (!allowedCompanyId) { + throw new InvocationScopeDeniedError(pluginId, method, "company context is required"); + } + if (requested.companyId !== allowedCompanyId) { throw new InvocationScopeDeniedError( pluginId, @@ -590,6 +602,40 @@ export function createHostClientHandlers( } } + function resolveRequiredCompanyId( + method: WorkerToHostMethodName, + params: unknown, + context?: WorkerHostCallContext, + ): string { + if (context?.invalidInvocationScope) { + throw new InvocationScopeDeniedError( + pluginId, + method, + "the worker referenced a missing, expired, or unknown invocation scope", + ); + } + + const requested = requestedCompanyScope(method, params); + const scopedCompanyId = readNonEmptyString(context?.invocationScope?.companyId); + if (requested.kind === "single") { + if (!scopedCompanyId) { + throw new InvocationScopeDeniedError(pluginId, method, "company context is required"); + } + if (requested.companyId !== scopedCompanyId) { + throw new InvocationScopeDeniedError( + pluginId, + method, + `requested company "${requested.companyId}" but the current invocation is scoped to company "${scopedCompanyId}"`, + ); + } + return scopedCompanyId; + } + + if (scopedCompanyId) return scopedCompanyId; + + throw new InvocationScopeDeniedError(pluginId, method, "company context is required"); + } + /** * Assert that the plugin has the required capability for a method. * Throws `CapabilityDeniedError` if the capability is missing. @@ -627,8 +673,9 @@ export function createHostClientHandlers( return { // Config - "config.get": gated("config.get", async () => { - return services.config.get(); + "config.get": gated("config.get", async (params, context) => { + const companyId = resolveRequiredCompanyId("config.get", params, context); + return services.config.get({ ...params, companyId }, context); }), "localFolders.declarations": gated("localFolders.declarations", async (params) => { @@ -696,8 +743,9 @@ export function createHostClientHandlers( }), // Secrets - "secrets.resolve": gated("secrets.resolve", async (params) => { - return services.secrets.resolve(params); + "secrets.resolve": gated("secrets.resolve", async (params, context) => { + const companyId = resolveRequiredCompanyId("secrets.resolve", params, context); + return services.secrets.resolve({ ...params, companyId }, context); }), // Activity diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 3717f48dc3..d32b7820b8 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -304,6 +304,7 @@ export type { PluginDatabaseClient, HumanCompanyMembershipRole, MembershipStatus, + EnvSecretRefBinding, } from "./types.js"; // Manifest and constant types re-exported from @paperclipai/shared diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 61f3e40336..ae3cbd86b5 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -45,6 +45,7 @@ import type { ExternalObjectLivenessState, ExternalObjectMentionConfidence, ExternalObjectMentionSourceKind, + EnvSecretRefBinding, } from "@paperclipai/shared"; export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared"; @@ -302,7 +303,7 @@ export interface WorkerHostCallContext { export interface InitializeParams { /** Full plugin manifest snapshot. */ manifest: PaperclipPluginManifestV1; - /** Resolved operator configuration (validated against `instanceConfigSchema`). */ + /** Bootstrap configuration. Company-scoped config is read via `ctx.config.get(companyId)`. */ config: Record; /** Instance-level metadata. */ instanceInfo: { @@ -333,8 +334,10 @@ export interface InitializeResult { * @see PLUGIN_SPEC.md §13.4 — `configChanged` */ export interface ConfigChangedParams { - /** The newly resolved configuration. */ + /** The newly resolved company-scoped configuration. */ config: Record; + /** Company whose plugin config changed. */ + companyId?: string | null; } /** @@ -934,7 +937,7 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[] */ export interface WorkerToHostMethods { // Config - "config.get": [params: Record, result: Record]; + "config.get": [params: { companyId?: string }, result: Record]; // Trusted local folders "localFolders.declarations": [ @@ -1071,7 +1074,7 @@ export interface WorkerToHostMethods { // Secrets "secrets.resolve": [ - params: { secretRef: string }, + params: { secretRef: string | EnvSecretRefBinding; companyId?: string; configPath?: string }, result: string, ]; diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index c53b930364..205b8caa1a 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -75,7 +75,7 @@ export interface TestHarnessOptions { manifest: PaperclipPluginManifestV1; /** Optional capability override. Defaults to `manifest.capabilities`. */ capabilities?: PluginCapability[]; - /** Initial config returned by `ctx.config.get()`. */ + /** Initial config returned by `ctx.config.get(companyId)`. */ config?: Record; } diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index efa84423b8..0b39e8a06e 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -46,6 +46,7 @@ import type { PermissionKey, PrincipalPermissionGrant, PrincipalType, + EnvSecretRefBinding, } from "@paperclipai/shared"; import type { PluginPerformActionContext } from "./protocol.js"; @@ -138,6 +139,7 @@ export type { PermissionKey, PrincipalPermissionGrant, PrincipalType, + EnvSecretRefBinding, } from "@paperclipai/shared"; // --------------------------------------------------------------------------- @@ -426,11 +428,11 @@ export interface PluginExecutionWorkspaceMetadata { */ export interface PluginConfigClient { /** - * Returns the resolved operator configuration for this plugin instance. - * Values are validated against the plugin's `instanceConfigSchema` by the - * host before being passed to the worker. + * Returns the resolved operator configuration for this plugin in a company. + * When called during a host-scoped invocation, the host may derive the + * companyId; otherwise callers must pass it explicitly. */ - get(): Promise>; + get(companyId?: string): Promise>; } export interface PluginLocalFolderProblem { @@ -642,9 +644,9 @@ export interface PluginHttpClient { * * Requires `secrets.read-ref` capability. * - * Plugins store secret *references* in their config (e.g. a secret name). - * This client resolves the reference through the Paperclip secret provider - * system and returns the resolved value at execution time. + * Plugins store shared `{ type: "secret_ref", secretId, version? }` bindings in + * company-scoped config. This client resolves a bound ref through the + * Paperclip secret provider system at execution time. * * @see PLUGIN_SPEC.md §22 — Secrets */ @@ -652,16 +654,19 @@ export interface PluginSecretsClient { /** * Resolve a secret reference to its current value. * - * The reference is a string identifier pointing to a secret configured - * in the Paperclip secret provider (e.g. `"MY_API_KEY"`). + * The reference must be the shared `secret_ref` object shape from plugin + * config. Legacy string UUID references fail closed. * * Secret values are resolved at call time and must never be cached or * written to logs, config, or other persistent storage. * - * @param secretRef - The secret reference string from plugin config + * @param secretRef - The secret reference object from plugin config * @returns The resolved secret value */ - resolve(secretRef: string): Promise; + resolve( + secretRef: string | EnvSecretRefBinding, + options?: { companyId?: string; configPath?: string }, + ): Promise; } /** diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index d63d2f7c4d..730fbb4742 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -422,8 +422,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, config: { - async get() { - return callHost("config.get", {} as Record); + async get(companyId?: string) { + return callHost("config.get", companyId ? { companyId } : {}); }, }, @@ -573,8 +573,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, secrets: { - async resolve(secretRef: string): Promise { - return callHost("secrets.resolve", { secretRef }); + async resolve(secretRef, options = {}): Promise { + return callHost("secrets.resolve", { + secretRef, + companyId: options.companyId, + configPath: options.configPath, + }); }, }, diff --git a/packages/plugins/sdk/tests/host-client-factory.test.ts b/packages/plugins/sdk/tests/host-client-factory.test.ts index eb79043e57..83cfb49945 100644 --- a/packages/plugins/sdk/tests/host-client-factory.test.ts +++ b/packages/plugins/sdk/tests/host-client-factory.test.ts @@ -9,6 +9,65 @@ import { import { PLUGIN_RPC_ERROR_CODES } from "../src/protocol.js"; describe("createHostClientHandlers invocation company scope", () => { + it("rejects worker-selected config and secret company ids without a host invocation scope", async () => { + const configGet = vi.fn(async () => ({ apiKeyRef: "unreachable" })); + const secretsResolve = vi.fn(async () => "unreachable"); + const services = { + config: { get: configGet }, + secrets: { resolve: secretsResolve }, + } as unknown as HostServices; + + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["secrets.read-ref"], + services, + }); + + await expect( + handlers["config.get"]({ companyId: "company-a" }), + ).rejects.toBeInstanceOf(InvocationScopeDeniedError); + await expect( + handlers["secrets.resolve"]({ + companyId: "company-a", + secretRef: { type: "secret_ref", secretId: "secret-a" }, + }), + ).rejects.toBeInstanceOf(InvocationScopeDeniedError); + expect(configGet).not.toHaveBeenCalled(); + expect(secretsResolve).not.toHaveBeenCalled(); + }); + + it("allows explicit config and secret company ids only when they match the host invocation scope", async () => { + const configGet = vi.fn(async () => ({ apiKeyRef: "ref" })); + const secretsResolve = vi.fn(async () => "resolved"); + const services = { + config: { get: configGet }, + secrets: { resolve: secretsResolve }, + } as unknown as HostServices; + + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["secrets.read-ref"], + services, + }); + const context = { invocationScope: { companyId: "company-a" } }; + + await expect( + handlers["config.get"]({ companyId: "company-a" }, context), + ).resolves.toEqual({ apiKeyRef: "ref" }); + await expect( + handlers["secrets.resolve"]({ + companyId: "company-a", + secretRef: { type: "secret_ref", secretId: "secret-a" }, + }, context), + ).resolves.toBe("resolved"); + + expect(configGet).toHaveBeenCalledWith({ companyId: "company-a" }, context); + expect(secretsResolve).toHaveBeenCalledWith({ + companyId: "company-a", + secretRef: { type: "secret_ref", secretId: "secret-a" }, + }, context); + }); + it("rejects company-scoped host calls outside the current invocation company", async () => { const projectsList = vi.fn(async () => []); const services = { diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index e5194ac476..1635ceb3d1 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -23,6 +23,24 @@ export const API = { goals: `${API_PREFIX}/goals`, approvals: `${API_PREFIX}/approvals`, secrets: `${API_PREFIX}/secrets`, + tools: `${API_PREFIX}/companies/:companyId/tools`, + toolExamples: `${API_PREFIX}/companies/:companyId/tools/examples`, + toolApplications: `${API_PREFIX}/companies/:companyId/tools/applications`, + toolConnections: `${API_PREFIX}/companies/:companyId/tools/connections`, + toolCatalog: `${API_PREFIX}/companies/:companyId/tools/catalog`, + toolProfiles: `${API_PREFIX}/companies/:companyId/tools/profiles`, + toolPolicies: `${API_PREFIX}/companies/:companyId/tools/policies`, + toolAudit: `${API_PREFIX}/companies/:companyId/tools/audit`, + toolRuntimeSlots: `${API_PREFIX}/companies/:companyId/tools/runtime-slots`, + toolRuntimeSlotStop: `${API_PREFIX}/companies/:companyId/tools/runtime-slots/:id/stop`, + toolRuntimeSlotRestart: `${API_PREFIX}/companies/:companyId/tools/runtime-slots/:id/restart`, + toolRuntimeHealth: `${API_PREFIX}/companies/:companyId/tools/runtime-health`, + toolGateway: `${API_PREFIX}/tool-gateway`, + smokeLab: `${API_PREFIX}/companies/:companyId/smoke-lab`, + smokeLabServices: `${API_PREFIX}/companies/:companyId/smoke-lab/services`, + smokeLabInstallFixtures: `${API_PREFIX}/companies/:companyId/smoke-lab/install-fixtures`, + smokeLabRuns: `${API_PREFIX}/companies/:companyId/smoke-lab/runs`, + smokeLabRunSteps: `${API_PREFIX}/companies/:companyId/smoke-lab/runs/:runId/steps`, userSecretDefinitions: `${API_PREFIX}/companies/:companyId/user-secret-definitions`, userSecretDefinition: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId`, userSecretDefinitionCoverage: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId/coverage`, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b9db9c76b9..e62ce48c0b 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -659,6 +659,7 @@ export const SECRET_BINDING_TARGET_TYPES = [ "plugin", "issue", "run", + "tool_connection", "system", ] as const; export type SecretBindingTargetType = (typeof SECRET_BINDING_TARGET_TYPES)[number]; @@ -674,6 +675,55 @@ export const SECRET_ACCESS_OUTCOMES = [ ] as const; export type SecretAccessOutcome = (typeof SECRET_ACCESS_OUTCOMES)[number]; +export const SECRET_PROJECTION_CLASSES = ["unclassified", "class_3_static_lease"] as const; +export type SecretProjectionClass = (typeof SECRET_PROJECTION_CLASSES)[number]; + +export const CLASS3_STATIC_LEASE_ALLOWLIST = [ + { + key: "slack.bot_token", + label: "Slack bot token", + targetType: "agent", + configPath: "env.SLACK_BOT_TOKEN", + envKey: "SLACK_BOT_TOKEN", + }, + { + key: "slack.bot_token", + label: "Slack bot token", + targetType: "routine", + configPath: "env.SLACK_BOT_TOKEN", + envKey: "SLACK_BOT_TOKEN", + }, + { + key: "slack.bot_token", + label: "Slack bot token governance connection", + targetType: "tool_connection", + configPath: "credentials.bot_token", + envKey: "SLACK_BOT_TOKEN", + }, + { + key: "discord.bot_token", + label: "Discord bot token", + targetType: "agent", + configPath: "env.DISCORD_BOT_TOKEN", + envKey: "DISCORD_BOT_TOKEN", + }, + { + key: "discord.bot_token", + label: "Discord bot token", + targetType: "routine", + configPath: "env.DISCORD_BOT_TOKEN", + envKey: "DISCORD_BOT_TOKEN", + }, + { + key: "discord.bot_token", + label: "Discord bot token governance connection", + targetType: "tool_connection", + configPath: "credentials.bot_token", + envKey: "DISCORD_BOT_TOKEN", + }, +] as const; +export type Class3StaticLeaseAllowlistKey = (typeof CLASS3_STATIC_LEASE_ALLOWLIST)[number]["key"]; + export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const; export type StorageProvider = (typeof STORAGE_PROVIDERS)[number]; @@ -860,6 +910,12 @@ export const PERMISSION_KEYS = [ "skills:create", "skills:suggest-changes", "environments:manage", + "tools:admin", + "tools:manage_connections", + "tools:manage_profiles", + "tools:view_audit", + "tools:use", + "tools:manage_runtime", "users:invite", "users:manage_permissions", "tasks:assign", @@ -870,6 +926,235 @@ export const PERMISSION_KEYS = [ ] as const; export type PermissionKey = (typeof PERMISSION_KEYS)[number]; +export const TOOL_APPLICATION_TYPES = ["mcp_http", "mcp_stdio", "paperclip_plugin", "a2a"] as const; +export type ToolApplicationType = (typeof TOOL_APPLICATION_TYPES)[number]; + +export const TOOL_APPLICATION_STATUSES = ["draft", "active", "disabled", "archived"] as const; +export type ToolApplicationStatus = (typeof TOOL_APPLICATION_STATUSES)[number]; + +export const TOOL_CONNECTION_KINDS = ["managed"] as const; +export type ToolConnectionKind = (typeof TOOL_CONNECTION_KINDS)[number]; + +export const TOOL_CONNECTION_HEALTH_STATUSES = [ + "unknown", + "healthy", + "degraded", + "failed", + "unchecked", + "ok", + "error", + "missing_secret", +] as const; +export type ToolConnectionHealthStatus = (typeof TOOL_CONNECTION_HEALTH_STATUSES)[number]; + +/** + * Health states that mean an app needs the user's attention (a bad/missing key + * or a degraded connection). Single source of truth shared by the needs- + * attention aggregation and the prosumer Apps surfaces so their counts agree. + */ +export const TOOL_CONNECTION_ATTENTION_HEALTH_STATUSES: readonly ToolConnectionHealthStatus[] = [ + "degraded", + "failed", + "error", + "missing_secret", +]; + +export function isToolConnectionAttentionHealth(status: ToolConnectionHealthStatus): boolean { + return TOOL_CONNECTION_ATTENTION_HEALTH_STATUSES.includes(status); +} + +export const TOOL_CATALOG_ENTRY_KINDS = ["tool", "resource", "prompt"] as const; +export type ToolCatalogEntryKind = (typeof TOOL_CATALOG_ENTRY_KINDS)[number]; + +export const TOOL_CATALOG_ENTRY_STATUSES = ["active", "disabled", "quarantined", "removed"] as const; +export type ToolCatalogEntryStatus = (typeof TOOL_CATALOG_ENTRY_STATUSES)[number]; + +export const TOOL_RISK_LEVELS = ["low", "medium", "high", "critical", "read", "write", "destructive"] as const; +export type ToolRiskLevel = (typeof TOOL_RISK_LEVELS)[number]; + +export const TOOL_PROFILE_STATUSES = ["draft", "active", "disabled", "archived"] as const; +export type ToolProfileStatus = (typeof TOOL_PROFILE_STATUSES)[number]; + +export const TOOL_PROFILE_DEFAULT_ACTIONS = ["deny", "allow"] as const; +export type ToolProfileDefaultAction = (typeof TOOL_PROFILE_DEFAULT_ACTIONS)[number]; + +export const TOOL_PROFILE_ENTRY_SELECTOR_TYPES = [ + "application", + "connection", + "catalog_entry", + "tool_name", + "risk_level", +] as const; +export type ToolProfileEntrySelectorType = (typeof TOOL_PROFILE_ENTRY_SELECTOR_TYPES)[number]; + +export const TOOL_PROFILE_ENTRY_EFFECTS = ["include", "exclude"] as const; +export type ToolProfileEntryEffect = (typeof TOOL_PROFILE_ENTRY_EFFECTS)[number]; + +export const TOOL_PROFILE_BINDING_TARGET_TYPES = ["company", "agent", "project", "routine", "issue", "gateway"] as const; +export type ToolProfileBindingTargetType = (typeof TOOL_PROFILE_BINDING_TARGET_TYPES)[number]; + +export const TOOL_MCP_GATEWAY_STATUSES = ["draft", "active", "disabled", "archived"] as const; +export type ToolMcpGatewayStatus = (typeof TOOL_MCP_GATEWAY_STATUSES)[number]; + +export const TOOL_MCP_GATEWAY_DEFAULT_PROFILE_MODES = [ + "gateway_only", + "inherit_context_then_gateway", + "gateway_then_context", +] as const; +export type ToolMcpGatewayDefaultProfileMode = (typeof TOOL_MCP_GATEWAY_DEFAULT_PROFILE_MODES)[number]; + +export const TOOL_MCP_GATEWAY_CONTEXT_SCOPE_TYPES = [ + "none", + "company", + "project", + "routine", + "issue", + "agent", +] as const; +export type ToolMcpGatewayContextScopeType = (typeof TOOL_MCP_GATEWAY_CONTEXT_SCOPE_TYPES)[number]; + +export const TOOL_MCP_GATEWAY_TOKEN_SUBJECT_TYPES = ["gateway_client", "heartbeat_run", "board_user", "agent"] as const; +export type ToolMcpGatewayTokenSubjectType = (typeof TOOL_MCP_GATEWAY_TOKEN_SUBJECT_TYPES)[number]; + +export const TOOL_MCP_GATEWAY_TOKEN_ACTIONS = ["tools/list", "tools/call"] as const; +export type ToolMcpGatewayTokenAction = (typeof TOOL_MCP_GATEWAY_TOKEN_ACTIONS)[number]; + +export const CONNECTION_TOKEN_ISSUANCE_PATHS = ["exchange", "oauth_access", "static"] as const; +export type ConnectionTokenIssuancePath = (typeof CONNECTION_TOKEN_ISSUANCE_PATHS)[number]; + +export const CONNECTION_TOKEN_ISSUANCE_OUTCOMES = [ + "success", + "denied", + "rate_limited", + "use_env_lease", + "upstream_error", + "failure", +] as const; +export type ConnectionTokenIssuanceOutcome = (typeof CONNECTION_TOKEN_ISSUANCE_OUTCOMES)[number]; + +export const TOOL_POLICY_TYPES = [ + "allow", + "block", + "require_approval", + "trust_rule", + "rate_limit", +] as const; +export type ToolPolicyType = (typeof TOOL_POLICY_TYPES)[number]; + +export const TOOL_POLICY_DECISIONS = ["allow", "deny", "require_approval", "rate_limited", "defer_runtime"] as const; +export type ToolPolicyDecision = (typeof TOOL_POLICY_DECISIONS)[number]; + +export const TOOL_INVOCATION_STATUSES = [ + "pending", + "authorized", + "denied", + "awaiting_approval", + "executing", + "succeeded", + "failed", + "cancelled", + "timed_out", + "rate_limited", +] as const; +export type ToolInvocationStatus = (typeof TOOL_INVOCATION_STATUSES)[number]; + +export const TOOL_INVOCATION_APPROVAL_STATES = [ + "not_required", + "required", + "pending", + "approved", + "rejected", + "expired", +] as const; +export type ToolInvocationApprovalState = (typeof TOOL_INVOCATION_APPROVAL_STATES)[number]; + +export const TOOL_ACTION_REQUEST_STATUSES = [ + "pending", + "approved", + "executing", + "rejected", + "expired", + "cancelled", + "executed", + "failed", +] as const; +export type ToolActionRequestStatus = (typeof TOOL_ACTION_REQUEST_STATUSES)[number]; + +export const TOOL_AUDIT_EVENT_TYPES = [ + "discovery", + "policy_decision", + "invocation_created", + "call_started", + "call_completed", + "call_failed", + "call_denied", + "approval_requested", + "approval_resolved", + "session_revoked", + "trust_rule_created", + "trust_rule_revoked", + "trust_rule_used", + "runtime_started", + "runtime_stopped", + "rate_limited", +] as const; +export type ToolAuditEventType = (typeof TOOL_AUDIT_EVENT_TYPES)[number]; + +export const TOOL_AUDIT_OUTCOMES = ["pending", "success", "failure", "denied", "timeout", "cancelled"] as const; +export type ToolAuditOutcome = (typeof TOOL_AUDIT_OUTCOMES)[number]; + +/** + * Connection-level lifecycle events surfaced on the per-app Activity tab + * alongside tool-call events (PAP-11284). These are derived from the + * company activity log rows scoped to a single tool connection. + */ +export const TOOL_CONNECTION_LIFECYCLE_EVENT_TYPES = [ + "app_connected", + "app_paused", + "app_resumed", + "allowlist_changed", + "reconnected", + "disconnected", + "actions_quarantined", +] as const; +export type ToolConnectionLifecycleEventType = (typeof TOOL_CONNECTION_LIFECYCLE_EVENT_TYPES)[number]; + +export const TOOL_RUNTIME_KINDS = ["remote_session", "local_stdio"] as const; +export type ToolRuntimeKind = (typeof TOOL_RUNTIME_KINDS)[number]; + +export const TOOL_RUNTIME_SLOT_STATUSES = ["starting", "running", "idle", "stopped", "failed", "disabled", "error"] as const; +export type ToolRuntimeSlotStatus = (typeof TOOL_RUNTIME_SLOT_STATUSES)[number]; + +export const TOOL_RATE_LIMIT_WINDOW_KINDS = ["minute", "hour", "day", "month"] as const; +export type ToolRateLimitWindowKind = (typeof TOOL_RATE_LIMIT_WINDOW_KINDS)[number]; + +export const TOOL_ACCESS_ACTIVITY_ACTIONS = [ + "tool_application.created", + "tool_application.updated", + "tool_application.archived", + "tool_connection.created", + "tool_connection.updated", + "tool_connection.tested", + "tool_connection.catalog_refreshed", + "tool_profile.created", + "tool_profile.updated", + "tool_profile.duplicated", + "tool_profile.deleted", + "tool_profile.new_tools_reviewed", + "tool_profile.bound", + "tool_profile.unbound", + "tool_policy.created", + "tool_policy.updated", + "tool_policy.disabled", + "tool_trust_rule.created", + "tool_trust_rule.revoked", + "tool_runtime_slot.started", + "tool_runtime_slot.stopped", + "tool_action_request.created", + "tool_action_request.resolved", +] as const; +export type ToolAccessActivityAction = (typeof TOOL_ACCESS_ACTIVITY_ACTIONS)[number]; + // --------------------------------------------------------------------------- // Plugin System — see doc/plugins/PLUGIN_SPEC.md for the full specification // --------------------------------------------------------------------------- diff --git a/packages/shared/src/humanize-connection.test.ts b/packages/shared/src/humanize-connection.test.ts new file mode 100644 index 0000000000..ba9e90a44c --- /dev/null +++ b/packages/shared/src/humanize-connection.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + connectionDisplaySecondaryHint, + humanizeConnectionDisplayName, +} from "./humanize-connection.js"; + +describe("humanizeConnectionDisplayName", () => { + it("hides raw IPs / hosts behind a generic label", () => { + expect(humanizeConnectionDisplayName("127.0.0.1")).toBe("Custom app"); + expect(humanizeConnectionDisplayName("127.0.0.1:8931")).toBe("Custom app"); + expect(humanizeConnectionDisplayName("localhost")).toBe("Custom app"); + expect(humanizeConnectionDisplayName("example.com:8080")).toBe("Custom app"); + expect(humanizeConnectionDisplayName("https://mcp.example.com/sse")).toBe("Custom app"); + }); + + it("drops the `Plugin:` prefix and title-cases the package leaf", () => { + expect(humanizeConnectionDisplayName("Plugin: paperclipai.plugin-briefs")).toBe("Briefs"); + expect(humanizeConnectionDisplayName("Plugin: acme.plugin-weekly-report")).toBe( + "Weekly Report", + ); + }); + + it("turns `vendor:tool` ids into Title Case With Spaces", () => { + expect(humanizeConnectionDisplayName("mcp-remote-fixture:update_note")).toBe("Update Note"); + expect(humanizeConnectionDisplayName("github:create_issue")).toBe("Create Issue"); + }); + + it("title-cases a bare snake/kebab identifier", () => { + expect(humanizeConnectionDisplayName("update_note")).toBe("Update Note"); + expect(humanizeConnectionDisplayName("send-email")).toBe("Send Email"); + }); + + it("passes through normal, already-human app names", () => { + expect(humanizeConnectionDisplayName("Zapier")).toBe("Zapier"); + expect(humanizeConnectionDisplayName("Notion")).toBe("Notion"); + expect(humanizeConnectionDisplayName("Google Drive")).toBe("Google Drive"); + }); + + it("prefers an explicit title when provided", () => { + expect( + humanizeConnectionDisplayName("mcp-remote-fixture:update_note", { title: "Update note" }), + ).toBe("Update note"); + // Blank/whitespace titles fall back to derivation. + expect(humanizeConnectionDisplayName("update_note", { title: " " })).toBe("Update Note"); + }); + + it("accepts a connection-like object and handles empty input", () => { + expect(humanizeConnectionDisplayName({ name: "Plugin: acme.plugin-briefs" })).toBe("Briefs"); + expect(humanizeConnectionDisplayName("")).toBe("Custom app"); + expect(humanizeConnectionDisplayName(null)).toBe("Custom app"); + }); +}); + +describe("connectionDisplaySecondaryHint", () => { + it("surfaces `hosted at …` only for network addresses", () => { + expect(connectionDisplaySecondaryHint("127.0.0.1")).toBe("hosted at 127.0.0.1"); + expect(connectionDisplaySecondaryHint("127.0.0.1:8931")).toBe("hosted at 127.0.0.1:8931"); + expect(connectionDisplaySecondaryHint({ name: "Zapier" })).toBeNull(); + expect(connectionDisplaySecondaryHint("Plugin: acme.plugin-briefs")).toBeNull(); + expect(connectionDisplaySecondaryHint("")).toBeNull(); + }); +}); diff --git a/packages/shared/src/humanize-connection.ts b/packages/shared/src/humanize-connection.ts new file mode 100644 index 0000000000..fbd414476a --- /dev/null +++ b/packages/shared/src/humanize-connection.ts @@ -0,0 +1,103 @@ +/** + * Humanize engineering connection / tool identifiers for the prosumer Apps + * surfaces (PAP-10897). + * + * `connection.name` (and tool ids) can carry raw IPs, `Plugin:` prefixes with + * dotted package paths, and `vendor:tool` ids. None of that vocabulary may leak + * into `/apps`, `/apps/attention`, or the App-detail header — only `/apps/advanced` + * is allowed to show the raw identifiers. This module turns those identifiers + * into recognizable, plain-language labels. + */ + +export interface HumanizableConnection { + name: string; +} + +type ConnectionLike = HumanizableConnection | string | null | undefined; + +function rawNameOf(input: ConnectionLike): string { + return (typeof input === "string" ? input : (input?.name ?? "")).trim(); +} + +/** IP / URL / host:port / localhost — anything that reads as a network address. */ +function looksLikeNetworkAddress(raw: string): boolean { + const v = raw.toLowerCase(); + if (v.includes("://")) return true; // any URL + if (v === "localhost" || v.startsWith("localhost:")) return true; + if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(v)) return true; // IPv4 (optional :port) + if (/^[a-z0-9.-]+:\d+$/.test(v)) return true; // host:port + return false; +} + +/** Title-case a snake/kebab/dotted identifier: `update_note` → `Update Note`. */ +function titleCaseIdentifier(value: string): string { + return value + .split(/[\s._-]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +/** `Plugin: paperclipai.plugin-briefs` → `Briefs`; null when not a plugin label. */ +function pluginPackageLabel(raw: string): string | null { + const match = /^plugin:\s*(.+)$/i.exec(raw); + if (!match) return null; + let leaf = match[1].trim(); + // Keep the package leaf only: `paperclipai.plugin-briefs` → `plugin-briefs`. + leaf = leaf.slice(leaf.lastIndexOf(".") + 1); + // Drop the `plugin-` scaffolding leftover: `plugin-briefs` → `briefs`. + leaf = leaf.replace(/^plugin[-_]/i, ""); + return titleCaseIdentifier(leaf) || "Custom app"; +} + +/** + * Turn an app/connection identifier (or a tool id) into a prosumer-friendly + * label. Pass `options.title` (e.g. a catalog entry's `title`) to prefer a + * known human title over any derivation. + * + * `127.0.0.1` → `Custom app` + * `Plugin: paperclipai.plugin-briefs` → `Briefs` + * `mcp-remote-fixture:update_note` → `Update Note` + * `Zapier` / `Notion` → unchanged + */ +export function humanizeConnectionDisplayName( + input: ConnectionLike, + options: { title?: string | null } = {}, +): string { + const title = options.title?.trim(); + if (title) return title; // a real, human title always wins + + const raw = rawNameOf(input); + if (!raw) return "Custom app"; + + if (looksLikeNetworkAddress(raw)) return "Custom app"; + + const pluginLabel = pluginPackageLabel(raw); + if (pluginLabel) return pluginLabel; + + // `vendor:tool` id (e.g. `mcp-remote-fixture:update_note`) → tool segment. + if (raw.includes(":") && !raw.includes("://")) { + const tool = raw.slice(raw.lastIndexOf(":") + 1).trim(); + if (tool) return titleCaseIdentifier(tool); + } + + // Already human (a space or any capital) → pass through untouched. + if (/\s/.test(raw) || /[A-Z]/.test(raw)) return raw; + + // Bare snake/kebab/dotted identifier → Title Case With Spaces. + if (/[._-]/.test(raw)) return titleCaseIdentifier(raw); + + return raw; +} + +/** + * Optional secondary line for the App-detail page only: when the raw name is a + * network address we hide it from the header but may still show `hosted at …` + * underneath as a small trust/clarity hint. Returns null when there's nothing + * worth surfacing. + */ +export function connectionDisplaySecondaryHint(input: ConnectionLike): string | null { + const raw = rawNameOf(input); + if (raw && looksLikeNetworkAddress(raw)) return `hosted at ${raw}`; + return null; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 59ba736c6e..38db63ec1d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -133,6 +133,21 @@ export { type SourceTrustPromotionSource, type SourceTrustMetadata, } from "./trust-policy.js"; +export { + TOOL_APP_GALLERY, + getToolAppGalleryEntry, + getToolAppGalleryEntryForUrl, + type AppGalleryAuthKind, + type AppGalleryCredentialField, + type AppGalleryEntry, + type AppGalleryKey, + type AppGalleryTransportTemplate, +} from "./tool-app-gallery.js"; +export { + humanizeConnectionDisplayName, + connectionDisplaySecondaryHint, + type HumanizableConnection, +} from "./humanize-connection.js"; export { COMPANY_STATUSES, DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES, @@ -229,6 +244,8 @@ export { SECRET_PROVIDERS, SECRET_PROVIDER_CONFIG_STATUSES, SECRET_PROVIDER_CONFIG_HEALTH_STATUSES, + SECRET_PROJECTION_CLASSES, + CLASS3_STATIC_LEASE_ALLOWLIST, SECRET_SCOPES, STORAGE_PROVIDERS, BILLING_TYPES, @@ -258,6 +275,39 @@ export { JOIN_REQUEST_TYPES, JOIN_REQUEST_STATUSES, PERMISSION_KEYS, + TOOL_ACTION_REQUEST_STATUSES, + TOOL_APPLICATION_STATUSES, + TOOL_APPLICATION_TYPES, + TOOL_AUDIT_EVENT_TYPES, + TOOL_AUDIT_OUTCOMES, + TOOL_CATALOG_ENTRY_KINDS, + TOOL_CATALOG_ENTRY_STATUSES, + TOOL_CONNECTION_HEALTH_STATUSES, + TOOL_CONNECTION_ATTENTION_HEALTH_STATUSES, + isToolConnectionAttentionHealth, + TOOL_CONNECTION_KINDS, + TOOL_CONNECTION_LIFECYCLE_EVENT_TYPES, + TOOL_INVOCATION_APPROVAL_STATES, + TOOL_INVOCATION_STATUSES, + TOOL_MCP_GATEWAY_CONTEXT_SCOPE_TYPES, + TOOL_MCP_GATEWAY_DEFAULT_PROFILE_MODES, + TOOL_MCP_GATEWAY_STATUSES, + TOOL_MCP_GATEWAY_TOKEN_ACTIONS, + TOOL_MCP_GATEWAY_TOKEN_SUBJECT_TYPES, + CONNECTION_TOKEN_ISSUANCE_PATHS, + CONNECTION_TOKEN_ISSUANCE_OUTCOMES, + TOOL_POLICY_DECISIONS, + TOOL_POLICY_TYPES, + TOOL_PROFILE_BINDING_TARGET_TYPES, + TOOL_PROFILE_DEFAULT_ACTIONS, + TOOL_PROFILE_ENTRY_EFFECTS, + TOOL_PROFILE_ENTRY_SELECTOR_TYPES, + TOOL_PROFILE_STATUSES, + TOOL_RATE_LIMIT_WINDOW_KINDS, + TOOL_RISK_LEVELS, + TOOL_RUNTIME_KINDS, + TOOL_RUNTIME_SLOT_STATUSES, + TOOL_ACCESS_ACTIVITY_ACTIONS, PLUGIN_API_VERSION, PLUGIN_STATUSES, PLUGIN_CATEGORIES, @@ -370,6 +420,8 @@ export { type SecretProvider, type SecretProviderConfigStatus, type SecretProviderConfigHealthStatus, + type SecretProjectionClass, + type Class3StaticLeaseAllowlistKey, type SecretScope, type StorageProvider, type BillingType, @@ -399,6 +451,9 @@ export { type JoinRequestType, type JoinRequestStatus, type PermissionKey, + type ConnectionTokenIssuancePath, + type ConnectionTokenIssuanceOutcome, + type ToolAccessActivityAction, type PluginStatus, type PluginCategory, type PluginCapability, @@ -784,6 +839,8 @@ export type { RequestConfirmationTarget, RequestConfirmationPayload, RequestConfirmationResult, + RequestConfirmationToolActionPayload, + RequestConfirmationToolActionResult, RequestCheckboxConfirmationOption, RequestCheckboxConfirmationPayload, RequestCheckboxConfirmationResult, @@ -962,6 +1019,131 @@ export type { SecretStatus, SecretVersionSelector, SecretVersionStatus, + ConnectToolAppResult, + ToolOAuthStartResult, + ToolActionRequest, + ToolAccessDecision, + ToolAccessDecisionInput, + ToolAccessReasonCode, + ToolAccessSelector, + ToolPolicyConditions, + ToolAppConnectionActionSummary, + ToolTrustRuleArgumentFilters, + ToolTrustRuleBatchApprovalConfig, + ToolTrustRuleScopeInput, + ToolMcpGateway, + ToolMcpGatewayAuthConfig, + ToolMcpGatewayBearerAuthConfig, + ToolMcpGatewayClientSnippet, + ToolMcpGatewayContextScopeType, + ToolMcpGatewayDefaultProfileMode, + ToolMcpGatewayCallerHeaderPolicy, + ToolMcpGatewayGeneratedMetadataHeaderPolicy, + ToolMcpGatewayHeaderPolicy, + ToolMcpGatewayMetadataPolicy, + ToolMcpGatewayOAuthReservedConfig, + ToolMcpGatewayOnDemandToolsConfig, + ToolMcpGatewayResponseHeaderPolicy, + ToolMcpGatewayStaticHeaderPolicy, + ToolMcpGatewayStatus, + ToolMcpGatewayToken, + ToolMcpGatewayTokenAction, + ToolMcpGatewayTokenCreated, + ToolMcpGatewayTokenSubjectType, + ToolMcpGatewayWithTokens, + FinishToolAppResult, + ToolActionRequestStatus, + ToolApplicationType, + ToolApplicationStatus, + ToolAuditEventType, + ToolAuditOutcome, + ToolCallEvent, + ToolCatalogEntryKind, + ToolConnectionHealthStatus, + ToolConnectionTransport, + ToolConnectionStatus, + ToolConnectionKind, + ToolCatalogEntryStatus, + ToolAppAttentionItem, + ToolAppAttentionReason, + ToolAppsAttentionResponse, + ToolActionRequestListItem, + ToolActionRequestsResponse, + ToolConnectionActivityResponse, + ToolConnectionTestDecision, + ToolConnectionTestToolAccess, + ToolConnectionAccessSummary, + ToolConnectionTestAgent, + ToolConnectionTestAgentsResponse, + ToolConnectionTestCallResult, + ToolConnectionTestCallStatus, + ToolConnectionTestCallStatusPhase, + ToolConnectionLifecycleEvent, + ToolConnectionLifecycleEventType, + ToolConnectionInstall, + ToolConnectionInstallSnapshot, + ToolConnectionInstallTargetType, + ConnectionTokenScope, + ConnectionTokenRequest, + ConnectionTokenAttribution, + ConnectionTokenMintedResponse, + ConnectionTokenUseEnvLeaseResponse, + ConnectionTokenResponse, + ConnectionTokenIssuance, + ToolCredentialSecretRef, + ToolInvocation, + ToolInvocationApprovalState, + ToolInvocationStatus, + ToolPolicy, + ToolPolicyDecision, + ToolPolicyType, + ToolProfile, + ToolProfileBinding, + ToolProfileBindingTargetType, + ToolProfileDefaultAction, + ToolProfileEntry, + ToolProfileEntryEffect, + ToolProfileEntrySelectorType, + ToolProfileEffectiveSummary, + ToolProfileNewToolReviewDecision, + ToolProfileNewToolReviewItem, + ToolProfileNewToolsReview, + ToolProfileNewToolsReviewResult, + ToolProfileSummary, + ToolProfileStatus, + ToolProfileWithDetails, + ToolRateLimitCounter, + ToolRateLimitRule, + ToolRateLimitWindowKind, + ToolRedactedValueSummary, + ToolRiskLevel, + ToolRunDecision, + ToolRunDecisionLookup, + ToolRuntimeKind, + ToolRuntimeHealthSummary, + ToolRuntimeMetricSnapshot, + ToolRuntimeAlertRecommendation, + ToolRuntimeAlertSeverity, + ToolRuntimeAlertStatus, + ToolRuntimeSupportMatrix, + ToolRuntimeSlotStatus, + ToolStdioCommandTemplate, + ToolStdioTemplateToolSummary, + McpConnectionCredentialRef, + ToolApplication, + ToolConnection, + ToolCatalogEntry, + ToolRuntimeSlot, + ToolConnectionHealthCheckResult, + ToolCatalogRefreshResult, + ToolExampleInstallResult, + ToolExampleSmokeCheck, + ToolExampleSmokeResult, + ToolExampleSummary, + McpJsonImportDraft, + McpJsonImportPreview, + CreateToolTrustRuleFromActionRequest, + RevokeToolTrustRule, Routine, RoutineEnvConfig, RoutineManagedByPlugin, @@ -1108,6 +1290,23 @@ export { MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, } from "./types/instance.js"; +export type { + SmokeLabServiceStatus, + SmokeRun, + SmokeRunStatus, + SmokeRunStep, + SmokeRunStepPath, + SmokeRunStepStatus, + SmokeRunTrigger, +} from "./types/smoke-lab.js"; + +export { + SMOKE_RUN_STATUSES, + SMOKE_RUN_STEP_PATHS, + SMOKE_RUN_STEP_STATUSES, + SMOKE_RUN_TRIGGERS, +} from "./types/smoke-lab.js"; + export type { CloudUpstreamConnectStartResponse, CloudUpstreamActivationDecision, @@ -1140,6 +1339,13 @@ export { patchInstanceExperimentalSettingsSchema, patchInstanceSettingsSchema, issueGraphLivenessAutoRecoveryRequestSchema, + createSmokeRunSchema, + updateSmokeRunSchema, + recordSmokeRunStepSchema, + smokeRunStatusSchema, + smokeRunStepPathSchema, + smokeRunStepStatusSchema, + smokeRunTriggerSchema, trustPresetSchema, lowTrustBoundarySchema, lowTrustReviewPresetPolicySchema, @@ -1147,6 +1353,9 @@ export { type PatchInstanceExperimentalSettings, type PatchInstanceSettings, type IssueGraphLivenessAutoRecoveryRequest, + type CreateSmokeRun, + type UpdateSmokeRun, + type RecordSmokeRunStep, type TrustPresetInput, type LowTrustBoundaryInput, type TrustAuthorizationPolicyInput, @@ -1443,6 +1652,88 @@ export { rotateSecretSchema, secretBindingTargetSchema, updateSecretSchema, + createToolActionRequestSchema, + toolApplicationTypeSchema, + toolApplicationStatusSchema, + toolAuditEventTypeSchema, + toolAuditOutcomeSchema, + toolCatalogEntryKindSchema, + toolCatalogEntryStatusSchema, + toolConnectionHealthStatusSchema, + toolConnectionKindSchema, + toolConnectionTransportSchema, + toolConnectionStatusSchema, + toolCredentialPlacementSchema, + toolCredentialSecretRefSchema, + mcpConnectionCredentialRefSchema, + toolInvocationApprovalStateSchema, + toolInvocationStatusSchema, + toolMcpGatewayAuthConfigSchema, + toolMcpGatewayContextScopeTypeSchema, + toolMcpGatewayDefaultProfileModeSchema, + toolMcpGatewayHeaderPolicySchema, + toolMcpGatewayMetadataPolicySchema, + toolMcpGatewayOnDemandToolsConfigSchema, + toolMcpGatewayStatusSchema, + toolMcpGatewayTokenActionSchema, + toolMcpGatewayTokenSubjectTypeSchema, + toolPolicyDecisionSchema, + toolPolicyTypeSchema, + toolProfileBindingTargetTypeSchema, + toolProfileDefaultActionSchema, + toolProfileEntryEffectSchema, + toolProfileEntrySelectorTypeSchema, + toolProfileStatusSchema, + toolRateLimitWindowKindSchema, + toolRedactedValueSummarySchema, + toolRiskLevelSchema, + toolRuntimeKindSchema, + toolRuntimeSlotStatusSchema, + toolTransportConfigSchema, + toolAccessSelectorSchema, + toolPolicyConditionsSchema, + toolRateLimitRuleSchema, + toolTrustRuleArgumentFiltersSchema, + toolTrustRuleBatchApprovalSchema, + toolTrustRuleScopeSchema, + connectionTokenRequestSchema, + toolConnectionTestCallSchema, + toolPolicyTestRequestSchema, + importMcpJsonSchema, + createToolTrustRuleFromActionRequestSchema, + revokeToolTrustRuleSchema, + connectToolAppSchema, + reconnectToolAppSchema, + createToolApplicationSchema, + finishToolAppSchema, + updateToolApplicationSchema, + createToolConnectionSchema, + createToolMcpGatewaySchema, + createToolMcpGatewayTokenSchema, + createToolStdioCommandTemplateSchema, + disableToolStdioCommandTemplateSchema, + updateToolConnectionSchema, + putToolConnectionInstallsSchema, + updateToolMcpGatewaySchema, + createToolInvocationSchema, + createToolPolicySchema, + createToolProfileBindingForProfileSchema, + createToolProfileBindingSchema, + createToolProfileEntryForProfileSchema, + createToolProfileEntrySchema, + createToolProfileSchema, + createToolProfileWithEntriesSchema, + deleteToolProfileSchema, + duplicateToolPolicySchema, + duplicateToolProfileSchema, + reorderToolPoliciesSchema, + reviewToolProfileNewToolsSchema, + unbindToolProfileBindingSchema, + updateToolPolicySchema, + updateToolProfileEntrySchema, + updateToolProfileSchema, + updateToolProfileWithEntriesSchema, + upsertToolCatalogEntrySchema, createRoutineSchema, updateRoutineSchema, createRoutineTriggerSchema, @@ -1472,6 +1763,42 @@ export { type RemoteSecretImportSelection, type RotateSecret, type UpdateSecret, + type ConnectToolApp, + type ReconnectToolApp, + type CreateToolActionRequest, + type CreateToolApplication, + type FinishToolApp, + type UpdateToolApplication, + type CreateToolConnection, + type CreateToolMcpGateway, + type CreateToolMcpGatewayToken, + type CreateToolStdioCommandTemplate, + type DisableToolStdioCommandTemplate, + type UpdateToolConnection, + type PutToolConnectionInstalls, + type UpdateToolMcpGateway, + type ConnectionTokenRequestInput, + type ImportMcpJson, + type ToolPolicyTestRequestInput, + type CreateToolInvocation, + type CreateToolPolicy, + type DuplicateToolPolicy, + type CreateToolProfile, + type CreateToolProfileBinding, + type CreateToolProfileBindingForProfile, + type CreateToolProfileEntry, + type CreateToolProfileEntryForProfile, + type CreateToolProfileWithEntries, + type DeleteToolProfile, + type DuplicateToolProfile, + type ReviewToolProfileNewTools, + type ReorderToolPolicies, + type UpdateToolPolicy, + type UpdateToolProfileEntry, + type UpdateToolProfile, + type UpdateToolProfileWithEntries, + type UnbindToolProfileBinding, + type UpsertToolCatalogEntry, type CreateRoutine, type UpdateRoutine, type CreateRoutineTrigger, diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts index 5251dc0360..8b99c694d4 100644 --- a/packages/shared/src/issue-thread-interactions.test.ts +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -3,6 +3,8 @@ import { acceptIssueThreadInteractionSchema, askUserQuestionsResultSchema, createIssueThreadInteractionSchema, + requestConfirmationPayloadSchema, + requestConfirmationResultSchema, submitIssueThreadInteractionVerdictsSchema, } from "./validators/issue.js"; @@ -39,6 +41,49 @@ describe("issue thread interaction schemas", () => { }); }); + it("round-trips versioned tool action payload and lifecycle metadata", () => { + const payload = requestConfirmationPayloadSchema.parse({ + version: 1, + prompt: "Approve send_email?", + toolAction: { + version: 1, + actionRequestId: "11111111-1111-4111-8111-111111111111", + invocationId: "22222222-2222-4222-8222-222222222222", + toolName: "send_email", + toolDisplayName: "Send email", + connectionId: "33333333-3333-4333-8333-333333333333", + applicationId: "44444444-4444-4444-8444-444444444444", + appDisplayName: "Gmail", + risk: "write", + previewMarkdown: "Send an email to the reviewed recipient.", + argumentsSummaryJson: '{"to":"recipient@example.com"}', + argumentsHash: "reviewed-arguments-hash", + expiresAt: "2026-07-12T16:00:00.000Z", + }, + }); + const result = requestConfirmationResultSchema.parse({ + version: 1, + outcome: "accepted", + toolAction: { + version: 1, + status: "executed", + errorCode: null, + errorMessage: null, + updatedAt: "2026-07-12T15:05:00.000Z", + }, + }); + + expect(payload.toolAction).toMatchObject({ + version: 1, + toolDisplayName: "Send email", + risk: "write", + argumentsHash: "reviewed-arguments-hash", + }); + expect(result.toolAction).toMatchObject({ version: 1, status: "executed" }); + expect(requestConfirmationPayloadSchema.parse({ version: 1, prompt: "Legacy confirmation?" }).toolAction) + .toBeUndefined(); + }); + it("accepts issue document targets for request_confirmation interactions", () => { const parsed = createIssueThreadInteractionSchema.parse({ kind: "request_confirmation", diff --git a/packages/shared/src/tool-app-gallery.test.ts b/packages/shared/src/tool-app-gallery.test.ts new file mode 100644 index 0000000000..2131806653 --- /dev/null +++ b/packages/shared/src/tool-app-gallery.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + getToolAppGalleryEntryForUrl, + TOOL_APP_GALLERY, +} from "./tool-app-gallery.js"; + +describe("tool app gallery URL matching", () => { + it("matches pasted links against gallery URL patterns", () => { + expect(getToolAppGalleryEntryForUrl("https://mcp.zapier.com/api/mcp")?.key).toBe("zapier"); + expect(getToolAppGalleryEntryForUrl("https://api.githubcopilot.com/mcp/")?.key).toBe("github"); + expect(getToolAppGalleryEntryForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.key).toBe("google-sheets"); + }); + + it("returns null for invalid or unknown links", () => { + expect(getToolAppGalleryEntryForUrl("not a url")).toBeNull(); + expect(getToolAppGalleryEntryForUrl("https://example.com/mcp")).toBeNull(); + expect(getToolAppGalleryEntryForUrl("https://docs.googleapis.com/drive/v3/files")).toBeNull(); + }); + + it("does not list Google Drive until its OAuth client flow is supported", () => { + expect(TOOL_APP_GALLERY.map((entry) => entry.key)).not.toContain("google-drive"); + expect(getToolAppGalleryEntryForUrl("https://mcp.google.com/drive")).toBeNull(); + }); + + it("keeps every gallery entry reachable through at least one pattern", () => { + for (const entry of TOOL_APP_GALLERY) { + const example = entry.urlPatterns[0]?.replace("*", "example"); + expect(example, `${entry.key} has a pattern`).toBeTruthy(); + expect(getToolAppGalleryEntryForUrl(example!)?.key).toBe(entry.key); + } + }); +}); diff --git a/packages/shared/src/tool-app-gallery.ts b/packages/shared/src/tool-app-gallery.ts new file mode 100644 index 0000000000..6caf86fa4c --- /dev/null +++ b/packages/shared/src/tool-app-gallery.ts @@ -0,0 +1,243 @@ +import type { ToolConnectionTransport } from "./types/tool-access.js"; + +export type AppGalleryAuthKind = "oauth" | "api_key" | "none"; + +export interface AppGalleryCredentialField { + label: string; + configPath: string; + helpUrl: string; + required?: boolean; + placement?: "header" | "env"; + key?: string; + prefix?: string | null; +} + +export type AppGalleryTransportTemplate = + | { + transport: Extract; + url: string; + } + | { + transport: Extract; + templateKey: string; + }; + +export interface AppGalleryEntry { + key: string; + name: string; + logoUrl: string; + tagline: string; + description?: string; + authKind: AppGalleryAuthKind; + transportTemplate: AppGalleryTransportTemplate; + credentialFields: AppGalleryCredentialField[]; + recommendedDefaults: Record; + urlPatterns: string[]; + availability?: { + available: boolean; + reason?: string | null; + robotEmail?: string | null; + }; + oauth?: { + provider: string; + scopes: string[]; + tokenUrl?: string | null; + metadataUrl?: string | null; + authorizationUrl?: string | null; + }; +} + +const favicon = (domain: string) => `https://www.google.com/s2/favicons?domain=${domain}&sz=64`; + +export const TOOL_APP_GALLERY = [ + { + key: "zapier", + name: "Zapier", + logoUrl: favicon("zapier.com"), + tagline: "Connect Zapier-hosted actions to your Paperclip agents.", + description: "Let agents use Zapier automations across the apps your business already runs. Good for handoffs, lightweight operations, and cross-app updates that should stay visible in Paperclip.", + authKind: "api_key", + transportTemplate: { + transport: "remote_http", + url: "https://mcp.zapier.com/api/mcp", + }, + credentialFields: [ + { + label: "Zapier MCP token", + configPath: "credentials.authorization", + helpUrl: "https://zapier.com/app/settings/authorizations", + required: true, + placement: "header", + key: "Authorization", + prefix: "Bearer ", + }, + ], + recommendedDefaults: { + access: "all_agents", + askFirstRiskLevels: ["write", "destructive"], + }, + urlPatterns: ["https://mcp.zapier.com/*"], + }, + { + key: "github", + name: "GitHub", + logoUrl: favicon("github.com"), + tagline: "Read and manage GitHub issues and pull requests.", + description: "Give agents a governed way to inspect repositories, issues, and pull requests. Useful when engineering work needs GitHub context or small updates without leaving Paperclip.", + authKind: "api_key", + transportTemplate: { + transport: "remote_http", + url: "https://api.githubcopilot.com/mcp/", + }, + credentialFields: [ + { + label: "GitHub token", + configPath: "credentials.authorization", + helpUrl: "https://github.com/settings/tokens", + required: true, + placement: "header", + key: "Authorization", + prefix: "Bearer ", + }, + ], + recommendedDefaults: { + access: "all_agents", + askFirstRiskLevels: ["write", "destructive"], + }, + urlPatterns: ["https://api.githubcopilot.com/mcp/*"], + }, + { + key: "slack", + name: "Slack", + logoUrl: favicon("slack.com"), + tagline: "Search channels and coordinate Slack actions.", + description: "Let agents search workspace conversations and coordinate in Slack when work needs team context. Message-sending actions can still ask a human first.", + authKind: "oauth", + transportTemplate: { + transport: "remote_http", + url: "https://mcp.slack.com/mcp", + }, + credentialFields: [], + recommendedDefaults: { + access: "all_agents", + askFirstRiskLevels: ["write", "destructive"], + }, + urlPatterns: ["https://mcp.slack.com/*"], + oauth: { + provider: "slack", + scopes: ["channels:read", "chat:write", "search:read"], + authorizationUrl: "https://slack.com/oauth/v2/authorize", + tokenUrl: "https://slack.com/api/oauth.v2.access", + }, + }, + { + key: "notion", + name: "Notion", + logoUrl: favicon("notion.so"), + tagline: "Search and update Notion workspace content.", + description: "Connect Notion so agents can find docs, read project notes, and update workspace pages. Use it for company memory that lives outside Paperclip.", + authKind: "oauth", + transportTemplate: { + transport: "remote_http", + url: "https://mcp.notion.com/mcp", + }, + credentialFields: [], + recommendedDefaults: { + access: "all_agents", + askFirstRiskLevels: ["write", "destructive"], + }, + urlPatterns: ["https://mcp.notion.com/*"], + oauth: { + provider: "notion", + scopes: ["read_content", "update_content"], + authorizationUrl: "https://api.notion.com/v1/oauth/authorize", + tokenUrl: "https://api.notion.com/v1/oauth/token", + }, + }, + { + key: "linear", + name: "Linear", + logoUrl: favicon("linear.app"), + tagline: "Read and update Linear issues from agent workflows.", + description: "Let agents look up Linear work and make issue updates when their Paperclip tasks depend on your existing product queue.", + authKind: "oauth", + transportTemplate: { + transport: "remote_http", + url: "https://mcp.linear.app/mcp", + }, + credentialFields: [], + recommendedDefaults: { + access: "all_agents", + askFirstRiskLevels: ["write", "destructive"], + }, + urlPatterns: ["https://mcp.linear.app/*"], + oauth: { + provider: "linear", + scopes: ["read", "write"], + authorizationUrl: "https://linear.app/oauth/authorize", + tokenUrl: "https://api.linear.app/oauth/token", + }, + }, + { + key: "google-sheets", + name: "Google Sheets", + logoUrl: favicon("sheets.google.com"), + tagline: "Read and update selected spreadsheets.", + description: "Let agents read and update only the spreadsheets you choose. Share each sheet with the robot email, then paste the sheet links here.", + authKind: "none", + transportTemplate: { + transport: "local_stdio", + templateKey: "paperclip.google-sheets", + }, + credentialFields: [], + recommendedDefaults: { + access: "all_agents", + askFirstRiskLevels: ["write", "destructive"], + }, + urlPatterns: ["https://docs.google.com/spreadsheets/*", "https://sheets.google.com/*"], + }, + { + key: "context7", + name: "Context7", + logoUrl: favicon("context7.com"), + tagline: "Fetch up-to-date library documentation with Context7.", + description: "Let agents pull current library documentation while they work. It is a low-risk reference app for coding and research tasks.", + authKind: "none", + transportTemplate: { + transport: "remote_http", + url: "https://mcp.context7.com/mcp", + }, + credentialFields: [], + recommendedDefaults: { + access: "all_agents", + askFirstRiskLevels: [], + }, + urlPatterns: ["https://mcp.context7.com/*"], + }, +] satisfies AppGalleryEntry[]; + +export type AppGalleryKey = (typeof TOOL_APP_GALLERY)[number]["key"]; + +export function getToolAppGalleryEntry(key: string): AppGalleryEntry | null { + return TOOL_APP_GALLERY.find((entry) => entry.key === key) ?? null; +} + +function wildcardPatternToRegExp(pattern: string): RegExp { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`, "i"); +} + +export function getToolAppGalleryEntryForUrl( + link: string, + entries: readonly AppGalleryEntry[] = TOOL_APP_GALLERY, +): AppGalleryEntry | null { + let normalized: string; + try { + normalized = new URL(link.trim()).toString(); + } catch { + return null; + } + return entries.find((entry) => + entry.urlPatterns.some((pattern) => wildcardPatternToRegExp(pattern).test(normalized)) + ) ?? null; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index e117d04b5b..d7aab5b78b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -48,6 +48,21 @@ export type { IssueGraphLivenessAutoRecoveryPreview, IssueGraphLivenessAutoRecoveryPreviewItem, } from "./instance.js"; +export type { + SmokeLabServiceStatus, + SmokeRun, + SmokeRunStatus, + SmokeRunStep, + SmokeRunStepPath, + SmokeRunStepStatus, + SmokeRunTrigger, +} from "./smoke-lab.js"; +export { + SMOKE_RUN_STATUSES, + SMOKE_RUN_STEP_PATHS, + SMOKE_RUN_STEP_STATUSES, + SMOKE_RUN_TRIGGERS, +} from "./smoke-lab.js"; export { DAILY_RETENTION_PRESETS, WEEKLY_RETENTION_PRESETS, @@ -304,6 +319,133 @@ export type { WorkspaceFileWorkspaceKind, ResolvedWorkspaceResource, } from "./workspace-file-resource.js"; +export type { + ToolActionRequest, + ToolAccessDecision, + ToolAccessDecisionInput, + ToolAccessReasonCode, + ToolAccessSelector, + ConnectToolAppResult, + FinishToolAppResult, + ToolOAuthStartResult, + ToolTrustRuleArgumentFilters, + ToolTrustRuleBatchApprovalConfig, + ToolTrustRuleScopeInput, + ToolActionRequestStatus, + ToolApplication, + ToolApplicationStatus, + ToolApplicationType, + ToolAuditEventType, + ToolAuditOutcome, + ToolCallEvent, + ToolCatalogEntry, + ToolCatalogEntryKind, + ToolCatalogEntryStatus, + ToolAppAttentionItem, + ToolAppAttentionReason, + ToolAppsAttentionResponse, + ToolActionRequestListItem, + ToolActionRequestsResponse, + ToolConnectionActivityResponse, + ToolConnectionLifecycleEvent, + ToolConnectionLifecycleEventType, + ToolConnectionInstall, + ToolConnectionInstallSnapshot, + ToolConnectionInstallTargetType, + ConnectionTokenAttribution, + ConnectionTokenIssuance, + ConnectionTokenMintedResponse, + ConnectionTokenRequest, + ConnectionTokenResponse, + ConnectionTokenScope, + ConnectionTokenUseEnvLeaseResponse, + ToolConnection, + ToolConnectionHealthStatus, + ToolConnectionTransport, + ToolConnectionStatus, + ToolConnectionKind, + ToolCredentialSecretRef, + ToolInvocation, + ToolInvocationApprovalState, + ToolInvocationStatus, + ToolPolicy, + ToolPolicyConditions, + ToolPolicyDecision, + ToolPolicyType, + ToolMcpGateway, + ToolMcpGatewayAuthConfig, + ToolMcpGatewayBearerAuthConfig, + ToolMcpGatewayClientSnippet, + ToolMcpGatewayContextScopeType, + ToolMcpGatewayDefaultProfileMode, + ToolMcpGatewayCallerHeaderPolicy, + ToolMcpGatewayGeneratedMetadataHeaderPolicy, + ToolMcpGatewayHeaderPolicy, + ToolMcpGatewayMetadataPolicy, + ToolMcpGatewayOAuthReservedConfig, + ToolMcpGatewayOnDemandToolsConfig, + ToolMcpGatewayResponseHeaderPolicy, + ToolMcpGatewayStaticHeaderPolicy, + ToolMcpGatewayStatus, + ToolMcpGatewayToken, + ToolMcpGatewayTokenAction, + ToolMcpGatewayTokenCreated, + ToolMcpGatewayTokenSubjectType, + ToolMcpGatewayWithTokens, + ToolProfile, + ToolProfileBinding, + ToolProfileBindingTargetType, + ToolProfileDefaultAction, + ToolProfileEntry, + ToolProfileEntryEffect, + ToolProfileEntrySelectorType, + ToolProfileEffectiveSummary, + ToolProfileNewToolReviewDecision, + ToolProfileNewToolReviewItem, + ToolProfileNewToolsReview, + ToolProfileNewToolsReviewResult, + ToolProfileSummary, + ToolProfileStatus, + ToolProfileWithDetails, + ToolRateLimitCounter, + ToolRateLimitRule, + ToolRateLimitWindowKind, + ToolRedactedValueSummary, + ToolRiskLevel, + ToolRunDecision, + ToolRunDecisionLookup, + ToolRuntimeKind, + ToolRuntimeHealthSummary, + ToolRuntimeMetricSnapshot, + ToolRuntimeAlertRecommendation, + ToolRuntimeAlertSeverity, + ToolRuntimeAlertStatus, + ToolRuntimeSupportMatrix, + ToolRuntimeSlot, + ToolRuntimeSlotStatus, + ToolStdioCommandTemplate, + ToolStdioTemplateToolSummary, + ToolAppConnectionActionSummary, + McpConnectionCredentialRef, + ToolConnectionHealthCheckResult, + ToolCatalogRefreshResult, + ToolExampleInstallResult, + ToolExampleSmokeCheck, + ToolExampleSmokeResult, + ToolExampleSummary, + McpJsonImportDraft, + McpJsonImportPreview, + CreateToolTrustRuleFromActionRequest, + RevokeToolTrustRule, + ToolConnectionTestDecision, + ToolConnectionTestToolAccess, + ToolConnectionAccessSummary, + ToolConnectionTestAgent, + ToolConnectionTestAgentsResponse, + ToolConnectionTestCallResult, + ToolConnectionTestCallStatus, + ToolConnectionTestCallStatusPhase, +} from "./tool-access.js"; export type { IssueWorkProduct, IssueWorkProductType, @@ -411,6 +553,8 @@ export type { RequestConfirmationTarget, RequestConfirmationPayload, RequestConfirmationResult, + RequestConfirmationToolActionPayload, + RequestConfirmationToolActionResult, RequestCheckboxConfirmationOption, RequestCheckboxConfirmationPayload, RequestCheckboxConfirmationResult, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 655626cfd9..4e608a5a96 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -48,6 +48,7 @@ export interface InstanceExperimentalSettings { enableEnvironments: boolean; enableIsolatedWorkspaces: boolean; enableStreamlinedLeftNavigation: boolean; + enableApps: boolean; enablePipelines: boolean; enableCases: boolean; enableConferenceRoomChat: boolean; @@ -56,6 +57,7 @@ export interface InstanceExperimentalSettings { enableExperimentalFileViewer: boolean; enableCloudSync: boolean; enableExternalObjects: boolean; + enableSmokeLab: boolean; enableBuiltInAgents: boolean; enableDecisions: boolean; enableGoalsSidebarLink: boolean; diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index d9c3324a47..d7163dbb7d 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1044,6 +1044,43 @@ export type RequestConfirmationTarget = | RequestConfirmationIssueDocumentTarget | RequestConfirmationCustomTarget; +/** + * Enrichment block carried on a `request_confirmation` interaction when it gates + * a write/destructive MCP tool call (PAP-13726 §D1). Its presence flips the feed + * card into the dedicated tool-approval rendering (PAP-13745). Arguments are + * redacted server-side before this reaches the client. + */ +export interface RequestConfirmationToolActionPayload { + version: 1; + actionRequestId: string; + invocationId: string; + toolName: string; + toolDisplayName: string; + connectionId: string | null; + applicationId: string | null; + appDisplayName: string | null; + risk: "write" | "destructive"; + previewMarkdown: string; + argumentsSummaryJson: string; + argumentsHash: string; + expiresAt: string; +} + +/** + * Lifecycle status written back onto the resolved interaction once the operator + * approves. `approve = run`, so the terminal states are executed/failed/expired — + * never a bare "accepted". + */ +export interface RequestConfirmationToolActionResult { + version: 1; + status: "approved" | "executing" | "executed" | "failed" | "expired"; + errorCode?: string | null; + errorMessage?: string | null; + resultSummary?: string | null; + resultHref?: string | null; + updatedAt: string; +} + export interface RequestConfirmationPayload { version: 1; prompt: string; @@ -1056,6 +1093,7 @@ export interface RequestConfirmationPayload { detailsMarkdown?: string | null; supersedeOnUserComment?: boolean; target?: RequestConfirmationTarget | null; + toolAction?: RequestConfirmationToolActionPayload; } export interface RequestCheckboxConfirmationOption { @@ -1122,6 +1160,7 @@ export interface RequestConfirmationResult { recoveryActionId?: string | null; updatedAt?: string | null; } | null; + toolAction?: RequestConfirmationToolActionResult; } export interface RequestCheckboxConfirmationResult extends RequestConfirmationResult { diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index be0fb0051c..0c090d7acf 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -740,15 +740,17 @@ export interface PluginStateRecord { // --------------------------------------------------------------------------- /** - * Domain type for a plugin's instance configuration as persisted in the + * Domain type for a plugin's company-scoped configuration as persisted in the * `plugin_config` table. * See PLUGIN_SPEC.md §21.3 for the schema definition. */ export interface PluginConfig { /** UUID primary key. */ id: string; - /** FK to `plugins.id`. Unique — each plugin has at most one config row. */ + /** FK to `plugins.id`. Unique together with `companyId`. */ pluginId: string; + /** FK to `companies.id`. */ + companyId: string; /** Operator-provided configuration values (validated against `instanceConfigSchema`). */ configJson: Record; /** Most recent config validation error, if any. */ diff --git a/packages/shared/src/types/secrets.ts b/packages/shared/src/types/secrets.ts index 742e7acd83..03fcbd77d6 100644 --- a/packages/shared/src/types/secrets.ts +++ b/packages/shared/src/types/secrets.ts @@ -5,6 +5,7 @@ import type { SecretProvider, SecretProviderConfigHealthStatus, SecretProviderConfigStatus, + SecretProjectionClass, SecretScope, SecretStatus, SecretVersionStatus, @@ -17,6 +18,7 @@ export type { SecretProvider, SecretProviderConfigHealthStatus, SecretProviderConfigStatus, + SecretProjectionClass, SecretScope, SecretStatus, SecretVersionStatus, @@ -33,6 +35,8 @@ export interface EnvSecretRefBinding { type: "secret_ref"; secretId: string; version?: SecretVersionSelector; + projectionClass?: SecretProjectionClass; + projectionAllowlistKey?: string | null; } export interface EnvUserSecretRefBinding { @@ -254,6 +258,8 @@ export interface CompanySecretBinding { versionSelector: SecretVersionSelector; required: boolean; label: string | null; + projectionClass: SecretProjectionClass; + projectionAllowlistKey: string | null; createdAt: Date; updatedAt: Date; } @@ -284,7 +290,7 @@ export interface SecretAccessEvent { credentialSubjectId: string | null; actorType: "agent" | "user" | "system" | "plugin"; actorId: string | null; - consumerType: SecretBindingTargetType; + consumerType: SecretBindingTargetType | "plugin_worker"; consumerId: string; configPath: string | null; issueId: string | null; diff --git a/packages/shared/src/types/smoke-lab.ts b/packages/shared/src/types/smoke-lab.ts new file mode 100644 index 0000000000..534b1a1ef3 --- /dev/null +++ b/packages/shared/src/types/smoke-lab.ts @@ -0,0 +1,46 @@ +export const SMOKE_RUN_TRIGGERS = ["manual", "routine", "ci"] as const; +export type SmokeRunTrigger = (typeof SMOKE_RUN_TRIGGERS)[number]; + +export const SMOKE_RUN_STATUSES = ["running", "passed", "failed", "cancelled"] as const; +export type SmokeRunStatus = (typeof SMOKE_RUN_STATUSES)[number]; + +export const SMOKE_RUN_STEP_PATHS = ["P1", "P2", "P3", "P4", "P5", "P6", "P7"] as const; +export type SmokeRunStepPath = (typeof SMOKE_RUN_STEP_PATHS)[number]; + +export const SMOKE_RUN_STEP_STATUSES = ["pass", "fail", "skipped"] as const; +export type SmokeRunStepStatus = (typeof SMOKE_RUN_STEP_STATUSES)[number]; + +export interface SmokeRun { + id: string; + companyId: string; + trigger: SmokeRunTrigger; + status: SmokeRunStatus; + startedAt: Date | string; + finishedAt: Date | string | null; + summary: Record; + createdAt: Date | string; + updatedAt: Date | string; +} + +export interface SmokeRunStep { + id: string; + companyId: string; + runId: string; + path: SmokeRunStepPath; + scenarioStep: string; + status: SmokeRunStepStatus; + detail: string | null; + screenshotArtifactRef: Record | null; + durationMs: number | null; + createdAt: Date | string; + updatedAt: Date | string; +} + +export interface SmokeLabServiceStatus { + id: "fake-oauth" | "http-mcp-fixture"; + label: string; + status: "stopped" | "running" | "error"; + url: string | null; + health: Record | null; + detail: string | null; +} diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts new file mode 100644 index 0000000000..72f33259cd --- /dev/null +++ b/packages/shared/src/types/tool-access.ts @@ -0,0 +1,1318 @@ +import type { + ConnectionTokenIssuanceOutcome, + ConnectionTokenIssuancePath, + SecretProjectionClass, + ToolActionRequestStatus, + ToolApplicationStatus, + ToolApplicationType, + ToolAuditEventType, + ToolAuditOutcome, + ToolCatalogEntryKind, + ToolCatalogEntryStatus, + ToolConnectionHealthStatus, + ToolConnectionKind, + ToolConnectionLifecycleEventType, + ToolInvocationApprovalState, + ToolInvocationStatus, + ToolMcpGatewayContextScopeType, + ToolMcpGatewayDefaultProfileMode, + ToolMcpGatewayStatus, + ToolMcpGatewayTokenAction, + ToolMcpGatewayTokenSubjectType, + ToolPolicyDecision, + ToolPolicyType, + ToolProfileBindingTargetType, + ToolProfileDefaultAction, + ToolProfileEntryEffect, + ToolProfileEntrySelectorType, + ToolProfileStatus, + ToolRateLimitWindowKind, + ToolRiskLevel, + ToolRuntimeKind, + ToolRuntimeSlotStatus, +} from "../constants.js"; + +export type { + ConnectionTokenIssuanceOutcome, + ConnectionTokenIssuancePath, + SecretProjectionClass, + ToolActionRequestStatus, + ToolApplicationStatus, + ToolApplicationType, + ToolAuditEventType, + ToolAuditOutcome, + ToolCatalogEntryKind, + ToolCatalogEntryStatus, + ToolConnectionHealthStatus, + ToolConnectionKind, + ToolConnectionLifecycleEventType, + ToolInvocationApprovalState, + ToolInvocationStatus, + ToolMcpGatewayContextScopeType, + ToolMcpGatewayDefaultProfileMode, + ToolMcpGatewayStatus, + ToolMcpGatewayTokenAction, + ToolMcpGatewayTokenSubjectType, + ToolPolicyDecision, + ToolPolicyType, + ToolProfileBindingTargetType, + ToolProfileDefaultAction, + ToolProfileEntryEffect, + ToolProfileEntrySelectorType, + ToolProfileStatus, + ToolRateLimitWindowKind, + ToolRiskLevel, + ToolRuntimeKind, + ToolRuntimeSlotStatus, +}; + +export type ToolActorType = "agent" | "user" | "system" | "plugin"; +export type ToolConnectionTransport = "remote_http" | "local_stdio"; +export type ToolConnectionStatus = "draft" | "active" | "disabled" | "archived"; +export type ToolConnectionInstallTargetType = "company" | "agent"; +export type ToolCredentialPlacement = "header" | "env"; + +export interface McpConnectionCredentialRef { + name: string; + secretId: string; + version?: number | "latest"; + placement: ToolCredentialPlacement; + key: string; + prefix?: string | null; +} + +export interface ToolCredentialSecretRef { + secretId: string; + versionSelector?: number | "latest"; + configPath: string; + required?: boolean; + label?: string | null; + projectionClass?: SecretProjectionClass; + projectionAllowlistKey?: string | null; +} + +export interface ToolRedactedValueSummary { + summary: string; + sizeBytes?: number | null; + sha256?: string | null; + redactedFields?: string[]; + artifactId?: string | null; +} + +export interface ToolApplication { + id: string; + companyId: string; + applicationKey?: string; + name: string; + description: string | null; + type: ToolApplicationType; + status: ToolApplicationStatus; + pluginId: string | null; + ownerAgentId: string | null; + ownerUserId: string | null; + metadata: Record | null; + archivedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface ToolConnection { + id: string; + companyId: string; + applicationId: string; + name: string; + connectionKind: ToolConnectionKind; + transport?: ToolConnectionTransport; + status?: ToolConnectionStatus; + transportConfig: Record; + config?: Record; + credentialSecretRefs: ToolCredentialSecretRef[]; + credentialRefs?: McpConnectionCredentialRef[]; + healthStatus: ToolConnectionHealthStatus; + healthMessage?: string | null; + healthCheckedAt: Date | null; + lastHealthAt?: Date | string | null; + lastCatalogRefreshAt?: Date | string | null; + lastError: string | null; + /** Most recent tool-call event timestamp for this connection; only populated by list endpoints. */ + lastUsedAt?: Date | string | null; + enabled: boolean; + createdByAgentId: string | null; + createdByUserId: string | null; + createdAt: Date; + updatedAt: Date; + installs?: ToolConnectionInstall[]; +} + +export interface ToolConnectionInstall { + id: string; + companyId: string; + connectionId: string; + targetType: ToolConnectionInstallTargetType; + targetId: string; + createdByAgentId: string | null; + createdByUserId: string | null; + createdAt: Date; +} + +export interface ToolConnectionInstallSnapshot { + connectionId: string; + installs: ToolConnectionInstall[]; +} + +export type ConnectionTokenScope = string | string[]; + +export interface ConnectionTokenRequest { + scope?: ConnectionTokenScope; + requestedTtlSeconds?: number; +} + +export interface ConnectionTokenAttribution { + agentId: string; + runId: string; + issueId: string | null; + projectId: string | null; + responsibleUserId: string | null; +} + +export interface ConnectionTokenMintedResponse { + status: "minted"; + connectionId: string; + path: "exchange"; + token: string; + tokenType: "Bearer" | string; + expiresAt: string; + ttlSeconds: number; + scope: string[]; + attribution: ConnectionTokenAttribution; +} + +export interface ConnectionTokenUseEnvLeaseResponse { + status: "use_env_lease"; + code: "use_env_lease"; + connectionId: string; + path: "static"; + message: string; + scope: string[]; + attribution: ConnectionTokenAttribution; +} + +export type ConnectionTokenResponse = ConnectionTokenMintedResponse | ConnectionTokenUseEnvLeaseResponse; + +export interface ConnectionTokenIssuance { + id: string; + companyId: string; + applicationId: string | null; + connectionId: string; + agentId: string; + runId: string | null; + issueId: string | null; + projectId: string | null; + responsibleUserId: string | null; + path: ConnectionTokenIssuancePath; + requestedScope: string[]; + issuedScope: string[]; + ttlSeconds: number | null; + expiresAt: Date | null; + tokenHash: string | null; + outcome: ConnectionTokenIssuanceOutcome; + errorCode: string | null; + metadata: Record | null; + createdAt: Date; +} + +export interface ToolCatalogEntry { + id: string; + companyId: string; + applicationId: string | null; + connectionId: string; + entryKind: ToolCatalogEntryKind; + name?: string; + toolName: string; + title: string | null; + description: string | null; + inputSchema: Record | null; + outputSchema: Record | null; + annotations: Record | null; + riskLevel: ToolRiskLevel; + isReadOnly: boolean; + isWrite: boolean; + isDestructive: boolean; + status: ToolCatalogEntryStatus; + addedAt: Date; + version: string | null; + versionHash?: string | null; + schemaHash: string | null; + firstSeenAt: Date; + lastSeenAt: Date; + reviewedAt: Date | null; + reviewedByAgentId: string | null; + reviewedByUserId: string | null; + quarantinedAt?: Date | string | null; + quarantineReason?: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface ToolProfile { + id: string; + companyId: string; + profileKey: string; + name: string; + description: string | null; + status: ToolProfileStatus; + defaultAction: ToolProfileDefaultAction; + newToolsReviewedAt: Date | null; + newToolsPendingCount?: number; + metadata: Record | null; + createdAt: Date; + updatedAt: Date; +} + +export interface ToolProfileEntry { + id: string; + companyId: string; + profileId: string; + selectorType: ToolProfileEntrySelectorType; + effect: ToolProfileEntryEffect; + applicationId: string | null; + connectionId: string | null; + catalogEntryId: string | null; + toolName: string | null; + riskLevel: ToolRiskLevel | null; + conditions: Record | null; + createdAt: Date; + updatedAt: Date; +} + +export interface ToolProfileBinding { + id: string; + companyId: string; + profileId: string; + targetType: ToolProfileBindingTargetType; + targetId: string; + priority: number; + metadata: Record | null; + createdByAgentId: string | null; + createdByUserId: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface ToolMcpGatewayBearerAuthConfig { + enabled: boolean; + tokenPrefix: "pcgw"; + defaultTtlSeconds: number | null; + requireFiniteExpiry: boolean; + longLivedTokenRequiresOverride: boolean; +} + +export interface ToolMcpGatewayOAuthReservedConfig { + enabled: false; + reservedFor: "v1_5"; + protectedResourceMetadataPath?: string | null; + dynamicClientRegistration?: false; + authorizationCodePkce?: false; +} + +export interface ToolMcpGatewayAuthConfig { + version: 1; + bearer: ToolMcpGatewayBearerAuthConfig; + oauth: ToolMcpGatewayOAuthReservedConfig; +} + +export interface ToolMcpGatewayStaticHeaderPolicy { + name: string; + valueRef?: string | null; + value?: string | null; +} + +export interface ToolMcpGatewayCallerHeaderPolicy { + enabled: boolean; + allowedHeaders: string[]; +} + +export interface ToolMcpGatewayGeneratedMetadataHeaderPolicy { + enabled: boolean; + allowedHeaders: string[]; +} + +export interface ToolMcpGatewayResponseHeaderPolicy { + forwardMcpRequiredHeaders: boolean; + forwardSafeCacheHeaders: boolean; +} + +export interface ToolMcpGatewayHeaderPolicy { + version: 1; + callerPassthrough: ToolMcpGatewayCallerHeaderPolicy; + staticHeaders: ToolMcpGatewayStaticHeaderPolicy[]; + generatedMetadata: ToolMcpGatewayGeneratedMetadataHeaderPolicy; + responseHeaders: ToolMcpGatewayResponseHeaderPolicy; +} + +export interface ToolMcpGatewayMetadataPolicy { + version: 1; + forwardCompanyId: boolean; + forwardGatewayId: boolean; + forwardProjectId: boolean; + forwardIssueId: boolean; + forwardAgentId: boolean; + forwardRunId: boolean; + forwardCorrelationId: boolean; +} + +export interface ToolMcpGatewayOnDemandToolsConfig { + enabled: boolean; + searchToolName: "search_tools"; + runToolName: "run_tool"; +} + +export interface ToolMcpGateway { + id: string; + companyId: string; + gatewayPublicId: string; + name: string; + displaySlug: string; + /** @deprecated Use displaySlug for UI labels and gatewayPublicId for protocol URLs. */ + slug: string; + description: string | null; + status: ToolMcpGatewayStatus; + profileId: string; + defaultProfileMode: ToolMcpGatewayDefaultProfileMode; + contextScopeType: ToolMcpGatewayContextScopeType; + contextScopeId: string | null; + agentId: string | null; + projectId: string | null; + issueId: string | null; + approvalIssueId: string | null; + endpointPath: string; + authConfig: ToolMcpGatewayAuthConfig; + headerPolicy: ToolMcpGatewayHeaderPolicy; + metadataPolicy: ToolMcpGatewayMetadataPolicy; + onDemandToolsConfig: ToolMcpGatewayOnDemandToolsConfig; + metadata: Record | null; + createdByAgentId: string | null; + createdByUserId: string | null; + archivedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface ToolMcpGatewayToken { + id: string; + companyId: string; + gatewayId: string; + name: string; + tokenPrefix: string; + subjectType: ToolMcpGatewayTokenSubjectType; + subjectId: string | null; + clientLabel: string; + ownerNote: string; + allowedActions: ToolMcpGatewayTokenAction[]; + expiresAt: Date | string | null; + expiryOverrideReason: string | null; + expiryOverrideByUserId: string | null; + expiryOverrideByAgentId: string | null; + expiryOverrideAt: Date | string | null; + lastUsedAt: Date | string | null; + revokedAt: Date | string | null; + createdByAgentId: string | null; + createdByUserId: string | null; + createdAt: Date | string; + updatedAt: Date | string; +} + +export interface ToolMcpGatewayTokenCreated extends ToolMcpGatewayToken { + token: string; +} + +export interface ToolMcpGatewayClientSnippet { + client: "cursor" | "claude_desktop" | "vscode" | "claude_code" | "opencode"; + label: string; + config: Record; + notes: string[]; +} + +export interface ToolMcpGatewayWithTokens extends ToolMcpGateway { + tokens: ToolMcpGatewayToken[]; + clientSnippets: ToolMcpGatewayClientSnippet[]; +} + +export interface ToolProfileSummary { + accessMode: "selected" | "all_except"; + allowedToolCount: number; + allowedApplicationCount: number; + excludedToolCount: number; + totalToolCount: number; + assignmentCount: number; + appliesToAgentCount: number; + isCompanyDefault: boolean; +} + +export interface ToolProfileWithDetails extends ToolProfile { + entries: ToolProfileEntry[]; + bindings: ToolProfileBinding[]; + summary: ToolProfileSummary; +} + +export type ToolProfileNewToolReviewDecision = "allow" | "keep_blocked"; + +export interface ToolProfileNewToolReviewItem { + catalogEntryId: string; + applicationId: string | null; + applicationName: string | null; + connectionId: string; + connectionName: string | null; + toolName: string; + title: string | null; + description: string | null; + capability: ToolRiskLevel; + riskLevel: ToolRiskLevel; + addedAt: Date; + firstSeenAt: Date; +} + +export interface ToolProfileNewToolsReview { + profileId: string; + reviewedAt: Date | null; + pendingCount: number; + tools: ToolProfileNewToolReviewItem[]; +} + +export interface ToolProfileNewToolsReviewResult { + profile: ToolProfileWithDetails; + reviewedAt: Date; + allowedCount: number; + keptBlockedCount: number; + entriesCreated: ToolProfileEntry[]; + reviewedCatalogEntryIds: string[]; +} + +export interface ToolProfileEffectiveSummary { + agentId: string; + profiles: ToolProfileWithDetails[]; + entries: ToolProfileEntry[]; + bindings: ToolProfileBinding[]; + allowedTools: ToolCatalogEntry[]; + allowedToolNames: string[]; + installedConnections: ToolConnection[]; +} + +export interface ToolPolicy { + id: string; + companyId: string; + name: string; + description: string | null; + policyType: ToolPolicyType; + priority: number; + enabled: boolean; + selectors: Record; + conditions: Record | null; + config: Record | null; + createdByAgentId: string | null; + createdByUserId: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface ToolRuntimeSlot { + id: string; + companyId: string; + applicationId: string | null; + connectionId: string | null; + projectWorkspaceId: string | null; + executionWorkspaceId: string | null; + issueId: string | null; + ownerScopeType: string; + ownerScopeId: string | null; + runtimeKind: ToolRuntimeKind; + slotKey?: string; + status: ToolRuntimeSlotStatus; + reuseKey: string | null; + workspaceScope: string | null; + credentialScopeHash: string | null; + provider: string | null; + providerRef: string | null; + processId: number | null; + commandTemplateKey: string | null; + healthStatus: string | null; + healthMessage?: string | null; + lastHealthCheckAt: Date | null; + lastStartedAt?: Date | string | null; + idleExpiresAt: Date | null; + idleDeadlineAt?: Date | string | null; + startedAt: Date | null; + stoppedAt: Date | null; + lastUsedAt: Date | null; + lastError: string | null; + metadata: Record | null; + createdAt: Date; + updatedAt: Date; +} + +export interface ToolStdioTemplateToolSummary { + name: string; + title?: string | null; + description?: string | null; + inputSchema?: Record | null; + annotations?: Record | null; +} + +export interface ToolStdioCommandTemplate { + id?: string; + companyId?: string; + templateId: string; + name: string; + title?: string | null; + description?: string | null; + status: "active" | "disabled"; + source: "built_in" | "admin"; + command?: string | null; + args: string[]; + envKeys: string[]; + tools: ToolStdioTemplateToolSummary[]; + createdByAgentId?: string | null; + createdByUserId?: string | null; + disabledAt?: Date | string | null; + createdAt?: Date | string | null; + updatedAt?: Date | string | null; +} + +export type ToolRuntimeAlertSeverity = "info" | "warning" | "critical"; +export type ToolRuntimeAlertStatus = "ok" | "firing" | "not_instrumented"; + +export interface ToolRuntimeAlertRecommendation { + name: string; + severity: ToolRuntimeAlertSeverity; + status: ToolRuntimeAlertStatus; + threshold: string; + observed: string; + description: string; + firstResponderAction: string; + runbookSection: string; +} + +export interface ToolRuntimeMetricSnapshot { + windowStartedAt: Date | string; + windowEndedAt: Date | string; + activeSlots: number; + startingSlots: number; + runningSlots: number; + idleSlots: number; + failedSlots: number; + stoppedSlots: number; + stuckStartingSlots: number; + stuckRunningSlots: number; + capacityDeferralsLastHour: number; + restartAttemptsLastHour: number; + restartSuppressionsLastHour: number; + idleEvictionsLastHour: number; + toolCallsLastHour: number; + toolTimeoutsLastHour: number; + toolFailuresLastHour: number; + timeoutRateLastHour: number; + failureRateLastHour: number; + averageToolLatencyMsLastHour: number | null; + p95ToolLatencyMsLastHour: number | null; + missingSecretFailuresLastHour: number; + auditWriteFailuresLastHour: number; + activeConnections: number; + disabledConnections: number; + degradedConnections: number; + remoteHttpConnections: number; + localStdioConnections: number; +} + +export interface ToolRuntimeSupportMatrix { + remoteHttp: { + supported: boolean; + note: string; + }; + localStdio: { + supported: boolean; + note: string; + }; +} + +export interface ToolRuntimeHealthSummary { + status: "ok" | "degraded" | "critical"; + generatedAt: Date | string; + runbookPath: string; + metrics: ToolRuntimeMetricSnapshot; + supportMatrix: ToolRuntimeSupportMatrix; + alerts: ToolRuntimeAlertRecommendation[]; + recommendations: ToolRuntimeAlertRecommendation[]; +} + +export interface ToolConnectionHealthCheckResult { + connection: ToolConnection; + runtimeSlot: ToolRuntimeSlot | null; +} + +export interface ToolCatalogRefreshResult { + connection: ToolConnection; + catalog: ToolCatalogEntry[]; + discoveredCount: number; + quarantinedCount: number; +} + +export type ToolAppAttentionReason = + | "health" + | "quarantined_catalog_entries" + | "pending_action_requests" + | "profile_new_tools"; + +export interface ToolAppAttentionProfileNewTools { + profileId: string; + profileName: string; + pendingCount: number; +} + +export interface ToolAppAttentionItem { + connection: ToolConnection; + healthNeedsAttention: boolean; + quarantinedCatalogEntryCount: number; + pendingActionRequestCount: number; + newToolsPendingReviewCount: number; + newToolsPendingProfiles: ToolAppAttentionProfileNewTools[]; + reasons: ToolAppAttentionReason[]; +} + +export interface ToolAppsAttentionResponse { + generatedAt: Date | string; + apps: ToolAppAttentionItem[]; + totals: { + connections: number; + health: number; + quarantinedCatalogEntries: number; + pendingActionRequests: number; + newToolsPendingReview: number; + newToolsPendingProfiles: number; + }; +} + +/** + * A connection-level lifecycle event (install, pause/resume, allowlist change, + * reconnect/disconnect, new-actions quarantine) surfaced on the per-app + * Activity tab alongside tool-call events (PAP-11284). Derived from the + * company activity log rows scoped to a single tool connection. + */ +export interface ToolConnectionLifecycleEvent { + id: string; + connectionId: string; + type: ToolConnectionLifecycleEventType; + actorType: ToolActorType; + /** Raw actor id (user id, agent id, or "board"/"system"); use actorDisplayName for rendering. */ + actorId: string | null; + agentId: string | null; + /** Server-resolved display name for the actor (agent name or user name/email), null when unknown. */ + actorDisplayName: string | null; + /** Event-specific structured detail, e.g. `{ added, removed }` for allowlist or `{ count }` for quarantine. */ + details: Record | null; + createdAt: Date; +} + +/** Recent tool-call and lifecycle events for a single app connection (App detail · Recent activity). */ +export interface ToolConnectionActivityResponse { + connectionId: string; + events: ToolCallEvent[]; + lifecycleEvents: ToolConnectionLifecycleEvent[]; + issues: Record; + actionRequests: Record; +} + +/** + * A pending (or recently resolved) "Ask first" request, enriched with the + * connection/app context the review-queue card needs to render a prosumer + * sentence without extra round-trips. + */ +export interface ToolActionRequestListItem { + request: ToolActionRequest; + toolName: string; + toolTitle: string | null; + connectionId: string | null; + connectionName: string | null; + applicationName: string | null; + riskLevel: ToolRiskLevel | null; + requestedByAgentId: string | null; +} + +export interface ToolActionRequestsResponse { + actionRequests: ToolActionRequestListItem[]; +} + +export interface ToolExampleSummary { + id: string; + title: string; + description: string; + fixture: { + transport: ToolConnectionTransport; + templateId: string; + available: boolean; + tools: Array<{ + name: string; + description?: string | null; + riskLevel: ToolRiskLevel; + readOnly: boolean; + }>; + }; + safeDefaultProfile: { + profileKey: string; + name: string; + defaultAction: ToolProfileDefaultAction; + allowedToolNames: string[]; + }; + install: { + installed: boolean; + canInstall: boolean; + reason?: string | null; + applicationId?: string | null; + connectionId?: string | null; + profileId?: string | null; + profileBindingId?: string | null; + }; +} + +export interface ToolExampleInstallResult { + example: ToolExampleSummary; + created: boolean; + application: ToolApplication; + connection: ToolConnection; + profile: ToolProfile; + profileEntries: ToolProfileEntry[]; + profileBinding: ToolProfileBinding; + catalog: ToolCatalogEntry[]; +} + +export interface ToolExampleSmokeCheck { + name: string; + ok: boolean; + toolName?: string | null; + expectedDecision?: ToolPolicyDecision | null; + decision?: ToolPolicyDecision | null; + reasonCode?: ToolAccessReasonCode | string | null; + explanation?: string | null; + auditEventId?: string | null; + toolCallEventId?: string | null; + details?: Record | null; +} + +export interface ToolExampleSmokeResult { + exampleId: string; + ok: boolean; + actor: { + actorType: ToolActorType; + actorId: string; + agentId?: string | null; + }; + connection: ToolConnection; + profile: ToolProfile; + checks: ToolExampleSmokeCheck[]; +} + +export interface ToolAppConnectionActionSummary { + catalogEntryId: string; + toolName: string; + title: string | null; + description: string | null; + riskLevel: ToolRiskLevel; + isReadOnly: boolean; + isWrite: boolean; + isDestructive: boolean; + status: ToolCatalogEntryStatus; +} + +export interface ConnectToolAppResult { + connectionId: string; + application: ToolApplication; + connection: ToolConnection; + catalog: ToolCatalogEntry[]; + actions: { + readOnly: ToolAppConnectionActionSummary[]; + canMakeChanges: ToolAppConnectionActionSummary[]; + }; + suggestedDefaults: Record; + auth?: { + kind: "oauth"; + startUrl: string | null; + } | null; +} + +export interface ToolOAuthStartResult { + connectionId: string; + provider: string; + authorizationUrl: string; + expiresAt: string; +} + +export interface FinishToolAppResult { + connection: ToolConnection; + profile: ToolProfile; + profileEntries: ToolProfileEntry[]; + profileBindings: ToolProfileBinding[]; + policies: ToolPolicy[]; +} + +export interface McpJsonImportDraft { + name: string; + transport: ToolConnectionTransport; + status: ToolConnectionStatus; + config: Record; + credentialRefs: McpConnectionCredentialRef[]; + credentialFields: Array<{ + configPath: string; + label: string; + placement: ToolCredentialPlacement; + key: string; + prefix: string | null; + required: boolean; + }>; + warnings: string[]; +} + +export interface McpJsonImportPreview { + drafts: McpJsonImportDraft[]; +} + +export interface ToolInvocation { + id: string; + companyId: string; + idempotencyKey: string | null; + actorType: ToolActorType; + actorId: string | null; + agentId: string | null; + issueId: string | null; + runId: string | null; + applicationId: string | null; + connectionId: string | null; + catalogEntryId: string | null; + toolName: string; + argumentsHash: string | null; + argumentsSummary: ToolRedactedValueSummary | null; + policyDecision: ToolPolicyDecision | null; + matchedPolicyIds: string[]; + approvalState: ToolInvocationApprovalState; + status: ToolInvocationStatus; + upstreamRequestId: string | null; + resultHash: string | null; + resultSummary: ToolRedactedValueSummary | null; + resultSizeBytes: number | null; + resultArtifactId: string | null; + errorCode: string | null; + errorMessage: string | null; + startedAt: Date | null; + completedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface ToolActionRequest { + id: string; + companyId: string; + invocationId: string; + issueId: string | null; + interactionId: string | null; + approvalId: string | null; + status: ToolActionRequestStatus; + canonicalArgumentsHash: string; + canonicalArgumentsSummary: ToolRedactedValueSummary; + signedArguments: string | null; + previewMarkdown: string | null; + requestedByAgentId: string | null; + requestedByUserId: string | null; + resolvedByAgentId: string | null; + resolvedByUserId: string | null; + decidedByAgentId?: string | null; + decidedByUserId?: string | null; + decidedAt?: Date | null; + expiresAt: Date | null; + resolvedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface ToolCallEvent { + id: string; + companyId: string; + eventType: ToolAuditEventType; + actorType: ToolActorType; + actorId: string | null; + agentId: string | null; + runId: string | null; + issueId: string | null; + applicationId: string | null; + connectionId: string | null; + catalogEntryId: string | null; + invocationId: string | null; + actionRequestId: string | null; + runtimeSlotId: string | null; + toolName: string | null; + decision: ToolPolicyDecision | null; + matchedPolicyIds: string[]; + reasonCode: string | null; + outcome: ToolAuditOutcome; + latencyMs: number | null; + argumentsSummary?: ToolRedactedValueSummary | null; + requestHash: string | null; + requestSummary: ToolRedactedValueSummary | null; + resultHash: string | null; + resultSummary: ToolRedactedValueSummary | null; + resultSizeBytes: number | null; + redactionPlan: Record | null; + rateLimitState: Record | null; + metadata: Record | null; + errorCode: string | null; + errorMessage: string | null; + createdAt: Date; +} + +export interface ToolRunDecision { + invocation: ToolInvocation; + actionRequest: ToolActionRequest | null; + auditEvents: ToolCallEvent[]; + latestAuditEvent: ToolCallEvent | null; + decision: ToolPolicyDecision | null; + outcome: ToolAuditOutcome | null; + reasonCode: string | null; + denialReason: string | null; + pendingAction: { + actionRequestId: string; + issueId: string | null; + interactionId: string | null; + approvalId: string | null; + status: ToolActionRequestStatus; + previewMarkdown: string | null; + } | null; +} + +export interface ToolRunDecisionLookup { + runId: string; + decisions: ToolRunDecision[]; +} + +export interface ToolRateLimitCounter { + id: string; + companyId: string; + policyId: string; + counterKey: string; + scopeType: string; + scopeId: string; + windowKind: ToolRateLimitWindowKind; + windowStartAt: Date; + limit: number; + remaining: number; + resetAt: Date; + createdAt: Date; + updatedAt: Date; +} + +export type ToolAccessReasonCode = + | "allow_trust_rule" + | "allow_profile" + | "allow_explicit_grant" + | "allow_policy" + | "requires_review_changed_tool" + | "requires_approval_policy" + | "deny_default" + | "deny_company_boundary" + | "deny_disabled_connection" + | "deny_disabled_application" + | "deny_archived_application" + | "deny_missing_tool" + | "deny_policy_block" + | "deny_run_context_mismatch" + | "deny_missing_agent" + | "rate_limited"; + +export interface ToolAccessSelector { + actorType?: ToolActorType; + actorTypes?: ToolActorType[]; + agentId?: string; + agentIds?: string[]; + projectId?: string; + projectIds?: string[]; + routineId?: string; + routineIds?: string[]; + issueId?: string; + issueIds?: string[]; + gatewayId?: string; + gatewayIds?: string[]; + gatewayPublicId?: string; + gatewayPublicIds?: string[]; + gatewayTokenId?: string; + gatewayTokenIds?: string[]; + clientSubjectType?: ToolMcpGatewayTokenSubjectType; + clientSubjectTypes?: ToolMcpGatewayTokenSubjectType[]; + clientName?: string; + clientNames?: string[]; + externalClient?: boolean; + applicationId?: string; + applicationIds?: string[]; + connectionId?: string; + connectionIds?: string[]; + catalogEntryId?: string; + catalogEntryIds?: string[]; + toolName?: string; + toolNames?: string[]; + riskLevel?: ToolRiskLevel; + riskLevels?: ToolRiskLevel[]; +} + +export interface ToolRateLimitRule { + limit: number; + windowSeconds: number; + keyBy?: Array<"company" | "agent" | "application" | "connection" | "tool">; +} + +export interface ToolTrustRuleArgumentFilters { + allowAny?: boolean; + exactHash?: string | null; + allowedHashes?: string[]; + fieldEquals?: Record; + fieldNotEquals?: Record; + fieldIn?: Record; + fieldMatches?: Record; + fieldExists?: string[]; + fieldAbsent?: string[]; +} + +export interface ToolPolicyConditions { + arguments?: { + fieldEquals?: Record; + fieldNotEquals?: Record; + fieldIn?: Record; + fieldMatches?: Record; + fieldExists?: string[]; + fieldAbsent?: string[]; + }; + args?: ToolPolicyConditions["arguments"]; + actor?: ToolAccessSelector; + context?: ToolAccessSelector & { + requireIssue?: boolean; + requireProject?: boolean; + requireRoutine?: boolean; + }; + risk?: { + levels?: ToolRiskLevel[]; + max?: ToolRiskLevel; + isWrite?: boolean; + isDestructive?: boolean; + }; + credentialScope?: Pick & { + applicationKey?: string; + applicationKeys?: string[]; + providerType?: string; + providerTypes?: string[]; + }; + trustBoundary?: { + providerType?: string; + providerTypes?: string[]; + applicationKey?: string; + applicationKeys?: string[]; + remoteHttpOnly?: boolean; + paperclipSelfOnly?: boolean; + }; + timeWindow?: { + startAt?: string; + endAt?: string; + daysOfWeekUtc?: number[]; + startHourUtc?: number; + endHourUtc?: number; + }; +} + +export interface ToolTrustRuleScopeInput { + includeAgent?: boolean; + includeProject?: boolean; + includeIssue?: boolean; + includeApplication?: boolean; + includeConnection?: boolean; + includeCatalogEntry?: boolean; + includeTool?: boolean; +} + +export interface ToolTrustRuleBatchApprovalConfig { + enabled?: boolean; + maxBatchSize?: number; + windowSeconds?: number; +} + +export interface CreateToolTrustRuleFromActionRequest { + name?: string; + description?: string | null; + priority?: number; + approvalThreshold?: number; + selectors?: ToolAccessSelector; + scope?: ToolTrustRuleScopeInput; + argumentFilters?: ToolTrustRuleArgumentFilters; + expiresAt?: Date | string | null; + batchApproval?: ToolTrustRuleBatchApprovalConfig | null; +} + +export interface RevokeToolTrustRule { + reason?: string | null; +} + +export interface ToolAccessDecisionInput { + companyId: string; + actor: { + actorType: ToolActorType; + actorId: string; + agentId?: string | null; + userId?: string | null; + }; + runContext?: { + heartbeatRunId?: string | null; + issueId?: string | null; + projectId?: string | null; + routineId?: string | null; + gatewayId?: string | null; + gatewayPublicId?: string | null; + gatewayTokenId?: string | null; + clientSubjectType?: ToolMcpGatewayTokenSubjectType | null; + clientSubjectId?: string | null; + clientName?: string | null; + externalClient?: boolean | null; + } | null; + request: { + applicationId?: string | null; + connectionId?: string | null; + catalogEntryId?: string | null; + providerType?: string | null; + applicationKey?: string | null; + upstreamToolName?: string | null; + riskLevel?: ToolRiskLevel | string | null; + toolName: string; + arguments?: unknown; + idempotencyKey?: string | null; + sideEffecting?: boolean; + }; + consumeRateLimit?: boolean; + writeAuditEvent?: boolean; +} + +export interface ToolAccessDecision { + decision: ToolPolicyDecision; + allowed: boolean; + reasonCode: ToolAccessReasonCode; + explanation: string; + effectiveProfileIds: string[]; + matchedPolicyIds: string[]; + redactionPlan?: Record | null; + policyExplanation?: Record | null; + argumentsSummary?: ToolRedactedValueSummary | null; + rateLimitState?: Record | null; + invocationId?: string | null; + actionRequestId?: string | null; +} + +/** + * How an action would behave for a given agent if they ran it right now — + * the same three-way outcome the Test tab and Permissions surface. + */ +export type ToolConnectionTestDecision = "allowed" | "ask_first" | "off"; + +/** Per-action access summary for one agent on one connection. */ +export interface ToolConnectionTestToolAccess { + /** Upstream tool name; what `test-calls` expects as `toolName`. */ + toolName: string; + /** Gateway-namespaced tool name (matches the catalog gateway entry). */ + gatewayToolName: string; + displayName: string | null; + risk: "read" | "write" | "destructive"; + decision: ToolConnectionTestDecision; + reasonCode: ToolAccessReasonCode | string | null; + matchedPolicyIds: string[]; +} + +/** Roll-up of how every action on a connection behaves for one agent. */ +export interface ToolConnectionAccessSummary { + connectionId: string; + toolCount: number; + allowedCount: number; + askFirstCount: number; + offCount: number; + /** + * When this agent's access to the connection was last reconfigured (ISO + * timestamp), powering the "Last changed by {Actor} · {relativeTime}" hint in + * the Test tab Off side panel. Null when no governing config has a timestamp. + */ + lastChangedAt: string | null; + /** Agent who made the most recent change, when attributable (policy/binding edits only). */ + lastChangedByAgentId: string | null; + /** Resolved display name for {@link lastChangedByAgentId}. */ + lastChangedByName: string | null; + tools: ToolConnectionTestToolAccess[]; +} + +/** An agent the board member may impersonate in the Test tab. */ +export interface ToolConnectionTestAgent { + id: string; + name: string; + role: string; + title: string | null; + status: string; + effectiveAccess: ToolConnectionAccessSummary; +} + +export interface ToolConnectionTestAgentsResponse { + agents: ToolConnectionTestAgent[]; +} + +/** Result of `POST /tool-connections/:id/test-calls`. */ +export interface ToolConnectionTestCallResult { + decision: ToolConnectionTestDecision; + invocationId: string; + /** Present (with `decision: "allowed"`) when the call ran to completion. */ + result?: unknown; + /** Present on a failed allowed run, or as the explanation for an off action. */ + error?: { message: string; reasonCode: ToolAccessReasonCode | string | null }; + /** Present (with `decision: "ask_first"`) — the parked approval request. */ + actionRequestId?: string; +} + +/** + * Lifecycle phase of an ask-first test call, polled through + * `GET /tool-connections/:id/test-calls/:actionRequestId`. + * + * - `waiting` — approval still pending in the Review tab. + * - `running` — approved; the tool is executing for the test. + * - `done` — approved and finished; see `result` / `error`. + * - `denied` — declined in the Review tab. + * - `cancelled` — request was cancelled or invalidated before approval. + * - `expired` — the approval window lapsed. + */ +export type ToolConnectionTestCallStatusPhase = + | "waiting" + | "running" + | "done" + | "denied" + | "cancelled" + | "expired"; + +/** Live status of an ask-first test call (`GET /tool-connections/:id/test-calls/:actionRequestId`). */ +export interface ToolConnectionTestCallStatus { + actionRequestId: string; + invocationId: string; + phase: ToolConnectionTestCallStatusPhase; + /** Redacted snapshot of the parameters the call was made with — powers the "Where" row. */ + parameters?: Record | null; + /** Present once `phase === "done"` and the tool succeeded. */ + result?: unknown; + /** Present once `phase === "done"` and the tool failed, or when the request was denied/expired. */ + error?: { message: string; reasonCode: ToolAccessReasonCode | string | null }; + /** Wall-clock duration of the executed call in ms, when known. */ + durationMs?: number | null; + /** ISO timestamp the request was created — for the "Waiting · {time}" label. */ + requestedAt: string; + /** ISO timestamp the request was resolved (approved/denied/cancelled), when applicable. */ + resolvedAt?: string | null; +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 29a12a77db..bb42ffc52f 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -21,6 +21,19 @@ export { type ResolveBudgetIncident, } from "./budget.js"; +export { + createSmokeRunSchema, + updateSmokeRunSchema, + recordSmokeRunStepSchema, + smokeRunStatusSchema, + smokeRunStepPathSchema, + smokeRunStepStatusSchema, + smokeRunTriggerSchema, + type CreateSmokeRun, + type UpdateSmokeRun, + type RecordSmokeRunStep, +} from "./smoke-lab.js"; + export { createCompanySchema, updateCompanySchema, @@ -695,3 +708,128 @@ export { type SetPluginState, type ListPluginState, } from "./plugin.js"; + +export { + createToolActionRequestSchema, + toolApplicationTypeSchema, + toolApplicationStatusSchema, + toolAuditEventTypeSchema, + toolAuditOutcomeSchema, + toolCatalogEntryKindSchema, + toolCatalogEntryStatusSchema, + toolConnectionHealthStatusSchema, + toolConnectionKindSchema, + toolConnectionStatusSchema, + toolConnectionTransportSchema, + toolCredentialSecretRefSchema, + toolCredentialPlacementSchema, + toolInvocationApprovalStateSchema, + toolInvocationStatusSchema, + toolMcpGatewayAuthConfigSchema, + toolMcpGatewayContextScopeTypeSchema, + toolMcpGatewayDefaultProfileModeSchema, + toolMcpGatewayHeaderPolicySchema, + toolMcpGatewayMetadataPolicySchema, + toolMcpGatewayOnDemandToolsConfigSchema, + toolMcpGatewayStatusSchema, + toolMcpGatewayTokenActionSchema, + toolMcpGatewayTokenSubjectTypeSchema, + toolPolicyDecisionSchema, + toolPolicyTypeSchema, + toolProfileBindingTargetTypeSchema, + toolProfileDefaultActionSchema, + toolProfileEntryEffectSchema, + toolProfileEntrySelectorTypeSchema, + toolProfileStatusSchema, + toolRateLimitWindowKindSchema, + toolRedactedValueSummarySchema, + toolRiskLevelSchema, + toolRuntimeKindSchema, + toolRuntimeSlotStatusSchema, + toolTransportConfigSchema, + toolAccessSelectorSchema, + toolPolicyConditionsSchema, + toolRateLimitRuleSchema, + toolTrustRuleArgumentFiltersSchema, + toolTrustRuleBatchApprovalSchema, + toolTrustRuleScopeSchema, + toolConnectionTestCallSchema, + connectionTokenIssuancePathSchema, + connectionTokenRequestSchema, + connectionTokenScopeSchema, + createToolTrustRuleFromActionRequestSchema, + revokeToolTrustRuleSchema, + toolPolicyTestRequestSchema, + importMcpJsonSchema, + mcpConnectionCredentialRefSchema, + createToolApplicationSchema, + connectToolAppSchema, + reconnectToolAppSchema, + finishToolAppSchema, + updateToolApplicationSchema, + createToolConnectionSchema, + createToolMcpGatewaySchema, + createToolMcpGatewayTokenSchema, + createToolStdioCommandTemplateSchema, + disableToolStdioCommandTemplateSchema, + updateToolConnectionSchema, + putToolConnectionInstallsSchema, + updateToolMcpGatewaySchema, + createToolInvocationSchema, + createToolPolicySchema, + createToolProfileBindingForProfileSchema, + createToolProfileBindingSchema, + createToolProfileEntryForProfileSchema, + createToolProfileEntrySchema, + createToolProfileSchema, + createToolProfileWithEntriesSchema, + deleteToolProfileSchema, + duplicateToolPolicySchema, + duplicateToolProfileSchema, + reorderToolPoliciesSchema, + reviewToolProfileNewToolsSchema, + unbindToolProfileBindingSchema, + updateToolPolicySchema, + updateToolProfileEntrySchema, + updateToolProfileSchema, + updateToolProfileWithEntriesSchema, + upsertToolCatalogEntrySchema, + type CreateToolActionRequest, + type ConnectToolApp, + type ReconnectToolApp, + type CreateToolApplication, + type FinishToolApp, + type UpdateToolApplication, + type CreateToolConnection, + type CreateToolMcpGateway, + type CreateToolMcpGatewayToken, + type CreateToolStdioCommandTemplate, + type DisableToolStdioCommandTemplate, + type UpdateToolConnection, + type PutToolConnectionInstalls, + type UpdateToolMcpGateway, + type CreateToolInvocation, + type CreateToolPolicy, + type CreateToolProfile, + type CreateToolProfileBinding, + type CreateToolProfileBindingForProfile, + type CreateToolProfileEntry, + type CreateToolProfileEntryForProfile, + type CreateToolProfileWithEntries, + type DeleteToolProfile, + type DuplicateToolPolicy, + type DuplicateToolProfile, + type ImportMcpJson, + type ReviewToolProfileNewTools, + type ReorderToolPolicies, + type UpdateToolPolicy, + type UpdateToolProfileEntry, + type UpdateToolProfile, + type UpdateToolProfileWithEntries, + type UnbindToolProfileBinding, + type UpsertToolCatalogEntry, + type ConnectionTokenRequestInput, + type ToolPolicyTestRequestInput, + type CreateToolTrustRuleFromActionRequest, + type RevokeToolTrustRule, +} from "./tool-access.js"; diff --git a/packages/shared/src/validators/instance.test.ts b/packages/shared/src/validators/instance.test.ts index 6f41db4834..3462bb6382 100644 --- a/packages/shared/src/validators/instance.test.ts +++ b/packages/shared/src/validators/instance.test.ts @@ -50,6 +50,12 @@ describe("instance experimental settings validators", () => { expect(settings.enableBuiltInAgents).toBe(false); }); + it("defaults apps off", () => { + const settings = instanceExperimentalSettingsSchema.parse({}); + + expect(settings.enableApps).toBe(false); + }); + it("accepts worktree run execution patches", () => { expect( patchInstanceExperimentalSettingsSchema.parse({ @@ -117,4 +123,14 @@ describe("instance experimental settings validators", () => { enableBuiltInAgents: true, }); }); + + it("accepts apps patches", () => { + expect( + patchInstanceExperimentalSettingsSchema.parse({ + enableApps: true, + }), + ).toEqual({ + enableApps: true, + }); + }); }); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index aef68079d3..f375f21b4b 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -42,6 +42,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableEnvironments: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false), enableStreamlinedLeftNavigation: z.boolean().default(true), + enableApps: z.boolean().default(false), enablePipelines: z.boolean().default(false), enableCases: z.boolean().default(false), enableConferenceRoomChat: z.boolean().default(false), @@ -50,6 +51,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableExperimentalFileViewer: z.boolean().default(false), enableCloudSync: z.boolean().default(false), enableExternalObjects: z.boolean().default(false), + enableSmokeLab: z.boolean().default(false), enableBuiltInAgents: z.boolean().default(false), enableDecisions: z.boolean().default(false), enableGoalsSidebarLink: z.boolean().default(false), diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 0726a0cf75..711aa33629 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -742,6 +742,22 @@ export const requestConfirmationTargetSchema = z.discriminatedUnion("type", [ requestConfirmationCustomTargetSchema, ]); +export const requestConfirmationToolActionPayloadSchema = z.object({ + version: z.literal(1), + actionRequestId: z.string().uuid(), + invocationId: z.string().uuid(), + toolName: z.string().trim().min(1).max(500), + toolDisplayName: z.string().trim().min(1).max(500), + connectionId: z.string().uuid().nullable(), + applicationId: z.string().uuid().nullable(), + appDisplayName: z.string().trim().min(1).max(500).nullable(), + risk: z.enum(["write", "destructive"]), + previewMarkdown: z.string().trim().min(1).max(20000), + argumentsSummaryJson: z.string().max(20000), + argumentsHash: z.string().trim().min(1).max(255), + expiresAt: z.string().datetime({ offset: true }), +}); + export const requestConfirmationPayloadSchema = z.object({ version: z.literal(1), prompt: z.string().trim().min(1).max(1000), @@ -754,6 +770,7 @@ export const requestConfirmationPayloadSchema = z.object({ detailsMarkdown: z.string().max(20000).nullable().optional(), supersedeOnUserComment: z.boolean().optional(), target: requestConfirmationTargetSchema.nullable().optional(), + toolAction: requestConfirmationToolActionPayloadSchema.optional(), }); export const requestCheckboxConfirmationOptionSchema = z.object({ @@ -867,6 +884,19 @@ export const requestConfirmationResumeFailureSchema = z.object({ updatedAt: z.string().trim().min(1).nullable().optional(), }); +export const requestConfirmationToolActionResultSchema = z.object({ + version: z.literal(1), + status: z.enum(["approved", "executing", "executed", "failed", "expired"]), + errorCode: z.string().trim().min(1).max(120).nullable().optional(), + errorMessage: z.string().trim().min(1).max(4000).nullable().optional(), + // Populated on `executed` so the card can report the outcome (e.g. "Row 42 + // added") instead of a bare checkmark, with an optional deep-link when the + // connector returns a URL (PAP-13745 §5 Executed / Peak-End). + resultSummary: z.string().trim().min(1).max(4000).nullable().optional(), + resultHref: z.string().trim().url().max(2000).nullable().optional(), + updatedAt: z.string().datetime({ offset: true }), +}); + export const requestConfirmationResultSchema = z.object({ version: z.literal(1), outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]), @@ -874,6 +904,7 @@ export const requestConfirmationResultSchema = z.object({ commentId: z.string().uuid().nullable().optional(), staleTarget: requestConfirmationTargetSchema.nullable().optional(), resumeFailure: requestConfirmationResumeFailureSchema.nullable().optional(), + toolAction: requestConfirmationToolActionResultSchema.optional(), }); export const requestCheckboxConfirmationResultSchema = requestConfirmationResultSchema.extend({ diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 4637519d37..9093c0042f 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -1135,25 +1135,27 @@ export const installPluginSchema = z.object({ export type InstallPlugin = z.infer; // --------------------------------------------------------------------------- -// Plugin config (instance configuration) schemas +// Plugin config (company-scoped configuration) schemas // --------------------------------------------------------------------------- /** - * Schema for creating or updating a plugin's instance configuration. + * Schema for creating or updating a plugin's company-scoped configuration. * configJson is validated permissively here; runtime validation against * the plugin's instanceConfigSchema is done at the service layer. */ export const upsertPluginConfigSchema = z.object({ + companyId: z.string().uuid(), configJson: z.record(z.string(), z.unknown()), }); export type UpsertPluginConfig = z.infer; /** - * Schema for partially updating a plugin's instance configuration. + * Schema for partially updating a plugin's company-scoped configuration. * Allows a partial merge of config values. */ export const patchPluginConfigSchema = z.object({ + companyId: z.string().uuid(), configJson: z.record(z.string(), z.unknown()), }); diff --git a/packages/shared/src/validators/secret.ts b/packages/shared/src/validators/secret.ts index e88b4c23d0..b0d55d8287 100644 --- a/packages/shared/src/validators/secret.ts +++ b/packages/shared/src/validators/secret.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { SECRET_BINDING_TARGET_TYPES, SECRET_MANAGED_MODES, + SECRET_PROJECTION_CLASSES, SECRET_PROVIDER_CONFIG_STATUSES, SECRET_PROVIDERS, SECRET_STATUSES, @@ -20,6 +21,8 @@ export const envBindingSecretRefSchema = z.object({ type: z.literal("secret_ref"), secretId: z.string().uuid(), version: secretVersionSelectorSchema.optional(), + projectionClass: z.enum(SECRET_PROJECTION_CLASSES).optional(), + projectionAllowlistKey: z.string().trim().min(1).max(160).optional().nullable(), }); export const envBindingUserSecretRefSchema = z.object({ @@ -135,6 +138,8 @@ export const createSecretBindingSchema = secretBindingTargetSchema.extend({ versionSelector: secretVersionSelectorSchema.default("latest"), required: z.boolean().default(true), label: z.string().optional().nullable(), + projectionClass: z.enum(SECRET_PROJECTION_CLASSES).optional(), + projectionAllowlistKey: z.string().trim().min(1).max(160).optional().nullable(), }); export type CreateSecretBinding = z.infer; diff --git a/packages/shared/src/validators/smoke-lab.ts b/packages/shared/src/validators/smoke-lab.ts new file mode 100644 index 0000000000..9672d163b7 --- /dev/null +++ b/packages/shared/src/validators/smoke-lab.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { + SMOKE_RUN_STATUSES, + SMOKE_RUN_STEP_PATHS, + SMOKE_RUN_STEP_STATUSES, + SMOKE_RUN_TRIGGERS, +} from "../types/smoke-lab.js"; + +export const smokeRunTriggerSchema = z.enum(SMOKE_RUN_TRIGGERS); +export const smokeRunStatusSchema = z.enum(SMOKE_RUN_STATUSES); +export const smokeRunStepPathSchema = z.enum(SMOKE_RUN_STEP_PATHS); +export const smokeRunStepStatusSchema = z.enum(SMOKE_RUN_STEP_STATUSES); + +export const createSmokeRunSchema = z.object({ + trigger: smokeRunTriggerSchema.default("manual"), + summary: z.record(z.string(), z.unknown()).default({}), +}).strict(); + +export const updateSmokeRunSchema = z.object({ + status: smokeRunStatusSchema, + summary: z.record(z.string(), z.unknown()).optional(), +}).strict(); + +export const recordSmokeRunStepSchema = z.object({ + path: smokeRunStepPathSchema, + scenarioStep: z.string().min(1).max(200), + status: smokeRunStepStatusSchema, + detail: z.string().max(4_000).nullable().optional(), + screenshotArtifactRef: z.record(z.string(), z.unknown()).nullable().optional(), + durationMs: z.number().int().min(0).max(24 * 60 * 60 * 1000).nullable().optional(), +}).strict(); + +export type CreateSmokeRun = z.infer; +export type UpdateSmokeRun = z.infer; +export type RecordSmokeRunStep = z.infer; diff --git a/packages/shared/src/validators/tool-access.test.ts b/packages/shared/src/validators/tool-access.test.ts new file mode 100644 index 0000000000..dc8f2f0056 --- /dev/null +++ b/packages/shared/src/validators/tool-access.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + createToolConnectionSchema, + toolRedactedValueSummarySchema, + toolTransportConfigSchema, +} from "./tool-access.js"; + +describe("tool access validators", () => { + it("rejects raw credential-looking fields in transport config", () => { + const parsed = toolTransportConfigSchema.safeParse({ + url: "https://example.test/mcp", + headers: { + Authorization: "Bearer raw-token", + }, + }); + + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues[0]?.message).toContain("credentialSecretRefs"); + } + }); + + it("accepts secret references for connection credentials", () => { + const parsed = createToolConnectionSchema.safeParse({ + applicationId: "11111111-1111-4111-8111-111111111111", + name: "GitHub fixture", + connectionKind: "managed", + transportConfig: { url: "https://example.test/mcp" }, + credentialSecretRefs: [ + { + secretId: "22222222-2222-4222-8222-222222222222", + configPath: "headers.Authorization", + versionSelector: "latest", + }, + ], + }); + + expect(parsed.success).toBe(true); + }); + + it("keeps invocation payload summaries redacted and bounded", () => { + const parsed = toolRedactedValueSummarySchema.parse({ + summary: "Redacted arguments: 2 fields omitted.", + sha256: "a".repeat(64), + redactedFields: ["headers.Authorization", "body.token"], + }); + + expect(parsed.redactedFields).toEqual(["headers.Authorization", "body.token"]); + }); +}); diff --git a/packages/shared/src/validators/tool-access.ts b/packages/shared/src/validators/tool-access.ts new file mode 100644 index 0000000000..7f5b0e811a --- /dev/null +++ b/packages/shared/src/validators/tool-access.ts @@ -0,0 +1,766 @@ +import { z } from "zod"; +import { + CONNECTION_TOKEN_ISSUANCE_PATHS, + SECRET_PROJECTION_CLASSES, + TOOL_ACTION_REQUEST_STATUSES, + TOOL_APPLICATION_STATUSES, + TOOL_APPLICATION_TYPES, + TOOL_AUDIT_EVENT_TYPES, + TOOL_AUDIT_OUTCOMES, + TOOL_CATALOG_ENTRY_KINDS, + TOOL_CATALOG_ENTRY_STATUSES, + TOOL_CONNECTION_HEALTH_STATUSES, + TOOL_CONNECTION_KINDS, + TOOL_INVOCATION_APPROVAL_STATES, + TOOL_INVOCATION_STATUSES, + TOOL_MCP_GATEWAY_CONTEXT_SCOPE_TYPES, + TOOL_MCP_GATEWAY_DEFAULT_PROFILE_MODES, + TOOL_MCP_GATEWAY_STATUSES, + TOOL_MCP_GATEWAY_TOKEN_ACTIONS, + TOOL_MCP_GATEWAY_TOKEN_SUBJECT_TYPES, + TOOL_POLICY_DECISIONS, + TOOL_POLICY_TYPES, + TOOL_PROFILE_BINDING_TARGET_TYPES, + TOOL_PROFILE_DEFAULT_ACTIONS, + TOOL_PROFILE_ENTRY_EFFECTS, + TOOL_PROFILE_ENTRY_SELECTOR_TYPES, + TOOL_PROFILE_STATUSES, + TOOL_RATE_LIMIT_WINDOW_KINDS, + TOOL_RISK_LEVELS, + TOOL_RUNTIME_KINDS, + TOOL_RUNTIME_SLOT_STATUSES, +} from "../constants.js"; +import { jsonSchemaSchema } from "./plugin.js"; + +export const toolApplicationTypeSchema = z.enum(TOOL_APPLICATION_TYPES); +export const toolApplicationStatusSchema = z.enum(TOOL_APPLICATION_STATUSES); +export const toolConnectionTransportSchema = z.enum(["remote_http", "local_stdio"]); +export const toolConnectionStatusSchema = z.enum(["draft", "active", "disabled", "archived"]); +export const toolConnectionInstallTargetTypeSchema = z.enum(["company", "agent"]); +export const toolCredentialPlacementSchema = z.enum(["header", "env"]); +export const toolConnectionKindSchema = z.enum(TOOL_CONNECTION_KINDS); +export const toolConnectionHealthStatusSchema = z.enum(TOOL_CONNECTION_HEALTH_STATUSES); +export const toolCatalogEntryKindSchema = z.enum(TOOL_CATALOG_ENTRY_KINDS); +export const toolCatalogEntryStatusSchema = z.enum(TOOL_CATALOG_ENTRY_STATUSES); +export const toolRiskLevelSchema = z.enum(TOOL_RISK_LEVELS); +export const toolProfileStatusSchema = z.enum(TOOL_PROFILE_STATUSES); +export const toolProfileDefaultActionSchema = z.enum(TOOL_PROFILE_DEFAULT_ACTIONS); +export const toolProfileEntrySelectorTypeSchema = z.enum(TOOL_PROFILE_ENTRY_SELECTOR_TYPES); +export const toolProfileEntryEffectSchema = z.enum(TOOL_PROFILE_ENTRY_EFFECTS); +export const toolProfileBindingTargetTypeSchema = z.enum(TOOL_PROFILE_BINDING_TARGET_TYPES); +export const toolPolicyTypeSchema = z.enum(TOOL_POLICY_TYPES); +export const toolPolicyDecisionSchema = z.enum(TOOL_POLICY_DECISIONS); +export const toolInvocationStatusSchema = z.enum(TOOL_INVOCATION_STATUSES); +export const toolInvocationApprovalStateSchema = z.enum(TOOL_INVOCATION_APPROVAL_STATES); +export const toolMcpGatewayStatusSchema = z.enum(TOOL_MCP_GATEWAY_STATUSES); +export const toolMcpGatewayDefaultProfileModeSchema = z.enum(TOOL_MCP_GATEWAY_DEFAULT_PROFILE_MODES); +export const toolMcpGatewayContextScopeTypeSchema = z.enum(TOOL_MCP_GATEWAY_CONTEXT_SCOPE_TYPES); +export const toolMcpGatewayTokenSubjectTypeSchema = z.enum(TOOL_MCP_GATEWAY_TOKEN_SUBJECT_TYPES); +export const toolMcpGatewayTokenActionSchema = z.enum(TOOL_MCP_GATEWAY_TOKEN_ACTIONS); +export const toolActionRequestStatusSchema = z.enum(TOOL_ACTION_REQUEST_STATUSES); +export const toolAuditEventTypeSchema = z.enum(TOOL_AUDIT_EVENT_TYPES); +export const toolAuditOutcomeSchema = z.enum(TOOL_AUDIT_OUTCOMES); +export const toolRuntimeKindSchema = z.enum(TOOL_RUNTIME_KINDS); +export const toolRuntimeSlotStatusSchema = z.enum(TOOL_RUNTIME_SLOT_STATUSES); +export const toolRateLimitWindowKindSchema = z.enum(TOOL_RATE_LIMIT_WINDOW_KINDS); + +const safeKeyPattern = /^[a-z0-9][a-z0-9._:-]*$/i; +const sensitiveConfigKeyPattern = + /^(access[-_]?key([-_]?id)?|api[-_]?key|authorization|bearer|client[-_]?secret|credential|credentials|jwt|password|passwd|private[-_]?key|refresh[-_]?token|secret|secret[-_]?access[-_]?key|secret[-_]?key|session[-_]?token|token)$/i; + +function rejectSensitiveConfigKeys(value: unknown, ctx: z.RefinementCtx, path: Array = []) { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + value.forEach((entry, index) => rejectSensitiveConfigKeys(entry, ctx, [...path, index])); + return; + } + for (const [key, nested] of Object.entries(value)) { + if (sensitiveConfigKeyPattern.test(key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [...path, key], + message: `Tool access config cannot persist sensitive field: ${key}. Use credentialSecretRefs instead.`, + }); + } + rejectSensitiveConfigKeys(nested, ctx, [...path, key]); + } +} + +export const toolCredentialSecretRefSchema = z.object({ + secretId: z.string().uuid(), + versionSelector: z.union([z.literal("latest"), z.number().int().positive()]).optional(), + configPath: z.string().trim().min(1).max(200), + required: z.boolean().optional(), + label: z.string().trim().max(120).optional().nullable(), + projectionClass: z.enum(SECRET_PROJECTION_CLASSES).optional(), + projectionAllowlistKey: z.string().trim().min(1).max(160).optional().nullable(), +}); + +export const mcpConnectionCredentialRefSchema = z.object({ + name: z.string().trim().min(1).max(120), + secretId: z.string().uuid(), + version: z.union([z.literal("latest"), z.number().int().positive()]).optional(), + placement: toolCredentialPlacementSchema, + key: z.string().trim().min(1).max(160), + prefix: z.string().max(120).nullable().optional(), +}); + +export const toolTransportConfigSchema = z.record(z.string(), z.unknown()).superRefine(rejectSensitiveConfigKeys); + +export const toolRedactedValueSummarySchema = z.object({ + summary: z.string().max(4000), + sizeBytes: z.number().int().min(0).optional().nullable(), + sha256: z.string().trim().regex(/^[a-f0-9]{64}$/i).optional().nullable(), + redactedFields: z.array(z.string().trim().min(1).max(200)).default([]).optional(), + artifactId: z.string().uuid().optional().nullable(), +}); + +export const createToolApplicationSchema = z.object({ + applicationKey: z.string().trim().min(1).max(160).regex(safeKeyPattern).optional(), + name: z.string().trim().min(1).max(160), + description: z.string().max(4000).optional().nullable(), + type: toolApplicationTypeSchema, + status: toolApplicationStatusSchema.optional(), + pluginId: z.string().uuid().optional().nullable(), + ownerAgentId: z.string().uuid().optional().nullable(), + ownerUserId: z.string().optional().nullable(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), +}); + +export type CreateToolApplication = z.infer; + +export const updateToolApplicationSchema = createToolApplicationSchema.partial().refine( + (value) => Object.keys(value).length > 0, + { message: "At least one tool application field is required" }, +); + +export type UpdateToolApplication = z.infer; + +export const createToolConnectionSchema = z.object({ + applicationId: z.string().uuid().optional(), + applicationName: z.string().trim().min(1).max(160).optional(), + name: z.string().trim().min(1).max(160), + transport: toolConnectionTransportSchema.optional(), + status: toolConnectionStatusSchema.optional(), + connectionKind: toolConnectionKindSchema.default("managed"), + config: toolTransportConfigSchema.optional(), + transportConfig: toolTransportConfigSchema.default({}), + credentialRefs: z.array(mcpConnectionCredentialRefSchema).optional(), + credentialSecretRefs: z.array(toolCredentialSecretRefSchema).default([]), + enabled: z.boolean().optional(), +}); + +export type CreateToolConnection = z.infer; + +export const updateToolConnectionSchema = createToolConnectionSchema.omit({ applicationId: true }).partial().refine( + (value) => Object.keys(value).length > 0, + { message: "At least one tool connection field is required" }, +); + +export type UpdateToolConnection = z.infer; + +export const putToolConnectionInstallsSchema = z.object({ + installs: z.array(z.object({ + targetType: toolConnectionInstallTargetTypeSchema, + targetId: z.string().trim().min(1).max(200), + })).max(1000), +}).strict(); + +export type PutToolConnectionInstalls = z.infer; + +export const connectionTokenIssuancePathSchema = z.enum(CONNECTION_TOKEN_ISSUANCE_PATHS); + +export const connectionTokenScopeSchema = z.union([ + z.string().trim().min(1).max(500), + z.array(z.string().trim().min(1).max(240)).max(100), +]); + +export const connectionTokenRequestSchema = z.object({ + scope: connectionTokenScopeSchema.optional(), + requestedTtlSeconds: z.number().int().positive().max(86_400).optional(), +}).strict(); + +export type ConnectionTokenRequestInput = z.infer; + +const envKeyPattern = /^[A-Z_][A-Z0-9_]*$/i; + +export const toolStdioTemplateToolSchema = z.object({ + name: z.string().trim().min(1).max(240), + title: z.string().trim().max(240).optional().nullable(), + description: z.string().max(8000).optional().nullable(), + inputSchema: jsonSchemaSchema.optional().nullable(), + annotations: z.record(z.string(), z.unknown()).optional().nullable(), +}); + +export const createToolStdioCommandTemplateSchema = z.object({ + templateId: z.string().trim().min(1).max(160).regex(safeKeyPattern), + name: z.string().trim().min(1).max(160), + description: z.string().max(4000).optional().nullable(), + command: z.string().trim().min(1).max(2000), + args: z.array(z.string().max(2000)).max(100).default([]), + envKeys: z.array(z.string().trim().min(1).max(160).regex(envKeyPattern)).max(200).default([]), + tools: z.array(toolStdioTemplateToolSchema).max(500).default([]), +}); + +export type CreateToolStdioCommandTemplate = z.infer; + +export const disableToolStdioCommandTemplateSchema = z.object({ + reason: z.string().trim().max(1000).optional().nullable(), +}); + +export type DisableToolStdioCommandTemplate = z.infer; + +export const connectToolAppSchema = z.object({ + galleryKey: z.string().trim().min(1).max(120).optional(), + link: z.string().trim().url().max(2000).optional(), + name: z.string().trim().min(1).max(160).optional(), + credentialValues: z.record(z.string().trim().min(1).max(200), z.string().min(1)).optional(), + configValues: z.record(z.string().trim().min(1).max(200), z.unknown()).optional(), + applicationId: z.string().uuid().optional(), +}).refine( + (value) => Boolean(value.galleryKey) !== Boolean(value.link), + { message: "Provide exactly one of galleryKey or link" }, +); + +export type ConnectToolApp = z.infer; + +export const reconnectToolAppSchema = z.object({ + credentialValues: z.record(z.string().trim().min(1).max(200), z.string().min(1)), +}); + +export type ReconnectToolApp = z.infer; + +export const finishToolAppSchema = z.object({ + enabledCatalogEntryIds: z.array(z.string().uuid()).max(500).default([]), + askFirstCatalogEntryIds: z.array(z.string().uuid()).max(500).default([]), + access: z.union([ + z.literal("all_agents"), + z.object({ agentIds: z.array(z.string().uuid()).min(1).max(250) }), + ]), +}); + +export type FinishToolApp = z.infer; + +export const upsertToolCatalogEntrySchema = z.object({ + applicationId: z.string().uuid(), + connectionId: z.string().uuid(), + entryKind: toolCatalogEntryKindSchema.default("tool"), + toolName: z.string().trim().min(1).max(240), + title: z.string().trim().max(240).optional().nullable(), + description: z.string().max(8000).optional().nullable(), + inputSchema: jsonSchemaSchema.optional().nullable(), + outputSchema: jsonSchemaSchema.optional().nullable(), + annotations: z.record(z.string(), z.unknown()).optional().nullable(), + riskLevel: toolRiskLevelSchema.default("medium"), + isReadOnly: z.boolean().default(false), + isWrite: z.boolean().default(false), + isDestructive: z.boolean().default(false), + status: toolCatalogEntryStatusSchema.default("active"), + version: z.string().trim().max(200).optional().nullable(), + schemaHash: z.string().trim().max(128).optional().nullable(), +}); + +export type UpsertToolCatalogEntry = z.infer; + +export const createToolProfileSchema = z.object({ + profileKey: z.string().trim().min(1).max(160).regex(safeKeyPattern), + name: z.string().trim().min(1).max(160), + description: z.string().max(4000).optional().nullable(), + status: toolProfileStatusSchema.default("active"), + defaultAction: toolProfileDefaultActionSchema.default("deny"), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), +}); + +export type CreateToolProfile = z.infer; + +export const updateToolProfileSchema = createToolProfileSchema.partial().refine( + (value) => Object.keys(value).length > 0, + { message: "At least one tool profile field is required" }, +); + +export type UpdateToolProfile = z.infer; + +export const createToolProfileEntrySchema = z.object({ + profileId: z.string().uuid(), + selectorType: toolProfileEntrySelectorTypeSchema, + effect: toolProfileEntryEffectSchema.default("include"), + applicationId: z.string().uuid().optional().nullable(), + connectionId: z.string().uuid().optional().nullable(), + catalogEntryId: z.string().uuid().optional().nullable(), + toolName: z.string().trim().min(1).max(240).optional().nullable(), + riskLevel: toolRiskLevelSchema.optional().nullable(), + conditions: z.record(z.string(), z.unknown()).optional().nullable(), +}); + +export type CreateToolProfileEntry = z.infer; + +export const createToolProfileEntryForProfileSchema = createToolProfileEntrySchema.omit({ profileId: true }); + +export type CreateToolProfileEntryForProfile = z.infer; + +export const updateToolProfileEntrySchema = createToolProfileEntryForProfileSchema.partial().refine( + (value) => Object.keys(value).length > 0, + { message: "At least one tool profile entry field is required" }, +); + +export type UpdateToolProfileEntry = z.infer; + +export const createToolProfileWithEntriesSchema = createToolProfileSchema.extend({ + entries: z.array(createToolProfileEntryForProfileSchema).max(250).optional(), +}); + +export type CreateToolProfileWithEntries = z.infer; + +export const duplicateToolProfileSchema = z.object({ + name: z.string().trim().min(1).max(160), + includeAssignments: z.boolean().default(false), +}); + +export type DuplicateToolProfile = z.infer; + +export const updateToolProfileWithEntriesSchema = createToolProfileSchema.partial().extend({ + entries: z.array(createToolProfileEntryForProfileSchema).max(250).optional(), +}).refine( + (value) => Object.keys(value).length > 0, + { message: "At least one tool profile field is required" }, +); + +export type UpdateToolProfileWithEntries = z.infer; + +export const reviewToolProfileNewToolsSchema = z.object({ + decisions: z.array(z.object({ + catalogEntryId: z.string().uuid(), + decision: z.enum(["allow", "keep_blocked"]), + })).min(1).max(250), +}); + +export type ReviewToolProfileNewTools = z.infer; + +export const deleteToolProfileSchema = z.object({ + force: z.boolean().default(false), + reassignToProfileId: z.string().uuid().optional(), +}).default({}); + +export type DeleteToolProfile = z.infer; + +export const createToolProfileBindingSchema = z.object({ + profileId: z.string().uuid(), + targetType: toolProfileBindingTargetTypeSchema, + targetId: z.string().trim().min(1).max(200), + priority: z.number().int().min(0).max(10000).default(100), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), +}); + +export type CreateToolProfileBinding = z.infer; + +export const createToolProfileBindingForProfileSchema = createToolProfileBindingSchema.omit({ profileId: true }); + +export type CreateToolProfileBindingForProfile = z.infer; + +export const unbindToolProfileBindingSchema = createToolProfileBindingForProfileSchema.pick({ + targetType: true, + targetId: true, +}); + +export type UnbindToolProfileBinding = z.infer; + +const headerNameSchema = z.string().trim().min(1).max(120).regex(/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/); + +export const toolMcpGatewayAuthConfigSchema = z.object({ + version: z.literal(1).default(1), + bearer: z.object({ + enabled: z.boolean().default(true), + tokenPrefix: z.literal("pcgw").default("pcgw"), + defaultTtlSeconds: z.number().int().positive().max(31_536_000).nullable().default(7_776_000), + requireFiniteExpiry: z.boolean().default(true), + longLivedTokenRequiresOverride: z.boolean().default(true), + }).default({}), + oauth: z.object({ + enabled: z.literal(false).default(false), + reservedFor: z.literal("v1_5").default("v1_5"), + protectedResourceMetadataPath: z.string().trim().max(240).optional().nullable(), + dynamicClientRegistration: z.literal(false).optional(), + authorizationCodePkce: z.literal(false).optional(), + }).default({}), +}); + +export const toolMcpGatewayHeaderPolicySchema = z.object({ + version: z.literal(1).default(1), + callerPassthrough: z.object({ + enabled: z.boolean().default(false), + allowedHeaders: z.array(headerNameSchema).max(50).default([]), + }).default({}), + staticHeaders: z.array(z.object({ + name: headerNameSchema, + valueRef: z.string().trim().max(240).optional().nullable(), + value: z.string().max(4000).optional().nullable(), + })).max(50).default([]), + generatedMetadata: z.object({ + enabled: z.boolean().default(false), + allowedHeaders: z.array(headerNameSchema).max(20).default([]), + }).default({}), + responseHeaders: z.object({ + forwardMcpRequiredHeaders: z.boolean().default(true), + forwardSafeCacheHeaders: z.boolean().default(true), + }).default({}), +}); + +export const toolMcpGatewayMetadataPolicySchema = z.object({ + version: z.literal(1).default(1), + forwardCompanyId: z.boolean().default(false), + forwardGatewayId: z.boolean().default(false), + forwardProjectId: z.boolean().default(false), + forwardIssueId: z.boolean().default(false), + forwardAgentId: z.boolean().default(false), + forwardRunId: z.boolean().default(false), + forwardCorrelationId: z.boolean().default(true), +}); + +export const toolMcpGatewayOnDemandToolsConfigSchema = z.object({ + enabled: z.boolean().default(false), + searchToolName: z.literal("search_tools").default("search_tools"), + runToolName: z.literal("run_tool").default("run_tool"), +}); + +export const createToolMcpGatewaySchema = z.object({ + name: z.string().trim().min(1).max(160), + slug: z.string().trim().min(1).max(120).regex(safeKeyPattern).optional(), + displaySlug: z.string().trim().min(1).max(120).regex(safeKeyPattern).optional(), + description: z.string().max(4000).optional().nullable(), + profileId: z.string().uuid(), + defaultProfileMode: toolMcpGatewayDefaultProfileModeSchema.default("gateway_only").optional(), + contextScopeType: toolMcpGatewayContextScopeTypeSchema.default("none").optional(), + contextScopeId: z.string().trim().min(1).max(200).optional().nullable(), + agentId: z.string().uuid().optional().nullable(), + projectId: z.string().uuid().optional().nullable(), + issueId: z.string().uuid().optional().nullable(), + approvalIssueId: z.string().uuid().optional().nullable(), + authConfig: toolMcpGatewayAuthConfigSchema.optional(), + headerPolicy: toolMcpGatewayHeaderPolicySchema.optional(), + metadataPolicy: toolMcpGatewayMetadataPolicySchema.optional(), + onDemandToolsConfig: toolMcpGatewayOnDemandToolsConfigSchema.optional(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), +}); + +export type CreateToolMcpGateway = z.infer; + +export const updateToolMcpGatewaySchema = createToolMcpGatewaySchema + .partial() + .extend({ status: toolMcpGatewayStatusSchema.optional() }) + .refine((value) => Object.keys(value).length > 0, { message: "At least one gateway field is required" }); + +export type UpdateToolMcpGateway = z.infer; + +export const createToolMcpGatewayTokenSchema = z.object({ + name: z.string().trim().min(1).max(160), + subjectType: toolMcpGatewayTokenSubjectTypeSchema.default("gateway_client").optional(), + subjectId: z.string().trim().min(1).max(240).optional().nullable(), + clientLabel: z.string().trim().min(1).max(160), + ownerNote: z.string().trim().min(1).max(1000), + allowedActions: z.array(toolMcpGatewayTokenActionSchema).min(1).max(TOOL_MCP_GATEWAY_TOKEN_ACTIONS.length).default(["tools/list", "tools/call"]).optional(), + expiresAt: z.coerce.date().optional().nullable(), + expiryOverrideReason: z.string().trim().min(1).max(1000).optional().nullable(), +}).superRefine((value, ctx) => { + if (value.subjectType && value.subjectType !== "gateway_client") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["subjectType"], + message: "Public V1 token minting only supports gateway_client subjects; heartbeat_run is runtime-managed, while board_user and agent are reserved for later OAuth/user-bound flows.", + }); + } + if (value.expiresAt === null && !value.expiryOverrideReason) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["expiryOverrideReason"], + message: "Non-expiring gateway tokens require an override reason.", + }); + } +}); + +export type CreateToolMcpGatewayToken = z.infer; + +const argumentConditionSchema = z.object({ + fieldEquals: z.record(z.string().trim().min(1).max(120), z.unknown()).optional(), + fieldNotEquals: z.record(z.string().trim().min(1).max(120), z.unknown()).optional(), + fieldIn: z.record(z.string().trim().min(1).max(120), z.array(z.unknown()).min(1).max(100)).optional(), + fieldMatches: z.record(z.string().trim().min(1).max(120), z.string().trim().min(1).max(500)).optional(), + fieldExists: z.array(z.string().trim().min(1).max(120)).max(100).optional(), + fieldAbsent: z.array(z.string().trim().min(1).max(120)).max(100).optional(), +}).strict().refine( + (value) => Object.values(value).some((nested) => Array.isArray(nested) ? nested.length > 0 : Boolean(nested && Object.keys(nested).length > 0)), + { message: "Argument conditions must include at least one field predicate" }, +); + +const timeWindowConditionSchema = z.object({ + startAt: z.string().trim().datetime({ offset: true }).optional(), + endAt: z.string().trim().datetime({ offset: true }).optional(), + daysOfWeekUtc: z.array(z.number().int().min(0).max(6)).max(7).optional(), + startHourUtc: z.number().int().min(0).max(23).optional(), + endHourUtc: z.number().int().min(0).max(24).optional(), +}).strict().refine( + (value) => value.startAt || value.endAt || value.daysOfWeekUtc?.length || value.startHourUtc !== undefined || value.endHourUtc !== undefined, + { message: "timeWindow must include at least one bound" }, +); + +const actorConditionSchema = z.object({ + actorType: z.enum(["agent", "user", "system", "plugin"]).optional(), + actorTypes: z.array(z.enum(["agent", "user", "system", "plugin"])).max(20).optional(), + agentId: z.string().uuid().optional(), + agentIds: z.array(z.string().uuid()).max(100).optional(), +}).strict(); + +const contextConditionSchema = z.object({ + projectId: z.string().uuid().optional(), + projectIds: z.array(z.string().uuid()).max(100).optional(), + routineId: z.string().uuid().optional(), + routineIds: z.array(z.string().uuid()).max(100).optional(), + issueId: z.string().uuid().optional(), + issueIds: z.array(z.string().uuid()).max(100).optional(), + requireIssue: z.boolean().optional(), + requireProject: z.boolean().optional(), + requireRoutine: z.boolean().optional(), +}).strict(); + +const credentialScopeConditionSchema = z.object({ + applicationId: z.string().uuid().optional(), + applicationIds: z.array(z.string().uuid()).max(100).optional(), + connectionId: z.string().uuid().optional(), + connectionIds: z.array(z.string().uuid()).max(100).optional(), + catalogEntryId: z.string().uuid().optional(), + catalogEntryIds: z.array(z.string().uuid()).max(100).optional(), + applicationKey: z.string().trim().min(1).max(160).optional(), + applicationKeys: z.array(z.string().trim().min(1).max(160)).max(100).optional(), + providerType: z.string().trim().min(1).max(160).optional(), + providerTypes: z.array(z.string().trim().min(1).max(160)).max(100).optional(), +}).strict(); + +export const toolPolicyConditionsSchema = z.object({ + arguments: argumentConditionSchema.optional(), + args: argumentConditionSchema.optional(), + actor: actorConditionSchema.optional(), + context: contextConditionSchema.optional(), + risk: z.object({ + levels: z.array(toolRiskLevelSchema).max(20).optional(), + max: toolRiskLevelSchema.optional(), + isWrite: z.boolean().optional(), + isDestructive: z.boolean().optional(), + }).strict().optional(), + credentialScope: credentialScopeConditionSchema.optional(), + trustBoundary: z.object({ + providerType: z.string().trim().min(1).max(160).optional(), + providerTypes: z.array(z.string().trim().min(1).max(160)).max(100).optional(), + applicationKey: z.string().trim().min(1).max(160).optional(), + applicationKeys: z.array(z.string().trim().min(1).max(160)).max(100).optional(), + remoteHttpOnly: z.boolean().optional(), + paperclipSelfOnly: z.boolean().optional(), + }).strict().optional(), + timeWindow: timeWindowConditionSchema.optional(), +}).strict().refine( + (value) => Object.keys(value).length > 0, + { message: "Tool policy conditions must include at least one supported condition group" }, +); + +export const createToolPolicySchema = z.object({ + name: z.string().trim().min(1).max(160), + description: z.string().max(4000).optional().nullable(), + policyType: toolPolicyTypeSchema, + priority: z.number().int().min(0).max(10000).default(100), + enabled: z.boolean().default(true), + selectors: z.record(z.string(), z.unknown()).default({}), + conditions: toolPolicyConditionsSchema.optional().nullable(), + config: z.record(z.string(), z.unknown()).optional().nullable(), +}); + +export type CreateToolPolicy = z.infer; + +export const updateToolPolicySchema = createToolPolicySchema.partial().refine( + (value) => Object.keys(value).length > 0, + { message: "At least one tool policy field is required" }, +); + +export type UpdateToolPolicy = z.infer; + +export const reorderToolPoliciesSchema = z.object({ + policyIds: z.array(z.string().uuid()).min(1).max(500), +}); + +export type ReorderToolPolicies = z.infer; + +export const duplicateToolPolicySchema = z.object({ + name: z.string().trim().min(1).max(160).optional(), +}); + +export type DuplicateToolPolicy = z.infer; + +export const createToolInvocationSchema = z.object({ + idempotencyKey: z.string().trim().min(1).max(300).optional().nullable(), + issueId: z.string().uuid().optional().nullable(), + runId: z.string().uuid().optional().nullable(), + applicationId: z.string().uuid().optional().nullable(), + connectionId: z.string().uuid().optional().nullable(), + catalogEntryId: z.string().uuid().optional().nullable(), + toolName: z.string().trim().min(1).max(240), + argumentsHash: z.string().trim().max(128).optional().nullable(), + argumentsSummary: toolRedactedValueSummarySchema.optional().nullable(), +}); + +export type CreateToolInvocation = z.infer; + +export const createToolActionRequestSchema = z.object({ + invocationId: z.string().uuid(), + issueId: z.string().uuid().optional().nullable(), + canonicalArgumentsHash: z.string().trim().min(1).max(128), + canonicalArgumentsSummary: toolRedactedValueSummarySchema, + signedArguments: z.string().trim().max(4096).optional().nullable(), + previewMarkdown: z.string().max(20_000).optional().nullable(), + expiresAt: z.coerce.date().optional().nullable(), +}); + +export type CreateToolActionRequest = z.infer; + +export const toolConnectionTestCallSchema = z.object({ + agentId: z.string().uuid(), + toolName: z.string().trim().min(1).max(240), + parameters: z.unknown().optional(), +}); + +export type ToolConnectionTestCallInput = z.infer; + +export const importMcpJsonSchema = z.object({ + mcpJson: z.union([z.string(), z.record(z.string(), z.unknown())]), +}); + +export type ImportMcpJson = z.infer; + +export const toolAccessSelectorSchema = z.object({ + actorType: z.enum(["agent", "user", "system", "plugin"]).optional(), + agentId: z.string().uuid().optional(), + agentIds: z.array(z.string().uuid()).optional(), + projectId: z.string().uuid().optional(), + projectIds: z.array(z.string().uuid()).optional(), + routineId: z.string().uuid().optional(), + routineIds: z.array(z.string().uuid()).optional(), + issueId: z.string().uuid().optional(), + issueIds: z.array(z.string().uuid()).optional(), + gatewayId: z.string().uuid().optional(), + gatewayIds: z.array(z.string().uuid()).optional(), + gatewayPublicId: z.string().trim().min(1).max(120).regex(safeKeyPattern).optional(), + gatewayPublicIds: z.array(z.string().trim().min(1).max(120).regex(safeKeyPattern)).optional(), + gatewayTokenId: z.string().uuid().optional(), + gatewayTokenIds: z.array(z.string().uuid()).optional(), + clientSubjectType: toolMcpGatewayTokenSubjectTypeSchema.optional(), + clientSubjectTypes: z.array(toolMcpGatewayTokenSubjectTypeSchema).optional(), + clientName: z.string().trim().min(1).max(160).optional(), + clientNames: z.array(z.string().trim().min(1).max(160)).optional(), + externalClient: z.boolean().optional(), + applicationId: z.string().uuid().optional(), + applicationIds: z.array(z.string().uuid()).optional(), + connectionId: z.string().uuid().optional(), + connectionIds: z.array(z.string().uuid()).optional(), + catalogEntryId: z.string().uuid().optional(), + catalogEntryIds: z.array(z.string().uuid()).optional(), + toolName: z.string().trim().min(1).max(240).optional(), + toolNames: z.array(z.string().trim().min(1).max(240)).optional(), + riskLevel: toolRiskLevelSchema.optional(), + riskLevels: z.array(toolRiskLevelSchema).optional(), +}); + +export const toolRateLimitRuleSchema = z.object({ + limit: z.number().int().positive().max(1_000_000), + windowSeconds: z.number().int().positive().max(31_536_000), + keyBy: z.array(z.enum(["company", "agent", "application", "connection", "tool"])).optional(), +}); + +export const toolTrustRuleArgumentFiltersSchema = z.object({ + allowAny: z.boolean().optional(), + exactHash: z.string().trim().regex(/^[a-f0-9]{64}$/i).optional().nullable(), + allowedHashes: z.array(z.string().trim().regex(/^[a-f0-9]{64}$/i)).max(100).optional(), + fieldEquals: z.record(z.string().trim().min(1).max(120), z.unknown()).optional(), + fieldNotEquals: z.record(z.string().trim().min(1).max(120), z.unknown()).optional(), + fieldIn: z.record(z.string().trim().min(1).max(120), z.array(z.unknown()).min(1).max(100)).optional(), + fieldMatches: z.record(z.string().trim().min(1).max(120), z.string().trim().min(1).max(500)).optional(), + fieldExists: z.array(z.string().trim().min(1).max(120)).max(100).optional(), + fieldAbsent: z.array(z.string().trim().min(1).max(120)).max(100).optional(), +}).refine( + (value) => value.allowAny === true + || Boolean(value.exactHash) + || Boolean(value.allowedHashes?.length) + || Boolean(value.fieldEquals && Object.keys(value.fieldEquals).length > 0) + || Boolean(value.fieldNotEquals && Object.keys(value.fieldNotEquals).length > 0) + || Boolean(value.fieldIn && Object.keys(value.fieldIn).length > 0) + || Boolean(value.fieldMatches && Object.keys(value.fieldMatches).length > 0) + || Boolean(value.fieldExists?.length) + || Boolean(value.fieldAbsent?.length), + { message: "Trust-rule argument filters must specify allowAny, a hash filter, or a field predicate" }, +); + +export const toolTrustRuleScopeSchema = z.object({ + includeAgent: z.boolean().optional(), + includeProject: z.boolean().optional(), + includeIssue: z.boolean().optional(), + includeApplication: z.boolean().optional(), + includeConnection: z.boolean().optional(), + includeCatalogEntry: z.boolean().optional(), + includeTool: z.boolean().optional(), +}); + +export const toolTrustRuleBatchApprovalSchema = z.object({ + enabled: z.boolean().optional(), + maxBatchSize: z.number().int().positive().max(100).optional(), + windowSeconds: z.number().int().positive().max(31_536_000).optional(), +}); + +export const createToolTrustRuleFromActionRequestSchema = z.object({ + name: z.string().trim().min(1).max(160).optional(), + description: z.string().max(4000).optional().nullable(), + priority: z.number().int().min(0).max(10000).default(40), + approvalThreshold: z.number().int().min(1).max(50).default(2), + selectors: toolAccessSelectorSchema.optional(), + scope: toolTrustRuleScopeSchema.optional(), + argumentFilters: toolTrustRuleArgumentFiltersSchema.optional(), + expiresAt: z.coerce.date().optional().nullable(), + batchApproval: toolTrustRuleBatchApprovalSchema.optional().nullable(), +}); + +export type CreateToolTrustRuleFromActionRequest = z.infer; + +export const revokeToolTrustRuleSchema = z.object({ + reason: z.string().trim().max(1000).optional().nullable(), +}); + +export type RevokeToolTrustRule = z.infer; + +export const toolPolicyTestRequestSchema = z.object({ + companyId: z.string().uuid(), + actor: z.object({ + actorType: z.enum(["agent", "user", "system", "plugin"]), + actorId: z.string().trim().min(1).max(240), + agentId: z.string().uuid().optional().nullable(), + }), + runContext: z.object({ + heartbeatRunId: z.string().uuid().optional().nullable(), + issueId: z.string().uuid().optional().nullable(), + projectId: z.string().uuid().optional().nullable(), + routineId: z.string().uuid().optional().nullable(), + gatewayId: z.string().uuid().optional().nullable(), + gatewayPublicId: z.string().trim().min(1).max(120).regex(safeKeyPattern).optional().nullable(), + gatewayTokenId: z.string().uuid().optional().nullable(), + clientSubjectType: toolMcpGatewayTokenSubjectTypeSchema.optional().nullable(), + clientSubjectId: z.string().trim().min(1).max(240).optional().nullable(), + clientName: z.string().trim().min(1).max(160).optional().nullable(), + externalClient: z.boolean().optional().nullable(), + }).optional().nullable(), + request: z.object({ + applicationId: z.string().uuid().optional().nullable(), + connectionId: z.string().uuid().optional().nullable(), + catalogEntryId: z.string().uuid().optional().nullable(), + toolName: z.string().trim().min(1).max(240), + arguments: z.unknown().optional(), + idempotencyKey: z.string().trim().min(1).max(512).optional().nullable(), + sideEffecting: z.boolean().optional(), + }), + consumeRateLimit: z.boolean().optional(), + writeAuditEvent: z.boolean().optional(), +}); + +export type ToolPolicyTestRequestInput = z.infer; diff --git a/server/src/__tests__/plugin-secrets-handler.test.ts b/server/src/__tests__/plugin-secrets-handler.test.ts index ec89c8722c..f073b65a93 100644 --- a/server/src/__tests__/plugin-secrets-handler.test.ts +++ b/server/src/__tests__/plugin-secrets-handler.test.ts @@ -1,29 +1,213 @@ -import { describe, expect, it } from "vitest"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { + companies, + companySecretBindings, + companySecretProviderConfigs, + companySecrets, + companySecretVersions, + createDb, + plugins, + secretAccessEvents, +} from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { createPluginSecretsHandler, - PLUGIN_SECRET_REFS_DISABLED_MESSAGE, + extractSecretRefBindingsFromConfig, } from "../services/plugin-secrets-handler.js"; +import { secretService } from "../services/secrets.js"; -describe("createPluginSecretsHandler", () => { - it("fails closed for plugin secret resolution until company scoping lands", async () => { - const handler = createPluginSecretsHandler({ - db: {} as never, - pluginId: "11111111-1111-4111-8111-111111111111", - }); +const pluginId = "11111111-1111-4111-8111-111111111111"; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip; - await expect( - handler.resolve({ secretRef: "77777777-7777-4777-8777-777777777777" }), - ).rejects.toThrow(PLUGIN_SECRET_REFS_DISABLED_MESSAGE); +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping plugin secret handler integration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describe("extractSecretRefBindingsFromConfig", () => { + it("ignores UUID strings outside schema-declared secret fields", () => { + const externalProjectId = "77777777-7777-4777-8777-777777777777"; + + expect(extractSecretRefBindingsFromConfig( + { externalProjectId }, + { type: "object", properties: { externalProjectId: { type: "string" } } }, + )).toEqual([]); }); - it("still rejects malformed secret refs before the feature-disable guard", async () => { - const handler = createPluginSecretsHandler({ - db: {} as never, - pluginId: "11111111-1111-4111-8111-111111111111", - }); + it("rejects legacy UUID strings at schema-declared secret fields", () => { + const secretId = "77777777-7777-4777-8777-777777777777"; - await expect( - handler.resolve({ secretRef: "not-a-uuid" }), - ).rejects.toThrow(/invalid secret reference/i); + expect(() => extractSecretRefBindingsFromConfig( + { token: secretId }, + { type: "object", properties: { token: { format: "secret-ref" } } }, + )).toThrow(/must use.*secret_ref/i); + }); +}); + +describe("createPluginSecretsHandler fail-closed guards", () => { + it("requires company context before touching the database", async () => { + const db = { select: vi.fn(() => { throw new Error("db should not be touched"); }) }; + const handler = createPluginSecretsHandler({ db: db as never, pluginId }); + + await expect( + handler.resolve({ secretRef: { type: "secret_ref", secretId: randomUUID() } }), + ).rejects.toThrow(/companyId is required/i); + expect(db.select).not.toHaveBeenCalled(); + }); + + it("rejects legacy string refs before provider resolution", async () => { + const db = { select: vi.fn(() => { throw new Error("db should not be touched"); }) }; + const handler = createPluginSecretsHandler({ db: db as never, pluginId }); + + await expect( + handler.resolve({ companyId: randomUUID(), secretRef: randomUUID() }), + ).rejects.toThrow(/use \{ type: "secret_ref"/i); + expect(db.select).not.toHaveBeenCalled(); + }); +}); + +describeEmbeddedPostgres("createPluginSecretsHandler shared vault integration", () => { + let stopDb: (() => Promise) | null = null; + let db!: ReturnType; + const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + const secretsTmpDir = path.join(os.tmpdir(), `paperclip-plugin-secrets-${randomUUID()}`); + + beforeAll(async () => { + mkdirSync(secretsTmpDir, { recursive: true }); + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key"); + const started = await startEmbeddedPostgresTestDatabase("plugin-secrets-handler"); + stopDb = started.cleanup; + db = createDb(started.connectionString); + }); + + afterEach(async () => { + await db.delete(secretAccessEvents); + await db.delete(companySecretBindings); + await db.delete(companySecretVersions); + await db.delete(companySecrets); + await db.delete(companySecretProviderConfigs); + await db.delete(plugins); + await db.delete(companies); + }); + + afterAll(async () => { + await stopDb?.(); + if (previousKeyFile === undefined) { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + } else { + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile; + } + rmSync(secretsTmpDir, { recursive: true, force: true }); + }); + + async function seedCompany(name: string) { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name, + issuePrefix: `P${companyId.slice(0, 7)}`.toUpperCase(), + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + return companyId; + } + + async function seedPlugin() { + await db.insert(plugins).values({ + id: pluginId, + pluginKey: "paperclip.plugin-secrets-test", + packageName: "@paperclipai/plugin-secrets-test", + version: "0.0.1", + apiVersion: 1, + categories: ["automation"], + manifestJson: { + id: "paperclip.plugin-secrets-test", + apiVersion: 1, + version: "0.0.1", + displayName: "Plugin Secrets Test", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: { worker: "./dist/worker.js" }, + }, + status: "ready", + installOrder: 1, + }); + } + + it("resolves bound plugin refs through secretService and emits plugin_worker access events", async () => { + await seedPlugin(); + const companyId = await seedCompany("Plugin Co"); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `plugin-api-key-${randomUUID()}`, + provider: "local_encrypted", + value: "resolved-plugin-secret", + }); + await svc.syncSecretRefsForTarget(companyId, { targetType: "plugin", targetId: pluginId }, [ + { secretId: secret.id, configPath: "apiKey" }, + ], { replaceAll: true }); + + const handler = createPluginSecretsHandler({ db, pluginId }); + await expect( + handler.resolve({ + companyId, + secretRef: { type: "secret_ref", secretId: secret.id, version: "latest" }, + }), + ).resolves.toBe("resolved-plugin-secret"); + + const events = await db + .select() + .from(secretAccessEvents) + .where(eq(secretAccessEvents.secretId, secret.id)); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + companyId, + secretId: secret.id, + consumerType: "plugin_worker", + consumerId: pluginId, + configPath: "apiKey", + pluginId, + outcome: "success", + errorCode: null, + }); + }); + + it("fails closed for cross-company resolve before secret provider access", async () => { + await seedPlugin(); + const companyA = await seedCompany("A"); + const companyB = await seedCompany("B"); + const svc = secretService(db); + const foreignSecret = await svc.create(companyB, { + name: `foreign-plugin-secret-${randomUUID()}`, + provider: "local_encrypted", + value: "foreign-value", + }); + await svc.syncSecretRefsForTarget(companyB, { targetType: "plugin", targetId: pluginId }, [ + { secretId: foreignSecret.id, configPath: "apiKey" }, + ], { replaceAll: true }); + + const handler = createPluginSecretsHandler({ db, pluginId }); + await expect( + handler.resolve({ + companyId: companyA, + secretRef: { type: "secret_ref", secretId: foreignSecret.id, version: "latest" }, + }), + ).rejects.toThrow(/not bound/i); + + const events = await db + .select() + .from(secretAccessEvents) + .where(eq(secretAccessEvents.secretId, foreignSecret.id)); + expect(events).toHaveLength(0); }); }); diff --git a/server/src/routes/plugin-ui-static.ts b/server/src/routes/plugin-ui-static.ts index 2784f59303..ca9cae7db0 100644 --- a/server/src/routes/plugin-ui-static.ts +++ b/server/src/routes/plugin-ui-static.ts @@ -34,6 +34,8 @@ import crypto from "node:crypto"; import type { Db } from "@paperclipai/db"; import { pluginRegistryService } from "../services/plugin-registry.js"; import { logger } from "../middleware/logger.js"; +import { assertCompanyAccess } from "./authz.js"; +import { badRequest } from "../errors.js"; // --------------------------------------------------------------------------- // Constants @@ -277,11 +279,23 @@ export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions return; } - // Step 2b: Check for devUiUrl in plugin config — proxy to local dev server - // when a plugin author has configured a dev server URL for hot-reload. + const rawCompanyId = req.query.companyId; + if ( + Array.isArray(rawCompanyId) || + (rawCompanyId !== undefined && typeof rawCompanyId !== "string") + ) { + throw badRequest('"companyId" must be a string when provided'); + } + const companyId = typeof rawCompanyId === "string" ? rawCompanyId.trim() : ""; + if (companyId) { + assertCompanyAccess(req, companyId); + } + + // Step 2b: Check for devUiUrl in company-scoped plugin config — proxy to + // local dev server when a plugin author has configured hot-reload. // See PLUGIN_SPEC.md §27.2 — Local Development Workflow try { - const configRow = await registry.getConfig(plugin.id); + const configRow = companyId ? await registry.getConfig(plugin.id, companyId) : null; const devUiUrl = configRow && typeof configRow === "object" && diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index ed0f71a124..e73023f1df 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -81,8 +81,7 @@ import { setStoredLocalFolder, } from "../services/plugin-local-folders.js"; import { - extractSecretRefPathsFromConfig, - PLUGIN_SECRET_REFS_DISABLED_MESSAGE, + extractSecretRefBindingsFromConfig, } from "../services/plugin-secrets-handler.js"; import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js"; @@ -2133,6 +2132,11 @@ export function pluginRoutes( router.get("/plugins/:pluginId/config", async (req, res) => { assertBoardOrgAccess(req); const { pluginId } = req.params; + const companyId = typeof req.query.companyId === "string" ? req.query.companyId.trim() : ""; + if (!companyId) { + throw badRequest('"companyId" is required and must be a non-empty string'); + } + assertCompanyAccess(req, companyId); const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { @@ -2140,7 +2144,7 @@ export function pluginRoutes( return; } - const config = await registry.getConfig(plugin.id); + const config = await registry.getConfig(plugin.id, companyId); res.json(config); }); @@ -2170,7 +2174,12 @@ export function pluginRoutes( return; } - const body = req.body as { configJson?: Record } | undefined; + const body = req.body as { companyId?: string; configJson?: Record } | undefined; + const companyId = typeof body?.companyId === "string" ? body.companyId.trim() : ""; + if (!companyId) { + throw badRequest('"companyId" is required and must be a non-empty string'); + } + assertCompanyAccess(req, companyId); if (!body?.configJson || typeof body.configJson !== "object") { res.status(400).json({ error: '"configJson" is required and must be an object' }); return; @@ -2201,13 +2210,14 @@ export function pluginRoutes( } try { - const secretRefsByPath = extractSecretRefPathsFromConfig(body.configJson, schema); - if (secretRefsByPath.size > 0) { - res.status(422).json({ error: PLUGIN_SECRET_REFS_DISABLED_MESSAGE }); + const secretRefs = extractSecretRefBindingsFromConfig(body.configJson, schema); + if (secretRefs.length > 0) { + res.status(422).json({ error: "Plugin secret references require the governed tool-access server layer" }); return; } - const result = await registry.upsertConfig(plugin.id, { + const result = await registry.upsertConfig(plugin.id, companyId, { + companyId, configJson: body.configJson, }); await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, { @@ -2225,7 +2235,7 @@ export function pluginRoutes( await bridgeDeps.workerManager.call( plugin.id, "configChanged", - { config: body.configJson }, + { config: body.configJson, companyId }, ); } catch (rpcErr) { if ( diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 4f0cdb3419..09eacd4d1c 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -205,6 +205,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableEnvironments: parsed.data.enableEnvironments ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true, + enableApps: parsed.data.enableApps ?? false, enablePipelines: parsed.data.enablePipelines ?? false, enableCases: parsed.data.enableCases ?? false, enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false, @@ -213,6 +214,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false, enableCloudSync: parsed.data.enableCloudSync ?? false, enableExternalObjects: parsed.data.enableExternalObjects ?? false, + enableSmokeLab: parsed.data.enableSmokeLab ?? false, enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false, enableDecisions: parsed.data.enableDecisions ?? false, enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false, @@ -234,6 +236,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableEnvironments: false, enableIsolatedWorkspaces: false, enableStreamlinedLeftNavigation: true, + enableApps: false, enablePipelines: false, enableCases: false, enableConferenceRoomChat: false, @@ -242,6 +245,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableExperimentalFileViewer: false, enableCloudSync: false, enableExternalObjects: false, + enableSmokeLab: false, enableBuiltInAgents: false, enableDecisions: false, enableGoalsSidebarLink: false, diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 32168f230b..6984f42974 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -1062,8 +1062,10 @@ export function buildHostServices( return { config: { - async get() { - const configRow = await registry.getConfig(pluginId); + async get(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const configRow = await registry.getConfig(pluginId, companyId); return configRow?.configJson ?? {}; }, }, @@ -1239,7 +1241,9 @@ export function buildHostServices( secrets: { async resolve(params) { - return secretsHandler.resolve(params); + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return secretsHandler.resolve({ ...params, companyId }); }, }, diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index d074af0408..faa0517fa0 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -2133,18 +2133,11 @@ export function pluginLoader( const hostHandlers = buildHostHandlers(pluginId, manifest); // ------------------------------------------------------------------ - // 4. Retrieve plugin config (if any) + // 4. Bootstrap worker config // ------------------------------------------------------------------ - let config: Record = {}; - try { - const configRow = await registry.getConfig(pluginId); - if (configRow && typeof configRow === "object" && "configJson" in configRow) { - config = (configRow as { configJson: Record }).configJson ?? {}; - } - } catch { - // Config may not exist yet — use empty object - log.debug({ pluginId }, "plugin-loader: no config found, using empty config"); - } + // Plugin configuration is company-scoped. Workers receive an empty + // bootstrap config and must use ctx.config.get(companyId) at runtime. + const config: Record = {}; // ------------------------------------------------------------------ // 5. Spawn worker process diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts index b9fc3678f5..1ee05092fb 100644 --- a/server/src/services/plugin-registry.ts +++ b/server/src/services/plugin-registry.ts @@ -280,27 +280,27 @@ export function pluginRegistryService(db: Db) { // ----- Config --------------------------------------------------------- - /** Retrieve a plugin's instance configuration. */ - getConfig: (pluginId: string) => + /** Retrieve a plugin's company-scoped configuration. */ + getConfig: (pluginId: string, companyId: string) => db .select() .from(pluginConfig) - .where(eq(pluginConfig.pluginId, pluginId)) + .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId))) .then((rows) => rows[0] ?? null), /** - * Create or fully replace a plugin's instance configuration. - * If a config row already exists for the plugin it is replaced; + * Create or fully replace a plugin's company-scoped configuration. + * If a config row already exists for the plugin/company pair it is replaced; * otherwise a new row is inserted. */ - upsertConfig: async (pluginId: string, input: UpsertPluginConfig) => { + upsertConfig: async (pluginId: string, companyId: string, input: UpsertPluginConfig) => { const plugin = await getById(pluginId); if (!plugin) throw notFound("Plugin not found"); const existing = await db .select() .from(pluginConfig) - .where(eq(pluginConfig.pluginId, pluginId)) + .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId))) .then((rows) => rows[0] ?? null); if (existing) { @@ -311,7 +311,7 @@ export function pluginRegistryService(db: Db) { lastError: null, updatedAt: new Date(), }) - .where(eq(pluginConfig.pluginId, pluginId)) + .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId))) .returning() .then((rows) => rows[0]); } @@ -320,6 +320,7 @@ export function pluginRegistryService(db: Db) { .insert(pluginConfig) .values({ pluginId, + companyId, configJson: input.configJson, }) .returning() @@ -327,17 +328,17 @@ export function pluginRegistryService(db: Db) { }, /** - * Partially update a plugin's instance configuration via shallow merge. + * Partially update a plugin's company-scoped configuration via shallow merge. * If no config row exists yet one is created with the supplied values. */ - patchConfig: async (pluginId: string, input: PatchPluginConfig) => { + patchConfig: async (pluginId: string, companyId: string, input: PatchPluginConfig) => { const plugin = await getById(pluginId); if (!plugin) throw notFound("Plugin not found"); const existing = await db .select() .from(pluginConfig) - .where(eq(pluginConfig.pluginId, pluginId)) + .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId))) .then((rows) => rows[0] ?? null); if (existing) { @@ -349,7 +350,7 @@ export function pluginRegistryService(db: Db) { lastError: null, updatedAt: new Date(), }) - .where(eq(pluginConfig.pluginId, pluginId)) + .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId))) .returning() .then((rows) => rows[0]); } @@ -358,6 +359,7 @@ export function pluginRegistryService(db: Db) { .insert(pluginConfig) .values({ pluginId, + companyId, configJson: input.configJson, }) .returning() @@ -368,11 +370,11 @@ export function pluginRegistryService(db: Db) { * Record an error against a plugin's config (e.g. validation failure * against the plugin's instanceConfigSchema). */ - setConfigError: async (pluginId: string, lastError: string | null) => { + setConfigError: async (pluginId: string, companyId: string, lastError: string | null) => { const rows = await db .update(pluginConfig) .set({ lastError, updatedAt: new Date() }) - .where(eq(pluginConfig.pluginId, pluginId)) + .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId))) .returning(); if (rows.length === 0) throw notFound("Plugin config not found"); @@ -380,10 +382,10 @@ export function pluginRegistryService(db: Db) { }, /** Delete a plugin's config row. */ - deleteConfig: async (pluginId: string) => { + deleteConfig: async (pluginId: string, companyId: string) => { const rows = await db .delete(pluginConfig) - .where(eq(pluginConfig.pluginId, pluginId)) + .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId))) .returning(); return rows[0] ?? null; diff --git a/server/src/services/plugin-secrets-handler.ts b/server/src/services/plugin-secrets-handler.ts index ccc5878a00..308acbe858 100644 --- a/server/src/services/plugin-secrets-handler.ts +++ b/server/src/services/plugin-secrets-handler.ts @@ -1,115 +1,148 @@ /** - * Plugin secrets host-side handler — resolves secret references through the - * Paperclip secret provider system. - * - * When a plugin worker calls `ctx.secrets.resolve(secretRef)`, the JSON-RPC - * request arrives at the host with `{ secretRef }`. This module provides the - * concrete `HostServices.secrets` adapter that: - * - * 1. Parses the `secretRef` string to identify the secret. - * 2. Looks up the secret record and its latest version in the database. - * 3. Delegates to the configured `SecretProviderModule` to decrypt / - * resolve the raw value. - * 4. Returns the resolved plaintext value to the worker. - * - * ## Secret Reference Format - * - * A `secretRef` is a **secret UUID** — the primary key (`id`) of a row in - * the `company_secrets` table. Operators place these UUIDs into plugin - * config values; plugin workers resolve them at execution time via - * `ctx.secrets.resolve(secretId)`. - * - * ## Security Invariants - * - * - Resolved values are **never** logged, persisted, or included in error - * messages (per PLUGIN_SPEC.md §22). - * - The handler is capability-gated: only plugins with `secrets.read-ref` - * declared in their manifest may call it (enforced by `host-client-factory`). - * - The host handler itself does not cache resolved values. Each call goes - * through the secret provider to honour rotation. - * - * @see PLUGIN_SPEC.md §22 — Secrets - * @see host-client-factory.ts — capability gating - * @see services/secrets.ts — secretService used by agent env bindings + * Plugin secrets host-side handler. Plugin workers may resolve shared + * `secret_ref` config bindings only with an explicit company context. */ +import { and, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; +import { companySecretBindings } from "@paperclipai/db"; +import type { EnvSecretRefBinding, SecretProjectionClass, SecretVersionSelector } from "@paperclipai/shared"; +import { envBindingSecretRefSchema } from "@paperclipai/shared"; import { collectSecretRefPaths, isUuidSecretRef, readConfigValueAtPath, } from "./json-schema-secret-refs.js"; - -export const PLUGIN_SECRET_REFS_DISABLED_MESSAGE = - "Plugin secret references are disabled until company-scoped plugin config lands"; +import { secretService } from "./secrets.js"; +import { unprocessable } from "../errors.js"; // --------------------------------------------------------------------------- // Error helpers // --------------------------------------------------------------------------- -function invalidSecretRef(secretRef: string): Error { - const err = new Error(`Invalid secret reference: ${secretRef}`); +function invalidSecretRef(secretRef: unknown): Error { + const rendered = typeof secretRef === "string" ? secretRef : JSON.stringify(secretRef); + const err = new Error( + `Invalid secret reference for plugin: ${rendered ?? ""}. Use { type: "secret_ref", secretId, version? }`, + ); err.name = "InvalidSecretRefError"; return err; } +function requireCompanyId(companyId: unknown): string { + if (typeof companyId !== "string" || companyId.trim().length === 0) { + throw unprocessable("companyId is required for plugin secret resolution"); + } + return companyId.trim(); +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseSecretRefBinding(value: unknown): EnvSecretRefBinding | null { + const parsed = envBindingSecretRefSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +function assertSecretRefBinding( + value: unknown, + path: string, + rejectLegacyUuid = false, +): EnvSecretRefBinding | null { + if (rejectLegacyUuid && typeof value === "string" && isUuidSecretRef(value)) { + throw unprocessable( + `Plugin secret ref at ${path} must use { type: "secret_ref", secretId, version? }`, + ); + } + if (!isPlainRecord(value) || value.type !== "secret_ref") return null; + const parsed = parseSecretRefBinding(value); + if (!parsed) { + throw unprocessable(`Invalid secret_ref binding at ${path}`); + } + return parsed; +} + +export interface PluginConfigSecretRefBinding { + secretId: string; + configPath: string; + versionSelector?: SecretVersionSelector; + required?: boolean; + label?: string | null; + projectionClass?: SecretProjectionClass; + projectionAllowlistKey?: string | null; +} + // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- -/** - * Extract secret reference UUIDs from a plugin's configJson, scoped to only - * the fields annotated with `format: "secret-ref"` in the schema. - * - * When no schema is provided, falls back to collecting all UUID-shaped strings - * (backwards-compatible for plugins without a declared instanceConfigSchema). - */ +/** Extract shared object-shaped secret refs from plugin config. */ +export function extractSecretRefBindingsFromConfig( + configJson: unknown, + schema?: Record | null, +): PluginConfigSecretRefBinding[] { + if (configJson == null || typeof configJson !== "object") return []; + + const refsByPath = new Map(); + const addRef = (binding: EnvSecretRefBinding, configPath: string) => { + refsByPath.set(configPath, { + secretId: binding.secretId, + configPath, + versionSelector: binding.version ?? "latest", + required: true, + label: configPath, + projectionClass: binding.projectionClass, + projectionAllowlistKey: binding.projectionAllowlistKey ?? null, + }); + }; + + const secretPaths = collectSecretRefPaths(schema); + for (const dotPath of secretPaths) { + const current = readConfigValueAtPath(configJson as Record, dotPath); + const binding = assertSecretRefBinding(current, dotPath, true); + if (binding) addRef(binding, dotPath); + } + + function walk(value: unknown, path: string): void { + const binding = assertSecretRefBinding(value, path || "$"); + if (binding) { + addRef(binding, path || "$"); + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, path ? `${path}.${index}` : String(index))); + return; + } + if (!isPlainRecord(value)) return; + for (const [key, child] of Object.entries(value)) { + walk(child, path ? `${path}.${key}` : key); + } + } + + walk(configJson, ""); + return [...refsByPath.values()]; +} + +/** Backward-compatible helper returning only secret IDs. */ export function extractSecretRefsFromConfig( configJson: unknown, schema?: Record | null, ): Set { - return new Set(extractSecretRefPathsFromConfig(configJson, schema).keys()); + return new Set(extractSecretRefBindingsFromConfig(configJson, schema).map((ref) => ref.secretId)); } +/** Backward-compatible helper returning secret IDs grouped by config path. */ export function extractSecretRefPathsFromConfig( configJson: unknown, schema?: Record | null, ): Map> { const refs = new Map>(); - const addRef = (secretRef: string, path: string) => { - const existing = refs.get(secretRef) ?? new Set(); - existing.add(path); - refs.set(secretRef, existing); - }; - if (configJson == null || typeof configJson !== "object") return new Map(); - - const secretPaths = collectSecretRefPaths(schema); - - // If schema declares secret-ref paths, extract only those values. - if (secretPaths.size > 0) { - for (const dotPath of secretPaths) { - const current = readConfigValueAtPath(configJson as Record, dotPath); - if (typeof current === "string" && isUuidSecretRef(current)) { - addRef(current, dotPath); - } - } - return refs; + for (const ref of extractSecretRefBindingsFromConfig(configJson, schema)) { + const paths = refs.get(ref.secretId) ?? new Set(); + paths.add(ref.configPath); + refs.set(ref.secretId, paths); } - - // Fallback: no schema or no secret-ref annotations — collect all UUIDs. - // This preserves backwards compatibility for plugins that omit - // instanceConfigSchema. - function walkAll(value: unknown): void { - if (typeof value === "string") { - if (isUuidSecretRef(value)) addRef(value, "$"); - } else if (Array.isArray(value)) { - for (const item of value) walkAll(item); - } else if (value !== null && typeof value === "object") { - for (const v of Object.values(value as Record)) walkAll(v); - } - } - - walkAll(configJson); return refs; } @@ -117,69 +150,28 @@ export function extractSecretRefPathsFromConfig( // Handler factory // --------------------------------------------------------------------------- -/** - * Input shape for the `secrets.resolve` handler. - * - * Matches `WorkerToHostMethods["secrets.resolve"][0]` from `protocol.ts`. - */ export interface PluginSecretsResolveParams { - /** The secret reference string (a secret UUID). */ - secretRef: string; + /** Shared secret reference object from company-scoped plugin config. */ + secretRef: string | EnvSecretRefBinding; + /** Authorized company context for this worker invocation. */ + companyId?: string; + /** Config path that produced this ref. Required when a secret appears in multiple paths. */ + configPath?: string; + actorType?: "agent" | "user" | "system" | "plugin"; + actorId?: string | null; + issueId?: string | null; + heartbeatRunId?: string | null; } -/** - * Options for creating the plugin secrets handler. - */ export interface PluginSecretsHandlerOptions { - /** Database connection. */ db: Db; - /** - * The plugin ID using this handler. - * Used for logging context only; never included in error payloads - * that reach the plugin worker. - */ pluginId: string; } -/** - * The `HostServices.secrets` adapter for the plugin host-client factory. - */ export interface PluginSecretsService { - /** - * Resolve a secret reference to its current plaintext value. - * - * @param params - Contains the `secretRef` (UUID of the secret) - * @returns The resolved secret value - * @throws {Error} If the secret is not found, has no versions, or - * the provider fails to resolve - */ resolve(params: PluginSecretsResolveParams): Promise; } -/** - * Create a `HostServices.secrets` adapter for a specific plugin. - * - * The returned service looks up secrets by UUID, fetches the latest version - * material, and delegates to the appropriate `SecretProviderModule` for - * decryption. - * - * @example - * ```ts - * const secretsHandler = createPluginSecretsHandler({ db, pluginId }); - * const handlers = createHostClientHandlers({ - * pluginId, - * capabilities: manifest.capabilities, - * services: { - * secrets: secretsHandler, - * // ... - * }, - * }); - * ``` - * - * @param options - Database connection and plugin identity - * @returns A `PluginSecretsService` suitable for `HostServices.secrets` - */ -/** Simple sliding-window rate limiter for secret resolution attempts. */ function createRateLimiter(maxAttempts: number, windowMs: number) { const attempts = new Map(); @@ -199,40 +191,95 @@ function createRateLimiter(maxAttempts: number, windowMs: number) { export function createPluginSecretsHandler( options: PluginSecretsHandlerOptions, ): PluginSecretsService { - const { pluginId } = options; - - // Rate limit: max 30 resolution attempts per plugin per minute + const { db, pluginId } = options; const rateLimiter = createRateLimiter(30, 60_000); + async function lookupBinding(input: { + companyId: string; + secretId: string; + versionSelector: SecretVersionSelector; + configPath?: string; + }) { + const conditions = [ + eq(companySecretBindings.companyId, input.companyId), + eq(companySecretBindings.targetType, "plugin"), + eq(companySecretBindings.targetId, pluginId), + eq(companySecretBindings.secretId, input.secretId), + ]; + if (input.configPath) { + conditions.push(eq(companySecretBindings.configPath, input.configPath)); + } + const rows = await db + .select() + .from(companySecretBindings) + .where(and(...conditions)); + const matchingVersion = rows.filter( + (row) => row.versionSelector === String(input.versionSelector), + ); + return matchingVersion; + } + return { async resolve(params: PluginSecretsResolveParams): Promise { - const { secretRef } = params; + if (typeof params.secretRef === "string") { + throw invalidSecretRef(params.secretRef.trim() || ""); + } - // --------------------------------------------------------------- - // 0. Rate limiting — prevent brute-force UUID enumeration - // --------------------------------------------------------------- - if (!rateLimiter.check(pluginId)) { + const bindingRef = parseSecretRefBinding(params.secretRef); + if (!bindingRef) throw invalidSecretRef(params.secretRef); + + const companyId = requireCompanyId(params.companyId); + + if (!rateLimiter.check(`${companyId}:${pluginId}`)) { const err = new Error("Rate limit exceeded for secret resolution"); err.name = "RateLimitExceededError"; throw err; } - // --------------------------------------------------------------- - // 1. Validate the ref format - // --------------------------------------------------------------- - if (!secretRef || typeof secretRef !== "string" || secretRef.trim().length === 0) { - throw invalidSecretRef(secretRef ?? ""); + const versionSelector = bindingRef.version ?? "latest"; + const bindings = await lookupBinding({ + companyId, + secretId: bindingRef.secretId, + versionSelector, + configPath: params.configPath, + }); + + if (bindings.length === 0) { + throw unprocessable( + `Secret is not bound to plugin:${pluginId}${params.configPath ? ` at ${params.configPath}` : ""}`, + { code: "binding_missing" }, + ); + } + if (bindings.length > 1) { + throw unprocessable( + "Plugin secret reference is ambiguous; pass configPath when resolving this secret", + { code: "binding_ambiguous" }, + ); } - const trimmedRef = secretRef.trim(); - - if (!isUuidSecretRef(trimmedRef)) { - throw invalidSecretRef(trimmedRef); - } - - // Fail closed until plugin config and worker runtime both carry an - // explicit company scope for secret bindings and resolution. - throw new Error(PLUGIN_SECRET_REFS_DISABLED_MESSAGE); + const binding = bindings[0]!; + return secretService(db).resolveSecretValue(companyId, bindingRef.secretId, versionSelector, { + bindingContext: { + consumerType: "plugin", + consumerId: pluginId, + configPath: binding.configPath, + actorType: params.actorType ?? "plugin", + actorId: params.actorId ?? pluginId, + issueId: params.issueId ?? null, + heartbeatRunId: params.heartbeatRunId ?? null, + pluginId, + }, + accessContext: { + consumerType: "plugin_worker", + consumerId: pluginId, + configPath: binding.configPath, + actorType: params.actorType ?? "plugin", + actorId: params.actorId ?? pluginId, + issueId: params.issueId ?? null, + heartbeatRunId: params.heartbeatRunId ?? null, + pluginId, + }, + }); }, }; } diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index ca84486cbc..791f58eba1 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -26,6 +26,7 @@ import type { RemoteSecretImportRowResult, SecretProviderConfigDiscoveryPreviewResult, SecretBindingTargetType, + SecretProjectionClass, SecretProvider, SecretProviderConfigHealthResponse, SecretProviderConfigHealthStatus, @@ -33,6 +34,7 @@ import type { SecretVersionSelector, } from "@paperclipai/shared"; import { + CLASS3_STATIC_LEASE_ALLOWLIST, createSecretProviderConfigSchema, deriveProjectUrlKey, envBindingSchema, @@ -377,7 +379,13 @@ async function cleanupPreparedProviderWrite(input: { type CanonicalEnvBinding = | { type: "plain"; value: string } - | { type: "secret_ref"; secretId: string; version: number | "latest" } + | { + type: "secret_ref"; + secretId: string; + version: number | "latest"; + projectionClass: SecretProjectionClass; + projectionAllowlistKey: string | null; + } | { type: "user_secret_ref"; key: string; @@ -386,8 +394,10 @@ type CanonicalEnvBinding = allowMissingOverride: boolean; }; +type SecretAccessConsumerType = SecretBindingTargetType | "plugin_worker"; + type SecretConsumerContext = { - consumerType: SecretBindingTargetType; + consumerType: SecretAccessConsumerType; consumerId: string; configPath?: string | null; responsibleUserId?: string | null; @@ -400,8 +410,12 @@ type SecretConsumerContext = { allowedBindingIds?: string[] | null; }; +type SecretBindingContext = Omit & { + consumerType: SecretBindingTargetType; +}; + type SecretResolutionOptions = { - bindingContext?: SecretConsumerContext; + bindingContext?: SecretBindingContext; accessContext?: SecretConsumerContext; allowUserSecretScope?: boolean; }; @@ -439,6 +453,10 @@ export type MissingRuntimeBinding = { errorCode?: SecretResolutionErrorCode; }; +function missingRuntimeConsumerType(consumerType: SecretAccessConsumerType): SecretBindingTargetType { + return consumerType === "plugin_worker" ? "plugin" : consumerType; +} + type RuntimeSecretResolution = { value: string; manifestEntry: RuntimeSecretManifestEntry; @@ -502,9 +520,41 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding { type: "secret_ref", secretId: binding.secretId, version: binding.version ?? "latest", + projectionClass: binding.projectionClass ?? "unclassified", + projectionAllowlistKey: binding.projectionAllowlistKey ?? null, }; } +function assertClass3StaticLeaseAllowed(input: { + targetType: SecretBindingTargetType; + configPath: string; + projectionClass?: string | null; + projectionAllowlistKey?: string | null; +}) { + const projectionClass = input.projectionClass ?? "unclassified"; + if (projectionClass !== "class_3_static_lease") return; + if (!input.projectionAllowlistKey?.trim()) { + throw unprocessable("Class-3 static lease bindings require an allowlist key", { + code: "class_3_static_lease_allowlist_required", + targetType: input.targetType, + configPath: input.configPath, + }); + } + const allowed = CLASS3_STATIC_LEASE_ALLOWLIST.some((entry) => + entry.key === input.projectionAllowlistKey + && entry.targetType === input.targetType + && entry.configPath === input.configPath + ); + if (!allowed) { + throw unprocessable("Class-3 static lease binding is outside the approved allowlist", { + code: "class_3_static_lease_not_allowed", + allowlistKey: input.projectionAllowlistKey, + targetType: input.targetType, + configPath: input.configPath, + }); + } +} + function defaultProviderConfigStatus(provider: SecretProvider): SecretProviderConfigStatus { return COMING_SOON_SECRET_PROVIDERS.has(provider) ? "coming_soon" : "ready"; } @@ -556,7 +606,7 @@ function missingUserSecretDefinitionRuntimeBinding( errorCode: "user_secret_definition_missing" | "user_secret_definition_inactive", ): MissingRuntimeBinding { return { - consumerType: context.consumerType, + consumerType: missingRuntimeConsumerType(context.consumerType), consumerId: context.consumerId, configPath: entry.configPath, envKey: entry.key, @@ -737,7 +787,7 @@ export function secretService(db: Db) { async function assertBindingContext( companyId: string, secretId: string, - context: SecretConsumerContext | undefined, + context: SecretBindingContext | undefined, ) { if (!context) return null; if (!context.configPath) { @@ -765,6 +815,12 @@ export function secretService(db: Db) { { code: "binding_not_allowed" }, ); } + assertClass3StaticLeaseAllowed({ + targetType: binding.targetType as SecretBindingTargetType, + configPath: binding.configPath, + projectionClass: binding.projectionClass, + projectionAllowlistKey: binding.projectionAllowlistKey, + }); return binding; } @@ -1053,16 +1109,22 @@ export function secretService(db: Db) { } } + function isSecretResolutionOptions( + value: SecretBindingContext | SecretResolutionOptions | undefined, + ): value is SecretResolutionOptions { + return Boolean(value && ("bindingContext" in value || "accessContext" in value)); + } + async function resolveSecretValue( companyId: string, secretId: string, version: number | "latest", - context?: SecretConsumerContext, + contextOrOptions?: SecretBindingContext | SecretResolutionOptions, ): Promise { - return (await resolveSecretValueInternal(companyId, secretId, version, { - bindingContext: context, - accessContext: context, - })).value; + const options = isSecretResolutionOptions(contextOrOptions) + ? contextOrOptions + : { bindingContext: contextOrOptions, accessContext: contextOrOptions }; + return (await resolveSecretValueInternal(companyId, secretId, version, options)).value; } async function resolveSecretValueForEphemeralAccess( @@ -1112,6 +1174,31 @@ export function secretService(db: Db) { })).value; } + async function resolveSecretVersion( + companyId: string, + secretId: string, + version: number | "latest", + context?: SecretBindingContext, + ): Promise { + const secret = await getById(secretId); + if (!secret) throw notFound("Secret not found"); + if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); + const resolvedVersion = version === "latest" ? secret.latestVersion : version; + if (secret.status === "deleted") { + throw new HttpError(404, "Secret not found", { code: "secret_deleted" }); + } + if (secret.status !== "active") { + throw unprocessable("Secret is not active", { code: "secret_inactive" }); + } + await assertBindingContext(companyId, secret.id, context); + const versionRow = await getSecretVersion(secret.id, resolvedVersion); + if (!versionRow) throw new HttpError(404, "Secret version not found", { code: "version_missing" }); + if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) { + throw unprocessable("Secret version is not active", { code: "version_inactive" }); + } + return resolvedVersion; + } + async function normalizeEnvConfig( companyId: string, envValue: unknown, @@ -1154,6 +1241,8 @@ export function secretService(db: Db) { type: "secret_ref", secretId: binding.secretId, version: binding.version, + projectionClass: binding.projectionClass, + projectionAllowlistKey: binding.projectionAllowlistKey, }; } return normalized; @@ -1226,6 +1315,8 @@ export function secretService(db: Db) { type: "secret_ref", secretId: binding.secretId, version: binding.version, + projectionClass: binding.projectionClass, + projectionAllowlistKey: binding.projectionAllowlistKey, }; } if (binding.type === "user_secret_ref") { @@ -2928,6 +3019,7 @@ export function secretService(db: Db) { getById, getByName, resolveSecretValue, + resolveSecretVersion, resolveSecretValueForEphemeralAccess, create: async ( @@ -3402,8 +3494,16 @@ export function secretService(db: Db) { versionSelector?: SecretVersionSelector; required?: boolean; label?: string | null; + projectionClass?: SecretProjectionClass; + projectionAllowlistKey?: string | null; }) => { await assertSecretInCompany(input.companyId, input.secretId); + assertClass3StaticLeaseAllowed({ + targetType: input.targetType, + configPath: input.configPath, + projectionClass: input.projectionClass, + projectionAllowlistKey: input.projectionAllowlistKey, + }); const existing = await db .select() .from(companySecretBindings) @@ -3428,6 +3528,8 @@ export function secretService(db: Db) { versionSelector: String(input.versionSelector ?? "latest"), required: input.required ?? true, label: input.label ?? null, + projectionClass: input.projectionClass ?? "unclassified", + projectionAllowlistKey: input.projectionAllowlistKey ?? null, }) .returning() .then((rows) => rows[0]); @@ -3442,6 +3544,8 @@ export function secretService(db: Db) { versionSelector?: SecretVersionSelector; required?: boolean; label?: string | null; + projectionClass?: SecretProjectionClass; + projectionAllowlistKey?: string | null; }>, options?: { replaceAll?: boolean }, ) => { @@ -3451,15 +3555,27 @@ export function secretService(db: Db) { versionSelector: SecretVersionSelector; required: boolean; label: string | null; + projectionClass: SecretProjectionClass; + projectionAllowlistKey: string | null; }> = []; for (const ref of refs) { await assertSecretInCompany(companyId, ref.secretId); + const projectionClass = ref.projectionClass ?? "unclassified"; + const projectionAllowlistKey = ref.projectionAllowlistKey ?? null; + assertClass3StaticLeaseAllowed({ + targetType: target.targetType, + configPath: ref.configPath, + projectionClass, + projectionAllowlistKey, + }); normalizedRefs.push({ secretId: ref.secretId, configPath: ref.configPath, versionSelector: ref.versionSelector ?? "latest", required: ref.required ?? true, label: ref.label ?? null, + projectionClass, + projectionAllowlistKey, }); } @@ -3514,6 +3630,8 @@ export function secretService(db: Db) { versionSelector: String(ref.versionSelector), required: ref.required, label: ref.label, + projectionClass: ref.projectionClass, + projectionAllowlistKey: ref.projectionAllowlistKey, })), ); }); @@ -3545,6 +3663,8 @@ export function secretService(db: Db) { secretId: string; configPath: string; versionSelector: SecretVersionSelector; + projectionClass: SecretProjectionClass; + projectionAllowlistKey: string | null; }> = []; const userRefs: Array<{ definitionKey: string; @@ -3574,10 +3694,19 @@ export function secretService(db: Db) { } if (binding.type !== "secret_ref") continue; await assertSecretInCompany(companyId, binding.secretId, bindingDb); + const configPath = `${pathPrefix}.${key}`; + assertClass3StaticLeaseAllowed({ + targetType: target.targetType, + configPath, + projectionClass: binding.projectionClass, + projectionAllowlistKey: binding.projectionAllowlistKey, + }); refs.push({ secretId: binding.secretId, - configPath: `${pathPrefix}.${key}`, + configPath, versionSelector: binding.version, + projectionClass: binding.projectionClass, + projectionAllowlistKey: binding.projectionAllowlistKey, }); } @@ -3602,6 +3731,8 @@ export function secretService(db: Db) { configPath: ref.configPath, versionSelector: String(ref.versionSelector), required: true, + projectionClass: ref.projectionClass, + projectionAllowlistKey: ref.projectionAllowlistKey, })), ); }; @@ -3684,7 +3815,7 @@ export function secretService(db: Db) { resolveEnvBindings: async ( companyId: string, envValue: unknown, - context?: Omit, + context?: Omit, ): Promise<{ env: Record; secretKeys: Set; manifest: RuntimeSecretManifestEntry[] }> => { const record = asRecord(envValue); if (!record) return { env: {} as Record, secretKeys: new Set(), manifest: [] }; @@ -3752,7 +3883,7 @@ export function secretService(db: Db) { collectMissingRuntimeBindings: async ( companyId: string, envValue: unknown, - context: Omit, + context: Omit, ): Promise => { const record = asRecord(envValue); if (!record) return []; @@ -3912,7 +4043,7 @@ export function secretService(db: Db) { companyId: string, adapterConfig: Record, adapterType: string | null | undefined, - context: Omit, + context: Omit, ): Promise => { const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(adapterType); const secretRefs = secretFieldKeys.flatMap((key) => { @@ -4068,7 +4199,7 @@ export function secretService(db: Db) { resolveAdapterConfigForRuntime: async ( companyId: string, adapterConfig: Record, - context?: Omit, + context?: Omit, opts?: ResolveAdapterConfigForRuntimeOptions, ): Promise<{ config: Record; secretKeys: Set; manifest: RuntimeSecretManifestEntry[] }> => { const resolved = { ...adapterConfig }; diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 9a6605fd8a..300f2e2454 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -55,6 +55,7 @@ const SERVER_INFO_TOGGLE_SELECTOR = 'button[aria-label="Toggle server info debug view experimental setting"]'; const BUILT_IN_AGENTS_TOGGLE_SELECTOR = 'button[aria-label="Toggle built-in agents experimental setting"]'; +const APPS_TOGGLE_SELECTOR = 'button[aria-label="Toggle apps experimental setting"]'; const AUTO_RECOVERY_TOGGLE_SELECTOR = 'button[aria-label="Toggle task graph liveness auto-recovery"]'; @@ -63,6 +64,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableEnvironments: false, enableIsolatedWorkspaces: false, enableStreamlinedLeftNavigation: true, + enableApps: false, enablePipelines: false, enableCases: false, enableConferenceRoomChat: false, @@ -75,6 +77,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableTaskWatchdogs: false, enableCloudSync: false, enableServerInfoDebugView: false, + enableSmokeLab: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -185,6 +188,19 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) expect(warning?.textContent).toContain("no compatibility guarantees"); }); + it("enables the Apps UI from experimental settings", async () => { + await renderPage(); + + const toggle = container.querySelector(APPS_TOGGLE_SELECTOR); + expect(toggle?.getAttribute("aria-checked")).toBe("false"); + + await act(() => toggle?.click()); + await flushReact(); + + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ enableApps: true }); + expect(container.querySelector(APPS_TOGGLE_SELECTOR)?.getAttribute("aria-checked")).toBe("true"); + }); + it("does not render the Conference Room Chat experimental setting for now", async () => { await renderPage(); diff --git a/ui/storybook/fixtures/paperclipData.ts b/ui/storybook/fixtures/paperclipData.ts index 52bd2b2a0f..5f6bae1297 100644 --- a/ui/storybook/fixtures/paperclipData.ts +++ b/ui/storybook/fixtures/paperclipData.ts @@ -1563,6 +1563,8 @@ export const storybookSecretBindings: CompanySecretBinding[] = [ versionSelector: "latest", required: true, label: "Codex agent env", + projectionClass: "unclassified", + projectionAllowlistKey: null, createdAt: new Date("2026-03-02T09:00:00.000Z"), updatedAt: new Date("2026-03-02T09:00:00.000Z"), }, @@ -1576,6 +1578,8 @@ export const storybookSecretBindings: CompanySecretBinding[] = [ versionSelector: "latest", required: true, label: "Paperclip App project env", + projectionClass: "unclassified", + projectionAllowlistKey: null, createdAt: new Date("2026-03-02T09:00:00.000Z"), updatedAt: new Date("2026-03-02T09:00:00.000Z"), }, @@ -1589,6 +1593,8 @@ export const storybookSecretBindings: CompanySecretBinding[] = [ versionSelector: 2, required: true, label: "Prod environment", + projectionClass: "unclassified", + projectionAllowlistKey: null, createdAt: new Date("2026-04-22T14:01:00.000Z"), updatedAt: new Date("2026-04-22T14:01:00.000Z"), },