Resolved conflicts: - ui CompanySettingsSidebar.tsx: keep both Secrets (local) and Cloud upstream (master) nav items - ui CompanySettingsNav.tsx + test: take master's cloud-upstream/members (drops deprecated `access` tab now consolidated into `members`) - server plugin-worker-manager.ts: take master's 15min RPC timeout cap - pnpm-lock.yaml: regenerated via `pnpm install` against merged package.json files Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ export const PAGE_ROUTE = "kitchensink";
|
||||
export const SLOT_IDS = {
|
||||
page: "kitchen-sink-page",
|
||||
settingsPage: "kitchen-sink-settings-page",
|
||||
companySettingsPage: "kitchen-sink-company-settings-page",
|
||||
dashboardWidget: "kitchen-sink-dashboard-widget",
|
||||
sidebar: "kitchen-sink-sidebar-link",
|
||||
sidebarPanel: "kitchen-sink-sidebar-panel",
|
||||
@@ -23,6 +24,7 @@ export const SLOT_IDS = {
|
||||
export const EXPORT_NAMES = {
|
||||
page: "KitchenSinkPage",
|
||||
settingsPage: "KitchenSinkSettingsPage",
|
||||
companySettingsPage: "KitchenSinkCompanySettingsPage",
|
||||
dashboardWidget: "KitchenSinkDashboardWidget",
|
||||
sidebar: "KitchenSinkSidebarLink",
|
||||
sidebarPanel: "KitchenSinkSidebarPanel",
|
||||
|
||||
@@ -194,6 +194,13 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
displayName: "Kitchen Sink Settings",
|
||||
exportName: EXPORT_NAMES.settingsPage,
|
||||
},
|
||||
{
|
||||
type: "companySettingsPage",
|
||||
id: SLOT_IDS.companySettingsPage,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.companySettingsPage,
|
||||
routePath: "kitchen-sink",
|
||||
},
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: SLOT_IDS.dashboardWidget,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
usePluginToast,
|
||||
type PluginCommentAnnotationProps,
|
||||
type PluginCommentContextMenuItemProps,
|
||||
type PluginCompanySettingsPageProps,
|
||||
type PluginDetailTabProps,
|
||||
type PluginPageProps,
|
||||
type PluginProjectSidebarItemProps,
|
||||
@@ -2236,6 +2237,33 @@ export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function KitchenSinkCompanySettingsPage({ context }: PluginCompanySettingsPageProps) {
|
||||
const hostNavigation = useHostNavigation();
|
||||
const overview = usePluginOverview(context.companyId);
|
||||
const href = hostNavigation.resolveHref("/company/settings/kitchen-sink");
|
||||
|
||||
return (
|
||||
<div style={layoutStack}>
|
||||
<Section title="Company Settings Slot">
|
||||
<div style={subtleCardStyle}>
|
||||
<div style={{ display: "grid", gap: "8px" }}>
|
||||
<strong>Mounted inside company settings</strong>
|
||||
<div style={mutedTextStyle}>
|
||||
This fixture proves a ready plugin can add a settings sidebar item and render with company context.
|
||||
</div>
|
||||
<JsonBlock value={{
|
||||
companyId: context.companyId,
|
||||
companyPrefix: context.companyPrefix,
|
||||
route: href,
|
||||
pluginId: overview.data?.pluginId ?? PLUGIN_ID,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
|
||||
const hostNavigation = useHostNavigation();
|
||||
const overview = usePluginOverview(context.companyId);
|
||||
|
||||
@@ -140,37 +140,14 @@ ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_runs ALTER COLUMN
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_source_snapshots ALTER COLUMN space_id SET NOT NULL;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings ALTER COLUMN space_id SET NOT NULL;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
target record;
|
||||
constraint_name text;
|
||||
BEGIN
|
||||
FOR target IN
|
||||
SELECT * FROM (VALUES
|
||||
('wiki_pages', ARRAY['company_id', 'wiki_id', 'path']::text[]),
|
||||
('paperclip_distillation_cursors', ARRAY['company_id', 'wiki_id', 'source_scope', 'scope_key', 'source_kind']::text[]),
|
||||
('paperclip_distillation_work_items', ARRAY['company_id', 'wiki_id', 'idempotency_key']::text[]),
|
||||
('paperclip_page_bindings', ARRAY['company_id', 'wiki_id', 'page_path']::text[])
|
||||
) AS targets(table_name, column_names)
|
||||
LOOP
|
||||
FOR constraint_name IN
|
||||
SELECT c.conname
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = 'plugin_llm_wiki_8f50da974f'
|
||||
AND t.relname = target.table_name
|
||||
AND c.contype = 'u'
|
||||
AND (
|
||||
SELECT array_agg(a.attname ORDER BY constraint_columns.ordinality)::text[]
|
||||
FROM unnest(c.conkey) WITH ORDINALITY AS constraint_columns(attnum, ordinality)
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = constraint_columns.attnum
|
||||
) = target.column_names
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I.%I DROP CONSTRAINT %I', 'plugin_llm_wiki_8f50da974f', target.table_name, constraint_name);
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END $$;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_pages
|
||||
DROP CONSTRAINT IF EXISTS wiki_pages_company_id_wiki_id_path_key;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors
|
||||
DROP CONSTRAINT IF EXISTS paperclip_distillation_cursor_company_id_wiki_id_source_sco_key;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items
|
||||
DROP CONSTRAINT IF EXISTS paperclip_distillation_work_i_company_id_wiki_id_idempotenc_key;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings
|
||||
DROP CONSTRAINT IF EXISTS paperclip_page_bindings_company_id_wiki_id_page_path_key;
|
||||
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_pages
|
||||
DROP CONSTRAINT IF EXISTS wiki_pages_company_wiki_space_path_key;
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Local-file LLM Wiki plugin for source ingestion, wiki browsing, query, lint, and maintenance workflows.",
|
||||
"files": [
|
||||
"agents",
|
||||
"dist",
|
||||
"migrations",
|
||||
"skills",
|
||||
"templates",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "node ./esbuild.config.mjs",
|
||||
|
||||
@@ -46,7 +46,7 @@ export const DEFAULT_AGENT_INSTRUCTIONS = DEFAULT_AGENT_INSTRUCTION_FILES["AGENT
|
||||
export const DEFAULT_IDEA = templateFile("IDEA.md");
|
||||
export const DEFAULT_INDEX = templateFile("wiki/index.md");
|
||||
export const DEFAULT_LOG = templateFile("wiki/log.md");
|
||||
export const DEFAULT_GITIGNORE = templateFile(".gitignore");
|
||||
export const DEFAULT_GITIGNORE = templateFile("gitignore.template");
|
||||
|
||||
export const QUERY_PROMPT = `Answer from the LLM Wiki using the installed wiki-query skill.
|
||||
|
||||
|
||||
@@ -155,6 +155,11 @@ type ManagedRoutine = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
type ManagedRoutineDefaultDrift = NonNullable<ManagedRoutine["defaultDrift"]>;
|
||||
type ManagedRoutinesListItemWithDrift = ManagedRoutinesListItem & {
|
||||
defaultDrift?: ManagedRoutineDefaultDrift | null;
|
||||
};
|
||||
|
||||
type ManagedSkill = {
|
||||
status: string;
|
||||
skillId?: string | null;
|
||||
@@ -5905,7 +5910,7 @@ function SettingsBody({ context, initialSection = "root" }: { context: { company
|
||||
const effectiveSelectedProjectId = selectedProjectId || data.managedProject.projectId || "";
|
||||
const currentProjectOption = projectOptions.find((project) => project.id === effectiveSelectedProjectId) ?? projectFallbackOption;
|
||||
const currentEventPolicy = eventPolicy ?? data.eventIngestion;
|
||||
const managedRoutineItems: ManagedRoutinesListItem[] = managedRoutines.map((routine) => {
|
||||
const managedRoutineItems: ManagedRoutinesListItemWithDrift[] = managedRoutines.map((routine) => {
|
||||
const fallback = routineFallbackFor(routine);
|
||||
const key = routine.resourceKey ?? routine.routineId ?? fallback.title;
|
||||
const status = managedRoutineStatus(routine);
|
||||
@@ -6132,7 +6137,7 @@ function SettingsBody({ context, initialSection = "root" }: { context: { company
|
||||
|
||||
async function resetManagedRoutineToDefaults(routine: ManagedRoutinesListItem) {
|
||||
if (!context.companyId || !routine.resourceKey) return;
|
||||
const changedFields = routine.defaultDrift?.changedFields ?? [];
|
||||
const changedFields = (routine as ManagedRoutinesListItemWithDrift).defaultDrift?.changedFields ?? [];
|
||||
const fieldList = changedFields.length > 0 ? changedFields.join(", ") : "managed defaults";
|
||||
const confirmed = typeof window === "undefined" || window.confirm(
|
||||
`Update "${routine.title}" to the current LLM Wiki plugin defaults? This replaces ${fieldList}. Cancel to keep the current custom routine text.`,
|
||||
|
||||
@@ -1102,10 +1102,10 @@ export async function listPaperclipIngestionCandidates(ctx: PluginContext, input
|
||||
return { projects, rootIssues: issues };
|
||||
}
|
||||
|
||||
export async function updateEventIngestionSettings(
|
||||
ctx: PluginContext,
|
||||
export async function updateEventIngestionSettings(
|
||||
ctx: PluginContext,
|
||||
input: { companyId: string; settings: WikiEventIngestionSettingsUpdate },
|
||||
): Promise<WikiEventIngestionSettings> {
|
||||
): Promise<WikiEventIngestionSettings> {
|
||||
await requirePaperclipIngestionPolicy(ctx, {
|
||||
companyId: input.companyId,
|
||||
wikiId: normalizeWikiId(input.settings.wikiId),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-workspace-diff",
|
||||
"version": "0.1.0",
|
||||
"description": "First-party execution workspace Changes tab powered by plugin-local workspace metadata",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/plugins/plugin-workspace-diff"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"workspace",
|
||||
"diff"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "tsc && node ./scripts/build-ui.mjs",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
||||
"test": "vitest run",
|
||||
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../scripts/generate-plugin-package-json.mjs",
|
||||
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"@pierre/diffs": "^1.1.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"esbuild": "^0.27.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import esbuild from "esbuild";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const packageRoot = path.resolve(__dirname, "..");
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
|
||||
outfile: path.join(packageRoot, "dist/ui/index.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
external: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"@paperclipai/plugin-sdk/ui",
|
||||
],
|
||||
logLevel: "info",
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { z } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export const workspaceDiffViewSchema = z.enum(["working-tree", "head"]);
|
||||
|
||||
export const workspaceDiffFileStatusSchema = z.enum([
|
||||
"added",
|
||||
"modified",
|
||||
"deleted",
|
||||
"renamed",
|
||||
"copied",
|
||||
"type_changed",
|
||||
"untracked",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
export const workspaceDiffPatchKindSchema = z.enum(["staged", "unstaged", "head", "untracked"]);
|
||||
|
||||
export const workspaceDiffWarningCodeSchema = z.enum([
|
||||
"base_ref_missing",
|
||||
"base_ref_invalid",
|
||||
"binary_file",
|
||||
"file_count_truncated",
|
||||
"file_oversized",
|
||||
"git_command_failed",
|
||||
"missing_cwd",
|
||||
"non_git_workspace",
|
||||
"patch_truncated",
|
||||
"path_filter_invalid",
|
||||
"symlink_target_outside_workspace",
|
||||
"workspace_path_invalid",
|
||||
]);
|
||||
|
||||
const queryBooleanSchema = z
|
||||
.union([z.boolean(), z.enum(["true", "false"])])
|
||||
.transform((value) => value === true || value === "true");
|
||||
|
||||
function normalizePathQuery(value: unknown): string[] {
|
||||
if (value == null) return [];
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
return values.flatMap((entry) => {
|
||||
if (typeof entry !== "string") return [];
|
||||
return entry
|
||||
.split(",")
|
||||
.map((filePath) => filePath.trim())
|
||||
.filter(Boolean);
|
||||
});
|
||||
}
|
||||
|
||||
export const workspaceDiffQuerySchema = z
|
||||
.object({
|
||||
view: workspaceDiffViewSchema.optional().default("working-tree"),
|
||||
baseRef: z.string().trim().min(1).max(240).optional().nullable(),
|
||||
includeUntracked: queryBooleanSchema.optional().default(true),
|
||||
path: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
paths: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((value) => ({
|
||||
view: value.view,
|
||||
baseRef: value.baseRef?.trim() || null,
|
||||
includeUntracked: value.includeUntracked,
|
||||
paths: normalizePathQuery(value.paths ?? value.path),
|
||||
}));
|
||||
|
||||
export const workspaceDiffWarningSchema = z.object({
|
||||
code: workspaceDiffWarningCodeSchema,
|
||||
message: z.string(),
|
||||
path: z.string().nullable(),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffCapsSchema = z.object({
|
||||
maxFiles: z.number().int().positive(),
|
||||
maxFileBytes: z.number().int().positive(),
|
||||
maxPatchBytes: z.number().int().positive(),
|
||||
maxTotalPatchBytes: z.number().int().positive(),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffFilePatchSchema = z.object({
|
||||
kind: workspaceDiffPatchKindSchema,
|
||||
patch: z.string().nullable(),
|
||||
additions: z.number().int().nonnegative(),
|
||||
deletions: z.number().int().nonnegative(),
|
||||
binary: z.boolean(),
|
||||
oversized: z.boolean(),
|
||||
truncated: z.boolean(),
|
||||
warnings: z.array(workspaceDiffWarningSchema),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffFileSchema = z.object({
|
||||
path: z.string(),
|
||||
oldPath: z.string().nullable(),
|
||||
status: workspaceDiffFileStatusSchema,
|
||||
staged: z.boolean(),
|
||||
unstaged: z.boolean(),
|
||||
untracked: z.boolean(),
|
||||
binary: z.boolean(),
|
||||
oversized: z.boolean(),
|
||||
truncated: z.boolean(),
|
||||
additions: z.number().int().nonnegative(),
|
||||
deletions: z.number().int().nonnegative(),
|
||||
sizeBytes: z.number().int().nonnegative().nullable(),
|
||||
patches: z.array(workspaceDiffFilePatchSchema),
|
||||
warnings: z.array(workspaceDiffWarningSchema),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffStatsSchema = z.object({
|
||||
fileCount: z.number().int().nonnegative(),
|
||||
stagedFileCount: z.number().int().nonnegative(),
|
||||
unstagedFileCount: z.number().int().nonnegative(),
|
||||
untrackedFileCount: z.number().int().nonnegative(),
|
||||
binaryFileCount: z.number().int().nonnegative(),
|
||||
oversizedFileCount: z.number().int().nonnegative(),
|
||||
truncatedFileCount: z.number().int().nonnegative(),
|
||||
additions: z.number().int().nonnegative(),
|
||||
deletions: z.number().int().nonnegative(),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffResponseSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
companyId: z.string(),
|
||||
view: workspaceDiffViewSchema,
|
||||
baseRef: z.string().nullable(),
|
||||
defaultBaseRef: z.string().nullable(),
|
||||
headSha: z.string().nullable(),
|
||||
includeUntracked: z.boolean(),
|
||||
paths: z.array(z.string()),
|
||||
files: z.array(workspaceDiffFileSchema),
|
||||
stats: workspaceDiffStatsSchema,
|
||||
warnings: z.array(workspaceDiffWarningSchema),
|
||||
caps: workspaceDiffCapsSchema,
|
||||
truncated: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
export type WorkspaceDiffView = z.infer<typeof workspaceDiffViewSchema>;
|
||||
export type WorkspaceDiffFileStatus = z.infer<typeof workspaceDiffFileStatusSchema>;
|
||||
export type WorkspaceDiffPatchKind = z.infer<typeof workspaceDiffPatchKindSchema>;
|
||||
export type WorkspaceDiffWarningCode = z.infer<typeof workspaceDiffWarningCodeSchema>;
|
||||
export type WorkspaceDiffQueryOptions = z.infer<typeof workspaceDiffQuerySchema>;
|
||||
export type WorkspaceDiffWarning = z.infer<typeof workspaceDiffWarningSchema>;
|
||||
export type WorkspaceDiffCaps = z.infer<typeof workspaceDiffCapsSchema>;
|
||||
export type WorkspaceDiffFilePatch = z.infer<typeof workspaceDiffFilePatchSchema>;
|
||||
export type WorkspaceDiffFile = z.infer<typeof workspaceDiffFileSchema>;
|
||||
export type WorkspaceDiffStats = z.infer<typeof workspaceDiffStatsSchema>;
|
||||
export type WorkspaceDiffResponse = z.infer<typeof workspaceDiffResponseSchema>;
|
||||
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
WorkspaceDiffFile,
|
||||
WorkspaceDiffFilePatch,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffWarning,
|
||||
} from "./contracts.js";
|
||||
|
||||
export type DiffRenderMode = "unified" | "split";
|
||||
|
||||
export interface DiffPatchViewModel {
|
||||
kind: WorkspaceDiffFilePatch["kind"];
|
||||
patch: string | null;
|
||||
lineCount: number;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
binary: boolean;
|
||||
oversized: boolean;
|
||||
truncated: boolean;
|
||||
warnings: WorkspaceDiffWarning[];
|
||||
}
|
||||
|
||||
export interface DiffFileViewModel {
|
||||
path: string;
|
||||
oldPath: string | null;
|
||||
status: WorkspaceDiffFile["status"];
|
||||
additions: number;
|
||||
deletions: number;
|
||||
binary: boolean;
|
||||
oversized: boolean;
|
||||
truncated: boolean;
|
||||
warnings: WorkspaceDiffWarning[];
|
||||
patchKinds: WorkspaceDiffFilePatch["kind"][];
|
||||
patches: DiffPatchViewModel[];
|
||||
patch: string | null;
|
||||
lineCount: number;
|
||||
longDiff: boolean;
|
||||
}
|
||||
|
||||
export interface DiffSummaryViewModel {
|
||||
changedLabel: string;
|
||||
lineLabel: string;
|
||||
warningCount: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<WorkspaceDiffFile["status"], string> = {
|
||||
added: "Added",
|
||||
modified: "Modified",
|
||||
deleted: "Deleted",
|
||||
renamed: "Renamed",
|
||||
copied: "Copied",
|
||||
type_changed: "Type changed",
|
||||
untracked: "Untracked",
|
||||
unknown: "Changed",
|
||||
};
|
||||
|
||||
export const LONG_DIFF_LINE_THRESHOLD = 400;
|
||||
|
||||
export function statusLabel(status: WorkspaceDiffFile["status"]) {
|
||||
return STATUS_LABELS[status] ?? "Changed";
|
||||
}
|
||||
|
||||
export function fileName(filePath: string) {
|
||||
return filePath.split("/").filter(Boolean).pop() ?? filePath;
|
||||
}
|
||||
|
||||
export function buildFilePatches(file: WorkspaceDiffFile): DiffPatchViewModel[] {
|
||||
return file.patches.map((patch) => {
|
||||
const textPatch = patch.patch?.trimEnd() ?? null;
|
||||
const lineCount = textPatch ? textPatch.split("\n").length : 0;
|
||||
return {
|
||||
kind: patch.kind,
|
||||
patch: textPatch && textPatch.length > 0 ? textPatch : null,
|
||||
lineCount,
|
||||
additions: patch.additions,
|
||||
deletions: patch.deletions,
|
||||
binary: patch.binary,
|
||||
oversized: patch.oversized,
|
||||
truncated: patch.truncated,
|
||||
warnings: patch.warnings,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildFilePatch(file: WorkspaceDiffFile): string | null {
|
||||
return buildFilePatches(file).find((patch) => patch.patch)?.patch ?? null;
|
||||
}
|
||||
|
||||
export function isLongDiffFile(file: Pick<DiffFileViewModel, "lineCount">) {
|
||||
return file.lineCount > LONG_DIFF_LINE_THRESHOLD;
|
||||
}
|
||||
|
||||
export function toFileViewModels(diff: WorkspaceDiffResponse | null | undefined): DiffFileViewModel[] {
|
||||
return (diff?.files ?? []).map((file) => {
|
||||
const patches = buildFilePatches(file);
|
||||
const lineCount = patches.reduce((count, patch) => count + patch.lineCount, 0);
|
||||
return {
|
||||
path: file.path,
|
||||
oldPath: file.oldPath,
|
||||
status: file.status,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
binary: file.binary,
|
||||
oversized: file.oversized,
|
||||
truncated: file.truncated,
|
||||
warnings: file.warnings,
|
||||
patchKinds: file.patches.map((patch) => patch.kind),
|
||||
patches,
|
||||
patch: patches.find((patch) => patch.patch)?.patch ?? null,
|
||||
lineCount,
|
||||
longDiff: isLongDiffFile({ lineCount }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function diffSummary(diff: WorkspaceDiffResponse | null | undefined): DiffSummaryViewModel {
|
||||
const stats = diff?.stats;
|
||||
const fileCount = stats?.fileCount ?? 0;
|
||||
const additions = stats?.additions ?? 0;
|
||||
const deletions = stats?.deletions ?? 0;
|
||||
const warningCount = diff?.warnings.length ?? 0;
|
||||
|
||||
return {
|
||||
changedLabel: `${fileCount} ${fileCount === 1 ? "file" : "files"}`,
|
||||
lineLabel: `+${additions} / -${deletions}`,
|
||||
warningCount,
|
||||
truncated: Boolean(diff?.truncated),
|
||||
};
|
||||
}
|
||||
|
||||
export function nextExpandedFileSet(
|
||||
current: ReadonlySet<string>,
|
||||
filePath: string,
|
||||
): Set<string> {
|
||||
const next = new Set(current);
|
||||
if (next.has(filePath)) next.delete(filePath);
|
||||
else next.add(filePath);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function initialExpandedFileSet(files: readonly DiffFileViewModel[]): Set<string> {
|
||||
return new Set(files.filter((file) => !file.longDiff).map((file) => file.path));
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as worker } from "./worker.js";
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip.workspace-diff";
|
||||
const CHANGES_TAB_SLOT_ID = "workspace-changes-tab";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Workspace Changes",
|
||||
description: "Adds a Changes tab to execution and project workspaces using plugin-local Git diff computation and @pierre/diffs.",
|
||||
author: "Paperclip",
|
||||
categories: ["workspace", "ui"],
|
||||
capabilities: [
|
||||
"ui.detailTab.register",
|
||||
"execution.workspaces.read",
|
||||
"project.workspaces.read",
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "detailTab",
|
||||
id: CHANGES_TAB_SLOT_ID,
|
||||
displayName: "Changes",
|
||||
exportName: "ChangesTab",
|
||||
entityTypes: ["execution_workspace", "project_workspace"],
|
||||
order: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,824 @@
|
||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui";
|
||||
import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs";
|
||||
import type { PatchDiffProps } from "@pierre/diffs/react";
|
||||
import { useFileDiffInstance } from "@pierre/diffs/react";
|
||||
import {
|
||||
createElement,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
diffSummary,
|
||||
fileName,
|
||||
initialExpandedFileSet,
|
||||
nextExpandedFileSet,
|
||||
statusLabel,
|
||||
toFileViewModels,
|
||||
type DiffFileViewModel,
|
||||
type DiffPatchViewModel,
|
||||
type DiffRenderMode,
|
||||
} from "../diff-model.js";
|
||||
import type { WorkspaceDiffResponse } from "../contracts.js";
|
||||
|
||||
type WorkspaceDiffData = WorkspaceDiffResponse;
|
||||
type WorkspacePatchDiffOptions = PatchDiffProps<undefined>["options"];
|
||||
type DiffViewMode = "working-tree" | "head";
|
||||
|
||||
type LucideIconProps = { size?: number };
|
||||
|
||||
const DEFAULT_FILE_SIDEBAR_WIDTH = 280;
|
||||
const MIN_FILE_SIDEBAR_WIDTH = 220;
|
||||
const MAX_FILE_SIDEBAR_WIDTH = 520;
|
||||
const FILE_SIDEBAR_WIDTH_STEP = 16;
|
||||
const FILE_SIDEBAR_WIDTH_STORAGE_KEY = "paperclip.workspace-diff.files-sidebar-width";
|
||||
|
||||
function makeLucideIcon(paths: ReactNode) {
|
||||
return function LucideIcon({ size = 16 }: LucideIconProps) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ width: size, height: size, display: "block" }}
|
||||
>
|
||||
{paths}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Plugin bundles cannot import host-only lucide-react; this mirrors lucide RefreshCw.
|
||||
const RefreshCwIcon = makeLucideIcon(
|
||||
<>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M8 16H3v5" />
|
||||
</>,
|
||||
);
|
||||
|
||||
function readInitialView(): DiffViewMode {
|
||||
if (typeof window === "undefined") return "working-tree";
|
||||
return new URLSearchParams(window.location.search).get("diffView") === "head" ? "head" : "working-tree";
|
||||
}
|
||||
|
||||
function hasInitialViewParam() {
|
||||
if (typeof window === "undefined") return false;
|
||||
return new URLSearchParams(window.location.search).has("diffView");
|
||||
}
|
||||
|
||||
function readInitialBaseRef() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return new URLSearchParams(window.location.search).get("baseRef") ?? "";
|
||||
}
|
||||
|
||||
function buttonClass(active = false) {
|
||||
return [
|
||||
"inline-flex h-8 items-center justify-center rounded-md border px-2.5 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-foreground/20 bg-foreground text-background"
|
||||
: "border-border bg-background text-muted-foreground hover:text-foreground",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function iconButtonClass(active = false) {
|
||||
return [
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-md border text-xs transition-colors",
|
||||
active
|
||||
? "border-foreground/20 bg-foreground text-background"
|
||||
: "border-border bg-background text-muted-foreground hover:text-foreground",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function clampFileSidebarWidth(width: number) {
|
||||
return Math.min(MAX_FILE_SIDEBAR_WIDTH, Math.max(MIN_FILE_SIDEBAR_WIDTH, width));
|
||||
}
|
||||
|
||||
function readStoredFileSidebarWidth() {
|
||||
if (typeof window === "undefined") return DEFAULT_FILE_SIDEBAR_WIDTH;
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(FILE_SIDEBAR_WIDTH_STORAGE_KEY);
|
||||
if (!stored) return DEFAULT_FILE_SIDEBAR_WIDTH;
|
||||
const parsed = Number.parseInt(stored, 10);
|
||||
return Number.isFinite(parsed) ? clampFileSidebarWidth(parsed) : DEFAULT_FILE_SIDEBAR_WIDTH;
|
||||
} catch {
|
||||
return DEFAULT_FILE_SIDEBAR_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredFileSidebarWidth(width: number) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(FILE_SIDEBAR_WIDTH_STORAGE_KEY, String(clampFileSidebarWidth(width)));
|
||||
} catch {
|
||||
// Storage can be unavailable; keep resize interactive even when persistence fails.
|
||||
}
|
||||
}
|
||||
|
||||
function useIsDesktopDiffLayout() {
|
||||
const [isDesktop, setIsDesktop] = useState(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
||||
return window.matchMedia("(min-width: 1024px)").matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
|
||||
const query = window.matchMedia("(min-width: 1024px)");
|
||||
const update = () => setIsDesktop(query.matches);
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
return isDesktop;
|
||||
}
|
||||
|
||||
function warningText(file: DiffFileViewModel) {
|
||||
if (file.binary) return "Binary file";
|
||||
if (file.oversized) return "Too large to render";
|
||||
if (file.truncated) return "Patch truncated";
|
||||
if (file.warnings.length > 0) return file.warnings[0]?.message ?? "Diff warning";
|
||||
if (file.patches.every((patch) => !patch.patch)) return "No text patch";
|
||||
return null;
|
||||
}
|
||||
|
||||
const PATCH_KIND_LABELS: Record<DiffPatchViewModel["kind"], string> = {
|
||||
staged: "Staged",
|
||||
unstaged: "Unstaged",
|
||||
head: "Head",
|
||||
untracked: "Untracked",
|
||||
};
|
||||
|
||||
function patchKindLabel(kind: DiffPatchViewModel["kind"]) {
|
||||
return PATCH_KIND_LABELS[kind] ?? "Patch";
|
||||
}
|
||||
|
||||
function patchWarningText(patch: DiffPatchViewModel) {
|
||||
if (patch.binary) return "Binary file";
|
||||
if (patch.oversized) return "Too large to render";
|
||||
if (patch.truncated) return "Patch truncated";
|
||||
if (patch.warnings.length > 0) return patch.warnings[0]?.message ?? "Diff warning";
|
||||
if (!patch.patch) return "No text patch";
|
||||
return null;
|
||||
}
|
||||
|
||||
function FileRow({
|
||||
file,
|
||||
active,
|
||||
expanded,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onCopy,
|
||||
}: {
|
||||
file: DiffFileViewModel;
|
||||
active: boolean;
|
||||
expanded: boolean;
|
||||
onSelect: () => void;
|
||||
onToggle: () => void;
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
const warning = warningText(file);
|
||||
const expandLabel = expanded ? "Collapse file" : "Expand file";
|
||||
const fileAriaLabel = expanded ? `Collapse ${file.path}` : `Expand ${file.path}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"group border-b border-border/70 px-3 py-2 last:border-b-0",
|
||||
active ? "bg-accent/60" : "bg-background hover:bg-muted/45",
|
||||
].join(" ")}
|
||||
>
|
||||
<div key="main" className="flex min-w-0 items-start gap-2">
|
||||
<button
|
||||
key="toggle"
|
||||
type="button"
|
||||
className="mt-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={onToggle}
|
||||
title={expandLabel}
|
||||
aria-label={fileAriaLabel}
|
||||
>
|
||||
{expanded ? "−" : "+"}
|
||||
</button>
|
||||
<button
|
||||
key="select"
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div key="name" className="truncate text-sm font-medium text-foreground">{fileName(file.path)}</div>
|
||||
<div key="path" className="truncate font-mono text-[11px] text-muted-foreground">{file.path}</div>
|
||||
</button>
|
||||
<button
|
||||
key="copy"
|
||||
type="button"
|
||||
className="text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||
onClick={onCopy}
|
||||
title="Copy path"
|
||||
aria-label={`Copy ${file.path}`}
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
</div>
|
||||
<div key="meta" className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 pl-5 text-[11px] text-muted-foreground">
|
||||
<span key="status">{statusLabel(file.status)}</span>
|
||||
<span key="additions" className="font-mono text-emerald-700 dark:text-emerald-300">{`+${file.additions}`}</span>
|
||||
<span key="deletions" className="font-mono text-red-700 dark:text-red-300">{`-${file.deletions}`}</span>
|
||||
{warning ? <span key="warning" className="text-amber-700 dark:text-amber-300">{warning}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The upstream React wrapper emits React 19 key warnings for its internal slot array.
|
||||
// This mounts the same Diffs custom element through the exported imperative hook.
|
||||
function WorkspacePatchDiff({
|
||||
patch,
|
||||
options,
|
||||
}: {
|
||||
patch: string;
|
||||
options: WorkspacePatchDiffOptions;
|
||||
}) {
|
||||
const fileDiff = useMemo(() => getSingularPatch(patch), [patch]);
|
||||
const { ref } = useFileDiffInstance({
|
||||
fileDiff,
|
||||
options,
|
||||
metrics: undefined,
|
||||
lineAnnotations: undefined,
|
||||
selectedLines: undefined,
|
||||
prerenderedHTML: undefined,
|
||||
hasGutterRenderUtility: false,
|
||||
hasCustomHeader: false,
|
||||
disableWorkerPool: false,
|
||||
});
|
||||
|
||||
return createElement(DIFFS_TAG_NAME, { ref });
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="border border-dashed border-border bg-background px-4 py-8 text-center">
|
||||
<div className="text-sm font-medium text-foreground">No workspace changes</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
The workspace matches its current comparison target.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="border border-dashed border-border bg-background px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Loading workspace changes…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm" role="alert">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">Unable to load workspace changes.</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
Retry the request or open the details below for the technical error.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass(false)}
|
||||
onClick={onRetry}
|
||||
aria-label="Retry loading workspace changes"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-xs font-medium text-muted-foreground hover:text-foreground">
|
||||
Troubleshooting details
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words border border-border bg-background px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{message || "No error message was provided."}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDiffPanel({
|
||||
file,
|
||||
mode,
|
||||
lineWrap,
|
||||
}: {
|
||||
file: DiffFileViewModel;
|
||||
mode: DiffRenderMode;
|
||||
lineWrap: boolean;
|
||||
}) {
|
||||
const warning = warningText(file);
|
||||
if (warning) {
|
||||
return (
|
||||
<div className="border border-dashed border-border bg-background px-4 py-6 text-sm text-muted-foreground">
|
||||
{warning ?? "No renderable patch is available for this file."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{file.patches.map((patch, index) => {
|
||||
const patchWarning = patchWarningText(patch);
|
||||
return (
|
||||
<div key={`${patch.kind}:${index}`} className="overflow-hidden border border-border bg-background">
|
||||
{file.patches.length > 1 ? (
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{patchKindLabel(patch.kind)}</span>
|
||||
<span className="font-mono text-emerald-700 dark:text-emerald-300">{`+${patch.additions}`}</span>
|
||||
<span className="font-mono text-red-700 dark:text-red-300">{`-${patch.deletions}`}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{patchWarning || !patch.patch ? (
|
||||
<div className="px-4 py-6 text-sm text-muted-foreground">
|
||||
{patchWarning ?? "No renderable patch is available for this file."}
|
||||
</div>
|
||||
) : (
|
||||
<WorkspacePatchDiff
|
||||
patch={patch.patch}
|
||||
options={{
|
||||
diffStyle: mode,
|
||||
overflow: lineWrap ? "wrap" : "scroll",
|
||||
disableLineNumbers: false,
|
||||
themeType: "system",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsedFilePanel({
|
||||
file,
|
||||
onExpand,
|
||||
}: {
|
||||
file: DiffFileViewModel;
|
||||
onExpand: () => void;
|
||||
}) {
|
||||
const title = file.longDiff ? "Large diff folded" : "Diff folded";
|
||||
const details = file.lineCount > 0
|
||||
? `${file.lineCount.toLocaleString()} lines`
|
||||
: statusLabel(file.status);
|
||||
|
||||
return (
|
||||
<div className="border border-dashed border-border bg-background px-4 py-5 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">{title}</div>
|
||||
<div className="mt-1 font-mono text-xs">{details}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass(false)}
|
||||
onClick={onExpand}
|
||||
aria-label={`Show diff for ${file.path}`}
|
||||
>
|
||||
Show file
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
const toast = usePluginToast();
|
||||
const [mode, setMode] = useState<DiffRenderMode>("split");
|
||||
const [lineWrap, setLineWrap] = useState(false);
|
||||
const [view, setView] = useState<DiffViewMode>(() => readInitialView());
|
||||
const [baseRef, setBaseRef] = useState(() => readInitialBaseRef());
|
||||
const baseRefTouchedRef = useRef(Boolean(baseRef.trim()));
|
||||
const viewTouchedRef = useRef(hasInitialViewParam());
|
||||
const [includeUntracked, setIncludeUntracked] = useState(false);
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(() => new Set());
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [fileSidebarWidth, setFileSidebarWidth] = useState(() => readStoredFileSidebarWidth());
|
||||
const [fileSidebarResizing, setFileSidebarResizing] = useState(false);
|
||||
const fileSidebarWidthRef = useRef(fileSidebarWidth);
|
||||
const fileSidebarDragRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
||||
const fileSectionRefs = useRef(new Map<string, HTMLElement>());
|
||||
const diffScrollRef = useRef<HTMLElement | null>(null);
|
||||
const scrollSyncFrameRef = useRef<number | null>(null);
|
||||
const usesDesktopDiffLayout = useIsDesktopDiffLayout();
|
||||
const requestedBaseRef = baseRef.trim();
|
||||
const effectiveView = view === "head" && !requestedBaseRef ? "working-tree" : view;
|
||||
const fileSidebarStyle = useMemo(
|
||||
() => usesDesktopDiffLayout ? { width: `${fileSidebarWidth}px` } : undefined,
|
||||
[fileSidebarWidth, usesDesktopDiffLayout],
|
||||
);
|
||||
|
||||
const params = useMemo(() => ({
|
||||
workspaceId: context.entityId,
|
||||
companyId: context.companyId ?? "",
|
||||
projectId: context.projectId ?? "",
|
||||
entityType: context.entityType,
|
||||
view: effectiveView,
|
||||
baseRef: requestedBaseRef || null,
|
||||
includeUntracked,
|
||||
}), [context.companyId, context.entityId, context.entityType, context.projectId, effectiveView, includeUntracked, requestedBaseRef]);
|
||||
|
||||
const { data, loading, error, refresh } = usePluginData<WorkspaceDiffData>("workspace-diff", params);
|
||||
const files = useMemo(() => toFileViewModels(data), [data]);
|
||||
const summary = useMemo(() => diffSummary(data), [data]);
|
||||
const selectedFile = files.find((file) => file.path === selectedPath) ?? files[0] ?? null;
|
||||
const compareLabel = `${data?.baseRef ? `base ${data.baseRef}` : "working tree"}${data?.headSha ? ` · ${data.headSha.slice(0, 12)}` : ""}`;
|
||||
|
||||
const setFileSectionRef = useCallback((filePath: string) => (node: HTMLElement | null) => {
|
||||
if (node) fileSectionRefs.current.set(filePath, node);
|
||||
else fileSectionRefs.current.delete(filePath);
|
||||
}, []);
|
||||
|
||||
const selectFile = useCallback((filePath: string) => {
|
||||
setSelectedPath(filePath);
|
||||
window.requestAnimationFrame(() => {
|
||||
fileSectionRefs.current.get(filePath)?.scrollIntoView({
|
||||
block: "start",
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const syncSelectedPathFromScroll = useCallback(() => {
|
||||
const container = diffScrollRef.current;
|
||||
if (!container || files.length === 0) return;
|
||||
|
||||
const containerTop = container.getBoundingClientRect().top;
|
||||
let nextPath = files[0]?.path ?? null;
|
||||
for (const file of files) {
|
||||
const section = fileSectionRefs.current.get(file.path);
|
||||
if (!section) continue;
|
||||
const offsetFromScrollTop = section.getBoundingClientRect().top - containerTop;
|
||||
if (offsetFromScrollTop <= 48) {
|
||||
nextPath = file.path;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextPath) {
|
||||
setSelectedPath((current) => current === nextPath ? current : nextPath);
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
const handleDiffScroll = useCallback(() => {
|
||||
if (scrollSyncFrameRef.current !== null) return;
|
||||
scrollSyncFrameRef.current = window.requestAnimationFrame(() => {
|
||||
scrollSyncFrameRef.current = null;
|
||||
syncSelectedPathFromScroll();
|
||||
});
|
||||
}, [syncSelectedPathFromScroll]);
|
||||
|
||||
const commitFileSidebarWidth = useCallback((nextWidth: number) => {
|
||||
const clamped = clampFileSidebarWidth(nextWidth);
|
||||
fileSidebarWidthRef.current = clamped;
|
||||
setFileSidebarWidth(clamped);
|
||||
writeStoredFileSidebarWidth(clamped);
|
||||
}, []);
|
||||
|
||||
const handleFileSidebarPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!usesDesktopDiffLayout) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
fileSidebarDragRef.current = {
|
||||
startX: event.clientX,
|
||||
startWidth: fileSidebarWidthRef.current,
|
||||
};
|
||||
setFileSidebarResizing(true);
|
||||
}, [usesDesktopDiffLayout]);
|
||||
|
||||
const handleFileSidebarPointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
||||
const drag = fileSidebarDragRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
const nextWidth = clampFileSidebarWidth(drag.startWidth + event.clientX - drag.startX);
|
||||
fileSidebarWidthRef.current = nextWidth;
|
||||
setFileSidebarWidth(nextWidth);
|
||||
}, []);
|
||||
|
||||
const endFileSidebarResize = useCallback(() => {
|
||||
if (!fileSidebarDragRef.current) return;
|
||||
|
||||
fileSidebarDragRef.current = null;
|
||||
setFileSidebarResizing(false);
|
||||
writeStoredFileSidebarWidth(fileSidebarWidthRef.current);
|
||||
}, []);
|
||||
|
||||
const handleFileSidebarKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!usesDesktopDiffLayout) return;
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
commitFileSidebarWidth(fileSidebarWidth - FILE_SIDEBAR_WIDTH_STEP);
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
commitFileSidebarWidth(fileSidebarWidth + FILE_SIDEBAR_WIDTH_STEP);
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
commitFileSidebarWidth(MIN_FILE_SIDEBAR_WIDTH);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
commitFileSidebarWidth(MAX_FILE_SIDEBAR_WIDTH);
|
||||
}
|
||||
}, [commitFileSidebarWidth, fileSidebarWidth, usesDesktopDiffLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultBaseRef = data?.defaultBaseRef?.trim();
|
||||
if (!defaultBaseRef) return;
|
||||
if (!baseRef.trim() && !baseRefTouchedRef.current) {
|
||||
setBaseRef(defaultBaseRef);
|
||||
}
|
||||
if (view === "working-tree" && !viewTouchedRef.current) {
|
||||
setView("head");
|
||||
}
|
||||
}, [baseRef, data?.defaultBaseRef, view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (files.length === 0) {
|
||||
setExpandedFiles(new Set());
|
||||
setSelectedPath(null);
|
||||
return;
|
||||
}
|
||||
setExpandedFiles(initialExpandedFileSet(files));
|
||||
setSelectedPath((current) => files.some((file) => file.path === current) ? current : files[0]?.path ?? null);
|
||||
}, [files]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollSyncFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollSyncFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileSidebarResizing || typeof document === "undefined") return;
|
||||
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const previousUserSelect = document.body.style.userSelect;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
return () => {
|
||||
document.body.style.cursor = previousCursor;
|
||||
document.body.style.userSelect = previousUserSelect;
|
||||
};
|
||||
}, [fileSidebarResizing]);
|
||||
|
||||
const copyPath = async (filePath: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(filePath);
|
||||
toast({ title: "Path copied", body: filePath });
|
||||
} catch {
|
||||
toast({ title: "Copy failed", body: filePath, tone: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div key="toolbar" className="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div key="summary" className="min-w-0">
|
||||
<div key="summary-line" className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span key="changed" className="font-medium text-foreground">{summary.changedLabel}</span>
|
||||
<span key="lines" className="font-mono text-xs text-muted-foreground">{summary.lineLabel}</span>
|
||||
{summary.truncated ? (
|
||||
<span key="truncated" className="text-xs text-amber-700 dark:text-amber-300">Truncated</span>
|
||||
) : null}
|
||||
{summary.warningCount > 0 ? (
|
||||
<span key="warnings" className="text-xs text-muted-foreground">{summary.warningCount} warnings</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div key="compare" className="mt-1 truncate font-mono text-xs text-muted-foreground">
|
||||
{compareLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key="actions" className="flex flex-wrap items-center gap-2">
|
||||
<div key="layout" className="inline-flex gap-1" aria-label="Diff layout">
|
||||
<button key="split" type="button" className={buttonClass(mode === "split")} onClick={() => setMode("split")}>
|
||||
Split
|
||||
</button>
|
||||
<button key="unified" type="button" className={buttonClass(mode === "unified")} onClick={() => setMode("unified")}>
|
||||
Unified
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
key="line-wrap"
|
||||
type="button"
|
||||
className={buttonClass(lineWrap)}
|
||||
onClick={() => setLineWrap((value) => !value)}
|
||||
title={lineWrap ? "Disable line wrapping" : "Enable line wrapping"}
|
||||
aria-pressed={lineWrap}
|
||||
>
|
||||
{lineWrap ? "Wrap on" : "Wrap lines"}
|
||||
</button>
|
||||
<div key="view" className="inline-flex gap-1" aria-label="Diff comparison">
|
||||
<button
|
||||
key="working-tree"
|
||||
type="button"
|
||||
className={buttonClass(effectiveView === "working-tree")}
|
||||
onClick={() => {
|
||||
viewTouchedRef.current = true;
|
||||
setView("working-tree");
|
||||
}}
|
||||
>
|
||||
Working tree
|
||||
</button>
|
||||
<button
|
||||
key="head"
|
||||
type="button"
|
||||
className={buttonClass(effectiveView === "head")}
|
||||
onClick={() => {
|
||||
viewTouchedRef.current = true;
|
||||
setView("head");
|
||||
}}
|
||||
>
|
||||
Against ref
|
||||
</button>
|
||||
</div>
|
||||
{view === "head" ? (
|
||||
<input
|
||||
key="base-ref"
|
||||
className="h-8 w-40 rounded-md border border-border bg-background px-2.5 font-mono text-xs outline-none transition-colors placeholder:text-muted-foreground focus:border-foreground/40"
|
||||
value={baseRef}
|
||||
onChange={(event) => {
|
||||
baseRefTouchedRef.current = true;
|
||||
setBaseRef(event.target.value);
|
||||
}}
|
||||
placeholder="origin/master"
|
||||
aria-label="Base ref"
|
||||
/>
|
||||
) : null}
|
||||
{view === "working-tree" ? (
|
||||
<button
|
||||
key="untracked"
|
||||
type="button"
|
||||
className={buttonClass(includeUntracked)}
|
||||
onClick={() => setIncludeUntracked((value) => !value)}
|
||||
>
|
||||
{includeUntracked ? "Untracked shown" : "Show untracked"}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
key="refresh"
|
||||
type="button"
|
||||
className={iconButtonClass(false)}
|
||||
onClick={() => refresh()}
|
||||
title="Refresh changes"
|
||||
aria-label="Refresh changes"
|
||||
>
|
||||
<RefreshCwIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<ErrorState message={error.message} onRetry={refresh} />
|
||||
) : files.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div key="content" className="flex flex-col gap-3 lg:h-[70vh] lg:min-h-[560px] lg:max-h-[820px] lg:flex-row">
|
||||
<aside
|
||||
key="files"
|
||||
className="relative flex min-w-0 flex-col border border-border bg-background lg:h-full lg:shrink-0 lg:overflow-hidden"
|
||||
style={fileSidebarStyle}
|
||||
>
|
||||
<div key="heading" className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Files
|
||||
</div>
|
||||
<div key="list" className="max-h-[70vh] overflow-auto lg:max-h-none lg:flex-1">
|
||||
{files.map((file, index) => (
|
||||
<FileRow
|
||||
key={`${file.path}:${index}`}
|
||||
file={file}
|
||||
active={file.path === selectedFile?.path}
|
||||
expanded={expandedFiles.has(file.path)}
|
||||
onSelect={() => selectFile(file.path)}
|
||||
onToggle={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
|
||||
onCopy={() => void copyPath(file.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize file list"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={MIN_FILE_SIDEBAR_WIDTH}
|
||||
aria-valuemax={MAX_FILE_SIDEBAR_WIDTH}
|
||||
aria-valuenow={fileSidebarWidth}
|
||||
tabIndex={0}
|
||||
className={[
|
||||
"absolute inset-y-0 right-0 z-20 hidden w-3 cursor-col-resize touch-none outline-none lg:block",
|
||||
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors",
|
||||
"hover:before:bg-border focus-visible:before:bg-ring",
|
||||
fileSidebarResizing ? "before:bg-ring" : "",
|
||||
].join(" ")}
|
||||
onPointerDown={handleFileSidebarPointerDown}
|
||||
onPointerMove={handleFileSidebarPointerMove}
|
||||
onPointerUp={endFileSidebarResize}
|
||||
onPointerCancel={endFileSidebarResize}
|
||||
onLostPointerCapture={endFileSidebarResize}
|
||||
onKeyDown={handleFileSidebarKeyDown}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
key="diffs"
|
||||
ref={diffScrollRef}
|
||||
className="max-h-[70vh] min-w-0 flex-1 space-y-3 overflow-auto lg:h-full lg:max-h-none lg:pr-1"
|
||||
onScroll={handleDiffScroll}
|
||||
>
|
||||
{files
|
||||
.map((file, index) => (
|
||||
<section
|
||||
key={`${file.path}:${index}`}
|
||||
ref={setFileSectionRef(file.path)}
|
||||
className={file.path === selectedFile?.path ? "scroll-mt-2" : undefined}
|
||||
>
|
||||
<div
|
||||
key="header"
|
||||
className="sticky top-0 z-30 flex min-w-0 items-center justify-between gap-3 border border-b-0 border-border bg-background px-3 py-2 shadow-sm"
|
||||
>
|
||||
<div key="left" className="flex min-w-0 items-start gap-2">
|
||||
<button
|
||||
key="collapse"
|
||||
type="button"
|
||||
className="mt-0.5 text-muted-foreground hover:text-foreground"
|
||||
title={expandedFiles.has(file.path) ? "Collapse file" : "Expand file"}
|
||||
aria-label={expandedFiles.has(file.path) ? `Collapse ${file.path}` : `Expand ${file.path}`}
|
||||
onClick={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
|
||||
>
|
||||
{expandedFiles.has(file.path) ? "−" : "+"}
|
||||
</button>
|
||||
<button
|
||||
key="select"
|
||||
type="button"
|
||||
className="min-w-0 text-left"
|
||||
onClick={() => selectFile(file.path)}
|
||||
>
|
||||
<div key="path" className="truncate text-sm font-medium">{file.path}</div>
|
||||
{file.oldPath ? (
|
||||
<div key="old-path" className="truncate font-mono text-[11px] text-muted-foreground">
|
||||
from {file.oldPath}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
<div key="actions" className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
key="copy"
|
||||
type="button"
|
||||
className={iconButtonClass(false)}
|
||||
title="Copy path"
|
||||
aria-label={`Copy ${file.path}`}
|
||||
onClick={() => void copyPath(file.path)}
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expandedFiles.has(file.path) ? (
|
||||
<FileDiffPanel key="diff" file={file} mode={mode} lineWrap={lineWrap} />
|
||||
) : (
|
||||
<CollapsedFilePanel
|
||||
key="collapsed"
|
||||
file={file}
|
||||
onExpand={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { definePlugin, runWorker, type PluginContext } from "@paperclipai/plugin-sdk";
|
||||
import { workspaceDiffQuerySchema } from "./contracts.js";
|
||||
import { workspaceDiffService } from "./workspace-diff.js";
|
||||
|
||||
const PLUGIN_NAME = "workspace-diff";
|
||||
|
||||
function readString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | null {
|
||||
const trimmed = readString(value);
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
export function resolveDefaultBaseRef(input: {
|
||||
workspaceBaseRef?: unknown;
|
||||
projectWorkspaceDefaultRef?: unknown;
|
||||
projectWorkspaceRepoRef?: unknown;
|
||||
}): string | null {
|
||||
return readOptionalString(input.workspaceBaseRef)
|
||||
?? readOptionalString(input.projectWorkspaceDefaultRef)
|
||||
?? readOptionalString(input.projectWorkspaceRepoRef);
|
||||
}
|
||||
|
||||
async function resolveProjectWorkspaceDefaultBaseRef(input: {
|
||||
ctx: PluginContext;
|
||||
projectId: string;
|
||||
companyId: string;
|
||||
projectWorkspaceId?: string | null;
|
||||
}): Promise<string | null> {
|
||||
if (!input.projectId) return null;
|
||||
const workspaces = await input.ctx.projects.listWorkspaces(input.projectId, input.companyId);
|
||||
const projectWorkspace = input.projectWorkspaceId
|
||||
? workspaces.find((candidate) => candidate.id === input.projectWorkspaceId)
|
||||
: workspaces.find((candidate) => candidate.isPrimary) ?? workspaces[0] ?? null;
|
||||
return projectWorkspace
|
||||
? resolveDefaultBaseRef({
|
||||
projectWorkspaceDefaultRef: projectWorkspace.defaultRef,
|
||||
projectWorkspaceRepoRef: projectWorkspace.repoRef,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
|
||||
const workspaceDiff = workspaceDiffService();
|
||||
|
||||
ctx.data.register("workspace-diff", async (params: Record<string, unknown>) => {
|
||||
const workspaceId = readString(params.workspaceId);
|
||||
const companyId = readString(params.companyId);
|
||||
if (!workspaceId || !companyId) {
|
||||
throw new Error("workspaceId and companyId are required");
|
||||
}
|
||||
|
||||
if (params.entityType === "project_workspace") {
|
||||
const projectId = readString(params.projectId);
|
||||
if (!projectId) {
|
||||
throw new Error("projectId is required for project workspace diffs");
|
||||
}
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
const workspace = workspaces.find((candidate) => candidate.id === workspaceId);
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found");
|
||||
}
|
||||
return workspaceDiff.getDiff({
|
||||
id: workspace.id,
|
||||
companyId,
|
||||
cwd: workspace.path,
|
||||
baseRef: resolveDefaultBaseRef({
|
||||
projectWorkspaceDefaultRef: workspace.defaultRef,
|
||||
projectWorkspaceRepoRef: workspace.repoRef,
|
||||
}),
|
||||
}, workspaceDiffQuerySchema.parse(params));
|
||||
}
|
||||
|
||||
const workspace = await ctx.executionWorkspaces.get(workspaceId, companyId);
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found");
|
||||
}
|
||||
let projectWorkspaceDefaultBaseRef: string | null = null;
|
||||
if (!readOptionalString(workspace.baseRef)) {
|
||||
projectWorkspaceDefaultBaseRef = await resolveProjectWorkspaceDefaultBaseRef({
|
||||
ctx,
|
||||
projectId: workspace.projectId || readString(params.projectId),
|
||||
companyId,
|
||||
projectWorkspaceId: workspace.projectWorkspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
return workspaceDiff.getDiff({
|
||||
...workspace,
|
||||
baseRef: resolveDefaultBaseRef({
|
||||
workspaceBaseRef: workspace.baseRef,
|
||||
projectWorkspaceDefaultRef: projectWorkspaceDefaultBaseRef,
|
||||
}),
|
||||
}, workspaceDiffQuerySchema.parse(params));
|
||||
});
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: `${PLUGIN_NAME} ready` };
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,845 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
WorkspaceDiffCaps,
|
||||
WorkspaceDiffFile,
|
||||
WorkspaceDiffFilePatch,
|
||||
WorkspaceDiffFileStatus,
|
||||
WorkspaceDiffPatchKind,
|
||||
WorkspaceDiffQueryOptions,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffWarning,
|
||||
WorkspaceDiffWarningCode,
|
||||
} from "./contracts.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const WORKSPACE_DIFF_CAPS: WorkspaceDiffCaps = {
|
||||
maxFiles: 200,
|
||||
maxFileBytes: 512 * 1024,
|
||||
maxPatchBytes: 256 * 1024,
|
||||
maxTotalPatchBytes: 1024 * 1024,
|
||||
};
|
||||
|
||||
const GIT_TIMEOUT_MS = 10_000;
|
||||
const GIT_LIST_MAX_BUFFER = 2 * 1024 * 1024;
|
||||
const OPEN_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0;
|
||||
|
||||
interface GitStatusEntry {
|
||||
status: WorkspaceDiffFileStatus;
|
||||
path: string;
|
||||
oldPath: string | null;
|
||||
}
|
||||
|
||||
type DiffScope = "staged" | "unstaged" | "head";
|
||||
|
||||
interface MutableWorkspaceDiffFile extends WorkspaceDiffFile {
|
||||
patchScopes: DiffScope[];
|
||||
}
|
||||
|
||||
interface PatchBudget {
|
||||
totalPatchBytes: number;
|
||||
}
|
||||
|
||||
type WorkspaceDiffTarget = Pick<PluginExecutionWorkspaceMetadata, "id" | "companyId" | "cwd" | "baseRef">;
|
||||
|
||||
function warning(code: WorkspaceDiffWarningCode, message: string, filePath: string | null = null): WorkspaceDiffWarning {
|
||||
return { code, message, path: filePath };
|
||||
}
|
||||
|
||||
function workspaceDiffError(code: WorkspaceDiffWarningCode, message: string, details: Record<string, unknown> = {}) {
|
||||
const error = new Error(message);
|
||||
Object.assign(error, { code, status: 422, details: { code, ...details } });
|
||||
return error;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
async function runGit(cwd: string, args: string[], maxBuffer = GIT_LIST_MAX_BUFFER) {
|
||||
try {
|
||||
return await execFileAsync("git", ["-C", cwd, ...args], {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer,
|
||||
});
|
||||
} catch (error) {
|
||||
const stderr = typeof (error as { stderr?: unknown }).stderr === "string"
|
||||
? String((error as { stderr?: unknown }).stderr).trim()
|
||||
: "";
|
||||
const message = stderr || toErrorMessage(error);
|
||||
throw workspaceDiffError("git_command_failed", message, { args });
|
||||
}
|
||||
}
|
||||
|
||||
async function realDirectory(value: string, code: WorkspaceDiffWarningCode) {
|
||||
if (!path.isAbsolute(value)) {
|
||||
throw workspaceDiffError(code, "Execution workspace path must be absolute", { cwd: value });
|
||||
}
|
||||
let stat: Awaited<ReturnType<typeof fs.stat>>;
|
||||
try {
|
||||
stat = await fs.stat(value);
|
||||
} catch {
|
||||
throw workspaceDiffError(code, "Execution workspace path does not exist", { cwd: value });
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
throw workspaceDiffError(code, "Execution workspace path is not a directory", { cwd: value });
|
||||
}
|
||||
return await fs.realpath(value);
|
||||
}
|
||||
|
||||
function isWithinDirectory(childPath: string, parentPath: string) {
|
||||
const relative = path.relative(parentPath, childPath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function resolveWorkspacePaths(workspace: WorkspaceDiffTarget) {
|
||||
if (!workspace.cwd?.trim()) {
|
||||
throw workspaceDiffError(
|
||||
"missing_cwd",
|
||||
"Execution workspace needs a local path before Paperclip can inspect diffs",
|
||||
{ workspaceId: workspace.id },
|
||||
);
|
||||
}
|
||||
|
||||
const cwd = await realDirectory(workspace.cwd.trim(), "workspace_path_invalid");
|
||||
let repoRoot: string;
|
||||
try {
|
||||
repoRoot = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
||||
} catch {
|
||||
throw workspaceDiffError(
|
||||
"non_git_workspace",
|
||||
"Execution workspace path is not inside a git repository",
|
||||
{ workspaceId: workspace.id, cwd },
|
||||
);
|
||||
}
|
||||
|
||||
const repoRootReal = await realDirectory(repoRoot, "non_git_workspace");
|
||||
if (!isWithinDirectory(cwd, repoRootReal)) {
|
||||
throw workspaceDiffError(
|
||||
"workspace_path_invalid",
|
||||
"Execution workspace path resolved outside its git repository",
|
||||
{ workspaceId: workspace.id, cwd, repoRoot: repoRootReal },
|
||||
);
|
||||
}
|
||||
|
||||
return { cwd, repoRoot: repoRootReal };
|
||||
}
|
||||
|
||||
function normalizePathFilter(rawPath: string) {
|
||||
const value = rawPath.trim().replaceAll("\\", "/");
|
||||
if (!value || value === ".") return null;
|
||||
if (value.includes("\0") || value.startsWith("/")) {
|
||||
throw workspaceDiffError("path_filter_invalid", "Path filters must be relative workspace paths", { path: rawPath });
|
||||
}
|
||||
const normalized = path.posix.normalize(value);
|
||||
if (
|
||||
normalized === "." ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
normalized.includes("/../")
|
||||
) {
|
||||
throw workspaceDiffError(
|
||||
"path_filter_invalid",
|
||||
"Path filters must not contain traversal segments",
|
||||
{ path: rawPath },
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePathFilters(paths: string[]) {
|
||||
return Array.from(new Set(paths.map(normalizePathFilter).filter((value): value is string => Boolean(value))));
|
||||
}
|
||||
|
||||
function statusFromGitStatus(status: string): WorkspaceDiffFileStatus {
|
||||
if (status.startsWith("R")) return "renamed";
|
||||
if (status.startsWith("C")) return "copied";
|
||||
switch (status[0]) {
|
||||
case "A":
|
||||
return "added";
|
||||
case "D":
|
||||
return "deleted";
|
||||
case "M":
|
||||
return "modified";
|
||||
case "T":
|
||||
return "type_changed";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function parseNameStatus(output: string): GitStatusEntry[] {
|
||||
const tokens = output.split("\0").filter(Boolean);
|
||||
const entries: GitStatusEntry[] = [];
|
||||
let index = 0;
|
||||
while (index < tokens.length) {
|
||||
const statusCode = tokens[index++] ?? "";
|
||||
if (!statusCode) continue;
|
||||
if (statusCode.startsWith("R") || statusCode.startsWith("C")) {
|
||||
const oldPath = tokens[index++] ?? "";
|
||||
const newPath = tokens[index++] ?? "";
|
||||
if (newPath) {
|
||||
entries.push({
|
||||
status: statusFromGitStatus(statusCode),
|
||||
path: newPath,
|
||||
oldPath: oldPath || null,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = tokens[index++] ?? "";
|
||||
if (filePath) {
|
||||
entries.push({
|
||||
status: statusFromGitStatus(statusCode),
|
||||
path: filePath,
|
||||
oldPath: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function readDiffNameStatus(cwd: string, scopeArgs: string[], paths: string[]) {
|
||||
const result = await runGit(cwd, [
|
||||
"diff",
|
||||
"--name-status",
|
||||
"-z",
|
||||
"--no-ext-diff",
|
||||
"--find-renames",
|
||||
...scopeArgs,
|
||||
"--",
|
||||
...paths,
|
||||
]);
|
||||
return parseNameStatus(result.stdout);
|
||||
}
|
||||
|
||||
async function readUntrackedPaths(cwd: string, paths: string[]) {
|
||||
const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...paths]);
|
||||
return result.stdout.split("\0").filter(Boolean);
|
||||
}
|
||||
|
||||
function ensureFile(
|
||||
files: Map<string, MutableWorkspaceDiffFile>,
|
||||
filePath: string,
|
||||
status: WorkspaceDiffFileStatus,
|
||||
oldPath: string | null,
|
||||
) {
|
||||
const existing = files.get(filePath);
|
||||
if (existing) {
|
||||
if (existing.status === "unknown" || status === "renamed" || status === "copied") {
|
||||
existing.status = status;
|
||||
}
|
||||
if (!existing.oldPath && oldPath) existing.oldPath = oldPath;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const file: MutableWorkspaceDiffFile = {
|
||||
path: filePath,
|
||||
oldPath,
|
||||
status,
|
||||
staged: false,
|
||||
unstaged: false,
|
||||
untracked: false,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
sizeBytes: null,
|
||||
patches: [],
|
||||
warnings: [],
|
||||
patchScopes: [],
|
||||
};
|
||||
files.set(filePath, file);
|
||||
return file;
|
||||
}
|
||||
|
||||
function addStatusEntries(
|
||||
files: Map<string, MutableWorkspaceDiffFile>,
|
||||
entries: GitStatusEntry[],
|
||||
scope: DiffScope,
|
||||
) {
|
||||
for (const entry of entries) {
|
||||
const file = ensureFile(files, entry.path, entry.status, entry.oldPath);
|
||||
if (scope === "staged") file.staged = true;
|
||||
else if (scope === "unstaged") file.unstaged = true;
|
||||
if (!file.patchScopes.includes(scope)) file.patchScopes.push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNumstat(output: string) {
|
||||
const line = output.split(/\r?\n/).find(Boolean);
|
||||
if (!line) return { additions: 0, deletions: 0, binary: false };
|
||||
const [additionsRaw, deletionsRaw] = line.split(/\t/);
|
||||
if (additionsRaw === "-" || deletionsRaw === "-") {
|
||||
return { additions: 0, deletions: 0, binary: true };
|
||||
}
|
||||
return {
|
||||
additions: Number.parseInt(additionsRaw ?? "0", 10) || 0,
|
||||
deletions: Number.parseInt(deletionsRaw ?? "0", 10) || 0,
|
||||
binary: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function readNumstat(cwd: string, scopeArgs: string[], filePath: string) {
|
||||
const result = await runGit(cwd, [
|
||||
"diff",
|
||||
"--numstat",
|
||||
"--no-ext-diff",
|
||||
"--find-renames",
|
||||
...scopeArgs,
|
||||
"--",
|
||||
filePath,
|
||||
], 128 * 1024);
|
||||
return parseNumstat(result.stdout);
|
||||
}
|
||||
|
||||
async function statWorkspaceFile(repoRoot: string, filePath: string) {
|
||||
const resolved = await resolveWorkspaceFilePath(repoRoot, filePath);
|
||||
if (resolved.status !== "ok") return null;
|
||||
let handle: Awaited<ReturnType<typeof fs.open>>;
|
||||
try {
|
||||
handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
return stat.isFile() ? stat.size : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveWorkspaceFilePath(repoRoot: string, filePath: string): Promise<
|
||||
| { status: "ok"; realPath: string }
|
||||
| { status: "missing" }
|
||||
| { status: "outside_workspace" }
|
||||
> {
|
||||
const target = path.resolve(repoRoot, filePath);
|
||||
if (!isWithinDirectory(target, repoRoot)) return { status: "outside_workspace" };
|
||||
try {
|
||||
const realPath = await fs.realpath(target);
|
||||
if (!isWithinDirectory(realPath, repoRoot)) return { status: "outside_workspace" };
|
||||
return { status: "ok", realPath };
|
||||
} catch {
|
||||
return { status: "missing" };
|
||||
}
|
||||
}
|
||||
|
||||
function isMaxBufferError(error: unknown) {
|
||||
return typeof error === "object"
|
||||
&& error !== null
|
||||
&& "code" in error
|
||||
&& (error as { code?: unknown }).code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
|
||||
}
|
||||
|
||||
async function readPatchOutput(cwd: string, args: string[]) {
|
||||
try {
|
||||
return await execFileAsync("git", ["-C", cwd, ...args], {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: WORKSPACE_DIFF_CAPS.maxPatchBytes + 64 * 1024,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isMaxBufferError(error)) {
|
||||
return null;
|
||||
}
|
||||
const stderr = typeof (error as { stderr?: unknown }).stderr === "string"
|
||||
? String((error as { stderr?: unknown }).stderr).trim()
|
||||
: "";
|
||||
throw workspaceDiffError("git_command_failed", stderr || toErrorMessage(error), { args });
|
||||
}
|
||||
}
|
||||
|
||||
function reservePatchBytes(
|
||||
patch: string,
|
||||
budget: PatchBudget,
|
||||
filePath: string,
|
||||
warnings: WorkspaceDiffWarning[],
|
||||
) {
|
||||
const patchBytes = Buffer.byteLength(patch, "utf8");
|
||||
if (patchBytes > WORKSPACE_DIFF_CAPS.maxPatchBytes) {
|
||||
warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", filePath));
|
||||
return null;
|
||||
}
|
||||
if (budget.totalPatchBytes + patchBytes > WORKSPACE_DIFF_CAPS.maxTotalPatchBytes) {
|
||||
warnings.push(warning("patch_truncated", "Workspace diff exceeded the total patch cap.", filePath));
|
||||
return null;
|
||||
}
|
||||
budget.totalPatchBytes += patchBytes;
|
||||
return patch;
|
||||
}
|
||||
|
||||
async function buildTrackedPatch(input: {
|
||||
cwd: string;
|
||||
repoRoot: string;
|
||||
filePath: string;
|
||||
kind: WorkspaceDiffPatchKind;
|
||||
scopeArgs: string[];
|
||||
budget: PatchBudget;
|
||||
}): Promise<WorkspaceDiffFilePatch> {
|
||||
const warnings: WorkspaceDiffWarning[] = [];
|
||||
const numstat = await readNumstat(input.cwd, input.scopeArgs, input.filePath);
|
||||
const sizeBytes = await statWorkspaceFile(input.repoRoot, input.filePath);
|
||||
|
||||
if (numstat.binary) {
|
||||
warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath));
|
||||
return {
|
||||
kind: input.kind,
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: true,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (sizeBytes !== null && sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) {
|
||||
warnings.push(warning("file_oversized", "File is too large to include a text patch.", input.filePath));
|
||||
return {
|
||||
kind: input.kind,
|
||||
patch: null,
|
||||
additions: numstat.additions,
|
||||
deletions: numstat.deletions,
|
||||
binary: false,
|
||||
oversized: true,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const patchOutput = await readPatchOutput(input.cwd, [
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--find-renames",
|
||||
"--unified=3",
|
||||
...input.scopeArgs,
|
||||
"--",
|
||||
input.filePath,
|
||||
]);
|
||||
if (!patchOutput) {
|
||||
warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", input.filePath));
|
||||
return {
|
||||
kind: input.kind,
|
||||
patch: null,
|
||||
additions: numstat.additions,
|
||||
deletions: numstat.deletions,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: true,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const patch = reservePatchBytes(patchOutput.stdout, input.budget, input.filePath, warnings);
|
||||
return {
|
||||
kind: input.kind,
|
||||
patch,
|
||||
additions: numstat.additions,
|
||||
deletions: numstat.deletions,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: patch === null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function isProbablyBinary(buffer: Buffer) {
|
||||
return buffer.subarray(0, Math.min(buffer.length, 8_000)).includes(0);
|
||||
}
|
||||
|
||||
function countAddedLines(content: string) {
|
||||
if (content.length === 0) return 0;
|
||||
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
|
||||
}
|
||||
|
||||
function buildUntrackedPatch(filePath: string, content: string) {
|
||||
const lines = content.length === 0 ? [] : content.split("\n");
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||
const lineCount = countAddedLines(content);
|
||||
const header = [
|
||||
`diff --git a/${filePath} b/${filePath}`,
|
||||
"new file mode 100644",
|
||||
"--- /dev/null",
|
||||
`+++ b/${filePath}`,
|
||||
];
|
||||
if (lineCount === 0) return `${header.join("\n")}\n`;
|
||||
const hunkLines = lines.map((line) => `+${line}`).join("\n");
|
||||
return [...header, `@@ -0,0 +1,${lineCount} @@`, hunkLines, ""].join("\n");
|
||||
}
|
||||
|
||||
async function buildUntrackedFilePatch(input: {
|
||||
repoRoot: string;
|
||||
filePath: string;
|
||||
budget: PatchBudget;
|
||||
}): Promise<WorkspaceDiffFilePatch> {
|
||||
const warnings: WorkspaceDiffWarning[] = [];
|
||||
const resolved = await resolveWorkspaceFilePath(input.repoRoot, input.filePath);
|
||||
if (resolved.status === "outside_workspace") {
|
||||
warnings.push(warning(
|
||||
"symlink_target_outside_workspace",
|
||||
"Untracked file resolves outside the workspace and is summarized without reading target bytes.",
|
||||
input.filePath,
|
||||
));
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
if (resolved.status === "missing") {
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
let handle: Awaited<ReturnType<typeof fs.open>>;
|
||||
try {
|
||||
handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW);
|
||||
} catch {
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
let sizeBytes: number;
|
||||
let buffer: Buffer | null = null;
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) {
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
sizeBytes = stat.size;
|
||||
if (sizeBytes <= WORKSPACE_DIFF_CAPS.maxFileBytes) {
|
||||
buffer = await handle.readFile();
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
if (sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) {
|
||||
warnings.push(warning("file_oversized", "Untracked file is too large to include a text patch.", input.filePath));
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: true,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (!buffer) {
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
if (isProbablyBinary(buffer)) {
|
||||
warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath));
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: true,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const content = buffer.toString("utf8");
|
||||
const patch = reservePatchBytes(buildUntrackedPatch(input.filePath, content), input.budget, input.filePath, warnings);
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch,
|
||||
additions: countAddedLines(content),
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: patch === null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function applyPatchToFile(file: MutableWorkspaceDiffFile, patch: WorkspaceDiffFilePatch, sizeBytes: number | null) {
|
||||
file.patches.push(patch);
|
||||
file.additions += patch.additions;
|
||||
file.deletions += patch.deletions;
|
||||
file.binary = file.binary || patch.binary;
|
||||
file.oversized = file.oversized || patch.oversized;
|
||||
file.truncated = file.truncated || patch.truncated;
|
||||
file.warnings.push(...patch.warnings);
|
||||
if (file.sizeBytes === null && sizeBytes !== null) file.sizeBytes = sizeBytes;
|
||||
}
|
||||
|
||||
function finalizeStats(files: WorkspaceDiffFile[]) {
|
||||
return {
|
||||
fileCount: files.length,
|
||||
stagedFileCount: files.filter((file) => file.staged).length,
|
||||
unstagedFileCount: files.filter((file) => file.unstaged).length,
|
||||
untrackedFileCount: files.filter((file) => file.untracked).length,
|
||||
binaryFileCount: files.filter((file) => file.binary).length,
|
||||
oversizedFileCount: files.filter((file) => file.oversized).length,
|
||||
truncatedFileCount: files.filter((file) => file.truncated).length,
|
||||
additions: files.reduce((sum, file) => sum + file.additions, 0),
|
||||
deletions: files.reduce((sum, file) => sum + file.deletions, 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveHeadSha(cwd: string) {
|
||||
try {
|
||||
return (await runGit(cwd, ["rev-parse", "HEAD"], 128 * 1024)).stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveVerifiedGitRef(cwd: string, refName: string) {
|
||||
const trimmed = refName.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${trimmed}^{commit}`], {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: 128 * 1024,
|
||||
});
|
||||
return trimmed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveGitUpstreamRef(cwd: string) {
|
||||
try {
|
||||
const upstream = (await execFileAsync(
|
||||
"git",
|
||||
["-C", cwd, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
||||
{
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: 128 * 1024,
|
||||
},
|
||||
)).stdout.trim();
|
||||
return upstream ? await resolveVerifiedGitRef(cwd, upstream) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInferredDefaultBaseRef(cwd: string) {
|
||||
const upstream = await resolveGitUpstreamRef(cwd);
|
||||
if (upstream) return upstream;
|
||||
|
||||
const candidates = ["origin/master", "origin/main", "master", "main"];
|
||||
const resolvedCandidates = await Promise.all(
|
||||
candidates.map((candidate) => resolveVerifiedGitRef(cwd, candidate)),
|
||||
);
|
||||
for (const resolved of resolvedCandidates) {
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveDefaultDiffBaseRef(cwd: string, workspace: WorkspaceDiffTarget) {
|
||||
return workspace.baseRef?.trim() || await resolveInferredDefaultBaseRef(cwd);
|
||||
}
|
||||
|
||||
async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: WorkspaceDiffTarget) {
|
||||
const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null;
|
||||
if (!resolvedBaseRef) {
|
||||
throw workspaceDiffError(
|
||||
"base_ref_missing",
|
||||
"A baseRef query parameter or execution workspace baseRef is required for head diffs",
|
||||
{ workspaceId: workspace.id },
|
||||
);
|
||||
}
|
||||
try {
|
||||
await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${resolvedBaseRef}^{commit}`], {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: 128 * 1024,
|
||||
});
|
||||
} catch {
|
||||
throw workspaceDiffError(
|
||||
"base_ref_invalid",
|
||||
`Could not resolve baseRef "${resolvedBaseRef}" in this workspace`,
|
||||
{ workspaceId: workspace.id, baseRef: resolvedBaseRef },
|
||||
);
|
||||
}
|
||||
return resolvedBaseRef;
|
||||
}
|
||||
|
||||
async function collectFiles(input: {
|
||||
cwd: string;
|
||||
workspace: WorkspaceDiffTarget;
|
||||
query: WorkspaceDiffQueryOptions;
|
||||
paths: string[];
|
||||
}) {
|
||||
const files = new Map<string, MutableWorkspaceDiffFile>();
|
||||
let baseRef: string | null = null;
|
||||
|
||||
if (input.query.view === "head") {
|
||||
baseRef = await resolveBaseRef(input.cwd, input.query.baseRef, input.workspace);
|
||||
addStatusEntries(
|
||||
files,
|
||||
await readDiffNameStatus(input.cwd, [`${baseRef}...HEAD`], input.paths),
|
||||
"head",
|
||||
);
|
||||
} else {
|
||||
addStatusEntries(files, await readDiffNameStatus(input.cwd, ["--cached"], input.paths), "staged");
|
||||
addStatusEntries(files, await readDiffNameStatus(input.cwd, [], input.paths), "unstaged");
|
||||
if (input.query.includeUntracked) {
|
||||
for (const untrackedPath of await readUntrackedPaths(input.cwd, input.paths)) {
|
||||
const file = ensureFile(files, untrackedPath, "untracked", null);
|
||||
file.untracked = true;
|
||||
if (!file.patchScopes.includes("unstaged")) file.patchScopes.push("unstaged");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { files, baseRef };
|
||||
}
|
||||
|
||||
export function workspaceDiffService() {
|
||||
return {
|
||||
async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise<WorkspaceDiffResponse> {
|
||||
const { cwd, repoRoot } = await resolveWorkspacePaths(workspace);
|
||||
const defaultBaseRef = await resolveDefaultDiffBaseRef(cwd, workspace);
|
||||
const workspaceWithDefaultBaseRef = { ...workspace, baseRef: defaultBaseRef };
|
||||
const paths = normalizePathFilters(query.paths);
|
||||
const warnings: WorkspaceDiffWarning[] = [];
|
||||
const { files: filesByPath, baseRef } = await collectFiles({
|
||||
cwd,
|
||||
workspace: workspaceWithDefaultBaseRef,
|
||||
query,
|
||||
paths,
|
||||
});
|
||||
const allFiles = Array.from(filesByPath.values()).sort((left, right) => left.path.localeCompare(right.path));
|
||||
const cappedFiles = allFiles.slice(0, WORKSPACE_DIFF_CAPS.maxFiles);
|
||||
if (allFiles.length > cappedFiles.length) {
|
||||
warnings.push(warning(
|
||||
"file_count_truncated",
|
||||
`Workspace diff includes ${allFiles.length} files, so only the first ${WORKSPACE_DIFF_CAPS.maxFiles} are returned.`,
|
||||
));
|
||||
}
|
||||
|
||||
const patchBudget: PatchBudget = { totalPatchBytes: 0 };
|
||||
for (const file of cappedFiles) {
|
||||
if (query.view === "head") {
|
||||
const patch = await buildTrackedPatch({
|
||||
cwd,
|
||||
repoRoot,
|
||||
filePath: file.path,
|
||||
kind: "head",
|
||||
scopeArgs: [`${baseRef}...HEAD`],
|
||||
budget: patchBudget,
|
||||
});
|
||||
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.staged) {
|
||||
const patch = await buildTrackedPatch({
|
||||
cwd,
|
||||
repoRoot,
|
||||
filePath: file.path,
|
||||
kind: "staged",
|
||||
scopeArgs: ["--cached"],
|
||||
budget: patchBudget,
|
||||
});
|
||||
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
||||
}
|
||||
if (file.unstaged) {
|
||||
const patch = await buildTrackedPatch({
|
||||
cwd,
|
||||
repoRoot,
|
||||
filePath: file.path,
|
||||
kind: "unstaged",
|
||||
scopeArgs: [],
|
||||
budget: patchBudget,
|
||||
});
|
||||
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
||||
}
|
||||
if (file.untracked) {
|
||||
const patch = await buildUntrackedFilePatch({
|
||||
repoRoot,
|
||||
filePath: file.path,
|
||||
budget: patchBudget,
|
||||
});
|
||||
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
||||
}
|
||||
}
|
||||
|
||||
const files = cappedFiles.map(({ patchScopes: _patchScopes, ...file }) => file);
|
||||
const patchWarnings = files.flatMap((file) => file.warnings);
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
companyId: workspace.companyId,
|
||||
view: query.view,
|
||||
baseRef,
|
||||
defaultBaseRef,
|
||||
headSha: await resolveHeadSha(cwd),
|
||||
includeUntracked: query.includeUntracked,
|
||||
paths,
|
||||
files,
|
||||
stats: finalizeStats(files),
|
||||
warnings: [...warnings, ...patchWarnings],
|
||||
caps: WORKSPACE_DIFF_CAPS,
|
||||
truncated: warnings.some((item) => item.code === "file_count_truncated")
|
||||
|| files.some((file) => file.truncated),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { workspaceDiffQuerySchema, workspaceDiffResponseSchema } from "../src/contracts.js";
|
||||
import { diffResponse } from "./fixtures.js";
|
||||
|
||||
describe("workspace diff plugin contracts", () => {
|
||||
it("normalizes query options from plugin data parameters", () => {
|
||||
expect(workspaceDiffQuerySchema.parse({
|
||||
view: "head",
|
||||
baseRef: " main ",
|
||||
includeUntracked: "false",
|
||||
path: ["src/app.ts, README.md", "packages/shared/src/index.ts"],
|
||||
})).toEqual({
|
||||
view: "head",
|
||||
baseRef: "main",
|
||||
includeUntracked: false,
|
||||
paths: ["src/app.ts", "README.md", "packages/shared/src/index.ts"],
|
||||
});
|
||||
});
|
||||
|
||||
it("validates the plugin-owned response shape", () => {
|
||||
expect(workspaceDiffResponseSchema.parse(diffResponse())).toMatchObject({
|
||||
workspaceId: "11111111-1111-4111-8111-111111111111",
|
||||
stats: { fileCount: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFilePatch,
|
||||
buildFilePatches,
|
||||
diffSummary,
|
||||
initialExpandedFileSet,
|
||||
LONG_DIFF_LINE_THRESHOLD,
|
||||
nextExpandedFileSet,
|
||||
statusLabel,
|
||||
toFileViewModels,
|
||||
} from "../src/diff-model.js";
|
||||
import { changedFile, diffResponse } from "./fixtures.js";
|
||||
|
||||
describe("workspace diff UI model", () => {
|
||||
it("summarizes changed files and line counts", () => {
|
||||
const diff = diffResponse();
|
||||
|
||||
expect(diffSummary(diff)).toMatchObject({
|
||||
changedLabel: "1 file",
|
||||
lineLabel: "+1 / -1",
|
||||
warningCount: 0,
|
||||
truncated: false,
|
||||
});
|
||||
expect(toFileViewModels(diff)[0]).toMatchObject({
|
||||
path: "src/app.ts",
|
||||
status: "modified",
|
||||
patchKinds: ["unstaged"],
|
||||
lineCount: 7,
|
||||
longDiff: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("represents empty workspace diffs", () => {
|
||||
const diff = diffResponse({ files: [] });
|
||||
|
||||
expect(toFileViewModels(diff)).toEqual([]);
|
||||
expect(diffSummary(diff).changedLabel).toBe("0 files");
|
||||
});
|
||||
|
||||
it("surfaces truncation and file warnings", () => {
|
||||
const warning = { code: "patch_truncated" as const, message: "Patch was truncated.", path: "src/app.ts" };
|
||||
const file = changedFile({
|
||||
truncated: true,
|
||||
warnings: [warning],
|
||||
patches: [],
|
||||
});
|
||||
const diff = diffResponse({ files: [file], truncated: true, warnings: [warning] });
|
||||
|
||||
expect(buildFilePatch(file)).toBeNull();
|
||||
expect(toFileViewModels(diff)[0]?.warnings).toEqual([warning]);
|
||||
expect(diffSummary(diff)).toMatchObject({
|
||||
warningCount: 1,
|
||||
truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not duplicate aggregated patch warnings", () => {
|
||||
const warning = { code: "patch_truncated" as const, message: "Patch was truncated.", path: "src/app.ts" };
|
||||
const file = changedFile({
|
||||
warnings: [warning],
|
||||
patches: [
|
||||
{
|
||||
kind: "unstaged",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: true,
|
||||
warnings: [warning],
|
||||
},
|
||||
],
|
||||
});
|
||||
const diff = diffResponse({ files: [file], warnings: [warning] });
|
||||
|
||||
expect(toFileViewModels(diff)[0]?.warnings).toEqual([warning]);
|
||||
expect(diffSummary(diff).warningCount).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps staged and unstaged patches renderable as separate single-file diffs", () => {
|
||||
const stagedPatch = [
|
||||
"diff --git a/src/app.ts b/src/app.ts",
|
||||
"index 1111111..2222222 100644",
|
||||
"--- a/src/app.ts",
|
||||
"+++ b/src/app.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-export const value = 1;",
|
||||
"+export const value = 2;",
|
||||
"",
|
||||
].join("\n");
|
||||
const unstagedPatch = [
|
||||
"diff --git a/src/app.ts b/src/app.ts",
|
||||
"index 2222222..3333333 100644",
|
||||
"--- a/src/app.ts",
|
||||
"+++ b/src/app.ts",
|
||||
"@@ -3 +3 @@",
|
||||
"-export const label = 'old';",
|
||||
"+export const label = 'new';",
|
||||
"",
|
||||
].join("\n");
|
||||
const file = changedFile({
|
||||
staged: true,
|
||||
unstaged: true,
|
||||
patches: [
|
||||
{
|
||||
kind: "staged",
|
||||
patch: stagedPatch,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings: [],
|
||||
},
|
||||
{
|
||||
kind: "unstaged",
|
||||
patch: unstagedPatch,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const patches = buildFilePatches(file);
|
||||
const viewModel = toFileViewModels(diffResponse({ files: [file] }))[0];
|
||||
|
||||
expect(buildFilePatch(file)).toBe(stagedPatch.trimEnd());
|
||||
expect(patches.map((patch) => patch.kind)).toEqual(["staged", "unstaged"]);
|
||||
expect(patches.map((patch) => patch.patch?.match(/^diff --git/gm)?.length ?? 0)).toEqual([1, 1]);
|
||||
expect(viewModel?.patches).toHaveLength(2);
|
||||
expect(viewModel?.patchKinds).toEqual(["staged", "unstaged"]);
|
||||
});
|
||||
|
||||
it("marks long text diffs so the UI can fold them by default", () => {
|
||||
const longPatch = [
|
||||
"diff --git a/src/large.ts b/src/large.ts",
|
||||
"index 1111111..2222222 100644",
|
||||
"--- a/src/large.ts",
|
||||
"+++ b/src/large.ts",
|
||||
"@@ -1,1 +1,1 @@",
|
||||
...Array.from({ length: LONG_DIFF_LINE_THRESHOLD }, (_, index) => `+export const value${index} = ${index};`),
|
||||
"",
|
||||
].join("\n");
|
||||
const files = toFileViewModels(diffResponse({
|
||||
files: [
|
||||
changedFile({ path: "src/small.ts" }),
|
||||
changedFile({
|
||||
path: "src/large.ts",
|
||||
additions: LONG_DIFF_LINE_THRESHOLD,
|
||||
deletions: 0,
|
||||
patches: [
|
||||
{
|
||||
kind: "unstaged",
|
||||
patch: longPatch,
|
||||
additions: LONG_DIFF_LINE_THRESHOLD,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}));
|
||||
const longFile = files.find((file) => file.path === "src/large.ts");
|
||||
const defaultExpanded = initialExpandedFileSet(files);
|
||||
|
||||
expect(longFile?.lineCount).toBeGreaterThan(LONG_DIFF_LINE_THRESHOLD);
|
||||
expect(longFile?.longDiff).toBe(true);
|
||||
expect(defaultExpanded.has("src/small.ts")).toBe(true);
|
||||
expect(defaultExpanded.has("src/large.ts")).toBe(false);
|
||||
});
|
||||
|
||||
it("toggles expanded file state without mutating the current set", () => {
|
||||
const current = new Set(["a.ts"]);
|
||||
const collapsed = nextExpandedFileSet(current, "a.ts");
|
||||
const expanded = nextExpandedFileSet(current, "b.ts");
|
||||
|
||||
expect(current.has("a.ts")).toBe(true);
|
||||
expect(collapsed.has("a.ts")).toBe(false);
|
||||
expect(expanded.has("b.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("labels file statuses for the sidebar", () => {
|
||||
expect(statusLabel("untracked")).toBe("Untracked");
|
||||
expect(statusLabel("type_changed")).toBe("Type changed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "../src/contracts.js";
|
||||
|
||||
export function changedFile(overrides: Partial<WorkspaceDiffFile> = {}): WorkspaceDiffFile {
|
||||
return {
|
||||
path: "src/app.ts",
|
||||
oldPath: null,
|
||||
status: "modified",
|
||||
staged: false,
|
||||
unstaged: true,
|
||||
untracked: false,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
sizeBytes: 120,
|
||||
patches: [
|
||||
{
|
||||
kind: "unstaged",
|
||||
patch: [
|
||||
"diff --git a/src/app.ts b/src/app.ts",
|
||||
"index 1111111..2222222 100644",
|
||||
"--- a/src/app.ts",
|
||||
"+++ b/src/app.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-export const value = 1;",
|
||||
"+export const value = 2;",
|
||||
"",
|
||||
].join("\n"),
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings: [],
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function diffResponse(overrides: Partial<WorkspaceDiffResponse> = {}): WorkspaceDiffResponse {
|
||||
const files = overrides.files ?? [changedFile()];
|
||||
const additions = files.reduce((sum, file) => sum + file.additions, 0);
|
||||
const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
|
||||
return {
|
||||
workspaceId: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "22222222-2222-4222-8222-222222222222",
|
||||
view: "working-tree",
|
||||
baseRef: null,
|
||||
defaultBaseRef: null,
|
||||
headSha: null,
|
||||
includeUntracked: true,
|
||||
paths: [],
|
||||
files,
|
||||
stats: {
|
||||
fileCount: files.length,
|
||||
stagedFileCount: files.filter((file) => file.staged).length,
|
||||
unstagedFileCount: files.filter((file) => file.unstaged).length,
|
||||
untrackedFileCount: files.filter((file) => file.untracked).length,
|
||||
binaryFileCount: files.filter((file) => file.binary).length,
|
||||
oversizedFileCount: files.filter((file) => file.oversized).length,
|
||||
truncatedFileCount: files.filter((file) => file.truncated).length,
|
||||
additions,
|
||||
deletions,
|
||||
},
|
||||
warnings: [],
|
||||
caps: {
|
||||
maxFiles: 200,
|
||||
maxFileBytes: 524288,
|
||||
maxPatchBytes: 131072,
|
||||
maxTotalPatchBytes: 1048576,
|
||||
},
|
||||
truncated: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest from "../src/manifest.js";
|
||||
import plugin, { resolveDefaultBaseRef } from "../src/worker.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function git(cwd: string, args: string[]) {
|
||||
return execFileAsync("git", ["-C", cwd, ...args], { cwd });
|
||||
}
|
||||
|
||||
async function createGitWorkspace() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-diff-plugin-"));
|
||||
tempRoots.push(root);
|
||||
await fs.mkdir(path.join(root, "src"), { recursive: true });
|
||||
await git(root, ["init"]);
|
||||
await git(root, ["config", "user.email", "paperclip@example.com"]);
|
||||
await git(root, ["config", "user.name", "Paperclip Test"]);
|
||||
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 1;\n");
|
||||
await git(root, ["add", "src/app.ts"]);
|
||||
await git(root, ["commit", "-m", "initial"]);
|
||||
await git(root, ["branch", "-M", "main"]);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("workspace diff plugin", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
it("declares workspace Changes tabs and workspace read capabilities", () => {
|
||||
expect(manifest.capabilities).toContain("ui.detailTab.register");
|
||||
expect(manifest.capabilities).toContain("execution.workspaces.read");
|
||||
expect(manifest.capabilities).toContain("project.workspaces.read");
|
||||
expect(manifest.ui?.slots).toContainEqual(expect.objectContaining({
|
||||
type: "detailTab",
|
||||
displayName: "Changes",
|
||||
entityTypes: ["execution_workspace", "project_workspace"],
|
||||
}));
|
||||
});
|
||||
|
||||
it("fetches changed execution workspace diffs from host metadata", async () => {
|
||||
const root = await createGitWorkspace();
|
||||
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 2;\n");
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.seed({
|
||||
executionWorkspaces: [{
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: null,
|
||||
path: root,
|
||||
cwd: root,
|
||||
repoUrl: null,
|
||||
baseRef: "HEAD",
|
||||
branchName: "main",
|
||||
providerType: "git_worktree",
|
||||
providerMetadata: null,
|
||||
}],
|
||||
});
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const result = await harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
view: "working-tree",
|
||||
includeUntracked: false,
|
||||
paths: ["src/app.ts"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
stats: { fileCount: 1 },
|
||||
files: [expect.objectContaining({ path: "src/app.ts" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty diff when the workspace has no changes", async () => {
|
||||
const root = await createGitWorkspace();
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.seed({
|
||||
executionWorkspaces: [{
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: null,
|
||||
path: root,
|
||||
cwd: root,
|
||||
repoUrl: null,
|
||||
baseRef: "HEAD",
|
||||
branchName: "main",
|
||||
providerType: "git_worktree",
|
||||
providerMetadata: null,
|
||||
}],
|
||||
});
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await expect(harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
})).resolves.toMatchObject({ files: [], truncated: false });
|
||||
});
|
||||
|
||||
it("fetches project workspace diffs from generic project workspace metadata", async () => {
|
||||
const root = await createGitWorkspace();
|
||||
await git(root, ["checkout", "-b", "feature"]);
|
||||
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 3;\n");
|
||||
await git(root, ["add", "src/app.ts"]);
|
||||
await git(root, ["commit", "-m", "project workspace change"]);
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.ctx.projects.listWorkspaces = async (projectId, companyId) => {
|
||||
expect(projectId).toBe("project-1");
|
||||
expect(companyId).toBe("company-1");
|
||||
return [{
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
name: "Primary",
|
||||
path: root,
|
||||
repoUrl: null,
|
||||
repoRef: "feature",
|
||||
defaultRef: "main",
|
||||
isPrimary: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}];
|
||||
};
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const result = await harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
entityType: "project_workspace",
|
||||
view: "head",
|
||||
includeUntracked: false,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
baseRef: "main",
|
||||
defaultBaseRef: "main",
|
||||
stats: { fileCount: 1 },
|
||||
files: [expect.objectContaining({ path: "src/app.ts" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the default base ref from workspace and project workspace metadata", () => {
|
||||
expect(resolveDefaultBaseRef({
|
||||
workspaceBaseRef: " release/main ",
|
||||
projectWorkspaceDefaultRef: "origin/main",
|
||||
projectWorkspaceRepoRef: "feature",
|
||||
})).toBe("release/main");
|
||||
expect(resolveDefaultBaseRef({
|
||||
workspaceBaseRef: null,
|
||||
projectWorkspaceDefaultRef: " origin/main ",
|
||||
projectWorkspaceRepoRef: "feature",
|
||||
})).toBe("origin/main");
|
||||
expect(resolveDefaultBaseRef({
|
||||
workspaceBaseRef: "",
|
||||
projectWorkspaceDefaultRef: null,
|
||||
projectWorkspaceRepoRef: " feature ",
|
||||
})).toBe("feature");
|
||||
expect(resolveDefaultBaseRef({
|
||||
workspaceBaseRef: "",
|
||||
projectWorkspaceDefaultRef: null,
|
||||
projectWorkspaceRepoRef: "",
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("uses project workspace default refs for execution workspace head diffs", async () => {
|
||||
const root = await createGitWorkspace();
|
||||
await git(root, ["checkout", "-b", "feature"]);
|
||||
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 4;\n");
|
||||
await git(root, ["add", "src/app.ts"]);
|
||||
await git(root, ["commit", "-m", "feature change"]);
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.seed({
|
||||
executionWorkspaces: [{
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
path: root,
|
||||
cwd: root,
|
||||
repoUrl: null,
|
||||
baseRef: null,
|
||||
branchName: "feature",
|
||||
providerType: "git_worktree",
|
||||
providerMetadata: null,
|
||||
}],
|
||||
});
|
||||
harness.ctx.projects.listWorkspaces = async (projectId, companyId) => {
|
||||
expect(projectId).toBe("project-1");
|
||||
expect(companyId).toBe("company-1");
|
||||
return [{
|
||||
id: "project-workspace-1",
|
||||
projectId: "project-1",
|
||||
name: "Primary",
|
||||
path: root,
|
||||
repoUrl: null,
|
||||
repoRef: "feature",
|
||||
defaultRef: "main",
|
||||
isPrimary: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}];
|
||||
};
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const result = await harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
view: "head",
|
||||
includeUntracked: false,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
baseRef: "main",
|
||||
defaultBaseRef: "main",
|
||||
stats: { fileCount: 1 },
|
||||
files: [expect.objectContaining({ path: "src/app.ts" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the primary project workspace default ref when execution workspace has no workspace link", async () => {
|
||||
const root = await createGitWorkspace();
|
||||
await git(root, ["checkout", "-b", "feature"]);
|
||||
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 5;\n");
|
||||
await git(root, ["add", "src/app.ts"]);
|
||||
await git(root, ["commit", "-m", "feature change"]);
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.seed({
|
||||
executionWorkspaces: [{
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: null,
|
||||
path: root,
|
||||
cwd: root,
|
||||
repoUrl: null,
|
||||
baseRef: null,
|
||||
branchName: "feature",
|
||||
providerType: "git_worktree",
|
||||
providerMetadata: null,
|
||||
}],
|
||||
});
|
||||
harness.ctx.projects.listWorkspaces = async (projectId, companyId) => {
|
||||
expect(projectId).toBe("project-1");
|
||||
expect(companyId).toBe("company-1");
|
||||
return [{
|
||||
id: "project-workspace-1",
|
||||
projectId: "project-1",
|
||||
name: "Primary",
|
||||
path: root,
|
||||
repoUrl: null,
|
||||
repoRef: "feature",
|
||||
defaultRef: "main",
|
||||
isPrimary: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}];
|
||||
};
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const result = await harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
view: "head",
|
||||
baseRef: null,
|
||||
includeUntracked: false,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
baseRef: "main",
|
||||
defaultBaseRef: "main",
|
||||
stats: { fileCount: 1 },
|
||||
files: [expect.objectContaining({ path: "src/app.ts" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("infers the default base ref from the execution workspace branch upstream", async () => {
|
||||
const root = await createGitWorkspace();
|
||||
await git(root, ["update-ref", "refs/remotes/origin/master", "HEAD"]);
|
||||
await git(root, ["checkout", "-b", "feature"]);
|
||||
await git(root, ["config", "branch.feature.remote", "origin"]);
|
||||
await git(root, ["config", "branch.feature.merge", "refs/heads/master"]);
|
||||
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 6;\n");
|
||||
await git(root, ["add", "src/app.ts"]);
|
||||
await git(root, ["commit", "-m", "feature change"]);
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.seed({
|
||||
executionWorkspaces: [{
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: null,
|
||||
path: root,
|
||||
cwd: root,
|
||||
repoUrl: null,
|
||||
baseRef: null,
|
||||
branchName: "feature",
|
||||
providerType: "git_worktree",
|
||||
providerMetadata: null,
|
||||
}],
|
||||
});
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await expect(harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
view: "working-tree",
|
||||
includeUntracked: false,
|
||||
})).resolves.toMatchObject({
|
||||
baseRef: null,
|
||||
defaultBaseRef: "origin/master",
|
||||
stats: { fileCount: 0 },
|
||||
});
|
||||
|
||||
await expect(harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
view: "head",
|
||||
baseRef: null,
|
||||
includeUntracked: false,
|
||||
})).resolves.toMatchObject({
|
||||
baseRef: "origin/master",
|
||||
defaultBaseRef: "origin/master",
|
||||
stats: { fileCount: 1 },
|
||||
files: [expect.objectContaining({ path: "src/app.ts" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a clear bridge error when required context is missing", async () => {
|
||||
const harness = createTestHarness({ manifest });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await expect(harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
})).rejects.toThrow("workspaceId and companyId are required");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ErrorState } from "../src/ui/index.js";
|
||||
|
||||
describe("workspace diff error state", () => {
|
||||
it("keeps bridge error details out of the primary headline", () => {
|
||||
const rawError = "Execution workspace not found";
|
||||
const html = renderToStaticMarkup(createElement(ErrorState, {
|
||||
message: rawError,
|
||||
onRetry: () => undefined,
|
||||
}));
|
||||
|
||||
expect(html).toContain("Unable to load workspace changes.");
|
||||
expect(html).toContain("Retry");
|
||||
expect(html).toContain("Troubleshooting details");
|
||||
expect(html).not.toContain(`font-medium text-foreground">${rawError}`);
|
||||
expect(html.indexOf(rawError)).toBeGreaterThan(html.indexOf("Troubleshooting details"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk";
|
||||
import type { WorkspaceDiffQueryOptions } from "../src/contracts.js";
|
||||
import { WORKSPACE_DIFF_CAPS, workspaceDiffService } from "../src/workspace-diff.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
async function runGit(cwd: string, args: string[]) {
|
||||
await execFileAsync("git", ["-C", cwd, ...args], { cwd });
|
||||
}
|
||||
|
||||
async function createTempRepo() {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-"));
|
||||
tempDirs.add(repoRoot);
|
||||
await runGit(repoRoot, ["init"]);
|
||||
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
||||
await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]);
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "delete-me.txt"), "charlie\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "rename-me.txt"), "delta\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3]));
|
||||
await runGit(repoRoot, ["add", "."]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
|
||||
await runGit(repoRoot, ["branch", "-M", "main"]);
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function createWorkspace(cwd: string | null, overrides: Partial<PluginExecutionWorkspaceMetadata> = {}): PluginExecutionWorkspaceMetadata {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
companyId: randomUUID(),
|
||||
projectId: randomUUID(),
|
||||
projectWorkspaceId: null,
|
||||
path: cwd,
|
||||
cwd,
|
||||
repoUrl: null,
|
||||
baseRef: null,
|
||||
branchName: "feature",
|
||||
providerType: "git_worktree",
|
||||
providerMetadata: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function workingTreeQuery(overrides: Partial<WorkspaceDiffQueryOptions> = {}): WorkspaceDiffQueryOptions {
|
||||
return {
|
||||
view: "working-tree",
|
||||
baseRef: null,
|
||||
includeUntracked: true,
|
||||
paths: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
});
|
||||
|
||||
describe("plugin workspace diff service", () => {
|
||||
it("returns staged, unstaged, renamed, deleted, untracked, binary, and oversized working-tree changes", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\nstaged\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "tracked-staged.txt"]);
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\nunstaged\n", "utf8");
|
||||
await runGit(repoRoot, ["mv", "rename-me.txt", "renamed.txt"]);
|
||||
await fs.rm(path.join(repoRoot, "delete-me.txt"));
|
||||
await fs.writeFile(path.join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3, 4, 5]));
|
||||
await fs.writeFile(path.join(repoRoot, "untracked.txt"), "brand new\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "empty-untracked.txt"), "", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "oversized.txt"), "x".repeat(WORKSPACE_DIFF_CAPS.maxFileBytes + 1), "utf8");
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
|
||||
const byPath = new Map(diff.files.map((file) => [file.path, file]));
|
||||
|
||||
expect(diff.view).toBe("working-tree");
|
||||
expect(byPath.get("tracked-staged.txt")).toMatchObject({ staged: true, unstaged: false, status: "modified", additions: 1 });
|
||||
expect(byPath.get("tracked-staged.txt")?.patches.map((patch) => patch.kind)).toEqual(["staged"]);
|
||||
expect(byPath.get("tracked-unstaged.txt")).toMatchObject({ staged: false, unstaged: true, status: "modified", additions: 1 });
|
||||
expect(byPath.get("renamed.txt")).toMatchObject({ oldPath: "rename-me.txt", staged: true, status: "renamed" });
|
||||
expect(byPath.get("delete-me.txt")).toMatchObject({ unstaged: true, status: "deleted", deletions: 1 });
|
||||
expect(byPath.get("untracked.txt")).toMatchObject({ untracked: true, status: "untracked", additions: 1 });
|
||||
expect(byPath.get("untracked.txt")?.patches[0]?.patch).toContain("+brand new");
|
||||
expect(byPath.get("empty-untracked.txt")?.patches[0]?.patch).toBe([
|
||||
"diff --git a/empty-untracked.txt b/empty-untracked.txt",
|
||||
"new file mode 100644",
|
||||
"--- /dev/null",
|
||||
"+++ b/empty-untracked.txt",
|
||||
"",
|
||||
].join("\n"));
|
||||
expect(byPath.get("binary.bin")).toMatchObject({ binary: true, unstaged: true });
|
||||
expect(byPath.get("oversized.txt")).toMatchObject({ oversized: true, untracked: true });
|
||||
expect(diff.warnings.map((item) => item.code)).toEqual(expect.arrayContaining(["binary_file", "file_oversized"]));
|
||||
}, 20_000);
|
||||
|
||||
it("returns head diffs against the requested base ref", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await runGit(repoRoot, ["checkout", "-b", "feature"]);
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\ncommitted\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "tracked-staged.txt"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Feature change"]);
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(
|
||||
createWorkspace(repoRoot, { baseRef: "main" }),
|
||||
workingTreeQuery({ view: "head", includeUntracked: false }),
|
||||
);
|
||||
|
||||
expect(diff.baseRef).toBe("main");
|
||||
expect(diff.files).toHaveLength(1);
|
||||
expect(diff.files[0]).toMatchObject({
|
||||
path: "tracked-staged.txt",
|
||||
staged: false,
|
||||
unstaged: false,
|
||||
untracked: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
});
|
||||
expect(diff.files[0]?.patches.map((patch) => patch.kind)).toEqual(["head"]);
|
||||
}, 20_000);
|
||||
|
||||
it("filters changed files by relative workspace paths", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\none\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\ntwo\n", "utf8");
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(
|
||||
createWorkspace(repoRoot),
|
||||
workingTreeQuery({ paths: ["tracked-staged.txt"] }),
|
||||
);
|
||||
|
||||
expect(diff.paths).toEqual(["tracked-staged.txt"]);
|
||||
expect(diff.files.map((file) => file.path)).toEqual(["tracked-staged.txt"]);
|
||||
}, 20_000);
|
||||
|
||||
it("applies output caps to large workspace responses", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
for (let index = 0; index < WORKSPACE_DIFF_CAPS.maxFiles + 1; index += 1) {
|
||||
await fs.writeFile(path.join(repoRoot, `untracked-${String(index).padStart(3, "0")}.txt`), "", "utf8");
|
||||
}
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
|
||||
|
||||
expect(diff.files).toHaveLength(WORKSPACE_DIFF_CAPS.maxFiles);
|
||||
expect(diff.truncated).toBe(true);
|
||||
expect(diff.warnings).toContainEqual(expect.objectContaining({ code: "file_count_truncated" }));
|
||||
}, 20_000);
|
||||
|
||||
it("does not follow untracked symlinks outside the repo", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-secret-"));
|
||||
tempDirs.add(outsideDir);
|
||||
const secretContent = "external secret should not appear\n";
|
||||
const secretPath = path.join(outsideDir, "secret.txt");
|
||||
await fs.writeFile(secretPath, secretContent, "utf8");
|
||||
await fs.symlink(secretPath, path.join(repoRoot, "leak.txt"));
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
|
||||
const leak = diff.files.find((file) => file.path === "leak.txt");
|
||||
const serialized = JSON.stringify(diff);
|
||||
|
||||
expect(leak).toMatchObject({ untracked: true, status: "untracked", additions: 0, sizeBytes: null });
|
||||
expect(leak?.patches[0]).toMatchObject({
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
warnings: [expect.objectContaining({ code: "symlink_target_outside_workspace" })],
|
||||
});
|
||||
expect(diff.warnings).toContainEqual(expect.objectContaining({
|
||||
code: "symlink_target_outside_workspace",
|
||||
path: "leak.txt",
|
||||
}));
|
||||
expect(serialized).not.toContain(secretContent.trim());
|
||||
}, 20_000);
|
||||
|
||||
it("surfaces missing cwd, non-git, invalid base refs, and unsafe path filters as plugin errors", async () => {
|
||||
const svc = workspaceDiffService();
|
||||
await expect(svc.getDiff(createWorkspace(null), workingTreeQuery()))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "missing_cwd" } });
|
||||
|
||||
const nonGitDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-non-git-"));
|
||||
tempDirs.add(nonGitDir);
|
||||
await expect(svc.getDiff(createWorkspace(nonGitDir), workingTreeQuery()))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "non_git_workspace" } });
|
||||
|
||||
const repoRoot = await createTempRepo();
|
||||
await expect(svc.getDiff(createWorkspace(repoRoot), workingTreeQuery({ paths: ["../secret"] })))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "path_filter_invalid" } });
|
||||
await expect(svc.getDiff(createWorkspace(repoRoot), workingTreeQuery({ view: "head", baseRef: "missing-ref" })))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "base_ref_invalid" } });
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -423,6 +423,17 @@ export async function handleBridgeRequest(request: Request, env: BridgeEnv): Pro
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
// Heartbeat keeps the SSE response alive during silent stretches
|
||||
// (e.g. npm install downloading silently). SSE comment lines (`:`)
|
||||
// are ignored by the client parser but keep the underlying HTTP
|
||||
// connection from idling out at the Cloudflare edge.
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
} catch {
|
||||
// Controller may already be closed; ignore.
|
||||
}
|
||||
}, 15_000);
|
||||
try {
|
||||
const result = await executeInSandbox({
|
||||
sandbox,
|
||||
@@ -444,6 +455,7 @@ export async function handleBridgeRequest(request: Request, env: BridgeEnv): Pro
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})));
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{
|
||||
"class_name": "Sandbox",
|
||||
"image": "./Dockerfile",
|
||||
"instance_type": "lite",
|
||||
"instance_type": "standard-2",
|
||||
"max_instances": 10
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { CloudflareDriverConfig } from "./types.js";
|
||||
|
||||
const DEFAULT_REQUESTED_CWD = "/workspace/paperclip";
|
||||
const DEFAULT_SLEEP_AFTER = "10m";
|
||||
const DEFAULT_SLEEP_AFTER = "1h";
|
||||
const DEFAULT_TIMEOUT_MS = 300_000;
|
||||
const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 300_000;
|
||||
const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
function readTrimmedString(value: unknown): string | null {
|
||||
|
||||
@@ -49,8 +49,9 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
},
|
||||
sleepAfter: {
|
||||
type: "string",
|
||||
default: "10m",
|
||||
description: "Idle timeout passed to getSandbox(). Ignored when keepAlive is true.",
|
||||
default: "1h",
|
||||
description:
|
||||
"Idle timeout passed to getSandbox() on lease creation. Defaults to 1 hour so a fresh sandbox survives normal Claude/Codex heartbeats. Ignored when keepAlive is true.",
|
||||
},
|
||||
normalizeId: {
|
||||
type: "boolean",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import plugin from "./plugin.js";
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
let plugin: typeof import("./plugin.js").default;
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
@@ -23,9 +23,11 @@ function requestBodyAt(index = 0): Record<string, unknown> {
|
||||
}
|
||||
|
||||
describe("Cloudflare sandbox provider plugin", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.resetModules();
|
||||
plugin = (await import("./plugin.js")).default;
|
||||
});
|
||||
|
||||
it("declares the Cloudflare environment lifecycle handlers", async () => {
|
||||
@@ -60,7 +62,7 @@ describe("Cloudflare sandbox provider plugin", () => {
|
||||
bridgeAuthToken: "secret-ref://bridge-token",
|
||||
reuseLease: true,
|
||||
keepAlive: true,
|
||||
sleepAfter: "10m",
|
||||
sleepAfter: "1h",
|
||||
normalizeId: false,
|
||||
requestedCwd: "/workspace/custom",
|
||||
sessionStrategy: "default",
|
||||
@@ -143,6 +145,29 @@ describe("Cloudflare sandbox provider plugin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults the sleepAfter passed to the bridge to 1h so long runs don't idle out", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
providerLeaseId: "pc-run-1-abcd1234",
|
||||
metadata: { provider: "cloudflare", remoteCwd: "/workspace/paperclip", resumedLease: false },
|
||||
}),
|
||||
);
|
||||
|
||||
await plugin.definition.onEnvironmentAcquireLease?.({
|
||||
driverKey: "cloudflare",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
requestedCwd: "/workspace/paperclip",
|
||||
config: {
|
||||
bridgeBaseUrl: "https://bridge.example.workers.dev",
|
||||
bridgeAuthToken: "resolved-token",
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestBodyAt()).toMatchObject({ sleepAfter: "1h" });
|
||||
});
|
||||
|
||||
it("returns expired lease semantics when resume reports lost state", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
@@ -210,6 +235,12 @@ describe("Cloudflare sandbox provider plugin", () => {
|
||||
});
|
||||
|
||||
it("routes bridge-channel execute calls through a dedicated session", async () => {
|
||||
// pluginLogger must be set for the streaming branch to be reachable, so
|
||||
// we can assert that bridge-channel calls take the non-streaming path
|
||||
// even when adapter sessions would otherwise stream.
|
||||
await plugin.definition.setup?.({
|
||||
logger: { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined },
|
||||
} as never);
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
exitCode: 0,
|
||||
@@ -248,6 +279,49 @@ describe("Cloudflare sandbox provider plugin", () => {
|
||||
},
|
||||
});
|
||||
expect(requestBodyAt().env).not.toHaveProperty("PAPERCLIP_SANDBOX_EXEC_CHANNEL");
|
||||
// Bridge-channel commands must use the non-streaming exec path. The
|
||||
// @cloudflare/sandbox SDK's streaming mode can drop the final stdout
|
||||
// chunk when a short shell exits the same tick it writes — bridge ops
|
||||
// carry machine-consumed stdout (readiness JSON, base64 file payloads,
|
||||
// queue response bodies) where that data loss surfaces as opaque
|
||||
// "invalid readiness JSON" / "Invalid bridge request payload" errors.
|
||||
expect(requestBodyAt().streamOutput).toBe(false);
|
||||
});
|
||||
|
||||
it("uses streaming exec for non-bridge adapter commands so live logs flow", async () => {
|
||||
// Streaming is gated on `pluginLogger` being set, which normally happens
|
||||
// in `setup()`. Wire a minimal logger so the streaming branch is reachable.
|
||||
await plugin.definition.setup?.({
|
||||
logger: { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined },
|
||||
} as never);
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
"event: stdout\ndata: {\"data\":\"hello\\n\"}\n\nevent: complete\ndata: {\"exitCode\":0,\"signal\":null,\"timedOut\":false,\"stdout\":\"hello\\n\",\"stderr\":\"\"}\n\n",
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "cloudflare",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
lease: { providerLeaseId: "pc-run-1-abcd1234", metadata: {} },
|
||||
command: "echo",
|
||||
args: ["hello"],
|
||||
cwd: "/workspace/paperclip",
|
||||
env: { KEEP_ME: "visible" },
|
||||
config: {
|
||||
bridgeBaseUrl: "https://bridge.example.workers.dev",
|
||||
bridgeAuthToken: "resolved-token",
|
||||
sessionStrategy: "named",
|
||||
sessionId: "paperclip",
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestBodyAt().streamOutput).toBe(true);
|
||||
});
|
||||
|
||||
it("maps lost-lease execute errors into a deterministic command failure", async () => {
|
||||
|
||||
@@ -317,7 +317,13 @@ const plugin = definePlugin({
|
||||
const { config, client } = bridgeClientFor(params.config);
|
||||
const session = resolveExecuteSession(config, params.env);
|
||||
try {
|
||||
const streamingOptions = pluginLogger
|
||||
// Bridge-channel commands carry machine-consumed stdout (JSON, base64,
|
||||
// file contents). The @cloudflare/sandbox SDK's streaming mode can drop
|
||||
// the final stdout chunk when the inner shell exits the same tick as it
|
||||
// writes (e.g. `cat ready.json && exit 0`), so we never stream for
|
||||
// bridge control traffic — only adapter sessions get live log forwarding.
|
||||
const isBridgeChannel = params.env?.[SANDBOX_EXEC_CHANNEL_ENV] === SANDBOX_EXEC_CHANNEL_BRIDGE;
|
||||
const streamingOptions = pluginLogger && !isBridgeChannel
|
||||
? {
|
||||
onOutput: async (stream: "stdout" | "stderr", chunk: string) => {
|
||||
logCloudflareExecChunk(pluginLogger, stream, chunk);
|
||||
|
||||
@@ -39,8 +39,9 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
},
|
||||
timeoutMs: {
|
||||
type: "number",
|
||||
description: "Sandbox timeout in milliseconds.",
|
||||
default: 300000,
|
||||
description:
|
||||
"Sandbox lifetime in milliseconds, refreshed on each command. Defaults to 1 hour. Raise this if your runs commonly idle longer than the default between commands.",
|
||||
default: 3600000,
|
||||
},
|
||||
reuseLease: {
|
||||
type: "boolean",
|
||||
|
||||
@@ -379,6 +379,59 @@ describe("E2B sandbox provider plugin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes the sandbox lifetime on every execute so long runs don't die mid-command", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
mockConnect.mockResolvedValue(sandbox);
|
||||
|
||||
await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
template: "base",
|
||||
apiKey: "resolved-key",
|
||||
timeoutMs: 1_800_000,
|
||||
reuseLease: false,
|
||||
},
|
||||
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
||||
command: "printf",
|
||||
args: ["hello"],
|
||||
cwd: "/workspace",
|
||||
env: {},
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(sandbox.setTimeout).toHaveBeenCalledWith(1_800_000);
|
||||
});
|
||||
|
||||
it("still runs the command when the setTimeout refresh fails transiently", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
sandbox.setTimeout.mockRejectedValueOnce(new Error("transient e2b api error"));
|
||||
mockConnect.mockResolvedValue(sandbox);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "e2b",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
config: {
|
||||
template: "base",
|
||||
apiKey: "resolved-key",
|
||||
timeoutMs: 1_800_000,
|
||||
reuseLease: false,
|
||||
},
|
||||
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
||||
command: "printf",
|
||||
args: ["hello"],
|
||||
cwd: "/workspace",
|
||||
env: {},
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(sandbox.setTimeout).toHaveBeenCalledWith(1_800_000);
|
||||
expect(sandbox.commands.run).toHaveBeenCalled();
|
||||
expect(result?.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("cleans up staged stdin even when writing it fails", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
const failure = new Error("write failed");
|
||||
|
||||
@@ -34,11 +34,11 @@ function parseDriverConfig(raw: Record<string, unknown>): E2bDriverConfig {
|
||||
const template = typeof raw.template === "string" && raw.template.trim().length > 0
|
||||
? raw.template.trim()
|
||||
: "base";
|
||||
const timeoutMs = Number(raw.timeoutMs ?? 300_000);
|
||||
const timeoutMs = Number(raw.timeoutMs ?? 3_600_000);
|
||||
return {
|
||||
template,
|
||||
apiKey: typeof raw.apiKey === "string" && raw.apiKey.trim().length > 0 ? raw.apiKey.trim() : null,
|
||||
timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 300_000,
|
||||
timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 3_600_000,
|
||||
reuseLease: raw.reuseLease === true,
|
||||
};
|
||||
}
|
||||
@@ -391,6 +391,18 @@ const plugin = definePlugin({
|
||||
|
||||
const config = parseDriverConfig(params.config);
|
||||
const sandbox = await connectSandbox(config, params.lease.providerLeaseId);
|
||||
// Refresh the sandbox death clock on every command. E2B's `timeoutMs` is
|
||||
// the absolute sandbox lifetime from create/connect; without this, a run
|
||||
// longer than `config.timeoutMs` will have its sandbox killed mid-command
|
||||
// and the next call throws "Sandbox is probably not running anymore".
|
||||
// The refresh is best-effort: the sandbox is already healthy at this
|
||||
// point, so a transient API error on setTimeout should not block the
|
||||
// command from running. Worst case the existing lifetime stands.
|
||||
try {
|
||||
await sandbox.setTimeout(config.timeoutMs);
|
||||
} catch {
|
||||
// ignore — keep going with the existing sandbox lifetime
|
||||
}
|
||||
const baseCommand = buildLoginShellScript({
|
||||
command: params.command,
|
||||
args: params.args ?? [],
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# `@paperclipai/plugin-modal`
|
||||
|
||||
First-party Modal sandbox provider plugin for Paperclip.
|
||||
|
||||
Like the other sandbox-provider packages in this repo, it lives inside the Paperclip monorepo but is intentionally excluded from the root `pnpm` workspace and shaped to publish and install like a standalone npm package. That lets operators install it from the Plugins page by package name without introducing root lockfile churn for Modal's SDK dependencies.
|
||||
|
||||
## Install
|
||||
|
||||
From a Paperclip instance, install:
|
||||
|
||||
```text
|
||||
@paperclipai/plugin-modal
|
||||
```
|
||||
|
||||
The host plugin installer runs `npm install` into the managed plugin directory, so the `modal` SDK dependency is pulled in during installation.
|
||||
|
||||
## Runtime support note
|
||||
|
||||
Modal's official JS SDK README pins support to **Node 22 or later**. Paperclip's repo baseline is currently `node >= 20`; empirically `modal@0.7.4` imports and operates against the Modal API under Node 20, so the plugin runs there today, but the vendor support contract is Node 22+. The plugin logs a startup warning when it detects Node `< 22`. Operators who can pin their Paperclip runtime to Node 22+ should do so; treat Node-20 usage as best-effort until the host bumps its baseline.
|
||||
|
||||
The empirical Node 20 compatibility check is recorded in [PAPA-352](/PAPA/issues/PAPA-352).
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure Modal from `Company Settings -> Environments`, not from the plugin's instance settings page.
|
||||
|
||||
| Field | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `appName` | yes | Modal App name. The plugin calls `modal.apps.fromName(appName, { createIfMissing: true })`, so the App is created on first acquire if it does not already exist. |
|
||||
| `image` | yes | Container image passed to `modal.images.fromRegistry()`, e.g. `python:3.13` or `node:20`. |
|
||||
| `tokenId` / `tokenSecret` | yes | Modal auth tokens. Both must be provided together. Paperclip stores pasted values as company secrets. The plugin worker runs in a child process that does not inherit host env vars, so `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET` set on the Paperclip server are **not** read by the plugin — provide the tokens in this form. |
|
||||
| `environment` | no | Optional Modal environment name. Falls back to the SDK profile default. |
|
||||
| `workdir` | no | Remote working directory inside the sandbox. Defaults to `/workspace/paperclip`. |
|
||||
| `sandboxTimeoutMs` | no | Maximum sandbox lifetime in milliseconds. Must be a positive multiple of `1000` between `1000` and `86_400_000` (24 hours). Defaults to `3_600_000` (1 hour). |
|
||||
| `idleTimeoutMs` | no | Optional idle timeout in milliseconds. Modal terminates the sandbox if no exec is active for this duration. Must be a positive multiple of `1000`. |
|
||||
| `execTimeoutMs` | no | Default per-exec timeout in milliseconds when the caller does not pass one. Must be a positive multiple of `1000`. Defaults to `300_000` (5 minutes). |
|
||||
| `blockNetwork` | no | Block all egress network access. |
|
||||
| `cidrAllowlist` | no | List of CIDRs the sandbox may reach. Cannot be combined with `blockNetwork`. |
|
||||
| `reuseLease` | no | When `true`, the sandbox is detached (not terminated) on release and reattached by id later. Defaults to `false`. |
|
||||
|
||||
### Reuse semantics
|
||||
|
||||
Modal does **not** expose a separate pause/resume primitive for sandboxes — there is no equivalent to e2b's `pause()`. The plugin implements `reuseLease` as follows:
|
||||
|
||||
- **`reuseLease: false` (default)**: On release the sandbox is `terminate()`d. Subsequent runs create a new sandbox.
|
||||
- **`reuseLease: true`**: On release the plugin calls `sandbox.detach()`. The sandbox keeps running on Modal until its configured `sandboxTimeoutMs` or `idleTimeoutMs` elapses. The next acquire/resume reconnects via `modal.sandboxes.fromId(providerLeaseId)`. If the sandbox has expired, `fromId` raises `NotFoundError` and the plugin reports the lease as expired so Paperclip reacquires.
|
||||
|
||||
Because there is no real pause, **`reuseLease: true` keeps billing running** until the sandbox or idle timeout cuts it off. Tune `idleTimeoutMs` to a value that matches your reuse window.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
cd packages/plugins/sandbox-providers/modal
|
||||
pnpm install --ignore-workspace --no-lockfile
|
||||
pnpm build
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
These commands assume the repo root has already been installed once so the local `@paperclipai/plugin-sdk` workspace package is available to the compiler during development.
|
||||
|
||||
## Operator verification
|
||||
|
||||
1. Provision Modal credentials in your Modal account (`modal token new`) or use a service account.
|
||||
2. Install the plugin from the Paperclip Plugins page.
|
||||
3. In `Company Settings -> Environments`, add a new Modal sandbox environment with at least `appName`, `image`, `tokenId`, and `tokenSecret`.
|
||||
4. Run the environment **Probe** action. A success result confirms auth, app creation, image pull, and `exec` round-trip.
|
||||
5. Run at least one Paperclip task with a remote-managed adapter (for example `claude_local`) bound to that environment. The adapter should provision the sandbox, run commands in it, and clean it up.
|
||||
|
||||
Full end-to-end manual QA is tracked separately in [PAPA-354](/PAPA/issues/PAPA-354).
|
||||
|
||||
## Package layout
|
||||
|
||||
- `src/manifest.ts` declares the sandbox-provider driver metadata
|
||||
- `src/plugin.ts` implements the environment lifecycle hooks
|
||||
- `src/worker.ts` boots the plugin under the host worker runtime
|
||||
- `paperclipPlugin.manifest` and `paperclipPlugin.worker` point the host at the built plugin entrypoints in `dist/`
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-modal",
|
||||
"version": "0.1.0",
|
||||
"description": "Modal sandbox provider plugin for Paperclip environments",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/plugins/sandbox-providers/modal"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"sandbox",
|
||||
"modal"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../../scripts/generate-plugin-package-json.mjs",
|
||||
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
|
||||
},
|
||||
"dependencies": {
|
||||
"modal": "^0.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as plugin } from "./plugin.js";
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip.modal-sandbox-provider";
|
||||
const PLUGIN_VERSION = "0.1.0";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: PLUGIN_VERSION,
|
||||
displayName: "Modal Sandbox Provider",
|
||||
description:
|
||||
"First-party sandbox provider plugin that provisions Modal sandboxes as Paperclip execution environments.",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
},
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "modal",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Modal Sandbox",
|
||||
description:
|
||||
"Provisions Modal sandboxes with configurable image, app, auth, timeouts, and network controls.",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["appName", "image"],
|
||||
properties: {
|
||||
appName: {
|
||||
type: "string",
|
||||
description:
|
||||
"Modal App name used as the parent for sandboxes. The plugin calls `modal.apps.fromName(appName, { createIfMissing: true })`, so the App is created on first acquire if it does not already exist.",
|
||||
},
|
||||
image: {
|
||||
type: "string",
|
||||
description:
|
||||
"Container image reference passed to `modal.images.fromRegistry()`, e.g. `python:3.13` or `node:20`.",
|
||||
},
|
||||
tokenId: {
|
||||
type: "string",
|
||||
format: "secret-ref",
|
||||
description:
|
||||
"Modal token ID. Paste a token or an existing Paperclip secret reference; saved environments store pasted values as company secrets. Required.",
|
||||
},
|
||||
tokenSecret: {
|
||||
type: "string",
|
||||
format: "secret-ref",
|
||||
description: "Modal token secret paired with tokenId. Required.",
|
||||
},
|
||||
environment: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional Modal environment name. Falls back to the SDK profile default.",
|
||||
},
|
||||
workdir: {
|
||||
type: "string",
|
||||
description: "Remote working directory inside the sandbox.",
|
||||
default: "/workspace/paperclip",
|
||||
},
|
||||
sandboxTimeoutMs: {
|
||||
type: "number",
|
||||
description:
|
||||
"Maximum sandbox lifetime in milliseconds. Must be a positive multiple of 1000 between 1000 and 86400000 (24 hours).",
|
||||
default: 3_600_000,
|
||||
},
|
||||
idleTimeoutMs: {
|
||||
type: "number",
|
||||
description:
|
||||
"Optional idle timeout in milliseconds. When set, Modal terminates the sandbox if no exec is active for this duration. Must be a positive multiple of 1000.",
|
||||
},
|
||||
execTimeoutMs: {
|
||||
type: "number",
|
||||
description:
|
||||
"Default per-exec timeout in milliseconds when the caller does not provide one. Must be a positive multiple of 1000.",
|
||||
default: 300_000,
|
||||
},
|
||||
blockNetwork: {
|
||||
type: "boolean",
|
||||
description: "Whether to block all egress network access from the sandbox.",
|
||||
default: false,
|
||||
},
|
||||
cidrAllowlist: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Optional list of CIDRs the sandbox is allowed to reach. Cannot be combined with blockNetwork.",
|
||||
},
|
||||
reuseLease: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"When true, the sandbox is detached (not terminated) on release and resumed by id later. Reuse relies on Modal's sandbox lifetime and idle timeout because Modal has no separate pause primitive.",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,703 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { MockNotFoundError, MockTimeoutError, MockSandboxTimeoutError } = vi.hoisted(() => {
|
||||
class MockNotFoundError extends Error {}
|
||||
class MockTimeoutError extends Error {}
|
||||
class MockSandboxTimeoutError extends Error {}
|
||||
return { MockNotFoundError, MockTimeoutError, MockSandboxTimeoutError };
|
||||
});
|
||||
|
||||
const mockAppFromName = vi.hoisted(() => vi.fn());
|
||||
const mockImageFromRegistry = vi.hoisted(() => vi.fn(() => ({ kind: "image" })));
|
||||
const mockSandboxesCreate = vi.hoisted(() => vi.fn());
|
||||
const mockSandboxesFromId = vi.hoisted(() => vi.fn());
|
||||
const mockClientClose = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("modal", () => ({
|
||||
ModalClient: class MockModalClient {
|
||||
apps = { fromName: mockAppFromName };
|
||||
images = { fromRegistry: mockImageFromRegistry };
|
||||
sandboxes = { create: mockSandboxesCreate, fromId: mockSandboxesFromId };
|
||||
close = mockClientClose;
|
||||
constructor(_params?: unknown) {}
|
||||
},
|
||||
NotFoundError: MockNotFoundError,
|
||||
TimeoutError: MockTimeoutError,
|
||||
SandboxTimeoutError: MockSandboxTimeoutError,
|
||||
}));
|
||||
|
||||
import plugin from "./plugin.js";
|
||||
|
||||
interface FakeSandboxOverrides {
|
||||
id?: string;
|
||||
execImpl?: (argv: string[], params?: unknown) => Promise<FakeProcess>;
|
||||
}
|
||||
|
||||
interface FakeProcess {
|
||||
stdout: { readText: () => Promise<string> };
|
||||
stderr: { readText: () => Promise<string> };
|
||||
wait: () => Promise<number>;
|
||||
}
|
||||
|
||||
function makeFakeProcess(input: {
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
throwOnWait?: unknown;
|
||||
}): FakeProcess {
|
||||
return {
|
||||
stdout: { readText: vi.fn().mockResolvedValue(input.stdout ?? "") },
|
||||
stderr: { readText: vi.fn().mockResolvedValue(input.stderr ?? "") },
|
||||
wait: vi.fn().mockImplementation(async () => {
|
||||
if (input.throwOnWait) throw input.throwOnWait;
|
||||
return input.exitCode ?? 0;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeSandbox(overrides: FakeSandboxOverrides = {}) {
|
||||
const execCalls: Array<{ argv: string[]; params?: unknown }> = [];
|
||||
const defaultExec = async (_argv: string[], _params?: unknown): Promise<FakeProcess> =>
|
||||
makeFakeProcess({ exitCode: 0, stdout: "paperclip-probe" });
|
||||
const exec = vi.fn().mockImplementation(async (argv: string[], params?: unknown) => {
|
||||
execCalls.push({ argv, params });
|
||||
return overrides.execImpl ? overrides.execImpl(argv, params) : defaultExec(argv, params);
|
||||
});
|
||||
const openedFiles: Array<{ path: string; mode: string; written: Uint8Array | null }> = [];
|
||||
const sandbox = {
|
||||
sandboxId: overrides.id ?? "sb-123",
|
||||
exec,
|
||||
execCalls,
|
||||
openedFiles,
|
||||
setTags: vi.fn().mockResolvedValue(undefined),
|
||||
terminate: vi.fn().mockResolvedValue(undefined),
|
||||
detach: vi.fn(),
|
||||
poll: vi.fn().mockResolvedValue(null),
|
||||
open: vi.fn().mockImplementation(async (path: string, mode: string) => {
|
||||
const entry: { path: string; mode: string; written: Uint8Array | null } = {
|
||||
path,
|
||||
mode,
|
||||
written: null,
|
||||
};
|
||||
openedFiles.push(entry);
|
||||
return {
|
||||
write: vi.fn().mockImplementation(async (data: Uint8Array) => {
|
||||
entry.written = data;
|
||||
}),
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}),
|
||||
};
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
type FakeSandbox = ReturnType<typeof createFakeSandbox>;
|
||||
|
||||
const baseAcquireParams = {
|
||||
driverKey: "modal",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
};
|
||||
|
||||
const baseConfig = {
|
||||
appName: "paperclip-app",
|
||||
image: "node:20",
|
||||
sandboxTimeoutMs: 3_600_000,
|
||||
execTimeoutMs: 300_000,
|
||||
reuseLease: false,
|
||||
};
|
||||
|
||||
const baseConfigWithTokens = {
|
||||
...baseConfig,
|
||||
tokenId: "config-id",
|
||||
tokenSecret: "config-secret",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppFromName.mockReset();
|
||||
mockImageFromRegistry.mockReset();
|
||||
mockImageFromRegistry.mockReturnValue({ kind: "image" });
|
||||
mockSandboxesCreate.mockReset();
|
||||
mockSandboxesFromId.mockReset();
|
||||
mockClientClose.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.MODAL_TOKEN_ID;
|
||||
delete process.env.MODAL_TOKEN_SECRET;
|
||||
});
|
||||
|
||||
describe("Modal sandbox provider plugin", () => {
|
||||
it("declares environment lifecycle handlers", async () => {
|
||||
expect(await plugin.definition.onHealth?.()).toEqual({
|
||||
status: "ok",
|
||||
message: "Modal sandbox provider plugin healthy",
|
||||
});
|
||||
expect(plugin.definition.onEnvironmentAcquireLease).toBeTypeOf("function");
|
||||
expect(plugin.definition.onEnvironmentExecute).toBeTypeOf("function");
|
||||
expect(plugin.definition.onEnvironmentReleaseLease).toBeTypeOf("function");
|
||||
expect(plugin.definition.onEnvironmentResumeLease).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("normalizes config when both tokens are provided", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "modal",
|
||||
config: {
|
||||
appName: " app-1 ",
|
||||
image: " node:20 ",
|
||||
tokenId: " token-id ",
|
||||
tokenSecret: " token-secret ",
|
||||
environment: " main ",
|
||||
workdir: " /srv/work ",
|
||||
sandboxTimeoutMs: "1800000",
|
||||
idleTimeoutMs: "60000",
|
||||
execTimeoutMs: "120000",
|
||||
reuseLease: true,
|
||||
blockNetwork: false,
|
||||
cidrAllowlist: ["10.0.0.0/8"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
normalizedConfig: {
|
||||
appName: "app-1",
|
||||
image: "node:20",
|
||||
tokenId: "token-id",
|
||||
tokenSecret: "token-secret",
|
||||
environment: "main",
|
||||
workdir: "/srv/work",
|
||||
sandboxTimeoutMs: 1_800_000,
|
||||
idleTimeoutMs: 60_000,
|
||||
execTimeoutMs: 120_000,
|
||||
blockNetwork: false,
|
||||
cidrAllowlist: ["10.0.0.0/8"],
|
||||
reuseLease: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores host MODAL_TOKEN_* env vars (plugin worker does not inherit them)", async () => {
|
||||
process.env.MODAL_TOKEN_ID = "host-id";
|
||||
process.env.MODAL_TOKEN_SECRET = "host-secret";
|
||||
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "modal",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: ["Modal sandbox environments require tokenId and tokenSecret."],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid config", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "modal",
|
||||
config: {
|
||||
appName: "",
|
||||
image: "",
|
||||
sandboxTimeoutMs: 1500,
|
||||
idleTimeoutMs: 1500,
|
||||
execTimeoutMs: 0,
|
||||
blockNetwork: true,
|
||||
cidrAllowlist: ["1.2.3.4/32"],
|
||||
tokenId: "only-id",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: [
|
||||
"Modal sandbox environments require an appName.",
|
||||
"Modal sandbox environments require an image reference.",
|
||||
"sandboxTimeoutMs must be a positive multiple of 1000 between 1000 and 86400000.",
|
||||
"idleTimeoutMs must be a positive multiple of 1000 when provided.",
|
||||
"execTimeoutMs must be a positive multiple of 1000.",
|
||||
"cidrAllowlist cannot be combined with blockNetwork.",
|
||||
"tokenId and tokenSecret must both be provided when either is set.",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires both tokens in config", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "modal",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: ["Modal sandbox environments require tokenId and tokenSecret."],
|
||||
});
|
||||
});
|
||||
|
||||
it("probes by creating, executing, and terminating a sandbox", async () => {
|
||||
const sandbox = createFakeSandbox();
|
||||
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
|
||||
mockSandboxesCreate.mockResolvedValue(sandbox);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentProbe?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: { ...baseConfig, workdir: "/srv/work" },
|
||||
});
|
||||
|
||||
expect(mockAppFromName).toHaveBeenCalledWith("paperclip-app", {
|
||||
createIfMissing: true,
|
||||
environment: undefined,
|
||||
});
|
||||
expect(mockImageFromRegistry).toHaveBeenCalledWith("node:20");
|
||||
expect(sandbox.setTags).toHaveBeenCalledWith(expect.objectContaining({
|
||||
"paperclip-provider": "modal",
|
||||
"paperclip-company-id": "c-1",
|
||||
}));
|
||||
// First exec is the mkdir for the workspace, second is the probe command.
|
||||
expect(sandbox.execCalls[0]?.argv).toEqual([
|
||||
"sh",
|
||||
"-lc",
|
||||
"mkdir -p '/srv/work'",
|
||||
]);
|
||||
expect(sandbox.execCalls[1]?.argv).toEqual([
|
||||
"sh",
|
||||
"-lc",
|
||||
"printf paperclip-probe",
|
||||
]);
|
||||
expect(sandbox.terminate).toHaveBeenCalled();
|
||||
expect(mockClientClose).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
metadata: {
|
||||
provider: "modal",
|
||||
sandboxId: "sb-123",
|
||||
remoteCwd: "/srv/work",
|
||||
reuseLease: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a failure probe result when the probe command exits non-zero", async () => {
|
||||
const sandbox = createFakeSandbox({
|
||||
execImpl: async (argv: string[]) => {
|
||||
if (argv[2] === "printf paperclip-probe") {
|
||||
return makeFakeProcess({ exitCode: 7, stdout: "boom" });
|
||||
}
|
||||
return makeFakeProcess({ exitCode: 0 });
|
||||
},
|
||||
});
|
||||
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
|
||||
mockSandboxesCreate.mockResolvedValue(sandbox);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentProbe?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
});
|
||||
|
||||
expect(result?.ok).toBe(false);
|
||||
expect(sandbox.terminate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the Modal client when probe fails before sandbox creation", async () => {
|
||||
mockAppFromName.mockRejectedValue(new Error("app lookup failed"));
|
||||
|
||||
const result = await plugin.definition.onEnvironmentProbe?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
summary: "Modal sandbox probe failed.",
|
||||
metadata: expect.objectContaining({
|
||||
error: "app lookup failed",
|
||||
}),
|
||||
});
|
||||
expect(mockClientClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("acquires a lease, applies tags, and ensures the workspace directory", async () => {
|
||||
const sandbox = createFakeSandbox({ id: "sb-acquire" });
|
||||
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
|
||||
mockSandboxesCreate.mockResolvedValue(sandbox);
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
||||
...baseAcquireParams,
|
||||
config: { ...baseConfig, reuseLease: true, workdir: "/srv/work" },
|
||||
});
|
||||
|
||||
expect(lease).toEqual({
|
||||
providerLeaseId: "sb-acquire",
|
||||
metadata: expect.objectContaining({
|
||||
provider: "modal",
|
||||
sandboxId: "sb-acquire",
|
||||
remoteCwd: "/srv/work",
|
||||
reuseLease: true,
|
||||
resumedLease: false,
|
||||
}),
|
||||
});
|
||||
expect(sandbox.setTags).toHaveBeenCalledWith(expect.objectContaining({
|
||||
"paperclip-run-id": "run-1",
|
||||
"paperclip-reuse-lease": "true",
|
||||
}));
|
||||
expect(sandbox.execCalls[0]?.argv).toEqual(["sh", "-lc", "mkdir -p '/srv/work'"]);
|
||||
});
|
||||
|
||||
it("terminates the sandbox if acquire workspace setup throws", async () => {
|
||||
const sandbox = createFakeSandbox({
|
||||
execImpl: async (argv: string[]) => {
|
||||
if (argv[2]?.startsWith("mkdir -p")) {
|
||||
return makeFakeProcess({ throwOnWait: new Error("mkdir failed") });
|
||||
}
|
||||
return makeFakeProcess({ exitCode: 0 });
|
||||
},
|
||||
});
|
||||
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
|
||||
mockSandboxesCreate.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(
|
||||
plugin.definition.onEnvironmentAcquireLease?.({
|
||||
...baseAcquireParams,
|
||||
config: baseConfig,
|
||||
}),
|
||||
).rejects.toThrow("mkdir failed");
|
||||
expect(sandbox.terminate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails acquire when workspace creation exits non-zero", async () => {
|
||||
const sandbox = createFakeSandbox({
|
||||
execImpl: async (argv: string[]) => {
|
||||
if (argv[2]?.startsWith("mkdir -p")) {
|
||||
return makeFakeProcess({ exitCode: 17 });
|
||||
}
|
||||
return makeFakeProcess({ exitCode: 0 });
|
||||
},
|
||||
});
|
||||
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
|
||||
mockSandboxesCreate.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(
|
||||
plugin.definition.onEnvironmentAcquireLease?.({
|
||||
...baseAcquireParams,
|
||||
config: baseConfig,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Failed to create remote workspace directory '/workspace/paperclip': mkdir exited with code 17",
|
||||
);
|
||||
expect(sandbox.terminate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes the Modal client when acquire fails before sandbox creation", async () => {
|
||||
mockAppFromName.mockRejectedValue(new Error("app lookup failed"));
|
||||
|
||||
await expect(
|
||||
plugin.definition.onEnvironmentAcquireLease?.({
|
||||
...baseAcquireParams,
|
||||
config: baseConfig,
|
||||
}),
|
||||
).rejects.toThrow("app lookup failed");
|
||||
expect(mockClientClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("treats missing leases as expired on resume", async () => {
|
||||
mockSandboxesFromId.mockRejectedValue(new MockNotFoundError("gone"));
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentResumeLease?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
providerLeaseId: "sb-missing",
|
||||
config: { ...baseConfig, reuseLease: true },
|
||||
});
|
||||
expect(lease).toEqual({ providerLeaseId: null, metadata: { expired: true } });
|
||||
});
|
||||
|
||||
it("resumes a reusable lease by reconnecting via fromId", async () => {
|
||||
const sandbox = createFakeSandbox({ id: "sb-resume" });
|
||||
mockSandboxesFromId.mockResolvedValue(sandbox);
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentResumeLease?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
providerLeaseId: "sb-resume",
|
||||
config: { ...baseConfig, reuseLease: true },
|
||||
});
|
||||
|
||||
expect(lease).toEqual({
|
||||
providerLeaseId: "sb-resume",
|
||||
metadata: expect.objectContaining({
|
||||
provider: "modal",
|
||||
sandboxId: "sb-resume",
|
||||
resumedLease: true,
|
||||
reuseLease: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("detaches the sandbox if resumed workspace setup fails", async () => {
|
||||
const sandbox = createFakeSandbox({
|
||||
id: "sb-resume",
|
||||
execImpl: async (argv: string[]) => {
|
||||
if (argv[2]?.startsWith("mkdir -p")) {
|
||||
return makeFakeProcess({ throwOnWait: new Error("mkdir failed") });
|
||||
}
|
||||
return makeFakeProcess({ exitCode: 0 });
|
||||
},
|
||||
});
|
||||
mockSandboxesFromId.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(
|
||||
plugin.definition.onEnvironmentResumeLease?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
providerLeaseId: "sb-resume",
|
||||
config: { ...baseConfig, reuseLease: true },
|
||||
}),
|
||||
).rejects.toThrow("mkdir failed");
|
||||
expect(sandbox.detach).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("detaches reusable leases and terminates ephemeral leases on release", async () => {
|
||||
const reusable = createFakeSandbox({ id: "sb-reuse" });
|
||||
const ephemeral = createFakeSandbox({ id: "sb-ephem" });
|
||||
mockSandboxesFromId.mockResolvedValueOnce(reusable).mockResolvedValueOnce(ephemeral);
|
||||
|
||||
await plugin.definition.onEnvironmentReleaseLease?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
providerLeaseId: "sb-reuse",
|
||||
config: { ...baseConfig, reuseLease: true },
|
||||
});
|
||||
await plugin.definition.onEnvironmentReleaseLease?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
providerLeaseId: "sb-ephem",
|
||||
config: { ...baseConfig, reuseLease: false },
|
||||
});
|
||||
|
||||
expect(reusable.detach).toHaveBeenCalled();
|
||||
expect(reusable.terminate).not.toHaveBeenCalled();
|
||||
expect(ephemeral.terminate).toHaveBeenCalled();
|
||||
expect(ephemeral.detach).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("destroys leases by terminating, ignoring missing sandboxes", async () => {
|
||||
const sandbox = createFakeSandbox({ id: "sb-destroy" });
|
||||
mockSandboxesFromId.mockResolvedValueOnce(sandbox);
|
||||
|
||||
await plugin.definition.onEnvironmentDestroyLease?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
providerLeaseId: "sb-destroy",
|
||||
config: baseConfig,
|
||||
});
|
||||
expect(sandbox.terminate).toHaveBeenCalled();
|
||||
|
||||
mockSandboxesFromId.mockRejectedValueOnce(new MockNotFoundError("missing"));
|
||||
await expect(
|
||||
plugin.definition.onEnvironmentDestroyLease?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
providerLeaseId: "sb-missing",
|
||||
config: baseConfig,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("realizes the workspace using the lease metadata cwd when available", async () => {
|
||||
const sandbox = createFakeSandbox({ id: "sb-real" });
|
||||
mockSandboxesFromId.mockResolvedValue(sandbox);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentRealizeWorkspace?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
lease: {
|
||||
providerLeaseId: "sb-real",
|
||||
metadata: { remoteCwd: "/srv/from-metadata" },
|
||||
},
|
||||
workspace: { localPath: "/local", remotePath: "/remote" },
|
||||
});
|
||||
|
||||
expect(sandbox.execCalls[0]?.argv).toEqual([
|
||||
"sh",
|
||||
"-lc",
|
||||
"mkdir -p '/srv/from-metadata'",
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
cwd: "/srv/from-metadata",
|
||||
metadata: { provider: "modal", remoteCwd: "/srv/from-metadata" },
|
||||
});
|
||||
});
|
||||
|
||||
it("executes commands with a login-shell wrapper that injects env after profile sourcing", async () => {
|
||||
const sandbox = createFakeSandbox({
|
||||
execImpl: async (argv: string[]) =>
|
||||
makeFakeProcess({
|
||||
exitCode: 5,
|
||||
stdout: "stdout-output",
|
||||
stderr: "stderr-output",
|
||||
}),
|
||||
});
|
||||
mockSandboxesFromId.mockResolvedValue(sandbox);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
lease: { providerLeaseId: "sb-exec", metadata: {} },
|
||||
command: "printf",
|
||||
args: ["hello"],
|
||||
cwd: "/srv/work",
|
||||
env: { FOO: "bar" },
|
||||
timeoutMs: 12_000,
|
||||
});
|
||||
|
||||
expect(sandbox.execCalls).toHaveLength(1);
|
||||
const call = sandbox.execCalls[0]!;
|
||||
expect(call.argv[0]).toBe("sh");
|
||||
expect(call.argv[1]).toBe("-lc");
|
||||
const script = call.argv[2]!;
|
||||
expect(script).toMatch(/\/etc\/profile/);
|
||||
expect(script).toMatch(/cd '\/srv\/work'/);
|
||||
expect(script).toMatch(/&& exec env FOO='bar' 'printf' 'hello'$/);
|
||||
expect(call.params).toMatchObject({
|
||||
timeoutMs: 12_000,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
exitCode: 5,
|
||||
timedOut: false,
|
||||
stdout: "stdout-output",
|
||||
stderr: "stderr-output",
|
||||
});
|
||||
});
|
||||
|
||||
it("stages stdin in the sandbox filesystem when execution needs redirected input", async () => {
|
||||
const sandbox = createFakeSandbox();
|
||||
mockSandboxesFromId.mockResolvedValue(sandbox);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
lease: { providerLeaseId: "sb-exec", metadata: {} },
|
||||
command: "cat",
|
||||
args: [],
|
||||
stdin: "input payload",
|
||||
cwd: "/srv/work",
|
||||
});
|
||||
|
||||
expect(sandbox.openedFiles).toHaveLength(1);
|
||||
expect(sandbox.openedFiles[0]?.path).toMatch(/^\/tmp\/paperclip-stdin-/);
|
||||
expect(sandbox.openedFiles[0]?.mode).toBe("w");
|
||||
expect(sandbox.openedFiles[0]?.written).not.toBeNull();
|
||||
expect(new TextDecoder().decode(sandbox.openedFiles[0]!.written!)).toBe("input payload");
|
||||
|
||||
// First exec is the user command; second is the rm cleanup.
|
||||
const userCall = sandbox.execCalls[0]!;
|
||||
expect(userCall.argv[2]).toMatch(/&& exec 'cat' < '\/tmp\/paperclip-stdin-/);
|
||||
const cleanupCall = sandbox.execCalls[1]!;
|
||||
expect(cleanupCall.argv[2]).toMatch(/^rm -f '\/tmp\/paperclip-stdin-/);
|
||||
expect(result?.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects invalid shell env keys before execution", async () => {
|
||||
const sandbox = createFakeSandbox();
|
||||
mockSandboxesFromId.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(
|
||||
plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
lease: { providerLeaseId: "sb-exec", metadata: {} },
|
||||
command: "printf",
|
||||
args: ["hello"],
|
||||
env: { "BAD-KEY": "v" },
|
||||
}),
|
||||
).rejects.toThrow("Invalid sandbox environment variable key: BAD-KEY");
|
||||
expect(sandbox.execCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns an error result when execute is called for an expired sandbox lease", async () => {
|
||||
mockSandboxesFromId.mockRejectedValue(new MockNotFoundError("gone"));
|
||||
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
lease: { providerLeaseId: "sb-expired", metadata: {} },
|
||||
command: "printf",
|
||||
args: ["hello"],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "Modal sandbox lease is no longer available.\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a timedOut result when Modal raises a TimeoutError during exec", async () => {
|
||||
const sandbox = createFakeSandbox({
|
||||
execImpl: async () =>
|
||||
makeFakeProcess({ throwOnWait: new MockTimeoutError("exec timed out") }),
|
||||
});
|
||||
mockSandboxesFromId.mockResolvedValue(sandbox);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
lease: { providerLeaseId: "sb-exec", metadata: {} },
|
||||
command: "sleep",
|
||||
args: ["60"],
|
||||
cwd: "/srv/work",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
exitCode: null,
|
||||
timedOut: true,
|
||||
stdout: "",
|
||||
stderr: "exec timed out\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an error result when execute is called without a provider lease id", async () => {
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
lease: { providerLeaseId: null, metadata: {} },
|
||||
command: "printf",
|
||||
args: ["hello"],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "No provider lease ID available for execution.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,660 @@
|
||||
import {
|
||||
ModalClient,
|
||||
NotFoundError,
|
||||
SandboxTimeoutError,
|
||||
TimeoutError,
|
||||
type App,
|
||||
type ContainerProcess,
|
||||
type Sandbox,
|
||||
type SandboxCreateParams,
|
||||
} from "modal";
|
||||
import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentDestroyLeaseParams,
|
||||
PluginEnvironmentExecuteParams,
|
||||
PluginEnvironmentExecuteResult,
|
||||
PluginEnvironmentLease,
|
||||
PluginEnvironmentProbeParams,
|
||||
PluginEnvironmentProbeResult,
|
||||
PluginEnvironmentRealizeWorkspaceParams,
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
PluginEnvironmentReleaseLeaseParams,
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentValidationResult,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
|
||||
const DEFAULT_WORKDIR = "/workspace/paperclip";
|
||||
const DEFAULT_SANDBOX_TIMEOUT_MS = 3_600_000;
|
||||
const DEFAULT_EXEC_TIMEOUT_MS = 300_000;
|
||||
const MAX_SANDBOX_TIMEOUT_MS = 86_400_000;
|
||||
|
||||
interface ModalDriverConfig {
|
||||
appName: string;
|
||||
image: string;
|
||||
tokenId: string | null;
|
||||
tokenSecret: string | null;
|
||||
environment: string | null;
|
||||
workdir: string;
|
||||
sandboxTimeoutMs: number;
|
||||
idleTimeoutMs: number | null;
|
||||
execTimeoutMs: number;
|
||||
blockNetwork: boolean;
|
||||
cidrAllowlist: string[] | null;
|
||||
reuseLease: boolean;
|
||||
}
|
||||
|
||||
function parseOptionalString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: unknown): number | null {
|
||||
if (value == null || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseStringArray(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const trimmed = value
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function parseDriverConfig(raw: Record<string, unknown>): ModalDriverConfig {
|
||||
const sandboxTimeoutMsRaw = parseOptionalNumber(raw.sandboxTimeoutMs);
|
||||
const execTimeoutMsRaw = parseOptionalNumber(raw.execTimeoutMs);
|
||||
const idleTimeoutMsRaw = parseOptionalNumber(raw.idleTimeoutMs);
|
||||
return {
|
||||
appName: parseOptionalString(raw.appName) ?? "",
|
||||
image: parseOptionalString(raw.image) ?? "",
|
||||
tokenId: parseOptionalString(raw.tokenId),
|
||||
tokenSecret: parseOptionalString(raw.tokenSecret),
|
||||
environment: parseOptionalString(raw.environment),
|
||||
workdir: parseOptionalString(raw.workdir) ?? DEFAULT_WORKDIR,
|
||||
sandboxTimeoutMs:
|
||||
sandboxTimeoutMsRaw != null ? Math.trunc(sandboxTimeoutMsRaw) : DEFAULT_SANDBOX_TIMEOUT_MS,
|
||||
idleTimeoutMs: idleTimeoutMsRaw != null ? Math.trunc(idleTimeoutMsRaw) : null,
|
||||
execTimeoutMs:
|
||||
execTimeoutMsRaw != null ? Math.trunc(execTimeoutMsRaw) : DEFAULT_EXEC_TIMEOUT_MS,
|
||||
blockNetwork: raw.blockNetwork === true,
|
||||
cidrAllowlist: parseStringArray(raw.cidrAllowlist),
|
||||
reuseLease: raw.reuseLease === true,
|
||||
};
|
||||
}
|
||||
|
||||
function isMultipleOf1000(value: number): boolean {
|
||||
return value > 0 && value % 1000 === 0;
|
||||
}
|
||||
|
||||
function resolveAuth(config: ModalDriverConfig): { tokenId: string; tokenSecret: string } | null {
|
||||
// The plugin worker runs in a child process that does not inherit host env
|
||||
// vars (see PluginWorkerManager.spawnProcess), so MODAL_TOKEN_ID /
|
||||
// MODAL_TOKEN_SECRET cannot be read here. Credentials must come from the
|
||||
// environment config, which Paperclip stores as company secrets.
|
||||
const tokenId = config.tokenId ?? "";
|
||||
const tokenSecret = config.tokenSecret ?? "";
|
||||
if (!tokenId && !tokenSecret) return null;
|
||||
if (!tokenId || !tokenSecret) {
|
||||
throw new Error("Modal sandbox environments require both tokenId and tokenSecret to be configured.");
|
||||
}
|
||||
return { tokenId, tokenSecret };
|
||||
}
|
||||
|
||||
function createModalClient(config: ModalDriverConfig): ModalClient {
|
||||
const auth = resolveAuth(config);
|
||||
const params: ConstructorParameters<typeof ModalClient>[0] = {};
|
||||
if (auth) {
|
||||
params.tokenId = auth.tokenId;
|
||||
params.tokenSecret = auth.tokenSecret;
|
||||
}
|
||||
if (config.environment) {
|
||||
params.environment = config.environment;
|
||||
}
|
||||
return new ModalClient(params);
|
||||
}
|
||||
|
||||
async function resolveApp(client: ModalClient, config: ModalDriverConfig): Promise<App> {
|
||||
return await client.apps.fromName(config.appName, {
|
||||
createIfMissing: true,
|
||||
environment: config.environment ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function buildSandboxCreateParams(input: {
|
||||
config: ModalDriverConfig;
|
||||
tags: Record<string, string>;
|
||||
}): SandboxCreateParams {
|
||||
const params: SandboxCreateParams = {
|
||||
workdir: input.config.workdir,
|
||||
timeoutMs: input.config.sandboxTimeoutMs,
|
||||
blockNetwork: input.config.blockNetwork,
|
||||
};
|
||||
if (input.config.idleTimeoutMs != null) {
|
||||
params.idleTimeoutMs = input.config.idleTimeoutMs;
|
||||
}
|
||||
if (input.config.cidrAllowlist && input.config.cidrAllowlist.length > 0) {
|
||||
params.cidrAllowlist = input.config.cidrAllowlist;
|
||||
}
|
||||
// Modal sandboxes accept tag metadata via setTags after creation; the create
|
||||
// RPC does not take tags directly. We pass them through input so the caller
|
||||
// can apply them after `create` resolves.
|
||||
void input.tags;
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildSandboxTags(input: {
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
runId?: string;
|
||||
reuseLease: boolean;
|
||||
}): Record<string, string> {
|
||||
return {
|
||||
"paperclip-provider": "modal",
|
||||
"paperclip-company-id": input.companyId,
|
||||
"paperclip-environment-id": input.environmentId,
|
||||
"paperclip-reuse-lease": input.reuseLease ? "true" : "false",
|
||||
...(input.runId ? { "paperclip-run-id": input.runId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function createSandboxFor(
|
||||
client: ModalClient,
|
||||
app: App,
|
||||
config: ModalDriverConfig,
|
||||
tags: Record<string, string>,
|
||||
): Promise<Sandbox> {
|
||||
const image = client.images.fromRegistry(config.image);
|
||||
const params = buildSandboxCreateParams({ config, tags });
|
||||
const sandbox = await client.sandboxes.create(app, image, params);
|
||||
try {
|
||||
await sandbox.setTags(tags);
|
||||
} catch (error) {
|
||||
// setTags is best-effort metadata; surface but do not block lease creation.
|
||||
console.warn(`Failed to set tags on Modal sandbox ${sandbox.sandboxId}: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function leaseMetadata(input: {
|
||||
config: ModalDriverConfig;
|
||||
sandbox: Sandbox;
|
||||
remoteCwd: string;
|
||||
resumedLease: boolean;
|
||||
}) {
|
||||
return {
|
||||
provider: "modal",
|
||||
shellCommand: "sh",
|
||||
sandboxId: input.sandbox.sandboxId,
|
||||
appName: input.config.appName,
|
||||
image: input.config.image,
|
||||
sandboxTimeoutMs: input.config.sandboxTimeoutMs,
|
||||
idleTimeoutMs: input.config.idleTimeoutMs,
|
||||
reuseLease: input.config.reuseLease,
|
||||
remoteCwd: input.remoteCwd,
|
||||
resumedLease: input.resumedLease,
|
||||
};
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function isValidShellEnvKey(value: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
||||
}
|
||||
|
||||
// Modal's `sandbox.exec` takes an argv array and bypasses the shell entirely,
|
||||
// so adapter probes that rely on PATH mutations from /etc/profile or ~/.bashrc
|
||||
// do not work without an explicit login shell. Mirroring the Daytona / E2B
|
||||
// providers, wrap the user command in a `sh -lc` script that sources common
|
||||
// login profiles plus nvm before invoking it. Env is set after profile sourcing
|
||||
// so caller env wins; stdin is staged to a temp file and shell-redirected so
|
||||
// fast-failing commands do not race a streaming stdin writer.
|
||||
function buildLoginShellScript(input: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdinPath?: string;
|
||||
}): string {
|
||||
const env = input.env ?? {};
|
||||
for (const key of Object.keys(env)) {
|
||||
if (!isValidShellEnvKey(key)) {
|
||||
throw new Error(`Invalid sandbox environment variable key: ${key}`);
|
||||
}
|
||||
}
|
||||
const envArgs = Object.entries(env)
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
||||
.map(([key, value]) => `${key}=${shellQuote(value)}`);
|
||||
const commandParts = [shellQuote(input.command), ...input.args.map(shellQuote)].join(" ");
|
||||
const redirected = input.stdinPath
|
||||
? `${commandParts} < ${shellQuote(input.stdinPath)}`
|
||||
: commandParts;
|
||||
const finalLine = envArgs.length > 0 ? `exec env ${envArgs.join(" ")} ${redirected}` : `exec ${redirected}`;
|
||||
const lines = [
|
||||
'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi',
|
||||
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
|
||||
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
|
||||
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
|
||||
'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"',
|
||||
'[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true',
|
||||
];
|
||||
if (input.cwd) {
|
||||
lines.push(`cd ${shellQuote(input.cwd)}`);
|
||||
}
|
||||
lines.push(finalLine);
|
||||
return lines.join(" && ");
|
||||
}
|
||||
|
||||
async function ensureRemoteWorkspace(sandbox: Sandbox, remoteCwd: string): Promise<void> {
|
||||
// Use a one-shot exec to mkdir -p; Modal does not expose a direct
|
||||
// filesystem mkdir helper and creating a file via `open()` does not create
|
||||
// intermediate directories.
|
||||
const proc = await sandbox.exec(["sh", "-lc", `mkdir -p ${shellQuote(remoteCwd)}`]);
|
||||
const exitCode = await proc.wait();
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Failed to create remote workspace directory '${remoteCwd}': mkdir exited with code ${exitCode}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function stageStdin(sandbox: Sandbox, stdin: string, remotePath: string): Promise<void> {
|
||||
const file = await sandbox.open(remotePath, "w");
|
||||
try {
|
||||
await file.write(new TextEncoder().encode(stdin));
|
||||
await file.flush();
|
||||
} finally {
|
||||
await file.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteStdinPath(sandbox: Sandbox, remotePath: string): Promise<void> {
|
||||
// Best-effort cleanup of the staged stdin file. We swallow errors because
|
||||
// it is fine for the file to outlive the sandbox if it is going to be
|
||||
// terminated, and a missing rm tool would otherwise mask the real result.
|
||||
try {
|
||||
const proc = await sandbox.exec(["sh", "-lc", `rm -f ${shellQuote(remotePath)}`]);
|
||||
await proc.wait();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function readProcessStreams(
|
||||
proc: ContainerProcess<string>,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
proc.stdout.readText(),
|
||||
proc.stderr.readText(),
|
||||
proc.wait(),
|
||||
]);
|
||||
return { stdout, stderr, exitCode };
|
||||
}
|
||||
|
||||
function isModalNotFound(error: unknown): boolean {
|
||||
return error instanceof NotFoundError;
|
||||
}
|
||||
|
||||
async function getSandboxOrNull(
|
||||
client: ModalClient,
|
||||
providerLeaseId: string,
|
||||
): Promise<Sandbox | null> {
|
||||
try {
|
||||
return await client.sandboxes.fromId(providerLeaseId);
|
||||
} catch (error) {
|
||||
if (isModalNotFound(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function warnIfUnsupportedNode(logger: { warn: (msg: string) => void } | undefined): void {
|
||||
const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
|
||||
if (Number.isFinite(major) && major < 22) {
|
||||
const message = `Modal sandbox provider is running on Node ${process.versions.node}; Modal officially supports Node 22+. The plugin will attempt to operate but vendor support is not guaranteed below Node 22.`;
|
||||
logger?.warn(message);
|
||||
}
|
||||
}
|
||||
|
||||
function leaseRemoteCwd(metadata: Record<string, unknown> | undefined, fallback: string): string {
|
||||
if (metadata && typeof metadata.remoteCwd === "string" && metadata.remoteCwd.trim().length > 0) {
|
||||
return metadata.remoteCwd.trim();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
warnIfUnsupportedNode(ctx.logger);
|
||||
ctx.logger.info("Modal sandbox provider plugin ready");
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Modal sandbox provider plugin healthy" };
|
||||
},
|
||||
|
||||
async onEnvironmentValidateConfig(
|
||||
params: PluginEnvironmentValidateConfigParams,
|
||||
): Promise<PluginEnvironmentValidationResult> {
|
||||
const config = parseDriverConfig(params.config);
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!config.appName) {
|
||||
errors.push("Modal sandbox environments require an appName.");
|
||||
}
|
||||
if (!config.image) {
|
||||
errors.push("Modal sandbox environments require an image reference.");
|
||||
}
|
||||
if (
|
||||
config.sandboxTimeoutMs < 1000 ||
|
||||
config.sandboxTimeoutMs > MAX_SANDBOX_TIMEOUT_MS ||
|
||||
!isMultipleOf1000(config.sandboxTimeoutMs)
|
||||
) {
|
||||
errors.push(
|
||||
"sandboxTimeoutMs must be a positive multiple of 1000 between 1000 and 86400000.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
config.idleTimeoutMs != null &&
|
||||
(config.idleTimeoutMs < 1000 || !isMultipleOf1000(config.idleTimeoutMs))
|
||||
) {
|
||||
errors.push("idleTimeoutMs must be a positive multiple of 1000 when provided.");
|
||||
}
|
||||
if (config.execTimeoutMs < 1000 || !isMultipleOf1000(config.execTimeoutMs)) {
|
||||
errors.push("execTimeoutMs must be a positive multiple of 1000.");
|
||||
}
|
||||
if (config.blockNetwork && config.cidrAllowlist && config.cidrAllowlist.length > 0) {
|
||||
errors.push("cidrAllowlist cannot be combined with blockNetwork.");
|
||||
}
|
||||
const hasTokenId = Boolean(config.tokenId);
|
||||
const hasTokenSecret = Boolean(config.tokenSecret);
|
||||
if (hasTokenId !== hasTokenSecret) {
|
||||
errors.push("tokenId and tokenSecret must both be provided when either is set.");
|
||||
} else if (!hasTokenId) {
|
||||
errors.push("Modal sandbox environments require tokenId and tokenSecret.");
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { ok: false, errors };
|
||||
}
|
||||
return { ok: true, normalizedConfig: { ...config } };
|
||||
},
|
||||
|
||||
async onEnvironmentProbe(
|
||||
params: PluginEnvironmentProbeParams,
|
||||
): Promise<PluginEnvironmentProbeResult> {
|
||||
const config = parseDriverConfig(params.config);
|
||||
const tags = buildSandboxTags({
|
||||
companyId: params.companyId,
|
||||
environmentId: params.environmentId,
|
||||
reuseLease: false,
|
||||
});
|
||||
const client = createModalClient(config);
|
||||
try {
|
||||
const app = await resolveApp(client, config);
|
||||
const sandbox = await createSandboxFor(client, app, config, tags);
|
||||
try {
|
||||
await ensureRemoteWorkspace(sandbox, config.workdir);
|
||||
const proc = await sandbox.exec(["sh", "-lc", "printf paperclip-probe"]);
|
||||
const { stdout, exitCode } = await readProcessStreams(proc);
|
||||
if (exitCode !== 0 || stdout.trim() !== "paperclip-probe") {
|
||||
return {
|
||||
ok: false,
|
||||
summary: `Modal sandbox probe failed: exit ${exitCode}, stdout=${JSON.stringify(stdout)}`,
|
||||
metadata: {
|
||||
provider: "modal",
|
||||
sandboxId: sandbox.sandboxId,
|
||||
appName: config.appName,
|
||||
image: config.image,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Connected to Modal sandbox in app ${config.appName}.`,
|
||||
metadata: {
|
||||
provider: "modal",
|
||||
sandboxId: sandbox.sandboxId,
|
||||
appName: config.appName,
|
||||
image: config.image,
|
||||
workdir: config.workdir,
|
||||
sandboxTimeoutMs: config.sandboxTimeoutMs,
|
||||
idleTimeoutMs: config.idleTimeoutMs,
|
||||
reuseLease: config.reuseLease,
|
||||
remoteCwd: config.workdir,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await sandbox.terminate().catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: "Modal sandbox probe failed.",
|
||||
metadata: {
|
||||
provider: "modal",
|
||||
appName: config.appName,
|
||||
image: config.image,
|
||||
reuseLease: config.reuseLease,
|
||||
error: formatErrorMessage(error),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentAcquireLease(
|
||||
params: PluginEnvironmentAcquireLeaseParams,
|
||||
): Promise<PluginEnvironmentLease> {
|
||||
const config = parseDriverConfig(params.config);
|
||||
const client = createModalClient(config);
|
||||
try {
|
||||
const app = await resolveApp(client, config);
|
||||
const tags = buildSandboxTags({
|
||||
companyId: params.companyId,
|
||||
environmentId: params.environmentId,
|
||||
runId: params.runId,
|
||||
reuseLease: config.reuseLease,
|
||||
});
|
||||
const sandbox = await createSandboxFor(client, app, config, tags);
|
||||
try {
|
||||
await ensureRemoteWorkspace(sandbox, config.workdir);
|
||||
return {
|
||||
providerLeaseId: sandbox.sandboxId,
|
||||
metadata: leaseMetadata({
|
||||
config,
|
||||
sandbox,
|
||||
remoteCwd: config.workdir,
|
||||
resumedLease: false,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
await sandbox.terminate().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
// Keep the client open for the lease lifetime is unnecessary; subsequent
|
||||
// calls construct their own client. Close the local handle to free
|
||||
// grpc resources.
|
||||
client.close();
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentResumeLease(
|
||||
params: PluginEnvironmentResumeLeaseParams,
|
||||
): Promise<PluginEnvironmentLease> {
|
||||
const config = parseDriverConfig(params.config);
|
||||
const client = createModalClient(config);
|
||||
try {
|
||||
const sandbox = await getSandboxOrNull(client, params.providerLeaseId);
|
||||
if (!sandbox) {
|
||||
return { providerLeaseId: null, metadata: { expired: true } };
|
||||
}
|
||||
try {
|
||||
await ensureRemoteWorkspace(sandbox, config.workdir);
|
||||
return {
|
||||
providerLeaseId: sandbox.sandboxId,
|
||||
metadata: leaseMetadata({ config, sandbox, remoteCwd: config.workdir, resumedLease: true }),
|
||||
};
|
||||
} catch (error) {
|
||||
// If we just resumed and workspace setup blew up, treat as a lease
|
||||
// failure rather than silently terminating the user's reusable
|
||||
// sandbox. Detach so the sandbox is not killed for a transient setup
|
||||
// error.
|
||||
void sandbox.detach();
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentReleaseLease(
|
||||
params: PluginEnvironmentReleaseLeaseParams,
|
||||
): Promise<void> {
|
||||
if (!params.providerLeaseId) return;
|
||||
const config = parseDriverConfig(params.config);
|
||||
const client = createModalClient(config);
|
||||
try {
|
||||
const sandbox = await getSandboxOrNull(client, params.providerLeaseId);
|
||||
if (!sandbox) return;
|
||||
if (config.reuseLease) {
|
||||
// Modal has no separate pause primitive. Detaching releases the local
|
||||
// grpc connection but leaves the sandbox running on Modal until its
|
||||
// configured sandboxTimeoutMs or idleTimeoutMs expires. The next
|
||||
// acquire/resume reconnects via sandboxes.fromId(providerLeaseId).
|
||||
void sandbox.detach();
|
||||
return;
|
||||
}
|
||||
await sandbox.terminate();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentDestroyLease(
|
||||
params: PluginEnvironmentDestroyLeaseParams,
|
||||
): Promise<void> {
|
||||
if (!params.providerLeaseId) return;
|
||||
const config = parseDriverConfig(params.config);
|
||||
const client = createModalClient(config);
|
||||
try {
|
||||
const sandbox = await getSandboxOrNull(client, params.providerLeaseId);
|
||||
if (!sandbox) return;
|
||||
await sandbox.terminate();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentRealizeWorkspace(
|
||||
params: PluginEnvironmentRealizeWorkspaceParams,
|
||||
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
|
||||
const config = parseDriverConfig(params.config);
|
||||
const fallback =
|
||||
params.workspace.remotePath ??
|
||||
params.workspace.localPath ??
|
||||
config.workdir;
|
||||
const remoteCwd = leaseRemoteCwd(params.lease.metadata, fallback);
|
||||
if (params.lease.providerLeaseId) {
|
||||
const client = createModalClient(config);
|
||||
try {
|
||||
const sandbox = await getSandboxOrNull(client, params.lease.providerLeaseId);
|
||||
if (sandbox) {
|
||||
await ensureRemoteWorkspace(sandbox, remoteCwd);
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
return {
|
||||
cwd: remoteCwd,
|
||||
metadata: { provider: "modal", remoteCwd },
|
||||
};
|
||||
},
|
||||
|
||||
async onEnvironmentExecute(
|
||||
params: PluginEnvironmentExecuteParams,
|
||||
): Promise<PluginEnvironmentExecuteResult> {
|
||||
if (!params.lease.providerLeaseId) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "No provider lease ID available for execution.",
|
||||
};
|
||||
}
|
||||
const config = parseDriverConfig(params.config);
|
||||
const client = createModalClient(config);
|
||||
const callerTimeoutMs =
|
||||
params.timeoutMs != null && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0
|
||||
? Math.max(1000, Math.trunc(params.timeoutMs / 1000) * 1000)
|
||||
: config.execTimeoutMs;
|
||||
|
||||
try {
|
||||
const sandbox = await getSandboxOrNull(client, params.lease.providerLeaseId);
|
||||
if (!sandbox) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "Modal sandbox lease is no longer available.\n",
|
||||
};
|
||||
}
|
||||
const stdinPath = params.stdin != null
|
||||
? `/tmp/paperclip-stdin-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
: null;
|
||||
try {
|
||||
if (stdinPath && params.stdin != null) {
|
||||
await stageStdin(sandbox, params.stdin, stdinPath);
|
||||
}
|
||||
const script = buildLoginShellScript({
|
||||
command: params.command,
|
||||
args: params.args ?? [],
|
||||
cwd: params.cwd ?? config.workdir,
|
||||
env: params.env,
|
||||
stdinPath: stdinPath ?? undefined,
|
||||
});
|
||||
const proc = await sandbox.exec(["sh", "-lc", script], {
|
||||
timeoutMs: callerTimeoutMs,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const { stdout, stderr, exitCode } = await readProcessStreams(proc);
|
||||
return {
|
||||
exitCode,
|
||||
timedOut: false,
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TimeoutError || error instanceof SandboxTimeoutError) {
|
||||
return {
|
||||
exitCode: null,
|
||||
timedOut: true,
|
||||
stdout: "",
|
||||
stderr: `${formatErrorMessage(error)}\n`,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (stdinPath) {
|
||||
await deleteStdinPath(sandbox, stdinPath);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { runWorker } from "@paperclipai/plugin-sdk";
|
||||
import plugin from "./plugin.js";
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -100,7 +100,7 @@ runWorker(plugin, import.meta.url);
|
||||
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
|
||||
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
|
||||
|
||||
**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
|
||||
**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `access`, `authorization`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
|
||||
|
||||
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
|
||||
|
||||
@@ -134,7 +134,7 @@ Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name,
|
||||
|
||||
**Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events.
|
||||
|
||||
**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime.
|
||||
**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime. Access and authorization host services require an active company-scoped invocation such as an event, API route, tool run, environment call, or UI bridge call; the requested `companyId` must match that active scope.
|
||||
|
||||
## Scheduled (recurring) jobs
|
||||
|
||||
@@ -321,6 +321,11 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
||||
| | `activity.read` |
|
||||
| | `costs.read` |
|
||||
| | `issues.orchestration.read` |
|
||||
| | `access.members.read` |
|
||||
| | `access.invites.read` |
|
||||
| | `authorization.grants.read` |
|
||||
| | `authorization.policies.read` |
|
||||
| | `authorization.audit.read` |
|
||||
| | `database.namespace.read` |
|
||||
| | `issues.create` |
|
||||
| | `issues.update` |
|
||||
@@ -348,6 +353,10 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
||||
| | `local.folders` |
|
||||
| **Agent** | `agent.tools.register` |
|
||||
| | `agents.invoke` |
|
||||
| | `access.members.write` |
|
||||
| | `access.invites.write` |
|
||||
| | `authorization.grants.write` |
|
||||
| | `authorization.policies.write` |
|
||||
| | `agent.sessions.create` |
|
||||
| | `agent.sessions.list` |
|
||||
| | `agent.sessions.send` |
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
*/
|
||||
|
||||
import type { PluginCapability } from "@paperclipai/shared";
|
||||
import type { WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
|
||||
import type { WorkerHostCallContext, WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
|
||||
import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -73,6 +73,19 @@ export class CapabilityDeniedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a worker→host call asks for company-scoped data outside the
|
||||
* company authorized for the current top-level plugin invocation.
|
||||
*/
|
||||
export class InvocationScopeDeniedError extends Error {
|
||||
override readonly name = "InvocationScopeDeniedError";
|
||||
readonly code = PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED;
|
||||
|
||||
constructor(pluginId: string, method: string, message: string) {
|
||||
super(`Plugin "${pluginId}" is not allowed to perform "${method}": ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host service interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -181,6 +194,11 @@ export interface HostServices {
|
||||
resetManaged(params: WorkerToHostMethods["projects.managed.reset"][0]): Promise<WorkerToHostMethods["projects.managed.reset"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `executionWorkspaces.get`. */
|
||||
executionWorkspaces: {
|
||||
get(params: WorkerToHostMethods["executionWorkspaces.get"][0]): Promise<WorkerToHostMethods["executionWorkspaces.get"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `routines.managed.*`. */
|
||||
routines: {
|
||||
managedGet(params: WorkerToHostMethods["routines.managed.get"][0]): Promise<WorkerToHostMethods["routines.managed.get"][1]>;
|
||||
@@ -252,6 +270,28 @@ export interface HostServices {
|
||||
create(params: WorkerToHostMethods["goals.create"][0]): Promise<WorkerToHostMethods["goals.create"][1]>;
|
||||
update(params: WorkerToHostMethods["goals.update"][0]): Promise<WorkerToHostMethods["goals.update"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `access.members.*` and `access.invites.*`. */
|
||||
access: {
|
||||
listMembers(params: WorkerToHostMethods["access.members.list"][0]): Promise<WorkerToHostMethods["access.members.list"][1]>;
|
||||
getMember(params: WorkerToHostMethods["access.members.get"][0]): Promise<WorkerToHostMethods["access.members.get"][1]>;
|
||||
updateMember(params: WorkerToHostMethods["access.members.update"][0]): Promise<WorkerToHostMethods["access.members.update"][1]>;
|
||||
listInvites(params: WorkerToHostMethods["access.invites.list"][0]): Promise<WorkerToHostMethods["access.invites.list"][1]>;
|
||||
createInvite(params: WorkerToHostMethods["access.invites.create"][0]): Promise<WorkerToHostMethods["access.invites.create"][1]>;
|
||||
revokeInvite(params: WorkerToHostMethods["access.invites.revoke"][0]): Promise<WorkerToHostMethods["access.invites.revoke"][1]>;
|
||||
};
|
||||
|
||||
/** Provides authorization grant, policy, preview, and audit helpers. */
|
||||
authorization: {
|
||||
listGrants(params: WorkerToHostMethods["authorization.grants.list"][0]): Promise<WorkerToHostMethods["authorization.grants.list"][1]>;
|
||||
setGrants(params: WorkerToHostMethods["authorization.grants.set"][0]): Promise<WorkerToHostMethods["authorization.grants.set"][1]>;
|
||||
policySummary(params: WorkerToHostMethods["authorization.policies.summary"][0]): Promise<WorkerToHostMethods["authorization.policies.summary"][1]>;
|
||||
getPolicy(params: WorkerToHostMethods["authorization.policies.get"][0]): Promise<WorkerToHostMethods["authorization.policies.get"][1]>;
|
||||
updatePolicy(params: WorkerToHostMethods["authorization.policies.update"][0]): Promise<WorkerToHostMethods["authorization.policies.update"][1]>;
|
||||
previewAssignment(params: WorkerToHostMethods["authorization.policies.previewAssignment"][0]): Promise<WorkerToHostMethods["authorization.policies.previewAssignment"][1]>;
|
||||
explainAssignment(params: WorkerToHostMethods["authorization.policies.explainAssignment"][0]): Promise<WorkerToHostMethods["authorization.policies.explainAssignment"][1]>;
|
||||
searchAudit(params: WorkerToHostMethods["authorization.audit.search"][0]): Promise<WorkerToHostMethods["authorization.audit.search"][1]>;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -287,6 +327,7 @@ export interface HostClientFactoryOptions {
|
||||
*/
|
||||
type HostHandler<M extends WorkerToHostMethodName> = (
|
||||
params: WorkerToHostMethods[M][0],
|
||||
context?: WorkerHostCallContext,
|
||||
) => Promise<WorkerToHostMethods[M][1]>;
|
||||
|
||||
/**
|
||||
@@ -368,6 +409,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||
"projects.listWorkspaces": "project.workspaces.read",
|
||||
"projects.getPrimaryWorkspace": "project.workspaces.read",
|
||||
"projects.getWorkspaceForIssue": "project.workspaces.read",
|
||||
"executionWorkspaces.get": "execution.workspaces.read",
|
||||
"projects.managed.get": "projects.managed",
|
||||
"projects.managed.reconcile": "projects.managed",
|
||||
"projects.managed.reset": "projects.managed",
|
||||
@@ -425,6 +467,24 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||
"goals.get": "goals.read",
|
||||
"goals.create": "goals.create",
|
||||
"goals.update": "goals.update",
|
||||
|
||||
// Access
|
||||
"access.members.list": "access.members.read",
|
||||
"access.members.get": "access.members.read",
|
||||
"access.members.update": "access.members.write",
|
||||
"access.invites.list": "access.invites.read",
|
||||
"access.invites.create": "access.invites.write",
|
||||
"access.invites.revoke": "access.invites.write",
|
||||
|
||||
// Authorization
|
||||
"authorization.grants.list": "authorization.grants.read",
|
||||
"authorization.grants.set": "authorization.grants.write",
|
||||
"authorization.policies.summary": "authorization.policies.read",
|
||||
"authorization.policies.get": "authorization.policies.read",
|
||||
"authorization.policies.update": "authorization.policies.write",
|
||||
"authorization.policies.previewAssignment": "authorization.policies.read",
|
||||
"authorization.policies.explainAssignment": "authorization.policies.read",
|
||||
"authorization.audit.search": "authorization.audit.read",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -455,6 +515,81 @@ export function createHostClientHandlers(
|
||||
const { pluginId, services } = options;
|
||||
const capabilitySet = new Set<PluginCapability>(options.capabilities);
|
||||
|
||||
type CompanyScopeRequest =
|
||||
| { kind: "none" }
|
||||
| { kind: "single"; companyId: string }
|
||||
| { kind: "all" };
|
||||
|
||||
const noCompanyScope: CompanyScopeRequest = { kind: "none" };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function requestedCompanyScope(
|
||||
method: WorkerToHostMethodName,
|
||||
params: unknown,
|
||||
): CompanyScopeRequest {
|
||||
if (method === "companies.list") return { kind: "all" };
|
||||
if (!isRecord(params)) return noCompanyScope;
|
||||
|
||||
const companyId = readNonEmptyString(params.companyId);
|
||||
if (companyId) return { kind: "single", companyId };
|
||||
|
||||
if (params.scopeKind === "company") {
|
||||
const scopeId = readNonEmptyString(params.scopeId);
|
||||
return scopeId ? { kind: "single", companyId: scopeId } : { kind: "all" };
|
||||
}
|
||||
|
||||
if (method === "events.subscribe" && isRecord(params.filter)) {
|
||||
const filterCompanyId = readNonEmptyString(params.filter.companyId);
|
||||
if (filterCompanyId) return { kind: "single", companyId: filterCompanyId };
|
||||
}
|
||||
|
||||
return noCompanyScope;
|
||||
}
|
||||
|
||||
function requireInvocationCompanyScope(
|
||||
method: WorkerToHostMethodName,
|
||||
params: unknown,
|
||||
context?: WorkerHostCallContext,
|
||||
): void {
|
||||
const requested = requestedCompanyScope(method, params);
|
||||
if (requested.kind === "none") return;
|
||||
|
||||
if (context?.invalidInvocationScope) {
|
||||
throw new InvocationScopeDeniedError(
|
||||
pluginId,
|
||||
method,
|
||||
"the worker referenced a missing, expired, or unknown invocation scope",
|
||||
);
|
||||
}
|
||||
|
||||
const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId);
|
||||
if (!allowedCompanyId) return;
|
||||
|
||||
if (requested.kind === "all") {
|
||||
if (method === "companies.list") return;
|
||||
throw new InvocationScopeDeniedError(
|
||||
pluginId,
|
||||
method,
|
||||
`the current invocation is scoped to company "${allowedCompanyId}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (requested.companyId !== allowedCompanyId) {
|
||||
throw new InvocationScopeDeniedError(
|
||||
pluginId,
|
||||
method,
|
||||
`requested company "${requested.companyId}" but the current invocation is scoped to company "${allowedCompanyId}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the plugin has the required capability for a method.
|
||||
* Throws `CapabilityDeniedError` if the capability is missing.
|
||||
@@ -479,9 +614,10 @@ export function createHostClientHandlers(
|
||||
method: M,
|
||||
handler: HostHandler<M>,
|
||||
): HostHandler<M> {
|
||||
return async (params: WorkerToHostMethods[M][0]) => {
|
||||
return async (params: WorkerToHostMethods[M][0], context?: WorkerHostCallContext) => {
|
||||
requireCapability(method);
|
||||
return handler(params);
|
||||
requireInvocationCompanyScope(method, params, context);
|
||||
return handler(params, context);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -585,8 +721,13 @@ export function createHostClientHandlers(
|
||||
}),
|
||||
|
||||
// Companies
|
||||
"companies.list": gated("companies.list", async (params) => {
|
||||
return services.companies.list(params);
|
||||
"companies.list": gated("companies.list", async (params, context) => {
|
||||
const rows = await services.companies.list(params);
|
||||
const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId);
|
||||
if (!allowedCompanyId) return rows;
|
||||
return rows.filter((company) =>
|
||||
isRecord(company) && company.id === allowedCompanyId,
|
||||
) as WorkerToHostMethods["companies.list"][1];
|
||||
}),
|
||||
"companies.get": gated("companies.get", async (params) => {
|
||||
return services.companies.get(params);
|
||||
@@ -608,6 +749,9 @@ export function createHostClientHandlers(
|
||||
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
|
||||
return services.projects.getWorkspaceForIssue(params);
|
||||
}),
|
||||
"executionWorkspaces.get": gated("executionWorkspaces.get", async (params) => {
|
||||
return services.executionWorkspaces.get(params);
|
||||
}),
|
||||
"projects.managed.get": gated("projects.managed.get", async (params) => {
|
||||
return services.projects.getManaged(params);
|
||||
}),
|
||||
@@ -763,6 +907,52 @@ export function createHostClientHandlers(
|
||||
"goals.update": gated("goals.update", async (params) => {
|
||||
return services.goals.update(params);
|
||||
}),
|
||||
|
||||
// Access
|
||||
"access.members.list": gated("access.members.list", async (params) => {
|
||||
return services.access.listMembers(params);
|
||||
}),
|
||||
"access.members.get": gated("access.members.get", async (params) => {
|
||||
return services.access.getMember(params);
|
||||
}),
|
||||
"access.members.update": gated("access.members.update", async (params) => {
|
||||
return services.access.updateMember(params);
|
||||
}),
|
||||
"access.invites.list": gated("access.invites.list", async (params) => {
|
||||
return services.access.listInvites(params);
|
||||
}),
|
||||
"access.invites.create": gated("access.invites.create", async (params) => {
|
||||
return services.access.createInvite(params);
|
||||
}),
|
||||
"access.invites.revoke": gated("access.invites.revoke", async (params) => {
|
||||
return services.access.revokeInvite(params);
|
||||
}),
|
||||
|
||||
// Authorization
|
||||
"authorization.grants.list": gated("authorization.grants.list", async (params) => {
|
||||
return services.authorization.listGrants(params);
|
||||
}),
|
||||
"authorization.grants.set": gated("authorization.grants.set", async (params) => {
|
||||
return services.authorization.setGrants(params);
|
||||
}),
|
||||
"authorization.policies.summary": gated("authorization.policies.summary", async (params) => {
|
||||
return services.authorization.policySummary(params);
|
||||
}),
|
||||
"authorization.policies.get": gated("authorization.policies.get", async (params) => {
|
||||
return services.authorization.getPolicy(params);
|
||||
}),
|
||||
"authorization.policies.update": gated("authorization.policies.update", async (params) => {
|
||||
return services.authorization.updatePolicy(params);
|
||||
}),
|
||||
"authorization.policies.previewAssignment": gated("authorization.policies.previewAssignment", async (params) => {
|
||||
return services.authorization.previewAssignment(params);
|
||||
}),
|
||||
"authorization.policies.explainAssignment": gated("authorization.policies.explainAssignment", async (params) => {
|
||||
return services.authorization.explainAssignment(params);
|
||||
}),
|
||||
"authorization.audit.search": gated("authorization.audit.search", async (params) => {
|
||||
return services.authorization.searchAudit(params);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export {
|
||||
createHostClientHandlers,
|
||||
getRequiredCapability,
|
||||
CapabilityDeniedError,
|
||||
InvocationScopeDeniedError,
|
||||
} from "./host-client-factory.js";
|
||||
|
||||
// JSON-RPC protocol helpers and constants
|
||||
@@ -128,6 +129,8 @@ export type {
|
||||
// JSON-RPC protocol types
|
||||
export type {
|
||||
JsonRpcId,
|
||||
JsonRpcInvocationScope,
|
||||
JsonRpcInvocationContext,
|
||||
JsonRpcRequest,
|
||||
JsonRpcSuccessResponse,
|
||||
JsonRpcError,
|
||||
@@ -137,6 +140,9 @@ export type {
|
||||
JsonRpcMessage,
|
||||
JsonRpcErrorCode,
|
||||
PluginRpcErrorCode,
|
||||
PluginInvocationScope,
|
||||
PluginInvocationContext,
|
||||
WorkerHostCallContext,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
ConfigChangedParams,
|
||||
@@ -145,6 +151,9 @@ export type {
|
||||
RunJobParams,
|
||||
GetDataParams,
|
||||
PerformActionParams,
|
||||
PluginPerformActionActorType,
|
||||
PluginPerformActionActorContext,
|
||||
PluginPerformActionContext,
|
||||
ExecuteToolParams,
|
||||
PluginEnvironmentDiagnostic,
|
||||
PluginEnvironmentDriverBaseParams,
|
||||
@@ -197,6 +206,7 @@ export type {
|
||||
PluginStateClient,
|
||||
PluginEntitiesClient,
|
||||
PluginProjectsClient,
|
||||
PluginExecutionWorkspacesClient,
|
||||
PluginSkillsClient,
|
||||
PluginCompaniesClient,
|
||||
PluginIssuesClient,
|
||||
@@ -217,6 +227,17 @@ export type {
|
||||
PluginIssueSubtree,
|
||||
PluginIssueSummariesClient,
|
||||
PluginAgentsClient,
|
||||
PluginAccessClient,
|
||||
PluginAccessMembersClient,
|
||||
PluginAccessInvitesClient,
|
||||
PluginAccessMember,
|
||||
PluginAccessInvite,
|
||||
PluginAuthorizationClient,
|
||||
PluginAuthorizationPolicySummary,
|
||||
PluginAuthorizationPolicyRecord,
|
||||
PluginAssignmentPreviewInput,
|
||||
PluginAuthorizationDecisionResult,
|
||||
PluginAuthorizationAuditEntry,
|
||||
PluginAgentSessionsClient,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
@@ -244,6 +265,7 @@ export type {
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginWorkspace,
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
Company,
|
||||
Project,
|
||||
Issue,
|
||||
@@ -251,7 +273,12 @@ export type {
|
||||
IssueDocumentSummary,
|
||||
Agent,
|
||||
Goal,
|
||||
PermissionKey,
|
||||
PrincipalPermissionGrant,
|
||||
PrincipalType,
|
||||
PluginDatabaseClient,
|
||||
HumanCompanyMembershipRole,
|
||||
MembershipStatus,
|
||||
} from "./types.js";
|
||||
|
||||
// Manifest and constant types re-exported from @paperclipai/shared
|
||||
@@ -351,6 +378,7 @@ export {
|
||||
PLUGIN_CAPABILITIES,
|
||||
PLUGIN_UI_SLOT_TYPES,
|
||||
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
||||
PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS,
|
||||
PLUGIN_STATE_SCOPE_KINDS,
|
||||
PLUGIN_JOB_STATUSES,
|
||||
PLUGIN_JOB_RUN_STATUSES,
|
||||
@@ -358,4 +386,9 @@ export {
|
||||
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
|
||||
PLUGIN_EVENT_TYPES,
|
||||
PLUGIN_BRIDGE_ERROR_CODES,
|
||||
PERMISSION_KEYS,
|
||||
HUMAN_COMPANY_MEMBERSHIP_ROLES,
|
||||
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
|
||||
MEMBERSHIP_STATUSES,
|
||||
PRINCIPAL_TYPES,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
Agent,
|
||||
Goal,
|
||||
PluginLocalFolderDeclaration,
|
||||
PrincipalPermissionGrant,
|
||||
} from "@paperclipai/shared";
|
||||
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
|
||||
|
||||
@@ -51,11 +52,19 @@ import type {
|
||||
PluginIssueWakeupBatchResult,
|
||||
PluginIssueWakeupResult,
|
||||
PluginJobContext,
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
PluginWorkspace,
|
||||
ToolRunContext,
|
||||
ToolResult,
|
||||
PluginLocalFolderListing,
|
||||
PluginLocalFolderStatus,
|
||||
PluginAccessInvite,
|
||||
PluginAccessMember,
|
||||
PluginAssignmentPreviewInput,
|
||||
PluginAuthorizationAuditEntry,
|
||||
PluginAuthorizationDecisionResult,
|
||||
PluginAuthorizationPolicyRecord,
|
||||
PluginAuthorizationPolicySummary,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
PluginHealthDiagnostics,
|
||||
@@ -78,6 +87,19 @@ export const JSONRPC_VERSION = "2.0" as const;
|
||||
*/
|
||||
export type JsonRpcId = string | number;
|
||||
|
||||
/**
|
||||
* Host-owned scope attached to a host→worker invocation. Workers may echo the
|
||||
* invocation id on nested worker→host calls, but they never author this scope.
|
||||
*/
|
||||
export interface JsonRpcInvocationScope {
|
||||
readonly companyId?: string | null;
|
||||
}
|
||||
|
||||
export interface JsonRpcInvocationContext {
|
||||
readonly id: string;
|
||||
readonly scope: JsonRpcInvocationScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON-RPC 2.0 request message.
|
||||
*
|
||||
@@ -95,6 +117,14 @@ export interface JsonRpcRequest<
|
||||
readonly method: TMethod;
|
||||
/** Structured parameters for the method call. */
|
||||
readonly params: TParams;
|
||||
/**
|
||||
* Host-issued metadata for the top-level plugin invocation that is currently
|
||||
* executing. The worker treats this as opaque and echoes only the id on
|
||||
* worker→host calls made from the same async execution context.
|
||||
*/
|
||||
readonly paperclipInvocation?: PluginInvocationContext;
|
||||
/** Opaque top-level invocation id echoed by worker→host requests. */
|
||||
readonly paperclipInvocationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,6 +185,13 @@ export interface JsonRpcNotification<
|
||||
readonly method: TMethod;
|
||||
/** Structured parameters for the notification. */
|
||||
readonly params: TParams;
|
||||
/**
|
||||
* Host-issued metadata for host→worker push notifications such as events.
|
||||
* Worker→host notifications echo only `paperclipInvocationId`.
|
||||
*/
|
||||
readonly paperclipInvocation?: PluginInvocationContext;
|
||||
/** Opaque top-level invocation id echoed by worker→host notifications. */
|
||||
readonly paperclipInvocationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,6 +246,8 @@ export const PLUGIN_RPC_ERROR_CODES = {
|
||||
TIMEOUT: -32003,
|
||||
/** The worker does not implement the requested optional method. */
|
||||
METHOD_NOT_IMPLEMENTED: -32004,
|
||||
/** The worker→host call attempted to escape the current invocation company scope. */
|
||||
INVOCATION_SCOPE_DENIED: -32005,
|
||||
/** A catch-all for errors that do not fit other categories. */
|
||||
UNKNOWN: -32099,
|
||||
} as const;
|
||||
@@ -216,6 +255,36 @@ export const PLUGIN_RPC_ERROR_CODES = {
|
||||
export type PluginRpcErrorCode =
|
||||
(typeof PLUGIN_RPC_ERROR_CODES)[keyof typeof PLUGIN_RPC_ERROR_CODES];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invocation scope metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Company scope attached by the host to one top-level plugin invocation.
|
||||
* Absence of this metadata means the invocation is instance/global scoped.
|
||||
*/
|
||||
export interface PluginInvocationScope {
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opaque invocation metadata generated by the host. Workers must not derive or
|
||||
* mutate this. They only echo the id on nested worker→host RPC calls.
|
||||
*/
|
||||
export interface PluginInvocationContext {
|
||||
id: string;
|
||||
scope: PluginInvocationScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context provided to host-side worker→host handlers after the worker echoes a
|
||||
* host-issued invocation id.
|
||||
*/
|
||||
export interface WorkerHostCallContext {
|
||||
invocationScope?: PluginInvocationScope | null;
|
||||
invalidInvocationScope?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host → Worker Method Signatures (§13 Host-Worker Protocol)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -301,6 +370,8 @@ export interface RunJobParams {
|
||||
export interface GetDataParams {
|
||||
/** Plugin-defined data key (e.g. `"sync-health"`). */
|
||||
key: string;
|
||||
/** Host-authorized active company scope, when this bridge call is company-scoped. */
|
||||
companyId?: string | null;
|
||||
/** Context and query parameters from the UI. */
|
||||
params: Record<string, unknown>;
|
||||
/** Optional launcher/container metadata from the host render environment. */
|
||||
@@ -312,11 +383,37 @@ export interface GetDataParams {
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.9 — `performAction`
|
||||
*/
|
||||
export type PluginPerformActionActorType = "user" | "agent" | "system";
|
||||
|
||||
export interface PluginPerformActionActorContext {
|
||||
/** Authenticated principal type resolved by the Paperclip host. */
|
||||
type: PluginPerformActionActorType;
|
||||
/** Authenticated board user id when `type === "user"`, otherwise null. */
|
||||
userId: string | null;
|
||||
/** Authenticated agent id when `type === "agent"`, otherwise null. */
|
||||
agentId: string | null;
|
||||
/** Authenticated heartbeat/run id when available. */
|
||||
runId: string | null;
|
||||
/** Company id authorized by the host bridge for this action, when applicable. */
|
||||
companyId: string | null;
|
||||
}
|
||||
|
||||
export interface PluginPerformActionContext {
|
||||
/** Immutable authenticated actor context supplied by the host. */
|
||||
actor: Readonly<PluginPerformActionActorContext>;
|
||||
/** Convenience alias for `actor.companyId`. */
|
||||
companyId: string | null;
|
||||
}
|
||||
|
||||
export interface PerformActionParams {
|
||||
/** Plugin-defined action key (e.g. `"resync"`). */
|
||||
key: string;
|
||||
/** Host-authorized active company scope, when this bridge call is company-scoped. */
|
||||
companyId?: string | null;
|
||||
/** Action parameters from the UI. */
|
||||
params: Record<string, unknown>;
|
||||
/** Authenticated actor context resolved by the host, never by caller params. */
|
||||
actorContext?: PluginPerformActionActorContext | null;
|
||||
/** Optional launcher/container metadata from the host render environment. */
|
||||
renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
|
||||
}
|
||||
@@ -792,6 +889,13 @@ export interface WorkerToHostMethods {
|
||||
params: { issueId: string; companyId: string },
|
||||
result: PluginWorkspace | null,
|
||||
];
|
||||
"executionWorkspaces.get": [
|
||||
params: {
|
||||
workspaceId: string;
|
||||
companyId: string;
|
||||
},
|
||||
result: PluginExecutionWorkspaceMetadata | null,
|
||||
];
|
||||
"projects.managed.get": [
|
||||
params: { projectKey: string; companyId: string },
|
||||
result: PluginManagedProjectResolution,
|
||||
@@ -1135,6 +1239,105 @@ export interface WorkerToHostMethods {
|
||||
},
|
||||
result: Goal,
|
||||
];
|
||||
|
||||
// Access
|
||||
"access.members.list": [
|
||||
params: { companyId: string; includeArchived?: boolean },
|
||||
result: PluginAccessMember[],
|
||||
];
|
||||
"access.members.get": [
|
||||
params: { memberId: string; companyId: string },
|
||||
result: PluginAccessMember | null,
|
||||
];
|
||||
"access.members.update": [
|
||||
params: {
|
||||
memberId: string;
|
||||
companyId: string;
|
||||
patch: {
|
||||
membershipRole?: string | null;
|
||||
status?: "pending" | "active" | "suspended";
|
||||
};
|
||||
},
|
||||
result: PluginAccessMember,
|
||||
];
|
||||
"access.invites.list": [
|
||||
params: {
|
||||
companyId: string;
|
||||
state?: "active" | "revoked" | "accepted" | "expired";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
result: { invites: PluginAccessInvite[]; nextOffset: number | null },
|
||||
];
|
||||
"access.invites.create": [
|
||||
params: {
|
||||
companyId: string;
|
||||
allowedJoinTypes?: "human" | "agent" | "both";
|
||||
humanRole?: string | null;
|
||||
defaultsPayload?: Record<string, unknown> | null;
|
||||
agentMessage?: string | null;
|
||||
},
|
||||
result: PluginAccessInvite & { token: string },
|
||||
];
|
||||
"access.invites.revoke": [
|
||||
params: { inviteId: string; companyId: string },
|
||||
result: PluginAccessInvite,
|
||||
];
|
||||
|
||||
// Authorization
|
||||
"authorization.grants.list": [
|
||||
params: { companyId: string; principalType?: string; principalId?: string },
|
||||
result: PrincipalPermissionGrant[],
|
||||
];
|
||||
"authorization.grants.set": [
|
||||
params: {
|
||||
companyId: string;
|
||||
principalType: string;
|
||||
principalId: string;
|
||||
grants: Array<{ permissionKey: string; scope?: Record<string, unknown> | null }>;
|
||||
grantedByUserId?: string | null;
|
||||
},
|
||||
result: PrincipalPermissionGrant[],
|
||||
];
|
||||
"authorization.policies.summary": [
|
||||
params: { companyId: string },
|
||||
result: PluginAuthorizationPolicySummary,
|
||||
];
|
||||
"authorization.policies.get": [
|
||||
params: { companyId: string; resourceType: "company" | "agent" | "project" | "issue"; resourceId: string },
|
||||
result: PluginAuthorizationPolicyRecord | null,
|
||||
];
|
||||
"authorization.policies.update": [
|
||||
params: {
|
||||
companyId: string;
|
||||
resourceType: "company" | "agent" | "project" | "issue";
|
||||
resourceId: string;
|
||||
policy: Record<string, unknown> | null;
|
||||
},
|
||||
result: PluginAuthorizationPolicyRecord,
|
||||
];
|
||||
"authorization.policies.previewAssignment": [
|
||||
params: PluginAssignmentPreviewInput,
|
||||
result: PluginAuthorizationDecisionResult,
|
||||
];
|
||||
"authorization.policies.explainAssignment": [
|
||||
params: PluginAssignmentPreviewInput,
|
||||
result: PluginAuthorizationDecisionResult,
|
||||
];
|
||||
"authorization.audit.search": [
|
||||
params: {
|
||||
companyId: string;
|
||||
action?: string;
|
||||
actorType?: string;
|
||||
actorId?: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
decision?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
result: PluginAuthorizationAuditEntry[],
|
||||
];
|
||||
}
|
||||
|
||||
/** Union of all worker→host method names. */
|
||||
|
||||
@@ -33,10 +33,15 @@ import type {
|
||||
ToolResult,
|
||||
ToolRunContext,
|
||||
PluginWorkspace,
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
PluginLocalFolderEntry,
|
||||
PluginLocalFolderStatus,
|
||||
PluginAccessMember,
|
||||
PrincipalPermissionGrant,
|
||||
PermissionKey,
|
||||
PrincipalType,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
@@ -52,6 +57,8 @@ import type {
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
PluginEnvironmentExecuteParams,
|
||||
PluginEnvironmentExecuteResult,
|
||||
PluginPerformActionActorContext,
|
||||
PluginPerformActionContext,
|
||||
} from "./protocol.js";
|
||||
|
||||
export interface TestHarnessOptions {
|
||||
@@ -69,10 +76,24 @@ export interface TestHarnessLogEntry {
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestHarnessPerformActionOptions {
|
||||
/**
|
||||
* Authenticated actor context to expose to the action handler. Omitted fields
|
||||
* default to null, and `type` defaults to `system`.
|
||||
*/
|
||||
actor?: Partial<PluginPerformActionActorContext> | null;
|
||||
/**
|
||||
* Host-authorized company scope. When provided, this is injected into
|
||||
* `params.companyId` so tests match the production bridge's anti-spoofing
|
||||
* behavior.
|
||||
*/
|
||||
companyId?: string | null;
|
||||
}
|
||||
|
||||
export interface TestHarness {
|
||||
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
|
||||
ctx: PluginContext;
|
||||
/** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
|
||||
/** Seed host entities for `ctx.companies/projects/issues/agents/goals/access/authorization` reads. */
|
||||
seed(input: {
|
||||
companies?: Company[];
|
||||
projects?: Project[];
|
||||
@@ -80,6 +101,10 @@ export interface TestHarness {
|
||||
issueComments?: IssueComment[];
|
||||
agents?: Agent[];
|
||||
goals?: Goal[];
|
||||
projectWorkspaces?: PluginWorkspace[];
|
||||
executionWorkspaces?: PluginExecutionWorkspaceMetadata[];
|
||||
accessMembers?: PluginAccessMember[];
|
||||
principalGrants?: PrincipalPermissionGrant[];
|
||||
}): void;
|
||||
setConfig(config: Record<string, unknown>): void;
|
||||
/** Dispatch a host or plugin event to registered handlers. */
|
||||
@@ -89,7 +114,11 @@ export interface TestHarness {
|
||||
/** Invoke a `ctx.data.register(...)` handler by key. */
|
||||
getData<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||
/** Invoke a `ctx.actions.register(...)` handler by key. */
|
||||
performAction<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||
performAction<T = unknown>(
|
||||
key: string,
|
||||
params?: Record<string, unknown>,
|
||||
options?: TestHarnessPerformActionOptions,
|
||||
): Promise<T>;
|
||||
/** Execute a registered tool handler via `ctx.tools.execute(...)`. */
|
||||
executeTool<T = ToolResult>(name: string, params: unknown, runCtx?: Partial<ToolRunContext>): Promise<T>;
|
||||
/** Read raw in-memory state for assertions. */
|
||||
@@ -437,7 +466,41 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const issueDocuments = new Map<string, IssueDocument>();
|
||||
const agents = new Map<string, Agent>();
|
||||
const goals = new Map<string, Goal>();
|
||||
const accessMembers = new Map<string, PluginAccessMember>();
|
||||
const principalGrants = new Map<string, PrincipalPermissionGrant[]>();
|
||||
|
||||
function principalGrantsKey(companyId: string, principalType: PrincipalType, principalId: string) {
|
||||
return `${companyId}:${principalType}:${principalId}`;
|
||||
}
|
||||
function getPrincipalGrants(companyId: string, principalType: PrincipalType, principalId: string) {
|
||||
return principalGrants.get(principalGrantsKey(companyId, principalType, principalId)) ?? [];
|
||||
}
|
||||
function setPrincipalGrants(
|
||||
companyId: string,
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
grants: Array<{ permissionKey: PermissionKey; scope?: Record<string, unknown> | null }>,
|
||||
) {
|
||||
const stamped = grants.map((grant) => ({
|
||||
principalType,
|
||||
principalId,
|
||||
permissionKey: grant.permissionKey,
|
||||
scope: grant.scope && typeof grant.scope === "object" ? grant.scope : null,
|
||||
})) as PrincipalPermissionGrant[];
|
||||
principalGrants.set(principalGrantsKey(companyId, principalType, principalId), stamped);
|
||||
const member = [...accessMembers.values()].find(
|
||||
(entry) =>
|
||||
entry.companyId === companyId
|
||||
&& entry.principalType === principalType
|
||||
&& entry.principalId === principalId,
|
||||
);
|
||||
if (member) {
|
||||
accessMembers.set(member.id, { ...member, grants: stamped, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
return stamped;
|
||||
}
|
||||
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
|
||||
const executionWorkspaces = new Map<string, PluginExecutionWorkspaceMetadata>();
|
||||
const localFolderStatuses = new Map<string, PluginLocalFolderStatus>();
|
||||
const localFolderFiles = new Map<string, string>();
|
||||
|
||||
@@ -448,7 +511,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const jobs = new Map<string, (job: PluginJobContext) => Promise<void>>();
|
||||
const launchers = new Map<string, PluginLauncherRegistration>();
|
||||
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<
|
||||
string,
|
||||
(params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>
|
||||
>();
|
||||
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
|
||||
|
||||
function localFolderKey(companyId: string, folderKey: string): string {
|
||||
@@ -459,6 +525,41 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
return `${localFolderKey(companyId, folderKey)}:${relativePath}`;
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] {
|
||||
return value === "user" || value === "agent" || value === "system" ? value : "system";
|
||||
}
|
||||
|
||||
function actionContextFor(
|
||||
params: Record<string, unknown>,
|
||||
options?: TestHarnessPerformActionOptions,
|
||||
): PluginPerformActionContext {
|
||||
const actorInput = options?.actor ?? null;
|
||||
const companyId = stringOrNull(options?.companyId) ?? stringOrNull(actorInput?.companyId) ?? stringOrNull(params.companyId);
|
||||
const actor = Object.freeze({
|
||||
type: actorTypeOrSystem(actorInput?.type),
|
||||
userId: stringOrNull(actorInput?.userId),
|
||||
agentId: stringOrNull(actorInput?.agentId),
|
||||
runId: stringOrNull(actorInput?.runId),
|
||||
companyId,
|
||||
});
|
||||
return Object.freeze({ actor, companyId });
|
||||
}
|
||||
|
||||
function paramsWithHostCompanyScope(
|
||||
params: Record<string, unknown>,
|
||||
context: PluginPerformActionContext,
|
||||
options?: TestHarnessPerformActionOptions,
|
||||
): Record<string, unknown> {
|
||||
if (Object.prototype.hasOwnProperty.call(options ?? {}, "companyId")) {
|
||||
return context.companyId ? { ...params, companyId: context.companyId } : { ...params };
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function normalizeLocalFolderRelativePath(relativePath: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const segment of relativePath.split(/[\\/]+/)) {
|
||||
@@ -975,6 +1076,13 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
},
|
||||
},
|
||||
},
|
||||
executionWorkspaces: {
|
||||
async get(workspaceId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "execution.workspaces.read");
|
||||
const workspace = executionWorkspaces.get(workspaceId);
|
||||
return workspace?.companyId === companyId ? workspace : null;
|
||||
},
|
||||
},
|
||||
routines: {
|
||||
managed: {
|
||||
async get(routineKey, companyId) {
|
||||
@@ -1604,6 +1712,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
createdByUserId: existing?.createdByUserId ?? null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
lockedAt: existing?.lockedAt ?? null,
|
||||
lockedByAgentId: existing?.lockedByAgentId ?? null,
|
||||
lockedByUserId: existing?.lockedByUserId ?? null,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
body: input.body,
|
||||
@@ -1969,6 +2080,156 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
return updated;
|
||||
},
|
||||
},
|
||||
access: {
|
||||
members: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "access.members.read");
|
||||
const cid = requireCompanyId(input.companyId);
|
||||
const includeArchived = input.includeArchived === true;
|
||||
return [...accessMembers.values()]
|
||||
.filter((member) => member.companyId === cid)
|
||||
.filter((member) => includeArchived || member.status !== ("archived" as PluginAccessMember["status"]))
|
||||
.map((member) => ({
|
||||
...member,
|
||||
grants: getPrincipalGrants(cid, member.principalType, member.principalId),
|
||||
}));
|
||||
},
|
||||
async get(memberId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "access.members.read");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const member = accessMembers.get(memberId);
|
||||
if (!member || member.companyId !== cid) return null;
|
||||
return {
|
||||
...member,
|
||||
grants: getPrincipalGrants(cid, member.principalType, member.principalId),
|
||||
};
|
||||
},
|
||||
async update(memberId, patch, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "access.members.write");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const member = accessMembers.get(memberId);
|
||||
if (!member || member.companyId !== cid) {
|
||||
throw new Error(`Membership not found: ${memberId}`);
|
||||
}
|
||||
const updated: PluginAccessMember = {
|
||||
...member,
|
||||
membershipRole: patch.membershipRole === undefined ? member.membershipRole : patch.membershipRole,
|
||||
status: patch.status === undefined ? member.status : patch.status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
accessMembers.set(memberId, updated);
|
||||
return {
|
||||
...updated,
|
||||
grants: getPrincipalGrants(cid, updated.principalType, updated.principalId),
|
||||
};
|
||||
},
|
||||
},
|
||||
invites: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "access.invites.read");
|
||||
requireCompanyId(input.companyId);
|
||||
return { invites: [], nextOffset: null };
|
||||
},
|
||||
async create(input) {
|
||||
requireCapability(manifest, capabilitySet, "access.invites.write");
|
||||
requireCompanyId(input.companyId);
|
||||
throw new Error("Invite creation is not implemented in the plugin test harness");
|
||||
},
|
||||
async revoke(inviteId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "access.invites.write");
|
||||
requireCompanyId(companyId);
|
||||
throw new Error(`Invite not found: ${inviteId}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
authorization: {
|
||||
grants: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "authorization.grants.read");
|
||||
const cid = requireCompanyId(input.companyId);
|
||||
if (input.principalType && input.principalId) {
|
||||
return getPrincipalGrants(cid, input.principalType, input.principalId);
|
||||
}
|
||||
const out: PrincipalPermissionGrant[] = [];
|
||||
for (const [key, grants] of principalGrants.entries()) {
|
||||
if (!key.startsWith(`${cid}:`)) continue;
|
||||
for (const grant of grants) {
|
||||
if (input.principalType && grant.principalType !== input.principalType) continue;
|
||||
if (input.principalId && grant.principalId !== input.principalId) continue;
|
||||
out.push(grant);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
async set(input) {
|
||||
requireCapability(manifest, capabilitySet, "authorization.grants.write");
|
||||
const cid = requireCompanyId(input.companyId);
|
||||
return setPrincipalGrants(cid, input.principalType, input.principalId, input.grants);
|
||||
},
|
||||
},
|
||||
policies: {
|
||||
async summary(companyId) {
|
||||
requireCapability(manifest, capabilitySet, "authorization.policies.read");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const members = [...accessMembers.values()].filter((member) => member.companyId === cid);
|
||||
let grantCount = 0;
|
||||
for (const [key, grants] of principalGrants.entries()) {
|
||||
if (key.startsWith(`${cid}:`)) grantCount += grants.length;
|
||||
}
|
||||
return {
|
||||
companyId: cid,
|
||||
permissionsMode: "simple",
|
||||
memberCount: members.length,
|
||||
activeMemberCount: members.filter((member) => member.status === "active").length,
|
||||
grantCount,
|
||||
advancedPolicyAvailable: false,
|
||||
};
|
||||
},
|
||||
async get(input) {
|
||||
requireCapability(manifest, capabilitySet, "authorization.policies.read");
|
||||
requireCompanyId(input.companyId);
|
||||
return null;
|
||||
},
|
||||
async update(input) {
|
||||
requireCapability(manifest, capabilitySet, "authorization.policies.write");
|
||||
const cid = requireCompanyId(input.companyId);
|
||||
return {
|
||||
companyId: cid,
|
||||
resourceType: input.resourceType,
|
||||
resourceId: input.resourceId,
|
||||
policy: input.policy,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
async previewAssignment(input) {
|
||||
requireCapability(manifest, capabilitySet, "authorization.policies.read");
|
||||
requireCompanyId(input.companyId);
|
||||
return {
|
||||
allowed: true,
|
||||
action: "issue.assign",
|
||||
explanation: "Allowed by simple company-wide defaults in the plugin test harness.",
|
||||
reason: "simple_mode",
|
||||
};
|
||||
},
|
||||
async explainAssignment(input) {
|
||||
requireCapability(manifest, capabilitySet, "authorization.policies.read");
|
||||
requireCompanyId(input.companyId);
|
||||
return {
|
||||
allowed: true,
|
||||
action: "issue.assign",
|
||||
explanation: "Allowed by simple company-wide defaults in the plugin test harness.",
|
||||
reason: "simple_mode",
|
||||
};
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
async search(input) {
|
||||
requireCapability(manifest, capabilitySet, "authorization.audit.read");
|
||||
requireCompanyId(input.companyId);
|
||||
return [];
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
register(key, handler) {
|
||||
dataHandlers.set(key, handler);
|
||||
@@ -2045,6 +2306,18 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
}
|
||||
for (const row of input.agents ?? []) agents.set(row.id, row);
|
||||
for (const row of input.goals ?? []) goals.set(row.id, row);
|
||||
for (const row of input.projectWorkspaces ?? []) {
|
||||
const list = projectWorkspaces.get(row.projectId) ?? [];
|
||||
list.push(row);
|
||||
projectWorkspaces.set(row.projectId, list);
|
||||
}
|
||||
for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row);
|
||||
for (const row of input.accessMembers ?? []) accessMembers.set(row.id, row);
|
||||
for (const row of input.principalGrants ?? []) {
|
||||
const list = principalGrants.get(principalGrantsKey(row.companyId, row.principalType, row.principalId)) ?? [];
|
||||
list.push(row);
|
||||
principalGrants.set(principalGrantsKey(row.companyId, row.principalType, row.principalId), list);
|
||||
}
|
||||
},
|
||||
setConfig(config) {
|
||||
currentConfig = { ...config };
|
||||
@@ -2087,10 +2360,15 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
if (!handler) throw new Error(`No data handler registered for '${key}'`);
|
||||
return await handler(params) as T;
|
||||
},
|
||||
async performAction<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
||||
async performAction<T = unknown>(
|
||||
key: string,
|
||||
params: Record<string, unknown> = {},
|
||||
options?: TestHarnessPerformActionOptions,
|
||||
) {
|
||||
const handler = actionHandlers.get(key);
|
||||
if (!handler) throw new Error(`No action handler registered for '${key}'`);
|
||||
return await handler(params) as T;
|
||||
const context = actionContextFor(params, options);
|
||||
return await handler(paramsWithHostCompanyScope(params, context, options), context) as T;
|
||||
},
|
||||
async executeTool<T = ToolResult>(name: string, params: unknown, runCtx: Partial<ToolRunContext> = {}) {
|
||||
const handler = toolHandlers.get(name);
|
||||
|
||||
@@ -39,7 +39,14 @@ import type {
|
||||
RoutineRun,
|
||||
Agent,
|
||||
Goal,
|
||||
HumanCompanyMembershipRole,
|
||||
InviteJoinType,
|
||||
MembershipStatus,
|
||||
PermissionKey,
|
||||
PrincipalPermissionGrant,
|
||||
PrincipalType,
|
||||
} from "@paperclipai/shared";
|
||||
import type { PluginPerformActionContext } from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-exports from @paperclipai/shared (plugin authors import from one place)
|
||||
@@ -120,6 +127,12 @@ export type {
|
||||
IssueSurfaceVisibility,
|
||||
Agent,
|
||||
Goal,
|
||||
HumanCompanyMembershipRole,
|
||||
InviteJoinType,
|
||||
MembershipStatus,
|
||||
PermissionKey,
|
||||
PrincipalPermissionGrant,
|
||||
PrincipalType,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -344,6 +357,12 @@ export interface PluginWorkspace {
|
||||
name: string;
|
||||
/** Absolute filesystem path to the workspace directory. */
|
||||
path: string;
|
||||
/** Repository URL, when known. */
|
||||
repoUrl: string | null;
|
||||
/** Checkout/ref requested for the workspace, when known. */
|
||||
repoRef: string | null;
|
||||
/** Default comparison ref for workspace tooling, when known. */
|
||||
defaultRef: string | null;
|
||||
/** Whether this is the project's primary workspace. */
|
||||
isPrimary: boolean;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
@@ -352,6 +371,40 @@ export interface PluginWorkspace {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution workspace metadata (read-only via ctx.executionWorkspaces)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Plugin-safe execution workspace metadata provided by the host. This exposes
|
||||
* the local/repository coordinates plugins need for workspace tooling without
|
||||
* giving the SDK a host-owned diff engine.
|
||||
*/
|
||||
export interface PluginExecutionWorkspaceMetadata {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** UUID of the owning company. */
|
||||
companyId: string;
|
||||
/** UUID of the parent project. */
|
||||
projectId: string;
|
||||
/** UUID of the backing project workspace, when present. */
|
||||
projectWorkspaceId: string | null;
|
||||
/** Absolute filesystem path to the workspace when locally realized. */
|
||||
path: string | null;
|
||||
/** Current working directory for local workspace tooling. */
|
||||
cwd: string | null;
|
||||
/** Repository URL, when known. */
|
||||
repoUrl: string | null;
|
||||
/** Base ref configured for the workspace, when known. */
|
||||
baseRef: string | null;
|
||||
/** Branch name configured for the workspace, when known. */
|
||||
branchName: string | null;
|
||||
/** Host provider type for the realized workspace. */
|
||||
providerType: string | null;
|
||||
/** Provider metadata already safe for plugin consumption. */
|
||||
providerMetadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host API surfaces exposed via PluginContext
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -818,6 +871,19 @@ export interface PluginProjectsClient {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.executionWorkspaces` — read execution workspace metadata.
|
||||
*
|
||||
* Requires `execution.workspaces.read`.
|
||||
*/
|
||||
export interface PluginExecutionWorkspacesClient {
|
||||
/**
|
||||
* Return plugin-safe metadata for an execution workspace. The host enforces
|
||||
* company access before returning any workspace coordinates.
|
||||
*/
|
||||
get(workspaceId: string, companyId: string): Promise<PluginExecutionWorkspaceMetadata | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.routines` — resolve and reconcile plugin-managed Paperclip routines.
|
||||
*
|
||||
@@ -892,9 +958,12 @@ export interface PluginActionsClient {
|
||||
* Register a handler for a plugin-defined action key.
|
||||
*
|
||||
* @param key - Stable string identifier for this action (e.g. `"resync"`)
|
||||
* @param handler - Async function that receives action params and returns a result
|
||||
* @param handler - Async function that receives action params plus immutable host actor context and returns a result
|
||||
*/
|
||||
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void;
|
||||
register(
|
||||
key: string,
|
||||
handler: (params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>,
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1523,6 +1592,169 @@ export interface PluginGoalsClient {
|
||||
): Promise<Goal>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Access and Authorization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PluginAccessMember {
|
||||
id: string;
|
||||
companyId: string;
|
||||
principalType: PrincipalType;
|
||||
principalId: string;
|
||||
status: MembershipStatus;
|
||||
membershipRole: string | null;
|
||||
grants: PrincipalPermissionGrant[];
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
export interface PluginAccessInvite {
|
||||
id: string;
|
||||
companyId: string | null;
|
||||
inviteType: string;
|
||||
allowedJoinTypes: InviteJoinType;
|
||||
defaultsPayload: Record<string, unknown> | null;
|
||||
expiresAt: Date | string;
|
||||
invitedByUserId: string | null;
|
||||
revokedAt: Date | string | null;
|
||||
acceptedAt: Date | string | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
state: "active" | "revoked" | "accepted" | "expired";
|
||||
}
|
||||
|
||||
export interface PluginAccessMembersClient {
|
||||
list(input: { companyId: string; includeArchived?: boolean }): Promise<PluginAccessMember[]>;
|
||||
get(memberId: string, companyId: string): Promise<PluginAccessMember | null>;
|
||||
update(
|
||||
memberId: string,
|
||||
patch: {
|
||||
membershipRole?: HumanCompanyMembershipRole | null;
|
||||
status?: Extract<MembershipStatus, "pending" | "active" | "suspended">;
|
||||
},
|
||||
companyId: string,
|
||||
): Promise<PluginAccessMember>;
|
||||
}
|
||||
|
||||
export interface PluginAccessInvitesClient {
|
||||
list(input: {
|
||||
companyId: string;
|
||||
state?: PluginAccessInvite["state"];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ invites: PluginAccessInvite[]; nextOffset: number | null }>;
|
||||
create(input: {
|
||||
companyId: string;
|
||||
allowedJoinTypes?: InviteJoinType;
|
||||
humanRole?: HumanCompanyMembershipRole | null;
|
||||
defaultsPayload?: Record<string, unknown> | null;
|
||||
agentMessage?: string | null;
|
||||
}): Promise<PluginAccessInvite & { token: string }>;
|
||||
revoke(inviteId: string, companyId: string): Promise<PluginAccessInvite>;
|
||||
}
|
||||
|
||||
export interface PluginAccessClient {
|
||||
/** Read and update company memberships. Requires `access.members.*`. */
|
||||
members: PluginAccessMembersClient;
|
||||
/** Read, create, and revoke company invites. Requires `access.invites.*`. */
|
||||
invites: PluginAccessInvitesClient;
|
||||
}
|
||||
|
||||
export interface PluginAuthorizationPolicySummary {
|
||||
companyId: string;
|
||||
permissionsMode: "simple";
|
||||
memberCount: number;
|
||||
activeMemberCount: number;
|
||||
grantCount: number;
|
||||
advancedPolicyAvailable: false;
|
||||
}
|
||||
|
||||
export interface PluginAuthorizationPolicyRecord {
|
||||
resourceType: "company" | "agent" | "project" | "issue";
|
||||
resourceId: string;
|
||||
companyId: string;
|
||||
policy: Record<string, unknown> | null;
|
||||
updatedAt: Date | string | null;
|
||||
}
|
||||
|
||||
export interface PluginAssignmentPreviewInput {
|
||||
companyId: string;
|
||||
actor:
|
||||
| { type: "board"; userId?: string | null; companyIds?: string[]; isInstanceAdmin?: boolean }
|
||||
| { type: "agent"; agentId: string; companyId: string };
|
||||
target: {
|
||||
issueId?: string | null;
|
||||
projectId?: string | null;
|
||||
parentIssueId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
status?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginAuthorizationDecisionResult {
|
||||
allowed: boolean;
|
||||
action: string;
|
||||
explanation: string;
|
||||
reason: string;
|
||||
grant?: {
|
||||
principalType: PrincipalType;
|
||||
principalId: string;
|
||||
permissionKey: PermissionKey;
|
||||
scope: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginAuthorizationAuditEntry {
|
||||
id: string;
|
||||
companyId: string;
|
||||
actorType: string;
|
||||
actorId: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: Date | string;
|
||||
}
|
||||
|
||||
export interface PluginAuthorizationClient {
|
||||
grants: {
|
||||
list(input: { companyId: string; principalType?: PrincipalType; principalId?: string }): Promise<PrincipalPermissionGrant[]>;
|
||||
set(input: {
|
||||
companyId: string;
|
||||
principalType: PrincipalType;
|
||||
principalId: string;
|
||||
grants: Array<{ permissionKey: PermissionKey; scope?: Record<string, unknown> | null }>;
|
||||
grantedByUserId?: string | null;
|
||||
}): Promise<PrincipalPermissionGrant[]>;
|
||||
};
|
||||
policies: {
|
||||
summary(companyId: string): Promise<PluginAuthorizationPolicySummary>;
|
||||
get(input: { companyId: string; resourceType: PluginAuthorizationPolicyRecord["resourceType"]; resourceId: string }): Promise<PluginAuthorizationPolicyRecord | null>;
|
||||
update(input: {
|
||||
companyId: string;
|
||||
resourceType: PluginAuthorizationPolicyRecord["resourceType"];
|
||||
resourceId: string;
|
||||
policy: Record<string, unknown> | null;
|
||||
}): Promise<PluginAuthorizationPolicyRecord>;
|
||||
previewAssignment(input: PluginAssignmentPreviewInput): Promise<PluginAuthorizationDecisionResult>;
|
||||
explainAssignment(input: PluginAssignmentPreviewInput): Promise<PluginAuthorizationDecisionResult>;
|
||||
};
|
||||
audit: {
|
||||
search(input: {
|
||||
companyId: string;
|
||||
action?: string;
|
||||
actorType?: string;
|
||||
actorId?: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
decision?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<PluginAuthorizationAuditEntry[]>;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming (worker → UI push channel)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1642,6 +1874,9 @@ export interface PluginContext {
|
||||
/** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */
|
||||
projects: PluginProjectsClient;
|
||||
|
||||
/** Read execution workspace metadata. Requires `execution.workspaces.read`. */
|
||||
executionWorkspaces: PluginExecutionWorkspacesClient;
|
||||
|
||||
/** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */
|
||||
routines: PluginRoutinesClient;
|
||||
|
||||
@@ -1660,6 +1895,12 @@ export interface PluginContext {
|
||||
/** Read and mutate goals. Requires `goals.read` for reads; `goals.create` / `goals.update` for write ops. */
|
||||
goals: PluginGoalsClient;
|
||||
|
||||
/** Read and manage access memberships and invites. Requires `access.*` capabilities. */
|
||||
access: PluginAccessClient;
|
||||
|
||||
/** Read and manage authorization grants, policy summaries, previews, and audit entries. Requires `authorization.*` capabilities. */
|
||||
authorization: PluginAuthorizationClient;
|
||||
|
||||
/** Register getData handlers for the plugin's UI components. */
|
||||
data: PluginDataClient;
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ export type {
|
||||
// Slot component prop interfaces
|
||||
export type {
|
||||
PluginPageProps,
|
||||
PluginCompanySettingsPageProps,
|
||||
PluginWidgetProps,
|
||||
PluginDetailTabProps,
|
||||
PluginSidebarProps,
|
||||
|
||||
@@ -54,6 +54,7 @@ export type {
|
||||
* Error codes:
|
||||
* - `WORKER_UNAVAILABLE` — plugin worker is not running
|
||||
* - `CAPABILITY_DENIED` — plugin lacks the required capability
|
||||
* - `INVOCATION_SCOPE_DENIED` — plugin call escaped the invocation company scope
|
||||
* - `WORKER_ERROR` — worker returned an error from its handler
|
||||
* - `TIMEOUT` — worker did not respond within the configured timeout
|
||||
* - `UNKNOWN` — unexpected bridge-level failure
|
||||
@@ -229,6 +230,18 @@ export interface PluginPageProps {
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin company settings page component.
|
||||
*
|
||||
* A company settings page is mounted at
|
||||
* `/:companyPrefix/company/settings/:routePath` and always receives the active
|
||||
* company id and prefix when available.
|
||||
*/
|
||||
export interface PluginCompanySettingsPageProps {
|
||||
/** The current host context, including company id and prefix. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin dashboard widget component.
|
||||
*
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import path from "node:path";
|
||||
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -66,6 +67,7 @@ import type {
|
||||
} from "./types.js";
|
||||
import type {
|
||||
JsonRpcId,
|
||||
JsonRpcNotification,
|
||||
JsonRpcRequest,
|
||||
JsonRpcResponse,
|
||||
InitializeParams,
|
||||
@@ -76,6 +78,8 @@ import type {
|
||||
RunJobParams,
|
||||
GetDataParams,
|
||||
PerformActionParams,
|
||||
PluginPerformActionActorContext,
|
||||
PluginPerformActionContext,
|
||||
ExecuteToolParams,
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentDestroyLeaseParams,
|
||||
@@ -85,6 +89,7 @@ import type {
|
||||
PluginEnvironmentResumeLeaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentProbeParams,
|
||||
PluginInvocationContext,
|
||||
WorkerToHostMethodName,
|
||||
WorkerToHostMethods,
|
||||
} from "./protocol.js";
|
||||
@@ -279,13 +284,17 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
let manifest: PaperclipPluginManifestV1 | null = null;
|
||||
let currentConfig: Record<string, unknown> = {};
|
||||
let databaseNamespace: string | null = null;
|
||||
const invocationContextStorage = new AsyncLocalStorage<PluginInvocationContext>();
|
||||
|
||||
// Plugin handler registrations (populated during setup())
|
||||
const eventHandlers: EventRegistration[] = [];
|
||||
const jobHandlers = new Map<string, (job: PluginJobContext) => Promise<void>>();
|
||||
const launcherRegistrations = new Map<string, PluginLauncherRegistration>();
|
||||
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<
|
||||
string,
|
||||
(params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>
|
||||
>();
|
||||
const toolHandlers = new Map<string, {
|
||||
declaration: Pick<import("@paperclipai/shared").PluginToolDeclaration, "displayName" | "description" | "parametersSchema">;
|
||||
fn: (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>;
|
||||
@@ -365,7 +374,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
});
|
||||
|
||||
try {
|
||||
const request = createRequest(method, params, id);
|
||||
const activeInvocation = invocationContextStorage.getStore();
|
||||
const request = {
|
||||
...createRequest(method, params, id),
|
||||
...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}),
|
||||
};
|
||||
sendMessage(request);
|
||||
} catch (err) {
|
||||
settle(reject, err instanceof Error ? err : new Error(String(err)));
|
||||
@@ -378,7 +391,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
*/
|
||||
function notifyHost(method: string, params: unknown): void {
|
||||
try {
|
||||
sendMessage(createNotification(method, params));
|
||||
const activeInvocation = invocationContextStorage.getStore();
|
||||
sendMessage({
|
||||
...createNotification(method, params),
|
||||
...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}),
|
||||
});
|
||||
} catch {
|
||||
// Swallow — the host may have closed stdin
|
||||
}
|
||||
@@ -657,6 +674,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
},
|
||||
},
|
||||
|
||||
executionWorkspaces: {
|
||||
async get(workspaceId: string, companyId: string) {
|
||||
return callHost("executionWorkspaces.get", { workspaceId, companyId });
|
||||
},
|
||||
},
|
||||
|
||||
routines: {
|
||||
managed: {
|
||||
async get(routineKey: string, companyId: string) {
|
||||
@@ -1080,6 +1103,85 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
},
|
||||
},
|
||||
|
||||
access: {
|
||||
members: {
|
||||
async list(input) {
|
||||
return callHost("access.members.list", {
|
||||
companyId: input.companyId,
|
||||
includeArchived: input.includeArchived,
|
||||
});
|
||||
},
|
||||
|
||||
async get(memberId: string, companyId: string) {
|
||||
return callHost("access.members.get", { memberId, companyId });
|
||||
},
|
||||
|
||||
async update(memberId: string, patch, companyId: string) {
|
||||
return callHost("access.members.update", { memberId, patch, companyId });
|
||||
},
|
||||
},
|
||||
|
||||
invites: {
|
||||
async list(input) {
|
||||
return callHost("access.invites.list", {
|
||||
companyId: input.companyId,
|
||||
state: input.state,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
});
|
||||
},
|
||||
|
||||
async create(input) {
|
||||
return callHost("access.invites.create", {
|
||||
companyId: input.companyId,
|
||||
allowedJoinTypes: input.allowedJoinTypes,
|
||||
humanRole: input.humanRole,
|
||||
defaultsPayload: input.defaultsPayload,
|
||||
agentMessage: input.agentMessage,
|
||||
});
|
||||
},
|
||||
|
||||
async revoke(inviteId: string, companyId: string) {
|
||||
return callHost("access.invites.revoke", { inviteId, companyId });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
authorization: {
|
||||
grants: {
|
||||
async list(input) {
|
||||
return callHost("authorization.grants.list", input);
|
||||
},
|
||||
async set(input) {
|
||||
return callHost("authorization.grants.set", input);
|
||||
},
|
||||
},
|
||||
|
||||
policies: {
|
||||
async summary(companyId: string) {
|
||||
return callHost("authorization.policies.summary", { companyId });
|
||||
},
|
||||
async get(input) {
|
||||
return callHost("authorization.policies.get", input);
|
||||
},
|
||||
async update(input) {
|
||||
return callHost("authorization.policies.update", input);
|
||||
},
|
||||
async previewAssignment(input) {
|
||||
return callHost("authorization.policies.previewAssignment", input);
|
||||
},
|
||||
async explainAssignment(input) {
|
||||
return callHost("authorization.policies.explainAssignment", input);
|
||||
},
|
||||
},
|
||||
|
||||
audit: {
|
||||
async search(input) {
|
||||
return callHost("authorization.audit.search", input);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void {
|
||||
dataHandlers.set(key, handler);
|
||||
@@ -1087,7 +1189,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
},
|
||||
|
||||
actions: {
|
||||
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void {
|
||||
register(
|
||||
key: string,
|
||||
handler: (params: Record<string, unknown>, context: PluginPerformActionContext) => Promise<unknown>,
|
||||
): void {
|
||||
actionHandlers.set(key, handler);
|
||||
},
|
||||
},
|
||||
@@ -1169,7 +1274,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
const { id, method, params } = request;
|
||||
|
||||
try {
|
||||
const result = await dispatchMethod(method, params);
|
||||
const invoke = () => dispatchMethod(method, params);
|
||||
const result = request.paperclipInvocation
|
||||
? await invocationContextStorage.run(request.paperclipInvocation, invoke)
|
||||
: await invoke();
|
||||
sendMessage(createSuccessResponse(id, result ?? null));
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
@@ -1407,11 +1515,36 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
if (!handler) {
|
||||
throw new Error(`No data handler registered for key "${params.key}"`);
|
||||
}
|
||||
return handler(
|
||||
params.renderEnvironment === undefined
|
||||
? params.params
|
||||
: { ...params.params, renderEnvironment: params.renderEnvironment },
|
||||
);
|
||||
return handler({
|
||||
...params.params,
|
||||
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
|
||||
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
|
||||
});
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] {
|
||||
return value === "user" || value === "agent" || value === "system" ? value : "system";
|
||||
}
|
||||
|
||||
function actionContextFromParams(params: PerformActionParams): PluginPerformActionContext {
|
||||
const rawActor = params.actorContext && typeof params.actorContext === "object"
|
||||
? params.actorContext
|
||||
: null;
|
||||
const actor = Object.freeze({
|
||||
type: actorTypeOrSystem(rawActor?.type),
|
||||
userId: stringOrNull(rawActor?.userId),
|
||||
agentId: stringOrNull(rawActor?.agentId),
|
||||
runId: stringOrNull(rawActor?.runId),
|
||||
companyId: stringOrNull(rawActor?.companyId),
|
||||
});
|
||||
return Object.freeze({
|
||||
actor,
|
||||
companyId: actor.companyId,
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePerformAction(params: PerformActionParams): Promise<unknown> {
|
||||
@@ -1420,9 +1553,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
throw new Error(`No action handler registered for key "${params.key}"`);
|
||||
}
|
||||
return handler(
|
||||
params.renderEnvironment === undefined
|
||||
? params.params
|
||||
: { ...params.params, renderEnvironment: params.renderEnvironment },
|
||||
{
|
||||
...params.params,
|
||||
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
|
||||
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
|
||||
},
|
||||
actionContextFromParams(params),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1591,14 +1727,20 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
});
|
||||
} else if (isJsonRpcNotification(message)) {
|
||||
// Dispatch host→worker push notifications
|
||||
const notif = message as { method: string; params?: unknown };
|
||||
const notif = message as JsonRpcNotification & { method: string; params?: unknown };
|
||||
const runNotification = (fn: () => void | Promise<void>) => {
|
||||
if (notif.paperclipInvocation) {
|
||||
return invocationContextStorage.run(notif.paperclipInvocation, fn);
|
||||
}
|
||||
return fn();
|
||||
};
|
||||
if (notif.method === "agents.sessions.event" && notif.params) {
|
||||
const event = notif.params as AgentSessionEvent;
|
||||
const cb = sessionEventCallbacks.get(event.sessionId);
|
||||
if (cb) cb(event);
|
||||
} else if (notif.method === "onEvent" && notif.params) {
|
||||
// Plugin event bus notifications — dispatch to registered event handlers
|
||||
handleOnEvent(notif.params as OnEventParams).catch((err) => {
|
||||
Promise.resolve(runNotification(() => handleOnEvent(notif.params as OnEventParams))).catch((err) => {
|
||||
notifyHost("log", {
|
||||
level: "error",
|
||||
message: `Failed to handle event notification: ${err instanceof Error ? err.message : String(err)}`,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { HostServices } from "../src/host-client-factory.js";
|
||||
import {
|
||||
CapabilityDeniedError,
|
||||
createHostClientHandlers,
|
||||
InvocationScopeDeniedError,
|
||||
} from "../src/host-client-factory.js";
|
||||
import { PLUGIN_RPC_ERROR_CODES } from "../src/protocol.js";
|
||||
|
||||
describe("createHostClientHandlers invocation company scope", () => {
|
||||
it("rejects company-scoped host calls outside the current invocation company", async () => {
|
||||
const projectsList = vi.fn(async () => []);
|
||||
const services = {
|
||||
projects: {
|
||||
list: projectsList,
|
||||
},
|
||||
} as unknown as HostServices;
|
||||
|
||||
const handlers = createHostClientHandlers({
|
||||
pluginId: "paperclip.test",
|
||||
capabilities: ["projects.read"],
|
||||
services,
|
||||
});
|
||||
|
||||
await expect(
|
||||
handlers["projects.list"](
|
||||
{ companyId: "company-b" },
|
||||
{ invocationScope: { companyId: "company-a" } },
|
||||
),
|
||||
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
||||
await expect(
|
||||
handlers["projects.list"](
|
||||
{ companyId: "company-b" },
|
||||
{ invocationScope: { companyId: "company-a" } },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
||||
});
|
||||
expect(projectsList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters companies.list to the current invocation company", async () => {
|
||||
const services = {
|
||||
companies: {
|
||||
list: vi.fn(async () => [
|
||||
{ id: "company-a", name: "Company A" },
|
||||
{ id: "company-b", name: "Company B" },
|
||||
]),
|
||||
},
|
||||
} as unknown as HostServices;
|
||||
|
||||
const handlers = createHostClientHandlers({
|
||||
pluginId: "paperclip.test",
|
||||
capabilities: ["companies.read"],
|
||||
services,
|
||||
});
|
||||
|
||||
await expect(
|
||||
handlers["companies.list"](
|
||||
{},
|
||||
{ invocationScope: { companyId: "company-a" } },
|
||||
),
|
||||
).resolves.toEqual([{ id: "company-a", name: "Company A" }]);
|
||||
});
|
||||
|
||||
it("rejects company-scope store access for a different company", async () => {
|
||||
const stateGet = vi.fn(async () => null);
|
||||
const services = {
|
||||
state: {
|
||||
get: stateGet,
|
||||
},
|
||||
} as unknown as HostServices;
|
||||
|
||||
const handlers = createHostClientHandlers({
|
||||
pluginId: "paperclip.test",
|
||||
capabilities: ["plugin.state.read"],
|
||||
services,
|
||||
});
|
||||
|
||||
await expect(
|
||||
handlers["state.get"](
|
||||
{ scopeKind: "company", scopeId: "company-b", stateKey: "settings" },
|
||||
{ invocationScope: { companyId: "company-a" } },
|
||||
),
|
||||
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
||||
expect(stateGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"access.members.list",
|
||||
"access.members.read",
|
||||
{ companyId: "company-a" },
|
||||
(services: HostServices) => vi.mocked(services.access.listMembers),
|
||||
],
|
||||
[
|
||||
"access.members.update",
|
||||
"access.members.write",
|
||||
{ companyId: "company-a", memberId: "member-a", patch: { status: "active" } },
|
||||
(services: HostServices) => vi.mocked(services.access.updateMember),
|
||||
],
|
||||
[
|
||||
"authorization.grants.set",
|
||||
"authorization.grants.write",
|
||||
{ companyId: "company-a", principalType: "agent", principalId: "agent-a", grants: [] },
|
||||
(services: HostServices) => vi.mocked(services.authorization.setGrants),
|
||||
],
|
||||
[
|
||||
"authorization.policies.update",
|
||||
"authorization.policies.write",
|
||||
{ companyId: "company-a", resourceType: "agent", resourceId: "agent-a", policy: null },
|
||||
(services: HostServices) => vi.mocked(services.authorization.updatePolicy),
|
||||
],
|
||||
[
|
||||
"authorization.audit.search",
|
||||
"authorization.audit.read",
|
||||
{ companyId: "company-a" },
|
||||
(services: HostServices) => vi.mocked(services.authorization.searchAudit),
|
||||
],
|
||||
] as const)(
|
||||
"rejects %s when the plugin lacks %s",
|
||||
async (method, capability, params, getDelegate) => {
|
||||
const services = {
|
||||
access: {
|
||||
listMembers: vi.fn(async () => []),
|
||||
updateMember: vi.fn(async () => ({ id: "member-a" })),
|
||||
},
|
||||
authorization: {
|
||||
setGrants: vi.fn(async () => []),
|
||||
updatePolicy: vi.fn(async () => ({ policy: null })),
|
||||
searchAudit: vi.fn(async () => []),
|
||||
},
|
||||
} as unknown as HostServices;
|
||||
const handlers = createHostClientHandlers({
|
||||
pluginId: "paperclip.test",
|
||||
capabilities: [],
|
||||
services,
|
||||
});
|
||||
|
||||
await expect(
|
||||
(handlers as Record<string, (input: unknown) => Promise<unknown>>)[method](params),
|
||||
).rejects.toMatchObject({
|
||||
name: "CapabilityDeniedError",
|
||||
message: expect.stringContaining(capability),
|
||||
});
|
||||
await expect(
|
||||
(handlers as Record<string, (input: unknown) => Promise<unknown>>)[method](params),
|
||||
).rejects.toBeInstanceOf(CapabilityDeniedError);
|
||||
expect(getDelegate(services)).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("checks invocation company scope before exposing authorization data", async () => {
|
||||
const searchAudit = vi.fn(async () => []);
|
||||
const services = {
|
||||
authorization: {
|
||||
searchAudit,
|
||||
},
|
||||
} as unknown as HostServices;
|
||||
const handlers = createHostClientHandlers({
|
||||
pluginId: "paperclip.test",
|
||||
capabilities: ["authorization.audit.read"],
|
||||
services,
|
||||
});
|
||||
|
||||
await expect(
|
||||
handlers["authorization.audit.search"](
|
||||
{ companyId: "company-b" },
|
||||
{ invocationScope: { companyId: "company-a" } },
|
||||
),
|
||||
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
||||
expect(searchAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestHarness } from "../src/testing.js";
|
||||
import type { PaperclipPluginManifestV1 } from "../src/types.js";
|
||||
|
||||
const manifest = {
|
||||
id: "paperclip.test-actions",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Test Actions",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: [],
|
||||
entrypoints: {},
|
||||
} satisfies PaperclipPluginManifestV1;
|
||||
|
||||
describe("createTestHarness action context", () => {
|
||||
it("passes immutable authenticated actor context and overrides caller company scope", async () => {
|
||||
const harness = createTestHarness({ manifest });
|
||||
|
||||
harness.ctx.actions.register("inspect", async (params, context) => ({
|
||||
paramsCompanyId: params.companyId,
|
||||
actor: context.actor,
|
||||
companyId: context.companyId,
|
||||
contextFrozen: Object.isFrozen(context),
|
||||
actorFrozen: Object.isFrozen(context.actor),
|
||||
}));
|
||||
|
||||
const result = await harness.performAction<{
|
||||
paramsCompanyId: unknown;
|
||||
actor: {
|
||||
type: string;
|
||||
userId: string | null;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
companyId: string | null;
|
||||
};
|
||||
companyId: string | null;
|
||||
contextFrozen: boolean;
|
||||
actorFrozen: boolean;
|
||||
}>(
|
||||
"inspect",
|
||||
{ companyId: "spoofed-company", value: true },
|
||||
{
|
||||
companyId: "host-company",
|
||||
actor: {
|
||||
type: "user",
|
||||
userId: "board-user-1",
|
||||
runId: "run-1",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.paramsCompanyId).toBe("host-company");
|
||||
expect(result.companyId).toBe("host-company");
|
||||
expect(result.actor).toEqual({
|
||||
type: "user",
|
||||
userId: "board-user-1",
|
||||
agentId: null,
|
||||
runId: "run-1",
|
||||
companyId: "host-company",
|
||||
});
|
||||
expect(result.contextFrozen).toBe(true);
|
||||
expect(result.actorFrozen).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps existing one-argument action handlers compatible", async () => {
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.ctx.actions.register("legacy", async (params) => ({ ok: params.ok }));
|
||||
|
||||
await expect(harness.performAction("legacy", { ok: true })).resolves.toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,26 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { isWorkerEntrypoint } from "../src/worker-rpc-host.js";
|
||||
import { definePlugin } from "../src/define-plugin.js";
|
||||
import {
|
||||
createRequest,
|
||||
createErrorResponse,
|
||||
createSuccessResponse,
|
||||
isJsonRpcRequest,
|
||||
isJsonRpcResponse,
|
||||
parseMessage,
|
||||
PLUGIN_RPC_ERROR_CODES,
|
||||
serializeMessage,
|
||||
type JsonRpcResponse,
|
||||
type PluginInvocationContext,
|
||||
} from "../src/protocol.js";
|
||||
import { isWorkerEntrypoint, startWorkerRpcHost } from "../src/worker-rpc-host.js";
|
||||
|
||||
describe("isWorkerEntrypoint", () => {
|
||||
const tempRoots: string[] = [];
|
||||
@@ -55,3 +70,229 @@ describe("isWorkerEntrypoint", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("worker performAction context", () => {
|
||||
it("does not derive context companyId from caller params without host actor context", async () => {
|
||||
const hostToWorker = new PassThrough();
|
||||
const workerToHost = new PassThrough();
|
||||
const hostReadline = createInterface({ input: workerToHost });
|
||||
const pending = new Map<string, (response: JsonRpcResponse) => void>();
|
||||
let nextRequestId = 1;
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.actions.register("inspect", async (params, context) => ({
|
||||
paramsCompanyId: params.companyId,
|
||||
actor: context.actor,
|
||||
companyId: context.companyId,
|
||||
}));
|
||||
},
|
||||
});
|
||||
const worker = startWorkerRpcHost({
|
||||
plugin,
|
||||
stdin: hostToWorker,
|
||||
stdout: workerToHost,
|
||||
});
|
||||
|
||||
function callWorker(method: string, params: unknown) {
|
||||
const id = `host-${nextRequestId++}`;
|
||||
const result = new Promise<unknown>((resolve, reject) => {
|
||||
pending.set(id, (response) => {
|
||||
if ("error" in response && response.error) {
|
||||
reject(new Error(response.error.message));
|
||||
return;
|
||||
}
|
||||
resolve((response as { result?: unknown }).result);
|
||||
});
|
||||
});
|
||||
hostToWorker.write(serializeMessage(createRequest(method, params, id)));
|
||||
return result;
|
||||
}
|
||||
|
||||
hostReadline.on("line", (line) => {
|
||||
const message = parseMessage(line);
|
||||
if (!isJsonRpcResponse(message)) return;
|
||||
pending.get(String(message.id))?.(message);
|
||||
pending.delete(String(message.id));
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(callWorker("initialize", {
|
||||
manifest: {
|
||||
id: "paperclip.test-worker-context",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Worker Context Test",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: [],
|
||||
entrypoints: {},
|
||||
},
|
||||
config: {},
|
||||
databaseNamespace: null,
|
||||
})).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await expect(callWorker("performAction", {
|
||||
key: "inspect",
|
||||
params: { companyId: "spoofed-company" },
|
||||
})).resolves.toEqual({
|
||||
paramsCompanyId: "spoofed-company",
|
||||
actor: {
|
||||
type: "system",
|
||||
userId: null,
|
||||
agentId: null,
|
||||
runId: null,
|
||||
companyId: null,
|
||||
},
|
||||
companyId: null,
|
||||
});
|
||||
} finally {
|
||||
worker.stop();
|
||||
hostReadline.close();
|
||||
hostToWorker.destroy();
|
||||
workerToHost.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("worker invocation scope propagation", () => {
|
||||
it("keeps overlapping company scopes local to each getData invocation", async () => {
|
||||
const hostToWorker = new PassThrough();
|
||||
const workerToHost = new PassThrough();
|
||||
const hostReadline = createInterface({ input: workerToHost });
|
||||
const pending = new Map<string, (response: JsonRpcResponse) => void>();
|
||||
const nestedInvocationIds: string[] = [];
|
||||
const invocationCompanies = new Map([
|
||||
["invocation-a", "company-a"],
|
||||
["invocation-b", "company-b"],
|
||||
]);
|
||||
let releaseCompanyA: (() => void) | null = null;
|
||||
let nextRequestId = 1;
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.data.register("probe", async (params) => {
|
||||
if (params.label === "a") {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseCompanyA = resolve;
|
||||
});
|
||||
}
|
||||
const company = await ctx.companies.get(String(params.requestedCompanyId));
|
||||
return { label: params.label, company };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const worker = startWorkerRpcHost({
|
||||
plugin,
|
||||
stdin: hostToWorker,
|
||||
stdout: workerToHost,
|
||||
});
|
||||
|
||||
function callWorker(method: string, params: unknown, invocation?: PluginInvocationContext) {
|
||||
const id = `host-${nextRequestId++}`;
|
||||
const request = {
|
||||
...createRequest(method, params, id),
|
||||
...(invocation ? { paperclipInvocation: invocation } : {}),
|
||||
};
|
||||
const result = new Promise<unknown>((resolve, reject) => {
|
||||
pending.set(id, (response) => {
|
||||
if ("error" in response && response.error) {
|
||||
reject(new Error(response.error.message));
|
||||
return;
|
||||
}
|
||||
resolve((response as { result?: unknown }).result);
|
||||
});
|
||||
});
|
||||
hostToWorker.write(serializeMessage(request));
|
||||
return result;
|
||||
}
|
||||
|
||||
hostReadline.on("line", (line) => {
|
||||
const message = parseMessage(line);
|
||||
if (isJsonRpcResponse(message)) {
|
||||
pending.get(String(message.id))?.(message);
|
||||
pending.delete(String(message.id));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isJsonRpcRequest(message)) return;
|
||||
if (message.method !== "companies.get") return;
|
||||
|
||||
const invocationId = (message as { paperclipInvocationId?: string }).paperclipInvocationId ?? "";
|
||||
const requestedCompanyId = (message.params as { companyId?: string }).companyId;
|
||||
const allowedCompanyId = invocationCompanies.get(invocationId);
|
||||
nestedInvocationIds.push(invocationId);
|
||||
if (requestedCompanyId !== allowedCompanyId) {
|
||||
hostToWorker.write(serializeMessage(createErrorResponse(
|
||||
message.id,
|
||||
PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED,
|
||||
`requested company "${requestedCompanyId}" but invocation "${invocationId}" is scoped to "${allowedCompanyId}"`,
|
||||
)));
|
||||
return;
|
||||
}
|
||||
|
||||
hostToWorker.write(serializeMessage(createSuccessResponse(message.id, {
|
||||
id: requestedCompanyId,
|
||||
})));
|
||||
|
||||
if (invocationId === "invocation-b") {
|
||||
releaseCompanyA?.();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await callWorker("initialize", {
|
||||
manifest: {
|
||||
id: "paperclip.scope-test",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Scope test",
|
||||
description: "Scope test",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["companies.read"],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
},
|
||||
config: {},
|
||||
instanceInfo: { instanceId: "test", hostVersion: "0.0.0" },
|
||||
apiVersion: 1,
|
||||
});
|
||||
|
||||
const companyARequest = callWorker(
|
||||
"getData",
|
||||
{
|
||||
key: "probe",
|
||||
companyId: "company-a",
|
||||
params: { label: "a", requestedCompanyId: "company-b" },
|
||||
},
|
||||
{ id: "invocation-a", scope: { companyId: "company-a" } },
|
||||
);
|
||||
const companyAExpectation = expect(companyARequest).rejects.toThrow(
|
||||
/requested company "company-b"/,
|
||||
);
|
||||
const companyBRequest = callWorker(
|
||||
"getData",
|
||||
{
|
||||
key: "probe",
|
||||
companyId: "company-b",
|
||||
params: { label: "b", requestedCompanyId: "company-b" },
|
||||
},
|
||||
{ id: "invocation-b", scope: { companyId: "company-b" } },
|
||||
);
|
||||
|
||||
await expect(companyBRequest).resolves.toEqual({
|
||||
label: "b",
|
||||
company: { id: "company-b" },
|
||||
});
|
||||
await companyAExpectation;
|
||||
|
||||
expect(nestedInvocationIds).toEqual(["invocation-b", "invocation-a"]);
|
||||
} finally {
|
||||
worker.stop();
|
||||
hostReadline.close();
|
||||
hostToWorker.destroy();
|
||||
workerToHost.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user