Merge upstream/master into dev (76 commits)

Resolved 5 conflicts:
- .github/workflows/docker.yml, release.yml: kept fork stubs (CI handled by build-prod/build-dev)
- server/src/routes/secrets.ts: kept fork's /usages route alongside upstream's /usage, /access-events
- server/src/services/secrets.ts: kept fork's usages() function and in-use deletion guard,
  layered before upstream's soft-delete + provider cleanup in remove()
- ui/src/api/secrets.ts: kept fork's usages() method alongside upstream's vault methods

Typechecks pass on @paperclipai/shared, @paperclipai/server, @paperclipai/ui.
This commit is contained in:
2026-05-11 18:01:34 -04:00
625 changed files with 145314 additions and 4442 deletions
+1
View File
@@ -11,6 +11,7 @@ export const API = {
goals: `${API_PREFIX}/goals`,
approvals: `${API_PREFIX}/approvals`,
secrets: `${API_PREFIX}/secrets`,
secretProviderConfigs: `${API_PREFIX}/secret-provider-configs`,
costs: `${API_PREFIX}/costs`,
activity: `${API_PREFIX}/activity`,
dashboard: `${API_PREFIX}/dashboard`,
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { paperclipConfigSchema } from "./config-schema.js";
describe("paperclip config schema", () => {
it("defaults omitted runtime paths to legacy instance-root locations", () => {
const parsed = paperclipConfigSchema.parse({
$meta: {
version: 1,
updatedAt: "2026-05-10T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
},
logging: {
mode: "file",
},
server: {},
});
expect(parsed.database.embeddedPostgresDataDir).toBe("~/.paperclip/instances/default/db");
expect(parsed.database.backup.dir).toBe("~/.paperclip/instances/default/data/backups");
expect(parsed.logging.logDir).toBe("~/.paperclip/instances/default/logs");
expect(parsed.storage.localDisk.baseDir).toBe("~/.paperclip/instances/default/data/storage");
expect(parsed.secrets.localEncrypted.keyFilePath).toBe("~/.paperclip/instances/default/secrets/master.key");
});
});
+116
View File
@@ -33,6 +33,7 @@ export const AGENT_ADAPTER_TYPES = [
"acpx_local",
"claude_local",
"codex_local",
"cursor_cloud",
"gemini_local",
"opencode_local",
"pi_local",
@@ -146,8 +147,29 @@ export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join(","
export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
export const ISSUE_WORK_MODES = ["standard", "planning"] as const;
export type IssueWorkMode = (typeof ISSUE_WORK_MODES)[number];
export const MAX_ISSUE_REQUEST_DEPTH = 1024;
export const ISSUE_COMMENT_AUTHOR_TYPES = ["user", "agent", "system"] as const;
export type IssueCommentAuthorType = (typeof ISSUE_COMMENT_AUTHOR_TYPES)[number];
export const ISSUE_COMMENT_PRESENTATION_KINDS = ["message", "system_notice"] as const;
export type IssueCommentPresentationKind = (typeof ISSUE_COMMENT_PRESENTATION_KINDS)[number];
export const ISSUE_COMMENT_PRESENTATION_TONES = ["neutral", "info", "success", "warning", "danger"] as const;
export type IssueCommentPresentationTone = (typeof ISSUE_COMMENT_PRESENTATION_TONES)[number];
export const ISSUE_COMMENT_METADATA_ROW_TYPES = [
"text",
"code",
"key_value",
"issue_link",
"agent_link",
"run_link",
] as const;
export type IssueCommentMetadataRowType = (typeof ISSUE_COMMENT_METADATA_ROW_TYPES)[number];
export function clampIssueRequestDepth(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.min(MAX_ISSUE_REQUEST_DEPTH, Math.max(0, Math.floor(value)));
@@ -190,6 +212,16 @@ export const ISSUE_ORIGIN_KINDS = [
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export type PluginIssueOriginKind = `plugin:${string}`;
export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind;
export const ISSUE_SURFACE_VISIBILITIES = ["default", "plugin_operation"] as const;
export type IssueSurfaceVisibility = (typeof ISSUE_SURFACE_VISIBILITIES)[number];
export function pluginOperationIssueOriginKind(pluginKey: string): PluginIssueOriginKind {
return `plugin:${pluginKey}:operation`;
}
export function isPluginOperationIssueOriginKind(originKind: string | null | undefined): boolean {
return typeof originKind === "string" && /^plugin:[^:]+:operation(?::|$)/.test(originKind);
}
export const ISSUE_RELATION_TYPES = ["blocks"] as const;
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
@@ -221,9 +253,39 @@ export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[num
export const ISSUE_EXECUTION_STAGE_TYPES = ["review", "approval"] as const;
export type IssueExecutionStageType = (typeof ISSUE_EXECUTION_STAGE_TYPES)[number];
export const ISSUE_MONITOR_SCHEDULED_BY = ["assignee", "board"] as const;
export type IssueMonitorScheduledBy = (typeof ISSUE_MONITOR_SCHEDULED_BY)[number];
export const ISSUE_EXECUTION_MONITOR_KINDS = ["external_service"] as const;
export type IssueExecutionMonitorKind = (typeof ISSUE_EXECUTION_MONITOR_KINDS)[number];
export const ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES = [
"wake_owner",
"create_recovery_issue",
"escalate_to_board",
] as const;
export type IssueExecutionMonitorRecoveryPolicy =
(typeof ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES)[number];
export const ISSUE_EXECUTION_STATE_STATUSES = ["idle", "pending", "changes_requested", "completed"] as const;
export type IssueExecutionStateStatus = (typeof ISSUE_EXECUTION_STATE_STATUSES)[number];
export const ISSUE_EXECUTION_MONITOR_STATE_STATUSES = ["scheduled", "triggered", "cleared"] as const;
export type IssueExecutionMonitorStateStatus = (typeof ISSUE_EXECUTION_MONITOR_STATE_STATUSES)[number];
export const ISSUE_EXECUTION_MONITOR_CLEAR_REASONS = [
"manual",
"triggered",
"done",
"cancelled",
"invalid_status",
"invalid_assignee",
"dispatch_skipped",
"timeout_exceeded",
"max_attempts_exhausted",
] as const;
export type IssueExecutionMonitorClearReason = (typeof ISSUE_EXECUTION_MONITOR_CLEAR_REASONS)[number];
export const ISSUE_EXECUTION_DECISION_OUTCOMES = ["approved", "changes_requested"] as const;
export type IssueExecutionDecisionOutcome = (typeof ISSUE_EXECUTION_DECISION_OUTCOMES)[number];
@@ -334,6 +396,54 @@ export const SECRET_PROVIDERS = [
] as const;
export type SecretProvider = (typeof SECRET_PROVIDERS)[number];
export const SECRET_PROVIDER_CONFIG_STATUSES = [
"ready",
"warning",
"coming_soon",
"disabled",
] as const;
export type SecretProviderConfigStatus = (typeof SECRET_PROVIDER_CONFIG_STATUSES)[number];
export const SECRET_PROVIDER_CONFIG_HEALTH_STATUSES = [
"ready",
"warning",
"error",
"coming_soon",
"disabled",
] as const;
export type SecretProviderConfigHealthStatus =
(typeof SECRET_PROVIDER_CONFIG_HEALTH_STATUSES)[number];
export const SECRET_STATUSES = ["active", "disabled", "archived", "deleted"] as const;
export type SecretStatus = (typeof SECRET_STATUSES)[number];
export const SECRET_MANAGED_MODES = ["paperclip_managed", "external_reference"] as const;
export type SecretManagedMode = (typeof SECRET_MANAGED_MODES)[number];
export const SECRET_VERSION_STATUSES = [
"current",
"previous",
"disabled",
"destroyed",
"failed",
] as const;
export type SecretVersionStatus = (typeof SECRET_VERSION_STATUSES)[number];
export const SECRET_BINDING_TARGET_TYPES = [
"agent",
"project",
"environment",
"routine",
"plugin",
"issue",
"run",
"system",
] as const;
export type SecretBindingTargetType = (typeof SECRET_BINDING_TARGET_TYPES)[number];
export const SECRET_ACCESS_OUTCOMES = ["success", "failure"] as const;
export type SecretAccessOutcome = (typeof SECRET_ACCESS_OUTCOMES)[number];
export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const;
export type StorageProvider = (typeof STORAGE_PROVIDERS)[number];
@@ -604,9 +714,13 @@ export const PLUGIN_CAPABILITIES = [
"issue.comments.create",
"issue.interactions.create",
"issue.documents.write",
"projects.managed",
"routines.managed",
"skills.managed",
"agents.pause",
"agents.resume",
"agents.invoke",
"agents.managed",
"agent.sessions.create",
"agent.sessions.list",
"agent.sessions.send",
@@ -628,6 +742,7 @@ export const PLUGIN_CAPABILITIES = [
"http.outbound",
"secrets.read-ref",
"environment.drivers.register",
"local.folders",
// Agent Tools
"agent.tools.register",
// UI
@@ -698,6 +813,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
"taskDetailView",
"dashboardWidget",
"sidebar",
"routeSidebar",
"sidebarPanel",
"projectSidebarItem",
"globalToolbarButton",
+36
View File
@@ -0,0 +1,36 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
resolveDefaultBackupDir,
resolveDefaultEmbeddedPostgresDir,
resolveDefaultLogsDir,
resolveDefaultSecretsKeyFilePath,
resolveDefaultStorageDir,
resolvePaperclipConfigPathForInstance,
resolvePaperclipInstanceRoot,
} from "./home-paths.js";
const ORIGINAL_ENV = { ...process.env };
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
describe("home path resolution", () => {
it("resolves config and runtime data directly under the instance root", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-home-paths-"));
process.env.PAPERCLIP_HOME = home;
delete process.env.PAPERCLIP_INSTANCE_ID;
const instanceRoot = path.join(home, "instances", "default");
expect(resolvePaperclipInstanceRoot()).toBe(instanceRoot);
expect(resolvePaperclipConfigPathForInstance()).toBe(path.join(instanceRoot, "config.json"));
expect(resolveDefaultEmbeddedPostgresDir()).toBe(path.join(instanceRoot, "db"));
expect(resolveDefaultBackupDir()).toBe(path.join(instanceRoot, "data", "backups"));
expect(resolveDefaultLogsDir()).toBe(path.join(instanceRoot, "logs"));
expect(resolveDefaultStorageDir()).toBe(path.join(instanceRoot, "data", "storage"));
expect(resolveDefaultSecretsKeyFilePath()).toBe(path.join(instanceRoot, "secrets", "master.key"));
});
});
+92
View File
@@ -0,0 +1,92 @@
import os from "node:os";
import path from "node:path";
export const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
export const PAPERCLIP_CONFIG_BASENAME = "config.json";
export const PAPERCLIP_ENV_FILENAME = ".env";
const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
export function expandHomePrefix(value: string): string {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
return value;
}
export function resolvePaperclipHomeDir(homeOverride?: string): string {
const raw = homeOverride?.trim() || process.env.PAPERCLIP_HOME?.trim();
if (raw) return path.resolve(expandHomePrefix(raw));
return path.resolve(os.homedir(), ".paperclip");
}
export function resolvePaperclipInstanceId(instanceIdOverride?: string): string {
const raw = instanceIdOverride?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_PAPERCLIP_INSTANCE_ID;
if (!PATH_SEGMENT_RE.test(raw)) {
throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`);
}
return raw;
}
export function resolvePaperclipInstanceRoot(input: {
homeDir?: string;
instanceId?: string;
} = {}): string {
return path.resolve(resolvePaperclipHomeDir(input.homeDir), "instances", resolvePaperclipInstanceId(input.instanceId));
}
export function resolvePaperclipInstanceConfigPath(input: {
homeDir?: string;
instanceId?: string;
} = {}): string {
return path.resolve(resolvePaperclipInstanceRoot(input), PAPERCLIP_CONFIG_BASENAME);
}
export function resolvePaperclipConfigPathForInstance(input: {
homeDir?: string;
instanceId?: string;
} = {}): string {
return resolvePaperclipInstanceConfigPath(input);
}
export function resolvePaperclipEnvPathForConfig(configPath: string): string {
return path.resolve(path.dirname(configPath), PAPERCLIP_ENV_FILENAME);
}
export function resolveDefaultEmbeddedPostgresDir(input: {
homeDir?: string;
instanceId?: string;
} = {}): string {
return path.resolve(resolvePaperclipInstanceRoot(input), "db");
}
export function resolveDefaultLogsDir(input: {
homeDir?: string;
instanceId?: string;
} = {}): string {
return path.resolve(resolvePaperclipInstanceRoot(input), "logs");
}
export function resolveDefaultSecretsKeyFilePath(input: {
homeDir?: string;
instanceId?: string;
} = {}): string {
return path.resolve(resolvePaperclipInstanceRoot(input), "secrets", "master.key");
}
export function resolveDefaultStorageDir(input: {
homeDir?: string;
instanceId?: string;
} = {}): string {
return path.resolve(resolvePaperclipInstanceRoot(input), "data", "storage");
}
export function resolveDefaultBackupDir(input: {
homeDir?: string;
instanceId?: string;
} = {}): string {
return path.resolve(resolvePaperclipInstanceRoot(input), "data", "backups");
}
export function resolveHomeAwarePath(value: string): string {
return path.resolve(expandHomePrefix(value));
}
+132
View File
@@ -19,12 +19,20 @@ export {
INBOX_MINE_ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
ISSUE_PRIORITIES,
ISSUE_WORK_MODES,
MAX_ISSUE_REQUEST_DEPTH,
ISSUE_COMMENT_AUTHOR_TYPES,
ISSUE_COMMENT_METADATA_ROW_TYPES,
ISSUE_COMMENT_PRESENTATION_KINDS,
ISSUE_COMMENT_PRESENTATION_TONES,
clampIssueRequestDepth,
ISSUE_THREAD_INTERACTION_KINDS,
ISSUE_THREAD_INTERACTION_STATUSES,
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
ISSUE_ORIGIN_KINDS,
ISSUE_SURFACE_VISIBILITIES,
pluginOperationIssueOriginKind,
isPluginOperationIssueOriginKind,
ISSUE_RELATION_TYPES,
ISSUE_TREE_CONTROL_MODES,
ISSUE_TREE_HOLD_RELEASE_POLICY_STRATEGIES,
@@ -35,7 +43,12 @@ export {
ISSUE_REFERENCE_SOURCE_KINDS,
ISSUE_EXECUTION_POLICY_MODES,
ISSUE_EXECUTION_STAGE_TYPES,
ISSUE_MONITOR_SCHEDULED_BY,
ISSUE_EXECUTION_MONITOR_KINDS,
ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES,
ISSUE_EXECUTION_STATE_STATUSES,
ISSUE_EXECUTION_MONITOR_STATE_STATUSES,
ISSUE_EXECUTION_MONITOR_CLEAR_REASONS,
ISSUE_EXECUTION_DECISION_OUTCOMES,
GOAL_LEVELS,
GOAL_STATUSES,
@@ -58,6 +71,8 @@ export {
APPROVAL_TYPES,
APPROVAL_STATUSES,
SECRET_PROVIDERS,
SECRET_PROVIDER_CONFIG_STATUSES,
SECRET_PROVIDER_CONFIG_HEALTH_STATUSES,
STORAGE_PROVIDERS,
BILLING_TYPES,
FINANCE_EVENT_KINDS,
@@ -122,12 +137,18 @@ export {
type AgentIconName,
type IssueStatus,
type IssuePriority,
type IssueWorkMode,
type IssueCommentAuthorType,
type IssueCommentMetadataRowType,
type IssueCommentPresentationKind,
type IssueCommentPresentationTone,
type IssueThreadInteractionKind,
type IssueThreadInteractionStatus,
type IssueThreadInteractionContinuationPolicy,
type BuiltInIssueOriginKind,
type PluginIssueOriginKind,
type IssueOriginKind,
type IssueSurfaceVisibility,
type IssueRelationType,
type IssueTreeControlMode,
type IssueTreeHoldReleasePolicyStrategy,
@@ -136,7 +157,12 @@ export {
type IssueReferenceSourceKind,
type IssueExecutionPolicyMode,
type IssueExecutionStageType,
type IssueMonitorScheduledBy,
type IssueExecutionMonitorKind,
type IssueExecutionMonitorRecoveryPolicy,
type IssueExecutionStateStatus,
type IssueExecutionMonitorStateStatus,
type IssueExecutionMonitorClearReason,
type IssueExecutionDecisionOutcome,
type GoalLevel,
type GoalStatus,
@@ -158,6 +184,8 @@ export {
type ApprovalType,
type ApprovalStatus,
type SecretProvider,
type SecretProviderConfigStatus,
type SecretProviderConfigHealthStatus,
type StorageProvider,
type BillingType,
type FinanceEventKind,
@@ -293,7 +321,15 @@ export type {
ProjectCodebase,
ProjectCodebaseOrigin,
ProjectGoalRef,
ProjectManagedByPlugin,
ProjectWorkspace,
CompanySearchHighlight,
CompanySearchIssueSummary,
CompanySearchResponse,
CompanySearchResult,
CompanySearchResultType,
CompanySearchScope,
CompanySearchSnippet,
ExecutionWorkspace,
ExecutionWorkspaceSummary,
ExecutionWorkspaceConfig,
@@ -337,9 +373,17 @@ export type {
IssueBlockerAttentionState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind,
IssueScheduledRetry,
IssueScheduledRetryStatus,
IssueRetryNowOutcome,
IssueRetryNowResponse,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,
IssueExecutionMonitorPolicy,
IssueExecutionMonitorState,
IssueRelation,
IssueRelationIssueSummary,
IssueExecutionPolicy,
@@ -349,6 +393,16 @@ export type {
IssueExecutionStagePrincipal,
IssueExecutionDecision,
IssueComment,
IssueCommentMetadata,
IssueCommentMetadataSection,
IssueCommentMetadataRow,
IssueCommentMetadataTextRow,
IssueCommentMetadataCodeRow,
IssueCommentMetadataKeyValueRow,
IssueCommentMetadataIssueLinkRow,
IssueCommentMetadataAgentLinkRow,
IssueCommentMetadataRunLinkRow,
IssueCommentPresentation,
IssueThreadInteractionActorFields,
SuggestedTaskDraft,
SuggestTasksPayload,
@@ -458,6 +512,7 @@ export type {
CompanyPortabilityProjectWorkspaceManifestEntry,
CompanyPortabilityIssueRoutineTriggerManifestEntry,
CompanyPortabilityIssueRoutineManifestEntry,
CompanyPortabilityIssueCommentManifestEntry,
CompanyPortabilityIssueManifestEntry,
CompanyPortabilityManifest,
CompanyPortabilityExportResult,
@@ -480,10 +535,38 @@ export type {
EnvBinding,
AgentEnvConfig,
CompanySecret,
CompanySecretProviderConfig,
SecretProviderConfigPayload,
SecretProviderConfigHealthDetails,
SecretProviderConfigHealthResponse,
CompanySecretBinding,
CompanySecretBindingTarget,
CompanySecretUsageBinding,
CompanySecretVersion,
SecretAccessEvent,
RemoteSecretImportCandidate,
RemoteSecretImportCandidateStatus,
RemoteSecretImportConflict,
RemoteSecretImportPreviewResult,
RemoteSecretImportResult,
RemoteSecretImportRowResult,
RemoteSecretImportRowStatus,
SecretAccessOutcome,
SecretBindingTargetType,
SecretManagedMode,
SecretProviderDescriptor,
SecretStatus,
SecretVersionSelector,
SecretVersionStatus,
Routine,
RoutineManagedByPlugin,
RoutineVariable,
RoutineVariableDefaultValue,
RoutineRevisionSnapshotRoutineV1,
RoutineRevisionSnapshotTriggerV1,
RoutineRevisionSnapshotV1,
RoutineRevisionSnapshot,
RoutineRevision,
RoutineTrigger,
RoutineRun,
RoutineTriggerSecretMaterial,
@@ -496,6 +579,18 @@ export type {
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginEnvironmentDriverDeclaration,
PluginManagedAgentDeclaration,
PluginManagedProjectDeclaration,
PluginManagedRoutineDeclaration,
PluginManagedSkillDeclaration,
PluginManagedSkillFileDeclaration,
PluginLocalFolderDeclaration,
PluginManagedAgentResolution,
PluginManagedProjectResolution,
PluginManagedRoutineResolution,
PluginManagedSkillResolution,
PluginManagedResourceKind,
PluginManagedResourceRef,
PluginUiSlotDeclaration,
PluginLauncherActionDeclaration,
PluginLauncherRenderDeclaration,
@@ -512,6 +607,7 @@ export type {
PluginMigrationRecord,
PluginStateRecord,
PluginConfig,
PluginCompanySettings,
PluginEntityRecord,
PluginEntityQuery,
PluginJobRecord,
@@ -520,6 +616,7 @@ export type {
QuotaWindow,
ProviderQuotaResult,
} from "./types/index.js";
export { COMPANY_SEARCH_SCOPES } from "./types/index.js";
export {
ISSUE_REFERENCE_IDENTIFIER_RE,
buildIssueReferenceHref,
@@ -644,8 +741,17 @@ export {
type CreateProjectWorkspace,
type UpdateProjectWorkspace,
projectExecutionWorkspacePolicySchema,
companySearchQuerySchema,
COMPANY_SEARCH_DEFAULT_LIMIT,
COMPANY_SEARCH_MAX_LIMIT,
COMPANY_SEARCH_MAX_OFFSET,
COMPANY_SEARCH_MAX_QUERY_LENGTH,
COMPANY_SEARCH_MAX_TOKENS,
type CompanySearchQuery,
createIssueSchema,
createIssueInputSchema,
createChildIssueSchema,
resolveCreateIssueStatusDefault,
createIssueLabelSchema,
updateIssueSchema,
issueExecutionPolicySchema,
@@ -653,6 +759,11 @@ export {
issueReviewRequestSchema,
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema,
issueCommentAuthorTypeSchema,
issueCommentPresentationSchema,
issueCommentMetadataRowSchema,
issueCommentMetadataSectionSchema,
issueCommentMetadataSchema,
addIssueCommentSchema,
issueThreadInteractionStatusSchema,
issueThreadInteractionKindSchema,
@@ -745,7 +856,19 @@ export {
envBindingSchema,
envConfigSchema,
createSecretSchema,
createSecretProviderConfigSchema,
updateSecretProviderConfigSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
remoteSecretImportSelectionSchema,
localEncryptedProviderConfigSchema,
awsSecretsManagerProviderConfigSchema,
gcpSecretManagerProviderConfigSchema,
vaultProviderConfigSchema,
secretProviderConfigPayloadSchema,
createSecretBindingSchema,
rotateSecretSchema,
secretBindingTargetSchema,
updateSecretSchema,
createRoutineSchema,
updateRoutineSchema,
@@ -754,7 +877,16 @@ export {
routineVariableSchema,
runRoutineSchema,
rotateRoutineTriggerSecretSchema,
routineRevisionSnapshotRoutineV1Schema,
routineRevisionSnapshotTriggerV1Schema,
routineRevisionSnapshotV1Schema,
routineRevisionSnapshotSchema,
type CreateSecret,
type CreateSecretProviderConfig,
type UpdateSecretProviderConfig,
type RemoteSecretImportPreview,
type RemoteSecretImport,
type RemoteSecretImportSelection,
type RotateSecret,
type UpdateSecret,
type CreateRoutine,
+7 -6
View File
@@ -10,6 +10,7 @@ import {
describe("issue references", () => {
it("normalizes identifiers to uppercase", () => {
expect(normalizeIssueIdentifier("pap-123")).toBe("PAP-123");
expect(normalizeIssueIdentifier("pc1a2-7")).toBe("PC1A2-7");
expect(normalizeIssueIdentifier("not-an-issue")).toBeNull();
});
@@ -27,14 +28,14 @@ describe("issue references", () => {
});
it("finds identifiers and issue paths in plain text", () => {
expect(findIssueReferenceMatches("See PAP-1, /issues/PAP-2, and https://x.test/PAP/issues/pap-3.")).toEqual([
expect(findIssueReferenceMatches("See PAP-1, /issues/PC1A2-2, and https://x.test/PAP/issues/pc1a2-3.")).toEqual([
{ index: 4, length: 5, identifier: "PAP-1", matchedText: "PAP-1" },
{ index: 11, length: 13, identifier: "PAP-2", matchedText: "/issues/PAP-2" },
{ index: 11, length: 15, identifier: "PC1A2-2", matchedText: "/issues/PC1A2-2" },
{
index: 30,
length: 31,
identifier: "PAP-3",
matchedText: "https://x.test/PAP/issues/pap-3",
index: 32,
length: 33,
identifier: "PC1A2-3",
matchedText: "https://x.test/PAP/issues/pc1a2-3",
},
]);
});
+2 -2
View File
@@ -1,4 +1,4 @@
export const ISSUE_REFERENCE_IDENTIFIER_RE = /^[A-Z]+-\d+$/;
export const ISSUE_REFERENCE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]*-\d+$/;
export interface IssueReferenceMatch {
index: number;
@@ -7,7 +7,7 @@ export interface IssueReferenceMatch {
matchedText: string;
}
const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\/[^\s<>()]+|[A-Z]+-\d+/gi;
const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\/[^\s<>()]+|[A-Z][A-Z0-9]*-\d+/gi;
function preserveNewlinesAsWhitespace(value: string) {
return value.replace(/[^\n]/g, " ");
@@ -1,5 +1,7 @@
import type { AgentEnvConfig, SecretProvider } from "./secrets.js";
import type { RoutineVariable } from "./routine.js";
import type { IssueCommentAuthorType } from "../constants.js";
import type { IssueCommentMetadata, IssueCommentPresentation } from "./issue.js";
export interface CompanyPortabilityInclude {
company: boolean;
@@ -98,6 +100,16 @@ export interface CompanyPortabilityIssueRoutineManifestEntry {
triggers: CompanyPortabilityIssueRoutineTriggerManifestEntry[];
}
export interface CompanyPortabilityIssueCommentManifestEntry {
body: string;
authorType: IssueCommentAuthorType;
authorAgentSlug: string | null;
authorUserId: string | null;
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
createdAt: string | null;
}
export interface CompanyPortabilityIssueManifestEntry {
slug: string;
identifier: string | null;
@@ -116,6 +128,7 @@ export interface CompanyPortabilityIssueManifestEntry {
billingCode: string | null;
executionWorkspaceSettings: Record<string, unknown> | null;
assigneeAdapterOverrides: Record<string, unknown> | null;
comments: CompanyPortabilityIssueCommentManifestEntry[];
metadata: Record<string, unknown> | null;
}
+5
View File
@@ -36,6 +36,11 @@ export interface IssueCostSummary {
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
/** number of distinct heartbeat runs aggregated across the issue tree */
runCount: number;
/** sum of wall-clock duration of each run in the tree (ms);
* still-running runs contribute (now - startedAt) so this ticks up live */
runtimeMs: number;
}
export interface CostByAgent {
+71 -1
View File
@@ -89,7 +89,17 @@ export type {
AdapterEnvironmentTestResult,
} from "./agent.js";
export type { AssetImage } from "./asset.js";
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js";
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type {
CompanySearchHighlight,
CompanySearchIssueSummary,
CompanySearchResponse,
CompanySearchResult,
CompanySearchResultType,
CompanySearchScope,
CompanySearchSnippet,
} from "./search.js";
export { COMPANY_SEARCH_SCOPES } from "./search.js";
export type {
ExecutionWorkspace,
ExecutionWorkspaceSummary,
@@ -134,17 +144,26 @@ export type {
} from "./work-product.js";
export type {
Issue,
IssueWorkMode,
IssueAssigneeAdapterOverrides,
IssueBlockerAttention,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind,
IssueScheduledRetry,
IssueScheduledRetryStatus,
IssueRetryNowOutcome,
IssueRetryNowResponse,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,
IssueRelation,
IssueRelationIssueSummary,
IssueExecutionMonitorPolicy,
IssueExecutionMonitorState,
IssueExecutionPolicy,
IssueExecutionState,
IssueExecutionStage,
@@ -153,6 +172,16 @@ export type {
IssueReviewRequest,
IssueExecutionDecision,
IssueComment,
IssueCommentMetadata,
IssueCommentMetadataSection,
IssueCommentMetadataRow,
IssueCommentMetadataTextRow,
IssueCommentMetadataCodeRow,
IssueCommentMetadataKeyValueRow,
IssueCommentMetadataIssueLinkRow,
IssueCommentMetadataAgentLinkRow,
IssueCommentMetadataRunLinkRow,
IssueCommentPresentation,
IssueThreadInteractionActorFields,
SuggestedTaskDraft,
SuggestTasksPayload,
@@ -215,12 +244,39 @@ export type {
EnvBinding,
AgentEnvConfig,
CompanySecret,
CompanySecretProviderConfig,
SecretProviderConfigPayload,
SecretProviderConfigHealthDetails,
SecretProviderConfigHealthResponse,
CompanySecretBinding,
CompanySecretBindingTarget,
CompanySecretUsageBinding,
CompanySecretVersion,
SecretAccessEvent,
RemoteSecretImportCandidate,
RemoteSecretImportCandidateStatus,
RemoteSecretImportConflict,
RemoteSecretImportPreviewResult,
RemoteSecretImportResult,
RemoteSecretImportRowResult,
RemoteSecretImportRowStatus,
SecretAccessOutcome,
SecretBindingTargetType,
SecretManagedMode,
SecretProviderDescriptor,
SecretStatus,
SecretVersionStatus,
} from "./secrets.js";
export type {
Routine,
RoutineManagedByPlugin,
RoutineVariable,
RoutineVariableDefaultValue,
RoutineRevisionSnapshotRoutineV1,
RoutineRevisionSnapshotTriggerV1,
RoutineRevisionSnapshotV1,
RoutineRevisionSnapshot,
RoutineRevision,
RoutineTrigger,
RoutineRun,
RoutineTriggerSecretMaterial,
@@ -288,6 +344,7 @@ export type {
CompanyPortabilityProjectWorkspaceManifestEntry,
CompanyPortabilityIssueRoutineTriggerManifestEntry,
CompanyPortabilityIssueRoutineManifestEntry,
CompanyPortabilityIssueCommentManifestEntry,
CompanyPortabilityIssueManifestEntry,
CompanyPortabilityManifest,
CompanyPortabilityExportResult,
@@ -314,6 +371,18 @@ export type {
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginEnvironmentDriverDeclaration,
PluginManagedAgentDeclaration,
PluginManagedProjectDeclaration,
PluginManagedRoutineDeclaration,
PluginManagedSkillDeclaration,
PluginManagedSkillFileDeclaration,
PluginLocalFolderDeclaration,
PluginManagedAgentResolution,
PluginManagedProjectResolution,
PluginManagedRoutineResolution,
PluginManagedSkillResolution,
PluginManagedResourceKind,
PluginManagedResourceRef,
PluginUiSlotDeclaration,
PluginLauncherActionDeclaration,
PluginLauncherRenderDeclaration,
@@ -330,6 +399,7 @@ export type {
PluginMigrationRecord,
PluginStateRecord,
PluginConfig,
PluginCompanySettings,
PluginEntityRecord,
PluginEntityQuery,
PluginJobRecord,
+162
View File
@@ -1,11 +1,21 @@
import type {
IssueCommentAuthorType,
IssueCommentMetadataRowType,
IssueCommentPresentationKind,
IssueCommentPresentationTone,
IssueExecutionMonitorClearReason,
IssueExecutionMonitorKind,
IssueExecutionMonitorRecoveryPolicy,
IssueExecutionMonitorStateStatus,
IssueExecutionDecisionOutcome,
IssueMonitorScheduledBy,
IssueExecutionPolicyMode,
IssueReferenceSourceKind,
IssueExecutionStageType,
IssueExecutionStateStatus,
IssueOriginKind,
IssuePriority,
IssueWorkMode,
ModelProfileKey,
IssueThreadInteractionContinuationPolicy,
IssueThreadInteractionKind,
@@ -17,6 +27,8 @@ import type { Project, ProjectWorkspace } from "./project.js";
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
import type { IssueWorkProduct } from "./work-product.js";
export type { IssueWorkMode };
export interface IssueAncestorProject {
id: string;
name: string;
@@ -157,6 +169,46 @@ export interface IssueProductivityReview {
updatedAt: Date;
}
export type SuccessfulRunHandoffStateKind = "required" | "resolved" | "escalated";
export interface SuccessfulRunHandoffState {
state: SuccessfulRunHandoffStateKind;
required: boolean;
sourceRunId: string | null;
correctiveRunId: string | null;
assigneeAgentId: string | null;
detectedProgressSummary: string | null;
createdAt: Date | string | null;
}
export type IssueScheduledRetryStatus = "scheduled_retry" | "queued" | "running" | "cancelled";
export interface IssueScheduledRetry {
runId: string;
status: IssueScheduledRetryStatus;
agentId: string;
agentName: string | null;
retryOfRunId: string | null;
scheduledRetryAt: Date | string | null;
scheduledRetryAttempt: number;
scheduledRetryReason: string | null;
retryExhaustedReason?: string | null;
error?: string | null;
errorCode?: string | null;
}
export type IssueRetryNowOutcome =
| "promoted"
| "already_promoted"
| "no_scheduled_retry"
| "gate_suppressed";
export interface IssueRetryNowResponse {
outcome: IssueRetryNowOutcome;
message: string;
scheduledRetry: IssueScheduledRetry | null;
}
export interface IssueRelation {
id: string;
companyId: string;
@@ -201,10 +253,40 @@ export interface IssueExecutionStage {
participants: IssueExecutionStageParticipant[];
}
export interface IssueExecutionMonitorPolicy {
nextCheckAt: string;
notes: string | null;
scheduledBy: IssueMonitorScheduledBy;
kind?: IssueExecutionMonitorKind | null;
serviceName?: string | null;
externalRef?: string | null;
timeoutAt?: string | null;
maxAttempts?: number | null;
recoveryPolicy?: IssueExecutionMonitorRecoveryPolicy | null;
}
export interface IssueExecutionPolicy {
mode: IssueExecutionPolicyMode;
commentRequired: boolean;
stages: IssueExecutionStage[];
monitor?: IssueExecutionMonitorPolicy | null;
}
export interface IssueExecutionMonitorState {
status: IssueExecutionMonitorStateStatus;
nextCheckAt: string | null;
lastTriggeredAt: string | null;
attemptCount: number;
notes: string | null;
scheduledBy: IssueMonitorScheduledBy | null;
kind?: IssueExecutionMonitorKind | null;
serviceName?: string | null;
externalRef?: string | null;
timeoutAt?: string | null;
maxAttempts?: number | null;
recoveryPolicy?: IssueExecutionMonitorRecoveryPolicy | null;
clearedAt: string | null;
clearReason: IssueExecutionMonitorClearReason | null;
}
export interface IssueReviewRequest {
@@ -222,6 +304,7 @@ export interface IssueExecutionState {
completedStageIds: string[];
lastDecisionId: string | null;
lastDecisionOutcome: IssueExecutionDecisionOutcome | null;
monitor?: IssueExecutionMonitorState | null;
}
export interface IssueExecutionDecision {
@@ -250,6 +333,7 @@ export interface Issue {
title: string;
description: string | null;
status: IssueStatus;
workMode: IssueWorkMode;
priority: IssuePriority;
assigneeAgentId: string | null;
assigneeUserId: string | null;
@@ -270,6 +354,11 @@ export interface Issue {
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
executionPolicy?: IssueExecutionPolicy | null;
executionState?: IssueExecutionState | null;
monitorNextCheckAt?: Date | null;
monitorLastTriggeredAt?: Date | null;
monitorAttemptCount?: number;
monitorNotes?: string | null;
monitorScheduledBy?: IssueMonitorScheduledBy | null;
executionWorkspaceId: string | null;
executionWorkspacePreference: string | null;
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
@@ -283,6 +372,8 @@ export interface Issue {
blocks?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention;
productivityReview?: IssueProductivityReview | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
scheduledRetry?: IssueScheduledRetry | null;
relatedWork?: IssueRelatedWorkSummary;
referencedIssueIdentifiers?: string[];
planDocument?: IssueDocument | null;
@@ -305,14 +396,84 @@ export interface IssueComment {
id: string;
companyId: string;
issueId: string;
authorType: IssueCommentAuthorType;
authorAgentId: string | null;
authorUserId: string | null;
body: string;
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
followUpRequested?: boolean;
createdAt: Date;
updatedAt: Date;
}
interface IssueCommentMetadataRowBase {
type: IssueCommentMetadataRowType;
label?: string | null;
}
export interface IssueCommentMetadataTextRow extends IssueCommentMetadataRowBase {
type: "text";
text: string;
}
export interface IssueCommentMetadataCodeRow extends IssueCommentMetadataRowBase {
type: "code";
code: string;
language?: string | null;
}
export interface IssueCommentMetadataKeyValueRow extends IssueCommentMetadataRowBase {
type: "key_value";
label: string;
value: string;
}
export interface IssueCommentMetadataIssueLinkRow extends IssueCommentMetadataRowBase {
type: "issue_link";
issueId?: string | null;
identifier?: string | null;
title?: string | null;
}
export interface IssueCommentMetadataAgentLinkRow extends IssueCommentMetadataRowBase {
type: "agent_link";
agentId: string;
name?: string | null;
}
export interface IssueCommentMetadataRunLinkRow extends IssueCommentMetadataRowBase {
type: "run_link";
runId: string;
title?: string | null;
}
export type IssueCommentMetadataRow =
| IssueCommentMetadataTextRow
| IssueCommentMetadataCodeRow
| IssueCommentMetadataKeyValueRow
| IssueCommentMetadataIssueLinkRow
| IssueCommentMetadataAgentLinkRow
| IssueCommentMetadataRunLinkRow;
export interface IssueCommentMetadataSection {
title?: string | null;
rows: IssueCommentMetadataRow[];
}
export interface IssueCommentMetadata {
version: 1;
sourceRunId?: string | null;
sections: IssueCommentMetadataSection[];
}
export interface IssueCommentPresentation {
kind: IssueCommentPresentationKind;
tone: IssueCommentPresentationTone;
title?: string | null;
detailsDefaultOpen: boolean;
}
export interface IssueThreadInteractionActorFields {
createdByAgentId?: string | null;
createdByUserId?: string | null;
@@ -327,6 +488,7 @@ export interface SuggestedTaskDraft {
title: string;
description?: string | null;
priority?: IssuePriority | null;
workMode?: IssueWorkMode | null;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
projectId?: string | null;
+240 -1
View File
@@ -16,7 +16,20 @@ import type {
PluginDatabaseMigrationStatus,
PluginDatabaseNamespaceMode,
PluginDatabaseNamespaceStatus,
AgentAdapterType,
AgentRole,
AgentStatus,
IssuePriority,
ProjectStatus,
RoutineCatchUpPolicy,
RoutineConcurrencyPolicy,
RoutineStatus,
IssueSurfaceVisibility,
} from "../constants.js";
import type { Agent } from "./agent.js";
import type { CompanySkill } from "./company-skill.js";
import type { Project } from "./project.js";
import type { Routine, RoutineTrigger, RoutineVariable } from "./routine.js";
// ---------------------------------------------------------------------------
// JSON Schema placeholder plugins declare config schemas as JSON Schema
@@ -113,6 +126,206 @@ export interface PluginEnvironmentDriverDeclaration {
configSchema: JsonSchema;
}
/**
* Declares a normal Paperclip agent that a plugin can provision and later
* resolve by stable key within each company.
*/
export interface PluginManagedAgentDeclaration {
/** Stable identifier for this managed agent, unique within the plugin. */
agentKey: string;
/** Suggested visible agent name. */
displayName: string;
/** Optional suggested role. Defaults to `general`. */
role?: AgentRole | string;
/** Optional suggested title shown in agent surfaces. */
title?: string | null;
/** Optional icon for agent list/detail surfaces. */
icon?: string | null;
/** Suggested capability summary for the agent. */
capabilities?: string | null;
/** Suggested adapter type. Defaults to `process`. */
adapterType?: AgentAdapterType | string;
/**
* Optional ordered list of compatible adapter types. When present, the host
* prefers the most-used compatible adapter already configured in the company,
* falling back to `adapterType`.
*/
adapterPreference?: Array<AgentAdapterType | string>;
/** Suggested adapter configuration. */
adapterConfig?: Record<string, unknown>;
/** Suggested Paperclip runtime configuration. */
runtimeConfig?: Record<string, unknown>;
/** Suggested permissions object. Normalized by the host on create/reset. */
permissions?: Record<string, unknown>;
/** Suggested starting status when no board approval is required. */
status?: Extract<AgentStatus, "idle" | "paused">;
/** Suggested monthly budget in cents. */
budgetMonthlyCents?: number;
/** Optional managed instructions content or pointer metadata for plugin UI. */
instructions?: {
entryFile?: string;
content?: string;
files?: Record<string, string>;
assetPath?: string;
};
}
/**
* Declares a company-scoped local folder a trusted plugin wants the operator
* to configure. The host treats this as a generic filesystem root: plugin
* code may request required relative folders/files, then use SDK helpers for
* path-safe reads and atomic writes under that root.
*/
export interface PluginLocalFolderDeclaration {
/** Stable identifier for this folder, unique within the plugin. */
folderKey: string;
/** Human-readable name shown in plugin settings. */
displayName: string;
/** Optional operator-facing description. */
description?: string;
/** Access level requested by the plugin. Defaults to `readWrite`. */
access?: "read" | "readWrite";
/** Relative directories expected to exist under the configured root. */
requiredDirectories?: string[];
/** Relative files expected to exist under the configured root. */
requiredFiles?: string[];
}
/**
* Declares a normal Paperclip project that a plugin can provision and later
* resolve by stable key within each company.
*/
export interface PluginManagedProjectDeclaration {
/** Stable identifier for this managed project, unique within the plugin. */
projectKey: string;
/** Suggested visible project name. */
displayName: string;
/** Suggested project description. */
description?: string | null;
/** Suggested starting status. Defaults to `in_progress`. */
status?: ProjectStatus;
/** Suggested project color. Defaults to the normal project palette. */
color?: string | null;
/** Optional plugin-specific defaults retained for reset/reconcile UI. */
settings?: Record<string, unknown>;
}
export interface PluginManagedSkillFileDeclaration {
/** Relative path inside the skill folder, for example `references/guide.md`. */
path: string;
/** File contents written when the skill is installed or reset. */
content: string;
}
/**
* Declares a company skill that a plugin can install into each company's
* skills library and later resolve by stable key.
*/
export interface PluginManagedSkillDeclaration {
/** Stable identifier for this managed skill, unique within the plugin. */
skillKey: string;
/** Suggested visible skill name. */
displayName: string;
/** Suggested skill slug. Defaults to `skillKey`. */
slug?: string;
/** Suggested skill description. */
description?: string | null;
/** Full `SKILL.md` contents. Defaults to generated markdown from display metadata. */
markdown?: string;
/** Additional files installed with the skill. */
files?: PluginManagedSkillFileDeclaration[];
}
export type PluginManagedResourceKind = "agent" | "project" | "routine" | "skill";
export interface PluginManagedResourceRef {
pluginKey?: string;
resourceKind: PluginManagedResourceKind;
resourceKey: string;
}
export interface PluginManagedRoutineDeclaration {
/** Stable identifier for this managed routine, unique within the plugin. */
routineKey: string;
/** Suggested routine title template. */
title: string;
/** Suggested routine description template. */
description?: string | null;
/** Stable managed agent reference for the default assignee. */
assigneeRef?: PluginManagedResourceRef | null;
/** Stable managed project reference for routine-created issues. */
projectRef?: PluginManagedResourceRef | null;
/** Optional goal id to set on the routine in this company. */
goalId?: string | null;
/** Suggested starting status. Defaults to `paused` when no assignee is resolved, otherwise `active`. */
status?: RoutineStatus;
/** Suggested issue priority. Defaults to `medium`. */
priority?: IssuePriority;
/** Suggested concurrency behavior. Defaults to core routine default. */
concurrencyPolicy?: RoutineConcurrencyPolicy;
/** Suggested missed-trigger behavior. Defaults to core routine default. */
catchUpPolicy?: RoutineCatchUpPolicy;
/** Suggested routine variables. */
variables?: RoutineVariable[];
/** Suggested triggers created when the routine is first reconciled. */
triggers?: Array<Pick<RoutineTrigger, "kind" | "label" | "enabled" | "cronExpression" | "timezone" | "signingMode" | "replayWindowSec">>;
/** Defaults for issues created by this routine. */
issueTemplate?: {
surfaceVisibility?: IssueSurfaceVisibility;
originId?: string | null;
billingCode?: string | null;
};
}
export interface PluginManagedAgentResolution {
pluginKey: string;
resourceKind: "agent";
resourceKey: string;
companyId: string;
agentId: string | null;
agent: Agent | null;
status: "missing" | "resolved" | "created" | "relinked" | "reset";
approvalId?: string | null;
defaultDrift?: {
entryFile: string;
changedFiles: string[];
} | null;
}
export interface PluginManagedProjectResolution {
pluginKey: string;
resourceKind: "project";
resourceKey: string;
companyId: string;
projectId: string | null;
project: Project | null;
status: "missing" | "resolved" | "created" | "relinked" | "reset";
}
export interface PluginManagedRoutineResolution {
pluginKey: string;
resourceKind: "routine";
resourceKey: string;
companyId: string;
routineId: string | null;
routine: Routine | null;
status: "missing" | "missing_refs" | "resolved" | "created" | "relinked" | "reset";
missingRefs?: PluginManagedResourceRef[];
}
export interface PluginManagedSkillResolution {
pluginKey: string;
resourceKind: "skill";
resourceKey: string;
companyId: string;
skillId: string | null;
skill: CompanySkill | null;
status: "missing" | "resolved" | "created" | "relinked" | "reset";
defaultDrift?: {
changedFiles: string[];
} | null;
}
/**
* Declares a UI extension slot the plugin fills with a React component.
*
@@ -133,7 +346,7 @@ export interface PluginUiSlotDeclaration {
*/
entityTypes?: PluginUiSlotEntityType[];
/**
* Optional company-scoped route segment for page slots.
* Optional company-scoped route segment for page and routeSidebar slots.
* Example: `kitchensink` becomes `/:companyPrefix/kitchensink`.
*/
routePath?: string;
@@ -322,6 +535,16 @@ export interface PaperclipPluginManifestV1 {
apiRoutes?: PluginApiRouteDeclaration[];
/** Environment drivers this plugin contributes. Requires `environment.drivers.register` capability. */
environmentDrivers?: PluginEnvironmentDriverDeclaration[];
/** Suggested company-scoped agents this plugin can provision and resolve by stable key. */
agents?: PluginManagedAgentDeclaration[];
/** Suggested company-scoped projects this plugin can provision and resolve by stable key. */
projects?: PluginManagedProjectDeclaration[];
/** Suggested company-scoped routines this plugin can provision and resolve by stable key. */
routines?: PluginManagedRoutineDeclaration[];
/** Suggested company skills this plugin can install and resolve by stable key. */
skills?: PluginManagedSkillDeclaration[];
/** Trusted local folders this plugin can configure and access by stable key. */
localFolders?: PluginLocalFolderDeclaration[];
/**
* Legacy top-level launcher declarations.
* Prefer `ui.launchers` for new manifests.
@@ -455,6 +678,22 @@ export interface PluginConfig {
updatedAt: Date;
}
/**
* Company-scoped plugin settings row. This is intentionally generic; plugin
* features such as local folders live inside `settingsJson` under namespaced
* keys instead of requiring feature-specific database columns.
*/
export interface PluginCompanySettings {
id: string;
companyId: string;
pluginId: string;
enabled: boolean;
settingsJson: Record<string, unknown>;
lastError: string | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Query filter for `ctx.entities.list`.
*/
+13
View File
@@ -52,6 +52,18 @@ export interface ProjectCodebase {
origin: ProjectCodebaseOrigin;
}
export interface ProjectManagedByPlugin {
id: string;
pluginId: string;
pluginKey: string;
pluginDisplayName: string;
resourceKind: "project";
resourceKey: string;
defaultsJson: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
export interface Project {
id: string;
companyId: string;
@@ -73,6 +85,7 @@ export interface Project {
codebase: ProjectCodebase;
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
managedByPlugin?: ProjectManagedByPlugin | null;
archivedAt: Date | null;
createdAt: Date;
updatedAt: Date;
+77 -1
View File
@@ -1,4 +1,13 @@
import type { IssueOriginKind, RoutineVariableType } from "../constants.js";
import type {
IssueOriginKind,
IssuePriority,
RoutineCatchUpPolicy,
RoutineConcurrencyPolicy,
RoutineStatus,
RoutineTriggerKind,
RoutineTriggerSigningMode,
RoutineVariableType,
} from "../constants.js";
export interface RoutineProjectSummary {
id: string;
@@ -50,6 +59,8 @@ export interface Routine {
concurrencyPolicy: string;
catchUpPolicy: string;
variables: RoutineVariable[];
latestRevisionId: string | null;
latestRevisionNumber: number;
createdByAgentId: string | null;
createdByUserId: string | null;
updatedByAgentId: string | null;
@@ -58,6 +69,71 @@ export interface Routine {
lastEnqueuedAt: Date | null;
createdAt: Date;
updatedAt: Date;
managedByPlugin?: RoutineManagedByPlugin | null;
}
export interface RoutineManagedByPlugin {
id: string;
pluginId: string;
pluginKey: string;
pluginDisplayName: string;
resourceKind: "routine";
resourceKey: string;
defaultsJson: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
export interface RoutineRevisionSnapshotRoutineV1 {
id: string;
companyId: string;
projectId: string | null;
goalId: string | null;
parentIssueId: string | null;
title: string;
description: string | null;
assigneeAgentId: string | null;
priority: IssuePriority;
status: RoutineStatus;
concurrencyPolicy: RoutineConcurrencyPolicy;
catchUpPolicy: RoutineCatchUpPolicy;
variables: RoutineVariable[];
}
export interface RoutineRevisionSnapshotTriggerV1 {
id: string;
kind: RoutineTriggerKind;
label: string | null;
enabled: boolean;
cronExpression: string | null;
timezone: string | null;
publicId: string | null;
signingMode: RoutineTriggerSigningMode | null;
replayWindowSec: number | null;
}
export interface RoutineRevisionSnapshotV1 {
version: 1;
routine: RoutineRevisionSnapshotRoutineV1;
triggers: RoutineRevisionSnapshotTriggerV1[];
}
export type RoutineRevisionSnapshot = RoutineRevisionSnapshotV1;
export interface RoutineRevision {
id: string;
companyId: string;
routineId: string;
revisionNumber: number;
title: string;
description: string | null;
snapshot: RoutineRevisionSnapshot;
changeSummary: string | null;
restoredFromRevisionId: string | null;
createdByAgentId: string | null;
createdByUserId: string | null;
createdByRunId: string | null;
createdAt: Date;
}
export interface RoutineTrigger {
+56
View File
@@ -0,0 +1,56 @@
import type { IssuePriority, IssueStatus } from "../constants.js";
export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "agents", "projects"] as const;
export type CompanySearchScope = (typeof COMPANY_SEARCH_SCOPES)[number];
export type CompanySearchResultType = "issue" | "agent" | "project";
export interface CompanySearchHighlight {
start: number;
end: number;
}
export interface CompanySearchSnippet {
field: string;
label: string;
text: string;
highlights: CompanySearchHighlight[];
}
export interface CompanySearchIssueSummary {
id: string;
identifier: string | null;
title: string;
status: IssueStatus;
priority: IssuePriority;
assigneeAgentId: string | null;
assigneeUserId: string | null;
projectId: string | null;
updatedAt: string;
}
export interface CompanySearchResult {
id: string;
type: CompanySearchResultType;
score: number;
title: string;
href: string;
matchedFields: string[];
sourceLabel: string | null;
snippet: string | null;
snippets: CompanySearchSnippet[];
issue?: CompanySearchIssueSummary;
updatedAt: string | null;
previewImageUrl: string | null;
}
export interface CompanySearchResponse {
query: string;
normalizedQuery: string;
scope: CompanySearchScope;
limit: number;
offset: number;
results: CompanySearchResult[];
countsByType: Record<CompanySearchResultType, number>;
hasMore: boolean;
}
+206 -5
View File
@@ -1,8 +1,24 @@
export type SecretProvider =
| "local_encrypted"
| "aws_secrets_manager"
| "gcp_secret_manager"
| "vault";
import type {
SecretAccessOutcome,
SecretBindingTargetType,
SecretManagedMode,
SecretProvider,
SecretProviderConfigHealthStatus,
SecretProviderConfigStatus,
SecretStatus,
SecretVersionStatus,
} from "../constants.js";
export type {
SecretAccessOutcome,
SecretBindingTargetType,
SecretManagedMode,
SecretProvider,
SecretProviderConfigHealthStatus,
SecretProviderConfigStatus,
SecretStatus,
SecretVersionStatus,
};
export type SecretVersionSelector = number | "latest";
@@ -25,13 +41,22 @@ export type AgentEnvConfig = Record<string, EnvBinding>;
export interface CompanySecret {
id: string;
companyId: string;
key: string;
name: string;
provider: SecretProvider;
status: SecretStatus;
managedMode: SecretManagedMode;
externalRef: string | null;
providerConfigId: string | null;
providerMetadata: Record<string, unknown> | null;
latestVersion: number;
description: string | null;
lastResolvedAt: Date | null;
lastRotatedAt: Date | null;
deletedAt: Date | null;
createdByAgentId: string | null;
createdByUserId: string | null;
referenceCount?: number;
createdAt: Date;
updatedAt: Date;
}
@@ -40,4 +65,180 @@ export interface SecretProviderDescriptor {
id: SecretProvider;
label: string;
requiresExternalRef: boolean;
supportsManagedValues?: boolean;
supportsExternalReferences?: boolean;
configured?: boolean;
}
export interface LocalEncryptedProviderConfig {
backupReminderAcknowledged?: boolean;
}
export interface AwsSecretsManagerProviderConfig {
region: string;
namespace?: string | null;
secretNamePrefix?: string | null;
kmsKeyId?: string | null;
ownerTag?: string | null;
environmentTag?: string | null;
}
export interface GcpSecretManagerProviderConfig {
projectId?: string | null;
location?: string | null;
namespace?: string | null;
secretNamePrefix?: string | null;
}
export interface VaultProviderConfig {
address?: string | null;
namespace?: string | null;
mountPath?: string | null;
secretPathPrefix?: string | null;
}
export type SecretProviderConfigPayload =
| LocalEncryptedProviderConfig
| AwsSecretsManagerProviderConfig
| GcpSecretManagerProviderConfig
| VaultProviderConfig;
export interface SecretProviderConfigHealthDetails {
code: string;
message: string;
missingFields?: string[];
guidance?: string[];
}
export interface CompanySecretProviderConfig {
id: string;
companyId: string;
provider: SecretProvider;
displayName: string;
status: SecretProviderConfigStatus;
isDefault: boolean;
config: SecretProviderConfigPayload;
healthStatus: SecretProviderConfigHealthStatus | null;
healthCheckedAt: Date | null;
healthMessage: string | null;
healthDetails: SecretProviderConfigHealthDetails | null;
disabledAt: Date | null;
createdByAgentId: string | null;
createdByUserId: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface SecretProviderConfigHealthResponse {
configId: string;
provider: SecretProvider;
status: SecretProviderConfigHealthStatus;
message: string;
details: SecretProviderConfigHealthDetails;
checkedAt: Date;
}
export interface CompanySecretVersion {
id: string;
secretId: string;
version: number;
providerVersionRef: string | null;
status: SecretVersionStatus;
fingerprintSha256: string;
rotationJobId: string | null;
createdAt: Date;
revokedAt: Date | null;
}
export interface CompanySecretBinding {
id: string;
companyId: string;
secretId: string;
targetType: SecretBindingTargetType;
targetId: string;
configPath: string;
versionSelector: SecretVersionSelector;
required: boolean;
label: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface CompanySecretBindingTarget {
type: SecretBindingTargetType;
id: string;
label: string;
href: string | null;
status: string | null;
}
export interface CompanySecretUsageBinding extends CompanySecretBinding {
target: CompanySecretBindingTarget;
}
export interface SecretAccessEvent {
id: string;
companyId: string;
secretId: string;
version: number | null;
provider: SecretProvider;
actorType: "agent" | "user" | "system" | "plugin";
actorId: string | null;
consumerType: SecretBindingTargetType;
consumerId: string;
configPath: string | null;
issueId: string | null;
heartbeatRunId: string | null;
pluginId: string | null;
outcome: SecretAccessOutcome;
errorCode: string | null;
createdAt: Date;
}
export type RemoteSecretImportCandidateStatus = "ready" | "duplicate" | "conflict";
export interface RemoteSecretImportConflict {
type: "exact_reference" | "name" | "key" | "provider_guardrail";
message: string;
existingSecretId?: string;
}
export interface RemoteSecretImportCandidate {
externalRef: string;
remoteName: string;
name: string;
key: string;
providerVersionRef: string | null;
providerMetadata: Record<string, unknown> | null;
status: RemoteSecretImportCandidateStatus;
importable: boolean;
conflicts: RemoteSecretImportConflict[];
}
export interface RemoteSecretImportPreviewResult {
providerConfigId: string;
provider: SecretProvider;
nextToken: string | null;
candidates: RemoteSecretImportCandidate[];
}
export type RemoteSecretImportRowStatus = "imported" | "skipped" | "error";
export interface RemoteSecretImportRowResult {
externalRef: string;
name: string;
key: string;
status: RemoteSecretImportRowStatus;
reason: string | null;
secretId: string | null;
conflicts: RemoteSecretImportConflict[];
}
export interface RemoteSecretImportResult {
providerConfigId: string;
provider: SecretProvider;
importedCount: number;
skippedCount: number;
errorCount: number;
results: RemoteSecretImportRowResult[];
}
@@ -1,5 +1,10 @@
import { z } from "zod";
import { MAX_COMPANY_ATTACHMENT_MAX_BYTES } from "../constants.js";
import {
issueCommentAuthorTypeSchema,
issueCommentMetadataSchema,
issueCommentPresentationSchema,
} from "./issue.js";
import { routineVariableSchema } from "./routine.js";
export const portabilityIncludeSchema = z
@@ -134,6 +139,16 @@ export const portabilityIssueRoutineManifestEntrySchema = z.object({
triggers: z.array(portabilityIssueRoutineTriggerManifestEntrySchema).default([]),
});
export const portabilityIssueCommentManifestEntrySchema = z.object({
body: z.string().min(1),
authorType: issueCommentAuthorTypeSchema,
authorAgentSlug: z.string().min(1).nullable(),
authorUserId: z.string().nullable(),
presentation: issueCommentPresentationSchema.nullable(),
metadata: issueCommentMetadataSchema.nullable(),
createdAt: z.string().datetime().nullable(),
});
export const portabilityIssueManifestEntrySchema = z.object({
slug: z.string().min(1),
identifier: z.string().min(1).nullable(),
@@ -152,6 +167,7 @@ export const portabilityIssueManifestEntrySchema = z.object({
billingCode: z.string().nullable(),
executionWorkspaceSettings: z.record(z.unknown()).nullable(),
assigneeAdapterOverrides: z.record(z.unknown()).nullable(),
comments: z.array(portabilityIssueCommentManifestEntrySchema).default([]),
metadata: z.record(z.unknown()).nullable(),
});
+43
View File
@@ -151,7 +151,9 @@ export {
export {
createIssueSchema,
createIssueInputSchema,
createChildIssueSchema,
resolveCreateIssueStatusDefault,
createIssueLabelSchema,
updateIssueSchema,
issueExecutionPolicySchema,
@@ -159,6 +161,11 @@ export {
issueReviewRequestSchema,
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema,
issueCommentAuthorTypeSchema,
issueCommentPresentationSchema,
issueCommentMetadataRowSchema,
issueCommentMetadataSectionSchema,
issueCommentMetadataSchema,
addIssueCommentSchema,
issueThreadInteractionStatusSchema,
issueThreadInteractionKindSchema,
@@ -207,6 +214,16 @@ export {
type RestoreIssueDocumentRevision,
} from "./issue.js";
export {
COMPANY_SEARCH_DEFAULT_LIMIT,
COMPANY_SEARCH_MAX_LIMIT,
COMPANY_SEARCH_MAX_OFFSET,
COMPANY_SEARCH_MAX_QUERY_LENGTH,
COMPANY_SEARCH_MAX_TOKENS,
companySearchQuerySchema,
type CompanySearchQuery,
} from "./search.js";
export {
createIssueTreeHoldSchema,
issueTreeControlModeSchema,
@@ -267,9 +284,27 @@ export {
envBindingSchema,
envConfigSchema,
createSecretSchema,
createSecretProviderConfigSchema,
updateSecretProviderConfigSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
remoteSecretImportSelectionSchema,
localEncryptedProviderConfigSchema,
awsSecretsManagerProviderConfigSchema,
gcpSecretManagerProviderConfigSchema,
vaultProviderConfigSchema,
secretProviderConfigPayloadSchema,
createSecretBindingSchema,
rotateSecretSchema,
secretBindingTargetSchema,
updateSecretSchema,
type CreateSecretBinding,
type CreateSecret,
type CreateSecretProviderConfig,
type UpdateSecretProviderConfig,
type RemoteSecretImportPreview,
type RemoteSecretImport,
type RemoteSecretImportSelection,
type RotateSecret,
type UpdateSecret,
} from "./secret.js";
@@ -280,6 +315,10 @@ export {
createRoutineTriggerSchema,
updateRoutineTriggerSchema,
routineVariableSchema,
routineRevisionSnapshotRoutineV1Schema,
routineRevisionSnapshotTriggerV1Schema,
routineRevisionSnapshotV1Schema,
routineRevisionSnapshotSchema,
runRoutineSchema,
rotateRoutineTriggerSecretSchema,
type CreateRoutine,
@@ -357,6 +396,8 @@ export {
pluginLauncherRenderDeclarationSchema,
pluginLauncherDeclarationSchema,
pluginDatabaseDeclarationSchema,
pluginManagedSkillFileDeclarationSchema,
pluginManagedSkillDeclarationSchema,
pluginApiRouteDeclarationSchema,
pluginManifestV1Schema,
installPluginSchema,
@@ -376,6 +417,8 @@ export {
type PluginLauncherRenderDeclarationInput,
type PluginLauncherDeclarationInput,
type PluginDatabaseDeclarationInput,
type PluginManagedSkillFileDeclarationInput,
type PluginManagedSkillDeclarationInput,
type PluginApiRouteDeclarationInput,
type PluginManifestV1Input,
type InstallPlugin,
@@ -54,6 +54,48 @@ describe("issue validators", () => {
expect(parsed.body).toBe("Progress update\n\nNext action.");
});
it("accepts structured issue comment presentation and metadata", () => {
const parsed = addIssueCommentSchema.parse({
body: "Paperclip needs a disposition before this issue can continue.",
authorType: "system",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Needs disposition",
},
metadata: {
version: 1,
sourceRunId: "11111111-1111-4111-8111-111111111111",
sections: [
{
title: "Evidence",
rows: [
{ type: "key_value", label: "Cause", value: "successful_run_missing_state" },
{ type: "issue_link", label: "Source issue", identifier: "PAP-3440" },
{ type: "run_link", label: "Run", runId: "11111111-1111-4111-8111-111111111111" },
],
},
],
},
});
expect(parsed.presentation?.detailsDefaultOpen).toBe(false);
expect(parsed.metadata?.sourceRunId).toBe("11111111-1111-4111-8111-111111111111");
expect(parsed.metadata?.sections[0]?.rows).toHaveLength(3);
});
it("rejects arbitrary issue comment metadata", () => {
const parsed = addIssueCommentSchema.safeParse({
body: "Hidden details",
metadata: {
version: 1,
transcript: "raw log dump",
},
});
expect(parsed.success).toBe(false);
});
it("normalizes escaped line breaks in generated task drafts", () => {
const parsed = suggestedTaskDraftSchema.parse({
clientKey: "task-1",
@@ -87,6 +129,39 @@ describe("issue validators", () => {
expect(parsed.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
});
it("defaults omitted create status to todo when an assignee is present", () => {
expect(createIssueSchema.parse({
title: "Assigned work",
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
}).status).toBe("todo");
expect(createIssueSchema.parse({ title: "Unassigned work" }).status).toBe("backlog");
expect(createIssueSchema.parse({
title: "Deliberately parked",
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
status: "backlog",
}).status).toBe("backlog");
});
it("defaults issue work mode to standard and accepts planning", () => {
expect(createIssueSchema.parse({ title: "Plan first" }).workMode).toBe("standard");
expect(createIssueSchema.parse({ title: "Plan first", workMode: "planning" }).workMode).toBe("planning");
expect(updateIssueSchema.parse({ workMode: "planning" }).workMode).toBe("planning");
expect(suggestedTaskDraftSchema.parse({
clientKey: "planning-child",
title: "Plan child",
workMode: "planning",
}).workMode).toBe("planning");
});
it("rejects unknown issue work modes", () => {
expect(createIssueSchema.safeParse({ title: "Plan first", workMode: "normal" }).success).toBe(false);
expect(suggestedTaskDraftSchema.safeParse({
clientKey: "bad-child",
title: "Bad child",
workMode: "analysis",
}).success).toBe(false);
});
it("clamps oversized requestDepth values on update", () => {
const parsed = updateIssueSchema.parse({
requestDepth: MAX_ISSUE_REQUEST_DEPTH + 1,
+183 -5
View File
@@ -1,10 +1,20 @@
import { z } from "zod";
import {
ISSUE_EXECUTION_DECISION_OUTCOMES,
ISSUE_EXECUTION_MONITOR_CLEAR_REASONS,
ISSUE_EXECUTION_MONITOR_KINDS,
ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES,
ISSUE_EXECUTION_MONITOR_STATE_STATUSES,
ISSUE_EXECUTION_POLICY_MODES,
ISSUE_EXECUTION_STAGE_TYPES,
ISSUE_EXECUTION_STATE_STATUSES,
ISSUE_COMMENT_AUTHOR_TYPES,
ISSUE_COMMENT_METADATA_ROW_TYPES,
ISSUE_COMMENT_PRESENTATION_KINDS,
ISSUE_COMMENT_PRESENTATION_TONES,
ISSUE_MONITOR_SCHEDULED_BY,
ISSUE_PRIORITIES,
ISSUE_WORK_MODES,
clampIssueRequestDepth,
ISSUE_STATUSES,
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
@@ -103,10 +113,40 @@ export const issueExecutionStageSchema = z.object({
participants: z.array(issueExecutionStageParticipantSchema).default([]),
});
export const issueExecutionMonitorPolicySchema = z.object({
nextCheckAt: z.string().datetime(),
notes: z.string().max(500).optional().nullable().default(null),
scheduledBy: z.enum(ISSUE_MONITOR_SCHEDULED_BY).optional().default("assignee"),
kind: z.enum(ISSUE_EXECUTION_MONITOR_KINDS).optional().nullable().default(null),
serviceName: z.string().trim().min(1).max(120).optional().nullable().default(null),
externalRef: z.string().trim().min(1).max(500).optional().nullable().default(null),
timeoutAt: z.string().datetime().optional().nullable().default(null),
maxAttempts: z.number().int().positive().max(100).optional().nullable().default(null),
recoveryPolicy: z.enum(ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES).optional().nullable().default(null),
});
export const issueExecutionPolicySchema = z.object({
mode: z.enum(ISSUE_EXECUTION_POLICY_MODES).optional().default("normal"),
commentRequired: z.boolean().optional().default(true),
stages: z.array(issueExecutionStageSchema).default([]),
monitor: issueExecutionMonitorPolicySchema.optional().nullable(),
});
export const issueExecutionMonitorStateSchema = z.object({
status: z.enum(ISSUE_EXECUTION_MONITOR_STATE_STATUSES),
nextCheckAt: z.string().datetime().nullable(),
lastTriggeredAt: z.string().datetime().nullable(),
attemptCount: z.number().int().nonnegative().default(0),
notes: z.string().max(500).nullable(),
scheduledBy: z.enum(ISSUE_MONITOR_SCHEDULED_BY).nullable(),
kind: z.enum(ISSUE_EXECUTION_MONITOR_KINDS).nullable().optional().default(null),
serviceName: z.string().trim().min(1).max(120).nullable().optional().default(null),
externalRef: z.string().trim().min(1).max(500).nullable().optional().default(null),
timeoutAt: z.string().datetime().nullable().optional().default(null),
maxAttempts: z.number().int().positive().max(100).nullable().optional().default(null),
recoveryPolicy: z.enum(ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES).nullable().optional().default(null),
clearedAt: z.string().datetime().nullable(),
clearReason: z.enum(ISSUE_EXECUTION_MONITOR_CLEAR_REASONS).nullable(),
});
export const issueReviewRequestSchema = z.object({
@@ -124,6 +164,7 @@ export const issueExecutionStateSchema = z.object({
completedStageIds: z.array(z.string().uuid()).default([]),
lastDecisionId: z.string().uuid().nullable(),
lastDecisionOutcome: z.enum(ISSUE_EXECUTION_DECISION_OUTCOMES).nullable(),
monitor: issueExecutionMonitorStateSchema.optional().nullable(),
});
const issueRequestDepthInputSchema = z
@@ -132,7 +173,48 @@ const issueRequestDepthInputSchema = z
.nonnegative()
.transform((value) => clampIssueRequestDepth(value));
export const createIssueSchema = z.object({
type IssueCreateStatusDefaultInput = {
status?: unknown;
assigneeAgentId?: unknown;
assigneeUserId?: unknown;
};
export function resolveCreateIssueStatusDefault(input: IssueCreateStatusDefaultInput): {
status: (typeof ISSUE_STATUSES)[number];
defaulted: boolean;
reason: "explicit" | "assigned_omitted_status" | "unassigned_omitted_status";
} {
if (typeof input.status === "string") {
return {
status: input.status as (typeof ISSUE_STATUSES)[number],
defaulted: false,
reason: "explicit",
};
}
const hasAssignee =
(typeof input.assigneeAgentId === "string" && input.assigneeAgentId.length > 0)
|| (typeof input.assigneeUserId === "string" && input.assigneeUserId.length > 0);
return {
status: hasAssignee ? "todo" : "backlog",
defaulted: true,
reason: hasAssignee ? "assigned_omitted_status" : "unassigned_omitted_status",
};
}
function withCreateIssueStatusDefault<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
return z.preprocess((input) => {
if (!input || typeof input !== "object" || Array.isArray(input)) return input;
const raw = input as Record<string, unknown>;
if (raw.status !== undefined) return input;
return {
...raw,
status: resolveCreateIssueStatusDefault(raw).status,
};
}, schema);
}
const createIssueBaseSchema = z.object({
projectId: z.string().uuid().optional().nullable(),
projectWorkspaceId: z.string().uuid().optional().nullable(),
goalId: z.string().uuid().optional().nullable(),
@@ -141,7 +223,8 @@ export const createIssueSchema = z.object({
inheritExecutionWorkspaceFromIssueId: z.string().uuid().optional().nullable(),
title: z.string().min(1),
description: multilineTextSchema.optional().nullable(),
status: z.enum(ISSUE_STATUSES).optional().default("backlog"),
status: z.enum(ISSUE_STATUSES),
workMode: z.enum(ISSUE_WORK_MODES).optional().default("standard"),
priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"),
assigneeAgentId: z.string().uuid().optional().nullable(),
assigneeUserId: z.string().optional().nullable(),
@@ -155,9 +238,15 @@ export const createIssueSchema = z.object({
labelIds: z.array(z.string().uuid()).optional(),
});
export const createIssueInputSchema = createIssueBaseSchema.extend({
status: createIssueBaseSchema.shape.status.optional(),
});
export const createIssueSchema = withCreateIssueStatusDefault(createIssueBaseSchema);
export type CreateIssue = z.infer<typeof createIssueSchema>;
export const createChildIssueSchema = createIssueSchema
export const createChildIssueSchema = withCreateIssueStatusDefault(createIssueBaseSchema
.omit({
parentId: true,
inheritExecutionWorkspaceFromIssueId: true,
@@ -165,7 +254,7 @@ export const createChildIssueSchema = createIssueSchema
.extend({
acceptanceCriteria: z.array(z.string().trim().min(1).max(500)).max(20).optional(),
blockParentUntilDone: z.boolean().optional().default(false),
});
}));
export type CreateChildIssue = z.infer<typeof createChildIssueSchema>;
@@ -176,7 +265,7 @@ export const createIssueLabelSchema = z.object({
export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
export const updateIssueSchema = createIssueSchema.partial().extend({
export const updateIssueSchema = createIssueBaseSchema.partial().extend({
requestDepth: issueRequestDepthInputSchema.optional(),
assigneeAgentId: z.string().trim().min(1).optional().nullable(),
comment: multilineTextSchema.pipe(z.string().min(1)).optional(),
@@ -197,8 +286,96 @@ export const checkoutIssueSchema = z.object({
export type CheckoutIssue = z.infer<typeof checkoutIssueSchema>;
const commentMetadataLabelSchema = z.string().trim().min(1).max(120);
const commentMetadataTextSchema = z.string().trim().min(1).max(2000);
export const issueCommentAuthorTypeSchema = z.enum(ISSUE_COMMENT_AUTHOR_TYPES);
export const issueCommentPresentationSchema = z.object({
kind: z.enum(ISSUE_COMMENT_PRESENTATION_KINDS).default("message"),
tone: z.enum(ISSUE_COMMENT_PRESENTATION_TONES).default("neutral"),
title: z.string().trim().min(1).max(160).nullable().optional(),
detailsDefaultOpen: z.boolean().optional().default(false),
}).strict();
export type IssueCommentPresentation = z.infer<typeof issueCommentPresentationSchema>;
const issueCommentMetadataBaseRowSchema = z.object({
type: z.enum(ISSUE_COMMENT_METADATA_ROW_TYPES),
label: commentMetadataLabelSchema.nullable().optional(),
});
const issueCommentMetadataTextRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("text"),
text: commentMetadataTextSchema,
}).strict();
const issueCommentMetadataCodeRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("code"),
code: z.string().min(1).max(4000),
language: z.string().trim().min(1).max(40).nullable().optional(),
}).strict();
const issueCommentMetadataKeyValueRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("key_value"),
label: commentMetadataLabelSchema,
value: commentMetadataTextSchema,
}).strict();
const issueCommentMetadataIssueLinkRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("issue_link"),
issueId: z.string().uuid().nullable().optional(),
identifier: z.string().trim().min(1).max(80).nullable().optional(),
title: z.string().trim().min(1).max(240).nullable().optional(),
}).strict();
const issueCommentMetadataAgentLinkRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("agent_link"),
agentId: z.string().uuid(),
name: z.string().trim().min(1).max(160).nullable().optional(),
}).strict();
const issueCommentMetadataRunLinkRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("run_link"),
runId: z.string().uuid(),
title: z.string().trim().min(1).max(160).nullable().optional(),
}).strict();
export const issueCommentMetadataRowSchema = z.discriminatedUnion("type", [
issueCommentMetadataTextRowSchema,
issueCommentMetadataCodeRowSchema,
issueCommentMetadataKeyValueRowSchema,
issueCommentMetadataIssueLinkRowSchema,
issueCommentMetadataAgentLinkRowSchema,
issueCommentMetadataRunLinkRowSchema,
]).superRefine((value, ctx) => {
if (value.type === "issue_link" && !value.issueId && !value.identifier) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Issue link rows require issueId or identifier",
path: ["issueId"],
});
}
});
export const issueCommentMetadataSectionSchema = z.object({
title: z.string().trim().min(1).max(160).nullable().optional(),
rows: z.array(issueCommentMetadataRowSchema).min(1).max(50),
}).strict();
export const issueCommentMetadataSchema = z.object({
version: z.literal(1),
sourceRunId: z.string().uuid().nullable().optional(),
sections: z.array(issueCommentMetadataSectionSchema).min(1).max(20),
}).strict();
export type IssueCommentMetadata = z.infer<typeof issueCommentMetadataSchema>;
export const addIssueCommentSchema = z.object({
body: multilineTextSchema.pipe(z.string().min(1)),
authorType: issueCommentAuthorTypeSchema.optional(),
presentation: issueCommentPresentationSchema.nullable().optional(),
metadata: issueCommentMetadataSchema.nullable().optional(),
reopen: z.boolean().optional(),
resume: z.boolean().optional(),
interrupt: z.boolean().optional(),
@@ -226,6 +403,7 @@ export const suggestedTaskDraftSchema = z.object({
title: z.string().trim().min(1).max(240),
description: multilineTextSchema.pipe(z.string().trim().max(20000)).nullable().optional(),
priority: z.enum(ISSUE_PRIORITIES).nullable().optional(),
workMode: z.enum(ISSUE_WORK_MODES).nullable().optional(),
assigneeAgentId: z.string().uuid().nullable().optional(),
assigneeUserId: z.string().trim().min(1).nullable().optional(),
projectId: z.string().uuid().nullable().optional(),
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { PLUGIN_CAPABILITIES } from "../constants.js";
import { pluginManagedRoutineDeclarationSchema, pluginManifestV1Schema, pluginUiSlotDeclarationSchema } from "./plugin.js";
describe("plugin capability constants", () => {
it("exposes each capability once", () => {
expect(new Set(PLUGIN_CAPABILITIES).size).toBe(PLUGIN_CAPABILITIES.length);
});
});
describe("plugin managed routine validators", () => {
it("accepts core issue surface visibility values in routine templates", () => {
const parsed = pluginManagedRoutineDeclarationSchema.parse({
routineKey: "wiki.refresh",
title: "Refresh Wiki",
issueTemplate: { surfaceVisibility: "default" },
});
expect(parsed.issueTemplate?.surfaceVisibility).toBe("default");
});
it("rejects non-core issue surface visibility values in routine templates", () => {
const parsed = pluginManagedRoutineDeclarationSchema.safeParse({
routineKey: "wiki.refresh",
title: "Refresh Wiki",
issueTemplate: { surfaceVisibility: "normal" },
});
expect(parsed.success).toBe(false);
});
});
describe("plugin managed skill validators", () => {
const baseManifest = {
id: "paperclip.test-managed-skills",
apiVersion: 1,
version: "0.1.0",
displayName: "Managed Skills",
description: "Managed skills test plugin.",
author: "Paperclip",
categories: ["automation"],
entrypoints: { worker: "./dist/worker.js" },
} as const;
it("requires skills.managed when managed skills are declared", () => {
const parsed = pluginManifestV1Schema.safeParse({
...baseManifest,
capabilities: [],
skills: [{ skillKey: "wiki-maintainer", displayName: "Wiki Maintainer" }],
});
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues.some((issue) => issue.message.includes("skills.managed"))).toBe(true);
});
it("accepts managed skills with the skills.managed capability", () => {
const parsed = pluginManifestV1Schema.parse({
...baseManifest,
capabilities: ["skills.managed"],
skills: [{ skillKey: "wiki-maintainer", displayName: "Wiki Maintainer" }],
});
expect(parsed.skills?.[0]?.skillKey).toBe("wiki-maintainer");
});
});
describe("plugin UI slot validators", () => {
it("accepts route-scoped sidebar slots with a routePath", () => {
const parsed = pluginUiSlotDeclarationSchema.parse({
type: "routeSidebar",
id: "wiki-route-sidebar",
displayName: "Wiki Sidebar",
exportName: "WikiSidebar",
routePath: "wiki",
});
expect(parsed.routePath).toBe("wiki");
});
it("requires route-scoped sidebar slots to declare a routePath", () => {
const parsed = pluginUiSlotDeclarationSchema.safeParse({
type: "routeSidebar",
id: "wiki-route-sidebar",
displayName: "Wiki Sidebar",
exportName: "WikiSidebar",
});
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues[0]?.message).toBe("routeSidebar slots require routePath");
});
it("keeps reserved company route protection for route-scoped sidebars", () => {
const parsed = pluginUiSlotDeclarationSchema.safeParse({
type: "routeSidebar",
id: "settings-route-sidebar",
displayName: "Settings Sidebar",
exportName: "SettingsSidebar",
routePath: "settings",
});
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
});
});
+268 -2
View File
@@ -15,7 +15,15 @@ import {
PLUGIN_API_ROUTE_AUTH_MODES,
PLUGIN_API_ROUTE_CHECKOUT_POLICIES,
PLUGIN_API_ROUTE_METHODS,
ISSUE_PRIORITIES,
ROUTINE_CATCH_UP_POLICIES,
ROUTINE_CONCURRENCY_POLICIES,
ROUTINE_STATUSES,
ROUTINE_TRIGGER_KINDS,
ROUTINE_TRIGGER_SIGNING_MODES,
ISSUE_SURFACE_VISIBILITIES,
} from "../constants.js";
import { routineVariableSchema } from "./routine.js";
// ---------------------------------------------------------------------------
// JSON Schema placeholder a permissive validator for JSON Schema objects
@@ -124,6 +132,142 @@ export type PluginEnvironmentDriverDeclarationInput = z.infer<
export type PluginToolDeclarationInput = z.infer<typeof pluginToolDeclarationSchema>;
export const pluginManagedAgentDeclarationSchema = z.object({
agentKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "agentKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
displayName: z.string().min(1).max(100),
role: z.string().min(1).max(100).optional(),
title: z.string().max(200).nullable().optional(),
icon: z.string().max(100).nullable().optional(),
capabilities: z.string().max(2000).nullable().optional(),
adapterType: z.string().min(1).max(100).optional(),
adapterPreference: z.array(z.string().min(1).max(100)).max(10).optional(),
adapterConfig: z.record(z.unknown()).optional(),
runtimeConfig: z.record(z.unknown()).optional(),
permissions: z.record(z.unknown()).optional(),
status: z.enum(["idle", "paused"]).optional(),
budgetMonthlyCents: z.number().int().min(0).optional(),
instructions: z.object({
entryFile: z.string().min(1).max(200).optional(),
content: z.string().max(200_000).optional(),
files: z.record(z.string().max(200_000)).optional(),
assetPath: z.string().min(1).max(500).optional(),
}).optional(),
});
export type PluginManagedAgentDeclarationInput = z.infer<typeof pluginManagedAgentDeclarationSchema>;
export const pluginManagedProjectDeclarationSchema = z.object({
projectKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "projectKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
displayName: z.string().min(1).max(120),
description: z.string().max(2000).nullable().optional(),
status: z.enum(["backlog", "planned", "in_progress", "completed", "cancelled"]).optional(),
color: z.string().max(32).nullable().optional(),
settings: z.record(z.unknown()).optional(),
});
export type PluginManagedProjectDeclarationInput = z.infer<typeof pluginManagedProjectDeclarationSchema>;
const pluginManagedResourceRefSchema = z.object({
pluginKey: z.string().min(1).max(100).optional(),
resourceKind: z.enum(["agent", "project", "routine", "skill"]),
resourceKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "resourceKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
});
export const pluginManagedRoutineDeclarationSchema = z.object({
routineKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "routineKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
title: z.string().trim().min(1).max(200),
description: z.string().max(10_000).nullable().optional(),
assigneeRef: pluginManagedResourceRefSchema.extend({ resourceKind: z.literal("agent") }).nullable().optional(),
projectRef: pluginManagedResourceRefSchema.extend({ resourceKind: z.literal("project") }).nullable().optional(),
goalId: z.string().uuid().nullable().optional(),
status: z.enum(ROUTINE_STATUSES).optional(),
priority: z.enum(ISSUE_PRIORITIES).optional(),
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional(),
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional(),
variables: z.array(routineVariableSchema).optional(),
triggers: z.array(z.object({
kind: z.enum(ROUTINE_TRIGGER_KINDS),
label: z.string().trim().max(120).nullable().optional(),
enabled: z.boolean().optional(),
cronExpression: z.string().trim().min(1).optional().nullable(),
timezone: z.string().trim().min(1).optional().nullable(),
signingMode: z.enum(ROUTINE_TRIGGER_SIGNING_MODES).optional().nullable(),
replayWindowSec: z.number().int().min(30).max(86_400).optional().nullable(),
})).max(20).optional(),
issueTemplate: z.object({
surfaceVisibility: z.enum(ISSUE_SURFACE_VISIBILITIES).optional(),
originId: z.string().trim().max(255).nullable().optional(),
billingCode: z.string().trim().max(200).nullable().optional(),
}).optional(),
});
export type PluginManagedRoutineDeclarationInput = z.infer<typeof pluginManagedRoutineDeclarationSchema>;
const pluginLocalFolderRelativePathSchema = z.string().min(1).max(500).refine(
(value) =>
!value.startsWith("/") &&
!value.includes("..") &&
!value.includes("\\") &&
!value.split("/").some((segment) => segment === "" || segment === "."),
{ message: "local folder paths must be relative paths without traversal, empty segments, or backslashes" },
);
export const pluginLocalFolderDeclarationSchema = z.object({
folderKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "folderKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
displayName: z.string().min(1).max(100),
description: z.string().max(500).optional(),
access: z.enum(["read", "readWrite"]).optional(),
requiredDirectories: z.array(pluginLocalFolderRelativePathSchema).optional(),
requiredFiles: z.array(pluginLocalFolderRelativePathSchema).optional(),
});
export type PluginLocalFolderDeclarationInput = z.infer<typeof pluginLocalFolderDeclarationSchema>;
export const pluginManagedSkillFileDeclarationSchema = z.object({
path: pluginLocalFolderRelativePathSchema.refine(
(value) => value.toLowerCase() !== "skill.md",
{ message: "managed skill files cannot replace SKILL.md; use markdown for the main skill file" },
),
content: z.string().max(200_000),
});
export type PluginManagedSkillFileDeclarationInput = z.infer<typeof pluginManagedSkillFileDeclarationSchema>;
export const pluginManagedSkillDeclarationSchema = z.object({
skillKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "skillKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
displayName: z.string().min(1).max(100),
slug: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "slug must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}).optional(),
description: z.string().max(2000).nullable().optional(),
markdown: z.string().max(200_000).optional(),
files: z.array(pluginManagedSkillFileDeclarationSchema).max(50).optional(),
}).superRefine((value, ctx) => {
const paths = (value.files ?? []).map((file) => file.path);
const duplicates = paths.filter((path, index) => paths.indexOf(path) !== index);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate managed skill file paths: ${[...new Set(duplicates)].join(", ")}`,
path: ["files"],
});
}
});
export type PluginManagedSkillDeclarationInput = z.infer<typeof pluginManagedSkillDeclarationSchema>;
/**
* Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin
* fills with a React component. Includes `superRefine` checks for slot-specific
@@ -178,10 +322,17 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["entityTypes"],
});
}
if (value.routePath && value.type !== "page") {
if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "routePath is only supported for page slots",
message: "routePath is only supported for page and routeSidebar slots",
path: ["routePath"],
});
}
if (value.type === "routeSidebar" && !value.routePath) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "routeSidebar slots require routePath",
path: ["routePath"],
});
}
@@ -471,6 +622,11 @@ export const pluginManifestV1Schema = z.object({
database: pluginDatabaseDeclarationSchema.optional(),
apiRoutes: z.array(pluginApiRouteDeclarationSchema).optional(),
environmentDrivers: z.array(pluginEnvironmentDriverDeclarationSchema).optional(),
agents: z.array(pluginManagedAgentDeclarationSchema).optional(),
projects: z.array(pluginManagedProjectDeclarationSchema).optional(),
routines: z.array(pluginManagedRoutineDeclarationSchema).optional(),
skills: z.array(pluginManagedSkillDeclarationSchema).optional(),
localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(),
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
ui: z.object({
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
@@ -529,6 +685,56 @@ export const pluginManifestV1Schema = z.object({
}
}
if (manifest.agents && manifest.agents.length > 0) {
if (!manifest.capabilities.includes("agents.managed")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'agents.managed' is required when managed agents are declared",
path: ["capabilities"],
});
}
}
if (manifest.projects && manifest.projects.length > 0) {
if (!manifest.capabilities.includes("projects.managed")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'projects.managed' is required when managed projects are declared",
path: ["capabilities"],
});
}
}
if (manifest.routines && manifest.routines.length > 0) {
if (!manifest.capabilities.includes("routines.managed")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'routines.managed' is required when managed routines are declared",
path: ["capabilities"],
});
}
}
if (manifest.skills && manifest.skills.length > 0) {
if (!manifest.capabilities.includes("skills.managed")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'skills.managed' is required when managed skills are declared",
path: ["capabilities"],
});
}
}
if (manifest.localFolders && manifest.localFolders.length > 0) {
if (!manifest.capabilities.includes("local.folders")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'local.folders' is required when local folders are declared",
path: ["capabilities"],
});
}
}
// jobs require jobs.schedule (PLUGIN_SPEC.md §17)
if (manifest.jobs && manifest.jobs.length > 0) {
if (!manifest.capabilities.includes("jobs.schedule")) {
@@ -664,6 +870,66 @@ export const pluginManifestV1Schema = z.object({
}
}
if (manifest.localFolders) {
const folderKeys = manifest.localFolders.map((folder) => folder.folderKey);
const duplicates = folderKeys.filter((key, i) => folderKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate local folder keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["localFolders"],
});
}
}
if (manifest.agents) {
const agentKeys = manifest.agents.map((agent) => agent.agentKey);
const duplicates = agentKeys.filter((key, i) => agentKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate managed agent keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["agents"],
});
}
}
if (manifest.projects) {
const projectKeys = manifest.projects.map((project) => project.projectKey);
const duplicates = projectKeys.filter((key, i) => projectKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate managed project keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["projects"],
});
}
}
if (manifest.routines) {
const routineKeys = manifest.routines.map((routine) => routine.routineKey);
const duplicates = routineKeys.filter((key, i) => routineKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate managed routine keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["routines"],
});
}
}
if (manifest.skills) {
const skillKeys = manifest.skills.map((skill) => skill.skillKey);
const duplicates = skillKeys.filter((key, i) => skillKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate managed skill keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["skills"],
});
}
}
// UI slot ids must be unique within the plugin (namespaced at runtime)
if (manifest.ui) {
if (manifest.ui.slots) {
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
routineRevisionSnapshotV1Schema,
updateRoutineSchema,
} from "./routine.js";
const routineId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
const triggerId = "33333333-3333-4333-8333-333333333333";
const baseRevisionId = "44444444-4444-4444-8444-444444444444";
describe("routine validators", () => {
it("accepts versioned routine revision snapshots with safe trigger metadata", () => {
const parsed = routineRevisionSnapshotV1Schema.parse({
version: 1,
routine: {
id: routineId,
companyId,
projectId: null,
goalId: null,
parentIssueId: null,
title: "Daily triage",
description: null,
assigneeAgentId: null,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
},
triggers: [{
id: triggerId,
kind: "webhook",
label: "Inbound",
enabled: true,
cronExpression: null,
timezone: null,
publicId: "routine_webhook_123",
signingMode: "bearer",
replayWindowSec: 300,
}],
});
expect(parsed.triggers[0]?.publicId).toBe("routine_webhook_123");
});
it("rejects secret-bearing trigger fields in routine revision snapshots", () => {
expect(() => routineRevisionSnapshotV1Schema.parse({
version: 1,
routine: {
id: routineId,
companyId,
projectId: null,
goalId: null,
parentIssueId: null,
title: "Daily triage",
description: null,
assigneeAgentId: null,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
},
triggers: [{
id: triggerId,
kind: "webhook",
label: "Inbound",
enabled: true,
cronExpression: null,
timezone: null,
publicId: "routine_webhook_123",
signingMode: "bearer",
replayWindowSec: 300,
secretId: "55555555-5555-4555-8555-555555555555",
}],
})).toThrow();
});
it("accepts optional base revision ids on routine updates", () => {
expect(updateRoutineSchema.parse({
title: "Daily triage",
baseRevisionId,
}).baseRevisionId).toBe(baseRevisionId);
});
});
+42 -1
View File
@@ -4,6 +4,7 @@ import {
ROUTINE_CATCH_UP_POLICIES,
ROUTINE_CONCURRENCY_POLICIES,
ROUTINE_STATUSES,
ROUTINE_TRIGGER_KINDS,
ROUTINE_TRIGGER_SIGNING_MODES,
ROUTINE_VARIABLE_TYPES,
} from "../constants.js";
@@ -63,9 +64,49 @@ export const createRoutineSchema = z.object({
export type CreateRoutine = z.infer<typeof createRoutineSchema>;
export const updateRoutineSchema = createRoutineSchema.partial();
export const updateRoutineSchema = createRoutineSchema.partial().extend({
baseRevisionId: z.string().uuid().optional().nullable(),
});
export type UpdateRoutine = z.infer<typeof updateRoutineSchema>;
export const routineRevisionSnapshotRoutineV1Schema = z.object({
id: z.string().uuid(),
companyId: z.string().uuid(),
projectId: z.string().uuid().nullable(),
goalId: z.string().uuid().nullable(),
parentIssueId: z.string().uuid().nullable(),
title: z.string().trim().min(1).max(200),
description: z.string().nullable(),
assigneeAgentId: z.string().uuid().nullable(),
priority: z.enum(ISSUE_PRIORITIES),
status: z.enum(ROUTINE_STATUSES),
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES),
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES),
variables: z.array(routineVariableSchema),
}).strict();
export const routineRevisionSnapshotTriggerV1Schema = z.object({
id: z.string().uuid(),
kind: z.enum(ROUTINE_TRIGGER_KINDS),
label: z.string().nullable(),
enabled: z.boolean(),
cronExpression: z.string().nullable(),
timezone: z.string().nullable(),
publicId: z.string().nullable(),
signingMode: z.enum(ROUTINE_TRIGGER_SIGNING_MODES).nullable(),
replayWindowSec: z.number().int().min(30).max(86_400).nullable(),
}).strict();
export const routineRevisionSnapshotV1Schema = z.object({
version: z.literal(1),
routine: routineRevisionSnapshotRoutineV1Schema,
triggers: z.array(routineRevisionSnapshotTriggerV1Schema),
}).strict();
export const routineRevisionSnapshotSchema = routineRevisionSnapshotV1Schema;
export type RoutineRevisionSnapshotV1 = z.infer<typeof routineRevisionSnapshotV1Schema>;
export type RoutineRevisionSnapshot = z.infer<typeof routineRevisionSnapshotSchema>;
const baseTriggerSchema = z.object({
label: z.string().trim().max(120).optional().nullable(),
enabled: z.boolean().optional().default(true),
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod";
import { COMPANY_SEARCH_SCOPES } from "../types/search.js";
export const COMPANY_SEARCH_MAX_QUERY_LENGTH = 200;
export const COMPANY_SEARCH_MAX_TOKENS = 8;
export const COMPANY_SEARCH_DEFAULT_LIMIT = 20;
export const COMPANY_SEARCH_MAX_LIMIT = 50;
export const COMPANY_SEARCH_MAX_OFFSET = 200;
function firstQueryValue(value: unknown): unknown {
return Array.isArray(value) ? value[0] : value;
}
function clampInteger(value: unknown, fallback: number, min: number, max: number) {
const raw = firstQueryValue(value);
const numeric = typeof raw === "number"
? raw
: typeof raw === "string" && raw.trim().length > 0
? Number.parseInt(raw, 10)
: Number.NaN;
if (!Number.isFinite(numeric)) return fallback;
return Math.min(max, Math.max(min, Math.floor(numeric)));
}
export const companySearchQuerySchema = z.object({
q: z.preprocess(firstQueryValue, z.string().optional().default(""))
.transform((value) => value.slice(0, COMPANY_SEARCH_MAX_QUERY_LENGTH)),
scope: z.preprocess(firstQueryValue, z.enum(COMPANY_SEARCH_SCOPES).catch("all")).optional().default("all"),
limit: z.unknown()
.optional()
.transform((value) => clampInteger(value, COMPANY_SEARCH_DEFAULT_LIMIT, 1, COMPANY_SEARCH_MAX_LIMIT)),
offset: z.unknown()
.optional()
.transform((value) => clampInteger(value, 0, 0, COMPANY_SEARCH_MAX_OFFSET)),
});
export type CompanySearchQuery = z.infer<typeof companySearchQuerySchema>;
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import {
createSecretProviderConfigSchema,
createSecretSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
secretProviderConfigPayloadSchema,
updateSecretProviderConfigSchema,
} from "./secret.js";
describe("secret validators", () => {
it("rejects externalRef on managed secrets", () => {
expect(() =>
createSecretSchema.parse({
name: "OpenAI API Key",
managedMode: "paperclip_managed",
value: "secret-value",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/other",
}),
).toThrow(/Managed secrets cannot set externalRef/);
});
it("allows externalRef on external reference secrets", () => {
const parsed = createSecretSchema.parse({
name: "Shared Secret",
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/other",
});
expect(parsed.externalRef).toContain(":secret:shared/other");
});
it("accepts non-sensitive local and AWS provider vault metadata", () => {
expect(() =>
createSecretProviderConfigSchema.parse({
provider: "local_encrypted",
displayName: "Local",
config: { backupReminderAcknowledged: true },
}),
).not.toThrow();
expect(() =>
createSecretProviderConfigSchema.parse({
provider: "aws_secrets_manager",
displayName: "AWS",
config: {
region: "us-east-1",
namespace: "production",
secretNamePrefix: "paperclip",
},
}),
).not.toThrow();
});
it("accepts origin-only Vault provider vault addresses", () => {
expect(() =>
createSecretProviderConfigSchema.parse({
provider: "vault",
displayName: "Vault draft",
config: { address: " https://vault.example.com/ " },
}),
).not.toThrow();
const parsed = secretProviderConfigPayloadSchema.parse({
provider: "vault",
config: { address: " https://vault.example.com/ " },
});
expect(parsed.provider).toBe("vault");
if (parsed.provider !== "vault") throw new Error("Expected vault provider payload");
expect(parsed.config.address).toBe("https://vault.example.com");
});
it.each([
"https://user:pass@vault.example.com",
"https://vault.example.com?token=hvs.x",
"https://vault.example.com#token=hvs.x",
"https://vault.example.com/v1/secret",
])("rejects credential-bearing or non-origin Vault addresses: %s", (address) => {
expect(() =>
createSecretProviderConfigSchema.parse({
provider: "vault",
displayName: "Vault draft",
config: { address },
}),
).toThrow(/origin-only HTTP\(S\) URL/i);
});
it("rejects unsafe Vault addresses in provider payload validation used by updates", () => {
expect(() =>
secretProviderConfigPayloadSchema.parse({
provider: "vault",
config: { address: "https://vault.example.com?client_token=hvs.x" },
}),
).toThrow(/origin-only HTTP\(S\) URL/i);
});
it("rejects unsafe Vault addresses in provider vault update payloads", () => {
expect(() =>
updateSecretProviderConfigSchema.parse({
config: { address: "https://vault.example.com#token=hvs.x" },
}),
).toThrow(/origin-only HTTP\(S\) URL/i);
});
it("validates AWS remote import preview and import payloads", () => {
expect(
remoteSecretImportPreviewSchema.parse({
providerConfigId: "11111111-1111-4111-8111-111111111111",
query: "openai",
pageSize: 50,
}),
).toEqual({
providerConfigId: "11111111-1111-4111-8111-111111111111",
query: "openai",
pageSize: 50,
});
expect(
remoteSecretImportSchema.parse({
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "OPENAI_API_KEY",
description: " Operator-entered Paperclip description ",
providerMetadata: { name: "prod/openai" },
},
],
}),
).toMatchObject({
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [
expect.objectContaining({
key: "OPENAI_API_KEY",
description: "Operator-entered Paperclip description",
}),
],
});
});
it("caps AWS remote import paging and row counts", () => {
expect(() =>
remoteSecretImportPreviewSchema.parse({
providerConfigId: "11111111-1111-4111-8111-111111111111",
pageSize: 101,
}),
).toThrow();
expect(() =>
remoteSecretImportSchema.parse({
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [],
}),
).toThrow();
});
});
+236 -3
View File
@@ -1,5 +1,11 @@
import { z } from "zod";
import { SECRET_PROVIDERS } from "../constants.js";
import {
SECRET_BINDING_TARGET_TYPES,
SECRET_MANAGED_MODES,
SECRET_PROVIDER_CONFIG_STATUSES,
SECRET_PROVIDERS,
SECRET_STATUSES,
} from "../constants.js";
export const envBindingPlainSchema = z.object({
type: z.literal("plain"),
@@ -23,25 +29,252 @@ export const envConfigSchema = z.record(envBindingSchema);
export const createSecretSchema = z.object({
name: z.string().min(1),
key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(),
provider: z.enum(SECRET_PROVIDERS).optional(),
value: z.string().min(1),
providerConfigId: z.string().uuid().optional().nullable(),
managedMode: z.enum(SECRET_MANAGED_MODES).optional(),
value: z.string().min(1).optional().nullable(),
description: z.string().optional().nullable(),
externalRef: z.string().optional().nullable(),
providerMetadata: z.record(z.unknown()).optional().nullable(),
providerVersionRef: z.string().optional().nullable(),
}).superRefine((value, ctx) => {
if ((value.managedMode ?? "paperclip_managed") === "external_reference") {
if (!value.externalRef?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["externalRef"],
message: "External reference secrets require externalRef",
});
}
return;
}
if (value.externalRef?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["externalRef"],
message: "Managed secrets cannot set externalRef",
});
}
if (!value.value?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["value"],
message: "Managed secrets require value",
});
}
});
export type CreateSecret = z.infer<typeof createSecretSchema>;
export const rotateSecretSchema = z.object({
value: z.string().min(1),
value: z.string().min(1).optional().nullable(),
externalRef: z.string().optional().nullable(),
providerVersionRef: z.string().optional().nullable(),
providerConfigId: z.string().uuid().optional().nullable(),
});
export type RotateSecret = z.infer<typeof rotateSecretSchema>;
export const updateSecretSchema = z.object({
name: z.string().min(1).optional(),
key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(),
status: z.enum(SECRET_STATUSES).optional(),
providerConfigId: z.string().uuid().optional().nullable(),
description: z.string().optional().nullable(),
externalRef: z.string().optional().nullable(),
providerMetadata: z.record(z.unknown()).optional().nullable(),
});
export type UpdateSecret = z.infer<typeof updateSecretSchema>;
export const secretBindingTargetSchema = z.object({
targetType: z.enum(SECRET_BINDING_TARGET_TYPES),
targetId: z.string().min(1),
configPath: z.string().min(1),
});
export const createSecretBindingSchema = secretBindingTargetSchema.extend({
secretId: z.string().uuid(),
versionSelector: z.union([z.literal("latest"), z.number().int().positive()]).default("latest"),
required: z.boolean().default(true),
label: z.string().optional().nullable(),
});
export type CreateSecretBinding = z.infer<typeof createSecretBindingSchema>;
const safeShortText = z.string().trim().min(1).max(160);
const optionalSafeShortText = safeShortText.optional().nullable();
const deniedProviderConfigKeyPattern =
/^(access[-_]?key([-_]?id)?|secret[-_]?access[-_]?key|secret[-_]?key|token|password|passwd|credential|credentials|private[-_]?key|pem|jwt|session[-_]?token|service[-_]?account([-_]?json)?|client[-_]?secret|secret[-_]?id|unseal[-_]?key|recovery[-_]?key|key[-_]?file([-_]?path)?|token[-_]?file([-_]?path)?)$/i;
function rejectSensitiveProviderConfigKeys(value: unknown, ctx: z.RefinementCtx) {
if (!value || typeof value !== "object" || Array.isArray(value)) return;
for (const key of Object.keys(value)) {
if (!deniedProviderConfigKeyPattern.test(key)) continue;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["config", key],
message: `Provider vault config cannot persist sensitive field: ${key}`,
});
}
}
export const localEncryptedProviderConfigSchema = z.object({
backupReminderAcknowledged: z.boolean().optional(),
}).strict();
export const awsSecretsManagerProviderConfigSchema = z.object({
region: z.string().trim().regex(/^[a-z]{2}(?:-gov)?-[a-z]+-\d+$/, "Invalid AWS region"),
namespace: optionalSafeShortText,
secretNamePrefix: optionalSafeShortText,
kmsKeyId: z.string().trim().min(1).max(512).optional().nullable(),
ownerTag: optionalSafeShortText,
environmentTag: optionalSafeShortText,
}).strict();
export const gcpSecretManagerProviderConfigSchema = z.object({
projectId: z.string().trim().min(1).max(128).regex(/^[a-z][a-z0-9-]{4,127}$/).optional().nullable(),
location: optionalSafeShortText,
namespace: optionalSafeShortText,
secretNamePrefix: optionalSafeShortText,
}).strict();
const vaultAddressSchema = z.preprocess(
(value) => typeof value === "string" ? value.trim() : value,
z.string().url().superRefine((value, ctx) => {
let url: URL;
try {
url = new URL(value);
} catch {
return;
}
const hasPath = url.pathname !== "" && url.pathname !== "/";
if (
(url.protocol !== "http:" && url.protocol !== "https:") ||
url.username ||
url.password ||
url.search ||
url.hash ||
hasPath
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Vault address must be an origin-only HTTP(S) URL without credentials, path, query, or fragment",
});
}
}).transform((value) => new URL(value).origin),
);
function rejectUnsafeVaultAddress(value: unknown, ctx: z.RefinementCtx) {
if (value === undefined || value === null) return;
const parsed = vaultAddressSchema.safeParse(value);
if (parsed.success) return;
for (const issue of parsed.error.issues) {
ctx.addIssue({
...issue,
path: ["config", "address", ...issue.path],
});
}
}
export const vaultProviderConfigSchema = z.object({
address: vaultAddressSchema.optional().nullable(),
namespace: optionalSafeShortText,
mountPath: optionalSafeShortText,
secretPathPrefix: optionalSafeShortText,
}).strict();
export const secretProviderConfigPayloadSchema = z.discriminatedUnion("provider", [
z.object({ provider: z.literal("local_encrypted"), config: localEncryptedProviderConfigSchema }),
z.object({ provider: z.literal("aws_secrets_manager"), config: awsSecretsManagerProviderConfigSchema }),
z.object({ provider: z.literal("gcp_secret_manager"), config: gcpSecretManagerProviderConfigSchema }),
z.object({ provider: z.literal("vault"), config: vaultProviderConfigSchema }),
]);
export const createSecretProviderConfigSchema = z.object({
provider: z.enum(SECRET_PROVIDERS),
displayName: z.string().trim().min(1).max(120),
status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(),
isDefault: z.boolean().optional(),
config: z.record(z.unknown()).default({}),
}).superRefine((value, ctx) => {
rejectSensitiveProviderConfigKeys(value.config, ctx);
const parsed = secretProviderConfigPayloadSchema.safeParse({
provider: value.provider,
config: value.config,
});
if (!parsed.success) {
for (const issue of parsed.error.issues) {
ctx.addIssue({
...issue,
path: issue.path[0] === "config" ? issue.path : ["config", ...issue.path],
});
}
}
const status = value.status ?? (["gcp_secret_manager", "vault"].includes(value.provider) ? "coming_soon" : "ready");
if ((value.provider === "gcp_secret_manager" || value.provider === "vault") && status !== "coming_soon" && status !== "disabled") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["status"],
message: `${value.provider} provider vaults are locked while coming soon`,
});
}
if ((status === "coming_soon" || status === "disabled") && value.isDefault) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["isDefault"],
message: "Only ready or warning provider vaults can be default",
});
}
});
export type CreateSecretProviderConfig = z.infer<typeof createSecretProviderConfigSchema>;
export const updateSecretProviderConfigSchema = z.object({
displayName: z.string().trim().min(1).max(120).optional(),
status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(),
isDefault: z.boolean().optional(),
config: z.record(z.unknown()).optional(),
}).superRefine((value, ctx) => {
if (value.config !== undefined) {
rejectSensitiveProviderConfigKeys(value.config, ctx);
rejectUnsafeVaultAddress(value.config.address, ctx);
}
if ((value.status === "coming_soon" || value.status === "disabled") && value.isDefault) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["isDefault"],
message: "Only ready or warning provider vaults can be default",
});
}
});
export type UpdateSecretProviderConfig = z.infer<typeof updateSecretProviderConfigSchema>;
export const remoteSecretImportPreviewSchema = z.object({
providerConfigId: z.string().uuid(),
query: z.string().trim().max(200).optional().nullable(),
nextToken: z.string().trim().min(1).max(4096).optional().nullable(),
pageSize: z.number().int().min(1).max(100).optional(),
});
export type RemoteSecretImportPreview = z.infer<typeof remoteSecretImportPreviewSchema>;
export const remoteSecretImportSelectionSchema = z.object({
externalRef: z.string().trim().min(1).max(2048),
name: z.string().trim().min(1).max(160).optional().nullable(),
key: z.string().trim().min(1).max(120).regex(/^[a-zA-Z0-9_.-]+$/).optional().nullable(),
description: z.string().trim().max(500).optional().nullable(),
providerVersionRef: z.string().trim().min(1).max(512).optional().nullable(),
providerMetadata: z.record(z.unknown()).optional().nullable(),
});
export const remoteSecretImportSchema = z.object({
providerConfigId: z.string().uuid(),
secrets: z.array(remoteSecretImportSelectionSchema).min(1).max(100),
});
export type RemoteSecretImportSelection = z.infer<typeof remoteSecretImportSelectionSchema>;
export type RemoteSecretImport = z.infer<typeof remoteSecretImportSchema>;