Files
hermes-agent/apps/desktop/src/hermes-capability-scope.test.ts
T
Teknium fe576ba48e fix(desktop): a 'This device' Capabilities pick reaches the local machine again under a remote registry primary
Since 1e9a12a71 (v0.20.6), a remote/cloud/ssh registry PRIMARY makes
globalRemoteActive() true, so the ambient v1 route — and with it every
unpinned Capabilities read — resolves to the remote gateway. The only
road back to this machine is an explicit connectionId:'local' pin, but
capabilityScoped() deliberately DROPPED that pin (pre-#91564, absent id
always meant the local pool), and profileScopeKey() collapsed
'local::<profile>' to the bare profile key.

Consequences on any desktop whose registry primary is remote:
- Capabilities -> MCP showed the REMOTE host's mcp_servers under every
  scope; locally configured (and connected) MCP servers vanished from
  the UI entirely.
- Picking 'default - This device' in the scope selector was a silent
  no-op: the collapsed cache key equaled the current scope key, so
  changeScope() early-returned and the selector snapped back.

Fix: capabilityScoped() forwards EVERY non-empty connection id, 'local'
included — Electron's apiRequestRegistryConnectionId/ensureRegistryBackend
already own 'local' pins (forced-local pooled child) and this is the
documented contract there. profileScopeKey() namespaces every explicit
pin ('local::<profile>') so a This-device pick and the ambient path never
share a cache row. profiles.ts profileOwnerScoped, which hand-patched
this exact hole for profile mutations, reduces to a named alias.

Live A/B (Electron + CDP, remote registry primary, local-only servers in
local config): v0.20.6 = local servers invisible, local pick no-op;
fixed = 'This device' lists local-server-alpha/beta, remote scope
unchanged.
2026-08-28 06:33:35 -07:00

117 lines
4.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getHermesConfigRecord,
getMcpCatalog,
getSkillContent,
getSkills,
getToolsets,
getUsageAnalytics,
installSkillFromHub,
profileScopeKey,
saveMcpServers,
setApiRequestConnection,
setApiRequestProfile,
setSkillEnabled,
setToolsetEnabled
} from './hermes'
// Contract: the Capabilities surface (skills / toolsets / MCP / hub / config)
// can be scoped to a (connection, profile) pair — a profile belongs to ONE
// gateway, so its skills/tools/MCP must be read from and written to THAT
// machine's backend. Three shapes:
// - no scope → ambient profile + ambient registry connection tag
// - string scope → explicit profile, ambient connection tag
// - object scope → explicit (connection, profile) pin; 'local' pins the
// local pool and DROPS the ambient connection tag
describe('capability helpers are connection-scoped', () => {
const api = vi.fn(async (_req: { connectionId?: string; path: string; profile?: string }) => ({}) as never)
beforeEach(() => {
;(window as { hermesDesktop?: unknown }).hermesDesktop = { api }
api.mockClear()
})
afterEach(() => {
setApiRequestProfile(null)
setApiRequestConnection(null)
delete (window as { hermesDesktop?: unknown }).hermesDesktop
})
const last = () => api.mock.calls.at(-1)?.[0] as { connectionId?: string; profile?: string }
it('omits both scopes when none are active (single-source users unaffected)', () => {
void getSkills()
expect(last()).not.toHaveProperty('profile')
expect(last()).not.toHaveProperty('connectionId')
})
it('carries the ambient registry connection on the legacy string/ambient path', () => {
// A window activated onto a registered remote gateway must read THAT
// machine's skills — not the local pool's (the ambient-tag half).
setApiRequestProfile('research')
setApiRequestConnection('gw-tailscale')
void getSkills()
void getToolsets()
void getHermesConfigRecord()
void getMcpCatalog()
void getUsageAnalytics(30)
for (const call of api.mock.calls) {
expect((call[0] as { connectionId?: string }).connectionId).toBe('gw-tailscale')
expect(call[0].profile).toBe('research')
}
})
it('string scopes override the profile but keep the ambient connection', () => {
setApiRequestProfile('research')
setApiRequestConnection('gw-tailscale')
void getSkills('coder')
expect(last().profile).toBe('coder')
expect(last().connectionId).toBe('gw-tailscale')
})
it('object scopes pin every read and write to the named connection', () => {
void getSkills({ connectionId: 'homelab', profile: 'inbox-bot' })
void getToolsets({ connectionId: 'homelab', profile: 'inbox-bot' })
void getSkillContent('arxiv', { connectionId: 'homelab', profile: 'inbox-bot' })
void setSkillEnabled('arxiv', false, { connectionId: 'homelab', profile: 'inbox-bot' })
void setToolsetEnabled('browser', true, { connectionId: 'homelab', profile: 'inbox-bot' })
void saveMcpServers({}, { connectionId: 'homelab', profile: 'inbox-bot' })
void installSkillFromHub('official/research/arxiv', { connectionId: 'homelab', profile: 'inbox-bot' })
for (const call of api.mock.calls) {
expect((call[0] as { connectionId?: string }).connectionId).toBe('homelab')
expect(call[0].profile).toBe('inbox-bot')
}
})
it("a 'local' pin carries an explicit connectionId even while a remote gateway is active", () => {
setApiRequestProfile('research')
setApiRequestConnection('gw-tailscale')
void getSkills({ connectionId: 'local', profile: 'coder' })
// The explicit pin must survive to Electron main: its registry resolver
// owns 'local' (forced-local pooled child). Omitting the key here let the
// ambient tag — or, worse, a remote registry PRIMARY on the v1 fallback
// route — absorb a "This device" pick (v0.20.6 regression, #91564 rung).
expect(last().profile).toBe('coder')
expect(last().connectionId).toBe('local')
})
it('profileScopeKey keeps legacy keys byte-identical and namespaces every explicit pin', () => {
expect(profileScopeKey()).toBe('default')
expect(profileScopeKey(null)).toBe('default')
expect(profileScopeKey('coder')).toBe('coder')
// A 'local' pin and the ambient path can resolve to DIFFERENT backends
// when the registry primary is remote — they must not share a cache row.
expect(profileScopeKey({ connectionId: 'local', profile: 'coder' })).toBe('local::coder')
expect(profileScopeKey({ connectionId: 'homelab', profile: 'coder' })).toBe('homelab::coder')
expect(profileScopeKey({ connectionId: 'homelab' })).toBe('homelab::default')
})
})