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

View File

@@ -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"));
}