feat(mcp) [split 2/8]: add governed access contracts (#9557)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Governed MCP access spans contracts, runtime enforcement, adapters,
UI surfaces, and operator verification
> - The parity reference PR #9534 is too large for effective automated
or human review
> - The feature therefore needs a linear stack whose individual diffs
stay below the 100-file review limit
> - This pull request is split 2/8 and focuses on database schema and
shared governance contracts
> - The benefit is a standalone, testable review boundary while
preserving byte-for-byte parity at the top of the stack

## Linked Issues or Issue Description

- Related parity reference: #9534
- Problem: The governed access model needs additive persistence and
synchronized shared types before server enforcement can compile.
- Proposed solution: Adds migrations 0148–0169, tool-access and Smoke
Lab schema, shared types/validators/gallery helpers, and the minimal
compile-required contract consumers identified by boundary testing.
- Alternatives considered: keeping #9534 as one 403-file review, or
rewriting the feature to manufacture seams; both were rejected in favor
of path extraction plus compile-driven boundary moves.
- Roadmap alignment: this advances the existing governed MCP/tool-access
work already represented by #9534; it does not introduce a separate
roadmap initiative.
- Stack position: base branch is `pap10341-split/01-demo-servers`.
- Merge policy: merge bottom-up, in order, only after the complete
eight-PR stack has been reviewed and the top-of-stack parity gate
remains empty.
- Requested review: QA for migrations/validators; Greptile on every PR.

## What Changed

- Adds migrations 0148–0169, tool-access and Smoke Lab schema, shared
types/validators/gallery helpers, and the minimal compile-required
contract consumers identified by boundary testing.
- Keeps this PR below 100 changed files and independently typecheckable.
- Preserves the final tree from #9534 when combined with the other seven
stack levels.

## Verification

- `pnpm typecheck` — passed, including migration numbering and safety
checks
- `pnpm --filter @paperclipai/db test` — passed
- `pnpm --filter @paperclipai/shared test` — passed

## Risks

- Migration or contract mistakes could affect every upper layer; all
migrations are additive/idempotent and compile consumers are included in
this boundary.
- Stack risk: merging out of order can expose incomplete layers;
mitigate by following the documented bottom-up merge policy.
- Parity risk: later edits to an intermediate branch can drift from
#9534; mitigate by re-running the empty top-of-stack diff before merge.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, exact model ID `gpt-5.4`; runtime-managed context
window; medium reasoning with repository, shell, Git, GitHub CLI, and
code-execution tools enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] Internal references are omitted except the execution-plan link
explicitly required for this coordinated split stack
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge


## Stack Coordination

- Internal execution plan:
[PAP-13874](/PAP/issues/PAP-13874#document-plan)
- Parity reference: #9534
- Stack: #9556#9557#9558#9559#9560#9561#9562#9563
- Merge bottom-up only after full-stack review and an empty parity diff
at #9563.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-14 12:57:20 -05:00 committed by GitHub
parent 7b35de65aa
commit 1de0a3bb1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
81 changed files with 7278 additions and 308 deletions

View File

@ -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 () => {

View File

@ -985,10 +985,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise<void> {
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();

View File

@ -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), {

View File

@ -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<typeof createDb>;

View File

@ -2,6 +2,7 @@ export {
createDb,
getPostgresDataDirectory,
ensurePostgresDatabase,
resetPostgresDatabase,
inspectMigrations,
applyPendingMigrations,
reconcilePendingMigrationHistory,

View File

@ -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");

View File

@ -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;

View File

@ -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

View File

@ -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");

View File

@ -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 $$;

View File

@ -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");

View File

@ -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");

View File

@ -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");

View File

@ -0,0 +1 @@
ALTER TABLE "tool_oauth_states" ADD COLUMN IF NOT EXISTS "created_by_session_id" text;

View File

@ -0,0 +1 @@
ALTER TABLE "tool_profiles" ADD COLUMN IF NOT EXISTS "new_tools_reviewed_at" timestamp with time zone;

View File

@ -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;

View File

@ -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");

View File

@ -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");

View File

@ -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;

View File

@ -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");

View File

@ -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;

View File

@ -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");

View File

@ -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");

View File

@ -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");

View File

@ -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";

View File

@ -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");

View File

@ -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");

View File

@ -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
}
]
}

View File

@ -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(),
},

View File

