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
+25 -12
View File
@@ -69,6 +69,15 @@ export interface AgentPermissionUpdate {
canAssignTasks: boolean;
}
export interface AgentWakeRequest {
source?: "timer" | "assignment" | "on_demand" | "automation";
triggerDetail?: "manual" | "ping" | "callback" | "system";
reason?: string | null;
payload?: Record<string, unknown> | null;
idempotencyKey?: string | null;
forceFreshSession?: boolean;
}
function withCompanyScope(path: string, companyId?: string) {
if (!companyId) return path;
const separator = path.includes("?") ? "&" : "?";
@@ -171,10 +180,19 @@ export const agentsApi = {
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
adapterModels: (companyId: string, type: string, options?: { refresh?: boolean }) =>
api.get<AdapterModel[]>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models${options?.refresh ? "?refresh=1" : ""}`,
),
adapterModels: (
companyId: string,
type: string,
options?: { refresh?: boolean; environmentId?: string | null },
) => {
const params = new URLSearchParams();
if (options?.refresh) params.set("refresh", "1");
if (options?.environmentId) params.set("environmentId", options.environmentId);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api.get<AdapterModel[]>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models${query}`,
);
},
detectModel: (companyId: string, type: string) =>
api.get<DetectedAdapterModel | null>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
@@ -195,16 +213,11 @@ export const agentsApi = {
`/companies/${companyId}/adapters/${type}/test-environment`,
data,
),
invoke: (id: string, companyId?: string) => api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), {}),
invoke: (id: string, companyId?: string, data: AgentWakeRequest = {}) =>
api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), data),
wakeup: (
id: string,
data: {
source?: "timer" | "assignment" | "on_demand" | "automation";
triggerDetail?: "manual" | "ping" | "callback" | "system";
reason?: string | null;
payload?: Record<string, unknown> | null;
idempotencyKey?: string | null;
},
data: AgentWakeRequest,
companyId?: string,
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
loginWithClaude: (id: string, companyId?: string) =>
+10 -1
View File
@@ -12,6 +12,7 @@ import type {
IssueComment,
IssueDocument,
IssueLabel,
IssueRetryNowResponse,
IssueThreadInteraction,
IssueTreeControlPreview,
IssueTreeHold,
@@ -43,6 +44,7 @@ export const issuesApi = {
workspaceId?: string;
executionWorkspaceId?: string;
originKind?: string;
originKindPrefix?: string;
originId?: string;
descendantOf?: string;
includeRoutineExecutions?: boolean;
@@ -66,6 +68,7 @@ export const issuesApi = {
if (filters?.workspaceId) params.set("workspaceId", filters.workspaceId);
if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId);
if (filters?.originKind) params.set("originKind", filters.originKind);
if (filters?.originKindPrefix) params.set("originKindPrefix", filters.originKindPrefix);
if (filters?.originId) params.set("originId", filters.originId);
if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf);
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
@@ -126,6 +129,9 @@ export const issuesApi = {
}>(`/issues/${id}/tree-control/state`),
releaseTreeHold: (id: string, holdId: string, data: ReleaseIssueTreeHold) =>
api.post<IssueTreeHold>(`/issues/${id}/tree-holds/${holdId}/release`, data),
checkMonitorNow: (id: string) => api.post<{ ok: true }>(`/issues/${id}/monitor/check-now`, {}),
retryScheduledRetryNow: (id: string) =>
api.post<IssueRetryNowResponse>(`/issues/${id}/scheduled-retry/retry-now`, {}),
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
checkout: (id: string, agentId: string) =>
api.post<Issue>(`/issues/${id}/checkout`, {
@@ -171,7 +177,10 @@ export const issuesApi = {
getComment: (id: string, commentId: string) =>
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`),
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
getCostSummary: (id: string) => api.get<IssueCostSummary>(`/issues/${id}/cost-summary`),
getCostSummary: (id: string, options: { excludeRoot?: boolean } = {}) => {
const qs = options.excludeRoot ? "?excludeRoot=true" : "";
return api.get<IssueCostSummary>(`/issues/${id}/cost-summary${qs}`);
},
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters ?? {})) {
+64
View File
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockApi = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
}));
vi.mock("./client", () => ({
api: mockApi,
}));
import { pluginsApi } from "./plugins";
describe("pluginsApi local folders", () => {
beforeEach(() => {
mockApi.get.mockReset();
mockApi.post.mockReset();
mockApi.put.mockReset();
mockApi.get.mockResolvedValue({});
mockApi.post.mockResolvedValue({});
mockApi.put.mockResolvedValue({});
});
it("lists company-scoped local folders for a plugin", async () => {
await pluginsApi.listLocalFolders("plugin-1", "company-1");
expect(mockApi.get).toHaveBeenCalledWith(
"/plugins/plugin-1/companies/company-1/local-folders",
);
});
it("validates a candidate folder path without saving", async () => {
await pluginsApi.validateLocalFolder("plugin-1", "company-1", "wiki-root", {
path: "/tmp/wiki",
access: "readWrite",
requiredFiles: ["WIKI.md"],
});
expect(mockApi.post).toHaveBeenCalledWith(
"/plugins/plugin-1/companies/company-1/local-folders/wiki-root/validate",
{
path: "/tmp/wiki",
access: "readWrite",
requiredFiles: ["WIKI.md"],
},
);
});
it("saves through the local-folder PUT endpoint", async () => {
await pluginsApi.configureLocalFolder("plugin-1", "company-1", "wiki-root", {
path: "/tmp/wiki",
requiredDirectories: ["wiki"],
});
expect(mockApi.put).toHaveBeenCalledWith(
"/plugins/plugin-1/companies/company-1/local-folders/wiki-root",
{
path: "/tmp/wiki",
requiredDirectories: ["wiki"],
},
);
});
});
+91
View File
@@ -14,6 +14,7 @@ import type {
PluginLauncherDeclaration,
PluginLauncherRenderContextSnapshot,
PluginUiSlotDeclaration,
PluginLocalFolderDeclaration,
PluginRecord,
PluginConfig,
PluginStatus,
@@ -140,6 +141,54 @@ export interface AvailablePluginExample {
tag: "example";
}
export interface PluginLocalFolderProblem {
code:
| "not_configured"
| "not_absolute"
| "missing"
| "not_directory"
| "not_readable"
| "not_writable"
| "missing_directory"
| "missing_file"
| "path_traversal"
| "symlink_escape"
| "atomic_write_failed";
message: string;
path?: string;
}
export interface PluginLocalFolderStatus {
folderKey: string;
configured: boolean;
path: string | null;
realPath: string | null;
access: "read" | "readWrite";
readable: boolean;
writable: boolean;
requiredDirectories: string[];
requiredFiles: string[];
missingDirectories: string[];
missingFiles: string[];
healthy: boolean;
problems: PluginLocalFolderProblem[];
checkedAt: string;
}
export interface PluginLocalFoldersResponse {
pluginId: string;
companyId: string;
declarations: PluginLocalFolderDeclaration[];
folders: PluginLocalFolderStatus[];
}
export interface PluginLocalFolderSaveInput {
path: string;
access?: "read" | "readWrite";
requiredDirectories?: string[];
requiredFiles?: string[];
}
/**
* Plugin management API client.
*
@@ -337,6 +386,48 @@ export const pluginsApi = {
testConfig: (pluginId: string, configJson: Record<string, unknown>) =>
api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { configJson }),
/**
* List manifest-declared and stored company-scoped local folders for a plugin.
*/
listLocalFolders: (pluginId: string, companyId: string) =>
api.get<PluginLocalFoldersResponse>(`/plugins/${pluginId}/companies/${companyId}/local-folders`),
/**
* Inspect a configured local folder without changing persisted settings.
*/
localFolderStatus: (pluginId: string, companyId: string, folderKey: string) =>
api.get<PluginLocalFolderStatus>(
`/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}/status`,
),
/**
* Validate a candidate local folder path without saving it.
*/
validateLocalFolder: (
pluginId: string,
companyId: string,
folderKey: string,
input: PluginLocalFolderSaveInput,
) =>
api.post<PluginLocalFolderStatus>(
`/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}/validate`,
input,
),
/**
* Persist a company-scoped local folder path and return its inspected status.
*/
configureLocalFolder: (
pluginId: string,
companyId: string,
folderKey: string,
input: PluginLocalFolderSaveInput,
) =>
api.put<PluginLocalFolderStatus>(
`/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}`,
input,
),
// ===========================================================================
// Bridge proxy endpoints — used by the plugin UI bridge runtime
// ===========================================================================
+23
View File
@@ -3,6 +3,7 @@ import type {
Routine,
RoutineDetail,
RoutineListItem,
RoutineRevision,
RoutineRun,
RoutineRunSummary,
RoutineTrigger,
@@ -21,6 +22,18 @@ export interface RotateRoutineTriggerResponse {
secretMaterial: RoutineTriggerSecretMaterial;
}
export interface RestoreRoutineRevisionSecretMaterial extends RoutineTriggerSecretMaterial {
triggerId: string;
}
export interface RestoreRoutineRevisionResponse {
routine: Routine;
revision: RoutineRevision;
restoredFromRevisionId: string;
restoredFromRevisionNumber: number;
secretMaterials: RestoreRoutineRevisionSecretMaterial[];
}
export const routinesApi = {
list: (companyId: string, filters?: { projectId?: string | null }) => {
const params = new URLSearchParams();
@@ -32,6 +45,16 @@ export const routinesApi = {
api.post<Routine>(`/companies/${companyId}/routines`, data),
get: (id: string) => api.get<RoutineDetail>(`/routines/${id}`),
update: (id: string, data: Record<string, unknown>) => api.patch<Routine>(`/routines/${id}`, data),
listRevisions: (id: string) => api.get<RoutineRevision[]>(`/routines/${id}/revisions`),
restoreRevision: (
id: string,
revisionId: string,
body: { changeSummary?: string | null } = {},
) =>
api.post<RestoreRoutineRevisionResponse>(
`/routines/${id}/revisions/${revisionId}/restore`,
body,
),
listRuns: (id: string, limit: number = 50) => api.get<RoutineRunSummary[]>(`/routines/${id}/runs?limit=${limit}`),
createTrigger: (id: string, data: Record<string, unknown>) =>
api.post<RoutineTriggerResponse>(`/routines/${id}/triggers`, data),
+23
View File
@@ -0,0 +1,23 @@
import type { CompanySearchResponse, CompanySearchScope } from "@paperclipai/shared";
import { api } from "./client";
export interface CompanySearchParams {
q: string;
scope?: CompanySearchScope;
limit?: number;
offset?: number;
}
export const searchApi = {
search: (companyId: string, params: CompanySearchParams) => {
const search = new URLSearchParams();
search.set("q", params.q);
if (params.scope) search.set("scope", params.scope);
if (params.limit !== undefined) search.set("limit", String(params.limit));
if (params.offset !== undefined) search.set("offset", String(params.offset));
const qs = search.toString();
return api.get<CompanySearchResponse>(
`/companies/${companyId}/search${qs ? `?${qs}` : ""}`,
);
},
};
+129 -16
View File
@@ -1,30 +1,143 @@
import type { CompanySecret, SecretProviderDescriptor, SecretProvider } from "@paperclipai/shared";
import type {
CompanySecret,
CompanySecretUsageBinding,
CompanySecretProviderConfig,
RemoteSecretImportPreviewResult,
RemoteSecretImportResult,
SecretAccessEvent,
SecretManagedMode,
SecretProvider,
SecretProviderConfigStatus,
SecretProviderConfigHealthResponse,
SecretProviderDescriptor,
SecretStatus,
} from "@paperclipai/shared";
import { api } from "./client";
export interface SecretUsageResponse {
secretId: string;
bindings: CompanySecretUsageBinding[];
}
export interface CreateSecretInput {
name: string;
key?: string;
provider?: SecretProvider;
managedMode?: SecretManagedMode;
value?: string | null;
description?: string | null;
externalRef?: string | null;
providerVersionRef?: string | null;
providerConfigId?: string | null;
providerMetadata?: Record<string, unknown> | null;
}
export interface SecretProviderHealthResponse {
providers: Array<{
provider: SecretProvider;
status: "ok" | "warn" | "error";
message: string;
warnings?: string[];
backupGuidance?: string[];
details?: Record<string, unknown>;
}>;
}
export interface UpdateSecretInput {
name?: string;
key?: string;
status?: SecretStatus;
description?: string | null;
externalRef?: string | null;
providerMetadata?: Record<string, unknown> | null;
}
export interface RotateSecretInput {
value?: string | null;
externalRef?: string | null;
providerVersionRef?: string | null;
providerConfigId?: string | null;
}
export interface CreateSecretProviderConfigInput {
provider: SecretProvider;
displayName: string;
status?: SecretProviderConfigStatus;
isDefault?: boolean;
config?: Record<string, unknown>;
}
export interface UpdateSecretProviderConfigInput {
displayName?: string;
status?: SecretProviderConfigStatus;
isDefault?: boolean;
config?: Record<string, unknown>;
}
export interface RemoteImportPreviewInput {
providerConfigId: string;
query?: string | null;
nextToken?: string | null;
pageSize?: number;
}
export interface RemoteImportSelectionInput {
externalRef: string;
name?: string | null;
key?: string | null;
description?: string | null;
providerVersionRef?: string | null;
providerMetadata?: Record<string, unknown> | null;
}
export interface RemoteImportInput {
providerConfigId: string;
secrets: RemoteImportSelectionInput[];
}
export const secretsApi = {
list: (companyId: string) => api.get<CompanySecret[]>(`/companies/${companyId}/secrets`),
providers: (companyId: string) =>
api.get<SecretProviderDescriptor[]>(`/companies/${companyId}/secret-providers`),
create: (
companyId: string,
data: {
name: string;
value: string;
provider?: SecretProvider;
description?: string | null;
externalRef?: string | null;
},
) => api.post<CompanySecret>(`/companies/${companyId}/secrets`, data),
rotate: (id: string, data: { value: string; externalRef?: string | null }) =>
providerHealth: (companyId: string) =>
api.get<SecretProviderHealthResponse>(`/companies/${companyId}/secret-providers/health`),
providerConfigs: (companyId: string) =>
api.get<CompanySecretProviderConfig[]>(`/companies/${companyId}/secret-provider-configs`),
createProviderConfig: (companyId: string, data: CreateSecretProviderConfigInput) =>
api.post<CompanySecretProviderConfig>(`/companies/${companyId}/secret-provider-configs`, data),
updateProviderConfig: (id: string, data: UpdateSecretProviderConfigInput) =>
api.patch<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`, data),
disableProviderConfig: (id: string) =>
api.delete<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`),
setDefaultProviderConfig: (id: string) =>
api.post<CompanySecretProviderConfig>(`/secret-provider-configs/${id}/default`, {}),
checkProviderConfigHealth: (id: string) =>
api.post<SecretProviderConfigHealthResponse>(`/secret-provider-configs/${id}/health`, {}),
create: (companyId: string, data: CreateSecretInput) =>
api.post<CompanySecret>(`/companies/${companyId}/secrets`, data),
update: (id: string, data: UpdateSecretInput) =>
api.patch<CompanySecret>(`/secrets/${id}`, data),
rotate: (id: string, data: RotateSecretInput) =>
api.post<CompanySecret>(`/secrets/${id}/rotate`, data),
update: (
id: string,
data: { name?: string; description?: string | null; externalRef?: string | null },
) => api.patch<CompanySecret>(`/secrets/${id}`, data),
disable: (id: string) =>
api.patch<CompanySecret>(`/secrets/${id}`, { status: "disabled" satisfies SecretStatus }),
enable: (id: string) =>
api.patch<CompanySecret>(`/secrets/${id}`, { status: "active" satisfies SecretStatus }),
archive: (id: string) =>
api.patch<CompanySecret>(`/secrets/${id}`, { status: "archived" satisfies SecretStatus }),
remove: (id: string) => api.delete<{ ok: true }>(`/secrets/${id}`),
usages: (id: string) =>
api.get<{
agents: { id: string; name: string; envKeys: string[] }[];
skills: { id: string; name: string; slug: string }[];
}>(`/secrets/${id}/usages`),
usage: (id: string) => api.get<SecretUsageResponse>(`/secrets/${id}/usage`),
accessEvents: (id: string) => api.get<SecretAccessEvent[]>(`/secrets/${id}/access-events`),
remoteImportPreview: (companyId: string, data: RemoteImportPreviewInput) =>
api.post<RemoteSecretImportPreviewResult>(
`/companies/${companyId}/secrets/remote-import/preview`,
data,
),
remoteImport: (companyId: string, data: RemoteImportInput) =>
api.post<RemoteSecretImportResult>(`/companies/${companyId}/secrets/remote-import`, data),
};