* Secrets: add inline allowlist review set * Secrets: narrow detect-secrets file exclusions * Secrets: exclude Docker fingerprint false positive * Secrets: allowlist test and docs false positives * Secrets: refresh baseline after allowlist updates * Secrets: fix gateway chat fixture pragma * Secrets: format pre-commit config * Android: keep talk mode fixture JSON valid * Feishu: rely on client timeout injection * Secrets: allowlist provider auth test fixtures * Secrets: allowlist onboard search fixtures * Secrets: allowlist onboard mode fixture * Secrets: allowlist gateway auth mode fixture * Secrets: allowlist APNS wake test key * Secrets: allowlist gateway reload fixtures * Secrets: allowlist moonshot video fixture * Secrets: allowlist auto audio fixture * Secrets: allowlist tiny audio fixture * Secrets: allowlist embeddings fixtures * Secrets: allowlist resolve fixtures * Secrets: allowlist target registry pattern fixtures * Secrets: allowlist gateway chat env fixture * Secrets: refresh baseline after fixture allowlists * Secrets: reapply gateway chat env allowlist * Secrets: reapply gateway chat env allowlist * Secrets: stabilize gateway chat env allowlist * Secrets: allowlist runtime snapshot save fixture * Secrets: allowlist oauth profile fixtures * Secrets: allowlist compaction identifier fixture * Secrets: allowlist model auth fixture * Secrets: allowlist model status fixtures * Secrets: allowlist custom onboarding fixture * Secrets: allowlist mattermost token summary fixtures * Secrets: allowlist gateway auth suite fixtures * Secrets: allowlist channel summary fixture * Secrets: allowlist provider usage auth fixtures * Secrets: allowlist media proxy fixture * Secrets: allowlist secrets audit fixtures * Secrets: refresh baseline after final fixture allowlists * Feishu: prefer explicit client timeout * Feishu: test direct timeout precedence
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
import {
|
|
promptSecretRefForOnboarding,
|
|
resolveSecretInputModeForEnvSelection,
|
|
} from "../commands/auth-choice.apply-helpers.js";
|
|
import {
|
|
normalizeGatewayTokenInput,
|
|
randomToken,
|
|
validateGatewayPasswordInput,
|
|
} from "../commands/onboard-helpers.js";
|
|
import type { GatewayAuthChoice, SecretInputMode } from "../commands/onboard-types.js";
|
|
import type { GatewayBindMode, GatewayTailscaleMode, OpenClawConfig } from "../config/config.js";
|
|
import { ensureControlUiAllowedOriginsForNonLoopbackBind } from "../config/gateway-control-ui-origins.js";
|
|
import {
|
|
normalizeSecretInputString,
|
|
resolveSecretInputRef,
|
|
type SecretInput,
|
|
} from "../config/types.secrets.js";
|
|
import {
|
|
maybeAddTailnetOriginToControlUiAllowedOrigins,
|
|
TAILSCALE_DOCS_LINES,
|
|
TAILSCALE_EXPOSURE_OPTIONS,
|
|
TAILSCALE_MISSING_BIN_NOTE_LINES,
|
|
} from "../gateway/gateway-config-prompts.shared.js";
|
|
import { DEFAULT_DANGEROUS_NODE_COMMANDS } from "../gateway/node-command-policy.js";
|
|
import { findTailscaleBinary } from "../infra/tailscale.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import { validateIPv4AddressInput } from "../shared/net/ipv4.js";
|
|
import { resolveOnboardingSecretInputString } from "./onboarding.secret-input.js";
|
|
import type {
|
|
GatewayWizardSettings,
|
|
QuickstartGatewayDefaults,
|
|
WizardFlow,
|
|
} from "./onboarding.types.js";
|
|
import type { WizardPrompter } from "./prompts.js";
|
|
|
|
type ConfigureGatewayOptions = {
|
|
flow: WizardFlow;
|
|
baseConfig: OpenClawConfig;
|
|
nextConfig: OpenClawConfig;
|
|
localPort: number;
|
|
quickstartGateway: QuickstartGatewayDefaults;
|
|
secretInputMode?: SecretInputMode;
|
|
prompter: WizardPrompter;
|
|
runtime: RuntimeEnv;
|
|
};
|
|
|
|
type ConfigureGatewayResult = {
|
|
nextConfig: OpenClawConfig;
|
|
settings: GatewayWizardSettings;
|
|
};
|
|
|
|
export async function configureGatewayForOnboarding(
|
|
opts: ConfigureGatewayOptions,
|
|
): Promise<ConfigureGatewayResult> {
|
|
const { flow, localPort, quickstartGateway, prompter } = opts;
|
|
let { nextConfig } = opts;
|
|
|
|
const port =
|
|
flow === "quickstart"
|
|
? quickstartGateway.port
|
|
: Number.parseInt(
|
|
String(
|
|
await prompter.text({
|
|
message: "Gateway port",
|
|
initialValue: String(localPort),
|
|
validate: (value) => (Number.isFinite(Number(value)) ? undefined : "Invalid port"),
|
|
}),
|
|
),
|
|
10,
|
|
);
|
|
|
|
let bind: GatewayWizardSettings["bind"] =
|
|
flow === "quickstart"
|
|
? quickstartGateway.bind
|
|
: await prompter.select<GatewayWizardSettings["bind"]>({
|
|
message: "Gateway bind",
|
|
options: [
|
|
{ value: "loopback", label: "Loopback (127.0.0.1)" },
|
|
{ value: "lan", label: "LAN (0.0.0.0)" },
|
|
{ value: "tailnet", label: "Tailnet (Tailscale IP)" },
|
|
{ value: "auto", label: "Auto (Loopback → LAN)" },
|
|
{ value: "custom", label: "Custom IP" },
|
|
],
|
|
});
|
|
|
|
let customBindHost = quickstartGateway.customBindHost;
|
|
if (bind === "custom") {
|
|
const needsPrompt = flow !== "quickstart" || !customBindHost;
|
|
if (needsPrompt) {
|
|
const input = await prompter.text({
|
|
message: "Custom IP address",
|
|
placeholder: "192.168.1.100",
|
|
initialValue: customBindHost ?? "",
|
|
validate: validateIPv4AddressInput,
|
|
});
|
|
customBindHost = typeof input === "string" ? input.trim() : undefined;
|
|
}
|
|
}
|
|
|
|
let authMode =
|
|
flow === "quickstart"
|
|
? quickstartGateway.authMode
|
|
: ((await prompter.select({
|
|
message: "Gateway auth",
|
|
options: [
|
|
{
|
|
value: "token",
|
|
label: "Token",
|
|
hint: "Recommended default (local + remote)",
|
|
},
|
|
{ value: "password", label: "Password" },
|
|
],
|
|
initialValue: "token",
|
|
})) as GatewayAuthChoice);
|
|
|
|
const tailscaleMode: GatewayWizardSettings["tailscaleMode"] =
|
|
flow === "quickstart"
|
|
? quickstartGateway.tailscaleMode
|
|
: await prompter.select<GatewayWizardSettings["tailscaleMode"]>({
|
|
message: "Tailscale exposure",
|
|
options: [...TAILSCALE_EXPOSURE_OPTIONS],
|
|
});
|
|
|
|
// Detect Tailscale binary before proceeding with serve/funnel setup.
|
|
// Persist the path so getTailnetHostname can reuse it for origin injection.
|
|
let tailscaleBin: string | null = null;
|
|
if (tailscaleMode !== "off") {
|
|
tailscaleBin = await findTailscaleBinary();
|
|
if (!tailscaleBin) {
|
|
await prompter.note(TAILSCALE_MISSING_BIN_NOTE_LINES.join("\n"), "Tailscale Warning");
|
|
}
|
|
}
|
|
|
|
let tailscaleResetOnExit = flow === "quickstart" ? quickstartGateway.tailscaleResetOnExit : false;
|
|
if (tailscaleMode !== "off" && flow !== "quickstart") {
|
|
await prompter.note(TAILSCALE_DOCS_LINES.join("\n"), "Tailscale");
|
|
tailscaleResetOnExit = Boolean(
|
|
await prompter.confirm({
|
|
message: "Reset Tailscale serve/funnel on exit?",
|
|
initialValue: false,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Safety + constraints:
|
|
// - Tailscale wants bind=loopback so we never expose a non-loopback server + tailscale serve/funnel at once.
|
|
// - Funnel requires password auth.
|
|
if (tailscaleMode !== "off" && bind !== "loopback") {
|
|
await prompter.note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note");
|
|
bind = "loopback";
|
|
customBindHost = undefined;
|
|
}
|
|
|
|
if (tailscaleMode === "funnel" && authMode !== "password") {
|
|
await prompter.note("Tailscale funnel requires password auth.", "Note");
|
|
authMode = "password";
|
|
}
|
|
|
|
let gatewayToken: string | undefined;
|
|
let gatewayTokenInput: SecretInput | undefined;
|
|
if (authMode === "token") {
|
|
const quickstartTokenString = normalizeSecretInputString(quickstartGateway.token);
|
|
const quickstartTokenRef = resolveSecretInputRef({
|
|
value: quickstartGateway.token,
|
|
defaults: nextConfig.secrets?.defaults,
|
|
}).ref;
|
|
const tokenMode =
|
|
flow === "quickstart" && opts.secretInputMode !== "ref" // pragma: allowlist secret
|
|
? quickstartTokenRef
|
|
? "ref"
|
|
: "plaintext"
|
|
: await resolveSecretInputModeForEnvSelection({
|
|
prompter,
|
|
explicitMode: opts.secretInputMode,
|
|
copy: {
|
|
modeMessage: "How do you want to provide the gateway token?",
|
|
plaintextLabel: "Generate/store plaintext token",
|
|
plaintextHint: "Default",
|
|
refLabel: "Use SecretRef",
|
|
refHint: "Store a reference instead of plaintext",
|
|
},
|
|
});
|
|
if (tokenMode === "ref") {
|
|
if (flow === "quickstart" && quickstartTokenRef) {
|
|
gatewayTokenInput = quickstartTokenRef;
|
|
gatewayToken = await resolveOnboardingSecretInputString({
|
|
config: nextConfig,
|
|
value: quickstartTokenRef,
|
|
path: "gateway.auth.token",
|
|
env: process.env,
|
|
});
|
|
} else {
|
|
const resolved = await promptSecretRefForOnboarding({
|
|
provider: "gateway-auth-token",
|
|
config: nextConfig,
|
|
prompter,
|
|
preferredEnvVar: "OPENCLAW_GATEWAY_TOKEN",
|
|
copy: {
|
|
sourceMessage: "Where is this gateway token stored?",
|
|
envVarPlaceholder: "OPENCLAW_GATEWAY_TOKEN",
|
|
},
|
|
});
|
|
gatewayTokenInput = resolved.ref;
|
|
gatewayToken = resolved.resolvedValue;
|
|
}
|
|
} else if (flow === "quickstart") {
|
|
gatewayToken =
|
|
(quickstartTokenString ?? normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN)) ||
|
|
randomToken();
|
|
gatewayTokenInput = gatewayToken;
|
|
} else {
|
|
const tokenInput = await prompter.text({
|
|
message: "Gateway token (blank to generate)",
|
|
placeholder: "Needed for multi-machine or non-loopback access",
|
|
initialValue:
|
|
quickstartTokenString ??
|
|
normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN) ??
|
|
"",
|
|
});
|
|
gatewayToken = normalizeGatewayTokenInput(tokenInput) || randomToken();
|
|
gatewayTokenInput = gatewayToken;
|
|
}
|
|
}
|
|
|
|
if (authMode === "password") {
|
|
let password: SecretInput | undefined =
|
|
flow === "quickstart" && quickstartGateway.password ? quickstartGateway.password : undefined;
|
|
if (!password) {
|
|
const selectedMode = await resolveSecretInputModeForEnvSelection({
|
|
prompter,
|
|
explicitMode: opts.secretInputMode,
|
|
copy: {
|
|
modeMessage: "How do you want to provide the gateway password?",
|
|
plaintextLabel: "Enter password now",
|
|
plaintextHint: "Stores the password directly in OpenClaw config",
|
|
},
|
|
});
|
|
if (selectedMode === "ref") {
|
|
const resolved = await promptSecretRefForOnboarding({
|
|
provider: "gateway-auth-password",
|
|
config: nextConfig,
|
|
prompter,
|
|
preferredEnvVar: "OPENCLAW_GATEWAY_PASSWORD",
|
|
copy: {
|
|
sourceMessage: "Where is this gateway password stored?",
|
|
envVarPlaceholder: "OPENCLAW_GATEWAY_PASSWORD",
|
|
},
|
|
});
|
|
password = resolved.ref;
|
|
} else {
|
|
password = String(
|
|
(await prompter.text({
|
|
message: "Gateway password",
|
|
validate: validateGatewayPasswordInput,
|
|
})) ?? "",
|
|
).trim();
|
|
}
|
|
}
|
|
nextConfig = {
|
|
...nextConfig,
|
|
gateway: {
|
|
...nextConfig.gateway,
|
|
auth: {
|
|
...nextConfig.gateway?.auth,
|
|
mode: "password",
|
|
password,
|
|
},
|
|
},
|
|
};
|
|
} else if (authMode === "token") {
|
|
nextConfig = {
|
|
...nextConfig,
|
|
gateway: {
|
|
...nextConfig.gateway,
|
|
auth: {
|
|
...nextConfig.gateway?.auth,
|
|
mode: "token",
|
|
token: gatewayTokenInput,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
nextConfig = {
|
|
...nextConfig,
|
|
gateway: {
|
|
...nextConfig.gateway,
|
|
port,
|
|
bind: bind as GatewayBindMode,
|
|
...(bind === "custom" && customBindHost ? { customBindHost } : {}),
|
|
tailscale: {
|
|
...nextConfig.gateway?.tailscale,
|
|
mode: tailscaleMode as GatewayTailscaleMode,
|
|
resetOnExit: tailscaleResetOnExit,
|
|
},
|
|
},
|
|
};
|
|
|
|
nextConfig = ensureControlUiAllowedOriginsForNonLoopbackBind(nextConfig, {
|
|
requireControlUiEnabled: true,
|
|
}).config;
|
|
nextConfig = await maybeAddTailnetOriginToControlUiAllowedOrigins({
|
|
config: nextConfig,
|
|
tailscaleMode,
|
|
tailscaleBin,
|
|
});
|
|
|
|
// If this is a new gateway setup (no existing gateway settings), start with a
|
|
// denylist for high-risk node commands. Users can arm these temporarily via
|
|
// /phone arm ... (phone-control plugin).
|
|
if (
|
|
!quickstartGateway.hasExisting &&
|
|
nextConfig.gateway?.nodes?.denyCommands === undefined &&
|
|
nextConfig.gateway?.nodes?.allowCommands === undefined &&
|
|
nextConfig.gateway?.nodes?.browser === undefined
|
|
) {
|
|
nextConfig = {
|
|
...nextConfig,
|
|
gateway: {
|
|
...nextConfig.gateway,
|
|
nodes: {
|
|
...nextConfig.gateway?.nodes,
|
|
denyCommands: [...DEFAULT_DANGEROUS_NODE_COMMANDS],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
nextConfig,
|
|
settings: {
|
|
port,
|
|
bind: bind as GatewayBindMode,
|
|
customBindHost: bind === "custom" ? customBindHost : undefined,
|
|
authMode,
|
|
gatewayToken,
|
|
tailscaleMode: tailscaleMode as GatewayTailscaleMode,
|
|
tailscaleResetOnExit,
|
|
},
|
|
};
|
|
}
|