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

@@ -177,6 +177,14 @@ Open source. Self-hosted. No Paperclip account required.
npx paperclipai onboard --yes
```
That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly:
```bash
npx paperclipai onboard --yes --bind lan
# or:
npx paperclipai onboard --yes --bind tailnet
```
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
Or manually:

View File

@@ -177,6 +177,14 @@ Open source. Self-hosted. No Paperclip account required.
npx paperclipai onboard --yes
```
That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly:
```bash
npx paperclipai onboard --yes --bind lan
# or:
npx paperclipai onboard --yes --bind tailnet
```
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
Or manually:

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared";
describe("network bind helpers", () => {
it("rejects non-loopback bind modes in local_trusted", () => {
expect(
validateConfiguredBindMode({
deploymentMode: "local_trusted",
deploymentExposure: "private",
bind: "lan",
host: "0.0.0.0",
}),
).toContain("local_trusted requires server.bind=loopback");
});
it("resolves tailnet bind using the detected tailscale address", () => {
const resolved = resolveRuntimeBind({
bind: "tailnet",
host: "127.0.0.1",
tailnetBindHost: "100.64.0.8",
});
expect(resolved.errors).toEqual([]);
expect(resolved.host).toBe("100.64.0.8");
});
it("requires a custom bind host when bind=custom", () => {
const resolved = resolveRuntimeBind({
bind: "custom",
host: "127.0.0.1",
});
expect(resolved.errors).toContain("server.customBindHost is required when server.bind=custom");
});
});

View File

@@ -74,6 +74,11 @@ function createExistingConfigFixture() {
return { configPath, configText: fs.readFileSync(configPath, "utf8") };
}
function createFreshConfigPath() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-fresh-"));
return path.join(root, ".paperclip", "config.json");
}
describe("onboard", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
@@ -105,4 +110,43 @@ describe("onboard", () => {
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
});
it("keeps --yes onboarding on local trusted loopback defaults", async () => {
const configPath = createFreshConfigPath();
process.env.HOST = "0.0.0.0";
process.env.PAPERCLIP_BIND = "lan";
await onboard({ config: configPath, yes: true, invokedByRun: true });
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
expect(raw.server.deploymentMode).toBe("local_trusted");
expect(raw.server.exposure).toBe("private");
expect(raw.server.bind).toBe("loopback");
expect(raw.server.host).toBe("127.0.0.1");
});
it("supports authenticated/private quickstart bind presets", async () => {
const configPath = createFreshConfigPath();
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
expect(raw.server.deploymentMode).toBe("authenticated");
expect(raw.server.exposure).toBe("private");
expect(raw.server.bind).toBe("tailnet");
expect(raw.server.host).toBe("0.0.0.0");
});
it("ignores deployment env overrides during --yes quickstart", async () => {
const configPath = createFreshConfigPath();
process.env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
await onboard({ config: configPath, yes: true, invokedByRun: true });
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
expect(raw.server.deploymentMode).toBe("local_trusted");
expect(raw.server.exposure).toBe("private");
expect(raw.server.bind).toBe("loopback");
expect(raw.server.host).toBe("127.0.0.1");
});
});

View File

@@ -1,24 +1,21 @@
import { inferBindModeFromHost } from "@paperclipai/shared";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
function isLoopbackHost(host: string) {
const normalized = host.trim().toLowerCase();
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
}
export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
const mode = config.server.deploymentMode;
const exposure = config.server.exposure;
const auth = config.auth;
const bind = config.server.bind ?? inferBindModeFromHost(config.server.host);
if (mode === "local_trusted") {
if (!isLoopbackHost(config.server.host)) {
if (bind !== "loopback") {
return {
name: "Deployment/auth mode",
status: "fail",
message: `local_trusted requires loopback host binding (found ${config.server.host})`,
message: `local_trusted requires loopback binding (found ${bind})`,
canRepair: false,
repairHint: "Run `paperclipai configure --section server` and set host to 127.0.0.1",
repairHint: "Run `paperclipai configure --section server` and choose Local trusted / loopback reachability",
};
}
return {
@@ -86,6 +83,6 @@ export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
return {
name: "Deployment/auth mode",
status: "pass",
message: `Mode ${mode}/${exposure} with auth URL mode ${auth.baseUrlMode}`,
message: `Mode ${mode}/${exposure} with bind ${bind} and auth URL mode ${auth.baseUrlMode}`,
};
}

View File

@@ -3,6 +3,7 @@ import * as p from "@clack/prompts";
import pc from "picocolors";
import { and, eq, gt, isNull } from "drizzle-orm";
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
import { inferBindModeFromHost } from "@paperclipai/shared";
import { loadPaperclipEnvFile } from "../config/env.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
@@ -40,9 +41,13 @@ function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) {
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
return config.auth.publicBaseUrl.replace(/\/+$/, "");
}
const host = config?.server.host ?? "localhost";
const bind = config?.server.bind ?? inferBindModeFromHost(config?.server.host);
const host =
bind === "custom"
? config?.server.customBindHost ?? config?.server.host ?? "localhost"
: config?.server.host ?? "localhost";
const port = config?.server.port ?? 3100;
const publicHost = host === "0.0.0.0" ? "localhost" : host;
const publicHost = host === "0.0.0.0" || bind === "lan" ? "localhost" : host;
return `http://${publicHost}:${port}`;
}

View File

