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/adapter-utils": "workspace:*",
|
||||||
"@paperclip/db": "workspace:*",
|
"@paperclip/db": "workspace:*",
|
||||||
"@paperclip/shared": "workspace:*",
|
"@paperclip/shared": "workspace:*",
|
||||||
|
"dotenv": "^17.0.1",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"picocolors": "^1.1.1"
|
"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;
|
repairHint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { agentJwtSecretCheck } from "./agent-jwt-secret-check.js";
|
||||||
export { configCheck } from "./config-check.js";
|
export { configCheck } from "./config-check.js";
|
||||||
export { databaseCheck } from "./database-check.js";
|
export { databaseCheck } from "./database-check.js";
|
||||||
export { llmCheck } from "./llm-check.js";
|
export { llmCheck } from "./llm-check.js";
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
import type { PaperclipConfig } from "../config/schema.js";
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
import { readConfig } from "../config/store.js";
|
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||||
import {
|
import {
|
||||||
|
agentJwtSecretCheck,
|
||||||
configCheck,
|
configCheck,
|
||||||
databaseCheck,
|
databaseCheck,
|
||||||
llmCheck,
|
llmCheck,
|
||||||
@@ -24,6 +25,7 @@ export async function doctor(opts: {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
|
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
|
||||||
|
|
||||||
|
const configPath = resolveConfigPath(opts.config);
|
||||||
const results: CheckResult[] = [];
|
const results: CheckResult[] = [];
|
||||||
|
|
||||||
// 1. Config check (must pass before others)
|
// 1. Config check (must pass before others)
|
||||||
@@ -53,24 +55,30 @@ export async function doctor(opts: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Database check
|
// 2. Agent JWT check
|
||||||
const dbResult = await databaseCheck(config);
|
const jwtResult = agentJwtSecretCheck();
|
||||||
|
results.push(jwtResult);
|
||||||
|
printResult(jwtResult);
|
||||||
|
await maybeRepair(jwtResult, opts);
|
||||||
|
|
||||||
|
// 3. Database check
|
||||||
|
const dbResult = await databaseCheck(config, configPath);
|
||||||
results.push(dbResult);
|
results.push(dbResult);
|
||||||
printResult(dbResult);
|
printResult(dbResult);
|
||||||
await maybeRepair(dbResult, opts);
|
await maybeRepair(dbResult, opts);
|
||||||
|
|
||||||
// 3. LLM check
|
// 4. LLM check
|
||||||
const llmResult = await llmCheck(config);
|
const llmResult = await llmCheck(config);
|
||||||
results.push(llmResult);
|
results.push(llmResult);
|
||||||
printResult(llmResult);
|
printResult(llmResult);
|
||||||
|
|
||||||
// 4. Log directory check
|
// 5. Log directory check
|
||||||
const logResult = logCheck(config);
|
const logResult = logCheck(config, configPath);
|
||||||
results.push(logResult);
|
results.push(logResult);
|
||||||
printResult(logResult);
|
printResult(logResult);
|
||||||
await maybeRepair(logResult, opts);
|
await maybeRepair(logResult, opts);
|
||||||
|
|
||||||
// 5. Port check
|
// 6. Port check
|
||||||
const portResult = await portCheck(config);
|
const portResult = await portCheck(config);
|
||||||
results.push(portResult);
|
results.push(portResult);
|
||||||
printResult(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 * as p from "@clack/prompts";
|
||||||
import pc from "picocolors";
|
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 type { PaperclipConfig } from "../config/schema.js";
|
||||||
|
import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js";
|
||||||
import { promptDatabase } from "../prompts/database.js";
|
import { promptDatabase } from "../prompts/database.js";
|
||||||
import { promptLlm } from "../prompts/llm.js";
|
import { promptLlm } from "../prompts/llm.js";
|
||||||
import { promptLogging } from "../prompts/logging.js";
|
import { promptLogging } from "../prompts/logging.js";
|
||||||
@@ -12,25 +13,18 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
|||||||
|
|
||||||
// Check for existing config
|
// Check for existing config
|
||||||
if (configExists(opts.config)) {
|
if (configExists(opts.config)) {
|
||||||
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
p.log.message(pc.dim(`${configPath} exists, updating config`));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
readConfig(opts.config);
|
readConfig(opts.config);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
p.log.message(
|
p.log.message(
|
||||||
pc.yellow(
|
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
|
// Database
|
||||||
@@ -104,6 +98,16 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
|||||||
p.log.step(pc.bold("Server"));
|
p.log.step(pc.bold("Server"));
|
||||||
const server = await promptServer();
|
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
|
// Assemble and write config
|
||||||
const config: PaperclipConfig = {
|
const config: PaperclipConfig = {
|
||||||
$meta: {
|
$meta: {
|
||||||
@@ -125,10 +129,14 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
|||||||
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
|
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
|
||||||
`Logging: ${logging.mode} → ${logging.logDir}`,
|
`Logging: ${logging.mode} → ${logging.logDir}`,
|
||||||
`Server: port ${server.port}`,
|
`Server: port ${server.port}`,
|
||||||
|
`Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Configuration saved",
|
"Configuration saved",
|
||||||
);
|
);
|
||||||
|
|
||||||
p.log.info(`Run ${pc.cyan("pnpm paperclip doctor")} to verify your setup.`);
|
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!");
|
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";
|
import { paperclipConfigSchema, type PaperclipConfig } from "./schema.js";
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH = ".paperclip/config.json";
|
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 {
|
export function resolveConfigPath(overridePath?: string): string {
|
||||||
if (overridePath) return path.resolve(overridePath);
|
if (overridePath) return path.resolve(overridePath);
|
||||||
if (process.env.PAPERCLIP_CONFIG) return path.resolve(process.env.PAPERCLIP_CONFIG);
|
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 {
|
function parseJson(filePath: string): unknown {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { onboard } from "./commands/onboard.js";
|
import { onboard } from "./commands/onboard.js";
|
||||||
import { doctor } from "./commands/doctor.js";
|
import { doctor } from "./commands/doctor.js";
|
||||||
|
import { envCommand } from "./commands/env.js";
|
||||||
import { configure } from "./commands/configure.js";
|
import { configure } from "./commands/configure.js";
|
||||||
import { heartbeatRun } from "./commands/heartbeat-run.js";
|
import { heartbeatRun } from "./commands/heartbeat-run.js";
|
||||||
|
|
||||||
@@ -27,6 +28,12 @@ program
|
|||||||
.option("-y, --yes", "Skip repair confirmation prompts")
|
.option("-y, --yes", "Skip repair confirmation prompts")
|
||||||
.action(doctor);
|
.action(doctor);
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("env")
|
||||||
|
.description("Print environment variables for deployment")
|
||||||
|
.option("-c, --config <path>", "Path to config file")
|
||||||
|
.action(envCommand);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("configure")
|
.command("configure")
|
||||||
.description("Update configuration sections")
|
.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. |
|
| **P2** | OpenClaw integration | First real external agent onboarding via invite link. |
|
||||||
| **P3** | CLI auth flow | `paperclip auth login` for developer-managed remote agents. |
|
| **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
|
## Open Questions
|
||||||
|
|||||||
@@ -57,11 +57,13 @@ export interface AdapterExecutionContext {
|
|||||||
context: Record<string, unknown>;
|
context: Record<string, unknown>;
|
||||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
||||||
|
authToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerAdapterModule {
|
export interface ServerAdapterModule {
|
||||||
type: string;
|
type: string;
|
||||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||||
|
supportsLocalAgentJwt?: boolean;
|
||||||
models?: { id: string; label: string }[];
|
models?: { id: string; label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +73,8 @@ export interface ServerAdapterModule {
|
|||||||
|
|
||||||
export type TranscriptEntry =
|
export type TranscriptEntry =
|
||||||
| { kind: "assistant"; ts: string; text: string }
|
| { 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_call"; ts: string; name: string; input: unknown }
|
||||||
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
|
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
|
||||||
| { kind: "init"; ts: string; model: string; sessionId: string }
|
| { 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> {
|
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(
|
const promptTemplate = asString(
|
||||||
config.promptTemplate,
|
config.promptTemplate,
|
||||||
@@ -63,10 +63,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
const cwd = asString(config.cwd, process.cwd());
|
const cwd = asString(config.cwd, process.cwd());
|
||||||
await ensureAbsoluteDirectory(cwd);
|
await ensureAbsoluteDirectory(cwd);
|
||||||
const envConfig = parseObject(config.env);
|
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) };
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||||
for (const [k, v] of Object.entries(envConfig)) {
|
for (const [k, v] of Object.entries(envConfig)) {
|
||||||
if (typeof v === "string") env[k] = v;
|
if (typeof v === "string") env[k] = v;
|
||||||
}
|
}
|
||||||
|
if (!hasExplicitApiKey && authToken) {
|
||||||
|
env.PAPERCLIP_API_KEY = authToken;
|
||||||
|
}
|
||||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { parseCodexJsonl } from "./parse.js";
|
import { parseCodexJsonl } from "./parse.js";
|
||||||
|
|
||||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
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(
|
const promptTemplate = asString(
|
||||||
config.promptTemplate,
|
config.promptTemplate,
|
||||||
@@ -31,10 +31,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
const cwd = asString(config.cwd, process.cwd());
|
const cwd = asString(config.cwd, process.cwd());
|
||||||
await ensureAbsoluteDirectory(cwd);
|
await ensureAbsoluteDirectory(cwd);
|
||||||
const envConfig = parseObject(config.env);
|
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) };
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||||
for (const [k, v] of Object.entries(envConfig)) {
|
for (const [k, v] of Object.entries(envConfig)) {
|
||||||
if (typeof v === "string") env[k] = v;
|
if (typeof v === "string") env[k] = v;
|
||||||
}
|
}
|
||||||
|
if (!hasExplicitApiKey && authToken) {
|
||||||
|
env.PAPERCLIP_API_KEY = authToken;
|
||||||
|
}
|
||||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
|
|
||||||
|
|||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -38,6 +38,9 @@ importers:
|
|||||||
commander:
|
commander:
|
||||||
specifier: ^13.1.0
|
specifier: ^13.1.0
|
||||||
version: 13.1.0
|
version: 13.1.0
|
||||||
|
dotenv:
|
||||||
|
specifier: ^17.0.1
|
||||||
|
version: 17.3.1
|
||||||
picocolors:
|
picocolors:
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
@@ -139,6 +142,9 @@ importers:
|
|||||||
detect-port:
|
detect-port:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.0
|
version: 2.1.0
|
||||||
|
dotenv:
|
||||||
|
specifier: ^17.0.1
|
||||||
|
version: 17.3.1
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.4
|
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)
|
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:
|
dezalgo@1.0.4:
|
||||||
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
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:
|
drizzle-kit@0.31.9:
|
||||||
resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==}
|
resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -4711,6 +4721,8 @@ snapshots:
|
|||||||
asap: 2.0.6
|
asap: 2.0.6
|
||||||
wrappy: 1.0.2
|
wrappy: 1.0.2
|
||||||
|
|
||||||
|
dotenv@17.3.1: {}
|
||||||
|
|
||||||
drizzle-kit@0.31.9:
|
drizzle-kit@0.31.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@drizzle-team/brocli': 0.10.2
|
'@drizzle-team/brocli': 0.10.2
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@paperclip/adapter-utils": "workspace:*",
|
"@paperclip/adapter-utils": "workspace:*",
|
||||||
"@paperclip/db": "workspace:*",
|
"@paperclip/db": "workspace:*",
|
||||||
"@paperclip/shared": "workspace:*",
|
"@paperclip/shared": "workspace:*",
|
||||||
|
"dotenv": "^17.0.1",
|
||||||
"detect-port": "^2.1.0",
|
"detect-port": "^2.1.0",
|
||||||
"drizzle-orm": "^0.38.4",
|
"drizzle-orm": "^0.38.4",
|
||||||
"express": "^5.1.0",
|
"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",
|
type: "claude_local",
|
||||||
execute: claudeExecute,
|
execute: claudeExecute,
|
||||||
models: claudeModels,
|
models: claudeModels,
|
||||||
|
supportsLocalAgentJwt: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const codexLocalAdapter: ServerAdapterModule = {
|
const codexLocalAdapter: ServerAdapterModule = {
|
||||||
type: "codex_local",
|
type: "codex_local",
|
||||||
execute: codexExecute,
|
execute: codexExecute,
|
||||||
models: codexModels,
|
models: codexModels,
|
||||||
|
supportsLocalAgentJwt: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
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 fs from "node:fs";
|
||||||
import path from "node:path";
|
|
||||||
import { paperclipConfigSchema, type PaperclipConfig } from "@paperclip/shared";
|
import { paperclipConfigSchema, type PaperclipConfig } from "@paperclip/shared";
|
||||||
|
import { resolvePaperclipConfigPath } from "./paths.js";
|
||||||
|
|
||||||
export function readConfigFile(): PaperclipConfig | null {
|
export function readConfigFile(): PaperclipConfig | null {
|
||||||
const configPath = process.env.PAPERCLIP_CONFIG
|
const configPath = resolvePaperclipConfigPath();
|
||||||
? path.resolve(process.env.PAPERCLIP_CONFIG)
|
|
||||||
: path.resolve(process.cwd(), ".paperclip/config.json");
|
|
||||||
|
|
||||||
if (!fs.existsSync(configPath)) return null;
|
if (!fs.existsSync(configPath)) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { readConfigFile } from "./config-file.js";
|
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";
|
type DatabaseMode = "embedded-postgres" | "postgres";
|
||||||
|
|
||||||
@@ -33,7 +41,7 @@ export function loadConfig(): Config {
|
|||||||
serveUi:
|
serveUi:
|
||||||
process.env.SERVE_UI !== undefined
|
process.env.SERVE_UI !== undefined
|
||||||
? process.env.SERVE_UI === "true"
|
? process.env.SERVE_UI === "true"
|
||||||
: fileConfig?.server.serveUi ?? false,
|
: fileConfig?.server.serveUi ?? true,
|
||||||
uiDevMiddleware: process.env.PAPERCLIP_UI_DEV_MIDDLEWARE === "true",
|
uiDevMiddleware: process.env.PAPERCLIP_UI_DEV_MIDDLEWARE === "true",
|
||||||
heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false",
|
heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false",
|
||||||
heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000),
|
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 type { RequestHandler } from "express";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclip/db";
|
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) {
|
function hashToken(token: string) {
|
||||||
return createHash("sha256").update(token).digest("hex");
|
return createHash("sha256").update(token).digest("hex");
|
||||||
@@ -32,6 +33,34 @@ export function actorMiddleware(db: Db): RequestHandler {
|
|||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
if (!key) {
|
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();
|
next();
|
||||||
return;
|
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 { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
import { listAdapterModels } from "../adapters/index.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) {
|
export function agentRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = agentService(db);
|
const svc = agentService(db);
|
||||||
@@ -407,7 +446,11 @@ export function agentRoutes(db: Db) {
|
|||||||
const afterSeq = Number(req.query.afterSeq ?? 0);
|
const afterSeq = Number(req.query.afterSeq ?? 0);
|
||||||
const limit = Number(req.query.limit ?? 200);
|
const limit = Number(req.query.limit ?? 200);
|
||||||
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? 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) => {
|
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 { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
|
||||||
import { getServerAdapter, runningProcesses } from "../adapters/index.js";
|
import { getServerAdapter, runningProcesses } from "../adapters/index.js";
|
||||||
import type { AdapterExecutionResult, AdapterInvocationMeta } 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";
|
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
||||||
|
|
||||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||||
@@ -170,9 +171,7 @@ export function heartbeatService(db: Db) {
|
|||||||
return {
|
return {
|
||||||
enabled: asBoolean(heartbeat.enabled, true),
|
enabled: asBoolean(heartbeat.enabled, true),
|
||||||
intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)),
|
intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)),
|
||||||
wakeOnAssignment: asBoolean(heartbeat.wakeOnAssignment, true),
|
wakeOnDemand: asBoolean(heartbeat.wakeOnDemand ?? heartbeat.wakeOnAssignment ?? heartbeat.wakeOnOnDemand ?? heartbeat.wakeOnAutomation, true),
|
||||||
wakeOnOnDemand: asBoolean(heartbeat.wakeOnOnDemand, true),
|
|
||||||
wakeOnAutomation: asBoolean(heartbeat.wakeOnAutomation, true),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +384,20 @@ export function heartbeatService(db: Db) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const adapter = getServerAdapter(agent.adapterType);
|
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({
|
const adapterResult = await adapter.execute({
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
agent,
|
agent,
|
||||||
@@ -393,6 +406,7 @@ export function heartbeatService(db: Db) {
|
|||||||
context,
|
context,
|
||||||
onLog,
|
onLog,
|
||||||
onMeta: onAdapterMeta,
|
onMeta: onAdapterMeta,
|
||||||
|
authToken: authToken ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
let outcome: "succeeded" | "failed" | "cancelled" | "timed_out";
|
let outcome: "succeeded" | "failed" | "cancelled" | "timed_out";
|
||||||
@@ -559,16 +573,8 @@ export function heartbeatService(db: Db) {
|
|||||||
await writeSkippedRequest("heartbeat.disabled");
|
await writeSkippedRequest("heartbeat.disabled");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (source === "assignment" && !policy.wakeOnAssignment) {
|
if (source !== "timer" && !policy.wakeOnDemand) {
|
||||||
await writeSkippedRequest("heartbeat.wakeOnAssignment.disabled");
|
await writeSkippedRequest("heartbeat.wakeOnDemand.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");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ export function issueService(db: Db) {
|
|||||||
return {
|
return {
|
||||||
list: async (companyId: string, filters?: IssueFilters) => {
|
list: async (companyId: string, filters?: IssueFilters) => {
|
||||||
const conditions = [eq(issues.companyId, companyId)];
|
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) {
|
if (filters?.assigneeAgentId) {
|
||||||
conditions.push(eq(issues.assigneeAgentId, 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";
|
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 {
|
export function printStartupBanner(opts: StartupBannerOptions): void {
|
||||||
const baseUrl = `http://localhost:${opts.listenPort}`;
|
const baseUrl = `http://localhost:${opts.listenPort}`;
|
||||||
const apiUrl = `${baseUrl}/api`;
|
const apiUrl = `${baseUrl}/api`;
|
||||||
const uiUrl = opts.uiMode === "none" ? "disabled" : baseUrl;
|
const uiUrl = opts.uiMode === "none" ? "disabled" : baseUrl;
|
||||||
const configPath = process.env.PAPERCLIP_CONFIG
|
const configPath = resolvePaperclipConfigPath();
|
||||||
? resolve(process.env.PAPERCLIP_CONFIG)
|
const envFilePath = resolvePaperclipEnvPath();
|
||||||
: resolve(process.cwd(), ".paperclip/config.json");
|
const agentJwtSecret = resolveAgentJwtSecretStatus(envFilePath);
|
||||||
|
|
||||||
const dbMode =
|
const dbMode =
|
||||||
opts.db.mode === "embedded-postgres"
|
opts.db.mode === "embedded-postgres"
|
||||||
@@ -105,11 +139,20 @@ export function printStartupBanner(opts: StartupBannerOptions): void {
|
|||||||
row("UI", uiUrl),
|
row("UI", uiUrl),
|
||||||
row("Database", dbDetails),
|
row("Database", dbDetails),
|
||||||
row("Migrations", opts.migrationSummary),
|
row("Migrations", opts.migrationSummary),
|
||||||
|
row(
|
||||||
|
"Agent JWT",
|
||||||
|
agentJwtSecret.status === "pass"
|
||||||
|
? color(agentJwtSecret.message, "green")
|
||||||
|
: color(agentJwtSecret.message, "yellow"),
|
||||||
|
),
|
||||||
row("Heartbeat", heartbeat),
|
row("Heartbeat", heartbeat),
|
||||||
row("Config", configPath),
|
row("Config", configPath),
|
||||||
|
agentJwtSecret.status === "warn"
|
||||||
|
? color(" ───────────────────────────────────────────────────────", "yellow")
|
||||||
|
: null,
|
||||||
color(" ───────────────────────────────────────────────────────", "blue"),
|
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
|
## 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
|
## The Heartbeat Procedure
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user