mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
Introduce bind presets for deployment setup
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
35
cli/src/__tests__/network-bind.test.ts
Normal file
35
cli/src/__tests__/network-bind.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ function defaultConfig(): PaperclipConfig {
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
bind: "loopback",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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...");
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
156
cli/src/config/server-bind.ts
Normal file
156
cli/src/config/server-bind.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
105
packages/shared/src/network-bind.ts
Normal file
105
packages/shared/src/network-bind.ts
Normal 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",
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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")}`),
|
||||
|
||||
Reference in New Issue
Block a user