fix(desktop): address connections-registry review findings
Review fixes from #86679 comments (trevorgordon981, helix4u, kshitijk4poor): - Edit inheritance: mergeConnectionInput preserves fields the editor does not carry (cloud org, ssh remoteHermesPath/remoteProfile) so a rename no longer wipes them. When the payload carries an ssh host string, stored user/port are NOT inherited — the composite host field is authoritative, fixing the stale user/port resurrection on edit. - Token hygiene: tokens only persist on token-auth remotes; switching an entry to oauth (or cloud) clears the stale envelope. - Plain-text opt-in: the panel now surfaces the same consent dialog as Settings -> Gateway on keyring-less machines (registry list exposes secureTokenStorage; save retries with allowPlainTextToken after consent). - Registry test isolation: hermes:connections:test builds the probe directly from the registry entry instead of coercing against v1 connection.json — no more inheriting the v1 global token for a different host, and the local entry now probes the app-managed backend (never v1 remote/ssh state, so the test button can no longer trigger a v1 file write). - 'local' id reserved at the validation boundary: a crafted IPC payload can no longer replace the local entry via upsert. - Cloud creation hidden in the editor (a dialable cloud entry comes from the Cloud sign-in/discovery flow); migrated cloud entries stay editable. - First-run migration write is guarded: a failed write keeps the migrated registry in memory instead of hard-failing every connections IPC call. - uniqueLabel(): single label-dedup helper — counts up instead of "X 2 2", clamps 253-char migrated URL-host labels under LABEL_MAX; used by normalizeRegistry and both migration paths. - UI copy: staged-rollout note replaces the "side by side" claim; test failure toast leads with the failure wording; dropped unused i18n keys. Tests: +9 pure-module cases (reserved id, token-drop rules, merge inheritance, ssh host precedence, uniqueLabel); electron+settings suites 1355 passed.
This commit is contained in:
@@ -17,12 +17,14 @@ import {
|
||||
labelKey,
|
||||
labelSlug,
|
||||
LOCAL_CONNECTION_ID,
|
||||
mergeConnectionInput,
|
||||
migrateV1ToRegistry,
|
||||
normalizeConnectionInput,
|
||||
normalizeRegistry,
|
||||
REGISTRY_VERSION,
|
||||
removeConnection,
|
||||
setPrimaryConnection,
|
||||
uniqueLabel,
|
||||
upsertConnection
|
||||
} from './connection-registry'
|
||||
|
||||
@@ -57,8 +59,96 @@ test('connectionIdForLabel suffixes on collision and never mints "local"', () =>
|
||||
assert.equal(connectionIdForLabel('Local', []), 'local-2')
|
||||
})
|
||||
|
||||
test('uniqueLabel counts up (never "X 2 2") and clamps long candidates', () => {
|
||||
assert.equal(uniqueLabel('Homelab', []), 'Homelab')
|
||||
assert.equal(uniqueLabel('Homelab', ['Homelab']), 'Homelab 2')
|
||||
assert.equal(uniqueLabel('Homelab', ['Homelab', 'Homelab 2']), 'Homelab 3')
|
||||
// Case-insensitive collision detection.
|
||||
assert.equal(uniqueLabel('homelab', ['HOMELAB']), 'homelab 2')
|
||||
|
||||
const long = 'x'.repeat(300)
|
||||
assert.ok(uniqueLabel(long, []).length <= 64)
|
||||
assert.ok(uniqueLabel(long, [uniqueLabel(long, [])]).length <= 64)
|
||||
})
|
||||
|
||||
// --- normalizeConnectionInput ---
|
||||
|
||||
test('save rejects the reserved "local" id on non-local kinds', () => {
|
||||
assert.throws(
|
||||
() => normalizeConnectionInput({ id: 'local', kind: 'remote', label: 'Sneaky', url: 'http://x:1' }, emptyRegistry()),
|
||||
/reserved/
|
||||
)
|
||||
})
|
||||
|
||||
test('token only persists on token-auth remotes; oauth/cloud drop it', () => {
|
||||
const registry = emptyRegistry()
|
||||
|
||||
const tokenAuth = normalizeConnectionInput(
|
||||
{ kind: 'remote', label: 'A', url: 'http://a:1', authMode: 'token', token: { enc: 'x' } },
|
||||
registry
|
||||
)
|
||||
|
||||
assert.deepEqual(tokenAuth.token, { enc: 'x' })
|
||||
|
||||
const oauth = normalizeConnectionInput(
|
||||
{ kind: 'remote', label: 'B', url: 'http://b:1', authMode: 'oauth', token: { enc: 'x' } },
|
||||
registry
|
||||
)
|
||||
|
||||
assert.equal(oauth.token, undefined)
|
||||
|
||||
const cloud = normalizeConnectionInput(
|
||||
{ kind: 'cloud', label: 'C', url: 'https://c.hermes.cloud', authMode: 'oauth', token: { enc: 'x' } },
|
||||
registry
|
||||
)
|
||||
|
||||
assert.equal(cloud.token, undefined)
|
||||
})
|
||||
|
||||
// --- mergeConnectionInput (edit inheritance) ---
|
||||
|
||||
test('merge preserves fields the editor does not carry (org, ssh extras)', () => {
|
||||
const cloud = { authMode: 'oauth' as const, id: 'c', kind: 'cloud' as const, label: 'Cloud', org: 'nous', url: 'https://a.cloud' }
|
||||
const renamed = mergeConnectionInput({ id: 'c', kind: 'cloud', label: 'Renamed', url: 'https://a.cloud' }, cloud)
|
||||
|
||||
assert.equal(renamed.org, 'nous')
|
||||
|
||||
const ssh = {
|
||||
host: 'homelab.lan',
|
||||
id: 's',
|
||||
keyPath: '/k/id',
|
||||
kind: 'ssh' as const,
|
||||
label: 'Box',
|
||||
port: 2222,
|
||||
remoteHermesPath: '/opt/hermes',
|
||||
remoteProfile: 'research',
|
||||
user: 'k'
|
||||
}
|
||||
|
||||
const labelOnly = mergeConnectionInput({ id: 's', kind: 'ssh', label: 'Renamed box' }, ssh)
|
||||
|
||||
assert.equal(labelOnly.remoteHermesPath, '/opt/hermes')
|
||||
assert.equal(labelOnly.remoteProfile, 'research')
|
||||
assert.equal(labelOnly.host, 'homelab.lan')
|
||||
assert.equal(labelOnly.user, 'k')
|
||||
assert.equal(labelOnly.port, 2222)
|
||||
})
|
||||
|
||||
test('merge: a supplied ssh host string beats stored user/port', () => {
|
||||
const ssh = { host: 'spark1', id: 's', kind: 'ssh' as const, label: 'Spark', port: 2222, user: 'tek' }
|
||||
const merged = mergeConnectionInput({ host: 'admin@newbox:2200', id: 's', kind: 'ssh', label: 'Spark' }, ssh)
|
||||
|
||||
// Stored user/port must NOT ride along — the host string is authoritative.
|
||||
assert.equal(merged.user, undefined)
|
||||
assert.equal(merged.port, undefined)
|
||||
|
||||
const entry = normalizeConnectionInput(merged, emptyRegistry())
|
||||
|
||||
assert.equal(entry.host, 'newbox')
|
||||
assert.equal(entry.user, 'admin')
|
||||
assert.equal(entry.port, 2200)
|
||||
})
|
||||
|
||||
test('save rejects a missing label with a device-name message', () => {
|
||||
assert.throws(
|
||||
() => normalizeConnectionInput({ kind: 'remote', label: ' ', url: 'http://10.0.0.5:9119' }, emptyRegistry()),
|
||||
|
||||
@@ -78,6 +78,33 @@ export function labelKey(label: string): string {
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a registry-unique label from a candidate: clamps to LABEL_MAX (a
|
||||
* migrated URL host can exceed it, which would fail validation on any later
|
||||
* edit) and suffixes " 2" / " 3" / … on collision. The single home of the
|
||||
* label-dedup rule — normalizeRegistry and the migration both use it.
|
||||
*/
|
||||
export function uniqueLabel(candidate: string, taken: Iterable<string>): string {
|
||||
const used = new Set([...taken].map(labelKey))
|
||||
|
||||
// Reserve room for a collision suffix so the suffixed form stays in-bounds.
|
||||
const base = String(candidate || '')
|
||||
.trim()
|
||||
.slice(0, LABEL_MAX - 4)
|
||||
|
||||
if (!used.has(labelKey(base))) {
|
||||
return base
|
||||
}
|
||||
|
||||
for (let n = 2; ; n += 1) {
|
||||
const suffixed = `${base} ${n}`
|
||||
|
||||
if (!used.has(labelKey(suffixed))) {
|
||||
return suffixed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Kebab-slug of a label for ids and @handles. Never empty for a non-empty label. */
|
||||
export function labelSlug(label: string): string {
|
||||
const slug = String(label || '')
|
||||
@@ -168,6 +195,15 @@ export function normalizeConnectionInput(input: ConnectionInput, registry: Conne
|
||||
return { id: LOCAL_CONNECTION_ID, kind: 'local', label }
|
||||
}
|
||||
|
||||
// The reserved local id can never be claimed by a non-local entry — a
|
||||
// crafted IPC payload ({id:'local', kind:'remote', …}) would otherwise
|
||||
// replace the local entry via upsert and break the exactly-one-local
|
||||
// invariant. connectionIdForLabel never mints 'local'; reject it when
|
||||
// supplied, too.
|
||||
if (input.id === LOCAL_CONNECTION_ID) {
|
||||
throw new Error('The id "local" is reserved for the local connection.')
|
||||
}
|
||||
|
||||
const id = input.id || connectionIdForLabel(label, registry.connections.map(c => c.id))
|
||||
|
||||
if (kind === 'ssh') {
|
||||
@@ -196,7 +232,11 @@ export function normalizeConnectionInput(input: ConnectionInput, registry: Conne
|
||||
const authMode = normAuthMode(input.authMode)
|
||||
const entry: RegistryConnection = { id, kind, label, url, authMode }
|
||||
|
||||
if (input.token !== undefined) {
|
||||
// A token is only meaningful for token-auth remotes. Dropping it here is
|
||||
// what clears the stale envelope when an entry is switched token→oauth
|
||||
// (or is a cloud entry, which authenticates via the portal session) —
|
||||
// otherwise dead secret material rides along on the edited entry.
|
||||
if (input.token !== undefined && kind === 'remote' && authMode === 'token') {
|
||||
entry.token = input.token
|
||||
}
|
||||
|
||||
@@ -212,6 +252,50 @@ export function normalizeConnectionInput(input: ConnectionInput, registry: Conne
|
||||
throw new Error(`Unknown connection kind: ${String(kind)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a (possibly partial) edit payload over the stored entry so fields the
|
||||
* editor doesn't carry survive a save. Renaming a migrated cloud entry must
|
||||
* not drop its `org` (downstream update-fanout uses it to skip
|
||||
* platform-managed instances), and renaming an ssh entry must not drop
|
||||
* `remoteHermesPath`/`remoteProfile`. Only fields the payload explicitly
|
||||
* carries (non-undefined) override; `token` is deliberately NOT merged here —
|
||||
* the caller owns secret handling.
|
||||
*/
|
||||
export function mergeConnectionInput(input: ConnectionInput, existing?: null | RegistryConnection): ConnectionInput {
|
||||
if (!existing || existing.kind !== input.kind) {
|
||||
return input
|
||||
}
|
||||
|
||||
const merged: ConnectionInput = { ...input }
|
||||
|
||||
const inherit = (field: keyof ConnectionInput & keyof RegistryConnection) => {
|
||||
if (merged[field] === undefined && existing[field] !== undefined) {
|
||||
;(merged as unknown as Record<string, unknown>)[field] = existing[field]
|
||||
}
|
||||
}
|
||||
|
||||
inherit('url')
|
||||
inherit('authMode')
|
||||
inherit('org')
|
||||
inherit('host')
|
||||
inherit('keyPath')
|
||||
inherit('remoteHermesPath')
|
||||
inherit('remoteProfile')
|
||||
|
||||
// ssh user/port: the editor shows ONE composite host field (user@host:port),
|
||||
// and normalizeSshConfig gives explicit user/port fields precedence over the
|
||||
// parsed host string. Inheriting stored user/port alongside a NEW host string
|
||||
// would resurrect the old values over what the user just typed — so when the
|
||||
// payload carries a host, the host string is authoritative and stored
|
||||
// user/port are NOT inherited.
|
||||
if (input.host === undefined || !String(input.host).trim()) {
|
||||
inherit('user')
|
||||
inherit('port')
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ── Registry-level operations (all pure: return a new registry) ────────────
|
||||
|
||||
function localEntry(label = 'This device'): RegistryConnection {
|
||||
@@ -253,9 +337,7 @@ export function normalizeRegistry(raw: unknown): ConnectionRegistry {
|
||||
kind === 'ssh' ? String(entry.host || 'ssh') : hostLabelFromBaseUrl(String(entry.url || '')) || String(kind)
|
||||
}
|
||||
|
||||
while (seenLabels.has(labelKey(label))) {
|
||||
label = `${label} 2`
|
||||
}
|
||||
label = uniqueLabel(label, seenLabels)
|
||||
|
||||
let id = kind === 'local' ? LOCAL_CONNECTION_ID : String(entry.id || '').trim()
|
||||
|
||||
@@ -348,11 +430,10 @@ export function migrateV1ToRegistry(v1: unknown): ConnectionRegistry {
|
||||
return existing
|
||||
}
|
||||
|
||||
let label = hostLabelFromBaseUrl(url) || (kind === 'cloud' ? 'Hermes Cloud' : 'Remote gateway')
|
||||
|
||||
while (connections.some(c => labelKey(c.label) === labelKey(label))) {
|
||||
label = `${label} 2`
|
||||
}
|
||||
const label = uniqueLabel(
|
||||
hostLabelFromBaseUrl(url) || (kind === 'cloud' ? 'Hermes Cloud' : 'Remote gateway'),
|
||||
connections.map(c => c.label)
|
||||
)
|
||||
|
||||
const entry: RegistryConnection = {
|
||||
id: connectionIdForLabel(label, connections.map(c => c.id)),
|
||||
@@ -392,11 +473,7 @@ export function migrateV1ToRegistry(v1: unknown): ConnectionRegistry {
|
||||
return existing
|
||||
}
|
||||
|
||||
let label = ssh.host
|
||||
|
||||
while (connections.some(c => labelKey(c.label) === labelKey(label))) {
|
||||
label = `${label} 2`
|
||||
}
|
||||
const label = uniqueLabel(ssh.host, connections.map(c => c.label))
|
||||
|
||||
const { mode: _mode, ...sshFields } = ssh
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
tokenPreview
|
||||
} from './connection-config'
|
||||
import {
|
||||
mergeConnectionInput,
|
||||
migrateV1ToRegistry,
|
||||
normalizeConnectionInput,
|
||||
normalizeRegistry,
|
||||
@@ -7590,9 +7591,21 @@ function readDesktopConnectionsRegistry() {
|
||||
|
||||
if (mtime === null) {
|
||||
// First run on this build: import the v1 single-connection config. The v1
|
||||
// file is NOT modified or deleted — older builds keep reading it.
|
||||
// file is NOT modified or deleted — older builds keep reading it. The
|
||||
// migration is deterministic over the v1 input, so even if two processes
|
||||
// race the first run (updater relaunch, second window), both derive the
|
||||
// same registry and the later atomic write is a no-op content-wise.
|
||||
registry = migrateV1ToRegistry(readDesktopConnectionConfig())
|
||||
writeDesktopConnectionsRegistry(registry)
|
||||
|
||||
try {
|
||||
writeDesktopConnectionsRegistry(registry)
|
||||
} catch {
|
||||
// Write failed (full disk, read-only userData). Keep the migrated
|
||||
// registry in memory so list/save keep working this session instead of
|
||||
// hard-failing every hermes:connections:* call.
|
||||
connectionRegistryCache = registry
|
||||
connectionRegistryCacheMtime = null
|
||||
}
|
||||
|
||||
return connectionRegistryCache
|
||||
}
|
||||
@@ -7638,18 +7651,33 @@ function sanitizeRegistryConnection(entry) {
|
||||
}
|
||||
|
||||
function sanitizeConnectionsRegistry(registry = readDesktopConnectionsRegistry()) {
|
||||
// Same keyring probe the v1 sanitize exposes: lets the Connections panel
|
||||
// offer the plain-text opt-in on keyring-less Linux instead of failing.
|
||||
let secureTokenStorage = false
|
||||
|
||||
try {
|
||||
secureTokenStorage = Boolean(safeStorage.isEncryptionAvailable())
|
||||
} catch {
|
||||
secureTokenStorage = false
|
||||
}
|
||||
|
||||
return {
|
||||
version: registry.version,
|
||||
primary: registry.primary,
|
||||
secureTokenStorage,
|
||||
connections: registry.connections.map(sanitizeRegistryConnection)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save (create or edit) a registry connection from a renderer payload.
|
||||
* Token handling mirrors coerceDesktopConnectionConfig: an incoming plaintext
|
||||
* token is encrypted (with the same plain-text opt-in seam); an absent token
|
||||
* field inherits the stored envelope on edit.
|
||||
* Edits merge over the stored entry (mergeConnectionInput) so fields the
|
||||
* editor doesn't carry — cloud `org`, ssh `remoteHermesPath`/`remoteProfile` —
|
||||
* survive a rename. Token handling mirrors coerceDesktopConnectionConfig: an
|
||||
* incoming plaintext token is encrypted (honoring the same allowPlainTextToken
|
||||
* opt-in seam as Settings → Gateway); an absent token field inherits the
|
||||
* stored envelope on edit; switching auth away from 'token' clears it
|
||||
* (normalizeConnectionInput drops tokens on non-token entries).
|
||||
*/
|
||||
function saveRegistryConnection(input: any = {}) {
|
||||
const registry = readDesktopConnectionsRegistry()
|
||||
@@ -7664,7 +7692,8 @@ function saveRegistryConnection(input: any = {}) {
|
||||
encryptSecret: encryptDesktopSecret
|
||||
})
|
||||
|
||||
const entry = normalizeConnectionInput({ ...input, token }, registry)
|
||||
const merged = mergeConnectionInput({ ...input, token }, existing)
|
||||
const entry = normalizeConnectionInput(merged, registry)
|
||||
|
||||
// Token-auth remotes must actually have a token to be dialable. OAuth and
|
||||
// cloud entries authenticate via cookies/native tokens instead.
|
||||
@@ -11352,12 +11381,8 @@ ipcMain.handle('hermes:connections:test', async (_event, id) => {
|
||||
throw new Error(`No connection with id "${String(id || '')}".`)
|
||||
}
|
||||
|
||||
// Reuse the existing probe stack by mapping the registry entry onto the
|
||||
// settings-payload shape testDesktopConnectionConfig already understands.
|
||||
if (entry.kind === 'local') {
|
||||
return testDesktopConnectionConfig({ mode: 'local' })
|
||||
}
|
||||
|
||||
// The ssh probe path in testDesktopConnectionConfig never consults v1
|
||||
// connection state, so mapping the entry onto it is safe.
|
||||
if (entry.kind === 'ssh') {
|
||||
return testDesktopConnectionConfig({
|
||||
mode: 'ssh',
|
||||
@@ -11369,13 +11394,53 @@ ipcMain.handle('hermes:connections:test', async (_event, id) => {
|
||||
})
|
||||
}
|
||||
|
||||
return testDesktopConnectionConfig({
|
||||
mode: entry.kind,
|
||||
remoteUrl: entry.url,
|
||||
remoteAuthMode: entry.authMode,
|
||||
remoteToken: decryptDesktopSecret(entry.token) || undefined,
|
||||
cloudOrg: entry.org
|
||||
})
|
||||
// Remote/cloud/local probe built DIRECTLY from the registry entry. Routing
|
||||
// through coerceDesktopConnectionConfig would use v1 connection.json as the
|
||||
// `existing` base: an entry with a broken/absent token would inherit the v1
|
||||
// global remote's token and send it to THIS entry's URL (cross-host
|
||||
// credential transmission + a false "reachable"), and testing the local
|
||||
// entry would probe whatever v1's global mode points at instead of the
|
||||
// app-managed local backend.
|
||||
let baseUrl
|
||||
let token = null
|
||||
let authMode = 'token'
|
||||
|
||||
if (entry.kind === 'local') {
|
||||
const local = await startHermes()
|
||||
baseUrl = local.baseUrl
|
||||
token = local.token
|
||||
authMode = normAuthMode(local.authMode)
|
||||
} else {
|
||||
baseUrl = normalizeRemoteBaseUrl(entry.url)
|
||||
authMode = normAuthMode(entry.authMode)
|
||||
|
||||
if (authMode !== 'oauth') {
|
||||
token = decryptDesktopSecret(entry.token)
|
||||
|
||||
if (!token) {
|
||||
throw new Error('This connection has no saved session token. Edit the connection and paste one.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const status = (await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })) as any
|
||||
|
||||
// Same HTTP+WS two-leg check as testDesktopConnectionConfig: HTTP alone is
|
||||
// a false positive when the WebSocket leg is blocked.
|
||||
const wsUrl = await resolveTestWsUrl(baseUrl, authMode, token, { mintTicket: mintGatewayWsTicket })
|
||||
|
||||
if (wsUrl && typeof globalThis.WebSocket === 'function') {
|
||||
const probe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket })
|
||||
|
||||
if (!probe.ok) {
|
||||
throw new Error(
|
||||
`Reached the gateway over HTTP, but the live WebSocket (/api/ws) connection failed: ${probe.reason} ` +
|
||||
'The HTTP check can pass while the WebSocket is blocked by a proxy, firewall, or gateway auth/origin guard.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, baseUrl, version: status?.version || null }
|
||||
})
|
||||
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
|
||||
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ const registry: DesktopConnectionsRegistry = {
|
||||
}
|
||||
],
|
||||
primary: 'local',
|
||||
secureTokenStorage: true,
|
||||
version: 2
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,6 @@ interface EditorState {
|
||||
authMode: 'oauth' | 'token'
|
||||
token: string
|
||||
host: string
|
||||
user: string
|
||||
port: string
|
||||
keyPath: string
|
||||
}
|
||||
|
||||
@@ -45,15 +43,18 @@ function editorFromConnection(conn: DesktopRegistryConnection): EditorState {
|
||||
url: conn.url || '',
|
||||
authMode: conn.authMode || 'token',
|
||||
token: '',
|
||||
host: conn.host || '',
|
||||
user: conn.user || '',
|
||||
port: conn.port ? String(conn.port) : '',
|
||||
// Reconstruct the composite the single ssh host field displays. The save
|
||||
// payload sends ONLY this string (never separate user/port), because
|
||||
// normalizeSshConfig gives explicit user/port fields precedence over the
|
||||
// parsed host string — sending stored user/port alongside a retyped host
|
||||
// would silently resurrect the old values.
|
||||
host: conn.host ? `${conn.user ? `${conn.user}@` : ''}${conn.host}${conn.port ? `:${conn.port}` : ''}` : '',
|
||||
keyPath: conn.keyPath || ''
|
||||
}
|
||||
}
|
||||
|
||||
function emptyEditor(kind: DesktopConnectionKind): EditorState {
|
||||
return { id: null, kind, label: '', url: '', authMode: 'token', token: '', host: '', user: '', port: '', keyPath: '' }
|
||||
return { id: null, kind, label: '', url: '', authMode: 'token', token: '', host: '', keyPath: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +73,7 @@ export function ConnectionsSettings() {
|
||||
const [busyId, setBusyId] = useState<null | string>(null)
|
||||
const [testingId, setTestingId] = useState<null | string>(null)
|
||||
const [removeTarget, setRemoveTarget] = useState<DesktopRegistryConnection | null>(null)
|
||||
const [plainTextConfirm, setPlainTextConfirm] = useState(false)
|
||||
|
||||
const bridge = window.hermesDesktop?.connections
|
||||
|
||||
@@ -97,46 +99,69 @@ export function ConnectionsSettings() {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!bridge || !editor) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
const payload: DesktopRegistryConnectionInput = {
|
||||
kind: editor.kind,
|
||||
label: editor.label
|
||||
const save = useCallback(
|
||||
async (allowPlainTextToken = false) => {
|
||||
if (!bridge || !editor) {
|
||||
return
|
||||
}
|
||||
|
||||
if (editor.id) {
|
||||
payload.id = editor.id
|
||||
}
|
||||
setSaving(true)
|
||||
|
||||
if (editor.kind === 'remote' || editor.kind === 'cloud') {
|
||||
payload.url = editor.url
|
||||
payload.authMode = editor.authMode
|
||||
|
||||
if (editor.token.trim()) {
|
||||
payload.token = editor.token.trim()
|
||||
try {
|
||||
const payload: DesktopRegistryConnectionInput = {
|
||||
kind: editor.kind,
|
||||
label: editor.label
|
||||
}
|
||||
} else if (editor.kind === 'ssh') {
|
||||
payload.host = editor.host
|
||||
payload.user = editor.user || undefined
|
||||
payload.port = editor.port.trim() ? Number(editor.port) : null
|
||||
payload.keyPath = editor.keyPath || undefined
|
||||
}
|
||||
|
||||
const result = await bridge.save(payload)
|
||||
setRegistry(result.registry)
|
||||
setEditor(null)
|
||||
} catch (err) {
|
||||
notifyError(err, s.saveFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [bridge, editor, s.saveFailed])
|
||||
if (editor.id) {
|
||||
payload.id = editor.id
|
||||
}
|
||||
|
||||
if (editor.kind === 'remote' || editor.kind === 'cloud') {
|
||||
payload.url = editor.url
|
||||
payload.authMode = editor.authMode
|
||||
|
||||
if (editor.token.trim()) {
|
||||
payload.token = editor.token.trim()
|
||||
}
|
||||
|
||||
if (allowPlainTextToken) {
|
||||
payload.allowPlainTextToken = true
|
||||
}
|
||||
} else if (editor.kind === 'ssh') {
|
||||
// The composite host string (user@host:port) is the single source
|
||||
// of truth — never send separate user/port (see editorFromConnection).
|
||||
payload.host = editor.host
|
||||
payload.keyPath = editor.keyPath || undefined
|
||||
}
|
||||
|
||||
const result = await bridge.save(payload)
|
||||
setRegistry(result.registry)
|
||||
setEditor(null)
|
||||
setPlainTextConfirm(false)
|
||||
} catch (err) {
|
||||
// Keyring-less machine and the user hasn't consented to plain-text
|
||||
// storage yet: raise the same opt-in dialog Settings → Gateway uses
|
||||
// instead of dead-ending the save.
|
||||
if (
|
||||
!allowPlainTextToken &&
|
||||
registry?.secureTokenStorage === false &&
|
||||
editor.kind === 'remote' &&
|
||||
editor.authMode === 'token' &&
|
||||
editor.token.trim()
|
||||
) {
|
||||
setPlainTextConfirm(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notifyError(err, s.saveFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
},
|
||||
[bridge, editor, registry?.secureTokenStorage, s.saveFailed]
|
||||
)
|
||||
|
||||
const remove = useCallback(async () => {
|
||||
if (!bridge || !removeTarget) {
|
||||
@@ -191,7 +216,7 @@ export function ConnectionsSettings() {
|
||||
if (reachable) {
|
||||
notify({ title: conn.label, message: s.testOk })
|
||||
} else {
|
||||
notifyError(new Error(result.error || s.testFailed), conn.label)
|
||||
notifyError(new Error(result.error || conn.label), s.testFailed)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, s.testFailed)
|
||||
@@ -216,7 +241,12 @@ export function ConnectionsSettings() {
|
||||
return (
|
||||
<SettingsContent>
|
||||
<SectionHeading icon={Globe} title={s.title} />
|
||||
<p className="mb-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{s.intro}</p>
|
||||
<p className="mb-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{s.intro}</p>
|
||||
{/* Storage-only slice: be explicit that routing consumption is staged so
|
||||
"Make primary" isn't read as an immediate connection switch. */}
|
||||
<p className="mb-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{s.stagedNote}
|
||||
</p>
|
||||
|
||||
{!registry || registry.connections.length === 0 ? (
|
||||
<EmptyState title={s.empty} />
|
||||
@@ -293,7 +323,11 @@ export function ConnectionsSettings() {
|
||||
{editor ? (
|
||||
<div className="mt-4 space-y-3 rounded-lg border border-border/60 p-4">
|
||||
<div className="grid grid-cols-2 gap-2 @2xl:grid-cols-4">
|
||||
{(['remote', 'cloud', 'ssh'] as const).map(kind => (
|
||||
{/* Cloud creation is deliberately absent: a dialable cloud entry
|
||||
comes from the Hermes Cloud sign-in/discovery flow (Settings →
|
||||
Gateway), not a hand-typed URL. Migrated/discovered cloud
|
||||
entries remain editable (kind buttons are disabled on edit). */}
|
||||
{(editor.kind === 'cloud' ? (['cloud'] as const) : (['remote', 'ssh'] as const)).map(kind => (
|
||||
<Button
|
||||
disabled={Boolean(editor.id)}
|
||||
key={kind}
|
||||
@@ -414,6 +448,17 @@ export function ConnectionsSettings() {
|
||||
open={Boolean(removeTarget)}
|
||||
title={s.removeConfirmTitle}
|
||||
/>
|
||||
|
||||
{/* Keyring-less opt-in: same consent flow as Settings → Gateway. */}
|
||||
<ConfirmDialog
|
||||
confirmLabel={t.settings.gateway.plainTextConfirmAction}
|
||||
description={t.settings.gateway.plainTextConfirmDesc}
|
||||
destructive
|
||||
onClose={() => setPlainTextConfirm(false)}
|
||||
onConfirm={() => save(true)}
|
||||
open={plainTextConfirm}
|
||||
title={t.settings.gateway.plainTextConfirmTitle}
|
||||
/>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -696,6 +696,9 @@ export interface DesktopConnectionsRegistry {
|
||||
version: number
|
||||
// id of the connection that owns the window/primary backend.
|
||||
primary: string
|
||||
// Whether OS-keychain-backed encryption (Electron safeStorage) is available;
|
||||
// false drives the plain-text token opt-in on keyring-less Linux.
|
||||
secureTokenStorage: boolean
|
||||
connections: DesktopRegistryConnection[]
|
||||
}
|
||||
|
||||
|
||||
@@ -626,8 +626,9 @@ export const en: Translations = {
|
||||
connections: {
|
||||
title: 'Connections',
|
||||
intro:
|
||||
'Register every place your agents live — this device, remote gateways on your network, and Hermes Cloud instances — and use them side by side.',
|
||||
loading: 'Loading connections…',
|
||||
'Register every place your agents live — this device, remote gateways on your network, and Hermes Cloud instances. All of them are stored here.',
|
||||
stagedNote:
|
||||
'Side-by-side routing is rolling out in stages: connections are managed here today, while the active connection is still chosen in Settings → Gateway.',
|
||||
loadFailed: 'Could not load connections',
|
||||
primaryPill: 'Primary',
|
||||
managedPill: 'This device',
|
||||
@@ -639,7 +640,6 @@ export const en: Translations = {
|
||||
`“${label}” will be removed from this app. The instance itself is not touched — you can add it again any time.`,
|
||||
makePrimary: 'Make primary',
|
||||
testConnection: 'Test',
|
||||
testing: 'Testing…',
|
||||
testOk: 'Reachable',
|
||||
testFailed: 'Connection test failed',
|
||||
saveFailed: 'Could not save the connection',
|
||||
|
||||
@@ -520,7 +520,7 @@ export interface Translations {
|
||||
connections: {
|
||||
title: string
|
||||
intro: string
|
||||
loading: string
|
||||
stagedNote: string
|
||||
loadFailed: string
|
||||
primaryPill: string
|
||||
managedPill: string
|
||||
@@ -531,7 +531,6 @@ export interface Translations {
|
||||
removeConfirmDesc: (label: string) => string
|
||||
makePrimary: string
|
||||
testConnection: string
|
||||
testing: string
|
||||
testOk: string
|
||||
testFailed: string
|
||||
saveFailed: string
|
||||
|
||||
@@ -832,8 +832,8 @@ export const zh: Translations = {
|
||||
// v2 多连接注册表:设置 → 连接。
|
||||
connections: {
|
||||
title: '连接',
|
||||
intro: '注册你的智能体所在的每个位置——本机、局域网中的远程网关、Hermes Cloud 实例——并同时使用它们。',
|
||||
loading: '正在加载连接…',
|
||||
intro: '注册你的智能体所在的每个位置——本机、局域网中的远程网关、Hermes Cloud 实例——全部保存在这里。',
|
||||
stagedNote: '多来源路由正在分阶段推出:目前在此管理连接,活动连接仍在“设置 → 网关”中选择。',
|
||||
loadFailed: '无法加载连接',
|
||||
primaryPill: '主连接',
|
||||
managedPill: '本机',
|
||||
@@ -844,7 +844,6 @@ export const zh: Translations = {
|
||||
removeConfirmDesc: (label: string) => `“${label}”将从本应用移除。实例本身不受影响——你可以随时重新添加。`,
|
||||
makePrimary: '设为主连接',
|
||||
testConnection: '测试',
|
||||
testing: '测试中…',
|
||||
testOk: '可访问',
|
||||
testFailed: '连接测试失败',
|
||||
saveFailed: '无法保存连接',
|
||||
|
||||
Reference in New Issue
Block a user