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:
@@ -18,6 +18,7 @@
|
||||
"@paperclip/adapter-utils": "workspace:*",
|
||||
"@paperclip/db": "workspace:*",
|
||||
"@paperclip/shared": "workspace:*",
|
||||
"dotenv": "^17.0.1",
|
||||
"commander": "^13.1.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
|
||||
40
cli/src/checks/agent-jwt-secret-check.ts
Normal file
40
cli/src/checks/agent-jwt-secret-check.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
ensureAgentJwtSecret,
|
||||
readAgentJwtSecretFromEnv,
|
||||
readAgentJwtSecretFromEnvFile,
|
||||
resolveAgentJwtEnvFile,
|
||||
} from "../config/env.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
|
||||
export function agentJwtSecretCheck(): CheckResult {
|
||||
if (readAgentJwtSecretFromEnv()) {
|
||||
return {
|
||||
name: "Agent JWT secret",
|
||||
status: "pass",
|
||||
message: "PAPERCLIP_AGENT_JWT_SECRET is set in environment",
|
||||
};
|
||||
}
|
||||
|
||||
const envPath = resolveAgentJwtEnvFile();
|
||||
const fileSecret = readAgentJwtSecretFromEnvFile(envPath);
|
||||
|
||||
if (fileSecret) {
|
||||
return {
|
||||
name: "Agent JWT secret",
|
||||
status: "warn",
|
||||
message: `PAPERCLIP_AGENT_JWT_SECRET is present in ${envPath} but not loaded into environment`,
|
||||
repairHint: `Set the value from ${envPath} in your shell before starting the Paperclip server`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Agent JWT secret",
|
||||
status: "fail",
|
||||
message: `PAPERCLIP_AGENT_JWT_SECRET missing from environment and ${envPath}`,
|
||||
canRepair: true,
|
||||
repair: () => {
|
||||
ensureAgentJwtSecret();
|
||||
},
|
||||
repairHint: `Run with --repair to create ${envPath} containing PAPERCLIP_AGENT_JWT_SECRET`,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export interface CheckResult {
|
||||
repairHint?: string;
|
||||
}
|
||||
|
||||
export { agentJwtSecretCheck } from "./agent-jwt-secret-check.js";
|
||||
export { configCheck } from "./config-check.js";
|
||||
export { databaseCheck } from "./database-check.js";
|
||||
export { llmCheck } from "./llm-check.js";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { readConfig } from "../config/store.js";
|
||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||
import {
|
||||
agentJwtSecretCheck,
|
||||
configCheck,
|
||||
databaseCheck,
|
||||
llmCheck,
|
||||
@@ -24,6 +25,7 @@ export async function doctor(opts: {
|
||||
}): Promise<void> {
|
||||
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const results: CheckResult[] = [];
|
||||
|
||||
// 1. Config check (must pass before others)
|
||||
@@ -53,24 +55,30 @@ export async function doctor(opts: {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Database check
|
||||
const dbResult = await databaseCheck(config);
|
||||
// 2. Agent JWT check
|
||||
const jwtResult = agentJwtSecretCheck();
|
||||
results.push(jwtResult);
|
||||
printResult(jwtResult);
|
||||
await maybeRepair(jwtResult, opts);
|
||||
|
||||
// 3. Database check
|
||||
const dbResult = await databaseCheck(config, configPath);
|
||||
results.push(dbResult);
|
||||
printResult(dbResult);
|
||||
await maybeRepair(dbResult, opts);
|
||||
|
||||
// 3. LLM check
|
||||
// 4. LLM check
|
||||
const llmResult = await llmCheck(config);
|
||||
results.push(llmResult);
|
||||
printResult(llmResult);
|
||||
|
||||
// 4. Log directory check
|
||||
const logResult = logCheck(config);
|
||||
// 5. Log directory check
|
||||
const logResult = logCheck(config, configPath);
|
||||
results.push(logResult);
|
||||
printResult(logResult);
|
||||
await maybeRepair(logResult, opts);
|
||||
|
||||
// 5. Port check
|
||||
// 6. Port check
|
||||
const portResult = await portCheck(config);
|
||||
results.push(portResult);
|
||||
printResult(portResult);
|
||||
|
||||
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("'", "'\\''")}'`;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { configExists, readConfig, writeConfig } from "../config/store.js";
|
||||
import { configExists, readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js";
|
||||
import { promptDatabase } from "../prompts/database.js";
|
||||
import { promptLlm } from "../prompts/llm.js";
|
||||
import { promptLogging } from "../prompts/logging.js";
|
||||
@@ -12,25 +13,18 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
||||
|
||||
// Check for existing config
|
||||
if (configExists(opts.config)) {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
p.log.message(pc.dim(`${configPath} exists, updating config`));
|
||||
|
||||
try {
|
||||
readConfig(opts.config);
|
||||
} catch (err) {
|
||||
p.log.message(
|
||||
pc.yellow(
|
||||
`Existing config appears invalid and will be replaced if you continue.\n${err instanceof Error ? err.message : String(err)}`,
|
||||
`Existing config appears invalid and will be updated.\n${err instanceof Error ? err.message : String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const overwrite = await p.confirm({
|
||||
message: "A config file already exists. Overwrite it?",
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (p.isCancel(overwrite) || !overwrite) {
|
||||
p.cancel("Keeping existing configuration.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Database
|
||||
@@ -104,6 +98,16 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
||||
p.log.step(pc.bold("Server"));
|
||||
const server = await promptServer();
|
||||
|
||||
const jwtSecret = ensureAgentJwtSecret();
|
||||
const envFilePath = resolveAgentJwtEnvFile();
|
||||
if (jwtSecret.created) {
|
||||
p.log.success(`Created ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
|
||||
} else if (process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim()) {
|
||||
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from environment`);
|
||||
} else {
|
||||
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
|
||||
}
|
||||
|
||||
// Assemble and write config
|
||||
const config: PaperclipConfig = {
|
||||
$meta: {
|
||||
@@ -125,10 +129,14 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
||||
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
|
||||
`Logging: ${logging.mode} → ${logging.logDir}`,
|
||||
`Server: port ${server.port}`,
|
||||
`Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured`,
|
||||
].join("\n"),
|
||||
"Configuration saved",
|
||||
);
|
||||
|
||||
p.log.info(`Run ${pc.cyan("pnpm paperclip doctor")} to verify your setup.`);
|
||||
p.log.message(
|
||||
`Before starting Paperclip, export ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from ${pc.dim(envFilePath)} (for example: ${pc.dim(`set -a; source ${envFilePath}; set +a`)})`,
|
||||
);
|
||||
p.outro("You're all set!");
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Command } from "commander";
|
||||
import { onboard } from "./commands/onboard.js";
|
||||
import { doctor } from "./commands/doctor.js";
|
||||
import { envCommand } from "./commands/env.js";
|
||||
import { configure } from "./commands/configure.js";
|
||||
import { heartbeatRun } from "./commands/heartbeat-run.js";
|
||||
|
||||
@@ -27,6 +28,12 @@ program
|
||||
.option("-y, --yes", "Skip repair confirmation prompts")
|
||||
.action(doctor);
|
||||
|
||||
program
|
||||
.command("env")
|
||||
.description("Print environment variables for deployment")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.action(envCommand);
|
||||
|
||||
program
|
||||
.command("configure")
|
||||
.description("Update configuration sections")
|
||||
|
||||
72
doc/plans/agent-authentication-implementation.md
Normal file
72
doc/plans/agent-authentication-implementation.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Agent Authentication — P0 Local Adapter JWT Implementation
|
||||
|
||||
## Scope
|
||||
|
||||
- In-scope adapters: `claude_local`, `codex_local`.
|
||||
- Goal: zero-configuration auth for local adapters while preserving static keys for all other call paths.
|
||||
- Out-of-scope for P0: rotation UX, per-device revocation list, and CLI onboarding.
|
||||
|
||||
## 1) Token format and config
|
||||
|
||||
- Use HS256 JWTs with claims:
|
||||
- `sub` (agent id)
|
||||
- `company_id`
|
||||
- `adapter_type`
|
||||
- `run_id`
|
||||
- `iat`
|
||||
- `exp`
|
||||
- optional `jti` (run token id)
|
||||
- New config/env settings:
|
||||
- `PAPERCLIP_AGENT_JWT_SECRET`
|
||||
- `PAPERCLIP_AGENT_JWT_TTL_SECONDS` (default: `172800`)
|
||||
- `PAPERCLIP_AGENT_JWT_ISSUER` (default: `paperclip`)
|
||||
- `PAPERCLIP_AGENT_JWT_AUDIENCE` (default: `paperclip-api`)
|
||||
|
||||
## 2) Dual authentication path in `actorMiddleware`
|
||||
|
||||
1. Keep the existing DB key lookup path unchanged (`agent_api_keys` hash lookup).
|
||||
2. If no DB key matches, add JWT verification in `server/src/middleware/auth.ts`.
|
||||
3. On JWT success:
|
||||
- set `req.actor = { type: "agent", agentId, companyId }`.
|
||||
- optionally guard against terminated agents.
|
||||
4. Continue board fallback for requests without valid authentication.
|
||||
|
||||
## 3) Opt-in adapter capability
|
||||
|
||||
1. Extend `ServerAdapterModule` (likely `packages/adapter-utils/src/types.ts`) with a capability flag:
|
||||
- `supportsLocalAgentJwt?: true`.
|
||||
2. Enable it on:
|
||||
- `server/src/adapters/registry.ts` for `claude_local` and `codex_local`.
|
||||
3. Keep `process`/`http` adapters unset for P0.
|
||||
4. In `server/src/services/heartbeat.ts`, when adapter supports JWT:
|
||||
- mint JWT per heartbeat run before execute.
|
||||
- include token in adapter execution context.
|
||||
|
||||
## 4) Local env injection behavior
|
||||
|
||||
1. In:
|
||||
- `packages/adapters/claude-local/src/server/execute.ts`
|
||||
- `packages/adapters/codex-local/src/server/execute.ts`
|
||||
|
||||
inject `PAPERCLIP_API_KEY` from context token.
|
||||
|
||||
- Preserve existing behavior for explicit user-defined env vars in `adapterConfig.env`:
|
||||
- if user already sets `PAPERCLIP_API_KEY`, do not overwrite it.
|
||||
- Continue injecting:
|
||||
- `PAPERCLIP_AGENT_ID`
|
||||
- `PAPERCLIP_COMPANY_ID`
|
||||
- `PAPERCLIP_API_URL`
|
||||
|
||||
## 5) Documentation updates
|
||||
|
||||
- Update operator-facing docs to remove manual key setup expectation for local adapters:
|
||||
- `skills/paperclip/SKILL.md`
|
||||
- `cli/src/commands/heartbeat-run.ts` output/help examples if they mention manual API key setup.
|
||||
|
||||
## 6) P0 acceptance criteria
|
||||
|
||||
- Local adapters authenticate without manual `PAPERCLIP_API_KEY` config.
|
||||
- Existing static keys (`agent_api_keys`) still work unchanged.
|
||||
- Auth remains company-scoped (`req.actor.companyId` used by existing checks).
|
||||
- JWT generation and verification errors are logged as non-leaking structured events.
|
||||
- Scope remains local-only (`claude_local`, `codex_local`) while adapter capability model is generic.
|
||||
@@ -205,6 +205,10 @@ On approval, the approver sets:
|
||||
| **P2** | OpenClaw integration | First real external agent onboarding via invite link. |
|
||||
| **P3** | CLI auth flow | `paperclip auth login` for developer-managed remote agents. |
|
||||
|
||||
## P0 Implementation Plan
|
||||
|
||||
See [`doc/plans/agent-authentication-implementation.md`](./agent-authentication-implementation.md) for the P0 local JWT execution plan.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -57,11 +57,13 @@ export interface AdapterExecutionContext {
|
||||
context: Record<string, unknown>;
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export interface ServerAdapterModule {
|
||||
type: string;
|
||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||
supportsLocalAgentJwt?: boolean;
|
||||
models?: { id: string; label: string }[];
|
||||
}
|
||||
|
||||
@@ -71,6 +73,8 @@ export interface ServerAdapterModule {
|
||||
|
||||
export type TranscriptEntry =
|
||||
| { kind: "assistant"; ts: string; text: string }
|
||||
| { kind: "thinking"; ts: string; text: string }
|
||||
| { kind: "user"; ts: string; text: string }
|
||||
| { kind: "tool_call"; ts: string; name: string; input: unknown }
|
||||
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
|
||||
| { kind: "init"; ts: string; model: string; sessionId: string }
|
||||
|
||||
@@ -48,7 +48,7 @@ async function buildSkillsDir(): Promise<string> {
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta } = ctx;
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
@@ -63,10 +63,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
await ensureAbsoluteDirectory(cwd);
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { parseCodexJsonl } from "./parse.js";
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta } = ctx;
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
@@ -31,10 +31,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
await ensureAbsoluteDirectory(cwd);
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -38,6 +38,9 @@ importers:
|
||||
commander:
|
||||
specifier: ^13.1.0
|
||||
version: 13.1.0
|
||||
dotenv:
|
||||
specifier: ^17.0.1
|
||||
version: 17.3.1
|
||||
picocolors:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
@@ -139,6 +142,9 @@ importers:
|
||||
detect-port:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
dotenv:
|
||||
specifier: ^17.0.1
|
||||
version: 17.3.1
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.4
|
||||
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
||||
@@ -2097,6 +2103,10 @@ packages:
|
||||
dezalgo@1.0.4:
|
||||
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
||||
|
||||
dotenv@17.3.1:
|
||||
resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
drizzle-kit@0.31.9:
|
||||
resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==}
|
||||
hasBin: true
|
||||
@@ -4711,6 +4721,8 @@ snapshots:
|
||||
asap: 2.0.6
|
||||
wrappy: 1.0.2
|
||||
|
||||
dotenv@17.3.1: {}
|
||||
|
||||
drizzle-kit@0.31.9:
|
||||
dependencies:
|
||||
'@drizzle-team/brocli': 0.10.2
|
||||
|
||||
@@ -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",
|
||||
|
||||
79
server/src/__tests__/agent-auth-jwt.test.ts
Normal file
79
server/src/__tests__/agent-auth-jwt.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>(
|
||||
|
||||
141
server/src/agent-auth-jwt.ts
Normal file
141
server/src/agent-auth-jwt.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
33
server/src/paths.ts
Normal 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);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ You run in **heartbeats** — short execution windows triggered by Paperclip. Ea
|
||||
|
||||
## Authentication
|
||||
|
||||
Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`. Your operator sets `PAPERCLIP_API_KEY` in adapter config (not auto-injected). All requests: `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL.
|
||||
Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`. For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL.
|
||||
|
||||
## The Heartbeat Procedure
|
||||
|
||||
|
||||
Reference in New Issue
Block a user