Introduce bind presets for deployment setup

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-10 07:32:16 -05:00 committed by Dotta
parent e1bf9d66a7
commit 2a84e53c1b
35 changed files with 915 additions and 176 deletions

View file

@ -66,6 +66,8 @@ vi.mock("../config.js", () => ({
loadConfig: vi.fn(() => ({
deploymentMode: "authenticated",
deploymentExposure: "private",
bind: "loopback",
customBindHost: undefined,
host: "127.0.0.1",
port: 3210,
allowedHostnames: [],

View file

@ -1,4 +1,5 @@
import { readConfigFile } from "./config-file.js";
import { execFileSync } from "node:child_process";
import { existsSync, realpathSync } from "node:fs";
import { resolve } from "node:path";
import { config as loadDotenv } from "dotenv";
@ -6,15 +7,20 @@ import { resolvePaperclipEnvPath } from "./paths.js";
import { maybeRepairLegacyWorktreeConfigAndEnvFiles } from "./worktree-config.js";
import {
AUTH_BASE_URL_MODES,
BIND_MODES,
DEPLOYMENT_EXPOSURES,
DEPLOYMENT_MODES,
SECRET_PROVIDERS,
STORAGE_PROVIDERS,
type BindMode,
type AuthBaseUrlMode,
type DeploymentExposure,
type DeploymentMode,
type SecretProvider,
type StorageProvider,
inferBindModeFromHost,
resolveRuntimeBind,
validateConfiguredBindMode,
} from "@paperclipai/shared";
import {
resolveDefaultBackupDir,
@ -44,6 +50,8 @@ type DatabaseMode = "embedded-postgres" | "postgres";
export interface Config {
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
bind: BindMode;
customBindHost: string | undefined;
host: string;
port: number;
allowedHostnames: string[];
@ -78,6 +86,24 @@ export interface Config {
telemetryEnabled: boolean;
}
function detectTailnetBindHost(): string | undefined {
const explicit = process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim();
if (explicit) return explicit;
try {
const stdout = execFileSync("tailscale", ["ip", "-4"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
} catch {
return undefined;
}
}
export function loadConfig(): Config {
const fileConfig = readConfigFile();
const fileDatabaseMode =
@ -148,6 +174,18 @@ export function loadConfig(): Config {
deploymentMode === "local_trusted"
? "private"
: (deploymentExposureFromEnv ?? fileConfig?.server.exposure ?? "private");
const bindFromEnvRaw = process.env.PAPERCLIP_BIND;
const bindFromEnv =
bindFromEnvRaw && BIND_MODES.includes(bindFromEnvRaw as BindMode)
? (bindFromEnvRaw as BindMode)
: null;
const configuredHost = process.env.HOST ?? fileConfig?.server.host ?? "127.0.0.1";
const tailnetBindHost = detectTailnetBindHost();
const bind =
bindFromEnv ??
fileConfig?.server.bind ??
inferBindModeFromHost(configuredHost, { tailnetBindHost });
const customBindHost = process.env.PAPERCLIP_BIND_HOST ?? fileConfig?.server.customBindHost;
const authBaseUrlModeFromEnvRaw = process.env.PAPERCLIP_AUTH_BASE_URL_MODE;
const authBaseUrlModeFromEnv =
authBaseUrlModeFromEnvRaw &&
@ -223,11 +261,32 @@ export function loadConfig(): Config {
fileDatabaseBackup?.dir ??
resolveDefaultBackupDir(),
);
const bindValidationErrors = validateConfiguredBindMode({
deploymentMode,
deploymentExposure,
bind,
host: configuredHost,
customBindHost,
});
if (bindValidationErrors.length > 0) {
throw new Error(bindValidationErrors[0]);
}
const resolvedBind = resolveRuntimeBind({
bind,
host: configuredHost,
customBindHost,
tailnetBindHost,
});
if (resolvedBind.errors.length > 0) {
throw new Error(resolvedBind.errors[0]);
}
return {
deploymentMode,
deploymentExposure,
host: process.env.HOST ?? fileConfig?.server.host ?? "127.0.0.1",
bind: resolvedBind.bind,
customBindHost: resolvedBind.customBindHost,
host: resolvedBind.host,
port: Number(process.env.PORT) || fileConfig?.server.port || 3100,
allowedHostnames,
authBaseUrlMode,

View file

@ -701,9 +701,10 @@ export async function startServer(): Promise<StartedServer> {
logger.warn({ err, url }, "Failed to open browser on startup");
});
}
printStartupBanner({
host: config.host,
deploymentMode: config.deploymentMode,
printStartupBanner({
bind: config.bind,
host: config.host,
deploymentMode: config.deploymentMode,
deploymentExposure: config.deploymentExposure,
authReady,
requestedPort: config.port,

View file

@ -928,7 +928,7 @@ function buildOnboardingDiscoveryDiagnostics(input: {
code: "openclaw_onboarding_private_loopback_bind",
level: "warn",
message: "Paperclip is bound to loopback in authenticated/private mode.",
hint: "Run with a reachable bind host or use pnpm dev --tailscale-auth for private-network onboarding."
hint: "Use a reachable private bind mode such as `pnpm dev --bind lan` or `pnpm dev --bind tailnet` for private-network onboarding."
});
}

View file

@ -1,6 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js";
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
import type { BindMode, DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
import { parse as parseEnvFileContents } from "dotenv";
@ -18,6 +18,7 @@ type EmbeddedPostgresInfo = {
};
type StartupBannerOptions = {
bind: BindMode;
host: string;
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
@ -148,6 +149,7 @@ export function printStartupBanner(opts: StartupBannerOptions): void {
color(" ───────────────────────────────────────────────────────", "blue"),
row("Mode", `${dbMode} | ${uiMode}`),
row("Deploy", `${opts.deploymentMode} (${opts.deploymentExposure})`),
row("Bind", `${opts.bind} ${color(`(${opts.host})`, "dim")}`),
row("Auth", opts.authReady ? color("ready", "green") : color("not-ready", "yellow")),
row("Server", portValue),
row("API", `${apiUrl} ${color(`(health: ${apiUrl}/health)`, "dim")}`),