Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/desktop/electron/eva-managed.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,26 @@ function publicEvaEnrollmentStatus(state, now = Date.now()) {
}
}

function resolveEvaManagedDesktopProfile(response) {
const current = typeof response?.current === 'string' ? response.current.trim() : ''
if (current === 'default' || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(current)) {
throw new EvaBrokerError('evaOS Agent could not verify its assigned profile.', 502, 'invalid-profile-scope')
}
return current
}

async function resolveEvaManagedDesktopProfileFromSources(readActiveProfile, readEnrollmentStatus) {
try {
return resolveEvaManagedDesktopProfile(await readActiveProfile())
} catch (error) {
if (Number(error?.statusCode) !== 404) {
throw error
}

return resolveEvaManagedDesktopProfile({ current: readEnrollmentStatus()?.agentId })
}
}

module.exports = {
EVA_MANAGED_POLICY,
EvaBrokerError,
Expand All @@ -737,5 +757,7 @@ module.exports = {
parseEvaDesktopAuthCallback,
pollEvaDeviceCode,
publicEvaEnrollmentStatus,
resolveEvaManagedDesktopProfile,
resolveEvaManagedDesktopProfileFromSources,
revokeEvaDesktopSession
}
64 changes: 63 additions & 1 deletion apps/desktop/electron/eva-managed.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ const {
normalizeHermesEnrollment,
parseEvaDesktopAuthCallback,
pollEvaDeviceCode,
publicEvaEnrollmentStatus
publicEvaEnrollmentStatus,
resolveEvaManagedDesktopProfile,
resolveEvaManagedDesktopProfileFromSources
} = require('./eva-managed.cjs')

const FUTURE = '2099-07-19T12:00:00.000Z'
Expand Down Expand Up @@ -695,3 +697,63 @@ test('renderer-facing enrollment status never exposes tokens or backend URLs', (
assert.equal(status.agentId, 'jane')
assert.doesNotMatch(serialized, /desktop-secret|runtime-secret|secret-endpoint/)
})

test('managed desktop profile uses only the backend-authoritative current process identity', () => {
assert.equal(resolveEvaManagedDesktopProfile({ active: 'asuka-eva02', current: 'asuka-eva02' }), 'asuka-eva02')
assert.equal(resolveEvaManagedDesktopProfile({ current: 'worker_alpha' }), 'worker_alpha')
assert.equal(resolveEvaManagedDesktopProfile({ current: 'worker-' }), 'worker-')
assert.equal(resolveEvaManagedDesktopProfile({ current: `a${'_'.repeat(63)}` }), `a${'_'.repeat(63)}`)
for (const response of [
null,
{},
{ current: 'default' },
{ current: '../main' },
{ current: 'ASUKA' },
{ current: `a${'_'.repeat(64)}` },
{ current: true },
{ current: 123 }
]) {
assert.throws(
() => resolveEvaManagedDesktopProfile(response),
error => error instanceof EvaBrokerError && error.code === 'invalid-profile-scope'
)
}
})

test('managed desktop profile falls back to enrolled identity only when the active endpoint is absent', async () => {
const missing = Object.assign(new Error('404: missing'), { statusCode: 404 })
assert.equal(
await resolveEvaManagedDesktopProfileFromSources(
async () => {
throw missing
},
() => ({ agentId: 'asuka-eva02' })
),
'asuka-eva02'
)

for (const error of [
Object.assign(new Error('unauthorized'), { statusCode: 401 }),
Object.assign(new Error('forbidden'), { statusCode: 403 }),
Object.assign(new Error('unavailable'), { statusCode: 503 }),
new Error('transport failed')
]) {
await assert.rejects(
() =>
resolveEvaManagedDesktopProfileFromSources(
async () => Promise.reject(error),
() => ({ agentId: 'asuka-eva02' })
),
candidate => candidate === error
)
}

await assert.rejects(
() => resolveEvaManagedDesktopProfileFromSources(async () => ({ current: true }), () => ({ agentId: 'asuka-eva02' })),
error => error instanceof EvaBrokerError && error.code === 'invalid-profile-scope'
)
await assert.rejects(
() => resolveEvaManagedDesktopProfileFromSources(async () => Promise.reject(missing), () => ({ agentId: 'default' })),
error => error instanceof EvaBrokerError && error.code === 'invalid-profile-scope'
)
})
20 changes: 16 additions & 4 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,14 @@
import { describeDevCdpDecision, resolveDevCdpPort } from './dev-cdp'
import { installEmbedReferer } from './embed-referer'
const { createEvaAppUpdater, safeApplyFailure, safeCheckFailure } = require('./eva-app-updater.cjs')
const {

Check warning on line 94 in apps/desktop/electron/main.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
assertEvaManagedLocalMutationAllowed,
assertEvaManagedLocalTerminalAllowed,
buildEvaAccountRendererResetScript,
EVA_MANAGED_POLICY
EVA_MANAGED_POLICY,
resolveEvaManagedDesktopProfileFromSources
} = require('./eva-managed.cjs')
const { createEvaMediaGrantCodec } = require('./eva-media-grant.cjs')

Check warning on line 101 in apps/desktop/electron/main.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
const { createEvaManagedRuntime } = require('./eva-runtime.cjs')
import { createEventDeduper } from './event-dedupe'
import { findGitBash as _findGitBash } from './find-git-bash'
Expand Down Expand Up @@ -639,10 +640,10 @@
// desktop.log lives under HERMES_HOME/logs/ so it sits next to agent.log,
// errors.log, gateway.log produced by hermes_logging.setup_logging — one log
// directory per user, regardless of which UI surface produced the line.
const DESKTOP_LOG_PATH = EVA_MANAGED_BUILD

Check warning on line 643 in apps/desktop/electron/main.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
? path.join(app.getPath('userData'), 'logs', 'evaos-agent-desktop.log')
: path.join(HERMES_HOME, 'logs', 'desktop.log')
const DESKTOP_LOG_FLUSH_MS = 120

Check warning on line 646 in apps/desktop/electron/main.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024
// Bound desktop.log on disk. It is an append-only forensic log, so a boot loop
// (version-skew crash -> backend exits instantly -> renderer keeps hitting
Expand Down Expand Up @@ -1045,10 +1046,10 @@
}

