mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
refactor(gateway): unify credential precedence across entrypoints
This commit is contained in:
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz.
|
||||
- Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path.
|
||||
- Gateway/Auth: refactor gateway credential resolution and websocket auth handshake paths to use shared typed auth contexts, including explicit `auth.deviceToken` support in connect frames and tests.
|
||||
- Gateway/Auth: unify call/probe/status/auth credential-source precedence on shared resolver helpers, with table-driven parity coverage across gateway entrypoints.
|
||||
- Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior.
|
||||
- Memory/FTS: add Korean stop-word filtering and particle-aware keyword extraction (including mixed Korean/English stems) for query expansion in FTS-only search mode. (#18899) Thanks @ruypang.
|
||||
- Memory/FTS: add Japanese-aware query expansion tokenization and stop-word filtering (including mixed-script terms like ASCII + katakana) for FTS-only search mode. Thanks @vincentkoc.
|
||||
@@ -64,6 +65,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Channels/Delivery: remove hardcoded WhatsApp delivery fallbacks; require explicit/session channel context or auto-pick the sole configured channel when unambiguous. (#23357) Thanks @lbo728.
|
||||
- ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early `gateway not connected` request races. (#23390) Thanks @janckerchen.
|
||||
- Gateway/Auth: preserve `OPENCLAW_GATEWAY_PASSWORD` env override precedence for remote gateway call credentials after shared resolver refactors, preventing stale configured remote passwords from overriding runtime secret rotation.
|
||||
- Gateway/Tools: when agent tools pass an allowlisted `gatewayUrl` override, resolve local override tokens from env/config fallback but keep remote overrides strict to `gateway.remote.token`, preventing local token leakage to remote targets.
|
||||
- Gateway/Client: keep cached device-auth tokens on `device token mismatch` closes when the client used explicit shared token/password credentials, avoiding accidental pairing-token churn during explicit-auth failures.
|
||||
- Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`).
|
||||
- Security/Audit: make `gateway.real_ip_fallback_enabled` severity conditional for loopback trusted-proxy setups (warn for loopback-only `trustedProxies`, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3.
|
||||
- Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
|
||||
@@ -101,6 +101,20 @@ You can persist a remote target so CLI commands use it by default:
|
||||
|
||||
When the gateway is loopback-only, keep the URL at `ws://127.0.0.1:18789` and open the SSH tunnel first.
|
||||
|
||||
## Credential precedence
|
||||
|
||||
Gateway call/probe credential resolution now follows one shared contract:
|
||||
|
||||
- Explicit credentials (`--token`, `--password`, or tool `gatewayToken`) always win.
|
||||
- Local mode defaults:
|
||||
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
|
||||
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password`
|
||||
- Remote mode defaults:
|
||||
- token: `gateway.remote.token` -> `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
|
||||
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.remote.password` -> `gateway.auth.password`
|
||||
- Remote probe/status token checks are strict by default: they use `gateway.remote.token` only (no local token fallback) when targeting remote mode.
|
||||
- Legacy `CLAWDBOT_GATEWAY_*` env vars are only used by compatibility call paths; probe/status/auth resolution uses `OPENCLAW_GATEWAY_*` only.
|
||||
|
||||
## Chat UI over SSH
|
||||
|
||||
WebChat no longer uses a separate HTTP port. The SwiftUI chat UI connects directly to the Gateway WebSocket.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { callGatewayTool, resolveGatewayOptions } from "./gateway.js";
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
const configState = vi.hoisted(() => ({
|
||||
value: {} as Record<string, unknown>,
|
||||
}));
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: () => ({}),
|
||||
loadConfig: () => configState.value,
|
||||
resolveGatewayPort: () => 18789,
|
||||
}));
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
@@ -11,8 +14,29 @@ vi.mock("../../gateway/call.js", () => ({
|
||||
}));
|
||||
|
||||
describe("gateway tool defaults", () => {
|
||||
const envSnapshot = {
|
||||
openclaw: process.env.OPENCLAW_GATEWAY_TOKEN,
|
||||
clawdbot: process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
callGatewayMock.mockClear();
|
||||
configState.value = {};
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (envSnapshot.openclaw === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = envSnapshot.openclaw;
|
||||
}
|
||||
if (envSnapshot.clawdbot === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = envSnapshot.clawdbot;
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves url undefined so callGateway can use config", () => {
|
||||
@@ -37,6 +61,69 @@ describe("gateway tool defaults", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses OPENCLAW_GATEWAY_TOKEN for allowlisted local overrides", () => {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token";
|
||||
const opts = resolveGatewayOptions({ gatewayUrl: "ws://127.0.0.1:18789" });
|
||||
expect(opts.url).toBe("ws://127.0.0.1:18789");
|
||||
expect(opts.token).toBe("env-token");
|
||||
});
|
||||
|
||||
it("falls back to config gateway.auth.token when env is unset for local overrides", () => {
|
||||
configState.value = {
|
||||
gateway: {
|
||||
auth: { token: "config-token" },
|
||||
},
|
||||
};
|
||||
const opts = resolveGatewayOptions({ gatewayUrl: "ws://127.0.0.1:18789" });
|
||||
expect(opts.token).toBe("config-token");
|
||||
});
|
||||
|
||||
it("uses gateway.remote.token for allowlisted remote overrides", () => {
|
||||
configState.value = {
|
||||
gateway: {
|
||||
remote: {
|
||||
url: "wss://gateway.example",
|
||||
token: "remote-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
const opts = resolveGatewayOptions({ gatewayUrl: "wss://gateway.example" });
|
||||
expect(opts.url).toBe("wss://gateway.example");
|
||||
expect(opts.token).toBe("remote-token");
|
||||
});
|
||||
|
||||
it("does not leak local env/config tokens to remote overrides", () => {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "local-env-token";
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = "legacy-env-token";
|
||||
configState.value = {
|
||||
gateway: {
|
||||
auth: { token: "local-config-token" },
|
||||
remote: {
|
||||
url: "wss://gateway.example",
|
||||
},
|
||||
},
|
||||
};
|
||||
const opts = resolveGatewayOptions({ gatewayUrl: "wss://gateway.example" });
|
||||
expect(opts.token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("explicit gatewayToken overrides fallback token resolution", () => {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "local-env-token";
|
||||
configState.value = {
|
||||
gateway: {
|
||||
remote: {
|
||||
url: "wss://gateway.example",
|
||||
token: "remote-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
const opts = resolveGatewayOptions({
|
||||
gatewayUrl: "wss://gateway.example",
|
||||
gatewayToken: "explicit-token",
|
||||
});
|
||||
expect(opts.token).toBe("explicit-token");
|
||||
});
|
||||
|
||||
it("uses least-privilege write scope for write methods", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
await callGatewayTool("wake", {}, { mode: "now", text: "hi" });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { loadConfig, resolveGatewayPort } from "../../config/config.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { resolveGatewayCredentialsFromConfig, trimToUndefined } from "../../gateway/credentials.js";
|
||||
import { resolveLeastPrivilegeOperatorScopesForMethod } from "../../gateway/method-scopes.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
||||
import { readStringParam } from "./common.js";
|
||||
@@ -12,6 +13,8 @@ export type GatewayCallOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type GatewayOverrideTarget = "local" | "remote";
|
||||
|
||||
export function readGatewayCallOptions(params: Record<string, unknown>): GatewayCallOptions {
|
||||
return {
|
||||
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
|
||||
@@ -50,10 +53,13 @@ function canonicalizeToolGatewayWsUrl(raw: string): { origin: string; key: strin
|
||||
return { origin, key };
|
||||
}
|
||||
|
||||
function validateGatewayUrlOverrideForAgentTools(urlOverride: string): string {
|
||||
const cfg = loadConfig();
|
||||
function validateGatewayUrlOverrideForAgentTools(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
urlOverride: string;
|
||||
}): { url: string; target: GatewayOverrideTarget } {
|
||||
const { cfg } = params;
|
||||
const port = resolveGatewayPort(cfg);
|
||||
const allowed = new Set<string>([
|
||||
const localAllowed = new Set<string>([
|
||||
`ws://127.0.0.1:${port}`,
|
||||
`wss://127.0.0.1:${port}`,
|
||||
`ws://localhost:${port}`,
|
||||
@@ -62,45 +68,73 @@ function validateGatewayUrlOverrideForAgentTools(urlOverride: string): string {
|
||||
`wss://[::1]:${port}`,
|
||||
]);
|
||||
|
||||
let remoteKey: string | undefined;
|
||||
const remoteUrl =
|
||||
typeof cfg.gateway?.remote?.url === "string" ? cfg.gateway.remote.url.trim() : "";
|
||||
if (remoteUrl) {
|
||||
try {
|
||||
const remote = canonicalizeToolGatewayWsUrl(remoteUrl);
|
||||
allowed.add(remote.key);
|
||||
remoteKey = remote.key;
|
||||
} catch {
|
||||
// ignore: misconfigured remote url; tools should fall back to default resolution.
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = canonicalizeToolGatewayWsUrl(urlOverride);
|
||||
if (!allowed.has(parsed.key)) {
|
||||
throw new Error(
|
||||
[
|
||||
"gatewayUrl override rejected.",
|
||||
`Allowed: ws(s) loopback on port ${port} (127.0.0.1/localhost/[::1])`,
|
||||
"Or: configure gateway.remote.url and omit gatewayUrl to use the configured remote gateway.",
|
||||
].join(" "),
|
||||
);
|
||||
const parsed = canonicalizeToolGatewayWsUrl(params.urlOverride);
|
||||
if (localAllowed.has(parsed.key)) {
|
||||
return { url: parsed.origin, target: "local" };
|
||||
}
|
||||
return parsed.origin;
|
||||
if (remoteKey && parsed.key === remoteKey) {
|
||||
return { url: parsed.origin, target: "remote" };
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
"gatewayUrl override rejected.",
|
||||
`Allowed: ws(s) loopback on port ${port} (127.0.0.1/localhost/[::1])`,
|
||||
"Or: configure gateway.remote.url and omit gatewayUrl to use the configured remote gateway.",
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGatewayOverrideToken(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
target: GatewayOverrideTarget;
|
||||
explicitToken?: string;
|
||||
}): string | undefined {
|
||||
if (params.explicitToken) {
|
||||
return params.explicitToken;
|
||||
}
|
||||
return resolveGatewayCredentialsFromConfig({
|
||||
cfg: params.cfg,
|
||||
env: process.env,
|
||||
modeOverride: params.target,
|
||||
remoteTokenFallback: params.target === "remote" ? "remote-only" : "remote-env-local",
|
||||
remotePasswordFallback: params.target === "remote" ? "remote-only" : "remote-env-local",
|
||||
}).token;
|
||||
}
|
||||
|
||||
export function resolveGatewayOptions(opts?: GatewayCallOptions) {
|
||||
// Prefer an explicit override; otherwise let callGateway choose based on config.
|
||||
const url =
|
||||
typeof opts?.gatewayUrl === "string" && opts.gatewayUrl.trim()
|
||||
? validateGatewayUrlOverrideForAgentTools(opts.gatewayUrl)
|
||||
: undefined;
|
||||
const token =
|
||||
typeof opts?.gatewayToken === "string" && opts.gatewayToken.trim()
|
||||
? opts.gatewayToken.trim()
|
||||
const cfg = loadConfig();
|
||||
const validatedOverride =
|
||||
trimToUndefined(opts?.gatewayUrl) !== undefined
|
||||
? validateGatewayUrlOverrideForAgentTools({
|
||||
cfg,
|
||||
urlOverride: String(opts?.gatewayUrl),
|
||||
})
|
||||
: undefined;
|
||||
const explicitToken = trimToUndefined(opts?.gatewayToken);
|
||||
const token = validatedOverride
|
||||
? resolveGatewayOverrideToken({
|
||||
cfg,
|
||||
target: validatedOverride.target,
|
||||
explicitToken,
|
||||
})
|
||||
: explicitToken;
|
||||
const timeoutMs =
|
||||
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
|
||||
? Math.max(1, Math.floor(opts.timeoutMs))
|
||||
: 30_000;
|
||||
return { url, token, timeoutMs };
|
||||
return { url: validatedOverride?.url, token, timeoutMs };
|
||||
}
|
||||
|
||||
export async function callGatewayTool<T = Record<string, unknown>>(
|
||||
|
||||
@@ -47,7 +47,14 @@ describe("runServiceRestart token drift", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
runtimeLogs.length = 0;
|
||||
loadConfig.mockClear();
|
||||
loadConfig.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: "config-token",
|
||||
},
|
||||
},
|
||||
});
|
||||
service.isLoaded.mockClear();
|
||||
service.readCommand.mockClear();
|
||||
service.restart.mockClear();
|
||||
@@ -76,6 +83,32 @@ describe("runServiceRestart token drift", () => {
|
||||
expect(payload.warnings?.[0]).toContain("gateway install --force");
|
||||
});
|
||||
|
||||
it("uses env-first token precedence when checking drift", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: "config-token",
|
||||
},
|
||||
},
|
||||
});
|
||||
service.readCommand.mockResolvedValue({
|
||||
environment: { OPENCLAW_GATEWAY_TOKEN: "env-token" },
|
||||
});
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "env-token");
|
||||
|
||||
await runServiceRestart({
|
||||
serviceNoun: "Gateway",
|
||||
service,
|
||||
renderStartHints: () => [],
|
||||
opts: { json: true },
|
||||
checkTokenDrift: true,
|
||||
});
|
||||
|
||||
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
|
||||
const payload = JSON.parse(jsonLine ?? "{}") as { warnings?: string[] };
|
||||
expect(payload.warnings).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips drift warning when disabled", async () => {
|
||||
await runServiceRestart({
|
||||
serviceNoun: "Node",
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { isWSL } from "../../infra/wsl.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
@@ -280,10 +281,11 @@ export async function runServiceRestart(params: {
|
||||
const command = await params.service.readCommand(process.env);
|
||||
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN;
|
||||
const cfg = loadConfig();
|
||||
const configToken =
|
||||
cfg.gateway?.auth?.token ||
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN ||
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
const configToken = resolveGatewayCredentialsFromConfig({
|
||||
cfg,
|
||||
env: process.env,
|
||||
modeOverride: "local",
|
||||
}).token;
|
||||
const driftIssue = checkTokenDrift({ serviceToken, configToken });
|
||||
if (driftIssue) {
|
||||
const warning = driftIssue.detail
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
import type { loadConfig } from "../config/config.js";
|
||||
import { resolveGatewayProbeAuth as resolveGatewayProbeAuthByMode } from "../gateway/probe-auth.js";
|
||||
export { pickGatewaySelfPresence } from "./gateway-presence.js";
|
||||
|
||||
export function resolveGatewayProbeAuth(cfg: ReturnType<typeof loadConfig>): {
|
||||
token?: string;
|
||||
password?: string;
|
||||
} {
|
||||
const isRemoteMode = cfg.gateway?.mode === "remote";
|
||||
const remote = isRemoteMode ? cfg.gateway?.remote : undefined;
|
||||
const authToken = cfg.gateway?.auth?.token;
|
||||
const authPassword = cfg.gateway?.auth?.password;
|
||||
const token = isRemoteMode
|
||||
? typeof remote?.token === "string" && remote.token.trim().length > 0
|
||||
? remote.token.trim()
|
||||
: undefined
|
||||
: process.env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
|
||||
(typeof authToken === "string" && authToken.trim().length > 0 ? authToken.trim() : undefined);
|
||||
const password =
|
||||
process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() ||
|
||||
(isRemoteMode
|
||||
? typeof remote?.password === "string" && remote.password.trim().length > 0
|
||||
? remote.password.trim()
|
||||
: undefined
|
||||
: typeof authPassword === "string" && authPassword.trim().length > 0
|
||||
? authPassword.trim()
|
||||
: undefined);
|
||||
return { token, password };
|
||||
return resolveGatewayProbeAuthByMode({
|
||||
cfg,
|
||||
mode: cfg.gateway?.mode === "remote" ? "remote" : "local",
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,6 +120,24 @@ describe("gateway auth", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps gateway auth config values ahead of env overrides", () => {
|
||||
expect(
|
||||
resolveGatewayAuth({
|
||||
authConfig: {
|
||||
token: "config-token",
|
||||
password: "config-password",
|
||||
},
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
}),
|
||||
).toMatchObject({
|
||||
token: "config-token",
|
||||
password: "config-password",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves explicit auth mode none from config", () => {
|
||||
expect(
|
||||
resolveGatewayAuth({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type AuthRateLimiter,
|
||||
type RateLimitCheckResult,
|
||||
} from "./auth-rate-limit.js";
|
||||
import { resolveGatewayCredentialsFromValues } from "./credentials.js";
|
||||
import {
|
||||
isLocalishHost,
|
||||
isLoopbackAddress,
|
||||
@@ -242,8 +243,16 @@ export function resolveGatewayAuth(params: {
|
||||
}
|
||||
}
|
||||
const env = params.env ?? process.env;
|
||||
const token = authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? undefined;
|
||||
const password = authConfig.password ?? env.OPENCLAW_GATEWAY_PASSWORD ?? undefined;
|
||||
const resolvedCredentials = resolveGatewayCredentialsFromValues({
|
||||
configToken: authConfig.token,
|
||||
configPassword: authConfig.password,
|
||||
env,
|
||||
includeLegacyEnv: false,
|
||||
tokenPrecedence: "config-first",
|
||||
passwordPrecedence: "config-first",
|
||||
});
|
||||
const token = resolvedCredentials.token;
|
||||
const password = resolvedCredentials.password;
|
||||
const trustedProxy = authConfig.trustedProxy;
|
||||
|
||||
let mode: ResolvedGatewayAuth["mode"];
|
||||
|
||||
@@ -287,6 +287,29 @@ describe("GatewayClient close handling", () => {
|
||||
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: signature invalid");
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("does not clear persisted device auth when explicit shared token is provided", () => {
|
||||
const onClose = vi.fn();
|
||||
const identity: DeviceIdentity = {
|
||||
deviceId: "dev-5",
|
||||
privateKeyPem: "private-key",
|
||||
publicKeyPem: "public-key",
|
||||
};
|
||||
const client = new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
deviceIdentity: identity,
|
||||
token: "shared-token",
|
||||
onClose,
|
||||
});
|
||||
|
||||
client.start();
|
||||
getLatestWs().emitClose(1008, "unauthorized: device token mismatch");
|
||||
|
||||
expect(clearDeviceAuthTokenMock).not.toHaveBeenCalled();
|
||||
expect(clearDevicePairingMock).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
|
||||
client.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GatewayClient connect auth payload", () => {
|
||||
|
||||
@@ -179,10 +179,14 @@ export class GatewayClient {
|
||||
this.ws.on("close", (code, reason) => {
|
||||
const reasonText = rawDataToString(reason);
|
||||
this.ws = null;
|
||||
// If closed due to device token mismatch, clear the stored token and pairing so next attempt can get a fresh one
|
||||
// Clear persisted device auth state only when device-token auth was active.
|
||||
// Shared token/password failures can return the same close reason but should
|
||||
// not erase a valid cached device token.
|
||||
if (
|
||||
code === 1008 &&
|
||||
reasonText.toLowerCase().includes("device token mismatch") &&
|
||||
!this.opts.token &&
|
||||
!this.opts.password &&
|
||||
this.opts.deviceIdentity
|
||||
) {
|
||||
const deviceId = this.opts.deviceIdentity.deviceId;
|
||||
|
||||
171
src/gateway/credential-precedence.parity.test.ts
Normal file
171
src/gateway/credential-precedence.parity.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveGatewayProbeAuth as resolveStatusGatewayProbeAuth } from "../commands/status.gateway-probe.js";
|
||||
import type { OpenClawConfig, loadConfig } from "../config/config.js";
|
||||
import { resolveGatewayAuth } from "./auth.js";
|
||||
import { resolveGatewayCredentialsFromConfig } from "./credentials.js";
|
||||
import { resolveGatewayProbeAuth } from "./probe-auth.js";
|
||||
|
||||
type ExpectedCredentialSet = {
|
||||
call: { token?: string; password?: string };
|
||||
probe: { token?: string; password?: string };
|
||||
status: { token?: string; password?: string };
|
||||
auth: { token?: string; password?: string };
|
||||
};
|
||||
|
||||
type TestCase = {
|
||||
name: string;
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
expected: ExpectedCredentialSet;
|
||||
};
|
||||
|
||||
function withGatewayAuthEnv<T>(env: NodeJS.ProcessEnv, fn: () => T): T {
|
||||
const keys = [
|
||||
"OPENCLAW_GATEWAY_TOKEN",
|
||||
"OPENCLAW_GATEWAY_PASSWORD",
|
||||
"CLAWDBOT_GATEWAY_TOKEN",
|
||||
"CLAWDBOT_GATEWAY_PASSWORD",
|
||||
] as const;
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const key of keys) {
|
||||
previous.set(key, process.env[key]);
|
||||
const nextValue = env[key];
|
||||
if (typeof nextValue === "string") {
|
||||
process.env[key] = nextValue;
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
for (const key of keys) {
|
||||
const value = previous.get(key);
|
||||
if (typeof value === "string") {
|
||||
process.env[key] = value;
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway credential precedence parity", () => {
|
||||
const cases: TestCase[] = [
|
||||
{
|
||||
name: "local mode: env overrides config for call/probe/status, auth remains config-first",
|
||||
cfg: {
|
||||
gateway: {
|
||||
mode: "local",
|
||||
auth: {
|
||||
token: "config-token",
|
||||
password: "config-password",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
expected: {
|
||||
call: { token: "env-token", password: "env-password" },
|
||||
probe: { token: "env-token", password: "env-password" },
|
||||
status: { token: "env-token", password: "env-password" },
|
||||
auth: { token: "config-token", password: "config-password" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remote mode with remote token configured",
|
||||
cfg: {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: {
|
||||
token: "remote-token",
|
||||
password: "remote-password",
|
||||
},
|
||||
auth: {
|
||||
token: "local-token",
|
||||
password: "local-password",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
expected: {
|
||||
call: { token: "remote-token", password: "env-password" },
|
||||
probe: { token: "remote-token", password: "env-password" },
|
||||
status: { token: "remote-token", password: "env-password" },
|
||||
auth: { token: "local-token", password: "local-password" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remote mode without remote token keeps remote probe/status strict",
|
||||
cfg: {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: {
|
||||
password: "remote-password",
|
||||
},
|
||||
auth: {
|
||||
token: "local-token",
|
||||
password: "local-password",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
expected: {
|
||||
call: { token: "env-token", password: "env-password" },
|
||||
probe: { token: undefined, password: "env-password" },
|
||||
status: { token: undefined, password: "env-password" },
|
||||
auth: { token: "local-token", password: "local-password" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "legacy env vars are ignored by probe/status/auth but still supported for call path",
|
||||
cfg: {
|
||||
gateway: {
|
||||
mode: "local",
|
||||
auth: {},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
env: {
|
||||
CLAWDBOT_GATEWAY_TOKEN: "legacy-token",
|
||||
CLAWDBOT_GATEWAY_PASSWORD: "legacy-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
expected: {
|
||||
call: { token: "legacy-token", password: "legacy-password" },
|
||||
probe: { token: undefined, password: undefined },
|
||||
status: { token: undefined, password: undefined },
|
||||
auth: { token: undefined, password: undefined },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)("$name", ({ cfg, env, expected }) => {
|
||||
const mode = cfg.gateway?.mode === "remote" ? "remote" : "local";
|
||||
const call = resolveGatewayCredentialsFromConfig({
|
||||
cfg,
|
||||
env,
|
||||
});
|
||||
const probe = resolveGatewayProbeAuth({
|
||||
cfg,
|
||||
mode,
|
||||
env,
|
||||
});
|
||||
const status = withGatewayAuthEnv(env, () => resolveStatusGatewayProbeAuth(cfg));
|
||||
const auth = resolveGatewayAuth({
|
||||
authConfig: cfg.gateway?.auth,
|
||||
env,
|
||||
});
|
||||
|
||||
expect(call).toEqual(expected.call);
|
||||
expect(probe).toEqual(expected.probe);
|
||||
expect(status).toEqual(expected.status);
|
||||
expect({ token: auth.token, password: auth.password }).toEqual(expected.auth);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveGatewayCredentialsFromConfig } from "./credentials.js";
|
||||
import {
|
||||
resolveGatewayCredentialsFromConfig,
|
||||
resolveGatewayCredentialsFromValues,
|
||||
} from "./credentials.js";
|
||||
|
||||
function cfg(input: Partial<OpenClawConfig>): OpenClawConfig {
|
||||
return input as OpenClawConfig;
|
||||
@@ -77,7 +80,7 @@ describe("resolveGatewayCredentialsFromConfig", () => {
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
token: "remote-token",
|
||||
password: "remote-password",
|
||||
password: "env-password",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,4 +124,72 @@ describe("resolveGatewayCredentialsFromConfig", () => {
|
||||
password: "env-password",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports remote-only token fallback for strict remote override call sites", () => {
|
||||
const resolved = resolveGatewayCredentialsFromConfig({
|
||||
cfg: cfg({
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: { url: "wss://gateway.example" },
|
||||
auth: { token: "local-token" },
|
||||
},
|
||||
}),
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
} as NodeJS.ProcessEnv,
|
||||
remoteTokenFallback: "remote-only",
|
||||
});
|
||||
expect(resolved.token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can disable legacy CLAWDBOT env fallback", () => {
|
||||
const resolved = resolveGatewayCredentialsFromConfig({
|
||||
cfg: cfg({
|
||||
gateway: {
|
||||
mode: "local",
|
||||
},
|
||||
}),
|
||||
env: {
|
||||
CLAWDBOT_GATEWAY_TOKEN: "legacy-token",
|
||||
CLAWDBOT_GATEWAY_PASSWORD: "legacy-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
includeLegacyEnv: false,
|
||||
});
|
||||
expect(resolved).toEqual({ token: undefined, password: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGatewayCredentialsFromValues", () => {
|
||||
it("supports config-first precedence for token/password", () => {
|
||||
const resolved = resolveGatewayCredentialsFromValues({
|
||||
configToken: "config-token",
|
||||
configPassword: "config-password",
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
includeLegacyEnv: false,
|
||||
tokenPrecedence: "config-first",
|
||||
passwordPrecedence: "config-first",
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
token: "config-token",
|
||||
password: "config-password",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses env-first precedence by default", () => {
|
||||
const resolved = resolveGatewayCredentialsFromValues({
|
||||
configToken: "config-token",
|
||||
configPassword: "config-password",
|
||||
env: {
|
||||
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
token: "env-token",
|
||||
password: "env-password",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,12 @@ export type ResolvedGatewayCredentials = {
|
||||
password?: string;
|
||||
};
|
||||
|
||||
function trimToUndefined(value: unknown): string | undefined {
|
||||
export type GatewayCredentialMode = "local" | "remote";
|
||||
export type GatewayCredentialPrecedence = "env-first" | "config-first";
|
||||
export type GatewayRemoteCredentialPrecedence = "remote-first" | "env-first";
|
||||
export type GatewayRemoteCredentialFallback = "remote-env-local" | "remote-only";
|
||||
|
||||
export function trimToUndefined(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
@@ -18,14 +23,88 @@ function trimToUndefined(value: unknown): string | undefined {
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function firstDefined(values: Array<string | undefined>): string | undefined {
|
||||
for (const value of values) {
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readGatewayTokenEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
includeLegacyEnv: boolean,
|
||||
): string | undefined {
|
||||
const primary = trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN);
|
||||
if (primary) {
|
||||
return primary;
|
||||
}
|
||||
if (!includeLegacyEnv) {
|
||||
return undefined;
|
||||
}
|
||||
return trimToUndefined(env.CLAWDBOT_GATEWAY_TOKEN);
|
||||
}
|
||||
|
||||
function readGatewayPasswordEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
includeLegacyEnv: boolean,
|
||||
): string | undefined {
|
||||
const primary = trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD);
|
||||
if (primary) {
|
||||
return primary;
|
||||
}
|
||||
if (!includeLegacyEnv) {
|
||||
return undefined;
|
||||
}
|
||||
return trimToUndefined(env.CLAWDBOT_GATEWAY_PASSWORD);
|
||||
}
|
||||
|
||||
export function resolveGatewayCredentialsFromValues(params: {
|
||||
configToken?: string;
|
||||
configPassword?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
includeLegacyEnv?: boolean;
|
||||
tokenPrecedence?: GatewayCredentialPrecedence;
|
||||
passwordPrecedence?: GatewayCredentialPrecedence;
|
||||
}): ResolvedGatewayCredentials {
|
||||
const env = params.env ?? process.env;
|
||||
const includeLegacyEnv = params.includeLegacyEnv ?? true;
|
||||
const envToken = readGatewayTokenEnv(env, includeLegacyEnv);
|
||||
const envPassword = readGatewayPasswordEnv(env, includeLegacyEnv);
|
||||
const configToken = trimToUndefined(params.configToken);
|
||||
const configPassword = trimToUndefined(params.configPassword);
|
||||
const tokenPrecedence = params.tokenPrecedence ?? "env-first";
|
||||
const passwordPrecedence = params.passwordPrecedence ?? "env-first";
|
||||
|
||||
const token =
|
||||
tokenPrecedence === "config-first"
|
||||
? firstDefined([configToken, envToken])
|
||||
: firstDefined([envToken, configToken]);
|
||||
const password =
|
||||
passwordPrecedence === "config-first"
|
||||
? firstDefined([configPassword, envPassword])
|
||||
: firstDefined([envPassword, configPassword]);
|
||||
|
||||
return { token, password };
|
||||
}
|
||||
|
||||
export function resolveGatewayCredentialsFromConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
explicitAuth?: ExplicitGatewayAuth;
|
||||
urlOverride?: string;
|
||||
remotePasswordPrecedence?: "remote-first" | "env-first";
|
||||
modeOverride?: GatewayCredentialMode;
|
||||
includeLegacyEnv?: boolean;
|
||||
localTokenPrecedence?: GatewayCredentialPrecedence;
|
||||
localPasswordPrecedence?: GatewayCredentialPrecedence;
|
||||
remoteTokenPrecedence?: GatewayRemoteCredentialPrecedence;
|
||||
remotePasswordPrecedence?: GatewayRemoteCredentialPrecedence;
|
||||
remoteTokenFallback?: GatewayRemoteCredentialFallback;
|
||||
remotePasswordFallback?: GatewayRemoteCredentialFallback;
|
||||
}): ResolvedGatewayCredentials {
|
||||
const env = params.env ?? process.env;
|
||||
const includeLegacyEnv = params.includeLegacyEnv ?? true;
|
||||
const explicitToken = trimToUndefined(params.explicitAuth?.token);
|
||||
const explicitPassword = trimToUndefined(params.explicitAuth?.password);
|
||||
if (explicitToken || explicitPassword) {
|
||||
@@ -35,27 +114,49 @@ export function resolveGatewayCredentialsFromConfig(params: {
|
||||
return {};
|
||||
}
|
||||
|
||||
const isRemoteMode = params.cfg.gateway?.mode === "remote";
|
||||
const remote = isRemoteMode ? params.cfg.gateway?.remote : undefined;
|
||||
|
||||
const envToken =
|
||||
trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN) ?? trimToUndefined(env.CLAWDBOT_GATEWAY_TOKEN);
|
||||
const envPassword =
|
||||
trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD) ??
|
||||
trimToUndefined(env.CLAWDBOT_GATEWAY_PASSWORD);
|
||||
const mode: GatewayCredentialMode =
|
||||
params.modeOverride ?? (params.cfg.gateway?.mode === "remote" ? "remote" : "local");
|
||||
const remote = mode === "remote" ? params.cfg.gateway?.remote : undefined;
|
||||
const envToken = readGatewayTokenEnv(env, includeLegacyEnv);
|
||||
const envPassword = readGatewayPasswordEnv(env, includeLegacyEnv);
|
||||
|
||||
const remoteToken = trimToUndefined(remote?.token);
|
||||
const remotePassword = trimToUndefined(remote?.password);
|
||||
const localToken = trimToUndefined(params.cfg.gateway?.auth?.token);
|
||||
const localPassword = trimToUndefined(params.cfg.gateway?.auth?.password);
|
||||
|
||||
const token = isRemoteMode ? (remoteToken ?? envToken ?? localToken) : (envToken ?? localToken);
|
||||
const passwordPrecedence = params.remotePasswordPrecedence ?? "remote-first";
|
||||
const password = isRemoteMode
|
||||
? passwordPrecedence === "env-first"
|
||||
? (envPassword ?? remotePassword ?? localPassword)
|
||||
: (remotePassword ?? envPassword ?? localPassword)
|
||||
: (envPassword ?? localPassword);
|
||||
const localTokenPrecedence = params.localTokenPrecedence ?? "env-first";
|
||||
const localPasswordPrecedence = params.localPasswordPrecedence ?? "env-first";
|
||||
|
||||
if (mode === "local") {
|
||||
const localResolved = resolveGatewayCredentialsFromValues({
|
||||
configToken: localToken,
|
||||
configPassword: localPassword,
|
||||
env,
|
||||
includeLegacyEnv,
|
||||
tokenPrecedence: localTokenPrecedence,
|
||||
passwordPrecedence: localPasswordPrecedence,
|
||||
});
|
||||
return localResolved;
|
||||
}
|
||||
|
||||
const remoteTokenFallback = params.remoteTokenFallback ?? "remote-env-local";
|
||||
const remotePasswordFallback = params.remotePasswordFallback ?? "remote-env-local";
|
||||
const remoteTokenPrecedence = params.remoteTokenPrecedence ?? "remote-first";
|
||||
const remotePasswordPrecedence = params.remotePasswordPrecedence ?? "env-first";
|
||||
|
||||
const token =
|
||||
remoteTokenFallback === "remote-only"
|
||||
? remoteToken
|
||||
: remoteTokenPrecedence === "env-first"
|
||||
? firstDefined([envToken, remoteToken, localToken])
|
||||
: firstDefined([remoteToken, envToken, localToken]);
|
||||
const password =
|
||||
remotePasswordFallback === "remote-only"
|
||||
? remotePassword
|
||||
: remotePasswordPrecedence === "env-first"
|
||||
? firstDefined([envPassword, remotePassword, localPassword])
|
||||
: firstDefined([remotePassword, envPassword, localPassword]);
|
||||
|
||||
return { token, password };
|
||||
}
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveGatewayCredentialsFromConfig } from "./credentials.js";
|
||||
|
||||
export function resolveGatewayProbeAuth(params: {
|
||||
cfg: OpenClawConfig;
|
||||
mode: "local" | "remote";
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): { token?: string; password?: string } {
|
||||
const env = params.env ?? process.env;
|
||||
const authToken = params.cfg.gateway?.auth?.token;
|
||||
const authPassword = params.cfg.gateway?.auth?.password;
|
||||
const remote = params.cfg.gateway?.remote;
|
||||
|
||||
const token =
|
||||
params.mode === "remote"
|
||||
? typeof remote?.token === "string" && remote.token.trim()
|
||||
? remote.token.trim()
|
||||
: undefined
|
||||
: env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
|
||||
(typeof authToken === "string" && authToken.trim() ? authToken.trim() : undefined);
|
||||
|
||||
const password =
|
||||
env.OPENCLAW_GATEWAY_PASSWORD?.trim() ||
|
||||
(params.mode === "remote"
|
||||
? typeof remote?.password === "string" && remote.password.trim()
|
||||
? remote.password.trim()
|
||||
: undefined
|
||||
: typeof authPassword === "string" && authPassword.trim()
|
||||
? authPassword.trim()
|
||||
: undefined);
|
||||
|
||||
return { token, password };
|
||||
return resolveGatewayCredentialsFromConfig({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
modeOverride: params.mode,
|
||||
includeLegacyEnv: false,
|
||||
remoteTokenFallback: "remote-only",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user