diff --git a/scripts/dev-runner.mjs b/scripts/dev-runner.mjs index 4f4f7c907f0..9063158ae7f 100644 --- a/scripts/dev-runner.mjs +++ b/scripts/dev-runner.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import { createInterface } from "node:readline/promises"; @@ -16,6 +17,8 @@ const gracefulShutdownTimeoutMs = 10_000; const changedPathSampleLimit = 5; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json"); +const devServerStatusToken = mode === "dev" ? randomUUID() : null; +const devServerStatusTokenHeader = "x-paperclip-dev-server-status-token"; const watchedDirectories = [ "cli", @@ -81,9 +84,11 @@ const env = { if (mode === "dev") { env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath; + env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = devServerStatusToken ?? ""; } if (mode === "watch") { + delete env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN; env.PAPERCLIP_MIGRATION_PROMPT ??= "never"; env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true"; } @@ -426,7 +431,9 @@ async function scanForBackendChanges() { async function getDevHealthPayload() { const serverPort = env.PORT ?? process.env.PORT ?? "3100"; - const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`); + const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`, { + headers: devServerStatusToken ? { [devServerStatusTokenHeader]: devServerStatusToken } : undefined, + }); if (!response.ok) { throw new Error(`Health request failed (${response.status})`); } diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index a30096882a4..e5d73794bde 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -1,5 +1,6 @@ #!/usr/bin/env -S node --import tsx import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import { createInterface } from "node:readline/promises"; @@ -35,6 +36,8 @@ const autoRestartPollIntervalMs = 2500; const gracefulShutdownTimeoutMs = 10_000; const changedPathSampleLimit = 5; const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json"); +const devServerStatusToken = mode === "dev" ? randomUUID() : null; +const devServerStatusTokenHeader = "x-paperclip-dev-server-status-token"; const watchedDirectories = [ "cli", @@ -133,10 +136,12 @@ const env: NodeJS.ProcessEnv = { if (mode === "dev") { env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath; + env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = devServerStatusToken ?? ""; env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true"; } if (mode === "watch") { + delete env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN; env.PAPERCLIP_MIGRATION_PROMPT ??= "never"; env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true"; } @@ -553,7 +558,9 @@ async function scanForBackendChanges() { } async function getDevHealthPayload() { - const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`); + const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`, { + headers: devServerStatusToken ? { [devServerStatusTokenHeader]: devServerStatusToken } : undefined, + }); if (!response.ok) { throw new Error(`Health request failed (${response.status})`); } diff --git a/server/src/__tests__/health-dev-server-token.test.ts b/server/src/__tests__/health-dev-server-token.test.ts new file mode 100644 index 00000000000..7c768a35a09 --- /dev/null +++ b/server/src/__tests__/health-dev-server-token.test.ts @@ -0,0 +1,128 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import express from "express"; +import request from "supertest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { healthRoutes } from "../routes/health.js"; + +const tempDirs: string[] = []; + +function createDevServerStatusFile(payload: unknown) { + const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-health-dev-server-")); + tempDirs.push(dir); + const filePath = path.join(dir, "dev-server-status.json"); + writeFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf8"); + return filePath; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("GET /health dev-server supervisor access", () => { + it("exposes dev-server metadata to the supervising dev runner in authenticated mode", async () => { + const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + const previousToken = process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN; + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({ + dirty: true, + lastChangedAt: "2026-03-20T12:00:00.000Z", + changedPathCount: 1, + changedPathsSample: ["server/src/routes/health.ts"], + pendingMigrations: [], + lastRestartAt: "2026-03-20T11:30:00.000Z", + }); + process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = "dev-runner-token"; + + let selectCall = 0; + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn(() => { + selectCall += 1; + if (selectCall === 1) { + return { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), + })), + }; + } + if (selectCall === 2) { + return { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([ + { + id: "settings-1", + general: {}, + experimental: { autoRestartDevServerWhenIdle: true }, + createdAt: new Date("2026-03-20T11:00:00.000Z"), + updatedAt: new Date("2026-03-20T11:00:00.000Z"), + }, + ]), + })), + }; + } + return { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 0 }]), + })), + }; + }), + } as unknown as Db; + + try { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "none", source: "none" }; + next(); + }); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "private", + authReady: true, + companyDeletionEnabled: true, + }), + ); + + const res = await request(app) + .get("/health") + .set("X-Paperclip-Dev-Server-Status-Token", "dev-runner-token"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + status: "ok", + deploymentMode: "authenticated", + bootstrapStatus: "ready", + bootstrapInviteActive: false, + devServer: { + enabled: true, + restartRequired: true, + reason: "backend_changes", + lastChangedAt: "2026-03-20T12:00:00.000Z", + changedPathCount: 1, + changedPathsSample: ["server/src/routes/health.ts"], + pendingMigrations: [], + autoRestartEnabled: true, + activeRunCount: 0, + waitingForIdle: false, + lastRestartAt: "2026-03-20T11:30:00.000Z", + }, + }); + } finally { + if (previousFile === undefined) { + delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + } else { + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile; + } + if (previousToken === undefined) { + delete process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN; + } else { + process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = previousToken; + } + } + }); +}); diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index a1992dd41c6..388585a5bdf 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto"; import { Router } from "express"; import type { Db } from "@paperclipai/db"; import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm"; @@ -16,6 +17,17 @@ function shouldExposeFullHealthDetails( return actorType === "board" || actorType === "agent"; } +function hasDevServerStatusToken(providedToken: string | undefined) { + const expectedToken = process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN?.trim(); + const token = providedToken?.trim(); + if (!expectedToken || !token) return false; + + const expected = Buffer.from(expectedToken); + const provided = Buffer.from(token); + if (expected.length !== provided.length) return false; + return timingSafeEqual(expected, provided); +} + export function healthRoutes( db?: Db, opts: { @@ -38,6 +50,8 @@ export function healthRoutes( actorType, opts.deploymentMode, ); + const exposeDevServerDetails = + exposeFullDetails || hasDevServerStatusToken(req.get("x-paperclip-dev-server-status-token")); if (!db) { res.json( @@ -90,7 +104,7 @@ export function healthRoutes( const persistedDevServerStatus = readPersistedDevServerStatus(); let devServer: ReturnType | undefined; - if (persistedDevServerStatus && typeof (db as { select?: unknown }).select === "function") { + if (exposeDevServerDetails && persistedDevServerStatus && typeof (db as { select?: unknown }).select === "function") { const instanceSettings = instanceSettingsService(db); const experimentalSettings = await instanceSettings.getExperimental(); const activeRunCount = await db @@ -111,6 +125,7 @@ export function healthRoutes( deploymentMode: opts.deploymentMode, bootstrapStatus, bootstrapInviteActive, + ...(devServer ? { devServer } : {}), }); return; }