Files
hermes-agent/apps/desktop/src/api/profiles.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

118 lines
4.1 KiB
TypeScript

import type {
ProfileCreatePayload,
ProfileDesktopOverlay,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, STARTUP_REQUEST_TIMEOUT_MS } from './client'
export function getProfiles(): Promise<ProfilesResponse> {
return hermesApi<ProfilesResponse>({
path: '/api/profiles',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> {
return hermesApi<{ name: string; ok: boolean; path: string }>({
path: '/api/profiles',
method: 'POST',
body
})
}
// Explicit (connection, profile) pin for a profile that lives on a gateway
// other than the foreground one — the fleet profile rail edits a remote
// square's SOUL/name in place. capabilityScoped now forwards a `'local'` pin
// itself (it must, or a remote registry PRIMARY absorbs "This device" reads),
// so this is a plain alias kept for the call sites' self-documenting name.
function profileOwnerScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } {
return capabilityScoped(scope)
}
export function renameProfile(
name: string,
newName: string,
scope?: ProfileScope
): Promise<{ name: string; ok: boolean; path: string }> {
return hermesApi<{ name: string; ok: boolean; path: string }>({
...profileOwnerScoped(scope),
path: `/api/profiles/${encodeURIComponent(name)}`,
method: 'PATCH',
body: { new_name: newName }
})
}
export function deleteProfile(name: string, scope?: ProfileScope): Promise<{ ok: boolean; path: string }> {
const normalized = name.trim()
const scopedProfile = scope && typeof scope === 'object' ? scope.profile?.trim() : undefined
if (!normalized) {
return Promise.reject(new Error('Profile name required'))
}
if (normalized.toLowerCase() === 'default' || scopedProfile?.toLowerCase() === 'default') {
return Promise.reject(new Error('The default profile cannot be deleted.'))
}
return hermesApi<{ ok: boolean; path: string }>({
...profileOwnerScoped(scope),
path: `/api/profiles/${encodeURIComponent(normalized)}`,
method: 'DELETE'
})
}
export function getProfileSoul(name: string, scope?: ProfileScope): Promise<ProfileSoul> {
return hermesApi<ProfileSoul>({
...profileOwnerScoped(scope),
path: `/api/profiles/${encodeURIComponent(name)}/soul`
})
}
export function updateProfileSoul(name: string, content: string, scope?: ProfileScope): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileOwnerScoped(scope),
path: `/api/profiles/${encodeURIComponent(name)}/soul`,
method: 'PUT',
body: { content }
})
}
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
return hermesApi<ProfileSetupCommand>({
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
})
}
/** Export a profile to a shareable .tar.gz on the backend's filesystem.
* `extraFiles` stages extra root-level files (desktop.json — the appearance/
* interface overlay) into the archive alongside the profile's own artifacts. */
export function exportProfileArchive(
name: string,
opts: { extraFiles?: Record<string, string>; output?: string } = {}
): Promise<{ archive: string; ok: boolean }> {
return hermesApi<{ archive: string; ok: boolean }>({
path: `/api/profiles/${encodeURIComponent(name)}/export`,
method: 'POST',
body: { extra_files: opts.extraFiles ?? {}, output: opts.output ?? '' },
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
/** Import a profile .tar.gz as a new profile. Returns the bundled desktop
* appearance overlay too (when the archive carried one) so the caller can
* apply theme/layout without another round-trip. */
export function importProfileArchive(
archive: string,
name?: string
): Promise<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }> {
return hermesApi<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }>({
path: '/api/profiles/import',
method: 'POST',
body: { archive, name: name || null },
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}