const grant = evaMediaGrants.verify(mediaUrl.pathname.replace(/^\/+/, ''))
if (!grant) {

Check warning on line 1049 in apps/desktop/electron/main.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
return new Response('Managed media grant expired or invalid', { status: 403 })
}
if (!STREAMABLE_MEDIA_EXTS.has(path.extname(grant.path).toLowerCase())) {

Check warning on line 1052 in apps/desktop/electron/main.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
return new Response('Unsupported media type', { status: 415 })
}

Expand Down Expand Up @@ -4309,7 +4310,7 @@
function httpStatusError(statusCode, detail) {
const error = new Error(`${statusCode}: ${detail}`) as Error & { statusCode: number }
error.statusCode = statusCode
return error

Check warning on line 4313 in apps/desktop/electron/main.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
}

function fetchJson(url, token, options: any = {}) {
Expand Down Expand Up @@ -6881,16 +6882,16 @@
window.close()
}
}
if (!mainWindow || mainWindow.isDestroyed()) {

Check warning on line 6885 in apps/desktop/electron/main.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
return
}
const rendererSession = mainWindow.webContents.session

Check warning on line 6888 in apps/desktop/electron/main.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
await mainWindow.webContents.executeJavaScript(buildEvaAccountRendererResetScript(), true).catch(() => undefined)
await Promise.allSettled([
rendererSession.clearStorageData({ storages: ['cachestorage', 'indexdb', 'serviceworkers'] }),
rendererSession.clearCache()
])
if (!mainWindow.isDestroyed()) {

Check warning on line 6894 in apps/desktop/electron/main.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
mainWindow.reload()
}
}
Expand Down Expand Up @@ -10225,16 +10226,27 @@
ipcMain.handle('hermes:eva:sign-out', async () => evaManagedRuntime.signOut())
ipcMain.handle('hermes:eva:refresh', async () => evaManagedRuntime.refresh())

