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:
@@ -15,6 +15,7 @@
|
||||
"@paperclip/adapter-utils": "workspace:*",
|
||||
"@paperclip/db": "workspace:*",
|
||||
"@paperclip/shared": "workspace:*",
|
||||
"dotenv": "^17.0.1",
|
||||
"detect-port": "^2.1.0",
|
||||
"drizzle-orm": "^0.38.4",
|
||||
"express": "^5.1.0",
|
||||
|
||||
79
server/src/__tests__/agent-auth-jwt.test.ts
Normal file
79
server/src/__tests__/agent-auth-jwt.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createLocalAgentJwt, verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
|
||||
describe("agent local JWT", () => {
|
||||
const secretEnv = "PAPERCLIP_AGENT_JWT_SECRET";
|
||||
const ttlEnv = "PAPERCLIP_AGENT_JWT_TTL_SECONDS";
|
||||
const issuerEnv = "PAPERCLIP_AGENT_JWT_ISSUER";
|
||||
const audienceEnv = "PAPERCLIP_AGENT_JWT_AUDIENCE";
|
||||
|
||||
const originalEnv = {
|
||||
secret: process.env[secretEnv],
|
||||
ttl: process.env[ttlEnv],
|
||||
issuer: process.env[issuerEnv],
|
||||
audience: process.env[audienceEnv],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env[secretEnv] = "test-secret";
|
||||
process.env[ttlEnv] = "3600";
|
||||
delete process.env[issuerEnv];
|
||||
delete process.env[audienceEnv];
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
if (originalEnv.secret === undefined) delete process.env[secretEnv];
|
||||
else process.env[secretEnv] = originalEnv.secret;
|
||||
if (originalEnv.ttl === undefined) delete process.env[ttlEnv];
|
||||
else process.env[ttlEnv] = originalEnv.ttl;
|
||||
if (originalEnv.issuer === undefined) delete process.env[issuerEnv];
|
||||
else process.env[issuerEnv] = originalEnv.issuer;
|
||||
if (originalEnv.audience === undefined) delete process.env[audienceEnv];
|
||||
else process.env[audienceEnv] = originalEnv.audience;
|
||||
});
|
||||
|
||||
it("creates and verifies a token", () => {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
||||
expect(typeof token).toBe("string");
|
||||
|
||||
const claims = verifyLocalAgentJwt(token!);
|
||||
expect(claims).toMatchObject({
|
||||
sub: "agent-1",
|
||||
company_id: "company-1",
|
||||
adapter_type: "claude_local",
|
||||
run_id: "run-1",
|
||||
iss: "paperclip",
|
||||
aud: "paperclip-api",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when secret is missing", () => {
|
||||
process.env[secretEnv] = "";
|
||||
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
||||
expect(token).toBeNull();
|
||||
expect(verifyLocalAgentJwt("abc.def.ghi")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects expired tokens", () => {
|
||||
process.env[ttlEnv] = "1";
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:05.000Z"));
|
||||
expect(verifyLocalAgentJwt(token!)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects issuer/audience mismatch", () => {
|
||||
process.env[issuerEnv] = "custom-issuer";
|
||||
process.env[audienceEnv] = "custom-audience";
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
const token = createLocalAgentJwt("agent-1", "company-1", "codex_local", "run-1");
|
||||
|
||||
process.env[issuerEnv] = "paperclip";
|
||||
process.env[audienceEnv] = "paperclip-api";
|
||||
expect(verifyLocalAgentJwt(token!)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -10,12 +10,14 @@ const claudeLocalAdapter: ServerAdapterModule = {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
models: claudeModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
};
|
||||
|
||||
const codexLocalAdapter: ServerAdapterModule = {
|
||||
type: "codex_local",
|
||||
execute: codexExecute,
|
||||
models: codexModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
|
||||
141
server/src/agent-auth-jwt.ts
Normal file
141
server/src/agent-auth-jwt.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
interface JwtHeader {
|
||||
alg: string;
|
||||
typ?: string;
|
||||
}
|
||||
|
||||
export interface LocalAgentJwtClaims {
|
||||
sub: string;
|
||||
company_id: string;
|
||||
adapter_type: string;
|
||||
run_id: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
iss?: string;
|
||||
aud?: string;
|
||||
jti?: string;
|
||||
}
|
||||
|
||||
const JWT_ALGORITHM = "HS256";
|
||||
|
||||
function parseNumber(value: string | undefined, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function jwtConfig() {
|
||||
const secret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
if (!secret) return null;
|
||||
|
||||
return {
|
||||
secret,
|
||||
ttlSeconds: parseNumber(process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS, 60 * 60 * 48),
|
||||
issuer: process.env.PAPERCLIP_AGENT_JWT_ISSUER ?? "paperclip",
|
||||
audience: process.env.PAPERCLIP_AGENT_JWT_AUDIENCE ?? "paperclip-api",
|
||||
};
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string) {
|
||||
return Buffer.from(value, "base64url").toString("utf8");
|
||||
}
|
||||
|
||||
function signPayload(secret: string, signingInput: string) {
|
||||
return createHmac("sha256", secret).update(signingInput).digest("base64url");
|
||||
}
|
||||
|
||||
function parseJson(value: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeCompare(a: string, b: string) {
|
||||
const left = Buffer.from(a);
|
||||
const right = Buffer.from(b);
|
||||
if (left.length !== right.length) return false;
|
||||
return timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
export function createLocalAgentJwt(agentId: string, companyId: string, adapterType: string, runId: string) {
|
||||
const config = jwtConfig();
|
||||
if (!config) return null;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const claims: LocalAgentJwtClaims = {
|
||||
sub: agentId,
|
||||
company_id: companyId,
|
||||
adapter_type: adapterType,
|
||||
run_id: runId,
|
||||
iat: now,
|
||||
exp: now + config.ttlSeconds,
|
||||
iss: config.issuer,
|
||||
aud: config.audience,
|
||||
};
|
||||
|
||||
const header = {
|
||||
alg: JWT_ALGORITHM,
|
||||
typ: "JWT",
|
||||
};
|
||||
|
||||
const signingInput = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(claims))}`;
|
||||
const signature = signPayload(config.secret, signingInput);
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null {
|
||||
if (!token) return null;
|
||||
const config = jwtConfig();
|
||||
if (!config) return null;
|
||||
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const [headerB64, claimsB64, signature] = parts;
|
||||
|
||||
const header = parseJson(base64UrlDecode(headerB64));
|
||||
if (!header || header.alg !== JWT_ALGORITHM) return null;
|
||||
|
||||
const signingInput = `${headerB64}.${claimsB64}`;
|
||||
const expectedSig = signPayload(config.secret, signingInput);
|
||||
if (!safeCompare(signature, expectedSig)) return null;
|
||||
|
||||
const claims = parseJson(base64UrlDecode(claimsB64));
|
||||
if (!claims) return null;
|
||||
|
||||
const sub = typeof claims.sub === "string" ? claims.sub : null;
|
||||
const companyId = typeof claims.company_id === "string" ? claims.company_id : null;
|
||||
const adapterType = typeof claims.adapter_type === "string" ? claims.adapter_type : null;
|
||||
const runId = typeof claims.run_id === "string" ? claims.run_id : null;
|
||||
const iat = typeof claims.iat === "number" ? claims.iat : null;
|
||||
const exp = typeof claims.exp === "number" ? claims.exp : null;
|
||||
if (!sub || !companyId || !adapterType || !runId || !iat || !exp) return null;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (exp < now) return null;
|
||||
|
||||
const issuer = typeof claims.iss === "string" ? claims.iss : undefined;
|
||||
const audience = typeof claims.aud === "string" ? claims.aud : undefined;
|
||||
if (issuer && issuer !== config.issuer) return null;
|
||||
if (audience && audience !== config.audience) return null;
|
||||
|
||||
return {
|
||||
sub,
|
||||
company_id: companyId,
|
||||
adapter_type: adapterType,
|
||||
run_id: runId,
|
||||
iat,
|
||||
exp,
|
||||
...(issuer ? { iss: issuer } : {}),
|
||||
...(audience ? { aud: audience } : {}),
|
||||
jti: typeof claims.jti === "string" ? claims.jti : undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { paperclipConfigSchema, type PaperclipConfig } from "@paperclip/shared";
|
||||
import { resolvePaperclipConfigPath } from "./paths.js";
|
||||
|
||||
export function readConfigFile(): PaperclipConfig | null {
|
||||
const configPath = process.env.PAPERCLIP_CONFIG
|
||||
? path.resolve(process.env.PAPERCLIP_CONFIG)
|
||||
: path.resolve(process.cwd(), ".paperclip/config.json");
|
||||
const configPath = resolvePaperclipConfigPath();
|
||||
|
||||
if (!fs.existsSync(configPath)) return null;
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { readConfigFile } from "./config-file.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { resolvePaperclipEnvPath } from "./paths.js";
|
||||
|
||||
const PAPERCLIP_ENV_FILE_PATH = resolvePaperclipEnvPath();
|
||||
if (existsSync(PAPERCLIP_ENV_FILE_PATH)) {
|
||||
loadDotenv({ path: PAPERCLIP_ENV_FILE_PATH, override: false, quiet: true });
|
||||
}
|
||||
|
||||
type DatabaseMode = "embedded-postgres" | "postgres";
|
||||
|
||||
@@ -33,7 +41,7 @@ export function loadConfig(): Config {
|
||||
serveUi:
|
||||
process.env.SERVE_UI !== undefined
|
||||
? process.env.SERVE_UI === "true"
|
||||
: fileConfig?.server.serveUi ?? false,
|
||||
: fileConfig?.server.serveUi ?? true,
|
||||
uiDevMiddleware: process.env.PAPERCLIP_UI_DEV_MIDDLEWARE === "true",
|
||||
heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false",
|
||||
heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000),
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
|
||||
import type { RequestHandler } from "express";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { agentApiKeys } from "@paperclip/db";
|
||||
import { agentApiKeys, agents } from "@paperclip/db";
|
||||
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
@@ -32,6 +33,34 @@ export function actorMiddleware(db: Db): RequestHandler {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!key) {
|
||||
const claims = verifyLocalAgentJwt(token);
|
||||
if (!claims) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const agentRecord = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, claims.sub))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!agentRecord || agentRecord.companyId !== claims.company_id) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (agentRecord.status === "terminated") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
req.actor = {
|
||||
type: "agent",
|
||||
agentId: claims.sub,
|
||||
companyId: claims.company_id,
|
||||
keyId: undefined,
|
||||
};
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
33
server/src/paths.ts
Normal file
33
server/src/paths.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const PAPERCLIP_CONFIG_BASENAME = "config.json";
|
||||
const PAPERCLIP_ENV_FILENAME = ".env";
|
||||
|
||||
function findConfigFileFromAncestors(startDir: string): string | null {
|
||||
const absoluteStartDir = path.resolve(startDir);
|
||||
let currentDir = absoluteStartDir;
|
||||
|
||||
while (true) {
|
||||
const candidate = path.resolve(currentDir, ".paperclip", PAPERCLIP_CONFIG_BASENAME);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const nextDir = path.resolve(currentDir, "..");
|
||||
if (nextDir === currentDir) break;
|
||||
currentDir = nextDir;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolvePaperclipConfigPath(overridePath?: string): string {
|
||||
if (overridePath) return path.resolve(overridePath);
|
||||
if (process.env.PAPERCLIP_CONFIG) return path.resolve(process.env.PAPERCLIP_CONFIG);
|
||||
return findConfigFileFromAncestors(process.cwd()) ?? path.resolve(process.cwd(), ".paperclip", PAPERCLIP_CONFIG_BASENAME);
|
||||
}
|
||||
|
||||
export function resolvePaperclipEnvPath(overrideConfigPath?: string): string {
|
||||
return path.resolve(path.dirname(resolvePaperclipConfigPath(overrideConfigPath)), PAPERCLIP_ENV_FILENAME);
|
||||
}
|
||||
@@ -11,6 +11,45 @@ import { agentService, heartbeatService, logActivity } from "../services/index.j
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { listAdapterModels } from "../adapters/index.js";
|
||||
|
||||
const SECRET_PAYLOAD_KEY_RE =
|
||||
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
|
||||
const REDACTED_EVENT_VALUE = "***REDACTED***";
|
||||
|
||||
function sanitizeValue(value: unknown): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (Array.isArray(value)) return value.map(sanitizeValue);
|
||||
if (typeof value !== "object") return value;
|
||||
if (value instanceof Date) return value;
|
||||
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return value;
|
||||
return sanitizeRecord(value as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function sanitizeRecord(record: Record<string, unknown>): Record<string, unknown> {
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const isSensitiveKey = SECRET_PAYLOAD_KEY_RE.test(key);
|
||||
if (isSensitiveKey) {
|
||||
redacted[key] = REDACTED_EVENT_VALUE;
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "string" && JWT_VALUE_RE.test(value)) {
|
||||
redacted[key] = REDACTED_EVENT_VALUE;
|
||||
continue;
|
||||
}
|
||||
redacted[key] = sanitizeValue(value);
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function redactEventPayload(payload: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (!payload) return null;
|
||||
if (Array.isArray(payload) || typeof payload !== "object") {
|
||||
return payload as Record<string, unknown>;
|
||||
}
|
||||
return sanitizeRecord(payload);
|
||||
}
|
||||
|
||||
export function agentRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = agentService(db);
|
||||
@@ -407,7 +446,11 @@ export function agentRoutes(db: Db) {
|
||||
const afterSeq = Number(req.query.afterSeq ?? 0);
|
||||
const limit = Number(req.query.limit ?? 200);
|
||||
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
|
||||
res.json(events);
|
||||
const redactedEvents = events.map((event) => ({
|
||||
...event,
|
||||
payload: redactEventPayload(event.payload),
|
||||
}));
|
||||
res.json(redactedEvents);
|
||||
});
|
||||
|
||||
router.get("/heartbeat-runs/:runId/log", async (req, res) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { publishLiveEvent } from "./live-events.js";
|
||||
import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
|
||||
import { getServerAdapter, runningProcesses } from "../adapters/index.js";
|
||||
import type { AdapterExecutionResult, AdapterInvocationMeta } from "../adapters/index.js";
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
||||
|
||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||
@@ -170,9 +171,7 @@ export function heartbeatService(db: Db) {
|
||||
return {
|
||||
enabled: asBoolean(heartbeat.enabled, true),
|
||||
intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)),
|
||||
wakeOnAssignment: asBoolean(heartbeat.wakeOnAssignment, true),
|
||||
wakeOnOnDemand: asBoolean(heartbeat.wakeOnOnDemand, true),
|
||||
wakeOnAutomation: asBoolean(heartbeat.wakeOnAutomation, true),
|
||||
wakeOnDemand: asBoolean(heartbeat.wakeOnDemand ?? heartbeat.wakeOnAssignment ?? heartbeat.wakeOnOnDemand ?? heartbeat.wakeOnAutomation, true),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -385,6 +384,20 @@ export function heartbeatService(db: Db) {
|
||||
};
|
||||
|
||||
const adapter = getServerAdapter(agent.adapterType);
|
||||
const authToken = adapter.supportsLocalAgentJwt
|
||||
? createLocalAgentJwt(agent.id, agent.companyId, agent.adapterType, run.id)
|
||||
: null;
|
||||
if (adapter.supportsLocalAgentJwt && !authToken) {
|
||||
logger.warn(
|
||||
{
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
adapterType: agent.adapterType,
|
||||
},
|
||||
"local agent jwt secret missing or invalid; running without injected PAPERCLIP_API_KEY",
|
||||
);
|
||||
}
|
||||
const adapterResult = await adapter.execute({
|
||||
runId: run.id,
|
||||
agent,
|
||||
@@ -393,6 +406,7 @@ export function heartbeatService(db: Db) {
|
||||
context,
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
authToken: authToken ?? undefined,
|
||||
});
|
||||
|
||||
let outcome: "succeeded" | "failed" | "cancelled" | "timed_out";
|
||||
@@ -559,16 +573,8 @@ export function heartbeatService(db: Db) {
|
||||
await writeSkippedRequest("heartbeat.disabled");
|
||||
return null;
|
||||
}
|
||||
if (source === "assignment" && !policy.wakeOnAssignment) {
|
||||
await writeSkippedRequest("heartbeat.wakeOnAssignment.disabled");
|
||||
return null;
|
||||
}
|
||||
if (source === "automation" && !policy.wakeOnAutomation) {
|
||||
await writeSkippedRequest("heartbeat.wakeOnAutomation.disabled");
|
||||
return null;
|
||||
}
|
||||
if (source === "on_demand" && triggerDetail === "ping" && !policy.wakeOnOnDemand) {
|
||||
await writeSkippedRequest("heartbeat.wakeOnOnDemand.disabled");
|
||||
if (source !== "timer" && !policy.wakeOnDemand) {
|
||||
await writeSkippedRequest("heartbeat.wakeOnDemand.disabled");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,10 @@ export function issueService(db: Db) {
|
||||
return {
|
||||
list: async (companyId: string, filters?: IssueFilters) => {
|
||||
const conditions = [eq(issues.companyId, companyId)];
|
||||
if (filters?.status) conditions.push(eq(issues.status, filters.status));
|
||||
if (filters?.status) {
|
||||
const statuses = filters.status.split(",").map((s) => s.trim());
|
||||
conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]) : inArray(issues.status, statuses));
|
||||
}
|
||||
if (filters?.assigneeAgentId) {
|
||||
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { resolve } from "node:path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js";
|
||||
|
||||
import { parse as parseEnvFileContents } from "dotenv";
|
||||
|
||||
type UiMode = "none" | "static" | "vite-dev";
|
||||
|
||||
@@ -53,13 +56,44 @@ function redactConnectionString(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgentJwtSecretStatus(
|
||||
envFilePath: string,
|
||||
): {
|
||||
status: "pass" | "warn";
|
||||
message: string;
|
||||
} {
|
||||
const envValue = process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim();
|
||||
if (envValue) {
|
||||
return {
|
||||
status: "pass",
|
||||
message: "set",
|
||||
};
|
||||
}
|
||||
|
||||
if (existsSync(envFilePath)) {
|
||||
const parsed = parseEnvFileContents(readFileSync(envFilePath, "utf-8"));
|
||||
const fileValue = typeof parsed.PAPERCLIP_AGENT_JWT_SECRET === "string" ? parsed.PAPERCLIP_AGENT_JWT_SECRET.trim() : "";
|
||||
if (fileValue) {
|
||||
return {
|
||||
status: "warn",
|
||||
message: `found in ${envFilePath} but not loaded`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "warn",
|
||||
message: "missing (run `pnpm paperclip onboard`)",
|
||||
};
|
||||
}
|
||||
|
||||
export function printStartupBanner(opts: StartupBannerOptions): void {
|
||||
const baseUrl = `http://localhost:${opts.listenPort}`;
|
||||
const apiUrl = `${baseUrl}/api`;
|
||||
const uiUrl = opts.uiMode === "none" ? "disabled" : baseUrl;
|
||||
const configPath = process.env.PAPERCLIP_CONFIG
|
||||
? resolve(process.env.PAPERCLIP_CONFIG)
|
||||
: resolve(process.cwd(), ".paperclip/config.json");
|
||||
const configPath = resolvePaperclipConfigPath();
|
||||
const envFilePath = resolvePaperclipEnvPath();
|
||||
const agentJwtSecret = resolveAgentJwtSecretStatus(envFilePath);
|
||||
|
||||
const dbMode =
|
||||
opts.db.mode === "embedded-postgres"
|
||||
@@ -105,11 +139,20 @@ export function printStartupBanner(opts: StartupBannerOptions): void {
|
||||
row("UI", uiUrl),
|
||||
row("Database", dbDetails),
|
||||
row("Migrations", opts.migrationSummary),
|
||||
row(
|
||||
"Agent JWT",
|
||||
agentJwtSecret.status === "pass"
|
||||
? color(agentJwtSecret.message, "green")
|
||||
: color(agentJwtSecret.message, "yellow"),
|
||||
),
|
||||
row("Heartbeat", heartbeat),
|
||||
row("Config", configPath),
|
||||
agentJwtSecret.status === "warn"
|
||||
? color(" ───────────────────────────────────────────────────────", "yellow")
|
||||
: null,
|
||||
color(" ───────────────────────────────────────────────────────", "blue"),
|
||||
"",
|
||||
];
|
||||
|
||||
console.log(lines.join("\n"));
|
||||
console.log(lines.filter((line): line is string => line !== null).join("\n"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user