mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-15 03:50:40 +00:00
Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails (#35094)
This commit is contained in:
147
src/cli/daemon-cli/install.integration.test.ts
Normal file
147
src/cli/daemon-cli/install.integration.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { makeTempWorkspace } from "../../test-helpers/workspace.js";
|
||||
import { captureEnv } from "../../test-utils/env.js";
|
||||
|
||||
const runtimeLogs: string[] = [];
|
||||
const runtimeErrors: string[] = [];
|
||||
|
||||
const serviceMock = vi.hoisted(() => ({
|
||||
label: "Gateway",
|
||||
loadedText: "loaded",
|
||||
notLoadedText: "not loaded",
|
||||
install: vi.fn(async (_opts?: { environment?: Record<string, string | undefined> }) => {}),
|
||||
uninstall: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
restart: vi.fn(async () => {}),
|
||||
isLoaded: vi.fn(async () => false),
|
||||
readCommand: vi.fn(async () => null),
|
||||
readRuntime: vi.fn(async () => ({ status: "stopped" as const })),
|
||||
}));
|
||||
|
||||
vi.mock("../../daemon/service.js", () => ({
|
||||
resolveGatewayService: () => serviceMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
defaultRuntime: {
|
||||
log: (message: string) => runtimeLogs.push(message),
|
||||
error: (message: string) => runtimeErrors.push(message),
|
||||
exit: (code: number) => {
|
||||
throw new Error(`__exit__:${code}`);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { runDaemonInstall } = await import("./install.js");
|
||||
const { clearConfigCache } = await import("../../config/config.js");
|
||||
|
||||
async function readJson(filePath: string): Promise<Record<string, unknown>> {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("runDaemonInstall integration", () => {
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
let tempHome: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
envSnapshot = captureEnv([
|
||||
"HOME",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_CONFIG_PATH",
|
||||
"OPENCLAW_GATEWAY_TOKEN",
|
||||
"CLAWDBOT_GATEWAY_TOKEN",
|
||||
"OPENCLAW_GATEWAY_PASSWORD",
|
||||
"CLAWDBOT_GATEWAY_PASSWORD",
|
||||
]);
|
||||
tempHome = await makeTempWorkspace("openclaw-daemon-install-int-");
|
||||
configPath = path.join(tempHome, "openclaw.json");
|
||||
process.env.HOME = tempHome;
|
||||
process.env.OPENCLAW_STATE_DIR = tempHome;
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
envSnapshot.restore();
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
vi.clearAllMocks();
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||
serviceMock.isLoaded.mockResolvedValue(false);
|
||||
await fs.writeFile(configPath, JSON.stringify({}, null, 2));
|
||||
clearConfigCache();
|
||||
});
|
||||
|
||||
it("fails closed when token SecretRef is required but unresolved", async () => {
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MISSING_GATEWAY_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
clearConfigCache();
|
||||
|
||||
await expect(runDaemonInstall({ json: true })).rejects.toThrow("__exit__:1");
|
||||
expect(serviceMock.install).not.toHaveBeenCalled();
|
||||
const joined = runtimeLogs.join("\n");
|
||||
expect(joined).toContain("SecretRef is configured but unresolved");
|
||||
expect(joined).toContain("MISSING_GATEWAY_TOKEN");
|
||||
});
|
||||
|
||||
it("auto-mints token when no source exists and persists the same token used for install env", async () => {
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
clearConfigCache();
|
||||
|
||||
await runDaemonInstall({ json: true });
|
||||
|
||||
expect(serviceMock.install).toHaveBeenCalledTimes(1);
|
||||
const updated = await readJson(configPath);
|
||||
const gateway = (updated.gateway ?? {}) as { auth?: { token?: string } };
|
||||
const persistedToken = gateway.auth?.token;
|
||||
expect(typeof persistedToken).toBe("string");
|
||||
expect((persistedToken ?? "").length).toBeGreaterThan(0);
|
||||
|
||||
const installEnv = serviceMock.install.mock.calls[0]?.[0]?.environment;
|
||||
expect(installEnv?.OPENCLAW_GATEWAY_TOKEN).toBe(persistedToken);
|
||||
});
|
||||
});
|
||||
249
src/cli/daemon-cli/install.test.ts
Normal file
249
src/cli/daemon-cli/install.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonActionResponse } from "./response.js";
|
||||
|
||||
const loadConfigMock = vi.hoisted(() => vi.fn());
|
||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789));
|
||||
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
||||
const resolveIsNixModeMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const resolveSecretInputRefMock = vi.hoisted(() =>
|
||||
vi.fn((): { ref: unknown } => ({ ref: undefined })),
|
||||
);
|
||||
const resolveGatewayAuthMock = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
mode: "token",
|
||||
token: undefined,
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
})),
|
||||
);
|
||||
const resolveSecretRefValuesMock = vi.hoisted(() => vi.fn());
|
||||
const randomTokenMock = vi.hoisted(() => vi.fn(() => "generated-token"));
|
||||
const buildGatewayInstallPlanMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
programArguments: ["openclaw", "gateway", "run"],
|
||||
workingDirectory: "/tmp",
|
||||
environment: {},
|
||||
})),
|
||||
);
|
||||
const parsePortMock = vi.hoisted(() => vi.fn(() => null));
|
||||
const isGatewayDaemonRuntimeMock = vi.hoisted(() => vi.fn(() => true));
|
||||
const installDaemonServiceAndEmitMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
const actionState = vi.hoisted(() => ({
|
||||
warnings: [] as string[],
|
||||
emitted: [] as DaemonActionResponse[],
|
||||
failed: [] as Array<{ message: string; hints?: string[] }>,
|
||||
}));
|
||||
|
||||
const service = vi.hoisted(() => ({
|
||||
label: "Gateway",
|
||||
loadedText: "loaded",
|
||||
notLoadedText: "not loaded",
|
||||
isLoaded: vi.fn(async () => false),
|
||||
install: vi.fn(async () => {}),
|
||||
uninstall: vi.fn(async () => {}),
|
||||
restart: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
readCommand: vi.fn(async () => null),
|
||||
readRuntime: vi.fn(async () => ({ status: "stopped" as const })),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: loadConfigMock,
|
||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||
resolveGatewayPort: resolveGatewayPortMock,
|
||||
writeConfigFile: writeConfigFileMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/paths.js", () => ({
|
||||
resolveIsNixMode: resolveIsNixModeMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/types.secrets.js", () => ({
|
||||
resolveSecretInputRef: resolveSecretInputRefMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../gateway/auth.js", () => ({
|
||||
resolveGatewayAuth: resolveGatewayAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../secrets/resolve.js", () => ({
|
||||
resolveSecretRefValues: resolveSecretRefValuesMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/onboard-helpers.js", () => ({
|
||||
randomToken: randomTokenMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/daemon-install-helpers.js", () => ({
|
||||
buildGatewayInstallPlan: buildGatewayInstallPlanMock,
|
||||
}));
|
||||
|
||||
vi.mock("./shared.js", () => ({
|
||||
parsePort: parsePortMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/daemon-runtime.js", () => ({
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME: "node",
|
||||
isGatewayDaemonRuntime: isGatewayDaemonRuntimeMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../daemon/service.js", () => ({
|
||||
resolveGatewayService: () => service,
|
||||
}));
|
||||
|
||||
vi.mock("./response.js", () => ({
|
||||
buildDaemonServiceSnapshot: vi.fn(),
|
||||
createDaemonActionContext: vi.fn(() => ({
|
||||
stdout: process.stdout,
|
||||
warnings: actionState.warnings,
|
||||
emit: (payload: DaemonActionResponse) => {
|
||||
actionState.emitted.push(payload);
|
||||
},
|
||||
fail: (message: string, hints?: string[]) => {
|
||||
actionState.failed.push({ message, hints });
|
||||
},
|
||||
})),
|
||||
installDaemonServiceAndEmit: installDaemonServiceAndEmitMock,
|
||||
}));
|
||||
|
||||
const runtimeLogs: string[] = [];
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
defaultRuntime: {
|
||||
log: (message: string) => runtimeLogs.push(message),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { runDaemonInstall } = await import("./install.js");
|
||||
|
||||
describe("runDaemonInstall", () => {
|
||||
beforeEach(() => {
|
||||
loadConfigMock.mockReset();
|
||||
readConfigFileSnapshotMock.mockReset();
|
||||
resolveGatewayPortMock.mockClear();
|
||||
writeConfigFileMock.mockReset();
|
||||
resolveIsNixModeMock.mockReset();
|
||||
resolveSecretInputRefMock.mockReset();
|
||||
resolveGatewayAuthMock.mockReset();
|
||||
resolveSecretRefValuesMock.mockReset();
|
||||
randomTokenMock.mockReset();
|
||||
buildGatewayInstallPlanMock.mockReset();
|
||||
parsePortMock.mockReset();
|
||||
isGatewayDaemonRuntimeMock.mockReset();
|
||||
installDaemonServiceAndEmitMock.mockReset();
|
||||
service.isLoaded.mockReset();
|
||||
runtimeLogs.length = 0;
|
||||
actionState.warnings.length = 0;
|
||||
actionState.emitted.length = 0;
|
||||
actionState.failed.length = 0;
|
||||
|
||||
loadConfigMock.mockReturnValue({ gateway: { auth: { mode: "token" } } });
|
||||
readConfigFileSnapshotMock.mockResolvedValue({ exists: false, valid: true, config: {} });
|
||||
resolveGatewayPortMock.mockReturnValue(18789);
|
||||
resolveIsNixModeMock.mockReturnValue(false);
|
||||
resolveSecretInputRefMock.mockReturnValue({ ref: undefined });
|
||||
resolveGatewayAuthMock.mockReturnValue({
|
||||
mode: "token",
|
||||
token: undefined,
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
});
|
||||
resolveSecretRefValuesMock.mockResolvedValue(new Map());
|
||||
randomTokenMock.mockReturnValue("generated-token");
|
||||
buildGatewayInstallPlanMock.mockResolvedValue({
|
||||
programArguments: ["openclaw", "gateway", "run"],
|
||||
workingDirectory: "/tmp",
|
||||
environment: {},
|
||||
});
|
||||
parsePortMock.mockReturnValue(null);
|
||||
isGatewayDaemonRuntimeMock.mockReturnValue(true);
|
||||
installDaemonServiceAndEmitMock.mockResolvedValue(undefined);
|
||||
service.isLoaded.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("fails install when token auth requires an unresolved token SecretRef", async () => {
|
||||
resolveSecretInputRefMock.mockReturnValue({
|
||||
ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
|
||||
});
|
||||
resolveSecretRefValuesMock.mockRejectedValue(new Error("secret unavailable"));
|
||||
|
||||
await runDaemonInstall({ json: true });
|
||||
|
||||
expect(actionState.failed[0]?.message).toContain("gateway.auth.token SecretRef is configured");
|
||||
expect(actionState.failed[0]?.message).toContain("unresolved");
|
||||
expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled();
|
||||
expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates token SecretRef but does not serialize resolved token into service env", async () => {
|
||||
resolveSecretInputRefMock.mockReturnValue({
|
||||
ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
|
||||
});
|
||||
resolveSecretRefValuesMock.mockResolvedValue(
|
||||
new Map([["env:default:OPENCLAW_GATEWAY_TOKEN", "resolved-from-secretref"]]),
|
||||
);
|
||||
|
||||
await runDaemonInstall({ json: true });
|
||||
|
||||
expect(actionState.failed).toEqual([]);
|
||||
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: undefined,
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFileMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
actionState.warnings.some((warning) =>
|
||||
warning.includes("gateway.auth.token is SecretRef-managed"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat env-template gateway.auth.token as plaintext during install", async () => {
|
||||
loadConfigMock.mockReturnValue({
|
||||
gateway: { auth: { mode: "token", token: "${OPENCLAW_GATEWAY_TOKEN}" } },
|
||||
});
|
||||
resolveSecretInputRefMock.mockReturnValue({
|
||||
ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
|
||||
});
|
||||
resolveSecretRefValuesMock.mockResolvedValue(
|
||||
new Map([["env:default:OPENCLAW_GATEWAY_TOKEN", "resolved-from-secretref"]]),
|
||||
);
|
||||
|
||||
await runDaemonInstall({ json: true });
|
||||
|
||||
expect(actionState.failed).toEqual([]);
|
||||
expect(resolveSecretRefValuesMock).toHaveBeenCalledTimes(1);
|
||||
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-mints and persists token when no source exists", async () => {
|
||||
randomTokenMock.mockReturnValue("minted-token");
|
||||
readConfigFileSnapshotMock.mockResolvedValue({
|
||||
exists: true,
|
||||
valid: true,
|
||||
config: { gateway: { auth: { mode: "token" } } },
|
||||
});
|
||||
|
||||
await runDaemonInstall({ json: true });
|
||||
|
||||
expect(actionState.failed).toEqual([]);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
|
||||
const writtenConfig = writeConfigFileMock.mock.calls[0]?.[0] as {
|
||||
gateway?: { auth?: { token?: string } };
|
||||
};
|
||||
expect(writtenConfig.gateway?.auth?.token).toBe("minted-token");
|
||||
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ token: "minted-token", port: 18789 }),
|
||||
);
|
||||
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
|
||||
expect(actionState.warnings.some((warning) => warning.includes("Auto-generated"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,10 @@ import {
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
isGatewayDaemonRuntime,
|
||||
} from "../../commands/daemon-runtime.js";
|
||||
import { randomToken } from "../../commands/onboard-helpers.js";
|
||||
import {
|
||||
loadConfig,
|
||||
readConfigFileSnapshot,
|
||||
resolveGatewayPort,
|
||||
writeConfigFile,
|
||||
} from "../../config/config.js";
|
||||
import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js";
|
||||
import { loadConfig, resolveGatewayPort } from "../../config/config.js";
|
||||
import { resolveIsNixMode } from "../../config/paths.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { resolveGatewayAuth } from "../../gateway/auth.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatCliCommand } from "../command-format.js";
|
||||
import {
|
||||
@@ -75,78 +69,29 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve effective auth mode to determine if token auto-generation is needed.
|
||||
// Password-mode and Tailscale-only installs do not need a token.
|
||||
const resolvedAuth = resolveGatewayAuth({
|
||||
authConfig: cfg.gateway?.auth,
|
||||
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
|
||||
const tokenResolution = await resolveGatewayInstallToken({
|
||||
config: cfg,
|
||||
env: process.env,
|
||||
explicitToken: opts.token,
|
||||
autoGenerateWhenMissing: true,
|
||||
persistGeneratedToken: true,
|
||||
});
|
||||
const needsToken =
|
||||
resolvedAuth.mode === "token" && !resolvedAuth.token && !resolvedAuth.allowTailscale;
|
||||
|
||||
let token: string | undefined =
|
||||
opts.token ||
|
||||
cfg.gateway?.auth?.token ||
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN ||
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
|
||||
if (!token && needsToken) {
|
||||
token = randomToken();
|
||||
const warnMsg = "No gateway token found. Auto-generated one and saving to config.";
|
||||
if (tokenResolution.unavailableReason) {
|
||||
fail(`Gateway install blocked: ${tokenResolution.unavailableReason}`);
|
||||
return;
|
||||
}
|
||||
for (const warning of tokenResolution.warnings) {
|
||||
if (json) {
|
||||
warnings.push(warnMsg);
|
||||
warnings.push(warning);
|
||||
} else {
|
||||
defaultRuntime.log(warnMsg);
|
||||
}
|
||||
|
||||
// Persist to config file so the gateway reads it at runtime
|
||||
// (launchd does not inherit shell env vars, and CLI tools also
|
||||
// read gateway.auth.token from config for gateway calls).
|
||||
try {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (snapshot.exists && !snapshot.valid) {
|
||||
// Config file exists but is corrupt/unparseable — don't risk overwriting.
|
||||
// Token is still embedded in the plist EnvironmentVariables.
|
||||
const msg = "Warning: config file exists but is invalid; skipping token persistence.";
|
||||
if (json) {
|
||||
warnings.push(msg);
|
||||
} else {
|
||||
defaultRuntime.log(msg);
|
||||
}
|
||||
} else {
|
||||
const baseConfig = snapshot.exists ? snapshot.config : {};
|
||||
if (!baseConfig.gateway?.auth?.token) {
|
||||
await writeConfigFile({
|
||||
...baseConfig,
|
||||
gateway: {
|
||||
...baseConfig.gateway,
|
||||
auth: {
|
||||
...baseConfig.gateway?.auth,
|
||||
mode: baseConfig.gateway?.auth?.mode ?? "token",
|
||||
token,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Another process wrote a token between loadConfig() and now.
|
||||
token = baseConfig.gateway.auth.token;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal: token is still embedded in the plist EnvironmentVariables.
|
||||
const msg = `Warning: could not persist token to config: ${String(err)}`;
|
||||
if (json) {
|
||||
warnings.push(msg);
|
||||
} else {
|
||||
defaultRuntime.log(msg);
|
||||
}
|
||||
defaultRuntime.log(warning);
|
||||
}
|
||||
}
|
||||
|
||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||
env: process.env,
|
||||
port,
|
||||
token,
|
||||
token: tokenResolution.token,
|
||||
runtime: runtimeRaw,
|
||||
warn: (message) => {
|
||||
if (json) {
|
||||
|
||||
@@ -5,7 +5,10 @@ import { checkTokenDrift } from "../../daemon/service-audit.js";
|
||||
import type { GatewayService } from "../../daemon/service.js";
|
||||
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
|
||||
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
|
||||
import { resolveGatewayCredentialsFromConfig } from "../../gateway/credentials.js";
|
||||
import {
|
||||
isGatewaySecretRefUnavailableError,
|
||||
resolveGatewayCredentialsFromConfig,
|
||||
} from "../../gateway/credentials.js";
|
||||
import { isWSL } from "../../infra/wsl.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
@@ -299,8 +302,15 @@ export async function runServiceRestart(params: {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: token drift check is best-effort
|
||||
} catch (err) {
|
||||
if (isGatewaySecretRefUnavailableError(err, "gateway.auth.token")) {
|
||||
const warning =
|
||||
"Unable to verify gateway token drift: gateway.auth.token SecretRef is configured but unavailable in this command path.";
|
||||
warnings.push(warning);
|
||||
if (!json) {
|
||||
defaultRuntime.log(`\n⚠️ ${warning}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,12 +123,14 @@ describe("gatherDaemonStatus", () => {
|
||||
"OPENCLAW_CONFIG_PATH",
|
||||
"OPENCLAW_GATEWAY_TOKEN",
|
||||
"OPENCLAW_GATEWAY_PASSWORD",
|
||||
"DAEMON_GATEWAY_TOKEN",
|
||||
"DAEMON_GATEWAY_PASSWORD",
|
||||
]);
|
||||
process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-cli";
|
||||
process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-cli/openclaw.json";
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
delete process.env.DAEMON_GATEWAY_TOKEN;
|
||||
delete process.env.DAEMON_GATEWAY_PASSWORD;
|
||||
callGatewayStatusProbe.mockClear();
|
||||
loadGatewayTlsRuntime.mockClear();
|
||||
@@ -218,6 +220,37 @@ describe("gatherDaemonStatus", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves daemon gateway auth token SecretRef values before probing", async () => {
|
||||
daemonLoadedConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
tls: { enabled: true },
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "${DAEMON_GATEWAY_TOKEN}",
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
process.env.DAEMON_GATEWAY_TOKEN = "daemon-secretref-token";
|
||||
|
||||
await gatherDaemonStatus({
|
||||
rpc: {},
|
||||
probe: true,
|
||||
deep: false,
|
||||
});
|
||||
|
||||
expect(callGatewayStatusProbe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: "daemon-secretref-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not resolve daemon password SecretRef when token auth is configured", async () => {
|
||||
daemonLoadedConfig = {
|
||||
gateway: {
|
||||
|
||||
@@ -9,7 +9,11 @@ import type {
|
||||
GatewayBindMode,
|
||||
GatewayControlUiConfig,
|
||||
} from "../../config/types.js";
|
||||
import { normalizeSecretInputString, resolveSecretInputRef } from "../../config/types.secrets.js";
|
||||
import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
resolveSecretInputRef,
|
||||
} from "../../config/types.secrets.js";
|
||||
import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js";
|
||||
import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
|
||||
import { findExtraGatewayServices } from "../../daemon/inspect.js";
|
||||
@@ -114,6 +118,61 @@ function readGatewayTokenEnv(env: Record<string, string | undefined>): string |
|
||||
return trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN) ?? trimToUndefined(env.CLAWDBOT_GATEWAY_TOKEN);
|
||||
}
|
||||
|
||||
function readGatewayPasswordEnv(env: Record<string, string | undefined>): string | undefined {
|
||||
return (
|
||||
trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD) ?? trimToUndefined(env.CLAWDBOT_GATEWAY_PASSWORD)
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveDaemonProbeToken(params: {
|
||||
daemonCfg: OpenClawConfig;
|
||||
mergedDaemonEnv: Record<string, string | undefined>;
|
||||
explicitToken?: string;
|
||||
explicitPassword?: string;
|
||||
}): Promise<string | undefined> {
|
||||
const explicitToken = trimToUndefined(params.explicitToken);
|
||||
if (explicitToken) {
|
||||
return explicitToken;
|
||||
}
|
||||
const envToken = readGatewayTokenEnv(params.mergedDaemonEnv);
|
||||
if (envToken) {
|
||||
return envToken;
|
||||
}
|
||||
const defaults = params.daemonCfg.secrets?.defaults;
|
||||
const configured = params.daemonCfg.gateway?.auth?.token;
|
||||
const { ref } = resolveSecretInputRef({
|
||||
value: configured,
|
||||
defaults,
|
||||
});
|
||||
if (!ref) {
|
||||
return normalizeSecretInputString(configured);
|
||||
}
|
||||
const authMode = params.daemonCfg.gateway?.auth?.mode;
|
||||
if (authMode === "password" || authMode === "none" || authMode === "trusted-proxy") {
|
||||
return undefined;
|
||||
}
|
||||
if (authMode !== "token") {
|
||||
const passwordCandidate =
|
||||
trimToUndefined(params.explicitPassword) ||
|
||||
readGatewayPasswordEnv(params.mergedDaemonEnv) ||
|
||||
(hasConfiguredSecretInput(params.daemonCfg.gateway?.auth?.password, defaults)
|
||||
? "__configured__"
|
||||
: undefined);
|
||||
if (passwordCandidate) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const resolved = await resolveSecretRefValues([ref], {
|
||||
config: params.daemonCfg,
|
||||
env: params.mergedDaemonEnv as NodeJS.ProcessEnv,
|
||||
});
|
||||
const token = trimToUndefined(resolved.get(secretRefKey(ref)));
|
||||
if (!token) {
|
||||
throw new Error("gateway.auth.token resolved to an empty or non-string value.");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function resolveDaemonProbePassword(params: {
|
||||
daemonCfg: OpenClawConfig;
|
||||
mergedDaemonEnv: Record<string, string | undefined>;
|
||||
@@ -124,7 +183,7 @@ async function resolveDaemonProbePassword(params: {
|
||||
if (explicitPassword) {
|
||||
return explicitPassword;
|
||||
}
|
||||
const envPassword = trimToUndefined(params.mergedDaemonEnv.OPENCLAW_GATEWAY_PASSWORD);
|
||||
const envPassword = readGatewayPasswordEnv(params.mergedDaemonEnv);
|
||||
if (envPassword) {
|
||||
return envPassword;
|
||||
}
|
||||
@@ -145,7 +204,9 @@ async function resolveDaemonProbePassword(params: {
|
||||
const tokenCandidate =
|
||||
trimToUndefined(params.explicitToken) ||
|
||||
readGatewayTokenEnv(params.mergedDaemonEnv) ||
|
||||
trimToUndefined(params.daemonCfg.gateway?.auth?.token);
|
||||
(hasConfiguredSecretInput(params.daemonCfg.gateway?.auth?.token, defaults)
|
||||
? "__configured__"
|
||||
: undefined);
|
||||
if (tokenCandidate) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -290,14 +351,19 @@ export async function gatherDaemonStatus(
|
||||
explicitPassword: opts.rpc.password,
|
||||
})
|
||||
: undefined;
|
||||
const daemonProbeToken = opts.probe
|
||||
? await resolveDaemonProbeToken({
|
||||
daemonCfg,
|
||||
mergedDaemonEnv,
|
||||
explicitToken: opts.rpc.token,
|
||||
explicitPassword: opts.rpc.password,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const rpc = opts.probe
|
||||
? await probeGatewayStatus({
|
||||
url: probeUrl,
|
||||
token:
|
||||
opts.rpc.token ||
|
||||
mergedDaemonEnv.OPENCLAW_GATEWAY_TOKEN ||
|
||||
daemonCfg.gateway?.auth?.token,
|
||||
token: daemonProbeToken,
|
||||
password: daemonProbePassword,
|
||||
tlsFingerprint:
|
||||
shouldUseLocalTlsRuntime && tlsRuntime?.enabled
|
||||
|
||||
Reference in New Issue
Block a user