@@ -54,6 +54,7 @@ function defaultConfig(): PaperclipConfig {
server: {
deploymentMode: "local_trusted",
exposure: "private",
bind: "loopback",
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],

View File

@@ -3,10 +3,14 @@ import path from "node:path";
import pc from "picocolors";
import {
AUTH_BASE_URL_MODES,
BIND_MODES,
DEPLOYMENT_EXPOSURES,
DEPLOYMENT_MODES,
SECRET_PROVIDERS,
STORAGE_PROVIDERS,
inferBindModeFromHost,
resolveRuntimeBind,
type BindMode,
type AuthBaseUrlMode,
type DeploymentExposure,
type DeploymentMode,
@@ -23,6 +27,7 @@ import { promptLogging } from "../prompts/logging.js";
import { defaultSecretsConfig } from "../prompts/secrets.js";
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
import { promptServer } from "../prompts/server.js";
import { buildPresetServerConfig } from "../config/server-bind.js";
import {
describeLocalInstancePaths,
expandHomePrefix,
@@ -46,6 +51,7 @@ type OnboardOptions = {
run?: boolean;
yes?: boolean;
invokedByRun?: boolean;
bind?: BindMode;
};
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
@@ -59,6 +65,9 @@ const ONBOARD_ENV_KEYS = [
"PAPERCLIP_DB_BACKUP_DIR",
"PAPERCLIP_DEPLOYMENT_MODE",
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
"PAPERCLIP_BIND",
"PAPERCLIP_BIND_HOST",
"PAPERCLIP_TAILNET_BIND_HOST",
"HOST",
"PORT",
"SERVE_UI",
@@ -104,29 +113,62 @@ function resolvePathFromEnv(rawValue: string | undefined): string | null {
return path.resolve(expandHomePrefix(rawValue.trim()));
}
function quickstartDefaultsFromEnv(): {
function describeServerBinding(server: Pick<PaperclipConfig["server"], "bind" | "customBindHost" | "host" | "port">): string {
const bind = server.bind ?? inferBindModeFromHost(server.host);
const detail =
bind === "custom"
? server.customBindHost ?? server.host
: bind === "tailnet"
? "detected tailscale address"
: server.host;
return `${bind}${detail ? ` (${detail})` : ""}:${server.port}`;
}
function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): {
defaults: OnboardDefaults;
usedEnvKeys: string[];
ignoredEnvKeys: Array<{ key: string; reason: string }>;
} {
const preferTrustedLocal = opts?.preferTrustedLocal ?? false;
const instanceId = resolvePaperclipInstanceId();
const defaultStorage = defaultStorageConfig();
const defaultSecrets = defaultSecretsConfig();
const databaseUrl = process.env.DATABASE_URL?.trim() || undefined;
const publicUrl =
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
process.env.BETTER_AUTH_URL?.trim() ||
process.env.BETTER_AUTH_BASE_URL?.trim() ||
undefined;
const deploymentMode =
parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted";
const publicUrl = preferTrustedLocal
? undefined
: (
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
process.env.BETTER_AUTH_URL?.trim() ||
process.env.BETTER_AUTH_BASE_URL?.trim() ||
undefined
);
const deploymentMode = preferTrustedLocal
? "local_trusted"
: (parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted");
const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>(
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE,
DEPLOYMENT_EXPOSURES,
);
const deploymentExposure =
deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private");
const bindFromEnv = parseEnumFromEnv<BindMode>(process.env.PAPERCLIP_BIND, BIND_MODES);
const customBindHostFromEnv = process.env.PAPERCLIP_BIND_HOST?.trim() || undefined;
const hostFromEnv = process.env.HOST?.trim() || undefined;
const configuredBindHost = customBindHostFromEnv ?? hostFromEnv;
const bind = preferTrustedLocal
? "loopback"
: (
deploymentMode === "local_trusted"
? "loopback"
: (bindFromEnv ?? (configuredBindHost ? inferBindModeFromHost(configuredBindHost) : "lan"))
);
const resolvedBind = resolveRuntimeBind({
bind,
host: hostFromEnv ?? (bind === "loopback" ? "127.0.0.1" : "0.0.0.0"),
customBindHost: customBindHostFromEnv,
tailnetBindHost: process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim(),
});
const authPublicBaseUrl = publicUrl;
const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>(
process.env.PAPERCLIP_AUTH_BASE_URL_MODE,
@@ -183,7 +225,9 @@ function quickstartDefaultsFromEnv(): {
server: {
deploymentMode,
exposure: deploymentExposure,
host: process.env.HOST ?? "127.0.0.1",
bind: resolvedBind.bind,
...(resolvedBind.customBindHost ? { customBindHost: resolvedBind.customBindHost } : {}),
host: resolvedBind.host,
port: Number(process.env.PORT) || 3100,
allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])),
serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
@@ -220,12 +264,49 @@ function quickstartDefaultsFromEnv(): {
},
};
const ignoredEnvKeys: Array<{ key: string; reason: string }> = [];
if (preferTrustedLocal) {
const forcedLocalReason = "Ignored because --yes quickstart forces trusted local loopback defaults";
for (const key of [
"PAPERCLIP_DEPLOYMENT_MODE",
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
"PAPERCLIP_BIND",
"PAPERCLIP_BIND_HOST",
"HOST",
"PAPERCLIP_AUTH_BASE_URL_MODE",
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
"PAPERCLIP_PUBLIC_URL",
"BETTER_AUTH_URL",
"BETTER_AUTH_BASE_URL",
] as const) {
if (process.env[key] !== undefined) {
ignoredEnvKeys.push({ key, reason: forcedLocalReason });
}
}
}
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) {
ignoredEnvKeys.push({
key: "PAPERCLIP_DEPLOYMENT_EXPOSURE",
reason: "Ignored because deployment mode local_trusted always forces private exposure",
});
}
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_BIND !== undefined) {
ignoredEnvKeys.push({
key: "PAPERCLIP_BIND",
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
});
}
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_BIND_HOST !== undefined) {
ignoredEnvKeys.push({
key: "PAPERCLIP_BIND_HOST",
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
});
}
if (deploymentMode === "local_trusted" && process.env.HOST !== undefined) {
ignoredEnvKeys.push({
key: "HOST",
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
});
}
const ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key));
const usedEnvKeys = ONBOARD_ENV_KEYS.filter(
@@ -239,6 +320,10 @@ function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "data
}
export async function onboard(opts: OnboardOptions): Promise<void> {
if (opts.bind && !["loopback", "lan", "tailnet"].includes(opts.bind)) {
throw new Error(`Unsupported bind preset for onboard: ${opts.bind}. Use loopback, lan, or tailnet.`);
}
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
const configPath = resolveConfigPath(opts.config);
@@ -293,7 +378,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
`Database: ${existingConfig.database.mode}`,
existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured",
`Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`,
`Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${existingConfig.server.host}:${existingConfig.server.port}`,
`Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${describeServerBinding(existingConfig.server)}`,
`Allowed hosts: ${existingConfig.server.allowedHostnames.length > 0 ? existingConfig.server.allowedHostnames.join(", ") : "(loopback only)"}`,
`Auth URL mode: ${existingConfig.auth.baseUrlMode}${existingConfig.auth.publicBaseUrl ? ` (${existingConfig.auth.publicBaseUrl})` : ""}`,
`Storage: ${existingConfig.storage.provider}`,
@@ -336,7 +421,13 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
let setupMode: SetupMode = "quickstart";
if (opts.yes) {
p.log.message(pc.dim("`--yes` enabled: using Quickstart defaults."));
p.log.message(
pc.dim(
opts.bind
? `\`--yes\` enabled: using Quickstart defaults with bind=${opts.bind}.`
: "`--yes` enabled: using Quickstart defaults.",
),
);
} else {
const setupModeChoice = await p.select({
message: "Choose setup path",
@@ -365,7 +456,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
if (tc) trackInstallStarted(tc);
let llm: PaperclipConfig["llm"] | undefined;
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv({
preferTrustedLocal: opts.yes === true && !opts.bind,
});
let {
database,
logging,
@@ -375,6 +468,16 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
secrets,
} = derivedDefaults;
if (opts.bind === "loopback" || opts.bind === "lan" || opts.bind === "tailnet") {
const preset = buildPresetServerConfig(opts.bind, {
port: server.port,
allowedHostnames: server.allowedHostnames,
serveUi: server.serveUi,
});
server = preset.server;
auth = preset.auth;
}
if (setupMode === "advanced") {
p.log.step(pc.bold("Database"));
database = await promptDatabase(database);
@@ -462,7 +565,13 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
);
} else {
p.log.step(pc.bold("Quickstart"));
p.log.message(pc.dim("Using quickstart defaults."));
p.log.message(
pc.dim(
opts.bind
? `Using quickstart defaults with bind=${opts.bind}.`
: `Using quickstart defaults: ${server.deploymentMode}/${server.exposure} @ ${describeServerBinding(server)}.`,
),
);
if (usedEnvKeys.length > 0) {
p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`));
} else {
@@ -521,7 +630,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
`Database: ${database.mode}`,
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
`Logging: ${logging.mode} -> ${logging.logDir}`,
`Server: ${server.deploymentMode}/${server.exposure} @ ${server.host}:${server.port}`,
`Server: ${server.deploymentMode}/${server.exposure} @ ${describeServerBinding(server)}`,
`Allowed hosts: ${server.allowedHostnames.length > 0 ? server.allowedHostnames.join(", ") : "(loopback only)"}`,
`Auth URL mode: ${auth.baseUrlMode}${auth.publicBaseUrl ? ` (${auth.publicBaseUrl})` : ""}`,
`Storage: ${storage.provider}`,

View File

@@ -22,6 +22,7 @@ interface RunOptions {
instance?: string;
repair?: boolean;
yes?: boolean;
bind?: "loopback" | "lan" | "tailnet";
}
interface StartedServer {
@@ -58,7 +59,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
}
p.log.step("No config found. Starting onboarding...");
await onboard({ config: configPath, invokedByRun: true });
await onboard({ config: configPath, invokedByRun: true, bind: opts.bind });
}
p.log.step("Running doctor checks...");

View File

@@ -214,6 +214,8 @@ export function buildWorktreeConfig(input: {
server: {
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
exposure: source?.server.exposure ?? "private",
...(source?.server.bind ? { bind: source.server.bind } : {}),
...(source?.server.customBindHost ? { customBindHost: source.server.customBindHost } : {}),
host: source?.server.host ?? "127.0.0.1",
port: serverPort,
allowedHostnames: source?.server.allowedHostnames ?? [],

View File

@@ -0,0 +1,156 @@
import {
ALL_INTERFACES_BIND_HOST,
LOOPBACK_BIND_HOST,
inferBindModeFromHost,
isAllInterfacesHost,
isLoopbackHost,
type BindMode,
type DeploymentExposure,
type DeploymentMode,
} from "@paperclipai/shared";
import type { AuthConfig, ServerConfig } from "./schema.js";
type BaseServerInput = {
port: number;
allowedHostnames: string[];
serveUi: boolean;
};
export function inferConfiguredBind(server?: Partial<ServerConfig>): BindMode {
if (server?.bind) return server.bind;
return inferBindModeFromHost(server?.customBindHost ?? server?.host);
}
export function buildPresetServerConfig(
bind: Exclude<BindMode, "custom">,
input: BaseServerInput,
): { server: ServerConfig; auth: AuthConfig } {
const host = bind === "loopback" ? LOOPBACK_BIND_HOST : ALL_INTERFACES_BIND_HOST;
return {
server: {
deploymentMode: bind === "loopback" ? "local_trusted" : "authenticated",
exposure: "private",
bind,
customBindHost: undefined,
host,
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
};
}
export function buildCustomServerConfig(input: BaseServerInput & {
deploymentMode: DeploymentMode;
exposure: DeploymentExposure;
host: string;
publicBaseUrl?: string;
}): { server: ServerConfig; auth: AuthConfig } {
const normalizedHost = input.host.trim();
const bind = isLoopbackHost(normalizedHost)
? "loopback"
: isAllInterfacesHost(normalizedHost)
? "lan"
: "custom";
return {
server: {
deploymentMode: input.deploymentMode,
exposure: input.deploymentMode === "local_trusted" ? "private" : input.exposure,
bind,
customBindHost: bind === "custom" ? normalizedHost : undefined,
host: normalizedHost,
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
},
auth:
input.deploymentMode === "authenticated" && input.exposure === "public"
? {
baseUrlMode: "explicit",
disableSignUp: false,
publicBaseUrl: input.publicBaseUrl,
}
: {
baseUrlMode: "auto",
disableSignUp: false,
},
};
}
export function resolveQuickstartServerConfig(input: {
bind?: BindMode | null;
deploymentMode?: DeploymentMode | null;
exposure?: DeploymentExposure | null;
host?: string | null;
port: number;
allowedHostnames: string[];
serveUi: boolean;
publicBaseUrl?: string;
}): { server: ServerConfig; auth: AuthConfig } {
const trimmedHost = input.host?.trim();
const explicitBind = input.bind ?? null;
if (explicitBind === "loopback" || explicitBind === "lan" || explicitBind === "tailnet") {
return buildPresetServerConfig(explicitBind, {
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
});
}
if (explicitBind === "custom") {
return buildCustomServerConfig({
deploymentMode: input.deploymentMode ?? "authenticated",
exposure: input.exposure ?? "private",
host: trimmedHost || LOOPBACK_BIND_HOST,
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
publicBaseUrl: input.publicBaseUrl,
});
}
if (trimmedHost) {
return buildCustomServerConfig({
deploymentMode: input.deploymentMode ?? (isLoopbackHost(trimmedHost) ? "local_trusted" : "authenticated"),
exposure: input.exposure ?? "private",
host: trimmedHost,
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
publicBaseUrl: input.publicBaseUrl,
});
}
if (input.deploymentMode === "authenticated") {
if (input.exposure === "public") {
return buildCustomServerConfig({
deploymentMode: "authenticated",
exposure: "public",
host: ALL_INTERFACES_BIND_HOST,
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
publicBaseUrl: input.publicBaseUrl,
});
}
return buildPresetServerConfig("lan", {
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
});
}
return buildPresetServerConfig("loopback", {
port: input.port,
allowedHostnames: input.allowedHostnames,
serveUi: input.serveUi,
});
}

View File

@@ -50,7 +50,8 @@ program
.description("Interactive first-run setup wizard")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("-y, --yes", "Accept defaults (quickstart + start immediately)", false)
.option("--bind <mode>", "Quickstart reachability preset (loopback, lan, tailnet)")
.option("-y, --yes", "Accept quickstart defaults (trusted local loopback unless --bind is set) and start immediately", false)
.option("--run", "Start Paperclip immediately after saving config", false)
.action(onboard);
@@ -108,6 +109,7 @@ program
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("-i, --instance <id>", "Local instance id (default: default)")
.option("--bind <mode>", "On first run, use onboarding reachability preset (loopback, lan, tailnet)")
.option("--repair", "Attempt automatic repairs during doctor", true)
.option("--no-repair", "Disable automatic repairs during doctor")
.action(runCommand);

View File

@@ -1,6 +1,17 @@
import * as p from "@clack/prompts";
import { isLoopbackHost, type BindMode } from "@paperclipai/shared";
import type { AuthConfig, ServerConfig } from "../config/schema.js";
import { parseHostnameCsv } from "../config/hostnames.js";
import {
buildCustomServerConfig,
buildPresetServerConfig,
inferConfiguredBind,
} from "../config/server-bind.js";
function cancelled(): never {
p.cancel("Setup cancelled.");
process.exit(0);
}
export async function promptServer(opts?: {
currentServer?: Partial<ServerConfig>;
@@ -8,69 +19,37 @@ export async function promptServer(opts?: {
}): Promise<{ server: ServerConfig; auth: AuthConfig }> {
const currentServer = opts?.currentServer;
const currentAuth = opts?.currentAuth;
const currentBind = inferConfiguredBind(currentServer);
const deploymentModeSelection = await p.select({
message: "Deployment mode",
const bindSelection = await p.select({
message: "Reachability",
options: [
{
value: "local_trusted",
label: "Local trusted",
hint: "Easiest for local setup (no login, localhost-only)",
value: "loopback" as const,
label: "Trusted local",
hint: "Recommended for first run: localhost only, no login friction",
},
{
value: "authenticated",
label: "Authenticated",
hint: "Login required; use for private network or public hosting",
value: "lan" as const,
label: "Private network",
hint: "Broad private bind for LAN, VPN, or legacy --tailscale-auth style access",
},
{
value: "tailnet" as const,
label: "Tailnet",
hint: "Private authenticated access using the machine's detected Tailscale address",
},
{
value: "custom" as const,
label: "Custom",
hint: "Choose exact auth mode, exposure, and host manually",
},
],
initialValue: currentServer?.deploymentMode ?? "local_trusted",
initialValue: currentBind,
});
if (p.isCancel(deploymentModeSelection)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"];
let exposure: ServerConfig["exposure"] = "private";
if (deploymentMode === "authenticated") {
const exposureSelection = await p.select({
message: "Exposure profile",
options: [
{
value: "private",
label: "Private network",
hint: "Private access (for example Tailscale), lower setup friction",
},
{
value: "public",
label: "Public internet",
hint: "Internet-facing deployment with stricter requirements",
},
],
initialValue: currentServer?.exposure ?? "private",
});
if (p.isCancel(exposureSelection)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
exposure = exposureSelection as ServerConfig["exposure"];
}
const hostDefault = deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0";
const hostStr = await p.text({
message: "Bind host",
defaultValue: currentServer?.host ?? hostDefault,
placeholder: hostDefault,
validate: (val) => {
if (!val.trim()) return "Host is required";
},
});
if (p.isCancel(hostStr)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
if (p.isCancel(bindSelection)) cancelled();
const bind = bindSelection as BindMode;
const portStr = await p.text({
message: "Server port",
@@ -84,15 +63,109 @@ export async function promptServer(opts?: {
},
});
if (p.isCancel(portStr)) {
p.cancel("Setup cancelled.");
process.exit(0);
if (p.isCancel(portStr)) cancelled();
const port = Number(portStr) || 3100;
const serveUi = currentServer?.serveUi ?? true;
if (bind === "loopback") {
return buildPresetServerConfig("loopback", {
port,
allowedHostnames: [],
serveUi,
});
}
if (bind === "lan" || bind === "tailnet") {
const allowedHostnamesInput = await p.text({
message: "Allowed private hostnames (comma-separated, optional)",
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
placeholder:
bind === "tailnet"
? "your-machine.tailnet.ts.net"
: "dotta-macbook-pro, host.docker.internal",
validate: (val) => {
try {
parseHostnameCsv(val);
return;
} catch (err) {
return err instanceof Error ? err.message : "Invalid hostname list";
}
},
});
if (p.isCancel(allowedHostnamesInput)) cancelled();
return buildPresetServerConfig(bind, {
port,
allowedHostnames: parseHostnameCsv(allowedHostnamesInput),
serveUi,
});
}
const deploymentModeSelection = await p.select({
message: "Auth mode",
options: [
{
value: "local_trusted",
label: "Local trusted",
hint: "No login required; only safe with loopback-only or similarly trusted access",
},
{
value: "authenticated",
label: "Authenticated",
hint: "Login required; supports both private-network and public deployments",
},
],
initialValue: currentServer?.deploymentMode ?? "authenticated",
});
if (p.isCancel(deploymentModeSelection)) cancelled();
const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"];
let exposure: ServerConfig["exposure"] = "private";
if (deploymentMode === "authenticated") {
const exposureSelection = await p.select({
message: "Exposure profile",
options: [
{
value: "private",
label: "Private network",
hint: "Private access only, with automatic URL handling",
},
{
value: "public",
label: "Public internet",
hint: "Internet-facing deployment with explicit public URL requirements",
},
],
initialValue: currentServer?.exposure ?? "private",
});
if (p.isCancel(exposureSelection)) cancelled();
exposure = exposureSelection as ServerConfig["exposure"];
}
const defaultHost =
currentServer?.customBindHost ??
currentServer?.host ??
(deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0");
const host = await p.text({
message: "Bind host",
defaultValue: defaultHost,
placeholder: defaultHost,
validate: (val) => {
if (!val.trim()) return "Host is required";
if (deploymentMode === "local_trusted" && !isLoopbackHost(val.trim())) {
return "Local trusted mode requires a loopback host such as 127.0.0.1";
}
},
});
if (p.isCancel(host)) cancelled();
let allowedHostnames: string[] = [];
if (deploymentMode === "authenticated" && exposure === "private") {
const allowedHostnamesInput = await p.text({
message: "Allowed hostnames (comma-separated, optional)",
message: "Allowed private hostnames (comma-separated, optional)",
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
placeholder: "dotta-macbook-pro, your-host.tailnet.ts.net",
validate: (val) => {
@@ -105,15 +178,11 @@ export async function promptServer(opts?: {
},
});
if (p.isCancel(allowedHostnamesInput)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
if (p.isCancel(allowedHostnamesInput)) cancelled();
allowedHostnames = parseHostnameCsv(allowedHostnamesInput);
}
const port = Number(portStr) || 3100;
let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false };
let publicBaseUrl: string | undefined;
if (deploymentMode === "authenticated" && exposure === "public") {
const urlInput = await p.text({
message: "Public base URL",
@@ -133,32 +202,17 @@ export async function promptServer(opts?: {
}
},
});
if (p.isCancel(urlInput)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
auth = {
baseUrlMode: "explicit",
disableSignUp: false,
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
};
} else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) {
auth = {
baseUrlMode: "explicit",
disableSignUp: false,
publicBaseUrl: currentAuth.publicBaseUrl,
};
if (p.isCancel(urlInput)) cancelled();
publicBaseUrl = urlInput.trim().replace(/\/+$/, "");
}
return {
server: {
deploymentMode,
exposure,
host: hostStr.trim(),
port,
allowedHostnames,
serveUi: currentServer?.serveUi ?? true,
},
auth,
};
return buildCustomServerConfig({
deploymentMode,
exposure,
host: host.trim(),
port,
allowedHostnames,
serveUi,
publicBaseUrl,
});
}

View File

@@ -32,10 +32,12 @@ Mode taxonomy and design intent are documented in `doc/DEPLOYMENT-MODES.md`.
Current CLI behavior:
- `paperclipai onboard` and `paperclipai configure --section server` set deployment mode in config
- server onboarding/configure ask for reachability intent and write `server.bind`
- `paperclipai run --bind <loopback|lan|tailnet>` passes a quickstart bind preset into first-run onboarding when config is missing
- runtime can override mode with `PAPERCLIP_DEPLOYMENT_MODE`
- `paperclipai run` and `paperclipai doctor` do not yet expose a direct `--mode` flag
- `paperclipai run` and `paperclipai doctor` still do not expose a direct low-level `--mode` flag
Target behavior (planned) is documented in `doc/DEPLOYMENT-MODES.md` section 5.
Canonical behavior is documented in `doc/DEPLOYMENT-MODES.md`.
Allow an authenticated/private hostname (for example custom Tailscale DNS):

View File

@@ -17,6 +17,11 @@ Paperclip supports two runtime modes:
This keeps one authenticated auth stack while still separating low-friction private-network defaults from internet-facing hardening requirements.
Paperclip now treats **bind** as a separate concern from auth:
- auth model: `local_trusted` vs `authenticated`, plus `private/public`
- reachability model: `server.bind = loopback | lan | tailnet | custom`
## 2. Canonical Model
| Runtime Mode | Exposure | Human auth | Primary use |
@@ -25,6 +30,15 @@ This keeps one authenticated auth stack while still separating low-friction priv
| `authenticated` | `private` | Login required | Private-network access (for example Tailscale/VPN/LAN) |
| `authenticated` | `public` | Login required | Internet-facing/cloud deployment |
## Reachability Model
| Bind | Meaning | Typical use |
|---|---|---|
| `loopback` | Listen on localhost only | default local usage, reverse-proxy deployments |
| `lan` | Listen on all interfaces (`0.0.0.0`) | LAN/VPN/private-network access |
| `tailnet` | Listen on a detected Tailscale IP | Tailscale-only access |
| `custom` | Listen on an explicit host/IP | advanced interface-specific setups |
## 3. Security Policy
## `local_trusted`
@@ -38,12 +52,14 @@ This keeps one authenticated auth stack while still separating low-friction priv
- login required
- low-friction URL handling (`auto` base URL mode)
- private-host trust policy required
- bind can be `loopback`, `lan`, `tailnet`, or `custom`
## `authenticated + public`
- login required
- explicit public URL required
- stricter deployment checks and failures in doctor
- recommended bind is `loopback` behind a reverse proxy; direct `lan/custom` is advanced
## 4. Onboarding UX Contract
@@ -55,14 +71,22 @@ pnpm paperclipai onboard
Server prompt behavior:
1. ask mode, default `local_trusted`
2. option copy:
- `local_trusted`: "Easiest for local setup (no login, localhost-only)"
- `authenticated`: "Login required; use for private network or public hosting"
3. if `authenticated`, ask exposure:
- `private`: "Private network access (for example Tailscale), lower setup friction"
- `public`: "Internet-facing deployment, stricter security requirements"
4. ask explicit public URL only for `authenticated + public`
1. quickstart `--yes` defaults to `server.bind=loopback` and therefore `local_trusted/private`
2. advanced server setup asks reachability first:
- `Trusted local``bind=loopback`, `local_trusted/private`
- `Private network``bind=lan`, `authenticated/private`
- `Tailnet``bind=tailnet`, `authenticated/private`
- `Custom` → manual mode/exposure/host entry
3. raw host entry is only required for the `Custom` path
4. explicit public URL is only required for `authenticated + public`
Examples:
```sh
pnpm paperclipai onboard --yes
pnpm paperclipai onboard --yes --bind lan
pnpm paperclipai run --bind tailnet
```
`configure --section server` follows the same interactive behavior.

View File

@@ -55,10 +55,23 @@ pnpm dev:stop
Tailscale/private-auth dev mode:
```sh
pnpm dev --tailscale-auth
pnpm dev --bind lan
```
This runs dev as `authenticated/private` and binds the server to `0.0.0.0` for private-network access.
This runs dev as `authenticated/private` with a private-network bind preset.
For Tailscale-only reachability on a detected tailnet address:
```sh
pnpm dev --bind tailnet
```
Legacy aliases still map to the old broad private-network behavior:
```sh
pnpm dev --tailscale-auth
pnpm dev --authenticated-private
```
Allow additional private hostnames (for example custom Tailscale hostnames):

View File

@@ -3,7 +3,7 @@ Use this exact checklist.
1. Start Paperclip in auth mode.
```bash
cd <paperclip-repo-root>
pnpm dev --tailscale-auth
pnpm dev --bind lan
```
Then verify:
```bash

View File

@@ -89,6 +89,8 @@ Show resolved environment configuration:
pnpm paperclipai env
```
This now includes bind-oriented deployment settings such as `PAPERCLIP_BIND` and `PAPERCLIP_BIND_HOST` when configured.
## `paperclipai allowed-hostname`
Allow a private hostname for authenticated/private mode:

View File

@@ -3,13 +3,14 @@ title: Deployment Modes
summary: local_trusted vs authenticated (private/public)
---
Paperclip supports two runtime modes with different security profiles.
Paperclip supports two runtime modes with different security profiles. Reachability is configured separately with `bind`.
## `local_trusted`
The default mode. Optimized for single-operator local use.
- **Host binding**: loopback only (localhost)
- **Bind**: `loopback`
- **Authentication**: no login required
- **Use case**: local development, solo experimentation
- **Board identity**: auto-created local board user
@@ -31,6 +32,7 @@ For private network access (Tailscale, VPN, LAN).
- **Authentication**: login required via Better Auth
- **URL handling**: auto base URL mode (lower friction)
- **Host trust**: private-host trust policy required
- **Bind**: choose `loopback`, `lan`, `tailnet`, or `custom`
```sh
pnpm paperclipai onboard
@@ -50,6 +52,7 @@ For internet-facing deployment.
- **Authentication**: login required
- **URL**: explicit public URL required
- **Security**: stricter deployment checks in doctor
- **Bind**: usually `loopback` behind a reverse proxy; `lan/custom` is advanced
```sh
pnpm paperclipai onboard
@@ -81,5 +84,5 @@ pnpm paperclipai configure --section server
Runtime override via environment variable:
```sh
PAPERCLIP_DEPLOYMENT_MODE=authenticated pnpm paperclipai run
PAPERCLIP_DEPLOYMENT_MODE=authenticated PAPERCLIP_BIND=lan pnpm paperclipai run
```

View File

@@ -10,11 +10,14 @@ All environment variables that Paperclip uses for server configuration.
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `3100` | Server port |
| `HOST` | `127.0.0.1` | Server host binding |
| `PAPERCLIP_BIND` | `loopback` | Reachability preset: `loopback`, `lan`, `tailnet`, or `custom` |
| `PAPERCLIP_BIND_HOST` | (unset) | Required when `PAPERCLIP_BIND=custom` |
| `HOST` | `127.0.0.1` | Legacy host override; prefer `PAPERCLIP_BIND` for new setups |
| `DATABASE_URL` | (embedded) | PostgreSQL connection string |
| `PAPERCLIP_HOME` | `~/.paperclip` | Base directory for all Paperclip data |
| `PAPERCLIP_INSTANCE_ID` | `default` | Instance identifier (for multiple local instances) |
| `PAPERCLIP_DEPLOYMENT_MODE` | `local_trusted` | Runtime mode override |
| `PAPERCLIP_DEPLOYMENT_EXPOSURE` | `private` | Exposure policy when deployment mode is `authenticated` |
## Secrets

View File

@@ -38,19 +38,26 @@ This does:
2. Runs `paperclipai doctor` with repair enabled
3. Starts the server when checks pass
## Tailscale/Private Auth Dev Mode
## Bind Presets In Dev
To run in `authenticated/private` mode for network access:
Default `pnpm dev` stays in `local_trusted` with loopback-only binding.
To open Paperclip to a private network with login enabled:
```sh
pnpm dev --bind lan
```
For Tailscale-only binding on a detected tailnet address:
```sh
pnpm dev --bind tailnet
```
Legacy aliases still work and map to the older broad private-network behavior:
```sh
pnpm dev --tailscale-auth
```
This binds the server to `0.0.0.0` for private-network access.
Alias:
```sh
pnpm dev --authenticated-private
```

View File

@@ -1,6 +1,6 @@
---
title: Tailscale Private Access
summary: Run Paperclip with Tailscale-friendly host binding and connect from other devices
summary: Run Paperclip with Tailscale-friendly bind presets and connect from other devices
---
Use this when you want to access Paperclip over Tailscale (or a private LAN/VPN) instead of only `localhost`.
@@ -8,20 +8,25 @@ Use this when you want to access Paperclip over Tailscale (or a private LAN/VPN)
## 1. Start Paperclip in private authenticated mode
```sh
pnpm dev --tailscale-auth
pnpm dev --bind tailnet
```
This configures:
Recommended behavior:
- `PAPERCLIP_DEPLOYMENT_MODE=authenticated`
- `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`
- `PAPERCLIP_AUTH_BASE_URL_MODE=auto`
- `HOST=0.0.0.0` (bind on all interfaces)
- `PAPERCLIP_BIND=tailnet`
Equivalent flag:
If you want the old broad private-network behavior instead, use:
```sh
pnpm dev --bind lan
```
Legacy aliases still map to `authenticated/private + bind=lan`:
pnpm dev --authenticated-private
pnpm dev --tailscale-auth
```
## 2. Find your reachable Tailscale address
@@ -73,5 +78,5 @@ Expected result:
## Troubleshooting
- Login or redirect errors on a private hostname: add it with `paperclipai allowed-hostname`.
- App only works on `localhost`: make sure you started with `--tailscale-auth` (or set `HOST=0.0.0.0` in private mode).
- App only works on `localhost`: make sure you started with `--bind lan` or `--bind tailnet` instead of plain `pnpm dev`.
- Can connect locally but not remotely: verify both devices are on the same Tailscale network and port `3100` is reachable.

View File

@@ -66,7 +66,7 @@ OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
### 1) Start Paperclip
```bash
pnpm dev --tailscale-auth
pnpm dev --bind lan
curl -fsS http://127.0.0.1:3100/api/health
```

View File

@@ -1,11 +1,13 @@
import { z } from "zod";
import {
AUTH_BASE_URL_MODES,
BIND_MODES,
DEPLOYMENT_EXPOSURES,
DEPLOYMENT_MODES,
SECRET_PROVIDERS,
STORAGE_PROVIDERS,
} from "./constants.js";
import { validateConfiguredBindMode } from "./network-bind.js";
export const configMetaSchema = z.object({
version: z.literal(1),
@@ -46,6 +48,8 @@ export const loggingConfigSchema = z.object({
export const serverConfigSchema = z.object({
deploymentMode: z.enum(DEPLOYMENT_MODES).default("local_trusted"),
exposure: z.enum(DEPLOYMENT_EXPOSURES).default("private"),
bind: z.enum(BIND_MODES).optional(),
customBindHost: z.string().optional(),
host: z.string().default("127.0.0.1"),
port: z.number().int().min(1).max(65535).default(3100),
allowedHostnames: z.array(z.string().min(1)).default([]),
@@ -132,15 +136,26 @@ export const paperclipConfigSchema = z
}),
})
.superRefine((value, ctx) => {
if (value.server.deploymentMode === "local_trusted") {
if (value.server.exposure !== "private") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "server.exposure must be private when deploymentMode is local_trusted",
path: ["server", "exposure"],
});
}
return;
if (value.server.deploymentMode === "local_trusted" && value.server.exposure !== "private") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "server.exposure must be private when deploymentMode is local_trusted",
path: ["server", "exposure"],
});
}
for (const message of validateConfiguredBindMode({
deploymentMode: value.server.deploymentMode,
deploymentExposure: value.server.exposure,
bind: value.server.bind,
host: value.server.host,
customBindHost: value.server.customBindHost,
})) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
path: message.includes("customBindHost") ? ["server", "customBindHost"] : ["server", "bind"],
});
}
if (value.auth.baseUrlMode === "explicit" && !value.auth.publicBaseUrl) {

View File

@@ -7,6 +7,9 @@ export type DeploymentMode = (typeof DEPLOYMENT_MODES)[number];
export const DEPLOYMENT_EXPOSURES = ["private", "public"] as const;
export type DeploymentExposure = (typeof DEPLOYMENT_EXPOSURES)[number];
export const BIND_MODES = ["loopback", "lan", "tailnet", "custom"] as const;
export type BindMode = (typeof BIND_MODES)[number];
export const AUTH_BASE_URL_MODES = ["auto", "explicit"] as const;
export type AuthBaseUrlMode = (typeof AUTH_BASE_URL_MODES)[number];

View File

@@ -3,6 +3,7 @@ export {
COMPANY_STATUSES,
DEPLOYMENT_MODES,
DEPLOYMENT_EXPOSURES,
BIND_MODES,
AUTH_BASE_URL_MODES,
AGENT_STATUSES,
AGENT_ADAPTER_TYPES,
@@ -79,6 +80,7 @@ export {
type CompanyStatus,
type DeploymentMode,
type DeploymentExposure,
type BindMode,
type AuthBaseUrlMode,
type AgentStatus,
type AgentAdapterType,
@@ -149,6 +151,16 @@ export {
type PluginBridgeErrorCode,
} from "./constants.js";
export {
ALL_INTERFACES_BIND_HOST,
LOOPBACK_BIND_HOST,
inferBindModeFromHost,
isAllInterfacesHost,
isLoopbackHost,
resolveRuntimeBind,
validateConfiguredBindMode,
} from "./network-bind.js";
export type {
Company,
FeedbackVote,

View File

@@ -0,0 +1,105 @@
import type { BindMode, DeploymentExposure, DeploymentMode } from "./constants.js";
export const LOOPBACK_BIND_HOST = "127.0.0.1";
export const ALL_INTERFACES_BIND_HOST = "0.0.0.0";
function normalizeHost(host: string | null | undefined): string | undefined {
const trimmed = host?.trim();
return trimmed ? trimmed : undefined;
}
export function isLoopbackHost(host: string | null | undefined): boolean {
const normalized = normalizeHost(host)?.toLowerCase();
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
}
export function isAllInterfacesHost(host: string | null | undefined): boolean {
const normalized = normalizeHost(host)?.toLowerCase();
return normalized === "0.0.0.0" || normalized === "::";
}
export function inferBindModeFromHost(
host: string | null | undefined,
opts?: { tailnetBindHost?: string | null | undefined },
): BindMode {
const normalized = normalizeHost(host);
const tailnetBindHost = normalizeHost(opts?.tailnetBindHost);
if (!normalized || isLoopbackHost(normalized)) return "loopback";
if (isAllInterfacesHost(normalized)) return "lan";
if (tailnetBindHost && normalized === tailnetBindHost) return "tailnet";
return "custom";
}
export function validateConfiguredBindMode(input: {
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
bind?: BindMode | null | undefined;
host?: string | null | undefined;
customBindHost?: string | null | undefined;
}): string[] {
const bind = input.bind ?? inferBindModeFromHost(input.host);
const customBindHost = normalizeHost(input.customBindHost);
const errors: string[] = [];
if (input.deploymentMode === "local_trusted" && bind !== "loopback") {
errors.push("local_trusted requires server.bind=loopback");
}
if (bind === "custom" && !customBindHost) {
const legacyHost = normalizeHost(input.host);
if (!legacyHost || isLoopbackHost(legacyHost) || isAllInterfacesHost(legacyHost)) {
errors.push("server.customBindHost is required when server.bind=custom");
}
}
if (input.deploymentMode === "authenticated" && input.deploymentExposure === "public" && bind === "tailnet") {
errors.push("server.bind=tailnet is only supported for authenticated/private deployments");
}
return errors;
}
export function resolveRuntimeBind(input: {
bind?: BindMode | null | undefined;
host?: string | null | undefined;
customBindHost?: string | null | undefined;
tailnetBindHost?: string | null | undefined;
}): {
bind: BindMode;
host: string;
customBindHost?: string;
errors: string[];
} {
const bind = input.bind ?? inferBindModeFromHost(input.host, { tailnetBindHost: input.tailnetBindHost });
const legacyHost = normalizeHost(input.host);
const customBindHost =
normalizeHost(input.customBindHost) ??
(bind === "custom" && legacyHost && !isLoopbackHost(legacyHost) && !isAllInterfacesHost(legacyHost)
? legacyHost
: undefined);
switch (bind) {
case "loopback":
return { bind, host: LOOPBACK_BIND_HOST, customBindHost, errors: [] };
case "lan":
return { bind, host: ALL_INTERFACES_BIND_HOST, customBindHost, errors: [] };
case "custom":
return customBindHost
? { bind, host: customBindHost, customBindHost, errors: [] }
: { bind, host: legacyHost ?? LOOPBACK_BIND_HOST, errors: ["server.customBindHost is required when server.bind=custom"] };
case "tailnet": {
const tailnetBindHost = normalizeHost(input.tailnetBindHost);
return tailnetBindHost
? { bind, host: tailnetBindHost, customBindHost, errors: [] }
: {
bind,
host: legacyHost ?? LOOPBACK_BIND_HOST,
customBindHost,
errors: [
"server.bind=tailnet requires a detected Tailscale address or PAPERCLIP_TAILNET_BIND_HOST",
],
};
}
}
}

View File

@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } f
import path from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { BIND_MODES, type BindMode } from "@paperclipai/shared";
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs";
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
@@ -62,13 +63,36 @@ const tailscaleAuthFlagNames = new Set([
]);
let tailscaleAuth = false;
let bindMode: BindMode | null = null;
let bindHost: string | null = null;
const forwardedArgs: string[] = [];
for (const arg of cliArgs) {
for (let index = 0; index < cliArgs.length; index += 1) {
const arg = cliArgs[index];
if (tailscaleAuthFlagNames.has(arg)) {
tailscaleAuth = true;
continue;
}
if (arg === "--bind") {
const value = cliArgs[index + 1];
if (!value || value.startsWith("--") || !BIND_MODES.includes(value as BindMode)) {
console.error(`[paperclip] invalid --bind value. Use one of: ${BIND_MODES.join(", ")}`);
process.exit(1);
}
bindMode = value as BindMode;
index += 1;
continue;
}
if (arg === "--bind-host") {
const value = cliArgs[index + 1];
if (!value || value.startsWith("--")) {
console.error("[paperclip] --bind-host requires a value");
process.exit(1);
}
bindHost = value;
index += 1;
continue;
}
forwardedArgs.push(arg);
}
@@ -78,6 +102,16 @@ if (process.env.npm_config_tailscale_auth === "true") {
if (process.env.npm_config_authenticated_private === "true") {
tailscaleAuth = true;
}
if (!bindMode && process.env.npm_config_bind && BIND_MODES.includes(process.env.npm_config_bind as BindMode)) {
bindMode = process.env.npm_config_bind as BindMode;
}
if (!bindHost && process.env.npm_config_bind_host) {
bindHost = process.env.npm_config_bind_host;
}
if (bindMode === "custom" && !bindHost) {
console.error("[paperclip] --bind custom requires --bind-host <host>");
process.exit(1);
}
const env: NodeJS.ProcessEnv = {
...process.env,
@@ -94,13 +128,36 @@ if (mode === "watch") {
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
}
if (tailscaleAuth) {
env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private";
env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto";
env.HOST = "0.0.0.0";
console.log("[paperclip] dev mode: authenticated/private (tailscale-friendly) on 0.0.0.0");
if (tailscaleAuth || bindMode) {
const effectiveBind = bindMode ?? "lan";
if (tailscaleAuth) {
console.log("[paperclip] note: --tailscale-auth/--authenticated-private are legacy aliases for --bind lan");
}
env.PAPERCLIP_BIND = effectiveBind;
if (bindHost) {
env.PAPERCLIP_BIND_HOST = bindHost;
} else {
delete env.PAPERCLIP_BIND_HOST;
}
if (effectiveBind === "loopback" && !tailscaleAuth) {
delete env.PAPERCLIP_DEPLOYMENT_MODE;
delete env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
delete env.PAPERCLIP_AUTH_BASE_URL_MODE;
console.log("[paperclip] dev mode: local_trusted (bind=loopback)");
} else {
env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private";
env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto";
console.log(
`[paperclip] dev mode: authenticated/private (bind=${effectiveBind}${bindHost ? `:${bindHost}` : ""})`,
);
}
} else {
delete env.PAPERCLIP_BIND;
delete env.PAPERCLIP_BIND_HOST;
delete env.PAPERCLIP_DEPLOYMENT_MODE;
delete env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
delete env.PAPERCLIP_AUTH_BASE_URL_MODE;
console.log("[paperclip] dev mode: local_trusted (default)");
}
@@ -108,7 +165,7 @@ const serverPort = Number.parseInt(env.PORT ?? process.env.PORT ?? "3100", 10) |
const devService = createDevServiceIdentity({
mode,
forwardedArgs,
tailscaleAuth,
networkProfile: tailscaleAuth ? `legacy:${bindMode ?? "lan"}` : (bindMode ?? "default"),
port: serverPort,
});

View File

@@ -8,7 +8,7 @@ export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)
export function createDevServiceIdentity(input: {
mode: "watch" | "dev";
forwardedArgs: string[];
tailscaleAuth: boolean;
networkProfile: string;
port: number;
}) {
const envFingerprint = createHash("sha256")
@@ -16,7 +16,7 @@ export function createDevServiceIdentity(input: {
JSON.stringify({
mode: input.mode,
forwardedArgs: input.forwardedArgs,
tailscaleAuth: input.tailscaleAuth,
networkProfile: input.networkProfile,
port: input.port,
}),
)

View File

@@ -237,6 +237,8 @@ async function main() {
server: {
deploymentMode: sourceConfig?.server?.deploymentMode ?? "local_trusted",
exposure: sourceConfig?.server?.exposure ?? "private",
...(sourceConfig?.server?.bind ? { bind: sourceConfig.server.bind } : {}),
...(sourceConfig?.server?.customBindHost ? { customBindHost: sourceConfig.server.customBindHost } : {}),
host: sourceConfig?.server?.host ?? "127.0.0.1",
port: serverPort,
allowedHostnames: sourceConfig?.server?.allowedHostnames ?? [],

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")}`),