@ -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,

View File

@ -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

View File

@ -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<Record<string, unknown>>().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,
),
}),
);

View File

@ -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<SmokeRunTrigger>().notNull(),
status: text("status").$type<SmokeRunStatus>().notNull().default("running"),
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
finishedAt: timestamp("finished_at", { withTimezone: true }),
summary: jsonb("summary").$type<Record<string, unknown>>().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<SmokeRunStepPath>().notNull(),
scenarioStep: text("scenario_step").notNull(),
status: text("status").$type<SmokeRunStepStatus>().notNull(),
detail: text("detail"),
screenshotArtifactRef: jsonb("screenshot_artifact_ref").$type<Record<string, unknown>>(),
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),
],
);

View File

@ -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<ToolApplicationType>().notNull(),
status: text("status").$type<ToolApplicationStatus>().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<Record<string, unknown>>().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<ToolConnectionKind>().notNull().default("managed"),
transport: text("transport").$type<ToolConnectionTransport>().notNull(),
status: text("status").$type<ToolConnectionStatus>().notNull().default("draft"),
enabled: boolean("enabled").notNull().default(false),
config: jsonb("config").$type<Record<string, unknown>>().notNull().default({}),
transportConfig: jsonb("transport_config").$type<Record<string, unknown>>().notNull().default({}),
credentialRefs: jsonb("credential_refs").$type<McpConnectionCredentialRef[]>().notNull().default([]),
credentialSecretRefs: jsonb("credential_secret_refs").$type<ToolCredentialSecretRef[]>().notNull().default([]),
healthStatus: text("health_status").$type<ToolConnectionHealthStatus>().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<ToolConnectionInstallTargetType>().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<ToolCatalogEntryKind>().notNull().default("tool"),
name: text("name").notNull(),
toolName: text("tool_name").notNull(),
title: text("title"),
description: text("description"),
inputSchema: jsonb("input_schema").$type<Record<string, unknown>>().notNull().default({}),
outputSchema: jsonb("output_schema").$type<Record<string, unknown>>(),
annotations: jsonb("annotations").$type<Record<string, unknown>>().notNull().default({}),
riskLevel: text("risk_level").$type<ToolRiskLevel>().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<ToolCatalogEntryStatus>().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<ToolProfileStatus>().notNull().default("active"),
defaultAction: text("default_action").$type<ToolProfileDefaultAction>().notNull().default("deny"),
newToolsReviewedAt: timestamp("new_tools_reviewed_at", { withTimezone: true }),
metadata: jsonb("metadata").$type<Record<string, unknown>>().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<ToolProfileEntrySelectorType>().notNull(),
effect: text("effect").$type<ToolProfileEntryEffect>().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<ToolRiskLevel>(),
conditions: jsonb("conditions").$type<Record<string, unknown>>(),
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<ToolProfileBindingTargetType>().notNull(),
targetId: text("target_id").notNull(),
priority: integer("priority").notNull().default(100),
metadata: jsonb("metadata").$type<Record<string, unknown>>().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<ToolMcpGatewayStatus>().notNull().default("active"),
profileId: uuid("profile_id").notNull().references(() => toolProfiles.id, { onDelete: "restrict" }),
defaultProfileMode: text("default_profile_mode").$type<ToolMcpGatewayDefaultProfileMode>().notNull().default("gateway_only"),
contextScopeType: text("context_scope_type").$type<ToolMcpGatewayContextScopeType>().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<ToolMcpGatewayAuthConfig>().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<ToolMcpGatewayHeaderPolicy>().notNull().default({
version: 1,
callerPassthrough: { enabled: false, allowedHeaders: [] },
staticHeaders: [],
generatedMetadata: { enabled: false, allowedHeaders: [] },
responseHeaders: { forwardMcpRequiredHeaders: true, forwardSafeCacheHeaders: true },
}),
metadataPolicy: jsonb("metadata_policy").$type<ToolMcpGatewayMetadataPolicy>().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<ToolMcpGatewayOnDemandToolsConfig>().notNull().default({
enabled: false,
searchToolName: "search_tools",
runToolName: "run_tool",
}),
metadata: jsonb("metadata").$type<Record<string, unknown>>().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<ToolMcpGatewayTokenSubjectType>().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<ToolMcpGatewayTokenAction[]>().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<ToolPolicyType>().notNull(),
priority: integer("priority").notNull().default(100),
enabled: boolean("enabled").notNull().default(true),
selectors: jsonb("selectors").$type<Record<string, unknown>>().notNull().default({}),
conditions: jsonb("conditions").$type<Record<string, unknown>>(),
config: jsonb("config").$type<Record<string, unknown>>(),
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<ToolRuntimeKind>().notNull().default("local_stdio"),
slotKey: text("slot_key").notNull(),
status: text("status").$type<ToolRuntimeSlotStatus>().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<ToolConnectionHealthStatus>().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<Record<string, unknown>>().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<string[]>().notNull().default([]),
envKeys: jsonb("env_keys").$type<string[]>().notNull().default([]),
tools: jsonb("tools").$type<Array<Record<string, unknown>>>().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<ToolMcpGatewayTokenSubjectType>(),
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<ToolMcpGatewayTokenSubjectType>(),
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<ToolRiskLevel>(),
toolName: text("tool_name").notNull(),
argumentsHash: text("arguments_hash"),
argumentsSummary: jsonb("arguments_summary").$type<ToolRedactedValueSummary>(),
policyDecision: text("policy_decision").$type<ToolPolicyDecision>(),
matchedPolicyIds: jsonb("matched_policy_ids").$type<string[]>().notNull().default([]),
policyExplanation: jsonb("policy_explanation").$type<Record<string, unknown>>(),
credentialScopeSummary: jsonb("credential_scope_summary").$type<Record<string, unknown>>(),
headerPolicySummary: jsonb("header_policy_summary").$type<Record<string, unknown>>(),
approvalState: text("approval_state").$type<ToolInvocationApprovalState>().notNull().default("not_required"),
status: text("status").$type<ToolInvocationStatus>().notNull().default("pending"),
upstreamRequestId: text("upstream_request_id"),
resultHash: text("result_hash"),
resultSummary: jsonb("result_summary").$type<ToolRedactedValueSummary>(),
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<ToolActionRequestStatus>().notNull().default("pending"),
canonicalArgumentsHash: text("canonical_arguments_hash").notNull(),
canonicalArgumentsSummary: jsonb("canonical_arguments_summary").$type<ToolRedactedValueSummary>().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<ToolAuditEventType>().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<ToolMcpGatewayTokenSubjectType>(),
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<ToolPolicyDecision>(),
matchedPolicyIds: jsonb("matched_policy_ids").$type<string[]>().notNull().default([]),
reasonCode: text("reason_code"),
policyExplanation: jsonb("policy_explanation").$type<Record<string, unknown>>(),
credentialScopeSummary: jsonb("credential_scope_summary").$type<Record<string, unknown>>(),
headerPolicySummary: jsonb("header_policy_summary").$type<Record<string, unknown>>(),
outcome: text("outcome").$type<ToolAuditOutcome>().notNull().default("pending"),
latencyMs: integer("latency_ms"),
argumentsSummary: jsonb("arguments_summary").$type<ToolRedactedValueSummary>(),
requestHash: text("request_hash"),
requestSummary: jsonb("request_summary").$type<ToolRedactedValueSummary>(),
resultHash: text("result_hash"),
resultSummary: jsonb("result_summary").$type<ToolRedactedValueSummary>(),
resultSizeBytes: integer("result_size_bytes"),
redactionPlan: jsonb("redaction_plan").$type<Record<string, unknown>>(),
rateLimitState: jsonb("rate_limit_state").$type<Record<string, unknown>>(),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
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<ConnectionTokenIssuancePath>().notNull(),
requestedScope: jsonb("requested_scope").$type<string[]>().notNull().default([]),
issuedScope: jsonb("issued_scope").$type<string[]>().notNull().default([]),
ttlSeconds: integer("ttl_seconds"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
tokenHash: text("token_hash"),
outcome: text("outcome").$type<ConnectionTokenIssuanceOutcome>().notNull(),
errorCode: text("error_code"),
metadata: jsonb("metadata").$type<Record<string, unknown>>().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<ToolRateLimitWindowKind>().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<Record<string, unknown>>().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),
],
);

