Files
openclaw/src/wizard/onboarding.gateway-config.ts
Vincent Koc 42e3d8d693 Secrets: add inline allowlist review set (#38314)
* 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
2026-03-06 19:35:26 -05:00

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,
},
};
}