mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
First PR: runtime. UI: #15728. Qualification: #15729.
## Thinking Path
> - Paperclip manages agent work through tasks and governed connections.
> - Chat connections already bind external conversations to those tasks.
> - Speko handles voice and telephony, while the selected Paperclip
agent does the work.
> - A call needs durable session authority, ordered follow-ups, and safe
result delivery.
> - This PR adds an explicit voice transport to the existing connection
runtime.
> - The UI and qualification suites follow in linked PRs. Speko stays
experimental.
## Linked Issues or Issue Description
**Problem or motivation**
Phone and browser voice conversations cannot use the chat connection
task lifecycle. A separate voice runtime is needed to preserve task
authority, retries, and reply history.
**Proposed solution**
Add company-scoped voice sessions, signed callbacks, durable replies,
inbound intake, and an agent-facing outbound-call tool. Reuse existing
tasks, queues, secrets, and publications.
**Roadmap alignment**
This extends the experimental chat connector entry points under Agent
Chat and Connected Apps. The project owner requested this feature.
Refs #11418 for the original provider catalog proposal. This
implementation uses the newer chat connection infrastructure rather than
an MCP catalog-only integration. Refs #15721 for remote OpenCode
startup.
## What Changed
- Add additive voice schema, shared contracts, session APIs, and
provider callbacks.
- Bind browser, inbound, and outbound sessions to an endpoint, task, and
selected agent.
- Add low-trust guest intake in an explicitly selected sandbox and keep
ordinary approval rules.
- Submit task work through session-scoped tools and push approved
replies through Speko POST /v1/calls/{id}/messages.
- Distinguish provider acceptance from confirmed spoken playback.
Reconcile reports without downloading recordings.
- Add protocol, migration, persistence, provider, and
delivery-diagnostic tests.
## Verification
- Master 3389bea708 is integrated. Database/shared builds, server
typecheck, migration upgrade/replay checks, and 46
communication-guidance/provider/protocol tests passed.
- The final persistence suite passed 85 real-database tests. Five
focused native continuation/authorization regressions passed. Five
callback ownership/rotation tests passed after the last runtime repair.
- Review repairs cover reconnect ownership, current caller authority,
duplicate delivery, multipart answers, task-page answers, guest question
handling, and saved sandbox recovery.
- A real OpenCode/OpenRouter DeepSeek task ran in Daytona and saved a
correct disk-space answer. This did not establish live-call playback.
- Full local typecheck/build passed before the final repairs; focused
typechecks passed after them. Latest-head CI runs the repository gates.
- The separate integrated browser qualification passed two journeys and
failed browser answer retrieval. The feature remains experimental. The
report in #15729 records this failure and outstanding real-provider
acceptance.
- No lockfiles are included. Speko migrations follow master as
0324–0330.
## Risks
- Voice sessions add migrations and provider-dependent lifecycle
behavior. Migrations preserve existing chat and AI transports.
- Callback origins must be reachable HTTPS. Provider keys and signing
secrets remain vaulted.
- Unverified callers receive task-scoped low-trust authority. Caller ID
does not grant private task access.
- Outbound telephony incurs provider charges. Provider acceptance is not
proof that the caller heard an answer.
- Keep the feature behind the existing experimental chat setting until
the real browser, inbound, outbound, and delayed-result acceptance
matrix passes.
## Model Used
OpenAI Codex, GPT-6, with tool use and code execution. The exact serving
model ID and context window are not exposed in this session.
## 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] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
1059 lines
49 KiB
TypeScript
1059 lines
49 KiB
TypeScript
import { connectionAgentInstructionsSchema } from "../connection-instructions.js";
|
|
import { isRemoteMcpConnectorMethod } from "../remote-mcp-connectors.js";
|
|
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 {
|
|
checkMcpRemoteHeaderName,
|
|
checkMcpRemoteHeaderValue,
|
|
mcpRemoteHeaderNameFromConfigPath,
|
|
mcpRemoteHeaderRejectionMessage,
|
|
} from "../mcp-remote-headers.js";
|
|
import { jsonSchemaSchema } from "./plugin.js";
|
|
import { objectWithoutDefaults } from "./partial.js";
|
|
|
|
export const toolApplicationTypeSchema = z.enum(TOOL_APPLICATION_TYPES);
|
|
export const toolApplicationStatusSchema = z.enum(TOOL_APPLICATION_STATUSES);
|
|
export const toolConnectionTransportSchema = z.enum(["mcp_remote", "rest_api", "local_stdio", "chat_sdk", "voice"]);
|
|
export const toolConnectionPurposeSchema = z.enum(["tool", "channel"]);
|
|
export const toolConnectionAuthKindSchema = z.enum(["oauth", "api_key", "none"]);
|
|
export const toolConnectionOwnershipSchema = z.enum(["platform_shared", "platform_provisioned", "customer", "dcr"]);
|
|
export const toolConnectionCredentialSourceSchema = z.enum(["paperclip_vault", "vercel_connect"]);
|
|
export const vercelConnectCredentialSummarySchema = z.object({
|
|
provider: z.literal("vercel_connect"),
|
|
connectorId: z.string().trim().min(1).max(255),
|
|
connectorUid: z.string().trim().min(1).max(255),
|
|
service: z.string().trim().min(1).max(255),
|
|
connectorType: z.string().trim().min(1).max(255),
|
|
principalMode: z.enum(["app", "user"]),
|
|
headerName: z.string().trim().min(1).max(160),
|
|
headerPrefix: z.string().max(120).nullable().optional(),
|
|
scopes: z.array(z.string().trim().min(1).max(500)).max(50),
|
|
}).strict();
|
|
export const vercelConnectGrantSummarySchema = z.object({
|
|
provider: z.literal("vercel_connect"),
|
|
subjectType: z.enum(["app", "user"]),
|
|
installationId: z.string().trim().min(1).max(255).optional(),
|
|
tenantId: z.string().trim().min(1).max(255).optional(),
|
|
tokenId: z.string().trim().min(1).max(255).optional(),
|
|
expiresAt: z.string().datetime({ offset: true }).optional(),
|
|
lastVerifiedAt: z.string().datetime({ offset: true }).optional(),
|
|
}).strict();
|
|
export const connectionGrantKindSchema = z.enum(["organization", "user", "agent"]);
|
|
export const connectionGrantStatusSchema = z.enum(["active", "revoked", "expired", "needs_reauthorization"]);
|
|
export const createConnectionGrantDelegationSchema = z.object({
|
|
agentId: z.string().guid(),
|
|
});
|
|
export type CreateConnectionGrantDelegation = z.infer<typeof createConnectionGrantDelegationSchema>;
|
|
export const toolConnectionCredentialPolicySchema = z.enum(["shared", "per_user", "per_user_with_fallback", "per_agent"]);
|
|
export const toolConnectionStatusSchema = z.enum(["draft", "active", "disabled", "archived"]);
|
|
export const toolConnectionInstallTargetTypeSchema = z.enum(["company", "agent"]);
|
|
export const toolCredentialPlacementSchema = z.enum(["header", "env", "url"]);
|
|
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().guid(),
|
|
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(),
|
|
keyScope: z.string().trim().min(1).max(160).optional(),
|
|
expiresAt: z.string().datetime({ offset: true }).optional(),
|
|
});
|
|
|
|
export const mcpConnectionCredentialRefSchema = z.object({
|
|
name: z.string().trim().min(1).max(120),
|
|
secretId: z.string().guid(),
|
|
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().guid().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().guid().optional().nullable(),
|
|
ownerAgentId: z.string().guid().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({
|
|
agentInstructions: connectionAgentInstructionsSchema.nullable().optional(),
|
|
applicationId: z.string().guid().optional(),
|
|
applicationName: z.string().trim().min(1).max(160).optional(),
|
|
name: z.string().trim().min(1).max(160),
|
|
connectionPurpose: toolConnectionPurposeSchema.default("tool"),
|
|
transport: toolConnectionTransportSchema.optional(),
|
|
authKind: toolConnectionAuthKindSchema.default("none"),
|
|
credentialPolicy: toolConnectionCredentialPolicySchema.optional(),
|
|
ownership: toolConnectionOwnershipSchema.default("customer"),
|
|
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 = objectWithoutDefaults(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 connectionGrantSchema = z.object({
|
|
id: z.string().guid(),
|
|
companyId: z.string().guid(),
|
|
connectionId: z.string().guid(),
|
|
kind: connectionGrantKindSchema,
|
|
subjectUserId: z.string().nullable(),
|
|
subjectAgentId: z.string().guid().nullable().optional(),
|
|
providerTenant: z.object({
|
|
name: z.string().trim().min(1).max(200).optional(),
|
|
externalId: z.string().trim().min(1).max(400).optional(),
|
|
oauth: z.object({
|
|
strategy: z.string().trim().min(1).max(100).optional(),
|
|
accessTokenExpiresAt: z.string().datetime().nullable().optional(),
|
|
scopes: z.array(z.string().trim().min(1).max(500)).max(20).optional(),
|
|
scopeSource: z.enum(["provider", "requested_fallback"]).optional(),
|
|
unrequestedScopes: z.array(z.string().trim().min(1).max(500)).max(20).optional(),
|
|
requestedScopes: z.array(z.string().trim().min(1).max(500)).max(20).optional(),
|
|
tokenType: z.string().trim().min(1).max(100).optional(),
|
|
refreshTokenExpiresAt: z.string().datetime().optional(),
|
|
refreshedAt: z.string().datetime().optional(),
|
|
}).optional(),
|
|
github: z.object({
|
|
userId: z.string().regex(/^[1-9][0-9]{0,30}$/),
|
|
login: z.string().trim().min(1).max(100),
|
|
avatarUrl: z.string().url().max(2000).optional(),
|
|
installationCount: z.number().int().nonnegative(),
|
|
repositoryCount: z.number().int().nonnegative(),
|
|
repositorySelection: z.enum(["all", "selected", "mixed", "none"]),
|
|
installationIds: z.array(z.string().regex(/^[1-9][0-9]{0,30}$/)).max(100),
|
|
installationOwnerLogins: z.array(z.string().trim().min(1).max(100)).max(100),
|
|
installationUrl: z.string().url().max(2000).optional(),
|
|
managementUrl: z.string().url().max(2000).optional(),
|
|
appSlug: z.string().regex(/^[a-z0-9-]{1,100}$/).optional(),
|
|
lastAccessRefreshAt: z.string().datetime().optional(),
|
|
lastWebhookAt: z.string().datetime().optional(),
|
|
webhookHealth: z.enum(["pending", "healthy", "unhealthy"]).optional(),
|
|
}).optional(),
|
|
}).nullable(),
|
|
credentialSecretRefs: z.array(toolCredentialSecretRefSchema),
|
|
externalCredential: vercelConnectGrantSummarySchema.nullable().optional(),
|
|
status: connectionGrantStatusSchema,
|
|
isDefault: z.boolean(),
|
|
createdByAgentId: z.string().guid().nullable(),
|
|
createdByUserId: z.string().nullable(),
|
|
revokedAt: z.coerce.date().nullable(),
|
|
revokedByAgentId: z.string().guid().nullable(),
|
|
revokedByUserId: z.string().nullable(),
|
|
lastUsedAt: z.coerce.date().nullable(),
|
|
createdAt: z.coerce.date(),
|
|
updatedAt: z.coerce.date(),
|
|
}).superRefine((grant, ctx) => {
|
|
const validSubject = (grant.kind === "user" && Boolean(grant.subjectUserId) && !grant.subjectAgentId)
|
|
|| (grant.kind === "agent" && Boolean(grant.subjectAgentId) && !grant.subjectUserId)
|
|
|| (grant.kind === "organization" && !grant.subjectUserId && !grant.subjectAgentId);
|
|
if (!validSubject) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["kind"], message: "User and agent grants require exactly their matching subject; organization grants cannot have a subject" });
|
|
}
|
|
if (grant.externalCredential && grant.credentialSecretRefs.length > 0) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["credentialSecretRefs"], message: "External grants cannot also contain Paperclip secret references" });
|
|
}
|
|
});
|
|
|
|
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>;
|
|
|
|
/**
|
|
* Audience replacement for an organization grant (PAP-17835). An empty array is
|
|
* the canonical encoding of "all organization members" — the UI never exposes
|
|
* "empty list" as the mental model, so the wire format carries the emptiness and
|
|
* the copy layer translates it.
|
|
*/
|
|
export const replaceConnectionGrantMembersSchema = z.object({
|
|
memberUserIds: z.array(z.string().trim().min(1).max(500)).max(1000),
|
|
}).strict();
|
|
|
|
export type ReplaceConnectionGrantMembersInput = z.infer<typeof replaceConnectionGrantMembersSchema>;
|
|
|
|
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 connectionTokenSubjectSchema = z.discriminatedUnion("type", [
|
|
z.object({ type: z.literal("app") }).strict(),
|
|
z.object({ type: z.literal("user"), userId: z.string().trim().min(1).max(500) }).strict(),
|
|
]);
|
|
|
|
export const connectionTokenRequestSchema = z.object({
|
|
subject: connectionTokenSubjectSchema.optional().default({ type: "app" }),
|
|
scope: connectionTokenScopeSchema.optional(),
|
|
requestedTtlSeconds: z.number().int().positive().max(86_400).optional(),
|
|
grantId: z.string().guid().optional(),
|
|
}).strict();
|
|
|
|
export const startConnectionAuthorizationSchema = z.object({
|
|
subjectUserId: z.string().trim().min(1).max(500),
|
|
scopes: z.array(z.string().trim().min(1).max(240)).max(100).optional(),
|
|
returnTo: z.string().trim().max(2000).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>;
|
|
|
|
/**
|
|
* How an operator says a generic remote MCP endpoint authenticates (PAP-17087).
|
|
*
|
|
* `auto` is the default and the only value the simple path sends: Paperclip
|
|
* probes the endpoint and branches on what it finds. The rest are the explicit
|
|
* choices behind "Advanced authentication", where the operator already knows.
|
|
*/
|
|
export const GENERIC_MCP_AUTH_MODES = ["auto", "none", "bearer", "custom_headers", "oauth"] as const;
|
|
|
|
export const genericMcpAuthModeSchema = z.enum(GENERIC_MCP_AUTH_MODES);
|
|
|
|
export type GenericMcpAuthMode = z.infer<typeof genericMcpAuthModeSchema>;
|
|
|
|
/**
|
|
* A preregistered OAuth client an operator pasted in because the authorization
|
|
* server supports neither CIMD nor dynamic registration. The secret is write-only:
|
|
* it becomes a Paperclip secret ref and is never read back.
|
|
*/
|
|
export const genericMcpOAuthClientSchema = z.object({
|
|
clientId: z.string().trim().min(1).max(4096),
|
|
clientSecret: z.string().min(1).max(16384).optional(),
|
|
}).strict();
|
|
|
|
export type GenericMcpOAuthClient = z.infer<typeof genericMcpOAuthClientSchema>;
|
|
|
|
/**
|
|
* Reject `headers.*` credential paths whose header name Paperclip refuses to
|
|
* send, and any value that could split the outbound request. This runs at the
|
|
* API boundary so both the guided wizard and normalized paste-config go through
|
|
* exactly one gate; the service re-checks when it projects the headers.
|
|
*/
|
|
function rejectUnsafeHeaderCredentials(
|
|
credentialValues: Record<string, string>,
|
|
ctx: z.RefinementCtx,
|
|
path: (string | number)[],
|
|
) {
|
|
for (const [configPath, value] of Object.entries(credentialValues)) {
|
|
const headerName = mcpRemoteHeaderNameFromConfigPath(configPath);
|
|
if (configPath.startsWith("headers.") && !headerName) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: [...path, configPath],
|
|
message: "Header names cannot be blank.",
|
|
});
|
|
continue;
|
|
}
|
|
if (!headerName) continue;
|
|
const nameCheck = checkMcpRemoteHeaderName(headerName);
|
|
if (!nameCheck.ok) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: [...path, configPath],
|
|
message: mcpRemoteHeaderRejectionMessage(headerName, nameCheck.reason!),
|
|
});
|
|
continue;
|
|
}
|
|
const valueCheck = checkMcpRemoteHeaderValue(value);
|
|
if (!valueCheck.ok) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: [...path, configPath],
|
|
message: mcpRemoteHeaderRejectionMessage(headerName, valueCheck.reason!),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export const connectToolAppSchema = z.object({
|
|
agentInstructions: connectionAgentInstructionsSchema.nullable().optional(),
|
|
galleryKey: z.string().trim().min(1).max(120).optional(),
|
|
connectionMethodKey: 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().guid().optional(),
|
|
/** Pending connection request this setup should resolve after authorization. */
|
|
interactionId: z.string().uuid().optional(),
|
|
/** Exact draft to continue after an interrupted setup. */
|
|
resumeConnectionId: z.string().guid().optional(),
|
|
/** Exact configured connection to reauthorize without replacing its identity. */
|
|
reconnectConnectionId: z.string().guid().optional(),
|
|
saveDraft: z.boolean().optional(),
|
|
authMode: genericMcpAuthModeSchema.optional(),
|
|
oauthClient: genericMcpOAuthClientSchema.optional(),
|
|
credentialSource: z.enum(["paperclip_vault", "vercel_connect"]).optional(),
|
|
vercelConnect: z.object({ connector: z.string().trim().min(1).max(255) }).strict().optional(),
|
|
/**
|
|
* Which identity this credential becomes (PAP-17835). `user` means "Just me":
|
|
* the credential is committed to the caller's own personal grant and never to
|
|
* the connection row's shared secret refs or the default organization grant.
|
|
* Omitted keeps the historical shared-credential behaviour.
|
|
*/
|
|
grantKind: connectionGrantKindSchema.optional(),
|
|
/** Same-company agent that owns a dedicated provider identity. */
|
|
subjectAgentId: z.string().guid().optional(),
|
|
}).superRefine((value, ctx) => {
|
|
if (value.configValues) rejectSensitiveConfigKeys(value.configValues, ctx, ["configValues"]);
|
|
if (value.credentialValues) rejectUnsafeHeaderCredentials(value.credentialValues, ctx, ["credentialValues"]);
|
|
if ((value.grantKind === "agent") !== Boolean(value.subjectAgentId)) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["subjectAgentId"], message: "subjectAgentId is required exactly for an agent grant" });
|
|
}
|
|
if (value.saveDraft && !isRemoteMcpConnectorMethod(value.galleryKey, value.connectionMethodKey)) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["saveDraft"], message: "Draft saving requires a remote MCP connector" });
|
|
}
|
|
if (value.authMode && value.galleryKey && !isRemoteMcpConnectorMethod(value.galleryKey, value.connectionMethodKey)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["authMode"],
|
|
message: "Authentication mode selection applies to a pasted URL, not a gallery app",
|
|
});
|
|
}
|
|
if (value.resumeConnectionId && value.reconnectConnectionId) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["reconnectConnectionId"], message: "Choose resume or reconnect, not both" });
|
|
}
|
|
if (value.resumeConnectionId && !value.galleryKey) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["resumeConnectionId"],
|
|
message: "Only gallery app setup can resume a draft connection",
|
|
});
|
|
}
|
|
const source = value.credentialSource ?? "paperclip_vault";
|
|
if (source === "vercel_connect") {
|
|
if (!value.vercelConnect) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["vercelConnect"], message: "A Vercel connector UID is required" });
|
|
}
|
|
if (value.credentialValues && Object.keys(value.credentialValues).length > 0) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["credentialValues"], message: "Vercel-backed connections cannot include provider credentials" });
|
|
}
|
|
if (value.oauthClient) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["oauthClient"], message: "Vercel-backed connections cannot include OAuth client credentials" });
|
|
}
|
|
} else if (value.vercelConnect) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["vercelConnect"], message: "Vercel connector metadata requires the vercel_connect credential source" });
|
|
}
|
|
}).refine(
|
|
(value) => Boolean(value.galleryKey) || Boolean(value.link),
|
|
{ message: "Provide a 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({
|
|
agentInstructions: connectionAgentInstructionsSchema.nullable().optional(),
|
|
/** Task setup adds access while preserving existing assignments and action policies. */
|
|
preserveExistingAccess: z.boolean().optional(),
|
|
enabledCatalogEntryIds: z.array(z.string().guid()).max(500).default([]),
|
|
askFirstCatalogEntryIds: z.array(z.string().guid()).max(500).default([]),
|
|
reviewedCatalogEntryIds: z.array(z.string().guid()).max(500).optional(),
|
|
access: z.union([
|
|
z.literal("all_agents"),
|
|
// Choosing specific agents may intentionally leave the connection unassigned.
|
|
z.object({ agentIds: z.array(z.string().guid()).max(250) }),
|
|
]),
|
|
});
|
|
|
|
export type FinishToolApp = z.infer<typeof finishToolAppSchema>;
|
|
|
|
/**
|
|
* Legacy promotion boundary retained for connection-intent flows. Interactive
|
|
* app setup chooses identity before OAuth and writes the token directly to that
|
|
* credential scope.
|
|
*/
|
|
export const finalizeOAuthAccessSchema = z.object({
|
|
grantKind: connectionGrantKindSchema,
|
|
}).strict();
|
|
|
|
export type FinalizeOAuthAccess = z.infer<typeof finalizeOAuthAccessSchema>;
|
|
|
|
export const startToolOAuthSchema = z.object({
|
|
asCurrentUser: z.boolean().optional(),
|
|
asAgentId: z.string().uuid().optional(),
|
|
interactionId: z.string().uuid().optional(),
|
|
}).strict().superRefine((value, ctx) => {
|
|
if (value.asCurrentUser && value.asAgentId) {
|
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["asAgentId"], message: "Choose either the current user or one dedicated agent" });
|
|
}
|
|
}).default({});
|
|
|
|
export type StartToolOAuth = z.infer<typeof startToolOAuthSchema>;
|
|
|
|
export const upsertToolCatalogEntrySchema = z.object({
|
|
applicationId: z.string().guid(),
|
|
connectionId: z.string().guid(),
|
|
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 = objectWithoutDefaults(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().guid(),
|
|
selectorType: toolProfileEntrySelectorTypeSchema,
|
|
effect: toolProfileEntryEffectSchema.default("include"),
|
|
applicationId: z.string().guid().optional().nullable(),
|
|
connectionId: z.string().guid().optional().nullable(),
|
|
catalogEntryId: z.string().guid().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 = objectWithoutDefaults(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 = objectWithoutDefaults(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().guid(),
|
|
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().guid().optional(),
|
|
}).prefault({});
|
|
|
|
export type DeleteToolProfile = z.infer<typeof deleteToolProfileSchema>;
|
|
|
|
export const createToolProfileBindingSchema = z.object({
|
|
profileId: z.string().guid(),
|
|
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),
|
|
}).prefault({}),
|
|
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(),
|
|
}).prefault({}),
|
|
});
|
|
|
|
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([]),
|
|
}).prefault({}),
|
|
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([]),
|
|
}).prefault({}),
|
|
responseHeaders: z.object({
|
|
forwardMcpRequiredHeaders: z.boolean().default(true),
|
|
forwardSafeCacheHeaders: z.boolean().default(true),
|
|
}).prefault({}),
|
|
});
|
|
|
|
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().guid(),
|
|
defaultProfileMode: toolMcpGatewayDefaultProfileModeSchema.default("gateway_only").optional(),
|
|
contextScopeType: toolMcpGatewayContextScopeTypeSchema.default("none").optional(),
|
|
contextScopeId: z.string().trim().min(1).max(200).optional().nullable(),
|
|
agentId: z.string().guid().optional().nullable(),
|
|
projectId: z.string().guid().optional().nullable(),
|
|
issueId: z.string().guid().optional().nullable(),
|
|
approvalIssueId: z.string().guid().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 = objectWithoutDefaults(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().max(1000).default(""),
|
|
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().guid().optional(),
|
|
agentIds: z.array(z.string().guid()).max(100).optional(),
|
|
}).strict();
|
|
|
|
const contextConditionSchema = z.object({
|
|
projectId: z.string().guid().optional(),
|
|
projectIds: z.array(z.string().guid()).max(100).optional(),
|
|
routineId: z.string().guid().optional(),
|
|
routineIds: z.array(z.string().guid()).max(100).optional(),
|
|
issueId: z.string().guid().optional(),
|
|
issueIds: z.array(z.string().guid()).max(100).optional(),
|
|
requireIssue: z.boolean().optional(),
|
|
requireProject: z.boolean().optional(),
|
|
requireRoutine: z.boolean().optional(),
|
|
}).strict();
|
|
|
|
const credentialScopeConditionSchema = z.object({
|
|
applicationId: z.string().guid().optional(),
|
|
applicationIds: z.array(z.string().guid()).max(100).optional(),
|
|
connectionId: z.string().guid().optional(),
|
|
connectionIds: z.array(z.string().guid()).max(100).optional(),
|
|
catalogEntryId: z.string().guid().optional(),
|
|
catalogEntryIds: z.array(z.string().guid()).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 = objectWithoutDefaults(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().guid()).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().guid().optional().nullable(),
|
|
runId: z.string().guid().optional().nullable(),
|
|
applicationId: z.string().guid().optional().nullable(),
|
|
connectionId: z.string().guid().optional().nullable(),
|
|
catalogEntryId: z.string().guid().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().guid(),
|
|
issueId: z.string().guid().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().guid(),
|
|
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().guid().optional(),
|
|
agentIds: z.array(z.string().guid()).optional(),
|
|
projectId: z.string().guid().optional(),
|
|
projectIds: z.array(z.string().guid()).optional(),
|
|
routineId: z.string().guid().optional(),
|
|
routineIds: z.array(z.string().guid()).optional(),
|
|
issueId: z.string().guid().optional(),
|
|
issueIds: z.array(z.string().guid()).optional(),
|
|
gatewayId: z.string().guid().optional(),
|
|
gatewayIds: z.array(z.string().guid()).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().guid().optional(),
|
|
gatewayTokenIds: z.array(z.string().guid()).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().guid().optional(),
|
|
applicationIds: z.array(z.string().guid()).optional(),
|
|
connectionId: z.string().guid().optional(),
|
|
connectionIds: z.array(z.string().guid()).optional(),
|
|
catalogEntryId: z.string().guid().optional(),
|
|
catalogEntryIds: z.array(z.string().guid()).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({
|
|
argumentMode: z.enum(["exact", "action"]).optional(),
|
|
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().guid(),
|
|
actor: z.object({
|
|
actorType: z.enum(["agent", "user", "system", "plugin"]),
|
|
actorId: z.string().trim().min(1).max(240),
|
|
agentId: z.string().guid().optional().nullable(),
|
|
}),
|
|
runContext: z.object({
|
|
heartbeatRunId: z.string().guid().optional().nullable(),
|
|
issueId: z.string().guid().optional().nullable(),
|
|
projectId: z.string().guid().optional().nullable(),
|
|
routineId: z.string().guid().optional().nullable(),
|
|
gatewayId: z.string().guid().optional().nullable(),
|
|
gatewayPublicId: z.string().trim().min(1).max(120).regex(safeKeyPattern).optional().nullable(),
|
|
gatewayTokenId: z.string().guid().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().guid().optional().nullable(),
|
|
connectionId: z.string().guid().optional().nullable(),
|
|
catalogEntryId: z.string().guid().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>;
|