From 4ee87f76ee7dc2646fe8fcdcb70202c4f46d97bd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:06:35 -0700 Subject: [PATCH] fix(desktop): read cron run-history from the owning gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Hermes Desktop works against a REGISTERED gateway connection, cron jobs execute on that gateway and persist their run sessions in the gateway's state.db. But every REST call in the app — the cron surface included — carried only `profile`, so `hermes:api` routed it through the local profile pool and `_list_cron_job_runs_sync` read a local state.db with zero `source='cron'` rows. Every job showed "No runs yet" while the same endpoint on the gateway returned the real runs (#87882). Fix at the routing seam: - HermesApiRequest gains an optional `connectionId`. The renderer's cron helpers (list/get/runs/delivery-targets/create/update/pause/resume/ trigger/delete/blueprints) now tag the active registry connection via a new connectionScoped() twin of profileScoped(), fed from the same setApiRequestConnection seam store/gateway already maintains for the plugin socket. - The hermes:api main-process handler resolves a tagged request through ensureRegistryBackend — the SAME pool the job list and WS traffic use — instead of the legacy profile route. Shared remote/cloud hosts (one gateway, many profiles) get the path scoped with ?profile= via the new pathWithProfileScope helper, factored out of pathWithGlobalRemoteProfile. - '' / 'local' / absent connectionId keep the byte-identical v1 route, so single-source and connection-config-remote users are unaffected. This covers the run-history panel, the sidebar cron peek, and every other cron surface in one place, since they all funnel through the same helpers. Fixes #87882 --- .../electron/connection-config.test.ts | 33 ++++++++++ apps/desktop/electron/connection-config.ts | 35 ++++++++++- apps/desktop/electron/main.ts | 60 +++++++++++++++++++ apps/desktop/src/global.d.ts | 6 ++ apps/desktop/src/hermes-cron-scope.test.ts | 33 ++++++++++ apps/desktop/src/hermes.ts | 21 +++++++ 6 files changed, 187 insertions(+), 1 deletion(-) diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index b6451cad5d..829630a91f 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -15,6 +15,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' import { + apiRequestRegistryConnectionId, AT_COOKIE_VARIANTS, authModeFromStatus, buildGatewayWsUrl, @@ -33,6 +34,7 @@ import { normalizeSshConfig, normAuthMode, pathWithGlobalRemoteProfile, + pathWithProfileScope, profileHasRemoteConnection, profileRemoteOverride, profileSshOverride, @@ -279,6 +281,37 @@ test('resolveProfileBackendRoute only tags a descriptor when the backend is shar } }) +// --- registry-pinned REST routing (cron run history on remote gateways, #87882) --- + +test('apiRequestRegistryConnectionId extracts a genuinely non-local connection id', () => { + assert.equal(apiRequestRegistryConnectionId({ connectionId: 'gw-tailscale', path: '/api/cron/jobs' }), 'gw-tailscale') + assert.equal(apiRequestRegistryConnectionId({ connectionId: ' gw-1 ', path: '/x' }), 'gw-1') +}) + +test('apiRequestRegistryConnectionId resolves null for the legacy/local routes', () => { + assert.equal(apiRequestRegistryConnectionId({ path: '/api/cron/jobs' }), null) + assert.equal(apiRequestRegistryConnectionId({ connectionId: '', path: '/x' }), null) + assert.equal(apiRequestRegistryConnectionId({ connectionId: 'local', path: '/x' }), null) + assert.equal(apiRequestRegistryConnectionId({ connectionId: null, path: '/x' }), null) + assert.equal(apiRequestRegistryConnectionId(null), null) + assert.equal(apiRequestRegistryConnectionId(undefined), null) +}) + +test('pathWithProfileScope scopes shared-remote requests to the profile unconditionally', () => { + // A sharedRemote registry gateway serves every profile from one host; the + // run-history read must land on the profile that owns the job's sessions. + assert.equal( + pathWithProfileScope('/api/cron/jobs/job-1/runs?limit=20', 'research'), + '/api/cron/jobs/job-1/runs?limit=20&profile=research' + ) +}) + +test('pathWithProfileScope keeps an explicit profile query and no-ops on empty profile', () => { + assert.equal(pathWithProfileScope('/api/cron/jobs?profile=all', 'research'), '/api/cron/jobs?profile=all') + assert.equal(pathWithProfileScope('/api/cron/jobs', ''), '/api/cron/jobs') + assert.equal(pathWithProfileScope('/api/cron/jobs', null), '/api/cron/jobs') +}) + // --- pathWithGlobalRemoteProfile --- test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => { diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index c3a676785d..c27e435133 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -446,9 +446,23 @@ function resolveProfileBackendRoute(profile, opts: ProfileRouteOptions = {}): Pr * serving backend is not already scoped to that profile. */ function pathWithGlobalRemoteProfile(path, profile, opts: ProfileRouteOptions = {}) { + if (!resolveProfileBackendRoute(profile, opts).scopePath) { + return path + } + + return pathWithProfileScope(path, profile) +} + +/** + * Unconditionally scope a REST path to a profile via `?profile=`. Used by the + * global-remote route above and by registry `sharedRemote` connections (one + * gateway host serving every profile, scoped per request). An explicit + * `?profile=` already on the path wins; an empty profile is a no-op. + */ +function pathWithProfileScope(path, profile) { const scopedProfile = connectionScopeKey(profile) - if (!resolveProfileBackendRoute(profile, opts).scopePath) { + if (!scopedProfile) { return path } @@ -475,6 +489,23 @@ function pathWithGlobalRemoteProfile(path, profile, opts: ProfileRouteOptions = return `${parsed.pathname}${parsed.search}${parsed.hash}` } +/** + * Registry connection a REST request is explicitly pinned to, or null for the + * legacy profile-routed path. `''`/`'local'` mean the local pool — callers + * only detour through the registry for a genuinely non-local connection, so + * single-source users keep the byte-identical v1 route. + */ +function apiRequestRegistryConnectionId(request): null | string { + const raw = request && typeof request === 'object' ? (request as { connectionId?: unknown }).connectionId : '' + const id = String(raw ?? '').trim() + + if (!id || id === 'local') { + return null + } + + return id +} + function tokenPreview(value) { const raw = String(value || '') @@ -588,6 +619,7 @@ function cookiesHavePrivyAccessToken(cookies) { } export { + apiRequestRegistryConnectionId, AT_COOKIE_VARIANTS, authModeFromStatus, buildGatewayWsUrl, @@ -607,6 +639,7 @@ export { normalizeSshConfig, normAuthMode, pathWithGlobalRemoteProfile, + pathWithProfileScope, PRIVY_ACCESS_COOKIE_VARIANTS, PRIVY_SESSION_COOKIE_VARIANTS, profileHasRemoteConnection, diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index c97d49f54a..265ce7a5a0 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -56,6 +56,7 @@ import { decideBootstrapRepair } from './bootstrap-repair-guard' import { runBootstrap } from './bootstrap-runner' import { applyConnectionChange, resolveTerminalConnection } from './connection-apply' import { + apiRequestRegistryConnectionId, authModeFromStatus, buildGatewayWsUrl, buildGatewayWsUrlWithTicket, @@ -73,6 +74,7 @@ import { normalizeSshConfig, normAuthMode, pathWithGlobalRemoteProfile, + pathWithProfileScope, profileHasRemoteConnection, profileRemoteOverride, profileSshOverride, @@ -12316,6 +12318,41 @@ async function getJsonForBackend(descriptor, path, opts: any = {}) { return fetchJson(url, descriptor.token, opts) } +// Any-method REST call against a resolved backend descriptor — the descriptor +// analogue of the hermes:api handler's own auth split: OAuth backends prefer a +// native bearer (cookieless RFC 8252 flow) and fall back to the OAuth cookie +// partition; token/local descriptors use the static session-token header. +async function fetchJsonForBackend( + descriptor, + path, + opts: { method?: string; body?: unknown; upload?: unknown; timeoutMs?: number } = {} +) { + const url = `${descriptor.baseUrl}${path}` + + if (descriptor.authMode === 'oauth') { + // The OAuth cookie path rides electron.net with JSON headers; multipart + // isn't wired there. Fail loudly rather than corrupting the upload. + if (opts.upload) { + throw new Error('File uploads are not supported against OAuth-gated remote backends yet.') + } + + const nativeAt = await ensureNativeAccessToken(descriptor.baseUrl).catch(() => null) + + if (nativeAt) { + return fetchJson(url, null, { method: opts.method, body: opts.body, timeoutMs: opts.timeoutMs, bearer: nativeAt }) + } + + return fetchJsonViaOauthSession(url, { method: opts.method, body: opts.body, timeoutMs: opts.timeoutMs }) + } + + return fetchJson(url, descriptor.token, { + method: opts.method, + body: opts.body, + upload: opts.upload, + timeoutMs: opts.timeoutMs + }) +} + ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl)) ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => { // Capability-gated login (RFC 8252). Probe the gateway's public /api/status: @@ -12724,6 +12761,29 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { } ipcMain.handle('hermes:api', async (_event, request) => { + // Registry-pinned request (request.connectionId): the renderer is working + // against a REGISTERED gateway connection, so the data — cron jobs and their + // run sessions included — lives in THAT host's state.db, not any local + // profile's. Resolve the backend through the registry (same pool the job + // list and WS traffic use) instead of the legacy profile route; a shared + // remote/cloud host serves every profile via ?profile=, so scope the path. + // '' / 'local' fall through to the byte-identical v1 route below (#87882). + const registryConnectionId = apiRequestRegistryConnectionId(request) + + if (registryConnectionId) { + const connection: any = await ensureRegistryBackend(registryConnectionId, request?.profile) + const requestPath = connection.sharedRemote + ? pathWithProfileScope(request.path, request?.profile) + : request.path + + return fetchJsonForBackend(connection, requestPath, { + method: request?.method, + body: request?.body, + upload: request?.upload, + timeoutMs: resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) + }) + } + // Remote-profile session requests would otherwise hit the local primary off // each profile's on-disk state.db — fine for local profiles, but a remote // profile's sessions live on its remote host, so the UI's IDs 404 (or mutations diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 80a4ae236c..39d32b4120 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -1004,6 +1004,12 @@ export interface HermesApiRequest { // (window) backend. Read-only cross-profile data is served by the primary, so // this is only needed for profile-scoped live/settings calls. profile?: string | null + // Route this REST call to a specific REGISTERED gateway connection (v2 + // registry). Data owned by a remote gateway — cron jobs and their run + // sessions — lives in that host's state.db, so requests for it must resolve + // through the owning connection, not the local profile pool. Omit / '' / + // 'local' keep the legacy profile-routed path. + connectionId?: string | null } export interface HermesNotification { diff --git a/apps/desktop/src/hermes-cron-scope.test.ts b/apps/desktop/src/hermes-cron-scope.test.ts index ac94bc6698..c49d5431a2 100644 --- a/apps/desktop/src/hermes-cron-scope.test.ts +++ b/apps/desktop/src/hermes-cron-scope.test.ts @@ -8,6 +8,7 @@ import { getCronJobs, pauseCronJob, resumeCronJob, + setApiRequestConnection, setApiRequestProfile, triggerCronJob, updateCronJob @@ -29,6 +30,7 @@ describe('cron helpers are profile-scoped', () => { afterEach(() => { setApiRequestProfile(null) + setApiRequestConnection(null) delete (window as { hermesDesktop?: unknown }).hermesDesktop }) @@ -57,6 +59,37 @@ describe('cron helpers are profile-scoped', () => { } }) + it('omits connectionId when the local pool serves the active gateway', () => { + void getCronJobRuns('job-1') + expect(api.mock.calls.at(-1)?.[0]).not.toHaveProperty('connectionId') + }) + + // Contract: with a registered gateway connection active, cron run sessions + // live in THAT gateway's state.db — not in any local profile's. Every cron + // helper must tag the owning connection so the main process routes the REST + // call to the same backend the job list (and its runs) actually live on. + // Without it, run history read a local state.db with zero cron rows and + // every job showed "No runs yet" (#87882). + it('forwards the active registry connection to every cron helper', () => { + setApiRequestProfile('research') + setApiRequestConnection('gw-tailscale') + + void getCronJobs('research') + void getCronJob('job-1') + void getCronJobRuns('job-1') + void createCronJob({ name: 'nightly', prompt: 'run', schedule: '0 3 * * *' } as never) + void updateCronJob('job-1', { enabled: false } as never) + void pauseCronJob('job-1') + void resumeCronJob('job-1') + void triggerCronJob('job-1') + void deleteCronJob('job-1') + + for (const call of api.mock.calls) { + expect((call[0] as { connectionId?: string }).connectionId).toBe('gw-tailscale') + expect(call[0].profile).toBe('research') + } + }) + it('list accepts an explicit ?profile= for endpoint-level filtering', () => { // profileScoped() routes the backend process; the list endpoint ALSO // aggregates 'all' by default, so callers pass an explicit profile to diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 92a803eeb9..a80d895d0f 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -284,6 +284,15 @@ export function setApiRequestConnection(connectionId: null | string): void { _apiConnectionId = connectionId || null } +// Registry connection scope for a REST request. A registered remote gateway +// owns its own state.db — cron jobs and their run sessions live THERE — so +// requests for gateway-owned data must carry the connection id for the main +// process to route them to that host (hermes:api's registry branch). Null / +// 'local' resolves to no tag, keeping single-source users byte-identical. +function connectionScoped(): { connectionId?: string } { + return _apiConnectionId ? { connectionId: _apiConnectionId } : {} +} + /** Registry connection id that connection-scoped WS calls should target * (null → the local pool). Read-only twin of setApiRequestConnection. */ export function getApiRequestConnection(): null | string { @@ -1545,6 +1554,7 @@ export function getCronJobs(profile?: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), + ...connectionScoped(), path: `/api/cron/jobs${suffix}`, timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) @@ -1553,6 +1563,7 @@ export function getCronJobs(profile?: string): Promise { export function getCronJob(jobId: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), + ...connectionScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}` }) } @@ -1560,6 +1571,7 @@ export function getCronJob(jobId: string): Promise { export async function getCronJobRuns(jobId: string, limit = 20): Promise { const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({ ...profileScoped(), + ...connectionScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}` }) @@ -1572,6 +1584,7 @@ export async function getCronJobRuns(jobId: string, limit = 20): Promise { const { targets } = await window.hermesDesktop.api<{ targets: CronDeliveryTarget[] }>({ ...profileScoped(), + ...connectionScoped(), path: '/api/cron/delivery-targets' }) @@ -1581,6 +1594,7 @@ export async function getCronDeliveryTargets(): Promise { export function createCronJob(body: CronJobCreatePayload): Promise { return window.hermesDesktop.api({ ...profileScoped(), + ...connectionScoped(), path: '/api/cron/jobs', method: 'POST', body @@ -1590,6 +1604,7 @@ export function createCronJob(body: CronJobCreatePayload): Promise { export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise { return window.hermesDesktop.api({ ...profileScoped(), + ...connectionScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, method: 'PUT', body: { updates } @@ -1599,6 +1614,7 @@ export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise { return window.hermesDesktop.api({ ...profileScoped(), + ...connectionScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`, method: 'POST' }) @@ -1607,6 +1623,7 @@ export function pauseCronJob(jobId: string): Promise { export function resumeCronJob(jobId: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), + ...connectionScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`, method: 'POST' }) @@ -1615,6 +1632,7 @@ export function resumeCronJob(jobId: string): Promise { export function triggerCronJob(jobId: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), + ...connectionScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`, method: 'POST', timeoutMs: CRON_TRIGGER_REQUEST_TIMEOUT_MS @@ -1624,6 +1642,7 @@ export function triggerCronJob(jobId: string): Promise { export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...profileScoped(), + ...connectionScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, method: 'DELETE' }) @@ -1643,6 +1662,7 @@ export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> { export function getAutomationBlueprints(): Promise<{ blueprints: AutomationBlueprint[] }> { return window.hermesDesktop.api<{ blueprints: AutomationBlueprint[] }>({ ...profileScoped(), + ...connectionScoped(), path: '/api/cron/blueprints', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) @@ -1654,6 +1674,7 @@ export function instantiateAutomationBlueprint( ): Promise { return window.hermesDesktop.api({ ...profileScoped(), + ...connectionScoped(), path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`, method: 'POST', body