diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 75dcfb0b3e00..400fa47e5f4a 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -41,6 +41,7 @@ import { resolveLeastPrivilegeOperatorScopesForMethod, type OperatorScope, } from "./method-scopes.js"; +import { allowsGatewayPrivateIngressNoAuth } from "./private-ingress-auth.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; export type { GatewayConnectionDetails }; @@ -267,10 +268,18 @@ function shouldOmitDeviceIdentityForGatewayCall(params: { const mode = params.opts.mode ?? GATEWAY_CLIENT_MODES.CLI; const clientName = params.opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI; const hasSharedAuth = Boolean(params.token || params.password); + // BrowserOS private-ingress no-auth: backend gateway-client calls on + // loopback can omit device identity entirely when running with the env + // flag. Same trust boundary as the server-side bypasses in + // server-runtime-config and connect-policy. Without this, in-process + // backend tools (e.g. the agent's cron tool) try to load device.json + // and the connection then fails handshake under no-auth. + const allowsLoopbackNoAuth = + isLoopbackGatewayUrl(params.url) && allowsGatewayPrivateIngressNoAuth(); return ( mode === GATEWAY_CLIENT_MODES.BACKEND && clientName === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT && - hasSharedAuth && + (hasSharedAuth || allowsLoopbackNoAuth) && isLoopbackGatewayUrl(params.url) ); } diff --git a/src/gateway/role-policy.ts b/src/gateway/role-policy.ts index 2f35920b17fe..fa6ddd66e8f3 100644 --- a/src/gateway/role-policy.ts +++ b/src/gateway/role-policy.ts @@ -1,4 +1,5 @@ import { isNodeRoleMethod } from "./method-scopes.js"; +import { allowsGatewayPrivateIngressNoAuth } from "./private-ingress-auth.js"; const GATEWAY_ROLES = ["operator", "node"] as const; @@ -11,8 +12,21 @@ export function parseGatewayRole(roleRaw: unknown): GatewayRole | null { return null; } -export function roleCanSkipDeviceIdentity(role: GatewayRole, sharedAuthOk: boolean): boolean { - return role === "operator" && sharedAuthOk; +export function roleCanSkipDeviceIdentity( + role: GatewayRole, + sharedAuthOk: boolean, + isLocalClient = false, +): boolean { + if (role !== "operator") { + return false; + } + if (sharedAuthOk) { + return true; + } + // BrowserOS private-ingress no-auth: trust loopback operator clients when + // the env flag is set. Mirrors the missing-device bypass in connect-policy + // and the bind-time bypass in server-runtime-config. + return isLocalClient && allowsGatewayPrivateIngressNoAuth(); } export function isRoleAuthorizedForMethod(role: GatewayRole, method: string): boolean { diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index 27284609174b..fd17b6bbc401 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -1,3 +1,4 @@ +import { allowsGatewayPrivateIngressNoAuth } from "../../private-ingress-auth.js"; import type { ConnectParams } from "../../protocol/index.js"; import type { GatewayRole } from "../../role-policy.js"; import { roleCanSkipDeviceIdentity } from "../../role-policy.js"; @@ -140,7 +141,13 @@ export function evaluateMissingDeviceIdentity(params: { return { kind: "reject-control-ui-insecure-auth" }; } } - if (roleCanSkipDeviceIdentity(params.role, params.sharedAuthOk)) { + if (roleCanSkipDeviceIdentity(params.role, params.sharedAuthOk, params.isLocalClient)) { + return { kind: "allow" }; + } + // BrowserOS private-ingress no-auth: extra defense for any future caller + // that bypasses roleCanSkipDeviceIdentity. Same trust boundary as the + // bind-time bypass in server-runtime-config (env flag set + loopback). + if (params.isLocalClient && allowsGatewayPrivateIngressNoAuth()) { return { kind: "allow" }; } if (!params.authOk && params.hasSharedAuth) {