View File

@ -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",

View File

@ -422,7 +422,7 @@ function hostFetchJson<T>(path: string, init?: RequestInit): Promise<T> {
});
}
function useSettingsConfig() {
function useSettingsConfig(companyId: string | null) {
const [configJson, setConfigJson] = useState<Record<string, unknown>>({ ...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<string, unknown> | null } | null>(`/api/plugins/${PLUGIN_ID}/config`)
hostFetchJson<{ configJson?: Record<string, unknown> | 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<string, unknown>) {
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<string | null>(null);
function setField(key: string, value: unknown) {

View File

@ -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, "id" | "createdAt">): DemoRecord {
return next;
}
async function getConfig(ctx: PluginContext): Promise<KitchenSinkConfig> {
const config = await ctx.config.get();
async function getConfig(ctx: PluginContext, companyId?: string): Promise<KitchenSinkConfig> {
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<void> {
await ctx.state.set({ scopeKind: "instance", stateKey }, value);
}
@ -248,13 +255,14 @@ function runtimeLaunchersSnapshot(): PluginLauncherRegistration[] {
}
async function registerDataHandlers(ctx: PluginContext): Promise<void> {
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<void> {
});
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<void> {
});
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<void> {
});
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<void> {
});
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<PluginHealthDiagnostics> {
const ctx = currentContext;
const config = ctx ? await getConfig(ctx) : DEFAULT_CONFIG;
const config: KitchenSinkConfig = DEFAULT_CONFIG;
return {
status: "ok",
message: "Kitchen Sink plugin ready",

View File

@ -639,11 +639,11 @@ function protectDistillationSourceBody(input: {
async function resolvePaperclipDistillationLimits(
ctx: PluginContext,
input: Pick<PaperclipSourceBundleInput, "maxCharacters" | "maxCharactersPerSource" | "routineRun">,
input: Pick<PaperclipSourceBundleInput, "companyId" | "maxCharacters" | "maxCharactersPerSource" | "routineRun">,
): Promise<PaperclipDistillationLimits> {
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<string, unknown>;
const config = await ctx.config.get(input.companyId) as Record<string, unknown>;
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<boolean> {
async function autoApplyEnabled(ctx: PluginContext, companyId: string, requested: boolean | undefined): Promise<boolean> {
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

View File

@ -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<PluginHealthDiagnostics>;
/**
* 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.
*

View File

@ -100,7 +100,10 @@ export class InvocationScopeDeniedError extends Error {
export interface HostServices {
/** Provides `config.get`. */
config: {
get(): Promise<Record<string, unknown>>;
get(
params: WorkerToHostMethods["config.get"][0],
context?: WorkerHostCallContext,
): Promise<Record<string, unknown>>;
};
/** 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<string>;
resolve(
params: WorkerToHostMethods["secrets.resolve"][0],
context?: WorkerHostCallContext,
): Promise<string>;
};
/** 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

View File

@ -304,6 +304,7 @@ export type {
PluginDatabaseClient,
HumanCompanyMembershipRole,
MembershipStatus,
EnvSecretRefBinding,
} from "./types.js";
// Manifest and constant types re-exported from @paperclipai/shared

View File

@ -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<string, unknown>;
/** 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<string, unknown>;
/** 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<string, never>, result: Record<string, unknown>];
"config.get": [params: { companyId?: string }, result: Record<string, unknown>];
// 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,
];

View File

@ -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<string, unknown>;
}

View File

@ -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<Record<string, unknown>>;
get(companyId?: string): Promise<Record<string, unknown>>;
}
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<string>;
resolve(
secretRef: string | EnvSecretRefBinding,
options?: { companyId?: string; configPath?: string },
): Promise<string>;
}
/**

View File

@ -422,8 +422,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
config: {
async get() {
return callHost("config.get", {} as Record<string, never>);
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<string> {
return callHost("secrets.resolve", { secretRef });
async resolve(secretRef, options = {}): Promise<string> {
return callHost("secrets.resolve", {
secretRef,
companyId: options.companyId,
configPath: options.configPath,
});
},
},

View File

@ -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 = {

View File

@ -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`,

View File

@ -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
// ---------------------------------------------------------------------------

View File

@ -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();
});
});

View File

@ -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;
}

View File

@ -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,

View File

@ -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",

View File

@ -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);
}
});
});

View File

@ -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<ToolConnectionTransport, "remote_http">;
url: string;
}
| {
transport: Extract<ToolConnectionTransport, "local_stdio">;
templateKey: string;
};
export interface AppGalleryEntry {
key: string;
name: string;
logoUrl: string;
tagline: string;
description?: string;
authKind: AppGalleryAuthKind;
transportTemplate: AppGalleryTransportTemplate;
credentialFields: AppGalleryCredentialField[];
recommendedDefaults: Record<string, unknown>;
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;
}

View File

@ -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,

View File

@ -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;

View File

@ -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 {

View File

@ -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<string, unknown>;
/** Most recent config validation error, if any. */

View File

@ -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;

View File

@ -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<string, unknown>;
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<string, unknown> | 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<string, unknown> | null;
detail: string | null;
}

File diff suppressed because it is too large Load Diff

View File

@ -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";

View File

@ -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,
});
});
});

View File

@ -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),

View File

@ -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({

View File

@ -1135,25 +1135,27 @@ export const installPluginSchema = z.object({
export type InstallPlugin = z.infer<typeof installPluginSchema>;
// ---------------------------------------------------------------------------
// 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<typeof upsertPluginConfigSchema>;
/**
* 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()),
});

View File

@ -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<typeof createSecretBindingSchema>;

View File

@ -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<typeof createSmokeRunSchema>;
export type UpdateSmokeRun = z.infer<typeof updateSmokeRunSchema>;
export type RecordSmokeRunStep = z.infer<typeof recordSmokeRunStepSchema>;

View File

@ -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"]);
});
});

View File

@ -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<string | number> = []) {
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<typeof createToolApplicationSchema>;
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<typeof updateToolApplicationSchema>;
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<typeof createToolConnectionSchema>;
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<typeof updateToolConnectionSchema>;
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<typeof putToolConnectionInstallsSchema>;
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<typeof connectionTokenRequestSchema>;
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<typeof createToolStdioCommandTemplateSchema>;
export const disableToolStdioCommandTemplateSchema = z.object({
reason: z.string().trim().max(1000).optional().nullable(),
});
export type DisableToolStdioCommandTemplate = z.infer<typeof disableToolStdioCommandTemplateSchema>;
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<typeof connectToolAppSchema>;
export const reconnectToolAppSchema = z.object({
credentialValues: z.record(z.string().trim().min(1).max(200), z.string().min(1)),
});
export type ReconnectToolApp = z.infer<typeof reconnectToolAppSchema>;
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<typeof finishToolAppSchema>;
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<typeof upsertToolCatalogEntrySchema>;
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<typeof createToolProfileSchema>;
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<typeof updateToolProfileSchema>;
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<typeof createToolProfileEntrySchema>;
export const createToolProfileEntryForProfileSchema = createToolProfileEntrySchema.omit({ profileId: true });
export type CreateToolProfileEntryForProfile = z.infer<typeof createToolProfileEntryForProfileSchema>;
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<typeof updateToolProfileEntrySchema>;
export const createToolProfileWithEntriesSchema = createToolProfileSchema.extend({
entries: z.array(createToolProfileEntryForProfileSchema).max(250).optional(),
});
export type CreateToolProfileWithEntries = z.infer<typeof createToolProfileWithEntriesSchema>;
export const duplicateToolProfileSchema = z.object({
name: z.string().trim().min(1).max(160),
includeAssignments: z.boolean().default(false),
});
export type DuplicateToolProfile = z.infer<typeof duplicateToolProfileSchema>;
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<typeof updateToolProfileWithEntriesSchema>;
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<typeof reviewToolProfileNewToolsSchema>;
export const deleteToolProfileSchema = z.object({
force: z.boolean().default(false),
reassignToProfileId: z.string().uuid().optional(),
}).default({});
export type DeleteToolProfile = z.infer<typeof deleteToolProfileSchema>;
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<typeof createToolProfileBindingSchema>;
export const createToolProfileBindingForProfileSchema = createToolProfileBindingSchema.omit({ profileId: true });
export type CreateToolProfileBindingForProfile = z.infer<typeof createToolProfileBindingForProfileSchema>;
export const unbindToolProfileBindingSchema = createToolProfileBindingForProfileSchema.pick({
targetType: true,
targetId: true,
});
export type UnbindToolProfileBinding = z.infer<typeof unbindToolProfileBindingSchema>;
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<typeof createToolMcpGatewaySchema>;
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<typeof updateToolMcpGatewaySchema>;
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<typeof createToolMcpGatewayTokenSchema>;
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<typeof createToolPolicySchema>;
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<typeof updateToolPolicySchema>;
export const reorderToolPoliciesSchema = z.object({
policyIds: z.array(z.string().uuid()).min(1).max(500),
});
export type ReorderToolPolicies = z.infer<typeof reorderToolPoliciesSchema>;
export const duplicateToolPolicySchema = z.object({
name: z.string().trim().min(1).max(160).optional(),
});
export type DuplicateToolPolicy = z.infer<typeof duplicateToolPolicySchema>;
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<typeof createToolInvocationSchema>;
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<typeof createToolActionRequestSchema>;
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<typeof toolConnectionTestCallSchema>;
export const importMcpJsonSchema = z.object({
mcpJson: z.union([z.string(), z.record(z.string(), z.unknown())]),
});
export type ImportMcpJson = z.infer<typeof importMcpJsonSchema>;
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<typeof createToolTrustRuleFromActionRequestSchema>;
export const revokeToolTrustRuleSchema = z.object({
reason: z.string().trim().max(1000).optional().nullable(),
});
export type RevokeToolTrustRule = z.infer<typeof revokeToolTrustRuleSchema>;
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<typeof toolPolicyTestRequestSchema>;

View File

@ -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<void>) | null = null;
let db!: ReturnType<typeof createDb>;
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);
});
});

View File

@ -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" &&

View File

@ -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<string, unknown> } | undefined;
const body = req.body as { companyId?: string; configJson?: Record<string, unknown> } | 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 (

View File

@ -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,

View File

@ -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 });
},
},

View File

@ -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<string, unknown> = {};
try {
const configRow = await registry.getConfig(pluginId);
if (configRow && typeof configRow === "object" && "configJson" in configRow) {
config = (configRow as { configJson: Record<string, unknown> }).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<string, unknown> = {};
// ------------------------------------------------------------------
// 5. Spawn worker process

View File

@ -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;

View File

@ -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 ?? "<empty>"}. 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<string, unknown> {
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<string, unknown> | null,
): PluginConfigSecretRefBinding[] {
if (configJson == null || typeof configJson !== "object") return [];
const refsByPath = new Map<string, PluginConfigSecretRefBinding>();
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<string, unknown>, 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<string, unknown> | null,
): Set<string> {
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<string, unknown> | null,
): Map<string, Set<string>> {
const refs = new Map<string, Set<string>>();
const addRef = (secretRef: string, path: string) => {
const existing = refs.get(secretRef) ?? new Set<string>();
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<string, unknown>, 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<string>();
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<string, unknown>)) 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<string>;
}
/**
* 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<string, number[]>();
@ -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<string> {
const { secretRef } = params;
if (typeof params.secretRef === "string") {
throw invalidSecretRef(params.secretRef.trim() || "<empty>");
}
// ---------------------------------------------------------------
// 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 ?? "<empty>");
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,
},
});
},
};
}

View File

@ -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<SecretConsumerContext, "consumerType"> & {
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<string> {
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<number> {
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<SecretConsumerContext, "configPath">,
context?: Omit<SecretBindingContext, "configPath">,
): Promise<{ env: Record<string, string>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => {
const record = asRecord(envValue);
if (!record) return { env: {} as Record<string, string>, secretKeys: new Set<string>(), manifest: [] };
@ -3752,7 +3883,7 @@ export function secretService(db: Db) {
collectMissingRuntimeBindings: async (
companyId: string,
envValue: unknown,
context: Omit<SecretConsumerContext, "configPath">,
context: Omit<SecretBindingContext, "configPath">,
): Promise<MissingRuntimeBinding[]> => {
const record = asRecord(envValue);
if (!record) return [];
@ -3912,7 +4043,7 @@ export function secretService(db: Db) {
companyId: string,
adapterConfig: Record<string, unknown>,
adapterType: string | null | undefined,
context: Omit<SecretConsumerContext, "configPath">,
context: Omit<SecretBindingContext, "configPath">,
): Promise<MissingRuntimeBinding[]> => {
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<string, unknown>,
context?: Omit<SecretConsumerContext, "configPath">,
context?: Omit<SecretBindingContext, "configPath">,
opts?: ResolveAdapterConfigForRuntimeOptions,
): Promise<{ config: Record<string, unknown>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => {
const resolved = { ...adapterConfig };

View File

@ -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<HTMLButtonElement>(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();

View File

@ -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"),
},