diff --git a/README.md b/README.md index 21edf20a..bff4b1c7 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/cli/README.md b/cli/README.md index 1826e376..4de796b5 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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: diff --git a/cli/src/__tests__/network-bind.test.ts b/cli/src/__tests__/network-bind.test.ts new file mode 100644 index 00000000..4c2bc555 --- /dev/null +++ b/cli/src/__tests__/network-bind.test.ts @@ -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"); + }); +}); diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts index df1a91b8..f686e071 100644 --- a/cli/src/__tests__/onboard.test.ts +++ b/cli/src/__tests__/onboard.test.ts @@ -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"); + }); }); diff --git a/cli/src/checks/deployment-auth-check.ts b/cli/src/checks/deployment-auth-check.ts index 580e7e08..6434ede0 100644 --- a/cli/src/checks/deployment-auth-check.ts +++ b/cli/src/checks/deployment-auth-check.ts @@ -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}`, }; } diff --git a/cli/src/commands/auth-bootstrap-ceo.ts b/cli/src/commands/auth-bootstrap-ceo.ts index dc720f63..d07b9a70 100644 --- a/cli/src/commands/auth-bootstrap-ceo.ts +++ b/cli/src/commands/auth-bootstrap-ceo.ts @@ -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}`; } diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index 83ff089b..b7d2dcbc 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -54,6 +54,7 @@ function defaultConfig(): PaperclipConfig { server: { deploymentMode: "local_trusted", exposure: "private", + bind: "loopback", host: "127.0.0.1", port: 3100, allowedHostnames: [], diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index d9b325a8..0fabfd0a 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -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; @@ -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): 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(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(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted"); const deploymentExposureFromEnv = parseEnumFromEnv( process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE, DEPLOYMENT_EXPOSURES, ); const deploymentExposure = deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private"); + const bindFromEnv = parseEnumFromEnv(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( 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 { + 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 { `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 { 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 { 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 { 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 { ); } 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 { `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}`, diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index b39ab06c..9bc9655b 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -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 { } 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..."); diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index d2b6c5f7..5d4a0721 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -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 ?? [], diff --git a/cli/src/config/server-bind.ts b/cli/src/config/server-bind.ts new file mode 100644 index 00000000..93cd3eff --- /dev/null +++ b/cli/src/config/server-bind.ts @@ -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): BindMode { + if (server?.bind) return server.bind; + return inferBindModeFromHost(server?.customBindHost ?? server?.host); +} + +export function buildPresetServerConfig( + bind: Exclude, + 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, + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 11739459..b2208798 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -50,7 +50,8 @@ program .description("Interactive first-run setup wizard") .option("-c, --config ", "Path to config file") .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) - .option("-y, --yes", "Accept defaults (quickstart + start immediately)", false) + .option("--bind ", "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 to config file") .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) .option("-i, --instance ", "Local instance id (default: default)") + .option("--bind ", "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); diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index e5c26180..78810188 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -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; @@ -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, + }); } diff --git a/doc/CLI.md b/doc/CLI.md index 6f945656..c124b447 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -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 ` 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): diff --git a/doc/DEPLOYMENT-MODES.md b/doc/DEPLOYMENT-MODES.md index 9d58d50a..a7b8d7fb 100644 --- a/doc/DEPLOYMENT-MODES.md +++ b/doc/DEPLOYMENT-MODES.md @@ -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. diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index f7e17195..dd203196 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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): diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index bdb098b3..32f27ea9 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -3,7 +3,7 @@ Use this exact checklist. 1. Start Paperclip in auth mode. ```bash cd -pnpm dev --tailscale-auth +pnpm dev --bind lan ``` Then verify: ```bash diff --git a/docs/cli/setup-commands.md b/docs/cli/setup-commands.md index 448ab7bb..bb3cf17f 100644 --- a/docs/cli/setup-commands.md +++ b/docs/cli/setup-commands.md @@ -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: diff --git a/docs/deploy/deployment-modes.md b/docs/deploy/deployment-modes.md index ae567f49..7d5e6032 100644 --- a/docs/deploy/deployment-modes.md +++ b/docs/deploy/deployment-modes.md @@ -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 ``` diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index bc5e4bcc..5ebef728 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -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 diff --git a/docs/deploy/local-development.md b/docs/deploy/local-development.md index 874477c1..3bc42334 100644 --- a/docs/deploy/local-development.md +++ b/docs/deploy/local-development.md @@ -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 ``` diff --git a/docs/deploy/tailscale-private-access.md b/docs/deploy/tailscale-private-access.md index 1e0d2467..bb40fa30 100644 --- a/docs/deploy/tailscale-private-access.md +++ b/docs/deploy/tailscale-private-access.md @@ -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. diff --git a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md index 66ff2a4a..7382c67a 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -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 ``` diff --git a/packages/shared/src/config-schema.ts b/packages/shared/src/config-schema.ts index 2399ceae..efa1bdee 100644 --- a/packages/shared/src/config-schema.ts +++ b/packages/shared/src/config-schema.ts @@ -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) { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 521ccf38..f62d2777 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 36505ece..8ec63026 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/packages/shared/src/network-bind.ts b/packages/shared/src/network-bind.ts new file mode 100644 index 00000000..eeda9c50 --- /dev/null +++ b/packages/shared/src/network-bind.ts @@ -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", + ], + }; + } + } +} diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 756a6b92..ba68f039 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -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 "); + 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, }); diff --git a/scripts/dev-service-profile.ts b/scripts/dev-service-profile.ts index 9c129b34..90efb322 100644 --- a/scripts/dev-service-profile.ts +++ b/scripts/dev-service-profile.ts @@ -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, }), ) diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 93e6a2d6..9fbd3ccc 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -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 ?? [], diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 78565645..056e812a 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -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: [], diff --git a/server/src/config.ts b/server/src/config.ts index 5ae50400..9be5c21a 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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, diff --git a/server/src/index.ts b/server/src/index.ts index 7bf5cea7..0e8db5a6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -701,9 +701,10 @@ export async function startServer(): Promise { 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, diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index a53bf0dc..e4893765 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -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." }); } diff --git a/server/src/startup-banner.ts b/server/src/startup-banner.ts index 1a52731b..a3ae2ed1 100644 --- a/server/src/startup-banner.ts +++ b/server/src/startup-banner.ts @@ -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")}`),