mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user