Implement local agent JWT authentication for adapters

Add HS256 JWT-based authentication for local adapters (claude_local, codex_local)
so agents authenticate automatically without manual API key configuration. The
server mints short-lived JWTs per heartbeat run and injects them as PAPERCLIP_API_KEY.
The auth middleware verifies JWTs alongside existing static API keys.

Includes: CLI onboard/doctor JWT secret management, env command for deployment,
config path resolution from ancestor directories, dotenv loading on server startup,
event payload secret redaction, multi-status issue filtering, and adapter transcript
parsing for thinking/user message kinds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-18 16:46:45 -06:00
parent 406f13220d
commit fe6a8687c1
28 changed files with 921 additions and 49 deletions

View File

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

View 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`,
};
}

View File

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

View File

@@ -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
View 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("'", "'\\''")}'`;
}

View File

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

View File

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

View File

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

View 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.

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

View File

@@ -15,6 +15,7 @@
"@paperclip/adapter-utils": "workspace:*",
"@paperclip/db": "workspace:*",
"@paperclip/shared": "workspace:*",
"dotenv": "^17.0.1",
"detect-port": "^2.1.0",
"drizzle-orm": "^0.38.4",
"express": "^5.1.0",

View File

@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createLocalAgentJwt, verifyLocalAgentJwt } from "../agent-auth-jwt.js";
describe("agent local JWT", () => {
const secretEnv = "PAPERCLIP_AGENT_JWT_SECRET";
const ttlEnv = "PAPERCLIP_AGENT_JWT_TTL_SECONDS";
const issuerEnv = "PAPERCLIP_AGENT_JWT_ISSUER";
const audienceEnv = "PAPERCLIP_AGENT_JWT_AUDIENCE";
const originalEnv = {
secret: process.env[secretEnv],
ttl: process.env[ttlEnv],
issuer: process.env[issuerEnv],
audience: process.env[audienceEnv],
};
beforeEach(() => {
process.env[secretEnv] = "test-secret";
process.env[ttlEnv] = "3600";
delete process.env[issuerEnv];
delete process.env[audienceEnv];
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
if (originalEnv.secret === undefined) delete process.env[secretEnv];
else process.env[secretEnv] = originalEnv.secret;
if (originalEnv.ttl === undefined) delete process.env[ttlEnv];
else process.env[ttlEnv] = originalEnv.ttl;
if (originalEnv.issuer === undefined) delete process.env[issuerEnv];
else process.env[issuerEnv] = originalEnv.issuer;
if (originalEnv.audience === undefined) delete process.env[audienceEnv];
else process.env[audienceEnv] = originalEnv.audience;
});
it("creates and verifies a token", () => {
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
expect(typeof token).toBe("string");
const claims = verifyLocalAgentJwt(token!);
expect(claims).toMatchObject({
sub: "agent-1",
company_id: "company-1",
adapter_type: "claude_local",
run_id: "run-1",
iss: "paperclip",
aud: "paperclip-api",
});
});
it("returns null when secret is missing", () => {
process.env[secretEnv] = "";
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
expect(token).toBeNull();
expect(verifyLocalAgentJwt("abc.def.ghi")).toBeNull();
});
it("rejects expired tokens", () => {
process.env[ttlEnv] = "1";
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
vi.setSystemTime(new Date("2026-01-01T00:00:05.000Z"));
expect(verifyLocalAgentJwt(token!)).toBeNull();
});
it("rejects issuer/audience mismatch", () => {
process.env[issuerEnv] = "custom-issuer";
process.env[audienceEnv] = "custom-audience";
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const token = createLocalAgentJwt("agent-1", "company-1", "codex_local", "run-1");
process.env[issuerEnv] = "paperclip";
process.env[audienceEnv] = "paperclip-api";
expect(verifyLocalAgentJwt(token!)).toBeNull();
});
});

View File

