fix(desktop): read cron run-history from the owning gateway

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
This commit is contained in:
Teknium
2026-08-16 16:06:35 -07:00
parent 5f8d488830
commit 4ee87f76ee
6 changed files with 187 additions and 1 deletions
@@ -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', () => {
+34 -1
View File
@@ -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,
+60
View File
@@ -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
+6
View File
@@ -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 {
@@ -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
+21
View File
@@ -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<CronJob[]> {
return window.hermesDesktop.api<CronJob[]>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs${suffix}`,
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
@@ -1553,6 +1563,7 @@ export function getCronJobs(profile?: string): Promise<CronJob[]> {
export function getCronJob(jobId: string): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`
})
}
@@ -1560,6 +1571,7 @@ export function getCronJob(jobId: string): Promise<CronJob> {
export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
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<Session
export async function getCronDeliveryTargets(): Promise<CronDeliveryTarget[]> {
const { targets } = await window.hermesDesktop.api<{ targets: CronDeliveryTarget[] }>({
...profileScoped(),
...connectionScoped(),
path: '/api/cron/delivery-targets'
})
@@ -1581,6 +1594,7 @@ export async function getCronDeliveryTargets(): Promise<CronDeliveryTarget[]> {
export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
...connectionScoped(),
path: '/api/cron/jobs',
method: 'POST',
body
@@ -1590,6 +1604,7 @@ export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
method: 'PUT',
body: { updates }
@@ -1599,6 +1614,7 @@ export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<C
export function pauseCronJob(jobId: string): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`,
method: 'POST'
})
@@ -1607,6 +1623,7 @@ export function pauseCronJob(jobId: string): Promise<CronJob> {
export function resumeCronJob(jobId: string): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`,
method: 'POST'
})
@@ -1615,6 +1632,7 @@ export function resumeCronJob(jobId: string): Promise<CronJob> {
export function triggerCronJob(jobId: string): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...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<CronJob> {
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<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`,
method: 'POST',
body