Expand plugin host surface (#5205)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The plugin system is the extension boundary for optional product
capabilities
> - Rich plugins need more than a worker entrypoint: they need scoped
database storage, local project folders, managed agents/routines, host
navigation, and reusable UI components
> - The LLM Wiki work exposed those missing host surfaces while keeping
plugin code outside the core control plane
> - This pull request expands the core plugin host, SDK, server APIs,
and UI bridge so plugins can declare and use those surfaces
> - The benefit is that future plugins can integrate with Paperclip
through documented, validated contracts instead of bespoke server or UI
imports
## What Changed
- Added plugin-managed database namespaces and migration tracking,
including Drizzle schema/migration files and SQL validation for
namespace isolation.
- Added server support for plugin local folders, managed agents, managed
routines, scoped plugin APIs, and plugin operation visibility.
- Expanded shared plugin manifest/types/validators and SDK
host/testing/UI exports for richer plugin surfaces.
- Added reusable UI pieces for file trees, managed routines, resizable
sidebars, route sidebars, and plugin bridge initialization.
- Updated plugin docs and example plugins to use the expanded host and
SDK surface.
## Verification
- `pnpm install --frozen-lockfile`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
packages/shared/src/validators/plugin.test.ts
server/src/__tests__/plugin-database.test.ts
server/src/__tests__/plugin-local-folders.test.ts
server/src/__tests__/plugin-managed-agents.test.ts
server/src/__tests__/plugin-managed-routines.test.ts
server/src/__tests__/plugin-orchestration-apis.test.ts
ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx
ui/src/components/ResizableSidebarPane.test.tsx
ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed:
11 files, 67 tests.
- Confirmed this PR changes 89 files and does not include
`pnpm-lock.yaml` or `.github/workflows/*`.
## Risks
- Medium: this expands plugin host contracts across db/shared/server/ui
and includes a new core migration (`0076_useful_elektra.sql`).
- The plugin database namespace validator is intentionally restrictive;
plugin authors may need follow-up affordances for SQL patterns that
remain blocked.
- Merge this before the LLM Wiki plugin PR so the plugin can resolve the
new SDK and host APIs.
> 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 coding agent, tool-enabled shell/git/GitHub
workflow. Context window size was not exposed by the runtime.
## 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
- [x] 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-05 07:42:57 -05:00
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 ) ;
[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
const normalized = normalizeRelativePath ( relativePath ) ;
const parentRelativePath = path . dirname ( normalized ) ;
if ( parentRelativePath !== "." ) {
await ensureDirectoryInsideRoot ( rootRealPath , parentRelativePath ) ;
}
const resolved = await resolvePluginLocalFolderPath ( rootRealPath , normalized ) ;
Expand plugin host surface (#5205)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The plugin system is the extension boundary for optional product
capabilities
> - Rich plugins need more than a worker entrypoint: they need scoped
database storage, local project folders, managed agents/routines, host
navigation, and reusable UI components
> - The LLM Wiki work exposed those missing host surfaces while keeping
plugin code outside the core control plane
> - This pull request expands the core plugin host, SDK, server APIs,
and UI bridge so plugins can declare and use those surfaces
> - The benefit is that future plugins can integrate with Paperclip
through documented, validated contracts instead of bespoke server or UI
imports
## What Changed
- Added plugin-managed database namespaces and migration tracking,
including Drizzle schema/migration files and SQL validation for
namespace isolation.
- Added server support for plugin local folders, managed agents, managed
routines, scoped plugin APIs, and plugin operation visibility.
- Expanded shared plugin manifest/types/validators and SDK
host/testing/UI exports for richer plugin surfaces.
- Added reusable UI pieces for file trees, managed routines, resizable
sidebars, route sidebars, and plugin bridge initialization.
- Updated plugin docs and example plugins to use the expanded host and
SDK surface.
## Verification
- `pnpm install --frozen-lockfile`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
packages/shared/src/validators/plugin.test.ts
server/src/__tests__/plugin-database.test.ts
server/src/__tests__/plugin-local-folders.test.ts
server/src/__tests__/plugin-managed-agents.test.ts
server/src/__tests__/plugin-managed-routines.test.ts
server/src/__tests__/plugin-orchestration-apis.test.ts
ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx
ui/src/components/ResizableSidebarPane.test.tsx
ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed:
11 files, 67 tests.
- Confirmed this PR changes 89 files and does not include
`pnpm-lock.yaml` or `.github/workflows/*`.
## Risks
- Medium: this expands plugin host contracts across db/shared/server/ui
and includes a new core migration (`0076_useful_elektra.sql`).
- The plugin database namespace validator is intentionally restrictive;
plugin authors may need follow-up affordances for SQL patterns that
remain blocked.
- Merge this before the LLM Wiki plugin PR so the plugin can resolve the
new SDK and host APIs.
> 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 coding agent, tool-enabled shell/git/GitHub
workflow. Context window size was not exposed by the runtime.
## 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
- [x] 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-05 07:42:57 -05:00
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 : {
[codex] Add LLM Wiki plugin host support (#5597)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies.
> - The plugin system needs host contracts and runtime support before
large plugins can integrate cleanly.
> - The source branch mixed the LLM Wiki package with supporting
host/runtime work, managed plugin skills, root-level storage spaces, and
a bookmarks reference plugin.
> - [PAP-9173](/PAP/issues/PAP-9173) asked for the current branch to be
split by file boundary: plugin package separately from everything else.
> - [PAP-9188](/PAP/issues/PAP-9188) clarified that LLM Wiki may have
plugin-local spaces, but Paperclip core should not reorganize top-level
local storage into spaces.
> - Follow-up review clarified that the bookmarks example should not
ship in this PR either.
> - This pull request contains the
non-`packages/plugins/plugin-llm-wiki/` host/runtime work, keeps runtime
state under the selected Paperclip instance root, and no longer includes
the bookmarks example.
## What Changed
- Added/updated plugin host contracts, SDK types, worker RPC plumbing,
managed plugin skill support, and related server tests.
- Removed the bookmarks example plugin package and its
bundled-example/workspace references.
- Removed the root-level local spaces CLI/migration surface and restored
instance-root runtime defaults for config, db, logs, storage, secrets,
workspaces, projects, and adapter homes.
- Replaced shared root `space-paths` helpers with `home-paths` helpers
for core runtime storage.
- Tightened stranded recovery unique-conflict detection so concurrent
recovery scans reuse the raced recovery issue when Postgres errors are
wrapped.
- Kept `packages/plugins/plugin-llm-wiki/` out of this PR diff;
plugin-local spaces remain in the stacked plugin-only PR.
## Verification
- `pnpm exec vitest run cli/src/__tests__/data-dir.test.ts
cli/src/__tests__/home-paths.test.ts cli/src/__tests__/onboard.test.ts
packages/shared/src/home-paths.test.ts
packages/db/src/runtime-config.test.ts
server/src/__tests__/agent-instructions-service.test.ts
server/src/__tests__/claude-local-execute.test.ts
server/src/__tests__/codex-local-execute.test.ts`
- `pnpm exec vitest run packages/db/src/runtime-config.test.ts`
- `pnpm exec vitest run
server/src/__tests__/plugin-routes-authz.test.ts`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts -t "reuses the
raced stranded recovery issue"` skipped locally because embedded
Postgres did not initialize on this macOS temp host; the code path was
typechecked and is covered by Linux CI.
- Boundary check: no core references remain for `PAPERCLIP_SPACE_ID`,
`spaces migrate-default`, `@paperclipai/shared/space-paths`,
`registerSpacesCommands`, or the removed bookmarks example.
- Previous PR head `4f23e034` had green GitHub checks: `verify`, all
four serialized server shards, `e2e`, `Canary Dry Run`, `policy`, Snyk,
and `Greptile Review`. Current head `582f466d` is re-running checks
after the bookmarks deletion.
## Risks
- Plugin host changes touch shared runtime paths, so regressions would
most likely appear in adapter startup, plugin loading, or local dev path
defaults.
- Removing the bookmarks example also removes one demonstration of
plugin database namespaces plus local-folder persistence; remaining
plugin examples still cover bundled example discovery and plugin host
flows.
- The plugin package itself is intentionally deferred to the stacked
plugin-only PR, where LLM Wiki plugin-local spaces live.
- Existing installs that tested the transient root-level spaces CLI
should stop using it; this PR intentionally removes that unsupported
migration surface before merge.
> 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 GPT-5 Codex via Codex CLI, tool use and local code execution
enabled; context window not exposed.
## 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, except where noted above
for host-specific embedded Postgres initialization
- [x] I have added or updated tests where applicable
- [x] 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
Stacked follow-up: PR #5592 contains only
`packages/plugins/plugin-llm-wiki/` and targets this branch.
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-10 07:34:12 -05:00
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 : {
Expand plugin host surface (#5205)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The plugin system is the extension boundary for optional product
capabilities
> - Rich plugins need more than a worker entrypoint: they need scoped
database storage, local project folders, managed agents/routines, host
navigation, and reusable UI components
> - The LLM Wiki work exposed those missing host surfaces while keeping
plugin code outside the core control plane
> - This pull request expands the core plugin host, SDK, server APIs,
and UI bridge so plugins can declare and use those surfaces
> - The benefit is that future plugins can integrate with Paperclip
through documented, validated contracts instead of bespoke server or UI
imports
## What Changed
- Added plugin-managed database namespaces and migration tracking,
including Drizzle schema/migration files and SQL validation for
namespace isolation.
- Added server support for plugin local folders, managed agents, managed
routines, scoped plugin APIs, and plugin operation visibility.
- Expanded shared plugin manifest/types/validators and SDK
host/testing/UI exports for richer plugin surfaces.
- Added reusable UI pieces for file trees, managed routines, resizable
sidebars, route sidebars, and plugin bridge initialization.
- Updated plugin docs and example plugins to use the expanded host and
SDK surface.
## Verification
- `pnpm install --frozen-lockfile`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
packages/shared/src/validators/plugin.test.ts
server/src/__tests__/plugin-database.test.ts
server/src/__tests__/plugin-local-folders.test.ts
server/src/__tests__/plugin-managed-agents.test.ts
server/src/__tests__/plugin-managed-routines.test.ts
server/src/__tests__/plugin-orchestration-apis.test.ts
ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx
ui/src/components/ResizableSidebarPane.test.tsx
ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed:
11 files, 67 tests.
- Confirmed this PR changes 89 files and does not include
`pnpm-lock.yaml` or `.github/workflows/*`.
## Risks
- Medium: this expands plugin host contracts across db/shared/server/ui
and includes a new core migration (`0076_useful_elektra.sql`).
- The plugin database namespace validator is intentionally restrictive;
plugin authors may need follow-up affordances for SQL patterns that
remain blocked.
- Merge this before the LLM Wiki plugin PR so the plugin can resolve the
new SDK and host APIs.
> 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 coding agent, tool-enabled shell/git/GitHub
workflow. Context window size was not exposed by the runtime.
## 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
- [x] 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-05 07:42:57 -05:00
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" ) ;
}
}