mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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`,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user