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

@@ -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",

View File

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

View File

@@ -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<string, ServerAdapterModule>(

View File

@@ -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<string, unknown> | null {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : 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,
};
}

View File

@@ -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;

View File

@@ -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),

View File

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

33
server/src/paths.ts Normal file
View File

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

View File

@@ -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<string, unknown>);
}
function sanitizeRecord(record: Record<string, unknown>): Record<string, unknown> {
const redacted: Record<string, unknown> = {};
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<string, unknown> | null): Record<string, unknown> | null {
if (!payload) return null;
if (Array.isArray(payload) || typeof payload !== "object") {
return payload as Record<string, unknown>;
}
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) => {

View File

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

View File

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

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