diff --git a/cli/src/checks/database-check.ts b/cli/src/checks/database-check.ts index 2e5fa6e4..783d7a4c 100644 --- a/cli/src/checks/database-check.ts +++ b/cli/src/checks/database-check.ts @@ -1,19 +1,7 @@ import fs from "node:fs"; -import path from "node:path"; import type { PaperclipConfig } from "../config/schema.js"; import type { CheckResult } from "./index.js"; - -function resolveConfigRelativePath(value: string, configPath?: string): string { - if (path.isAbsolute(value)) return value; - const candidates = [path.resolve(value)]; - if (configPath) { - candidates.unshift(path.resolve(path.dirname(configPath), "..", "server", value)); - candidates.unshift(path.resolve(path.dirname(configPath), value)); - } - candidates.push(path.resolve(process.cwd(), "server", value)); - const uniqueCandidates = Array.from(new Set(candidates)); - return uniqueCandidates.find((candidate) => fs.existsSync(candidate)) ?? uniqueCandidates[0]; -} +import { resolveRuntimeLikePath } from "./path-resolver.js"; export async function databaseCheck(config: PaperclipConfig, configPath?: string): Promise { if (config.database.mode === "postgres") { @@ -48,7 +36,7 @@ export async function databaseCheck(config: PaperclipConfig, configPath?: string } if (config.database.mode === "embedded-postgres") { - const dataDir = resolveConfigRelativePath(config.database.embeddedPostgresDataDir, configPath); + const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath); const reportedPath = dataDir; if (!fs.existsSync(dataDir)) { return { diff --git a/cli/src/checks/index.ts b/cli/src/checks/index.ts index c8a868f5..8f15918a 100644 --- a/cli/src/checks/index.ts +++ b/cli/src/checks/index.ts @@ -13,3 +13,4 @@ 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"; diff --git a/cli/src/checks/log-check.ts b/cli/src/checks/log-check.ts index 804bfb90..0d68b5cf 100644 --- a/cli/src/checks/log-check.ts +++ b/cli/src/checks/log-check.ts @@ -1,22 +1,10 @@ import fs from "node:fs"; -import path from "node:path"; import type { PaperclipConfig } from "../config/schema.js"; import type { CheckResult } from "./index.js"; - -function resolveConfigRelativePath(value: string, configPath?: string): string { - if (path.isAbsolute(value)) return value; - const candidates = [path.resolve(value)]; - if (configPath) { - candidates.unshift(path.resolve(path.dirname(configPath), "..", "server", value)); - candidates.unshift(path.resolve(path.dirname(configPath), value)); - } - candidates.push(path.resolve(process.cwd(), "server", value)); - const uniqueCandidates = Array.from(new Set(candidates)); - return uniqueCandidates.find((candidate) => fs.existsSync(candidate)) ?? uniqueCandidates[0]; -} +import { resolveRuntimeLikePath } from "./path-resolver.js"; export function logCheck(config: PaperclipConfig, configPath?: string): CheckResult { - const logDir = resolveConfigRelativePath(config.logging.logDir, configPath); + const logDir = resolveRuntimeLikePath(config.logging.logDir, configPath); const reportedDir = logDir; if (!fs.existsSync(logDir)) { diff --git a/cli/src/checks/path-resolver.ts b/cli/src/checks/path-resolver.ts new file mode 100644 index 00000000..17a99482 --- /dev/null +++ b/cli/src/checks/path-resolver.ts @@ -0,0 +1 @@ +export { resolveRuntimeLikePath } from "../utils/path-resolver.js"; diff --git a/cli/src/checks/secrets-check.ts b/cli/src/checks/secrets-check.ts new file mode 100644 index 00000000..4b739a41 --- /dev/null +++ b/cli/src/checks/secrets-check.ts @@ -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, + 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 `paperclip 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, + ); +} diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index 4d817f93..9354bbde 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -1,19 +1,22 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; -import { readConfig, writeConfig, configExists } from "../config/store.js"; +import { readConfig, writeConfig, configExists, resolveConfigPath } from "../config/store.js"; import type { PaperclipConfig } from "../config/schema.js"; +import { ensureLocalSecretsKeyFile } from "../config/secrets-key.js"; import { promptDatabase } from "../prompts/database.js"; import { promptLlm } from "../prompts/llm.js"; import { promptLogging } from "../prompts/logging.js"; +import { defaultSecretsConfig, promptSecrets } from "../prompts/secrets.js"; import { promptServer } from "../prompts/server.js"; -type Section = "llm" | "database" | "logging" | "server"; +type Section = "llm" | "database" | "logging" | "server" | "secrets"; const SECTION_LABELS: Record = { llm: "LLM Provider", database: "Database", logging: "Logging", server: "Server", + secrets: "Secrets", }; function defaultConfig(): PaperclipConfig { @@ -36,6 +39,7 @@ function defaultConfig(): PaperclipConfig { port: 3100, serveUi: true, }, + secrets: defaultSecretsConfig(), }; } @@ -44,6 +48,7 @@ export async function configure(opts: { section?: string; }): Promise { p.intro(pc.bgCyan(pc.black(" paperclip configure "))); + const configPath = resolveConfigPath(opts.config); if (!configExists(opts.config)) { p.log.error("No config file found. Run `paperclip onboard` first."); @@ -112,6 +117,21 @@ export async function configure(opts: { case "server": config.server = await promptServer(); break; + case "secrets": + config.secrets = await promptSecrets(config.secrets); + { + const keyResult = ensureLocalSecretsKeyFile(config, configPath); + if (keyResult.status === "created") { + p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`); + } else if (keyResult.status === "existing") { + p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`)); + } else if (keyResult.status === "skipped_provider") { + p.log.message(pc.dim("Skipping local key file management for non-local provider")); + } else { + p.log.message(pc.dim("Skipping local key file management because PAPERCLIP_SECRETS_MASTER_KEY is set")); + } + } + break; } config.$meta.updatedAt = new Date().toISOString(); diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index 0b68d002..678facef 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -9,6 +9,7 @@ import { llmCheck, logCheck, portCheck, + secretsCheck, type CheckResult, } from "../checks/index.js"; @@ -61,24 +62,30 @@ export async function doctor(opts: { printResult(jwtResult); await maybeRepair(jwtResult, opts); - // 3. Database check + // 3. Secrets adapter check + const secretsResult = secretsCheck(config, configPath); + results.push(secretsResult); + printResult(secretsResult); + await maybeRepair(secretsResult, opts); + + // 4. Database check const dbResult = await databaseCheck(config, configPath); results.push(dbResult); printResult(dbResult); await maybeRepair(dbResult, opts); - // 4. LLM check + // 5. LLM check const llmResult = await llmCheck(config); results.push(llmResult); printResult(llmResult); - // 5. Log directory check + // 6. Log directory check const logResult = logCheck(config, configPath); results.push(logResult); printResult(logResult); await maybeRepair(logResult, opts); - // 6. Port check + // 7. Port check const portResult = await portCheck(config); results.push(portResult); printResult(portResult); diff --git a/cli/src/commands/env.ts b/cli/src/commands/env.ts index 9f34bd07..1b7de4a8 100644 --- a/cli/src/commands/env.ts +++ b/cli/src/commands/env.ts @@ -22,6 +22,8 @@ const DEFAULT_AGENT_JWT_TTL_SECONDS = "172800"; const DEFAULT_AGENT_JWT_ISSUER = "paperclip"; const DEFAULT_AGENT_JWT_AUDIENCE = "paperclip-api"; const DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS = "30000"; +const DEFAULT_SECRETS_PROVIDER = "local_encrypted"; +const DEFAULT_SECRETS_KEY_FILE_PATH = "./data/secrets/master.key"; export async function envCommand(opts: { config?: string }): Promise { p.intro(pc.bgCyan(pc.black(" paperclip env "))); @@ -108,6 +110,17 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st const heartbeatInterval = process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ?? DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS; const heartbeatEnabled = process.env.HEARTBEAT_SCHEDULER_ENABLED ?? "true"; + const secretsProvider = + process.env.PAPERCLIP_SECRETS_PROVIDER ?? + config?.secrets?.provider ?? + DEFAULT_SECRETS_PROVIDER; + const secretsStrictMode = + process.env.PAPERCLIP_SECRETS_STRICT_MODE ?? + String(config?.secrets?.strictMode ?? false); + const secretsKeyFilePath = + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE ?? + config?.secrets?.localEncrypted?.keyFilePath ?? + DEFAULT_SECRETS_KEY_FILE_PATH; const rows: EnvVarRow[] = [ { @@ -176,6 +189,39 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st required: false, note: "Set to `false` to disable timer scheduling", }, + { + key: "PAPERCLIP_SECRETS_PROVIDER", + value: secretsProvider, + source: process.env.PAPERCLIP_SECRETS_PROVIDER + ? "env" + : config?.secrets?.provider + ? "config" + : "default", + required: false, + note: "Default provider for new secrets", + }, + { + key: "PAPERCLIP_SECRETS_STRICT_MODE", + value: secretsStrictMode, + source: process.env.PAPERCLIP_SECRETS_STRICT_MODE + ? "env" + : config?.secrets?.strictMode !== undefined + ? "config" + : "default", + required: false, + note: "Require secret refs for sensitive env keys", + }, + { + key: "PAPERCLIP_SECRETS_MASTER_KEY_FILE", + value: secretsKeyFilePath, + source: process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE + ? "env" + : config?.secrets?.localEncrypted?.keyFilePath + ? "config" + : "default", + required: false, + note: "Path to local encrypted secrets key file", + }, ]; const defaultConfigPath = resolveConfigPath(); diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 0b3f1698..881f2481 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -6,6 +6,7 @@ import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js"; import { promptDatabase } from "../prompts/database.js"; import { promptLlm } from "../prompts/llm.js"; import { promptLogging } from "../prompts/logging.js"; +import { defaultSecretsConfig } from "../prompts/secrets.js"; import { promptServer } from "../prompts/server.js"; export async function onboard(opts: { config?: string }): Promise { @@ -98,6 +99,15 @@ export async function onboard(opts: { config?: string }): Promise { p.log.step(pc.bold("Server")); const server = await promptServer(); + // Secrets + p.log.step(pc.bold("Secrets")); + const secrets = defaultSecretsConfig(); + p.log.message( + pc.dim( + `Using defaults: provider=${secrets.provider}, strictMode=${secrets.strictMode}, keyFile=${secrets.localEncrypted.keyFilePath}`, + ), + ); + const jwtSecret = ensureAgentJwtSecret(); const envFilePath = resolveAgentJwtEnvFile(); if (jwtSecret.created) { @@ -119,6 +129,7 @@ export async function onboard(opts: { config?: string }): Promise { database, logging, server, + secrets, }; writeConfig(config, opts.config); @@ -129,6 +140,7 @@ export async function onboard(opts: { config?: string }): Promise { llm ? `LLM: ${llm.provider}` : "LLM: not configured", `Logging: ${logging.mode} → ${logging.logDir}`, `Server: port ${server.port}`, + `Secrets: ${secrets.provider} (strict mode ${secrets.strictMode ? "on" : "off"})`, `Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured`, ].join("\n"), "Configuration saved", diff --git a/cli/src/config/schema.ts b/cli/src/config/schema.ts index 5a07ec75..b3a18ee2 100644 --- a/cli/src/config/schema.ts +++ b/cli/src/config/schema.ts @@ -5,10 +5,14 @@ export { databaseConfigSchema, loggingConfigSchema, serverConfigSchema, + secretsConfigSchema, + secretsLocalEncryptedConfigSchema, type PaperclipConfig, type LlmConfig, type DatabaseConfig, type LoggingConfig, type ServerConfig, + type SecretsConfig, + type SecretsLocalEncryptedConfig, type ConfigMeta, } from "@paperclip/shared"; diff --git a/cli/src/index.ts b/cli/src/index.ts index 79a2de29..fd997ec3 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -38,7 +38,7 @@ program .command("configure") .description("Update configuration sections") .option("-c, --config ", "Path to config file") - .option("-s, --section
", "Section to configure (llm, database, logging, server)") + .option("-s, --section
", "Section to configure (llm, database, logging, server, secrets)") .action(configure); const heartbeat = program.command("heartbeat").description("Heartbeat utilities"); diff --git a/cli/src/prompts/secrets.ts b/cli/src/prompts/secrets.ts new file mode 100644 index 00000000..748cdf33 --- /dev/null +++ b/cli/src/prompts/secrets.ts @@ -0,0 +1,94 @@ +import * as p from "@clack/prompts"; +import type { SecretProvider } from "@paperclip/shared"; +import type { SecretsConfig } from "../config/schema.js"; + +const DEFAULT_KEY_FILE_PATH = "./data/secrets/master.key"; + +export function defaultSecretsConfig(): SecretsConfig { + return { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: DEFAULT_KEY_FILE_PATH, + }, + }; +} + +export async function promptSecrets(current?: SecretsConfig): Promise { + const base = current ?? defaultSecretsConfig(); + + const provider = await p.select({ + message: "Secrets provider", + options: [ + { + value: "local_encrypted" as const, + label: "Local encrypted (recommended)", + hint: "best for single-developer installs", + }, + { + value: "aws_secrets_manager" as const, + label: "AWS Secrets Manager", + hint: "requires external adapter integration", + }, + { + value: "gcp_secret_manager" as const, + label: "GCP Secret Manager", + hint: "requires external adapter integration", + }, + { + value: "vault" as const, + label: "HashiCorp Vault", + hint: "requires external adapter integration", + }, + ], + initialValue: base.provider, + }); + + if (p.isCancel(provider)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + + const strictMode = await p.confirm({ + message: "Require secret refs for sensitive env vars?", + initialValue: base.strictMode, + }); + + if (p.isCancel(strictMode)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + + let keyFilePath = base.localEncrypted.keyFilePath || DEFAULT_KEY_FILE_PATH; + if (provider === "local_encrypted") { + const keyPath = await p.text({ + message: "Local encrypted key file path", + defaultValue: keyFilePath, + placeholder: DEFAULT_KEY_FILE_PATH, + validate: (value) => { + if (!value || value.trim().length === 0) return "Key file path is required"; + }, + }); + + if (p.isCancel(keyPath)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + keyFilePath = keyPath.trim(); + } + + if (provider !== "local_encrypted") { + p.note( + `${provider} is not fully wired in this build yet. Keep local_encrypted unless you are actively implementing that adapter.`, + "Heads up", + ); + } + + return { + provider: provider as SecretProvider, + strictMode, + localEncrypted: { + keyFilePath, + }, + }; +}