Files
paperclip/server/src/services/plugin-local-folders.ts
T
Dotta d734bd43d1 [codex] Roll up May 17 branch changes (#6210)
## Thinking Path

> - Paperclip is the control plane for autonomous AI companies, so agent
work needs visible ownership, recovery, and operator controls.
> - This local branch had accumulated several related control-plane
reliability and operator-experience fixes across recovery actions,
watchdog folding, model-profile defaults, mentions, markdown editing,
plugin launchers, and small UI polish.
> - The branch needed to be converted into a PR against the current
`origin/master` without losing dirty work or including lockfile/workflow
churn.
> - The safest standalone shape is a single rollup PR because the
recovery/server/UI files overlap heavily across the local commits and
splitting would create avoidable conflicts.
> - This pull request replays the local branch onto latest
`origin/master`, preserves the uncommitted work as logical commits, and
adds a Zod 4 validator compatibility fix found during verification.
> - The benefit is that the May 17 local branch can be reviewed and
merged as one coherent, conflict-free branch under the 100-file Greptile
limit.

## What Changed

- Rebased the local May 17 branch work onto current `origin/master` in a
dedicated worktree.
- Preserved and committed previously dirty changes for recovery retry
handling, plugin/sidebar launcher polish, and `.herenow` ignores.
- Added recovery-action behavior for returning source issues to `todo`
when retrying source-scoped recovery.
- Included the existing local recovery/liveness/watchdog fold, Codex
cheap-profile, markdown/mention, duplicate-agent, and UI polish commits
from the branch.
- Normalized shared validator `z.record(...)` schemas to explicit
string-key records for Zod 4 compatibility.
- Confirmed the PR has no `pnpm-lock.yaml` or `.github/workflows/*`
changes and stays below the 100-file Greptile limit.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `npm run install` in
`node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3` to build the
local native sqlite3 binding after installing with scripts disabled
- `pnpm exec vitest run packages/shared/src/validators/issue.test.ts
packages/shared/src/project-mentions.test.ts
packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/heartbeat-model-profile.test.ts
server/src/__tests__/issue-recovery-actions.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
server/src/__tests__/plugin-local-folders.test.ts
ui/src/components/IssueRecoveryActionCard.test.tsx
ui/src/components/Sidebar.test.tsx
ui/src/components/SidebarAccountMenu.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/MarkdownEditor.test.tsx
ui/src/components/MarkdownBody.test.tsx
ui/src/lib/duplicate-agent-payload.test.ts
ui/src/pages/Routines.test.tsx`
- First pass: 13 files passed with 201 passing tests; 3 server files
failed before sqlite3 native binding was built.
- After rebuilding sqlite3:
`server/src/__tests__/heartbeat-model-profile.test.ts`,
`server/src/__tests__/issue-recovery-actions.test.ts`, and
`server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts`
passed/loaded; embedded Postgres tests were skipped by the local host
guard.
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/adapter-utils typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`

## Risks

- Medium risk: this is a broad rollup PR across recovery semantics,
server tests, shared validators, and UI surfaces.
- Some embedded Postgres tests skipped locally due the host guard, so CI
should provide the stronger database-backed signal.
- UI changes were covered by component tests, but no browser screenshot
was captured in this PR creation pass.
- This branch may overlap with existing recovery/liveness PR work; merge
this PR independently or restack/close overlapping branches rather than
merging duplicate implementations together.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent, tool-enabled local repository
and GitHub workflow, medium reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-17 17:15:06 -05:00

618 lines
21 KiB
TypeScript

import { constants as fsConstants, promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import type {
PluginLocalFolderDeclaration,
PluginLocalFolderEntry,
PluginLocalFolderListing,
PluginLocalFolderProblem,
PluginLocalFolderStatus,
} from "@paperclipai/plugin-sdk";
import { badRequest, forbidden, notFound } from "../errors.js";
export interface StoredPluginLocalFolderConfig {
path: string;
access?: "read" | "readWrite";
requiredDirectories?: string[];
requiredFiles?: string[];
updatedAt?: string;
}
export interface PluginLocalFolderSettingsJson {
localFolders?: Record<string, StoredPluginLocalFolderConfig>;
[key: string]: unknown;
}
const LOCAL_FOLDER_KEY_PATTERN = /^[a-z0-9][a-z0-9._:-]*$/;
function problem(
code: PluginLocalFolderProblem["code"],
message: string,
problemPath?: string,
): PluginLocalFolderProblem {
return { code, message, path: problemPath };
}
export function assertPluginLocalFolderKey(folderKey: string) {
if (!LOCAL_FOLDER_KEY_PATTERN.test(folderKey)) {
throw badRequest("folderKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens");
}
}
export function findLocalFolderDeclaration(
declarations: PluginLocalFolderDeclaration[] | undefined,
folderKey: string,
) {
return declarations?.find((declaration) => declaration.folderKey === folderKey) ?? null;
}
export function requireLocalFolderDeclaration(
declarations: PluginLocalFolderDeclaration[] | undefined,
folderKey: string,
) {
assertPluginLocalFolderKey(folderKey);
const declaration = findLocalFolderDeclaration(declarations, folderKey);
if (!declaration) {
throw badRequest("Local folder key is not declared by this plugin manifest");
}
return declaration;
}
function normalizeRelativePath(relativePath: string): string {
if (
!relativePath ||
path.isAbsolute(relativePath) ||
relativePath.includes("\\") ||
relativePath.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
) {
throw forbidden("Local folder relative paths must stay inside the configured root");
}
return relativePath;
}
function validateRequiredPath(pathValue: string, label: string): string {
try {
return normalizeRelativePath(pathValue);
} catch {
throw badRequest(`${label} must contain only relative paths without traversal, empty segments, or backslashes`);
}
}
function normalizeListRelativePath(relativePath: string | null | undefined): string | null {
const trimmed = relativePath?.trim();
if (!trimmed) return null;
return normalizeRelativePath(trimmed);
}
function normalizeMaxEntries(value: number | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 1000;
return Math.max(1, Math.min(5000, Math.floor(value)));
}
function mergeFolderConfig(
declaration: PluginLocalFolderDeclaration | null,
stored: StoredPluginLocalFolderConfig | null,
override?: Partial<StoredPluginLocalFolderConfig>,
): StoredPluginLocalFolderConfig | null {
const pathValue = override?.path ?? stored?.path;
if (!pathValue) return null;
return {
path: pathValue,
access: declaration?.access ?? override?.access ?? stored?.access ?? "readWrite",
requiredDirectories:
declaration?.requiredDirectories ?? override?.requiredDirectories ?? stored?.requiredDirectories ?? [],
requiredFiles:
declaration?.requiredFiles ?? override?.requiredFiles ?? stored?.requiredFiles ?? [],
updatedAt: stored?.updatedAt,
};
}
export function getStoredLocalFolders(settingsJson: Record<string, unknown> | null | undefined) {
const folders = (settingsJson as PluginLocalFolderSettingsJson | undefined)?.localFolders;
if (!folders || typeof folders !== "object") return {};
return folders;
}
export function setStoredLocalFolder(
settingsJson: Record<string, unknown> | null | undefined,
folderKey: string,
config: StoredPluginLocalFolderConfig,
): PluginLocalFolderSettingsJson {
return {
...(settingsJson ?? {}),
localFolders: {
...getStoredLocalFolders(settingsJson),
[folderKey]: {
...config,
updatedAt: new Date().toISOString(),
},
},
};
}
export async function inspectPluginLocalFolder(input: {
folderKey: string;
declaration?: PluginLocalFolderDeclaration | null;
storedConfig?: StoredPluginLocalFolderConfig | null;
overrideConfig?: Partial<StoredPluginLocalFolderConfig>;
}): Promise<PluginLocalFolderStatus> {
assertPluginLocalFolderKey(input.folderKey);
const config = mergeFolderConfig(
input.declaration ?? null,
input.storedConfig ?? null,
input.overrideConfig,
);
const access = config?.access ?? input.declaration?.access ?? "readWrite";
const requiredDirectories = (config?.requiredDirectories ?? []).map((item) =>
validateRequiredPath(item, "requiredDirectories"),
);
const requiredFiles = (config?.requiredFiles ?? []).map((item) =>
validateRequiredPath(item, "requiredFiles"),
);
const checkedAt = new Date().toISOString();
if (!config?.path) {
return {
folderKey: input.folderKey,
configured: false,
path: null,
realPath: null,
access,
readable: false,
writable: false,
requiredDirectories,
requiredFiles,
missingDirectories: requiredDirectories,
missingFiles: requiredFiles,
healthy: false,
problems: [problem("not_configured", "No local folder path is configured.")],
checkedAt,
};
}
const configuredPath = path.resolve(config.path);
const problems: PluginLocalFolderProblem[] = [];
const missingDirectories: string[] = [];
const missingFiles: string[] = [];
const markRequiredPathsMissing = () => {
missingDirectories.push(...requiredDirectories);
missingFiles.push(...requiredFiles);
};
let realPath: string | null = null;
let readable = false;
let writable = false;
if (!path.isAbsolute(config.path)) {
problems.push(problem("not_absolute", "Local folder path must be absolute.", config.path));
}
try {
const stat = await fs.stat(configuredPath);
if (!stat.isDirectory()) {
problems.push(problem("not_directory", "Configured local folder path is not a directory.", configuredPath));
markRequiredPathsMissing();
} else {
realPath = await fs.realpath(configuredPath);
try {
await fs.access(realPath, fsConstants.R_OK);
readable = true;
} catch {
problems.push(problem("not_readable", "Configured local folder is not readable.", configuredPath));
}
if (access === "readWrite") {
try {
await fs.access(realPath, fsConstants.W_OK);
const probePath = path.join(realPath, `.paperclip-local-folder-probe-${process.pid}-${Date.now()}`);
await fs.writeFile(probePath, "");
await fs.rm(probePath, { force: true });
writable = true;
} catch {
problems.push(problem("not_writable", "Configured local folder is not writable.", configuredPath));
}
}
for (const requiredDir of requiredDirectories) {
const requiredStatus = await inspectChildPath(realPath, requiredDir, "directory");
if (!requiredStatus.exists) {
missingDirectories.push(requiredDir);
problems.push(problem("missing_directory", "Required directory is missing.", requiredDir));
} else if (!requiredStatus.contained) {
problems.push(problem("symlink_escape", "Required directory escapes the configured root.", requiredDir));
} else if (!requiredStatus.matchesKind) {
missingDirectories.push(requiredDir);
problems.push(problem("missing_directory", "Required path is not a directory.", requiredDir));
}
}
for (const requiredFile of requiredFiles) {
const requiredStatus = await inspectChildPath(realPath, requiredFile, "file");
if (!requiredStatus.exists) {
missingFiles.push(requiredFile);
problems.push(problem("missing_file", "Required file is missing.", requiredFile));
} else if (!requiredStatus.contained) {
problems.push(problem("symlink_escape", "Required file escapes the configured root.", requiredFile));
} else if (!requiredStatus.matchesKind) {
missingFiles.push(requiredFile);
problems.push(problem("missing_file", "Required path is not a file.", requiredFile));
}
}
}
} catch (error) {
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
problems.push(problem(code === "ENOENT" ? "missing" : "not_readable", "Configured local folder cannot be inspected.", configuredPath));
if (code === "ENOENT") {
markRequiredPathsMissing();
}
}
return {
folderKey: input.folderKey,
configured: true,
path: configuredPath,
realPath,
access,
readable,
writable: access === "read" ? false : writable,
requiredDirectories,
requiredFiles,
missingDirectories,
missingFiles,
healthy:
problems.length === 0 &&
readable &&
(access === "read" || writable),
problems,
checkedAt,
};
}
function isInsideRoot(rootRealPath: string, candidateRealPath: string) {
const relative = path.relative(rootRealPath, candidateRealPath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
async function assertPathInsideRoot(rootRealPath: string, candidatePath: string) {
const candidateRealPath = await fs.realpath(candidatePath);
if (!isInsideRoot(rootRealPath, candidateRealPath)) {
throw forbidden("Local folder symlink escape is not allowed");
}
return candidateRealPath;
}
async function ensureDirectoryInsideRoot(rootRealPath: string, relativePath: string) {
const normalized = normalizeRelativePath(relativePath);
const segments = normalized.split("/");
let currentRealPath = rootRealPath;
for (const segment of segments) {
const nextPath = path.join(currentRealPath, segment);
try {
const stat = await fs.stat(nextPath);
if (!stat.isDirectory()) {
throw badRequest("Required directory path exists but is not a directory");
}
} catch (error) {
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
if (code !== "ENOENT") throw error;
await fs.mkdir(nextPath);
}
const nextRealPath = await fs.realpath(nextPath);
if (!isInsideRoot(rootRealPath, nextRealPath)) {
throw forbidden("Local folder symlink escape is not allowed");
}
currentRealPath = nextRealPath;
}
}
export async function preparePluginLocalFolder(input: {
folderKey: string;
declaration?: PluginLocalFolderDeclaration | null;
storedConfig?: StoredPluginLocalFolderConfig | null;
overrideConfig?: Partial<StoredPluginLocalFolderConfig>;
}) {
assertPluginLocalFolderKey(input.folderKey);
const config = mergeFolderConfig(
input.declaration ?? null,
input.storedConfig ?? null,
input.overrideConfig,
);
const access = config?.access ?? input.declaration?.access ?? "readWrite";
if (!config?.path || access !== "readWrite" || !path.isAbsolute(config.path)) return;
const configuredPath = path.resolve(config.path);
try {
const stat = await fs.stat(configuredPath);
if (!stat.isDirectory()) return;
} catch (error) {
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
if (code !== "ENOENT") return;
try {
await fs.mkdir(configuredPath, { recursive: true });
} catch {
return;
}
}
const rootRealPath = await fs.realpath(configuredPath);
for (const requiredDir of config.requiredDirectories ?? []) {
await ensureDirectoryInsideRoot(rootRealPath, validateRequiredPath(requiredDir, "requiredDirectories"));
}
}
async function inspectChildPath(
rootRealPath: string,
relativePath: string,
kind: "directory" | "file",
) {
let resolvedPath: Awaited<ReturnType<typeof resolvePluginLocalFolderPath>>;
try {
resolvedPath = await resolvePluginLocalFolderPath(rootRealPath, relativePath, {
mustExist: true,
allowMissingLeaf: true,
});
} catch {
return { exists: true, contained: false, matchesKind: false };
}
if (!resolvedPath.exists) {
return { exists: false, contained: true, matchesKind: false };
}
const stat = await fs.stat(resolvedPath.realPath);
return {
exists: true,
contained: true,
matchesKind: kind === "directory" ? stat.isDirectory() : stat.isFile(),
};
}
export async function resolvePluginLocalFolderPath(
rootPath: string,
relativePath: string,
options?: { mustExist?: boolean; allowMissingLeaf?: boolean },
) {
const normalized = normalizeRelativePath(relativePath);
const rootRealPath = await fs.realpath(rootPath);
const absolutePath = path.resolve(rootRealPath, normalized);
const relativeFromRoot = path.relative(rootRealPath, absolutePath);
if (relativeFromRoot.startsWith("..") || path.isAbsolute(relativeFromRoot)) {
throw forbidden("Local folder path traversal is not allowed");
}
try {
const realPath = await fs.realpath(absolutePath);
const realRelative = path.relative(rootRealPath, realPath);
if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
throw forbidden("Local folder symlink escape is not allowed");
}
return { absolutePath, realPath, exists: true };
} catch (error) {
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
if (code !== "ENOENT" || options?.mustExist) {
if (options?.allowMissingLeaf && code === "ENOENT") {
return { absolutePath, realPath: absolutePath, exists: false };
}
throw error;
}
const parentRealPath = await fs.realpath(path.dirname(absolutePath));
const parentRelative = path.relative(rootRealPath, parentRealPath);
if (parentRelative.startsWith("..") || path.isAbsolute(parentRelative)) {
throw forbidden("Local folder symlink escape is not allowed");
}
return { absolutePath, realPath: absolutePath, exists: false };
}
}
export async function readPluginLocalFolderText(rootPath: string, relativePath: string) {
const resolved = await resolvePluginLocalFolderPath(rootPath, relativePath, { mustExist: true });
const stat = await fs.stat(resolved.realPath);
if (!stat.isFile()) {
throw badRequest("Local folder read target must be a file");
}
return fs.readFile(resolved.realPath, "utf8");
}
export async function listPluginLocalFolderEntries(
rootPath: string,
options: { relativePath?: string | null; recursive?: boolean; maxEntries?: number } = {},
): Promise<PluginLocalFolderListing> {
const rootRealPath = await fs.realpath(rootPath);
const relativePath = normalizeListRelativePath(options.relativePath);
const target = relativePath
? await resolvePluginLocalFolderPath(rootRealPath, relativePath, { mustExist: true })
: { absolutePath: rootRealPath, realPath: rootRealPath, exists: true };
const targetStat = await fs.stat(target.realPath);
if (!targetStat.isDirectory()) {
throw badRequest("Local folder list target must be a directory");
}
const maxEntries = normalizeMaxEntries(options.maxEntries);
const entries: PluginLocalFolderEntry[] = [];
let truncated = false;
const visit = async (directoryRealPath: string, directoryRelativePath: string | null) => {
if (truncated) return;
const dirents = await fs.readdir(directoryRealPath, { withFileTypes: true });
dirents.sort((a, b) => a.name.localeCompare(b.name));
for (const dirent of dirents) {
if (entries.length >= maxEntries) {
truncated = true;
return;
}
const childRelativePath = directoryRelativePath ? `${directoryRelativePath}/${dirent.name}` : dirent.name;
let resolvedChild: Awaited<ReturnType<typeof resolvePluginLocalFolderPath>>;
try {
resolvedChild = await resolvePluginLocalFolderPath(rootRealPath, childRelativePath, { mustExist: true });
} catch {
continue;
}
const stat = await fs.stat(resolvedChild.realPath).catch(() => null);
if (!stat) continue;
const kind = stat.isDirectory() ? "directory" : stat.isFile() ? "file" : null;
if (!kind) continue;
entries.push({
path: childRelativePath,
name: dirent.name,
kind,
size: kind === "file" ? stat.size : null,
modifiedAt: stat.mtime.toISOString(),
});
if (options.recursive && kind === "directory") {
await visit(resolvedChild.realPath, childRelativePath);
if (truncated) return;
}
}
};
await visit(target.realPath, relativePath);
return {
folderKey: "list-result",
relativePath,
entries,
truncated,
};
}
export async function writePluginLocalFolderTextAtomic(
rootPath: string,
relativePath: string,
contents: string,
) {
const rootRealPath = await fs.realpath(rootPath);
const normalized = normalizeRelativePath(relativePath);
const parentRelativePath = path.dirname(normalized);
if (parentRelativePath !== ".") {
await ensureDirectoryInsideRoot(rootRealPath, parentRelativePath);
}
const resolved = await resolvePluginLocalFolderPath(rootRealPath, normalized);
await assertPathInsideRoot(rootRealPath, path.dirname(resolved.absolutePath));
const tempPath = path.join(
path.dirname(resolved.absolutePath),
`.paperclip-${path.basename(resolved.absolutePath)}-${process.pid}-${randomUUID()}.tmp`,
);
let tempCreated = false;
try {
const handle = await fs.open(tempPath, "wx");
tempCreated = true;
try {
await assertPathInsideRoot(rootRealPath, tempPath);
await handle.writeFile(contents, "utf8");
await handle.sync();
} finally {
await handle.close();
}
} catch (error) {
if (tempCreated) {
await fs.rm(tempPath, { force: true });
}
throw error;
}
try {
await resolvePluginLocalFolderPath(rootRealPath, relativePath);
await fs.rename(tempPath, resolved.absolutePath);
await resolvePluginLocalFolderPath(rootRealPath, relativePath, { mustExist: true });
} catch (error) {
await fs.rm(tempPath, { force: true });
throw error;
}
if (process.platform !== "win32") {
const dirHandle = await fs.open(path.dirname(resolved.absolutePath), "r");
try {
await dirHandle.sync();
} finally {
await dirHandle.close();
}
}
return inspectPluginLocalFolder({
folderKey: "write-result",
storedConfig: {
path: rootPath,
access: "readWrite",
},
});
}
export async function deletePluginLocalFolderFile(
rootPath: string,
relativePath: string,
folderKey: string,
) {
const rootRealPath = await fs.realpath(rootPath);
let resolved: Awaited<ReturnType<typeof resolvePluginLocalFolderPath>>;
try {
resolved = await resolvePluginLocalFolderPath(rootRealPath, relativePath, {
mustExist: true,
allowMissingLeaf: true,
});
} catch (error) {
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
if (code !== "ENOENT") throw error;
return inspectPluginLocalFolder({
folderKey,
storedConfig: {
path: rootPath,
access: "readWrite",
},
});
}
if (resolved.exists) {
const stat = await fs.lstat(resolved.absolutePath);
if (stat.isDirectory()) {
throw badRequest("Local folder delete target must be a file");
}
await fs.rm(resolved.absolutePath, { force: true });
if (process.platform !== "win32") {
const dirHandle = await fs.open(path.dirname(resolved.absolutePath), "r");
try {
await dirHandle.sync();
} finally {
await dirHandle.close();
}
}
}
return inspectPluginLocalFolder({
folderKey,
storedConfig: {
path: rootPath,
access: "readWrite",
},
});
}
export function defaultLocalFolderBasePath(pluginKey: string, companyId: string) {
return path.join(os.homedir(), ".paperclip", "plugin-data", companyId, pluginKey);
}
export function assertConfiguredLocalFolder(status: PluginLocalFolderStatus) {
if (!status.configured || !status.realPath || !status.readable) {
throw notFound("Local folder is not configured or readable");
}
if (!status.healthy) {
throw badRequest("Local folder is not healthy");
}
}
export function assertWritableConfiguredLocalFolder(status: PluginLocalFolderStatus) {
if (!status.configured || !status.realPath || !status.readable) {
throw notFound("Local folder is not configured or readable");
}
const onlyMissingRequiredPaths = status.problems.every((item) =>
item.code === "missing_directory" || item.code === "missing_file"
);
if (!status.healthy && !onlyMissingRequiredPaths) {
throw badRequest("Local folder is not healthy");
}
}