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:
DottaandPaperclip authored and GitHub committed 2026-07-14 12:57:20 -05:00
1 parent 7b35de65aa
commit 1de0a3bb1e
81 files changed
+7277 -307

No files matched your search

+74
View File
@@ -1,6 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { gunzipSync } from "node:zlib";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres"; import postgres from "postgres";
import { createBufferedTextFileWriter, runDatabaseBackup, runDatabaseRestore } from "./backup-lib.js"; import { createBufferedTextFileWriter, runDatabaseBackup, runDatabaseRestore } from "./backup-lib.js";
@@ -410,6 +411,79 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
60_000, 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( it(
"restores legacy public-only backups without migration history", "restores legacy public-only backups without migration history",
async () => { async () => {
+4 -1
View File
@@ -985,10 +985,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise<void> { export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise<void> {
const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5));
let psqlRestoreError: unknown = null;
try { try {
await restoreWithPsql(opts, connectTimeout); await restoreWithPsql(opts, connectTimeout);
return; return;
} catch (error) { } catch (error) {
psqlRestoreError = error;
if (!(await hasStatementBreakpoints(opts.backupFile))) { if (!(await hasStatementBreakpoints(opts.backupFile))) {
throw new Error( throw new Error(
`Failed to restore ${basename(opts.backupFile)} with psql: ${sanitizeRestoreErrorMessage(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()) .map((line) => line.trim())
.find((line) => line.length > 0 && !line.startsWith("--")) .find((line) => line.length > 0 && !line.startsWith("--"))
: null; : null;
const psqlMessage = psqlRestoreError === null ? "" : `; psql error: ${sanitizeRestoreErrorMessage(psqlRestoreError)}`;
throw new Error( 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 { } finally {
await sql.end(); await sql.end();
+29
View File
@@ -5,6 +5,7 @@ import postgres from "postgres";
import { import {
applyPendingMigrations, applyPendingMigrations,
inspectMigrations, inspectMigrations,
resetPostgresDatabase,
} from "./client.js"; } from "./client.js";
import { import {
getEmbeddedPostgresTestSupport, 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", () => { describeEmbeddedPostgres("applyPendingMigrations", () => {
it("rejects unallowlisted migration backfills that bump updated_at on user-visible tables", async () => { 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), { const entries = await fs.promises.readdir(new URL("./migrations", import.meta.url), {
+21
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>; export type Db = ReturnType<typeof createDb>;
+1
View File
@@ -2,6 +2,7 @@ export {
createDb, createDb,
getPostgresDataDirectory, getPostgresDataDirectory,
ensurePostgresDatabase, ensurePostgresDatabase,
resetPostgresDatabase,
inspectMigrations, inspectMigrations,
applyPendingMigrations, applyPendingMigrations,
reconcilePendingMigrationHistory, reconcilePendingMigrationHistory,
@@ -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");
@@ -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;
@@ -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
@@ -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");
@@ -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 $$;
@@ -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");
@@ -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");
@@ -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");
@@ -0,0 +1 @@
ALTER TABLE "tool_oauth_states" ADD COLUMN IF NOT EXISTS "created_by_session_id" text;
@@ -0,0 +1 @@
ALTER TABLE "tool_profiles" ADD COLUMN IF NOT EXISTS "new_tools_reviewed_at" timestamp with time zone;
@@ -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;
@@ -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");
@@ -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");
@@ -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;
@@ -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");
@@ -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;
@@ -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");
@@ -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");
@@ -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");
@@ -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";
@@ -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");
@@ -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");
@@ -1023,6 +1023,160 @@
"when": 1783953514660, "when": 1783953514660,
"tag": "0147_cost_event_status", "tag": "0147_cost_event_status",
"breakpoints": true "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
} }
] ]
} }
@@ -14,6 +14,8 @@ export const companySecretBindings = pgTable(
versionSelector: text("version_selector").notNull().default("latest"), versionSelector: text("version_selector").notNull().default("latest"),
required: boolean("required").notNull().default(true), required: boolean("required").notNull().default(true),
label: text("label"), label: text("label"),
projectionClass: text("projection_class").notNull().default("unclassified"),
projectionAllowlistKey: text("projection_allowlist_key"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, },
+25
View File
@@ -87,6 +87,7 @@ export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_
export { heartbeatRuns } from "./heartbeat_runs.js"; export { heartbeatRuns } from "./heartbeat_runs.js";
export { heartbeatRunEvents } from "./heartbeat_run_events.js"; export { heartbeatRunEvents } from "./heartbeat_run_events.js";
export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js"; export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js";
export { smokeRuns, smokeRunSteps } from "./smoke_lab.js";
export { costEvents } from "./cost_events.js"; export { costEvents } from "./cost_events.js";
export { financeEvents } from "./finance_events.js"; export { financeEvents } from "./finance_events.js";
export { approvals } from "./approvals.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 { companySecretBindings } from "./company_secret_bindings.js";
export { userSecretDeclarations } from "./user_secret_declarations.js"; export { userSecretDeclarations } from "./user_secret_declarations.js";
export { secretAccessEvents } from "./secret_access_events.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 { export {
companySkills, companySkills,
companySkillVersions, companySkillVersions,
@@ -6,8 +6,9 @@ import { plugins } from "./plugins.js";
* `plugin_company_settings` table — stores operator-managed plugin settings * `plugin_company_settings` table — stores operator-managed plugin settings
* scoped to a specific company. * scoped to a specific company.
* *
* This is distinct from `plugin_config`, which stores instance-wide plugin * This is distinct from `plugin_config`, which stores the plugin's declared
* configuration. Each company can have at most one settings row per plugin. * operator configuration. Each company can have at most one settings row per
* plugin.
* *
* Rows represent explicit overrides from the default company behavior: * Rows represent explicit overrides from the default company behavior:
* - no row => plugin is enabled for the company by default * - no row => plugin is enabled for the company by default
+10 -4
View File
@@ -1,10 +1,10 @@
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core"; import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { plugins } from "./plugins.js"; import { plugins } from "./plugins.js";
/** /**
* `plugin_config` table — stores operator-provided instance configuration * `plugin_config` table — stores operator-provided configuration for each
* for each plugin (one row per plugin, enforced by a unique index on * plugin within a company (one row per plugin/company pair).
* `plugin_id`).
* *
* The `config_json` column holds the values that the operator enters in the * The `config_json` column holds the values that the operator enters in the
* plugin settings UI. These values are validated at runtime against the * plugin settings UI. These values are validated at runtime against the
@@ -19,12 +19,18 @@ export const pluginConfig = pgTable(
pluginId: uuid("plugin_id") pluginId: uuid("plugin_id")
.notNull() .notNull()
.references(() => plugins.id, { onDelete: "cascade" }), .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({}), configJson: jsonb("config_json").$type<Record<string, unknown>>().notNull().default({}),
lastError: text("last_error"), lastError: text("last_error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, },
(table) => ({ (table) => ({
pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId), pluginCompanyIdx: uniqueIndex("plugin_config_plugin_company_idx").on(
table.pluginId,
table.companyId,
),
}), }),
); );
+48
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),
],
);
+813
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),
],
);
@@ -65,10 +65,11 @@ const plugin = definePlugin({
async setup(ctx) { async setup(ctx) {
ctx.logger.info(`${PLUGIN_NAME} plugin setup`); ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
// Expose the current plugin config so UI components can read operator // Expose the current company-scoped plugin config so UI components can read
// settings from the canonical instance config store. // operator settings from the canonical config store.
ctx.data.register("plugin-config", async () => { ctx.data.register("plugin-config", async (params) => {
const config = await ctx.config.get(); const companyId = typeof params.companyId === "string" ? params.companyId : "";
const config = companyId ? await ctx.config.get(companyId) : null;
return { return {
showFilesInSidebar: config?.showFilesInSidebar === true, showFilesInSidebar: config?.showFilesInSidebar === true,
commentAnnotationMode: config?.commentAnnotationMode ?? "both", commentAnnotationMode: config?.commentAnnotationMode ?? "both",
@@ -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 [configJson, setConfigJson] = useState<Record<string, unknown>>({ ...DEFAULT_CONFIG });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -430,8 +430,15 @@ function useSettingsConfig() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (!companyId) {
setLoading(false);
setError("Select a company before loading plugin config.");
return () => {
cancelled = true;
};
}
setLoading(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) => { .then((result) => {
if (cancelled) return; if (cancelled) return;
setConfigJson({ ...DEFAULT_CONFIG, ...(result?.configJson ?? {}) }); setConfigJson({ ...DEFAULT_CONFIG, ...(result?.configJson ?? {}) });
@@ -447,14 +454,15 @@ function useSettingsConfig() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [companyId]);
async function save(nextConfig: Record<string, unknown>) { async function save(nextConfig: Record<string, unknown>) {
if (!companyId) throw new Error("Select a company before saving plugin config.");
setSaving(true); setSaving(true);
try { try {
await hostFetchJson(`/api/plugins/${PLUGIN_ID}/config`, { await hostFetchJson(`/api/plugins/${PLUGIN_ID}/config`, {
method: "POST", method: "POST",
body: JSON.stringify({ configJson: nextConfig }), body: JSON.stringify({ companyId, configJson: nextConfig }),
}); });
setConfigJson(nextConfig); setConfigJson(nextConfig);
setError(null); setError(null);
@@ -2103,7 +2111,7 @@ export function KitchenSinkPage({ context }: PluginPageProps) {
} }
export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) { 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); const [savedMessage, setSavedMessage] = useState<string | null>(null);
function setField(key: string, value: unknown) { function setField(key: string, value: unknown) {
@@ -8,6 +8,7 @@ import {
definePlugin, definePlugin,
runWorker, runWorker,
type PaperclipPlugin, type PaperclipPlugin,
type EnvSecretRefBinding,
type PluginContext, type PluginContext,
type PluginEntityQuery, type PluginEntityQuery,
type PluginEvent, type PluginEvent,
@@ -41,7 +42,7 @@ type KitchenSinkConfig = {
showCommentContextMenuItem?: boolean; showCommentContextMenuItem?: boolean;
enableWorkspaceDemos?: boolean; enableWorkspaceDemos?: boolean;
enableProcessDemos?: boolean; enableProcessDemos?: boolean;
secretRefExample?: string; secretRefExample?: string | EnvSecretRefBinding;
httpDemoUrl?: string; httpDemoUrl?: string;
allowedCommands?: string[]; allowedCommands?: string[];
workspaceScratchFile?: string; workspaceScratchFile?: string;
@@ -91,14 +92,20 @@ function pushRecord(record: Omit<DemoRecord, "id" | "createdAt">): DemoRecord {
return next; return next;
} }
async function getConfig(ctx: PluginContext): Promise<KitchenSinkConfig> { async function getConfig(ctx: PluginContext, companyId?: string): Promise<KitchenSinkConfig> {
const config = await ctx.config.get(); const config = await ctx.config.get(companyId);
return { return {
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
...(config as KitchenSinkConfig), ...(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> { async function writeInstanceState(ctx: PluginContext, stateKey: string, value: unknown): Promise<void> {
await ctx.state.set({ scopeKind: "instance", stateKey }, value); await ctx.state.set({ scopeKind: "instance", stateKey }, value);
} }
@@ -248,13 +255,14 @@ function runtimeLaunchersSnapshot(): PluginLauncherRegistration[] {
} }
async function registerDataHandlers(ctx: PluginContext): Promise<void> { async function registerDataHandlers(ctx: PluginContext): Promise<void> {
ctx.data.register("plugin-config", async () => { ctx.data.register("plugin-config", async (params) => {
return await getConfig(ctx); const companyId = typeof params.companyId === "string" ? params.companyId : undefined;
return await getConfig(ctx, companyId);
}); });
ctx.data.register("overview", async (params) => { ctx.data.register("overview", async (params) => {
const companyId = typeof params.companyId === "string" ? params.companyId : ""; 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 companies = await ctx.companies.list({ limit: 200, offset: 0 });
const projects = companyId ? await ctx.projects.list({ companyId, limit: 200, offset: 0 }) : []; const projects = companyId ? await ctx.projects.list({ companyId, limit: 200, offset: 0 }) : [];
const issues = companyId ? await listIssuesForCompany(ctx, companyId, 200) : []; 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) => { 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 const url = typeof params.url === "string" && params.url.length > 0
? params.url ? params.url
: config.httpDemoUrl || DEFAULT_CONFIG.httpDemoUrl; : config.httpDemoUrl || DEFAULT_CONFIG.httpDemoUrl;
@@ -607,29 +616,32 @@ async function registerActionHandlers(ctx: PluginContext): Promise<void> {
}); });
ctx.actions.register("resolve-secret", async (params) => { ctx.actions.register("resolve-secret", async (params) => {
const config = await getConfig(ctx); const companyId = getCurrentCompanyId(params);
const secretRef = typeof params.secretRef === "string" && params.secretRef.length > 0 const config = await getConfig(ctx, companyId);
const secretRef = isSecretRefBinding(params.secretRef)
? params.secretRef ? params.secretRef
: config.secretRefExample || ""; : isSecretRefBinding(config.secretRefExample)
? config.secretRefExample
: null;
if (!secretRef) { if (!secretRef) {
throw new Error("No secret reference configured"); 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({ pushRecord({
level: "info", level: "info",
source: "secrets", source: "secrets",
message: `Resolved secret reference ${secretRef}`, message: `Resolved secret reference ${secretRef.secretId}`,
}); });
return { return {
secretRef, secretRef: secretRef.secretId,
resolvedLength: resolved.length, resolvedLength: resolved.length,
preview: resolved.length > 0 ? `${resolved.slice(0, 2)}***` : "", preview: resolved.length > 0 ? `${resolved.slice(0, 2)}***` : "",
}; };
}); });
ctx.actions.register("run-process", async (params) => { ctx.actions.register("run-process", async (params) => {
const config = await getConfig(ctx);
const companyId = getCurrentCompanyId(params); const companyId = getCurrentCompanyId(params);
const config = await getConfig(ctx, companyId);
const projectId = typeof params.projectId === "string" ? params.projectId : ""; const projectId = typeof params.projectId === "string" ? params.projectId : "";
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined; const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
const commandKey = typeof params.commandKey === "string" ? params.commandKey : "pwd"; 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) => { 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) { if (!config.enableWorkspaceDemos) {
throw new Error("Workspace demos are disabled in plugin settings"); throw new Error("Workspace demos are disabled in plugin settings");
} }
const companyId = getCurrentCompanyId(params);
const projectId = typeof params.projectId === "string" ? params.projectId : ""; const projectId = typeof params.projectId === "string" ? params.projectId : "";
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined; const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0 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) => { 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) { if (!config.enableWorkspaceDemos) {
throw new Error("Workspace demos are disabled in plugin settings"); throw new Error("Workspace demos are disabled in plugin settings");
} }
const companyId = getCurrentCompanyId(params);
const projectId = typeof params.projectId === "string" ? params.projectId : ""; const projectId = typeof params.projectId === "string" ? params.projectId : "";
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined; const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0 const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0
@@ -965,8 +977,7 @@ const plugin: PaperclipPlugin = definePlugin({
}, },
async onHealth(): Promise<PluginHealthDiagnostics> { async onHealth(): Promise<PluginHealthDiagnostics> {
const ctx = currentContext; const config: KitchenSinkConfig = DEFAULT_CONFIG;
const config = ctx ? await getConfig(ctx) : DEFAULT_CONFIG;
return { return {
status: "ok", status: "ok",
message: "Kitchen Sink plugin ready", message: "Kitchen Sink plugin ready",
@@ -639,11 +639,11 @@ function protectDistillationSourceBody(input: {
async function resolvePaperclipDistillationLimits( async function resolvePaperclipDistillationLimits(
ctx: PluginContext, ctx: PluginContext,
input: Pick<PaperclipSourceBundleInput, "maxCharacters" | "maxCharactersPerSource" | "routineRun">, input: Pick<PaperclipSourceBundleInput, "companyId" | "maxCharacters" | "maxCharactersPerSource" | "routineRun">,
): Promise<PaperclipDistillationLimits> { ): Promise<PaperclipDistillationLimits> {
assertRequestedCharacterLimit("maxCharacters", input.maxCharacters, DEFAULT_MAX_PAPERCLIP_CURSOR_WINDOW_CHARS); assertRequestedCharacterLimit("maxCharacters", input.maxCharacters, DEFAULT_MAX_PAPERCLIP_CURSOR_WINDOW_CHARS);
assertRequestedCharacterLimit("maxCharactersPerSource", input.maxCharactersPerSource, DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_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( const maxCharactersPerSource = Math.min(
normalizeBundleLimit(input.maxCharactersPerSource, DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS), normalizeBundleLimit(input.maxCharactersPerSource, DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS),
normalizeBundleLimit(config.maxPaperclipIssueSourceCharacters, 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); return Math.ceil((characters / 1000) * costCentsPerThousandSourceCharacters);
} }
async function assertSourceWithinConfiguredLimit(ctx: PluginContext, contents: string) { async function assertSourceWithinConfiguredLimit(ctx: PluginContext, companyId: string, contents: string) {
const config = await ctx.config.get(); const config = await ctx.config.get(companyId);
const maxSourceBytes = normalizeMaxSourceBytes(config.maxSourceBytes); const maxSourceBytes = normalizeMaxSourceBytes(config.maxSourceBytes);
const sourceBytes = byteLength(contents); const sourceBytes = byteLength(contents);
if (sourceBytes > maxSourceBytes) { if (sourceBytes > maxSourceBytes) {
@@ -1866,7 +1866,7 @@ export async function captureWikiSource(ctx: PluginContext, input: CaptureSource
const wikiId = normalizeWikiId(input.wikiId); const wikiId = normalizeWikiId(input.wikiId);
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug }); const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
const title = stringField(input.title) ?? "Untitled source"; 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 hash = contentHash(input.contents);
const rawPath = input.rawPath const rawPath = input.rawPath
? assertRawPath(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) { if (getDistillationAutoApplyRestriction().autoApplyRestriction) {
return false; return false;
} }
const config = await ctx.config.get(); const config = await ctx.config.get(companyId);
const configured = (config as { autoApplyIngestPatches?: unknown }).autoApplyIngestPatches !== false; const configured = (config as { autoApplyIngestPatches?: unknown }).autoApplyIngestPatches !== false;
return configured && requested !== false; return configured && requested !== false;
} }
@@ -3462,7 +3462,7 @@ export async function distillPaperclipProjectPage(ctx: PluginContext, input: Pap
} }
const autoApplyRestriction = getDistillationAutoApplyRestriction(); const autoApplyRestriction = getDistillationAutoApplyRestriction();
const canAutoApply = await autoApplyEnabled(ctx, input.autoApply); const canAutoApply = await autoApplyEnabled(ctx, input.companyId, input.autoApply);
if (!canAutoApply || reviewRequired) { if (!canAutoApply || reviewRequired) {
const autoApplyWarning = const autoApplyWarning =
autoApplyRestriction.autoApplyRestriction autoApplyRestriction.autoApplyRestriction
+6 -4
View File
@@ -19,10 +19,12 @@
* *
* // Subscribe to events * // Subscribe to events
* ctx.events.on("issue.created", async (event) => { * 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/...`, { * await ctx.http.fetch(`https://api.linear.app/...`, {
* method: "POST", * method: "POST",
* headers: { Authorization: `Bearer ${await ctx.secrets.resolve(config.apiKeyRef as string)}` }, * headers: { Authorization: `Bearer ${apiKey}` },
* body: JSON.stringify({ title: event.payload.title }), * body: JSON.stringify({ title: event.payload.title }),
* }); * });
* }); * });
@@ -203,8 +205,8 @@ export interface PluginDefinition {
onHealth?(): Promise<PluginHealthDiagnostics>; onHealth?(): Promise<PluginHealthDiagnostics>;
/** /**
* Called when the operator updates the plugin's instance configuration at * Called when the operator updates this plugin's company-scoped configuration
* runtime, without restarting the worker. * at runtime, without restarting the worker.
* *
* If not implemented, the host restarts the worker to apply the new config. * If not implemented, the host restarts the worker to apply the new config.
* *
@@ -100,7 +100,10 @@ export class InvocationScopeDeniedError extends Error {
export interface HostServices { export interface HostServices {
/** Provides `config.get`. */ /** Provides `config.get`. */
config: { 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. */ /** Provides trusted company-scoped local folder helpers. */
@@ -147,7 +150,10 @@ export interface HostServices {
/** Provides `secrets.resolve`. */ /** Provides `secrets.resolve`. */
secrets: { secrets: {
resolve(params: WorkerToHostMethods["secrets.resolve"][0]): Promise<string>; resolve(
params: WorkerToHostMethods["secrets.resolve"][0],
context?: WorkerHostCallContext,
): Promise<string>;
}; };
/** Provides `activity.log`. */ /** Provides `activity.log`. */
@@ -570,10 +576,12 @@ export function createHostClientHandlers(
} }
const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId); const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId);
if (!allowedCompanyId) return;
if (requested.kind === "all") { if (requested.kind === "all") {
if (method === "companies.list") return; if (method === "companies.list") return;
if (!allowedCompanyId) {
throw new InvocationScopeDeniedError(pluginId, method, "company context is required");
}
throw new InvocationScopeDeniedError( throw new InvocationScopeDeniedError(
pluginId, pluginId,
method, method,
@@ -581,6 +589,10 @@ export function createHostClientHandlers(
); );
} }
if (!allowedCompanyId) {
throw new InvocationScopeDeniedError(pluginId, method, "company context is required");
}
if (requested.companyId !== allowedCompanyId) { if (requested.companyId !== allowedCompanyId) {
throw new InvocationScopeDeniedError( throw new InvocationScopeDeniedError(
pluginId, 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. * Assert that the plugin has the required capability for a method.
* Throws `CapabilityDeniedError` if the capability is missing. * Throws `CapabilityDeniedError` if the capability is missing.
@@ -627,8 +673,9 @@ export function createHostClientHandlers(
return { return {
// Config // Config
"config.get": gated("config.get", async () => { "config.get": gated("config.get", async (params, context) => {
return services.config.get(); const companyId = resolveRequiredCompanyId("config.get", params, context);
return services.config.get({ ...params, companyId }, context);
}), }),
"localFolders.declarations": gated("localFolders.declarations", async (params) => { "localFolders.declarations": gated("localFolders.declarations", async (params) => {
@@ -696,8 +743,9 @@ export function createHostClientHandlers(
}), }),
// Secrets // Secrets
"secrets.resolve": gated("secrets.resolve", async (params) => { "secrets.resolve": gated("secrets.resolve", async (params, context) => {
return services.secrets.resolve(params); const companyId = resolveRequiredCompanyId("secrets.resolve", params, context);
return services.secrets.resolve({ ...params, companyId }, context);
}), }),
// Activity // Activity
+1
View File
@@ -304,6 +304,7 @@ export type {
PluginDatabaseClient, PluginDatabaseClient,
HumanCompanyMembershipRole, HumanCompanyMembershipRole,
MembershipStatus, MembershipStatus,
EnvSecretRefBinding,
} from "./types.js"; } from "./types.js";
// Manifest and constant types re-exported from @paperclipai/shared // Manifest and constant types re-exported from @paperclipai/shared
+7 -4
View File
@@ -45,6 +45,7 @@ import type {
ExternalObjectLivenessState, ExternalObjectLivenessState,
ExternalObjectMentionConfidence, ExternalObjectMentionConfidence,
ExternalObjectMentionSourceKind, ExternalObjectMentionSourceKind,
EnvSecretRefBinding,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared"; export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
@@ -302,7 +303,7 @@ export interface WorkerHostCallContext {
export interface InitializeParams { export interface InitializeParams {
/** Full plugin manifest snapshot. */ /** Full plugin manifest snapshot. */
manifest: PaperclipPluginManifestV1; manifest: PaperclipPluginManifestV1;
/** Resolved operator configuration (validated against `instanceConfigSchema`). */ /** Bootstrap configuration. Company-scoped config is read via `ctx.config.get(companyId)`. */
config: Record<string, unknown>; config: Record<string, unknown>;
/** Instance-level metadata. */ /** Instance-level metadata. */
instanceInfo: { instanceInfo: {
@@ -333,8 +334,10 @@ export interface InitializeResult {
* @see PLUGIN_SPEC.md §13.4 — `configChanged` * @see PLUGIN_SPEC.md §13.4 — `configChanged`
*/ */
export interface ConfigChangedParams { export interface ConfigChangedParams {
/** The newly resolved configuration. */ /** The newly resolved company-scoped configuration. */
config: Record<string, unknown>; 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 { export interface WorkerToHostMethods {
// Config // Config
"config.get": [params: Record<string, never>, result: Record<string, unknown>]; "config.get": [params: { companyId?: string }, result: Record<string, unknown>];
// Trusted local folders // Trusted local folders
"localFolders.declarations": [ "localFolders.declarations": [
@@ -1071,7 +1074,7 @@ export interface WorkerToHostMethods {
// Secrets // Secrets
"secrets.resolve": [ "secrets.resolve": [
params: { secretRef: string }, params: { secretRef: string | EnvSecretRefBinding; companyId?: string; configPath?: string },
result: string, result: string,
]; ];
+1 -1
View File
@@ -75,7 +75,7 @@ export interface TestHarnessOptions {
manifest: PaperclipPluginManifestV1; manifest: PaperclipPluginManifestV1;
/** Optional capability override. Defaults to `manifest.capabilities`. */ /** Optional capability override. Defaults to `manifest.capabilities`. */
capabilities?: PluginCapability[]; capabilities?: PluginCapability[];
/** Initial config returned by `ctx.config.get()`. */ /** Initial config returned by `ctx.config.get(companyId)`. */
config?: Record<string, unknown>; config?: Record<string, unknown>;
} }
+16 -11
View File
@@ -46,6 +46,7 @@ import type {
PermissionKey, PermissionKey,
PrincipalPermissionGrant, PrincipalPermissionGrant,
PrincipalType, PrincipalType,
EnvSecretRefBinding,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import type { PluginPerformActionContext } from "./protocol.js"; import type { PluginPerformActionContext } from "./protocol.js";
@@ -138,6 +139,7 @@ export type {
PermissionKey, PermissionKey,
PrincipalPermissionGrant, PrincipalPermissionGrant,
PrincipalType, PrincipalType,
EnvSecretRefBinding,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -426,11 +428,11 @@ export interface PluginExecutionWorkspaceMetadata {
*/ */
export interface PluginConfigClient { export interface PluginConfigClient {
/** /**
* Returns the resolved operator configuration for this plugin instance. * Returns the resolved operator configuration for this plugin in a company.
* Values are validated against the plugin's `instanceConfigSchema` by the * When called during a host-scoped invocation, the host may derive the
* host before being passed to the worker. * companyId; otherwise callers must pass it explicitly.
*/ */
get(): Promise<Record<string, unknown>>; get(companyId?: string): Promise<Record<string, unknown>>;
} }
export interface PluginLocalFolderProblem { export interface PluginLocalFolderProblem {
@@ -642,9 +644,9 @@ export interface PluginHttpClient {
* *
* Requires `secrets.read-ref` capability. * Requires `secrets.read-ref` capability.
* *
* Plugins store secret *references* in their config (e.g. a secret name). * Plugins store shared `{ type: "secret_ref", secretId, version? }` bindings in
* This client resolves the reference through the Paperclip secret provider * company-scoped config. This client resolves a bound ref through the
* system and returns the resolved value at execution time. * Paperclip secret provider system at execution time.
* *
* @see PLUGIN_SPEC.md §22 — Secrets * @see PLUGIN_SPEC.md §22 — Secrets
*/ */
@@ -652,16 +654,19 @@ export interface PluginSecretsClient {
/** /**
* Resolve a secret reference to its current value. * Resolve a secret reference to its current value.
* *
* The reference is a string identifier pointing to a secret configured * The reference must be the shared `secret_ref` object shape from plugin
* in the Paperclip secret provider (e.g. `"MY_API_KEY"`). * config. Legacy string UUID references fail closed.
* *
* Secret values are resolved at call time and must never be cached or * Secret values are resolved at call time and must never be cached or
* written to logs, config, or other persistent storage. * 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 * @returns The resolved secret value
*/ */
resolve(secretRef: string): Promise<string>; resolve(
secretRef: string | EnvSecretRefBinding,
options?: { companyId?: string; configPath?: string },
): Promise<string>;
} }
/** /**
+8 -4
View File
@@ -422,8 +422,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
}, },
config: { config: {
async get() { async get(companyId?: string) {
return callHost("config.get", {} as Record<string, never>); return callHost("config.get", companyId ? { companyId } : {});
}, },
}, },
@@ -573,8 +573,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
}, },
secrets: { secrets: {
async resolve(secretRef: string): Promise<string> { async resolve(secretRef, options = {}): Promise<string> {
return callHost("secrets.resolve", { secretRef }); return callHost("secrets.resolve", {
secretRef,
companyId: options.companyId,
configPath: options.configPath,
});
}, },
}, },
@@ -9,6 +9,65 @@ import {
import { PLUGIN_RPC_ERROR_CODES } from "../src/protocol.js"; import { PLUGIN_RPC_ERROR_CODES } from "../src/protocol.js";
describe("createHostClientHandlers invocation company scope", () => { 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 () => { it("rejects company-scoped host calls outside the current invocation company", async () => {
const projectsList = vi.fn(async () => []); const projectsList = vi.fn(async () => []);
const services = { const services = {
+18
View File
@@ -23,6 +23,24 @@ export const API = {
goals: `${API_PREFIX}/goals`, goals: `${API_PREFIX}/goals`,
approvals: `${API_PREFIX}/approvals`, approvals: `${API_PREFIX}/approvals`,
secrets: `${API_PREFIX}/secrets`, 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`, userSecretDefinitions: `${API_PREFIX}/companies/:companyId/user-secret-definitions`,
userSecretDefinition: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId`, userSecretDefinition: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId`,
userSecretDefinitionCoverage: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId/coverage`, userSecretDefinitionCoverage: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId/coverage`,
+285
View File
@@ -659,6 +659,7 @@ export const SECRET_BINDING_TARGET_TYPES = [
"plugin", "plugin",
"issue", "issue",
"run", "run",
"tool_connection",
"system", "system",
] as const; ] as const;
export type SecretBindingTargetType = (typeof SECRET_BINDING_TARGET_TYPES)[number]; export type SecretBindingTargetType = (typeof SECRET_BINDING_TARGET_TYPES)[number];
@@ -674,6 +675,55 @@ export const SECRET_ACCESS_OUTCOMES = [
] as const; ] as const;
export type SecretAccessOutcome = (typeof SECRET_ACCESS_OUTCOMES)[number]; 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 const STORAGE_PROVIDERS = ["local_disk", "s3"] as const;
export type StorageProvider = (typeof STORAGE_PROVIDERS)[number]; export type StorageProvider = (typeof STORAGE_PROVIDERS)[number];
@@ -860,6 +910,12 @@ export const PERMISSION_KEYS = [
"skills:create", "skills:create",
"skills:suggest-changes", "skills:suggest-changes",
"environments:manage", "environments:manage",
"tools:admin",
"tools:manage_connections",
"tools:manage_profiles",
"tools:view_audit",
"tools:use",
"tools:manage_runtime",
"users:invite", "users:invite",
"users:manage_permissions", "users:manage_permissions",
"tasks:assign", "tasks:assign",
@@ -870,6 +926,235 @@ export const PERMISSION_KEYS = [
] as const; ] as const;
export type PermissionKey = (typeof PERMISSION_KEYS)[number]; 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 // Plugin System — see doc/plugins/PLUGIN_SPEC.md for the full specification
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -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();
});
});
+103
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;
}
+327
View File
@@ -133,6 +133,21 @@ export {
type SourceTrustPromotionSource, type SourceTrustPromotionSource,
type SourceTrustMetadata, type SourceTrustMetadata,
} from "./trust-policy.js"; } 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 { export {
COMPANY_STATUSES, COMPANY_STATUSES,
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES, DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
@@ -229,6 +244,8 @@ export {
SECRET_PROVIDERS, SECRET_PROVIDERS,
SECRET_PROVIDER_CONFIG_STATUSES, SECRET_PROVIDER_CONFIG_STATUSES,
SECRET_PROVIDER_CONFIG_HEALTH_STATUSES, SECRET_PROVIDER_CONFIG_HEALTH_STATUSES,
SECRET_PROJECTION_CLASSES,
CLASS3_STATIC_LEASE_ALLOWLIST,
SECRET_SCOPES, SECRET_SCOPES,
STORAGE_PROVIDERS, STORAGE_PROVIDERS,
BILLING_TYPES, BILLING_TYPES,
@@ -258,6 +275,39 @@ export {
JOIN_REQUEST_TYPES, JOIN_REQUEST_TYPES,
JOIN_REQUEST_STATUSES, JOIN_REQUEST_STATUSES,
PERMISSION_KEYS, 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_API_VERSION,
PLUGIN_STATUSES, PLUGIN_STATUSES,
PLUGIN_CATEGORIES, PLUGIN_CATEGORIES,
@@ -370,6 +420,8 @@ export {
type SecretProvider, type SecretProvider,
type SecretProviderConfigStatus, type SecretProviderConfigStatus,
type SecretProviderConfigHealthStatus, type SecretProviderConfigHealthStatus,
type SecretProjectionClass,
type Class3StaticLeaseAllowlistKey,
type SecretScope, type SecretScope,
type StorageProvider, type StorageProvider,
type BillingType, type BillingType,
@@ -399,6 +451,9 @@ export {
type JoinRequestType, type JoinRequestType,
type JoinRequestStatus, type JoinRequestStatus,
type PermissionKey, type PermissionKey,
type ConnectionTokenIssuancePath,
type ConnectionTokenIssuanceOutcome,
type ToolAccessActivityAction,
type PluginStatus, type PluginStatus,
type PluginCategory, type PluginCategory,
type PluginCapability, type PluginCapability,
@@ -784,6 +839,8 @@ export type {
RequestConfirmationTarget, RequestConfirmationTarget,
RequestConfirmationPayload, RequestConfirmationPayload,
RequestConfirmationResult, RequestConfirmationResult,
RequestConfirmationToolActionPayload,
RequestConfirmationToolActionResult,
RequestCheckboxConfirmationOption, RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload, RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult, RequestCheckboxConfirmationResult,
@@ -962,6 +1019,131 @@ export type {
SecretStatus, SecretStatus,
SecretVersionSelector, SecretVersionSelector,
SecretVersionStatus, 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, Routine,
RoutineEnvConfig, RoutineEnvConfig,
RoutineManagedByPlugin, RoutineManagedByPlugin,
@@ -1108,6 +1290,23 @@ export {
MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
} from "./types/instance.js"; } 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 { export type {
CloudUpstreamConnectStartResponse, CloudUpstreamConnectStartResponse,
CloudUpstreamActivationDecision, CloudUpstreamActivationDecision,
@@ -1140,6 +1339,13 @@ export {
patchInstanceExperimentalSettingsSchema, patchInstanceExperimentalSettingsSchema,
patchInstanceSettingsSchema, patchInstanceSettingsSchema,
issueGraphLivenessAutoRecoveryRequestSchema, issueGraphLivenessAutoRecoveryRequestSchema,
createSmokeRunSchema,
updateSmokeRunSchema,
recordSmokeRunStepSchema,
smokeRunStatusSchema,
smokeRunStepPathSchema,
smokeRunStepStatusSchema,
smokeRunTriggerSchema,
trustPresetSchema, trustPresetSchema,
lowTrustBoundarySchema, lowTrustBoundarySchema,
lowTrustReviewPresetPolicySchema, lowTrustReviewPresetPolicySchema,
@@ -1147,6 +1353,9 @@ export {
type PatchInstanceExperimentalSettings, type PatchInstanceExperimentalSettings,
type PatchInstanceSettings, type PatchInstanceSettings,
type IssueGraphLivenessAutoRecoveryRequest, type IssueGraphLivenessAutoRecoveryRequest,
type CreateSmokeRun,
type UpdateSmokeRun,
type RecordSmokeRunStep,
type TrustPresetInput, type TrustPresetInput,
type LowTrustBoundaryInput, type LowTrustBoundaryInput,
type TrustAuthorizationPolicyInput, type TrustAuthorizationPolicyInput,
@@ -1443,6 +1652,88 @@ export {
rotateSecretSchema, rotateSecretSchema,
secretBindingTargetSchema, secretBindingTargetSchema,
updateSecretSchema, 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, createRoutineSchema,
updateRoutineSchema, updateRoutineSchema,
createRoutineTriggerSchema, createRoutineTriggerSchema,
@@ -1472,6 +1763,42 @@ export {
type RemoteSecretImportSelection, type RemoteSecretImportSelection,
type RotateSecret, type RotateSecret,
type UpdateSecret, 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 CreateRoutine,
type UpdateRoutine, type UpdateRoutine,
type CreateRoutineTrigger, type CreateRoutineTrigger,
@@ -3,6 +3,8 @@ import {
acceptIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema,
askUserQuestionsResultSchema, askUserQuestionsResultSchema,
createIssueThreadInteractionSchema, createIssueThreadInteractionSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
submitIssueThreadInteractionVerdictsSchema, submitIssueThreadInteractionVerdictsSchema,
} from "./validators/issue.js"; } 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", () => { it("accepts issue document targets for request_confirmation interactions", () => {
const parsed = createIssueThreadInteractionSchema.parse({ const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_confirmation", kind: "request_confirmation",
@@ -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);
}
});
});
+243
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;
}
+144
View File
@@ -48,6 +48,21 @@ export type {
IssueGraphLivenessAutoRecoveryPreview, IssueGraphLivenessAutoRecoveryPreview,
IssueGraphLivenessAutoRecoveryPreviewItem, IssueGraphLivenessAutoRecoveryPreviewItem,
} from "./instance.js"; } 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 { export {
DAILY_RETENTION_PRESETS, DAILY_RETENTION_PRESETS,
WEEKLY_RETENTION_PRESETS, WEEKLY_RETENTION_PRESETS,
@@ -304,6 +319,133 @@ export type {
WorkspaceFileWorkspaceKind, WorkspaceFileWorkspaceKind,
ResolvedWorkspaceResource, ResolvedWorkspaceResource,
} from "./workspace-file-resource.js"; } 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 { export type {
IssueWorkProduct, IssueWorkProduct,
IssueWorkProductType, IssueWorkProductType,
@@ -411,6 +553,8 @@ export type {
RequestConfirmationTarget, RequestConfirmationTarget,
RequestConfirmationPayload, RequestConfirmationPayload,
RequestConfirmationResult, RequestConfirmationResult,
RequestConfirmationToolActionPayload,
RequestConfirmationToolActionResult,
RequestCheckboxConfirmationOption, RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload, RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult, RequestCheckboxConfirmationResult,
+2
View File
@@ -48,6 +48,7 @@ export interface InstanceExperimentalSettings {
enableEnvironments: boolean; enableEnvironments: boolean;
enableIsolatedWorkspaces: boolean; enableIsolatedWorkspaces: boolean;
enableStreamlinedLeftNavigation: boolean; enableStreamlinedLeftNavigation: boolean;
enableApps: boolean;
enablePipelines: boolean; enablePipelines: boolean;
enableCases: boolean; enableCases: boolean;
enableConferenceRoomChat: boolean; enableConferenceRoomChat: boolean;
@@ -56,6 +57,7 @@ export interface InstanceExperimentalSettings {
enableExperimentalFileViewer: boolean; enableExperimentalFileViewer: boolean;
enableCloudSync: boolean; enableCloudSync: boolean;
enableExternalObjects: boolean; enableExternalObjects: boolean;
enableSmokeLab: boolean;
enableBuiltInAgents: boolean; enableBuiltInAgents: boolean;
enableDecisions: boolean; enableDecisions: boolean;
enableGoalsSidebarLink: boolean; enableGoalsSidebarLink: boolean;
+39
View File
@@ -1044,6 +1044,43 @@ export type RequestConfirmationTarget =
| RequestConfirmationIssueDocumentTarget | RequestConfirmationIssueDocumentTarget
| RequestConfirmationCustomTarget; | 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 { export interface RequestConfirmationPayload {
version: 1; version: 1;
prompt: string; prompt: string;
@@ -1056,6 +1093,7 @@ export interface RequestConfirmationPayload {
detailsMarkdown?: string | null; detailsMarkdown?: string | null;
supersedeOnUserComment?: boolean; supersedeOnUserComment?: boolean;
target?: RequestConfirmationTarget | null; target?: RequestConfirmationTarget | null;
toolAction?: RequestConfirmationToolActionPayload;
} }
export interface RequestCheckboxConfirmationOption { export interface RequestCheckboxConfirmationOption {
@@ -1122,6 +1160,7 @@ export interface RequestConfirmationResult {
recoveryActionId?: string | null; recoveryActionId?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
} | null; } | null;
toolAction?: RequestConfirmationToolActionResult;
} }
export interface RequestCheckboxConfirmationResult extends RequestConfirmationResult { export interface RequestCheckboxConfirmationResult extends RequestConfirmationResult {
+4 -2
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. * `plugin_config` table.
* See PLUGIN_SPEC.md §21.3 for the schema definition. * See PLUGIN_SPEC.md §21.3 for the schema definition.
*/ */
export interface PluginConfig { export interface PluginConfig {
/** UUID primary key. */ /** UUID primary key. */
id: string; 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; pluginId: string;
/** FK to `companies.id`. */
companyId: string;
/** Operator-provided configuration values (validated against `instanceConfigSchema`). */ /** Operator-provided configuration values (validated against `instanceConfigSchema`). */
configJson: Record<string, unknown>; configJson: Record<string, unknown>;
/** Most recent config validation error, if any. */ /** Most recent config validation error, if any. */
+7 -1
View File
@@ -5,6 +5,7 @@ import type {
SecretProvider, SecretProvider,
SecretProviderConfigHealthStatus, SecretProviderConfigHealthStatus,
SecretProviderConfigStatus, SecretProviderConfigStatus,
SecretProjectionClass,
SecretScope, SecretScope,
SecretStatus, SecretStatus,
SecretVersionStatus, SecretVersionStatus,
@@ -17,6 +18,7 @@ export type {
SecretProvider, SecretProvider,
SecretProviderConfigHealthStatus, SecretProviderConfigHealthStatus,
SecretProviderConfigStatus, SecretProviderConfigStatus,
SecretProjectionClass,
SecretScope, SecretScope,
SecretStatus, SecretStatus,
SecretVersionStatus, SecretVersionStatus,
@@ -33,6 +35,8 @@ export interface EnvSecretRefBinding {
type: "secret_ref"; type: "secret_ref";
secretId: string; secretId: string;
version?: SecretVersionSelector; version?: SecretVersionSelector;
projectionClass?: SecretProjectionClass;
projectionAllowlistKey?: string | null;
} }
export interface EnvUserSecretRefBinding { export interface EnvUserSecretRefBinding {
@@ -254,6 +258,8 @@ export interface CompanySecretBinding {
versionSelector: SecretVersionSelector; versionSelector: SecretVersionSelector;
required: boolean; required: boolean;
label: string | null; label: string | null;
projectionClass: SecretProjectionClass;
projectionAllowlistKey: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -284,7 +290,7 @@ export interface SecretAccessEvent {
credentialSubjectId: string | null; credentialSubjectId: string | null;
actorType: "agent" | "user" | "system" | "plugin"; actorType: "agent" | "user" | "system" | "plugin";
actorId: string | null; actorId: string | null;
consumerType: SecretBindingTargetType; consumerType: SecretBindingTargetType | "plugin_worker";
consumerId: string; consumerId: string;
configPath: string | null; configPath: string | null;
issueId: string | null; issueId: string | null;
+46
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
+138
View File
@@ -21,6 +21,19 @@ export {
type ResolveBudgetIncident, type ResolveBudgetIncident,
} from "./budget.js"; } from "./budget.js";
export {
createSmokeRunSchema,
updateSmokeRunSchema,
recordSmokeRunStepSchema,
smokeRunStatusSchema,
smokeRunStepPathSchema,
smokeRunStepStatusSchema,
smokeRunTriggerSchema,
type CreateSmokeRun,
type UpdateSmokeRun,
type RecordSmokeRunStep,
} from "./smoke-lab.js";
export { export {
createCompanySchema, createCompanySchema,
updateCompanySchema, updateCompanySchema,
@@ -695,3 +708,128 @@ export {
type SetPluginState, type SetPluginState,
type ListPluginState, type ListPluginState,
} from "./plugin.js"; } 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";
@@ -50,6 +50,12 @@ describe("instance experimental settings validators", () => {
expect(settings.enableBuiltInAgents).toBe(false); expect(settings.enableBuiltInAgents).toBe(false);
}); });
it("defaults apps off", () => {
const settings = instanceExperimentalSettingsSchema.parse({});
expect(settings.enableApps).toBe(false);
});
it("accepts worktree run execution patches", () => { it("accepts worktree run execution patches", () => {
expect( expect(
patchInstanceExperimentalSettingsSchema.parse({ patchInstanceExperimentalSettingsSchema.parse({
@@ -117,4 +123,14 @@ describe("instance experimental settings validators", () => {
enableBuiltInAgents: true, enableBuiltInAgents: true,
}); });
}); });
it("accepts apps patches", () => {
expect(
patchInstanceExperimentalSettingsSchema.parse({
enableApps: true,
}),
).toEqual({
enableApps: true,
});
});
}); });
@@ -42,6 +42,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableEnvironments: z.boolean().default(false), enableEnvironments: z.boolean().default(false),
enableIsolatedWorkspaces: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false),
enableStreamlinedLeftNavigation: z.boolean().default(true), enableStreamlinedLeftNavigation: z.boolean().default(true),
enableApps: z.boolean().default(false),
enablePipelines: z.boolean().default(false), enablePipelines: z.boolean().default(false),
enableCases: z.boolean().default(false), enableCases: z.boolean().default(false),
enableConferenceRoomChat: z.boolean().default(false), enableConferenceRoomChat: z.boolean().default(false),
@@ -50,6 +51,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableExperimentalFileViewer: z.boolean().default(false), enableExperimentalFileViewer: z.boolean().default(false),
enableCloudSync: z.boolean().default(false), enableCloudSync: z.boolean().default(false),
enableExternalObjects: z.boolean().default(false), enableExternalObjects: z.boolean().default(false),
enableSmokeLab: z.boolean().default(false),
enableBuiltInAgents: z.boolean().default(false), enableBuiltInAgents: z.boolean().default(false),
enableDecisions: z.boolean().default(false), enableDecisions: z.boolean().default(false),
enableGoalsSidebarLink: z.boolean().default(false), enableGoalsSidebarLink: z.boolean().default(false),
+31
View File
@@ -742,6 +742,22 @@ export const requestConfirmationTargetSchema = z.discriminatedUnion("type", [
requestConfirmationCustomTargetSchema, 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({ export const requestConfirmationPayloadSchema = z.object({
version: z.literal(1), version: z.literal(1),
prompt: z.string().trim().min(1).max(1000), prompt: z.string().trim().min(1).max(1000),
@@ -754,6 +770,7 @@ export const requestConfirmationPayloadSchema = z.object({
detailsMarkdown: z.string().max(20000).nullable().optional(), detailsMarkdown: z.string().max(20000).nullable().optional(),
supersedeOnUserComment: z.boolean().optional(), supersedeOnUserComment: z.boolean().optional(),
target: requestConfirmationTargetSchema.nullable().optional(), target: requestConfirmationTargetSchema.nullable().optional(),
toolAction: requestConfirmationToolActionPayloadSchema.optional(),
}); });
export const requestCheckboxConfirmationOptionSchema = z.object({ export const requestCheckboxConfirmationOptionSchema = z.object({
@@ -867,6 +884,19 @@ export const requestConfirmationResumeFailureSchema = z.object({
updatedAt: z.string().trim().min(1).nullable().optional(), 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({ export const requestConfirmationResultSchema = z.object({
version: z.literal(1), version: z.literal(1),
outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]), 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(), commentId: z.string().uuid().nullable().optional(),
staleTarget: requestConfirmationTargetSchema.nullable().optional(), staleTarget: requestConfirmationTargetSchema.nullable().optional(),
resumeFailure: requestConfirmationResumeFailureSchema.nullable().optional(), resumeFailure: requestConfirmationResumeFailureSchema.nullable().optional(),
toolAction: requestConfirmationToolActionResultSchema.optional(),
}); });
export const requestCheckboxConfirmationResultSchema = requestConfirmationResultSchema.extend({ export const requestCheckboxConfirmationResultSchema = requestConfirmationResultSchema.extend({
+5 -3
View File
@@ -1135,25 +1135,27 @@ export const installPluginSchema = z.object({
export type InstallPlugin = z.infer<typeof installPluginSchema>; 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 * configJson is validated permissively here; runtime validation against
* the plugin's instanceConfigSchema is done at the service layer. * the plugin's instanceConfigSchema is done at the service layer.
*/ */
export const upsertPluginConfigSchema = z.object({ export const upsertPluginConfigSchema = z.object({
companyId: z.string().uuid(),
configJson: z.record(z.string(), z.unknown()), configJson: z.record(z.string(), z.unknown()),
}); });
export type UpsertPluginConfig = z.infer<typeof upsertPluginConfigSchema>; 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. * Allows a partial merge of config values.
*/ */
export const patchPluginConfigSchema = z.object({ export const patchPluginConfigSchema = z.object({
companyId: z.string().uuid(),
configJson: z.record(z.string(), z.unknown()), configJson: z.record(z.string(), z.unknown()),
}); });
+5
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { import {
SECRET_BINDING_TARGET_TYPES, SECRET_BINDING_TARGET_TYPES,
SECRET_MANAGED_MODES, SECRET_MANAGED_MODES,
SECRET_PROJECTION_CLASSES,
SECRET_PROVIDER_CONFIG_STATUSES, SECRET_PROVIDER_CONFIG_STATUSES,
SECRET_PROVIDERS, SECRET_PROVIDERS,
SECRET_STATUSES, SECRET_STATUSES,
@@ -20,6 +21,8 @@ export const envBindingSecretRefSchema = z.object({
type: z.literal("secret_ref"), type: z.literal("secret_ref"),
secretId: z.string().uuid(), secretId: z.string().uuid(),
version: secretVersionSelectorSchema.optional(), 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({ export const envBindingUserSecretRefSchema = z.object({
@@ -135,6 +138,8 @@ export const createSecretBindingSchema = secretBindingTargetSchema.extend({
versionSelector: secretVersionSelectorSchema.default("latest"), versionSelector: secretVersionSelectorSchema.default("latest"),
required: z.boolean().default(true), required: z.boolean().default(true),
label: z.string().optional().nullable(), 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>; export type CreateSecretBinding = z.infer<typeof createSecretBindingSchema>;
@@ -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>;
@@ -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"]);
});
});
@@ -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>;
@@ -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 { import {
createPluginSecretsHandler, createPluginSecretsHandler,
PLUGIN_SECRET_REFS_DISABLED_MESSAGE, extractSecretRefBindingsFromConfig,
} from "../services/plugin-secrets-handler.js"; } from "../services/plugin-secrets-handler.js";
import { secretService } from "../services/secrets.js";
describe("createPluginSecretsHandler", () => { const pluginId = "11111111-1111-4111-8111-111111111111";
it("fails closed for plugin secret resolution until company scoping lands", async () => { const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const handler = createPluginSecretsHandler({ const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
db: {} as never,
pluginId: "11111111-1111-4111-8111-111111111111",
});
await expect( if (!embeddedPostgresSupport.supported) {
handler.resolve({ secretRef: "77777777-7777-4777-8777-777777777777" }), console.warn(
).rejects.toThrow(PLUGIN_SECRET_REFS_DISABLED_MESSAGE); `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 () => { it("rejects legacy UUID strings at schema-declared secret fields", () => {
const handler = createPluginSecretsHandler({ const secretId = "77777777-7777-4777-8777-777777777777";
db: {} as never,
pluginId: "11111111-1111-4111-8111-111111111111",
});
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( await expect(
handler.resolve({ secretRef: "not-a-uuid" }), handler.resolve({ secretRef: { type: "secret_ref", secretId: randomUUID() } }),
).rejects.toThrow(/invalid secret reference/i); ).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);
}); });
}); });
+17 -3
View File
@@ -34,6 +34,8 @@ import crypto from "node:crypto";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { pluginRegistryService } from "../services/plugin-registry.js"; import { pluginRegistryService } from "../services/plugin-registry.js";
import { logger } from "../middleware/logger.js"; import { logger } from "../middleware/logger.js";
import { assertCompanyAccess } from "./authz.js";
import { badRequest } from "../errors.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
@@ -277,11 +279,23 @@ export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions
return; return;
} }
// Step 2b: Check for devUiUrl in plugin config — proxy to local dev server const rawCompanyId = req.query.companyId;
// when a plugin author has configured a dev server URL for hot-reload. 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 // See PLUGIN_SPEC.md §27.2 — Local Development Workflow
try { try {
const configRow = await registry.getConfig(plugin.id); const configRow = companyId ? await registry.getConfig(plugin.id, companyId) : null;
const devUiUrl = const devUiUrl =
configRow && configRow &&
typeof configRow === "object" && typeof configRow === "object" &&
+19 -9
View File
@@ -81,8 +81,7 @@ import {
setStoredLocalFolder, setStoredLocalFolder,
} from "../services/plugin-local-folders.js"; } from "../services/plugin-local-folders.js";
import { import {
extractSecretRefPathsFromConfig, extractSecretRefBindingsFromConfig,
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
} from "../services/plugin-secrets-handler.js"; } from "../services/plugin-secrets-handler.js";
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.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) => { router.get("/plugins/:pluginId/config", async (req, res) => {
assertBoardOrgAccess(req); assertBoardOrgAccess(req);
const { pluginId } = req.params; 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); const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) { if (!plugin) {
@@ -2140,7 +2144,7 @@ export function pluginRoutes(
return; return;
} }
const config = await registry.getConfig(plugin.id); const config = await registry.getConfig(plugin.id, companyId);
res.json(config); res.json(config);
}); });
@@ -2170,7 +2174,12 @@ export function pluginRoutes(
return; 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") { if (!body?.configJson || typeof body.configJson !== "object") {
res.status(400).json({ error: '"configJson" is required and must be an object' }); res.status(400).json({ error: '"configJson" is required and must be an object' });
return; return;
@@ -2201,13 +2210,14 @@ export function pluginRoutes(
} }
try { try {
const secretRefsByPath = extractSecretRefPathsFromConfig(body.configJson, schema); const secretRefs = extractSecretRefBindingsFromConfig(body.configJson, schema);
if (secretRefsByPath.size > 0) { if (secretRefs.length > 0) {
res.status(422).json({ error: PLUGIN_SECRET_REFS_DISABLED_MESSAGE }); res.status(422).json({ error: "Plugin secret references require the governed tool-access server layer" });
return; return;
} }
const result = await registry.upsertConfig(plugin.id, { const result = await registry.upsertConfig(plugin.id, companyId, {
companyId,
configJson: body.configJson, configJson: body.configJson,
}); });
await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, { await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, {
@@ -2225,7 +2235,7 @@ export function pluginRoutes(
await bridgeDeps.workerManager.call( await bridgeDeps.workerManager.call(
plugin.id, plugin.id,
"configChanged", "configChanged",
{ config: body.configJson }, { config: body.configJson, companyId },
); );
} catch (rpcErr) { } catch (rpcErr) {
if ( if (
+4
View File
@@ -205,6 +205,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableEnvironments: parsed.data.enableEnvironments ?? false, enableEnvironments: parsed.data.enableEnvironments ?? false,
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true, enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true,
enableApps: parsed.data.enableApps ?? false,
enablePipelines: parsed.data.enablePipelines ?? false, enablePipelines: parsed.data.enablePipelines ?? false,
enableCases: parsed.data.enableCases ?? false, enableCases: parsed.data.enableCases ?? false,
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false, enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
@@ -213,6 +214,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false, enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false,
enableCloudSync: parsed.data.enableCloudSync ?? false, enableCloudSync: parsed.data.enableCloudSync ?? false,
enableExternalObjects: parsed.data.enableExternalObjects ?? false, enableExternalObjects: parsed.data.enableExternalObjects ?? false,
enableSmokeLab: parsed.data.enableSmokeLab ?? false,
enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false, enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false,
enableDecisions: parsed.data.enableDecisions ?? false, enableDecisions: parsed.data.enableDecisions ?? false,
enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false, enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false,
@@ -234,6 +236,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableEnvironments: false, enableEnvironments: false,
enableIsolatedWorkspaces: false, enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: true, enableStreamlinedLeftNavigation: true,
enableApps: false,
enablePipelines: false, enablePipelines: false,
enableCases: false, enableCases: false,
enableConferenceRoomChat: false, enableConferenceRoomChat: false,
@@ -242,6 +245,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableExperimentalFileViewer: false, enableExperimentalFileViewer: false,
enableCloudSync: false, enableCloudSync: false,
enableExternalObjects: false, enableExternalObjects: false,
enableSmokeLab: false,
enableBuiltInAgents: false, enableBuiltInAgents: false,
enableDecisions: false, enableDecisions: false,
enableGoalsSidebarLink: false, enableGoalsSidebarLink: false,
+7 -3
View File
@@ -1062,8 +1062,10 @@ export function buildHostServices(
return { return {
config: { config: {
async get() { async get(params) {
const configRow = await registry.getConfig(pluginId); const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
const configRow = await registry.getConfig(pluginId, companyId);
return configRow?.configJson ?? {}; return configRow?.configJson ?? {};
}, },
}, },
@@ -1239,7 +1241,9 @@ export function buildHostServices(
secrets: { secrets: {
async resolve(params) { async resolve(params) {
return secretsHandler.resolve(params); const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return secretsHandler.resolve({ ...params, companyId });
}, },
}, },
+4 -11
View File
@@ -2133,18 +2133,11 @@ export function pluginLoader(
const hostHandlers = buildHostHandlers(pluginId, manifest); const hostHandlers = buildHostHandlers(pluginId, manifest);
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 4. Retrieve plugin config (if any) // 4. Bootstrap worker config
// ------------------------------------------------------------------ // ------------------------------------------------------------------
let config: Record<string, unknown> = {}; // Plugin configuration is company-scoped. Workers receive an empty
try { // bootstrap config and must use ctx.config.get(companyId) at runtime.
const configRow = await registry.getConfig(pluginId); const config: Record<string, unknown> = {};
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");
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 5. Spawn worker process // 5. Spawn worker process
+18 -16
View File
@@ -280,27 +280,27 @@ export function pluginRegistryService(db: Db) {
// ----- Config --------------------------------------------------------- // ----- Config ---------------------------------------------------------
/** Retrieve a plugin's instance configuration. */ /** Retrieve a plugin's company-scoped configuration. */
getConfig: (pluginId: string) => getConfig: (pluginId: string, companyId: string) =>
db db
.select() .select()
.from(pluginConfig) .from(pluginConfig)
.where(eq(pluginConfig.pluginId, pluginId)) .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
.then((rows) => rows[0] ?? null), .then((rows) => rows[0] ?? null),
/** /**
* Create or fully replace a plugin's instance configuration. * Create or fully replace a plugin's company-scoped configuration.
* If a config row already exists for the plugin it is replaced; * If a config row already exists for the plugin/company pair it is replaced;
* otherwise a new row is inserted. * 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); const plugin = await getById(pluginId);
if (!plugin) throw notFound("Plugin not found"); if (!plugin) throw notFound("Plugin not found");
const existing = await db const existing = await db
.select() .select()
.from(pluginConfig) .from(pluginConfig)
.where(eq(pluginConfig.pluginId, pluginId)) .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
.then((rows) => rows[0] ?? null); .then((rows) => rows[0] ?? null);
if (existing) { if (existing) {
@@ -311,7 +311,7 @@ export function pluginRegistryService(db: Db) {
lastError: null, lastError: null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(pluginConfig.pluginId, pluginId)) .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
.returning() .returning()
.then((rows) => rows[0]); .then((rows) => rows[0]);
} }
@@ -320,6 +320,7 @@ export function pluginRegistryService(db: Db) {
.insert(pluginConfig) .insert(pluginConfig)
.values({ .values({
pluginId, pluginId,
companyId,
configJson: input.configJson, configJson: input.configJson,
}) })
.returning() .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. * 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); const plugin = await getById(pluginId);
if (!plugin) throw notFound("Plugin not found"); if (!plugin) throw notFound("Plugin not found");
const existing = await db const existing = await db
.select() .select()
.from(pluginConfig) .from(pluginConfig)
.where(eq(pluginConfig.pluginId, pluginId)) .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
.then((rows) => rows[0] ?? null); .then((rows) => rows[0] ?? null);
if (existing) { if (existing) {
@@ -349,7 +350,7 @@ export function pluginRegistryService(db: Db) {
lastError: null, lastError: null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(pluginConfig.pluginId, pluginId)) .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
.returning() .returning()
.then((rows) => rows[0]); .then((rows) => rows[0]);
} }
@@ -358,6 +359,7 @@ export function pluginRegistryService(db: Db) {
.insert(pluginConfig) .insert(pluginConfig)
.values({ .values({
pluginId, pluginId,
companyId,
configJson: input.configJson, configJson: input.configJson,
}) })
.returning() .returning()
@@ -368,11 +370,11 @@ export function pluginRegistryService(db: Db) {
* Record an error against a plugin's config (e.g. validation failure * Record an error against a plugin's config (e.g. validation failure
* against the plugin's instanceConfigSchema). * 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 const rows = await db
.update(pluginConfig) .update(pluginConfig)
.set({ lastError, updatedAt: new Date() }) .set({ lastError, updatedAt: new Date() })
.where(eq(pluginConfig.pluginId, pluginId)) .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
.returning(); .returning();
if (rows.length === 0) throw notFound("Plugin config not found"); 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. */ /** Delete a plugin's config row. */
deleteConfig: async (pluginId: string) => { deleteConfig: async (pluginId: string, companyId: string) => {
const rows = await db const rows = await db
.delete(pluginConfig) .delete(pluginConfig)
.where(eq(pluginConfig.pluginId, pluginId)) .where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
.returning(); .returning();
return rows[0] ?? null; return rows[0] ?? null;
+198 -151
View File
@@ -1,115 +1,148 @@
/** /**
* Plugin secrets host-side handler — resolves secret references through the * Plugin secrets host-side handler. Plugin workers may resolve shared
* Paperclip secret provider system. * `secret_ref` config bindings only with an explicit company context.
*
* 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
*/ */
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; 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 { import {
collectSecretRefPaths, collectSecretRefPaths,
isUuidSecretRef, isUuidSecretRef,
readConfigValueAtPath, readConfigValueAtPath,
} from "./json-schema-secret-refs.js"; } from "./json-schema-secret-refs.js";
import { secretService } from "./secrets.js";
export const PLUGIN_SECRET_REFS_DISABLED_MESSAGE = import { unprocessable } from "../errors.js";
"Plugin secret references are disabled until company-scoped plugin config lands";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Error helpers // Error helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function invalidSecretRef(secretRef: string): Error { function invalidSecretRef(secretRef: unknown): Error {
const err = new Error(`Invalid secret reference: ${secretRef}`); 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"; err.name = "InvalidSecretRefError";
return err; 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 // Validation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** Extract shared object-shaped secret refs from plugin config. */
* Extract secret reference UUIDs from a plugin's configJson, scoped to only export function extractSecretRefBindingsFromConfig(
* the fields annotated with `format: "secret-ref"` in the schema. configJson: unknown,
* schema?: Record<string, unknown> | null,
* When no schema is provided, falls back to collecting all UUID-shaped strings ): PluginConfigSecretRefBinding[] {
* (backwards-compatible for plugins without a declared instanceConfigSchema). 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( export function extractSecretRefsFromConfig(
configJson: unknown, configJson: unknown,
schema?: Record<string, unknown> | null, schema?: Record<string, unknown> | null,
): Set<string> { ): 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( export function extractSecretRefPathsFromConfig(
configJson: unknown, configJson: unknown,
schema?: Record<string, unknown> | null, schema?: Record<string, unknown> | null,
): Map<string, Set<string>> { ): Map<string, Set<string>> {
const refs = new Map<string, Set<string>>(); const refs = new Map<string, Set<string>>();
const addRef = (secretRef: string, path: string) => { for (const ref of extractSecretRefBindingsFromConfig(configJson, schema)) {
const existing = refs.get(secretRef) ?? new Set<string>(); const paths = refs.get(ref.secretId) ?? new Set<string>();
existing.add(path); paths.add(ref.configPath);
refs.set(secretRef, existing); refs.set(ref.secretId, paths);
};
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;
} }
// 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; return refs;
} }
@@ -117,69 +150,28 @@ export function extractSecretRefPathsFromConfig(
// Handler factory // Handler factory
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Input shape for the `secrets.resolve` handler.
*
* Matches `WorkerToHostMethods["secrets.resolve"][0]` from `protocol.ts`.
*/
export interface PluginSecretsResolveParams { export interface PluginSecretsResolveParams {
/** The secret reference string (a secret UUID). */ /** Shared secret reference object from company-scoped plugin config. */
secretRef: string; 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 { export interface PluginSecretsHandlerOptions {
/** Database connection. */
db: Db; 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; pluginId: string;
} }
/**
* The `HostServices.secrets` adapter for the plugin host-client factory.
*/
export interface PluginSecretsService { 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>; 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) { function createRateLimiter(maxAttempts: number, windowMs: number) {
const attempts = new Map<string, number[]>(); const attempts = new Map<string, number[]>();
@@ -199,40 +191,95 @@ function createRateLimiter(maxAttempts: number, windowMs: number) {
export function createPluginSecretsHandler( export function createPluginSecretsHandler(
options: PluginSecretsHandlerOptions, options: PluginSecretsHandlerOptions,
): PluginSecretsService { ): PluginSecretsService {
const { pluginId } = options; const { db, pluginId } = options;
// Rate limit: max 30 resolution attempts per plugin per minute
const rateLimiter = createRateLimiter(30, 60_000); 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 { return {
async resolve(params: PluginSecretsResolveParams): Promise<string> { async resolve(params: PluginSecretsResolveParams): Promise<string> {
const { secretRef } = params; if (typeof params.secretRef === "string") {
throw invalidSecretRef(params.secretRef.trim() || "<empty>");
}
// --------------------------------------------------------------- const bindingRef = parseSecretRefBinding(params.secretRef);
// 0. Rate limiting — prevent brute-force UUID enumeration if (!bindingRef) throw invalidSecretRef(params.secretRef);
// ---------------------------------------------------------------
if (!rateLimiter.check(pluginId)) { const companyId = requireCompanyId(params.companyId);
if (!rateLimiter.check(`${companyId}:${pluginId}`)) {
const err = new Error("Rate limit exceeded for secret resolution"); const err = new Error("Rate limit exceeded for secret resolution");
err.name = "RateLimitExceededError"; err.name = "RateLimitExceededError";
throw err; throw err;
} }
// --------------------------------------------------------------- const versionSelector = bindingRef.version ?? "latest";
// 1. Validate the ref format const bindings = await lookupBinding({
// --------------------------------------------------------------- companyId,
if (!secretRef || typeof secretRef !== "string" || secretRef.trim().length === 0) { secretId: bindingRef.secretId,
throw invalidSecretRef(secretRef ?? "<empty>"); 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(); const binding = bindings[0]!;
return secretService(db).resolveSecretValue(companyId, bindingRef.secretId, versionSelector, {
if (!isUuidSecretRef(trimmedRef)) { bindingContext: {
throw invalidSecretRef(trimmedRef); consumerType: "plugin",
} consumerId: pluginId,
configPath: binding.configPath,
// Fail closed until plugin config and worker runtime both carry an actorType: params.actorType ?? "plugin",
// explicit company scope for secret bindings and resolution. actorId: params.actorId ?? pluginId,
throw new Error(PLUGIN_SECRET_REFS_DISABLED_MESSAGE); 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,
},
});
}, },
}; };
} }
+146 -15
View File
@@ -26,6 +26,7 @@ import type {
RemoteSecretImportRowResult, RemoteSecretImportRowResult,
SecretProviderConfigDiscoveryPreviewResult, SecretProviderConfigDiscoveryPreviewResult,
SecretBindingTargetType, SecretBindingTargetType,
SecretProjectionClass,
SecretProvider, SecretProvider,
SecretProviderConfigHealthResponse, SecretProviderConfigHealthResponse,
SecretProviderConfigHealthStatus, SecretProviderConfigHealthStatus,
@@ -33,6 +34,7 @@ import type {
SecretVersionSelector, SecretVersionSelector,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { import {
CLASS3_STATIC_LEASE_ALLOWLIST,
createSecretProviderConfigSchema, createSecretProviderConfigSchema,
deriveProjectUrlKey, deriveProjectUrlKey,
envBindingSchema, envBindingSchema,
@@ -377,7 +379,13 @@ async function cleanupPreparedProviderWrite(input: {
type CanonicalEnvBinding = type CanonicalEnvBinding =
| { type: "plain"; value: string } | { 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"; type: "user_secret_ref";
key: string; key: string;
@@ -386,8 +394,10 @@ type CanonicalEnvBinding =
allowMissingOverride: boolean; allowMissingOverride: boolean;
}; };
type SecretAccessConsumerType = SecretBindingTargetType | "plugin_worker";
type SecretConsumerContext = { type SecretConsumerContext = {
consumerType: SecretBindingTargetType; consumerType: SecretAccessConsumerType;
consumerId: string; consumerId: string;
configPath?: string | null; configPath?: string | null;
responsibleUserId?: string | null; responsibleUserId?: string | null;
@@ -400,8 +410,12 @@ type SecretConsumerContext = {
allowedBindingIds?: string[] | null; allowedBindingIds?: string[] | null;
}; };
type SecretBindingContext = Omit<SecretConsumerContext, "consumerType"> & {
consumerType: SecretBindingTargetType;
};
type SecretResolutionOptions = { type SecretResolutionOptions = {
bindingContext?: SecretConsumerContext; bindingContext?: SecretBindingContext;
accessContext?: SecretConsumerContext; accessContext?: SecretConsumerContext;
allowUserSecretScope?: boolean; allowUserSecretScope?: boolean;
}; };
@@ -439,6 +453,10 @@ export type MissingRuntimeBinding = {
errorCode?: SecretResolutionErrorCode; errorCode?: SecretResolutionErrorCode;
}; };
function missingRuntimeConsumerType(consumerType: SecretAccessConsumerType): SecretBindingTargetType {
return consumerType === "plugin_worker" ? "plugin" : consumerType;
}
type RuntimeSecretResolution = { type RuntimeSecretResolution = {
value: string; value: string;
manifestEntry: RuntimeSecretManifestEntry; manifestEntry: RuntimeSecretManifestEntry;
@@ -502,9 +520,41 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
type: "secret_ref", type: "secret_ref",
secretId: binding.secretId, secretId: binding.secretId,
version: binding.version ?? "latest", 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 { function defaultProviderConfigStatus(provider: SecretProvider): SecretProviderConfigStatus {
return COMING_SOON_SECRET_PROVIDERS.has(provider) ? "coming_soon" : "ready"; 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", errorCode: "user_secret_definition_missing" | "user_secret_definition_inactive",
): MissingRuntimeBinding { ): MissingRuntimeBinding {
return { return {
consumerType: context.consumerType, consumerType: missingRuntimeConsumerType(context.consumerType),
consumerId: context.consumerId, consumerId: context.consumerId,
configPath: entry.configPath, configPath: entry.configPath,
envKey: entry.key, envKey: entry.key,
@@ -737,7 +787,7 @@ export function secretService(db: Db) {
async function assertBindingContext( async function assertBindingContext(
companyId: string, companyId: string,
secretId: string, secretId: string,
context: SecretConsumerContext | undefined, context: SecretBindingContext | undefined,
) { ) {
if (!context) return null; if (!context) return null;
if (!context.configPath) { if (!context.configPath) {
@@ -765,6 +815,12 @@ export function secretService(db: Db) {
{ code: "binding_not_allowed" }, { code: "binding_not_allowed" },
); );
} }
assertClass3StaticLeaseAllowed({
targetType: binding.targetType as SecretBindingTargetType,
configPath: binding.configPath,
projectionClass: binding.projectionClass,
projectionAllowlistKey: binding.projectionAllowlistKey,
});
return binding; 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( async function resolveSecretValue(
companyId: string, companyId: string,
secretId: string, secretId: string,
version: number | "latest", version: number | "latest",
context?: SecretConsumerContext, contextOrOptions?: SecretBindingContext | SecretResolutionOptions,
): Promise<string> { ): Promise<string> {
return (await resolveSecretValueInternal(companyId, secretId, version, { const options = isSecretResolutionOptions(contextOrOptions)
bindingContext: context, ? contextOrOptions
accessContext: context, : { bindingContext: contextOrOptions, accessContext: contextOrOptions };
})).value; return (await resolveSecretValueInternal(companyId, secretId, version, options)).value;
} }
async function resolveSecretValueForEphemeralAccess( async function resolveSecretValueForEphemeralAccess(
@@ -1112,6 +1174,31 @@ export function secretService(db: Db) {
})).value; })).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( async function normalizeEnvConfig(
companyId: string, companyId: string,
envValue: unknown, envValue: unknown,
@@ -1154,6 +1241,8 @@ export function secretService(db: Db) {
type: "secret_ref", type: "secret_ref",
secretId: binding.secretId, secretId: binding.secretId,
version: binding.version, version: binding.version,
projectionClass: binding.projectionClass,
projectionAllowlistKey: binding.projectionAllowlistKey,
}; };
} }
return normalized; return normalized;
@@ -1226,6 +1315,8 @@ export function secretService(db: Db) {
type: "secret_ref", type: "secret_ref",
secretId: binding.secretId, secretId: binding.secretId,
version: binding.version, version: binding.version,
projectionClass: binding.projectionClass,
projectionAllowlistKey: binding.projectionAllowlistKey,
}; };
} }
if (binding.type === "user_secret_ref") { if (binding.type === "user_secret_ref") {
@@ -2928,6 +3019,7 @@ export function secretService(db: Db) {
getById, getById,
getByName, getByName,
resolveSecretValue, resolveSecretValue,
resolveSecretVersion,
resolveSecretValueForEphemeralAccess, resolveSecretValueForEphemeralAccess,
create: async ( create: async (
@@ -3402,8 +3494,16 @@ export function secretService(db: Db) {
versionSelector?: SecretVersionSelector; versionSelector?: SecretVersionSelector;
required?: boolean; required?: boolean;
label?: string | null; label?: string | null;
projectionClass?: SecretProjectionClass;
projectionAllowlistKey?: string | null;
}) => { }) => {
await assertSecretInCompany(input.companyId, input.secretId); await assertSecretInCompany(input.companyId, input.secretId);
assertClass3StaticLeaseAllowed({
targetType: input.targetType,
configPath: input.configPath,
projectionClass: input.projectionClass,
projectionAllowlistKey: input.projectionAllowlistKey,
});
const existing = await db const existing = await db
.select() .select()
.from(companySecretBindings) .from(companySecretBindings)
@@ -3428,6 +3528,8 @@ export function secretService(db: Db) {
versionSelector: String(input.versionSelector ?? "latest"), versionSelector: String(input.versionSelector ?? "latest"),
required: input.required ?? true, required: input.required ?? true,
label: input.label ?? null, label: input.label ?? null,
projectionClass: input.projectionClass ?? "unclassified",
projectionAllowlistKey: input.projectionAllowlistKey ?? null,
}) })
.returning() .returning()
.then((rows) => rows[0]); .then((rows) => rows[0]);
@@ -3442,6 +3544,8 @@ export function secretService(db: Db) {
versionSelector?: SecretVersionSelector; versionSelector?: SecretVersionSelector;
required?: boolean; required?: boolean;
label?: string | null; label?: string | null;
projectionClass?: SecretProjectionClass;
projectionAllowlistKey?: string | null;
}>, }>,
options?: { replaceAll?: boolean }, options?: { replaceAll?: boolean },
) => { ) => {
@@ -3451,15 +3555,27 @@ export function secretService(db: Db) {
versionSelector: SecretVersionSelector; versionSelector: SecretVersionSelector;
required: boolean; required: boolean;
label: string | null; label: string | null;
projectionClass: SecretProjectionClass;
projectionAllowlistKey: string | null;
}> = []; }> = [];
for (const ref of refs) { for (const ref of refs) {
await assertSecretInCompany(companyId, ref.secretId); 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({ normalizedRefs.push({
secretId: ref.secretId, secretId: ref.secretId,
configPath: ref.configPath, configPath: ref.configPath,
versionSelector: ref.versionSelector ?? "latest", versionSelector: ref.versionSelector ?? "latest",
required: ref.required ?? true, required: ref.required ?? true,
label: ref.label ?? null, label: ref.label ?? null,
projectionClass,
projectionAllowlistKey,
}); });
} }
@@ -3514,6 +3630,8 @@ export function secretService(db: Db) {
versionSelector: String(ref.versionSelector), versionSelector: String(ref.versionSelector),
required: ref.required, required: ref.required,
label: ref.label, label: ref.label,
projectionClass: ref.projectionClass,
projectionAllowlistKey: ref.projectionAllowlistKey,
})), })),
); );
}); });
@@ -3545,6 +3663,8 @@ export function secretService(db: Db) {
secretId: string; secretId: string;
configPath: string; configPath: string;
versionSelector: SecretVersionSelector; versionSelector: SecretVersionSelector;
projectionClass: SecretProjectionClass;
projectionAllowlistKey: string | null;
}> = []; }> = [];
const userRefs: Array<{ const userRefs: Array<{
definitionKey: string; definitionKey: string;
@@ -3574,10 +3694,19 @@ export function secretService(db: Db) {
} }
if (binding.type !== "secret_ref") continue; if (binding.type !== "secret_ref") continue;
await assertSecretInCompany(companyId, binding.secretId, bindingDb); await assertSecretInCompany(companyId, binding.secretId, bindingDb);
const configPath = `${pathPrefix}.${key}`;
assertClass3StaticLeaseAllowed({
targetType: target.targetType,
configPath,
projectionClass: binding.projectionClass,
projectionAllowlistKey: binding.projectionAllowlistKey,
});
refs.push({ refs.push({
secretId: binding.secretId, secretId: binding.secretId,
configPath: `${pathPrefix}.${key}`, configPath,
versionSelector: binding.version, versionSelector: binding.version,
projectionClass: binding.projectionClass,
projectionAllowlistKey: binding.projectionAllowlistKey,
}); });
} }
@@ -3602,6 +3731,8 @@ export function secretService(db: Db) {
configPath: ref.configPath, configPath: ref.configPath,
versionSelector: String(ref.versionSelector), versionSelector: String(ref.versionSelector),
required: true, required: true,
projectionClass: ref.projectionClass,
projectionAllowlistKey: ref.projectionAllowlistKey,
})), })),
); );
}; };
@@ -3684,7 +3815,7 @@ export function secretService(db: Db) {
resolveEnvBindings: async ( resolveEnvBindings: async (
companyId: string, companyId: string,
envValue: unknown, envValue: unknown,
context?: Omit<SecretConsumerContext, "configPath">, context?: Omit<SecretBindingContext, "configPath">,
): Promise<{ env: Record<string, string>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => { ): Promise<{ env: Record<string, string>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => {
const record = asRecord(envValue); const record = asRecord(envValue);
if (!record) return { env: {} as Record<string, string>, secretKeys: new Set<string>(), manifest: [] }; if (!record) return { env: {} as Record<string, string>, secretKeys: new Set<string>(), manifest: [] };
@@ -3752,7 +3883,7 @@ export function secretService(db: Db) {
collectMissingRuntimeBindings: async ( collectMissingRuntimeBindings: async (
companyId: string, companyId: string,
envValue: unknown, envValue: unknown,
context: Omit<SecretConsumerContext, "configPath">, context: Omit<SecretBindingContext, "configPath">,
): Promise<MissingRuntimeBinding[]> => { ): Promise<MissingRuntimeBinding[]> => {
const record = asRecord(envValue); const record = asRecord(envValue);
if (!record) return []; if (!record) return [];
@@ -3912,7 +4043,7 @@ export function secretService(db: Db) {
companyId: string, companyId: string,
adapterConfig: Record<string, unknown>, adapterConfig: Record<string, unknown>,
adapterType: string | null | undefined, adapterType: string | null | undefined,
context: Omit<SecretConsumerContext, "configPath">, context: Omit<SecretBindingContext, "configPath">,
): Promise<MissingRuntimeBinding[]> => { ): Promise<MissingRuntimeBinding[]> => {
const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(adapterType); const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(adapterType);
const secretRefs = secretFieldKeys.flatMap((key) => { const secretRefs = secretFieldKeys.flatMap((key) => {
@@ -4068,7 +4199,7 @@ export function secretService(db: Db) {
resolveAdapterConfigForRuntime: async ( resolveAdapterConfigForRuntime: async (
companyId: string, companyId: string,
adapterConfig: Record<string, unknown>, adapterConfig: Record<string, unknown>,
context?: Omit<SecretConsumerContext, "configPath">, context?: Omit<SecretBindingContext, "configPath">,
opts?: ResolveAdapterConfigForRuntimeOptions, opts?: ResolveAdapterConfigForRuntimeOptions,
): Promise<{ config: Record<string, unknown>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => { ): Promise<{ config: Record<string, unknown>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => {
const resolved = { ...adapterConfig }; const resolved = { ...adapterConfig };
@@ -55,6 +55,7 @@ const SERVER_INFO_TOGGLE_SELECTOR =
'button[aria-label="Toggle server info debug view experimental setting"]'; 'button[aria-label="Toggle server info debug view experimental setting"]';
const BUILT_IN_AGENTS_TOGGLE_SELECTOR = const BUILT_IN_AGENTS_TOGGLE_SELECTOR =
'button[aria-label="Toggle built-in agents experimental setting"]'; '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 = const AUTO_RECOVERY_TOGGLE_SELECTOR =
'button[aria-label="Toggle task graph liveness auto-recovery"]'; 'button[aria-label="Toggle task graph liveness auto-recovery"]';
@@ -63,6 +64,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableEnvironments: false, enableEnvironments: false,
enableIsolatedWorkspaces: false, enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: true, enableStreamlinedLeftNavigation: true,
enableApps: false,
enablePipelines: false, enablePipelines: false,
enableCases: false, enableCases: false,
enableConferenceRoomChat: false, enableConferenceRoomChat: false,
@@ -75,6 +77,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableTaskWatchdogs: false, enableTaskWatchdogs: false,
enableCloudSync: false, enableCloudSync: false,
enableServerInfoDebugView: false, enableServerInfoDebugView: false,
enableSmokeLab: false,
autoRestartDevServerWhenIdle: false, autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: false, enableIssueGraphLivenessAutoRecovery: false,
issueGraphLivenessAutoRecoveryLookbackHours: 24, issueGraphLivenessAutoRecoveryLookbackHours: 24,
@@ -185,6 +188,19 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
expect(warning?.textContent).toContain("no compatibility guarantees"); 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 () => { it("does not render the Conference Room Chat experimental setting for now", async () => {
await renderPage(); await renderPage();
+6
View File
@@ -1563,6 +1563,8 @@ export const storybookSecretBindings: CompanySecretBinding[] = [
versionSelector: "latest", versionSelector: "latest",
required: true, required: true,
label: "Codex agent env", label: "Codex agent env",
projectionClass: "unclassified",
projectionAllowlistKey: null,
createdAt: new Date("2026-03-02T09:00:00.000Z"), createdAt: new Date("2026-03-02T09:00:00.000Z"),
updatedAt: 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", versionSelector: "latest",
required: true, required: true,
label: "Paperclip App project env", label: "Paperclip App project env",
projectionClass: "unclassified",
projectionAllowlistKey: null,
createdAt: new Date("2026-03-02T09:00:00.000Z"), createdAt: new Date("2026-03-02T09:00:00.000Z"),
updatedAt: 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, versionSelector: 2,
required: true, required: true,
label: "Prod environment", label: "Prod environment",
projectionClass: "unclassified",
projectionAllowlistKey: null,
createdAt: new Date("2026-04-22T14:01:00.000Z"), createdAt: new Date("2026-04-22T14:01:00.000Z"),
updatedAt: new Date("2026-04-22T14:01:00.000Z"), updatedAt: new Date("2026-04-22T14:01:00.000Z"),
}, },