forked from farhoodlabs/paperclip
9c6f551595
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The plugin system is the extension path for optional capabilities that should not require core product changes for every integration. > - Plugins need scoped host APIs for issue orchestration, documents, wakeups, summaries, activity attribution, and isolated database state. > - Without those host APIs, richer plugins either cannot coordinate Paperclip work safely or need privileged core-side special cases. > - This pull request adds the plugin orchestration host surface, scoped route dispatch, a database namespace layer, and a smoke plugin that exercises the contract. > - The benefit is a broader plugin API that remains company-scoped, auditable, and covered by tests. ## What Changed - Added plugin orchestration host APIs for issue creation, document access, wakeups, summaries, plugin-origin activity, and scoped API route dispatch. - Added plugin database namespace tables, schema exports, migration checks, and idempotent replay coverage under migration `0059_plugin_database_namespaces`. - Added shared plugin route/API types and validators used by server and SDK boundaries. - Expanded plugin SDK types, protocol helpers, worker RPC host behavior, and testing utilities for orchestration flows. - Added the `plugin-orchestration-smoke-example` package to exercise scoped routes, restricted database namespaces, issue orchestration, documents, wakeups, summaries, and UI status surfaces. - Kept the new orchestration smoke fixture out of the root pnpm workspace importer so this PR preserves the repository policy of not committing `pnpm-lock.yaml`. - Updated plugin docs and database docs for the new orchestration and database namespace surfaces. - Rebased the branch onto `public-gh/master`, resolved conflicts, and removed `pnpm-lock.yaml` from the final PR diff. ## Verification - `pnpm install --frozen-lockfile` - `pnpm --filter @paperclipai/db typecheck` - `pnpm exec vitest run packages/db/src/client.test.ts` - `pnpm exec vitest run server/src/__tests__/plugin-database.test.ts server/src/__tests__/plugin-orchestration-apis.test.ts server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/plugin-scoped-api-routes.test.ts server/src/__tests__/plugin-sdk-orchestration-contract.test.ts` - From `packages/plugins/examples/plugin-orchestration-smoke-example`: `pnpm exec vitest run --config ./vitest.config.ts` - `pnpm --dir packages/plugins/examples/plugin-orchestration-smoke-example run typecheck` - `pnpm --filter @paperclipai/server typecheck` - PR CI on latest head `293fc67c`: `policy`, `verify`, `e2e`, and `security/snyk` all passed. ## Risks - Medium risk: this expands plugin host authority, so route auth, company scoping, and plugin-origin activity attribution need careful review. - Medium risk: database namespace migration behavior must remain idempotent for environments that may have seen earlier branch versions. - Medium risk: the orchestration smoke fixture is intentionally excluded from the root workspace importer to avoid a `pnpm-lock.yaml` PR diff; direct fixture verification remains listed above. - Low operational risk from the PR setup itself: the branch is rebased onto current `master`, the migration is ordered after upstream `0057`/`0058`, and `pnpm-lock.yaml` is not in the final diff. > 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`. Roadmap checked: this work aligns with the completed Plugin system milestone and extends the plugin surface rather than duplicating an unrelated planned core feature. ## Model Used - OpenAI Codex, GPT-5-based coding agent in a tool-enabled CLI environment. Exact hosted model build and context-window size are not exposed by the runtime; reasoning/tool use were enabled for repository inspection, editing, testing, git operations, and PR creation. ## 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 (N/A: no core UI screen change; example plugin UI contract is covered by tests) - [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>
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
import { readConfigFile } from "./config-file.js";
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync, realpathSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { config as loadDotenv } from "dotenv";
|
|
import { resolvePaperclipEnvPath } from "./paths.js";
|
|
import { maybeRepairLegacyWorktreeConfigAndEnvFiles } from "./worktree-config.js";
|
|
import {
|
|
AUTH_BASE_URL_MODES,
|
|
BIND_MODES,
|
|
DEPLOYMENT_EXPOSURES,
|
|
DEPLOYMENT_MODES,
|
|
SECRET_PROVIDERS,
|
|
STORAGE_PROVIDERS,
|
|
type BindMode,
|
|
type AuthBaseUrlMode,
|
|
type DeploymentExposure,
|
|
type DeploymentMode,
|
|
type SecretProvider,
|
|
type StorageProvider,
|
|
inferBindModeFromHost,
|
|
resolveRuntimeBind,
|
|
validateConfiguredBindMode,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
resolveDefaultBackupDir,
|
|
resolveDefaultEmbeddedPostgresDir,
|
|
resolveDefaultSecretsKeyFilePath,
|
|
resolveDefaultStorageDir,
|
|
resolveHomeAwarePath,
|
|
} from "./home-paths.js";
|
|
|
|
const PAPERCLIP_ENV_FILE_PATH = resolvePaperclipEnvPath();
|
|
if (existsSync(PAPERCLIP_ENV_FILE_PATH)) {
|
|
loadDotenv({ path: PAPERCLIP_ENV_FILE_PATH, override: false, quiet: true });
|
|
}
|
|
|
|
const CWD_ENV_PATH = resolve(process.cwd(), ".env");
|
|
const isSameFile = existsSync(CWD_ENV_PATH) && existsSync(PAPERCLIP_ENV_FILE_PATH)
|
|
? realpathSync(CWD_ENV_PATH) === realpathSync(PAPERCLIP_ENV_FILE_PATH)
|
|
: CWD_ENV_PATH === PAPERCLIP_ENV_FILE_PATH;
|
|
if (!isSameFile && existsSync(CWD_ENV_PATH)) {
|
|
loadDotenv({ path: CWD_ENV_PATH, override: false, quiet: true });
|
|
}
|
|
|
|
maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
|
|
|
const TAILSCALE_DETECT_TIMEOUT_MS = 3000;
|
|
|
|
type DatabaseMode = "embedded-postgres" | "postgres";
|
|
|
|
export interface Config {
|
|
deploymentMode: DeploymentMode;
|
|
deploymentExposure: DeploymentExposure;
|
|
bind: BindMode;
|
|
customBindHost: string | undefined;
|
|
host: string;
|
|
port: number;
|
|
allowedHostnames: string[];
|
|
authBaseUrlMode: AuthBaseUrlMode;
|
|
authPublicBaseUrl: string | undefined;
|
|
authDisableSignUp: boolean;
|
|
databaseMode: DatabaseMode;
|
|
databaseUrl: string | undefined;
|
|
databaseMigrationUrl: string | undefined;
|
|
embeddedPostgresDataDir: string;
|
|
embeddedPostgresPort: number;
|
|
databaseBackupEnabled: boolean;
|
|
databaseBackupIntervalMinutes: number;
|
|
databaseBackupRetentionDays: number;
|
|
databaseBackupDir: string;
|
|
serveUi: boolean;
|
|
uiDevMiddleware: boolean;
|
|
secretsProvider: SecretProvider;
|
|
secretsStrictMode: boolean;
|
|
secretsMasterKeyFilePath: string;
|
|
storageProvider: StorageProvider;
|
|
storageLocalDiskBaseDir: string;
|
|
storageS3Bucket: string;
|
|
storageS3Region: string;
|
|
storageS3Endpoint: string | undefined;
|
|
storageS3Prefix: string;
|
|
storageS3ForcePathStyle: boolean;
|
|
feedbackExportBackendUrl: string | undefined;
|
|
feedbackExportBackendToken: string | undefined;
|
|
heartbeatSchedulerEnabled: boolean;
|
|
heartbeatSchedulerIntervalMs: number;
|
|
companyDeletionEnabled: boolean;
|
|
telemetryEnabled: boolean;
|
|
}
|
|
|
|
function detectTailnetBindHost(): string | undefined {
|
|
const explicit = process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim();
|
|
if (explicit) return explicit;
|
|
|
|
try {
|
|
const stdout = execFileSync("tailscale", ["ip", "-4"], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
timeout: TAILSCALE_DETECT_TIMEOUT_MS,
|
|
});
|
|
return stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.find(Boolean);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function loadConfig(): Config {
|
|
const fileConfig = readConfigFile();
|
|
const fileDatabaseMode =
|
|
(fileConfig?.database.mode === "postgres" ? "postgres" : "embedded-postgres") as DatabaseMode;
|
|
|
|
const fileDbUrl =
|
|
fileDatabaseMode === "postgres"
|
|
? fileConfig?.database.connectionString
|
|
: undefined;
|
|
const fileDatabaseBackup = fileConfig?.database.backup;
|
|
const fileSecrets = fileConfig?.secrets;
|
|
const fileStorage = fileConfig?.storage;
|
|
const strictModeFromEnv = process.env.PAPERCLIP_SECRETS_STRICT_MODE;
|
|
const secretsStrictMode =
|
|
strictModeFromEnv !== undefined
|
|
? strictModeFromEnv === "true"
|
|
: (fileSecrets?.strictMode ?? false);
|
|
|
|
const providerFromEnvRaw = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
|
const providerFromEnv =
|
|
providerFromEnvRaw && SECRET_PROVIDERS.includes(providerFromEnvRaw as SecretProvider)
|
|
? (providerFromEnvRaw as SecretProvider)
|
|
: null;
|
|
const providerFromFile = fileSecrets?.provider;
|
|
const secretsProvider: SecretProvider = providerFromEnv ?? providerFromFile ?? "local_encrypted";
|
|
|
|
const storageProviderFromEnvRaw = process.env.PAPERCLIP_STORAGE_PROVIDER;
|
|
const storageProviderFromEnv =
|
|
storageProviderFromEnvRaw && STORAGE_PROVIDERS.includes(storageProviderFromEnvRaw as StorageProvider)
|
|
? (storageProviderFromEnvRaw as StorageProvider)
|
|
: null;
|
|
const storageProvider: StorageProvider = storageProviderFromEnv ?? fileStorage?.provider ?? "local_disk";
|
|
const storageLocalDiskBaseDir = resolveHomeAwarePath(
|
|
process.env.PAPERCLIP_STORAGE_LOCAL_DIR ??
|
|
fileStorage?.localDisk?.baseDir ??
|
|
resolveDefaultStorageDir(),
|
|
);
|
|
const storageS3Bucket = process.env.PAPERCLIP_STORAGE_S3_BUCKET ?? fileStorage?.s3?.bucket ?? "paperclip";
|
|
const storageS3Region = process.env.PAPERCLIP_STORAGE_S3_REGION ?? fileStorage?.s3?.region ?? "us-east-1";
|
|
const storageS3Endpoint = process.env.PAPERCLIP_STORAGE_S3_ENDPOINT ?? fileStorage?.s3?.endpoint ?? undefined;
|
|
const storageS3Prefix = process.env.PAPERCLIP_STORAGE_S3_PREFIX ?? fileStorage?.s3?.prefix ?? "";
|
|
const storageS3ForcePathStyle =
|
|
process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE !== undefined
|
|
? process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE === "true"
|
|
: (fileStorage?.s3?.forcePathStyle ?? false);
|
|
const feedbackExportBackendUrl =
|
|
process.env.PAPERCLIP_FEEDBACK_EXPORT_BACKEND_URL?.trim() ||
|
|
process.env.PAPERCLIP_TELEMETRY_BACKEND_URL?.trim() ||
|
|
undefined;
|
|
const feedbackExportBackendToken =
|
|
process.env.PAPERCLIP_FEEDBACK_EXPORT_BACKEND_TOKEN?.trim() ||
|
|
process.env.PAPERCLIP_TELEMETRY_BACKEND_TOKEN?.trim() ||
|
|
undefined;
|
|
|
|
const deploymentModeFromEnvRaw = process.env.PAPERCLIP_DEPLOYMENT_MODE;
|
|
const deploymentModeFromEnv =
|
|
deploymentModeFromEnvRaw && DEPLOYMENT_MODES.includes(deploymentModeFromEnvRaw as DeploymentMode)
|
|
? (deploymentModeFromEnvRaw as DeploymentMode)
|
|
: null;
|
|
const deploymentMode: DeploymentMode = deploymentModeFromEnv ?? fileConfig?.server.deploymentMode ?? "local_trusted";
|
|
const deploymentExposureFromEnvRaw = process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
|
|
const deploymentExposureFromEnv =
|
|
deploymentExposureFromEnvRaw &&
|
|
DEPLOYMENT_EXPOSURES.includes(deploymentExposureFromEnvRaw as DeploymentExposure)
|
|
? (deploymentExposureFromEnvRaw as DeploymentExposure)
|
|
: null;
|
|
const deploymentExposure: DeploymentExposure =
|
|
deploymentMode === "local_trusted"
|
|
? "private"
|
|
: (deploymentExposureFromEnv ?? fileConfig?.server.exposure ?? "private");
|
|
const bindFromEnvRaw = process.env.PAPERCLIP_BIND;
|
|
const bindFromEnv =
|
|
bindFromEnvRaw && BIND_MODES.includes(bindFromEnvRaw as BindMode)
|
|
? (bindFromEnvRaw as BindMode)
|
|
: null;
|
|
const configuredHost = process.env.HOST ?? fileConfig?.server.host ?? "127.0.0.1";
|
|
const tailnetBindHost = detectTailnetBindHost();
|
|
const bind =
|
|
bindFromEnv ??
|
|
fileConfig?.server.bind ??
|
|
inferBindModeFromHost(configuredHost, { tailnetBindHost });
|
|
const customBindHost = process.env.PAPERCLIP_BIND_HOST ?? fileConfig?.server.customBindHost;
|
|
const authBaseUrlModeFromEnvRaw = process.env.PAPERCLIP_AUTH_BASE_URL_MODE;
|
|
const authBaseUrlModeFromEnv =
|
|
authBaseUrlModeFromEnvRaw &&
|
|
AUTH_BASE_URL_MODES.includes(authBaseUrlModeFromEnvRaw as AuthBaseUrlMode)
|
|
? (authBaseUrlModeFromEnvRaw as AuthBaseUrlMode)
|
|
: null;
|
|
const publicUrlFromEnv = process.env.PAPERCLIP_PUBLIC_URL;
|
|
const authPublicBaseUrlRaw =
|
|
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
|
|
process.env.BETTER_AUTH_URL ??
|
|
process.env.BETTER_AUTH_BASE_URL ??
|
|
publicUrlFromEnv ??
|
|
fileConfig?.auth?.publicBaseUrl;
|
|
const authPublicBaseUrl = authPublicBaseUrlRaw?.trim() || undefined;
|
|
const authBaseUrlMode: AuthBaseUrlMode =
|
|
authBaseUrlModeFromEnv ??
|
|
fileConfig?.auth?.baseUrlMode ??
|
|
(authPublicBaseUrl ? "explicit" : "auto");
|
|
const disableSignUpFromEnv = process.env.PAPERCLIP_AUTH_DISABLE_SIGN_UP;
|
|
const authDisableSignUp: boolean =
|
|
disableSignUpFromEnv !== undefined
|
|
? disableSignUpFromEnv === "true"
|
|
: (fileConfig?.auth?.disableSignUp ?? false);
|
|
const allowedHostnamesFromEnvRaw = process.env.PAPERCLIP_ALLOWED_HOSTNAMES;
|
|
const allowedHostnamesFromEnv = allowedHostnamesFromEnvRaw
|
|
? allowedHostnamesFromEnvRaw
|
|
.split(",")
|
|
.map((value) => value.trim().toLowerCase())
|
|
.filter((value) => value.length > 0)
|
|
: null;
|
|
const publicUrlHostname = authPublicBaseUrl
|
|
? (() => {
|
|
try {
|
|
return new URL(authPublicBaseUrl).hostname.trim().toLowerCase();
|
|
} catch {
|
|
return null;
|
|
}
|
|
})()
|
|
: null;
|
|
const allowedHostnames = Array.from(
|
|
new Set(
|
|
[
|
|
...(allowedHostnamesFromEnv ?? fileConfig?.server.allowedHostnames ?? []),
|
|
...(publicUrlHostname ? [publicUrlHostname] : []),
|
|
]
|
|
.map((value) => value.trim().toLowerCase())
|
|
.filter(Boolean),
|
|
),
|
|
);
|
|
const companyDeletionEnvRaw = process.env.PAPERCLIP_ENABLE_COMPANY_DELETION;
|
|
const companyDeletionEnabled =
|
|
companyDeletionEnvRaw !== undefined
|
|
? companyDeletionEnvRaw === "true"
|
|
: deploymentMode === "local_trusted";
|
|
const databaseBackupEnabled =
|
|
process.env.PAPERCLIP_DB_BACKUP_ENABLED !== undefined
|
|
? process.env.PAPERCLIP_DB_BACKUP_ENABLED === "true"
|
|
: (fileDatabaseBackup?.enabled ?? true);
|
|
const databaseBackupIntervalMinutes = Math.max(
|
|
1,
|
|
Number(process.env.PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES) ||
|
|
fileDatabaseBackup?.intervalMinutes ||
|
|
60,
|
|
);
|
|
const databaseBackupRetentionDays = Math.max(
|
|
1,
|
|
Number(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) ||
|
|
fileDatabaseBackup?.retentionDays ||
|
|
7,
|
|
);
|
|
const databaseBackupDir = resolveHomeAwarePath(
|
|
process.env.PAPERCLIP_DB_BACKUP_DIR ??
|
|
fileDatabaseBackup?.dir ??
|
|
resolveDefaultBackupDir(),
|
|
);
|
|
const bindValidationErrors = validateConfiguredBindMode({
|
|
deploymentMode,
|
|
deploymentExposure,
|
|
bind,
|
|
host: configuredHost,
|
|
customBindHost,
|
|
});
|
|
if (bindValidationErrors.length > 0) {
|
|
throw new Error(bindValidationErrors[0]);
|
|
}
|
|
const resolvedBind = resolveRuntimeBind({
|
|
bind,
|
|
host: configuredHost,
|
|
customBindHost,
|
|
tailnetBindHost,
|
|
});
|
|
if (resolvedBind.errors.length > 0) {
|
|
throw new Error(resolvedBind.errors[0]);
|
|
}
|
|
|
|
return {
|
|
deploymentMode,
|
|
deploymentExposure,
|
|
bind: resolvedBind.bind,
|
|
customBindHost: resolvedBind.customBindHost,
|
|
host: resolvedBind.host,
|
|
port: Number(process.env.PORT) || fileConfig?.server.port || 3100,
|
|
allowedHostnames,
|
|
authBaseUrlMode,
|
|
authPublicBaseUrl,
|
|
authDisableSignUp,
|
|
databaseMode: fileDatabaseMode,
|
|
databaseUrl: process.env.DATABASE_URL ?? fileDbUrl,
|
|
databaseMigrationUrl: process.env.DATABASE_MIGRATION_URL,
|
|
embeddedPostgresDataDir: resolveHomeAwarePath(
|
|
fileConfig?.database.embeddedPostgresDataDir ?? resolveDefaultEmbeddedPostgresDir(),
|
|
),
|
|
embeddedPostgresPort: fileConfig?.database.embeddedPostgresPort ?? 54329,
|
|
databaseBackupEnabled,
|
|
databaseBackupIntervalMinutes,
|
|
databaseBackupRetentionDays,
|
|
databaseBackupDir,
|
|
serveUi:
|
|
process.env.SERVE_UI !== undefined
|
|
? process.env.SERVE_UI === "true"
|
|
: fileConfig?.server.serveUi ?? true,
|
|
uiDevMiddleware: process.env.PAPERCLIP_UI_DEV_MIDDLEWARE === "true",
|
|
secretsProvider,
|
|
secretsStrictMode,
|
|
secretsMasterKeyFilePath:
|
|
resolveHomeAwarePath(
|
|
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE ??
|
|
fileSecrets?.localEncrypted.keyFilePath ??
|
|
resolveDefaultSecretsKeyFilePath(),
|
|
),
|
|
storageProvider,
|
|
storageLocalDiskBaseDir,
|
|
storageS3Bucket,
|
|
storageS3Region,
|
|
storageS3Endpoint,
|
|
storageS3Prefix,
|
|
storageS3ForcePathStyle,
|
|
feedbackExportBackendUrl,
|
|
feedbackExportBackendToken,
|
|
heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false",
|
|
heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000),
|
|
companyDeletionEnabled,
|
|
telemetryEnabled: fileConfig?.telemetry?.enabled ?? true,
|
|
};
|
|
}
|