From fe6a8687c1c51eb7ce9004a234db47de657441a0 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Wed, 18 Feb 2026 16:46:45 -0600 Subject: [PATCH] Implement local agent JWT authentication for adapters Add HS256 JWT-based authentication for local adapters (claude_local, codex_local) so agents authenticate automatically without manual API key configuration. The server mints short-lived JWTs per heartbeat run and injects them as PAPERCLIP_API_KEY. The auth middleware verifies JWTs alongside existing static API keys. Includes: CLI onboard/doctor JWT secret management, env command for deployment, config path resolution from ancestor directories, dotenv loading on server startup, event payload secret redaction, multi-status issue filtering, and adapter transcript parsing for thinking/user message kinds. Co-Authored-By: Claude Opus 4.6 --- cli/package.json | 1 + cli/src/checks/agent-jwt-secret-check.ts | 40 ++++ cli/src/checks/index.ts | 1 + cli/src/commands/doctor.ts | 22 +- cli/src/commands/env.ts | 209 ++++++++++++++++++ cli/src/commands/onboard.ts | 32 ++- cli/src/config/env.ts | 91 ++++++++ cli/src/config/store.ts | 21 +- cli/src/index.ts | 7 + .../agent-authentication-implementation.md | 72 ++++++ doc/plans/agent-authentication.md | 4 + packages/adapter-utils/src/types.ts | 4 + .../claude-local/src/server/execute.ts | 7 +- .../codex-local/src/server/execute.ts | 7 +- pnpm-lock.yaml | 12 + server/package.json | 1 + server/src/__tests__/agent-auth-jwt.test.ts | 79 +++++++ server/src/adapters/registry.ts | 2 + server/src/agent-auth-jwt.ts | 141 ++++++++++++ server/src/config-file.ts | 6 +- server/src/config.ts | 10 +- server/src/middleware/auth.ts | 31 ++- server/src/paths.ts | 33 +++ server/src/routes/agents.ts | 45 +++- server/src/services/heartbeat.ts | 32 +-- server/src/services/issues.ts | 5 +- server/src/startup-banner.ts | 53 ++++- skills/paperclip/SKILL.md | 2 +- 28 files changed, 921 insertions(+), 49 deletions(-) create mode 100644 cli/src/checks/agent-jwt-secret-check.ts create mode 100644 cli/src/commands/env.ts create mode 100644 cli/src/config/env.ts create mode 100644 doc/plans/agent-authentication-implementation.md create mode 100644 server/src/__tests__/agent-auth-jwt.test.ts create mode 100644 server/src/agent-auth-jwt.ts create mode 100644 server/src/paths.ts diff --git a/cli/package.json b/cli/package.json index 1b03e4c3..d2d4f1dc 100644 --- a/cli/package.json +++ b/cli/package.json @@ -18,6 +18,7 @@ "@paperclip/adapter-utils": "workspace:*", "@paperclip/db": "workspace:*", "@paperclip/shared": "workspace:*", + "dotenv": "^17.0.1", "commander": "^13.1.0", "picocolors": "^1.1.1" }, diff --git a/cli/src/checks/agent-jwt-secret-check.ts b/cli/src/checks/agent-jwt-secret-check.ts new file mode 100644 index 00000000..a90f97d6 --- /dev/null +++ b/cli/src/checks/agent-jwt-secret-check.ts @@ -0,0 +1,40 @@ +import { + ensureAgentJwtSecret, + readAgentJwtSecretFromEnv, + readAgentJwtSecretFromEnvFile, + resolveAgentJwtEnvFile, +} from "../config/env.js"; +import type { CheckResult } from "./index.js"; + +export function agentJwtSecretCheck(): CheckResult { + if (readAgentJwtSecretFromEnv()) { + return { + name: "Agent JWT secret", + status: "pass", + message: "PAPERCLIP_AGENT_JWT_SECRET is set in environment", + }; + } + + const envPath = resolveAgentJwtEnvFile(); + 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(); + }, + repairHint: `Run with --repair to create ${envPath} containing PAPERCLIP_AGENT_JWT_SECRET`, + }; +} diff --git a/cli/src/checks/index.ts b/cli/src/checks/index.ts index a9107e64..c8a868f5 100644 --- a/cli/src/checks/index.ts +++ b/cli/src/checks/index.ts @@ -7,6 +7,7 @@ export interface CheckResult { repairHint?: string; } +export { agentJwtSecretCheck } from "./agent-jwt-secret-check.js"; export { configCheck } from "./config-check.js"; export { databaseCheck } from "./database-check.js"; export { llmCheck } from "./llm-check.js"; diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index ac1b87c3..0b68d002 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -1,8 +1,9 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; import type { PaperclipConfig } from "../config/schema.js"; -import { readConfig } from "../config/store.js"; +import { readConfig, resolveConfigPath } from "../config/store.js"; import { + agentJwtSecretCheck, configCheck, databaseCheck, llmCheck, @@ -24,6 +25,7 @@ export async function doctor(opts: { }): Promise { p.intro(pc.bgCyan(pc.black(" paperclip doctor "))); + const configPath = resolveConfigPath(opts.config); const results: CheckResult[] = []; // 1. Config check (must pass before others) @@ -53,24 +55,30 @@ export async function doctor(opts: { return; } - // 2. Database check - const dbResult = await databaseCheck(config); + // 2. Agent JWT check + const jwtResult = agentJwtSecretCheck(); + results.push(jwtResult); + printResult(jwtResult); + await maybeRepair(jwtResult, opts); + + // 3. Database check + const dbResult = await databaseCheck(config, configPath); results.push(dbResult); printResult(dbResult); await maybeRepair(dbResult, opts); - // 3. LLM check + // 4. LLM check const llmResult = await llmCheck(config); results.push(llmResult); printResult(llmResult); - // 4. Log directory check - const logResult = logCheck(config); + // 5. Log directory check + const logResult = logCheck(config, configPath); results.push(logResult); printResult(logResult); await maybeRepair(logResult, opts); - // 5. Port check + // 6. 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 new file mode 100644 index 00000000..9f34bd07 --- /dev/null +++ b/cli/src/commands/env.ts @@ -0,0 +1,209 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import type { PaperclipConfig } from "../config/schema.js"; +import { configExists, readConfig, resolveConfigPath } from "../config/store.js"; +import { + readAgentJwtSecretFromEnv, + readAgentJwtSecretFromEnvFile, + resolveAgentJwtEnvFile, +} from "../config/env.js"; + +type EnvSource = "env" | "config" | "file" | "default" | "missing"; + +type EnvVarRow = { + key: string; + value: string; + source: EnvSource; + required: boolean; + note: string; +}; + +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"; + +export async function envCommand(opts: { config?: string }): Promise { + p.intro(pc.bgCyan(pc.black(" paperclip env "))); + + const configPath = resolveConfigPath(opts.config); + let config: PaperclipConfig | null = null; + let configReadError: string | null = null; + + if (configExists(opts.config)) { + p.log.message(pc.dim(`Config file: ${configPath}`)); + try { + config = readConfig(opts.config); + } catch (err) { + configReadError = err instanceof Error ? err.message : String(err); + p.log.message(pc.yellow(`Could not parse config: ${configReadError}`)); + } + } else { + p.log.message(pc.dim(`Config file missing: ${configPath}`)); + } + + const rows = collectDeploymentEnvRows(config, configPath); + const missingRequired = rows.filter((row) => row.required && row.source === "missing"); + const sortedRows = rows.sort((a, b) => Number(b.required) - Number(a.required) || a.key.localeCompare(b.key)); + + const requiredRows = sortedRows.filter((row) => row.required); + const optionalRows = sortedRows.filter((row) => !row.required); + + const formatSection = (title: string, entries: EnvVarRow[]) => { + if (entries.length === 0) return; + + p.log.message(pc.bold(title)); + for (const entry of entries) { + const status = entry.source === "missing" ? pc.red("missing") : entry.source === "default" ? pc.yellow("default") : pc.green("set"); + const sourceNote = { + env: "environment", + config: "config", + file: "file", + default: "default", + missing: "missing", + }[entry.source]; + p.log.message( + `${pc.cyan(entry.key)} ${status.padEnd(7)} ${pc.dim(`[${sourceNote}] ${entry.note}`)}${entry.source === "missing" ? "" : ` ${pc.dim("=>")} ${pc.white(quoteShellValue(entry.value))}`}`, + ); + } + }; + + formatSection("Required environment variables", requiredRows); + formatSection("Optional environment variables", optionalRows); + + const exportRows = rows.map((row) => (row.source === "missing" ? { ...row, value: "" } : row)); + const uniqueRows = uniqueByKey(exportRows); + const exportBlock = uniqueRows.map((row) => `export ${row.key}=${quoteShellValue(row.value)}`).join("\n"); + + if (configReadError) { + p.log.error(`Could not load config cleanly: ${configReadError}`); + } + + p.note( + exportBlock || "No values detected. Set required variables manually.", + "Deployment export block", + ); + + if (missingRequired.length > 0) { + p.log.message( + pc.yellow( + `Missing required values: ${missingRequired.map((row) => row.key).join(", ")}. Set these before deployment.`, + ), + ); + } else { + p.log.message(pc.green("All required deployment variables are present.")); + } + p.outro("Done"); +} + +function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: string): EnvVarRow[] { + const agentJwtEnvFile = resolveAgentJwtEnvFile(); + const jwtEnv = readAgentJwtSecretFromEnv(); + const jwtFile = jwtEnv ? null : readAgentJwtSecretFromEnvFile(agentJwtEnvFile); + const jwtSource = jwtEnv ? "env" : jwtFile ? "file" : "missing"; + + const dbUrl = process.env.DATABASE_URL ?? config?.database?.connectionString ?? ""; + const databaseMode = config?.database?.mode ?? "embedded-postgres"; + const dbUrlSource: EnvSource = process.env.DATABASE_URL ? "env" : config?.database?.connectionString ? "config" : "missing"; + + const heartbeatInterval = process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ?? DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS; + const heartbeatEnabled = process.env.HEARTBEAT_SCHEDULER_ENABLED ?? "true"; + + const rows: EnvVarRow[] = [ + { + key: "PAPERCLIP_AGENT_JWT_SECRET", + value: jwtEnv ?? jwtFile ?? "", + source: jwtSource, + required: true, + note: + jwtSource === "missing" + ? "Generate during onboard or set manually (required for local adapter authentication)" + : jwtSource === "env" + ? "Set in process environment" + : `Set in ${agentJwtEnvFile}`, + }, + { + key: "DATABASE_URL", + value: dbUrl, + source: dbUrlSource, + required: true, + note: + databaseMode === "postgres" + ? "Configured for postgres mode (required)" + : "Required for live deployment with managed PostgreSQL", + }, + { + key: "PORT", + value: + process.env.PORT ?? + (config?.server?.port !== undefined ? String(config.server.port) : "3100"), + source: process.env.PORT ? "env" : config?.server?.port !== undefined ? "config" : "default", + required: false, + note: "HTTP listen port", + }, + { + key: "PAPERCLIP_AGENT_JWT_TTL_SECONDS", + value: process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS ?? DEFAULT_AGENT_JWT_TTL_SECONDS, + source: process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS ? "env" : "default", + required: false, + note: "JWT lifetime in seconds", + }, + { + key: "PAPERCLIP_AGENT_JWT_ISSUER", + value: process.env.PAPERCLIP_AGENT_JWT_ISSUER ?? DEFAULT_AGENT_JWT_ISSUER, + source: process.env.PAPERCLIP_AGENT_JWT_ISSUER ? "env" : "default", + required: false, + note: "JWT issuer", + }, + { + key: "PAPERCLIP_AGENT_JWT_AUDIENCE", + value: process.env.PAPERCLIP_AGENT_JWT_AUDIENCE ?? DEFAULT_AGENT_JWT_AUDIENCE, + source: process.env.PAPERCLIP_AGENT_JWT_AUDIENCE ? "env" : "default", + required: false, + note: "JWT audience", + }, + { + key: "HEARTBEAT_SCHEDULER_INTERVAL_MS", + value: heartbeatInterval, + source: process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ? "env" : "default", + required: false, + note: "Heartbeat worker interval in ms", + }, + { + key: "HEARTBEAT_SCHEDULER_ENABLED", + value: heartbeatEnabled, + source: process.env.HEARTBEAT_SCHEDULER_ENABLED ? "env" : "default", + required: false, + note: "Set to `false` to disable timer scheduling", + }, + ]; + + const defaultConfigPath = resolveConfigPath(); + if (process.env.PAPERCLIP_CONFIG || configPath !== defaultConfigPath) { + rows.push({ + key: "PAPERCLIP_CONFIG", + value: process.env.PAPERCLIP_CONFIG ?? configPath, + source: process.env.PAPERCLIP_CONFIG ? "env" : "default", + required: false, + note: "Optional path override for config file", + }); + } + + return rows; +} + +function uniqueByKey(rows: EnvVarRow[]): EnvVarRow[] { + const seen = new Set(); + const result: EnvVarRow[] = []; + for (const row of rows) { + if (seen.has(row.key)) continue; + seen.add(row.key); + result.push(row); + } + return result; +} + +function quoteShellValue(value: string): string { + if (value === "") return "\"\""; + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index b92463fb..0b3f1698 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -1,7 +1,8 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; -import { configExists, readConfig, writeConfig } from "../config/store.js"; +import { configExists, readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; import type { PaperclipConfig } from "../config/schema.js"; +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"; @@ -12,25 +13,18 @@ export async function onboard(opts: { config?: string }): Promise { // Check for existing config if (configExists(opts.config)) { + const configPath = resolveConfigPath(opts.config); + p.log.message(pc.dim(`${configPath} exists, updating config`)); + try { readConfig(opts.config); } catch (err) { p.log.message( pc.yellow( - `Existing config appears invalid and will be replaced if you continue.\n${err instanceof Error ? err.message : String(err)}`, + `Existing config appears invalid and will be updated.\n${err instanceof Error ? err.message : String(err)}`, ), ); } - - const overwrite = await p.confirm({ - message: "A config file already exists. Overwrite it?", - initialValue: false, - }); - - if (p.isCancel(overwrite) || !overwrite) { - p.cancel("Keeping existing configuration."); - return; - } } // Database @@ -104,6 +98,16 @@ export async function onboard(opts: { config?: string }): Promise { p.log.step(pc.bold("Server")); const server = await promptServer(); + const jwtSecret = ensureAgentJwtSecret(); + const envFilePath = resolveAgentJwtEnvFile(); + if (jwtSecret.created) { + p.log.success(`Created ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); + } else if (process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim()) { + p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from environment`); + } else { + p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); + } + // Assemble and write config const config: PaperclipConfig = { $meta: { @@ -125,10 +129,14 @@ export async function onboard(opts: { config?: string }): Promise { llm ? `LLM: ${llm.provider}` : "LLM: not configured", `Logging: ${logging.mode} → ${logging.logDir}`, `Server: port ${server.port}`, + `Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured`, ].join("\n"), "Configuration saved", ); p.log.info(`Run ${pc.cyan("pnpm paperclip doctor")} to verify your setup.`); + p.log.message( + `Before starting Paperclip, export ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from ${pc.dim(envFilePath)} (for example: ${pc.dim(`set -a; source ${envFilePath}; set +a`)})`, + ); p.outro("You're all set!"); } diff --git a/cli/src/config/env.ts b/cli/src/config/env.ts new file mode 100644 index 00000000..98b5938f --- /dev/null +++ b/cli/src/config/env.ts @@ -0,0 +1,91 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomBytes } from "node:crypto"; +import { config as loadDotenv, parse as parseEnvFileContents } from "dotenv"; +import { resolveConfigPath } from "./store.js"; + +const JWT_SECRET_ENV_KEY = "PAPERCLIP_AGENT_JWT_SECRET"; +function resolveEnvFilePath() { + return path.resolve(path.dirname(resolveConfigPath()), ".env"); +} + +const ENV_FILE_PATH = resolveEnvFilePath(); +const loadedEnvFiles = new Set(); + +function isNonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function parseEnvFile(contents: string) { + try { + return parseEnvFileContents(contents); + } catch { + return {}; + } +} + +function renderEnvFile(entries: Record) { + const lines = [ + "# Paperclip environment variables", + "# Generated by `paperclip onboard`", + ...Object.entries(entries).map(([key, value]) => `${key}=${value}`), + "", + ]; + return lines.join("\n"); +} + +export function resolveAgentJwtEnvFile(): string { + return ENV_FILE_PATH; +} + +export function loadAgentJwtEnvFile(filePath = ENV_FILE_PATH): void { + if (loadedEnvFiles.has(filePath)) return; + + if (!fs.existsSync(filePath)) return; + loadedEnvFiles.add(filePath); + loadDotenv({ path: filePath, override: false, quiet: true }); +} + +export function readAgentJwtSecretFromEnv(): string | null { + loadAgentJwtEnvFile(); + const raw = process.env[JWT_SECRET_ENV_KEY]; + return isNonEmpty(raw) ? raw!.trim() : null; +} + +export function readAgentJwtSecretFromEnvFile(filePath = ENV_FILE_PATH): string | null { + if (!fs.existsSync(filePath)) return null; + + const raw = fs.readFileSync(filePath, "utf-8"); + const values = parseEnvFile(raw); + const value = values[JWT_SECRET_ENV_KEY]; + return isNonEmpty(value) ? value!.trim() : null; +} + +export function ensureAgentJwtSecret(): { secret: string; created: boolean } { + const existingEnv = readAgentJwtSecretFromEnv(); + if (existingEnv) { + return { secret: existingEnv, created: false }; + } + + const existingFile = readAgentJwtSecretFromEnvFile(); + const secret = existingFile ?? randomBytes(32).toString("hex"); + const created = !existingFile; + + if (!existingFile) { + writeAgentJwtEnv(secret); + } + + return { secret, created }; +} + +export function writeAgentJwtEnv(secret: string, filePath = ENV_FILE_PATH): void { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + + const current = fs.existsSync(filePath) ? parseEnvFile(fs.readFileSync(filePath, "utf-8")) : {}; + current[JWT_SECRET_ENV_KEY] = secret; + + fs.writeFileSync(filePath, renderEnvFile(current), { + mode: 0o600, + }); +} diff --git a/cli/src/config/store.ts b/cli/src/config/store.ts index e7b59ebf..973244be 100644 --- a/cli/src/config/store.ts +++ b/cli/src/config/store.ts @@ -3,11 +3,30 @@ import path from "node:path"; import { paperclipConfigSchema, type PaperclipConfig } from "./schema.js"; const DEFAULT_CONFIG_PATH = ".paperclip/config.json"; +const DEFAULT_CONFIG_BASENAME = "config.json"; + +function findConfigFileFromAncestors(startDir: string): string | null { + const absoluteStartDir = path.resolve(startDir); + let currentDir = absoluteStartDir; + + while (true) { + const candidate = path.resolve(currentDir, ".paperclip", DEFAULT_CONFIG_BASENAME); + if (fs.existsSync(candidate)) { + return candidate; + } + + const nextDir = path.resolve(currentDir, ".."); + if (nextDir === currentDir) break; + currentDir = nextDir; + } + + return null; +} export function resolveConfigPath(overridePath?: string): string { if (overridePath) return path.resolve(overridePath); if (process.env.PAPERCLIP_CONFIG) return path.resolve(process.env.PAPERCLIP_CONFIG); - return path.resolve(process.cwd(), DEFAULT_CONFIG_PATH); + return findConfigFileFromAncestors(process.cwd()) ?? path.resolve(process.cwd(), DEFAULT_CONFIG_PATH); } function parseJson(filePath: string): unknown { diff --git a/cli/src/index.ts b/cli/src/index.ts index ff052fbf..79a2de29 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import { onboard } from "./commands/onboard.js"; import { doctor } from "./commands/doctor.js"; +import { envCommand } from "./commands/env.js"; import { configure } from "./commands/configure.js"; import { heartbeatRun } from "./commands/heartbeat-run.js"; @@ -27,6 +28,12 @@ program .option("-y, --yes", "Skip repair confirmation prompts") .action(doctor); +program + .command("env") + .description("Print environment variables for deployment") + .option("-c, --config ", "Path to config file") + .action(envCommand); + program .command("configure") .description("Update configuration sections") diff --git a/doc/plans/agent-authentication-implementation.md b/doc/plans/agent-authentication-implementation.md new file mode 100644 index 00000000..809d07dd --- /dev/null +++ b/doc/plans/agent-authentication-implementation.md @@ -0,0 +1,72 @@ +# Agent Authentication — P0 Local Adapter JWT Implementation + +## Scope + +- In-scope adapters: `claude_local`, `codex_local`. +- Goal: zero-configuration auth for local adapters while preserving static keys for all other call paths. +- Out-of-scope for P0: rotation UX, per-device revocation list, and CLI onboarding. + +## 1) Token format and config + +- Use HS256 JWTs with claims: + - `sub` (agent id) + - `company_id` + - `adapter_type` + - `run_id` + - `iat` + - `exp` + - optional `jti` (run token id) +- New config/env settings: + - `PAPERCLIP_AGENT_JWT_SECRET` + - `PAPERCLIP_AGENT_JWT_TTL_SECONDS` (default: `172800`) + - `PAPERCLIP_AGENT_JWT_ISSUER` (default: `paperclip`) + - `PAPERCLIP_AGENT_JWT_AUDIENCE` (default: `paperclip-api`) + +## 2) Dual authentication path in `actorMiddleware` + +1. Keep the existing DB key lookup path unchanged (`agent_api_keys` hash lookup). +2. If no DB key matches, add JWT verification in `server/src/middleware/auth.ts`. +3. On JWT success: + - set `req.actor = { type: "agent", agentId, companyId }`. + - optionally guard against terminated agents. +4. Continue board fallback for requests without valid authentication. + +## 3) Opt-in adapter capability + +1. Extend `ServerAdapterModule` (likely `packages/adapter-utils/src/types.ts`) with a capability flag: + - `supportsLocalAgentJwt?: true`. +2. Enable it on: + - `server/src/adapters/registry.ts` for `claude_local` and `codex_local`. +3. Keep `process`/`http` adapters unset for P0. +4. In `server/src/services/heartbeat.ts`, when adapter supports JWT: + - mint JWT per heartbeat run before execute. + - include token in adapter execution context. + +## 4) Local env injection behavior + +1. In: + - `packages/adapters/claude-local/src/server/execute.ts` + - `packages/adapters/codex-local/src/server/execute.ts` + + inject `PAPERCLIP_API_KEY` from context token. + +- Preserve existing behavior for explicit user-defined env vars in `adapterConfig.env`: + - if user already sets `PAPERCLIP_API_KEY`, do not overwrite it. +- Continue injecting: + - `PAPERCLIP_AGENT_ID` + - `PAPERCLIP_COMPANY_ID` + - `PAPERCLIP_API_URL` + +## 5) Documentation updates + +- Update operator-facing docs to remove manual key setup expectation for local adapters: + - `skills/paperclip/SKILL.md` + - `cli/src/commands/heartbeat-run.ts` output/help examples if they mention manual API key setup. + +## 6) P0 acceptance criteria + +- Local adapters authenticate without manual `PAPERCLIP_API_KEY` config. +- Existing static keys (`agent_api_keys`) still work unchanged. +- Auth remains company-scoped (`req.actor.companyId` used by existing checks). +- JWT generation and verification errors are logged as non-leaking structured events. +- Scope remains local-only (`claude_local`, `codex_local`) while adapter capability model is generic. diff --git a/doc/plans/agent-authentication.md b/doc/plans/agent-authentication.md index 4ce3a110..8eb990f8 100644 --- a/doc/plans/agent-authentication.md +++ b/doc/plans/agent-authentication.md @@ -205,6 +205,10 @@ On approval, the approver sets: | **P2** | OpenClaw integration | First real external agent onboarding via invite link. | | **P3** | CLI auth flow | `paperclip auth login` for developer-managed remote agents. | +## P0 Implementation Plan + +See [`doc/plans/agent-authentication-implementation.md`](./agent-authentication-implementation.md) for the P0 local JWT execution plan. + --- ## Open Questions diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 4f83a350..e07f03a8 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -57,11 +57,13 @@ export interface AdapterExecutionContext { context: Record; onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; onMeta?: (meta: AdapterInvocationMeta) => Promise; + authToken?: string; } export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; + supportsLocalAgentJwt?: boolean; models?: { id: string; label: string }[]; } @@ -71,6 +73,8 @@ export interface ServerAdapterModule { export type TranscriptEntry = | { kind: "assistant"; ts: string; text: string } + | { kind: "thinking"; ts: string; text: string } + | { kind: "user"; ts: string; text: string } | { kind: "tool_call"; ts: string; name: string; input: unknown } | { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean } | { kind: "init"; ts: string; model: string; sessionId: string } diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 078d0163..9c0c75a7 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -48,7 +48,7 @@ async function buildSkillsDir(): Promise { } export async function execute(ctx: AdapterExecutionContext): Promise { - const { runId, agent, runtime, config, context, onLog, onMeta } = ctx; + const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; const promptTemplate = asString( config.promptTemplate, @@ -63,10 +63,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const env: Record = { ...buildPaperclipEnv(agent) }; for (const [k, v] of Object.entries(envConfig)) { if (typeof v === "string") env[k] = v; } + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); await ensureCommandResolvable(command, cwd, runtimeEnv); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 75ef0815..a46ab217 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -16,7 +16,7 @@ import { import { parseCodexJsonl } from "./parse.js"; export async function execute(ctx: AdapterExecutionContext): Promise { - const { runId, agent, runtime, config, context, onLog, onMeta } = ctx; + const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; const promptTemplate = asString( config.promptTemplate, @@ -31,10 +31,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const env: Record = { ...buildPaperclipEnv(agent) }; for (const [k, v] of Object.entries(envConfig)) { if (typeof v === "string") env[k] = v; } + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); await ensureCommandResolvable(command, cwd, runtimeEnv); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a95a6e22..05f947cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: commander: specifier: ^13.1.0 version: 13.1.0 + dotenv: + specifier: ^17.0.1 + version: 17.3.1 picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -139,6 +142,9 @@ importers: detect-port: specifier: ^2.1.0 version: 2.1.0 + dotenv: + specifier: ^17.0.1 + version: 17.3.1 drizzle-orm: specifier: ^0.38.4 version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) @@ -2097,6 +2103,10 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + drizzle-kit@0.31.9: resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==} hasBin: true @@ -4711,6 +4721,8 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 + dotenv@17.3.1: {} + drizzle-kit@0.31.9: dependencies: '@drizzle-team/brocli': 0.10.2 diff --git a/server/package.json b/server/package.json index d43f4797..095a0b0b 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "@paperclip/adapter-utils": "workspace:*", "@paperclip/db": "workspace:*", "@paperclip/shared": "workspace:*", + "dotenv": "^17.0.1", "detect-port": "^2.1.0", "drizzle-orm": "^0.38.4", "express": "^5.1.0", diff --git a/server/src/__tests__/agent-auth-jwt.test.ts b/server/src/__tests__/agent-auth-jwt.test.ts new file mode 100644 index 00000000..1cc8a60a --- /dev/null +++ b/server/src/__tests__/agent-auth-jwt.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createLocalAgentJwt, verifyLocalAgentJwt } from "../agent-auth-jwt.js"; + +describe("agent local JWT", () => { + const secretEnv = "PAPERCLIP_AGENT_JWT_SECRET"; + const ttlEnv = "PAPERCLIP_AGENT_JWT_TTL_SECONDS"; + const issuerEnv = "PAPERCLIP_AGENT_JWT_ISSUER"; + const audienceEnv = "PAPERCLIP_AGENT_JWT_AUDIENCE"; + + const originalEnv = { + secret: process.env[secretEnv], + ttl: process.env[ttlEnv], + issuer: process.env[issuerEnv], + audience: process.env[audienceEnv], + }; + + beforeEach(() => { + process.env[secretEnv] = "test-secret"; + process.env[ttlEnv] = "3600"; + delete process.env[issuerEnv]; + delete process.env[audienceEnv]; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + if (originalEnv.secret === undefined) delete process.env[secretEnv]; + else process.env[secretEnv] = originalEnv.secret; + if (originalEnv.ttl === undefined) delete process.env[ttlEnv]; + else process.env[ttlEnv] = originalEnv.ttl; + if (originalEnv.issuer === undefined) delete process.env[issuerEnv]; + else process.env[issuerEnv] = originalEnv.issuer; + if (originalEnv.audience === undefined) delete process.env[audienceEnv]; + else process.env[audienceEnv] = originalEnv.audience; + }); + + it("creates and verifies a token", () => { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1"); + expect(typeof token).toBe("string"); + + const claims = verifyLocalAgentJwt(token!); + expect(claims).toMatchObject({ + sub: "agent-1", + company_id: "company-1", + adapter_type: "claude_local", + run_id: "run-1", + iss: "paperclip", + aud: "paperclip-api", + }); + }); + + it("returns null when secret is missing", () => { + process.env[secretEnv] = ""; + const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1"); + expect(token).toBeNull(); + expect(verifyLocalAgentJwt("abc.def.ghi")).toBeNull(); + }); + + it("rejects expired tokens", () => { + process.env[ttlEnv] = "1"; + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1"); + + vi.setSystemTime(new Date("2026-01-01T00:00:05.000Z")); + expect(verifyLocalAgentJwt(token!)).toBeNull(); + }); + + it("rejects issuer/audience mismatch", () => { + process.env[issuerEnv] = "custom-issuer"; + process.env[audienceEnv] = "custom-audience"; + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const token = createLocalAgentJwt("agent-1", "company-1", "codex_local", "run-1"); + + process.env[issuerEnv] = "paperclip"; + process.env[audienceEnv] = "paperclip-api"; + expect(verifyLocalAgentJwt(token!)).toBeNull(); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 585cb37b..c5b11e34 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -10,12 +10,14 @@ const claudeLocalAdapter: ServerAdapterModule = { type: "claude_local", execute: claudeExecute, models: claudeModels, + supportsLocalAgentJwt: true, }; const codexLocalAdapter: ServerAdapterModule = { type: "codex_local", execute: codexExecute, models: codexModels, + supportsLocalAgentJwt: true, }; const adaptersByType = new Map( diff --git a/server/src/agent-auth-jwt.ts b/server/src/agent-auth-jwt.ts new file mode 100644 index 00000000..6ec696b9 --- /dev/null +++ b/server/src/agent-auth-jwt.ts @@ -0,0 +1,141 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +interface JwtHeader { + alg: string; + typ?: string; +} + +export interface LocalAgentJwtClaims { + sub: string; + company_id: string; + adapter_type: string; + run_id: string; + iat: number; + exp: number; + iss?: string; + aud?: string; + jti?: string; +} + +const JWT_ALGORITHM = "HS256"; + +function parseNumber(value: string | undefined, fallback: number) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.floor(parsed); +} + +function jwtConfig() { + const secret = process.env.PAPERCLIP_AGENT_JWT_SECRET; + if (!secret) return null; + + return { + secret, + ttlSeconds: parseNumber(process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS, 60 * 60 * 48), + issuer: process.env.PAPERCLIP_AGENT_JWT_ISSUER ?? "paperclip", + audience: process.env.PAPERCLIP_AGENT_JWT_AUDIENCE ?? "paperclip-api", + }; +} + +function base64UrlEncode(value: string) { + return Buffer.from(value, "utf8").toString("base64url"); +} + +function base64UrlDecode(value: string) { + return Buffer.from(value, "base64url").toString("utf8"); +} + +function signPayload(secret: string, signingInput: string) { + return createHmac("sha256", secret).update(signingInput).digest("base64url"); +} + +function parseJson(value: string): Record | null { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" ? parsed as Record : null; + } catch { + return null; + } +} + +function safeCompare(a: string, b: string) { + const left = Buffer.from(a); + const right = Buffer.from(b); + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); +} + +export function createLocalAgentJwt(agentId: string, companyId: string, adapterType: string, runId: string) { + const config = jwtConfig(); + if (!config) return null; + + const now = Math.floor(Date.now() / 1000); + const claims: LocalAgentJwtClaims = { + sub: agentId, + company_id: companyId, + adapter_type: adapterType, + run_id: runId, + iat: now, + exp: now + config.ttlSeconds, + iss: config.issuer, + aud: config.audience, + }; + + const header = { + alg: JWT_ALGORITHM, + typ: "JWT", + }; + + const signingInput = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(claims))}`; + const signature = signPayload(config.secret, signingInput); + + return `${signingInput}.${signature}`; +} + +export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null { + if (!token) return null; + const config = jwtConfig(); + if (!config) return null; + + const parts = token.split("."); + if (parts.length !== 3) return null; + const [headerB64, claimsB64, signature] = parts; + + const header = parseJson(base64UrlDecode(headerB64)); + if (!header || header.alg !== JWT_ALGORITHM) return null; + + const signingInput = `${headerB64}.${claimsB64}`; + const expectedSig = signPayload(config.secret, signingInput); + if (!safeCompare(signature, expectedSig)) return null; + + const claims = parseJson(base64UrlDecode(claimsB64)); + if (!claims) return null; + + const sub = typeof claims.sub === "string" ? claims.sub : null; + const companyId = typeof claims.company_id === "string" ? claims.company_id : null; + const adapterType = typeof claims.adapter_type === "string" ? claims.adapter_type : null; + const runId = typeof claims.run_id === "string" ? claims.run_id : null; + const iat = typeof claims.iat === "number" ? claims.iat : null; + const exp = typeof claims.exp === "number" ? claims.exp : null; + if (!sub || !companyId || !adapterType || !runId || !iat || !exp) return null; + + const now = Math.floor(Date.now() / 1000); + if (exp < now) return null; + + const issuer = typeof claims.iss === "string" ? claims.iss : undefined; + const audience = typeof claims.aud === "string" ? claims.aud : undefined; + if (issuer && issuer !== config.issuer) return null; + if (audience && audience !== config.audience) return null; + + return { + sub, + company_id: companyId, + adapter_type: adapterType, + run_id: runId, + iat, + exp, + ...(issuer ? { iss: issuer } : {}), + ...(audience ? { aud: audience } : {}), + jti: typeof claims.jti === "string" ? claims.jti : undefined, + }; +} diff --git a/server/src/config-file.ts b/server/src/config-file.ts index 6b8260c7..19167786 100644 --- a/server/src/config-file.ts +++ b/server/src/config-file.ts @@ -1,11 +1,9 @@ import fs from "node:fs"; -import path from "node:path"; import { paperclipConfigSchema, type PaperclipConfig } from "@paperclip/shared"; +import { resolvePaperclipConfigPath } from "./paths.js"; export function readConfigFile(): PaperclipConfig | null { - const configPath = process.env.PAPERCLIP_CONFIG - ? path.resolve(process.env.PAPERCLIP_CONFIG) - : path.resolve(process.cwd(), ".paperclip/config.json"); + const configPath = resolvePaperclipConfigPath(); if (!fs.existsSync(configPath)) return null; diff --git a/server/src/config.ts b/server/src/config.ts index b684d901..797ce390 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,4 +1,12 @@ import { readConfigFile } from "./config-file.js"; +import { existsSync } from "node:fs"; +import { config as loadDotenv } from "dotenv"; +import { resolvePaperclipEnvPath } from "./paths.js"; + +const PAPERCLIP_ENV_FILE_PATH = resolvePaperclipEnvPath(); +if (existsSync(PAPERCLIP_ENV_FILE_PATH)) { + loadDotenv({ path: PAPERCLIP_ENV_FILE_PATH, override: false, quiet: true }); +} type DatabaseMode = "embedded-postgres" | "postgres"; @@ -33,7 +41,7 @@ export function loadConfig(): Config { serveUi: process.env.SERVE_UI !== undefined ? process.env.SERVE_UI === "true" - : fileConfig?.server.serveUi ?? false, + : fileConfig?.server.serveUi ?? true, uiDevMiddleware: process.env.PAPERCLIP_UI_DEV_MIDDLEWARE === "true", heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false", heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000), diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index cce6f6b6..74b07dd3 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -2,7 +2,8 @@ import { createHash } from "node:crypto"; import type { RequestHandler } from "express"; import { and, eq, isNull } from "drizzle-orm"; import type { Db } from "@paperclip/db"; -import { agentApiKeys } from "@paperclip/db"; +import { agentApiKeys, agents } from "@paperclip/db"; +import { verifyLocalAgentJwt } from "../agent-auth-jwt.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -32,6 +33,34 @@ export function actorMiddleware(db: Db): RequestHandler { .then((rows) => rows[0] ?? null); if (!key) { + const claims = verifyLocalAgentJwt(token); + if (!claims) { + next(); + return; + } + + const agentRecord = await db + .select() + .from(agents) + .where(eq(agents.id, claims.sub)) + .then((rows) => rows[0] ?? null); + + if (!agentRecord || agentRecord.companyId !== claims.company_id) { + next(); + return; + } + + if (agentRecord.status === "terminated") { + next(); + return; + } + + req.actor = { + type: "agent", + agentId: claims.sub, + companyId: claims.company_id, + keyId: undefined, + }; next(); return; } diff --git a/server/src/paths.ts b/server/src/paths.ts new file mode 100644 index 00000000..07492c02 --- /dev/null +++ b/server/src/paths.ts @@ -0,0 +1,33 @@ +import fs from "node:fs"; +import path from "node:path"; + +const PAPERCLIP_CONFIG_BASENAME = "config.json"; +const PAPERCLIP_ENV_FILENAME = ".env"; + +function findConfigFileFromAncestors(startDir: string): string | null { + const absoluteStartDir = path.resolve(startDir); + let currentDir = absoluteStartDir; + + while (true) { + const candidate = path.resolve(currentDir, ".paperclip", PAPERCLIP_CONFIG_BASENAME); + if (fs.existsSync(candidate)) { + return candidate; + } + + const nextDir = path.resolve(currentDir, ".."); + if (nextDir === currentDir) break; + currentDir = nextDir; + } + + return null; +} + +export function resolvePaperclipConfigPath(overridePath?: string): string { + if (overridePath) return path.resolve(overridePath); + if (process.env.PAPERCLIP_CONFIG) return path.resolve(process.env.PAPERCLIP_CONFIG); + return findConfigFileFromAncestors(process.cwd()) ?? path.resolve(process.cwd(), ".paperclip", PAPERCLIP_CONFIG_BASENAME); +} + +export function resolvePaperclipEnvPath(overrideConfigPath?: string): string { + return path.resolve(path.dirname(resolvePaperclipConfigPath(overrideConfigPath)), PAPERCLIP_ENV_FILENAME); +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 9a26c0c9..dffae2dc 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -11,6 +11,45 @@ import { agentService, heartbeatService, logActivity } from "../services/index.j import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { listAdapterModels } from "../adapters/index.js"; +const SECRET_PAYLOAD_KEY_RE = + /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; +const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/; +const REDACTED_EVENT_VALUE = "***REDACTED***"; + +function sanitizeValue(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (Array.isArray(value)) return value.map(sanitizeValue); + if (typeof value !== "object") return value; + if (value instanceof Date) return value; + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return value; + return sanitizeRecord(value as Record); +} + +function sanitizeRecord(record: Record): Record { + const redacted: Record = {}; + for (const [key, value] of Object.entries(record)) { + const isSensitiveKey = SECRET_PAYLOAD_KEY_RE.test(key); + if (isSensitiveKey) { + redacted[key] = REDACTED_EVENT_VALUE; + continue; + } + if (typeof value === "string" && JWT_VALUE_RE.test(value)) { + redacted[key] = REDACTED_EVENT_VALUE; + continue; + } + redacted[key] = sanitizeValue(value); + } + return redacted; +} + +function redactEventPayload(payload: Record | null): Record | null { + if (!payload) return null; + if (Array.isArray(payload) || typeof payload !== "object") { + return payload as Record; + } + return sanitizeRecord(payload); +} + export function agentRoutes(db: Db) { const router = Router(); const svc = agentService(db); @@ -407,7 +446,11 @@ export function agentRoutes(db: Db) { const afterSeq = Number(req.query.afterSeq ?? 0); const limit = Number(req.query.limit ?? 200); const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200); - res.json(events); + const redactedEvents = events.map((event) => ({ + ...event, + payload: redactEventPayload(event.payload), + })); + res.json(redactedEvents); }); router.get("/heartbeat-runs/:runId/log", async (req, res) => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 25754766..68e562a7 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -14,6 +14,7 @@ import { publishLiveEvent } from "./live-events.js"; import { getRunLogStore, type RunLogHandle } from "./run-log-store.js"; import { getServerAdapter, runningProcesses } from "../adapters/index.js"; import type { AdapterExecutionResult, AdapterInvocationMeta } from "../adapters/index.js"; +import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; @@ -170,9 +171,7 @@ export function heartbeatService(db: Db) { return { enabled: asBoolean(heartbeat.enabled, true), intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)), - wakeOnAssignment: asBoolean(heartbeat.wakeOnAssignment, true), - wakeOnOnDemand: asBoolean(heartbeat.wakeOnOnDemand, true), - wakeOnAutomation: asBoolean(heartbeat.wakeOnAutomation, true), + wakeOnDemand: asBoolean(heartbeat.wakeOnDemand ?? heartbeat.wakeOnAssignment ?? heartbeat.wakeOnOnDemand ?? heartbeat.wakeOnAutomation, true), }; } @@ -385,6 +384,20 @@ export function heartbeatService(db: Db) { }; const adapter = getServerAdapter(agent.adapterType); + const authToken = adapter.supportsLocalAgentJwt + ? createLocalAgentJwt(agent.id, agent.companyId, agent.adapterType, run.id) + : null; + if (adapter.supportsLocalAgentJwt && !authToken) { + logger.warn( + { + companyId: agent.companyId, + agentId: agent.id, + runId: run.id, + adapterType: agent.adapterType, + }, + "local agent jwt secret missing or invalid; running without injected PAPERCLIP_API_KEY", + ); + } const adapterResult = await adapter.execute({ runId: run.id, agent, @@ -393,6 +406,7 @@ export function heartbeatService(db: Db) { context, onLog, onMeta: onAdapterMeta, + authToken: authToken ?? undefined, }); let outcome: "succeeded" | "failed" | "cancelled" | "timed_out"; @@ -559,16 +573,8 @@ export function heartbeatService(db: Db) { await writeSkippedRequest("heartbeat.disabled"); return null; } - if (source === "assignment" && !policy.wakeOnAssignment) { - await writeSkippedRequest("heartbeat.wakeOnAssignment.disabled"); - return null; - } - if (source === "automation" && !policy.wakeOnAutomation) { - await writeSkippedRequest("heartbeat.wakeOnAutomation.disabled"); - return null; - } - if (source === "on_demand" && triggerDetail === "ping" && !policy.wakeOnOnDemand) { - await writeSkippedRequest("heartbeat.wakeOnOnDemand.disabled"); + if (source !== "timer" && !policy.wakeOnDemand) { + await writeSkippedRequest("heartbeat.wakeOnDemand.disabled"); return null; } diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index df226372..5955fe35 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -49,7 +49,10 @@ export function issueService(db: Db) { return { list: async (companyId: string, filters?: IssueFilters) => { const conditions = [eq(issues.companyId, companyId)]; - if (filters?.status) conditions.push(eq(issues.status, filters.status)); + if (filters?.status) { + const statuses = filters.status.split(",").map((s) => s.trim()); + conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]) : inArray(issues.status, statuses)); + } if (filters?.assigneeAgentId) { conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId)); } diff --git a/server/src/startup-banner.ts b/server/src/startup-banner.ts index 912f24b5..89a069c0 100644 --- a/server/src/startup-banner.ts +++ b/server/src/startup-banner.ts @@ -1,4 +1,7 @@ -import { resolve } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js"; + +import { parse as parseEnvFileContents } from "dotenv"; type UiMode = "none" | "static" | "vite-dev"; @@ -53,13 +56,44 @@ function redactConnectionString(raw: string): string { } } +function resolveAgentJwtSecretStatus( + envFilePath: string, +): { + status: "pass" | "warn"; + message: string; +} { + const envValue = process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim(); + if (envValue) { + return { + status: "pass", + message: "set", + }; + } + + if (existsSync(envFilePath)) { + const parsed = parseEnvFileContents(readFileSync(envFilePath, "utf-8")); + const fileValue = typeof parsed.PAPERCLIP_AGENT_JWT_SECRET === "string" ? parsed.PAPERCLIP_AGENT_JWT_SECRET.trim() : ""; + if (fileValue) { + return { + status: "warn", + message: `found in ${envFilePath} but not loaded`, + }; + } + } + + return { + status: "warn", + message: "missing (run `pnpm paperclip onboard`)", + }; +} + export function printStartupBanner(opts: StartupBannerOptions): void { const baseUrl = `http://localhost:${opts.listenPort}`; const apiUrl = `${baseUrl}/api`; const uiUrl = opts.uiMode === "none" ? "disabled" : baseUrl; - const configPath = process.env.PAPERCLIP_CONFIG - ? resolve(process.env.PAPERCLIP_CONFIG) - : resolve(process.cwd(), ".paperclip/config.json"); + const configPath = resolvePaperclipConfigPath(); + const envFilePath = resolvePaperclipEnvPath(); + const agentJwtSecret = resolveAgentJwtSecretStatus(envFilePath); const dbMode = opts.db.mode === "embedded-postgres" @@ -105,11 +139,20 @@ export function printStartupBanner(opts: StartupBannerOptions): void { row("UI", uiUrl), row("Database", dbDetails), row("Migrations", opts.migrationSummary), + row( + "Agent JWT", + agentJwtSecret.status === "pass" + ? color(agentJwtSecret.message, "green") + : color(agentJwtSecret.message, "yellow"), + ), row("Heartbeat", heartbeat), row("Config", configPath), + agentJwtSecret.status === "warn" + ? color(" ───────────────────────────────────────────────────────", "yellow") + : null, color(" ───────────────────────────────────────────────────────", "blue"), "", ]; - console.log(lines.join("\n")); + console.log(lines.filter((line): line is string => line !== null).join("\n")); } diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 99aa74e3..e4a48c57 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -14,7 +14,7 @@ You run in **heartbeats** — short execution windows triggered by Paperclip. Ea ## Authentication -Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`. Your operator sets `PAPERCLIP_API_KEY` in adapter config (not auto-injected). All requests: `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL. +Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`. For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL. ## The Heartbeat Procedure