mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 19:04:31 +00:00
Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails (#35094)
This commit is contained in:
@@ -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}".`,
|
||||
|
||||
110
src/commands/configure.daemon.test.ts
Normal file
110
src/commands/configure.daemon.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
226
src/commands/doctor-gateway-auth-token.test.ts
Normal file
226
src/commands/doctor-gateway-auth-token.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
54
src/commands/doctor-gateway-auth-token.ts
Normal file
54
src/commands/doctor-gateway-auth-token.ts
Normal 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) };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: " " } },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
283
src/commands/gateway-install-token.test.ts
Normal file
283
src/commands/gateway-install-token.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
147
src/commands/gateway-install-token.ts
Normal file
147
src/commands/gateway-install-token.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
235
src/commands/gateway-status/helpers.test.ts
Normal file
235
src/commands/gateway-status/helpers.test.ts
Normal 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");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -92,7 +92,6 @@ export async function runNonInteractiveOnboardingLocal(params: {
|
||||
opts,
|
||||
runtime,
|
||||
port: gatewayResult.port,
|
||||
gatewayToken: gatewayResult.gatewayToken,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -144,6 +144,7 @@ export type OnboardOptions = {
|
||||
gatewayBind?: GatewayBind;
|
||||
gatewayAuth?: GatewayAuthChoice;
|
||||
gatewayToken?: string;
|
||||
gatewayTokenRefEnv?: string;
|
||||
gatewayPassword?: string;
|
||||
tailscale?: TailscaleMode;
|
||||
tailscaleResetOnExit?: boolean;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user