Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails (#35094)

This commit is contained in:
Josh Avant
2026-03-05 12:53:56 -06:00
committed by GitHub
parent bc66a8fa81
commit 72cf9253fc
112 changed files with 5750 additions and 465 deletions

View File

@@ -1,6 +1,10 @@
import { resolveEnvApiKey } from "../agents/model-auth.js";
import type { OpenClawConfig } from "../config/types.js";
import { type SecretInput, type SecretRef } from "../config/types.secrets.js";
import {
isValidEnvSecretRefId,
type SecretInput,
type SecretRef,
} from "../config/types.secrets.js";
import { encodeJsonPointerToken } from "../secrets/json-pointer.js";
import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js";
import {
@@ -15,7 +19,6 @@ import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
import type { SecretInputMode } from "./onboard-types.js";
const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/;
const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/;
type SecretRefChoice = "env" | "provider";
@@ -127,7 +130,7 @@ export async function promptSecretRefForOnboarding(params: {
placeholder: params.copy?.envVarPlaceholder ?? "OPENAI_API_KEY",
validate: (value) => {
const candidate = value.trim();
if (!ENV_SECRET_REF_ID_RE.test(candidate)) {
if (!isValidEnvSecretRefId(candidate)) {
return (
params.copy?.envVarFormatError ??
'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).'
@@ -144,7 +147,7 @@ export async function promptSecretRefForOnboarding(params: {
});
const envCandidate = String(envVarRaw ?? "").trim();
const envVar =
envCandidate && ENV_SECRET_REF_ID_RE.test(envCandidate) ? envCandidate : defaultEnvVar;
envCandidate && isValidEnvSecretRefId(envCandidate) ? envCandidate : defaultEnvVar;
if (!envVar) {
throw new Error(
`No valid environment variable name provided for provider "${params.provider}".`,

View File

@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const withProgress = vi.hoisted(() => vi.fn(async (_opts, run) => run({ setLabel: vi.fn() })));
const loadConfig = vi.hoisted(() => vi.fn());
const resolveGatewayInstallToken = vi.hoisted(() => vi.fn());
const buildGatewayInstallPlan = vi.hoisted(() => vi.fn());
const note = vi.hoisted(() => vi.fn());
const serviceInstall = vi.hoisted(() => vi.fn(async () => {}));
const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../cli/progress.js", () => ({
withProgress,
}));
vi.mock("../config/config.js", () => ({
loadConfig,
}));
vi.mock("./gateway-install-token.js", () => ({
resolveGatewayInstallToken,
}));
vi.mock("./daemon-install-helpers.js", () => ({
buildGatewayInstallPlan,
gatewayInstallErrorHint: vi.fn(() => "hint"),
}));
vi.mock("../terminal/note.js", () => ({
note,
}));
vi.mock("./configure.shared.js", () => ({
confirm: vi.fn(async () => true),
select: vi.fn(async () => "node"),
}));
vi.mock("./daemon-runtime.js", () => ({
DEFAULT_GATEWAY_DAEMON_RUNTIME: "node",
GATEWAY_DAEMON_RUNTIME_OPTIONS: [{ value: "node", label: "Node" }],
}));
vi.mock("../daemon/service.js", () => ({
resolveGatewayService: vi.fn(() => ({
isLoaded: vi.fn(async () => false),
install: serviceInstall,
})),
}));
vi.mock("./onboard-helpers.js", () => ({
guardCancel: (value: unknown) => value,
}));
vi.mock("./systemd-linger.js", () => ({
ensureSystemdUserLingerInteractive,
}));
const { maybeInstallDaemon } = await import("./configure.daemon.js");
describe("maybeInstallDaemon", () => {
beforeEach(() => {
vi.clearAllMocks();
loadConfig.mockReturnValue({});
resolveGatewayInstallToken.mockResolvedValue({
token: undefined,
tokenRefConfigured: true,
warnings: [],
});
buildGatewayInstallPlan.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
workingDirectory: "/tmp",
environment: {},
});
});
it("does not serialize SecretRef token into service environment", async () => {
await maybeInstallDaemon({
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
port: 18789,
});
expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1);
expect(buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
}),
);
expect(serviceInstall).toHaveBeenCalledTimes(1);
});
it("blocks install when token SecretRef is unresolved", async () => {
resolveGatewayInstallToken.mockResolvedValue({
token: undefined,
tokenRefConfigured: true,
unavailableReason: "gateway.auth.token SecretRef is configured but unresolved (boom).",
warnings: [],
});
await maybeInstallDaemon({
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
port: 18789,
});
expect(note).toHaveBeenCalledWith(
expect.stringContaining("Gateway install blocked"),
"Gateway",
);
expect(buildGatewayInstallPlan).not.toHaveBeenCalled();
expect(serviceInstall).not.toHaveBeenCalled();
});
});

View File

@@ -10,13 +10,13 @@ import {
GATEWAY_DAEMON_RUNTIME_OPTIONS,
type GatewayDaemonRuntime,
} from "./daemon-runtime.js";
import { resolveGatewayInstallToken } from "./gateway-install-token.js";
import { guardCancel } from "./onboard-helpers.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
export async function maybeInstallDaemon(params: {
runtime: RuntimeEnv;
port: number;
gatewayToken?: string;
daemonRuntime?: GatewayDaemonRuntime;
}) {
const service = resolveGatewayService();
@@ -88,10 +88,26 @@ export async function maybeInstallDaemon(params: {
progress.setLabel("Preparing Gateway service…");
const cfg = loadConfig();
const tokenResolution = await resolveGatewayInstallToken({
config: cfg,
env: process.env,
});
for (const warning of tokenResolution.warnings) {
note(warning, "Gateway");
}
if (tokenResolution.unavailableReason) {
installError = [
"Gateway install blocked:",
tokenResolution.unavailableReason,
"Fix gateway auth config/token input and rerun configure.",
].join(" ");
progress.setLabel("Gateway service install blocked.");
return;
}
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port: params.port,
token: params.gatewayToken,
token: tokenResolution.token,
runtime: daemonRuntime,
warn: (message, title) => note(message, title),
config: cfg,

View File

@@ -10,7 +10,10 @@ function expectGeneratedTokenFromInput(token: string | undefined, literalToAvoid
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe(literalToAvoid);
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
if (typeof result?.token !== "string") {
throw new Error("Expected generated token to be a string.");
}
expect(result.token.length).toBeGreaterThan(0);
}
describe("buildGatewayAuthConfig", () => {
@@ -73,6 +76,23 @@ describe("buildGatewayAuthConfig", () => {
expectGeneratedTokenFromInput("null", "null");
});
it("preserves SecretRef tokens when token mode is selected", () => {
const tokenRef = {
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
} as const;
const result = buildGatewayAuthConfig({
mode: "token",
token: tokenRef,
});
expect(result).toEqual({
mode: "token",
token: tokenRef,
});
});
it("builds trusted-proxy config with all options", () => {
const result = buildGatewayAuthConfig({
mode: "trusted-proxy",

View File

@@ -1,5 +1,6 @@
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import type { OpenClawConfig, GatewayAuthConfig } from "../config/config.js";
import { isSecretRef, type SecretInput } from "../config/types.secrets.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js";
@@ -17,7 +18,7 @@ import { randomToken } from "./onboard-helpers.js";
type GatewayAuthChoice = "token" | "password" | "trusted-proxy";
/** Reject undefined, empty, and common JS string-coercion artifacts for token auth. */
function sanitizeTokenValue(value: string | undefined): string | undefined {
function sanitizeTokenValue(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
@@ -39,7 +40,7 @@ const ANTHROPIC_OAUTH_MODEL_KEYS = [
export function buildGatewayAuthConfig(params: {
existing?: GatewayAuthConfig;
mode: GatewayAuthChoice;
token?: string;
token?: SecretInput;
password?: string;
trustedProxy?: {
userHeader: string;
@@ -54,6 +55,9 @@ export function buildGatewayAuthConfig(params: {
}
if (params.mode === "token") {
if (isSecretRef(params.token)) {
return { ...base, mode: "token", token: params.token };
}
// Keep token mode always valid: treat empty/undefined/"undefined"/"null" as missing and generate a token.
const token = sanitizeTokenValue(params.token) ?? randomToken();
return { ...base, mode: "token", token };

View File

@@ -68,7 +68,13 @@ async function runGatewayPrompt(params: {
}) {
vi.clearAllMocks();
mocks.resolveGatewayPort.mockReturnValue(18789);
mocks.select.mockImplementation(async () => params.selectQueue.shift());
mocks.select.mockImplementation(async (input) => {
const next = params.selectQueue.shift();
if (next !== undefined) {
return next;
}
return input.initialValue ?? input.options[0]?.value;
});
mocks.text.mockImplementation(async () => params.textQueue.shift());
mocks.randomToken.mockReturnValue(params.randomToken ?? "generated-token");
mocks.confirm.mockResolvedValue(params.confirmResult ?? true);
@@ -95,7 +101,7 @@ async function runTrustedProxyPrompt(params: {
describe("promptGatewayConfig", () => {
it("generates a token when the prompt returns undefined", async () => {
const { result } = await runGatewayPrompt({
selectQueue: ["loopback", "token", "off"],
selectQueue: ["loopback", "token", "off", "plaintext"],
textQueue: ["18789", undefined],
randomToken: "generated-token",
authConfigFactory: ({ mode, token, password }) => ({ mode, token, password }),
@@ -163,7 +169,7 @@ describe("promptGatewayConfig", () => {
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
const { result } = await runGatewayPrompt({
// bind=loopback, auth=token, tailscale=serve
selectQueue: ["loopback", "token", "serve"],
selectQueue: ["loopback", "token", "serve", "plaintext"],
textQueue: ["18789", "my-token"],
confirmResult: true,
authConfigFactory: ({ mode, token }) => ({ mode, token }),
@@ -190,7 +196,7 @@ describe("promptGatewayConfig", () => {
it("does not add Tailscale origin when getTailnetHostname fails", async () => {
mocks.getTailnetHostname.mockRejectedValue(new Error("not found"));
const { result } = await runGatewayPrompt({
selectQueue: ["loopback", "token", "serve"],
selectQueue: ["loopback", "token", "serve", "plaintext"],
textQueue: ["18789", "my-token"],
confirmResult: true,
authConfigFactory: ({ mode, token }) => ({ mode, token }),
@@ -208,7 +214,7 @@ describe("promptGatewayConfig", () => {
},
},
},
selectQueue: ["loopback", "token", "serve"],
selectQueue: ["loopback", "token", "serve", "plaintext"],
textQueue: ["18789", "my-token"],
confirmResult: true,
authConfigFactory: ({ mode, token }) => ({ mode, token }),
@@ -223,7 +229,7 @@ describe("promptGatewayConfig", () => {
it("formats IPv6 Tailscale fallback addresses as valid HTTPS origins", async () => {
mocks.getTailnetHostname.mockResolvedValue("fd7a:115c:a1e0::12");
const { result } = await runGatewayPrompt({
selectQueue: ["loopback", "token", "serve"],
selectQueue: ["loopback", "token", "serve", "plaintext"],
textQueue: ["18789", "my-token"],
confirmResult: true,
authConfigFactory: ({ mode, token }) => ({ mode, token }),
@@ -232,4 +238,29 @@ describe("promptGatewayConfig", () => {
"https://[fd7a:115c:a1e0::12]",
);
});
it("stores gateway token as SecretRef when token source is ref", async () => {
const previous = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = "env-gateway-token";
try {
const { call, result } = await runGatewayPrompt({
selectQueue: ["loopback", "token", "off", "ref"],
textQueue: ["18789", "OPENCLAW_GATEWAY_TOKEN"],
authConfigFactory: ({ mode, token }) => ({ mode, token }),
});
expect(call?.token).toEqual({
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
});
expect(result.token).toBeUndefined();
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = previous;
}
}
});
});

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveGatewayPort } from "../config/config.js";
import { isValidEnvSecretRefId, type SecretInput } from "../config/types.secrets.js";
import {
maybeAddTailnetOriginToControlUiAllowedOrigins,
TAILSCALE_DOCS_LINES,
@@ -8,6 +9,7 @@ import {
} from "../gateway/gateway-config-prompts.shared.js";
import { findTailscaleBinary } from "../infra/tailscale.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
import { validateIPv4AddressInput } from "../shared/net/ipv4.js";
import { note } from "../terminal/note.js";
import { buildGatewayAuthConfig } from "./configure.gateway-auth.js";
@@ -20,6 +22,7 @@ import {
} from "./onboard-helpers.js";
type GatewayAuthChoice = "token" | "password" | "trusted-proxy";
type GatewayTokenInputMode = "plaintext" | "ref";
export async function promptGatewayConfig(
cfg: OpenClawConfig,
@@ -156,7 +159,8 @@ export async function promptGatewayConfig(
tailscaleResetOnExit = false;
}
let gatewayToken: string | undefined;
let gatewayToken: SecretInput | undefined;
let gatewayTokenForCalls: string | undefined;
let gatewayPassword: string | undefined;
let trustedProxyConfig:
| { userHeader: string; requiredHeaders?: string[]; allowUsers?: string[] }
@@ -165,14 +169,65 @@ export async function promptGatewayConfig(
let next = cfg;
if (authMode === "token") {
const tokenInput = guardCancel(
await text({
message: "Gateway token (blank to generate)",
initialValue: randomToken(),
const tokenInputMode = guardCancel(
await select<GatewayTokenInputMode>({
message: "Gateway token source",
options: [
{
value: "plaintext",
label: "Generate/store plaintext token",
hint: "Default",
},
{
value: "ref",
label: "Use SecretRef",
hint: "Store an env-backed reference instead of plaintext",
},
],
initialValue: "plaintext",
}),
runtime,
);
gatewayToken = normalizeGatewayTokenInput(tokenInput) || randomToken();
if (tokenInputMode === "ref") {
const envVar = guardCancel(
await text({
message: "Gateway token env var",
initialValue: "OPENCLAW_GATEWAY_TOKEN",
placeholder: "OPENCLAW_GATEWAY_TOKEN",
validate: (value) => {
const candidate = String(value ?? "").trim();
if (!isValidEnvSecretRefId(candidate)) {
return "Use an env var name like OPENCLAW_GATEWAY_TOKEN.";
}
const resolved = process.env[candidate]?.trim();
if (!resolved) {
return `Environment variable "${candidate}" is missing or empty in this session.`;
}
return undefined;
},
}),
runtime,
);
const envVarName = String(envVar ?? "").trim();
gatewayToken = {
source: "env",
provider: resolveDefaultSecretProviderAlias(cfg, "env", {
preferFirstProviderForSource: true,
}),
id: envVarName,
};
note(`Validated ${envVarName}. OpenClaw will store a token SecretRef.`, "Gateway token");
} else {
const tokenInput = guardCancel(
await text({
message: "Gateway token (blank to generate)",
initialValue: randomToken(),
}),
runtime,
);
gatewayTokenForCalls = normalizeGatewayTokenInput(tokenInput) || randomToken();
gatewayToken = gatewayTokenForCalls;
}
}
if (authMode === "password") {
@@ -294,5 +349,5 @@ export async function promptGatewayConfig(
tailscaleBin,
});
return { config: next, port, token: gatewayToken };
return { config: next, port, token: gatewayTokenForCalls };
}

View File

@@ -4,13 +4,13 @@ import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import { normalizeSecretInputString } from "../config/types.secrets.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { note } from "../terminal/note.js";
import { resolveUserPath } from "../utils.js";
import { createClackPrompter } from "../wizard/clack-prompter.js";
import { resolveOnboardingSecretInputString } from "../wizard/onboarding.secret-input.js";
import { WizardCancelledError } from "../wizard/prompts.js";
import { removeChannelConfigWizard } from "./configure.channels.js";
import { maybeInstallDaemon } from "./configure.daemon.js";
@@ -48,6 +48,23 @@ import { setupSkills } from "./onboard-skills.js";
type ConfigureSectionChoice = WizardSection | "__continue";
async function resolveGatewaySecretInputForWizard(params: {
cfg: OpenClawConfig;
value: unknown;
path: string;
}): Promise<string | undefined> {
try {
return await resolveOnboardingSecretInputString({
config: params.cfg,
value: params.value,
path: params.path,
env: process.env,
});
} catch {
return undefined;
}
}
async function runGatewayHealthCheck(params: {
cfg: OpenClawConfig;
runtime: RuntimeEnv;
@@ -61,10 +78,22 @@ async function runGatewayHealthCheck(params: {
});
const remoteUrl = params.cfg.gateway?.remote?.url?.trim();
const wsUrl = params.cfg.gateway?.mode === "remote" && remoteUrl ? remoteUrl : localLinks.wsUrl;
const token = params.cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN;
const configuredToken = await resolveGatewaySecretInputForWizard({
cfg: params.cfg,
value: params.cfg.gateway?.auth?.token,
path: "gateway.auth.token",
});
const configuredPassword = await resolveGatewaySecretInputForWizard({
cfg: params.cfg,
value: params.cfg.gateway?.auth?.password,
path: "gateway.auth.password",
});
const token =
process.env.OPENCLAW_GATEWAY_TOKEN ?? process.env.CLAWDBOT_GATEWAY_TOKEN ?? configuredToken;
const password =
normalizeSecretInputString(params.cfg.gateway?.auth?.password) ??
process.env.OPENCLAW_GATEWAY_PASSWORD;
process.env.OPENCLAW_GATEWAY_PASSWORD ??
process.env.CLAWDBOT_GATEWAY_PASSWORD ??
configuredPassword;
await waitForGatewayReachable({
url: wsUrl,
@@ -305,18 +334,37 @@ export async function runConfigureWizard(
}
const localUrl = "ws://127.0.0.1:18789";
const baseLocalProbeToken = await resolveGatewaySecretInputForWizard({
cfg: baseConfig,
value: baseConfig.gateway?.auth?.token,
path: "gateway.auth.token",
});
const baseLocalProbePassword = await resolveGatewaySecretInputForWizard({
cfg: baseConfig,
value: baseConfig.gateway?.auth?.password,
path: "gateway.auth.password",
});
const localProbe = await probeGatewayReachable({
url: localUrl,
token: baseConfig.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN,
token:
process.env.OPENCLAW_GATEWAY_TOKEN ??
process.env.CLAWDBOT_GATEWAY_TOKEN ??
baseLocalProbeToken,
password:
normalizeSecretInputString(baseConfig.gateway?.auth?.password) ??
process.env.OPENCLAW_GATEWAY_PASSWORD,
process.env.OPENCLAW_GATEWAY_PASSWORD ??
process.env.CLAWDBOT_GATEWAY_PASSWORD ??
baseLocalProbePassword,
});
const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? "";
const baseRemoteProbeToken = await resolveGatewaySecretInputForWizard({
cfg: baseConfig,
value: baseConfig.gateway?.remote?.token,
path: "gateway.remote.token",
});
const remoteProbe = remoteUrl
? await probeGatewayReachable({
url: remoteUrl,
token: normalizeSecretInputString(baseConfig.gateway?.remote?.token),
token: baseRemoteProbeToken,
})
: null;
@@ -374,10 +422,6 @@ export async function runConfigureWizard(
baseConfig.agents?.defaults?.workspace ??
DEFAULT_WORKSPACE;
let gatewayPort = resolveGatewayPort(baseConfig);
let gatewayToken: string | undefined =
normalizeSecretInputString(nextConfig.gateway?.auth?.token) ??
normalizeSecretInputString(baseConfig.gateway?.auth?.token) ??
process.env.OPENCLAW_GATEWAY_TOKEN;
const persistConfig = async () => {
nextConfig = applyWizardMetadata(nextConfig, {
@@ -486,7 +530,6 @@ export async function runConfigureWizard(
const gateway = await promptGatewayConfig(nextConfig, runtime);
nextConfig = gateway.config;
gatewayPort = gateway.port;
gatewayToken = gateway.token;
}
if (selected.includes("channels")) {
@@ -505,7 +548,7 @@ export async function runConfigureWizard(
await promptDaemonPort();
}
await maybeInstallDaemon({ runtime, port: gatewayPort, gatewayToken });
await maybeInstallDaemon({ runtime, port: gatewayPort });
}
if (selected.includes("health")) {
@@ -541,7 +584,6 @@ export async function runConfigureWizard(
const gateway = await promptGatewayConfig(nextConfig, runtime);
nextConfig = gateway.config;
gatewayPort = gateway.port;
gatewayToken = gateway.token;
didConfigureGateway = true;
await persistConfig();
}
@@ -564,7 +606,6 @@ export async function runConfigureWizard(
await maybeInstallDaemon({
runtime,
port: gatewayPort,
gatewayToken,
});
}
@@ -598,12 +639,29 @@ export async function runConfigureWizard(
});
// Try both new and old passwords since gateway may still have old config.
const newPassword =
normalizeSecretInputString(nextConfig.gateway?.auth?.password) ??
process.env.OPENCLAW_GATEWAY_PASSWORD;
process.env.OPENCLAW_GATEWAY_PASSWORD ??
process.env.CLAWDBOT_GATEWAY_PASSWORD ??
(await resolveGatewaySecretInputForWizard({
cfg: nextConfig,
value: nextConfig.gateway?.auth?.password,
path: "gateway.auth.password",
}));
const oldPassword =
normalizeSecretInputString(baseConfig.gateway?.auth?.password) ??
process.env.OPENCLAW_GATEWAY_PASSWORD;
const token = nextConfig.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_PASSWORD ??
process.env.CLAWDBOT_GATEWAY_PASSWORD ??
(await resolveGatewaySecretInputForWizard({
cfg: baseConfig,
value: baseConfig.gateway?.auth?.password,
path: "gateway.auth.password",
}));
const token =
process.env.OPENCLAW_GATEWAY_TOKEN ??
process.env.CLAWDBOT_GATEWAY_TOKEN ??
(await resolveGatewaySecretInputForWizard({
cfg: nextConfig,
value: nextConfig.gateway?.auth?.token,
path: "gateway.auth.token",
}));
let gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,

View File

@@ -8,6 +8,7 @@ const detectBrowserOpenSupportMock = vi.hoisted(() => vi.fn());
const openUrlMock = vi.hoisted(() => vi.fn());
const formatControlUiSshHintMock = vi.hoisted(() => vi.fn());
const copyToClipboardMock = vi.hoisted(() => vi.fn());
const resolveSecretRefValuesMock = vi.hoisted(() => vi.fn());
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: readConfigFileSnapshotMock,
@@ -25,6 +26,10 @@ vi.mock("../infra/clipboard.js", () => ({
copyToClipboard: copyToClipboardMock,
}));
vi.mock("../secrets/resolve.js", () => ({
resolveSecretRefValues: resolveSecretRefValuesMock,
}));
const runtime = {
log: vi.fn(),
error: vi.fn(),
@@ -37,7 +42,7 @@ function resetRuntime() {
runtime.exit.mockClear();
}
function mockSnapshot(token = "abc") {
function mockSnapshot(token: unknown = "abc") {
readConfigFileSnapshotMock.mockResolvedValue({
path: "/tmp/openclaw.json",
exists: true,
@@ -53,6 +58,7 @@ function mockSnapshot(token = "abc") {
httpUrl: "http://127.0.0.1:18789/",
wsUrl: "ws://127.0.0.1:18789",
});
resolveSecretRefValuesMock.mockReset();
}
describe("dashboardCommand", () => {
@@ -65,6 +71,8 @@ describe("dashboardCommand", () => {
openUrlMock.mockClear();
formatControlUiSshHintMock.mockClear();
copyToClipboardMock.mockClear();
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
});
it("opens and copies the dashboard link by default", async () => {
@@ -115,4 +123,71 @@ describe("dashboardCommand", () => {
"Browser launch disabled (--no-open). Use the URL above.",
);
});
it("prints non-tokenized URL with guidance when token SecretRef is unresolved", async () => {
mockSnapshot({
source: "env",
provider: "default",
id: "MISSING_GATEWAY_TOKEN",
});
copyToClipboardMock.mockResolvedValue(true);
detectBrowserOpenSupportMock.mockResolvedValue({ ok: true });
openUrlMock.mockResolvedValue(true);
resolveSecretRefValuesMock.mockRejectedValue(new Error("missing env var"));
await dashboardCommand(runtime);
expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("Token auto-auth unavailable"),
);
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining(
"gateway.auth.token SecretRef is unresolved (env:default:MISSING_GATEWAY_TOKEN).",
),
);
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("missing env var"));
});
it("keeps URL non-tokenized when token SecretRef is unresolved but env fallback exists", async () => {
mockSnapshot({
source: "env",
provider: "default",
id: "MISSING_GATEWAY_TOKEN",
});
process.env.OPENCLAW_GATEWAY_TOKEN = "fallback-token";
copyToClipboardMock.mockResolvedValue(true);
detectBrowserOpenSupportMock.mockResolvedValue({ ok: true });
openUrlMock.mockResolvedValue(true);
resolveSecretRefValuesMock.mockRejectedValue(new Error("missing env var"));
await dashboardCommand(runtime);
expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(openUrlMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("Token auto-auth is disabled for SecretRef-managed"),
);
expect(runtime.log).not.toHaveBeenCalledWith(
expect.stringContaining("Token auto-auth unavailable"),
);
});
it("resolves env-template gateway.auth.token before building dashboard URL", async () => {
mockSnapshot("${CUSTOM_GATEWAY_TOKEN}");
copyToClipboardMock.mockResolvedValue(true);
detectBrowserOpenSupportMock.mockResolvedValue({ ok: true });
openUrlMock.mockResolvedValue(true);
resolveSecretRefValuesMock.mockResolvedValue(
new Map([["env:default:CUSTOM_GATEWAY_TOKEN", "resolved-secret-token"]]),
);
await dashboardCommand(runtime);
expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(openUrlMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("Token auto-auth is disabled for SecretRef-managed"),
);
});
});

View File

@@ -1,7 +1,11 @@
import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { copyToClipboard } from "../infra/clipboard.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
import {
detectBrowserOpenSupport,
formatControlUiSshHint,
@@ -13,6 +17,69 @@ type DashboardOptions = {
noOpen?: boolean;
};
function readGatewayTokenEnv(env: NodeJS.ProcessEnv): string | undefined {
const primary = env.OPENCLAW_GATEWAY_TOKEN?.trim();
if (primary) {
return primary;
}
const legacy = env.CLAWDBOT_GATEWAY_TOKEN?.trim();
return legacy || undefined;
}
async function resolveDashboardToken(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): Promise<{
token?: string;
source?: "config" | "env" | "secretRef";
unresolvedRefReason?: string;
tokenSecretRefConfigured: boolean;
}> {
const { ref } = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
});
const configToken =
ref || typeof cfg.gateway?.auth?.token !== "string"
? undefined
: cfg.gateway.auth.token.trim() || undefined;
if (configToken) {
return { token: configToken, source: "config", tokenSecretRefConfigured: false };
}
if (!ref) {
const envToken = readGatewayTokenEnv(env);
return envToken
? { token: envToken, source: "env", tokenSecretRefConfigured: false }
: { tokenSecretRefConfigured: false };
}
const refLabel = `${ref.source}:${ref.provider}:${ref.id}`;
try {
const resolved = await resolveSecretRefValues([ref], {
config: cfg,
env,
});
const value = resolved.get(secretRefKey(ref));
if (typeof value === "string" && value.trim().length > 0) {
return { token: value.trim(), source: "secretRef", tokenSecretRefConfigured: true };
}
const envToken = readGatewayTokenEnv(env);
return envToken
? { token: envToken, source: "env", tokenSecretRefConfigured: true }
: {
unresolvedRefReason: `gateway.auth.token SecretRef is unresolved (${refLabel}).`,
tokenSecretRefConfigured: true,
};
} catch {
const envToken = readGatewayTokenEnv(env);
return envToken
? { token: envToken, source: "env", tokenSecretRefConfigured: true }
: {
unresolvedRefReason: `gateway.auth.token SecretRef is unresolved (${refLabel}).`,
tokenSecretRefConfigured: true,
};
}
}
export async function dashboardCommand(
runtime: RuntimeEnv = defaultRuntime,
options: DashboardOptions = {},
@@ -23,7 +90,8 @@ export async function dashboardCommand(
const bind = cfg.gateway?.bind ?? "loopback";
const basePath = cfg.gateway?.controlUi?.basePath;
const customBindHost = cfg.gateway?.customBindHost;
const token = cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? "";
const resolvedToken = await resolveDashboardToken(cfg, process.env);
const token = resolvedToken.token ?? "";
// LAN URLs fail secure-context checks in browsers.
// Coerce only lan->loopback and preserve other bind modes.
@@ -33,12 +101,25 @@ export async function dashboardCommand(
customBindHost,
basePath,
});
// Avoid embedding externally managed SecretRef tokens in terminal/clipboard/browser args.
const includeTokenInUrl = token.length > 0 && !resolvedToken.tokenSecretRefConfigured;
// Prefer URL fragment to avoid leaking auth tokens via query params.
const dashboardUrl = token
const dashboardUrl = includeTokenInUrl
? `${links.httpUrl}#token=${encodeURIComponent(token)}`
: links.httpUrl;
runtime.log(`Dashboard URL: ${dashboardUrl}`);
if (resolvedToken.tokenSecretRefConfigured && token) {
runtime.log(
"Token auto-auth is disabled for SecretRef-managed gateway.auth.token; use your external token source if prompted.",
);
}
if (resolvedToken.unresolvedRefReason) {
runtime.log(`Token auto-auth unavailable: ${resolvedToken.unresolvedRefReason}`);
runtime.log(
"Set OPENCLAW_GATEWAY_TOKEN in this shell or resolve your secret provider, then rerun `openclaw dashboard`.",
);
}
const copied = await copyToClipboard(dashboardUrl).catch(() => false);
runtime.log(copied ? "Copied to clipboard." : "Copy to clipboard unavailable.");
@@ -54,7 +135,7 @@ export async function dashboardCommand(
hint = formatControlUiSshHint({
port,
basePath,
token: token || undefined,
token: includeTokenInUrl ? token || undefined : undefined,
});
}
} else {

View File

@@ -0,0 +1,226 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withEnvAsync } from "../test-utils/env.js";
import {
resolveGatewayAuthTokenForService,
shouldRequireGatewayTokenForInstall,
} from "./doctor-gateway-auth-token.js";
describe("resolveGatewayAuthTokenForService", () => {
it("returns plaintext gateway.auth.token when configured", async () => {
const resolved = await resolveGatewayAuthTokenForService(
{
gateway: {
auth: {
token: "config-token",
},
},
} as OpenClawConfig,
{} as NodeJS.ProcessEnv,
);
expect(resolved).toEqual({ token: "config-token" });
});
it("resolves SecretRef-backed gateway.auth.token", async () => {
const resolved = await resolveGatewayAuthTokenForService(
{
gateway: {
auth: {
token: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
{
CUSTOM_GATEWAY_TOKEN: "resolved-token",
} as NodeJS.ProcessEnv,
);
expect(resolved).toEqual({ token: "resolved-token" });
});
it("resolves env-template gateway.auth.token via SecretRef resolution", async () => {
const resolved = await resolveGatewayAuthTokenForService(
{
gateway: {
auth: {
token: "${CUSTOM_GATEWAY_TOKEN}",
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
{
CUSTOM_GATEWAY_TOKEN: "resolved-token",
} as NodeJS.ProcessEnv,
);
expect(resolved).toEqual({ token: "resolved-token" });
});
it("falls back to OPENCLAW_GATEWAY_TOKEN when SecretRef is unresolved", async () => {
const resolved = await resolveGatewayAuthTokenForService(
{
gateway: {
auth: {
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
{
OPENCLAW_GATEWAY_TOKEN: "env-fallback-token",
} as NodeJS.ProcessEnv,
);
expect(resolved).toEqual({ token: "env-fallback-token" });
});
it("falls back to OPENCLAW_GATEWAY_TOKEN when SecretRef resolves to empty", async () => {
const resolved = await resolveGatewayAuthTokenForService(
{
gateway: {
auth: {
token: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
{
CUSTOM_GATEWAY_TOKEN: " ",
OPENCLAW_GATEWAY_TOKEN: "env-fallback-token",
} as NodeJS.ProcessEnv,
);
expect(resolved).toEqual({ token: "env-fallback-token" });
});
it("returns unavailableReason when SecretRef is unresolved without env fallback", async () => {
const resolved = await resolveGatewayAuthTokenForService(
{
gateway: {
auth: {
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
{} as NodeJS.ProcessEnv,
);
expect(resolved.token).toBeUndefined();
expect(resolved.unavailableReason).toContain("gateway.auth.token SecretRef is configured");
});
});
describe("shouldRequireGatewayTokenForInstall", () => {
it("requires token when auth mode is token", () => {
const required = shouldRequireGatewayTokenForInstall(
{
gateway: {
auth: {
mode: "token",
},
},
} as OpenClawConfig,
{} as NodeJS.ProcessEnv,
);
expect(required).toBe(true);
});
it("does not require token when auth mode is password", () => {
const required = shouldRequireGatewayTokenForInstall(
{
gateway: {
auth: {
mode: "password",
},
},
} as OpenClawConfig,
{} as NodeJS.ProcessEnv,
);
expect(required).toBe(false);
});
it("requires token in inferred mode when password env exists only in shell", async () => {
await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "password-from-env" }, async () => {
const required = shouldRequireGatewayTokenForInstall(
{
gateway: {
auth: {},
},
} as OpenClawConfig,
process.env,
);
expect(required).toBe(true);
});
});
it("does not require token in inferred mode when password is configured", () => {
const required = shouldRequireGatewayTokenForInstall(
{
gateway: {
auth: {
password: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
{} as NodeJS.ProcessEnv,
);
expect(required).toBe(false);
});
it("does not require token in inferred mode when password env is configured in config", () => {
const required = shouldRequireGatewayTokenForInstall(
{
gateway: {
auth: {},
},
env: {
vars: {
OPENCLAW_GATEWAY_PASSWORD: "configured-password",
},
},
} as OpenClawConfig,
{} as NodeJS.ProcessEnv,
);
expect(required).toBe(false);
});
it("requires token in inferred mode when no password candidate exists", () => {
const required = shouldRequireGatewayTokenForInstall(
{
gateway: {
auth: {},
},
} as OpenClawConfig,
{} as NodeJS.ProcessEnv,
);
expect(required).toBe(true);
});
});

View File

@@ -0,0 +1,54 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
export { shouldRequireGatewayTokenForInstall } from "../gateway/auth-install-policy.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
function readGatewayTokenEnv(env: NodeJS.ProcessEnv): string | undefined {
const value = env.OPENCLAW_GATEWAY_TOKEN ?? env.CLAWDBOT_GATEWAY_TOKEN;
const trimmed = value?.trim();
return trimmed || undefined;
}
export async function resolveGatewayAuthTokenForService(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
): Promise<{ token?: string; unavailableReason?: string }> {
const { ref } = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
});
const configToken =
ref || typeof cfg.gateway?.auth?.token !== "string"
? undefined
: cfg.gateway.auth.token.trim() || undefined;
if (configToken) {
return { token: configToken };
}
if (ref) {
try {
const resolved = await resolveSecretRefValues([ref], {
config: cfg,
env,
});
const value = resolved.get(secretRefKey(ref));
if (typeof value === "string" && value.trim().length > 0) {
return { token: value.trim() };
}
const envToken = readGatewayTokenEnv(env);
if (envToken) {
return { token: envToken };
}
return { unavailableReason: "gateway.auth.token SecretRef resolved to an empty value." };
} catch (err) {
const envToken = readGatewayTokenEnv(env);
if (envToken) {
return { token: envToken };
}
return {
unavailableReason: `gateway.auth.token SecretRef is configured but unresolved (${String(err)}).`,
};
}
}
return { token: readGatewayTokenEnv(env) };
}

View File

@@ -28,6 +28,7 @@ import {
} from "./daemon-runtime.js";
import { buildGatewayRuntimeHints, formatGatewayRuntimeSummary } from "./doctor-format.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
import { resolveGatewayInstallToken } from "./gateway-install-token.js";
import { formatHealthCheckFailure } from "./health-format.js";
import { healthCommand } from "./health.js";
@@ -171,11 +172,29 @@ export async function maybeRepairGatewayDaemon(params: {
},
DEFAULT_GATEWAY_DAEMON_RUNTIME,
);
const tokenResolution = await resolveGatewayInstallToken({
config: params.cfg,
env: process.env,
});
for (const warning of tokenResolution.warnings) {
note(warning, "Gateway");
}
if (tokenResolution.unavailableReason) {
note(
[
"Gateway service install aborted.",
tokenResolution.unavailableReason,
"Fix gateway auth config/token input and rerun doctor.",
].join("\n"),
"Gateway",
);
return;
}
const port = resolveGatewayPort(params.cfg, process.env);
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port,
token: params.cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN,
token: tokenResolution.token,
runtime: daemonRuntime,
warn: (message, title) => note(message, title),
config: params.cfg,

View File

@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
install: vi.fn(),
auditGatewayServiceConfig: vi.fn(),
buildGatewayInstallPlan: vi.fn(),
resolveGatewayInstallToken: vi.fn(),
resolveGatewayPort: vi.fn(() => 18789),
resolveIsNixMode: vi.fn(() => false),
findExtraGatewayServices: vi.fn().mockResolvedValue([]),
@@ -57,6 +58,10 @@ vi.mock("./daemon-install-helpers.js", () => ({
buildGatewayInstallPlan: mocks.buildGatewayInstallPlan,
}));
vi.mock("./gateway-install-token.js", () => ({
resolveGatewayInstallToken: mocks.resolveGatewayInstallToken,
}));
import {
maybeRepairGatewayServiceConfig,
maybeScanExtraGatewayServices,
@@ -114,6 +119,11 @@ function setupGatewayTokenRepairScenario(expectedToken: string) {
OPENCLAW_GATEWAY_TOKEN: expectedToken,
},
});
mocks.resolveGatewayInstallToken.mockResolvedValue({
token: expectedToken,
tokenRefConfigured: false,
warnings: [],
});
mocks.install.mockResolvedValue(undefined);
}
@@ -172,6 +182,57 @@ describe("maybeRepairGatewayServiceConfig", () => {
expect(mocks.install).toHaveBeenCalledTimes(1);
});
});
it("treats SecretRef-managed gateway token as non-persisted service state", async () => {
mocks.readCommand.mockResolvedValue({
programArguments: gatewayProgramArguments,
environment: {
OPENCLAW_GATEWAY_TOKEN: "stale-token",
},
});
mocks.resolveGatewayInstallToken.mockResolvedValue({
token: undefined,
tokenRefConfigured: true,
warnings: [],
});
mocks.auditGatewayServiceConfig.mockResolvedValue({
ok: false,
issues: [],
});
mocks.buildGatewayInstallPlan.mockResolvedValue({
programArguments: gatewayProgramArguments,
workingDirectory: "/tmp",
environment: {},
});
mocks.install.mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: {
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
},
},
},
};
await runRepair(cfg);
expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith(
expect.objectContaining({
expectedGatewayToken: undefined,
}),
);
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
}),
);
expect(mocks.install).toHaveBeenCalledTimes(1);
});
});
describe("maybeScanExtraGatewayServices", () => {

View File

@@ -5,6 +5,7 @@ import path from "node:path";
import { promisify } from "node:util";
import type { OpenClawConfig } from "../config/config.js";
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import {
findExtraGatewayServices,
renderGatewayServiceCleanupHints,
@@ -22,7 +23,9 @@ import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import { buildGatewayInstallPlan } from "./daemon-install-helpers.js";
import { DEFAULT_GATEWAY_DAEMON_RUNTIME, type GatewayDaemonRuntime } from "./daemon-runtime.js";
import { resolveGatewayAuthTokenForService } from "./doctor-gateway-auth-token.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
import { resolveGatewayInstallToken } from "./gateway-install-token.js";
const execFileAsync = promisify(execFile);
@@ -55,16 +58,6 @@ function normalizeExecutablePath(value: string): string {
return path.resolve(value);
}
function resolveGatewayAuthToken(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string | undefined {
const configToken = cfg.gateway?.auth?.token?.trim();
if (configToken) {
return configToken;
}
const envToken = env.OPENCLAW_GATEWAY_TOKEN ?? env.CLAWDBOT_GATEWAY_TOKEN;
const trimmedEnvToken = envToken?.trim();
return trimmedEnvToken || undefined;
}
function extractDetailPath(detail: string, prefix: string): string | null {
if (!detail.startsWith(prefix)) {
return null;
@@ -219,12 +212,35 @@ export async function maybeRepairGatewayServiceConfig(
return;
}
const expectedGatewayToken = resolveGatewayAuthToken(cfg, process.env);
const tokenRefConfigured = Boolean(
resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
}).ref,
);
const gatewayTokenResolution = await resolveGatewayAuthTokenForService(cfg, process.env);
if (gatewayTokenResolution.unavailableReason) {
note(
`Unable to verify gateway service token drift: ${gatewayTokenResolution.unavailableReason}`,
"Gateway service config",
);
}
const expectedGatewayToken = tokenRefConfigured ? undefined : gatewayTokenResolution.token;
const audit = await auditGatewayServiceConfig({
env: process.env,
command,
expectedGatewayToken,
});
const serviceToken = command.environment?.OPENCLAW_GATEWAY_TOKEN?.trim();
if (tokenRefConfigured && serviceToken) {
audit.issues.push({
code: SERVICE_AUDIT_CODES.gatewayTokenMismatch,
message:
"Gateway service OPENCLAW_GATEWAY_TOKEN should be unset when gateway.auth.token is SecretRef-managed",
detail: "service token is stale",
level: "recommended",
});
}
const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues);
const systemNodeInfo = needsNodeRuntime
? await resolveSystemNodeInfo({ env: process.env })
@@ -243,10 +259,24 @@ export async function maybeRepairGatewayServiceConfig(
const port = resolveGatewayPort(cfg, process.env);
const runtimeChoice = detectGatewayRuntime(command.programArguments);
const installTokenResolution = await resolveGatewayInstallToken({
config: cfg,
env: process.env,
});
for (const warning of installTokenResolution.warnings) {
note(warning, "Gateway service config");
}
if (installTokenResolution.unavailableReason) {
note(
`Unable to verify gateway service token drift: ${installTokenResolution.unavailableReason}`,
"Gateway service config",
);
return;
}
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port,
token: expectedGatewayToken,
token: installTokenResolution.token,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
warn: (message, title) => note(message, title),

View File

@@ -45,13 +45,11 @@ async function launchctlGetenv(name: string): Promise<string | undefined> {
}
function hasConfigGatewayCreds(cfg: OpenClawConfig): boolean {
const localToken =
typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway.auth.token : undefined;
const localPassword = cfg.gateway?.auth?.password;
const remoteToken = cfg.gateway?.remote?.token;
const remotePassword = cfg.gateway?.remote?.password;
return Boolean(
hasConfiguredSecretInput(localToken) ||
hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults) ||
hasConfiguredSecretInput(localPassword, cfg.secrets?.defaults) ||
hasConfiguredSecretInput(remoteToken, cfg.secrets?.defaults) ||
hasConfiguredSecretInput(remotePassword, cfg.secrets?.defaults),

View File

@@ -61,6 +61,22 @@ describe("noteSecurityWarnings gateway exposure", () => {
expect(message).not.toContain("CRITICAL");
});
it("treats SecretRef token config as authenticated for exposure warning level", async () => {
const cfg = {
gateway: {
bind: "lan",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
},
},
} as OpenClawConfig;
await noteSecurityWarnings(cfg);
const message = lastMessage();
expect(message).toContain("WARNING");
expect(message).not.toContain("CRITICAL");
});
it("treats whitespace token as missing", async () => {
const cfg = {
gateway: { bind: "lan", auth: { mode: "token", token: " " } },

View File

@@ -2,6 +2,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig, GatewayBindMode } from "../config/config.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js";
import { resolveDmAllowState } from "../security/dm-policy-shared.js";
@@ -44,8 +45,12 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
});
const authToken = resolvedAuth.token?.trim() ?? "";
const authPassword = resolvedAuth.password?.trim() ?? "";
const hasToken = authToken.length > 0;
const hasPassword = authPassword.length > 0;
const hasToken =
authToken.length > 0 ||
hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults);
const hasPassword =
authPassword.length > 0 ||
hasConfiguredSecretInput(cfg.gateway?.auth?.password, cfg.secrets?.defaults);
const hasSharedSecret =
(resolvedAuth.mode === "token" && hasToken) ||
(resolvedAuth.mode === "password" && hasPassword);

View File

@@ -12,7 +12,9 @@ import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { CONFIG_PATH, readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { resolveGatewayService } from "../daemon/service.js";
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
@@ -117,6 +119,17 @@ export async function doctorCommand(
}
note(lines.join("\n"), "Gateway");
}
if (resolveMode(cfg) === "local" && hasAmbiguousGatewayAuthModeConfig(cfg)) {
note(
[
"gateway.auth.token and gateway.auth.password are both configured while gateway.auth.mode is unset.",
"Set an explicit mode to avoid ambiguous auth selection and startup/runtime failures.",
`Set token mode: ${formatCliCommand("openclaw config set gateway.auth.mode token")}`,
`Set password mode: ${formatCliCommand("openclaw config set gateway.auth.mode password")}`,
].join("\n"),
"Gateway auth",
);
}
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
cfg = await maybeRemoveDeprecatedCliAuthProfiles(cfg, prompter);
@@ -130,39 +143,54 @@ export async function doctorCommand(
note(gatewayDetails.remoteFallbackNote, "Gateway");
}
if (resolveMode(cfg) === "local" && sourceConfigValid) {
const gatewayTokenRef = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
}).ref;
const auth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
});
const needsToken = auth.mode !== "password" && (auth.mode !== "token" || !auth.token);
if (needsToken) {
note(
"Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).",
"Gateway auth",
);
const shouldSetToken =
options.generateGatewayToken === true
? true
: options.nonInteractive === true
? false
: await prompter.confirmRepair({
message: "Generate and configure a gateway token now?",
initialValue: true,
});
if (shouldSetToken) {
const nextToken = randomToken();
cfg = {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
mode: "token",
token: nextToken,
if (gatewayTokenRef) {
note(
[
"Gateway token is managed via SecretRef and is currently unavailable.",
"Doctor will not overwrite gateway.auth.token with a plaintext value.",
"Resolve/rotate the external secret source, then rerun doctor.",
].join("\n"),
"Gateway auth",
);
} else {
note(
"Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).",
"Gateway auth",
);
const shouldSetToken =
options.generateGatewayToken === true
? true
: options.nonInteractive === true
? false
: await prompter.confirmRepair({
message: "Generate and configure a gateway token now?",
initialValue: true,
});
if (shouldSetToken) {
const nextToken = randomToken();
cfg = {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
mode: "token",
token: nextToken,
},
},
},
};
note("Gateway token configured.", "Gateway auth");
};
note("Gateway token configured.", "Gateway auth");
}
}
}
}

View File

@@ -87,4 +87,33 @@ describe("doctor command", () => {
);
expect(warned).toBe(false);
});
it("warns when token and password are both configured and gateway.auth.mode is unset", async () => {
mockDoctorConfigSnapshot({
config: {
gateway: {
mode: "local",
auth: {
token: "token-value",
password: "password-value",
},
},
},
});
note.mockClear();
await doctorCommand(createDoctorRuntime(), {
nonInteractive: true,
workspaceSuggestions: false,
});
const gatewayAuthNote = note.mock.calls.find((call) => call[1] === "Gateway auth");
expect(gatewayAuthNote).toBeTruthy();
expect(String(gatewayAuthNote?.[0])).toContain("gateway.auth.mode is unset");
expect(String(gatewayAuthNote?.[0])).toContain("openclaw config set gateway.auth.mode token");
expect(String(gatewayAuthNote?.[0])).toContain(
"openclaw config set gateway.auth.mode password",
);
});
});

View File

@@ -0,0 +1,283 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn());
const resolveSecretInputRefMock = vi.hoisted(() =>
vi.fn((): { ref: unknown } => ({ ref: undefined })),
);
const hasConfiguredSecretInputMock = vi.hoisted(() =>
vi.fn((value: unknown) => {
if (typeof value === "string") {
return value.trim().length > 0;
}
return value != null;
}),
);
const resolveGatewayAuthMock = vi.hoisted(() =>
vi.fn(() => ({
mode: "token",
token: undefined,
password: undefined,
allowTailscale: false,
})),
);
const shouldRequireGatewayTokenForInstallMock = vi.hoisted(() => vi.fn(() => true));
const resolveSecretRefValuesMock = vi.hoisted(() => vi.fn());
const secretRefKeyMock = vi.hoisted(() => vi.fn(() => "env:default:OPENCLAW_GATEWAY_TOKEN"));
const randomTokenMock = vi.hoisted(() => vi.fn(() => "generated-token"));
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: readConfigFileSnapshotMock,
writeConfigFile: writeConfigFileMock,
}));
vi.mock("../config/types.secrets.js", () => ({
resolveSecretInputRef: resolveSecretInputRefMock,
hasConfiguredSecretInput: hasConfiguredSecretInputMock,
}));
vi.mock("../gateway/auth.js", () => ({
resolveGatewayAuth: resolveGatewayAuthMock,
}));
vi.mock("../gateway/auth-install-policy.js", () => ({
shouldRequireGatewayTokenForInstall: shouldRequireGatewayTokenForInstallMock,
}));
vi.mock("../secrets/ref-contract.js", () => ({
secretRefKey: secretRefKeyMock,
}));
vi.mock("../secrets/resolve.js", () => ({
resolveSecretRefValues: resolveSecretRefValuesMock,
}));
vi.mock("./onboard-helpers.js", () => ({
randomToken: randomTokenMock,
}));
const { resolveGatewayInstallToken } = await import("./gateway-install-token.js");
describe("resolveGatewayInstallToken", () => {
beforeEach(() => {
vi.clearAllMocks();
readConfigFileSnapshotMock.mockResolvedValue({ exists: false, valid: true, config: {} });
resolveSecretInputRefMock.mockReturnValue({ ref: undefined });
hasConfiguredSecretInputMock.mockImplementation((value: unknown) => {
if (typeof value === "string") {
return value.trim().length > 0;
}
return value != null;
});
resolveSecretRefValuesMock.mockResolvedValue(new Map());
shouldRequireGatewayTokenForInstallMock.mockReturnValue(true);
resolveGatewayAuthMock.mockReturnValue({
mode: "token",
token: undefined,
password: undefined,
allowTailscale: false,
});
randomTokenMock.mockReturnValue("generated-token");
});
it("uses plaintext gateway.auth.token when configured", async () => {
const result = await resolveGatewayInstallToken({
config: {
gateway: { auth: { token: "config-token" } },
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
});
expect(result).toEqual({
token: "config-token",
tokenRefConfigured: false,
unavailableReason: undefined,
warnings: [],
});
});
it("validates SecretRef token but does not persist resolved plaintext", async () => {
const tokenRef = { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" };
resolveSecretInputRefMock.mockReturnValue({ ref: tokenRef });
resolveSecretRefValuesMock.mockResolvedValue(
new Map([["env:default:OPENCLAW_GATEWAY_TOKEN", "resolved-token"]]),
);
const result = await resolveGatewayInstallToken({
config: {
gateway: { auth: { mode: "token", token: tokenRef } },
} as OpenClawConfig,
env: { OPENCLAW_GATEWAY_TOKEN: "resolved-token" } as NodeJS.ProcessEnv,
});
expect(result.token).toBeUndefined();
expect(result.tokenRefConfigured).toBe(true);
expect(result.unavailableReason).toBeUndefined();
expect(result.warnings.some((message) => message.includes("SecretRef-managed"))).toBeTruthy();
});
it("returns unavailable reason when token SecretRef is unresolved in token mode", async () => {
resolveSecretInputRefMock.mockReturnValue({
ref: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
});
resolveSecretRefValuesMock.mockRejectedValue(new Error("missing env var"));
const result = await resolveGatewayInstallToken({
config: {
gateway: { auth: { mode: "token", token: "${MISSING_GATEWAY_TOKEN}" } },
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
});
expect(result.token).toBeUndefined();
expect(result.unavailableReason).toContain("gateway.auth.token SecretRef is configured");
});
it("returns unavailable reason when token and password are both configured and mode is unset", async () => {
const result = await resolveGatewayInstallToken({
config: {
gateway: {
auth: {
token: "token-value",
password: "password-value",
},
},
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
autoGenerateWhenMissing: true,
persistGeneratedToken: true,
});
expect(result.token).toBeUndefined();
expect(result.unavailableReason).toContain("gateway.auth.mode is unset");
expect(result.unavailableReason).toContain("openclaw config set gateway.auth.mode token");
expect(result.unavailableReason).toContain("openclaw config set gateway.auth.mode password");
expect(writeConfigFileMock).not.toHaveBeenCalled();
expect(resolveSecretRefValuesMock).not.toHaveBeenCalled();
});
it("auto-generates token when no source exists and auto-generation is enabled", async () => {
const result = await resolveGatewayInstallToken({
config: {
gateway: { auth: { mode: "token" } },
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
autoGenerateWhenMissing: true,
});
expect(result.token).toBe("generated-token");
expect(result.unavailableReason).toBeUndefined();
expect(
result.warnings.some((message) => message.includes("without saving to config")),
).toBeTruthy();
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("persists auto-generated token when requested", async () => {
const result = await resolveGatewayInstallToken({
config: {
gateway: { auth: { mode: "token" } },
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
autoGenerateWhenMissing: true,
persistGeneratedToken: true,
});
expect(result.warnings.some((message) => message.includes("saving to config"))).toBeTruthy();
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
gateway: {
auth: {
mode: "token",
token: "generated-token",
},
},
}),
);
});
it("drops generated plaintext when config changes to SecretRef before persist", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
config: {
gateway: {
auth: {
token: "${OPENCLAW_GATEWAY_TOKEN}",
},
},
},
issues: [],
});
resolveSecretInputRefMock.mockReturnValueOnce({ ref: undefined }).mockReturnValueOnce({
ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
});
const result = await resolveGatewayInstallToken({
config: {
gateway: { auth: { mode: "token" } },
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
autoGenerateWhenMissing: true,
persistGeneratedToken: true,
});
expect(result.token).toBeUndefined();
expect(
result.warnings.some((message) => message.includes("skipping plaintext token persistence")),
).toBeTruthy();
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("does not auto-generate when inferred mode has password SecretRef configured", async () => {
shouldRequireGatewayTokenForInstallMock.mockReturnValue(false);
const result = await resolveGatewayInstallToken({
config: {
gateway: {
auth: {
password: { source: "env", provider: "default", id: "GATEWAY_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
autoGenerateWhenMissing: true,
persistGeneratedToken: true,
});
expect(result.token).toBeUndefined();
expect(result.unavailableReason).toBeUndefined();
expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false);
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("skips token SecretRef resolution when token auth is not required", async () => {
const tokenRef = { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" };
resolveSecretInputRefMock.mockReturnValue({ ref: tokenRef });
shouldRequireGatewayTokenForInstallMock.mockReturnValue(false);
const result = await resolveGatewayInstallToken({
config: {
gateway: {
auth: {
mode: "password",
token: tokenRef,
},
},
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
});
expect(resolveSecretRefValuesMock).not.toHaveBeenCalled();
expect(result.unavailableReason).toBeUndefined();
expect(result.warnings).toEqual([]);
expect(result.token).toBeUndefined();
expect(result.tokenRefConfigured).toBe(true);
});
});

View File

@@ -0,0 +1,147 @@
import { formatCliCommand } from "../cli/command-format.js";
import { readConfigFileSnapshot, writeConfigFile, type OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { shouldRequireGatewayTokenForInstall } from "../gateway/auth-install-policy.js";
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
import { randomToken } from "./onboard-helpers.js";
type GatewayInstallTokenOptions = {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
explicitToken?: string;
autoGenerateWhenMissing?: boolean;
persistGeneratedToken?: boolean;
};
export type GatewayInstallTokenResolution = {
token?: string;
tokenRefConfigured: boolean;
unavailableReason?: string;
warnings: string[];
};
function formatAmbiguousGatewayAuthModeReason(): string {
return [
"gateway.auth.token and gateway.auth.password are both configured while gateway.auth.mode is unset.",
`Set ${formatCliCommand("openclaw config set gateway.auth.mode token")} or ${formatCliCommand("openclaw config set gateway.auth.mode password")}.`,
].join(" ");
}
export async function resolveGatewayInstallToken(
options: GatewayInstallTokenOptions,
): Promise<GatewayInstallTokenResolution> {
const cfg = options.config;
const warnings: string[] = [];
const tokenRef = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
}).ref;
const tokenRefConfigured = Boolean(tokenRef);
const configToken =
tokenRef || typeof cfg.gateway?.auth?.token !== "string"
? undefined
: cfg.gateway.auth.token.trim() || undefined;
const explicitToken = options.explicitToken?.trim() || undefined;
const envToken =
options.env.OPENCLAW_GATEWAY_TOKEN?.trim() || options.env.CLAWDBOT_GATEWAY_TOKEN?.trim();
if (hasAmbiguousGatewayAuthModeConfig(cfg)) {
return {
token: undefined,
tokenRefConfigured,
unavailableReason: formatAmbiguousGatewayAuthModeReason(),
warnings,
};
}
const resolvedAuth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
});
const needsToken =
shouldRequireGatewayTokenForInstall(cfg, options.env) && !resolvedAuth.allowTailscale;
let token: string | undefined = explicitToken || configToken || (tokenRef ? undefined : envToken);
let unavailableReason: string | undefined;
if (tokenRef && !token && needsToken) {
try {
const resolved = await resolveSecretRefValues([tokenRef], {
config: cfg,
env: options.env,
});
const value = resolved.get(secretRefKey(tokenRef));
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error("gateway.auth.token resolved to an empty or non-string value.");
}
warnings.push(
"gateway.auth.token is SecretRef-managed; install will not persist a resolved token in service environment. Ensure the SecretRef is resolvable in the daemon runtime context.",
);
} catch (err) {
unavailableReason = `gateway.auth.token SecretRef is configured but unresolved (${String(err)}).`;
}
}
const allowAutoGenerate = options.autoGenerateWhenMissing ?? false;
const persistGeneratedToken = options.persistGeneratedToken ?? false;
if (!token && needsToken && !tokenRef && allowAutoGenerate) {
token = randomToken();
warnings.push(
persistGeneratedToken
? "No gateway token found. Auto-generated one and saving to config."
: "No gateway token found. Auto-generated one for this run without saving to config.",
);
if (persistGeneratedToken) {
// Persist token in config so daemon and CLI share a stable credential source.
try {
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
warnings.push("Warning: config file exists but is invalid; skipping token persistence.");
} else {
const baseConfig = snapshot.exists ? snapshot.config : {};
const existingTokenRef = resolveSecretInputRef({
value: baseConfig.gateway?.auth?.token,
defaults: baseConfig.secrets?.defaults,
}).ref;
const baseConfigToken =
existingTokenRef || typeof baseConfig.gateway?.auth?.token !== "string"
? undefined
: baseConfig.gateway.auth.token.trim() || undefined;
if (!existingTokenRef && !baseConfigToken) {
await writeConfigFile({
...baseConfig,
gateway: {
...baseConfig.gateway,
auth: {
...baseConfig.gateway?.auth,
mode: baseConfig.gateway?.auth?.mode ?? "token",
token,
},
},
});
} else if (baseConfigToken) {
token = baseConfigToken;
} else {
token = undefined;
warnings.push(
"Warning: gateway.auth.token is SecretRef-managed; skipping plaintext token persistence.",
);
}
}
} catch (err) {
warnings.push(`Warning: could not persist token to config: ${String(err)}`);
}
}
}
return {
token,
tokenRefConfigured,
unavailableReason,
warnings,
};
}

View File

@@ -184,6 +184,268 @@ describe("gateway-status command", () => {
expect(targets[0]?.summary).toBeTruthy();
});
it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => {
loadConfig.mockReturnValueOnce({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "local",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
} as unknown as ReturnType<typeof loadConfig>);
await runGatewayStatus(runtime, { timeout: "1000", json: true });
});
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>;
};
const unresolvedWarning = parsed.warnings?.find(
(warning) =>
warning.code === "auth_secretref_unresolved" &&
warning.message?.includes("gateway.auth.token SecretRef is unresolved"),
);
expect(unresolvedWarning).toBeTruthy();
expect(unresolvedWarning?.targetIds).toContain("localLoopback");
expect(unresolvedWarning?.message).toContain("env:default:MISSING_GATEWAY_TOKEN");
expect(unresolvedWarning?.message).not.toContain("missing or empty");
});
it("does not resolve local token SecretRef when OPENCLAW_GATEWAY_TOKEN is set", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: "env-token",
MISSING_GATEWAY_TOKEN: undefined,
},
async () => {
loadConfig.mockReturnValueOnce({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "local",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
} as unknown as ReturnType<typeof loadConfig>);
await runGatewayStatus(runtime, { timeout: "1000", json: true });
},
);
expect(runtimeErrors).toHaveLength(0);
expect(probeGateway).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({
token: "env-token",
}),
}),
);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
warnings?: Array<{ code?: string; message?: string }>;
};
const unresolvedWarning = parsed.warnings?.find(
(warning) =>
warning.code === "auth_secretref_unresolved" &&
warning.message?.includes("gateway.auth.token SecretRef is unresolved"),
);
expect(unresolvedWarning).toBeUndefined();
});
it("does not resolve local password SecretRef in token mode", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: "env-token",
MISSING_GATEWAY_PASSWORD: undefined,
},
async () => {
loadConfig.mockReturnValueOnce({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "local",
auth: {
mode: "token",
token: "config-token",
password: { source: "env", provider: "default", id: "MISSING_GATEWAY_PASSWORD" },
},
},
} as unknown as ReturnType<typeof loadConfig>);
await runGatewayStatus(runtime, { timeout: "1000", json: true });
},
);
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
warnings?: Array<{ code?: string; message?: string }>;
};
const unresolvedPasswordWarning = parsed.warnings?.find(
(warning) =>
warning.code === "auth_secretref_unresolved" &&
warning.message?.includes("gateway.auth.password SecretRef is unresolved"),
);
expect(unresolvedPasswordWarning).toBeUndefined();
});
it("resolves env-template gateway.auth.token before probing targets", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
await withEnvAsync(
{
CUSTOM_GATEWAY_TOKEN: "resolved-gateway-token",
OPENCLAW_GATEWAY_TOKEN: undefined,
CLAWDBOT_GATEWAY_TOKEN: undefined,
},
async () => {
loadConfig.mockReturnValueOnce({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "local",
auth: {
mode: "token",
token: "${CUSTOM_GATEWAY_TOKEN}",
},
},
} as unknown as ReturnType<typeof loadConfig>);
await runGatewayStatus(runtime, { timeout: "1000", json: true });
},
);
expect(runtimeErrors).toHaveLength(0);
expect(probeGateway).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({
token: "resolved-gateway-token",
}),
}),
);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
warnings?: Array<{ code?: string }>;
};
const unresolvedWarning = parsed.warnings?.find(
(warning) => warning.code === "auth_secretref_unresolved",
);
expect(unresolvedWarning).toBeUndefined();
});
it("emits stable SecretRef auth configuration booleans in --json output", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
const previousProbeImpl = probeGateway.getMockImplementation();
probeGateway.mockImplementation(async (opts: { url: string }) => ({
ok: true,
url: opts.url,
connectLatencyMs: 20,
error: null,
close: null,
health: { ok: true },
status: {
linkChannel: {
id: "whatsapp",
label: "WhatsApp",
linked: true,
authAgeMs: 1_000,
},
sessions: { count: 1 },
},
presence: [{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" }],
configSnapshot: {
path: "/tmp/secretref-config.json",
exists: true,
valid: true,
config: {
secrets: {
defaults: {
env: "default",
},
},
gateway: {
mode: "remote",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
password: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_PASSWORD" },
},
remote: {
url: "wss://remote.example:18789",
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
password: { source: "env", provider: "default", id: "REMOTE_GATEWAY_PASSWORD" },
},
},
discovery: {
wideArea: { enabled: true },
},
},
issues: [],
legacyIssues: [],
},
}));
try {
await runGatewayStatus(runtime, { timeout: "1000", json: true });
} finally {
if (previousProbeImpl) {
probeGateway.mockImplementation(previousProbeImpl);
} else {
probeGateway.mockReset();
}
}
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
targets?: Array<Record<string, unknown>>;
};
const configRemoteTarget = parsed.targets?.find((target) => target.kind === "configRemote");
expect(configRemoteTarget?.config).toMatchInlineSnapshot(`
{
"discovery": {
"wideAreaEnabled": true,
},
"exists": true,
"gateway": {
"authMode": "token",
"authPasswordConfigured": true,
"authTokenConfigured": true,
"bind": null,
"controlUiBasePath": null,
"controlUiEnabled": null,
"mode": "remote",
"port": null,
"remotePasswordConfigured": true,
"remoteTokenConfigured": true,
"remoteUrl": "wss://remote.example:18789",
"tailscaleMode": null,
},
"issues": [],
"legacyIssues": [],
"path": "/tmp/secretref-config.json",
"valid": true,
}
`);
});
it("supports SSH tunnel targets", async () => {
const { runtime, runtimeLogs } = createRuntimeCapture();

View File

@@ -152,10 +152,14 @@ export async function gatewayStatusCommand(
try {
const probed = await Promise.all(
targets.map(async (target) => {
const auth = resolveAuthForTarget(cfg, target, {
const authResolution = await resolveAuthForTarget(cfg, target, {
token: typeof opts.token === "string" ? opts.token : undefined,
password: typeof opts.password === "string" ? opts.password : undefined,
});
const auth = {
token: authResolution.token,
password: authResolution.password,
};
const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind);
const probe = await probeGateway({
url: target.url,
@@ -166,7 +170,13 @@ export async function gatewayStatusCommand(
? extractConfigSummary(probe.configSnapshot)
: null;
const self = pickGatewaySelfPresence(probe.presence);
return { target, probe, configSummary, self };
return {
target,
probe,
configSummary,
self,
authDiagnostics: authResolution.diagnostics ?? [],
};
}),
);
@@ -214,6 +224,18 @@ export async function gatewayStatusCommand(
targetIds: reachable.map((p) => p.target.id),
});
}
for (const result of probed) {
if (result.authDiagnostics.length === 0) {
continue;
}
for (const diagnostic of result.authDiagnostics) {
warnings.push({
code: "auth_secretref_unresolved",
message: diagnostic,
targetIds: [result.target.id],
});
}
}
if (opts.json) {
runtime.log(

View File

@@ -0,0 +1,235 @@
import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../../test-utils/env.js";
import { extractConfigSummary, resolveAuthForTarget } from "./helpers.js";
describe("extractConfigSummary", () => {
it("marks SecretRef-backed gateway auth credentials as configured", () => {
const summary = extractConfigSummary({
path: "/tmp/openclaw.json",
exists: true,
valid: true,
issues: [],
legacyIssues: [],
config: {
secrets: {
defaults: {
env: "default",
},
},
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
password: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_PASSWORD" },
},
remote: {
url: "wss://remote.example:18789",
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
password: { source: "env", provider: "default", id: "REMOTE_GATEWAY_PASSWORD" },
},
},
},
});
expect(summary.gateway.authTokenConfigured).toBe(true);
expect(summary.gateway.authPasswordConfigured).toBe(true);
expect(summary.gateway.remoteTokenConfigured).toBe(true);
expect(summary.gateway.remotePasswordConfigured).toBe(true);
});
it("still treats empty plaintext auth values as not configured", () => {
const summary = extractConfigSummary({
path: "/tmp/openclaw.json",
exists: true,
valid: true,
issues: [],
legacyIssues: [],
config: {
gateway: {
auth: {
mode: "token",
token: " ",
password: "",
},
remote: {
token: " ",
password: "",
},
},
},
});
expect(summary.gateway.authTokenConfigured).toBe(false);
expect(summary.gateway.authPasswordConfigured).toBe(false);
expect(summary.gateway.remoteTokenConfigured).toBe(false);
expect(summary.gateway.remotePasswordConfigured).toBe(false);
});
});
describe("resolveAuthForTarget", () => {
it("resolves local auth token SecretRef before probing local targets", async () => {
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_GATEWAY_PASSWORD: undefined,
LOCAL_GATEWAY_TOKEN: "resolved-local-token",
},
async () => {
const auth = await resolveAuthForTarget(
{
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
auth: {
token: { source: "env", provider: "default", id: "LOCAL_GATEWAY_TOKEN" },
},
},
},
{
id: "localLoopback",
kind: "localLoopback",
url: "ws://127.0.0.1:18789",
active: true,
},
{},
);
expect(auth).toEqual({ token: "resolved-local-token", password: undefined });
},
);
});
it("resolves remote auth token SecretRef before probing remote targets", async () => {
await withEnvAsync(
{
REMOTE_GATEWAY_TOKEN: "resolved-remote-token",
},
async () => {
const auth = await resolveAuthForTarget(
{
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
remote: {
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
},
},
},
{
id: "configRemote",
kind: "configRemote",
url: "wss://remote.example:18789",
active: true,
},
{},
);
expect(auth).toEqual({ token: "resolved-remote-token", password: undefined });
},
);
});
it("resolves remote auth even when local auth mode is none", async () => {
await withEnvAsync(
{
REMOTE_GATEWAY_TOKEN: "resolved-remote-token",
},
async () => {
const auth = await resolveAuthForTarget(
{
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
auth: {
mode: "none",
},
remote: {
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
},
},
},
{
id: "configRemote",
kind: "configRemote",
url: "wss://remote.example:18789",
active: true,
},
{},
);
expect(auth).toEqual({ token: "resolved-remote-token", password: undefined });
},
);
});
it("does not force remote auth type from local auth mode", async () => {
const auth = await resolveAuthForTarget(
{
gateway: {
auth: {
mode: "password",
},
remote: {
token: "remote-token",
password: "remote-password",
},
},
},
{
id: "configRemote",
kind: "configRemote",
url: "wss://remote.example:18789",
active: true,
},
{},
);
expect(auth).toEqual({ token: "remote-token", password: undefined });
});
it("redacts resolver internals from unresolved SecretRef diagnostics", async () => {
await withEnvAsync(
{
MISSING_GATEWAY_TOKEN: undefined,
},
async () => {
const auth = await resolveAuthForTarget(
{
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
},
{
id: "localLoopback",
kind: "localLoopback",
url: "ws://127.0.0.1:18789",
active: true,
},
{},
);
expect(auth.diagnostics).toContain(
"gateway.auth.token SecretRef is unresolved (env:default:MISSING_GATEWAY_TOKEN).",
);
expect(auth.diagnostics?.join("\n")).not.toContain("missing or empty");
},
);
});
});

View File

@@ -1,6 +1,8 @@
import { resolveGatewayPort } from "../../config/config.js";
import type { OpenClawConfig, ConfigFileSnapshot } from "../../config/types.js";
import { hasConfiguredSecretInput } from "../../config/types.secrets.js";
import type { GatewayProbeResult } from "../../gateway/probe.js";
import { resolveConfiguredSecretInputString } from "../../gateway/resolve-configured-secret-input-string.js";
import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js";
import { colorize, theme } from "../../terminal/theme.js";
import { pickGatewaySelfPresence } from "../gateway-presence.js";
@@ -144,38 +146,124 @@ export function sanitizeSshTarget(value: unknown): string | null {
return trimmed.replace(/^ssh\\s+/, "");
}
export function resolveAuthForTarget(
function readGatewayTokenEnv(env: NodeJS.ProcessEnv = process.env): string | undefined {
const token = env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim();
return token || undefined;
}
function readGatewayPasswordEnv(env: NodeJS.ProcessEnv = process.env): string | undefined {
const password = env.OPENCLAW_GATEWAY_PASSWORD?.trim() || env.CLAWDBOT_GATEWAY_PASSWORD?.trim();
return password || undefined;
}
export async function resolveAuthForTarget(
cfg: OpenClawConfig,
target: GatewayStatusTarget,
overrides: { token?: string; password?: string },
): { token?: string; password?: string } {
): Promise<{ token?: string; password?: string; diagnostics?: string[] }> {
const tokenOverride = overrides.token?.trim() ? overrides.token.trim() : undefined;
const passwordOverride = overrides.password?.trim() ? overrides.password.trim() : undefined;
if (tokenOverride || passwordOverride) {
return { token: tokenOverride, password: passwordOverride };
}
const diagnostics: string[] = [];
const authMode = cfg.gateway?.auth?.mode;
const tokenOnly = authMode === "token";
const passwordOnly = authMode === "password";
const resolveToken = async (value: unknown, path: string): Promise<string | undefined> => {
const tokenResolution = await resolveConfiguredSecretInputString({
config: cfg,
env: process.env,
value,
path,
unresolvedReasonStyle: "detailed",
});
if (tokenResolution.unresolvedRefReason) {
diagnostics.push(tokenResolution.unresolvedRefReason);
}
return tokenResolution.value;
};
const resolvePassword = async (value: unknown, path: string): Promise<string | undefined> => {
const passwordResolution = await resolveConfiguredSecretInputString({
config: cfg,
env: process.env,
value,
path,
unresolvedReasonStyle: "detailed",
});
if (passwordResolution.unresolvedRefReason) {
diagnostics.push(passwordResolution.unresolvedRefReason);
}
return passwordResolution.value;
};
if (target.kind === "configRemote" || target.kind === "sshTunnel") {
const token =
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : "";
const remotePassword = (cfg.gateway?.remote as { password?: unknown } | undefined)?.password;
const password = typeof remotePassword === "string" ? remotePassword.trim() : "";
const remoteTokenValue = cfg.gateway?.remote?.token;
const remotePasswordValue = (cfg.gateway?.remote as { password?: unknown } | undefined)
?.password;
const token = await resolveToken(remoteTokenValue, "gateway.remote.token");
const password = token
? undefined
: await resolvePassword(remotePasswordValue, "gateway.remote.password");
return {
token: token.length > 0 ? token : undefined,
password: password.length > 0 ? password : undefined,
token,
password,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
}
const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || "";
const envPassword = process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || "";
const cfgToken =
typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway.auth.token.trim() : "";
const cfgPassword =
typeof cfg.gateway?.auth?.password === "string" ? cfg.gateway.auth.password.trim() : "";
const authDisabled = authMode === "none" || authMode === "trusted-proxy";
if (authDisabled) {
return {};
}
const envToken = readGatewayTokenEnv();
const envPassword = readGatewayPasswordEnv();
if (tokenOnly) {
if (envToken) {
return { token: envToken };
}
const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token");
return {
token,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
}
if (passwordOnly) {
if (envPassword) {
return { password: envPassword };
}
const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password");
return {
password,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
}
if (envToken) {
return { token: envToken };
}
const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token");
if (token) {
return {
token,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
}
if (envPassword) {
return {
password: envPassword,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
}
const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password");
return {
token: envToken || cfgToken || undefined,
password: envPassword || cfgPassword || undefined,
token,
password,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
}
@@ -191,6 +279,10 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
const cfg = (snap?.config ?? {}) as Record<string, unknown>;
const gateway = (cfg.gateway ?? {}) as Record<string, unknown>;
const secrets = (cfg.secrets ?? {}) as Record<string, unknown>;
const secretDefaults = (secrets.defaults ?? undefined) as
| { env?: string; file?: string; exec?: string }
| undefined;
const discovery = (cfg.discovery ?? {}) as Record<string, unknown>;
const wideArea = (discovery.wideArea ?? {}) as Record<string, unknown>;
@@ -200,15 +292,12 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
const tailscale = (gateway.tailscale ?? {}) as Record<string, unknown>;
const authMode = typeof auth.mode === "string" ? auth.mode : null;
const authTokenConfigured = typeof auth.token === "string" ? auth.token.trim().length > 0 : false;
const authPasswordConfigured =
typeof auth.password === "string" ? auth.password.trim().length > 0 : false;
const authTokenConfigured = hasConfiguredSecretInput(auth.token, secretDefaults);
const authPasswordConfigured = hasConfiguredSecretInput(auth.password, secretDefaults);
const remoteUrl = typeof remote.url === "string" ? normalizeWsUrl(remote.url) : null;
const remoteTokenConfigured =
typeof remote.token === "string" ? remote.token.trim().length > 0 : false;
const remotePasswordConfigured =
typeof remote.password === "string" ? String(remote.password).trim().length > 0 : false;
const remoteTokenConfigured = hasConfiguredSecretInput(remote.token, secretDefaults);
const remotePasswordConfigured = hasConfiguredSecretInput(remote.password, secretDefaults);
const wideAreaEnabled = typeof wideArea.enabled === "boolean" ? wideArea.enabled : null;

View File

@@ -9,7 +9,7 @@ const gatewayClientCalls: Array<{
url?: string;
token?: string;
password?: string;
onHelloOk?: () => void;
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void;
onClose?: (code: number, reason: string) => void;
}> = [];
const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {});
@@ -20,13 +20,13 @@ vi.mock("../gateway/client.js", () => ({
url?: string;
token?: string;
password?: string;
onHelloOk?: () => void;
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void;
};
constructor(params: {
url?: string;
token?: string;
password?: string;
onHelloOk?: () => void;
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void;
}) {
this.params = params;
gatewayClientCalls.push(params);
@@ -35,7 +35,7 @@ vi.mock("../gateway/client.js", () => ({
return { ok: true };
}
start() {
queueMicrotask(() => this.params.onHelloOk?.());
queueMicrotask(() => this.params.onHelloOk?.({ features: { methods: ["health"] } }));
}
stop() {}
},
@@ -191,6 +191,84 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
});
}, 60_000);
it("writes gateway token SecretRef from --gateway-token-ref-env", async () => {
await withStateDir("state-env-token-ref-", async (stateDir) => {
const envToken = "tok_env_ref_123";
const workspace = path.join(stateDir, "openclaw");
const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = envToken;
try {
await runNonInteractiveOnboarding(
{
nonInteractive: true,
mode: "local",
workspace,
authChoice: "skip",
skipSkills: true,
skipHealth: true,
installDaemon: false,
gatewayBind: "loopback",
gatewayAuth: "token",
gatewayTokenRefEnv: "OPENCLAW_GATEWAY_TOKEN",
},
runtime,
);
const configPath = resolveStateConfigPath(process.env, stateDir);
const cfg = await readJsonFile<{
gateway?: { auth?: { mode?: string; token?: unknown } };
}>(configPath);
expect(cfg?.gateway?.auth?.mode).toBe("token");
expect(cfg?.gateway?.auth?.token).toEqual({
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
});
} finally {
if (prevToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
}
}
});
}, 60_000);
it("fails when --gateway-token-ref-env points to a missing env var", async () => {
await withStateDir("state-env-token-ref-missing-", async (stateDir) => {
const workspace = path.join(stateDir, "openclaw");
const previous = process.env.MISSING_GATEWAY_TOKEN_ENV;
delete process.env.MISSING_GATEWAY_TOKEN_ENV;
try {
await expect(
runNonInteractiveOnboarding(
{
nonInteractive: true,
mode: "local",
workspace,
authChoice: "skip",
skipSkills: true,
skipHealth: true,
installDaemon: false,
gatewayBind: "loopback",
gatewayAuth: "token",
gatewayTokenRefEnv: "MISSING_GATEWAY_TOKEN_ENV",
},
runtime,
),
).rejects.toThrow(/MISSING_GATEWAY_TOKEN_ENV/);
} finally {
if (previous === undefined) {
delete process.env.MISSING_GATEWAY_TOKEN_ENV;
} else {
process.env.MISSING_GATEWAY_TOKEN_ENV = previous;
}
}
});
}, 60_000);
it("writes gateway.remote url/token and callGateway uses them", async () => {
await withStateDir("state-remote-", async () => {
const port = getPseudoPort(30_000);

View File

@@ -92,7 +92,6 @@ export async function runNonInteractiveOnboardingLocal(params: {
opts,
runtime,
port: gatewayResult.port,
gatewayToken: gatewayResult.gatewayToken,
});
}

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
const buildGatewayInstallPlan = vi.hoisted(() => vi.fn());
const gatewayInstallErrorHint = vi.hoisted(() => vi.fn(() => "hint"));
const resolveGatewayInstallToken = vi.hoisted(() => vi.fn());
const serviceInstall = vi.hoisted(() => vi.fn(async () => {}));
const ensureSystemdUserLingerNonInteractive = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../../daemon-install-helpers.js", () => ({
buildGatewayInstallPlan,
gatewayInstallErrorHint,
}));
vi.mock("../../gateway-install-token.js", () => ({
resolveGatewayInstallToken,
}));
vi.mock("../../../daemon/service.js", () => ({
resolveGatewayService: vi.fn(() => ({
install: serviceInstall,
})),
}));
vi.mock("../../../daemon/systemd.js", () => ({
isSystemdUserServiceAvailable: vi.fn(async () => true),
}));
vi.mock("../../daemon-runtime.js", () => ({
DEFAULT_GATEWAY_DAEMON_RUNTIME: "node",
isGatewayDaemonRuntime: vi.fn(() => true),
}));
vi.mock("../../systemd-linger.js", () => ({
ensureSystemdUserLingerNonInteractive,
}));
const { installGatewayDaemonNonInteractive } = await import("./daemon-install.js");
describe("installGatewayDaemonNonInteractive", () => {
beforeEach(() => {
vi.clearAllMocks();
resolveGatewayInstallToken.mockResolvedValue({
token: undefined,
tokenRefConfigured: true,
warnings: [],
});
buildGatewayInstallPlan.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
workingDirectory: "/tmp",
environment: {},
});
});
it("does not pass plaintext token for SecretRef-managed install", async () => {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await installGatewayDaemonNonInteractive({
nextConfig: {
gateway: {
auth: {
mode: "token",
token: {
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
},
},
},
} as OpenClawConfig,
opts: { installDaemon: true },
runtime,
port: 18789,
});
expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1);
expect(buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
}),
);
expect(serviceInstall).toHaveBeenCalledTimes(1);
});
it("aborts with actionable error when SecretRef is unresolved", async () => {
resolveGatewayInstallToken.mockResolvedValue({
token: undefined,
tokenRefConfigured: true,
unavailableReason: "gateway.auth.token SecretRef is configured but unresolved (boom).",
warnings: [],
});
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await installGatewayDaemonNonInteractive({
nextConfig: {} as OpenClawConfig,
opts: { installDaemon: true },
runtime,
port: 18789,
});
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Gateway install blocked"));
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(buildGatewayInstallPlan).not.toHaveBeenCalled();
expect(serviceInstall).not.toHaveBeenCalled();
});
});

View File

@@ -4,6 +4,7 @@ import { isSystemdUserServiceAvailable } from "../../../daemon/systemd.js";
import type { RuntimeEnv } from "../../../runtime.js";
import { buildGatewayInstallPlan, gatewayInstallErrorHint } from "../../daemon-install-helpers.js";
import { DEFAULT_GATEWAY_DAEMON_RUNTIME, isGatewayDaemonRuntime } from "../../daemon-runtime.js";
import { resolveGatewayInstallToken } from "../../gateway-install-token.js";
import type { OnboardOptions } from "../../onboard-types.js";
import { ensureSystemdUserLingerNonInteractive } from "../../systemd-linger.js";
@@ -12,9 +13,8 @@ export async function installGatewayDaemonNonInteractive(params: {
opts: OnboardOptions;
runtime: RuntimeEnv;
port: number;
gatewayToken?: string;
}) {
const { opts, runtime, port, gatewayToken } = params;
const { opts, runtime, port } = params;
if (!opts.installDaemon) {
return;
}
@@ -34,10 +34,28 @@ export async function installGatewayDaemonNonInteractive(params: {
}
const service = resolveGatewayService();
const tokenResolution = await resolveGatewayInstallToken({
config: params.nextConfig,
env: process.env,
});
for (const warning of tokenResolution.warnings) {
runtime.log(warning);
}
if (tokenResolution.unavailableReason) {
runtime.error(
[
"Gateway install blocked:",
tokenResolution.unavailableReason,
"Fix gateway auth config/token input and rerun onboarding.",
].join(" "),
);
runtime.exit(1);
return;
}
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port,
token: gatewayToken,
token: tokenResolution.token,
runtime: daemonRuntimeRaw,
warn: (message) => runtime.log(message),
config: params.nextConfig,

View File

@@ -1,5 +1,7 @@
import type { OpenClawConfig } from "../../../config/config.js";
import { isValidEnvSecretRefId } from "../../../config/types.secrets.js";
import type { RuntimeEnv } from "../../../runtime.js";
import { resolveDefaultSecretProviderAlias } from "../../../secrets/ref-contract.js";
import { normalizeGatewayTokenInput, randomToken } from "../../onboard-helpers.js";
import type { OnboardOptions } from "../../onboard-types.js";
@@ -49,26 +51,65 @@ export function applyNonInteractiveGatewayConfig(params: {
}
let nextConfig = params.nextConfig;
let gatewayToken =
normalizeGatewayTokenInput(opts.gatewayToken) ||
normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN) ||
undefined;
const explicitGatewayToken = normalizeGatewayTokenInput(opts.gatewayToken);
const envGatewayToken = normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN);
let gatewayToken = explicitGatewayToken || envGatewayToken || undefined;
const gatewayTokenRefEnv = String(opts.gatewayTokenRefEnv ?? "").trim();
if (authMode === "token") {
if (!gatewayToken) {
gatewayToken = randomToken();
}
nextConfig = {
...nextConfig,
gateway: {
...nextConfig.gateway,
auth: {
...nextConfig.gateway?.auth,
mode: "token",
token: gatewayToken,
if (gatewayTokenRefEnv) {
if (!isValidEnvSecretRefId(gatewayTokenRefEnv)) {
runtime.error(
"Invalid --gateway-token-ref-env (use env var name like OPENCLAW_GATEWAY_TOKEN).",
);
runtime.exit(1);
return null;
}
if (explicitGatewayToken) {
runtime.error("Use either --gateway-token or --gateway-token-ref-env, not both.");
runtime.exit(1);
return null;
}
const resolvedFromEnv = process.env[gatewayTokenRefEnv]?.trim();
if (!resolvedFromEnv) {
runtime.error(`Environment variable "${gatewayTokenRefEnv}" is missing or empty.`);
runtime.exit(1);
return null;
}
gatewayToken = resolvedFromEnv;
nextConfig = {
...nextConfig,
gateway: {
...nextConfig.gateway,
auth: {
...nextConfig.gateway?.auth,
mode: "token",
token: {
source: "env",
provider: resolveDefaultSecretProviderAlias(nextConfig, "env", {
preferFirstProviderForSource: true,
}),
id: gatewayTokenRefEnv,
},
},
},
},
};
};
} else {
if (!gatewayToken) {
gatewayToken = randomToken();
}
nextConfig = {
...nextConfig,
gateway: {
...nextConfig.gateway,
auth: {
...nextConfig.gateway?.auth,
mode: "token",
token: gatewayToken,
},
},
};
}
}
if (authMode === "password") {

View File

@@ -144,6 +144,7 @@ export type OnboardOptions = {
gatewayBind?: GatewayBind;
gatewayAuth?: GatewayAuthChoice;
gatewayToken?: string;
gatewayTokenRefEnv?: string;
gatewayPassword?: string;
tailscale?: TailscaleMode;
tailscaleResetOnExit?: boolean;

View File

@@ -10,7 +10,7 @@ import type { GatewayService } from "../daemon/service.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js";
import { resolveGatewayProbeAuth } from "../gateway/probe-auth.js";
import { resolveGatewayProbeAuthSafe } from "../gateway/probe-auth.js";
import { probeGateway } from "../gateway/probe.js";
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
@@ -116,9 +116,11 @@ export async function statusAllCommand(
const remoteUrlMissing = isRemoteMode && !remoteUrlRaw;
const gatewayMode = isRemoteMode ? "remote" : "local";
const localFallbackAuth = resolveGatewayProbeAuth({ cfg, mode: "local" });
const remoteAuth = resolveGatewayProbeAuth({ cfg, mode: "remote" });
const probeAuth = isRemoteMode && !remoteUrlMissing ? remoteAuth : localFallbackAuth;
const localProbeAuthResolution = resolveGatewayProbeAuthSafe({ cfg, mode: "local" });
const remoteProbeAuthResolution = resolveGatewayProbeAuthSafe({ cfg, mode: "remote" });
const probeAuthResolution =
isRemoteMode && !remoteUrlMissing ? remoteProbeAuthResolution : localProbeAuthResolution;
const probeAuth = probeAuthResolution.auth;
const gatewayProbe = await probeGateway({
url: connection.url,
@@ -179,8 +181,8 @@ export async function statusAllCommand(
const callOverrides = remoteUrlMissing
? {
url: connection.url,
token: localFallbackAuth.token,
password: localFallbackAuth.password,
token: localProbeAuthResolution.auth.token,
password: localProbeAuthResolution.auth.password,
}
: {};
@@ -292,6 +294,9 @@ export async function statusAllCommand(
Item: "Gateway",
Value: `${gatewayMode}${remoteUrlMissing ? " (remote.url missing)" : ""} · ${gatewayTarget} (${connection.urlSource}) · ${gatewayStatus}${gatewayAuth}`,
},
...(probeAuthResolution.warning
? [{ Item: "Gateway auth warning", Value: probeAuthResolution.warning }]
: []),
{ Item: "Security", Value: `Run: ${formatCliCommand("openclaw security audit --deep")}` },
gatewaySelfLine
? { Item: "Gateway self", Value: gatewaySelfLine }

View File

@@ -30,7 +30,6 @@ import {
formatTokensCompact,
shortenText,
} from "./status.format.js";
import { resolveGatewayProbeAuth } from "./status.gateway-probe.js";
import { scanStatus } from "./status.scan.js";
import {
formatUpdateAvailableHint,
@@ -118,6 +117,8 @@ export async function statusCommand(
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbeAuth,
gatewayProbeAuthWarning,
gatewayProbe,
gatewayReachable,
gatewaySelf,
@@ -195,6 +196,7 @@ export async function statusCommand(
connectLatencyMs: gatewayProbe?.connectLatencyMs ?? null,
self: gatewaySelf,
error: gatewayProbe?.error ?? null,
authWarning: gatewayProbeAuthWarning ?? null,
},
gatewayService: daemon,
nodeService: nodeDaemon,
@@ -250,7 +252,7 @@ export async function statusCommand(
: warn(gatewayProbe?.error ? `unreachable (${gatewayProbe.error})` : "unreachable");
const auth =
gatewayReachable && !remoteUrlMissing
? ` · auth ${formatGatewayAuthUsed(resolveGatewayProbeAuth(cfg))}`
? ` · auth ${formatGatewayAuthUsed(gatewayProbeAuth)}`
: "";
const self =
gatewaySelf?.host || gatewaySelf?.version || gatewaySelf?.platform
@@ -411,6 +413,9 @@ export async function statusCommand(
Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine,
},
{ Item: "Gateway", Value: gatewayValue },
...(gatewayProbeAuthWarning
? [{ Item: "Gateway auth warning", Value: warn(gatewayProbeAuthWarning) }]
: []),
{ Item: "Gateway service", Value: daemonValue },
{ Item: "Node service", Value: nodeDaemonValue },
{ Item: "Agents", Value: agentsValue },

View File

@@ -1,14 +1,24 @@
import type { loadConfig } from "../config/config.js";
import { resolveGatewayProbeAuth as resolveGatewayProbeAuthByMode } from "../gateway/probe-auth.js";
import { resolveGatewayProbeAuthSafe } from "../gateway/probe-auth.js";
export { pickGatewaySelfPresence } from "./gateway-presence.js";
export function resolveGatewayProbeAuth(cfg: ReturnType<typeof loadConfig>): {
token?: string;
password?: string;
export function resolveGatewayProbeAuthResolution(cfg: ReturnType<typeof loadConfig>): {
auth: {
token?: string;
password?: string;
};
warning?: string;
} {
return resolveGatewayProbeAuthByMode({
return resolveGatewayProbeAuthSafe({
cfg,
mode: cfg.gateway?.mode === "remote" ? "remote" : "local",
env: process.env,
});
}
export function resolveGatewayProbeAuth(cfg: ReturnType<typeof loadConfig>): {
token?: string;
password?: string;
} {
return resolveGatewayProbeAuthResolution(cfg).auth;
}

View File

@@ -14,7 +14,10 @@ import { runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { buildChannelsTable } from "./status-all/channels.js";
import { getAgentLocalStatuses } from "./status.agent-local.js";
import { pickGatewaySelfPresence, resolveGatewayProbeAuth } from "./status.gateway-probe.js";
import {
pickGatewaySelfPresence,
resolveGatewayProbeAuthResolution,
} from "./status.gateway-probe.js";
import { getStatusSummary } from "./status.summary.js";
import { getUpdateCheckResult } from "./status.update.js";
@@ -34,6 +37,11 @@ type GatewayProbeSnapshot = {
gatewayConnection: ReturnType<typeof buildGatewayConnectionDetails>;
remoteUrlMissing: boolean;
gatewayMode: "local" | "remote";
gatewayProbeAuth: {
token?: string;
password?: string;
};
gatewayProbeAuthWarning?: string;
gatewayProbe: Awaited<ReturnType<typeof probeGateway>> | null;
};
@@ -73,14 +81,29 @@ async function resolveGatewayProbeSnapshot(params: {
typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url : "";
const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim();
const gatewayMode = isRemoteMode ? "remote" : "local";
const gatewayProbeAuthResolution = resolveGatewayProbeAuthResolution(params.cfg);
let gatewayProbeAuthWarning = gatewayProbeAuthResolution.warning;
const gatewayProbe = remoteUrlMissing
? null
: await probeGateway({
url: gatewayConnection.url,
auth: resolveGatewayProbeAuth(params.cfg),
auth: gatewayProbeAuthResolution.auth,
timeoutMs: Math.min(params.opts.all ? 5000 : 2500, params.opts.timeoutMs ?? 10_000),
}).catch(() => null);
return { gatewayConnection, remoteUrlMissing, gatewayMode, gatewayProbe };
if (gatewayProbeAuthWarning && gatewayProbe?.ok === false) {
gatewayProbe.error = gatewayProbe.error
? `${gatewayProbe.error}; ${gatewayProbeAuthWarning}`
: gatewayProbeAuthWarning;
gatewayProbeAuthWarning = undefined;
}
return {
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbeAuth: gatewayProbeAuthResolution.auth,
gatewayProbeAuthWarning,
gatewayProbe,
};
}
async function resolveChannelsStatus(params: {
@@ -110,6 +133,11 @@ export type StatusScanResult = {
gatewayConnection: ReturnType<typeof buildGatewayConnectionDetails>;
remoteUrlMissing: boolean;
gatewayMode: "local" | "remote";
gatewayProbeAuth: {
token?: string;
password?: string;
};
gatewayProbeAuthWarning?: string;
gatewayProbe: Awaited<ReturnType<typeof probeGateway>> | null;
gatewayReachable: boolean;
gatewaySelf: ReturnType<typeof pickGatewaySelfPresence>;
@@ -188,7 +216,14 @@ async function scanStatusJsonFast(opts: {
? `https://${tailscaleDns}${normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath)}`
: null;
const { gatewayConnection, remoteUrlMissing, gatewayMode, gatewayProbe } = gatewaySnapshot;
const {
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbeAuth,
gatewayProbeAuthWarning,
gatewayProbe,
} = gatewaySnapshot;
const gatewayReachable = gatewayProbe?.ok === true;
const gatewaySelf = gatewayProbe?.presence
? pickGatewaySelfPresence(gatewayProbe.presence)
@@ -209,6 +244,8 @@ async function scanStatusJsonFast(opts: {
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbeAuth,
gatewayProbeAuthWarning,
gatewayProbe,
gatewayReachable,
gatewaySelf,
@@ -283,8 +320,14 @@ export async function scanStatus(
progress.tick();
progress.setLabel("Probing gateway…");
const { gatewayConnection, remoteUrlMissing, gatewayMode, gatewayProbe } =
await resolveGatewayProbeSnapshot({ cfg, opts });
const {
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbeAuth,
gatewayProbeAuthWarning,
gatewayProbe,
} = await resolveGatewayProbeSnapshot({ cfg, opts });
const gatewayReachable = gatewayProbe?.ok === true;
const gatewaySelf = gatewayProbe?.presence
? pickGatewaySelfPresence(gatewayProbe.presence)
@@ -326,6 +369,8 @@ export async function scanStatus(
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbeAuth,
gatewayProbeAuthWarning,
gatewayProbe,
gatewayReachable,
gatewaySelf,

View File

@@ -1,5 +1,5 @@
import type { Mock } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../test-utils/env.js";
let envSnapshot: ReturnType<typeof captureEnv>;
@@ -146,6 +146,7 @@ async function withEnvVar<T>(key: string, value: string, run: () => Promise<T>):
}
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn().mockReturnValue({ session: {} }),
loadSessionStore: vi.fn().mockReturnValue({
"+1000": createDefaultSessionStoreEntry(),
}),
@@ -345,7 +346,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({ session: {} }),
loadConfig: mocks.loadConfig,
};
});
vi.mock("../daemon/service.js", () => ({
@@ -389,6 +390,11 @@ const runtime = {
const runtimeLogMock = runtime.log as Mock<(...args: unknown[]) => void>;
describe("statusCommand", () => {
afterEach(() => {
mocks.loadConfig.mockReset();
mocks.loadConfig.mockReturnValue({ session: {} });
});
it("prints JSON when requested", async () => {
await statusCommand({ json: true }, runtime as never);
const payload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0]));
@@ -481,6 +487,28 @@ describe("statusCommand", () => {
});
});
it("warns instead of crashing when gateway auth SecretRef is unresolved for probe auth", async () => {
mocks.loadConfig.mockReturnValue({
session: {},
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
});
await statusCommand({ json: true }, runtime as never);
const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0]));
expect(payload.gateway.error).toContain("gateway.auth.token");
expect(payload.gateway.error).toContain("SecretRef");
});
it("surfaces channel runtime errors from the gateway", async () => {
mockProbeGatewayResult({
ok: true,