merge: wave 2 executor (01-02) into main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a5b8fcf9d3
commit
aa1a78d7d9
1081 files changed
+445341
No files matched your search
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
ensureAgentJwtSecret,
|
||||
readAgentJwtSecretFromEnv,
|
||||
readAgentJwtSecretFromEnvFile,
|
||||
resolveAgentJwtEnvFile,
|
||||
} from "../config/env.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
|
||||
export function agentJwtSecretCheck(configPath?: string): CheckResult {
|
||||
if (readAgentJwtSecretFromEnv(configPath)) {
|
||||
return {
|
||||
name: "Agent JWT secret",
|
||||
status: "pass",
|
||||
message: "PAPERCLIP_AGENT_JWT_SECRET is set in environment",
|
||||
};
|
||||
}
|
||||
|
||||
const envPath = resolveAgentJwtEnvFile(configPath);
|
||||
const fileSecret = readAgentJwtSecretFromEnvFile(envPath);
|
||||
|
||||
if (fileSecret) {
|
||||
return {
|
||||
name: "Agent JWT secret",
|
||||
status: "warn",
|
||||
message: `PAPERCLIP_AGENT_JWT_SECRET is present in ${envPath} but not loaded into environment`,
|
||||
repairHint: `Set the value from ${envPath} in your shell before starting the Paperclip server`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Agent JWT secret",
|
||||
status: "fail",
|
||||
message: `PAPERCLIP_AGENT_JWT_SECRET missing from environment and ${envPath}`,
|
||||
canRepair: true,
|
||||
repair: () => {
|
||||
ensureAgentJwtSecret(configPath);
|
||||
},
|
||||
repairHint: `Run with --repair to create ${envPath} containing PAPERCLIP_AGENT_JWT_SECRET`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readConfig, configExists, resolveConfigPath } from "../config/store.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
|
||||
export function configCheck(configPath?: string): CheckResult {
|
||||
const filePath = resolveConfigPath(configPath);
|
||||
|
||||
if (!configExists(configPath)) {
|
||||
return {
|
||||
name: "Config file",
|
||||
status: "fail",
|
||||
message: `Config file not found at ${filePath}`,
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai onboard` to create one",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
readConfig(configPath);
|
||||
return {
|
||||
name: "Config file",
|
||||
status: "pass",
|
||||
message: `Valid config at ${filePath}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
name: "Config file",
|
||||
status: "fail",
|
||||
message: `Invalid config: ${err instanceof Error ? err.message : String(err)}`,
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section database` (or `paperclipai onboard` to recreate)",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from "node:fs";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
||||
|
||||
export async function databaseCheck(config: PaperclipConfig, configPath?: string): Promise<CheckResult> {
|
||||
if (config.database.mode === "postgres") {
|
||||
if (!config.database.connectionString) {
|
||||
return {
|
||||
name: "Database",
|
||||
status: "fail",
|
||||
message: "PostgreSQL mode selected but no connection string configured",
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section database`",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { createDb } = await import("@paperclipai/db");
|
||||
const db = createDb(config.database.connectionString);
|
||||
await db.execute("SELECT 1");
|
||||
return {
|
||||
name: "Database",
|
||||
status: "pass",
|
||||
message: "PostgreSQL connection successful",
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
name: "Database",
|
||||
status: "fail",
|
||||
message: `Cannot connect to PostgreSQL: ${err instanceof Error ? err.message : String(err)}`,
|
||||
canRepair: false,
|
||||
repairHint: "Check your connection string and ensure PostgreSQL is running",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (config.database.mode === "embedded-postgres") {
|
||||
const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath);
|
||||
const reportedPath = dataDir;
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(reportedPath, { recursive: true });
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Database",
|
||||
status: "pass",
|
||||
message: `Embedded PostgreSQL configured at ${dataDir} (port ${config.database.embeddedPostgresPort})`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Database",
|
||||
status: "fail",
|
||||
message: `Unknown database mode: ${String(config.database.mode)}`,
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section database`",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
|
||||
function isLoopbackHost(host: string) {
|
||||
const normalized = host.trim().toLowerCase();
|
||||
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
|
||||
}
|
||||
|
||||
export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
|
||||
const mode = config.server.deploymentMode;
|
||||
const exposure = config.server.exposure;
|
||||
const auth = config.auth;
|
||||
|
||||
if (mode === "local_trusted") {
|
||||
if (!isLoopbackHost(config.server.host)) {
|
||||
return {
|
||||
name: "Deployment/auth mode",
|
||||
status: "fail",
|
||||
message: `local_trusted requires loopback host binding (found ${config.server.host})`,
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section server` and set host to 127.0.0.1",
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: "Deployment/auth mode",
|
||||
status: "pass",
|
||||
message: "local_trusted mode is configured for loopback-only access",
|
||||
};
|
||||
}
|
||||
|
||||
const secret =
|
||||
process.env.BETTER_AUTH_SECRET?.trim() ??
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim();
|
||||
if (!secret) {
|
||||
return {
|
||||
name: "Deployment/auth mode",
|
||||
status: "fail",
|
||||
message: "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET)",
|
||||
canRepair: false,
|
||||
repairHint: "Set BETTER_AUTH_SECRET before starting Paperclip",
|
||||
};
|
||||
}
|
||||
|
||||
if (auth.baseUrlMode === "explicit" && !auth.publicBaseUrl) {
|
||||
return {
|
||||
name: "Deployment/auth mode",
|
||||
status: "fail",
|
||||
message: "auth.baseUrlMode=explicit requires auth.publicBaseUrl",
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section server` and provide a base URL",
|
||||
};
|
||||
}
|
||||
|
||||
if (exposure === "public") {
|
||||
if (auth.baseUrlMode !== "explicit" || !auth.publicBaseUrl) {
|
||||
return {
|
||||
name: "Deployment/auth mode",
|
||||
status: "fail",
|
||||
message: "authenticated/public requires explicit auth.publicBaseUrl",
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section server` and select public exposure",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const url = new URL(auth.publicBaseUrl);
|
||||
if (url.protocol !== "https:") {
|
||||
return {
|
||||
name: "Deployment/auth mode",
|
||||
status: "warn",
|
||||
message: "Public exposure should use an https:// auth.publicBaseUrl",
|
||||
canRepair: false,
|
||||
repairHint: "Use HTTPS in production for secure session cookies",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
name: "Deployment/auth mode",
|
||||
status: "fail",
|
||||
message: "auth.publicBaseUrl is not a valid URL",
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section server` and provide a valid URL",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Deployment/auth mode",
|
||||
status: "pass",
|
||||
message: `Mode ${mode}/${exposure} with auth URL mode ${auth.baseUrlMode}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface CheckResult {
|
||||
name: string;
|
||||
status: "pass" | "warn" | "fail";
|
||||
message: string;
|
||||
canRepair?: boolean;
|
||||
repair?: () => void | Promise<void>;
|
||||
repairHint?: string;
|
||||
}
|
||||
|
||||
export { agentJwtSecretCheck } from "./agent-jwt-secret-check.js";
|
||||
export { configCheck } from "./config-check.js";
|
||||
export { deploymentAuthCheck } from "./deployment-auth-check.js";
|
||||
export { databaseCheck } from "./database-check.js";
|
||||
export { llmCheck } from "./llm-check.js";
|
||||
export { logCheck } from "./log-check.js";
|
||||
export { portCheck } from "./port-check.js";
|
||||
export { secretsCheck } from "./secrets-check.js";
|
||||
export { storageCheck } from "./storage-check.js";
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
|
||||
export async function llmCheck(config: PaperclipConfig): Promise<CheckResult> {
|
||||
if (!config.llm) {
|
||||
return {
|
||||
name: "LLM provider",
|
||||
status: "pass",
|
||||
message: "No LLM provider configured (optional)",
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.llm.apiKey) {
|
||||
return {
|
||||
name: "LLM provider",
|
||||
status: "pass",
|
||||
message: `${config.llm.provider} configured but no API key set (optional)`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.llm.provider === "claude") {
|
||||
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": config.llm.apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-sonnet-4-5-20250929",
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}),
|
||||
});
|
||||
if (res.ok || res.status === 400) {
|
||||
return { name: "LLM provider", status: "pass", message: "Claude API key is valid" };
|
||||
}
|
||||
if (res.status === 401) {
|
||||
return {
|
||||
name: "LLM provider",
|
||||
status: "fail",
|
||||
message: "Claude API key is invalid (401)",
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section llm`",
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: "LLM provider",
|
||||
status: "warn",
|
||||
message: `Claude API returned status ${res.status}`,
|
||||
};
|
||||
} else {
|
||||
const res = await fetch("https://api.openai.com/v1/models", {
|
||||
headers: { Authorization: `Bearer ${config.llm.apiKey}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
return { name: "LLM provider", status: "pass", message: "OpenAI API key is valid" };
|
||||
}
|
||||
if (res.status === 401) {
|
||||
return {
|
||||
name: "LLM provider",
|
||||
status: "fail",
|
||||
message: "OpenAI API key is invalid (401)",
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section llm`",
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: "LLM provider",
|
||||
status: "warn",
|
||||
message: `OpenAI API returned status ${res.status}`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
name: "LLM provider",
|
||||
status: "warn",
|
||||
message: "Could not reach API to validate key",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import fs from "node:fs";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
||||
|
||||
export function logCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
||||
const logDir = resolveRuntimeLikePath(config.logging.logDir, configPath);
|
||||
const reportedDir = logDir;
|
||||
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(reportedDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
fs.accessSync(reportedDir, fs.constants.W_OK);
|
||||
return {
|
||||
name: "Log directory",
|
||||
status: "pass",
|
||||
message: `Log directory is writable: ${reportedDir}`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
name: "Log directory",
|
||||
status: "fail",
|
||||
message: `Log directory is not writable: ${logDir}`,
|
||||
canRepair: false,
|
||||
repairHint: "Check file permissions on the log directory",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { resolveRuntimeLikePath } from "../utils/path-resolver.js";
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { checkPort } from "../utils/net.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
|
||||
export async function portCheck(config: PaperclipConfig): Promise<CheckResult> {
|
||||
const port = config.server.port;
|
||||
const result = await checkPort(port);
|
||||
|
||||
if (result.available) {
|
||||
return {
|
||||
name: "Server port",
|
||||
status: "pass",
|
||||
message: `Port ${port} is available`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Server port",
|
||||
status: "warn",
|
||||
message: result.error ?? `Port ${port} is not available`,
|
||||
canRepair: false,
|
||||
repairHint: `Check what's using port ${port} with: lsof -i :${port}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
||||
|
||||
function decodeMasterKey(raw: string): Buffer | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^[A-Fa-f0-9]{64}$/.test(trimmed)) {
|
||||
return Buffer.from(trimmed, "hex");
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(trimmed, "base64");
|
||||
if (decoded.length === 32) return decoded;
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
|
||||
if (Buffer.byteLength(trimmed, "utf8") === 32) {
|
||||
return Buffer.from(trimmed, "utf8");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function withStrictModeNote(
|
||||
base: Pick<CheckResult, "name" | "status" | "message" | "canRepair" | "repair" | "repairHint">,
|
||||
config: PaperclipConfig,
|
||||
): CheckResult {
|
||||
const strictModeDisabledInDeployedSetup =
|
||||
config.database.mode === "postgres" && config.secrets.strictMode === false;
|
||||
if (!strictModeDisabledInDeployedSetup) return base;
|
||||
|
||||
if (base.status === "fail") return base;
|
||||
return {
|
||||
...base,
|
||||
status: "warn",
|
||||
message: `${base.message}; strict secret mode is disabled for postgres deployment`,
|
||||
repairHint: base.repairHint
|
||||
? `${base.repairHint}. Consider enabling secrets.strictMode`
|
||||
: "Consider enabling secrets.strictMode",
|
||||
};
|
||||
}
|
||||
|
||||
export function secretsCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
||||
const provider = config.secrets.provider;
|
||||
if (provider !== "local_encrypted") {
|
||||
return {
|
||||
name: "Secrets adapter",
|
||||
status: "fail",
|
||||
message: `${provider} is configured, but this build only supports local_encrypted`,
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section secrets` and set provider to local_encrypted",
|
||||
};
|
||||
}
|
||||
|
||||
const envMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
if (envMasterKey && envMasterKey.trim().length > 0) {
|
||||
if (!decodeMasterKey(envMasterKey)) {
|
||||
return {
|
||||
name: "Secrets adapter",
|
||||
status: "fail",
|
||||
message:
|
||||
"PAPERCLIP_SECRETS_MASTER_KEY is invalid (expected 32-byte base64, 64-char hex, or raw 32-char string)",
|
||||
canRepair: false,
|
||||
repairHint: "Set PAPERCLIP_SECRETS_MASTER_KEY to a valid key or unset it to use a key file",
|
||||
};
|
||||
}
|
||||
|
||||
return withStrictModeNote(
|
||||
{
|
||||
name: "Secrets adapter",
|
||||
status: "pass",
|
||||
message: "Local encrypted provider configured via PAPERCLIP_SECRETS_MASTER_KEY",
|
||||
},
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
const keyFileOverride = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const configuredPath =
|
||||
keyFileOverride && keyFileOverride.trim().length > 0
|
||||
? keyFileOverride.trim()
|
||||
: config.secrets.localEncrypted.keyFilePath;
|
||||
const keyFilePath = resolveRuntimeLikePath(configuredPath, configPath);
|
||||
|
||||
if (!fs.existsSync(keyFilePath)) {
|
||||
return withStrictModeNote(
|
||||
{
|
||||
name: "Secrets adapter",
|
||||
status: "warn",
|
||||
message: `Secrets key file does not exist yet: ${keyFilePath}`,
|
||||
canRepair: true,
|
||||
repair: () => {
|
||||
fs.mkdirSync(path.dirname(keyFilePath), { recursive: true });
|
||||
fs.writeFileSync(keyFilePath, randomBytes(32).toString("base64"), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
try {
|
||||
fs.chmodSync(keyFilePath, 0o600);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
},
|
||||
repairHint: "Run with --repair to create a local encrypted secrets key file",
|
||||
},
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(keyFilePath, "utf8");
|
||||
} catch (err) {
|
||||
return {
|
||||
name: "Secrets adapter",
|
||||
status: "fail",
|
||||
message: `Could not read secrets key file: ${err instanceof Error ? err.message : String(err)}`,
|
||||
canRepair: false,
|
||||
repairHint: "Check file permissions or set PAPERCLIP_SECRETS_MASTER_KEY",
|
||||
};
|
||||
}
|
||||
|
||||
if (!decodeMasterKey(raw)) {
|
||||
return {
|
||||
name: "Secrets adapter",
|
||||
status: "fail",
|
||||
message: `Invalid key material in ${keyFilePath}`,
|
||||
canRepair: false,
|
||||
repairHint: "Replace with valid key material or delete it and run doctor --repair",
|
||||
};
|
||||
}
|
||||
|
||||
return withStrictModeNote(
|
||||
{
|
||||
name: "Secrets adapter",
|
||||
status: "pass",
|
||||
message: `Local encrypted provider configured with key file ${keyFilePath}`,
|
||||
},
|
||||
config,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import fs from "node:fs";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
||||
|
||||
export function storageCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
||||
if (config.storage.provider === "local_disk") {
|
||||
const baseDir = resolveRuntimeLikePath(config.storage.localDisk.baseDir, configPath);
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
fs.accessSync(baseDir, fs.constants.W_OK);
|
||||
return {
|
||||
name: "Storage",
|
||||
status: "pass",
|
||||
message: `Local disk storage is writable: ${baseDir}`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
name: "Storage",
|
||||
status: "fail",
|
||||
message: `Local storage directory is not writable: ${baseDir}`,
|
||||
canRepair: false,
|
||||
repairHint: "Check file permissions for storage.localDisk.baseDir",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const bucket = config.storage.s3.bucket.trim();
|
||||
const region = config.storage.s3.region.trim();
|
||||
if (!bucket || !region) {
|
||||
return {
|
||||
name: "Storage",
|
||||
status: "fail",
|
||||
message: "S3 storage requires non-empty bucket and region",
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section storage`",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Storage",
|
||||
status: "warn",
|
||||
message: `S3 storage configured (bucket=${bucket}, region=${region}). Reachability check is skipped in doctor.`,
|
||||
canRepair: false,
|
||||
repairHint: "Verify credentials and endpoint in deployment environment",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user