fix(gateway): scoped no-auth local backend bypass (#75781)

When gateway.auth.mode is 'none', the local backend self-pairing skip was
gated on sharedAuthOk, which stays false for no-auth mode. The missing-device
handler still rejected with 1008: device identity required.

Fix: shouldSkipLocalBackendSelfPairing now bypasses sharedAuthOk entirely
when authMethod is 'none' and the connection is local (direct_local or
shared_secret_loopback_local) without browser origin. Remote and
browser-originated connections still require proper device auth.

ClawSweeper P1: Make the none-auth backend bypass reachable
ClawSweeper P2: Test the reachable none-auth connect state

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ava Daigo
2026-05-03 23:46:58 +00:00
committed by Sally O'Malley
parent fd08fd0b1f
commit f29efde73a
4 changed files with 95 additions and 5 deletions

View File

@@ -1204,6 +1204,7 @@ Docs: https://docs.openclaw.ai
- Agents/replies: defer implicit image model discovery and keep OAuth auth-store adoption on persisted profiles during reply startup, cutting OCM MarCodex warm prep to sub-second in live checks. Thanks @shakkernerd.
- Plugins/tools: enforce `contracts.tools` as the manifest ownership contract for plugin tool registration, rejecting undeclared runtime tool names and adding bundled plugin drift coverage. Thanks @shakkernerd.
- Agents/Codex: stop prompting message-tool-only source turns to finish with `NO_REPLY`, so quiet turns are represented by not calling the visible message tool instead of conflicting final-text instructions. Thanks @pashpashpash.
- Gateway/config: report failed backup restores as failed in logs and config observe audit records instead of marking them valid. (#70515) Thanks @davidangularme.
- Compaction: use the active session model fallback chain for implicit summarization failures without persisting fallback model selection, so Azure content-filter 400s can recover. Fixes #64960. (#74470) Thanks @jalehman and @OpenCodeEngineer.
- Gateway/config: allow `gateway config.patch` to update documented subagent thinking defaults. Fixes #75764. (#75802) Thanks @kAIborg24.

View File

@@ -78,6 +78,31 @@ describe("resolveConnectAuthState", () => {
});
describe("resolveConnectAuthDecision", () => {
it("sets sharedAuthOk false when auth mode is none (no shared secret provided)", async () => {
const state = await resolveConnectAuthState({
resolvedAuth: {
mode: "none",
} satisfies ResolvedGatewayAuth,
connectAuth: {},
hasDeviceIdentity: false,
req: {
headers: {},
socket: { remoteAddress: "127.0.0.1" },
} as never,
trustedProxies: [],
allowRealIpFallback: false,
rateLimiter: createLimiter(),
clientIp: "127.0.0.1",
});
expect(state.authOk).toBe(true);
expect(state.authMethod).toBe("none");
// auth:none does NOT set sharedAuthOk globally — it's not a shared secret.
// Only shouldSkipLocalBackendSelfPairing treats auth:none as shared-auth-scoped
// for local backend connections specifically.
expect(state.sharedAuthOk).toBe(false);
});
it("resets the shared-secret limiter after device-token auth succeeds", async () => {
const rateLimiter = createLimiter();
await resolveConnectAuthDecision({

View File

@@ -451,6 +451,64 @@ describe("handshake auth helpers", () => {
).toBe(false);
});
it("skips backend self-pairing when auth mode is none (scoped, sharedAuthOk-independent)", () => {
const connectParams = {
client: {
id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
mode: GATEWAY_CLIENT_MODES.BACKEND,
},
} as ConnectParams;
// auth:none on local backend skips regardless of sharedAuthOk
expect(
shouldSkipLocalBackendSelfPairing({
connectParams,
locality: "direct_local",
hasBrowserOriginHeader: false,
sharedAuthOk: true,
authMethod: "none",
}),
).toBe(true);
expect(
shouldSkipLocalBackendSelfPairing({
connectParams,
locality: "shared_secret_loopback_local",
hasBrowserOriginHeader: false,
sharedAuthOk: true,
authMethod: "none",
}),
).toBe(true);
// sharedAuthOk=false is fine for auth:none on local backend
expect(
shouldSkipLocalBackendSelfPairing({
connectParams,
locality: "direct_local",
hasBrowserOriginHeader: false,
sharedAuthOk: false,
authMethod: "none",
}),
).toBe(true);
// Remote connections with auth:none should NOT skip
expect(
shouldSkipLocalBackendSelfPairing({
connectParams,
locality: "remote",
hasBrowserOriginHeader: false,
sharedAuthOk: true,
authMethod: "none",
}),
).toBe(false);
// Browser origin with auth:none should NOT skip
expect(
shouldSkipLocalBackendSelfPairing({
connectParams,
locality: "direct_local",
hasBrowserOriginHeader: true,
sharedAuthOk: false,
authMethod: "none",
}),
).toBe(false);
});
it("classifies non-CLI loopback + shared-secret clients as shared_secret_loopback_local", () => {
const connectParams = {
client: {

View File

@@ -262,13 +262,19 @@ export function shouldSkipLocalBackendSelfPairing(params: {
if (!isBackendClient) {
return false;
}
const isLocal =
params.locality === "direct_local" || params.locality === "shared_secret_loopback_local";
if (!isLocal || params.hasBrowserOriginHeader) {
return false;
}
// No-auth local backend: scoped bypass — not shared secret, but local-only
// device-less operation is safe when auth.mode is explicitly "none".
if (params.authMethod === "none") {
return true;
}
const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
const usesDeviceTokenAuth = params.authMethod === "device-token";
return (
(params.locality === "direct_local" || params.locality === "shared_secret_loopback_local") &&
!params.hasBrowserOriginHeader &&
((params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth)
);
return (params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth;
}
function resolveSignatureToken(connectParams: ConnectParams): string | null {