fix(onboarding): honor classic gateway options (#112396)

* fix(onboarding): honor classic gateway options

* test(wizard): type gateway setup mock

* docs(onboarding): document quickstart gateway flags

* chore(release): defer changelog entry
This commit is contained in:
Peter Steinberger
2026-07-22 06:47:57 -07:00
committed by GitHub
parent 685fb849bb
commit 1a121da22c
8 changed files with 426 additions and 43 deletions
+6 -2
View File
@@ -77,8 +77,12 @@ not overwrite the existing skill.
- `--classic`: opens the full step-by-step wizard. It cannot be combined with
`--non-interactive`; omit `--classic` for automated setup.
- `--flow quickstart`: opens the classic wizard with minimal prompts and
auto-generates a gateway token.
- `--flow quickstart`: opens the classic wizard with minimal prompts, uses
token auth by default, and generates a token when no stored or explicit
credential applies. Explicit local Gateway flags such as
`--gateway-port`, `--gateway-bind`, `--gateway-auth`, and `--tailscale`
override the corresponding stored or default quickstart values; omitted
options keep their current values.
- `--flow manual` (alias `advanced`): opens the classic wizard with full prompts
for port, bind, and auth.
- `--flow import`: runs a detected migration provider (for example Hermes via `--import-from hermes`), previews the plan, then applies after confirmation. When an interactive import supplies a default model, onboarding requires that route to pass a live completion before it skips provider setup; a failed imported route returns to provider configuration. Import only runs against a fresh OpenClaw setup - reset config, credentials, sessions, and workspace state first if any exist. Use [`openclaw migrate`](/cli/migrate) for dry-run plans, overwrite mode, reports, and exact mappings.
+47
View File
@@ -458,6 +458,53 @@ describe("setupWizardCommand", () => {
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates gateway token env refs before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
gatewayTokenRefEnv: "MISSING_GATEWAY_TOKEN_ENV",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining('Environment variable "MISSING_GATEWAY_TOKEN_ENV" is missing'),
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
});
it("rejects conflicting gateway token inputs before reset", async () => {
const previous = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token";
const runtime = makeRuntime();
try {
await setupWizardCommand(
{
reset: true,
gatewayToken: "plaintext-token",
gatewayTokenRefEnv: "OPENCLAW_GATEWAY_TOKEN",
},
runtime,
);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = previous;
}
}
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("Use either --gateway-token or --gateway-token-ref-env"),
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
});
it("validates dependent auth-choice options before reset", async () => {
const runtime = makeRuntime();
+22
View File
@@ -8,6 +8,7 @@ import { formatCliCommand } from "../cli/command-format.js";
import { formatInvalidPortOption } from "../cli/error-format.js";
import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isValidEnvSecretRefId } from "../config/types.secrets.js";
import { formatErrorMessage } from "../infra/errors.js";
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { resolveProviderMatch } from "../plugins/provider-auth-choice-helpers.js";
@@ -102,6 +103,27 @@ function validatePreflightOptions(opts: OnboardOptions, runtime: RuntimeEnv): bo
) {
return rejectOption(runtime, formatInvalidPortOption("--gateway-port"));
}
if (opts.gatewayTokenRefEnv !== undefined) {
const gatewayTokenRefEnv = opts.gatewayTokenRefEnv.trim();
if (!isValidEnvSecretRefId(gatewayTokenRefEnv)) {
return rejectOption(
runtime,
"Invalid --gateway-token-ref-env. Use an environment variable name like OPENCLAW_GATEWAY_TOKEN.",
);
}
if (opts.gatewayToken !== undefined) {
return rejectOption(
runtime,
"Use either --gateway-token or --gateway-token-ref-env, not both. Prefer --gateway-token-ref-env to avoid writing plaintext tokens.",
);
}
if (!process.env[gatewayTokenRefEnv]?.trim()) {
return rejectOption(
runtime,
`Environment variable "${gatewayTokenRefEnv}" is missing or empty. Export it first, then rerun ${formatCliCommand("openclaw onboard")}.`,
);
}
}
if (opts.nonInteractive && opts.mode === "remote" && !opts.remoteUrl?.trim()) {
return rejectOption(
runtime,
+75
View File
@@ -25,6 +25,7 @@ vi.mock("../infra/tailscale.js", () => ({
}));
import { configureGatewayForSetup } from "./setup.gateway-config.js";
import { resolveQuickstartGatewayDefaults } from "./setup.shared.js";
describe("configureGatewayForSetup", () => {
function createPrompter(params: { selectQueue: string[]; textQueue: Array<string | undefined> }) {
@@ -341,4 +342,78 @@ describe("configureGatewayForSetup", () => {
expect(result.nextConfig.gateway?.auth?.token).toEqual(quickstartGateway.token);
expect(result.settings.gatewayToken).toBe("token-from-exec");
});
it("persists an explicit classic quickstart env token ref", async () => {
const previous = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = "token-from-env-ref";
try {
const quickstartGateway = resolveQuickstartGatewayDefaults(
{},
{ gatewayTokenRefEnv: "OPENCLAW_GATEWAY_TOKEN" },
);
const result = await configureGatewayForSetup({
flow: "quickstart",
baseConfig: {},
nextConfig: {},
localPort: 18789,
quickstartGateway,
prompter: createPrompter({ selectQueue: [], textQueue: [] }),
runtime: createRuntime(),
});
expect(result.nextConfig.gateway?.auth).toEqual({
mode: "token",
token: {
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
},
});
expect(result.settings.gatewayToken).toBe("token-from-env-ref");
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = previous;
}
}
});
it("persists classic quickstart overrides through gateway safety normalization", async () => {
const password = ["classic", "gateway", "placeholder"].join("-");
mocks.getTailnetHostname.mockResolvedValue("test-tailnet.ts.net");
const note = vi.fn(async () => {});
const prompter = buildWizardPrompter({ note });
const quickstartGateway = resolveQuickstartGatewayDefaults(
{},
{
gatewayPort: 19001,
gatewayBind: "lan",
gatewayAuth: "token",
gatewayToken: "unused-token",
gatewayPassword: password,
tailscale: "funnel",
tailscaleResetOnExit: true,
},
);
const result = await configureGatewayForSetup({
flow: "quickstart",
baseConfig: {},
nextConfig: {},
localPort: 18789,
quickstartGateway,
prompter,
runtime: createRuntime(),
});
expect(result.nextConfig.gateway).toMatchObject({
port: 19001,
bind: "loopback",
auth: { mode: "password", password },
tailscale: { mode: "funnel", resetOnExit: true },
});
expect(result.nextConfig.gateway?.auth?.token).toBeUndefined();
expect(JSON.stringify(note.mock.calls)).not.toContain(password);
});
});
+99 -1
View File
@@ -17,7 +17,105 @@ vi.mock("../plugins/install-record-commit.js", async (importOriginal) => ({
commitConfigWriteWithPendingPluginInstalls: mocks.commitConfigWriteWithPendingPluginInstalls,
}));
import { writeWizardConfigFile } from "./setup.shared.js";
import { resolveQuickstartGatewayDefaults, writeWizardConfigFile } from "./setup.shared.js";
describe("resolveQuickstartGatewayDefaults", () => {
const storedConfig: OpenClawConfig = {
gateway: {
port: 19111,
bind: "custom",
customBindHost: "192.0.2.10",
auth: {
mode: "token",
token: "stored-token",
password: "stored-password",
},
tailscale: {
mode: "serve",
resetOnExit: true,
},
},
};
it("overlays every explicitly supplied classic quickstart gateway option", () => {
const result = resolveQuickstartGatewayDefaults(storedConfig, {
gatewayPort: 19001,
gatewayBind: "lan",
gatewayAuth: "password",
gatewayToken: "explicit-token",
gatewayPassword: "explicit-password",
tailscale: "off",
tailscaleResetOnExit: false,
});
expect(result).toEqual({
hasExisting: true,
port: 19001,
bind: "lan",
authMode: "password",
tailscaleMode: "off",
token: "explicit-token",
password: "explicit-password",
customBindHost: "192.0.2.10",
tailscaleResetOnExit: false,
});
});
it("preserves stored quickstart defaults when no override is defined", () => {
expect(resolveQuickstartGatewayDefaults(storedConfig)).toEqual({
hasExisting: true,
port: 19111,
bind: "custom",
authMode: "token",
tailscaleMode: "serve",
token: "stored-token",
password: "stored-password",
customBindHost: "192.0.2.10",
tailscaleResetOnExit: true,
});
});
it("aligns credential-only overrides while keeping an explicit auth mode authoritative", () => {
expect(
resolveQuickstartGatewayDefaults(storedConfig, {
gatewayPassword: "explicit-password",
}).authMode,
).toBe("password");
expect(
resolveQuickstartGatewayDefaults(
{ gateway: { auth: { mode: "password", password: "stored-password" } } },
{ gatewayToken: "explicit-token" },
).authMode,
).toBe("token");
expect(
resolveQuickstartGatewayDefaults(storedConfig, {
gatewayAuth: "password",
gatewayToken: "explicit-token",
}).authMode,
).toBe("password");
expect(
resolveQuickstartGatewayDefaults(storedConfig, {
gatewayAuth: "token",
gatewayPassword: "explicit-password",
}).authMode,
).toBe("token");
});
it("maps an explicit env-backed token to the canonical SecretRef", () => {
expect(
resolveQuickstartGatewayDefaults(storedConfig, {
gatewayTokenRefEnv: " OPENCLAW_GATEWAY_TOKEN ",
}),
).toMatchObject({
authMode: "token",
token: {
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
},
});
});
});
describe("writeWizardConfigFile pending install ownership", () => {
beforeEach(() => {
+54 -7
View File
@@ -9,6 +9,7 @@ import {
stripPendingPluginInstallRecords,
unchangedPendingPluginInstallRecordIds,
} from "../plugins/install-record-commit.js";
import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
import { isPlainObject } from "../utils.js";
import { t } from "./i18n/index.js";
import { WizardCancelledError, type WizardPrompter } from "./prompts.js";
@@ -19,6 +20,33 @@ import {
} from "./setup.security-note.js";
import type { QuickstartGatewayDefaults } from "./setup.types.js";
type QuickstartGatewayOptionOverrides = Pick<
OnboardOptions,
| "gatewayPort"
| "gatewayBind"
| "gatewayAuth"
| "gatewayToken"
| "gatewayTokenRefEnv"
| "gatewayPassword"
| "tailscale"
| "tailscaleResetOnExit"
>;
export function hasQuickstartGatewayOverrides(
overrides: QuickstartGatewayOptionOverrides,
): boolean {
return (
overrides.gatewayPort !== undefined ||
overrides.gatewayBind !== undefined ||
overrides.gatewayAuth !== undefined ||
overrides.gatewayToken !== undefined ||
overrides.gatewayTokenRefEnv !== undefined ||
overrides.gatewayPassword !== undefined ||
overrides.tailscale !== undefined ||
overrides.tailscaleResetOnExit !== undefined
);
}
function mergeWizardConfigValueOntoLatest(current: unknown, base: unknown, next: unknown): unknown {
if (isDeepStrictEqual(next, base)) {
return current;
@@ -171,6 +199,7 @@ function applySecurityAcknowledgement(config: OpenClawConfig): OpenClawConfig {
/** Derive quickstart gateway defaults, preserving any existing gateway settings. */
export function resolveQuickstartGatewayDefaults(
baseConfig: OpenClawConfig,
overrides: QuickstartGatewayOptionOverrides = {},
): QuickstartGatewayDefaults {
const hasExisting =
typeof baseConfig.gateway?.port === "number" ||
@@ -206,15 +235,33 @@ export function resolveQuickstartGatewayDefaults(
? tailscaleRaw
: "off";
const explicitAuthMode =
overrides.gatewayAuth ??
(overrides.gatewayToken !== undefined || overrides.gatewayTokenRefEnv !== undefined
? "token"
: overrides.gatewayPassword !== undefined
? "password"
: undefined);
return {
hasExisting,
port: resolveGatewayPort(baseConfig),
bind,
authMode,
tailscaleMode,
token: baseConfig.gateway?.auth?.token,
password: baseConfig.gateway?.auth?.password,
port: overrides.gatewayPort ?? resolveGatewayPort(baseConfig),
bind: overrides.gatewayBind ?? bind,
authMode: explicitAuthMode ?? authMode,
tailscaleMode: overrides.tailscale ?? tailscaleMode,
token:
overrides.gatewayTokenRefEnv !== undefined
? {
source: "env",
provider: resolveDefaultSecretProviderAlias(baseConfig, "env", {
preferFirstProviderForSource: true,
}),
id: overrides.gatewayTokenRefEnv.trim(),
}
: (overrides.gatewayToken ?? baseConfig.gateway?.auth?.token),
password: overrides.gatewayPassword ?? baseConfig.gateway?.auth?.password,
customBindHost: baseConfig.gateway?.customBindHost,
tailscaleResetOnExit: baseConfig.gateway?.tailscale?.resetOnExit ?? false,
tailscaleResetOnExit:
overrides.tailscaleResetOnExit ?? baseConfig.gateway?.tailscale?.resetOnExit ?? false,
};
}
+95 -1
View File
@@ -31,6 +31,7 @@ type ApplyAuthChoice = typeof import("../commands/auth-choice.js").applyAuthChoi
type PrepareAuthChoice = typeof import("../commands/auth-choice.js").prepareAuthChoice;
type VerifySetupInferenceConfig =
typeof import("../system-agent/setup-inference.js").verifySetupInferenceConfig;
type ConfigureGatewayForSetup = typeof import("./setup.gateway-config.js").configureGatewayForSetup;
const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ profiles: {} })));
const keepCurrentAuthChoice = vi.hoisted(() => "__keep-current" as const);
@@ -63,7 +64,7 @@ const applyPrimaryModel = vi.hoisted(() => vi.fn((cfg) => cfg));
const promptDefaultModel = vi.hoisted(() => vi.fn<PromptDefaultModel>(async () => ({})));
const promptCustomApiConfig = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config })));
const configureGatewayForSetup = vi.hoisted(() =>
vi.fn(async (args) => ({
vi.fn<ConfigureGatewayForSetup>(async (args) => ({
nextConfig: args.nextConfig,
settings: {
port: args.localPort ?? 18789,
@@ -2104,6 +2105,99 @@ describe("runSetupWizard", () => {
);
});
it("persists explicit classic quickstart gateway options without printing the password", async () => {
const password = ["classic", "password", "placeholder"].join("-");
const note: WizardPrompter["note"] = vi.fn(async () => {});
const prompter = buildWizardPrompter({ note });
const runtime = createRuntime();
readConfigFileSnapshot.mockResolvedValueOnce(
configSnapshot({
gateway: {
port: 19111,
bind: "loopback",
auth: { mode: "token", token: "stored-token" },
tailscale: { mode: "off", resetOnExit: false },
},
}),
);
replaceConfigFile.mockClear();
configureGatewayForSetup.mockImplementationOnce(async (args) => ({
nextConfig: {
...args.nextConfig,
gateway: {
...args.nextConfig.gateway,
port: args.quickstartGateway.port,
bind: args.quickstartGateway.bind,
auth: {
...args.nextConfig.gateway?.auth,
mode: args.quickstartGateway.authMode,
password: args.quickstartGateway.password,
},
tailscale: {
...args.nextConfig.gateway?.tailscale,
mode: args.quickstartGateway.tailscaleMode,
resetOnExit: args.quickstartGateway.tailscaleResetOnExit,
},
},
},
settings: {
port: args.quickstartGateway.port,
bind: args.quickstartGateway.bind,
authMode: args.quickstartGateway.authMode,
gatewayToken: undefined,
tailscaleMode: args.quickstartGateway.tailscaleMode,
tailscaleResetOnExit: args.quickstartGateway.tailscaleResetOnExit,
},
}));
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
mode: "local",
authChoice: "skip",
gatewayPort: 19001,
gatewayBind: "lan",
gatewayAuth: "password",
gatewayPassword: password,
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
const gatewaySetup = getMockCallArg(configureGatewayForSetup, 0, 0, "gateway setup");
expect(requireRecord(gatewaySetup, "gateway setup").quickstartGateway).toMatchObject({
port: 19001,
bind: "lan",
authMode: "password",
password,
});
expect(
persistedWizardConfigs().some(
(config) =>
config.gateway?.port === 19001 &&
config.gateway.bind === "lan" &&
config.gateway.auth?.mode === "password" &&
config.gateway.auth.password === password,
),
).toBe(true);
const visibleOutput = [
...getWizardNoteCalls(note).flat(),
...((runtime.log as unknown as ReturnType<typeof vi.fn>).mock.calls.flat() as unknown[]),
...((runtime.error as unknown as ReturnType<typeof vi.fn>).mock.calls.flat() as unknown[]),
].join("\n");
expect(visibleOutput).toContain("19001");
expect(visibleOutput).not.toContain("Keeping your current gateway settings:");
expect(visibleOutput).not.toContain(password);
});
it("shows the resolved gateway port in quickstart for fresh envs", async () => {
const previousPort = process.env.OPENCLAW_GATEWAY_PORT;
process.env.OPENCLAW_GATEWAY_PORT = "18791";
+28 -32
View File
@@ -26,6 +26,7 @@ import {
import { runSetupModelAuthStep, type SetupModelAuthCandidate } from "./setup.model-auth.js";
import { resolveSetupSecretInputString } from "./setup.secret-input.js";
import {
hasQuickstartGatewayOverrides,
readSetupConfigFileSnapshot,
readValidSetupConfigFile,
requireRiskAcknowledgement,
@@ -343,8 +344,13 @@ async function runSetupWizardOnce(
flow = "quickstart";
}
const wizardFlow: WizardFlow = flow === "advanced" ? "advanced" : "quickstart";
const hasExplicitQuickstartGatewayOverrides =
wizardFlow === "quickstart" && hasQuickstartGatewayOverrides(opts);
const quickstartGateway: QuickstartGatewayDefaults = resolveQuickstartGatewayDefaults(baseConfig);
const quickstartGateway: QuickstartGatewayDefaults = resolveQuickstartGatewayDefaults(
baseConfig,
wizardFlow === "quickstart" ? opts : undefined,
);
if (flow === "quickstart") {
const formatBind = (value: "loopback" | "lan" | "auto" | "custom" | "tailnet") => {
@@ -371,37 +377,27 @@ async function runSetupWizardOnce(
const formatTailscale = (value: "off" | "serve" | "funnel") => {
return t(`wizard.gatewayTailscale.${value}`);
};
const quickstartLines = quickstartGateway.hasExisting
? [
t("wizard.setup.quickstartKeepSettings"),
t("wizard.setup.quickstartGatewayPort", { port: quickstartGateway.port }),
t("wizard.setup.quickstartGatewayBind", { bind: formatBind(quickstartGateway.bind) }),
...(quickstartGateway.bind === "custom" && quickstartGateway.customBindHost
? [
t("wizard.setup.quickstartGatewayCustomIp", {
host: quickstartGateway.customBindHost,
}),
]
: []),
t("wizard.setup.quickstartGatewayAuth", {
auth: formatAuth(quickstartGateway.authMode),
}),
t("wizard.setup.quickstartTailscaleExposure", {
exposure: formatTailscale(quickstartGateway.tailscaleMode),
}),
t("wizard.setup.quickstartDirectChannels"),
]
: [
t("wizard.setup.quickstartGatewayPort", { port: quickstartGateway.port }),
t("wizard.setup.quickstartGatewayBind", { bind: t("wizard.gateway.bindLoopback") }),
t("wizard.setup.quickstartGatewayAuth", {
auth: t("wizard.setup.quickstartAuthTokenDefault"),
}),
t("wizard.setup.quickstartTailscaleExposure", {
exposure: t("wizard.gatewayTailscale.off"),
}),
t("wizard.setup.quickstartDirectChannels"),
];
const quickstartLines = [
...(quickstartGateway.hasExisting && !hasExplicitQuickstartGatewayOverrides
? [t("wizard.setup.quickstartKeepSettings")]
: []),
t("wizard.setup.quickstartGatewayPort", { port: quickstartGateway.port }),
t("wizard.setup.quickstartGatewayBind", { bind: formatBind(quickstartGateway.bind) }),
...(quickstartGateway.bind === "custom" && quickstartGateway.customBindHost
? [
t("wizard.setup.quickstartGatewayCustomIp", {
host: quickstartGateway.customBindHost,
}),
]
: []),
t("wizard.setup.quickstartGatewayAuth", {
auth: formatAuth(quickstartGateway.authMode),
}),
t("wizard.setup.quickstartTailscaleExposure", {
exposure: formatTailscale(quickstartGateway.tailscaleMode),
}),
t("wizard.setup.quickstartDirectChannels"),
];
await prompter.note(quickstartLines.join("\n"), "QuickStart");
}