fix(desktop-plugins): copy root-layout desktop halves; no false desktop success

A mixed package with plugin.yaml + __init__.py and a ROOT plugin.js is
reported by the install probe as carrying both halves, so a browser tab
(and native Desktop on a local backend) installs the agent half alone and
leaves the desktop half to the reconcile pass. Both reconcilers (the host's
desktop_plugin_halves.py and Electron's desktop-plugins-root.ts) only read
desktop/plugin.js, so nothing was copied while the dialog still said
"Desktop plugin <name> installed".

- One entry rule for every reader: desktop_source / desktopHalfSource (root
  plugin.js wins, else desktop/plugin.js), used by both reconcilers, the
  Webapp installer, findDesktopEntry, plugins.manage list's has_desktop_half
  and the plugin doctor's stale-copy check.
- A root-layout half publishes the package root as the installers do,
  without .git and __pycache__, held to the installer's plain-tree rule
  (no links, 64 MB cap); a refused tree is logged and not copied. The
  marker's source is the package root, so the prune follows it.
- The install dialog reports the desktop half only when the reconcile
  copied it or <root>/<name>/plugin.js is already there; an agent install
  that left no desktop half reports "Desktop plugin install failed".
This commit is contained in:
Teknium committed 2026-10-28 17:21:32 -07:00
1 parent c95dfcb3ae
commit ca8e43aea7
12 files changed
+396 -77

No files matched your search

@@ -13,6 +13,7 @@ import path from 'node:path'
import { parse as parseYaml } from 'yaml'
import {
desktopHalfSource,
PACKAGE_MARKER,
publishDesktopTree,
withDesktopPluginsRoot,
@@ -185,20 +186,16 @@ async function pathIsFile(filePath: string): Promise<boolean> {
}
}
/** The desktop entry by the one rule the reconcile applies to an installed package too
* (`desktopHalfSource`): a root `plugin.js` wins over `desktop/plugin.js`. */
export function findDesktopEntry(pluginRoot: string): { entryFile: string; sourceSubdir: string } | null {
const rootPlugin = path.join(pluginRoot, 'plugin.js')
const sourceDir = desktopHalfSource(pluginRoot)
if (pathExistsSync(rootPlugin)) {
return { entryFile: rootPlugin, sourceSubdir: '.' }
if (!sourceDir) {
return null
}
const nestedPlugin = path.join(pluginRoot, 'desktop', 'plugin.js')
if (pathExistsSync(nestedPlugin)) {
return { entryFile: nestedPlugin, sourceSubdir: 'desktop' }
}
return null
return { entryFile: path.join(sourceDir, 'plugin.js'), sourceSubdir: path.relative(pluginRoot, sourceDir) || '.' }
}
/** Files beside a `plugin.yaml` that the agent side loads: a `register(ctx)` package, or a dashboard
@@ -220,6 +220,45 @@ describe('reconcileUnifiedDesktopHalves', () => {
expect(raw.repo).toBe('file:///srv/raw.git')
})
it('copies a root-layout package (plugin.js at the package root) as the installers publish it, minus .git and __pycache__', async () => {
// plugin.yaml + __init__.py + a ROOT plugin.js: the probe reports both halves, so a local-backend
// install puts the agent half down alone and counts on this pass for the desktop one.
const home = makeHome()
const appRoot = path.join(home, 'desktop-plugins')
const pkg = path.join(home, 'plugins', 'radar')
write(path.join(pkg, 'plugin.yaml'), 'name: radar')
write(path.join(pkg, '__init__.py'), 'def register(ctx):\n pass\n')
write(path.join(pkg, 'plugin.js'), 'root entry')
write(path.join(pkg, 'panel.js'), 'asset')
write(path.join(pkg, '__pycache__', 'x.pyc'), 'pyc')
write(path.join(pkg, '.git', 'config'), '[remote "origin"]\n\turl = https://github.com/o/radar\n')
// A root entry beside a link would publish whatever the link names: refused, never copied.
const leaky = path.join(home, 'plugins', 'leaky')
write(path.join(leaky, 'plugin.js'), 'x')
write(path.join(home, 'secret'), 's')
fs.symlinkSync(path.join(home, 'secret'), path.join(leaky, 'secret'))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([path.join(appRoot, 'radar')])
} finally {
warn.mockRestore()
}
expect(fs.readFileSync(path.join(appRoot, 'radar', 'plugin.js'), 'utf8')).toBe('root entry')
expect(fs.readFileSync(path.join(appRoot, 'radar', 'panel.js'), 'utf8')).toBe('asset')
expect(fs.existsSync(path.join(appRoot, 'radar', '.git'))).toBe(false)
expect(fs.existsSync(path.join(appRoot, 'radar', '__pycache__'))).toBe(false)
expect(fs.existsSync(path.join(appRoot, 'leaky'))).toBe(false)
const marker = JSON.parse(fs.readFileSync(path.join(appRoot, 'radar', PACKAGE_MARKER), 'utf8'))
expect(marker).toMatchObject({ package: 'radar', source: pkg, repo: 'https://github.com/o/radar' })
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([])
fs.rmSync(path.join(pkg, 'plugin.js'))
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([path.join(appRoot, 'radar')])
expect(fs.existsSync(path.join(appRoot, 'radar'))).toBe(false)
})
it('never overwrites a standalone plugin the user installed under the same name', async () => {
const home = makeHome()
const appRoot = path.join(home, 'desktop-plugins')
+128 -23
View File
@@ -215,8 +215,104 @@ export async function writeDesktopHalfMarker(dir: string, marker: DesktopHalfMar
await fs.promises.writeFile(path.join(dir, PACKAGE_MARKER), JSON.stringify(marker, null, 2) + '\n')
}
/** Copy one unified package's `desktop/` half into the app root as
* `<appRoot>/<packageName>/`, stamping the marker. Skips when the root copy is
/** Where a package's desktop plugin lives, in the order every installer and
* the install probe read it (the gateway's `desktop_source` and
* `detect_components` agree): the package root when a `plugin.js` sits there,
* else its `desktop/` folder. */
const DESKTOP_SOURCE_DIRS = ['.', 'desktop'] as const
/** The folder that is `packageDir`'s desktop plugin, or `null`. */
export function desktopHalfSource(packageDir: string): null | string {
for (const subdir of DESKTOP_SOURCE_DIRS) {
try {
if (fs.statSync(path.join(packageDir, subdir, 'plugin.js')).isFile()) {
return path.join(packageDir, subdir)
}
} catch {
// Not this layout.
}
}
return null
}
/** `desktopHalfSource` with the entry's stat. A candidate the app may not stat
* ends the search: falling through to the other layout would copy a half the
* installer would not have picked. */
async function desktopHalfEntry(
packageDir: string,
packageName: string
): Promise<null | { sourceDir: string; stat: fs.Stats }> {
for (const subdir of DESKTOP_SOURCE_DIRS) {
const sourceDir = path.join(packageDir, subdir)
try {
const stat = await fs.promises.stat(path.join(sourceDir, 'plugin.js'))
if (stat.isFile()) {
return { sourceDir, stat }
}
} catch (error) {
if (!isMissing(error)) {
console.warn(`[desktop-plugins] cannot read ${packageName}: ${String(error)}`)
return null
}
}
}
return null
}
/** The whole published folder; the loader reads at most a 16 MB `plugin.js` from it. */
const TREE_MAX_BYTES = 64 * 1024 * 1024
/** A root-layout half publishes the package root, never its clone's `.git` or
* the agent half's `__pycache__` (the gateway's `ROOT_LAYOUT_SKIP`). */
const ROOT_LAYOUT_SKIP = new Set(['.git', '__pycache__'])
const rootLayoutFilter = (src: string) => !ROOT_LAYOUT_SKIP.has(path.basename(src))
/** Why `source` cannot be published as a desktop plugin, else `null`: it must
* be a plain tree (a link could publish any folder of this machine) under
* `TREE_MAX_BYTES` (the gateway's `check_desktop_tree`). `source` itself may
* be a linked dev checkout; nothing inside it may be a link. */
async function plainTreeRefusal(source: string): Promise<null | string> {
let total = 0
const walk = async (dir: string): Promise<null | string> => {
for (const entry of await fs.promises.readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory() && ROOT_LAYOUT_SKIP.has(entry.name)) {
continue
}
if (entry.isSymbolicLink()) {
return `The desktop plugin contains a link (${path.relative(source, full)}); links are not installed.`
}
if (entry.isDirectory()) {
const refused = await walk(full)
if (refused) {
return refused
}
} else if ((total += (await fs.promises.lstat(full)).size) > TREE_MAX_BYTES) {
return `The desktop plugin is larger than ${TREE_MAX_BYTES / (1024 * 1024)} MB.`
}
}
return null
}
return walk(source)
}
/** Copy one unified package's desktop half into the app root as
* `<appRoot>/<packageName>/`, stamping the marker: its `desktop/` folder, or
* the package root (minus `ROOT_LAYOUT_SKIP`) when `plugin.js` sits at the root, as the
* installers publish a root-layout repository. Skips when the root copy is
* already current for this source; replaces it when the source is newer.
* Returns the target path when a copy (or an adoption) happened. */
export async function materializeDesktopHalf(
@@ -224,24 +320,15 @@ export async function materializeDesktopHalf(
appRoot: string,
packageName = path.basename(packageDir)
): Promise<null | string> {
const sourceDir = path.join(packageDir, 'desktop')
const found = await desktopHalfEntry(packageDir, packageName)
if (!found) {
return null
}
const { sourceDir, stat } = found
const entry = path.join(sourceDir, 'plugin.js')
let stat: fs.Stats
try {
stat = await fs.promises.stat(entry)
} catch (error) {
if (!isMissing(error)) {
console.warn(`[desktop-plugins] cannot read ${packageName}: ${String(error)}`)
}
return null
}
if (!stat.isFile()) {
return null
}
const rootLayout = sourceDir === path.join(packageDir, '.')
const target = path.join(appRoot, packageName)
const existing = await readMarker(target)
@@ -295,7 +382,24 @@ export async function materializeDesktopHalf(
}
}
await publishDesktopTree(sourceDir, target, staged => writeDesktopHalfMarker(staged, marker))
if (rootLayout) {
// The whole package root is published, agent payload included: hold it to
// the installer's plain-tree rule.
const refused = await plainTreeRefusal(sourceDir)
if (refused) {
console.warn(`[desktop-plugins] not copying the desktop half of ${packageName}: ${refused}`)
return null
}
}
await publishDesktopTree(
sourceDir,
target,
staged => writeDesktopHalfMarker(staged, marker),
rootLayout ? rootLayoutFilter : undefined
)
return target
}
@@ -310,7 +414,8 @@ export async function materializeDesktopHalf(
export async function publishDesktopTree(
sourceDir: string,
target: string,
finalize?: (staged: string) => Promise<void>
finalize?: (staged: string) => Promise<void>,
filter?: (src: string) => boolean
): Promise<void> {
const parent = path.dirname(target)
const name = path.basename(target)
@@ -320,7 +425,7 @@ export async function publishDesktopTree(
const staged = path.join(stagingRoot, name)
try {
await fs.promises.cp(sourceDir, staged, { force: true, recursive: true })
await fs.promises.cp(sourceDir, staged, { filter, force: true, recursive: true })
await finalize?.(staged)
// rename() refuses to replace a non-empty directory, so the old copy goes first.
await fs.promises.rm(target, { force: true, recursive: true })
@@ -388,7 +493,7 @@ async function reconcilePass(hermesHome: string, appRoot: string): Promise<strin
continue
}
if (result || fs.existsSync(path.join(pluginsRoot, name, 'desktop', 'plugin.js'))) {
if (result || desktopHalfSource(path.join(pluginsRoot, name))) {
seen.add(name)
}
@@ -236,8 +236,9 @@ describe('Plugin install in the browser-hosted app with a backend that installs
it('installs a mixed package once and has the host copy its desktop half out of that checkout', async () => {
const reviewed = 'e404b6816b20237a82f6fbe2749b5a576cf75dba'
// The browser bridge's door to the host's desktop-plugins folder (browser-desktop-plugins.ts).
const reconcileDesktopPlugins = vi.fn(async () => [] as string[])
// The browser bridge's door to the host's desktop-plugins folder (browser-desktop-plugins.ts): the
// host's pass copies the package's half into desktop-plugins/pkg.
const reconcileDesktopPlugins = vi.fn(async () => ['/srv/hermes/desktop-plugins/pkg'])
window.hermesDesktop = { reconcileDesktopPlugins } as unknown as Window['hermesDesktop']
try {
@@ -265,6 +266,41 @@ describe('Plugin install in the browser-hosted app with a backend that installs
}
})
it('never reports a desktop half installed when the host copied none out of the package', async () => {
// The probe saw a desktop half, but the host's pass put nothing down for it (an entry it does not
// read, a refused tree): the folder the loader scans has no pkg/, so the dialog must not say it does.
const reconcileDesktopPlugins = vi.fn(async () => [] as string[])
const desktopPluginsRoot = vi.fn(async () => '/srv/hermes/desktop-plugins')
const readDir = vi.fn(async (path: string) => ({
entries: path === '/srv/hermes/desktop-plugins' ? [{ name: 'other', path: `${path}/other`, isDirectory: true }] : []
}))
window.hermesDesktop = { desktopPluginsRoot, readDir, reconcileDesktopPlugins } as unknown as Window['hermesDesktop']
try {
desktopBackend(
{ ok: true, agent: true, agent_name: 'pkg', desktop: true, desktop_name: 'pkg', sha: 'e404b6816b20237a82f6fbe2749b5a576cf75dba', warnings: [] },
{ ok: true, plugin_name: 'pkg', paired_with_agent: true },
{ ok: true, plugin_name: 'pkg', enabled: true }
)
renderModal()
act(() => openPluginInstallRequest({ repo: 'https://github.com/example/pkg' }))
await waitFor(() => expect(installButton()?.disabled).toBe(false))
fireEvent.click(installButton()!)
expect(await screen.findByText('Desktop plugin install failed')).toBeTruthy()
expect(reconcileDesktopPlugins).toHaveBeenCalled()
expect(readDir).toHaveBeenCalledWith('/srv/hermes/desktop-plugins')
expect($notifications.get().some(n => n.message === 'Desktop plugin pkg installed')).toBe(false)
expect($notifications.get().some(n => n.message.startsWith('Agent plugin pkg installed'))).toBe(true)
} finally {
delete (window as { hermesDesktop?: unknown }).hermesDesktop
}
})
it('says the install is unavailable when the backend predates the action', async () => {
requestGateway.mockImplementation(async (method, params) => {
if (method !== 'plugins.manage') {
@@ -173,6 +173,31 @@ async function cloneDesktopHalf(plan: InstallPlan, copy: InstallModalCopy, repor
await discoverRuntimePlugins()
}
/** Whether the app's desktop-plugins root holds `<name>/plugin.js` after a reconcile: a pass that copied
* it names the folder; one that found it already current touches nothing, so the folder is read. A
* reconcile that put nothing down (no entry the host recognises, a refused tree) must not read as
* installed. */
async function desktopHalfLanded(name: string, touched: readonly string[]): Promise<boolean> {
if (touched.some(folder => folder.split(/[\\/]/).pop() === name)) {
return true
}
const desktop = window.hermesDesktop
if (!desktop?.desktopPluginsRoot || !desktop.readDir) {
return false
}
try {
const root = await desktop.desktopPluginsRoot()
const folder = (await desktop.readDir(root)).entries.find(entry => entry.isDirectory && entry.name === name)
return Boolean(folder && (await desktop.readDir(folder.path)).entries.some(entry => entry.name === 'plugin.js'))
} catch {
return false
}
}
async function installDesktopHalf(
plan: InstallPlan,
components: InstallComponents,
@@ -186,9 +211,13 @@ async function installDesktopHalf(
// answers "already exists" without Force, and cloning would land desktop-plugins/<git-name>/ beside
// the package copy (#100412).
const touched = (await window.hermesDesktop?.reconcileDesktopPlugins?.()) ?? []
const name = agentResult?.pluginName ?? plan.probe.agentName ?? plan.request.repo
if (agentResult?.ok || touched.length > 0) {
report.successes.push(deps.copy.desktopSuccess(plan.probe.agentName ?? plan.request.repo))
if (await desktopHalfLanded(name, touched)) {
report.successes.push(deps.copy.desktopSuccess(name))
} else if (agentResult?.ok) {
// The agent half is in, but the host copied no desktop half out of it: never report one installed.
report.errors.push(deps.copy.desktopFailed)
}
if (touched.length > 0) {
+81 -16
View File
@@ -31,6 +31,10 @@ from utils import rmtree_readonly
logger = logging.getLogger(__name__)
PACKAGE_MARKER = ".hermes-package.json"
#: The whole published folder; the loader reads at most a 16 MB ``plugin.js`` from it.
TREE_MAX_BYTES = 64 * 1024 * 1024
#: Folders a root-layout half never publishes (Electron's ``ROOT_LAYOUT_SKIP`` matches).
ROOT_LAYOUT_SKIP = (".git", "__pycache__")
_GIT_ORIGIN = re.compile(r'\[remote "origin"\][^[]*?url\s*=\s*(\S+)')
#: Electron serializes every writer of the root in its main process; the server's threads do here.
#: Public: ``desktop_plugin_install`` takes it too, so a clone-publish never interleaves with a
@@ -106,7 +110,46 @@ def _same_file(a: Path, b: Path) -> bool:
return False
def publish_desktop_tree(source_dir: Path, target: Path, marker: dict | None) -> None:
def desktop_source(package_dir: Path) -> Path | None:
"""The folder that is *package_dir*'s desktop plugin, by the rule every installer and the install
probe share (Electron's ``findDesktopEntry``, ``detect_components``): the package root itself when
a ``plugin.js`` sits there, else its ``desktop/`` folder. ``None`` when neither holds one."""
for source in (package_dir, package_dir / "desktop"):
if (source / "plugin.js").is_file():
return source
return None
def root_layout_ignore(source_dir: Path, package_dir: Path):
"""What a root-layout half leaves behind when its package root is published: the clone's ``.git``,
as ``desktop_plugin_install`` drops it (nothing reads it, and Git's read-only objects make a
folder Windows cannot delete), and the agent half's ``__pycache__``. A ``desktop/`` half is
copied whole."""
return shutil.ignore_patterns(*ROOT_LAYOUT_SKIP) if source_dir == package_dir else None
def check_desktop_tree(source: Path, base: Path, *, linked_root: bool = False,
skip: tuple[str, ...] = ()) -> str | None:
"""Why *source* cannot be published as a desktop plugin, else ``None``. It must be a plain tree:
a link could publish any folder of the host, and the copy is capped at :data:`TREE_MAX_BYTES`.
*linked_root* accepts *source* itself being a link (a dev checkout linked into ``plugins/``);
every entry inside it is still held to the rule. Folders named in *skip* are left out of the
copy (:func:`root_layout_ignore`), so they are not walked."""
if source.is_symlink() and not linked_root:
return f"The desktop plugin contains a link ({source.relative_to(base)}); links are not installed."
total = 0
for folder, dirs, files in os.walk(source):
dirs[:] = [n for n in dirs if n not in skip]
for entry in (Path(folder) / n for n in dirs + files):
if entry.is_symlink():
return f"The desktop plugin contains a link ({entry.relative_to(base)}); links are not installed."
total += sum((Path(folder) / n).stat().st_size for n in files)
if total > TREE_MAX_BYTES:
return f"The desktop plugin is larger than {TREE_MAX_BYTES // (1024 * 1024)} MB."
return None
def publish_desktop_tree(source_dir: Path, target: Path, marker: dict | None, ignore=None) -> None:
"""Copy through a staging sibling and rename the finished tree into place, marker included, so a
failure leaves the old folder or none: never a partial, marker-less one that a later pass would
take for a hand install. A standalone plugin is published with no marker (``None``): one would
@@ -115,7 +158,7 @@ def publish_desktop_tree(source_dir: Path, target: Path, marker: dict | None) ->
staging = Path(tempfile.mkdtemp(prefix=f".{target.name}.staging-", dir=target.parent))
staged = staging / target.name
try:
shutil.copytree(source_dir, staged, symlinks=True)
shutil.copytree(source_dir, staged, symlinks=True, ignore=ignore)
if marker is None:
# Only an installer writes the marker: one shipped in the repository would claim
# another package's agent half (Electron's ``installDesktopPluginFromGit`` drops it too).
@@ -131,20 +174,34 @@ def publish_desktop_tree(source_dir: Path, target: Path, marker: dict | None) ->
shutil.rmtree(staging, ignore_errors=True)
def _desktop_entry(package_dir: Path, name: str) -> tuple[Path, os.stat_result] | None:
"""``(source_dir, plugin.js stat)`` of *package_dir*'s desktop half under :func:`desktop_source`'s
rule (root ``plugin.js`` first, then ``desktop/plugin.js``), or ``None``. A candidate we may not
stat is logged and ends the search: falling through to the other layout would copy a half the
installer would not have picked."""
for source_dir in (package_dir, package_dir / "desktop"):
try:
entry_stat = (source_dir / "plugin.js").stat()
except (FileNotFoundError, NotADirectoryError):
continue
except OSError as exc:
logger.warning("desktop-plugins: cannot read %s: %s", name, exc)
return None
if stat.S_ISREG(entry_stat.st_mode):
return source_dir, entry_stat
return None
def materialize_desktop_half(package_dir: Path, app_root: Path, name: str) -> Path | None:
"""Copy *package_dir*'s ``desktop/`` half to ``<app_root>/<name>/``. Returns the folder when it
was copied or adopted, ``None`` when it is already current, absent, or a user's own plugin."""
source_dir = package_dir / "desktop"
"""Copy *package_dir*'s desktop half to ``<app_root>/<name>/``: its ``desktop/`` folder, or the
package root (minus :data:`ROOT_LAYOUT_SKIP`) when ``plugin.js`` sits at the root, as the
installers publish a root-layout repository. Returns the folder when it was copied or adopted, ``None`` when it is
already current, absent, refused (a link or an oversized tree), or a user's own plugin."""
found = _desktop_entry(package_dir, name)
if found is None:
return None
source_dir, entry_stat = found
entry = source_dir / "plugin.js"
try:
entry_stat = entry.stat()
except (FileNotFoundError, NotADirectoryError):
return None
except OSError as exc:
logger.warning("desktop-plugins: cannot read %s: %s", name, exc)
return None
if not stat.S_ISREG(entry_stat.st_mode):
return None
target = app_root / name
existing = read_package_marker(target)
@@ -177,7 +234,15 @@ def materialize_desktop_half(package_dir: Path, app_root: Path, name: str) -> Pa
):
return None
publish_desktop_tree(source_dir, target, marker)
ignore = root_layout_ignore(source_dir, package_dir)
if ignore is not None:
# The whole package root is published, agent payload included: hold it to the installer's
# plain-tree rule rather than copy a link to anywhere on the host into the served folder.
refused = check_desktop_tree(source_dir, package_dir, linked_root=linked, skip=ROOT_LAYOUT_SKIP)
if refused:
logger.warning("desktop-plugins: not copying the desktop half of %s: %s", name, refused)
return None
publish_desktop_tree(source_dir, target, marker, ignore=ignore)
return target
@@ -212,7 +277,7 @@ def reconcile_desktop_halves(hermes_root: Path, app_root: Path) -> list[str]:
# One unreadable package must not stop every other half from loading.
logger.warning("desktop-plugins: skipping unreadable package %s: %s", name, exc)
continue
if result is not None or (plugins_root / name / "desktop" / "plugin.js").exists():
if result is not None or desktop_source(plugins_root / name) is not None:
seen.add(name)
if result is not None:
touched.append(str(result))
+6 -17
View File
@@ -14,7 +14,6 @@ the way any other installed plugin does, on the next scan of ``desktop-plugins/`
from __future__ import annotations
import os
import re
import tempfile
from pathlib import Path
@@ -26,8 +25,6 @@ from utils import rmtree_readonly
DESKTOP_PLUGINS_DIR = "desktop-plugins"
_COMMIT = re.compile(r"[0-9a-f]{40}")
#: The whole published folder; the loader reads at most a 16 MB ``plugin.js`` from it.
_TREE_MAX_BYTES = 64 * 1024 * 1024
def desktop_plugins_root() -> Path:
@@ -148,20 +145,12 @@ def install_desktop_plugin(identifier: str, *, ref: Optional[str], catalog_name:
def _desktop_tree(plugin_root: Path) -> Path:
"""The folder that is the desktop plugin: the repository root when ``plugin.js`` sits there, else
its ``desktop/`` subfolder (Electron's ``findDesktopEntry``, narrowed to those two). It must be a
plain tree inside the clone: a link could publish any folder of the host, and the copy is capped."""
source = plugin_root if (plugin_root / "plugin.js").is_file() else plugin_root / "desktop"
total = 0
for folder, dirs, files in os.walk(source):
for entry in (Path(folder), *(Path(folder) / n for n in dirs + files)):
if entry.is_symlink():
raise _pc().PluginOperationError(
f"The desktop plugin contains a link ({entry.relative_to(plugin_root)}); links are not installed.")
total += sum((Path(folder) / n).stat().st_size for n in files)
if total > _TREE_MAX_BYTES:
raise _pc().PluginOperationError(
f"The desktop plugin is larger than {_TREE_MAX_BYTES // (1024 * 1024)} MB.")
"""The folder that is the desktop plugin (:func:`halves.desktop_source`, the rule the host's
reconcile applies to an installed package too). It must be a plain tree inside the clone."""
source = halves.desktop_source(plugin_root) or plugin_root / "desktop"
refused = halves.check_desktop_tree(source, plugin_root)
if refused:
raise _pc().PluginOperationError(refused)
return source
+8 -4
View File
@@ -334,15 +334,19 @@ def _check_manifest_v2(report: DoctorReport, manifest: Any) -> None:
def _check_desktop_half_copy(report: DoctorReport, path: Path) -> None:
"""Warn when the Desktop app runs a stale copy of this package's ``desktop/plugin.js``.
"""Warn when the Desktop app runs a stale copy of this package's desktop ``plugin.js``.
Electron copies a unified package's desktop half to ``<root>/desktop-plugins/<name>/`` and loads
THAT copy, so editing the package in place changes nothing on screen until the copy is refreshed.
The copy carries a ``.hermes-package.json`` marker whose ``source`` names the folder it came from.
"""
source = path / "desktop" / "plugin.js"
if not source.is_file():
from hermes_cli.desktop_plugin_halves import desktop_source
source_dir = desktop_source(path)
if source_dir is None:
return
source = source_dir / "plugin.js"
shown = source.relative_to(path).as_posix()
from hermes_constants import get_default_hermes_root
app_root = get_default_hermes_root() / "desktop-plugins"
@@ -360,7 +364,7 @@ def _check_desktop_half_copy(report: DoctorReport, path: Path) -> None:
except (OSError, ValueError, KeyError, TypeError):
continue
report.warning(
f"Desktop runs a stale copy of desktop/plugin.js ({marker_file.parent}); your edits are not "
f"Desktop runs a stale copy of {shown} ({marker_file.parent}); your edits are not "
"loaded. Refresh it with Capabilities → Plugins → Rescan in the Desktop app (or restart it). "
"To develop in place, symlink the checkout into plugins/ instead: the Desktop then re-syncs "
"the copy on every save.")
@@ -99,3 +99,38 @@ def test_a_users_own_plugin_is_never_overwritten_and_the_default_home_wins(webap
(hermes_root / "plugins" / "notes" / "desktop" / "plugin.js").unlink()
webapp.post("/api/desktop-plugins/reconcile")
assert (mine / "plugin.js").read_text(encoding="utf-8") == "mine"
def test_a_root_layout_package_brings_its_desktop_half_as_the_installers_publish_it(webapp, hermes_root, tmp_path):
# plugin.yaml + __init__.py + a ROOT plugin.js: the install probe reports both halves, so the
# tab installs the agent half alone and counts on this pass for the desktop one.
package = hermes_root / "plugins" / "radar"
(package / ".git").mkdir(parents=True)
(package / ".git" / "config").write_text('[remote "origin"]\n\turl = https://github.com/example/radar\n')
(package / "plugin.yaml").write_text("name: radar\n", encoding="utf-8")
(package / "__init__.py").write_text("def register(ctx):\n pass\n", encoding="utf-8")
(package / "plugin.js").write_text("export default { id: 'radar' }", encoding="utf-8")
(package / "panel.js").write_text("export const panel = 1", encoding="utf-8")
(package / "__pycache__").mkdir()
(package / "__pycache__" / "x.pyc").write_bytes(b"pyc")
# A root plugin.js the user could reach through a link would publish anything on the host.
linked = hermes_root / "plugins" / "leaky"
linked.mkdir()
(linked / "plugin.js").write_text("export default {}", encoding="utf-8")
(tmp_path / "secret").write_text("x", encoding="utf-8")
(linked / "secret").symlink_to(tmp_path / "secret")
loaded = _loaded(webapp)
text, marker = loaded["radar"]
assert text == "export default { id: 'radar' }"
assert (marker["package"], marker["source"], marker["repo"]) == ("radar", str(package), "https://github.com/example/radar")
copy = hermes_root / "desktop-plugins" / "radar"
# The repository root is the plugin folder, its assets with it; the clone's .git and the agent half's bytecode are not.
assert (copy / "panel.js").read_text(encoding="utf-8") == "export const panel = 1"
assert not (copy / ".git").exists() and not (copy / "__pycache__").exists()
assert "leaky" not in loaded
(package / "plugin.js").unlink()
webapp.post("/api/desktop-plugins/reconcile")
assert "radar" not in _loaded(webapp)
@@ -75,6 +75,21 @@ def test_plugins_manage_list_reports_desktop_half(tmp_path):
assert by_name["snap"]["has_desktop_half"] is False
assert by_name["snap"]["servers"] == []
def test_plugins_manage_list_reports_a_root_layout_desktop_half(tmp_path):
"""A ROOT plugin.js beside plugin.yaml is a desktop half too (the install probe and the host's
reconcile both read it), so the row pairs with its app-level copy like a ``desktop/`` one."""
rooted = tmp_path / "radar"
rooted.mkdir()
(rooted / "plugin.js").write_text("export default {}")
rows = [("radar", "1.0", "Radar", "user", rooted, "radar")]
with patch("hermes_cli.plugins_cmd._discover_all_plugins", return_value=rows), \
patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()), \
patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()), \
patch("hermes_cli.plugins_cmd_catalog.catalog_rows_maps", return_value=({}, {}, {})):
resp = server.handle_request({"id": "1", "method": "plugins.manage", "params": {"action": "list"}})
assert resp["result"]["plugins"][0]["has_desktop_half"] is True
def test_plugins_manage_list_resolves_the_live_catalog_once_per_listing(tmp_path):
"""The server-sentence display name looks up the curated catalog title, but that lookup must
cost ONE live-catalog resolution per listing, not one per installed plugin:
+3 -3
View File
@@ -1675,15 +1675,15 @@ def _plugin_rows() -> list[dict]:
# enable: _plugin_status reports the truthful default instead of "not enabled" (reads as OFF).
status = pc._plugin_status(name, enabled, disabled, key=key, source=source, dir_path=_dir, active=active)
# key = canonical registry key (names collide across category dirs); portable = Agent Plugins v1.
# ``has_desktop_half``: the package also ships a Desktop UI half (``desktop/plugin.js``). The
# desktop app pairs its app-level copy of that half with this row so one package is ONE row.
# ``has_desktop_half``: the package also ships a Desktop UI half (``desktop_source``: a root or
# ``desktop/`` plugin.js). The app pairs its app-level copy of that half with this ONE row.
_dir_path = Path(str(_dir)) if _dir else None
portable = pc._is_portable_plugin_dir(_dir)
out.append({
"name": name, "key": key, "version": str(version or ""), "description": desc or "",
"source": source, "status": status, "portable": portable,
"install_dir": str(_dir_path) if _dir_path else "",
"has_desktop_half": bool(_dir_path and (_dir_path / "desktop" / "plugin.js").is_file()),
"has_desktop_half": bool(_dir_path and _tools_mod("hermes_cli.desktop_plugin_halves").desktop_source(_dir_path)),
# Manifest ``config_schema`` + current values: the Plugins hub renders these as a form.
"settings_schema": _tools_mod("hermes_cli.plugins_settings").plugin_settings_fields(key, _dir_path),
"servers": _plugin_server_rows(_dir_path, key, portable=portable, catalog_titles=titles),
@@ -2150,6 +2150,11 @@ pipeline as the standalone disk door (hot reload included):
The `desktop/plugin.js` half is an ordinary disk plugin — same contract, same
imports, same `ctx.rest('/…')` reaching the `plugin_api.py` sitting beside it.
A `plugin.js` at the package root works too, as it does for a standalone
install: the root `plugin.js` wins over `desktop/plugin.js`, and the whole
package folder is copied as the desktop half (without `.git` and
`__pycache__`). It must hold no symlinks and stay under 64 MB, or nothing is
copied.
Installing, sharing, or removing the feature is one folder: the app-root copy
is refreshed when the source `plugin.js` changes (`hermes plugins update`, or
**Rescan**) and removed when the package folder disappears. The copy is what