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:
91
cli/src/config/env.ts
Normal file
91
cli/src/config/env.ts
Normal file
@@ -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<string>();
|
||||
|
||||
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<string, string>) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user