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:
Vincent Koc
2026-07-05 06:52:56 -07:00
committed by GitHub
parent 8a698108e0
commit 16865d58ca
8 changed files with 201 additions and 24 deletions

View File

@@ -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)

View File

@@ -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);
});

View File

@@ -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);

View File

@@ -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", () => {

View File

@@ -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({

View File

@@ -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({

View File

@@ -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 ??

View File

@@ -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;