ipcMain.handle('hermes:profile:get', async () => ({
profile: EVA_MANAGED_BUILD ? 'default' : readActiveDesktopProfile()
}))
ipcMain.handle('hermes:profile:get', async () => {
if (!EVA_MANAGED_BUILD) {
Comment thread
100yenadmin marked this conversation as resolved.
return { profile: readActiveDesktopProfile() }
}

const profile = await resolveEvaManagedDesktopProfileFromSources(
() => evaManagedRuntime.requestApi({ path: '/api/profiles/active', method: 'GET' }),
() => evaManagedRuntime.status()
)

return { profile }
})
ipcMain.handle('hermes:profile:set', async (_event, name) => {
if (EVA_MANAGED_BUILD) {
if (!name || name === 'default') {
return { profile: 'default' }
}

throw new Error('evaOS Agent uses the agent assigned by Electric Sheep; Desktop profiles cannot change it.')
}

const next = writeActiveDesktopProfile(name)

// Switching profiles is a backend re-home: relaunch the dashboard under the
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/release-notes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# evaOS Agent 2026.7.20-es.12

- Restores the assigned managed profile after every app restart so sessions load and new chats stay on the authorized agent.
- Loads managed MCP configuration before server discovery, avoiding a manual reload on fresh sessions.
- Restores `/reload-mcp` in the desktop command palette with the existing confirmation-preserving backend action.
- Adds short-lived, profile-authoritative authentication for Pipedream's native MCP without placing developer or provider credentials on customer VMs.
- Uses root-configured customer, Hermes agent, and app identity for that token refresh and no longer reads a per-app provider-grant file.
- Runs tools annotated exactly `readOnlyHint: true` directly and routes every write-capable or unannotated MCP call through Hermes' existing approval mode before any connection or RPC.
Expand Down
194 changes: 191 additions & 3 deletions apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { MemoryRouter, useLocation, useNavigate } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { $desktopBoot, applyDesktopBootProgress } from '@/store/boot'
import { $activeGatewayProfile } from '@/store/profile'
import { $gatewayState, $sessionsLoading } from '@/store/session'

import { takeGatewaySurvivor } from './gateway-hmr-survivor'
Expand Down Expand Up @@ -79,6 +80,10 @@ class FakeWebSocket {
this.emit('close', {})
}

message(frame: unknown) {
this.emit('message', { data: JSON.stringify(frame) })
}

private emit(type: string, ev: unknown) {
for (const fn of this.listeners[type] ?? []) {
fn(ev)
Expand Down Expand Up @@ -125,13 +130,20 @@ function fakeDesktop() {

function Harness({
beforeConnectionSwitch = () => undefined,
handleGatewayEvent = () => undefined,
onGatewayReady = () => undefined,
refreshSessions
}: { beforeConnectionSwitch?: () => void; refreshSessions?: () => Promise<void> } = {}) {
}: {
beforeConnectionSwitch?: () => void
handleGatewayEvent?: (event: { profile?: string }) => void
onGatewayReady?: (gateway: unknown) => void
refreshSessions?: () => Promise<void>
} = {}) {
useGatewayBoot({
beforeConnectionSwitch,
handleGatewayEvent: () => undefined,
handleGatewayEvent,
onConnectionReady: () => undefined,
onGatewayReady: () => undefined,
onGatewayReady,
refreshHermesConfig: async () => undefined,
refreshSessions: refreshSessions ?? (async () => undefined)
})
Expand Down Expand Up @@ -170,6 +182,7 @@ beforeEach(() => {
navigateRoute = null
;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket
;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop()
$activeGatewayProfile.set('default')
$gatewayState.set('idle')
$desktopBoot.set({
error: null,
Expand Down Expand Up @@ -220,6 +233,130 @@ async function advanceBackoff() {
}

describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => {
it('adopts the backend-authoritative managed profile before session refresh', async () => {
const desktop = fakeDesktop()
desktop.profile.get = vi.fn(async () => ({ profile: 'asuka-eva02' }))
const refreshSessions = vi.fn(async () => undefined)

;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { ...desktop, eva: {} }

render(
<MemoryRouter>
<Harness refreshSessions={refreshSessions} />
</MemoryRouter>
)
await flushAsync()

expect($activeGatewayProfile.get()).toBe('asuka-eva02')
expect(refreshSessions).toHaveBeenCalled()
expect($desktopBoot.get().error).toBeNull()
})

it('tags primary gateway events with the profile adopted during managed boot', async () => {
const desktop = fakeDesktop()
desktop.profile.get = vi.fn(async () => ({ profile: 'asuka-eva02' }))
const events: Array<{ profile?: string }> = []

;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { ...desktop, eva: {} }

render(
<MemoryRouter>
<Harness handleGatewayEvent={event => events.push(event)} />
</MemoryRouter>
)
await flushAsync()

FakeWebSocket.instances[0]?.message({
jsonrpc: '2.0',
method: 'event',
params: { type: 'session.updated', session_id: 's-1' }
})

expect(events).toContainEqual(expect.objectContaining({ profile: 'asuka-eva02' }))
})

it('does not connect the managed gateway before the authoritative profile resolves', async () => {
const desktop = fakeDesktop()
let resolveProfile: ((value: { profile: string }) => void) | undefined
const profile = new Promise<{ profile: string }>(resolve => {
resolveProfile = resolve
})
desktop.profile.get = vi.fn(() => profile)
const events: Array<{ profile?: string }> = []

;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { ...desktop, eva: {} }

render(
<MemoryRouter>
<Harness handleGatewayEvent={event => events.push(event)} />
</MemoryRouter>
)
await flushAsync()

expect(FakeWebSocket.instances).toHaveLength(0)
expect(events).toEqual([])

resolveProfile?.({ profile: 'asuka-eva02' })
await flushAsync()

FakeWebSocket.instances[0]?.message({
jsonrpc: '2.0',
method: 'event',
params: { type: 'session.updated', session_id: 's-1' }
})

expect(events).toContainEqual(expect.objectContaining({ profile: 'asuka-eva02' }))
})

it('fails closed when a managed boot cannot verify its assigned profile', async () => {
const desktop = fakeDesktop()
desktop.profile.get = vi.fn(async () => {
throw new Error('assigned profile unavailable')
})
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { ...desktop, eva: {} }
$activeGatewayProfile.set('previous-profile')
const gateways: unknown[] = []

render(
<MemoryRouter>
<Harness onGatewayReady={gateway => gateways.push(gateway)} />
</MemoryRouter>
)
await flushAsync()

expect($activeGatewayProfile.get()).toBe('default')
expect($desktopBoot.get().error).toBe('assigned profile unavailable')
expect($desktopBoot.get().visible).toBe(true)
expect(FakeWebSocket.instances).toHaveLength(0)
expect(gateways.at(-1)).toBeNull()
})

it('keeps non-managed event scope aligned when profile lookup falls back to default', async () => {
const desktop = fakeDesktop()
desktop.profile.get = vi.fn(async () => {
throw new Error('profile preference unavailable')
})
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = desktop
$activeGatewayProfile.set('previous-profile')
const events: Array<{ profile?: string }> = []

render(
<MemoryRouter>
<Harness handleGatewayEvent={event => events.push(event)} />
</MemoryRouter>
)
await flushAsync()

FakeWebSocket.instances[0]?.message({
jsonrpc: '2.0',
method: 'event',
params: { type: 'session.updated', session_id: 's-default' }
})

expect($activeGatewayProfile.get()).toBe('default')
expect(events).toContainEqual(expect.objectContaining({ profile: 'default' }))
})

it('redirects managed sign-in-required boot to Gateway settings without a generic boot failure', async () => {
const desktop = fakeDesktop()
desktop.getConnection = vi.fn(async () => {
Expand Down Expand Up @@ -356,6 +493,57 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
expect($gatewayState.get()).toBe('open')
})

it('does not reconnect an applied managed gateway before its authoritative profile resolves', async () => {
const desktop = fakeDesktop()
const events: Array<{ profile?: string }> = []
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { ...desktop, eva: {} }

render(
<MemoryRouter>
<Harness handleGatewayEvent={event => events.push(event)} />
</MemoryRouter>
)
await flushAsync()
expect(FakeWebSocket.instances).toHaveLength(1)

let resolveProfile: ((value: { profile: string }) => void) | undefined
const profile = new Promise<{ profile: string }>(resolve => {
resolveProfile = resolve
})
desktop.profile.get = vi.fn(() => profile)

act(() => connectionApplied?.())
await flushAsync()

expect(FakeWebSocket.instances).toHaveLength(1)
expect(events).toEqual([])

await act(async () => {
resolveProfile?.({ profile: 'asuka-eva02' })
await Promise.resolve()
await Promise.resolve()
})

expect(FakeWebSocket.instances).toHaveLength(2)
expect(FakeWebSocket.instances[1]?.readyState).toBe(0)
FakeWebSocket.instances[0]?.message({
jsonrpc: '2.0',
method: 'event',
params: { type: 'session.updated', session_id: 's-old' }
})
expect(events).toEqual([])

await flushAsync()

expect(FakeWebSocket.instances).toHaveLength(2)
FakeWebSocket.instances[1]?.message({
jsonrpc: '2.0',
method: 'event',
params: { type: 'session.updated', session_id: 's-2' }
})
expect(events).toContainEqual(expect.objectContaining({ profile: 'asuka-eva02' }))
})

it('a remote that drops post-boot keeps looping with NO boot.error (the dead-end CONNECTING combo)', async () => {
renderHarness()
await flushAsync()
Expand Down
Loading
Loading