Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8656a4e
feat(gateway): add session-scoped Desktop connection mode
jackulau Aug 9, 2026
981bd10
feat(gateway): bind the Desktop-announced connection mode per turn
jackulau Aug 9, 2026
1fbf8e4
feat(tools): expose the Desktop connection mode to skill subprocesses
jackulau Aug 9, 2026
3cfb724
feat(mcp): carry the Desktop connection mode in per-call _meta
jackulau Aug 9, 2026
1743f0e
feat(desktop): expose the resolved connection mode to plugins
jackulau Aug 9, 2026
0f4082d
docs(desktop): document the Desktop connection-mode API
jackulau Aug 9, 2026
ec06a7e
refactor(desktop): tighten the connection-mode announcement seams
jackulau Aug 9, 2026
b3f1675
fix(gateway): refresh the connection mode when reopening a live chat
jackulau Aug 9, 2026
a23e1ee
fix(gateway): carry the Desktop connection mode across compute-host t…
jackulau Aug 13, 2026
f87b849
fix(gateway): inherit the Desktop connection mode in background and p…
jackulau Aug 13, 2026
6d1293f
fix(agent): route SKILL.md inline shell through the central subproces…
jackulau Aug 13, 2026
cce6013
fix(desktop): announce the connection mode on plugin host.request RPCs
jackulau Aug 13, 2026
b9cfc65
fix(desktop): publish gateway, profile, and connection descriptor ato…
jackulau Aug 13, 2026
c597570
fix(desktop): contain plugin connection-mode listener exceptions
jackulau Aug 13, 2026
c19786b
docs(desktop): fix the FastMCP connection-mode example for the pinned…
jackulau Aug 13, 2026
abbf890
fix(desktop): announce the connection mode through host.getGateway() too
jackulau Aug 16, 2026
0b4193c
fix(desktop): publish the agent activation atomically too
jackulau Aug 16, 2026
79a362d
fix(desktop): fail the agent switch closed when its descriptor lookup…
jackulau Aug 16, 2026
869476d
fix(desktop): publish a gateway switch in one nanostores batch
jackulau Aug 17, 2026
5a778ac
fix(desktop): keep the full request signature through host.getGateway()
jackulau Aug 17, 2026
287806c
fix(agent): fail closed when the inline-shell env factory is unavailable
jackulau Aug 17, 2026
7608633
fix(desktop): make connection_mode a renderer-owned field
jackulau Aug 17, 2026
44f3f19
refactor(desktop): wrap the prepareGatewayForAgent signature at the p…
jackulau Aug 17, 2026
9105b70
fix(desktop): guard the profile publication on its activation result too
jackulau Aug 17, 2026
22d27d8
docs(desktop): state one fallthrough contract at the agent seam
jackulau Aug 17, 2026
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 agent/skill_preprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,27 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
raising, so one bad snippet can't wreck the whole skill message.
"""
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
# Same central env factory as every other spawn surface: the snippet gets
# the session-context stamps (HERMES_SESSION_*, the write-only Desktop
# connection-mode stamp) and passes through the inherited-value scrub —
# a value inherited from the user's shell is stripped, never honored.
try:
from tools.environments.local import build_subprocess_env

_run_env = build_subprocess_env()
except Exception:
# FAIL CLOSED. Falling back to ``env=None`` makes subprocess.run
# inherit the RAW parent environment, which is the one door the scrub
# above exists to shut: an ambient HERMES_DESKTOP_CONNECTION_MODE=local
# set in the user's shell would reach the snippet and be read as the
# resolved Desktop mode, re-opening the spoofing path. Not running the
# snippet is the safe outcome and costs nothing the caller cannot
# absorb — it already treats a marker as a non-fatal result.
logger.warning(
"build_subprocess_env unavailable for inline shell; refusing to run the snippet",
exc_info=True,
)
return "[inline-shell error: sanitized environment unavailable]"
try:
completed = subprocess.run(
["bash", "-c", command],
Expand All @@ -78,6 +99,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
timeout=max(1, int(timeout)),
check=False,
stdin=subprocess.DEVNULL,
env=_run_env,
**_popen_kwargs,
)
except subprocess.TimeoutExpired:
Expand Down
14 changes: 11 additions & 3 deletions apps/desktop/src/app/gateway/hooks/use-gateway-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef } from 'react'

import type { HermesGateway } from '@/hermes'
import { announceConnectionMode } from '@/lib/connection-mode'
import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
import { $activeGatewayProfile } from '@/store/profile'
import { $gatewayState, setConnection } from '@/store/session'
Expand Down Expand Up @@ -104,15 +105,22 @@ export function useGatewayRequest() {
}, [])

const requestGateway = useCallback(
async <T>(method: string, params: Record<string, unknown> = {}, timeoutMs?: number, signal?: AbortSignal) => {
async <T>(method: string, rawParams: Record<string, unknown> = {}, timeoutMs?: number, signal?: AbortSignal) => {
const gateway = gatewayRef.current

if (!gateway) {
throw new Error('Hermes gateway unavailable')
}

// Announce the live connection mode on session/prompt RPCs (#82140).
// Resolved per attempt, not per call: $connection is published in the
// same synchronous frame as a profile switch (ensureGatewayProfile) and
// is rewritten by the reconnect below, so re-reading on the retry sends
// the mode of the connection the retry actually lands on.
const announce = () => announceConnectionMode(method, rawParams)

try {
return await gateway.request<T>(method, params, timeoutMs, signal)
return await gateway.request<T>(method, announce(), timeoutMs, signal)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)

Expand All @@ -138,7 +146,7 @@ export function useGatewayRequest() {
throw error
}

return recovered.request<T>(method, params, timeoutMs, signal)
return recovered.request<T>(method, announce(), timeoutMs, signal)
}
},
[ensureGatewayOpen]
Expand Down
119 changes: 118 additions & 1 deletion apps/desktop/src/contrib/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'

import type { HermesConnection } from '@/global'
import { dispatchPluginNativeNotification } from '@/store/native-notifications'
import { $connection, setConnection } from '@/store/session'

import { createPluginContext } from './plugin'

Expand Down Expand Up @@ -60,3 +62,118 @@ describe('createPluginContext.os', () => {
}
})
})

describe('createPluginContext.connection', () => {
afterEach(() => {
setConnection(null)
})

const conn = (mode?: 'local' | 'remote') =>
({ baseUrl: 'http://127.0.0.1:8787', mode, token: 'secret' }) as unknown as HermesConnection

it('reports the live mode without exposing the descriptor', () => {
setConnection(conn('remote'))
const ctx = createPluginContext('demo')

expect(ctx.connection.mode()).toBe('remote')
// The whole door is two functions — there is no descriptor to reach past.
expect(Object.keys(ctx.connection).sort()).toEqual(['mode', 'onModeChange'])
})

it('reports null before a connection resolves', () => {
expect(createPluginContext('demo').connection.mode()).toBeNull()
})

it('fires immediately and on every real transition', () => {
setConnection(conn('local'))
const seen: Array<'local' | 'remote' | null> = []
createPluginContext('demo').connection.onModeChange(mode => seen.push(mode))

setConnection(conn('remote'))
setConnection(null)

expect(seen).toEqual(['local', 'remote', null])
})

it('stays quiet when a descriptor refresh does not move the mode', () => {
setConnection(conn('remote'))
const listener = vi.fn()
createPluginContext('demo').connection.onModeChange(listener)

// A reconnect re-mints the descriptor (new token/wsUrl) on the same mode.
setConnection({ ...conn('remote'), token: 'rotated' } as unknown as HermesConnection)

expect(listener).toHaveBeenCalledTimes(1)
})

it('stops listening when the plugin unloads, even if the disposer is ignored', () => {
setConnection(conn('local'))
const disposers: Array<() => void> = []
const listener = vi.fn()
createPluginContext('demo', dispose => disposers.push(dispose)).connection.onModeChange(listener)

disposers.forEach(dispose => dispose())
setConnection(conn('remote'))

expect(listener).toHaveBeenCalledTimes(1)
expect($connection.get()?.mode).toBe('remote')
})

it('contains a throw from the immediate notification', () => {
// The immediate call runs inside the plugin's register; a throw must not
// escape into the loader.
setConnection(conn('local'))
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)

let unsubscribe = () => {}

try {
expect(() => {
unsubscribe = createPluginContext('demo').connection.onModeChange(() => {
throw new Error('plugin bug')
})
}).not.toThrow()
expect(error).toHaveBeenCalledWith(expect.stringContaining('demo'), expect.any(Error))
} finally {
unsubscribe()
error.mockRestore()
}
})

it('contains a throw on a real transition and keeps other listeners running', () => {
// The subscription fires inside core setConnection (boot, reconnect,
// profile switch); a plugin throw escaping there would abort profile
// synchronization. It must be contained, attributed, and must not starve
// sibling listeners — including the thrower staying subscribed for
// later transitions.
setConnection(conn('local'))
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const unsubscribes: Array<() => void> = []

try {
const seen: Array<'local' | 'remote' | null> = []
unsubscribes.push(
createPluginContext('bad').connection.onModeChange(mode => {
if (mode === 'remote') {
throw new Error('plugin bug')
}
})
)
unsubscribes.push(createPluginContext('good').connection.onModeChange(mode => seen.push(mode)))

expect(() => setConnection(conn('remote'))).not.toThrow()
expect(seen).toEqual(['local', 'remote'])
expect(error).toHaveBeenCalledWith(expect.stringContaining('bad'), expect.any(Error))

// The throwing listener is still subscribed and hears later transitions
// (a non-throwing one this time — nothing new is reported).
error.mockClear()
expect(() => setConnection(conn('local'))).not.toThrow()
expect(seen).toEqual(['local', 'remote', 'local'])
expect(error).not.toHaveBeenCalled()
} finally {
unsubscribes.forEach(unsubscribe => unsubscribe())
error.mockRestore()
}
})
})
69 changes: 69 additions & 0 deletions apps/desktop/src/contrib/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@

import { pluginRest, type PluginRestOptions, pluginSocket } from '@/hermes'
import { createPluginI18n, type PluginI18n } from '@/i18n'
import { type HermesConnectionMode, resolveConnectionMode } from '@/lib/connection-mode'
import { readKey, writeKey } from '@/lib/storage'
import { dispatchPluginNativeNotification, type PluginNativeNotificationInput } from '@/store/native-notifications'
import { $connection } from '@/store/session'

import { registry } from './registry'
import type { Contribution } from './types'

export type { PluginRestOptions } from '@/hermes'
export type { HermesConnectionMode } from '@/lib/connection-mode'
export type { PluginNativeNotificationInput } from '@/store/native-notifications'

/** A contribution as a plugin author writes it — provenance + id scoping are
Expand Down Expand Up @@ -56,6 +59,25 @@ export interface PluginOs {
writeClipboard: (text: string) => Promise<boolean>
}

/** The supported read of the resolved backend connection — the connection's
* *shape*, never its credentials. `mode` is `'local'` when this Desktop drives
* its own local backend (a path the agent reports is already openable here),
* `'remote'` when it drives an SSH/URL/cloud backend (a gateway-side path must
* be transferred first), and `null` when it isn't resolved yet.
*
* Base URL, host, tokens, SSH keys, and auth mode stay behind the Electron
* bridge on purpose; a plugin that needs to move a file should ask the backend
* to do it, not dial the backend itself. See #82140. */
export interface PluginConnection {
/** The live mode, read at call time. */
mode: () => HermesConnectionMode | null
/** Subscribe to mode changes (connection switch, profile switch, reconnect).
* Fires immediately with the current value. Returns an unsubscribe; it is
* also registered with `onDispose`, so a plugin that ignores the return
* value still stops listening when it unloads. */
onModeChange: (listener: (mode: HermesConnectionMode | null) => void) => () => void
}

export interface PluginContext {
/** The resolved plugin source tag, e.g. `'plugin:cost-meter'`. */
readonly source: string
Expand All @@ -81,6 +103,9 @@ export interface PluginContext {
* manager, clipboard — attributed to this plugin, result-shaped (never
* throws for a missing capability). */
os: PluginOs
/** Is the backend's filesystem the machine the user is looking at? The
* supported answer to that question — mode only, no credentials. */
connection: PluginConnection
/** Plugin-scoped persistence. */
storage: PluginStorage
/** Plugin-scoped i18n: ship + register locale bundles under this plugin,
Expand Down Expand Up @@ -156,6 +181,49 @@ function createPluginOs(pluginId: string): PluginOs {
}
}

// Reads the resolved mode off the live connection atom rather than calling the
// Electron bridge: the atom is what stays in lockstep with the ACTIVE profile
// (published atomically with a profile switch), so a plugin sees the same mode the session
// RPCs announce. A raw bridge.getConnection() would describe the primary window
// backend, which is the wrong answer whenever a background profile is active.
function createPluginConnection(pluginId: string, track: (dispose: () => void) => () => void): PluginConnection {
// Isolate every listener call: the subscription below runs inside core
// connection updates (setConnection during boot, reconnect, and profile
// switches), so a plugin throw escaping here would abort profile
// synchronization/reconnect code — one bad plugin destabilizing the
// renderer. Same containment contract as gateway event listeners.
const invoke = (listener: (mode: HermesConnectionMode | null) => void, mode: HermesConnectionMode | null) => {
try {
listener(mode)
} catch (error) {
console.error(`[plugins] ${pluginId}: connection mode listener failed`, error)
}
}

return {
mode: () => resolveConnectionMode($connection.get()),
onModeChange: listener => {
let previous = resolveConnectionMode($connection.get())

invoke(listener, previous)

// $connection changes on every reconnect and descriptor refresh, most of
// which don't move the mode. Only forward real transitions so a plugin
// can put its transfer/cleanup work straight in the listener.
return track(
$connection.subscribe(connection => {
const next = resolveConnectionMode(connection)

if (next !== previous) {
previous = next
invoke(listener, next)
}
})
)
}
}
}

/** Build the scoped context handed to a plugin's `register`. `onDispose`
* receives every registration's disposer (the loader's unload/reload hook). */
export function createPluginContext(pluginId: string, onDispose?: (dispose: () => void) => void): PluginContext {
Expand All @@ -176,6 +244,7 @@ export function createPluginContext(pluginId: string, onDispose?: (dispose: () =
rest: <T>(path: string, opts?: PluginRestOptions) => pluginRest<T>(pluginId, path, opts),
socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)),
os: createPluginOs(pluginId),
connection: createPluginConnection(pluginId, track),
storage: createPluginStorage(pluginId),
i18n: createPluginI18n(pluginId, track)
}
Expand Down
Loading
Loading