fix(onboard): preserve gateway settings on rerun (#111569)

* fix(onboard): preserve gateway config on rerun

* fix(onboard): scope remote secrets to endpoint

* fix(onboard): honor rerun override boundaries

* fix(onboard): secure inherited tailscale auth

* fix(onboard): honor explicit token auth

* fix(onboard): enforce funnel auth on rerun

* fix(onboard): preserve env password on rerun
This commit is contained in:
Peter Steinberger
2026-07-19 20:18:34 -07:00
committed by GitHub
parent cacd98304e
commit 6d39d3cf0b
11 changed files with 262 additions and 35 deletions
+1
View File
@@ -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`,
+11
View File
@@ -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([
+10 -1
View File
@@ -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 <token>", "Remote Gateway token (optional)")
.option("--tailscale <mode>", "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,
+9
View File
@@ -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,
});
+4 -1
View File
@@ -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 <password>", "Gateway password (password auth)")
.option("--tailscale <mode>", "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")
@@ -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" });
});
});
@@ -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<string, { enabled?: boolean }> } };
@@ -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<string, { enabled?: boolean }> } };
}>();
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);
@@ -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,
@@ -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" });
});
});
@@ -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 } : {}),
},
},
};
+18 -2
View File
@@ -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);