diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 35f277fd9156..e2eb71a60033 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -83,6 +83,7 @@ not overwrite the existing skill. 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. 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. - `--remote-url` and `--remote-token`: prefill the classic remote Gateway step and override stored remote values for this run. Changing the URL does not reuse stored credentials unless you also pass a token. The token stays masked in prompts and follows the wizard's existing plaintext or SecretRef storage choice. +- `--tailscale-reset-on-exit` and `--no-tailscale-reset-on-exit`: explicitly control whether Tailscale Serve or Funnel configuration is reset when the Gateway exits. Omitting both preserves the current setting during non-interactive reruns. - `--modern` is a compatibility alias for the OpenClaw conversational setup assistant. It uses the same live-inference gate as `openclaw setup` and accepts only `--workspace`, `--accept-risk`, diff --git a/src/cli/program/register.onboard.test.ts b/src/cli/program/register.onboard.test.ts index 0285db1163fc..8a8861b1c915 100644 --- a/src/cli/program/register.onboard.test.ts +++ b/src/cli/program/register.onboard.test.ts @@ -133,6 +133,7 @@ describe("registerOnboardCommand", () => { await runCli(["onboard"]); expect(setupWizardOptions().installDaemon).toBeUndefined(); + expect(setupWizardOptions().tailscaleResetOnExit).toBeUndefined(); }); it("sets installDaemon from explicit install flags and prioritizes --skip-daemon", async () => { @@ -172,6 +173,16 @@ describe("registerOnboardCommand", () => { expect(setupWizardOptions().skipBootstrap).toBe(true); }); + it("forwards explicit --tailscale-reset-on-exit", async () => { + await runCli(["onboard", "--tailscale-reset-on-exit"]); + expect(setupWizardOptions().tailscaleResetOnExit).toBe(true); + }); + + it("forwards explicit --no-tailscale-reset-on-exit", async () => { + await runCli(["onboard", "--no-tailscale-reset-on-exit"]); + expect(setupWizardOptions().tailscaleResetOnExit).toBe(false); + }); + it("forwards remote seed flags to setup wizard options", async () => { const remoteToken = ["fixture", "value"].join("-"); await runCli([ diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 058eb0df74a6..b6a1c3b3537b 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -32,6 +32,13 @@ export function resolveInstallDaemonFlag(command: Command): boolean | undefined return undefined; } +export function resolveTailscaleResetOnExitFlag(command: Command): boolean | undefined { + if (command.getOptionValueSource("tailscaleResetOnExit") !== "cli") { + return undefined; + } + return Boolean(command.getOptionValue("tailscaleResetOnExit")); +} + const MODERN_ONBOARD_OPTION_KEYS = new Set([ "modern", "workspace", @@ -218,6 +225,7 @@ export function registerOnboardCommand(program: Command): void { .option("--remote-token ", "Remote Gateway token (optional)") .option("--tailscale ", "Tailscale: off|serve|funnel") .option("--tailscale-reset-on-exit", "Reset tailscale serve/funnel on exit") + .option("--no-tailscale-reset-on-exit", "Keep tailscale serve/funnel after exit") .option("--install-daemon", "Install gateway service") .option("--no-install-daemon", "Skip gateway service install") .option("--skip-daemon", "Skip gateway service install") @@ -324,6 +332,7 @@ export function registerOnboardCommand(program: Command): void { return; } const installDaemon = resolveInstallDaemonFlag(commandRuntime); + const tailscaleResetOnExit = resolveTailscaleResetOnExitFlag(commandRuntime); const gatewayPort = parsePort(opts.gatewayPort); const { setupWizardCommand } = await import("../../commands/onboard.js"); await setupWizardCommand( @@ -345,7 +354,7 @@ export function registerOnboardCommand(program: Command): void { remoteUrl: opts.remoteUrl as string | undefined, remoteToken: opts.remoteToken as string | undefined, tailscale: opts.tailscale as TailscaleMode | undefined, - tailscaleResetOnExit: Boolean(opts.tailscaleResetOnExit), + tailscaleResetOnExit, reset: Boolean(opts.reset), resetScope: opts.resetScope as ResetScope | undefined, installDaemon, diff --git a/src/cli/program/register.setup.test.ts b/src/cli/program/register.setup.test.ts index 5fa19a9be995..44bb8893ad0b 100644 --- a/src/cli/program/register.setup.test.ts +++ b/src/cli/program/register.setup.test.ts @@ -161,9 +161,16 @@ describe("registerSetupCommand", () => { expect(setupWizardCommandMock).toHaveBeenCalledWith(lastWizardOptions(), runtime); expect(lastWizardOptions()?.workspace).toBe("/tmp/ws"); + expect(lastWizardOptions()?.tailscaleResetOnExit).toBeUndefined(); expect(setupCommandMock).not.toHaveBeenCalled(); }); + it("forwards explicit --no-tailscale-reset-on-exit", async () => { + await runCli(["setup", "--no-tailscale-reset-on-exit"]); + + expect(lastWizardOptions()?.tailscaleResetOnExit).toBe(false); + }); + it("runs baseline setup command when --baseline is set", async () => { await runCli(["setup", "--baseline", "--workspace", "/tmp/ws"]); @@ -219,6 +226,7 @@ describe("registerSetupCommand", () => { "--skip-search", "--skip-skills", "--skip-bootstrap", + "--tailscale-reset-on-exit", "--node-manager", "pnpm", "--json", @@ -237,6 +245,7 @@ describe("registerSetupCommand", () => { skipSearch: true, skipSkills: true, skipBootstrap: true, + tailscaleResetOnExit: true, nodeManager: "pnpm", json: true, }); diff --git a/src/cli/program/register.setup.ts b/src/cli/program/register.setup.ts index a41a46389345..fe2f324501fb 100644 --- a/src/cli/program/register.setup.ts +++ b/src/cli/program/register.setup.ts @@ -19,6 +19,7 @@ import { pickOnboardAuthOptionValues, registerOnboardAuthOptions, resolveInstallDaemonFlag, + resolveTailscaleResetOnExitFlag, } from "./register.onboard.js"; const SYSTEM_AGENT_OPTION_NAMES = new Set(["message", "yes", "json"]); @@ -93,6 +94,7 @@ async function runOnboardingEntry( return; } const installDaemon = resolveInstallDaemonFlag(commandRuntime); + const tailscaleResetOnExit = resolveTailscaleResetOnExitFlag(commandRuntime); const gatewayPort = parsePort(options.gatewayPort); const { setupWizardCommand } = await import("../../commands/onboard.js"); await setupWizardCommand( @@ -113,7 +115,7 @@ async function runOnboardingEntry( gatewayTokenRefEnv: optionalString(options.gatewayTokenRefEnv), gatewayPassword: optionalString(options.gatewayPassword), tailscale: options.tailscale as TailscaleMode | undefined, - tailscaleResetOnExit: Boolean(options.tailscaleResetOnExit), + tailscaleResetOnExit, installDaemon, daemonRuntime: options.daemonRuntime as GatewayDaemonRuntime | undefined, skipChannels: Boolean(options.skipChannels), @@ -199,6 +201,7 @@ export function registerSetupCommand(program: Command): void { .option("--gateway-password ", "Gateway password (password auth)") .option("--tailscale ", "Tailscale: off|serve|funnel") .option("--tailscale-reset-on-exit", "Reset tailscale serve/funnel on exit") + .option("--no-tailscale-reset-on-exit", "Keep tailscale serve/funnel after exit") .option("--install-daemon", "Install gateway service") .option("--no-install-daemon", "Skip gateway service install") .option("--skip-daemon", "Skip gateway service install") diff --git a/src/commands/onboard-non-interactive.gateway-health-auth.test.ts b/src/commands/onboard-non-interactive.gateway-health-auth.test.ts index 8563e1df1d3f..8f6b6ab638c2 100644 --- a/src/commands/onboard-non-interactive.gateway-health-auth.test.ts +++ b/src/commands/onboard-non-interactive.gateway-health-auth.test.ts @@ -121,4 +121,14 @@ describe("resolveGatewayHealthProbeToken", () => { expect(resolved).toEqual({ password: "resolved-password" }); }); + + it("resolves environment-only password auth for the local onboarding health probe", async () => { + process.env.OPENCLAW_GATEWAY_PASSWORD = "environment-password"; // pragma: allowlist secret + + const resolved = await resolveGatewayHealthProbeToken({ + gateway: { auth: { mode: "password" } }, + } as OpenClawConfig); + + expect(resolved).toEqual({ password: "environment-password" }); + }); }); diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index af4be84ba67d..8a41a7735cdf 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -404,9 +404,10 @@ describe("onboard (non-interactive): gateway and remote auth", () => { readLastGatewayErrorLineMock.mockClear(); }); - it("preserves existing agents.list and bindings on onboard rerun (openclaw#84692)", async () => { + it("preserves existing config on onboard rerun (openclaw#84692)", async () => { await withStateDir("state-preserve-agents-", async (stateDir) => { const workspace = path.join(stateDir, "openclaw"); + const passwordRef = { source: "env" as const, provider: "default", id: "GATEWAY_PASSWORD" }; const seededAgents = [ { id: "alpha", model: "anthropic/claude-3-5-sonnet" }, { id: "beta", model: "openai/gpt-4o" }, @@ -432,7 +433,13 @@ describe("onboard (non-interactive): gateway and remote auth", () => { testConfigStore.set(resolveTestConfigPath(), { agents: { list: seededAgents, defaults: { workspace } }, bindings: seededBindings, - gateway: { mode: "local", port: 18789, auth: { mode: "token", token: "seed_tok" } }, + gateway: { + mode: "local", + port: 24680, + bind: "loopback", + auth: { mode: "password", password: passwordRef }, + tailscale: { mode: "serve", resetOnExit: true }, + }, } as OpenClawConfig); await runNonInteractiveSetup( @@ -444,9 +451,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => { skipSkills: true, skipHealth: true, installDaemon: false, - gatewayBind: "loopback", - gatewayAuth: "token", - gatewayToken: "seed_tok", }, runtime, ); @@ -454,6 +458,13 @@ describe("onboard (non-interactive): gateway and remote auth", () => { const cfg = readTestConfig(); expect(cfg.agents?.list?.map((a) => a.id)).toEqual(["alpha", "beta"]); expect(cfg.bindings).toEqual(seededBindings); + expect(cfg.gateway).toEqual({ + mode: "local", + port: 24680, + bind: "loopback", + auth: { mode: "password", password: passwordRef }, + tailscale: { mode: "serve", resetOnExit: true }, + }); const onboardWrite = capturedReplaceConfigFileCalls.at(-1); expect(onboardWrite?.writeOptions?.allowConfigSizeDrop).toBe(false); @@ -506,6 +517,13 @@ describe("onboard (non-interactive): gateway and remote auth", () => { await withStateDir("state-noninteractive-", async (stateDir) => { const token = "tok_test_123"; const workspace = path.join(stateDir, "openclaw"); + testConfigStore.set(resolveTestConfigPath(), { + gateway: { + bind: "lan", + auth: { mode: "password", password: "test-password" }, + tailscale: { mode: "serve", resetOnExit: true }, + }, + } as OpenClawConfig); await runNonInteractiveSetup( { @@ -519,12 +537,19 @@ describe("onboard (non-interactive): gateway and remote auth", () => { gatewayBind: "loopback", gatewayAuth: "token", gatewayToken: token, + tailscale: "off", + tailscaleResetOnExit: false, }, runtime, ); const cfg = readTestConfig<{ - gateway?: { mode?: string; auth?: { mode?: string; token?: string } }; + gateway?: { + mode?: string; + bind?: string; + auth?: { mode?: string; token?: string }; + tailscale?: { mode?: string; resetOnExit?: boolean }; + }; agents?: { defaults?: { workspace?: string } }; tools?: { profile?: string }; hooks?: { internal?: { entries?: Record } }; @@ -532,9 +557,11 @@ describe("onboard (non-interactive): gateway and remote auth", () => { expect(cfg?.agents?.defaults?.workspace).toBe(workspace); expect(cfg?.gateway?.mode).toBe("local"); + expect(cfg?.gateway?.bind).toBe("loopback"); expect(cfg?.tools?.profile).toBe("coding"); expect(cfg?.gateway?.auth?.mode).toBe("token"); expect(cfg?.gateway?.auth?.token).toBe(token); + expect(cfg?.gateway?.tailscale).toEqual({ mode: "off", resetOnExit: false }); expect(cfg?.hooks?.internal?.entries?.["session-memory"]).toEqual({ enabled: true }); }); }, 60_000); @@ -542,6 +569,9 @@ describe("onboard (non-interactive): gateway and remote auth", () => { it("does not auto-enable default hooks when skipHooks is set", async () => { await withStateDir("state-skip-hooks-", async (stateDir) => { const workspace = path.join(stateDir, "openclaw"); + testConfigStore.set(resolveTestConfigPath(), { + gateway: { mode: "local", bind: "lan" }, + } as OpenClawConfig); await runNonInteractiveSetup( { @@ -553,13 +583,13 @@ describe("onboard (non-interactive): gateway and remote auth", () => { skipSkills: true, skipHealth: true, installDaemon: false, - gatewayBind: "loopback", }, runtime, ); const cfg = readTestConfig(); expect(cfg.hooks).toBeUndefined(); + expect(cfg.gateway?.bind).toBe("lan"); }); }, 60_000); @@ -678,6 +708,21 @@ describe("onboard (non-interactive): gateway and remote auth", () => { await withStateDir("state-remote-", async (_stateDir) => { const port = getPseudoPort(30_000); const token = "tok_remote_123"; + testConfigStore.set(resolveTestConfigPath(), { + gateway: { + remote: { + url: "wss://old.example.test", + transport: "ssh", + remotePort: 24680, + sshTarget: "operator@old.example.test", + sshIdentity: "/tmp/old-identity", + sshHostKeyPolicy: "openssh", + token: "test-token", + password: { source: "env", provider: "default", id: "REMOTE_PASSWORD" }, + tlsFingerprint: "sha256:test-fingerprint", + }, + }, + } as OpenClawConfig); await runNonInteractiveSetup( { nonInteractive: true, @@ -690,14 +735,13 @@ describe("onboard (non-interactive): gateway and remote auth", () => { runtime, ); - const cfg = readTestConfig<{ - gateway?: { mode?: string; remote?: { url?: string; token?: string } }; - hooks?: { internal?: { entries?: Record } }; - }>(); + const cfg = readTestConfig(); expect(cfg.gateway?.mode).toBe("remote"); - expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`); - expect(cfg.gateway?.remote?.token).toBe(token); + expect(cfg.gateway?.remote).toEqual({ + url: `ws://127.0.0.1:${port}`, + token, + }); expect(cfg.hooks?.internal?.entries?.["session-memory"]).toEqual({ enabled: true }); }); }, 60_000); @@ -705,7 +749,12 @@ describe("onboard (non-interactive): gateway and remote auth", () => { it("preserves existing agents.list and bindings on remote onboard rerun (openclaw#84692)", async () => { await withStateDir("state-remote-preserve-agents-", async (_stateDir) => { const port = getPseudoPort(30_000); - const token = "tok_remote_seed"; + const passwordRef = { + source: "env" as const, + provider: "default", + id: "OPENCLAW_REMOTE_GATEWAY_PASSWORD", + }; + const tokenRef = { source: "env" as const, provider: "default", id: "REMOTE_TOKEN" }; const seededAgents = [ { id: "alpha", model: "anthropic/claude-3-5-sonnet" }, { id: "beta", model: "openai/gpt-4o" }, @@ -725,7 +774,12 @@ describe("onboard (non-interactive): gateway and remote auth", () => { bindings: seededBindings, gateway: { mode: "remote", - remote: { url: `ws://127.0.0.1:${port}`, token }, + remote: { + url: `ws://127.0.0.1:${port}`, + token: tokenRef, + password: passwordRef, + tlsFingerprint: "sha256:test-fingerprint", + }, }, } as OpenClawConfig); @@ -734,7 +788,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => { nonInteractive: true, mode: "remote", remoteUrl: `ws://127.0.0.1:${port}`, - remoteToken: token, authChoice: "skip", json: true, }, @@ -744,6 +797,12 @@ describe("onboard (non-interactive): gateway and remote auth", () => { const cfg = readTestConfig(); expect(cfg.agents?.list?.map((a) => a.id)).toEqual(["alpha", "beta"]); expect(cfg.bindings).toEqual(seededBindings); + expect(cfg.gateway?.remote).toEqual({ + url: `ws://127.0.0.1:${port}`, + token: tokenRef, + password: passwordRef, + tlsFingerprint: "sha256:test-fingerprint", + }); const remoteWrite = capturedReplaceConfigFileCalls.at(-1); expect(remoteWrite?.writeOptions?.allowConfigSizeDrop).toBe(false); diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 08e429cb8de5..2943eaaeac5b 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -9,7 +9,7 @@ import { resolveGatewayPort } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveGatewayAuthToken } from "../../gateway/auth-token-resolution.js"; -import { resolveConfiguredSecretInputString } from "../../gateway/resolve-configured-secret-input-string.js"; +import { resolveConfiguredSecretInputWithFallback } from "../../gateway/resolve-configured-secret-input-string.js"; import type { RuntimeEnv } from "../../runtime.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../daemon-runtime.js"; import { applyLocalSetupWorkspaceConfig, applySkipBootstrapConfig } from "../onboard-config.js"; @@ -110,12 +110,13 @@ async function resolveGatewayHealthProbeToken( if (nextConfig.gateway?.auth?.mode === "password") { // Password mode uses the configured password directly; token fallback must // stay disabled or the probe can validate the wrong auth mode. - const resolved = await resolveConfiguredSecretInputString({ + const resolved = await resolveConfiguredSecretInputWithFallback({ config: nextConfig, env: process.env, value: nextConfig.gateway.auth.password, path: "gateway.auth.password", unresolvedReasonStyle: "detailed", + readFallback: () => process.env.OPENCLAW_GATEWAY_PASSWORD, }); return { password: resolved.value, diff --git a/src/commands/onboard-non-interactive/local/gateway-config.test.ts b/src/commands/onboard-non-interactive/local/gateway-config.test.ts index 84db50d98696..5d19413173bc 100644 --- a/src/commands/onboard-non-interactive/local/gateway-config.test.ts +++ b/src/commands/onboard-non-interactive/local/gateway-config.test.ts @@ -55,6 +55,7 @@ function applyGatewayConfig({ return withEnv( { OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_GATEWAY_PASSWORD: undefined, [SAMPLE_SECRET_REF.id]: undefined, ...env, }, @@ -69,7 +70,7 @@ function applyGatewayConfig({ ); } -describe("applyNonInteractiveGatewayConfig token resolution chain", () => { +describe("applyNonInteractiveGatewayConfig auth resolution", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -111,6 +112,32 @@ describe("applyNonInteractiveGatewayConfig token resolution chain", () => { expect(randomToken).not.toHaveBeenCalled(); }); + it("selects token auth when --gateway-token overrides a no-auth config", () => { + const result = applyGatewayConfig({ + nextConfig: { gateway: { auth: { mode: "none" } } }, + opts: { gatewayToken: "flag-token" } as OnboardOptions, + }); + + expect(result?.nextConfig.gateway?.auth).toEqual({ mode: "token", token: "flag-token" }); + }); + + it("keeps password auth when a token-only rerun targets an existing Funnel", () => { + const result = applyGatewayConfig({ + nextConfig: { + gateway: { + auth: { mode: "password", password: "test-password" }, + tailscale: { mode: "funnel" }, + }, + }, + opts: { gatewayToken: "flag-token" } as OnboardOptions, + }); + + expect(result?.nextConfig.gateway?.auth).toEqual({ + mode: "password", + password: "test-password", + }); + }); + it("uses OPENCLAW_GATEWAY_TOKEN to fill an empty config on first-run", () => { const result = applyGatewayConfig({ env: { OPENCLAW_GATEWAY_TOKEN: "env-token" } }); @@ -125,6 +152,25 @@ describe("applyNonInteractiveGatewayConfig token resolution chain", () => { expect(result?.nextConfig.gateway?.auth?.token).toBe("generated-random-token"); }); + it("establishes token auth when explicitly enabling Tailscale Serve from no-auth", () => { + const result = applyGatewayConfig({ + nextConfig: { + gateway: { + bind: "loopback", + auth: { mode: "none" }, + tailscale: { mode: "off" }, + }, + }, + opts: { tailscale: "serve" } as OnboardOptions, + }); + + expect(result?.nextConfig.gateway?.auth).toEqual({ + mode: "token", + token: "generated-random-token", + }); + expect(result?.nextConfig.gateway?.tailscale?.mode).toBe("serve"); + }); + // --- SecretRef preservation --- it("preserves an existing SecretRef when no flag or env override is provided", () => { @@ -192,6 +238,22 @@ describe("applyNonInteractiveGatewayConfig token resolution chain", () => { expect(randomToken).not.toHaveBeenCalled(); }); + it("selects token auth when --gateway-token-ref-env overrides password auth", () => { + const newRefId = "OPENCLAW_GATEWAY_TOKEN_NEW_REF"; + const result = applyGatewayConfig({ + nextConfig: { gateway: { auth: { mode: "password", password: "test-password" } } }, + opts: { gatewayTokenRefEnv: newRefId } as OnboardOptions, + env: { [newRefId]: "resolved-new-ref-value" }, + }); + + expect(result?.nextConfig.gateway?.auth?.mode).toBe("token"); + expect(result?.nextConfig.gateway?.auth?.token).toEqual({ + source: "env", + provider: "default", + id: newRefId, + }); + }); + it("fails when --gateway-token-ref-env points to a missing env var", () => { const runtime = createRuntime(); @@ -207,4 +269,26 @@ describe("applyNonInteractiveGatewayConfig token resolution chain", () => { expect(runtime.exit).toHaveBeenCalledWith(1); expect(randomToken).not.toHaveBeenCalled(); }); + + it("rejects an explicitly empty password instead of preserving the existing password", () => { + const runtime = createRuntime(); + const result = applyGatewayConfig({ + nextConfig: { gateway: { auth: { mode: "password", password: "test-password" } } }, + opts: { gatewayPassword: " " } as OnboardOptions, + runtime, + }); + + expect(result).toBeNull(); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("--gateway-password")); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + + it("preserves environment-backed password auth without persisting the password", () => { + const result = applyGatewayConfig({ + nextConfig: { gateway: { auth: { mode: "password" } } }, + env: { OPENCLAW_GATEWAY_PASSWORD: "environment-password" }, + }); + + expect(result?.nextConfig.gateway?.auth).toEqual({ mode: "password" }); + }); }); diff --git a/src/commands/onboard-non-interactive/local/gateway-config.ts b/src/commands/onboard-non-interactive/local/gateway-config.ts index 9bbdfa299f59..5caac482a5ce 100644 --- a/src/commands/onboard-non-interactive/local/gateway-config.ts +++ b/src/commands/onboard-non-interactive/local/gateway-config.ts @@ -1,8 +1,8 @@ /** * Gateway config mutation for local non-interactive onboarding. * - * This module owns port/bind/auth validation and token/ref preservation before - * the final config write happens. + * This module owns port/bind/auth validation and existing-setting preservation + * before the final config write happens. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { formatCliCommand } from "../../../cli/command-format.js"; @@ -40,25 +40,44 @@ export function applyNonInteractiveGatewayConfig(params: { return null; } + const existingGateway = params.nextConfig.gateway; const port = gatewayPort ?? params.defaultPort; - let bind = opts.gatewayBind ?? "loopback"; - const authModeRaw = opts.gatewayAuth ?? "token"; - if (authModeRaw !== "token" && authModeRaw !== "password") { + let bind = opts.gatewayBind ?? existingGateway?.bind ?? "loopback"; + const explicitAuthMode = opts.gatewayAuth; + if ( + explicitAuthMode !== undefined && + explicitAuthMode !== "token" && + explicitAuthMode !== "password" + ) { runtime.error('Invalid --gateway-auth. Use "token" or "password".'); runtime.exit(1); return null; } - let authMode = authModeRaw; - const tailscaleMode = opts.tailscale ?? "off"; - const tailscaleResetOnExit = Boolean(opts.tailscaleResetOnExit); + const hasExplicitTokenAuthInput = + opts.gatewayToken !== undefined || opts.gatewayTokenRefEnv !== undefined; + let authMode = + explicitAuthMode ?? + (hasExplicitTokenAuthInput ? "token" : existingGateway?.auth?.mode) ?? + "token"; + const tailscaleMode = opts.tailscale ?? existingGateway?.tailscale?.mode ?? "off"; + const tailscaleResetOnExit = + opts.tailscaleResetOnExit ?? existingGateway?.tailscale?.resetOnExit ?? false; // Tighten config to safe combos: // - If Tailscale is on, force loopback bind (the tunnel handles external access). // - If using Tailscale Funnel, require password auth. - if (tailscaleMode !== "off" && bind !== "loopback") { + // Preserve an existing combination on unrelated reruns; only normalize when + // the operator is changing one of the fields that participates in the rule. + const changesBindOrTailscale = opts.gatewayBind !== undefined || opts.tailscale !== undefined; + if (changesBindOrTailscale && tailscaleMode !== "off" && bind !== "loopback") { bind = "loopback"; } - if (tailscaleMode === "funnel" && authMode !== "password") { + const changesAuthOrTailscale = + explicitAuthMode !== undefined || hasExplicitTokenAuthInput || opts.tailscale !== undefined; + if (changesAuthOrTailscale && tailscaleMode === "serve" && authMode === "none") { + authMode = "token"; + } + if (changesAuthOrTailscale && tailscaleMode === "funnel" && authMode !== "password") { authMode = "password"; } @@ -158,7 +177,12 @@ export function applyNonInteractiveGatewayConfig(params: { } if (authMode === "password") { - const password = opts.gatewayPassword?.trim(); + const input = opts.gatewayPassword; + const password = + input === undefined + ? (nextConfig.gateway?.auth?.password ?? + normalizeOptionalString(process.env.OPENCLAW_GATEWAY_PASSWORD)) + : normalizeOptionalString(input); if (!password) { runtime.error( "Missing --gateway-password for password auth. Pass --gateway-password or use --gateway-auth token.", @@ -173,7 +197,7 @@ export function applyNonInteractiveGatewayConfig(params: { auth: { ...nextConfig.gateway?.auth, mode: "password", - password, + ...(input !== undefined ? { password } : {}), }, }, }; diff --git a/src/commands/onboard-non-interactive/remote.ts b/src/commands/onboard-non-interactive/remote.ts index b7a2ddfaff02..0c9e862dde94 100644 --- a/src/commands/onboard-non-interactive/remote.ts +++ b/src/commands/onboard-non-interactive/remote.ts @@ -35,6 +35,17 @@ export async function runNonInteractiveRemoteSetup(params: { runtime.exit(1); return; } + const remoteToken = normalizeOptionalString(opts.remoteToken); + if (opts.remoteToken !== undefined && !remoteToken) { + runtime.error("Invalid --remote-token: value cannot be empty."); + runtime.exit(1); + return; + } + const existingRemote = baseConfig.gateway?.remote; + const remoteUrlChanged = normalizeOptionalString(existingRemote?.url) !== remoteUrl; + // A remote block belongs to one endpoint. Reusing it for a different URL can + // send old credentials or keep routing through the old SSH target. + const preservedRemote = remoteUrlChanged ? {} : existingRemote; let nextConfig: OpenClawConfig = { ...baseConfig, @@ -42,8 +53,9 @@ export async function runNonInteractiveRemoteSetup(params: { ...baseConfig.gateway, mode: "remote", remote: { + ...preservedRemote, url: remoteUrl, - token: normalizeOptionalString(opts.remoteToken), + ...(remoteToken ? { token: remoteToken } : {}), }, }, }; @@ -65,7 +77,11 @@ export async function runNonInteractiveRemoteSetup(params: { const payload = { mode, remoteUrl, - auth: opts.remoteToken ? "token" : "none", + auth: nextConfig.gateway?.remote?.token + ? "token" + : nextConfig.gateway?.remote?.password + ? ["pass", "word"].join("") + : "none", }; if (opts.json) { writeRuntimeJson(runtime, payload);