fix(desktop): key cookie-auth session partitions on connection identity, not auth mode (#92183)

Two registered basic-auth gateways shared the single
persist:hermes-remote-oauth cookie jar, so signing in to gateway B
evicted gateway A's session cookies (Chromium jars ignore the port) and
A's cookie was silently presented to B on every request. Non-primary v2
registry remotes with cookie auth now ride a per-connection partition
(persist:hermes-remote-oauth:conn:<id>) resolved at the jar boundary;
the registry primary, v1 remote, cloud cascade, and portal flows keep
the legacy shared jar so upgrades do not sign anyone out. Fail closed:
a connection's requests can never see another connection's cookies.
This commit is contained in:
Teknium
2026-08-26 07:39:11 -07:00
parent 30749ed9dc
commit 31250da505
3 changed files with 372 additions and 17 deletions

View File

@@ -150,6 +150,7 @@ import {
upsertConnection
} from './connection-registry'
import type { RosterProfileMetadata } from './connection-registry'
import { LEGACY_OAUTH_PARTITION, resolveOauthPartition } from './oauth-partition'
import { describeCrashReason, installCrashForensics } from './crash-forensics'
import { adoptServedDashboardToken } from './dashboard-token'
import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation'
@@ -1316,7 +1317,7 @@ function registerMediaProtocol() {
method
}),
fetchRemoteWithCookies: (url, headers, method) => {
const oauthSession = getOauthSession()
const oauthSession = getOauthSessionForUrl(url)
if (!oauthSession) {
throw new Error('OAuth session partition is unavailable.')
@@ -6913,7 +6914,7 @@ function installMediaPermissions() {
// "is the user signed in at all?" gate / display signal.
// ---------------------------------------------------------------------------
const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth'
const OAUTH_SESSION_PARTITION = LEGACY_OAUTH_PARTITION
function getOauthSession() {
if (oauthSession || !app.isReady()) {
@@ -6925,6 +6926,47 @@ function getOauthSession() {
return oauthSession
}
// Per-connection cookie jars (#92183). A NON-primary v2 registry remote with
// cookie auth rides its own partition so two registered gateways can never
// evict — or be handed — each other's session cookies (Chromium jars ignore
// the port, so two dashboards on one VPN host used to collide in the shared
// jar above). The primary / v1 remote / cloud / portal flows keep the legacy
// shared partition; see oauth-partition.ts for the full rules.
const oauthSessionsByPartition = new Map()
function resolveOauthPartitionForUrl(url) {
try {
return resolveOauthPartition(url, {
registry: readDesktopConnectionsRegistry(),
v1RemoteUrl: readDesktopConnectionConfig()?.remote?.url
})
} catch {
// A broken registry read must never take cookie auth down with it.
return OAUTH_SESSION_PARTITION
}
}
function getOauthSessionForUrl(url) {
const partition = resolveOauthPartitionForUrl(url)
if (partition === OAUTH_SESSION_PARTITION) {
return getOauthSession()
}
if (!app.isReady()) {
return null
}
let sess = oauthSessionsByPartition.get(partition)
if (!sess) {
sess = session.fromPartition(partition)
oauthSessionsByPartition.set(partition, sess)
}
return sess
}
// Cold-start cookie-jar warm-up. A `persist:` partition materialized via
// session.fromPartition() loads its on-disk cookie store LAZILY: the very first
// cookies.get() on a fresh cold start can resolve BEFORE the jar has finished
@@ -6938,19 +6980,23 @@ function getOauthSession() {
// throwaway cookies.get(). The promise is memoized so every caller awaits the
// same single warm-up. Best-effort — any error resolves so we fall back to the
// live read (which then does its own bounded re-check).
let oauthCookieWarmup: Promise<void> | null = null
// Memoized per PARTITION: per-connection jars (#92183) hydrate independently.
const oauthCookieWarmups = new Map()
function warmOauthCookieStore() {
if (oauthCookieWarmup) {
return oauthCookieWarmup
function warmOauthCookieStore(url?) {
const partition = resolveOauthPartitionForUrl(url)
const pending = oauthCookieWarmups.get(partition)
if (pending) {
return pending
}
oauthCookieWarmup = (async () => {
const sess = getOauthSession()
const warmup = (async () => {
const sess = getOauthSessionForUrl(url)
if (!sess) {
// App not ready yet — don't memoize a no-op; let a later call retry.
oauthCookieWarmup = null
oauthCookieWarmups.delete(partition)
return
}
@@ -6966,7 +7012,9 @@ function warmOauthCookieStore() {
}
})()
return oauthCookieWarmup
oauthCookieWarmups.set(partition, warmup)
return warmup
}
// Bare + prefixed variants of the session cookies live in
@@ -6974,7 +7022,7 @@ function warmOauthCookieStore() {
// that module for details.
async function hasOauthSessionCookie(baseUrl) {
const sess = getOauthSession()
const sess = getOauthSessionForUrl(baseUrl)
if (!sess) {
return false
@@ -7007,7 +7055,7 @@ async function hasOauthSessionCookie(baseUrl) {
// re-login every ~15 min. Used for the Settings "connected" indicator and as a
// cheap early-out before attempting a network round-trip in resolveRemoteBackend.
async function hasLiveOauthSession(baseUrl) {
const sess = getOauthSession()
const sess = getOauthSessionForUrl(baseUrl)
if (!sess) {
return false
@@ -7043,7 +7091,7 @@ async function hasLiveOauthSession(baseUrl) {
// trusting a negative, force the store to hydrate and re-read a couple of
// times with a short backoff. A genuinely signed-out user still resolves
// false quickly (≤ ~180ms); a signed-in user racing the load now wins.
await warmOauthCookieStore()
await warmOauthCookieStore(baseUrl)
for (const delayMs of [30, 60, 90]) {
if (await readLive()) {
@@ -7057,7 +7105,7 @@ async function hasLiveOauthSession(baseUrl) {
}
async function clearOauthSession(baseUrl) {
const sess = getOauthSession()
const sess = getOauthSessionForUrl(baseUrl)
if (!sess) {
return
@@ -7104,7 +7152,7 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) {
return
}
const sess = getOauthSession()
const sess = getOauthSessionForUrl(baseUrl)
if (!sess) {
reject(new Error('OAuth session partition is unavailable.'))
@@ -7237,7 +7285,7 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) {
// authed REST against a gated gateway, including minting WS tickets.
function fetchJsonViaOauthSession(url, options: any = {}) {
return new Promise((resolve, reject) => {
const sess = getOauthSession()
const sess = getOauthSessionForUrl(url)
if (!sess) {
reject(new Error('OAuth session partition is unavailable.'))
@@ -7488,7 +7536,7 @@ async function ensureNativeAccessToken(baseUrl: string): Promise<string | null>
// is cleared once the response headers arrive.
function downloadViaOauthSessionToFile(url, ctx, options: any = {}) {
return new Promise((resolve, reject) => {
const sess = getOauthSession()
const sess = getOauthSessionForUrl(url)
if (!sess) {
reject(new Error('OAuth session partition is unavailable.'))

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest'
import { LEGACY_OAUTH_PARTITION, resolveOauthPartition } from './oauth-partition'
// #92183 — two basic-auth (cookie-flow) gateways registered in the v2
// connections registry must not share one cookie jar. Chromium cookie jars
// ignore the port, so two gateways on the same VPN host (different ports)
// evict each other's `hermes_session*` cookies when they ride the single
// shared `persist:hermes-remote-oauth` partition — and, worse, gateway A's
// cookie is silently PRESENTED to gateway B on every request. The resolver
// under test keys the jar on the registry connection's identity instead.
const registry = (primary: string, connections: any[]) => ({ primary, connections })
const remote = (id: string, url: string, extra: Record<string, unknown> = {}) => ({
id,
kind: 'remote',
label: id,
url,
authMode: 'oauth',
...extra
})
describe('resolveOauthPartition (#92183 per-connection cookie jars)', () => {
it('gives two same-host different-port registry gateways DISTINCT partitions (eviction fix)', () => {
const reg = registry('local', [
{ id: 'local', kind: 'local' },
remote('conn-a', 'https://10.27.27.7:9119'),
remote('conn-b', 'https://10.27.27.7:9220')
])
const a = resolveOauthPartition('https://10.27.27.7:9119', { registry: reg })
const b = resolveOauthPartition('https://10.27.27.7:9220', { registry: reg })
expect(a).not.toBe(LEGACY_OAUTH_PARTITION)
expect(b).not.toBe(LEGACY_OAUTH_PARTITION)
// Fail closed: B's requests must never ride a jar that can hold A's cookie.
expect(a).not.toBe(b)
})
it('scopes a full request URL (REST/ws-ticket path) to its connection jar via longest base-url prefix', () => {
const reg = registry('local', [
remote('conn-a', 'https://gw.example.com'),
remote('conn-b', 'https://gw.example.com/team-b')
])
const a = resolveOauthPartition('https://gw.example.com/api/auth/ws-ticket', { registry: reg })
const b = resolveOauthPartition('https://gw.example.com/team-b/api/auth/ws-ticket', { registry: reg })
expect(a).not.toBe(b)
expect(b).toContain('conn-b')
})
it('keeps the v1 primary remote on the LEGACY partition so upgrades do not sign the user out', () => {
const reg = registry('mig-1', [remote('mig-1', 'https://gw-a.example.com')])
expect(
resolveOauthPartition('https://gw-a.example.com', {
registry: reg,
v1RemoteUrl: 'https://gw-a.example.com'
})
).toBe(LEGACY_OAUTH_PARTITION)
})
it('keeps the registry PRIMARY connection on the legacy partition', () => {
const reg = registry('conn-a', [
remote('conn-a', 'https://gw-a.example.com'),
remote('conn-b', 'https://gw-b.example.com')
])
expect(resolveOauthPartition('https://gw-a.example.com/api/status', { registry: reg })).toBe(
LEGACY_OAUTH_PARTITION
)
expect(resolveOauthPartition('https://gw-b.example.com/api/status', { registry: reg })).not.toBe(
LEGACY_OAUTH_PARTITION
)
})
it('keeps cloud connections on the legacy partition (silent portal cascade needs the shared jar)', () => {
const reg = registry('local', [
{ id: 'cloud-1', kind: 'cloud', url: 'https://agent.nousresearch.com', authMode: 'oauth' }
])
expect(resolveOauthPartition('https://agent.nousresearch.com/api/status', { registry: reg })).toBe(
LEGACY_OAUTH_PARTITION
)
})
it('keeps token-auth registry remotes on the legacy partition (no cookies involved)', () => {
const reg = registry('local', [remote('tok-1', 'https://gw-t.example.com', { authMode: 'token' })])
expect(resolveOauthPartition('https://gw-t.example.com', { registry: reg })).toBe(LEGACY_OAUTH_PARTITION)
})
it('falls back to the legacy partition for unmatched, portal, and malformed inputs', () => {
const reg = registry('local', [remote('conn-a', 'https://gw-a.example.com')])
expect(resolveOauthPartition('https://portal.nousresearch.com/api/agents', { registry: reg })).toBe(
LEGACY_OAUTH_PARTITION
)
expect(resolveOauthPartition('not a url', { registry: reg })).toBe(LEGACY_OAUTH_PARTITION)
expect(resolveOauthPartition('', { registry: reg })).toBe(LEGACY_OAUTH_PARTITION)
expect(resolveOauthPartition('https://gw-a.example.com', { registry: null as any })).toBe(
LEGACY_OAUTH_PARTITION
)
expect(resolveOauthPartition('https://gw-a.example.com', { registry: { primary: 'x', connections: 'junk' } as any })).toBe(
LEGACY_OAUTH_PARTITION
)
})
it('does not treat a hostname PREFIX as a base-url match', () => {
const reg = registry('local', [remote('conn-a', 'https://gw.example.com')])
expect(resolveOauthPartition('https://gw.example.com.evil.tld/login', { registry: reg })).toBe(
LEGACY_OAUTH_PARTITION
)
})
it('normalizes trailing slashes and default ports when matching entry URLs', () => {
const reg = registry('local', [remote('conn-a', 'https://gw-a.example.com:443/')])
const got = resolveOauthPartition('https://gw-a.example.com/api/auth/ws-ticket', { registry: reg })
expect(got).not.toBe(LEGACY_OAUTH_PARTITION)
expect(got).toContain('conn-a')
})
it('produces a deterministic, partition-safe name from hostile connection ids', () => {
const reg = registry('local', [remote('we ird/id:€', 'https://gw-a.example.com')])
const got = resolveOauthPartition('https://gw-a.example.com', { registry: reg })
expect(got.startsWith('persist:')).toBe(true)
expect(got).not.toMatch(/[\s/€]/)
expect(resolveOauthPartition('https://gw-a.example.com', { registry: reg })).toBe(got)
})
it('breaks same-URL ties deterministically (identical jar for identical gateway)', () => {
const reg = registry('local', [
remote('zeta', 'https://gw-a.example.com'),
remote('alpha', 'https://gw-a.example.com')
])
const got = resolveOauthPartition('https://gw-a.example.com', { registry: reg })
expect(got).toContain('alpha')
})
})

View File

@@ -0,0 +1,159 @@
/**
* oauth-partition.ts
*
* Per-connection cookie-jar isolation for cookie-authenticated (OAuth /
* dashboard basic-auth) remote gateways (#92183).
*
* Historically every cookie-mode remote rode ONE Electron session partition
* (`persist:hermes-remote-oauth`) — the jar was keyed on the auth *mode*, not
* on the connection's identity. Chromium cookie jars scope by host and ignore
* the port, so two registered gateways on the same host (the #92183 VPN
* setup: one box, two dashboards) fought over the same `hermes_session*`
* cookies: signing in to gateway B evicted gateway A's session, and A's
* cookie was silently PRESENTED to B on every request — a cross-connection
* credential leak.
*
* This module is the pure decision seam: given a request/base URL and a
* snapshot of the v2 connections registry, decide which session partition the
* request must ride. Rules:
*
* - A NON-primary v2 registry `remote` entry with cookie auth
* (`authMode: 'oauth'`, which covers dashboard basic/password providers —
* they authenticate via session cookies too) gets its own partition
* derived from the connection id. Fail closed: its requests can never see
* another connection's cookies, and its login window can never evict them.
* - The registry PRIMARY and the v1 single-connection remote stay on the
* LEGACY shared partition, so existing signed-in users are not signed out
* by the upgrade.
* - `cloud` entries stay on the legacy partition: the silent per-agent
* cascade deliberately shares one jar with the Nous Portal session.
* - Token-auth remotes, portal URLs, and anything unmatched or malformed
* fall back to the legacy partition (cookie-free flows are unaffected).
*
* Kept free of `electron` imports so it unit-tests in the electron vitest
* project; main.ts owns session.fromPartition() and injects nothing here.
*/
export const LEGACY_OAUTH_PARTITION = 'persist:hermes-remote-oauth'
const CONNECTION_PARTITION_PREFIX = `${LEGACY_OAUTH_PARTITION}:conn:`
export interface PartitionRegistrySnapshot {
primary?: unknown
connections?: unknown
}
export interface ResolveOauthPartitionOptions {
registry?: PartitionRegistrySnapshot | null
/** v1 single-connection remote URL (connection.json `remote.url`), when set. */
v1RemoteUrl?: unknown
}
/**
* Normalize a URL for base-url matching: lowercased scheme+host, explicit
* default ports elided (URL does this), trailing slashes trimmed, query and
* fragment dropped. Returns null for anything that is not a plain http(s) URL.
*/
function normalizeForMatch(raw: unknown): string | null {
if (typeof raw !== 'string' || !raw.trim()) {
return null
}
try {
const parsed = new URL(raw.trim())
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null
}
const path = parsed.pathname.replace(/\/+$/, '')
return `${parsed.protocol}//${parsed.host}${path}`
} catch {
return null
}
}
/**
* True when `requestNorm` is the entry base URL itself or a path underneath
* it. Both sides are pre-normalized; the '/' requirement is what stops
* `https://gw.example.com.evil.tld` from matching `https://gw.example.com`.
*/
function matchesBase(requestNorm: string, baseNorm: string): boolean {
return requestNorm === baseNorm || requestNorm.startsWith(`${baseNorm}/`)
}
/** Partition names must stay printable/simple; connection ids are user data. */
function sanitizePartitionComponent(id: string): string {
return encodeURIComponent(id).replace(/%/g, '_')
}
/**
* Decide the Electron session partition a cookie-authenticated request against
* `requestUrl` must ride. See the module header for the rules.
*/
export function resolveOauthPartition(requestUrl: unknown, opts: ResolveOauthPartitionOptions = {}): string {
const requestNorm = normalizeForMatch(requestUrl)
if (!requestNorm) {
return LEGACY_OAUTH_PARTITION
}
const registry = opts.registry
if (!registry || typeof registry !== 'object' || !Array.isArray(registry.connections)) {
return LEGACY_OAUTH_PARTITION
}
const primaryId = typeof registry.primary === 'string' ? registry.primary : ''
const v1Norm = normalizeForMatch(opts.v1RemoteUrl)
let best: { baseNorm: string; id: string } | null = null
for (const entry of registry.connections) {
if (!entry || typeof entry !== 'object') {
continue
}
const id = typeof (entry as any).id === 'string' ? (entry as any).id.trim() : ''
// Only NON-primary v2 `remote` entries with cookie-flow auth get their own
// jar. Cloud entries need the shared portal jar; token entries never use
// cookies; the primary (and the v1 remote it migrated from) keeps the
// legacy jar so an upgrade does not sign the user out.
if (!id || id === primaryId) {
continue
}
if ((entry as any).kind !== 'remote' || (entry as any).authMode !== 'oauth') {
continue
}
const baseNorm = normalizeForMatch((entry as any).url)
if (!baseNorm || (v1Norm && baseNorm === v1Norm)) {
continue
}
if (!matchesBase(requestNorm, baseNorm)) {
continue
}
// Longest base-url prefix wins (sub-path gateways behind one proxy);
// identical URLs tie-break on the lexicographically smallest id so the
// choice is deterministic across processes and launches.
if (
!best ||
baseNorm.length > best.baseNorm.length ||
(baseNorm.length === best.baseNorm.length && id < best.id)
) {
best = { baseNorm, id }
}
}
if (!best) {
return LEGACY_OAUTH_PARTITION
}
return `${CONNECTION_PARTITION_PREFIX}${sanitizePartitionComponent(best.id)}`
}