Fixed fatal startup crash caused by an unloadable plugin

This commit is contained in:
Tony West committed 2026-08-28 11:12:17 -04:00
1 parent 115c330be3
commit 74f9ee92ad
9 files changed
+286 -13

No files matched your search

+9
View File
@@ -8,6 +8,7 @@ import (
"github.com/BishopFox/joro/internal/browser"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/plugins"
"github.com/BishopFox/joro/internal/update"
)
@@ -135,6 +136,14 @@ func (s *APIServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
return
}
// The new binary will not load plugins the old one built. Say so while the
// banner is still on screen; after the restart it is a row in Settings.
if s.pluginManager != nil {
if notice := plugins.RebuildNotice(s.pluginManager.PluginDir()); notice != "" {
progress(notice)
}
}
s.hub.Broadcast() <- event.WSEvent{
Type: "system.update.restarting",
Data: map[string]string{},
+8
View File
@@ -64,6 +64,14 @@ type Config struct {
// engagement where nothing may leave the machine — a webhook is the one feature whose
// bytes go somewhere Joro's scope does not describe.
NoWebhooks bool
// NoPlugins skips loading plugins from ~/.joro/plugins/ — the files are still
// listed, so the UI can delete one, but no plugin code runs.
//
// This is a recovery switch, not only a posture one. A Go plugin is dlopen'd
// before the API server starts, and the runtime can throw on a file it will not
// accept, so a plugin the host cannot load is a binary that will not boot. Every
// other route out of that state needs a shell in ~/.joro/plugins/.
NoPlugins bool
}
// Default returns a Config populated with sensible defaults.
+159 -1
View File
@@ -2,18 +2,27 @@ package plugins
import (
"crypto/sha256"
"debug/buildinfo"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"plugin"
"regexp"
"runtime"
"runtime/debug"
"strings"
"github.com/BishopFox/joro/sdk"
)
// sdkModule is the SDK import path, whose version must agree between host and
// plugin. Named here rather than inlined because verifyPluginObject looks it up
// on both sides.
const sdkModule = "github.com/BishopFox/joro/sdk"
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
// reservedNames may not be used as plugin names because they conflict
@@ -76,7 +85,11 @@ func loadPlugins(dir string) ([]loadedPlugin, []loadFailure, []error) {
}
lp.filename = name
manifest := lp.ext.Manifest()
manifest, err := safeManifest(lp.ext)
if err != nil {
failed = append(failed, loadFailure{filename: name, err: err})
continue
}
if err := validateManifest(manifest); err != nil {
failed = append(failed, loadFailure{filename: name, err: err})
continue
@@ -96,12 +109,109 @@ func loadPlugins(dir string) ([]loadedPlugin, []loadFailure, []error) {
return loaded, failed, nil
}
// verifyPluginObject reports why path is not a plugin this binary can load,
// and must be called before plugin.Open.
//
// plugin.Open does not merely return an error on a file it cannot use: for some
// files Go's runtime calls throw(), which no recover() can catch and which takes
// the whole process down. plugin_lastmoduleinit walks the module chain
//
// for pmd := firstmoduledata.next; pmd != nil; pmd = pmd.next {
// if pmd.bad { md = nil; continue }
// md = pmd
// }
// if md == nil { throw("runtime: no plugin module data") }
//
// so any dlopen that appends no usable Go module is fatal — a file that is not a
// Go object, one built with a buildmode other than plugin, a truncated download,
// or a chain whose tail an earlier rejected load already marked bad. The plugin
// dir is operator-writable and survives an in-app update that replaces the
// binary underneath it, so a file the host can no longer load is the expected
// steady state, not an exotic one. Joro loads plugins before the API server
// starts, so letting one through means a binary that cannot boot at all and an
// operator with no way to reach the UI and delete it.
//
// Everything here is therefore a gate rather than a warning, and rejecting a
// plugin that would in fact have loaded costs a rebuild, where accepting one
// that would not costs the process. Dropping any of these checks re-opens an
// unrecoverable crash.
func verifyPluginObject(path string) error {
bi, err := buildinfo.ReadFile(path)
if err != nil {
// ReadFile's own wrapper repeats the full path, which the caller already
// reports as a filename and which does not fit a one-line banner.
if inner := errors.Unwrap(err); inner != nil {
err = inner
}
return fmt.Errorf("not a Go plugin (no Go build information): %w", err)
}
var buildmode string
for _, s := range bi.Settings {
if s.Key == "-buildmode" {
buildmode = s.Value
break
}
}
// cmd/go records -buildmode unconditionally, so a real plugin always carries
// it and an empty value means the file was not stamped by `go build` at all.
if buildmode != "plugin" {
if buildmode == "" {
return fmt.Errorf("not built as a Go plugin (no -buildmode recorded); rebuild with `joro --build-plugin <dir> --install`")
}
return fmt.Errorf("built with -buildmode=%s, want plugin; rebuild with `joro --build-plugin <dir> --install`", buildmode)
}
// Go's own check is a per-package content hash, which an exact version match
// implies. Comparing version strings instead reports the mismatch in the terms
// the operator can act on, and does so before dlopen rather than after.
if bi.GoVersion != runtime.Version() {
return fmt.Errorf("built with %s, this joro binary is %s; rebuild with `joro --build-plugin <dir> --install`", bi.GoVersion, runtime.Version())
}
if hostSDK, ok := hostSDKVersion(); ok {
if pluginSDK, ok := moduleVersion(bi.Deps, sdkModule); ok && pluginSDK != hostSDK {
return fmt.Errorf("built against %s %s, this joro binary embeds %s; rebuild with `joro --build-plugin <dir> --install`", sdkModule, pluginSDK, hostSDK)
}
}
return nil
}
// hostSDKVersion returns the SDK version this binary was built against.
func hostSDKVersion() (string, bool) {
info, ok := debug.ReadBuildInfo()
if !ok {
return "", false
}
return moduleVersion(info.Deps, sdkModule)
}
// moduleVersion finds a module's effective version in a dependency list,
// following a replace directive to the module that actually got built.
func moduleVersion(deps []*debug.Module, path string) (string, bool) {
for _, d := range deps {
if d == nil || d.Path != path {
continue
}
if d.Replace != nil {
return d.Replace.Version, true
}
return d.Version, true
}
return "", false
}
func loadOne(path string) (loadedPlugin, error) {
hash, err := fileHash(path)
if err != nil {
return loadedPlugin{}, fmt.Errorf("hash: %w", err)
}
if err := verifyPluginObject(path); err != nil {
return loadedPlugin{}, err
}
p, err := plugin.Open(path)
if err != nil {
// "plugin: not implemented" surfaces when the host binary was built
@@ -131,6 +241,54 @@ func isPluginFile(name string) bool {
return strings.HasSuffix(name, ".so") || strings.HasSuffix(name, ".dylib")
}
// safeManifest calls a plugin's Manifest with panic recovery. It is the first
// call into plugin code on the load path, and it runs before there is a name to
// report the plugin by, so an unguarded panic here takes down startup with a
// stack trace naming no file.
func safeManifest(ext sdk.Plugin) (m sdk.Manifest, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic in Manifest: %v", r)
}
}()
return ext.Manifest(), nil
}
// installedPlugins returns the plugin filenames in dir, ignoring everything
// else. A missing dir is not an error: it means no plugins are installed.
func installedPlugins(dir string) []string {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []string
for _, entry := range entries {
if !entry.IsDir() && isPluginFile(entry.Name()) {
out = append(out, entry.Name())
}
}
return out
}
// RebuildNotice returns a one-line warning for an operator who has just updated
// Joro, or "" when no plugins are installed.
//
// A plugin is only loadable by the exact toolchain that built the host, so
// replacing the binary invalidates every installed .so — silently, since the
// plugin dir is untouched by an update. Sharing isPluginFile with the loader is
// what keeps the notice and the thing it warns about counting the same files.
func RebuildNotice(dir string) string {
n := len(installedPlugins(dir))
if n == 0 {
return ""
}
noun := "plugin"
if n > 1 {
noun = "plugins"
}
return fmt.Sprintf("Note: %d installed %s must be rebuilt against this version before it will load again: `joro --build-plugin <dir> --install`", n, noun)
}
func validateManifest(m sdk.Manifest) error {
if !nameRe.MatchString(m.Name) {
return fmt.Errorf("invalid name %q: must match %s", m.Name, nameRe.String())
+20 -1
View File
@@ -18,7 +18,7 @@ type PluginInfo struct {
Version string `json:"version"`
Desc string `json:"description"`
Type sdk.PluginType `json:"type"`
Status string `json:"status"` // "loaded", "error"
Status string `json:"status"` // "loaded", "error", "removed", "disabled"
Error string `json:"error,omitempty"`
Hash string `json:"hash"` // SHA-256 of .so file
Filename string `json:"filename"` // original .so/.dylib filename
@@ -191,6 +191,25 @@ func (m *Manager) Start(ctx context.Context) error {
return nil
}
// StartDisabled is Start with the loading left out, for --no-plugins.
//
// It still lists what is installed, as rows no plugin code was run to produce.
// Booting with plugins off is how an operator recovers from a plugin that
// prevents booting at all, so the list is the point: an empty one would hide the
// very file the flag exists to delete, and the delete endpoint works from a row.
func (m *Manager) StartDisabled() error {
for _, name := range installedPlugins(m.pluginDir) {
m.allPlugins = append(m.allPlugins, PluginInfo{
Status: "disabled",
Filename: name,
})
}
if len(m.allPlugins) > 0 {
log.Printf("[plugins] --no-plugins: %d installed plugin(s) not loaded", len(m.allPlugins))
}
return nil
}
// Shutdown calls Shutdown() on each loaded plugin with panic recovery.
func (m *Manager) Shutdown() {
m.mu.RLock()
+23 -8
View File
@@ -32,7 +32,7 @@ import (
"github.com/BishopFox/joro/internal/xsshunter"
)
var version = "v1.15.0"
var version = "v1.15.1"
var commit = "dev" // injected via -ldflags at build time
func main() {
@@ -66,6 +66,7 @@ func main() {
flag.BoolVar(&cfg.AutomationScripting, "automation-scripting", false, "Expose script.run, which executes submitted JavaScript in a sandboxed worker process against Joro's automation SDK (off by default; still requires an explicit grant)")
flag.BoolVar(&cfg.AutomationCommands, "automation-commands", false, "Allow installed command automations to run local operating-system commands, on a trigger or as a lens (off by default; operator-installed only, never reachable from an automation token)")
flag.BoolVar(&cfg.NoWebhooks, "no-webhooks", false, "Disable outbound webhooks entirely (no routes, no webhooks.json, no outbound requests)")
flag.BoolVar(&cfg.NoPlugins, "no-plugins", false, "Do not load plugins from ~/.joro/plugins/. They are still listed in Settings so one can be deleted — use this to start after a plugin the binary cannot load")
// scriptWorker is how a script sandbox re-execs this binary. Not an operator
// switch: it reads a job from stdin and speaks a private protocol on stdout.
@@ -353,6 +354,9 @@ func runProxyMode(ctx context.Context, cfg config.Config) {
if err := update.RunUpdate(func(msg string) { fmt.Println(msg) }); err != nil {
log.Printf("Update failed: %v", err)
} else {
if notice := plugins.RebuildNotice(filepath.Join(cfg.DataDir, "plugins")); notice != "" {
fmt.Println(notice)
}
fmt.Println("Restarting...")
if err := update.Restart(); err != nil {
log.Fatalf("Restart failed: %v", err)
@@ -399,10 +403,18 @@ func runProxyMode(ctx context.Context, cfg config.Config) {
hub := api.NewHub()
go hub.Run()
// Load plugins from ~/.joro/plugins/.
// Load plugins from ~/.joro/plugins/. Under --no-plugins they are listed but
// never opened, which is the only way back from a plugin that stops the binary
// booting at all.
pluginMgr := plugins.NewManager(filepath.Join(cfg.DataDir, "plugins"), hub.Broadcast())
if err := pluginMgr.Start(ctx); err != nil {
log.Printf("plugin manager: %v", err)
var pluginErr error
if cfg.NoPlugins {
pluginErr = pluginMgr.StartDisabled()
} else {
pluginErr = pluginMgr.Start(ctx)
}
if pluginErr != nil {
log.Printf("plugin manager: %v", pluginErr)
}
defer pluginMgr.Shutdown()
@@ -551,13 +563,16 @@ func runBuildPlugin(srcDir, output string, install bool, dataDir string) int {
}
// Go's plugin loader rejects .so files built with a different Go toolchain
// version than the host binary. Warn loudly when `go build` in srcDir
// would use a different toolchain than this binary was compiled with.
// version than the host binary, so building one is producing an artifact that
// cannot load — and an unloadable plugin left in ~/.joro/plugins/ is what
// stops the host booting. Refuse rather than warn: the file not existing is
// strictly better than the file existing and being installed.
// Run `go version` from srcDir so the plugin's go.mod (and any
// auto-toolchain directive) influences which version is reported.
if v, ok := localGoVersion(srcDir); ok && v != runtime.Version() {
fmt.Fprintf(os.Stderr, " Warning: local Go toolchain (%s) differs from this binary's Go version (%s).\n", v, runtime.Version())
fmt.Fprintf(os.Stderr, " The built plugin may fail to load. Match versions or rebuild joro from source.\n")
fmt.Fprintf(os.Stderr, "error: local Go toolchain (%s) differs from this binary's Go version (%s).\n", v, runtime.Version())
fmt.Fprintf(os.Stderr, " The built plugin would not load. Match versions or rebuild joro from source.\n")
return 1
}
// Run go build -buildmode=plugin, forwarding the host's ABI-relevant
+7 -1
View File
@@ -2,11 +2,12 @@ import { useCallback, useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router'
import Toasts from './components/Toasts'
import UpdateBanner from './components/UpdateBanner'
import PluginBanner from './components/PluginBanner'
import ErrorBoundary from './components/ErrorBoundary'
import { Navigate, NavLink, Route, Routes } from 'react-router'
import ContextMenu from './components/ContextMenu'
import { getSelectionMenuItems } from './lib/selectionMenu'
import { api } from './lib/api'
import { api, type PluginInfo } from './lib/api'
import { connectWS } from './lib/ws'
import { Settings, isTeamMode, useSettingsStore } from './stores/settingsStore'
import { useTeamConnectionStore, type RelayState } from './stores/teamConnectionStore'
@@ -67,6 +68,7 @@ export default function App() {
const [needsAuth, setNeedsAuth] = useState(false)
const [pluginTabs, setPluginTabs] = useState<Array<{ to: string; label: string }>>([])
const [dashboardPlugin, setDashboardPlugin] = useState<string | null>(null)
const [failedPlugins, setFailedPlugins] = useState<PluginInfo[]>([])
const hiddenTabs = useHiddenTabsStore((s) => s.hiddenTabs)
const stagedCount = useDeadDropStore((s) => s.staged.length)
const activeProject = useProjectStore((s) => s.active)
@@ -127,6 +129,9 @@ export default function App() {
)
const dash = plugs.find((e) => e.type === 'dashboard' && e.status === 'loaded')
if (dash) setDashboardPlugin(dash.name)
// A plugin that would not load has no tab to show, so the banner is the
// only place an operator who never opens Settings would hear about it.
setFailedPlugins(plugs.filter((e) => e.status === 'error'))
}).catch(() => {})
}, [checkTeamMode])
@@ -209,6 +214,7 @@ export default function App() {
<div className="flex flex-col h-screen">
<Toasts />
<UpdateBanner />
<PluginBanner failed={failedPlugins} />
{/* Top nav */}
<header className="flex items-center gap-0.5 px-2 lg:px-3 h-10 bg-surface-card border-b border-border shrink-0 overflow-x-auto">
<span className="text-accent text-sm font-bold uppercase tracking-wider mr-3 lg:mr-6 shrink-0">Joro</span>
+53
View File
@@ -0,0 +1,53 @@
import { useState } from 'react'
import { Link } from 'react-router'
import { AlertTriangle } from 'lucide-react'
import type { PluginInfo } from '../lib/api'
// PluginBanner reports plugins that did not load. The plugin list is owned by
// App (which needs it for the tab nav anyway), so this takes the failed rows as
// a prop rather than fetching a third copy of it.
//
// One banner, never one per plugin: a host binary that has moved past the
// toolchain its plugins were built against fails all of them at once, and the
// operator's next step is the same page either way. Only the single-failure case
// is worth naming a file for; past that the count is the useful part and the
// Settings table has the rest.
export default function PluginBanner({ failed }: { failed: PluginInfo[] }) {
const [dismissed, setDismissed] = useState(false)
if (failed.length === 0 || dismissed) return null
const only = failed.length === 1 ? failed[0] : null
return (
<div className="flex items-center justify-between px-3 py-1.5 bg-surface-input border-b border-border text-xs text-content-secondary">
<span className="flex items-center gap-1.5 min-w-0">
<AlertTriangle size={12} className="text-semantic-warning shrink-0" />
<span className="truncate">
{only ? (
<>
Plugin <code className="text-semantic-warning">{only.filename}</code> failed to load
{only.error ? `: ${only.error}` : '.'}
</>
) : (
<>{failed.length} plugins failed to load.</>
)}
</span>
</span>
<div className="flex items-center gap-2 ml-4 shrink-0">
<Link
to="/settings"
className="text-accent-secondary hover:text-accent-secondary-hover underline"
>
Open plugin settings
</Link>
<button
onClick={() => setDismissed(true)}
className="text-content-muted hover:text-content-primary text-xs"
>
Dismiss
</button>
</div>
</div>
)
}
+4
View File
@@ -225,6 +225,10 @@ function ManagePanel({ plugins, onRefresh }: { plugins: PluginInfo[]; onRefresh:
<Tooltip content="File deleted. The plugin keeps running until you restart.">
<span className="text-semantic-warning font-semibold">Removed</span>
</Tooltip>
) : p.status === 'disabled' ? (
<Tooltip content="Joro was started with --no-plugins, so this file was listed but never loaded.">
<span className="text-content-muted font-semibold">Disabled</span>
</Tooltip>
) : (
<Tooltip content={p.error || 'Error'}>
<span className="text-semantic-error font-semibold">
+3 -2
View File
@@ -928,8 +928,9 @@ export interface PluginInfo {
type: string // "exec_provider" | "tab" | "feature" | "proxy_hook" | "dashboard"
// "removed" means the file is deleted but the code is still loaded, which lasts
// until a restart. A row with status "error" and an empty name is a file that
// would not load at all, so it has only a filename and a reason.
status: string // "loaded" | "error" | "removed"
// would not load at all, so it has only a filename and a reason. "disabled" is
// the same shape, for a file --no-plugins listed without opening.
status: string // "loaded" | "error" | "removed" | "disabled"
error?: string
hash: string
filename: string