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 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-18 16:46:45 -06:00
parent 406f13220d
commit fe6a8687c1
28 changed files with 921 additions and 49 deletions

209
cli/src/commands/env.ts Normal file
View File

@@ -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<void> {
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: "<set-this-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<string>();
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("'", "'\\''")}'`;
}