diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index edca46cf4d49..c09701271ecb 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -843,7 +843,7 @@ Gateway or node host and check `openclaw nodes pending` again. - If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking). - `trustedProxies`: reverse proxy IPs that terminate TLS or inject forwarded-client headers. Only list proxies you control. Loopback entries are still valid for same-host proxy/local-detection setups (for example Tailscale Serve or a local reverse proxy), but they do **not** make loopback requests eligible for `gateway.auth.mode: "trusted-proxy"`. - `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior. -- `gateway.nodes.pairing.autoApproveLocal`: silently approves pairing, role upgrades, and scope upgrades from trusted local connections (default: `true`). Set `false` to require explicit approval for every device; metadata-only reconnect refreshes remain automatic. +- `gateway.nodes.pairing.autoApproveLocal`: silently approves pairing, role upgrades, and scope upgrades from trusted local connections (default: `true`). Scope upgrades additionally require the connection itself to prove local-grade credentials (auth mode `none`, or the shared token/password); Tailscale, trusted-proxy, and device-token connects keep their paired scopes as a durable cap. Set `false` to require explicit approval for every device; metadata-only reconnect refreshes remain automatic. - `gateway.nodes.pairing.autoApproveCidrs`: optional CIDR/IP allowlist for auto-approving first-time node device pairing with no requested scopes. It is disabled when unset. This does not auto-approve operator/browser/Control UI/WebChat pairing, and it does not auto-approve role, scope, metadata, or public-key upgrades. - `gateway.nodes.pairing.sshVerify`: SSH-verified auto-approval for first-time node device pairing (default: enabled). The gateway SSHes back to the pairing host (BatchMode, strict host keys) and approves only on an exact `openclaw node identity` device-key match. Same eligibility floor as `autoApproveCidrs`; probes are limited to private/CGNAT source addresses unless `cidrs` overrides them. Set `false` to disable, or `{ user, identity, timeoutMs, cidrs }` to tune. See [Node pairing](/gateway/pairing#ssh-verified-device-auto-approval-default). - `gateway.nodes.commands.allow` / `gateway.nodes.commands.deny`: global allow/deny shaping for declared node commands after pairing and platform allowlist evaluation. `commands.allow` is the one-time persistent enable for classified commands such as `camera.snap`, `camera.clip`, `desktop.stream`, `screen.record`, `health.summary`, `sms.search`, and `sms.send`; `commands.deny` removes a command even if a platform default or explicit allow would otherwise include it. Computer and mobile UI control instead rely on default-off node-local enablement plus pairing. iOS Health permission, Android SMS permission, and Gateway command authorization are independent. After a node changes its declared command list, reject and re-approve that device pairing so the gateway stores the updated command snapshot. diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index b32ce5b7cffc..00656fce3502 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -232,7 +232,15 @@ function mergePairedNodesWithEffectiveNodes( async function tryReadNodeList(opts: NodesRpcOpts): Promise { try { return parseNodeList(await callNodeDiagnosticsGatewayCli("node.list", opts, {})); - } catch { + } catch (error) { + // Best-effort enrichment may degrade to pairing-only rows, but never + // silently: without this notice the table looks authoritative while + // omitting connected/commands state. Stderr keeps --json output clean. + defaultRuntime.error( + getNodesTheme().muted( + `live node view unavailable (${formatErrorMessage(error)}); showing paired-only data`, + ), + ); return null; } } diff --git a/src/cli/program.nodes-basic.e2e.test.ts b/src/cli/program.nodes-basic.e2e.test.ts index e33c672c1c85..6f8a8b7d6ea1 100644 --- a/src/cli/program.nodes-basic.e2e.test.ts +++ b/src/cli/program.nodes-basic.e2e.test.ts @@ -215,6 +215,12 @@ describe("cli program (nodes basics)", () => { const output = getRuntimeOutput(); expect(output).toContain("Pending: 0 · Paired: 1"); expect(output).toContain("Pairing Scoped"); + // The degraded table must never look authoritative: the fallback is + // announced on stderr so --json stdout stays parseable. + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("live node view unavailable"), + ); + expect(output).not.toContain("live node view unavailable"); }); it("sanitizes untrusted nodes list table fields while preserving JSON values", async () => { diff --git a/src/gateway/server.auth.control-ui.pairing.suite.ts b/src/gateway/server.auth.control-ui.pairing.suite.ts index 72c5a645b46a..4cc7a23c17cb 100644 --- a/src/gateway/server.auth.control-ui.pairing.suite.ts +++ b/src/gateway/server.auth.control-ui.pairing.suite.ts @@ -59,7 +59,7 @@ export function registerControlUiPairingSuite(): void { metadata.approvedScopes = ["operator.read", null, 42, ""]; }); }; - test("auto-approves local-direct operator pairing despite a remote-looking host header", async () => { + test("auto-approves local-direct operator pairing and scope upgrades despite a remote-looking host header", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); const { server, port, prevToken, identityPath, identity, client } = await startControlUiServerWithOperatorIdentity(); @@ -104,23 +104,22 @@ export function registerControlUiPairingSuite(): void { nonce: nonce2, }), }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("pairing required"); + // A local shared-auth connect could pair a fresh identity at admin, so the + // widening self-approves silently instead of queueing an unanswerable prompt. + expect(res.ok).toBe(true); pairing = await listDevicePairing(); const pendingAfterAdmin = pairing.pending.filter( (entry) => entry.deviceId === identity.deviceId, ); - expect(pendingAfterAdmin).toHaveLength(1); - expectArrayIncludes(pendingAfterAdmin[0]?.scopes, ["operator.admin"]); - if (!(await getPairedDevice(identity.deviceId))) { - throw new Error(`expected paired device ${identity.deviceId}`); - } + expect(pendingAfterAdmin).toHaveLength(0); + const widened = await getPairedDevice(identity.deviceId); + expectArrayIncludes(widened?.approvedScopes, ["operator.admin", "operator.read"]); ws2.close(); await server.close(); restoreGatewayToken(prevToken); }); - test("requires approval for loopback scope upgrades for control ui clients", async () => { + test("silently widens loopback control ui scope upgrades under shared auth", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); const { server, port, prevToken } = await startControlUiServer("secret"); const { identity, identityPath } = await seedApprovedOperatorReadPairing({ @@ -144,21 +143,22 @@ export function registerControlUiPairingSuite(): void { nonce: nonce2, }), }); - expect(upgraded.ok).toBe(false); - expect(upgraded.error?.message ?? "").toContain("pairing required"); + // A fresh Control UI browser identity holding the shared secret could pair + // at admin silently, so an existing row widens the same way. + expect(upgraded.ok).toBe(true); const pending = await listDevicePairing(); const pendingUpgrade = pending.pending.filter((entry) => entry.deviceId === identity.deviceId); - expect(pendingUpgrade).toHaveLength(1); - expectArrayIncludes(pendingUpgrade[0]?.scopes, ["operator.admin"]); + expect(pendingUpgrade).toHaveLength(0); const updated = await getPairedDevice(identity.deviceId); - expect(updated?.tokens?.operator?.scopes ?? []).not.toContain("operator.admin"); + expect(updated?.tokens?.operator?.scopes ?? []).toContain("operator.admin"); ws2.close(); await server.close(); restoreGatewayToken(prevToken); }); - test("returns pairing-required for malformed persisted access lists", async () => { + test("silently repairs malformed persisted access lists on local re-approval", async () => { + const { getPairedDevice } = await import("../infra/device-pairing.js"); const { identity, identityPath } = await seedApprovedOperatorReadPairing({ identityPrefix: "openclaw-device-malformed-access-", clientId: TEST_OPERATOR_CLIENT.id, @@ -185,11 +185,12 @@ export function registerControlUiPairingSuite(): void { }), }); - expect(result.ok).toBe(false); - expect(result.error?.message ?? "").toContain("pairing required"); - expect((result.error?.details as { reason?: string } | undefined)?.reason).toBe( - "scope-upgrade", - ); + // Malformed persisted access lists never grant access by themselves: the + // connect is re-authorized by a fresh silent local approval, which also + // rewrites the row with a clean scope list. + expect(result.ok).toBe(true); + const repaired = await getPairedDevice(identity.deviceId); + expect(repaired?.approvedScopes ?? []).toContain("operator.admin"); } finally { ws?.close(); await server.close(); @@ -284,7 +285,7 @@ export function registerControlUiPairingSuite(): void { } }); - test("auto-approves local-direct node pairing, then queues operator scope approval", async () => { + test("auto-approves local-direct node pairing, then silently grants operator scopes", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); const { identityPath, identity, client } = await createOperatorIdentityFixture("openclaw-device-scope-"); @@ -318,15 +319,13 @@ export function registerControlUiPairingSuite(): void { "operator.read", "operator.write", ]); - expect(operatorConnect.ok).toBe(false); - expect(operatorConnect.error?.message ?? "").toContain("pairing required"); + expect(operatorConnect.ok).toBe(true); const pending = await listDevicePairing(); const pendingForTestDevice = pending.pending.filter( (entry) => entry.deviceId === identity.deviceId, ); - expect(pendingForTestDevice).toHaveLength(1); - expectArrayIncludes(pendingForTestDevice[0]?.scopes, ["operator.read", "operator.write"]); + expect(pendingForTestDevice).toHaveLength(0); const paired = await getPairedDevice(identity.deviceId); expectArrayIncludes(paired?.roles, ["node", "operator"]); @@ -430,7 +429,7 @@ export function registerControlUiPairingSuite(): void { } }); - test("requires approval for local scope upgrades even when paired metadata is legacy-shaped", async () => { + test("silently widens local scope upgrades even when paired metadata is legacy-shaped", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); const { identity, identityPath } = await seedApprovedOperatorReadPairing({ identityPrefix: "openclaw-device-legacy-", @@ -461,68 +460,18 @@ export function registerControlUiPairingSuite(): void { nonce: upgradeNonce, }), }); - expect(upgraded.ok).toBe(false); - expect(upgraded.error?.message ?? "").toContain("pairing required"); - expect( - ( - upgraded.error?.details as - | { - reason?: string; - requestedRole?: string; - requestedScopes?: string[]; - approvedScopes?: string[]; - } - | undefined - )?.reason, - ).toBe("scope-upgrade"); - expect( - ( - upgraded.error?.details as - | { - reason?: string; - requestedRole?: string; - requestedScopes?: string[]; - approvedScopes?: string[]; - } - | undefined - )?.requestedRole, - ).toBe("operator"); - expect( - ( - upgraded.error?.details as - | { - reason?: string; - requestedRole?: string; - requestedScopes?: string[]; - approvedScopes?: string[]; - } - | undefined - )?.requestedScopes, - ).toEqual(["operator.admin"]); - expect( - ( - upgraded.error?.details as - | { - reason?: string; - requestedRole?: string; - requestedScopes?: string[]; - approvedScopes?: string[]; - } - | undefined - )?.approvedScopes, - ).toEqual(["operator.read"]); + // Legacy-shaped rows must not break the upgrade flow: the silent local + // approval rewrites the row with the widened, normalized scope list. + expect(upgraded.ok).toBe(true); wsUpgrade.close(); const pendingUpgrade = (await listDevicePairing()).pending.find( (entry) => entry.deviceId === identity.deviceId, ); - if (!pendingUpgrade) { - throw new Error(`expected pending upgrade for device ${identity.deviceId}`); - } - expectArrayIncludes(pendingUpgrade.scopes, ["operator.admin"]); + expect(pendingUpgrade).toBeUndefined(); const repaired = await getPairedDevice(identity.deviceId); expect(repaired?.role).toBe("operator"); - expectArrayIncludes(repaired?.approvedScopes, ["operator.read"]); + expectArrayIncludes(repaired?.approvedScopes, ["operator.admin", "operator.read"]); } finally { ws2?.close(); await server.close(); diff --git a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts index 1c58df9dea92..9157d1ab8cfb 100644 --- a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts +++ b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts @@ -153,7 +153,8 @@ async function expectRejectedScopeUpgradeAttempt({ } describe("gateway silent scope-upgrade reconnect", () => { - test("does not silently widen a read-scoped paired device to admin on shared-auth reconnect", async () => { + test("keeps scope upgrades on manual approval when autoApproveLocal is disabled", async () => { + const { replaceConfigFile } = await import("../config/config.js"); const started = await startServerWithClient("secret"); const paired = await issueReadScopedOperatorToken({ name: "silent-scope-upgrade-reconnect-poc", @@ -168,6 +169,14 @@ describe("gateway silent scope-upgrade reconnect", () => { try { ({ ws: watcherWs, requestedEvent } = await watchScopeUpgradeRequests(started.port)); + // autoApproveLocal=false is the operator opt-out that keeps every local + // access grant — including scope upgrades — on the explicit prompt path. + // Flipped after the watcher connected so only the upgrade attempt is + // gated. + await replaceConfigFile({ + nextConfig: { gateway: { nodes: { pairing: { autoApproveLocal: false } } } }, + afterWrite: { mode: "auto" }, + }); sharedAuthReconnectWs = await openTrackedWs(started.port); const sharedAuthUpgradeAttempt = await connectReq(sharedAuthReconnectWs, { token: "secret", diff --git a/src/gateway/server/ws-connection/connect-device-pairing.test.ts b/src/gateway/server/ws-connection/connect-device-pairing.test.ts index 9f2749efdfdd..d8771de7a702 100644 --- a/src/gateway/server/ws-connection/connect-device-pairing.test.ts +++ b/src/gateway/server/ws-connection/connect-device-pairing.test.ts @@ -117,6 +117,67 @@ describe("gateway connect pairing exemptions", () => { }, ); + test.each([ + { + name: "auth-none CLI client", + auth: { mode: "none" } as const, + client: { + id: GATEWAY_CLIENT_NAMES.CLI, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.CLI, + }, + }, + { + name: "token-auth native app client", + auth: { mode: "token", token: "local-secret" } as const, + client: { + id: GATEWAY_CLIENT_NAMES.MACOS_APP, + version: "1.0.0", + platform: "darwin", + mode: GATEWAY_CLIENT_MODES.UI, + }, + }, + ])("silently widens a narrow local pairing row for a $name", async ({ name, auth, client }) => { + testState.gatewayAuth = auth; + const started = await startServerWithClient(undefined, { auth }); + const identityName = `silent-widen-${name.replaceAll(" ", "-")}`; + const paired = await pairDeviceIdentity({ + name: identityName, + role: "operator", + scopes: ["operator.pairing"], + clientId: client.id, + clientMode: client.mode, + }); + + try { + // Deliberately NOT a superset of the row: the merge must self-grant the + // union (requested + already-held), not require the client to re-request + // its existing scopes. + const widened = await connectReq(started.ws, { + client, + role: "operator", + scopes: ["operator.write"], + deviceIdentityPath: paired.identityPath, + skipDefaultAuth: auth.mode === "none", + ...(auth.mode === "token" ? { token: auth.token } : {}), + prePairDevice: false, + }); + expect(widened.ok, JSON.stringify(widened)).toBe(true); + + const row = await getPairedDevice(paired.identity.deviceId); + // The widened grant merges into the row; the original approval + // provenance is retained rather than rewritten to "silent". + expect(row?.approvedScopes).toEqual( + expect.arrayContaining(["operator.pairing", "operator.write"]), + ); + } finally { + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); + test("keeps a narrow pairing row as the Tailscale Control UI scope cap", async () => { const tailscaleOrigin = "https://gateway.tailnet.ts.net"; const auth = { diff --git a/src/gateway/server/ws-connection/connect-device-pairing.ts b/src/gateway/server/ws-connection/connect-device-pairing.ts index 4f69fc57f096..d89302990ade 100644 --- a/src/gateway/server/ws-connection/connect-device-pairing.ts +++ b/src/gateway/server/ws-connection/connect-device-pairing.ts @@ -262,6 +262,7 @@ export async function authorizeGatewayConnectDevice( isControlUi, isWebchat, isNativeAppUi, + authMethod, reason, }); const allowSilentTrustedCidrsNodePairing = shouldAutoApproveNodePairingFromTrustedCidrs({ @@ -386,15 +387,16 @@ export async function authorizeGatewayConnectDevice( scopes: bootstrapPairingScopes ?? [], } : {}), + // Scope upgrades ride the same silent-local rule as initial pairing: + // shouldAllowSilentLocalPairing already restricts them to local-grade + // auth (none/token/password), so identity-proxy and bearer-token rows + // stay a durable cap while owner-credentialed local clients widen + // without a prompt they could bypass with a fresh identity anyway. silent: - reason === "scope-upgrade" && - !allowSetupCodeHandoffBootstrapPairing && - !allowControlUiOwnerBootstrapPairing - ? false - : allowSilentLocalPairing || - allowSilentTrustedCidrsNodePairing || - allowSetupCodeHandoffBootstrapPairing || - allowControlUiOperatorBootstrapPairing, + allowSilentLocalPairing || + allowSilentTrustedCidrsNodePairing || + allowSetupCodeHandoffBootstrapPairing || + allowControlUiOperatorBootstrapPairing, }); const trustedProxyAutoApproveScopes = allowTrustedProxyDeviceAutoApproval && @@ -459,7 +461,16 @@ export async function authorizeGatewayConnectDevice( { accessMetadata: clientAccessMetadata }, ) : await approveDevicePairing(pairing.request.requestId, { - callerScopes: scopes, + // A silent self-grant's authority is locality plus proven + // local-grade auth, not the requested scope list. Approval + // merges the existing row's scopes back in, so the caller + // set must cover requested plus already-held — nothing new. + callerScopes: uniqueStrings([ + ...scopes, + ...(existingPairedDevice + ? resolvePairedAccessScopes(existingPairedDevice) + : []), + ]), accessMetadata: clientAccessMetadata, // Same-host local approvals are prune-eligible "silent"; // trusted-CIDR approvals cross hosts and must never be diff --git a/src/gateway/server/ws-connection/connect-existing-device.ts b/src/gateway/server/ws-connection/connect-existing-device.ts index d80893313fdf..b55c79b82f10 100644 --- a/src/gateway/server/ws-connection/connect-existing-device.ts +++ b/src/gateway/server/ws-connection/connect-existing-device.ts @@ -80,6 +80,7 @@ export async function authorizeExistingGatewayDevice(params: { isControlUi, isWebchat, isNativeAppUi, + authMethod, reason: "metadata-upgrade", }); if (!allowSilentMetadataUpgrade) { diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts index b92a5237e50f..7175cea87a22 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -117,6 +117,7 @@ function allowSilentLocalPairing(overrides: Partial) { hasBrowserOriginHeader: false, isControlUi: false, isWebchat: false, + authMethod: "token", reason: "not-paired", ...overrides, }); @@ -305,6 +306,25 @@ describe("handshake auth helpers", () => { ).toBe(false); }); + it("limits silent scope-upgrade to local-grade auth methods", () => { + for (const authMethod of ["none", "token", "password"] as const) { + expect(allowSilentLocalPairing({ authMethod, reason: "scope-upgrade" })).toBe(true); + } + // Identity-proxy and bearer-token connects never proved local-grade + // credentials, so their pairing rows stay a durable scope cap. + for (const authMethod of [ + "tailscale", + "trusted-proxy", + "device-token", + "bootstrap-token", + ] as const) { + expect(allowSilentLocalPairing({ authMethod, reason: "scope-upgrade" })).toBe(false); + expect(allowSilentLocalPairing({ authMethod, reason: "not-paired" })).toBe(true); + expect(allowSilentLocalPairing({ authMethod, reason: "role-upgrade" })).toBe(true); + } + expect(allowSilentLocalPairing({ authMethod: undefined, reason: "scope-upgrade" })).toBe(false); + }); + it("rejects silent role-upgrade for remote clients", () => { expect( allowSilentLocalPairing({ diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.ts index e344d1756b62..cdb61a957196 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -95,6 +95,7 @@ export function shouldAllowSilentLocalPairing(params: { isControlUi: boolean; isWebchat: boolean; isNativeAppUi?: boolean; + authMethod?: GatewayAuthResult["method"]; reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade"; }): boolean { if (params.locality === "remote") { @@ -103,60 +104,46 @@ export function shouldAllowSilentLocalPairing(params: { if (params.hasBrowserOriginHeader && !params.isControlUi && !params.isWebchat) { return false; } + if (params.reason === "metadata-upgrade") { + // Metadata-only reconnect refreshes stay automatic even when the operator + // disabled autoApproveLocal, to avoid approval churn after benign client or + // OS metadata changes. Direct-local refresh is limited to first-party + // native app UI clients; node-host, Browser, and Control-UI metadata + // pinning stays on the explicit approval path. + return ( + !params.hasBrowserOriginHeader && + !params.isControlUi && + !params.isWebchat && + ((params.locality === "direct_local" && params.isNativeAppUi === true) || + params.locality === "cli_container_local" || + params.locality === "shared_secret_loopback_local") + ); + } // Operators can require explicit approval for pairing and access upgrades. - // Metadata-only reconnect refreshes stay automatic to avoid approval churn - // after benign client or OS metadata changes. - if (params.autoApproveLocal === false && params.reason !== "metadata-upgrade") { + if (params.autoApproveLocal === false) { return false; } - if ( - params.reason === "not-paired" || - params.reason === "scope-upgrade" || - params.reason === "role-upgrade" - ) { - return true; + if (params.reason === "scope-upgrade") { + // Silently widening an existing row grants nothing a fresh local identity + // could not get through silent initial pairing — but only when this + // connect proved local-grade credentials itself. Identity-proxy methods + // (tailscale, trusted-proxy) and bearer device tokens never did, so their + // rows remain a durable scope cap. + return ( + params.authMethod === "none" || + params.authMethod === "token" || + params.authMethod === "password" + ); } - // metadata-upgrade auto-approves only for non-browser local reconnects that - // already proved possession of local/shared credentials. Direct-local - // metadata refresh is limited to first-party native app UI clients, covering - // same-host app reconnects after OS version metadata changes while keeping - // node-host, Browser, and Control-UI metadata pinning on the explicit approval path. - if ( - params.reason === "metadata-upgrade" && - !params.hasBrowserOriginHeader && - !params.isControlUi && - !params.isWebchat && - ((params.locality === "direct_local" && params.isNativeAppUi === true) || - params.locality === "cli_container_local" || - params.locality === "shared_secret_loopback_local") - ) { - return true; - } - return false; + return true; } -function isCliContainerLocalEquivalent(params: { - connectParams: ConnectParams; - requestHost?: string; - remoteAddress?: string; - hasProxyHeaders: boolean; - 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; - const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; - return ( - isCliClient && - params.sharedAuthOk && - usesSharedSecretAuth && - !params.hasProxyHeaders && - !params.hasBrowserOriginHeader && - isLoopbackAddress(params.remoteAddress) && - isPrivateOrLoopbackHost(resolveHostName(params.requestHost)) - ); +function isCliCliClient(client: ConnectParams["client"]): boolean { + return client.id === GATEWAY_CLIENT_IDS.CLI && client.mode === GATEWAY_CLIENT_MODES.CLI; +} + +function isSharedSecretAuthMethod(method: GatewayAuthResult["method"]): boolean { + return method === "token" || method === "password"; } function isSharedSecretLoopbackLocalEquivalent(params: { @@ -167,10 +154,9 @@ function isSharedSecretLoopbackLocalEquivalent(params: { sharedAuthOk: boolean; authMethod: GatewayAuthResult["method"]; }): boolean { - const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; return ( params.sharedAuthOk && - usesSharedSecretAuth && + isSharedSecretAuthMethod(params.authMethod) && !params.hasProxyHeaders && !params.hasBrowserOriginHeader && isLoopbackAddress(params.remoteAddress) && @@ -203,11 +189,10 @@ function isControlUiBrowserContainerLocalEquivalent(params: { const isControlUiBrowser = params.connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.WEBCHAT; - const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; return ( isControlUiBrowser && params.sharedAuthOk && - usesSharedSecretAuth && + isSharedSecretAuthMethod(params.authMethod) && !params.hasProxyHeaders && params.hasBrowserOriginHeader && isPrivateOrLoopbackAddress(params.remoteAddress) && @@ -230,44 +215,15 @@ export function resolvePairingLocality(params: { if (params.isLocalClient) { return "direct_local"; } - if ( - isControlUiBrowserContainerLocalEquivalent({ - connectParams: params.connectParams, - requestHost: params.requestHost, - requestOrigin: params.requestOrigin, - remoteAddress: params.remoteAddress, - hasProxyHeaders: params.hasProxyHeaders, - hasBrowserOriginHeader: params.hasBrowserOriginHeader, - sharedAuthOk: params.sharedAuthOk, - authMethod: params.authMethod, - }) - ) { + if (isControlUiBrowserContainerLocalEquivalent(params)) { return "browser_container_local"; } - if ( - isCliContainerLocalEquivalent({ - connectParams: params.connectParams, - requestHost: params.requestHost, - remoteAddress: params.remoteAddress, - hasProxyHeaders: params.hasProxyHeaders, - hasBrowserOriginHeader: params.hasBrowserOriginHeader, - sharedAuthOk: params.sharedAuthOk, - authMethod: params.authMethod, - }) - ) { - return "cli_container_local"; - } - if ( - isSharedSecretLoopbackLocalEquivalent({ - requestHost: params.requestHost, - remoteAddress: params.remoteAddress, - hasProxyHeaders: params.hasProxyHeaders, - hasBrowserOriginHeader: params.hasBrowserOriginHeader, - sharedAuthOk: params.sharedAuthOk, - authMethod: params.authMethod, - }) - ) { - return "shared_secret_loopback_local"; + if (isSharedSecretLoopbackLocalEquivalent(params)) { + // The CLI container lane shares the shared-secret loopback predicate; only + // the client class distinguishes it for scope-preservation policy. + return isCliCliClient(params.connectParams.client) + ? "cli_container_local" + : "shared_secret_loopback_local"; } return "remote"; } @@ -282,22 +238,18 @@ export function shouldSkipLocalBackendSelfPairing(params: { const isBackendClient = params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND; - if (!isBackendClient) { - return false; - } const isLocal = params.locality === "direct_local" || params.locality === "shared_secret_loopback_local"; - if (!isLocal || params.hasBrowserOriginHeader) { + if (!isBackendClient || !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.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth; + return ( + params.authMethod === "none" || + params.authMethod === "device-token" || + (params.sharedAuthOk && isSharedSecretAuthMethod(params.authMethod)) + ); } export function shouldPreserveLocalCliSharedAuthScopes(params: { @@ -307,15 +259,13 @@ export function shouldPreserveLocalCliSharedAuthScopes(params: { 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; + return ( + isCliCliClient(params.connectParams.client) && + (params.locality === "direct_local" || params.locality === "cli_container_local") && + !params.hasBrowserOriginHeader && + params.sharedAuthOk && + isSharedSecretAuthMethod(params.authMethod) + ); } function resolveSignatureToken(connectParams: ConnectParams): string | null {