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
|
||||
|
||||
@@ -17,24 +17,45 @@ const ensureDevGatewayConfig = vi.fn(async (_opts?: unknown) => {});
|
||||
const runGatewayLoop = vi.fn(async ({ start }: { start: () => Promise<unknown> }) => {
|
||||
await start();
|
||||
});
|
||||
const configState = vi.hoisted(() => ({
|
||||
cfg: {} as Record<string, unknown>,
|
||||
snapshot: { exists: false } as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
const { runtimeErrors, defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture();
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
getConfigPath: () => "/tmp/openclaw-test-missing-config.json",
|
||||
loadConfig: () => ({}),
|
||||
readConfigFileSnapshot: async () => ({ exists: false }),
|
||||
loadConfig: () => configState.cfg,
|
||||
readConfigFileSnapshot: async () => configState.snapshot,
|
||||
resolveStateDir: () => "/tmp",
|
||||
resolveGatewayPort: () => 18789,
|
||||
}));
|
||||
|
||||
vi.mock("../../gateway/auth.js", () => ({
|
||||
resolveGatewayAuth: (params: { authConfig?: { token?: string }; env?: NodeJS.ProcessEnv }) => ({
|
||||
mode: "token",
|
||||
token: params.authConfig?.token ?? params.env?.OPENCLAW_GATEWAY_TOKEN,
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
}),
|
||||
resolveGatewayAuth: (params: {
|
||||
authConfig?: { mode?: string; token?: unknown; password?: unknown };
|
||||
authOverride?: { mode?: string; token?: unknown; password?: unknown };
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}) => {
|
||||
const mode = params.authOverride?.mode ?? params.authConfig?.mode ?? "token";
|
||||
const token =
|
||||
(typeof params.authOverride?.token === "string" ? params.authOverride.token : undefined) ??
|
||||
(typeof params.authConfig?.token === "string" ? params.authConfig.token : undefined) ??
|
||||
params.env?.OPENCLAW_GATEWAY_TOKEN;
|
||||
const password =
|
||||
(typeof params.authOverride?.password === "string"
|
||||
? params.authOverride.password
|
||||
: undefined) ??
|
||||
(typeof params.authConfig?.password === "string" ? params.authConfig.password : undefined) ??
|
||||
params.env?.OPENCLAW_GATEWAY_PASSWORD;
|
||||
return {
|
||||
mode,
|
||||
token,
|
||||
password,
|
||||
allowTailscale: false,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../gateway/server.js", () => ({
|
||||
@@ -106,6 +127,8 @@ describe("gateway run option collisions", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
resetRuntimeCapture();
|
||||
configState.cfg = {};
|
||||
configState.snapshot = { exists: false };
|
||||
startGatewayServer.mockClear();
|
||||
setGatewayWsLogStyle.mockClear();
|
||||
setVerbose.mockClear();
|
||||
@@ -190,4 +213,30 @@ describe("gateway run option collisions", () => {
|
||||
'Invalid --auth (use "none", "token", "password", or "trusted-proxy")',
|
||||
);
|
||||
});
|
||||
|
||||
it("allows password mode preflight when password is configured via SecretRef", async () => {
|
||||
configState.cfg = {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "password",
|
||||
password: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_PASSWORD" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
};
|
||||
configState.snapshot = { exists: true, parsed: configState.cfg };
|
||||
|
||||
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
|
||||
|
||||
expect(startGatewayServer).toHaveBeenCalledWith(
|
||||
18789,
|
||||
expect.objectContaining({
|
||||
bind: "loopback",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resolveStateDir,
|
||||
resolveGatewayPort,
|
||||
} from "../../config/config.js";
|
||||
import { hasConfiguredSecretInput } from "../../config/types.secrets.js";
|
||||
import { resolveGatewayAuth } from "../../gateway/auth.js";
|
||||
import { startGatewayServer } from "../../gateway/server.js";
|
||||
import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js";
|
||||
@@ -308,9 +309,22 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
|
||||
const passwordValue = resolvedAuth.password;
|
||||
const hasToken = typeof tokenValue === "string" && tokenValue.trim().length > 0;
|
||||
const hasPassword = typeof passwordValue === "string" && passwordValue.trim().length > 0;
|
||||
const tokenConfigured =
|
||||
hasToken ||
|
||||
hasConfiguredSecretInput(
|
||||
authOverride?.token ?? cfg.gateway?.auth?.token,
|
||||
cfg.secrets?.defaults,
|
||||
);
|
||||
const passwordConfigured =
|
||||
hasPassword ||
|
||||
hasConfiguredSecretInput(
|
||||
authOverride?.password ?? cfg.gateway?.auth?.password,
|
||||
cfg.secrets?.defaults,
|
||||
);
|
||||
const hasSharedSecret =
|
||||
(resolvedAuthMode === "token" && hasToken) || (resolvedAuthMode === "password" && hasPassword);
|
||||
const canBootstrapToken = resolvedAuthMode === "token" && !hasToken;
|
||||
(resolvedAuthMode === "token" && tokenConfigured) ||
|
||||
(resolvedAuthMode === "password" && passwordConfigured);
|
||||
const canBootstrapToken = resolvedAuthMode === "token" && !tokenConfigured;
|
||||
const authHints: string[] = [];
|
||||
if (miskeys.hasGatewayToken) {
|
||||
authHints.push('Found "gateway.token" in config. Use "gateway.auth.token" instead.');
|
||||
@@ -320,7 +334,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
|
||||
'"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.',
|
||||
);
|
||||
}
|
||||
if (resolvedAuthMode === "password" && !hasPassword) {
|
||||
if (resolvedAuthMode === "password" && !passwordConfigured) {
|
||||
defaultRuntime.error(
|
||||
[
|
||||
"Gateway auth is set to password, but no password is configured.",
|
||||
|
||||
@@ -129,6 +129,16 @@ describe("registerOnboardCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards --gateway-token-ref-env", async () => {
|
||||
await runCli(["onboard", "--gateway-token-ref-env", "OPENCLAW_GATEWAY_TOKEN"]);
|
||||
expect(onboardCommandMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gatewayTokenRefEnv: "OPENCLAW_GATEWAY_TOKEN",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports errors via runtime on onboard command failures", async () => {
|
||||
onboardCommandMock.mockRejectedValueOnce(new Error("onboard failed"));
|
||||
|
||||
|
||||
@@ -104,6 +104,10 @@ export function registerOnboardCommand(program: Command) {
|
||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
|
||||
.option("--gateway-auth <mode>", "Gateway auth: token|password")
|
||||
.option("--gateway-token <token>", "Gateway token (token auth)")
|
||||
.option(
|
||||
"--gateway-token-ref-env <name>",
|
||||
"Gateway token SecretRef env var name (token auth; e.g. OPENCLAW_GATEWAY_TOKEN)",
|
||||
)
|
||||
.option("--gateway-password <password>", "Gateway password (password auth)")
|
||||
.option("--remote-url <url>", "Remote Gateway WebSocket URL")
|
||||
.option("--remote-token <token>", "Remote Gateway token (optional)")
|
||||
@@ -177,6 +181,7 @@ export function registerOnboardCommand(program: Command) {
|
||||
gatewayBind: opts.gatewayBind as GatewayBind | undefined,
|
||||
gatewayAuth: opts.gatewayAuth as GatewayAuthChoice | undefined,
|
||||
gatewayToken: opts.gatewayToken as string | undefined,
|
||||
gatewayTokenRefEnv: opts.gatewayTokenRefEnv as string | undefined,
|
||||
gatewayPassword: opts.gatewayPassword as string | undefined,
|
||||
remoteUrl: opts.remoteUrl as string | undefined,
|
||||
remoteToken: opts.remoteToken as string | undefined,
|
||||
|
||||
@@ -293,6 +293,30 @@ describe("registerQrCli", () => {
|
||||
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails when token and password SecretRefs are both configured with inferred mode", async () => {
|
||||
vi.stubEnv("QR_INFERRED_GATEWAY_TOKEN", "inferred-token");
|
||||
loadConfig.mockReturnValue({
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
auth: {
|
||||
token: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_TOKEN" },
|
||||
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expectQrExit(["--setup-code-only"]);
|
||||
const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
|
||||
expect(output).toContain("gateway.auth.mode is unset");
|
||||
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exits with error when gateway config is not pairable", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import qrcode from "qrcode-terminal";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveSecretInputRef } from "../config/types.secrets.js";
|
||||
import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js";
|
||||
import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairing/setup-code.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -81,11 +81,11 @@ function shouldResolveLocalGatewayPasswordSecret(
|
||||
return false;
|
||||
}
|
||||
const envToken = readGatewayTokenEnv(env);
|
||||
const configToken =
|
||||
typeof cfg.gateway?.auth?.token === "string" && cfg.gateway.auth.token.trim().length > 0
|
||||
? cfg.gateway.auth.token.trim()
|
||||
: undefined;
|
||||
return !envToken && !configToken;
|
||||
const configTokenConfigured = hasConfiguredSecretInput(
|
||||
cfg.gateway?.auth?.token,
|
||||
cfg.secrets?.defaults,
|
||||
);
|
||||
return !envToken && !configTokenConfigured;
|
||||
}
|
||||
|
||||
async function resolveLocalGatewayPasswordSecretIfNeeded(
|
||||
|
||||
168
src/cli/qr-dashboard.integration.test.ts
Normal file
168
src/cli/qr-dashboard.integration.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Command } from "commander";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
|
||||
const loadConfigMock = vi.hoisted(() => vi.fn());
|
||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789));
|
||||
const copyToClipboardMock = vi.hoisted(() => vi.fn(async () => false));
|
||||
|
||||
const runtimeLogs: string[] = [];
|
||||
const runtimeErrors: string[] = [];
|
||||
const runtime = vi.hoisted(() => ({
|
||||
log: (message: string) => runtimeLogs.push(message),
|
||||
error: (message: string) => runtimeErrors.push(message),
|
||||
exit: (code: number) => {
|
||||
throw new Error(`__exit__:${code}`);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: loadConfigMock,
|
||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||
resolveGatewayPort: resolveGatewayPortMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/clipboard.js", () => ({
|
||||
copyToClipboard: copyToClipboardMock,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: runtime,
|
||||
}));
|
||||
|
||||
const { registerQrCli } = await import("./qr-cli.js");
|
||||
const { registerMaintenanceCommands } = await import("./program/register.maintenance.js");
|
||||
|
||||
function createGatewayTokenRefFixture() {
|
||||
return {
|
||||
secrets: {
|
||||
providers: {
|
||||
default: {
|
||||
source: "env",
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.local",
|
||||
port: 18789,
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "SHARED_GATEWAY_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodeSetupCode(setupCode: string): { url?: string; token?: string; password?: string } {
|
||||
const padded = setupCode.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padLength = (4 - (padded.length % 4)) % 4;
|
||||
const normalized = padded + "=".repeat(padLength);
|
||||
const json = Buffer.from(normalized, "base64").toString("utf8");
|
||||
return JSON.parse(json) as { url?: string; token?: string; password?: string };
|
||||
}
|
||||
|
||||
async function runCli(args: string[]): Promise<void> {
|
||||
const program = new Command();
|
||||
registerQrCli(program);
|
||||
registerMaintenanceCommands(program);
|
||||
await program.parseAsync(args, { from: "user" });
|
||||
}
|
||||
|
||||
describe("cli integration: qr + dashboard token SecretRef", () => {
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
|
||||
beforeAll(() => {
|
||||
envSnapshot = captureEnv([
|
||||
"SHARED_GATEWAY_TOKEN",
|
||||
"OPENCLAW_GATEWAY_TOKEN",
|
||||
"CLAWDBOT_GATEWAY_TOKEN",
|
||||
"OPENCLAW_GATEWAY_PASSWORD",
|
||||
"CLAWDBOT_GATEWAY_PASSWORD",
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
envSnapshot.restore();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
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;
|
||||
delete process.env.SHARED_GATEWAY_TOKEN;
|
||||
});
|
||||
|
||||
it("uses the same resolved token SecretRef for both qr and dashboard commands", async () => {
|
||||
const fixture = createGatewayTokenRefFixture();
|
||||
process.env.SHARED_GATEWAY_TOKEN = "shared-token-123";
|
||||
loadConfigMock.mockReturnValue(fixture);
|
||||
readConfigFileSnapshotMock.mockResolvedValue({
|
||||
path: "/tmp/openclaw.json",
|
||||
exists: true,
|
||||
valid: true,
|
||||
issues: [],
|
||||
config: fixture,
|
||||
});
|
||||
|
||||
await runCli(["qr", "--setup-code-only"]);
|
||||
const setupCode = runtimeLogs.at(-1);
|
||||
expect(setupCode).toBeTruthy();
|
||||
const payload = decodeSetupCode(setupCode ?? "");
|
||||
expect(payload.url).toBe("ws://gateway.local:18789");
|
||||
expect(payload.token).toBe("shared-token-123");
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
await runCli(["dashboard", "--no-open"]);
|
||||
const joined = runtimeLogs.join("\n");
|
||||
expect(joined).toContain("Dashboard URL: http://127.0.0.1:18789/");
|
||||
expect(joined).not.toContain("#token=");
|
||||
expect(joined).toContain(
|
||||
"Token auto-auth is disabled for SecretRef-managed gateway.auth.token",
|
||||
);
|
||||
expect(joined).not.toContain("Token auto-auth unavailable");
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails qr but keeps dashboard actionable when the shared token SecretRef is unresolved", async () => {
|
||||
const fixture = createGatewayTokenRefFixture();
|
||||
loadConfigMock.mockReturnValue(fixture);
|
||||
readConfigFileSnapshotMock.mockResolvedValue({
|
||||
path: "/tmp/openclaw.json",
|
||||
exists: true,
|
||||
valid: true,
|
||||
issues: [],
|
||||
config: fixture,
|
||||
});
|
||||
|
||||
await expect(runCli(["qr", "--setup-code-only"])).rejects.toThrow("__exit__:1");
|
||||
expect(runtimeErrors.join("\n")).toMatch(/SHARED_GATEWAY_TOKEN/);
|
||||
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
await runCli(["dashboard", "--no-open"]);
|
||||
const joined = runtimeLogs.join("\n");
|
||||
expect(joined).toContain("Dashboard URL: http://127.0.0.1:18789/");
|
||||
expect(joined).not.toContain("#token=");
|
||||
expect(joined).toContain("Token auto-auth unavailable");
|
||||
expect(joined).toContain("Set OPENCLAW_GATEWAY_TOKEN");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user