feat(store): plugin store host module — signed catalogs, tiered trust, install jobs
The index is the verification gate: a catalog entry pins one exact version
plus that version's tarball integrity hash, so 'verified on every release'
is a property of the data rather than a promise about process. Nothing can
express 'track latest' for a catalogued plugin.
- store/index.rs signed index parse + ed25519 verify (ring), validate-and-drop
per entry, semver minHost/advisory ranges
- store/sources.rs built-in unom source (compiled-in URL + two key slots for
rotation) + operator sources in plugin-sources.json
- store/catalog.rs https fetch with size/timeout/redirect caps, signature before
parse, last-good disk cache (stale-but-usable when offline)
- store/jobs.rs single-flight install/uninstall: registry-integrity preflight
against the pin, spawn the runner CLI with live log capture,
post-install version check with rollback, provenance record,
runner restart (discovery is startup-only)
- store/manifest.rs install provenance; absence means CLI-installed
- mgmt/store.rs 12 routes under /api/v1/store, denied to the plugin token
(a plugin that can install plugins is an escalation primitive)
Also generalizes runner discovery and listInstalled from @punktfunk/plugin-*
to ANY scope's plugin-*: catalog entries must be scoped so the scope can map
to that entry's registry, so a third-party plugin necessarily arrives under
its own scope and would otherwise install but never run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -116,7 +116,11 @@ fn forward_to_runner(args: &[String]) -> Result<()> {
|
||||
|
||||
/// Resolve how to invoke the runner CLI: the program plus any leading args (the bundled bun needs
|
||||
/// the runner script path passed to it).
|
||||
fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> {
|
||||
///
|
||||
/// Also the plugin store's executor seam ([`crate::store::jobs`]): a console-triggered install runs
|
||||
/// the *same* package ops through the *same* runner as the CLI, so there is exactly one
|
||||
/// implementation of "install a plugin" on the box (design D4).
|
||||
pub(crate) fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// The installer lays the payload out as {app}\punktfunk-host.exe, {app}\bun\bun.exe and
|
||||
@@ -177,17 +181,163 @@ fn disable() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn status() -> Result<()> {
|
||||
let active = systemctl_output(&["is-active", UNIT]).unwrap_or_else(|| "unknown".into());
|
||||
let enabled = systemctl_output(&["is-enabled", UNIT]).unwrap_or_else(|| "unknown".into());
|
||||
println!("runner: {UNIT}\nenabled: {enabled}\nactive: {active}");
|
||||
if active != "active" {
|
||||
let st = runtime_status();
|
||||
println!(
|
||||
"runner: {}\nstate: {}\nenabled: {}",
|
||||
st.unit,
|
||||
if !st.installed {
|
||||
"not installed"
|
||||
} else if st.running {
|
||||
"running"
|
||||
} else {
|
||||
"stopped"
|
||||
},
|
||||
st.enabled
|
||||
);
|
||||
if let Some(principal) = &st.principal {
|
||||
println!("runs as: {principal}");
|
||||
}
|
||||
if st.installed && !st.running {
|
||||
println!("\nStart it with: punktfunk-host plugins enable");
|
||||
} else if !st.installed {
|
||||
println!("\n{}", st.detail);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- runtime state, shared by the CLI and the plugin store's mgmt API --------------------------
|
||||
|
||||
/// Whether the plugin runner is present, switched on, and up.
|
||||
///
|
||||
/// The store's console surface needs this as data (to offer "enable the runner" before the first
|
||||
/// install, and to explain why a freshly installed plugin isn't running yet), so it lives here
|
||||
/// rather than being formatted straight to stdout like the CLI once did.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RuntimeStatus {
|
||||
/// Is the runner payload / service unit on this box at all?
|
||||
pub installed: bool,
|
||||
/// Is it configured to start (systemd `enabled`, or a non-`Disabled` scheduled task)?
|
||||
pub enabled: bool,
|
||||
/// Is it up right now?
|
||||
pub running: bool,
|
||||
/// The unit / task name, so operator-facing copy can name the thing to look at.
|
||||
pub unit: &'static str,
|
||||
/// Windows: the account the task runs as (the SYSTEM→LocalService migration is visible here).
|
||||
pub principal: Option<String>,
|
||||
/// One line of human-readable context, mostly for the "not installed" case.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn runtime_status() -> RuntimeStatus {
|
||||
let enabled_raw = systemctl_output(&["is-enabled", UNIT]);
|
||||
let active = systemctl_output(&["is-active", UNIT]).unwrap_or_default();
|
||||
// `is-enabled` answers `not-found` when the unit file isn't installed at all; the runner
|
||||
// payload being present is the other half of "can we install plugins".
|
||||
let unit_known = enabled_raw.as_deref().is_some_and(|s| s != "not-found");
|
||||
let installed = unit_known || runner_command().is_ok();
|
||||
RuntimeStatus {
|
||||
installed,
|
||||
enabled: enabled_raw.as_deref() == Some("enabled"),
|
||||
running: active == "active",
|
||||
unit: UNIT,
|
||||
principal: None,
|
||||
detail: if installed {
|
||||
String::new()
|
||||
} else {
|
||||
"the plugin runner package isn't installed (Debian/Ubuntu: `sudo apt install \
|
||||
punktfunk-scripting`)"
|
||||
.into()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn runtime_status() -> RuntimeStatus {
|
||||
let out = powershell_output(&format!(
|
||||
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
||||
if ($null -eq $t) {{ 'missing' }} else {{ \"$($t.State)|$($t.Principal.UserId)\" }}"
|
||||
));
|
||||
match out.as_deref().map(str::trim) {
|
||||
Some("missing") | None => RuntimeStatus {
|
||||
installed: false,
|
||||
enabled: false,
|
||||
running: false,
|
||||
unit: TASK,
|
||||
principal: None,
|
||||
detail: "reinstall punktfunk with the scripting component to get the plugin runner"
|
||||
.into(),
|
||||
},
|
||||
Some(raw) => {
|
||||
let (state, principal) = raw.split_once('|').unwrap_or((raw, ""));
|
||||
RuntimeStatus {
|
||||
installed: true,
|
||||
enabled: !state.eq_ignore_ascii_case("Disabled"),
|
||||
running: state.eq_ignore_ascii_case("Running"),
|
||||
unit: TASK,
|
||||
principal: (!principal.is_empty()).then(|| principal.to_string()),
|
||||
detail: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub(crate) fn runtime_status() -> RuntimeStatus {
|
||||
RuntimeStatus {
|
||||
installed: false,
|
||||
enabled: false,
|
||||
running: false,
|
||||
unit: "punktfunk-scripting",
|
||||
principal: None,
|
||||
detail: "the plugin runner is only available on Linux and Windows hosts".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the runner on or off — the [`enable`]/[`disable`] the CLI runs, exposed for the store's
|
||||
/// `POST /store/runtime`. On Windows this is reached from the SYSTEM service, which already clears
|
||||
/// the elevation bar the CLI has to check for.
|
||||
pub(crate) fn set_runtime_enabled(enabled: bool) -> Result<()> {
|
||||
if enabled {
|
||||
enable()
|
||||
} else {
|
||||
disable()
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the runner so it rediscovers installed units. Returns `false` (not an error) when it
|
||||
/// isn't running — there is nothing to restart, and the store reports that as "installed, but the
|
||||
/// runner is off" rather than as a failure.
|
||||
///
|
||||
/// Unit discovery happens once at runner startup ([`sdk/src/runner.ts`]), so this restart *is* the
|
||||
/// activation step for a newly installed plugin.
|
||||
pub(crate) fn restart_runtime() -> Result<bool> {
|
||||
let st = runtime_status();
|
||||
if !st.installed || !st.running {
|
||||
return Ok(false);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
run_systemctl(&["restart", UNIT])?;
|
||||
Ok(true)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Stop then start: `Restart-ScheduledTask` does not exist, and a Start on an already-
|
||||
// running task is a no-op rather than a restart.
|
||||
powershell(&format!(
|
||||
"Stop-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
||||
Start-ScheduledTask -TaskName {TASK} -ErrorAction Stop"
|
||||
))?;
|
||||
Ok(true)
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn run_systemctl(args: &[&str]) -> Result<()> {
|
||||
let status = Command::new("systemctl")
|
||||
@@ -451,33 +601,6 @@ fn icacls_path() -> String {
|
||||
.unwrap_or_else(|_| "icacls".to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn status() -> Result<()> {
|
||||
let out = powershell_output(&format!(
|
||||
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
|
||||
if ($null -eq $t) {{ 'missing' }} else {{ \
|
||||
\"$($t.State)|$($t.Principal.UserId)\" }}"
|
||||
));
|
||||
match out.as_deref().map(str::trim) {
|
||||
Some("missing") | None => {
|
||||
println!(
|
||||
"runner: {TASK}\nstate: not installed\n\nReinstall punktfunk with the \
|
||||
scripting component to get the plugin runner."
|
||||
);
|
||||
}
|
||||
Some(state) => {
|
||||
// "State|Principal" — the principal line makes the SYSTEM→LocalService migration
|
||||
// verifiable at a glance (`plugins enable` converges a legacy SYSTEM task).
|
||||
let (state, principal) = state.split_once('|').unwrap_or((state, "?"));
|
||||
println!("runner: {TASK}\nstate: {state}\nruns as: {principal}");
|
||||
if state.eq_ignore_ascii_case("Disabled") {
|
||||
println!("\nEnable it with: punktfunk-host plugins enable");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve powershell by full System32 path rather than PATH — CreateProcess searches the launching
|
||||
/// EXE's own directory first, so a planted `powershell.exe` beside the host binary would otherwise
|
||||
/// run with our privileges (security-review 2026-07-17; matches service.rs / pf_vdisplay).
|
||||
@@ -603,8 +726,3 @@ fn enable() -> Result<()> {
|
||||
fn disable() -> Result<()> {
|
||||
bail!("the plugin runner is only available on Linux and Windows hosts")
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
fn status() -> Result<()> {
|
||||
bail!("the plugin runner is only available on Linux and Windows hosts")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user