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:
209
cli/src/commands/env.ts
Normal file
209
cli/src/commands/env.ts
Normal 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("'", "'\\''")}'`;
|
||||
}
|
||||
Reference in New Issue
Block a user