@@ -10,12 +10,14 @@ const claudeLocalAdapter: ServerAdapterModule = {
type: "claude_local",
execute: claudeExecute,
models: claudeModels,
supportsLocalAgentJwt: true,
};
const codexLocalAdapter: ServerAdapterModule = {
type: "codex_local",
execute: codexExecute,
models: codexModels,
supportsLocalAgentJwt: true,
};
const adaptersByType = new Map<string, ServerAdapterModule>(

View File

@@ -0,0 +1,141 @@
import { createHmac, timingSafeEqual } from "node:crypto";
interface JwtHeader {
alg: string;
typ?: string;
}
export interface LocalAgentJwtClaims {
sub: string;
company_id: string;
adapter_type: string;
run_id: string;
iat: number;
exp: number;
iss?: string;
aud?: string;
jti?: string;
}
const JWT_ALGORITHM = "HS256";
function parseNumber(value: string | undefined, fallback: number) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.floor(parsed);
}
function jwtConfig() {
const secret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
if (!secret) return null;
return {
secret,
ttlSeconds: parseNumber(process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS, 60 * 60 * 48),
issuer: process.env.PAPERCLIP_AGENT_JWT_ISSUER ?? "paperclip",
audience: process.env.PAPERCLIP_AGENT_JWT_AUDIENCE ?? "paperclip-api",
};
}
function base64UrlEncode(value: string) {
return Buffer.from(value, "utf8").toString("base64url");
}
function base64UrlDecode(value: string) {
return Buffer.from(value, "base64url").toString("utf8");
}
function signPayload(secret: string, signingInput: string) {
return createHmac("sha256", secret).update(signingInput).digest("base64url");
}
function parseJson(value: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : null;
} catch {
return null;
}
}
function safeCompare(a: string, b: string) {
const left = Buffer.from(a);
const right = Buffer.from(b);
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
export function createLocalAgentJwt(agentId: string, companyId: string, adapterType: string, runId: string) {
const config = jwtConfig();
if (!config) return null;
const now = Math.floor(Date.now() / 1000);
const claims: LocalAgentJwtClaims = {
sub: agentId,
company_id: companyId,
adapter_type: adapterType,
run_id: runId,
iat: now,
exp: now + config.ttlSeconds,
iss: config.issuer,
aud: config.audience,
};
const header = {
alg: JWT_ALGORITHM,
typ: "JWT",
};
const signingInput = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(claims))}`;
const signature = signPayload(config.secret, signingInput);
return `${signingInput}.${signature}`;
}
export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null {
if (!token) return null;
const config = jwtConfig();
if (!config) return null;
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerB64, claimsB64, signature] = parts;
const header = parseJson(base64UrlDecode(headerB64));
if (!header || header.alg !== JWT_ALGORITHM) return null;
const signingInput = `${headerB64}.${claimsB64}`;
const expectedSig = signPayload(config.secret, signingInput);
if (!safeCompare(signature, expectedSig)) return null;
const claims = parseJson(base64UrlDecode(claimsB64));
if (!claims) return null;
const sub = typeof claims.sub === "string" ? claims.sub : null;
const companyId = typeof claims.company_id === "string" ? claims.company_id : null;
const adapterType = typeof claims.adapter_type === "string" ? claims.adapter_type : null;
const runId = typeof claims.run_id === "string" ? claims.run_id : null;
const iat = typeof claims.iat === "number" ? claims.iat : null;
const exp = typeof claims.exp === "number" ? claims.exp : null;
if (!sub || !companyId || !adapterType || !runId || !iat || !exp) return null;
const now = Math.floor(Date.now() / 1000);
if (exp < now) return null;
const issuer = typeof claims.iss === "string" ? claims.iss : undefined;
const audience = typeof claims.aud === "string" ? claims.aud : undefined;
if (issuer && issuer !== config.issuer) return null;
if (audience && audience !== config.audience) return null;
return {
sub,
company_id: companyId,
adapter_type: adapterType,
run_id: runId,
iat,
exp,
...(issuer ? { iss: issuer } : {}),
...(audience ? { aud: audience } : {}),
jti: typeof claims.jti === "string" ? claims.jti : undefined,
};
}

View File

@@ -1,11 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import { paperclipConfigSchema, type PaperclipConfig } from "@paperclip/shared";
import { resolvePaperclipConfigPath } from "./paths.js";
export function readConfigFile(): PaperclipConfig | null {
const configPath = process.env.PAPERCLIP_CONFIG
? path.resolve(process.env.PAPERCLIP_CONFIG)
: path.resolve(process.cwd(), ".paperclip/config.json");
const configPath = resolvePaperclipConfigPath();
if (!fs.existsSync(configPath)) return null;

View File

@@ -1,4 +1,12 @@
import { readConfigFile } from "./config-file.js";
import { existsSync } from "node:fs";
import { config as loadDotenv } from "dotenv";
import { resolvePaperclipEnvPath } from "./paths.js";
const PAPERCLIP_ENV_FILE_PATH = resolvePaperclipEnvPath();
if (existsSync(PAPERCLIP_ENV_FILE_PATH)) {
loadDotenv({ path: PAPERCLIP_ENV_FILE_PATH, override: false, quiet: true });
}
type DatabaseMode = "embedded-postgres" | "postgres";
@@ -33,7 +41,7 @@ export function loadConfig(): Config {
serveUi:
process.env.SERVE_UI !== undefined
? process.env.SERVE_UI === "true"
: fileConfig?.server.serveUi ?? false,
: fileConfig?.server.serveUi ?? true,
uiDevMiddleware: process.env.PAPERCLIP_UI_DEV_MIDDLEWARE === "true",
heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false",
heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000),

View File

@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
import type { RequestHandler } from "express";
import { and, eq, isNull } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { agentApiKeys } from "@paperclip/db";
import { agentApiKeys, agents } from "@paperclip/db";
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
@@ -32,6 +33,34 @@ export function actorMiddleware(db: Db): RequestHandler {
.then((rows) => rows[0] ?? null);
if (!key) {
const claims = verifyLocalAgentJwt(token);
if (!claims) {
next();
return;
}
const agentRecord = await db
.select()
.from(agents)
.where(eq(agents.id, claims.sub))
.then((rows) => rows[0] ?? null);
if (!agentRecord || agentRecord.companyId !== claims.company_id) {
next();
return;
}
if (agentRecord.status === "terminated") {
next();
return;
}
req.actor = {
type: "agent",
agentId: claims.sub,
companyId: claims.company_id,
keyId: undefined,
};
next();
return;
}

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

@@ -0,0 +1,33 @@
import fs from "node:fs";
import path from "node:path";
const PAPERCLIP_CONFIG_BASENAME = "config.json";
const PAPERCLIP_ENV_FILENAME = ".env";
function findConfigFileFromAncestors(startDir: string): string | null {
const absoluteStartDir = path.resolve(startDir);
let currentDir = absoluteStartDir;
while (true) {
const candidate = path.resolve(currentDir, ".paperclip", PAPERCLIP_CONFIG_BASENAME);
if (fs.existsSync(candidate)) {
return candidate;
}
const nextDir = path.resolve(currentDir, "..");
if (nextDir === currentDir) break;
currentDir = nextDir;
}
return null;
}
export function resolvePaperclipConfigPath(overridePath?: string): string {
if (overridePath) return path.resolve(overridePath);
if (process.env.PAPERCLIP_CONFIG) return path.resolve(process.env.PAPERCLIP_CONFIG);
return findConfigFileFromAncestors(process.cwd()) ?? path.resolve(process.cwd(), ".paperclip", PAPERCLIP_CONFIG_BASENAME);
}
export function resolvePaperclipEnvPath(overrideConfigPath?: string): string {
return path.resolve(path.dirname(resolvePaperclipConfigPath(overrideConfigPath)), PAPERCLIP_ENV_FILENAME);
}

View File

@@ -11,6 +11,45 @@ import { agentService, heartbeatService, logActivity } from "../services/index.j
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { listAdapterModels } from "../adapters/index.js";
const SECRET_PAYLOAD_KEY_RE =
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
const REDACTED_EVENT_VALUE = "***REDACTED***";
function sanitizeValue(value: unknown): unknown {
if (value === null || value === undefined) return value;
if (Array.isArray(value)) return value.map(sanitizeValue);
if (typeof value !== "object") return value;
if (value instanceof Date) return value;
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return value;
return sanitizeRecord(value as Record<string, unknown>);
}
function sanitizeRecord(record: Record<string, unknown>): Record<string, unknown> {
const redacted: Record<string, unknown> = {};
for (const [key, value] of Object.entries(record)) {
const isSensitiveKey = SECRET_PAYLOAD_KEY_RE.test(key);
if (isSensitiveKey) {
redacted[key] = REDACTED_EVENT_VALUE;
continue;
}
if (typeof value === "string" && JWT_VALUE_RE.test(value)) {
redacted[key] = REDACTED_EVENT_VALUE;
continue;
}
redacted[key] = sanitizeValue(value);
}
return redacted;
}
function redactEventPayload(payload: Record<string, unknown> | null): Record<string, unknown> | null {
if (!payload) return null;
if (Array.isArray(payload) || typeof payload !== "object") {
return payload as Record<string, unknown>;
}
return sanitizeRecord(payload);
}
export function agentRoutes(db: Db) {
const router = Router();
const svc = agentService(db);
@@ -407,7 +446,11 @@ export function agentRoutes(db: Db) {
const afterSeq = Number(req.query.afterSeq ?? 0);
const limit = Number(req.query.limit ?? 200);
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
res.json(events);
const redactedEvents = events.map((event) => ({
...event,
payload: redactEventPayload(event.payload),
}));
res.json(redactedEvents);
});
router.get("/heartbeat-runs/:runId/log", async (req, res) => {

View File

@@ -14,6 +14,7 @@ import { publishLiveEvent } from "./live-events.js";
import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
import { getServerAdapter, runningProcesses } from "../adapters/index.js";
import type { AdapterExecutionResult, AdapterInvocationMeta } from "../adapters/index.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
@@ -170,9 +171,7 @@ export function heartbeatService(db: Db) {
return {
enabled: asBoolean(heartbeat.enabled, true),
intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)),
wakeOnAssignment: asBoolean(heartbeat.wakeOnAssignment, true),
wakeOnOnDemand: asBoolean(heartbeat.wakeOnOnDemand, true),
wakeOnAutomation: asBoolean(heartbeat.wakeOnAutomation, true),
wakeOnDemand: asBoolean(heartbeat.wakeOnDemand ?? heartbeat.wakeOnAssignment ?? heartbeat.wakeOnOnDemand ?? heartbeat.wakeOnAutomation, true),
};
}
@@ -385,6 +384,20 @@ export function heartbeatService(db: Db) {
};
const adapter = getServerAdapter(agent.adapterType);
const authToken = adapter.supportsLocalAgentJwt
? createLocalAgentJwt(agent.id, agent.companyId, agent.adapterType, run.id)
: null;
if (adapter.supportsLocalAgentJwt && !authToken) {
logger.warn(
{
companyId: agent.companyId,
agentId: agent.id,
runId: run.id,
adapterType: agent.adapterType,
},
"local agent jwt secret missing or invalid; running without injected PAPERCLIP_API_KEY",
);
}
const adapterResult = await adapter.execute({
runId: run.id,
agent,
@@ -393,6 +406,7 @@ export function heartbeatService(db: Db) {
context,
onLog,
onMeta: onAdapterMeta,
authToken: authToken ?? undefined,
});
let outcome: "succeeded" | "failed" | "cancelled" | "timed_out";
@@ -559,16 +573,8 @@ export function heartbeatService(db: Db) {
await writeSkippedRequest("heartbeat.disabled");
return null;
}
if (source === "assignment" && !policy.wakeOnAssignment) {
await writeSkippedRequest("heartbeat.wakeOnAssignment.disabled");
return null;
}
if (source === "automation" && !policy.wakeOnAutomation) {
await writeSkippedRequest("heartbeat.wakeOnAutomation.disabled");
return null;
}
if (source === "on_demand" && triggerDetail === "ping" && !policy.wakeOnOnDemand) {
await writeSkippedRequest("heartbeat.wakeOnOnDemand.disabled");
if (source !== "timer" && !policy.wakeOnDemand) {
await writeSkippedRequest("heartbeat.wakeOnDemand.disabled");
return null;
}

View File

@@ -49,7 +49,10 @@ export function issueService(db: Db) {
return {
list: async (companyId: string, filters?: IssueFilters) => {
const conditions = [eq(issues.companyId, companyId)];
if (filters?.status) conditions.push(eq(issues.status, filters.status));
if (filters?.status) {
const statuses = filters.status.split(",").map((s) => s.trim());
conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]) : inArray(issues.status, statuses));
}
if (filters?.assigneeAgentId) {
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
}

View File

@@ -1,4 +1,7 @@
import { resolve } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js";
import { parse as parseEnvFileContents } from "dotenv";
type UiMode = "none" | "static" | "vite-dev";
@@ -53,13 +56,44 @@ function redactConnectionString(raw: string): string {
}
}
function resolveAgentJwtSecretStatus(
envFilePath: string,
): {
status: "pass" | "warn";
message: string;
} {
const envValue = process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim();
if (envValue) {
return {
status: "pass",
message: "set",
};
}
if (existsSync(envFilePath)) {
const parsed = parseEnvFileContents(readFileSync(envFilePath, "utf-8"));
const fileValue = typeof parsed.PAPERCLIP_AGENT_JWT_SECRET === "string" ? parsed.PAPERCLIP_AGENT_JWT_SECRET.trim() : "";
if (fileValue) {
return {
status: "warn",
message: `found in ${envFilePath} but not loaded`,
};
}
}
return {
status: "warn",
message: "missing (run `pnpm paperclip onboard`)",
};
}
export function printStartupBanner(opts: StartupBannerOptions): void {
const baseUrl = `http://localhost:${opts.listenPort}`;
const apiUrl = `${baseUrl}/api`;
const uiUrl = opts.uiMode === "none" ? "disabled" : baseUrl;
const configPath = process.env.PAPERCLIP_CONFIG
? resolve(process.env.PAPERCLIP_CONFIG)
: resolve(process.cwd(), ".paperclip/config.json");
const configPath = resolvePaperclipConfigPath();
const envFilePath = resolvePaperclipEnvPath();
const agentJwtSecret = resolveAgentJwtSecretStatus(envFilePath);
const dbMode =
opts.db.mode === "embedded-postgres"
@@ -105,11 +139,20 @@ export function printStartupBanner(opts: StartupBannerOptions): void {
row("UI", uiUrl),
row("Database", dbDetails),
row("Migrations", opts.migrationSummary),
row(
"Agent JWT",
agentJwtSecret.status === "pass"
? color(agentJwtSecret.message, "green")
: color(agentJwtSecret.message, "yellow"),
),
row("Heartbeat", heartbeat),
row("Config", configPath),
agentJwtSecret.status === "warn"
? color(" ───────────────────────────────────────────────────────", "yellow")
: null,
color(" ───────────────────────────────────────────────────────", "blue"),
"",
];
console.log(lines.join("\n"));
console.log(lines.filter((line): line is string => line !== null).join("\n"));
}

View File

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