mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-12 15:06:04 +00:00
fix(gateway): keep local CLI shared auth off device scopes (#96002)
* fix(gateway): keep local CLI shared auth off device scopes * fix(gateway): preserve auth-none CLI device identity --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- **WhatsApp restart recovery:** stop automatic restart loops after logged-out or connection-replaced disconnects until the account reconnects. (#78511) Thanks @openperf.
|
||||
- **Local Gateway CLI auth:** keep loopback CLI token/password calls off durable device scopes so read probes cannot block later write/admin commands behind a stale pairing baseline. (#95997) Thanks @vincentkoc.
|
||||
- **iMessage group warnings:** suppress the false drop-all startup warning when an effective group sender allowlist can admit groups, and point true empty-allowlist configurations at the correct remedy. (#100046)
|
||||
- **Control UI mobile login:** keep Gateway recovery guidance visible after connection failures, make the disconnected gate scroll safely on constrained screens, and improve mobile keyboard and tap-target behavior. (#100208)
|
||||
|
||||
|
||||
@@ -552,7 +552,7 @@ describe("callGateway url resolution", () => {
|
||||
expect(lastClientOptions?.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps device identity enabled for explicit CLI loopback shared-token auth", async () => {
|
||||
it("omits device identity for explicit CLI loopback shared-token auth", async () => {
|
||||
setLocalLoopbackGatewayConfig();
|
||||
|
||||
await callGateway({
|
||||
@@ -564,6 +564,19 @@ describe("callGateway url resolution", () => {
|
||||
|
||||
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789");
|
||||
expect(lastClientOptions?.token).toBe("explicit-token");
|
||||
expect(lastClientOptions?.deviceIdentity).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps CLI device identity when an ambient token is inactive under auth mode none", async () => {
|
||||
getRuntimeConfig.mockReturnValue({
|
||||
gateway: { mode: "local", bind: "loopback", auth: { mode: "none" } },
|
||||
});
|
||||
setGatewayNetworkDefaults();
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "inactive-env-token";
|
||||
|
||||
await callGatewayCli({ method: "health" });
|
||||
|
||||
expect(lastClientOptions?.token).toBe("inactive-env-token");
|
||||
expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityState.value);
|
||||
});
|
||||
|
||||
|
||||
@@ -484,31 +484,33 @@ function isLoopbackGatewayUrl(rawUrl: string): boolean {
|
||||
function shouldOmitDeviceIdentityForGatewayCall(params: {
|
||||
opts: CallGatewayBaseOptions;
|
||||
url: string;
|
||||
authMode: ReturnType<typeof resolveGatewayAuth>["mode"];
|
||||
token?: string;
|
||||
password?: string;
|
||||
allowAuthNone?: boolean;
|
||||
}): boolean {
|
||||
const mode = params.opts.mode ?? GATEWAY_CLIENT_MODES.CLI;
|
||||
const clientName = params.opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI;
|
||||
const hasDirectLocalBackendAuth =
|
||||
Boolean(params.token || params.password) || params.allowAuthNone === true;
|
||||
return (
|
||||
// Inactive ambient credentials must not turn an auth-none CLI call device-less.
|
||||
// Omit identity only when the Gateway will actually authenticate the supplied secret.
|
||||
const hasSharedSecretAuth =
|
||||
(params.authMode === "token" && Boolean(params.token)) ||
|
||||
(params.authMode === "password" && Boolean(params.password));
|
||||
const isLoopback = isLoopbackGatewayUrl(params.url);
|
||||
const isLocalBackendSharedAuth =
|
||||
mode === GATEWAY_CLIENT_MODES.BACKEND &&
|
||||
clientName === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT &&
|
||||
hasDirectLocalBackendAuth &&
|
||||
isLoopbackGatewayUrl(params.url)
|
||||
);
|
||||
(hasSharedSecretAuth || params.allowAuthNone === true) &&
|
||||
isLoopback;
|
||||
const isLocalCliSharedAuth =
|
||||
mode === GATEWAY_CLIENT_MODES.CLI &&
|
||||
clientName === GATEWAY_CLIENT_NAMES.CLI &&
|
||||
hasSharedSecretAuth &&
|
||||
isLoopback;
|
||||
return isLocalBackendSharedAuth || isLocalCliSharedAuth;
|
||||
}
|
||||
|
||||
function resolveDeviceIdentityForGatewayCall(params: {
|
||||
opts: CallGatewayBaseOptions;
|
||||
url: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
}): ReturnType<typeof loadOrCreateDeviceIdentity> | null {
|
||||
if (shouldOmitDeviceIdentityForGatewayCall(params)) {
|
||||
return null;
|
||||
}
|
||||
function resolveDeviceIdentityForGatewayCall(): DeviceIdentity | null {
|
||||
try {
|
||||
return gatewayCallDeps.loadOrCreateDeviceIdentity();
|
||||
} catch {
|
||||
@@ -1163,12 +1165,12 @@ async function callGatewayWithScopes<T = Record<string, unknown>>(
|
||||
const tlsFingerprint = await resolveGatewayTlsFingerprint({ opts, context, url });
|
||||
const token = useStoredDeviceAuth ? undefined : resolvedCredentials.token;
|
||||
const password = useStoredDeviceAuth ? undefined : resolvedCredentials.password;
|
||||
const allowAuthNone =
|
||||
opts.requireLocalBackendSharedAuth === true &&
|
||||
resolveGatewayCallAuth(context.config).mode === "none";
|
||||
const authMode = resolveGatewayCallAuth(context.config).mode;
|
||||
const allowAuthNone = opts.requireLocalBackendSharedAuth === true && authMode === "none";
|
||||
const omitDeviceIdentity = shouldOmitDeviceIdentityForGatewayCall({
|
||||
opts,
|
||||
url,
|
||||
authMode,
|
||||
token,
|
||||
password,
|
||||
allowAuthNone,
|
||||
@@ -1182,7 +1184,7 @@ async function callGatewayWithScopes<T = Record<string, unknown>>(
|
||||
opts.deviceIdentity === undefined
|
||||
? omitDeviceIdentity
|
||||
? null
|
||||
: resolveDeviceIdentityForGatewayCall({ opts, url, token, password })
|
||||
: resolveDeviceIdentityForGatewayCall()
|
||||
: opts.deviceIdentity;
|
||||
if (useStoredDeviceAuth) {
|
||||
const storedAuth = loadStoredOperatorDeviceAuthToken(deviceIdentity);
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
ConnectErrorDetailCodes,
|
||||
createSignedDevice,
|
||||
getFreePort,
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
readConnectChallengeNonce,
|
||||
openWs,
|
||||
originForPort,
|
||||
@@ -23,6 +25,13 @@ import {
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
const CLI_CLIENT = {
|
||||
id: GATEWAY_CLIENT_NAMES.CLI,
|
||||
version: "1.0.0",
|
||||
platform: "test",
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
};
|
||||
|
||||
function expectAuthErrorDetails(params: {
|
||||
details: unknown;
|
||||
expectedCode: string;
|
||||
@@ -96,6 +105,36 @@ async function expectLocalBackendGatewayClientScopesPreserved(
|
||||
}
|
||||
}
|
||||
|
||||
async function expectLocalCliSharedAuthScopesPreserved(
|
||||
port: number,
|
||||
auth: { token?: string; password?: string },
|
||||
) {
|
||||
const ws = await openWs(port);
|
||||
try {
|
||||
const res = await connectReq(ws, {
|
||||
...auth,
|
||||
client: { ...CLI_CLIENT },
|
||||
scopes: ["operator.admin"],
|
||||
device: null,
|
||||
});
|
||||
expect(res.ok, JSON.stringify(res)).toBe(true);
|
||||
|
||||
const helloOk = res.payload as
|
||||
| {
|
||||
auth?: {
|
||||
scopes?: unknown;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(helloOk?.auth?.scopes).toEqual(["operator.admin"]);
|
||||
|
||||
const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false });
|
||||
expect(adminRes.ok).toBe(true);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway auth compatibility baseline", () => {
|
||||
describe("token mode", () => {
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
@@ -133,6 +172,10 @@ describe("gateway auth compatibility baseline", () => {
|
||||
await expectLocalBackendGatewayClientScopesPreserved(port, { token: "secret" });
|
||||
});
|
||||
|
||||
test("preserves scopes for direct-local CLI shared-token connects without device identity", async () => {
|
||||
await expectLocalCliSharedAuthScopesPreserved(port, { token: "secret" });
|
||||
});
|
||||
|
||||
test("returns stable token-missing details for control ui without token", async () => {
|
||||
const ws = await openWs(port, { origin: originForPort(port) });
|
||||
try {
|
||||
@@ -306,6 +349,10 @@ describe("gateway auth compatibility baseline", () => {
|
||||
test("preserves scopes for direct-local backend shared-password connects without device identity", async () => {
|
||||
await expectLocalBackendGatewayClientScopesPreserved(port, { password: "secret" });
|
||||
});
|
||||
|
||||
test("preserves scopes for direct-local CLI shared-password connects without device identity", async () => {
|
||||
await expectLocalCliSharedAuthScopesPreserved(port, { password: "secret" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("none mode", () => {
|
||||
|
||||
@@ -271,6 +271,44 @@ describe("gateway silent scope-upgrade reconnect", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps direct-local CLI shared-token calls off stale paired CLI baseline", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const identity = await approveReadScopedDevice({
|
||||
clientId: GATEWAY_CLIENT_NAMES.CLI,
|
||||
clientMode: GATEWAY_CLIENT_MODES.CLI,
|
||||
});
|
||||
|
||||
try {
|
||||
const health = await callGateway({
|
||||
url: `ws://127.0.0.1:${started.port}`,
|
||||
token: "secret",
|
||||
method: "health",
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
timeoutMs: 2_000,
|
||||
});
|
||||
expect(health.ok).toBe(true);
|
||||
|
||||
const admin = await callGateway({
|
||||
url: `ws://127.0.0.1:${started.port}`,
|
||||
token: "secret",
|
||||
method: "set-heartbeats",
|
||||
params: { enabled: false },
|
||||
scopes: ["operator.admin"],
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
timeoutMs: 2_000,
|
||||
});
|
||||
expect(admin.ok).toBe(true);
|
||||
|
||||
const pending = await devicePairingModule.listDevicePairing();
|
||||
expect(pending.pending).toHaveLength(0);
|
||||
await expectReadScopedPairing(identity.deviceId);
|
||||
} finally {
|
||||
await closeStartedGateway(started);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps local native approval clients off stale paired gateway-client baseline", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const identity = await approveReadScopedDevice({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolvePairingLocality,
|
||||
resolveUnauthorizedHandshakeContext,
|
||||
shouldAllowSilentLocalPairing,
|
||||
shouldPreserveLocalCliSharedAuthScopes,
|
||||
shouldSkipLocalBackendSelfPairing,
|
||||
} from "./handshake-auth-helpers.js";
|
||||
|
||||
@@ -30,6 +31,7 @@ type PairingLocalityOverrides = {
|
||||
};
|
||||
type SilentLocalPairingParams = Parameters<typeof shouldAllowSilentLocalPairing>[0];
|
||||
type BackendSelfPairingParams = Parameters<typeof shouldSkipLocalBackendSelfPairing>[0];
|
||||
type LocalCliSharedAuthScopeParams = Parameters<typeof shouldPreserveLocalCliSharedAuthScopes>[0];
|
||||
|
||||
const CONTROL_UI_WEBCHAT_CONNECT_PARAMS = {
|
||||
client: {
|
||||
@@ -132,6 +134,17 @@ function skipBackendSelfPairing(overrides: Partial<BackendSelfPairingParams> = {
|
||||
});
|
||||
}
|
||||
|
||||
function preserveLocalCliSharedAuthScopes(overrides: Partial<LocalCliSharedAuthScopeParams> = {}) {
|
||||
return shouldPreserveLocalCliSharedAuthScopes({
|
||||
connectParams: CLI_CONNECT_PARAMS,
|
||||
locality: "direct_local",
|
||||
hasBrowserOriginHeader: false,
|
||||
sharedAuthOk: true,
|
||||
authMethod: "token",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("handshake auth helpers", () => {
|
||||
it("pins browser-origin loopback clients to the synthetic rate-limit ip", () => {
|
||||
const rateLimiter = createRateLimiter();
|
||||
@@ -422,6 +435,36 @@ describe("handshake auth helpers", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves local CLI shared-auth scopes only for token/password loopback auth", () => {
|
||||
expect(preserveLocalCliSharedAuthScopes()).toBe(true);
|
||||
expect(
|
||||
preserveLocalCliSharedAuthScopes({
|
||||
locality: "cli_container_local",
|
||||
authMethod: "password",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
preserveLocalCliSharedAuthScopes({
|
||||
locality: "remote",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
preserveLocalCliSharedAuthScopes({
|
||||
authMethod: "device-token",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
preserveLocalCliSharedAuthScopes({
|
||||
hasBrowserOriginHeader: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
preserveLocalCliSharedAuthScopes({
|
||||
connectParams: GATEWAY_BACKEND_CONNECT_PARAMS,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects pairing bypass when browser origin header is present", () => {
|
||||
expect(
|
||||
skipBackendSelfPairing({
|
||||
|
||||
@@ -283,6 +283,24 @@ export function shouldSkipLocalBackendSelfPairing(params: {
|
||||
return (params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth;
|
||||
}
|
||||
|
||||
export function shouldPreserveLocalCliSharedAuthScopes(params: {
|
||||
connectParams: ConnectParams;
|
||||
locality: PairingLocalityKind;
|
||||
hasBrowserOriginHeader: boolean;
|
||||
sharedAuthOk: boolean;
|
||||
authMethod: GatewayAuthResult["method"];
|
||||
}): boolean {
|
||||
const isCliClient =
|
||||
params.connectParams.client.id === GATEWAY_CLIENT_IDS.CLI &&
|
||||
params.connectParams.client.mode === GATEWAY_CLIENT_MODES.CLI;
|
||||
if (!isCliClient) {
|
||||
return false;
|
||||
}
|
||||
const isLocal = params.locality === "direct_local" || params.locality === "cli_container_local";
|
||||
const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
|
||||
return isLocal && !params.hasBrowserOriginHeader && params.sharedAuthOk && usesSharedSecretAuth;
|
||||
}
|
||||
|
||||
function resolveSignatureToken(connectParams: ConnectParams): string | null {
|
||||
return (
|
||||
connectParams.auth?.token ??
|
||||
|
||||
@@ -169,6 +169,7 @@ import {
|
||||
resolvePairingLocality,
|
||||
resolveUnauthorizedHandshakeContext,
|
||||
shouldAllowSilentLocalPairing,
|
||||
shouldPreserveLocalCliSharedAuthScopes,
|
||||
shouldSkipLocalBackendSelfPairing,
|
||||
} from "./handshake-auth-helpers.js";
|
||||
import {
|
||||
@@ -1026,6 +1027,13 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
sharedAuthOk,
|
||||
authMethod,
|
||||
});
|
||||
let preserveLocalCliSharedAuthScopes = shouldPreserveLocalCliSharedAuthScopes({
|
||||
connectParams,
|
||||
locality: pairingLocality,
|
||||
hasBrowserOriginHeader,
|
||||
sharedAuthOk,
|
||||
authMethod,
|
||||
});
|
||||
const handleMissingDeviceIdentity = (): boolean => {
|
||||
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
|
||||
isControlUi,
|
||||
@@ -1051,13 +1059,13 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
hasSharedAuth,
|
||||
isLocalClient,
|
||||
});
|
||||
// Shared token/password auth can bypass pairing for trusted operators.
|
||||
// Device-less clients still clear self-declared scopes by default, with
|
||||
// one narrow exception: the direct-local backend gateway-client shared-
|
||||
// auth handoff used for in-process control-plane coordination.
|
||||
// Device-less shared auth clears self-declared scopes by default.
|
||||
// Only first-party local control paths preserve scopes: backend self-
|
||||
// calls and CLI shared-secret calls that already proved loopback auth.
|
||||
if (
|
||||
!device &&
|
||||
!skipLocalBackendSelfPairing &&
|
||||
!preserveLocalCliSharedAuthScopes &&
|
||||
shouldClearUnboundScopesForMissingDeviceIdentity({
|
||||
decision,
|
||||
controlUiAuthPolicy,
|
||||
@@ -1237,6 +1245,13 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
sharedAuthOk,
|
||||
authMethod,
|
||||
});
|
||||
preserveLocalCliSharedAuthScopes = shouldPreserveLocalCliSharedAuthScopes({
|
||||
connectParams,
|
||||
locality: pairingLocality,
|
||||
hasBrowserOriginHeader,
|
||||
sharedAuthOk,
|
||||
authMethod,
|
||||
});
|
||||
if (!authOk) {
|
||||
rejectUnauthorized(authResult);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user