feat!: remove plugin system
Deletes the plugin host, the aoe-plugin-api crate, the bundled plugins directory, the CLI subcommands, the TUI plugin pane, and the dashboard's plugin slots, panes, commands, and sort/filter contributions. BREAKING CHANGE: `aoe plugin` and `aoe graft` are gone, installed plugins are no longer loaded, and the `default-plugins` cargo feature is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,6 @@ on:
|
||||
- 'acp-worker/adapters/**'
|
||||
- 'docker/**'
|
||||
- 'themes/**'
|
||||
- 'plugins/**'
|
||||
# web/** is excluded: lockfile bumps land on main via the
|
||||
# nix-npm-hash bot's PR which bumps flake.nix in the same commit.
|
||||
workflow_dispatch:
|
||||
|
||||
+13
-16
@@ -82,34 +82,31 @@ jobs:
|
||||
# the standalone test bins). `--features serve` leaves the e2e target
|
||||
# gated off, so it is skipped here while every non-e2e test binary stays
|
||||
# auto-discovered. `--features serve` is a strict superset of the default
|
||||
# suite and pulls in the aoe.web plugin (serve-gated) the e2e tests assert
|
||||
# on, so the two feature gates are both covered across this leg + serve-e2e.
|
||||
# suite, so both feature gates are covered across this leg + serve-e2e.
|
||||
- name: Cargo test (serve, everything except e2e)
|
||||
if: matrix.leg == 'serve-rest'
|
||||
run: cargo test --features serve
|
||||
# bare-core: the three no-default-features feature corners.
|
||||
# - lib/bins: with every bundled plugin compiled out, core must still
|
||||
# build and pass its unit tests. `--no-default-features` also drops
|
||||
# `serve`, so this doubles as the TUI-only compile gate, keeping the
|
||||
# ~27 cfg(feature = "serve") sites from rotting.
|
||||
# - e2e: acceptance criterion 1 of #268 (issue #2097): with all default
|
||||
# plugins disabled, aoe still creates, attaches to, and destroys a
|
||||
# bare-core: the TUI-only feature corner.
|
||||
# - lib/bins: `--no-default-features` drops `serve`, so this is the
|
||||
# TUI-only compile gate, keeping the ~27 cfg(feature = "serve") sites
|
||||
# from rotting.
|
||||
# - e2e: with serve off, aoe still creates, attaches to, and destroys a
|
||||
# tmux + worktree session from CLI and TUI. The serve-gated e2e modules
|
||||
# self-gate to empty here. `e2e-tests` opts the gated target back in.
|
||||
# Filtered to the modules that carry that criterion instead of the
|
||||
# whole 134-test suite: the rest re-asserts serve-off behavior the
|
||||
# serve-e2e leg already covers with plugins on, for ~90s of duplicate
|
||||
# wall on a concurrency-bound workflow.
|
||||
# - check --all-targets: default-plugins on, serve off compile gate;
|
||||
# `e2e-tests` keeps the e2e target inside the --all-targets sweep, which
|
||||
# the gate would otherwise silently drop.
|
||||
# whole suite: the rest re-asserts serve-off behavior the serve-e2e leg
|
||||
# already covers, for ~90s of duplicate wall on a concurrency-bound
|
||||
# workflow.
|
||||
# - check --all-targets: serve-off compile gate; `e2e-tests` keeps the
|
||||
# e2e target inside the --all-targets sweep, which the gate would
|
||||
# otherwise silently drop.
|
||||
- name: Cargo test (bare core, no default features)
|
||||
if: matrix.leg == 'bare-core'
|
||||
run: |
|
||||
cargo test --no-default-features --lib --bins
|
||||
cargo test --no-default-features --features e2e-tests --test e2e -- \
|
||||
--test-threads=3 cli:: tui_launch:: new_session::
|
||||
cargo check --no-default-features --features default-plugins,e2e-tests --all-targets
|
||||
cargo check --no-default-features --features e2e-tests --all-targets
|
||||
# Folded in from a former standalone `hook-privdrop` job. That job existed
|
||||
# only to build a lib test binary it could re-run under sudo, which meant a
|
||||
# third full compile of the crate at a third feature set (~1.7 min) plus
|
||||
|
||||
@@ -7,13 +7,12 @@
|
||||
Most of the tree is self-describing; the entries below carry context that reading
|
||||
the code alone would not give you.
|
||||
|
||||
- `src/process/`: OS-specific process handling (`macos.rs`, `linux.rs`) plus `worker.rs`, the protocol-agnostic worker-subprocess substrate (process-group signalling, liveness, on-disk worker paths) that the plugin host will reuse, and the ACP worker layer built on it that `src/acp/` consumes: `worker_registry.rs` (on-disk registry of detached ACP workers) and `runner.rs` (the `aoe __acp-runner` shim that owns an agent subprocess and outlives `aoe serve`).
|
||||
- `src/process/`: OS-specific process handling (`macos.rs`, `linux.rs`) plus `worker.rs`, the protocol-agnostic worker-subprocess substrate (process-group signalling, liveness, on-disk worker paths), and the ACP worker layer built on it that `src/acp/` consumes: `worker_registry.rs` (on-disk registry of detached ACP workers) and `runner.rs` (the `aoe __acp-runner` shim that owns an agent subprocess and outlives `aoe serve`).
|
||||
- `src/events/`: protocol-agnostic durable event-log storage core (topic-keyed SQLite seq log, retention, keyset scans, attachments); `src/acp/`'s `EventStore` is the first consumer.
|
||||
- `src/migrations/`: versioned data migrations for breaking changes (see below).
|
||||
- `tests/e2e/`: end-to-end tests exercising the full `aoe` binary (see E2E Tests below).
|
||||
- `docs/development/adding-agents.md`: guide for adding a new agent to AoE.
|
||||
- `docs/development/adding-settings.md`: guide for adding a setting via the single-source schema.
|
||||
- `aoe-plugin-api/`: plugin manifest and capability types (see `docs/development/internals/plugin-system.md`).
|
||||
- `contrib/`: community-maintained integration files (e.g., OpenClaw skill). Checked by `cargo xtask check-skill` in CI.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
Generated
-14
@@ -102,7 +102,6 @@ dependencies = [
|
||||
"agent-of-empires",
|
||||
"ansi-to-tui",
|
||||
"anyhow",
|
||||
"aoe-plugin-api",
|
||||
"aoe-settings-derive",
|
||||
"argon2",
|
||||
"axum",
|
||||
@@ -274,18 +273,6 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "aoe-plugin-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 2.0.20",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aoe-settings-derive"
|
||||
version = "0.1.0"
|
||||
@@ -5628,7 +5615,6 @@ name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"agent-of-empires",
|
||||
"aoe-plugin-api",
|
||||
"clap",
|
||||
"clap-markdown",
|
||||
"ctrlc",
|
||||
|
||||
+9
-20
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = [".", "xtask", "aoe-settings-derive", "aoe-plugin-api"]
|
||||
members = [".", "xtask", "aoe-settings-derive"]
|
||||
|
||||
[package]
|
||||
name = "agent-of-empires"
|
||||
@@ -40,9 +40,6 @@ tokio = { version = "1.52", features = ["full"] }
|
||||
# Settings single-source-of-truth derive (#1692)
|
||||
aoe-settings-derive = { path = "aoe-settings-derive" }
|
||||
|
||||
# Plugin manifest and capability types (#268)
|
||||
aoe-plugin-api = { path = "aoe-plugin-api" }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_ignored = "0.1"
|
||||
@@ -168,11 +165,10 @@ agent-client-protocol = { version = "1.0", optional = true, features = ["unstabl
|
||||
# Semver parsing for ACP adapter compatibility checks (see src/acp/agent_compat.rs).
|
||||
semver = { version = "1", optional = true }
|
||||
|
||||
# Tar stream handling. Used by the structured view's bundled-Node fallback
|
||||
# (with xz2 for tar.xz) and by external plugin install (with flate2 for
|
||||
# tar.gz). The `tar` and `flate2` crates are pure Rust and unconditional so
|
||||
# plugin install works in a TUI-only build; xz2 needs system liblzma and stays
|
||||
# gated to the structured view (serve).
|
||||
# Tar stream handling for the structured view's bundled-Node fallback (with
|
||||
# xz2 for tar.xz); flate2 also backs the live-WS permessage-deflate codec. Both
|
||||
# are pure Rust and unconditional; xz2 needs system liblzma and stays gated to
|
||||
# the structured view (serve).
|
||||
tar = "0.4"
|
||||
flate2 = "1"
|
||||
xz2 = { version = "0.1", optional = true }
|
||||
@@ -188,26 +184,19 @@ tokio-tungstenite = { version = "0.30", default-features = false, features = ["r
|
||||
# escape the intended root.
|
||||
cap-std = { version = "4.0.2", optional = true }
|
||||
|
||||
# Unix-only: O_NOFOLLOW for race-free plugin-tree reads/copies (no std constant).
|
||||
# Unix-only: O_NOFOLLOW and friends for race-free directory walks (no std constants).
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[features]
|
||||
# Lean by default: a plain `cargo build` needs no Node/npm. `serve` (the web
|
||||
# dashboard) stays opt-in (`cargo build --features serve`); the aoe.web builtin
|
||||
# rides along with it. Released binaries are built `--features serve`, so every
|
||||
# distributed binary ships the dashboard. `default-plugins` stays on by default
|
||||
# so the bundled-plugin set is the norm for source builds; it currently gates
|
||||
# nothing on its own (aoe.web needs `serve`), but reserves the on-by-default
|
||||
# slot for first-party plugins that do not require the dashboard.
|
||||
default = ["default-plugins"]
|
||||
# dashboard) stays opt-in (`cargo build --features serve`). Released binaries
|
||||
# are built `--features serve`, so every distributed binary ships the dashboard.
|
||||
default = []
|
||||
# Re-exports private tmux env helpers via `crate::tmux::test_support` so that
|
||||
# integration tests in `tests/` can poke them. Not enabled by default — only
|
||||
# used by `cargo test`.
|
||||
test-support = []
|
||||
# Compile in the bundled first-party plugins. Build the bare core (acceptance
|
||||
# criterion 1 of #268) by opting out with `--no-default-features`.
|
||||
default-plugins = []
|
||||
# Gates the `e2e` test target (tests/e2e/main.rs). Off by default so CI can run
|
||||
# the serve suite as two parallel shards without enumerating every non-e2e test
|
||||
# binary: `cargo test --features serve` runs everything except e2e (the target
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "aoe-plugin-api"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.85"
|
||||
publish = false
|
||||
description = "Plugin manifest and capability types for the Agent of Empires plugin system (#268)"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/agent-of-empires/agent-of-empires"
|
||||
readme = "README.md"
|
||||
keywords = ["aoe", "agent-of-empires", "plugin"]
|
||||
categories = ["development-tools"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "1.1"
|
||||
thiserror = "2.0"
|
||||
sha2 = "0.11"
|
||||
# Parses and matches the manifest's `aoe_version` host-compatibility range.
|
||||
# Direct dep here (not via the host) because plugin install/load runs in
|
||||
# TUI-only builds, where the main crate's `serve`-gated semver is absent.
|
||||
semver = "1"
|
||||
@@ -1,25 +0,0 @@
|
||||
# aoe-plugin-api
|
||||
|
||||
The stable types a plugin author (and the in-tree host) compiles against for
|
||||
the [Agent of Empires](https://github.com/agent-of-empires/agent-of-empires)
|
||||
plugin system: the `aoe-plugin.toml` manifest schema (`PluginManifest`) and the
|
||||
`PluginId` newtype.
|
||||
|
||||
Plugins do not depend on this crate to run; a worker speaks newline-delimited
|
||||
JSON-RPC over stdio in any language. This crate is the host-side schema and the
|
||||
reference for what a manifest may declare.
|
||||
|
||||
See `docs/development/writing-plugins.md` in the main repository for the
|
||||
authoring guide, and `docs/development/internals/plugin-system.md` for the
|
||||
architecture and security model.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The manifest carries an `api_version`; the host rejects a manifest targeting a
|
||||
newer version than it supports. The public enums and structs are
|
||||
`#[non_exhaustive]`, so adding a variant or field is not a breaking change for
|
||||
downstream Rust consumers.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
@@ -1,166 +0,0 @@
|
||||
//! ACP capability-discovery DTOs for the `acp.capabilities.get` worker RPC
|
||||
//! (API v9, #2897).
|
||||
//!
|
||||
//! This is the stable wire contract a session-driving plugin (for example
|
||||
//! `plugin-cron`) pins its fixtures against. The host assembles it from the
|
||||
//! static agent registry plus the last option catalog each agent advertised.
|
||||
//! `acp.capabilities.get` never launches an agent, so a never-run agent reports
|
||||
//! `CatalogStatus::Undiscovered` with empty lists; `acp.capabilities.probe`
|
||||
//! (API v11, the `acp.capabilities.probe` grant) runs a handshake-only probe to
|
||||
//! populate the catalog first, then returns the same shape. All lists are
|
||||
//! sorted by id so serialized fixtures are deterministic. Fields are additive
|
||||
//! from here on; an incompatible reshape bumps the crate `API_VERSION`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Response of `acp.capabilities.get`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AcpCapabilitiesResponse {
|
||||
pub agents: Vec<AcpAgentCapability>,
|
||||
}
|
||||
|
||||
/// One agent the host can run in a structured session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AcpAgentCapability {
|
||||
/// Stable agent id, the value `sessions.create` accepts as `agent_id`.
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub catalog_status: CatalogStatus,
|
||||
/// RFC3339 timestamp of the advertised catalog snapshot; `Some` only when
|
||||
/// `catalog_status` is `Discovered`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub catalog_updated_at: Option<String>,
|
||||
pub models: Vec<AcpModelCapability>,
|
||||
pub modes: Vec<AcpModeCapability>,
|
||||
/// Reasoning-effort / thought-level choices the agent advertised (for
|
||||
/// example claude's `think`/`ultrathink`). Empty for agents that do not
|
||||
/// expose one or whose catalog is undiscovered. Added in API v11; omitted
|
||||
/// from the wire when empty so v10 fixtures stay byte-stable.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub thinking: Vec<AcpThinkingCapability>,
|
||||
}
|
||||
|
||||
/// A model choice the agent advertised.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AcpModelCapability {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
/// A reasoning-effort / thought-level choice the agent advertised.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AcpThinkingCapability {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
/// A permission/approval mode choice, carrying the HOST's security
|
||||
/// classification. The plugin must not infer safety from mode names; the
|
||||
/// host assigns `approval_class` and enforces it at `sessions.create`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AcpModeCapability {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub approval_class: ApprovalClass,
|
||||
}
|
||||
|
||||
/// Whether the host has ever observed this agent's advertised option catalog.
|
||||
/// Models/modes are populated only after the agent has run at least once.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CatalogStatus {
|
||||
Undiscovered,
|
||||
Discovered,
|
||||
}
|
||||
|
||||
/// Host-assigned security class of an approval mode. `Unattended` requires
|
||||
/// the distinct high-severity `session.unattended` grant at
|
||||
/// `sessions.create`; the host classifies unknown modes as `Unattended`
|
||||
/// (fail closed), never trusting a plugin- or agent-supplied label.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ApprovalClass {
|
||||
/// Approvals prompt a human through the host UI (adapter default).
|
||||
Interactive,
|
||||
/// A reviewed mode that preserves host approvals or prohibits mutation
|
||||
/// (for example a plan/read-only preset).
|
||||
Guarded,
|
||||
/// The agent can act without a human present (bypass or auto-write
|
||||
/// modes, and every mode the host cannot classify).
|
||||
Unattended,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wire_fixture_is_stable() {
|
||||
let response = AcpCapabilitiesResponse {
|
||||
agents: vec![AcpAgentCapability {
|
||||
id: "claude".into(),
|
||||
display_name: "Claude Code".into(),
|
||||
catalog_status: CatalogStatus::Discovered,
|
||||
catalog_updated_at: Some("2026-07-16T00:00:00Z".into()),
|
||||
models: vec![AcpModelCapability {
|
||||
id: "sonnet".into(),
|
||||
display_name: "Sonnet".into(),
|
||||
}],
|
||||
modes: vec![
|
||||
AcpModeCapability {
|
||||
id: "bypassPermissions".into(),
|
||||
display_name: "Bypass Permissions".into(),
|
||||
approval_class: ApprovalClass::Unattended,
|
||||
},
|
||||
AcpModeCapability {
|
||||
id: "plan".into(),
|
||||
display_name: "Plan".into(),
|
||||
approval_class: ApprovalClass::Guarded,
|
||||
},
|
||||
],
|
||||
thinking: vec![AcpThinkingCapability {
|
||||
id: "think".into(),
|
||||
display_name: "Think".into(),
|
||||
}],
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_value(&response).expect("serialize");
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"agents": [{
|
||||
"id": "claude",
|
||||
"display_name": "Claude Code",
|
||||
"catalog_status": "discovered",
|
||||
"catalog_updated_at": "2026-07-16T00:00:00Z",
|
||||
"models": [{"id": "sonnet", "display_name": "Sonnet"}],
|
||||
"modes": [
|
||||
{"id": "bypassPermissions", "display_name": "Bypass Permissions", "approval_class": "unattended"},
|
||||
{"id": "plan", "display_name": "Plan", "approval_class": "guarded"}
|
||||
],
|
||||
"thinking": [{"id": "think", "display_name": "Think"}]
|
||||
}]
|
||||
})
|
||||
);
|
||||
let round: AcpCapabilitiesResponse = serde_json::from_value(json).expect("deserialize");
|
||||
assert_eq!(round, response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undiscovered_omits_updated_at() {
|
||||
let agent = AcpAgentCapability {
|
||||
id: "codex".into(),
|
||||
display_name: "Codex".into(),
|
||||
catalog_status: CatalogStatus::Undiscovered,
|
||||
catalog_updated_at: None,
|
||||
models: vec![],
|
||||
modes: vec![],
|
||||
thinking: vec![],
|
||||
};
|
||||
let json = serde_json::to_value(&agent).expect("serialize");
|
||||
assert!(json.get("catalog_updated_at").is_none());
|
||||
// Empty thinking is omitted so v10 fixtures stay byte-stable.
|
||||
assert!(json.get("thinking").is_none());
|
||||
assert_eq!(json["catalog_status"], "undiscovered");
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
//! Capability taxonomy and trust levels for the plugin system.
|
||||
//!
|
||||
//! A capability gates runtime access to a resource that can affect user data,
|
||||
//! host state, the OS, or the network. Static contributions (commands,
|
||||
//! keybinds, themes, ui, status, panes) are NOT capabilities; they are plain
|
||||
//! manifest sections that need no grant. A capability is what the one-time
|
||||
//! install prompt asks the user to approve, and what a persisted grant is
|
||||
//! pinned to.
|
||||
//!
|
||||
//! Capabilities are open strings rather than a closed enum so a follow-up issue
|
||||
//! can introduce a new permission without bumping `api_version`. The host
|
||||
//! validates a requested capability against [`KNOWN_CAPABILITIES`] at install
|
||||
//! and grant time, rejecting an unknown one rather than silently granting it.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A capability a plugin requests in its manifest `capabilities = [...]` array.
|
||||
///
|
||||
/// Stored as a free string; [`CapabilityId::is_known`] reports whether this
|
||||
/// host version recognizes it. The host never grants an unknown capability.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct CapabilityId(String);
|
||||
|
||||
impl CapabilityId {
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Whether this host version recognizes the capability. An unknown
|
||||
/// capability is rejected at install (`unsupported capability; upgrade
|
||||
/// aoe`), never silently granted.
|
||||
pub fn is_known(&self) -> bool {
|
||||
KNOWN_CAPABILITIES.contains(&self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CapabilityId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for CapabilityId {
|
||||
fn from(value: &str) -> Self {
|
||||
Self(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource/effect capabilities this host version understands.
|
||||
///
|
||||
/// Each gates a runtime resource that the worker (#2095) or a contribution
|
||||
/// handler reaches. A plugin's own declared settings need no `config.*`:
|
||||
/// `config.read` / `config.write` mean host/global or other-plugin
|
||||
/// configuration, not the plugin's own table.
|
||||
pub const KNOWN_CAPABILITIES: &[&str] = &[
|
||||
// Executing plugin code at all is materially different from loading static
|
||||
// metadata, so it is its own capability.
|
||||
"runtime.worker",
|
||||
// Reading and mutating the session the plugin is attached to.
|
||||
"session.read",
|
||||
"session.write",
|
||||
// Host/global or other-plugin configuration (NOT the plugin's own settings).
|
||||
"config.read",
|
||||
"config.write",
|
||||
// Spawning OS subprocesses beyond the plugin's own worker.
|
||||
"process.spawn",
|
||||
// Outbound network access.
|
||||
"net",
|
||||
// Filesystem access outside the plugin's own directory. Read is split from
|
||||
// write because the two carry very different risk.
|
||||
"fs.read",
|
||||
"fs.write",
|
||||
// Clipboard read is far more sensitive than write, so they are separate.
|
||||
"clipboard.read",
|
||||
"clipboard.write",
|
||||
// Posting desktop / TUI notifications.
|
||||
"notifications",
|
||||
// Opening an external URL in the user's browser, driven by a command's
|
||||
// `action` (or a future host RPC). Distinct from a rendered `href` anchor
|
||||
// the user clicks, which needs no grant.
|
||||
"browser_open",
|
||||
// Reading and mutating the active ACP composer draft through a
|
||||
// host-mediated composer action. The dashboard owns the actual draft state;
|
||||
// plugins only receive a click-scoped snapshot or request a validated edit.
|
||||
"composer.read",
|
||||
"composer.write",
|
||||
// Reading the ACP capability catalog: which agents exist and their
|
||||
// advertised structured-session models/modes. Read-only discovery; the host
|
||||
// never launches an agent to answer it.
|
||||
"acp.capabilities.read",
|
||||
// Triggering a handshake-only catalog probe: the host spawns the agent
|
||||
// adapter, runs initialize + session/new (no prompt turn, so no tokens),
|
||||
// records the advertised models/modes/thought-levels, and tears the process
|
||||
// down. Distinct from the read grant because it makes the host spawn a real
|
||||
// process (CPU, startup latency, possibly network auth), a different risk
|
||||
// axis than reading a cached catalog.
|
||||
"acp.capabilities.probe",
|
||||
// Creating a host-owned structured-view session (the host validates agent,
|
||||
// model, mode, and repository trust; the plugin cannot bypass those).
|
||||
"session.create",
|
||||
// Delivering a prompt/turn to a session the plugin created. Scoped to the
|
||||
// creating plugin; it is NOT a license to write to arbitrary user sessions.
|
||||
"session.prompt",
|
||||
// A distinct, high-severity grant required when `session.create` selects a
|
||||
// host-classified unattended (auto-approval) mode, i.e. the plugin may start
|
||||
// an agent and send it a prompt with no user present. Never implied by
|
||||
// `session.create` or `session.prompt`; repository trust still applies.
|
||||
"session.unattended",
|
||||
];
|
||||
|
||||
/// How far a plugin is trusted. Host-assigned at load time, never declared in
|
||||
/// the manifest.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TrustLevel {
|
||||
/// Compiled into the binary. Fully trusted: capabilities are auto-granted,
|
||||
/// no install prompt.
|
||||
Builtin,
|
||||
/// Installed from an external source (GitHub or a local dir). Untrusted:
|
||||
/// every requested capability must be granted by the user.
|
||||
Community,
|
||||
}
|
||||
|
||||
impl TrustLevel {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
TrustLevel::Builtin => "builtin",
|
||||
TrustLevel::Community => "community",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TrustLevel {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Identifier of a plugin, e.g. `aoe.status` or `someuser.review-helper`.
|
||||
///
|
||||
/// Lowercase ASCII segments separated by dots; segments may contain digits and
|
||||
/// hyphens but must start with a letter. The id namespaces everything the
|
||||
/// plugin touches: its config table (`[plugins."<id>"]`), its `plugin_meta`
|
||||
/// slot on sessions, its event topics (`plugin.<id>.*`), and its canonical
|
||||
/// action names (`plugin.<id>.<action>`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct PluginId(String);
|
||||
|
||||
/// Rejection reason for a malformed plugin id.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("invalid plugin id {id:?}: {reason}")]
|
||||
#[non_exhaustive]
|
||||
pub struct InvalidPluginId {
|
||||
pub id: String,
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
impl PluginId {
|
||||
pub fn new(id: impl Into<String>) -> Result<Self, InvalidPluginId> {
|
||||
let id = id.into();
|
||||
let reject = |reason| {
|
||||
Err(InvalidPluginId {
|
||||
id: id.clone(),
|
||||
reason,
|
||||
})
|
||||
};
|
||||
if id.is_empty() {
|
||||
return reject("empty");
|
||||
}
|
||||
if id.len() > 64 {
|
||||
return reject("longer than 64 bytes");
|
||||
}
|
||||
for segment in id.split('.') {
|
||||
let mut chars = segment.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_lowercase() => {}
|
||||
_ => {
|
||||
return reject("each dot-separated segment must start with a lowercase letter")
|
||||
}
|
||||
}
|
||||
if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
|
||||
return reject("segments may only contain lowercase letters, digits, and hyphens");
|
||||
}
|
||||
}
|
||||
Ok(Self(id))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Whether this id sits in a namespace reserved for first-party plugins:
|
||||
/// `aoe.*` (bundled builtins) and `agent-of-empires.*` (official plugins
|
||||
/// shipped through the featured index). The host lets a community install
|
||||
/// use a reserved namespace only when the source is featured-verified, so
|
||||
/// a third party cannot publish as `aoe.web` or `agent-of-empires.github`
|
||||
/// and usurp the builtin/official id or the
|
||||
/// `plugin_meta` namespace. Builtin manifests are loaded from inside the
|
||||
/// binary and never pass through that install gate.
|
||||
pub fn is_reserved_namespace(&self) -> bool {
|
||||
matches!(
|
||||
self.0.split('.').next(),
|
||||
Some("aoe") | Some("agent-of-empires")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PluginId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for PluginId {
|
||||
type Error = InvalidPluginId;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PluginId> for String {
|
||||
fn from(value: PluginId) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_dotted_lowercase_ids() {
|
||||
for ok in ["aoe.status", "a", "someuser.review-helper", "x.y2.z-3"] {
|
||||
assert!(PluginId::new(ok).is_ok(), "{ok} should be valid");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_ids() {
|
||||
for bad in [
|
||||
"",
|
||||
"Aoe.status",
|
||||
"aoe..status",
|
||||
"aoe.2fast",
|
||||
"-x",
|
||||
"aoe.st_at",
|
||||
"aoe.st at",
|
||||
] {
|
||||
assert!(PluginId::new(bad).is_err(), "{bad} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_namespace_policy_is_pinned() {
|
||||
for reserved in ["aoe.status", "aoe.web", "agent-of-empires.github"] {
|
||||
assert!(
|
||||
PluginId::new(reserved).unwrap().is_reserved_namespace(),
|
||||
"{reserved} should be reserved"
|
||||
);
|
||||
}
|
||||
for open in ["someuser.review-helper", "acme.review", "aoextra.thing"] {
|
||||
assert!(
|
||||
!PluginId::new(open).unwrap().is_reserved_namespace(),
|
||||
"{open} should be open"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trips_and_validates() {
|
||||
let id: PluginId = serde_json::from_str("\"aoe.status\"").unwrap();
|
||||
assert_eq!(id.as_str(), "aoe.status");
|
||||
assert_eq!(serde_json::to_string(&id).unwrap(), "\"aoe.status\"");
|
||||
assert!(serde_json::from_str::<PluginId>("\"Not Valid\"").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
//! Plugin manifest types for the Agent of Empires plugin system.
|
||||
//!
|
||||
//! This crate is the stable surface a plugin author (and the in-tree host)
|
||||
//! compiles against: the `aoe-plugin.toml` manifest schema, the capability
|
||||
//! taxonomy, and the validation rules that gate a manifest before it loads.
|
||||
//! The contribution sections (capabilities, commands, keybinds, settings,
|
||||
//! themes, ui, runtime worker) are defined here. Settings and themes are
|
||||
//! consumed by the Tier 0 registries (#2094); keybinds/commands resolve and
|
||||
//! graft at Tier 0 but execute only with the runtime host (#2095); ui slots
|
||||
//! land with #2366; the status section's consumer is the status reference
|
||||
//! plugin (#2096). Panes are not a manifest section: they ship as a `ui` slot
|
||||
//! kind (#2432). See `docs/development/internals/plugin-system.md`.
|
||||
|
||||
pub mod acp;
|
||||
mod capability;
|
||||
mod id;
|
||||
mod manifest;
|
||||
pub mod session;
|
||||
|
||||
pub use capability::{CapabilityId, TrustLevel, KNOWN_CAPABILITIES};
|
||||
pub use id::{InvalidPluginId, PluginId};
|
||||
pub use manifest::{
|
||||
lucide_icon_name_ok, screenshot_path_ok, BuildStep, ClientAction, CommandContribution,
|
||||
KeybindContribution, ManifestError, ObjectFieldContribution, ObjectFieldType, OptionSource,
|
||||
PluginManifest, RuntimeSpec, Screenshot, SettingContribution, SettingType, StatusContribution,
|
||||
ThemeContribution, UiContribution, UiSlot, MAX_SCREENSHOTS,
|
||||
};
|
||||
|
||||
/// Version of the manifest schema and host API this crate describes.
|
||||
///
|
||||
/// A manifest declares the `api_version` it was written against; the host
|
||||
/// refuses manifests targeting a newer version than it understands. Bumped to
|
||||
/// 2 when the contribution sections and capability taxonomy were added; 3 when
|
||||
/// the `detail-panel` slot became the dockable `pane` slot (with
|
||||
/// `default_location`); 4 when the `status` contribution section and the
|
||||
/// `aoe_version` host-compatibility field were added; 5 when the `screenshots`
|
||||
/// presentation metadata was added; 6 when a command could declare a
|
||||
/// client-executed `action` (`ClientAction`); 7 when `icon` and `icon_asset`
|
||||
/// identity metadata were added; 8 when plugins could contribute composer
|
||||
/// actions; 9 when the host gained ACP-capability discovery, host-owned
|
||||
/// session creation / prompt delivery (with the `session.unattended` grant),
|
||||
/// plugin-private storage, and structured settings widgets (`object_list`,
|
||||
/// `dynamic_select`); 10 when the `settings-page` full-page slot and the
|
||||
/// `tool-card-badge` slot were added; 11 when `acp.capabilities.probe` let a
|
||||
/// plugin trigger a handshake-only catalog probe and the capability response
|
||||
/// grew a `thinking` (thought-level) list; 12 when the pane block vocabulary
|
||||
/// gained the `callout`, `bar`, and `columns` kinds, clickable/badged `row`s,
|
||||
/// header-summary and scrollable `section`s, `disabled`/`variant` actions, and
|
||||
/// the pane-level `footer`; 13 when the global `home-pane` slot and the
|
||||
/// `sparkline` block kind were added.
|
||||
pub const API_VERSION: u32 = 13;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,208 +0,0 @@
|
||||
//! Session create / turn-delivery DTOs for the `sessions.create` and
|
||||
//! `sessions.turn.send` worker RPCs (API v9, #2897).
|
||||
//!
|
||||
//! The host validates every field against its own catalogs and policy; a
|
||||
//! plugin cannot pick a view other than structured, pre-approve repository
|
||||
//! trust, or pass agent launch flags. The caller's plugin identity comes
|
||||
//! from the RPC connection, never from these payloads.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Parameters of `sessions.create`. Requires the `session.create` grant;
|
||||
/// additionally `session.prompt` when `initial_turn` is present and
|
||||
/// `session.unattended` when the host classifies `mode_id` as unattended.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SessionsCreateRequest {
|
||||
/// Agent to run, an id from `acp.capabilities.get`.
|
||||
pub agent_id: String,
|
||||
/// Project directory the session runs in. Canonicalized and checked
|
||||
/// against repository trust by the host, fail-closed. Absent or empty
|
||||
/// means *no project*: the host provisions a throwaway scratch session
|
||||
/// (no repo, so no trust anchor). Optional since API v11; a present value
|
||||
/// keeps the v9/v10 behavior.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_path: Option<String>,
|
||||
/// Additional repository paths for a multi-repo session, each canonicalized
|
||||
/// and existence-checked by the host. Only valid alongside a
|
||||
/// `project_path` (the first repo is the trust anchor); combining extras
|
||||
/// with a scratch session is refused. API v11.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extra_project_paths: Vec<String>,
|
||||
/// Run the session inside the host's sandbox (a container). The host uses
|
||||
/// its configured sandbox image; a plugin cannot pick an image. Sandboxing
|
||||
/// only narrows what the agent can reach, so it needs no extra grant beyond
|
||||
/// `session.create`. The create fails synchronously when no container
|
||||
/// runtime is installed or running; when a runtime is present the container
|
||||
/// is started asynchronously after the create returns, so image-pull or
|
||||
/// startup failures surface on the session later, not as a create error.
|
||||
/// API v11.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub sandbox: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_id: Option<String>,
|
||||
/// Approval mode id. Omitted means the adapter default (interactive).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mode_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<String>,
|
||||
/// First prompt, accepted atomically with the create and delivered once
|
||||
/// the worker is live (at-least-once).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub initial_turn: Option<InitialTurn>,
|
||||
/// Create-deduplication key, scoped to the calling plugin. Retrying with
|
||||
/// the same key and payload returns the existing session
|
||||
/// (`created: false`); a different payload under the same key is a
|
||||
/// conflict. Retained while the session record exists.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub idempotency_key: Option<String>,
|
||||
}
|
||||
|
||||
/// The initial prompt of a created session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct InitialTurn {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Response of `sessions.create`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionsCreateResponse {
|
||||
pub session_id: String,
|
||||
/// `false` when an existing session was returned by idempotency.
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// Parameters of `sessions.turn.send`. Requires the `session.prompt` grant;
|
||||
/// the target must have been created by the calling plugin.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TurnSendRequest {
|
||||
pub session_id: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn create_request_wire_fixture_is_stable() {
|
||||
let request = SessionsCreateRequest {
|
||||
agent_id: "claude".into(),
|
||||
project_path: Some("/home/user/project".into()),
|
||||
extra_project_paths: Vec::new(),
|
||||
sandbox: false,
|
||||
model_id: Some("sonnet".into()),
|
||||
mode_id: Some("plan".into()),
|
||||
title: Some("nightly maintenance".into()),
|
||||
group: None,
|
||||
initial_turn: Some(InitialTurn {
|
||||
text: "Run the nightly task".into(),
|
||||
}),
|
||||
idempotency_key: Some("job-1:2026-07-16T03:00:00Z".into()),
|
||||
};
|
||||
let json = serde_json::to_value(&request).expect("serialize");
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"agent_id": "claude",
|
||||
"project_path": "/home/user/project",
|
||||
"model_id": "sonnet",
|
||||
"mode_id": "plan",
|
||||
"title": "nightly maintenance",
|
||||
"initial_turn": {"text": "Run the nightly task"},
|
||||
"idempotency_key": "job-1:2026-07-16T03:00:00Z"
|
||||
})
|
||||
);
|
||||
let round: SessionsCreateRequest = serde_json::from_value(json).expect("deserialize");
|
||||
assert_eq!(round, request);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_request_rejects_bypass_flags() {
|
||||
// No unknown field can smuggle a host-side knob (allow_untrusted,
|
||||
// extra args, env) through the create payload.
|
||||
let err = serde_json::from_value::<SessionsCreateRequest>(serde_json::json!({
|
||||
"agent_id": "claude",
|
||||
"project_path": "/p",
|
||||
"allow_untrusted": true
|
||||
}))
|
||||
.expect_err("unknown fields must be rejected");
|
||||
assert!(err.to_string().contains("allow_untrusted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_request_omits_project_path() {
|
||||
// No project selected: project_path is absent on the wire (scratch),
|
||||
// and an omitted project_path round-trips to None.
|
||||
let request = SessionsCreateRequest {
|
||||
agent_id: "claude".into(),
|
||||
project_path: None,
|
||||
extra_project_paths: Vec::new(),
|
||||
sandbox: false,
|
||||
model_id: None,
|
||||
mode_id: None,
|
||||
title: None,
|
||||
group: None,
|
||||
initial_turn: None,
|
||||
idempotency_key: None,
|
||||
};
|
||||
let json = serde_json::to_value(&request).expect("serialize");
|
||||
assert!(json.get("project_path").is_none());
|
||||
assert!(json.get("extra_project_paths").is_none());
|
||||
let round: SessionsCreateRequest = serde_json::from_value(json).expect("deserialize");
|
||||
assert_eq!(round, request);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_flag_serializes_only_when_set() {
|
||||
let mut request = SessionsCreateRequest {
|
||||
agent_id: "claude".into(),
|
||||
project_path: Some("/p".into()),
|
||||
extra_project_paths: Vec::new(),
|
||||
sandbox: false,
|
||||
model_id: None,
|
||||
mode_id: None,
|
||||
title: None,
|
||||
group: None,
|
||||
initial_turn: None,
|
||||
idempotency_key: None,
|
||||
};
|
||||
// Default (false) is omitted so it never bloats a fixture.
|
||||
assert!(serde_json::to_value(&request)
|
||||
.expect("serialize")
|
||||
.get("sandbox")
|
||||
.is_none());
|
||||
request.sandbox = true;
|
||||
let json = serde_json::to_value(&request).expect("serialize");
|
||||
assert_eq!(json["sandbox"], serde_json::json!(true));
|
||||
let round: SessionsCreateRequest = serde_json::from_value(json).expect("deserialize");
|
||||
assert!(round.sandbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_repo_request_carries_extra_paths() {
|
||||
let request = SessionsCreateRequest {
|
||||
agent_id: "claude".into(),
|
||||
project_path: Some("/repos/app".into()),
|
||||
extra_project_paths: vec!["/repos/lib".into(), "/repos/proto".into()],
|
||||
sandbox: false,
|
||||
model_id: None,
|
||||
mode_id: None,
|
||||
title: None,
|
||||
group: None,
|
||||
initial_turn: None,
|
||||
idempotency_key: None,
|
||||
};
|
||||
let json = serde_json::to_value(&request).expect("serialize");
|
||||
assert_eq!(
|
||||
json["extra_project_paths"],
|
||||
serde_json::json!(["/repos/lib", "/repos/proto"])
|
||||
);
|
||||
let round: SessionsCreateRequest = serde_json::from_value(json).expect("deserialize");
|
||||
assert_eq!(round, request);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+2
-151
@@ -43,17 +43,6 @@ This document contains the help content for the `aoe` command-line program.
|
||||
* [`aoe group create`↴](#aoe-group-create)
|
||||
* [`aoe group delete`↴](#aoe-group-delete)
|
||||
* [`aoe group move`↴](#aoe-group-move)
|
||||
* [`aoe plugin`↴](#aoe-plugin)
|
||||
* [`aoe plugin list`↴](#aoe-plugin-list)
|
||||
* [`aoe plugin info`↴](#aoe-plugin-info)
|
||||
* [`aoe plugin enable`↴](#aoe-plugin-enable)
|
||||
* [`aoe plugin disable`↴](#aoe-plugin-disable)
|
||||
* [`aoe plugin install`↴](#aoe-plugin-install)
|
||||
* [`aoe plugin update`↴](#aoe-plugin-update)
|
||||
* [`aoe plugin uninstall`↴](#aoe-plugin-uninstall)
|
||||
* [`aoe plugin hash`↴](#aoe-plugin-hash)
|
||||
* [`aoe plugin discover`↴](#aoe-plugin-discover)
|
||||
* [`aoe plugin outdated`↴](#aoe-plugin-outdated)
|
||||
* [`aoe profile`↴](#aoe-profile)
|
||||
* [`aoe profile list`↴](#aoe-profile-list)
|
||||
* [`aoe profile create`↴](#aoe-profile-create)
|
||||
@@ -134,7 +123,6 @@ Run without arguments to launch the TUI dashboard.
|
||||
* `killall` — Force-stop everything aoe is running: the serve daemon, all agent workers, and all aoe tmux sessions. Destructive and unprompted
|
||||
* `session` — Manage session lifecycle (start, stop, attach, etc.)
|
||||
* `group` — Manage groups for organizing sessions
|
||||
* `plugin` — Manage plugins (list, info, enable, disable, install, update, uninstall)
|
||||
* `profile` — Manage profiles (separate workspaces)
|
||||
* `project` — Manage the project registry used by multi-repo session pickers
|
||||
* `worktree` — Manage git worktrees for parallel development
|
||||
@@ -764,143 +752,6 @@ Move session to group
|
||||
|
||||
|
||||
|
||||
## `aoe plugin`
|
||||
|
||||
Manage plugins (list, info, enable, disable, install, update, uninstall)
|
||||
|
||||
**Usage:** `aoe plugin <COMMAND>`
|
||||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `list` — List every known plugin with version, validation, and state
|
||||
* `info` — Show one plugin's manifest details
|
||||
* `enable` — Enable a plugin's contributions
|
||||
* `disable` — Disable a plugin; its settings stay on disk for re-enabling
|
||||
* `install` — Install an external plugin from a `gh:owner/repo[@ref]` slug or a local directory. With no `@ref`, installs the repo's latest release; an explicit `@ref` installs unverified, un-audited code. Community plugins run at your own risk
|
||||
* `update` — Update an installed external plugin from its recorded source. Prompts to re-approve capabilities if the update changes the capability set
|
||||
* `uninstall` — Uninstall an external plugin, removing its files and capability grant
|
||||
* `hash` — Print the deterministic source tree hash for a plugin directory, the value a maintainer pins in the featured index
|
||||
* `discover` — Search GitHub's `aoe-plugin` topic for installable plugins
|
||||
* `outdated` — List installed external plugins that have an update available
|
||||
|
||||
|
||||
|
||||
## `aoe plugin list`
|
||||
|
||||
List every known plugin with version, validation, and state
|
||||
|
||||
**Usage:** `aoe plugin list`
|
||||
|
||||
|
||||
|
||||
## `aoe plugin info`
|
||||
|
||||
Show one plugin's manifest details
|
||||
|
||||
**Usage:** `aoe plugin info <ID>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<ID>` — Plugin id, e.g. `aoe.web`
|
||||
|
||||
|
||||
|
||||
## `aoe plugin enable`
|
||||
|
||||
Enable a plugin's contributions
|
||||
|
||||
**Usage:** `aoe plugin enable <ID>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<ID>` — Plugin id
|
||||
|
||||
|
||||
|
||||
## `aoe plugin disable`
|
||||
|
||||
Disable a plugin; its settings stay on disk for re-enabling
|
||||
|
||||
**Usage:** `aoe plugin disable <ID>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<ID>` — Plugin id
|
||||
|
||||
|
||||
|
||||
## `aoe plugin install`
|
||||
|
||||
Install an external plugin from a `gh:owner/repo[@ref]` slug or a local directory. With no `@ref`, installs the repo's latest release; an explicit `@ref` installs unverified, un-audited code. Community plugins run at your own risk
|
||||
|
||||
**Usage:** `aoe plugin install [OPTIONS] <SOURCE>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<SOURCE>` — `gh:owner/repo` (latest release) or `gh:owner/repo@ref` (unverified) or a local directory path
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--yes` — Grant all requested capabilities without prompting
|
||||
|
||||
|
||||
|
||||
## `aoe plugin update`
|
||||
|
||||
Update an installed external plugin from its recorded source. Prompts to re-approve capabilities if the update changes the capability set
|
||||
|
||||
**Usage:** `aoe plugin update <ID>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<ID>` — Plugin id
|
||||
|
||||
|
||||
|
||||
## `aoe plugin uninstall`
|
||||
|
||||
Uninstall an external plugin, removing its files and capability grant
|
||||
|
||||
**Usage:** `aoe plugin uninstall <ID>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<ID>` — Plugin id
|
||||
|
||||
|
||||
|
||||
## `aoe plugin hash`
|
||||
|
||||
Print the deterministic source tree hash for a plugin directory, the value a maintainer pins in the featured index
|
||||
|
||||
**Usage:** `aoe plugin hash <PATH>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<PATH>` — Path to the plugin directory
|
||||
|
||||
|
||||
|
||||
## `aoe plugin discover`
|
||||
|
||||
Search GitHub's `aoe-plugin` topic for installable plugins
|
||||
|
||||
**Usage:** `aoe plugin discover [QUERY]`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<QUERY>` — Optional free-text term to narrow the search
|
||||
|
||||
|
||||
|
||||
## `aoe plugin outdated`
|
||||
|
||||
List installed external plugins that have an update available
|
||||
|
||||
**Usage:** `aoe plugin outdated`
|
||||
|
||||
|
||||
|
||||
## `aoe profile`
|
||||
|
||||
Manage profiles (separate workspaces)
|
||||
@@ -1189,13 +1040,13 @@ Inspect resolved settings and their provenance
|
||||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `explain` — Explain where a setting's effective value comes from. KEY is a core `section.field` (e.g. `acp.default_agent`) or a plugin `plugin:<id>.<field>` (e.g. `plugin:acme.kit.retries`)
|
||||
* `explain` — Explain where a setting's effective value comes from. KEY is a `section.field` (e.g. `acp.default_agent`)
|
||||
|
||||
|
||||
|
||||
## `aoe settings explain`
|
||||
|
||||
Explain where a setting's effective value comes from. KEY is a core `section.field` (e.g. `acp.default_agent`) or a plugin `plugin:<id>.<field>` (e.g. `plugin:acme.kit.retries`)
|
||||
Explain where a setting's effective value comes from. KEY is a `section.field` (e.g. `acp.default_agent`)
|
||||
|
||||
**Usage:** `aoe settings explain <KEY>`
|
||||
|
||||
|
||||
@@ -126,14 +126,6 @@ are not user-facing settings. A few things are deliberately not schematized:
|
||||
**Do not use `update_config` for it:** that writes `config.toml`, which
|
||||
strips `app_state` on save, so the change would not persist.
|
||||
|
||||
## Plugin settings
|
||||
|
||||
The above is for core settings. A plugin declares its own settings in its
|
||||
`aoe-plugin.toml` manifest, not in a `Config` struct; the host turns each into a
|
||||
virtual `plugin:<id>` schema section that renders and validates through the same
|
||||
path. See the Tier 0 registries section in
|
||||
[the plugin system internals](internals/plugin-system.md).
|
||||
|
||||
## Breaking changes
|
||||
|
||||
Renaming or relocating a stored field is a breaking change to `config.toml`;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,8 +42,8 @@ Top-level roots:
|
||||
|
||||
An outcome the call site has already classified as expected is **debug**, even
|
||||
when it is a failure underneath: a best-effort `git worktree unlock` that finds
|
||||
nothing to unlock, or a plugin that is not launching because the user switched
|
||||
it off. Those are the configuration and the code working as intended, and at
|
||||
nothing to unlock, or a feature that stays idle because the user switched it
|
||||
off. Those are the configuration and the code working as intended, and at
|
||||
warn they crowd out the failures nobody chose. Where a shared helper cannot
|
||||
tell the two apart, give it a quiet variant the classifying caller opts into
|
||||
(`git::command::run_git_quiet`) rather than dropping the record entirely; the
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# Writing Plugins
|
||||
|
||||
This guide takes you from nothing to an installed, running Agent of Empires
|
||||
plugin. For the full manifest schema see the
|
||||
[Plugin API Reference](../plugin-api.md); for the architecture and security model
|
||||
see [Plugin System Internals](internals/plugin-system.md). For installing and
|
||||
managing plugins as a user, see [Plugins](../plugins.md).
|
||||
|
||||
A plugin is a directory with an `aoe-plugin.toml` manifest and, optionally, a
|
||||
worker: an executable the host spawns that speaks JSON-RPC 2.0 over
|
||||
newline-delimited JSON on stdio, in any language. The host does not link your
|
||||
code; the manifest is the contract.
|
||||
|
||||
## Scaffold from the template
|
||||
|
||||
The official starter generates a complete plugin (manifest, worker, tests, CI)
|
||||
in Python, Node, or Rust:
|
||||
|
||||
```sh
|
||||
cookiecutter gh:agent-of-empires/plugin-template
|
||||
```
|
||||
|
||||
Pick a `runtime` when prompted. The generated project builds, passes its tests,
|
||||
and answers a `status` command out of the box. The rest of this guide explains
|
||||
what it generated.
|
||||
|
||||
## The manifest
|
||||
|
||||
Every plugin declares identity, what it contributes, and (if it has a worker)
|
||||
how to build and launch it:
|
||||
|
||||
```toml
|
||||
id = "dev.example.my-plugin"
|
||||
name = "My Plugin"
|
||||
version = "0.1.0"
|
||||
api_version = 8
|
||||
aoe_version = ">=1.11.0, <2.0.0"
|
||||
description = "What the plugin does."
|
||||
|
||||
capabilities = ["runtime.worker"]
|
||||
|
||||
[[commands]]
|
||||
id = "status"
|
||||
title = "My Plugin: status"
|
||||
description = "Show the status summary."
|
||||
|
||||
[[settings]]
|
||||
key = "enabled"
|
||||
label = "Enable My Plugin"
|
||||
type = "boolean"
|
||||
default = true
|
||||
|
||||
[[ui]]
|
||||
slot = "pane"
|
||||
id = "my_plugin_pane"
|
||||
```
|
||||
|
||||
Pick an `id` outside the reserved `aoe.*` and `agent-of-empires.*` namespaces.
|
||||
Set `api_version` to the schema version you target (currently `8`) and
|
||||
`aoe_version` to the host range you have tested against. Every key is documented
|
||||
in the [Plugin API Reference](../plugin-api.md).
|
||||
|
||||
## Capabilities
|
||||
|
||||
A worker requests only the runtime grants it uses. `runtime.worker` is required
|
||||
to run any code; add `net`, `session.read`, `notifications`, and so on as
|
||||
needed. Static contributions (commands, keybinds, themes, ui, status) need no
|
||||
capability. The user is prompted to grant the exact declared set at install, and
|
||||
the grant is pinned to the manifest hash, so an update that widens capabilities
|
||||
must be re-approved. Keep the list honest and minimal.
|
||||
|
||||
## The worker
|
||||
|
||||
The host spawns the worker, sends one JSON-RPC request per line on stdin, and
|
||||
reads one response per line on stdout. The worker exits when stdin reaches EOF.
|
||||
|
||||
A request, and the response your `status` handler returns:
|
||||
|
||||
```json
|
||||
{"jsonrpc": "2.0", "id": 1, "method": "my-plugin.status", "params": {}}
|
||||
{"jsonrpc": "2.0", "id": 1, "result": {"ok": true, "message": "running"}}
|
||||
```
|
||||
|
||||
The host maps a command id to a fully namespaced method, `plugin.<id>.<command-id>`,
|
||||
so the example above is abbreviated: a worker for `dev.example.my-plugin` actually
|
||||
receives `plugin.dev.example.my-plugin.status`. Dispatch on the trailing segment of
|
||||
`method` so either form works. Return a JSON-RPC error with code `-32601` for an
|
||||
unknown method. A message with no `id` is a notification; do not respond to it.
|
||||
|
||||
## Build and launch
|
||||
|
||||
The worker entrypoint must be **plugin-relative**, never resolved on the
|
||||
daemon's `PATH`. Build into `.aoe-build/`, which the host excludes from the
|
||||
plugin's integrity hash, then point `command` at the built artifact:
|
||||
|
||||
```toml
|
||||
[runtime]
|
||||
kind = "command"
|
||||
command = [".aoe-build/venv/bin/my-plugin-worker"]
|
||||
|
||||
[[runtime.build]]
|
||||
command = ["python3", "-m", "venv", ".aoe-build/venv"]
|
||||
platforms = ["linux", "macos"]
|
||||
|
||||
[[runtime.build]]
|
||||
command = [".aoe-build/venv/bin/pip", "install", "."]
|
||||
platforms = ["linux", "macos"]
|
||||
```
|
||||
|
||||
Build steps run once, at install and update, in the user's interactive shell
|
||||
(where `PATH` is reliable). A compiled plugin can instead ship a release asset
|
||||
with `kind = "release-binary"`; see the reference.
|
||||
|
||||
## Install and test locally
|
||||
|
||||
```sh
|
||||
aoe plugin install ./my-plugin # runs the build steps, prompts for grants
|
||||
aoe plugin list
|
||||
aoe plugin update my-plugin # re-runs build, re-approves changed grants
|
||||
aoe plugin uninstall my-plugin
|
||||
```
|
||||
|
||||
Drive the worker by hand before installing, to confirm the protocol:
|
||||
|
||||
```sh
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"my-plugin.status","params":{}}' | <your-worker>
|
||||
```
|
||||
|
||||
The starter ships a worker-contract test (it spawns the worker, sends a request,
|
||||
and asserts the response) plus its CI. Keep that test green; it is the cheapest
|
||||
guard on the protocol.
|
||||
|
||||
## Publish
|
||||
|
||||
Push a `vX.Y.Z` tag to cut a GitHub release (the starter's release workflow does
|
||||
the rest). Users install the latest release with
|
||||
`aoe plugin install gh:your-org/my-plugin`. To be listed in the Agent of Empires
|
||||
featured index, which lets a plugin claim a verified namespace, open a PR adding
|
||||
your release's source tree hash to the featured index in the main repository.
|
||||
@@ -125,7 +125,7 @@ Requires `cloudflared` on the host:
|
||||
|
||||
### CityHall client mode
|
||||
|
||||
Set the `AOE_CITYHALL_MODE` environment variable (to any value), or pass `--cityhall`, to start the dashboard as a locked-down end-user client: only the message composer and the structured (chat) view are reachable. Terminal and diff panes and project management are hidden in the UI and rejected server-side, so a direct API or WebSocket call cannot reach them either: the terminal keystroke and raw-output routes, git clone/branch/is-repo probes, agent/worker lifecycle and config routes, project CRUD, profile CRUD, the MCP keep/drop/resolve routes, and the plugin install/uninstall/enable/update routes all return 403, and session creation is server-derived (every client-controlled spawn field, including `command_override` and `trust_hooks`, is reset). The session list is filtered to the structured sessions the mode creates, and the session-lifecycle routes (ensure/start/stop/delete/rename/etc) refuse any non-structured target, so a locked-down client cannot enumerate and respawn or destroy a plain/terminal session created by the TUI or another client on the same daemon. Reachability is enforced default-deny by a middleware in front of the router: in CityHall mode every mutating request (POST/PUT/PATCH/DELETE) whose route is not on an explicit allowlist is refused before the handler runs, so a newly added route is closed until it is deliberately classified (an exhaustiveness test fails the build otherwise). The per-route checks remain as defense in depth. Settings are curated down to Theme (without the color-mode and idle-decay knobs; the server also drops a client-supplied color mode), a delete-to-trash toggle (the profile-settings write is field-filtered to just the trash cluster), MCP servers (display only), Telemetry, and Plugins (display only: the marketplace and every lifecycle control are hidden and closed server-side); the profile switcher and all other settings are removed. New sessions are created by name only; each spans every configured project and runs the default agent in structured view, so the deployment's default agent must be ACP-capable (session creation is rejected otherwise, and it fails if no project is configured). Worktrees are enabled by default and the ACP worker ceiling is raised.
|
||||
Set the `AOE_CITYHALL_MODE` environment variable (to any value), or pass `--cityhall`, to start the dashboard as a locked-down end-user client: only the message composer and the structured (chat) view are reachable. Terminal and diff panes and project management are hidden in the UI and rejected server-side, so a direct API or WebSocket call cannot reach them either: the terminal keystroke and raw-output routes, git clone/branch/is-repo probes, agent/worker lifecycle and config routes, project CRUD, profile CRUD, and the MCP keep/drop/resolve routes all return 403, and session creation is server-derived (every client-controlled spawn field, including `command_override` and `trust_hooks`, is reset). The session list is filtered to the structured sessions the mode creates, and the session-lifecycle routes (ensure/start/stop/delete/rename/etc) refuse any non-structured target, so a locked-down client cannot enumerate and respawn or destroy a plain/terminal session created by the TUI or another client on the same daemon. Reachability is enforced default-deny by a middleware in front of the router: in CityHall mode every mutating request (POST/PUT/PATCH/DELETE) whose route is not on an explicit allowlist is refused before the handler runs, so a newly added route is closed until it is deliberately classified (an exhaustiveness test fails the build otherwise). The per-route checks remain as defense in depth. Settings are curated down to Theme (without the color-mode and idle-decay knobs; the server also drops a client-supplied color mode), a delete-to-trash toggle (the profile-settings write is field-filtered to just the trash cluster), MCP servers (display only), and Telemetry; the profile switcher and all other settings are removed. New sessions are created by name only; each spans every configured project and runs the default agent in structured view, so the deployment's default agent must be ACP-capable (session creation is rejected otherwise, and it fails if no project is configured). Worktrees are enabled by default and the ACP worker ceiling is raised.
|
||||
|
||||
```bash
|
||||
AOE_CITYHALL_MODE=1 aoe serve --host 0.0.0.0
|
||||
|
||||
@@ -36,7 +36,7 @@ Individual settings also appear in the palette under `Settings`. A writable togg
|
||||
|
||||
The first time you open the dashboard in a browser, a **Choose your theme** card appears before anything else. Picking a theme applies it live and saves it to your default profile; you can switch freely, then click **Continue**. Change it later in Settings > Appearance. The card is skipped in read-only mode and for anyone who already finished the tutorial.
|
||||
|
||||
After the theme card, an interactive walkthrough highlights the major regions (command bar, sidebar, starting a session, settings, and inside a session the diff panel and composer). Two of its steps open Settings for you: the Worktree tab, explaining the per-session path templates, and the Plugins tab, explaining how to find, install, and trust plugins. Each step lists its keyboard shortcuts and has a **Skip** button.
|
||||
After the theme card, an interactive walkthrough highlights the major regions (command bar, sidebar, starting a session, settings, and inside a session the diff panel and composer). One of its steps opens Settings for you: the Worktree tab, explaining the per-session path templates. Each step lists its keyboard shortcuts and has a **Skip** button.
|
||||
|
||||
Completing or skipping the tour records `app_state.has_seen_web_tour` on the server, so it does not relaunch on reload or on another device pointed at the same server. That flag persists in the server's `state.toml` (a sibling of `config.toml`, see [Configuration Reference](../configuration.md#statetoml)); the `GET /api/settings` JSON key is unchanged, still `app_state.has_seen_web_tour`. To replay it, open the overflow menu and choose **Show tutorial**; re-triggering adapts to where you are (dashboard regions, or composer / mode picker / send controls inside a session). It does not auto-launch on touch devices, where it is menu-only.
|
||||
|
||||
|
||||
@@ -1,660 +0,0 @@
|
||||
# Plugin API Reference
|
||||
|
||||
The field-by-field reference for `aoe-plugin.toml`, the manifest every Agent of
|
||||
Empires plugin ships. The schema lives in the `aoe-plugin-api` crate
|
||||
(`PluginManifest`) and is the source of truth; this page documents it for plugin
|
||||
authors. The host parses the manifest strictly (unknown keys are rejected), so
|
||||
every key here maps to a schema field.
|
||||
|
||||
For a guided introduction see [Writing Plugins](development/writing-plugins.md).
|
||||
To scaffold a working plugin, use the starter template:
|
||||
|
||||
```sh
|
||||
cookiecutter gh:agent-of-empires/plugin-template
|
||||
```
|
||||
|
||||
## Versioning
|
||||
|
||||
A manifest carries two independent version axes.
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `api_version` | The manifest *schema* version. The current schema is `13`. The host rejects a manifest whose `api_version` is newer than it supports. Bump it as you adopt newer sections (see below). |
|
||||
| `aoe_version` | A semver requirement on the *host app* version, e.g. `">=1.11.0, <2.0.0"`. The host refuses to install, and skips loading, a plugin whose requirement excludes the running version. Optional; requires `api_version >= 4`. |
|
||||
|
||||
Schema additions by `api_version`: `2` added contributions (commands, keybinds, settings, ui), `3` added the `pane` UI slot, `4` added `status` and `aoe_version`, `5` added `screenshots`, `6` added a command `action`, `7` added identity icons, `8` added the `composer-action` UI slot, `9` added session-driving worker RPCs (see [Session-driving RPCs](#session-driving-rpcs)), plugin-private storage, and the `dynamic_select` / `object_list` / `cron` settings widgets, `10` added the `tool-card-badge` UI slot, `11` added the `acp.capabilities.probe` RPC + capability, a `thinking` (thought-level) list on the capability response, the `dynamic_multi_select` object-list field widget, and an optional `project_path` (empty = scratch session), `extra_project_paths`, and a `sandbox` flag on `sessions.create`, plus a `multiline` attribute for `string` settings fields, `12` grew the pane block vocabulary (see [Pane payload](#pane-payload)): the `callout`, `bar` and `columns` kinds, clickable `row`s carrying `params`, header summaries and scrollable bodies on `section`, `disabled` / `variant` / `href` on `action`, and a pane-level `footer`, `13` added the global `home-pane` UI slot (a host-wide docked pane carrying the same block vocabulary as `pane`) and the `sparkline` block kind (a history plot with optional per-sample `bands` coloring).
|
||||
|
||||
## Top-level fields
|
||||
|
||||
```toml
|
||||
id = "dev.example.my-plugin"
|
||||
name = "My Plugin"
|
||||
version = "0.1.0"
|
||||
api_version = 8
|
||||
aoe_version = ">=1.11.0, <2.0.0"
|
||||
description = "What the plugin does."
|
||||
capabilities = ["runtime.worker"]
|
||||
```
|
||||
|
||||
| Key | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `id` | string | yes | Plugin id (see [Plugin id](#plugin-id)). Namespaces config, events, and action names. |
|
||||
| `name` | string | yes | Human-readable display name. |
|
||||
| `version` | string | yes | Semantic version of the plugin. |
|
||||
| `api_version` | integer | yes | Manifest schema version, `1` to `13`. |
|
||||
| `description` | string | no | Shown in plugin listings. Defaults to empty. |
|
||||
| `aoe_version` | string | no | Host-app semver requirement. Requires `api_version >= 4`. |
|
||||
| `capabilities` | array of string | no | Runtime grants the worker needs (see [Capabilities](#capabilities)). Static contributions need none. |
|
||||
| `screenshots` | array | no | Up to 8. Requires `api_version >= 5`. See [Screenshots](#screenshots). |
|
||||
| `setting_defaults` | table | no | Overrides for core host settings, keyed by canonical path (e.g. `"theme.idle_decay_minutes"`). Resolution is user value, then plugin override, then core default. |
|
||||
|
||||
## Plugin id
|
||||
|
||||
A dotted, lowercase ASCII identifier such as `dev.example.review-helper`. Each
|
||||
dot-separated segment starts with a lowercase letter and may contain digits and
|
||||
hyphens; the whole id is at most 64 bytes. The `aoe.*` and `agent-of-empires.*`
|
||||
namespaces are reserved for bundled and officially featured plugins; a community
|
||||
install cannot claim them.
|
||||
|
||||
## Capabilities
|
||||
|
||||
Capabilities gate runtime resource access. They are prompted once at install and
|
||||
pinned to the manifest hash; an update that widens them must be re-approved.
|
||||
Declare only what the worker uses. Static contributions (commands, keybinds,
|
||||
themes, ui, status) need no capability.
|
||||
|
||||
| Capability | Grants |
|
||||
|---|---|
|
||||
| `runtime.worker` | Running any plugin code at all (host RPCs the worker initiates). Any worker needs this. |
|
||||
| `session.read` | Reading the attached session. |
|
||||
| `session.write` | Mutating the attached session. |
|
||||
| `config.read` | Reading host or other-plugin configuration (not the plugin's own settings). |
|
||||
| `config.write` | Writing host or other-plugin configuration. |
|
||||
| `process.spawn` | Spawning processes beyond the plugin's own worker. |
|
||||
| `net` | Outbound network access. |
|
||||
| `fs.read` | Filesystem reads outside the plugin directory. |
|
||||
| `fs.write` | Filesystem writes outside the plugin directory. |
|
||||
| `clipboard.read` | Reading the clipboard. |
|
||||
| `clipboard.write` | Writing the clipboard. |
|
||||
| `notifications` | Posting desktop / TUI notifications. |
|
||||
| `browser_open` | Opening a URL in the user's browser from a command `action`. |
|
||||
| `composer.read` | Reading a click-scoped snapshot of the active ACP composer draft from a `composer-action`. |
|
||||
| `composer.write` | Publishing a host-validated draft edit from a `composer-action` UI-state payload. |
|
||||
| `acp.capabilities.read` | Discovering available agents and their advertised models/modes via `acp.capabilities.get` (`api_version >= 9`). |
|
||||
| `acp.capabilities.probe` | Triggering a handshake-only catalog probe via `acp.capabilities.probe`: the host spawns the agent adapter, runs initialize + `session/new` (no prompt turn, so no tokens), records the advertised models/modes/thought-levels, and tears it down. Distinct from `acp.capabilities.read` because it spawns a real process (`api_version >= 11`). |
|
||||
| `session.create` | Creating a host-owned structured session via `sessions.create` (`api_version >= 9`). |
|
||||
| `session.prompt` | Delivering a turn to a session the plugin created via `sessions.turn.send`, and the initial turn on `sessions.create` (`api_version >= 9`). |
|
||||
| `session.unattended` | Creating a session in a host-classified *unattended* approval mode. A distinct, high-severity grant, never implied by `session.create` or `session.prompt` (`api_version >= 9`). See [Session-driving RPCs](#session-driving-rpcs). |
|
||||
|
||||
A capability this host version does not recognize is rejected, not granted.
|
||||
|
||||
## Commands
|
||||
|
||||
Palette and CLI entries, namespaced by the host as `plugin.<id>.<command-id>`.
|
||||
|
||||
```toml
|
||||
[[commands]]
|
||||
id = "status"
|
||||
title = "My Plugin: status"
|
||||
description = "Show the status summary."
|
||||
```
|
||||
|
||||
| Key | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `id` | string | yes | Command id. Empty is unaddressable. |
|
||||
| `title` | string | no | Display name. |
|
||||
| `description` | string | no | Help text. |
|
||||
| `action` | table | no | A client-executed action. Requires `api_version >= 6` and the `browser_open` capability. |
|
||||
|
||||
### Command action
|
||||
|
||||
```toml
|
||||
[commands.action]
|
||||
kind = "open-ui-link"
|
||||
slot = "row-badge"
|
||||
id = "my_badge"
|
||||
```
|
||||
|
||||
The only `kind` is `open-ui-link`: it opens the `href` from the plugin's own
|
||||
`(slot, id)` UI-state entry in the browser, with no worker round-trip. The
|
||||
`(slot, id)` pair must match a declared `[[ui]]` entry on a per-session slot.
|
||||
|
||||
## Keybinds
|
||||
|
||||
```toml
|
||||
[[keybinds]]
|
||||
command = "status"
|
||||
key = "Ctrl+Shift+G"
|
||||
```
|
||||
|
||||
| Key | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `command` | string | yes | Target command id (a plugin or core command). |
|
||||
| `key` | string | yes | Key chord, e.g. `Ctrl+Shift+G`. Core bindings win a collision. |
|
||||
|
||||
## Settings
|
||||
|
||||
Plugin-declared settings, rendered on the TUI and web settings surfaces and
|
||||
stored under `[plugins."<id>".settings]`. The worker reads them via the
|
||||
`config.get` host RPC.
|
||||
|
||||
```toml
|
||||
[[settings]]
|
||||
key = "refresh_secs"
|
||||
label = "Refresh interval (seconds)"
|
||||
description = "How often the worker polls."
|
||||
type = "integer"
|
||||
default = 120
|
||||
min = 0
|
||||
max = 86400
|
||||
advanced = true
|
||||
```
|
||||
|
||||
| Key | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `key` | string | yes | Setting key, stored under the plugin's settings table. |
|
||||
| `label` | string | no | Display label. |
|
||||
| `description` | string | no | Help text. |
|
||||
| `type` | string | no | Value type (see below). Defaults to `string`. |
|
||||
| `options` | array of string | no | Allowed values for `select`; ignored otherwise. |
|
||||
| `min` / `max` | integer | no | Inclusive bounds for `integer`; ignored otherwise. |
|
||||
| `default` | any | no | Declared default. Must match `type`. Absent means the type's zero value. |
|
||||
| `advanced` | bool | no | Group under the Advanced fold. Defaults to `false`. |
|
||||
| `multiline` | bool | no | Render a `string` field as a multi-line textarea; ignored for other types (`api_version >= 11`). |
|
||||
| `option_source` | string | no | Host source for a `dynamic_select` (`api_version >= 9`). |
|
||||
| `depends_on` | array of string | no | Sibling keys whose values parameterize a `dynamic_select` (`api_version >= 9`). |
|
||||
| `fields` | array | no | Item fields of an `object_list` (`api_version >= 9`). |
|
||||
| `item_id_key` | string | no | Item field holding each `object_list` row's stable id; defaults to `_id` (host-generated) (`api_version >= 9`). |
|
||||
| `min_items` / `max_items` | integer | no | Inclusive item-count bounds for an `object_list` (`api_version >= 9`). |
|
||||
|
||||
Setting types:
|
||||
|
||||
| `type` | Widget |
|
||||
|---|---|
|
||||
| `string` | Text input (default). |
|
||||
| `bool` (or `boolean`) | Toggle. |
|
||||
| `integer` | Number input, bounded by `min` / `max`. |
|
||||
| `select` | Dropdown over a non-empty `options` array. |
|
||||
| `dynamic_select` | Dropdown whose choices the host resolves from `option_source` (`api_version >= 9`). |
|
||||
| `dynamic_multi_select` | Multi-select (checkbox list) whose choices the host resolves from `option_source`; the stored value is an array of chosen values. Object-list item fields only (`api_version >= 11`). |
|
||||
| `cron` | Validated 5-field cron expression text field (`api_version >= 9`). |
|
||||
| `object_list` | A repeatable list of structured items described by `fields` (`api_version >= 9`). |
|
||||
|
||||
### Dynamic selects (`api_version >= 9`)
|
||||
|
||||
A `dynamic_select` renders a dropdown whose options the **host** resolves at
|
||||
render time, so the plugin never ships a hardcoded list that could drift from
|
||||
the host's real agents, models, or projects. Set `option_source` to one of:
|
||||
|
||||
| `option_source` | Choices |
|
||||
|---|---|
|
||||
| `acp.agents` | ACP-capable agents the host knows *and whose adapter is installed on this host*. Uninstalled harnesses are not offered. |
|
||||
| `acp.models` | Models the selected agent advertised. Needs the agent via `depends_on`. |
|
||||
| `acp.modes` | Approval modes the selected agent advertised. Needs the agent via `depends_on`. |
|
||||
| `projects` | Registered projects (value is the project path). |
|
||||
| `groups` | Existing session group paths. |
|
||||
|
||||
`depends_on` names sibling keys whose current values parameterize the source;
|
||||
`acp.models` and `acp.modes` require the selected agent. When the selected
|
||||
agent's option catalog has never been discovered, resolving `acp.models` /
|
||||
`acp.modes` runs a one-shot handshake probe (see `acp.capabilities.probe`) to
|
||||
populate it, so the picker self-fills on first open instead of staying empty
|
||||
until the agent has run a live session. Saved ids are advisory: the host
|
||||
revalidates them when a session is actually created, so a model that later
|
||||
disappears from the catalog surfaces as an error at creation, not silently at
|
||||
save.
|
||||
|
||||
### Object lists (`api_version >= 9`)
|
||||
|
||||
An `object_list` is a repeatable list of structured records (for example, a
|
||||
cron plugin's schedule entries), stored on disk as a TOML array of tables under
|
||||
`[[plugins."<id>".settings.<key>]]`. It is **one level deep**: each item field
|
||||
is declared in `fields` and cannot itself be an `object_list`. Every item
|
||||
carries a stable id under `item_id_key` (host-generated on add, never changed on
|
||||
edit or reorder) so a worker can track an entry across edits.
|
||||
|
||||
```toml
|
||||
[[settings]]
|
||||
key = "jobs"
|
||||
label = "Scheduled jobs"
|
||||
type = "object_list"
|
||||
item_id_key = "id"
|
||||
max_items = 50
|
||||
|
||||
[[settings.fields]]
|
||||
key = "agent_id"
|
||||
label = "Agent"
|
||||
type = "dynamic_select"
|
||||
option_source = "acp.agents"
|
||||
required = true
|
||||
|
||||
[[settings.fields]]
|
||||
key = "model_id"
|
||||
label = "Model"
|
||||
type = "dynamic_select"
|
||||
option_source = "acp.models"
|
||||
depends_on = ["agent_id"]
|
||||
|
||||
[[settings.fields]]
|
||||
key = "schedule"
|
||||
label = "Schedule"
|
||||
type = "cron"
|
||||
required = true
|
||||
```
|
||||
|
||||
Each item field takes the same `key` / `label` / `description` / `type` /
|
||||
`options` / `min` / `max` / `default` / `multiline` / `option_source` /
|
||||
`depends_on` keys as a top-level setting, plus `required` (the item must carry a
|
||||
non-empty value). An
|
||||
item field's `type` cannot be `object_list`. An item field may be a
|
||||
`dynamic_multi_select` (`api_version >= 11`): like `dynamic_select` it names an
|
||||
`option_source` and may `depends_on` siblings, but its stored value is an array
|
||||
of the chosen option values.
|
||||
|
||||
## Session-driving RPCs
|
||||
|
||||
With `api_version >= 9` a worker can discover ACP capabilities and create
|
||||
host-owned structured sessions, the primitives an automation plugin (for
|
||||
example a scheduler) needs. These are worker RPCs, not manifest keys; the host
|
||||
enforces a strict security model around them.
|
||||
|
||||
| Method | Capability | Purpose |
|
||||
|---|---|---|
|
||||
| `acp.capabilities.get` | `acp.capabilities.read` | List agents and their advertised models / modes / thought-levels (never launches an agent; a never-run agent reports `catalog_status: undiscovered` with empty lists). |
|
||||
| `acp.capabilities.probe` | `acp.capabilities.probe` | Populate the catalog for one agent (optional `agent_id`; otherwise every undiscovered registry agent) via a handshake-only probe, then return the same shape as `acp.capabilities.get`. Spawns the adapter and runs initialize + `session/new` with **no prompt turn** (no tokens); each probe degrades to a no-op on failure. `api_version >= 11`. |
|
||||
| `sessions.create` | `session.create` (+ `session.prompt` for an initial turn, + `session.unattended` for an unattended mode) | Create a structured session, optionally with an initial turn and a plugin-scoped idempotency key. |
|
||||
| `sessions.turn.send` | `session.prompt` | Deliver a turn to a session **this plugin created**. |
|
||||
| `plugin.storage.get` / `set` / `cas` / `remove` | `runtime.worker` | Plugin-private durable key/value storage (see [Plugin storage](#plugin-storage)). |
|
||||
|
||||
**Project selection (`api_version >= 11`).** `sessions.create` takes an optional
|
||||
`project_path` and an optional `extra_project_paths` array. Omitting
|
||||
`project_path` (or sending it empty) creates a **scratch** session: a throwaway
|
||||
working directory with no repository, hence no trust anchor. When present, the
|
||||
`project_path` is the trust-checked primary repo and each `extra_project_paths`
|
||||
entry is an additional repo of a multi-repo session; combining extras with a
|
||||
scratch session (no `project_path`) is refused. Every path is canonicalized and
|
||||
existence-checked host-side, fail-closed (capped per call).
|
||||
|
||||
**Sandbox (`api_version >= 11`).** Set `sandbox: true` to run the session inside
|
||||
the host's container sandbox. The host uses its own configured sandbox image; a
|
||||
plugin cannot pick an image. Sandboxing only *narrows* what the agent can reach,
|
||||
so it needs no grant beyond `session.create`. The create fails synchronously
|
||||
when no container runtime is installed or running; when one is present the
|
||||
container starts asynchronously after the create returns, so image-pull or
|
||||
startup problems surface on the session later, not as a create error.
|
||||
|
||||
**Approval-mode classification.** The plugin proposes a `mode_id`; the **host**
|
||||
decides its security class, never the plugin. A mode is *interactive* (omitted /
|
||||
adapter default), *guarded* (a reviewed read-only or plan preset), or
|
||||
*unattended* (a bypass or auto-write mode, and every mode the host does not
|
||||
recognize, which fail closed to unattended). An unattended mode requires the
|
||||
distinct `session.unattended` grant on top of `session.create`.
|
||||
|
||||
**Repository trust is enforced regardless of grants.** A session against a
|
||||
repository whose hooks need approval is refused even with `session.unattended`;
|
||||
a plugin cannot pre-approve repository trust. See
|
||||
[Unattended sessions](development/internals/plugin-system.md#unattended-plugin-sessions)
|
||||
for the full model.
|
||||
|
||||
**Ownership.** `sessions.turn.send` only reaches a session the calling plugin
|
||||
created; a plugin cannot deliver turns to a user's or another plugin's session.
|
||||
|
||||
**Idempotency.** `sessions.create` accepts an `idempotency_key` scoped to the
|
||||
plugin: retrying with the same key and payload returns the existing session
|
||||
(`created: false`); a different payload under the same key is a conflict.
|
||||
|
||||
**Limits.** Per plugin: 20 session creates per hour, 5 active plugin-created
|
||||
sessions, 120 turns per hour. Exceeding a limit returns a `rate_limited` /
|
||||
`concurrency_limited` error. Disabling the plugin stops all of its automation.
|
||||
|
||||
**Settings-change events.** After a settings write the host sends the plugin's
|
||||
worker a `plugin.settings.changed` notification carrying `{ revision,
|
||||
changed_keys }`; the worker re-reads the affected values via `config.get`
|
||||
(whose response includes the current `revision`). Polling `config.get` remains
|
||||
a fallback for a worker that was down when the write landed.
|
||||
|
||||
## Plugin storage
|
||||
|
||||
A worker has a host-backed, private key/value store, namespaced by its plugin
|
||||
id, that survives daemon and worker restarts (it is not the install directory,
|
||||
which an upgrade can replace). No capability beyond `runtime.worker` is needed:
|
||||
a plugin can only reach its own namespace.
|
||||
|
||||
| Method | Params | Returns |
|
||||
|---|---|---|
|
||||
| `plugin.storage.get` | `{ key }` | `{ value }` (null if absent) |
|
||||
| `plugin.storage.set` | `{ key, value }` | `{}` |
|
||||
| `plugin.storage.cas` | `{ key, expected, value }` | `{ swapped, current }` |
|
||||
| `plugin.storage.remove` | `{ key }` | `{ removed }` |
|
||||
|
||||
Quotas per plugin: 64 keys, 256-byte keys, 64 KiB values. `cas` (compare-and-swap)
|
||||
enables safe concurrent updates: the write applies only when the stored value
|
||||
equals `expected`.
|
||||
|
||||
## UI slots
|
||||
|
||||
Declares the host-rendered slots the worker pushes state into via the
|
||||
`ui.state.set` host RPC.
|
||||
|
||||
```toml
|
||||
[[ui]]
|
||||
slot = "pane"
|
||||
id = "my_pane"
|
||||
```
|
||||
|
||||
| Key | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `slot` | string | yes | One of the slot names below. Unknown slots are rejected. |
|
||||
| `id` | string | no | Addressing id for `(slot, id)` state pushes. Required to be non-empty when a command `action` targets it. |
|
||||
|
||||
| Slot | Scope | Renders |
|
||||
|---|---|---|
|
||||
| `status-bar` | global | A segment in the dashboard status bar. |
|
||||
| `card` | global | A card on the dashboard overview. |
|
||||
| `sort-key` | global | A named sort option over a `row-column` value. |
|
||||
| `filter-facet` | global | A named filter over a `row-column` value. |
|
||||
| `row-badge` | per-session | A badge on the session row. |
|
||||
| `row-column` | per-session | A text column on the session row. |
|
||||
| `detail-badge` | per-session | A badge in the session detail view. |
|
||||
| `pane` | per-session | A dockable tool-window pane (requires `api_version >= 3`). See [Pane payload](#pane-payload). |
|
||||
| `home-pane` | global | A host-wide docked pane on the dashboard overview and the structured-view pane overlay, carrying the same block vocabulary as `pane` but session-less (requires `api_version >= 13`). Several plugins' home panes stack in snapshot order. |
|
||||
| `settings-page` | global | A full page under Settings, using the same block vocabulary as `pane` (requires `api_version >= 10`). |
|
||||
| `composer-action` | per-session | A button beside the ACP composer controls (requires `api_version >= 8`). |
|
||||
| `tool-card-badge` | per-session | A pill on a transcript MCP or skill tool-call card, matched by target (requires `api_version >= 10`). |
|
||||
| `notification` | n/a | A transient notification pushed via `ui.notify`; gated by the `notifications` capability, not a slot declaration. |
|
||||
|
||||
### Pane payload
|
||||
|
||||
A `pane` entry renders a dockable tool-window. The worker pushes it with
|
||||
`ui.state.set`:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "GitHub",
|
||||
"default_location": "right",
|
||||
"icon": "git-branch",
|
||||
"blocks": [{ "kind": "heading", "text": "GitHub" }],
|
||||
"footer": { "text": "refreshed 12:07", "value": "blocked", "tone": "danger", "icon": "refresh-cw" }
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Notes |
|
||||
|---|---|---|
|
||||
| `title` | string | Shown on the dock tab. |
|
||||
| `body` | string | The simple form: plain text, used only when `blocks` is absent. |
|
||||
| `blocks` | array | The block list (below). Takes precedence over `body`. |
|
||||
| `default_location` | string | `right` or `bottom`. The dock it first opens in; the user can move it after. |
|
||||
| `icon` | string | Lucide name for the activity-bar / dock-tab icon. A manifest `icon_asset` outranks it. |
|
||||
| `footer` | table | A status line pinned below the scrolling block list: `text` left, tone-colored `value` right, plus an optional `icon`. Requires `api_version >= 12`. |
|
||||
|
||||
The whole payload is capped at 64 KiB. Everything else on the entry is validated
|
||||
strictly, but `blocks` is stored as opaque JSON: **each surface renders the kinds
|
||||
it knows and silently drops the rest.** That is the forward-compatibility
|
||||
contract, and it cuts both ways. A new kind needs no host change, and an older
|
||||
host will render nothing for it, so a pane whose layout depends on a newer kind
|
||||
should say so with `api_version` (and `aoe_version`) rather than degrade silently.
|
||||
|
||||
The `settings-page` slot takes the same block vocabulary, minus
|
||||
`default_location` and `footer` (a full page is not docked, so neither has
|
||||
anything to attach to).
|
||||
|
||||
#### Block kinds
|
||||
|
||||
| `kind` | Required | Optional |
|
||||
|---|---|---|
|
||||
| `heading` | `text` | |
|
||||
| `note` | `text` | `tone` |
|
||||
| `divider` | | |
|
||||
| `row` | one of `label` / `value` / `prefix` / `icon` / `avatar` | `sublabel`, `tone`, `value_tone`, `color`, `href`, `tooltip`, `mono`, `selected`, `badges`, `method`, `params` |
|
||||
| `section` | | `title`, `children`, `value`, `value_tone`, `badges`, `icon`, `tone`, `boxed`, `scroll`, `collapsible`, `collapsed` |
|
||||
| `callout` | one of `title` / `detail` | `icon`, `tone`, `color`, `actions` |
|
||||
| `bar` | `segments` | `caption` |
|
||||
| `sparkline` | `values` | `max`, `tone`, `bands`, `caption` (requires `api_version >= 13`) |
|
||||
| `columns` | `children` | |
|
||||
| `action` | `label`, plus one of `method` / `href` / `disabled` | `icon`, `tone`, `tooltip`, `variant` |
|
||||
| `comment` | one of `author` / `body` | `path`, `line`, `resolved`, `href` |
|
||||
|
||||
`tone` is one of `neutral` / `info` / `success` / `warn` / `danger`. `color` is a
|
||||
validated `#rgb` / `#rrggbb` literal for a hue no tone names (a merged PR's
|
||||
purple); anything else is ignored.
|
||||
|
||||
**`row`** lays out at most two lines: `prefix` (mono, tone-tinted) and `label`
|
||||
lead the first with `value` pinned right, and `sublabel` leads the second with
|
||||
`badges` pinned right. `value_tone` colors the trailing token independently of the
|
||||
row, for a status glyph beside a neutral scalar such as a timestamp. `mono`
|
||||
monospaces the row's own text. Each entry in `badges` is `{ text?, icon?, tone?,
|
||||
tooltip? }` and renders as a compact glyph or token, not a pill.
|
||||
|
||||
A `method` makes the row body a button that fires that worker method; an `href`
|
||||
alongside it becomes a separate trailing open-externally link, so a selectable row
|
||||
can still link out. With `href` alone the whole row is the link. `selected` marks
|
||||
the row as the pane's current subject.
|
||||
|
||||
**`section`** groups `children`. The header takes a right-pinned `value` summary
|
||||
or a run of `badges` (count pills). `boxed` draws it as a bordered card, `scroll`
|
||||
caps the body height so a long list scrolls inside the section instead of pushing
|
||||
the rest of the pane away, and `collapsible` folds it via a native `<details>`
|
||||
(`collapsed` sets the initial state).
|
||||
|
||||
**`callout`** is a tone-bordered verdict card: a glyph, a `title`, a `detail`
|
||||
paragraph, and its own `actions` laid out full width. Use it for the one thing the
|
||||
pane is telling the user; use a `section` for a list.
|
||||
|
||||
**`bar`** is a proportional stacked bar over `segments`, each
|
||||
`{ value, tone?, color?, label? }`. Segments without a positive numeric `value` are
|
||||
dropped, and a bar left with nothing renders nothing. `caption` sits beneath it.
|
||||
|
||||
**`sparkline`** plots `values` (an array of numbers, oldest first) as a compact
|
||||
history line. `max` fixes the top of the scale (default: the largest value), so a
|
||||
series plots against a stable ceiling instead of auto-scaling each refresh; a
|
||||
single `tone` colors the whole line. `bands` is a list of `{ at, tone }`
|
||||
thresholds that recolor each sample by the highest band its value reaches, for a
|
||||
green/amber/red pressure line. `caption` sits beneath. An empty `values` renders
|
||||
nothing.
|
||||
|
||||
**`columns`** lays its `children` side by side in equal fractions. A single child
|
||||
spans the full width, so eliding one card collapses the row cleanly rather than
|
||||
leaving a gap.
|
||||
|
||||
**`action`** forwards `method` to the worker (see [Pane actions](#pane-actions)).
|
||||
With `href` and no `method` it is a link-out button instead, for something the host
|
||||
cannot do itself. `disabled` renders it inert, which is how a blocked state reads
|
||||
without pretending to be clickable; a disabled action never navigates either.
|
||||
`variant: "primary"` gives the brand-filled treatment.
|
||||
|
||||
#### Pane actions
|
||||
|
||||
Clicking an `action` block, or a `row` carrying a `method`, POSTs to
|
||||
`/api/plugins/{id}/action` with `{ method, params, session_id }`. `params` is the
|
||||
block's own `params` object, forwarded verbatim, so one method can serve every row
|
||||
in a list:
|
||||
|
||||
```json
|
||||
{ "kind": "row", "label": "warn when daemon is stale", "prefix": "#3231",
|
||||
"method": "github.select_pr", "params": { "pr": "o/r#3231" } }
|
||||
```
|
||||
|
||||
The host merges in the authoritative `session_id` (a plugin cannot spoof it) and
|
||||
delivers the call to the worker as a **fire-and-forget JSON-RPC notification**:
|
||||
there is no reply and no return value. The worker does its work and re-pushes its
|
||||
UI state; the clicked control spins until the plugin's UI revision moves, with a
|
||||
15s timeout fallback. Actions are read-write-mode only and are not passphrase
|
||||
gated, so treat every method as reachable by anyone who can use the dashboard.
|
||||
|
||||
The native TUI renders panes read-only for now: it draws the text of every kind
|
||||
above (dropping icons, hrefs and tooltips, and stacking `columns`) but cannot fire
|
||||
an action, so `action` blocks appear as inert `[action] <label>` labels.
|
||||
|
||||
### Composer action payload
|
||||
|
||||
A `composer-action` entry renders a host-owned button in the web dashboard ACP
|
||||
composer. The worker pushes it with `ui.state.set`:
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "Dictate",
|
||||
"method": "dictation.start",
|
||||
"icon": "mic",
|
||||
"tooltip": "Start dictation",
|
||||
"tone": "info",
|
||||
"disabled": false
|
||||
}
|
||||
```
|
||||
|
||||
`label` and `method` are required. On click, the dashboard POSTs `method` to
|
||||
`/api/plugins/{id}/action` with the active `session_id`. When the plugin has
|
||||
`composer.read`, the forwarded params include:
|
||||
|
||||
```json
|
||||
{
|
||||
"composer": {
|
||||
"text": "current draft",
|
||||
"selection_start": 0,
|
||||
"selection_end": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Without `composer.read`, the server strips that snapshot before forwarding the
|
||||
action to the worker.
|
||||
|
||||
To mutate the draft, include a `draft_operation` in the pushed payload. This
|
||||
requires `composer.write`.
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "Dictate",
|
||||
"method": "dictation.start",
|
||||
"draft_operation": {
|
||||
"kind": "insert-text",
|
||||
"id": "transcript-1",
|
||||
"text": "Hello from dictation."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`kind` is `insert-text`, `replace-selection`, or `set-text`. `id` must be stable
|
||||
and non-empty; the web dashboard applies each operation id once so a persistent
|
||||
UI-state entry cannot replay the edit on every poll.
|
||||
|
||||
### Tool-card badge payload
|
||||
|
||||
A `tool-card-badge` entry attaches provenance pills to transcript tool-call
|
||||
cards. Declare one slot id per session and push a single entry whose `items`
|
||||
list carries every badge you want; the host matches each `item` to a card by its
|
||||
`target`. `target.kind` is `mcp` or `skill` and `target.name` is the raw MCP
|
||||
server name or skill name (matched exactly, not canonicalized), since an MCP
|
||||
server and a skill can share a name. Requires `api_version >= 10`.
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "target": { "kind": "mcp", "name": "github" }, "text": "Company", "tone": "info", "icon": "building-2" },
|
||||
{ "target": { "kind": "skill", "name": "deploy" }, "text": "Verified" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each item needs `text` or `icon` (a badge with neither renders nothing) and a
|
||||
non-empty target name; `tone` and `tooltip` are optional. Empty `items: []`
|
||||
clears the plugin's badges. Rendered in the web dashboard; the native TUI ignores
|
||||
this slot for now.
|
||||
|
||||
## Status
|
||||
|
||||
Status segments the plugin contributes, consumed by the status surface. Requires
|
||||
`api_version >= 4`.
|
||||
|
||||
```toml
|
||||
[[status]]
|
||||
id = "pr_state"
|
||||
label = "PR state"
|
||||
```
|
||||
|
||||
| Key | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `id` | string | yes | Stable segment id. |
|
||||
| `label` | string | no | Human-readable text. |
|
||||
|
||||
## Themes
|
||||
|
||||
```toml
|
||||
[[themes]]
|
||||
name = "My Theme"
|
||||
path = "themes/my-theme.toml"
|
||||
```
|
||||
|
||||
| Key | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | yes | Theme name in the picker. Must not collide with a builtin. |
|
||||
| `path` | string | yes | Theme TOML path, relative to the plugin directory. |
|
||||
|
||||
## Screenshots
|
||||
|
||||
Up to 8 marketplace screenshots, shown in the plugin detail view. Requires
|
||||
`api_version >= 5`.
|
||||
|
||||
```toml
|
||||
[[screenshots]]
|
||||
path = "assets/screenshots/overview.png"
|
||||
alt = "The plugin's pane showing live status."
|
||||
caption = "Live status in the pane."
|
||||
```
|
||||
|
||||
| Key | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `path` | string | yes | Repository-relative image path. No URL scheme, no leading separator, no `..`; must be PNG, JPEG, GIF, or WebP. |
|
||||
| `alt` | string | yes | Accessible description; non-empty. |
|
||||
| `caption` | string | no | Caption shown beneath the image. |
|
||||
|
||||
## Runtime
|
||||
|
||||
The worker the host spawns and supervises. Omit it for a static, metadata-only
|
||||
plugin. Two kinds.
|
||||
|
||||
### Command
|
||||
|
||||
The host runs the build steps at install or update, then launches `command`.
|
||||
|
||||
```toml
|
||||
[runtime]
|
||||
kind = "command"
|
||||
command = [".aoe-build/venv/bin/my-plugin-worker"]
|
||||
|
||||
[[runtime.build]]
|
||||
command = ["python3", "-m", "venv", ".aoe-build/venv"]
|
||||
platforms = ["linux", "macos"]
|
||||
```
|
||||
|
||||
| Key | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `command` | array of string | yes | argv. Plugin-relative by default (must contain a path separator, never absolute) so the daemon's `PATH` never decides whether the worker launches. With `system = true` it must instead be a bare program name resolved on `PATH`. |
|
||||
| `system` | bool | no | Resolve `command[0]` on the host `PATH` (for genuine system tools only). Defaults to `false`. |
|
||||
| `build` | array | no | Ordered build steps, run once at install or update inside the plugin directory, in the user's interactive shell. |
|
||||
|
||||
Build into `.aoe-build/` (the host's build-output directory); the host excludes
|
||||
it from the plugin tree hash, so a venv, `node_modules`, or `target/` there does
|
||||
not break integrity verification.
|
||||
|
||||
#### Build step
|
||||
|
||||
| Key | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `command` | array of string | yes | argv, same resolution policy as the launch `command`. |
|
||||
| `platforms` | array of string | no | Restrict to OS names: `linux`, `macos`, `windows`. Empty runs on all. |
|
||||
|
||||
### Release binary
|
||||
|
||||
The host downloads a release asset instead of building from source.
|
||||
|
||||
```toml
|
||||
[runtime]
|
||||
kind = "release-binary"
|
||||
asset = "my-plugin-${target}.tar.gz"
|
||||
bin = "my-plugin-worker"
|
||||
```
|
||||
|
||||
| Key | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `asset` | string | yes | Asset-name template; `${os}`, `${arch}`, `${target}` are substituted before matching the release. |
|
||||
| `bin` | string | no | Executable path inside the extracted archive. Omit to run the downloaded asset directly (a raw, non-archive binary). |
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
# Plugins
|
||||
|
||||
Agent of Empires keeps its core small (sessions, tmux, worktrees) and grows a
|
||||
plugin system so optional capabilities can be enabled or disabled at runtime
|
||||
instead of bloating the core. The core ships first-party plugins bundled with
|
||||
the binary and can install external community plugins from GitHub or a local
|
||||
directory. Plugins can contribute settings and UI, and workers run through the
|
||||
capability-gated plugin host.
|
||||
|
||||
To build your own, start with [Writing Plugins](development/writing-plugins.md)
|
||||
and the [Plugin API Reference](plugin-api.md). The official starter scaffolds a
|
||||
working plugin in Python, Node, or Rust:
|
||||
|
||||
```sh
|
||||
cookiecutter gh:agent-of-empires/plugin-template
|
||||
```
|
||||
|
||||
## Managing plugins
|
||||
|
||||
Three equivalent surfaces:
|
||||
|
||||
- **CLI**: `aoe plugin list`, `aoe plugin info <id>`, `aoe plugin enable <id>`,
|
||||
`aoe plugin disable <id>`, `aoe plugin install <source>`,
|
||||
`aoe plugin update <id>`, `aoe plugin uninstall <id>`.
|
||||
- **TUI**: open the command palette and run "Manage plugins", or open Settings
|
||||
and select the Plugins tab (the same manager, hosted inline). Space toggles
|
||||
enable/disable.
|
||||
- **Web dashboard**: Settings, then the Plugins tab. The same list and toggles.
|
||||
Enabling or disabling a plugin requires an elevated (passphrase) session when
|
||||
login is enabled and is blocked in read-only mode; localhost browsers skip
|
||||
the passphrase step, matching the CLI's same-host trust model.
|
||||
|
||||
A plugin's enable-state is stored under `[plugins."<id>"]` in `config.toml` and
|
||||
survives every config save.
|
||||
|
||||
## Bundled plugins
|
||||
|
||||
| Plugin | What it does | Disabled behavior |
|
||||
|---|---|---|
|
||||
| `aoe.web` | The web dashboard management marker. Present whenever the dashboard is compiled in (`--features serve`), so every released binary ships it, enabled by default. | When disabled, `aoe serve` is an unrecognized subcommand (hidden from `aoe --help`); re-enable with `aoe plugin enable aoe.web`. `--stop` / `--status` / `--restart` still reach a running daemon. |
|
||||
|
||||
`aoe.web` is the only bundled plugin today, and it rides along with the web
|
||||
dashboard. So a release binary (or any `cargo build --features serve`) shows it
|
||||
in `aoe plugin list`, while a TUI-only build (`cargo build`, no `serve`) has an
|
||||
empty registry and `aoe plugin list` reports no plugins. That is expected, not a
|
||||
bug.
|
||||
|
||||
The bundled set is deliberately minimal while the system is proven out. More
|
||||
first-party plugins land as each piece is verified.
|
||||
|
||||
## Installing external plugins
|
||||
|
||||
External plugins are community code that you install at your own risk. Install,
|
||||
update, and uninstall from the CLI (`aoe plugin`) or from the web dashboard's
|
||||
Plugins settings (Marketplace searches the `aoe-plugin` GitHub topic; each
|
||||
mutating action confirms the plugin's capabilities first). See Trust and
|
||||
capabilities below.
|
||||
|
||||
```sh
|
||||
aoe plugin install gh:owner/repo # latest release (the audited default)
|
||||
aoe plugin install gh:owner/repo@v1.2.3 # an explicit tag, branch, or commit
|
||||
aoe plugin install ./path/to/plugin # a local directory
|
||||
aoe plugin update <id>
|
||||
aoe plugin uninstall <id>
|
||||
```
|
||||
|
||||
With no `@ref`, install resolves the repo's latest stable GitHub release (the
|
||||
audited default path) and installs that tag. An explicit `@ref` installs
|
||||
unverified, un-audited code and asks you to confirm first (`--yes` skips the
|
||||
prompt). If the repo has published no release, install warns and falls back to
|
||||
the default branch behind the same confirmation. The recorded source stays
|
||||
ref-less, so `aoe plugin update` keeps tracking the latest release; an `@ref`
|
||||
install keeps following that ref.
|
||||
|
||||
A plugin lands under `<app_dir>/plugins/<id>/`. A GitHub source is cloned and
|
||||
pinned to the exact commit; if the plugin ships a compiled worker as a release
|
||||
binary, the asset for your platform is downloaded into the plugin directory. To
|
||||
install from a GitHub Enterprise host, set `AOE_GITHUB_CLONE_BASE` to its base
|
||||
URL.
|
||||
|
||||
### Trust and capabilities
|
||||
|
||||
Bundled plugins are `builtin` and fully trusted. Installed plugins are
|
||||
`community` and untrusted: their manifest declares the capabilities they need
|
||||
(network access, filesystem access, spawning processes, and so on), and install
|
||||
prompts you once to grant that exact set. Run non-interactively with `--yes` to
|
||||
grant without prompting. A capability this version of aoe does not recognize is
|
||||
rejected rather than granted; upgrade aoe.
|
||||
|
||||
A grant is pinned to the installed manifest. If an update expands what the
|
||||
plugin can do (new capabilities, changed build steps or UI slots, a runtime or
|
||||
trust change), it must be approved before the new version becomes active. You
|
||||
can approve in a terminal with `aoe plugin update <id>`, or in-app: the web
|
||||
dashboard's plugin settings and the TUI plugin manager show an Update action
|
||||
that opens an approval popup describing exactly what changed. Declining keeps
|
||||
the current version active and stops the prompt from reappearing until the next
|
||||
version. The approval is pinned to the exact fetched content, so an update that
|
||||
changed since you reviewed it is refused rather than applied. `aoe plugin
|
||||
install` and `aoe plugin update` report the resolved trust level (`featured`,
|
||||
`community`, or `local`) in their success output, and `aoe plugin list` and
|
||||
`aoe plugin info <id>` show each plugin's trust level and whether it is granted.
|
||||
An external plugin cannot use the reserved `aoe.*` /
|
||||
`agent-of-empires.*` id namespace.
|
||||
|
||||
Resolved versions live in `<app_dir>/plugins.lock` (the exact commit, manifest
|
||||
hash, and release asset per plugin), so an install is reproducible.
|
||||
@@ -35,10 +35,6 @@ kinds:
|
||||
allow/deny mix, agent switches, plan-mode use, queued prompts),
|
||||
- for `aoe serve` only, coarse deployment enums: auth mode (`token` /
|
||||
`passphrase` / `none`) and exposure (`tunnel` / `tailscale` / `local`),
|
||||
- a plugin census: installed count per source (`builtin` / `featured` /
|
||||
`community` / `local`) and the active state of builtin and featured
|
||||
plugins by id. Unfeatured GitHub and local installs are counted by source
|
||||
but never named,
|
||||
- the version-health signals below.
|
||||
|
||||
Model names are mapped to a coarse family vocabulary (`claude`, `openai`,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# First-party web dashboard plugin marker (P8 of #268).
|
||||
#
|
||||
# The dashboard itself is Tier 2 trusted-native code compiled behind the
|
||||
# `serve` cargo feature; this manifest puts it under plugin management so it
|
||||
# can be disabled at runtime (`aoe plugin disable aoe.web`) independently of
|
||||
# the compile-time feature. `aoe serve` refuses to start while disabled.
|
||||
# Full extraction of the web crate is the last phase of the proving sequence.
|
||||
|
||||
id = "aoe.web"
|
||||
name = "Web Dashboard"
|
||||
version = "1.0.0"
|
||||
api_version = 1
|
||||
description = "The aoe serve web dashboard and REST/WebSocket API. Disable to keep aoe TUI-only at runtime; the serve command refuses to start while disabled."
|
||||
@@ -1,45 +0,0 @@
|
||||
# Curated / featured plugin index (issue #2364).
|
||||
#
|
||||
# This file is compiled into the aoe binary. Each entry is a maintainer's
|
||||
# attestation that an exact plugin source tree was reviewed: install and update
|
||||
# refuse unless the plugin's fetched source slug matches, and a featured entry
|
||||
# is the only thing that lets a community install claim a reserved (`aoe.*` /
|
||||
# `agent-of-empires.*`) namespace.
|
||||
#
|
||||
# Each entry lists one or more vetted releases as `version -> tree_hash`. An
|
||||
# install whose fetched source tree hashes to any listed value is
|
||||
# featured-verified; an install of the same id at an unlisted hash is treated as
|
||||
# an unvetted version (community), not a tamper-refuse (the reserved-namespace
|
||||
# gate still blocks an unvetted version of a reserved-namespace plugin).
|
||||
#
|
||||
# A `tree_hash` is produced by `aoe plugin hash <plugin-dir>` on a clean
|
||||
# checkout. Generate it on a canonical platform with LF line endings; the hash
|
||||
# covers source files only (a release-binary worker is not pinned here, so
|
||||
# release-binary plugins cannot be featured yet). To ship a new release, run
|
||||
# `aoe plugin hash` against the new tag and add a `"<version>" = "sha256:..."`
|
||||
# entry inside the `versions` map alongside the existing ones.
|
||||
#
|
||||
# Schema (one or more vetted releases per plugin id):
|
||||
#
|
||||
# [plugins."agent-of-empires.example"]
|
||||
# source = "gh:agent-of-empires/example"
|
||||
# versions = { "1.0" = "sha256:<hex>", "1.1" = "sha256:<hex>" }
|
||||
|
||||
[plugins."agent-of-empires.github"]
|
||||
source = "gh:agent-of-empires/plugin-github"
|
||||
# 1.1.0 is intentionally unpinned: its `.venv`-in-source-root build bricked at
|
||||
# load (load-time tree_hash trips over the venv symlink, so the reserved
|
||||
# namespace gate skips it). Dropping the pin downgrades it to community so the
|
||||
# reserved-namespace install gate blocks it outright. Fixed in 1.2.0, which
|
||||
# builds under `.aoe-build/`.
|
||||
#
|
||||
# 2.0.0 is the first release whose manifest declares `api_version = 12`, and its
|
||||
# `aoe_version` floor is `>=1.14.0`. It therefore stays unloadable until the aoe
|
||||
# release that carries api_version 12 ships; the load-time host_compat check
|
||||
# skips it on 1.13.x rather than failing startup. Pinning it here is what lets it
|
||||
# claim the reserved namespace once that release lands.
|
||||
versions = {"0.1.0" = "sha256:6b506512d4efbc8a70c4d989188e701e547f5c35bd690c28276df435ced940fc", "1.0.0" = "sha256:b2f293d77244b03ddd98deb4426e96a09184a9454e2424bdd00a8e9d93ff4388", "1.2.0" = "sha256:e5fe509a3e7d39030350d7ee7efcd94623e9ec8ce7707a121804ec95212aba8a", "1.3.0" = "sha256:112fc40480a0dff2abacaa38062f619653a8153ac61bfeab9f14721090ee8e28", "1.4.0" = "sha256:f2d231e8d0877a8092c75efaa06016359be8da30c2c4a9ffe88ee7c7f3dfac18", "1.5.0" = "sha256:c61ae57dd94baf895869279a0ebe63a4bf478c3356a26077a6f269ee16eb5685", "1.6.0" = "sha256:353e30d21a5ceee9c7522cec0ab49f2e66a234c6f6a318a40ec24bd9436de903", "1.6.1" = "sha256:b3067c0483b4fc12d32d5084809190216fe53a1c1e471dabfe8748bf37592511", "1.7.0" = "sha256:98e8c88e133de38df499433ec0a8dc0b1831066a4fc4d37a3bf275dcc6f21606", "1.7.1" = "sha256:7a843bbb4c9ef37d5a367b2305aff62902c7d7111fd25e2c80058c7de255aaca", "1.7.2" = "sha256:dc15857292f26768b17a60689ac24c88486e1c8c3260df625fd6de7a6b8b7ba6", "1.8.0" = "sha256:5f8d13ed463beb1127b4fa207804b6f91b80a94fedcf6e07f3e9e08cbf2fec09", "2.0.0" = "sha256:f5343edc97e1a7f0892064abc07645f7f4d96b6badbd508be3b5ede53b2ac4e7"}
|
||||
|
||||
[plugins."agent-of-empires.cron"]
|
||||
source = "gh:agent-of-empires/plugin-cron"
|
||||
versions = {"1.0.0" = "sha256:b98ab9304a9906bd33acdcc5b4c1f5e9d6eb44db442d7409f82dcd8b0eeac2ac"}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Handshake-only ACP catalog probe (plugin-picker model-discovery fix).
|
||||
//! Handshake-only ACP catalog probe.
|
||||
//!
|
||||
//! The structured-session model / mode / thought-level pickers are fed by the
|
||||
//! `config_options` an agent advertises. Those are cached per-agent in
|
||||
|
||||
+4
-113
@@ -18,15 +18,13 @@ use crate::acp::protocol::{
|
||||
ApprovalDecisionWire, FilesResponse, PromptRequest, ReplayResponse, ResolveApprovalRequest,
|
||||
SwitchAgentRequest, SwitchAgentResponse,
|
||||
};
|
||||
use crate::plugin::ui_state::UiSnapshot;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Percent-encode set for a single URL path segment. A well-formed fqid
|
||||
/// (`plugin.<id>.<command>`, dotted lowercase) is left intact so it round-trips
|
||||
/// to the same string server-side, while structurally dangerous bytes (`/`,
|
||||
/// `?`, `#`, `%`, space, controls) are escaped so a malformed id can never
|
||||
/// break out of its path segment.
|
||||
/// Percent-encode set for a single URL path segment. A well-formed id is left
|
||||
/// intact so it round-trips to the same string server-side, while structurally
|
||||
/// dangerous bytes (`/`, `?`, `#`, `%`, space, controls) are escaped so a
|
||||
/// malformed id can never break out of its path segment.
|
||||
const PATH_SEGMENT: &AsciiSet = &CONTROLS
|
||||
.add(b' ')
|
||||
.add(b'/')
|
||||
@@ -48,27 +46,6 @@ struct SessionsEnvelope<T> {
|
||||
sessions: Vec<T>,
|
||||
}
|
||||
|
||||
/// One active plugin command as the daemon reports it (`GET
|
||||
/// /api/plugins/commands`), the source of truth the structured view resolves
|
||||
/// keybinds against: for a session on a remote daemon the plugin may not be
|
||||
/// installed on the TUI's own machine, so its local registry cannot resolve or
|
||||
/// execute it. Mirrors the server's `PluginCommandView`; only the execution
|
||||
/// fields are kept.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct PluginCommandView {
|
||||
pub fqid: String,
|
||||
pub plugin_id: String,
|
||||
#[serde(default)]
|
||||
pub keybinds: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub action: Option<aoe_plugin_api::ClientAction>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct PluginCommandsEnvelope {
|
||||
commands: Vec<PluginCommandView>,
|
||||
}
|
||||
|
||||
/// What the daemon did with a prompt, from the `/acp/prompt` 202 body. Wire
|
||||
/// mirror of the server's `PromptDispatchResponse`; see
|
||||
/// `docs/development/server-owned-prompt-dispatch.md`.
|
||||
@@ -320,73 +297,6 @@ impl HttpClient {
|
||||
Ok(res.json::<PromptDispatchWire>().await.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `GET /api/plugins/ui-state`. The daemon-wide plugin UI snapshot
|
||||
/// (host-rendered slots + notifications) the web dashboard polls; the
|
||||
/// native structured view renders the TUI-applicable subset (#2402).
|
||||
/// Global, not session-scoped, so a miss must not be classified as a
|
||||
/// session-not-found.
|
||||
pub async fn plugin_ui_state(&self) -> Result<UiSnapshot, HttpError> {
|
||||
let url = format!("{}/api/plugins/ui-state", self.endpoint.base_url);
|
||||
let res = self.auth(self.http.get(&url)).send().await?;
|
||||
let res = check_global_status(res).await?;
|
||||
Ok(res.json::<UiSnapshot>().await?)
|
||||
}
|
||||
|
||||
/// `GET /api/plugins/commands`. The daemon's active plugin commands with
|
||||
/// their keybinds and client actions. The structured view resolves plugin
|
||||
/// chords against this rather than the TUI's local registry, so a session on
|
||||
/// a remote daemon can drive plugins installed only there. Global, like
|
||||
/// `plugin_ui_state`.
|
||||
pub async fn plugin_commands(&self) -> Result<Vec<PluginCommandView>, HttpError> {
|
||||
let url = format!("{}/api/plugins/commands", self.endpoint.base_url);
|
||||
let res = self.auth(self.http.get(&url)).send().await?;
|
||||
let res = check_global_status(res).await?;
|
||||
Ok(res.json::<PluginCommandsEnvelope>().await?.commands)
|
||||
}
|
||||
|
||||
/// `POST /api/plugins/commands/{fqid}/invoke`. Dispatch an action-less
|
||||
/// plugin command to its worker as a fire-and-forget notification (the TUI
|
||||
/// twin of the web palette's invoke). Global, like `plugin_ui_state`; the
|
||||
/// daemon validates the command and session. `fqid` has no slashes, so it
|
||||
/// is a single path segment.
|
||||
pub async fn invoke_plugin_command(
|
||||
&self,
|
||||
fqid: &str,
|
||||
session_id: &str,
|
||||
) -> Result<(), HttpError> {
|
||||
let url = format!(
|
||||
"{}/api/plugins/commands/{}/invoke",
|
||||
self.endpoint.base_url,
|
||||
utf8_percent_encode(fqid, PATH_SEGMENT)
|
||||
);
|
||||
let body = serde_json::json!({ "session_id": session_id });
|
||||
let res = self.auth(self.http.post(&url)).json(&body).send().await?;
|
||||
check_global_status(res).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `POST /api/plugins/{id}/enabled`. Toggling through the daemon (rather
|
||||
/// than writing config locally) lets its plugin host reconcile workers
|
||||
/// live: enabling launches the worker, disabling tears it down. Global,
|
||||
/// like `plugin_ui_state`.
|
||||
pub async fn set_plugin_enabled(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), HttpError> {
|
||||
let url = format!(
|
||||
"{}/api/plugins/{}/enabled",
|
||||
self.endpoint.base_url, plugin_id
|
||||
);
|
||||
let res = self
|
||||
.auth(self.http.post(&url))
|
||||
.json(&serde_json::json!({ "enabled": enabled }))
|
||||
.send()
|
||||
.await?;
|
||||
check_global_status(res).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/{id}/acp/cancel`.
|
||||
pub async fn cancel(&self, session_id: &str) -> Result<(), HttpError> {
|
||||
let url = format!(
|
||||
@@ -678,25 +588,6 @@ async fn check_status(
|
||||
Err(classify_error(status, &body, session_id))
|
||||
}
|
||||
|
||||
/// Status check for daemon-wide (non-session) endpoints. Like
|
||||
/// [`check_status`] but never mints `SessionNotFound`: a 404 here means the
|
||||
/// route is absent (e.g. an older daemon without the plugin UI endpoint),
|
||||
/// not a missing session, so it maps to a plain `Server` error.
|
||||
async fn check_global_status(res: reqwest::Response) -> Result<reqwest::Response, HttpError> {
|
||||
let status = res.status();
|
||||
if status.is_success() {
|
||||
return Ok(res);
|
||||
}
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED => Err(HttpError::Unauthorized),
|
||||
StatusCode::FORBIDDEN if body.contains("read-only") || body.contains("read_only") => {
|
||||
Err(HttpError::ReadOnly)
|
||||
}
|
||||
_ => Err(HttpError::Server { status, body }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a non-success daemon response onto a typed error. Split out from
|
||||
/// `check_status` so the status/body dispatch is unit-testable without a
|
||||
/// live `reqwest::Response`.
|
||||
|
||||
@@ -29,7 +29,7 @@ pub mod ws;
|
||||
|
||||
pub use daemon_manager::{require_daemon, ManagerError};
|
||||
pub use discovery::{discover, DaemonEndpoint, DiscoveryError, Source};
|
||||
pub use http::{HttpClient, HttpError, PluginCommandView, REPLAY_PAGE_SIZE};
|
||||
pub use http::{HttpClient, HttpError, REPLAY_PAGE_SIZE};
|
||||
pub use ws::{
|
||||
connect as ws_connect, connect_with as ws_connect_with, WsError, WsHandle, WsMessage,
|
||||
};
|
||||
|
||||
+2
-2
@@ -460,7 +460,7 @@ pub struct AcpState {
|
||||
pub usage: Option<SessionUsage>,
|
||||
/// Slash commands the agent advertised in its most recent
|
||||
/// `AvailableCommandsUpdate`. Empty until the agent emits one. Used
|
||||
/// by the composer's `/` picker so users see real plugin/skill/MCP
|
||||
/// by the composer's `/` picker so users see real skill/MCP
|
||||
/// commands instead of a hard-coded placeholder list.
|
||||
#[serde(default)]
|
||||
pub available_commands: Vec<AvailableCommand>,
|
||||
@@ -897,7 +897,7 @@ pub enum Event {
|
||||
/// Full snapshot of the slash commands the agent advertises. Comes
|
||||
/// from ACP `SessionUpdate::AvailableCommandsUpdate`. Replaces the
|
||||
/// previous list (the agent re-broadcasts the full set whenever it
|
||||
/// changes; e.g. after plugin enable/disable).
|
||||
/// changes).
|
||||
AvailableCommandsUpdated {
|
||||
commands: Vec<AvailableCommand>,
|
||||
},
|
||||
|
||||
@@ -19,7 +19,6 @@ use super::list::ListArgs;
|
||||
use super::log_level::LogLevelArgs;
|
||||
use super::logs::LogsArgs;
|
||||
use super::mcp::McpCommands;
|
||||
use super::plugin::PluginCommands;
|
||||
use super::profile::ProfileCommands;
|
||||
use super::project::ProjectCommands;
|
||||
use super::ps::PsArgs;
|
||||
@@ -133,12 +132,6 @@ pub enum Commands {
|
||||
command: GroupCommands,
|
||||
},
|
||||
|
||||
/// Manage plugins (list, info, enable, disable, install, update, uninstall)
|
||||
Plugin {
|
||||
#[command(subcommand)]
|
||||
command: PluginCommands,
|
||||
},
|
||||
|
||||
/// Manage profiles (separate workspaces)
|
||||
Profile {
|
||||
#[command(subcommand)]
|
||||
@@ -260,7 +253,6 @@ pub fn command_name(command: &Commands) -> Option<&'static str> {
|
||||
Commands::Stop { .. } => return None,
|
||||
Commands::Session { .. } => "session",
|
||||
Commands::Group { .. } => "group",
|
||||
Commands::Plugin { .. } => "plugin",
|
||||
Commands::Profile { .. } => "profile",
|
||||
Commands::Project { .. } => "project",
|
||||
Commands::Worktree { .. } => "worktree",
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
//! Runtime grafting of plugin-declared commands onto the clap tree.
|
||||
//!
|
||||
//! Core commands stay clap-derive. Active plugins' declared commands are
|
||||
//! appended to the derived [`Command`] at runtime so they appear in `aoe --help`
|
||||
//! and parse. Dispatch tries the core derive first (`Cli::from_arg_matches`); a
|
||||
//! grafted command falls through to [`dispatch_plugin_command`]. Core always
|
||||
//! wins a name conflict: a plugin command whose name collides with a core
|
||||
//! subcommand is not grafted.
|
||||
//!
|
||||
//! Tier 0 has no executor, so a grafted command parses and is discoverable but
|
||||
//! reports that it needs the plugin runtime (#2095) when invoked.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{ArgMatches, Command, CommandFactory};
|
||||
|
||||
use super::definition::Cli;
|
||||
|
||||
/// A command a plugin contributes to the CLI.
|
||||
pub struct PluginCommand {
|
||||
pub plugin_id: String,
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Commands declared by the active plugin set.
|
||||
pub fn plugin_commands() -> Vec<PluginCommand> {
|
||||
let mut out = Vec::new();
|
||||
for p in crate::plugin::registry().active() {
|
||||
for c in &p.manifest.commands {
|
||||
out.push(PluginCommand {
|
||||
plugin_id: p.id().to_string(),
|
||||
name: c.id.clone(),
|
||||
title: c.title.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The clap command augmented with active plugins' commands. A plugin command
|
||||
/// whose name collides with a core subcommand (or an already-grafted plugin
|
||||
/// command) is skipped, so core always wins. When the `aoe.web` plugin is
|
||||
/// disabled, `serve` is hidden from `--help` (it is rejected as unrecognized at
|
||||
/// invocation, see `serve_start_blocked`); this path already loads the registry,
|
||||
/// so no extra cost is added to the fast parse path.
|
||||
pub fn augmented_command() -> Command {
|
||||
let cmd = graft_onto(Cli::command(), plugin_commands());
|
||||
#[cfg(feature = "serve")]
|
||||
let cmd = hide_disabled_serve(cmd, web_disabled());
|
||||
cmd
|
||||
}
|
||||
|
||||
/// True when the builtin `aoe.web` plugin is present and disabled.
|
||||
#[cfg(feature = "serve")]
|
||||
pub fn web_disabled() -> bool {
|
||||
crate::plugin::registry()
|
||||
.get("aoe.web")
|
||||
.is_some_and(|p| !p.enabled)
|
||||
}
|
||||
|
||||
/// Hide `serve` from `--help` when the dashboard plugin is off. It stays
|
||||
/// parseable, since the lifecycle verbs must keep working; a fresh start is
|
||||
/// rejected as unrecognized in `main` (see `serve_start_blocked`).
|
||||
#[cfg(feature = "serve")]
|
||||
fn hide_disabled_serve(cmd: Command, web_disabled: bool) -> Command {
|
||||
if web_disabled {
|
||||
cmd.mut_subcommand("serve", |c| c.hide(true))
|
||||
} else {
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a fresh `aoe serve` start must be rejected as an unrecognized
|
||||
/// subcommand: the command is `serve`, it is not a daemon lifecycle verb
|
||||
/// (`--stop` / `--status` / `--restart`, which must always reach a running
|
||||
/// daemon), and the `aoe.web` plugin is disabled.
|
||||
#[cfg(feature = "serve")]
|
||||
pub fn serve_start_blocked(cli: &Cli, web_disabled: bool) -> bool {
|
||||
let Some(super::definition::Commands::Serve(args)) = &cli.command else {
|
||||
return false;
|
||||
};
|
||||
if args.stop || args.status || args.restart {
|
||||
return false;
|
||||
}
|
||||
web_disabled
|
||||
}
|
||||
|
||||
/// Graft `commands` onto `cmd`, skipping any whose name collides with an
|
||||
/// existing subcommand (core wins) or with an earlier grafted command.
|
||||
fn graft_onto(mut cmd: Command, commands: Vec<PluginCommand>) -> Command {
|
||||
let core: HashSet<String> = cmd
|
||||
.get_subcommands()
|
||||
.map(|s| s.get_name().to_string())
|
||||
.collect();
|
||||
let mut grafted: HashSet<String> = HashSet::new();
|
||||
for pc in commands {
|
||||
if core.contains(&pc.name) || !grafted.insert(pc.name.clone()) {
|
||||
continue;
|
||||
}
|
||||
let about = if pc.title.is_empty() {
|
||||
format!("Plugin command (from {})", pc.plugin_id)
|
||||
} else {
|
||||
format!("{} (from {})", pc.title, pc.plugin_id)
|
||||
};
|
||||
cmd = cmd.subcommand(Command::new(pc.name).about(about));
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Handle a grafted plugin command. At Tier 0 there is no executor, so this
|
||||
/// reports the command is plugin-provided and needs the runtime (#2095).
|
||||
pub fn dispatch_plugin_command(matches: &ArgMatches) -> Result<()> {
|
||||
let Some(name) = matches.subcommand_name() else {
|
||||
anyhow::bail!("no command given");
|
||||
};
|
||||
match plugin_commands().into_iter().find(|p| p.name == name) {
|
||||
Some(pc) => {
|
||||
println!(
|
||||
"'{name}' is a command from plugin '{}'. Running plugin commands needs the \
|
||||
plugin runtime, which is not available yet.",
|
||||
pc.plugin_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
None => anyhow::bail!("unknown command '{name}'"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn augmented_command_keeps_core_subcommands() {
|
||||
// With no active plugins in the test process, augmentation is a no-op:
|
||||
// the command still carries the core subcommands and no others.
|
||||
let core: HashSet<String> = Cli::command()
|
||||
.get_subcommands()
|
||||
.map(|s| s.get_name().to_string())
|
||||
.collect();
|
||||
let augmented: HashSet<String> = augmented_command()
|
||||
.get_subcommands()
|
||||
.map(|s| s.get_name().to_string())
|
||||
.collect();
|
||||
assert_eq!(core, augmented);
|
||||
// Sanity: a known core command is present.
|
||||
assert!(augmented.contains("add"));
|
||||
}
|
||||
|
||||
fn pc(plugin_id: &str, name: &str) -> PluginCommand {
|
||||
PluginCommand {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
name: name.to_string(),
|
||||
title: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graft_onto_skips_core_and_duplicate_names() {
|
||||
let commands = vec![
|
||||
// Collides with the core `add` command: core wins, not grafted.
|
||||
pc("acme.kit", "add"),
|
||||
pc("acme.kit", "do-thing"),
|
||||
// Duplicate of an already-grafted plugin command: skipped.
|
||||
pc("acme.other", "do-thing"),
|
||||
];
|
||||
let cmd = graft_onto(Cli::command(), commands);
|
||||
let names: Vec<&str> = cmd.get_subcommands().map(|s| s.get_name()).collect();
|
||||
assert_eq!(names.iter().filter(|n| **n == "add").count(), 1);
|
||||
assert_eq!(names.iter().filter(|n| **n == "do-thing").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_rejects_unknown_command() {
|
||||
let matches = Cli::command()
|
||||
.try_get_matches_from(["aoe", "agents"])
|
||||
.expect("core agents parses");
|
||||
// `agents` is a core command, not a plugin one: dispatch refuses it
|
||||
// rather than claiming it as a plugin command.
|
||||
assert!(dispatch_plugin_command(&matches).is_err());
|
||||
}
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
fn parse(args: &[&str]) -> Cli {
|
||||
use clap::FromArgMatches;
|
||||
Cli::from_arg_matches(
|
||||
&Cli::command()
|
||||
.try_get_matches_from(args)
|
||||
.expect("args parse"),
|
||||
)
|
||||
.expect("into Cli")
|
||||
}
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
#[test]
|
||||
fn serve_start_blocked_only_when_web_off_and_not_lifecycle() {
|
||||
let start = parse(&["aoe", "serve"]);
|
||||
assert!(serve_start_blocked(&start, true));
|
||||
assert!(!serve_start_blocked(&start, false));
|
||||
// Lifecycle verbs always reach the daemon, even with the plugin off.
|
||||
for verb in ["--stop", "--status", "--restart"] {
|
||||
let c = parse(&["aoe", "serve", verb]);
|
||||
assert!(
|
||||
!serve_start_blocked(&c, true),
|
||||
"{verb} must bypass the gate"
|
||||
);
|
||||
}
|
||||
// A non-serve command is never blocked.
|
||||
assert!(!serve_start_blocked(&parse(&["aoe", "agents"]), true));
|
||||
}
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
#[test]
|
||||
fn hide_disabled_serve_hides_only_when_disabled() {
|
||||
let shown = hide_disabled_serve(Cli::command(), false);
|
||||
assert!(!shown
|
||||
.find_subcommand("serve")
|
||||
.expect("serve present")
|
||||
.is_hide_set());
|
||||
let hidden = hide_disabled_serve(Cli::command(), true);
|
||||
assert!(hidden
|
||||
.find_subcommand("serve")
|
||||
.expect("serve present")
|
||||
.is_hide_set());
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ pub mod agents;
|
||||
pub mod cityhall;
|
||||
pub mod definition;
|
||||
pub mod extract_session_id;
|
||||
pub mod graft;
|
||||
pub mod group;
|
||||
pub mod init;
|
||||
pub mod killall;
|
||||
@@ -17,7 +16,6 @@ pub mod log_level;
|
||||
pub mod logs;
|
||||
pub mod mcp;
|
||||
pub mod output;
|
||||
pub mod plugin;
|
||||
pub mod profile;
|
||||
pub mod project;
|
||||
pub mod ps;
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
//! `aoe plugin`: plugin management (list, info, enable, disable, install,
|
||||
//! update, uninstall).
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum PluginCommands {
|
||||
/// List every known plugin with version, validation, and state
|
||||
List,
|
||||
/// Show one plugin's manifest details
|
||||
Info {
|
||||
/// Plugin id, e.g. `aoe.web`
|
||||
id: String,
|
||||
},
|
||||
/// Enable a plugin's contributions
|
||||
Enable {
|
||||
/// Plugin id
|
||||
id: String,
|
||||
},
|
||||
/// Disable a plugin; its settings stay on disk for re-enabling
|
||||
Disable {
|
||||
/// Plugin id
|
||||
id: String,
|
||||
},
|
||||
/// Install an external plugin from a `gh:owner/repo[@ref]` slug or a local
|
||||
/// directory. With no `@ref`, installs the repo's latest release; an
|
||||
/// explicit `@ref` installs unverified, un-audited code. Community plugins
|
||||
/// run at your own risk.
|
||||
Install {
|
||||
/// `gh:owner/repo` (latest release) or `gh:owner/repo@ref` (unverified)
|
||||
/// or a local directory path
|
||||
source: String,
|
||||
/// Grant all requested capabilities without prompting
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Update an installed external plugin from its recorded source. Prompts to
|
||||
/// re-approve capabilities if the update changes the capability set.
|
||||
Update {
|
||||
/// Plugin id
|
||||
id: String,
|
||||
},
|
||||
/// Uninstall an external plugin, removing its files and capability grant
|
||||
Uninstall {
|
||||
/// Plugin id
|
||||
id: String,
|
||||
},
|
||||
/// Print the deterministic source tree hash for a plugin directory, the
|
||||
/// value a maintainer pins in the featured index
|
||||
Hash {
|
||||
/// Path to the plugin directory
|
||||
path: String,
|
||||
},
|
||||
/// Search GitHub's `aoe-plugin` topic for installable plugins
|
||||
Discover {
|
||||
/// Optional free-text term to narrow the search
|
||||
query: Option<String>,
|
||||
},
|
||||
/// List installed external plugins that have an update available
|
||||
Outdated,
|
||||
}
|
||||
|
||||
pub async fn run(command: PluginCommands) -> Result<()> {
|
||||
match command {
|
||||
PluginCommands::List => run_list(),
|
||||
PluginCommands::Info { id } => run_info(&id),
|
||||
PluginCommands::Enable { id } => run_set_enabled(&id, true).await,
|
||||
PluginCommands::Disable { id } => run_set_enabled(&id, false).await,
|
||||
PluginCommands::Install { source, yes } => run_install(&source, yes).await,
|
||||
PluginCommands::Update { id } => run_update(&id).await,
|
||||
PluginCommands::Uninstall { id } => run_uninstall(&id),
|
||||
PluginCommands::Hash { path } => run_hash(&path),
|
||||
PluginCommands::Discover { query } => run_discover(query.as_deref()).await,
|
||||
PluginCommands::Outdated => run_outdated().await,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_hash(path: &str) -> Result<()> {
|
||||
let hash = crate::plugin::integrity::tree_hash(std::path::Path::new(path))?;
|
||||
println!("{hash}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn state_label(plugin: &crate::plugin::LoadedPlugin) -> &'static str {
|
||||
if !plugin.enabled {
|
||||
"disabled"
|
||||
} else if plugin.needs_reapproval() {
|
||||
"needs approval"
|
||||
} else {
|
||||
"enabled"
|
||||
}
|
||||
}
|
||||
|
||||
fn run_list() -> Result<()> {
|
||||
let registry = crate::plugin::registry();
|
||||
if registry.all().is_empty() {
|
||||
println!("No plugins installed.");
|
||||
} else {
|
||||
println!("{:<20} {:<9} {:<12} STATE", "ID", "VERSION", "VALIDATION");
|
||||
for plugin in registry.all() {
|
||||
println!(
|
||||
"{:<20} {:<9} {:<12} {}",
|
||||
plugin.id(),
|
||||
plugin.manifest.version,
|
||||
plugin.validation.as_str(),
|
||||
state_label(plugin),
|
||||
);
|
||||
}
|
||||
}
|
||||
for err in registry.load_errors() {
|
||||
eprintln!("warning: {err}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_info(id: &str) -> Result<()> {
|
||||
let registry = crate::plugin::registry();
|
||||
let Some(plugin) = registry.get(id) else {
|
||||
anyhow::bail!("unknown plugin {id:?}; see `aoe plugin list`");
|
||||
};
|
||||
let m = &plugin.manifest;
|
||||
println!("{} ({})", m.name, m.id);
|
||||
println!(" version: {}", m.version);
|
||||
println!(" validation: {}", plugin.validation.as_str());
|
||||
println!(" state: {}", state_label(plugin));
|
||||
if let Some(source) = &plugin.source {
|
||||
println!(" source: {source}");
|
||||
}
|
||||
if m.capabilities.is_empty() {
|
||||
println!(" caps: none");
|
||||
} else {
|
||||
let caps: Vec<&str> = m.capabilities.iter().map(|c| c.as_str()).collect();
|
||||
println!(
|
||||
" caps: {} ({})",
|
||||
caps.join(", "),
|
||||
if plugin.granted {
|
||||
"granted"
|
||||
} else {
|
||||
"not granted"
|
||||
}
|
||||
);
|
||||
}
|
||||
if !m.ui.is_empty() {
|
||||
println!(" ui:");
|
||||
for u in &m.ui {
|
||||
println!(" - {} ({})", u.slot.as_str(), u.id);
|
||||
}
|
||||
}
|
||||
if !m.description.is_empty() {
|
||||
println!(" about: {}", m.description);
|
||||
}
|
||||
if !m.keybinds.is_empty() {
|
||||
println!(" keybinds:");
|
||||
for kb in &m.keybinds {
|
||||
// A core binding on the same chord always wins; flag the conflict so
|
||||
// the author knows the plugin keybind will never fire (#2094).
|
||||
// An unparseable key is skipped by the TUI resolver, so flag it
|
||||
// here rather than print it as if it were usable.
|
||||
let note = match crate::tui::home::bindings::parse_chord(&kb.key) {
|
||||
Some(c) if crate::tui::home::bindings::core_shadows(&c) => " (shadowed by core)",
|
||||
Some(_) => "",
|
||||
None => " (invalid key, ignored)",
|
||||
};
|
||||
println!(" {} -> {}{note}", kb.key, kb.command);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_set_enabled(id: &str, enabled: bool) -> Result<()> {
|
||||
use crate::plugin::install::LiveToggle;
|
||||
let outcome = crate::plugin::install::set_enabled_live(id, enabled).await?;
|
||||
println!("{} {id}.", if enabled { "Enabled" } else { "Disabled" });
|
||||
match outcome {
|
||||
LiveToggle::Daemon => println!(" the running daemon reconciled its workers."),
|
||||
LiveToggle::Local => {}
|
||||
LiveToggle::LocalDaemonStale { reason } => println!(
|
||||
" warning: a daemon is running but was not updated ({reason}); restart it or toggle from the dashboard."
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_report(report: &crate::plugin::install::InstallReport, verb: &str) -> String {
|
||||
let mut out = format!("{verb} {} {}.\n", report.id, report.version);
|
||||
out.push_str(&format!(" validation: {}\n", report.validation.as_str()));
|
||||
out.push_str(" capabilities: ");
|
||||
if report.capabilities.is_empty() {
|
||||
out.push_str("none");
|
||||
} else {
|
||||
out.push_str(&report.capabilities.join(", "));
|
||||
}
|
||||
// Surface inactivity whenever the grant did not cover the install, including
|
||||
// the empty-capabilities case (declining a UI-only manifest change leaves a
|
||||
// plugin ungranted with no capabilities to list).
|
||||
if !report.granted {
|
||||
out.push_str(" (not granted, plugin inactive)");
|
||||
} else if !report.capabilities.is_empty() {
|
||||
out.push_str(" (granted)");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn print_report(report: &crate::plugin::install::InstallReport, verb: &str) {
|
||||
println!("{}", format_report(report, verb));
|
||||
}
|
||||
|
||||
async fn run_install(source: &str, yes: bool) -> Result<()> {
|
||||
let report = crate::plugin::install::install(source, yes).await?;
|
||||
print_report(&report, "Installed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_update(id: &str) -> Result<()> {
|
||||
let report = crate::plugin::install::update(id).await?;
|
||||
print_report(&report, "Updated");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_uninstall(id: &str) -> Result<()> {
|
||||
crate::plugin::install::uninstall(id)?;
|
||||
println!("Uninstalled {id}.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_discover(query: Option<&str>) -> Result<()> {
|
||||
let results = crate::plugin::discover::discover(query).await?;
|
||||
if results.is_empty() {
|
||||
println!("No plugins found on the `aoe-plugin` topic.");
|
||||
return Ok(());
|
||||
}
|
||||
println!("{:<11} {:<6} {:<32} ABOUT", "BADGE", "STARS", "SOURCE");
|
||||
for r in &results {
|
||||
let about = r.description.as_deref().unwrap_or("");
|
||||
println!(
|
||||
"{:<11} {:<6} {:<32} {}",
|
||||
r.badge.as_str(),
|
||||
r.stars,
|
||||
r.slug,
|
||||
about
|
||||
);
|
||||
}
|
||||
println!("\nInstall with:\n aoe plugin install <source>");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_outdated() -> Result<()> {
|
||||
let statuses = crate::plugin::update_check::outdated().await;
|
||||
if statuses.is_empty() {
|
||||
println!("No external plugins installed.");
|
||||
return Ok(());
|
||||
}
|
||||
let mut any_outdated = false;
|
||||
for s in &statuses {
|
||||
if let Some(err) = &s.error {
|
||||
println!("error {:<20} {}", s.id, err);
|
||||
} else if s.needs_update {
|
||||
any_outdated = true;
|
||||
let available = s.available.as_deref().unwrap_or("modified");
|
||||
println!("needs update {:<20} {} -> {}", s.id, s.current, available);
|
||||
} else {
|
||||
println!("up to date {:<20} {}", s.id, s.current);
|
||||
}
|
||||
}
|
||||
if any_outdated {
|
||||
println!("\nUpdate with:\n aoe plugin update <id>");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::format_report;
|
||||
use crate::plugin::install::InstallReport;
|
||||
use crate::plugin::registry::ValidationState;
|
||||
|
||||
#[test]
|
||||
fn report_shows_validation_line() {
|
||||
let report = InstallReport {
|
||||
id: "acme.foo".into(),
|
||||
version: "1.2.3".into(),
|
||||
capabilities: vec!["session.read".into(), "filesystem.read".into()],
|
||||
granted: true,
|
||||
validation: ValidationState::Community,
|
||||
};
|
||||
let out = format_report(&report, "Installed");
|
||||
assert_eq!(
|
||||
out,
|
||||
"Installed acme.foo 1.2.3.\n validation: community\n capabilities: session.read, filesystem.read (granted)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_install_validation_labelled_local() {
|
||||
let report = InstallReport {
|
||||
id: "acme.foo".into(),
|
||||
version: "0.1.0".into(),
|
||||
capabilities: vec![],
|
||||
granted: true,
|
||||
validation: ValidationState::Local,
|
||||
};
|
||||
let out = format_report(&report, "Installed");
|
||||
assert!(
|
||||
out.contains("\n validation: local\n"),
|
||||
"local install surfaces its validation: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_with_no_capabilities_still_warns() {
|
||||
// An ungranted update with no capabilities (e.g. a declined UI-only
|
||||
// manifest change) must still flag that the plugin is inactive.
|
||||
let report = InstallReport {
|
||||
id: "acme.foo".into(),
|
||||
version: "0.1.0".into(),
|
||||
capabilities: vec![],
|
||||
granted: false,
|
||||
validation: ValidationState::Community,
|
||||
};
|
||||
let out = format_report(&report, "Updated");
|
||||
assert!(
|
||||
out.ends_with(" capabilities: none (not granted, plugin inactive)"),
|
||||
"inactivity is surfaced with no capabilities: {out:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-8
@@ -7,9 +7,8 @@ use crate::session::settings_schema::{resolve, SettingSource};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum SettingsCommands {
|
||||
/// Explain where a setting's effective value comes from. KEY is a core
|
||||
/// `section.field` (e.g. `acp.default_agent`) or a plugin
|
||||
/// `plugin:<id>.<field>` (e.g. `plugin:acme.kit.retries`).
|
||||
/// Explain where a setting's effective value comes from. KEY is a
|
||||
/// `section.field` (e.g. `acp.default_agent`).
|
||||
Explain {
|
||||
/// The setting key to explain.
|
||||
key: String,
|
||||
@@ -25,10 +24,6 @@ pub fn run(command: SettingsCommands) -> Result<()> {
|
||||
fn source_label(source: &SettingSource) -> String {
|
||||
match source {
|
||||
SettingSource::User => "user value".to_string(),
|
||||
SettingSource::PluginDefault { plugin } => {
|
||||
format!("plugin default ({plugin}, declared, not yet applied at runtime)")
|
||||
}
|
||||
SettingSource::ManifestDefault { plugin } => format!("manifest default ({plugin})"),
|
||||
SettingSource::SchemaDefault => "schema default".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -53,7 +48,7 @@ fn run_explain(key: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
let Some(resolved) = resolve(key) else {
|
||||
bail!("'{key}' is not a known setting. Use a core `section.field` or a `plugin:<id>.<field>` key.");
|
||||
bail!("'{key}' is not a known setting. Use a `section.field` key.");
|
||||
};
|
||||
let value = serde_json::to_string(&resolved.value).unwrap_or_else(|_| "null".to_string());
|
||||
println!("{key} = {value}");
|
||||
|
||||
+6
-25
@@ -1,5 +1,5 @@
|
||||
//! Protocol-agnostic durable event log: the storage substrate behind the
|
||||
//! ACP transcript store and, in time, the plugin host's event bus.
|
||||
//! ACP transcript store.
|
||||
//!
|
||||
//! This module owns the SQLite mechanics that have nothing to do with any
|
||||
//! particular event payload: schema creation, the append + per-topic
|
||||
@@ -289,25 +289,6 @@ pub fn insert_event(
|
||||
.with_context(|| format!("insert {topic}@{seq}"))
|
||||
}
|
||||
|
||||
/// Count events for `topic` whose `created_at` is at or after
|
||||
/// `min_created_at` (same clock as `insert_event`, unix millis). Used by the
|
||||
/// plugin automation policy's rolling-window rate limits (#2897).
|
||||
pub fn count_since(
|
||||
conn: &Connection,
|
||||
schema: &Schema,
|
||||
topic: &str,
|
||||
min_created_at: i64,
|
||||
) -> Result<u64> {
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE session_id = ?1 AND created_at >= ?2",
|
||||
schema.events_table()
|
||||
);
|
||||
let count: i64 = conn
|
||||
.query_row(&sql, params![topic, min_created_at], |row| row.get(0))
|
||||
.with_context(|| format!("count events for {topic}"))?;
|
||||
Ok(count.max(0) as u64)
|
||||
}
|
||||
|
||||
/// Prune the oldest events for `topic` beyond `max_events`, exempting any
|
||||
/// event whose payload starts with one of `pinned_prefixes` (matched on the
|
||||
/// externally-tagged JSON discriminant). Attachment blobs at or below the
|
||||
@@ -888,11 +869,11 @@ mod tests {
|
||||
fn schema_rejects_bad_prefix() {
|
||||
assert!(Schema::new("").is_err());
|
||||
assert!(Schema::new("ACP").is_err());
|
||||
assert!(Schema::new("plugin-host").is_err());
|
||||
assert!(Schema::new("plugin1").is_err());
|
||||
let s = Schema::new("plugin_host").unwrap();
|
||||
assert_eq!(s.events_table(), "plugin_host_events");
|
||||
assert_eq!(s.attachments_table(), "plugin_host_attachments");
|
||||
assert!(Schema::new("event-log").is_err());
|
||||
assert!(Schema::new("log1").is_err());
|
||||
let s = Schema::new("event_log").unwrap();
|
||||
assert_eq!(s.events_table(), "event_log_events");
|
||||
assert_eq!(s.attachments_table(), "event_log_attachments");
|
||||
}
|
||||
|
||||
/// The log is genuinely topic-keyed and payload-opaque: drive it with a
|
||||
|
||||
+1
-107
@@ -5,7 +5,7 @@
|
||||
//! typed [`GitHubError`] taxonomy. Only unauthenticated public reads (such as
|
||||
//! the update check) are wired up today via [`GitHubClient::unauthenticated`].
|
||||
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS, NON_ALPHANUMERIC};
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
@@ -20,10 +20,6 @@ const TAG_SEGMENT: &AsciiSet = &CONTROLS
|
||||
.add(b'%')
|
||||
.add(b'&')
|
||||
.add(b'+');
|
||||
/// Encode a search `q` value: encode everything non-alphanumeric (spaces,
|
||||
/// `:`, etc.) so the qualifier syntax (`topic:aoe-plugin fork:false`) survives
|
||||
/// into the query string intact.
|
||||
const QUERY_VALUE: &AsciiSet = NON_ALPHANUMERIC;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Deserialize;
|
||||
@@ -62,10 +58,6 @@ pub struct GitHubRelease {
|
||||
/// channel the update path tracks.
|
||||
#[serde(default)]
|
||||
pub prerelease: bool,
|
||||
/// Release assets (downloadable binaries). Empty for the update check; used
|
||||
/// by plugin install to fetch a release-binary worker.
|
||||
#[serde(default)]
|
||||
pub assets: Vec<GitHubAsset>,
|
||||
}
|
||||
|
||||
/// The result of comparing two commits (`/compare/{base}...{head}`). Only the
|
||||
@@ -98,34 +90,6 @@ pub struct GitHubCommitInner {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// A single downloadable asset attached to a release.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GitHubAsset {
|
||||
pub name: String,
|
||||
pub browser_download_url: String,
|
||||
}
|
||||
|
||||
/// A repository returned by the search API (the subset plugin discovery shows).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GitHubRepo {
|
||||
/// `owner/repo`.
|
||||
pub full_name: String,
|
||||
#[serde(default)]
|
||||
pub html_url: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stargazers_count: u64,
|
||||
#[serde(default)]
|
||||
pub topics: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SearchReposResponse {
|
||||
#[serde(default)]
|
||||
items: Vec<GitHubRepo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApiErrorBody {
|
||||
message: Option<String>,
|
||||
@@ -200,65 +164,6 @@ impl GitHubClient {
|
||||
self.send_json(self.http.get(url)).await
|
||||
}
|
||||
|
||||
/// `GET /repos/{owner}/{repo}/releases/tags/{tag}`
|
||||
pub async fn release_by_tag(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
tag: &str,
|
||||
) -> Result<GitHubRelease> {
|
||||
// A tag like `release/1.2.3` is valid and must not split into extra path
|
||||
// segments, or the API 404s on a real tag.
|
||||
let tag = utf8_percent_encode(tag, TAG_SEGMENT);
|
||||
let url = format!(
|
||||
"{}/repos/{}/{}/releases/tags/{}",
|
||||
self.api_base, owner, repo, tag
|
||||
);
|
||||
self.send_json(self.http.get(url)).await
|
||||
}
|
||||
|
||||
/// `GET /search/repositories?q={query}&sort=stars&order=desc`
|
||||
///
|
||||
/// Unauthenticated search is heavily rate limited (about 10 requests per
|
||||
/// minute per IP); a 403/429 surfaces as [`GitHubError::RateLimited`] so the
|
||||
/// caller can say so plainly rather than reporting a generic API error.
|
||||
pub async fn search_repositories(&self, query: &str, per_page: u8) -> Result<Vec<GitHubRepo>> {
|
||||
let q = utf8_percent_encode(query, QUERY_VALUE);
|
||||
let url = format!(
|
||||
"{}/search/repositories?q={q}&sort=stars&order=desc&per_page={per_page}",
|
||||
self.api_base
|
||||
);
|
||||
let response: SearchReposResponse = self.send_json(self.http.get(url)).await?;
|
||||
Ok(response.items)
|
||||
}
|
||||
|
||||
/// Fetch a single file's raw contents via the contents API (`Accept:
|
||||
/// application/vnd.github.raw`). Used to read a plugin's `aoe-plugin.toml`
|
||||
/// for the details view without cloning. `reference` pins the branch, tag,
|
||||
/// or commit (`?ref=`); `None` reads the repo's default branch.
|
||||
pub async fn get_repo_file(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
path: &str,
|
||||
reference: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let path = utf8_percent_encode(path, TAG_SEGMENT);
|
||||
let mut url = format!(
|
||||
"{}/repos/{}/{}/contents/{}",
|
||||
self.api_base, owner, repo, path
|
||||
);
|
||||
if let Some(reference) = reference {
|
||||
url.push_str("?ref=");
|
||||
url.extend(utf8_percent_encode(reference, TAG_SEGMENT));
|
||||
}
|
||||
self.send_text(self.http.get(url).header(
|
||||
ACCEPT,
|
||||
HeaderValue::from_static("application/vnd.github.raw"),
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_json<T: DeserializeOwned>(&self, request: reqwest::RequestBuilder) -> Result<T> {
|
||||
let response = request.send().await.map_err(classify_transport_error)?;
|
||||
let status = response.status();
|
||||
@@ -269,17 +174,6 @@ impl GitHubClient {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
Err(classify_status(status, &headers, &body))
|
||||
}
|
||||
|
||||
async fn send_text(&self, request: reqwest::RequestBuilder) -> Result<String> {
|
||||
let response = request.send().await.map_err(classify_transport_error)?;
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
return response.text().await.map_err(GitHubError::Http);
|
||||
}
|
||||
let headers = response.headers().clone();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
Err(classify_status(status, &headers, &body))
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_transport_error(error: reqwest::Error) -> GitHubError {
|
||||
|
||||
+1
-2
@@ -10,8 +10,7 @@ pub mod client;
|
||||
pub mod error;
|
||||
|
||||
pub use client::{
|
||||
GitHubAsset, GitHubClient, GitHubClientConfig, GitHubCompare, GitHubCompareCommit,
|
||||
GitHubRelease, GitHubRepo,
|
||||
GitHubClient, GitHubClientConfig, GitHubCompare, GitHubCompareCommit, GitHubRelease,
|
||||
};
|
||||
pub use error::{GitHubError, Result};
|
||||
|
||||
|
||||
+1
-1
@@ -1959,7 +1959,7 @@ pub fn install_kiro_hooks_with_events(
|
||||
/// `--agent <name>`, returning the path AoE installs its status hooks into.
|
||||
///
|
||||
/// Kiro resolves `--agent <name>` by the `name` field inside each
|
||||
/// `<agents_dir>/*.json`, not by the filename stem. Generators (plugin/managed
|
||||
/// `<agents_dir>/*.json`, not by the filename stem. Generators (managed
|
||||
/// agent tooling) render files as `<prefix>-<name>.json`, so filename and
|
||||
/// logical name diverge; assuming `filename == name` installs into a file Kiro
|
||||
/// never loads and status detection silently fails. Matching the `name` field
|
||||
|
||||
+2
-3
@@ -11,8 +11,8 @@ pub mod claude_settings;
|
||||
pub mod cli;
|
||||
pub mod containers;
|
||||
/// Protocol-agnostic durable event log, the storage substrate behind the
|
||||
/// ACP transcript store and (later) the plugin host's event bus. Serve-gated
|
||||
/// because its only consumer today is the serve-gated acp module.
|
||||
/// ACP transcript store. Serve-gated because its only consumer today is the
|
||||
/// serve-gated acp module.
|
||||
#[cfg(feature = "serve")]
|
||||
pub mod events;
|
||||
pub mod file_watch;
|
||||
@@ -21,7 +21,6 @@ pub mod github;
|
||||
pub mod hooks;
|
||||
pub mod logging;
|
||||
pub mod migrations;
|
||||
pub mod plugin;
|
||||
pub mod process;
|
||||
#[cfg(feature = "serve")]
|
||||
pub mod server;
|
||||
|
||||
@@ -130,7 +130,6 @@ pub const DEFAULT_TARGET_ROOTS: &[&str] = &[
|
||||
"containers",
|
||||
"git",
|
||||
"migrations",
|
||||
"plugin",
|
||||
"web",
|
||||
// `log` is the meta-target prefix for filter-swap audit events
|
||||
// (`log.runtime`). Without this, `log.runtime` would be dropped
|
||||
@@ -167,7 +166,6 @@ pub const KNOWN_SUB_TARGETS: &[&str] = &[
|
||||
"acp.supervisor",
|
||||
"acp.event_store",
|
||||
"acp.runner",
|
||||
"plugin.host",
|
||||
"terminal.ws",
|
||||
"terminal.ws.bytes",
|
||||
"auth.token",
|
||||
|
||||
+4
-46
@@ -5,7 +5,7 @@ use agent_of_empires::logging::{self, LogConfig, ProcessContext, SubscriberTarge
|
||||
use agent_of_empires::migrations;
|
||||
use agent_of_empires::tui;
|
||||
use anyhow::Result;
|
||||
use clap::{CommandFactory, FromArgMatches, Parser};
|
||||
use clap::{CommandFactory, Parser};
|
||||
use clap_complete::generate;
|
||||
|
||||
/// Did the user invoke `aoe serve`? Feature-gated because `Commands::Serve`
|
||||
@@ -57,23 +57,6 @@ fn is_serve_daemon_child(_cli: &Cli) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// When the `aoe.web` plugin is disabled, a fresh `aoe serve` start behaves as
|
||||
/// an unrecognized subcommand rather than starting the dashboard (the dashboard
|
||||
/// surface is a plugin, so a disabled plugin means the command is not available).
|
||||
/// The daemon lifecycle verbs (`--stop` / `--status` / `--restart`) stay usable
|
||||
/// so a running daemon can always be inspected and brought down. Returns the
|
||||
/// clap error to raise, or `None` when the invocation is allowed. Only the
|
||||
/// caller calls `.exit()`, so the decision stays unit-testable.
|
||||
#[cfg(feature = "serve")]
|
||||
fn serve_unavailable_error(cli: &Cli) -> Option<clap::Error> {
|
||||
cli::graft::serve_start_blocked(cli, cli::graft::web_disabled()).then(|| {
|
||||
Cli::command().error(
|
||||
clap::error::ErrorKind::InvalidSubcommand,
|
||||
"unrecognized subcommand 'serve'",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Hidden internal helper for the VT live-preview path (`[tmux] vt_live`,
|
||||
@@ -118,30 +101,7 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the core clap tree first. On success (every valid core command,
|
||||
// including the app-data-free ones like completion/init/agents) this never
|
||||
// touches the plugin registry. Only an error, --help/--version, or an
|
||||
// unknown subcommand falls through to the augmented tree, which grafts
|
||||
// active plugins' commands (loading the registry); there a grafted plugin
|
||||
// command is dispatched to the plugin handler, and core wins name conflicts.
|
||||
let cli = match Cli::try_parse() {
|
||||
Ok(cli) => cli,
|
||||
Err(_) => {
|
||||
let matches = cli::graft::augmented_command().get_matches();
|
||||
match Cli::from_arg_matches(&matches) {
|
||||
Ok(cli) => cli,
|
||||
Err(_) => return cli::graft::dispatch_plugin_command(&matches),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// With the `aoe.web` plugin disabled, a fresh `aoe serve` start is treated
|
||||
// as an unrecognized subcommand. Done here, before any logging/app-dir side
|
||||
// effects, so a rejected start creates no serve log or ProcessContext.
|
||||
#[cfg(feature = "serve")]
|
||||
if let Some(err) = serve_unavailable_error(&cli) {
|
||||
err.exit();
|
||||
}
|
||||
let cli = Cli::parse();
|
||||
|
||||
// If the user passed --daemon-url, mirror the value into the env
|
||||
// var so the acp::client::discovery layer (used by both the
|
||||
@@ -306,9 +266,8 @@ async fn main() -> Result<()> {
|
||||
// sees. It is skipped for the detached `--daemon-child`, whose stderr is
|
||||
// already redirected into the same log file by `cli::serve`, so printing
|
||||
// would duplicate the tracing line. Errors before logging init bypass the
|
||||
// sink: clap parse and the serve-availability check exit through clap,
|
||||
// while the pre-clap `__vt-pipe` / `__smart-rename` helpers and the
|
||||
// plugin-command dispatch return before it. That pre-init window is a
|
||||
// sink: clap parse exits through clap, while the pre-clap `__vt-pipe` /
|
||||
// `__smart-rename` helpers return before it. That pre-init window is a
|
||||
// known limitation.
|
||||
if let Err(e) = run(cli, should_init, debug_namespace_drift, debug_log_warning).await {
|
||||
tracing::error!(target: "log.runtime", "fatal: {e:#}");
|
||||
@@ -435,7 +394,6 @@ async fn run(
|
||||
Some(Commands::Killall(args)) => cli::killall::run(args).await,
|
||||
Some(Commands::Session { command }) => cli::session::run(&profile, command).await,
|
||||
Some(Commands::Group { command }) => cli::group::run(&profile, command).await,
|
||||
Some(Commands::Plugin { command }) => cli::plugin::run(command).await,
|
||||
Some(Commands::Profile { command }) => cli::profile::run(&profile, command).await,
|
||||
Some(Commands::Project { command }) => {
|
||||
cli::project::run(&profile, profile_explicit, command).await
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
//! Opt-in clean-only plugin auto-update sweep at startup.
|
||||
//!
|
||||
//! Gated on `updates.auto_update_plugins` (off by default). When on, the TUI and
|
||||
//! `aoe serve` spawn [`spawn_if_enabled`] at startup; it checks installed
|
||||
//! external plugins for updates and applies only the ones that need no new
|
||||
//! consent. Anything that changes capabilities, build steps, or UI slots is
|
||||
//! skipped and left for a manual `aoe plugin update`, so a background sweep never
|
||||
//! grants new capabilities or runs a changed build step unattended, and never
|
||||
//! deactivates a working plugin.
|
||||
//!
|
||||
//! ponytail: no cross-process lock around the sweep; it runs once at startup and
|
||||
//! the pre-existing install/update path is itself unguarded. Add an on-disk
|
||||
//! plugin-op lock if concurrent CLI/daemon mutation becomes a real problem.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::session::Config;
|
||||
|
||||
use super::{install, update_check};
|
||||
|
||||
/// Surfaces a consent-needed auto-update skip in-product. Kept abstract so this
|
||||
/// module (which compiles in TUI-only builds) never references the serve-gated
|
||||
/// plugin host. The `aoe serve` daemon implements it on `PluginHost`.
|
||||
pub trait UpdateNotifier: Send + Sync {
|
||||
fn needs_approval(&self, plugin_id: &str, reason: &str);
|
||||
}
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
impl UpdateNotifier for super::host::PluginHost {
|
||||
fn needs_approval(&self, plugin_id: &str, reason: &str) {
|
||||
self.notify_host(
|
||||
plugin_id,
|
||||
super::ui_state::Tone::Warn,
|
||||
format!("Update for {plugin_id} needs approval"),
|
||||
Some(reason.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a sweep did, for logging and tests.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SweepSummary {
|
||||
pub applied: Vec<String>,
|
||||
pub skipped: Vec<(String, String)>,
|
||||
pub errors: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Check outdated external plugins and apply only the clean updates. Logs each
|
||||
/// outcome. Safe to call regardless of the setting; callers gate on it via
|
||||
/// [`spawn_if_enabled`].
|
||||
///
|
||||
/// When a `notifier` is present (the `aoe serve` daemon), an update skipped
|
||||
/// because it needs fresh consent also surfaces a notification so the dashboard
|
||||
/// shows it in-product instead of only logging a CLI instruction, unless the
|
||||
/// user already dismissed that exact version.
|
||||
pub async fn sweep(notifier: Option<&Arc<dyn UpdateNotifier>>) -> SweepSummary {
|
||||
let mut summary = SweepSummary::default();
|
||||
for status in update_check::outdated().await {
|
||||
if let Some(error) = &status.error {
|
||||
tracing::warn!(
|
||||
target: "plugin.auto_update",
|
||||
plugin = %status.id,
|
||||
%error,
|
||||
"could not check plugin for updates",
|
||||
);
|
||||
summary.errors.push((status.id.clone(), error.clone()));
|
||||
continue;
|
||||
}
|
||||
if !status.needs_update {
|
||||
continue;
|
||||
}
|
||||
match install::update_clean(&status.id).await {
|
||||
Ok(install::UpdateOutcome::Applied(report)) => {
|
||||
tracing::info!(
|
||||
target: "plugin.auto_update",
|
||||
plugin = %report.id,
|
||||
version = %report.version,
|
||||
"auto-updated plugin",
|
||||
);
|
||||
summary.applied.push(report.id);
|
||||
}
|
||||
Ok(install::UpdateOutcome::Skipped {
|
||||
id,
|
||||
reason,
|
||||
fingerprint,
|
||||
}) => {
|
||||
tracing::info!(
|
||||
target: "plugin.auto_update",
|
||||
plugin = %id,
|
||||
%reason,
|
||||
"skipped plugin auto-update; run `aoe plugin update` to review",
|
||||
);
|
||||
if let Some(notifier) = notifier {
|
||||
if !already_dismissed(&id, &fingerprint) {
|
||||
notifier.needs_approval(&id, &reason);
|
||||
}
|
||||
}
|
||||
summary.skipped.push((id, reason));
|
||||
}
|
||||
Err(e) => {
|
||||
let error = format!("{e:#}");
|
||||
tracing::warn!(
|
||||
target: "plugin.auto_update",
|
||||
plugin = %status.id,
|
||||
%error,
|
||||
"plugin auto-update failed",
|
||||
);
|
||||
summary.errors.push((status.id, error));
|
||||
}
|
||||
}
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
/// Whether the user already dismissed in-app the exact version a sweep skipped,
|
||||
/// so the sweep does not re-notify on every daemon restart.
|
||||
fn already_dismissed(id: &str, fingerprint: &str) -> bool {
|
||||
Config::load()
|
||||
.ok()
|
||||
.and_then(|c| c.plugins.get(id).and_then(|p| p.dismissed_update.clone()))
|
||||
.as_deref()
|
||||
== Some(fingerprint)
|
||||
}
|
||||
|
||||
/// Spawn the sweep in the background when the setting opts in. Non-blocking so
|
||||
/// startup is never delayed by network or git; the registry is reloaded inside
|
||||
/// `install::update_clean` as each update lands. `notifier` is the running
|
||||
/// plugin host (`aoe serve`), used to surface consent-needed skips as
|
||||
/// notifications; `None` in TUI-only contexts, where there is no ring.
|
||||
pub fn spawn_if_enabled(config: &Config, notifier: Option<Arc<dyn UpdateNotifier>>) {
|
||||
if !config.updates.auto_update_plugins {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
let summary = sweep(notifier.as_ref()).await;
|
||||
tracing::info!(
|
||||
target: "plugin.auto_update",
|
||||
applied = summary.applied.len(),
|
||||
skipped = summary.skipped.len(),
|
||||
errors = summary.errors.len(),
|
||||
"plugin auto-update sweep complete",
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
//! Host-owned policy for plugin-driven session automation (#2897):
|
||||
//! approval-mode classification and the durable audit ledger behind it.
|
||||
//!
|
||||
//! Classification is security policy, so it never derives from agent- or
|
||||
//! plugin-supplied metadata: the option catalog only proves a mode is
|
||||
//! currently AVAILABLE, while the trusted table below (plus the adapter
|
||||
//! profiles' bypass ids) decides what a mode is ALLOWED to do. Unknown modes
|
||||
//! classify as unattended, fail closed.
|
||||
//!
|
||||
//! The ledger is a second, host-private [`crate::events`] schema inside the
|
||||
//! existing `plugin_events.db`. It is never addressable from worker RPCs
|
||||
//! (workers reach only the public event-bus schema), so a plugin cannot read
|
||||
//! or forge policy records.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use rusqlite::Connection;
|
||||
|
||||
use aoe_plugin_api::acp::ApprovalClass;
|
||||
|
||||
use crate::acp::option_catalog::AgentOptionEntry;
|
||||
use crate::acp::state::ConfigOptionCategory;
|
||||
use crate::events;
|
||||
/// Per-topic ledger cap.
|
||||
const LEDGER_RETENTION_PER_TOPIC: usize = 2000;
|
||||
|
||||
/// Reviewed approval semantics for mode ids the host understands. The
|
||||
/// less-restrictive entries (`default`, `plan`) are honored only for a reviewed
|
||||
/// adapter (see [`classify_mode`]); an unreviewed agent reusing one of these ids
|
||||
/// falls back to unattended. Everything else: the adapter profile's bypass id is
|
||||
/// unattended, and an unknown id classifies unattended.
|
||||
const TRUSTED_MODE_TABLE: &[(&str, ApprovalClass)] = &[
|
||||
// Adapter default approval-prompting presets.
|
||||
("default", ApprovalClass::Interactive),
|
||||
// Claude's plan preset: read/analyze, edits still prompt.
|
||||
("plan", ApprovalClass::Guarded),
|
||||
// Auto-writes files without a human approving each edit: unattended
|
||||
// behavior even though shell commands still prompt.
|
||||
("acceptEdits", ApprovalClass::Unattended),
|
||||
// Adapter bypass ids (also covered by yolo_mode_id resolution).
|
||||
("bypassPermissions", ApprovalClass::Unattended),
|
||||
("agent-full-access", ApprovalClass::Unattended),
|
||||
("yolo", ApprovalClass::Unattended),
|
||||
];
|
||||
|
||||
/// Outcome of classifying a requested approval mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ModeDecision {
|
||||
/// The mode is usable; enforce the class (unattended needs the grant).
|
||||
Class(ApprovalClass),
|
||||
/// The mode id is neither trusted-table-known nor advertised by the
|
||||
/// agent's discovered catalog.
|
||||
UnknownMode,
|
||||
/// An explicit mode was requested but the agent's catalog has never
|
||||
/// been discovered and the id is not independently known; refusing is
|
||||
/// safer than guessing.
|
||||
CatalogNotDiscovered,
|
||||
}
|
||||
|
||||
/// Classify `mode_id` for `agent_key`. `catalog` is the agent's last
|
||||
/// advertised option snapshot, `None` when never discovered.
|
||||
///
|
||||
/// The benign classifications (an omitted default, or the `default`/`plan`
|
||||
/// table entries) describe the *reviewed* adapters' conventions. An unreviewed
|
||||
/// agent (one with no static profile) could advertise a mode literally named
|
||||
/// `default` or `plan`, or ship an omitted default that silently auto-applies,
|
||||
/// so its less-restrictive treatment is not trusted: those cases fail closed to
|
||||
/// unattended and thus require `session.unattended`. The always-unattended
|
||||
/// entries and adapter bypass ids apply regardless.
|
||||
pub(crate) fn classify_mode(
|
||||
agent_key: &str,
|
||||
mode_id: Option<&str>,
|
||||
catalog: Option<&AgentOptionEntry>,
|
||||
) -> ModeDecision {
|
||||
let profile = crate::acp::agent_profiles::resolve(agent_key);
|
||||
// Only adapters with a reviewed static profile get the benign treatment;
|
||||
// anything falling back to DEFAULT fails closed.
|
||||
let reviewed = crate::acp::agent_profiles::is_reviewed(agent_key);
|
||||
|
||||
let Some(mode_id) = mode_id else {
|
||||
// Omitted mode = the agent's own default. Trusted to prompt only for a
|
||||
// reviewed adapter; an unreviewed default could auto-apply, so fail
|
||||
// closed.
|
||||
return ModeDecision::Class(if reviewed {
|
||||
ApprovalClass::Interactive
|
||||
} else {
|
||||
ApprovalClass::Unattended
|
||||
});
|
||||
};
|
||||
if profile.yolo_mode_id == Some(mode_id) {
|
||||
return ModeDecision::Class(ApprovalClass::Unattended);
|
||||
}
|
||||
if let Some((_, class)) = TRUSTED_MODE_TABLE.iter().find(|(id, _)| *id == mode_id) {
|
||||
// Honor a less-restrictive class only for a reviewed adapter; an
|
||||
// unreviewed agent reusing the id gets the fail-closed treatment.
|
||||
let effective = if *class == ApprovalClass::Unattended || reviewed {
|
||||
*class
|
||||
} else {
|
||||
ApprovalClass::Unattended
|
||||
};
|
||||
return ModeDecision::Class(effective);
|
||||
}
|
||||
let Some(catalog) = catalog else {
|
||||
return ModeDecision::CatalogNotDiscovered;
|
||||
};
|
||||
let advertised = catalog.options.iter().any(|opt| {
|
||||
opt.category == ConfigOptionCategory::Mode
|
||||
&& opt.options.iter().any(|choice| choice.value == mode_id)
|
||||
});
|
||||
if advertised {
|
||||
// Available but semantically unknown to the host: fail closed.
|
||||
ModeDecision::Class(ApprovalClass::Unattended)
|
||||
} else {
|
||||
ModeDecision::UnknownMode
|
||||
}
|
||||
}
|
||||
|
||||
/// Durable audit ledger. One per daemon, owned by the plugin host.
|
||||
pub struct AutomationPolicy {
|
||||
/// Ledger connection; sync mutex, tiny critical sections, no `await`
|
||||
/// while held (callers run queries via spawn_blocking-free short calls;
|
||||
/// the SQLite file is local and the tables are indexed by topic).
|
||||
ledger: std::sync::Mutex<Ledger>,
|
||||
}
|
||||
|
||||
struct Ledger {
|
||||
conn: Connection,
|
||||
schema: events::Schema,
|
||||
}
|
||||
|
||||
impl AutomationPolicy {
|
||||
/// Open (creating on first use) the private audit schema inside the
|
||||
/// plugin event-bus database.
|
||||
pub(crate) fn open(plugin_events_db: &Path) -> Result<Self> {
|
||||
let schema = events::Schema::new("plugin_automation_audit")?;
|
||||
let conn = events::open(plugin_events_db, &schema)?;
|
||||
Ok(Self {
|
||||
ledger: std::sync::Mutex::new(Ledger { conn, schema }),
|
||||
})
|
||||
}
|
||||
|
||||
/// Record a policy decision or operation outcome in the audit ledger.
|
||||
/// Best-effort: auditing must never fail the operation itself. Never
|
||||
/// records prompt contents.
|
||||
pub(crate) fn audit(&self, plugin_id: &str, record: serde_json::Value) {
|
||||
let topic = format!("decision/{plugin_id}");
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let ledger = self.ledger.lock().expect("ledger mutex poisoned");
|
||||
let seq = events::highest_seq(&ledger.conn, &ledger.schema, &topic) + 1;
|
||||
if let Err(e) = events::insert_event(
|
||||
&ledger.conn,
|
||||
&ledger.schema,
|
||||
&topic,
|
||||
seq,
|
||||
&record.to_string(),
|
||||
now,
|
||||
) {
|
||||
tracing::warn!(
|
||||
target: "plugin.automation",
|
||||
plugin = %plugin_id,
|
||||
"audit record write failed: {e:#}"
|
||||
);
|
||||
}
|
||||
events::prune_retention(
|
||||
&ledger.conn,
|
||||
&ledger.schema,
|
||||
&topic,
|
||||
LEDGER_RETENTION_PER_TOPIC,
|
||||
&[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::acp::state::{ConfigOptionChoice, ConfigOptionDescriptor};
|
||||
|
||||
fn catalog_with_modes(modes: &[&str]) -> AgentOptionEntry {
|
||||
AgentOptionEntry {
|
||||
updated_at: "2026-07-16T00:00:00Z".to_string(),
|
||||
options: vec![ConfigOptionDescriptor {
|
||||
id: "mode".to_string(),
|
||||
name: "Mode".to_string(),
|
||||
description: None,
|
||||
category: ConfigOptionCategory::Mode,
|
||||
current_value: String::new(),
|
||||
options: modes
|
||||
.iter()
|
||||
.map(|m| ConfigOptionChoice {
|
||||
value: (*m).to_string(),
|
||||
name: (*m).to_string(),
|
||||
description: None,
|
||||
})
|
||||
.collect(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_table() {
|
||||
use ApprovalClass::*;
|
||||
use ModeDecision::*;
|
||||
let catalog = catalog_with_modes(&["default", "plan", "acceptEdits", "customMode"]);
|
||||
|
||||
// Omitted mode: adapter default, interactive.
|
||||
assert_eq!(classify_mode("claude", None, None), Class(Interactive));
|
||||
// Adapter bypass id from the profile: unattended, catalog or not.
|
||||
assert_eq!(
|
||||
classify_mode("claude", Some("bypassPermissions"), None),
|
||||
Class(Unattended)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_mode("codex", Some("agent-full-access"), None),
|
||||
Class(Unattended)
|
||||
);
|
||||
// Trusted table entries work without a discovered catalog.
|
||||
assert_eq!(classify_mode("claude", Some("plan"), None), Class(Guarded));
|
||||
assert_eq!(
|
||||
classify_mode("claude", Some("default"), None),
|
||||
Class(Interactive)
|
||||
);
|
||||
// acceptEdits auto-writes: unattended even though advertised.
|
||||
assert_eq!(
|
||||
classify_mode("claude", Some("acceptEdits"), Some(&catalog)),
|
||||
Class(Unattended)
|
||||
);
|
||||
// Advertised but unknown semantics: fail closed to unattended.
|
||||
assert_eq!(
|
||||
classify_mode("claude", Some("customMode"), Some(&catalog)),
|
||||
Class(Unattended)
|
||||
);
|
||||
// Not advertised, not known: invalid.
|
||||
assert_eq!(
|
||||
classify_mode("claude", Some("nope"), Some(&catalog)),
|
||||
UnknownMode
|
||||
);
|
||||
// Explicit unknown mode with no catalog yet: refuse rather than guess.
|
||||
assert_eq!(
|
||||
classify_mode("claude", Some("customMode"), None),
|
||||
CatalogNotDiscovered
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreviewed_agent_fails_closed() {
|
||||
use ApprovalClass::*;
|
||||
use ModeDecision::*;
|
||||
|
||||
// An unreviewed agent cannot inherit the benign classifications by
|
||||
// reusing a trusted id, nor by omitting the mode.
|
||||
assert_eq!(classify_mode("shady-agent", None, None), Class(Unattended));
|
||||
assert_eq!(
|
||||
classify_mode("shady-agent", Some("default"), None),
|
||||
Class(Unattended)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_mode("shady-agent", Some("plan"), None),
|
||||
Class(Unattended)
|
||||
);
|
||||
// Always-unattended ids stay unattended for anyone.
|
||||
assert_eq!(
|
||||
classify_mode("shady-agent", Some("acceptEdits"), None),
|
||||
Class(Unattended)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
//! Best-effort changelog assembly for an in-UI plugin update preview.
|
||||
//!
|
||||
//! Given the prior installed ref/commit and the target ref/commit, produce a
|
||||
//! human-readable list of what changed between them. Release-tracking updates
|
||||
//! show GitHub release notes; everything else (a branch/ref-tracked install, a
|
||||
//! moved tag whose content changed, or a release whose notes cannot be
|
||||
//! bracketed) falls back to commit subjects from the compare endpoint.
|
||||
//!
|
||||
//! This is presentation metadata, not a gate: every failure path (rate limit,
|
||||
//! 404, a local source) returns [`UpdateChangelog::unavailable`] rather than an
|
||||
//! error, so a missing changelog never blocks an update the user already chose
|
||||
//! to review. It is assembled only in `install::preview_update`, behind an
|
||||
//! explicit user action, so the extra unauthenticated GitHub request (60/hr/IP)
|
||||
//! is never spent on a background sweep.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::github::{
|
||||
GitHubClient, GitHubClientConfig, GitHubCompareCommit, GitHubError, GitHubRelease,
|
||||
DEFAULT_USER_AGENT,
|
||||
};
|
||||
|
||||
use super::source::PluginSource;
|
||||
|
||||
/// At most this many release entries before marking the changelog truncated.
|
||||
const RELEASES_CAP: usize = 20;
|
||||
/// At most this many commit entries before marking the changelog truncated.
|
||||
const COMMITS_CAP: usize = 50;
|
||||
/// Truncate a release body to this many bytes (on a char boundary) so a
|
||||
/// pathological release note does not bloat the preview payload.
|
||||
const BODY_CAP: usize = 8 * 1024;
|
||||
|
||||
/// What changed between the installed version and the update target. `entries`
|
||||
/// is newest-first. `truncated` flags that more existed than are shown.
|
||||
/// `unavailable_reason` distinguishes "could not load the changelog" from "there
|
||||
/// were genuinely no entries" (both leave `entries` empty).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UpdateChangelog {
|
||||
pub entries: Vec<ChangelogEntry>,
|
||||
pub truncated: bool,
|
||||
pub unavailable_reason: Option<String>,
|
||||
/// A GitHub URL for the full history when the changelog is capped or a
|
||||
/// surface cannot show it all (the releases page, or the compare view). The
|
||||
/// non-scrollable TUI popup links to it; the web modal shows it on truncation.
|
||||
pub more_url: Option<String>,
|
||||
}
|
||||
|
||||
/// One changelog item: a published release's notes, or a single commit subject.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ChangelogEntry {
|
||||
Release {
|
||||
tag: String,
|
||||
body: Option<String>,
|
||||
published_at: Option<String>,
|
||||
},
|
||||
Commit {
|
||||
sha: String,
|
||||
subject: String,
|
||||
url: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl UpdateChangelog {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
truncated: false,
|
||||
unavailable_reason: None,
|
||||
more_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unavailable(reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
truncated: false,
|
||||
unavailable_reason: Some(reason.into()),
|
||||
more_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The human GitHub base URL for a source, `https://github.com/{owner}/{repo}`.
|
||||
fn github_web_base(owner: &str, repo: &str) -> String {
|
||||
format!("https://github.com/{owner}/{repo}")
|
||||
}
|
||||
|
||||
/// Map a GitHub error to a short, user-facing "unavailable" reason. A rate limit
|
||||
/// is the common one worth naming so the user knows to retry later.
|
||||
fn unavailable_for(err: &GitHubError) -> UpdateChangelog {
|
||||
let reason = match err {
|
||||
GitHubError::RateLimited => "GitHub rate limit reached; changelog unavailable.",
|
||||
_ => "Changelog unavailable.",
|
||||
};
|
||||
UpdateChangelog::unavailable(reason)
|
||||
}
|
||||
|
||||
fn client() -> Result<GitHubClient, GitHubError> {
|
||||
GitHubClient::unauthenticated(GitHubClientConfig {
|
||||
api_base: super::fetch::github_api_base(),
|
||||
user_agent: DEFAULT_USER_AGENT.to_string(),
|
||||
timeout: Duration::from_secs(30),
|
||||
})
|
||||
}
|
||||
|
||||
/// The first line of a commit message, the conventional "subject".
|
||||
fn subject(message: &str) -> String {
|
||||
message.lines().next().unwrap_or("").trim().to_string()
|
||||
}
|
||||
|
||||
/// Truncate `body` to [`BODY_CAP`] bytes on a char boundary, appending an
|
||||
/// ellipsis when cut.
|
||||
fn cap_body(body: String) -> String {
|
||||
if body.len() <= BODY_CAP {
|
||||
return body;
|
||||
}
|
||||
let mut end = BODY_CAP;
|
||||
while end > 0 && !body.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}…", &body[..end])
|
||||
}
|
||||
|
||||
/// Build the changelog between the installed version and the update target.
|
||||
/// Best-effort: returns an `unavailable` changelog rather than erroring on any
|
||||
/// failure.
|
||||
pub async fn build(
|
||||
source: &PluginSource,
|
||||
prior_ref: Option<&str>,
|
||||
prior_commit: Option<&str>,
|
||||
target_ref: Option<&str>,
|
||||
target_commit: Option<&str>,
|
||||
) -> UpdateChangelog {
|
||||
let (owner, repo) = match source {
|
||||
PluginSource::Github { owner, repo, .. } => (owner.as_str(), repo.as_str()),
|
||||
PluginSource::Local(_) => {
|
||||
return UpdateChangelog::unavailable("Changelog is only available for GitHub plugins.")
|
||||
}
|
||||
};
|
||||
|
||||
let client = match client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return unavailable_for(&e),
|
||||
};
|
||||
|
||||
// Release path: only when the refs are distinct release tags we can bracket
|
||||
// in the releases list. A moved tag (same ref, changed content) or an
|
||||
// unbracketable pair falls through to the commit compare.
|
||||
if let (Some(prior_ref), Some(target_ref)) = (prior_ref, target_ref) {
|
||||
if prior_ref != target_ref {
|
||||
if let Some(changelog) =
|
||||
release_changelog(&client, owner, repo, prior_ref, target_ref).await
|
||||
{
|
||||
return changelog;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (prior_commit, target_commit) {
|
||||
(Some(base), Some(head)) => commit_changelog(&client, owner, repo, base, head).await,
|
||||
_ => UpdateChangelog::unavailable("Changelog unavailable."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect release notes for the published releases strictly newer than
|
||||
/// `prior_ref` up to and including `target_ref`, bracketed by tag identity in
|
||||
/// the list order GitHub returns (newest-first). Returns `None` to signal "fall
|
||||
/// back to commits" when the pair cannot be bracketed (target tag absent, no
|
||||
/// release entries between them); returns `Some(unavailable)` on an API error so
|
||||
/// a rate limit does not silently turn into a noisy commit dump.
|
||||
async fn release_changelog(
|
||||
client: &GitHubClient,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
prior_ref: &str,
|
||||
target_ref: &str,
|
||||
) -> Option<UpdateChangelog> {
|
||||
let releases = match client.list_releases(owner, repo, 100).await {
|
||||
Ok(r) => r,
|
||||
Err(GitHubError::NotFound { .. }) => return None,
|
||||
Err(e) => return Some(unavailable_for(&e)),
|
||||
};
|
||||
bracket_releases(&releases, prior_ref, target_ref).map(|(entries, truncated)| UpdateChangelog {
|
||||
entries,
|
||||
truncated,
|
||||
unavailable_reason: None,
|
||||
more_url: Some(format!("{}/releases", github_web_base(owner, repo))),
|
||||
})
|
||||
}
|
||||
|
||||
/// Pure release-bracketing: from the releases list (newest-first as GitHub
|
||||
/// returns it), collect published (non-draft, non-prerelease) entries from
|
||||
/// `target_ref` down to, but excluding, `prior_ref`. Returns `None` when the
|
||||
/// pair cannot be bracketed (target tag absent, or nothing sits between them),
|
||||
/// signalling the caller to fall back to commits. The bool is the truncation
|
||||
/// flag (cap hit, or the prior tag was older than the fetched page).
|
||||
fn bracket_releases(
|
||||
releases: &[GitHubRelease],
|
||||
prior_ref: &str,
|
||||
target_ref: &str,
|
||||
) -> Option<(Vec<ChangelogEntry>, bool)> {
|
||||
let mut entries = Vec::new();
|
||||
let mut truncated = false;
|
||||
let mut collecting = false;
|
||||
let mut found_prior = false;
|
||||
for release in releases.iter().filter(|r| !r.draft && !r.prerelease) {
|
||||
if release.tag_name == target_ref {
|
||||
collecting = true;
|
||||
}
|
||||
if !collecting {
|
||||
continue;
|
||||
}
|
||||
if release.tag_name == prior_ref {
|
||||
found_prior = true;
|
||||
break;
|
||||
}
|
||||
if entries.len() >= RELEASES_CAP {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
entries.push(ChangelogEntry::Release {
|
||||
tag: release.tag_name.clone(),
|
||||
body: release
|
||||
.body
|
||||
.clone()
|
||||
.map(|b| cap_body(b.trim().to_string()))
|
||||
.filter(|b| !b.is_empty()),
|
||||
published_at: release.published_at.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
// Target tag never appeared, or nothing sits between the two tags: not a
|
||||
// usable release bracket.
|
||||
return None;
|
||||
}
|
||||
// The prior tag was older than the fetched page (or filtered out), so there
|
||||
// may be releases we did not show.
|
||||
Some((entries, truncated || !found_prior))
|
||||
}
|
||||
|
||||
/// Commit subjects on `head` not on `base`, newest-first. The compare endpoint
|
||||
/// returns commits oldest-first and caps the list at 250, so reverse for display
|
||||
/// and mark truncation off `total_commits`.
|
||||
async fn commit_changelog(
|
||||
client: &GitHubClient,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
base: &str,
|
||||
head: &str,
|
||||
) -> UpdateChangelog {
|
||||
let compare = match client.compare_commits(owner, repo, base, head).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return unavailable_for(&e),
|
||||
};
|
||||
|
||||
// identical / behind have nothing meaningful to show going forward.
|
||||
if compare.commits.is_empty() {
|
||||
return UpdateChangelog::empty();
|
||||
}
|
||||
|
||||
let (entries, truncated) = map_commits(&compare.commits, compare.total_commits);
|
||||
UpdateChangelog {
|
||||
entries,
|
||||
truncated,
|
||||
unavailable_reason: None,
|
||||
more_url: Some(format!(
|
||||
"{}/compare/{base}...{head}",
|
||||
github_web_base(owner, repo)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure commit mapping: the compare endpoint returns commits oldest-first and
|
||||
/// caps the list at 250, so reverse for newest-first display, take [`COMMITS_CAP`],
|
||||
/// and mark truncation off the gap between `total_commits` and what was returned
|
||||
/// or shown.
|
||||
fn map_commits(commits: &[GitHubCompareCommit], total_commits: u64) -> (Vec<ChangelogEntry>, bool) {
|
||||
let returned = commits.len() as u64;
|
||||
let entries: Vec<ChangelogEntry> = commits
|
||||
.iter()
|
||||
.rev()
|
||||
.take(COMMITS_CAP)
|
||||
.map(|c| ChangelogEntry::Commit {
|
||||
sha: c.sha.clone(),
|
||||
subject: subject(&c.commit.message),
|
||||
url: (!c.html_url.is_empty()).then(|| c.html_url.clone()),
|
||||
})
|
||||
.collect();
|
||||
let truncated = total_commits > returned || returned as usize > COMMITS_CAP;
|
||||
(entries, truncated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn subject_takes_first_line() {
|
||||
assert_eq!(subject("feat: add thing\n\nlong body"), "feat: add thing");
|
||||
assert_eq!(subject(" trimmed "), "trimmed");
|
||||
assert_eq!(subject(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_body_truncates_on_char_boundary() {
|
||||
let body = "é".repeat(BODY_CAP); // each 'é' is 2 bytes, so this exceeds the cap
|
||||
let capped = cap_body(body);
|
||||
assert!(capped.ends_with('…'));
|
||||
// The slice point landed on a valid boundary (no panic) and is bounded.
|
||||
assert!(capped.len() <= BODY_CAP + "…".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_body_keeps_short_bodies() {
|
||||
assert_eq!(cap_body("short".to_string()), "short");
|
||||
}
|
||||
|
||||
fn release(tag: &str, body: Option<&str>, prerelease: bool, draft: bool) -> GitHubRelease {
|
||||
GitHubRelease {
|
||||
tag_name: tag.to_string(),
|
||||
body: body.map(str::to_string),
|
||||
published_at: Some("2026-01-01T00:00:00Z".to_string()),
|
||||
draft,
|
||||
prerelease,
|
||||
assets: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn release_tags(entries: &[ChangelogEntry]) -> Vec<&str> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
ChangelogEntry::Release { tag, .. } => tag.as_str(),
|
||||
ChangelogEntry::Commit { .. } => panic!("expected release entry"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracket_collects_between_target_and_prior_exclusive() {
|
||||
// Newest-first, as GitHub returns. prior=v1.0.0, target=v1.2.0.
|
||||
let releases = vec![
|
||||
release("v1.3.0", Some("newer, excluded"), false, false),
|
||||
release("v1.2.0", Some("target notes"), false, false),
|
||||
release("v1.1.0", Some("middle notes"), false, false),
|
||||
release("v1.0.0", Some("prior, excluded"), false, false),
|
||||
];
|
||||
let (entries, truncated) = bracket_releases(&releases, "v1.0.0", "v1.2.0").unwrap();
|
||||
assert_eq!(release_tags(&entries), vec!["v1.2.0", "v1.1.0"]);
|
||||
assert!(!truncated, "prior tag was found in the page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracket_filters_drafts_and_prereleases() {
|
||||
let releases = vec![
|
||||
release("v1.2.0", Some("target"), false, false),
|
||||
release("v1.2.0-rc1", Some("rc"), true, false),
|
||||
release("v1.1.5-draft", Some("draft"), false, true),
|
||||
release("v1.1.0", Some("middle"), false, false),
|
||||
release("v1.0.0", None, false, false),
|
||||
];
|
||||
let (entries, _) = bracket_releases(&releases, "v1.0.0", "v1.2.0").unwrap();
|
||||
assert_eq!(release_tags(&entries), vec!["v1.2.0", "v1.1.0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracket_returns_none_when_target_absent() {
|
||||
let releases = vec![release("v1.1.0", None, false, false)];
|
||||
assert!(bracket_releases(&releases, "v1.0.0", "v9.9.9").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracket_truncates_when_prior_older_than_page() {
|
||||
// prior tag is not present (older than the fetched page) => truncated.
|
||||
let releases = vec![
|
||||
release("v2.0.0", Some("a"), false, false),
|
||||
release("v1.9.0", Some("b"), false, false),
|
||||
];
|
||||
let (entries, truncated) = bracket_releases(&releases, "v1.0.0", "v2.0.0").unwrap();
|
||||
assert_eq!(release_tags(&entries), vec!["v2.0.0", "v1.9.0"]);
|
||||
assert!(truncated, "prior tag not in page should flag truncation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracket_empty_body_becomes_none() {
|
||||
let releases = vec![
|
||||
release("v1.1.0", Some(" "), false, false),
|
||||
release("v1.0.0", None, false, false),
|
||||
];
|
||||
let (entries, _) = bracket_releases(&releases, "v1.0.0", "v1.1.0").unwrap();
|
||||
match &entries[0] {
|
||||
ChangelogEntry::Release { body, .. } => assert!(body.is_none()),
|
||||
_ => panic!("expected release"),
|
||||
}
|
||||
}
|
||||
|
||||
fn commit(sha: &str, message: &str, url: &str) -> GitHubCompareCommit {
|
||||
GitHubCompareCommit {
|
||||
sha: sha.to_string(),
|
||||
html_url: url.to_string(),
|
||||
commit: crate::github::client::GitHubCommitInner {
|
||||
message: message.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_commits_reverses_to_newest_first_and_takes_subject() {
|
||||
// GitHub returns oldest-first; display is newest-first.
|
||||
let commits = vec![
|
||||
commit("aaa", "first\n\nbody", "http://x/aaa"),
|
||||
commit("bbb", "second", ""),
|
||||
];
|
||||
let (entries, truncated) = map_commits(&commits, 2);
|
||||
match (&entries[0], &entries[1]) {
|
||||
(
|
||||
ChangelogEntry::Commit {
|
||||
sha: s0,
|
||||
subject: j0,
|
||||
url: u0,
|
||||
},
|
||||
ChangelogEntry::Commit {
|
||||
sha: s1,
|
||||
subject: j1,
|
||||
url: u1,
|
||||
},
|
||||
) => {
|
||||
assert_eq!((s0.as_str(), j0.as_str()), ("bbb", "second"));
|
||||
assert_eq!(u0, &None, "empty html_url maps to None");
|
||||
assert_eq!((s1.as_str(), j1.as_str()), ("aaa", "first"));
|
||||
assert_eq!(u1.as_deref(), Some("http://x/aaa"));
|
||||
}
|
||||
_ => panic!("expected commit entries"),
|
||||
}
|
||||
assert!(!truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_commits_flags_truncation_when_total_exceeds_returned() {
|
||||
let commits = vec![commit("aaa", "x", "")];
|
||||
let (_, truncated) = map_commits(&commits, 300);
|
||||
assert!(truncated, "total_commits > returned must flag truncation");
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
//! Normalized Tier 0 contributions from the active plugin set.
|
||||
//!
|
||||
//! Each surface (themes, settings schema, keybinds, CLI) reads its slice of the
|
||||
//! manifest through one place here rather than walking `registry().active()` and
|
||||
//! manifest fields itself, so contribution-filtering rules (active-only, path
|
||||
//! safety, id namespacing) live once.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use super::registry::LoadedPlugin;
|
||||
|
||||
/// Resolve a plugin-relative resource path under the plugin's install
|
||||
/// directory, rejecting anything that escapes it (absolute paths, `..`). A
|
||||
/// builtin (no on-disk dir) ships no file resources, so it returns `None`.
|
||||
fn resolve_under_dir(plugin: &LoadedPlugin, rel: &str) -> Option<PathBuf> {
|
||||
let dir = plugin.dir.as_ref()?;
|
||||
let rel = Path::new(rel);
|
||||
// Reject syntactic escapes first: empty, rooted (absolute or Windows
|
||||
// root-relative like `\Windows\...`), a drive prefix, or any `..`.
|
||||
if rel.as_os_str().is_empty()
|
||||
|| rel.has_root()
|
||||
|| rel
|
||||
.components()
|
||||
.any(|c| matches!(c, Component::ParentDir | Component::Prefix(_)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Then canonicalize both and require the resolved candidate to stay under
|
||||
// the plugin directory, so a symlink inside the plugin dir cannot point
|
||||
// outside it. A non-existent file canonicalizes to None and is dropped (it
|
||||
// could not load anyway).
|
||||
let base = dir.canonicalize().ok()?;
|
||||
let candidate = base.join(rel).canonicalize().ok()?;
|
||||
candidate.starts_with(&base).then_some(candidate)
|
||||
}
|
||||
|
||||
/// Themes contributed by active plugins, as `(name, path)` pairs. The path is
|
||||
/// resolved under the contributing plugin's directory; unsafe or builtin-only
|
||||
/// paths are skipped.
|
||||
pub fn active_themes(plugins: &[&LoadedPlugin]) -> Vec<(String, PathBuf)> {
|
||||
let mut out = Vec::new();
|
||||
for plugin in plugins {
|
||||
for theme in &plugin.manifest.themes {
|
||||
if let Some(path) = resolve_under_dir(plugin, &theme.path) {
|
||||
out.push((theme.name.clone(), path));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::plugin::registry::ValidationState;
|
||||
use aoe_plugin_api::{PluginManifest, ThemeContribution, TrustLevel};
|
||||
|
||||
fn loaded(dir: Option<PathBuf>, themes: Vec<ThemeContribution>) -> LoadedPlugin {
|
||||
let mut manifest = PluginManifest::from_toml_str(
|
||||
r#"
|
||||
id = "acme.kit"
|
||||
name = "Kit"
|
||||
version = "0.1.0"
|
||||
api_version = 2
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
manifest.themes = themes;
|
||||
LoadedPlugin {
|
||||
manifest,
|
||||
enabled: true,
|
||||
trust: TrustLevel::Community,
|
||||
validation: ValidationState::Community,
|
||||
source: None,
|
||||
dir,
|
||||
manifest_hash: "sha256:x".into(),
|
||||
granted: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn theme(name: &str, path: &str) -> ThemeContribution {
|
||||
ThemeContribution {
|
||||
name: name.into(),
|
||||
path: path.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_relative_theme_under_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("acme.kit");
|
||||
std::fs::create_dir_all(dir.join("themes")).unwrap();
|
||||
let file = dir.join("themes/dark.toml");
|
||||
std::fs::write(&file, "background = \"#000000\"\n").unwrap();
|
||||
|
||||
let p = loaded(Some(dir), vec![theme("kit-dark", "themes/dark.toml")]);
|
||||
let themes = active_themes(&[&p]);
|
||||
assert_eq!(themes.len(), 1);
|
||||
assert_eq!(themes[0].0, "kit-dark");
|
||||
assert_eq!(themes[0].1, file.canonicalize().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_escaping_and_builtin_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("acme.kit");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let escaping = loaded(
|
||||
Some(dir),
|
||||
vec![
|
||||
theme("abs", "/etc/evil.toml"),
|
||||
theme("dotdot", "../../etc/evil.toml"),
|
||||
theme("empty", ""),
|
||||
],
|
||||
);
|
||||
assert!(active_themes(&[&escaping]).is_empty());
|
||||
|
||||
// A builtin (no dir) contributes no file themes.
|
||||
let builtin = loaded(None, vec![theme("x", "x.toml")]);
|
||||
assert!(active_themes(&[&builtin]).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symlink_escape() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("acme.kit");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let outside = tmp.path().join("outside.toml");
|
||||
std::fs::write(&outside, "background = \"#000000\"\n").unwrap();
|
||||
// A symlink inside the plugin dir pointing outside it must be rejected.
|
||||
std::os::unix::fs::symlink(&outside, dir.join("link.toml")).unwrap();
|
||||
|
||||
let p = loaded(Some(dir), vec![theme("esc", "link.toml")]);
|
||||
assert!(
|
||||
active_themes(&[&p]).is_empty(),
|
||||
"a symlink escaping the plugin dir must not resolve"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,918 +0,0 @@
|
||||
//! GitHub plugin discovery over the `aoe-plugin` topic.
|
||||
//!
|
||||
//! Discovery is an explicit action (CLI `aoe plugin discover`, TUI `d`, the
|
||||
//! dashboard "Search GitHub" button), never a background task. It runs one
|
||||
//! GitHub search and badges each result by matching the repo slug against the
|
||||
//! featured index and the installed set. It deliberately does NOT clone, read,
|
||||
//! or parse each repo's `aoe-plugin.toml` (an N+1 that would burn the
|
||||
//! unauthenticated API rate limit). The one exception is a best-effort `HEAD`
|
||||
//! on the raw CDN for unvetted results: the `aoe-plugin` topic collides with
|
||||
//! Age of Empires game projects, so a repo that provably carries no manifest is
|
||||
//! dropped rather than offered for install. Existence is not validation, so a
|
||||
//! result is still "a GitHub repository tagged `aoe-plugin` that appears to
|
||||
//! carry a manifest", not "a verified plugin". Install remains the trust
|
||||
//! boundary: it fetches the manifest, prompts for capabilities, and enforces
|
||||
//! the featured pin (`install::install`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use aoe_plugin_api::{lucide_icon_name_ok, screenshot_path_ok, MAX_SCREENSHOTS};
|
||||
use futures_util::{stream, StreamExt};
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::github::{GitHubClient, GitHubClientConfig, GitHubRepo, DEFAULT_USER_AGENT};
|
||||
|
||||
/// Characters to percent-encode in a `raw.githubusercontent.com` path while
|
||||
/// keeping `/` (segment separators) and the unreserved set intact. The path is
|
||||
/// already structurally validated by [`screenshot_path_ok`]; this guards the
|
||||
/// remaining URL-unsafe bytes (spaces, `?`, `#`, `%`, ...).
|
||||
const RAW_PATH: &AsciiSet = &CONTROLS
|
||||
.add(b' ')
|
||||
.add(b'"')
|
||||
.add(b'#')
|
||||
.add(b'%')
|
||||
.add(b'<')
|
||||
.add(b'>')
|
||||
.add(b'?')
|
||||
.add(b'`')
|
||||
.add(b'{')
|
||||
.add(b'}')
|
||||
.add(b'|')
|
||||
.add(b'^')
|
||||
.add(b'\\')
|
||||
.add(b'[')
|
||||
.add(b']');
|
||||
|
||||
use super::featured::FeaturedIndex;
|
||||
use super::source::PluginSource;
|
||||
|
||||
/// The GitHub topic plugins are published under.
|
||||
const PLUGIN_TOPIC: &str = "aoe-plugin";
|
||||
|
||||
/// Per-probe timeout. Short on purpose: the manifest probe is a filter, not a
|
||||
/// dependency, so a slow CDN must not hold the search hostage.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
|
||||
/// Wall-clock ceiling for the whole probe phase. Concurrency alone does not
|
||||
/// bound it: a full 30-result page at [`PROBE_CONCURRENCY`] is four serial
|
||||
/// batches, so uniform timeouts would cost four times [`PROBE_TIMEOUT`].
|
||||
/// Probes still in flight when this fires are simply inconclusive.
|
||||
const PROBE_PHASE_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
|
||||
/// How many manifest probes run at once.
|
||||
const PROBE_CONCURRENCY: usize = 8;
|
||||
|
||||
/// How a discovered repository relates to what the host already knows.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DiscoveryBadge {
|
||||
/// This source slug is already installed.
|
||||
Installed,
|
||||
/// This source slug is pinned in the featured index (a curated source; not a
|
||||
/// claim that the current tree matches the pin).
|
||||
Featured,
|
||||
/// A GitHub repo tagged `aoe-plugin` that is neither installed nor featured.
|
||||
Unvetted,
|
||||
}
|
||||
|
||||
impl DiscoveryBadge {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
DiscoveryBadge::Installed => "installed",
|
||||
DiscoveryBadge::Featured => "featured",
|
||||
DiscoveryBadge::Unvetted => "unvetted",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One discovery result, repo-level and ready to render on any surface.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DiscoveryResult {
|
||||
/// `gh:owner/repo`, the slug `aoe plugin install` accepts.
|
||||
pub slug: String,
|
||||
pub html_url: String,
|
||||
pub description: Option<String>,
|
||||
pub stars: u64,
|
||||
pub badge: DiscoveryBadge,
|
||||
/// Whether this source is in the featured index, tracked independently of
|
||||
/// `badge`: an installed-and-featured repo shows the `Installed` badge but
|
||||
/// must still rank as featured (the badge is one-of, ranking is not).
|
||||
pub featured: bool,
|
||||
/// The exact `aoe plugin install` command for this plugin, shown alongside
|
||||
/// the in-app Install button for users who prefer the terminal.
|
||||
pub install_command: String,
|
||||
/// The repo owner's GitHub avatar (`github.com/{owner}.png`), shown as a
|
||||
/// source-identity affordance. This is NOT the plugin's own identity icon
|
||||
/// (`aoe-plugin.toml`'s `icon`/`icon_asset`, only known after a manifest
|
||||
/// fetch): discovery is deliberately repo-level and never fetches each
|
||||
/// result's manifest (see the module doc), so the owner avatar is the only
|
||||
/// zero-cost visual identity available at search time. Always resolvable
|
||||
/// from `full_name` with no extra request; GitHub serves this path for any
|
||||
/// owner.
|
||||
pub source_avatar_url: String,
|
||||
}
|
||||
|
||||
/// Search the `aoe-plugin` topic and badge each result. `query` is an optional
|
||||
/// free-text term ANDed with the topic filter.
|
||||
pub async fn discover(query: Option<&str>) -> Result<Vec<DiscoveryResult>> {
|
||||
let client = client()?;
|
||||
|
||||
let mut q = format!("topic:{PLUGIN_TOPIC} fork:false archived:false");
|
||||
if let Some(term) = query.map(str::trim).filter(|t| !t.is_empty()) {
|
||||
q.push(' ');
|
||||
q.push_str(term);
|
||||
}
|
||||
let repos = client.search_repositories(&q, 30).await?;
|
||||
|
||||
// Treat a featured-index load failure as fatal, matching install-time
|
||||
// `verify_featured`: silently defaulting to an empty index would re-badge
|
||||
// every curated plugin as unvetted and drop featured-first ordering, so
|
||||
// discovery and install would disagree about the same trust signal.
|
||||
let featured = FeaturedIndex::load()?;
|
||||
let installed = installed_slugs();
|
||||
let badged = badge_repos(repos, &featured, &installed);
|
||||
let probes = probe_unvetted(&badged).await;
|
||||
Ok(rank(drop_missing(badged, &probes)))
|
||||
}
|
||||
|
||||
/// The outcome of one `aoe-plugin.toml` existence probe. Tri-state on purpose:
|
||||
/// a bool would conflate "this repo has no manifest" with "we could not
|
||||
/// check", and only the former may drop a result.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ManifestProbe {
|
||||
Present,
|
||||
Missing,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Map a probe response to an outcome; `None` is a transport error or timeout.
|
||||
/// Only a definitive 404 is `Missing`: a 403/429 (CDN throttling) or a 5xx says
|
||||
/// nothing about the repo, so everything else fails open.
|
||||
fn classify(status: Option<StatusCode>) -> ManifestProbe {
|
||||
match status {
|
||||
Some(s) if s.is_success() => ManifestProbe::Present,
|
||||
Some(StatusCode::NOT_FOUND) => ManifestProbe::Missing,
|
||||
_ => ManifestProbe::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a `gh:owner/repo` slug. [`badge_repos`] already validated the shape.
|
||||
fn owner_repo(slug: &str) -> Option<(&str, &str)> {
|
||||
slug.strip_prefix("gh:")?.split_once('/')
|
||||
}
|
||||
|
||||
/// `HEAD` the repo's default-branch `aoe-plugin.toml` on the raw CDN. Not the
|
||||
/// contents API [`GitHubClient::get_repo_file`] uses: that draws on the 60/hr
|
||||
/// unauthenticated core budget, which one 30-result page would half exhaust,
|
||||
/// while the CDN is not on that budget. `HEAD` is enough because the question
|
||||
/// is existence, and it downloads no body.
|
||||
async fn probe_manifest(http: &reqwest::Client, owner: &str, repo: &str) -> ManifestProbe {
|
||||
let url = raw_url(owner, repo, None, "aoe-plugin.toml");
|
||||
classify(http.head(url).send().await.ok().map(|r| r.status()))
|
||||
}
|
||||
|
||||
/// Probe every unvetted result concurrently, keyed by slug. Installed and
|
||||
/// featured sources are deliberately never probed: both install from a pinned
|
||||
/// ref, so a manifest missing from the default branch today does not make them
|
||||
/// uninstallable, and acting on that would be a false alarm on a working
|
||||
/// plugin. Curation drift belongs in featured-index maintenance, not here.
|
||||
async fn probe_unvetted(results: &[DiscoveryResult]) -> HashMap<String, ManifestProbe> {
|
||||
let targets: Vec<(String, String, String)> = results
|
||||
.iter()
|
||||
.filter(|r| r.badge == DiscoveryBadge::Unvetted)
|
||||
.filter_map(|r| {
|
||||
owner_repo(&r.slug)
|
||||
.map(|(owner, repo)| (r.slug.clone(), owner.to_string(), repo.to_string()))
|
||||
})
|
||||
.collect();
|
||||
let attempted = targets.len();
|
||||
if attempted == 0 {
|
||||
return HashMap::new();
|
||||
}
|
||||
let http = match reqwest::Client::builder()
|
||||
.user_agent(DEFAULT_USER_AGENT)
|
||||
.timeout(PROBE_TIMEOUT)
|
||||
.build()
|
||||
{
|
||||
Ok(http) => http,
|
||||
// No client means no evidence, so every result stays.
|
||||
Err(_) => return HashMap::new(),
|
||||
};
|
||||
|
||||
let mut probes = stream::iter(targets.into_iter().map(|(slug, owner, repo)| {
|
||||
let http = &http;
|
||||
async move { (slug, probe_manifest(http, &owner, &repo).await) }
|
||||
}))
|
||||
.buffer_unordered(PROBE_CONCURRENCY);
|
||||
|
||||
// Drain under a phase deadline rather than wrapping the collect: a single
|
||||
// timeout around the whole thing would throw away the probes that already
|
||||
// came back, so one hung repo would unfilter the entire page.
|
||||
let deadline = tokio::time::Instant::now() + PROBE_PHASE_TIMEOUT;
|
||||
let mut out = HashMap::new();
|
||||
while let Ok(Some((slug, probe))) = tokio::time::timeout_at(deadline, probes.next()).await {
|
||||
out.insert(slug, probe);
|
||||
}
|
||||
log_probes(attempted, &out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Report probe outcomes in aggregate. A page where nothing came back
|
||||
/// conclusive means the filter did not run at all (a network that reaches
|
||||
/// `api.github.com` but blocks `raw.githubusercontent.com` does this), which is
|
||||
/// worth saying out loud since the topic-collision results are then unfiltered.
|
||||
fn log_probes(attempted: usize, probes: &HashMap<String, ManifestProbe>) {
|
||||
let count = |want: ManifestProbe| probes.values().filter(|p| **p == want).count();
|
||||
let present = count(ManifestProbe::Present);
|
||||
let missing = count(ManifestProbe::Missing);
|
||||
// Probes that never completed before the deadline are inconclusive too.
|
||||
let unknown = attempted - present - missing;
|
||||
if present == 0 && missing == 0 {
|
||||
tracing::warn!(
|
||||
target: "plugin.discover",
|
||||
unknown,
|
||||
"every manifest probe was inconclusive; raw.githubusercontent.com may be unreachable, so topic-collision results are not filtered"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
target: "plugin.discover",
|
||||
present,
|
||||
missing,
|
||||
unknown,
|
||||
"manifest probes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the results a probe proved carry no manifest. A slug absent from
|
||||
/// `probes` (never probed, or still in flight when the phase deadline fired) is
|
||||
/// retained, so the filter always fails open.
|
||||
fn drop_missing(
|
||||
results: Vec<DiscoveryResult>,
|
||||
probes: &HashMap<String, ManifestProbe>,
|
||||
) -> Vec<DiscoveryResult> {
|
||||
results
|
||||
.into_iter()
|
||||
.filter(|r| probes.get(&r.slug) != Some(&ManifestProbe::Missing))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The normalized `gh:owner/repo` slugs of every installed external GitHub
|
||||
/// plugin, lower-cased for case-insensitive matching.
|
||||
fn installed_slugs() -> Vec<String> {
|
||||
super::registry()
|
||||
.all()
|
||||
.iter()
|
||||
.filter_map(|p| p.source.as_deref())
|
||||
.filter_map(|s| PluginSource::parse(s).ok())
|
||||
.filter(|s| matches!(s, PluginSource::Github { .. }))
|
||||
.map(|s| s.slug().to_ascii_lowercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Map raw repos to badged results. Pure given the featured index and the
|
||||
/// installed slug set, so it is unit-tested without the network.
|
||||
fn badge_repos(
|
||||
repos: Vec<GitHubRepo>,
|
||||
featured: &FeaturedIndex,
|
||||
installed: &[String],
|
||||
) -> Vec<DiscoveryResult> {
|
||||
repos
|
||||
.into_iter()
|
||||
.filter_map(|repo| {
|
||||
// A search result is `owner/repo`; anything else is not installable.
|
||||
if repo.full_name.split('/').filter(|s| !s.is_empty()).count() != 2 {
|
||||
return None;
|
||||
}
|
||||
let slug = format!("gh:{}", repo.full_name);
|
||||
let normalized = slug.to_ascii_lowercase();
|
||||
let is_installed = installed.contains(&normalized);
|
||||
let is_featured = featured.is_featured_source(&slug);
|
||||
// Installed wins the one-of display badge, but `featured` is kept
|
||||
// separately so an installed-and-featured repo still ranks featured.
|
||||
let badge = if is_installed {
|
||||
DiscoveryBadge::Installed
|
||||
} else if is_featured {
|
||||
DiscoveryBadge::Featured
|
||||
} else {
|
||||
DiscoveryBadge::Unvetted
|
||||
};
|
||||
// Already validated as exactly two non-empty segments above.
|
||||
let owner = repo.full_name.split('/').next().unwrap_or_default();
|
||||
Some(DiscoveryResult {
|
||||
install_command: format!("aoe plugin install {slug}"),
|
||||
slug,
|
||||
html_url: repo.html_url,
|
||||
featured: is_featured,
|
||||
description: repo.description.filter(|d| !d.is_empty()),
|
||||
stars: repo.stargazers_count,
|
||||
badge,
|
||||
source_avatar_url: format!("https://github.com/{owner}.png?size=64"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rank featured sources first, then by GitHub stars descending (#2105 will add
|
||||
/// popularity ranking; until then this is the issue's "featured status + stars").
|
||||
fn rank(mut results: Vec<DiscoveryResult>) -> Vec<DiscoveryResult> {
|
||||
results.sort_by(|a, b| {
|
||||
b.featured
|
||||
.cmp(&a.featured)
|
||||
.then(b.stars.cmp(&a.stars))
|
||||
.then(a.slug.cmp(&b.slug))
|
||||
});
|
||||
results
|
||||
}
|
||||
|
||||
fn client() -> Result<GitHubClient> {
|
||||
Ok(GitHubClient::unauthenticated(GitHubClientConfig {
|
||||
api_base: api_base(),
|
||||
user_agent: DEFAULT_USER_AGENT.to_string(),
|
||||
timeout: Duration::from_secs(30),
|
||||
})?)
|
||||
}
|
||||
|
||||
fn api_base() -> String {
|
||||
std::env::var("AOE_UPDATE_API_BASE")
|
||||
.unwrap_or_else(|_| crate::github::DEFAULT_GITHUB_API_BASE.to_string())
|
||||
}
|
||||
|
||||
/// The manifest fields a detail view shows, parsed leniently (unknown and
|
||||
/// future keys are ignored) so a plugin targeting a newer `api_version` than
|
||||
/// this host can install still renders in the modal.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DetailManifest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub api_version: u32,
|
||||
pub capabilities: Vec<String>,
|
||||
pub ui_contributions: Vec<UiSlotView>,
|
||||
/// Screenshot/GIF previews, each resolved to a browser-fetchable URL on
|
||||
/// `raw.githubusercontent.com`. Author-declared paths that fail validation
|
||||
/// are dropped here rather than failing the whole detail.
|
||||
pub screenshots: Vec<ScreenshotView>,
|
||||
/// Lucide kebab-case identity icon name, straight from the manifest.
|
||||
pub icon: Option<String>,
|
||||
/// The manifest's `icon_asset`, resolved to a `raw.githubusercontent.com`
|
||||
/// URL exactly like screenshots. `None` below `api_version >= 7` or when
|
||||
/// the declared path fails validation.
|
||||
pub icon_asset_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UiSlotView {
|
||||
pub slot: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// A screenshot resolved for the detail modal: `src` is a fully-qualified
|
||||
/// `raw.githubusercontent.com` URL the browser fetches directly.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ScreenshotView {
|
||||
pub src: String,
|
||||
pub alt: String,
|
||||
pub caption: String,
|
||||
}
|
||||
|
||||
/// Resolve a manifest's raw screenshots into browser-fetchable views. Gated on
|
||||
/// `api_version >= 5` to mirror [`aoe_plugin_api::PluginManifest::validate`], so
|
||||
/// the detail modal never shows media for a manifest the install path would
|
||||
/// reject; entries that fail [`screenshot_path_ok`] or have empty alt text are
|
||||
/// dropped (one bad entry never poisons the whole detail), capped at
|
||||
/// [`MAX_SCREENSHOTS`].
|
||||
fn resolve_screenshots(
|
||||
api_version: u32,
|
||||
raws: Vec<RawScreenshot>,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
reference: Option<&str>,
|
||||
) -> Vec<ScreenshotView> {
|
||||
if api_version < 5 {
|
||||
return Vec::new();
|
||||
}
|
||||
raws.into_iter()
|
||||
.filter(|s| screenshot_path_ok(&s.path) && !s.alt.trim().is_empty())
|
||||
.take(MAX_SCREENSHOTS)
|
||||
.map(|s| ScreenshotView {
|
||||
src: raw_url(owner, repo, reference, &s.path),
|
||||
alt: s.alt,
|
||||
caption: s.caption,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve a manifest's raw `icon` name, gated on `api_version >= 7` and
|
||||
/// syntax-checked via [`lucide_icon_name_ok`], so a malformed or pre-7 name
|
||||
/// from attacker-influenced remote manifest content never reaches the client.
|
||||
/// Extracted from the `details()` call site so this filter is independently
|
||||
/// testable without a network-backed `details()` call.
|
||||
fn resolve_icon_name(api_version: u32, icon: Option<String>) -> Option<String> {
|
||||
if api_version < 7 {
|
||||
return None;
|
||||
}
|
||||
icon.filter(|i| lucide_icon_name_ok(i))
|
||||
}
|
||||
|
||||
/// Resolve a manifest's raw `icon_asset` into a browser-fetchable URL. Gated
|
||||
/// on `api_version >= 7` to mirror [`aoe_plugin_api::PluginManifest::validate`],
|
||||
/// and dropped on an invalid path, exactly like [`resolve_screenshots`].
|
||||
fn resolve_icon_asset(
|
||||
api_version: u32,
|
||||
path: Option<String>,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
reference: Option<&str>,
|
||||
) -> Option<String> {
|
||||
if api_version < 7 {
|
||||
return None;
|
||||
}
|
||||
let path = path?;
|
||||
screenshot_path_ok(&path).then(|| raw_url(owner, repo, reference, &path))
|
||||
}
|
||||
|
||||
/// Build the `raw.githubusercontent.com` URL for a repository-relative path.
|
||||
/// `reference` defaults to `HEAD` (the repo's default branch) when the source
|
||||
/// is unpinned. The path is already validated by [`screenshot_path_ok`]; this
|
||||
/// only percent-encodes the remaining URL-unsafe bytes per segment.
|
||||
fn raw_url(owner: &str, repo: &str, reference: Option<&str>, path: &str) -> String {
|
||||
let reference = reference.unwrap_or("HEAD");
|
||||
format!(
|
||||
"https://raw.githubusercontent.com/{}/{}/{}/{}",
|
||||
utf8_percent_encode(owner, RAW_PATH),
|
||||
utf8_percent_encode(repo, RAW_PATH),
|
||||
utf8_percent_encode(reference, RAW_PATH),
|
||||
utf8_percent_encode(path, RAW_PATH),
|
||||
)
|
||||
}
|
||||
|
||||
/// The on-demand detail for one plugin source: its manifest fields plus the
|
||||
/// repo's published release tags (the available versions).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PluginDetail {
|
||||
pub source: String,
|
||||
pub manifest: Option<DetailManifest>,
|
||||
/// Why the manifest could not be read/parsed, if it could not.
|
||||
pub manifest_error: Option<String>,
|
||||
/// Published GitHub release tags, newest first (the available versions).
|
||||
pub release_tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Lenient `aoe-plugin.toml` shape for the detail view. Unlike the strict host
|
||||
/// parser it ignores unknown fields and does not range-check `api_version`, so a
|
||||
/// not-yet-installable plugin still shows its version/description/capabilities.
|
||||
#[derive(Deserialize)]
|
||||
struct RawManifest {
|
||||
id: String,
|
||||
name: String,
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
api_version: u32,
|
||||
#[serde(default)]
|
||||
capabilities: Vec<String>,
|
||||
#[serde(default)]
|
||||
ui: Vec<RawUi>,
|
||||
#[serde(default)]
|
||||
screenshots: Vec<RawScreenshot>,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
#[serde(default)]
|
||||
icon_asset: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawUi {
|
||||
slot: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawScreenshot {
|
||||
#[serde(default)]
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
alt: String,
|
||||
#[serde(default)]
|
||||
caption: String,
|
||||
}
|
||||
|
||||
/// Fetch the detail for a `gh:owner/repo` source: its `aoe-plugin.toml` (read
|
||||
/// via the contents API, no clone) and the repo's release tags. A manifest that
|
||||
/// is missing or unparseable is reported in `manifest_error` while the release
|
||||
/// tags still load, so the modal degrades gracefully.
|
||||
pub async fn details(source: &str) -> Result<PluginDetail> {
|
||||
let parsed = PluginSource::parse(source)?;
|
||||
let PluginSource::Github { owner, repo, .. } = &parsed else {
|
||||
bail!("details are only available for a gh:owner/repo source");
|
||||
};
|
||||
// Honor a pinned tag/commit so a ref-pinned installed plugin's modal shows
|
||||
// the installed version, not whatever is on HEAD today.
|
||||
let reference = parsed.reference();
|
||||
let client = client()?;
|
||||
|
||||
let manifest = match client
|
||||
.get_repo_file(owner, repo, "aoe-plugin.toml", reference)
|
||||
.await
|
||||
{
|
||||
Ok(text) => toml::from_str::<RawManifest>(&text)
|
||||
.map(|m| DetailManifest {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
version: m.version,
|
||||
description: m.description,
|
||||
api_version: m.api_version,
|
||||
capabilities: m.capabilities,
|
||||
ui_contributions: m
|
||||
.ui
|
||||
.into_iter()
|
||||
.map(|u| UiSlotView {
|
||||
slot: u.slot,
|
||||
id: u.id,
|
||||
})
|
||||
.collect(),
|
||||
screenshots: resolve_screenshots(
|
||||
m.api_version,
|
||||
m.screenshots,
|
||||
owner,
|
||||
repo,
|
||||
reference,
|
||||
),
|
||||
icon: resolve_icon_name(m.api_version, m.icon),
|
||||
icon_asset_url: resolve_icon_asset(
|
||||
m.api_version,
|
||||
m.icon_asset,
|
||||
owner,
|
||||
repo,
|
||||
reference,
|
||||
),
|
||||
})
|
||||
.map_err(|e| format!("aoe-plugin.toml is invalid: {e}")),
|
||||
Err(e) => Err(format!("{e}")),
|
||||
};
|
||||
|
||||
// Release tags are best-effort: a repo with no releases is normal, so a
|
||||
// failure here just yields an empty list rather than failing the request.
|
||||
let release_tags = client
|
||||
.list_releases(owner, repo, 30)
|
||||
.await
|
||||
.map(|rs| rs.into_iter().map(|r| r.tag_name).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let (manifest, manifest_error) = match manifest {
|
||||
Ok(m) => (Some(m), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
Ok(PluginDetail {
|
||||
source: parsed.slug(),
|
||||
manifest,
|
||||
manifest_error,
|
||||
release_tags,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn repo(full_name: &str, stars: u64) -> GitHubRepo {
|
||||
GitHubRepo {
|
||||
full_name: full_name.to_string(),
|
||||
html_url: format!("https://github.com/{full_name}"),
|
||||
description: Some("a plugin".to_string()),
|
||||
stargazers_count: stars,
|
||||
topics: vec!["aoe-plugin".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn featured(slug: &str) -> FeaturedIndex {
|
||||
FeaturedIndex::from_toml_str(&format!(
|
||||
"[plugins.\"x.y\"]\nsource = \"{slug}\"\nversions = {{ \"1.0\" = \"sha256:abc\" }}\n"
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn badges_installed_featured_unvetted() {
|
||||
let repos = vec![
|
||||
repo("acme/installed", 5),
|
||||
repo("acme/vetted", 10),
|
||||
repo("acme/random", 100),
|
||||
];
|
||||
let index = featured("gh:acme/vetted");
|
||||
let installed = vec!["gh:acme/installed".to_string()];
|
||||
let out = badge_repos(repos, &index, &installed);
|
||||
let by_slug = |slug: &str| out.iter().find(|r| r.slug == slug).unwrap().badge;
|
||||
assert_eq!(by_slug("gh:acme/installed"), DiscoveryBadge::Installed);
|
||||
assert_eq!(by_slug("gh:acme/vetted"), DiscoveryBadge::Featured);
|
||||
assert_eq!(by_slug("gh:acme/random"), DiscoveryBadge::Unvetted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_match_is_case_insensitive() {
|
||||
let repos = vec![repo("Acme/Widget", 1)];
|
||||
let installed = vec!["gh:acme/widget".to_string()];
|
||||
let out = badge_repos(repos, &FeaturedIndex::default(), &installed);
|
||||
assert_eq!(out[0].badge, DiscoveryBadge::Installed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranks_featured_first_then_stars() {
|
||||
// A low-star featured result outranks a high-star unvetted one.
|
||||
let repos = vec![repo("acme/popular", 999), repo("acme/vetted", 1)];
|
||||
let index = featured("gh:acme/vetted");
|
||||
let out = rank(badge_repos(repos, &index, &[]));
|
||||
assert_eq!(out[0].slug, "gh:acme/vetted");
|
||||
assert_eq!(out[1].slug, "gh:acme/popular");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_and_featured_still_ranks_featured() {
|
||||
// A repo that is both installed and featured shows the Installed badge
|
||||
// but must still outrank a high-star unvetted repo (#2473 review).
|
||||
let repos = vec![repo("acme/popular", 999), repo("acme/vetted", 1)];
|
||||
let index = featured("gh:acme/vetted");
|
||||
let installed = vec!["gh:acme/vetted".to_string()];
|
||||
let out = rank(badge_repos(repos, &index, &installed));
|
||||
assert_eq!(out[0].slug, "gh:acme/vetted");
|
||||
assert_eq!(out[0].badge, DiscoveryBadge::Installed);
|
||||
assert!(out[0].featured);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_non_owner_repo_results() {
|
||||
let repos = vec![repo("not-a-slug", 1), repo("a/b/c", 1)];
|
||||
let out = badge_repos(repos, &FeaturedIndex::default(), &[]);
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_maps_only_404_to_missing() {
|
||||
let cases = [
|
||||
(Some(StatusCode::OK), ManifestProbe::Present),
|
||||
(Some(StatusCode::NOT_FOUND), ManifestProbe::Missing),
|
||||
// CDN throttling and server errors say nothing about the repo, so
|
||||
// they must never drop a result.
|
||||
(Some(StatusCode::FORBIDDEN), ManifestProbe::Unknown),
|
||||
(Some(StatusCode::TOO_MANY_REQUESTS), ManifestProbe::Unknown),
|
||||
(
|
||||
Some(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
ManifestProbe::Unknown,
|
||||
),
|
||||
// A transport error or a timeout.
|
||||
(None, ManifestProbe::Unknown),
|
||||
];
|
||||
for (status, expected) in cases {
|
||||
assert_eq!(classify(status), expected, "{status:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_only_the_results_a_probe_proved_missing() {
|
||||
let repos = vec![
|
||||
repo("acme/missing", 1),
|
||||
repo("acme/present", 1),
|
||||
repo("acme/unknown", 1),
|
||||
repo("acme/unprobed", 1),
|
||||
repo("acme/installed", 1),
|
||||
repo("acme/vetted", 1),
|
||||
];
|
||||
let index = featured("gh:acme/vetted");
|
||||
let installed = vec!["gh:acme/installed".to_string()];
|
||||
let badged = badge_repos(repos, &index, &installed);
|
||||
// Installed and featured slugs are never probed, so they are absent
|
||||
// from the map even when their default branch lost its manifest;
|
||||
// `unprobed` stands in for a probe that missed the phase deadline.
|
||||
let probes = HashMap::from([
|
||||
("gh:acme/missing".to_string(), ManifestProbe::Missing),
|
||||
("gh:acme/present".to_string(), ManifestProbe::Present),
|
||||
("gh:acme/unknown".to_string(), ManifestProbe::Unknown),
|
||||
]);
|
||||
let kept: Vec<String> = drop_missing(badged, &probes)
|
||||
.into_iter()
|
||||
.map(|r| r.slug)
|
||||
.collect();
|
||||
assert!(
|
||||
!kept.contains(&"gh:acme/missing".to_string()),
|
||||
"a confirmed-missing manifest must drop the result: {kept:?}"
|
||||
);
|
||||
assert_eq!(kept.len(), 5, "everything else fails open: {kept:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_of_missing_manifests_yields_the_empty_state() {
|
||||
let badged = badge_repos(
|
||||
vec![repo("acme/a", 1), repo("acme/b", 1)],
|
||||
&FeaturedIndex::default(),
|
||||
&[],
|
||||
);
|
||||
let probes = HashMap::from([
|
||||
("gh:acme/a".to_string(), ManifestProbe::Missing),
|
||||
("gh:acme/b".to_string(), ManifestProbe::Missing),
|
||||
]);
|
||||
assert!(drop_missing(badged, &probes).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_manifest_probe_targets_the_raw_cdn_not_the_api() {
|
||||
// The probe must not draw on the 60/hr unauthenticated core API budget,
|
||||
// which is the whole reason discovery avoided per-repo manifest reads.
|
||||
let url = raw_url("acme", "widget", None, "aoe-plugin.toml");
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://raw.githubusercontent.com/acme/widget/HEAD/aoe-plugin.toml"
|
||||
);
|
||||
assert!(!url.starts_with(&api_base()), "{url}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_manifest_parse_tolerates_newer_api_version_and_unknown_keys() {
|
||||
// A plugin targeting an api_version this host cannot install must still
|
||||
// render in the detail modal, and unknown/future keys are ignored.
|
||||
let toml = r#"
|
||||
id = "acme.future"
|
||||
name = "Future"
|
||||
version = "9.9.9"
|
||||
api_version = 99
|
||||
description = "from the future"
|
||||
capabilities = ["net"]
|
||||
some_unknown_future_key = true
|
||||
|
||||
[[ui]]
|
||||
slot = "status-bar"
|
||||
id = "s"
|
||||
"#;
|
||||
let m: RawManifest = toml::from_str(toml).expect("lenient parse");
|
||||
assert_eq!(m.version, "9.9.9");
|
||||
assert_eq!(m.api_version, 99);
|
||||
assert_eq!(m.capabilities, vec!["net"]);
|
||||
assert_eq!(m.ui.len(), 1);
|
||||
assert_eq!(m.ui[0].slot, "status-bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_url_defaults_to_head_and_encodes_path() {
|
||||
assert_eq!(
|
||||
raw_url("acme", "widget", None, "docs/shots/a.png"),
|
||||
"https://raw.githubusercontent.com/acme/widget/HEAD/docs/shots/a.png"
|
||||
);
|
||||
// A pinned ref is honored; spaces in a path are percent-encoded while
|
||||
// the `/` separators survive.
|
||||
assert_eq!(
|
||||
raw_url("acme", "widget", Some("v1.2.0"), "media/cool demo.gif"),
|
||||
"https://raw.githubusercontent.com/acme/widget/v1.2.0/media/cool%20demo.gif"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_manifest_parses_screenshots_and_drops_bad_entries() {
|
||||
let toml = r#"
|
||||
id = "acme.widget"
|
||||
name = "Widget"
|
||||
version = "1.0.0"
|
||||
api_version = 5
|
||||
|
||||
[[screenshots]]
|
||||
path = "docs/a.png"
|
||||
alt = "good"
|
||||
|
||||
[[screenshots]]
|
||||
path = "https://tracker.example.com/x.png"
|
||||
alt = "bad url"
|
||||
|
||||
[[screenshots]]
|
||||
path = "docs/b.png"
|
||||
alt = " "
|
||||
"#;
|
||||
let m: RawManifest = toml::from_str(toml).expect("lenient parse");
|
||||
let kept = resolve_screenshots(m.api_version, m.screenshots, "acme", "widget", None);
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(
|
||||
kept[0].src,
|
||||
"https://raw.githubusercontent.com/acme/widget/HEAD/docs/a.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshots_gated_out_below_api_version_5() {
|
||||
// A v4 manifest must not surface screenshots in the detail modal, since
|
||||
// the strict install validator rejects them; the lenient detail path
|
||||
// mirrors that gate.
|
||||
let toml = r#"
|
||||
id = "acme.widget"
|
||||
name = "Widget"
|
||||
version = "1.0.0"
|
||||
api_version = 4
|
||||
|
||||
[[screenshots]]
|
||||
path = "docs/a.png"
|
||||
alt = "good"
|
||||
"#;
|
||||
let m: RawManifest = toml::from_str(toml).expect("lenient parse");
|
||||
let kept = resolve_screenshots(m.api_version, m.screenshots, "acme", "widget", None);
|
||||
assert!(kept.is_empty(), "v4 must not expose screenshots");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_command_uses_the_slug() {
|
||||
let out = badge_repos(vec![repo("acme/widget", 1)], &FeaturedIndex::default(), &[]);
|
||||
assert_eq!(out[0].install_command, "aoe plugin install gh:acme/widget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_avatar_url_derives_from_the_owner_with_no_extra_request() {
|
||||
let out = badge_repos(vec![repo("acme/widget", 1)], &FeaturedIndex::default(), &[]);
|
||||
assert_eq!(
|
||||
out[0].source_avatar_url,
|
||||
"https://github.com/acme.png?size=64"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_manifest_parses_icon_and_resolves_icon_asset() {
|
||||
let toml = r#"
|
||||
id = "acme.widget"
|
||||
name = "Widget"
|
||||
version = "1.0.0"
|
||||
api_version = 7
|
||||
icon = "git-branch"
|
||||
icon_asset = "assets/icon.png"
|
||||
"#;
|
||||
let m: RawManifest = toml::from_str(toml).expect("lenient parse");
|
||||
assert_eq!(
|
||||
resolve_icon_name(m.api_version, m.icon.clone()).as_deref(),
|
||||
Some("git-branch")
|
||||
);
|
||||
let url = resolve_icon_asset(m.api_version, m.icon_asset, "acme", "widget", None);
|
||||
assert_eq!(
|
||||
url.as_deref(),
|
||||
Some("https://raw.githubusercontent.com/acme/widget/HEAD/assets/icon.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_name_gated_out_below_api_version_7() {
|
||||
let toml = r#"
|
||||
id = "acme.widget"
|
||||
name = "Widget"
|
||||
version = "1.0.0"
|
||||
api_version = 6
|
||||
icon = "git-branch"
|
||||
"#;
|
||||
let m: RawManifest = toml::from_str(toml).expect("lenient parse");
|
||||
assert!(
|
||||
resolve_icon_name(m.api_version, m.icon).is_none(),
|
||||
"v6 must not expose icon"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_name_drops_an_invalid_name() {
|
||||
let toml = r#"
|
||||
id = "acme.widget"
|
||||
name = "Widget"
|
||||
version = "1.0.0"
|
||||
api_version = 7
|
||||
icon = "GitHub"
|
||||
"#;
|
||||
let m: RawManifest = toml::from_str(toml).expect("lenient parse");
|
||||
assert!(
|
||||
resolve_icon_name(m.api_version, m.icon).is_none(),
|
||||
"a non-kebab-case name must be dropped, not surfaced to the client"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_asset_gated_out_below_api_version_7() {
|
||||
let toml = r#"
|
||||
id = "acme.widget"
|
||||
name = "Widget"
|
||||
version = "1.0.0"
|
||||
api_version = 6
|
||||
icon_asset = "assets/icon.png"
|
||||
"#;
|
||||
let m: RawManifest = toml::from_str(toml).expect("lenient parse");
|
||||
let url = resolve_icon_asset(m.api_version, m.icon_asset, "acme", "widget", None);
|
||||
assert!(url.is_none(), "v6 must not expose icon_asset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_asset_drops_an_invalid_path() {
|
||||
let toml = r#"
|
||||
id = "acme.widget"
|
||||
name = "Widget"
|
||||
version = "1.0.0"
|
||||
api_version = 7
|
||||
icon_asset = "https://tracker.example.com/x.png"
|
||||
"#;
|
||||
let m: RawManifest = toml::from_str(toml).expect("lenient parse");
|
||||
let url = resolve_icon_asset(m.api_version, m.icon_asset, "acme", "widget", None);
|
||||
assert!(url.is_none(), "an absolute URL path must be dropped");
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
//! The curated / featured plugin index.
|
||||
//!
|
||||
//! `plugins/featured.toml` is compiled into the binary and pins one or more
|
||||
//! vetted plugin releases to their source
|
||||
//! [`tree_hash`](super::integrity::tree_hash). A featured entry is the
|
||||
//! maintainer's attestation that each listed tree was reviewed: it is what makes
|
||||
//! "is this plugin safe" answerable, and it is the only thing that lets a
|
||||
//! community install claim a reserved (`aoe.*` / `agent-of-empires.*`)
|
||||
//! namespace. An entry holds a `version -> tree_hash` map so a new release can
|
||||
//! be vetted alongside older ones; an install whose fetched tree hashes to any
|
||||
//! vetted value is featured-verified.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The compiled-in index. Ships effectively empty; entries land as maintainers
|
||||
/// vet plugin releases.
|
||||
const EMBEDDED: &str = include_str!("../../plugins/featured.toml");
|
||||
|
||||
/// One featured plugin's vetted releases, keyed by plugin id in the index.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FeaturedEntry {
|
||||
/// The canonical source slug the plugin must be installed from
|
||||
/// (`gh:owner/repo`).
|
||||
pub source: String,
|
||||
/// Vetted releases as `version -> sha256:<hex>` of the source tree. The
|
||||
/// version label is informational (it documents which release each hash
|
||||
/// belongs to); the verified decision is membership in the set of values.
|
||||
pub versions: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl FeaturedEntry {
|
||||
/// Whether `tree_hash` is one of this entry's vetted release hashes.
|
||||
pub fn verifies(&self, tree_hash: &str) -> bool {
|
||||
self.versions.values().any(|v| v == tree_hash)
|
||||
}
|
||||
}
|
||||
|
||||
/// The parsed featured index.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct FeaturedIndex {
|
||||
#[serde(default)]
|
||||
plugins: BTreeMap<String, FeaturedEntry>,
|
||||
}
|
||||
|
||||
impl FeaturedIndex {
|
||||
/// Load the curated index.
|
||||
///
|
||||
/// In debug builds `AOE_FEATURED_INDEX_PATH` overrides the embedded file so
|
||||
/// tests can supply their own pins. Release builds ALWAYS use the
|
||||
/// compiled-in index: the curated set is a root of trust, so it must not be
|
||||
/// redefinable by the process environment in a shipped binary (an env
|
||||
/// override would let any caller elevate a malicious plugin into a reserved
|
||||
/// namespace).
|
||||
pub fn load() -> Result<Self> {
|
||||
#[cfg(debug_assertions)]
|
||||
if let Ok(path) = std::env::var("AOE_FEATURED_INDEX_PATH") {
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("reading AOE_FEATURED_INDEX_PATH {path}"))?;
|
||||
return Self::from_toml_str(&text);
|
||||
}
|
||||
Self::from_toml_str(EMBEDDED)
|
||||
}
|
||||
|
||||
pub fn from_toml_str(text: &str) -> Result<Self> {
|
||||
toml::from_str(text).context("parsing featured plugin index")
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&FeaturedEntry> {
|
||||
self.plugins.get(id)
|
||||
}
|
||||
|
||||
/// Whether any featured entry is pinned to this source slug (case-insensitive,
|
||||
/// GitHub slugs are not case-sensitive). Discovery uses this to badge a search
|
||||
/// result as a featured *source*, without fetching its manifest; it is not a
|
||||
/// claim that the repo's current tree matches the pinned `tree_hash`, which
|
||||
/// only install-time `verify_featured` enforces.
|
||||
pub fn is_featured_source(&self, slug: &str) -> bool {
|
||||
self.plugins
|
||||
.values()
|
||||
.any(|e| e.source.eq_ignore_ascii_case(slug))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn embedded_index_parses() {
|
||||
// A broken embedded featured.toml is a build defect; catch it in CI.
|
||||
FeaturedIndex::from_toml_str(EMBEDDED).expect("embedded featured.toml must parse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_up_by_id() {
|
||||
let index = FeaturedIndex::from_toml_str(
|
||||
r#"
|
||||
[plugins."agent-of-empires.example"]
|
||||
source = "gh:agent-of-empires/example"
|
||||
versions = { "1.0" = "sha256:abc" }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let entry = index.get("agent-of-empires.example").expect("present");
|
||||
assert_eq!(entry.source, "gh:agent-of-empires/example");
|
||||
assert_eq!(
|
||||
entry.versions.get("1.0").map(String::as_str),
|
||||
Some("sha256:abc")
|
||||
);
|
||||
assert!(index.get("acme.absent").is_none());
|
||||
|
||||
// Source-slug match is case-insensitive and ignores the keying id.
|
||||
assert!(index.is_featured_source("gh:agent-of-empires/example"));
|
||||
assert!(index.is_featured_source("gh:Agent-Of-Empires/Example"));
|
||||
assert!(!index.is_featured_source("gh:someone/else"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verifies_any_vetted_hash() {
|
||||
let index = FeaturedIndex::from_toml_str(
|
||||
r#"
|
||||
[plugins."agent-of-empires.example"]
|
||||
source = "gh:agent-of-empires/example"
|
||||
versions = { "1.0" = "sha256:aaa", "1.1" = "sha256:bbb" }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let entry = index.get("agent-of-empires.example").expect("present");
|
||||
assert!(entry.verifies("sha256:aaa"));
|
||||
assert!(entry.verifies("sha256:bbb"));
|
||||
assert!(!entry.verifies("sha256:ccc"));
|
||||
}
|
||||
}
|
||||
@@ -1,665 +0,0 @@
|
||||
//! Fetching an external plugin into a staging tree, ready to be moved into
|
||||
//! place by [`crate::plugin::install`].
|
||||
//!
|
||||
//! Two source kinds, selected by [`PluginSource`]:
|
||||
//!
|
||||
//! - A GitHub repo is `git clone`d (shallow when possible), the requested ref
|
||||
//! is checked out, and the exact commit is resolved for the lockfile. The
|
||||
//! `.git` directory is stripped; the working tree is the plugin.
|
||||
//! - A local directory is copied verbatim (minus `.git`).
|
||||
//!
|
||||
//! If the manifest declares a `release-binary` runtime, the matching release
|
||||
//! asset for the host platform is downloaded from the repo's GitHub releases
|
||||
//! and unpacked into the tree. The worker is not launched here; that is #2095.
|
||||
//! A local source never fetches a release: its binary must already be present.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use aoe_plugin_api::{PluginManifest, RuntimeSpec};
|
||||
|
||||
use crate::github::{GitHubClient, GitHubClientConfig, GitHubError, DEFAULT_USER_AGENT};
|
||||
|
||||
use super::source::PluginSource;
|
||||
|
||||
/// A plugin fetched into a staging tree, not yet installed.
|
||||
pub struct FetchedPlugin {
|
||||
/// Keeps the staging directory alive until the tree is moved into place.
|
||||
_staging: tempfile::TempDir,
|
||||
/// The plugin tree to move to `<app_dir>/plugins/<id>/`.
|
||||
pub tree: PathBuf,
|
||||
pub manifest: PluginManifest,
|
||||
/// Raw `aoe-plugin.toml` bytes, for hashing the grant against.
|
||||
pub manifest_bytes: Vec<u8>,
|
||||
/// `sha256:<hex>` over the source tree, computed before any release-binary
|
||||
/// is injected so it matches an author's `aoe plugin hash` of the checkout.
|
||||
pub tree_hash: String,
|
||||
pub source: PluginSource,
|
||||
pub requested_ref: Option<String>,
|
||||
pub resolved_commit: Option<String>,
|
||||
pub release_tag: Option<String>,
|
||||
pub asset_name: Option<String>,
|
||||
pub asset_sha256: Option<String>,
|
||||
}
|
||||
|
||||
/// Fetch a plugin from its source into a staging tree.
|
||||
pub async fn fetch(source: &PluginSource) -> Result<FetchedPlugin> {
|
||||
let plugins_root = super::plugins_dir()?;
|
||||
std::fs::create_dir_all(&plugins_root)
|
||||
.with_context(|| format!("creating {}", plugins_root.display()))?;
|
||||
// Stage under the plugins dir so the final rename into place is same-filesystem.
|
||||
let staging = tempfile::Builder::new()
|
||||
.prefix(".staging-")
|
||||
.tempdir_in(&plugins_root)
|
||||
.context("creating plugin staging dir")?;
|
||||
let tree = staging.path().join("tree");
|
||||
|
||||
let (requested_ref, resolved_commit) = match source {
|
||||
PluginSource::Github { reference, .. } => {
|
||||
let url = source
|
||||
.github_clone_url()
|
||||
.expect("github source yields a clone url");
|
||||
let reference = reference.clone();
|
||||
let tree_clone = tree.clone();
|
||||
let sha = tokio::task::spawn_blocking(move || {
|
||||
git_clone_checkout(&url, reference.as_deref(), &tree_clone)
|
||||
})
|
||||
.await
|
||||
.context("git clone task panicked")??;
|
||||
(source.reference().map(String::from), Some(sha))
|
||||
}
|
||||
PluginSource::Local(path) => {
|
||||
if !path.is_dir() {
|
||||
bail!("local plugin source {} is not a directory", path.display());
|
||||
}
|
||||
copy_tree(path, &tree)?;
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
|
||||
let (manifest, manifest_bytes) = read_manifest(&tree)?;
|
||||
|
||||
// The reserved build-output dir is excluded from the tree hash, so a source
|
||||
// that ships it could hide files from the pin. It must only ever be created
|
||||
// by build steps, never committed; refuse a source tree that contains it.
|
||||
if tree.join(super::integrity::BUILD_OUTPUT_DIR).exists() {
|
||||
bail!(
|
||||
"plugin source ships the reserved build-output directory {:?}; it must only be produced by build steps, not committed",
|
||||
super::integrity::BUILD_OUTPUT_DIR
|
||||
);
|
||||
}
|
||||
|
||||
// Hash the source tree before any release-binary is injected below, so the
|
||||
// value matches `aoe plugin hash` run on the author's checkout (which has
|
||||
// no downloaded worker) and can be checked against the featured pin.
|
||||
let tree_hash = super::integrity::tree_hash(&tree)?;
|
||||
|
||||
let mut release_tag = None;
|
||||
let mut asset_name = None;
|
||||
let mut asset_sha256 = None;
|
||||
if let Some(RuntimeSpec::ReleaseBinary { asset, bin }) = &manifest.runtime {
|
||||
match source {
|
||||
PluginSource::Github { .. } => {
|
||||
let (tag, name, sha) = download_release_binary(
|
||||
source,
|
||||
&manifest,
|
||||
asset,
|
||||
bin.as_deref(),
|
||||
&tree,
|
||||
requested_ref.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
release_tag = Some(tag);
|
||||
asset_name = Some(name);
|
||||
asset_sha256 = Some(sha);
|
||||
}
|
||||
PluginSource::Local(_) => {
|
||||
// A local source ships its binary in the directory already; there
|
||||
// is no release to pull from.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(FetchedPlugin {
|
||||
_staging: staging,
|
||||
tree,
|
||||
manifest,
|
||||
manifest_bytes,
|
||||
tree_hash,
|
||||
source: source.clone(),
|
||||
requested_ref,
|
||||
resolved_commit,
|
||||
release_tag,
|
||||
asset_name,
|
||||
asset_sha256,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_manifest(tree: &Path) -> Result<(PluginManifest, Vec<u8>)> {
|
||||
let path = tree.join("aoe-plugin.toml");
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
// Discovery filters most of these out, but it fails open when the raw
|
||||
// CDN is unreachable, so this stays the authoritative answer and leads
|
||||
// with the likely cause rather than a staging path. Scoped to a real
|
||||
// absence: a permission error or a directory at that path is an I/O
|
||||
// problem, not a repo that turned out to be an Age of Empires mod.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
|
||||
"no aoe-plugin.toml at {}; this repository is not an installable plugin. \
|
||||
The GitHub `aoe-plugin` topic is also used by unrelated Age of Empires projects.",
|
||||
path.display()
|
||||
),
|
||||
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
|
||||
};
|
||||
let text = std::str::from_utf8(&bytes).context("aoe-plugin.toml is not valid UTF-8")?;
|
||||
let manifest = PluginManifest::from_toml_str(text).map_err(|e| anyhow!("{e}"))?;
|
||||
Ok((manifest, bytes))
|
||||
}
|
||||
|
||||
/// Run `git` with the given args, returning trimmed stdout. Surfaces stderr on
|
||||
/// failure and a clear hint when git is not installed.
|
||||
// ponytail: no explicit timeout; git fails on its own for unreachable remotes.
|
||||
fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<String> {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| anyhow!("failed to run git (is it installed and on PATH?): {e}"))?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Resolve the commit a GitHub source's ref currently points at, without
|
||||
/// cloning, via `git ls-remote`. `reference` of `None` means the remote `HEAD`.
|
||||
/// Used by the update check to tell whether a newer commit exists.
|
||||
///
|
||||
/// A `reference` that is already a full commit sha is returned as-is: `ls-remote`
|
||||
/// cannot resolve a bare sha, and a commit-pinned install can never be outdated.
|
||||
/// For a branch or tag, an annotated tag's peeled (`^{}`) target is preferred so
|
||||
/// the result is the commit the install would actually check out.
|
||||
pub fn ls_remote(url: &str, reference: Option<&str>) -> Result<String> {
|
||||
if let Some(r) = reference {
|
||||
if is_full_commit_sha(r) {
|
||||
return Ok(r.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
let target = reference.unwrap_or("HEAD");
|
||||
// An annotated tag's peeled `^{}` commit is only emitted when the refspec
|
||||
// asks for it; without it ls-remote returns the tag object, which never
|
||||
// equals the commit the install checked out (a phantom "update available",
|
||||
// #2646). Request both so parse_ls_remote can prefer the peeled commit. A
|
||||
// branch or HEAD has no `^{}` to match, so the extra pattern is a no-op.
|
||||
let peeled = format!("{target}^{{}}");
|
||||
let out = run_git(&["ls-remote", url, target, &peeled], None)?;
|
||||
parse_ls_remote(&out, target)
|
||||
}
|
||||
|
||||
/// The tag of the repo's latest stable GitHub release, or `None` when the repo
|
||||
/// has published no release. `GET /releases/latest` already excludes prereleases
|
||||
/// and drafts, so this is the stable channel; a 404 (no releases) maps to `None`
|
||||
/// while any other API error propagates. Used by the default install path
|
||||
/// (no `@ref`) and the rolling update check.
|
||||
pub async fn latest_release_tag(owner: &str, repo: &str) -> Result<Option<String>> {
|
||||
let client = GitHubClient::unauthenticated(GitHubClientConfig {
|
||||
api_base: github_api_base(),
|
||||
user_agent: DEFAULT_USER_AGENT.to_string(),
|
||||
timeout: Duration::from_secs(60),
|
||||
})?;
|
||||
match client.latest_release(owner, repo).await {
|
||||
Ok(release) => Ok(Some(release.tag_name)),
|
||||
Err(GitHubError::NotFound { .. }) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_full_commit_sha(s: &str) -> bool {
|
||||
s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Pick the resolved commit from `git ls-remote` output (`<sha>\t<ref>` lines),
|
||||
/// preferring an annotated tag's peeled `^{}` target over the tag object itself.
|
||||
fn parse_ls_remote(out: &str, target: &str) -> Result<String> {
|
||||
let mut first = None;
|
||||
for line in out.lines() {
|
||||
let Some((sha, name)) = line.split_once('\t') else {
|
||||
continue;
|
||||
};
|
||||
if name.ends_with("^{}") {
|
||||
return Ok(sha.trim().to_string());
|
||||
}
|
||||
first.get_or_insert_with(|| sha.trim().to_string());
|
||||
}
|
||||
first.ok_or_else(|| anyhow!("ref {target:?} not found on the remote"))
|
||||
}
|
||||
|
||||
fn path_arg(path: &Path) -> Result<&str> {
|
||||
path.to_str()
|
||||
.ok_or_else(|| anyhow!("non-UTF-8 path: {}", path.display()))
|
||||
}
|
||||
|
||||
/// Clone `url` into `dest`, check out `reference` (if any), strip `.git`, and
|
||||
/// return the resolved commit. A shallow clone of the ref is tried first; an
|
||||
/// arbitrary commit ref falls back to a full clone plus checkout.
|
||||
fn git_clone_checkout(url: &str, reference: Option<&str>, dest: &Path) -> Result<String> {
|
||||
let dest_str = path_arg(dest)?;
|
||||
|
||||
// `core.autocrlf=false` keeps the checkout byte-for-byte as committed, so
|
||||
// the tree hash is the same on every platform; without it a Windows clone
|
||||
// would rewrite line endings and never match a pin generated on Linux.
|
||||
let shallow = match reference {
|
||||
Some(reference) => run_git(
|
||||
&[
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--branch",
|
||||
reference,
|
||||
"--",
|
||||
url,
|
||||
dest_str,
|
||||
],
|
||||
None,
|
||||
)
|
||||
.is_ok(),
|
||||
None => run_git(
|
||||
&[
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--",
|
||||
url,
|
||||
dest_str,
|
||||
],
|
||||
None,
|
||||
)
|
||||
.is_ok(),
|
||||
};
|
||||
|
||||
if !shallow {
|
||||
// A partial clone may have created dest; clear it before retrying.
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
run_git(
|
||||
&["-c", "core.autocrlf=false", "clone", "--", url, dest_str],
|
||||
None,
|
||||
)?;
|
||||
if let Some(reference) = reference {
|
||||
// `--` separates the revision from pathspecs so a ref that begins
|
||||
// with a dash is not parsed as a flag.
|
||||
run_git(
|
||||
&[
|
||||
"-c",
|
||||
"advice.detachedHead=false",
|
||||
"checkout",
|
||||
reference,
|
||||
"--",
|
||||
],
|
||||
Some(dest),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let sha = run_git(&["rev-parse", "HEAD"], Some(dest))?;
|
||||
// The plugin is the working tree, not a git checkout; drop the history.
|
||||
let _ = std::fs::remove_dir_all(dest.join(".git"));
|
||||
Ok(sha)
|
||||
}
|
||||
|
||||
/// Recursively copy `src` into `dst`, skipping `.git` and rejecting symlinks.
|
||||
///
|
||||
/// A symlink is a hard error rather than a silent skip: `integrity::tree_hash`
|
||||
/// also rejects symlinks, so skipping one here would make the install-time hash
|
||||
/// disagree with the `aoe plugin hash` an author runs on the same directory
|
||||
/// (and following one risks escaping the tree).
|
||||
fn copy_tree(src: &Path, dst: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(dst).with_context(|| format!("creating {}", dst.display()))?;
|
||||
for entry in std::fs::read_dir(src).with_context(|| format!("reading {}", src.display()))? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
if name == ".git" {
|
||||
continue;
|
||||
}
|
||||
let file_type = entry.file_type()?;
|
||||
let from = entry.path();
|
||||
let to = dst.join(&name);
|
||||
if file_type.is_symlink() {
|
||||
bail!(
|
||||
"plugin source contains a symlink ({}); symlinks are not allowed",
|
||||
from.display()
|
||||
);
|
||||
} else if file_type.is_dir() {
|
||||
copy_tree(&from, &to)?;
|
||||
} else {
|
||||
std::fs::copy(&from, &to)
|
||||
.with_context(|| format!("copying {} to {}", from.display(), to.display()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn github_api_base() -> String {
|
||||
std::env::var("AOE_UPDATE_API_BASE")
|
||||
.unwrap_or_else(|_| crate::github::DEFAULT_GITHUB_API_BASE.to_string())
|
||||
}
|
||||
|
||||
/// Resolve the release for the host platform, download the matching asset, and
|
||||
/// unpack it into `tree`. Returns `(release_tag, asset_name, asset_sha256)`.
|
||||
async fn download_release_binary(
|
||||
source: &PluginSource,
|
||||
manifest: &PluginManifest,
|
||||
asset_template: &str,
|
||||
bin: Option<&str>,
|
||||
tree: &Path,
|
||||
requested_ref: Option<&str>,
|
||||
) -> Result<(String, String, String)> {
|
||||
let (owner, repo) = match source {
|
||||
PluginSource::Github { owner, repo, .. } => (owner.as_str(), repo.as_str()),
|
||||
PluginSource::Local(_) => bail!("a release-binary worker requires a GitHub source"),
|
||||
};
|
||||
|
||||
let client = GitHubClient::unauthenticated(GitHubClientConfig {
|
||||
api_base: github_api_base(),
|
||||
user_agent: DEFAULT_USER_AGENT.to_string(),
|
||||
timeout: Duration::from_secs(60),
|
||||
})?;
|
||||
|
||||
let release = match requested_ref {
|
||||
Some(tag) => client
|
||||
.release_by_tag(owner, repo, tag)
|
||||
.await
|
||||
.with_context(|| format!("no release tagged {tag:?} for {owner}/{repo}"))?,
|
||||
None => client
|
||||
.latest_release(owner, repo)
|
||||
.await
|
||||
.with_context(|| format!("no latest release for {owner}/{repo}"))?,
|
||||
};
|
||||
|
||||
let wanted = render_asset_template(asset_template, &manifest.version);
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == wanted)
|
||||
.ok_or_else(|| {
|
||||
let available: Vec<&str> = release.assets.iter().map(|a| a.name.as_str()).collect();
|
||||
anyhow!(
|
||||
"release {} has no asset {wanted:?} for this platform; available: [{}]",
|
||||
release.tag_name,
|
||||
available.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
let bytes = http_get_bytes(&asset.browser_download_url).await?;
|
||||
let sha = sha256_hex(&bytes);
|
||||
install_asset_into(tree, &asset.name, bin, &bytes)?;
|
||||
Ok((release.tag_name.clone(), asset.name.clone(), sha))
|
||||
}
|
||||
|
||||
/// Substitute the platform tokens in an asset name template. Supported:
|
||||
/// `${os}` (e.g. `linux`, `macos`), `${arch}` (e.g. `x86_64`, `aarch64`), and
|
||||
/// `${version}` (the manifest version).
|
||||
fn render_asset_template(template: &str, version: &str) -> String {
|
||||
template
|
||||
.replace("${os}", std::env::consts::OS)
|
||||
.replace("${arch}", std::env::consts::ARCH)
|
||||
.replace("${version}", version)
|
||||
}
|
||||
|
||||
async fn http_get_bytes(url: &str) -> Result<Vec<u8>> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(DEFAULT_USER_AGENT)
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()?;
|
||||
let response = client.get(url).send().await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
bail!("downloading {url} failed: HTTP {status}");
|
||||
}
|
||||
Ok(response.bytes().await?.to_vec())
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fmt::Write;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
let digest = hasher.finalize();
|
||||
let mut out = String::with_capacity(7 + digest.len() * 2);
|
||||
out.push_str("sha256:");
|
||||
for byte in digest {
|
||||
let _ = write!(out, "{byte:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Place a downloaded asset into the plugin tree. A `.tar.gz` archive is
|
||||
/// unpacked and `bin` (required) names the executable within; any other asset
|
||||
/// is treated as a raw binary written as `bin` (or the asset name). The result
|
||||
/// is made executable.
|
||||
fn install_asset_into(
|
||||
tree: &Path,
|
||||
asset_name: &str,
|
||||
bin: Option<&str>,
|
||||
bytes: &[u8],
|
||||
) -> Result<()> {
|
||||
if asset_name.ends_with(".tar.gz") || asset_name.ends_with(".tgz") {
|
||||
let decoder = flate2::read::GzDecoder::new(bytes);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive
|
||||
.unpack(tree)
|
||||
.with_context(|| format!("unpacking {asset_name}"))?;
|
||||
let bin_rel =
|
||||
bin.ok_or_else(|| anyhow!("a release-binary archive asset must set `bin`"))?;
|
||||
ensure_executable(&safe_tree_path(tree, bin_rel)?)
|
||||
} else {
|
||||
let name = bin.unwrap_or(asset_name);
|
||||
let path = safe_tree_path(tree, name)?;
|
||||
std::fs::write(&path, bytes).with_context(|| format!("writing {}", path.display()))?;
|
||||
ensure_executable(&path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Join a manifest-provided relative path onto the plugin tree, rejecting
|
||||
/// anything that would escape it. `bin` is untrusted manifest input, so an
|
||||
/// absolute path or a `..` component must not turn install into an arbitrary
|
||||
/// write or chmod outside the staging dir.
|
||||
fn safe_tree_path(tree: &Path, rel: &str) -> Result<PathBuf> {
|
||||
use std::path::Component;
|
||||
let candidate = Path::new(rel);
|
||||
let safe = candidate
|
||||
.components()
|
||||
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir));
|
||||
if !safe || candidate.as_os_str().is_empty() {
|
||||
bail!("plugin path {rel:?} must be a relative path inside the plugin (no absolute path or `..`)");
|
||||
}
|
||||
Ok(tree.join(candidate))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn ensure_executable(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if !path.exists() {
|
||||
bail!(
|
||||
"expected binary {} missing after extraction",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
let mut perms = std::fs::metadata(path)?.permissions();
|
||||
perms.set_mode(perms.mode() | 0o755);
|
||||
std::fs::set_permissions(path, perms)
|
||||
.with_context(|| format!("making {} executable", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn ensure_executable(path: &Path) -> Result<()> {
|
||||
if !path.exists() {
|
||||
bail!(
|
||||
"expected binary {} missing after extraction",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_ls_remote_prefers_peeled_tag() {
|
||||
let out = "1111111111111111111111111111111111111111\trefs/tags/v1\n\
|
||||
2222222222222222222222222222222222222222\trefs/tags/v1^{}";
|
||||
assert_eq!(
|
||||
parse_ls_remote(out, "v1").unwrap(),
|
||||
"2222222222222222222222222222222222222222"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_remote_takes_first_when_unpeeled() {
|
||||
let out = "3333333333333333333333333333333333333333\tHEAD";
|
||||
assert_eq!(
|
||||
parse_ls_remote(out, "HEAD").unwrap(),
|
||||
"3333333333333333333333333333333333333333"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_remote_errors_when_empty() {
|
||||
assert!(parse_ls_remote("", "nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ls_remote_returns_pinned_sha_as_is() {
|
||||
let sha = "abcdef0123456789abcdef0123456789abcdef01";
|
||||
assert_eq!(ls_remote("unused://", Some(sha)).unwrap(), sha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ls_remote_peels_annotated_tag_to_commit() {
|
||||
// `git ls-remote <url> v1` returns the annotated tag object, but the
|
||||
// install checks out the peeled commit. ls_remote must resolve to the
|
||||
// commit so the update check does not report a phantom update (#2646).
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path();
|
||||
let git = |args: &[&str]| {
|
||||
std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(path)
|
||||
.env("GIT_AUTHOR_NAME", "t")
|
||||
.env("GIT_AUTHOR_EMAIL", "t@t")
|
||||
.env("GIT_COMMITTER_NAME", "t")
|
||||
.env("GIT_COMMITTER_EMAIL", "t@t")
|
||||
.output()
|
||||
.expect("run git")
|
||||
};
|
||||
// git absent: nothing to test, skip rather than fail.
|
||||
if !git(&["init", "-q"]).status.success() {
|
||||
return;
|
||||
}
|
||||
std::fs::write(path.join("f"), b"x").unwrap();
|
||||
git(&["add", "f"]);
|
||||
git(&["commit", "-qm", "c"]);
|
||||
git(&["tag", "-a", "v1", "-m", "release"]);
|
||||
|
||||
let sha_of = |rev: &str| {
|
||||
String::from_utf8(git(&["rev-parse", rev]).stdout)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string()
|
||||
};
|
||||
let commit = sha_of("HEAD");
|
||||
let tag_object = sha_of("v1");
|
||||
// An annotated tag's object is distinct from the commit it points at;
|
||||
// without that, this test would not distinguish the bug from the fix.
|
||||
assert_ne!(
|
||||
commit, tag_object,
|
||||
"annotated tag object should differ from the commit"
|
||||
);
|
||||
|
||||
let resolved = ls_remote(path.to_str().unwrap(), Some("v1")).unwrap();
|
||||
assert_eq!(
|
||||
resolved, commit,
|
||||
"ls_remote should return the peeled commit, not the tag object"
|
||||
);
|
||||
assert_ne!(resolved, tag_object);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_platform_tokens() {
|
||||
let rendered = render_asset_template("w-${os}-${arch}-${version}.tar.gz", "1.2.3");
|
||||
assert!(rendered.starts_with("w-"));
|
||||
assert!(rendered.ends_with("-1.2.3.tar.gz"));
|
||||
assert!(rendered.contains(std::env::consts::OS));
|
||||
assert!(rendered.contains(std::env::consts::ARCH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_tree_path_rejects_escapes() {
|
||||
let tree = Path::new("/plugins/acme");
|
||||
assert!(safe_tree_path(tree, "bin/worker").is_ok());
|
||||
assert!(safe_tree_path(tree, "worker").is_ok());
|
||||
for bad in ["../../.bashrc", "/etc/passwd", "a/../../b", ""] {
|
||||
assert!(
|
||||
safe_tree_path(tree, bad).is_err(),
|
||||
"{bad} should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_asset_is_written_executable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
install_asset_into(dir.path(), "thing", Some("thing"), b"#!/bin/sh\n").unwrap();
|
||||
let path = dir.path().join("thing");
|
||||
assert!(path.exists());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
||||
assert!(mode & 0o111 != 0, "should be executable, mode {mode:o}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_tree_skips_git() {
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
std::fs::write(src.path().join("aoe-plugin.toml"), b"x").unwrap();
|
||||
std::fs::create_dir(src.path().join(".git")).unwrap();
|
||||
std::fs::write(src.path().join(".git").join("config"), b"y").unwrap();
|
||||
let dst = tempfile::tempdir().unwrap();
|
||||
let into = dst.path().join("tree");
|
||||
copy_tree(src.path(), &into).unwrap();
|
||||
assert!(into.join("aoe-plugin.toml").exists());
|
||||
assert!(!into.join(".git").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn copy_tree_rejects_symlinks() {
|
||||
let src = tempfile::tempdir().unwrap();
|
||||
std::fs::write(src.path().join("real"), b"x").unwrap();
|
||||
std::os::unix::fs::symlink("real", src.path().join("link")).unwrap();
|
||||
let dst = tempfile::tempdir().unwrap();
|
||||
let err = copy_tree(src.path(), &dst.path().join("tree"))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("symlink"), "got: {err}");
|
||||
}
|
||||
}
|
||||
-1091
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,239 +0,0 @@
|
||||
//! Deterministic content hash over a plugin's source tree.
|
||||
//!
|
||||
//! This is the hash a maintainer pins in `plugins/featured.toml` and an author
|
||||
//! reproduces with `aoe plugin hash`. It covers the source files only: a
|
||||
//! downloaded release-binary worker is excluded (it is injected after this is
|
||||
//! computed, and is pinned separately by the lockfile's `asset_sha256`), so an
|
||||
//! author's repo checkout and the installed tree hash to the same value.
|
||||
//!
|
||||
//! The format is versioned (`HASH_PREFIX`) so the hashed fields can change
|
||||
//! later (for example folding in the executable bit once #2095 launches
|
||||
//! workers) without a new value silently colliding with an old pin.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Domain-separation header. Bump the version when the hashed fields change.
|
||||
const HASH_PREFIX: &[u8] = b"aoe-plugin-tree-hash-v1\0";
|
||||
|
||||
/// Reserved directory for a plugin's build output. A `command` runtime's build
|
||||
/// steps (a Python `.venv`, `node_modules`, compiled artifacts) must write here,
|
||||
/// never into the source tree. It is excluded from the hash at every level, like
|
||||
/// `.git`, so a build that mutates the install tree does not change the source
|
||||
/// hash: an author's `aoe plugin hash` of a clean checkout and the live load-path
|
||||
/// re-derivation over the built tree produce the same value, keeping a featured
|
||||
/// pin verifiable after the build runs. Build output is therefore not attested by
|
||||
/// the pin (build steps already run unsandboxed at the user's trust); a fixed
|
||||
/// reserved name keeps the exclusion out of attacker control, unlike a
|
||||
/// manifest-declared list which a tampered manifest could widen to hide source.
|
||||
pub const BUILD_OUTPUT_DIR: &str = ".aoe-build";
|
||||
|
||||
/// Deterministic `sha256:<hex>` over the files in `dir`.
|
||||
///
|
||||
/// Files are sorted by their forward-slash relative path; each contributes
|
||||
/// `file\0<path>\0<len><content>` to the digest, where `<len>` is the content
|
||||
/// length as 8 little-endian bytes so a path/content boundary is unambiguous.
|
||||
/// `.git` and the reserved [`BUILD_OUTPUT_DIR`] are skipped at every level (both
|
||||
/// are stripped from, or generated into, an installed tree). A symlink or a
|
||||
/// non-UTF-8 path is an error, not a silent skip, so nothing that would be
|
||||
/// installed escapes the hash. File mode is deliberately excluded for
|
||||
/// cross-platform determinism (Windows has no executable bit).
|
||||
pub fn tree_hash(dir: &Path) -> Result<String> {
|
||||
let mut files = Vec::new();
|
||||
collect(dir, dir, &mut files)?;
|
||||
files.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(HASH_PREFIX);
|
||||
for (rel, contents) in &files {
|
||||
hasher.update(b"file\0");
|
||||
hasher.update(rel.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update((contents.len() as u64).to_le_bytes());
|
||||
hasher.update(contents);
|
||||
}
|
||||
Ok(format_digest(&hasher.finalize()))
|
||||
}
|
||||
|
||||
fn collect(root: &Path, dir: &Path, out: &mut Vec<(String, Vec<u8>)>) -> Result<()> {
|
||||
for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
|
||||
let entry = entry?;
|
||||
// Skip git history at every level. Skip the reserved build-output dir
|
||||
// before inspecting the entry's type (a build output like a `.venv`
|
||||
// holds symlinks the check below would reject, and is not hashed
|
||||
// source), but ONLY at the root: it is a single top-level dir, so a
|
||||
// nested `<sub>/.aoe-build` is ordinary source, hashed and
|
||||
// symlink-checked like anything else rather than silently dropped.
|
||||
if entry.file_name() == ".git" {
|
||||
continue;
|
||||
}
|
||||
if dir == root && entry.file_name() == BUILD_OUTPUT_DIR {
|
||||
continue;
|
||||
}
|
||||
let file_type = entry.file_type()?;
|
||||
let path = entry.path();
|
||||
if file_type.is_symlink() {
|
||||
bail!(
|
||||
"plugin tree contains a symlink ({}); symlinks are not allowed in a hashed tree",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
if file_type.is_dir() {
|
||||
collect(root, &path, out)?;
|
||||
} else {
|
||||
let rel = path
|
||||
.strip_prefix(root)
|
||||
.expect("entry path is under root")
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("non-UTF-8 path in plugin tree: {}", path.display()))?
|
||||
.replace('\\', "/");
|
||||
let contents =
|
||||
std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
|
||||
out.push((rel, contents));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_digest(digest: &[u8]) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut out = String::with_capacity(7 + digest.len() * 2);
|
||||
out.push_str("sha256:");
|
||||
for byte in digest {
|
||||
let _ = write!(out, "{byte:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(dir: &Path, rel: &str, contents: &[u8]) {
|
||||
let path = dir.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_and_prefixed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "aoe-plugin.toml", b"id = \"a.b\"\n");
|
||||
write(dir.path(), "src/main.rs", b"fn main() {}\n");
|
||||
|
||||
let first = tree_hash(dir.path()).unwrap();
|
||||
let second = tree_hash(dir.path()).unwrap();
|
||||
assert_eq!(first, second, "hash is stable across runs");
|
||||
assert!(first.starts_with("sha256:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_change_flips_hash() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "f.txt", b"one");
|
||||
let before = tree_hash(dir.path()).unwrap();
|
||||
write(dir.path(), "f.txt", b"two");
|
||||
assert_ne!(before, tree_hash(dir.path()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_independent_but_path_sensitive() {
|
||||
// Two files swapping their contents must not hash the same: the path is
|
||||
// bound to its content, not just concatenated alongside it.
|
||||
let a = tempfile::tempdir().unwrap();
|
||||
write(a.path(), "x", b"1");
|
||||
write(a.path(), "y", b"2");
|
||||
let b = tempfile::tempdir().unwrap();
|
||||
write(b.path(), "x", b"2");
|
||||
write(b.path(), "y", b"1");
|
||||
assert_ne!(tree_hash(a.path()).unwrap(), tree_hash(b.path()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_dir_is_skipped() {
|
||||
let with_git = tempfile::tempdir().unwrap();
|
||||
write(with_git.path(), "aoe-plugin.toml", b"x");
|
||||
write(with_git.path(), ".git/config", b"junk");
|
||||
let without_git = tempfile::tempdir().unwrap();
|
||||
write(without_git.path(), "aoe-plugin.toml", b"x");
|
||||
assert_eq!(
|
||||
tree_hash(with_git.path()).unwrap(),
|
||||
tree_hash(without_git.path()).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_output_dir_is_skipped() {
|
||||
let with_build = tempfile::tempdir().unwrap();
|
||||
write(with_build.path(), "aoe-plugin.toml", b"x");
|
||||
write(
|
||||
with_build.path(),
|
||||
&format!("{BUILD_OUTPUT_DIR}/venv/pyvenv.cfg"),
|
||||
b"junk",
|
||||
);
|
||||
let without_build = tempfile::tempdir().unwrap();
|
||||
write(without_build.path(), "aoe-plugin.toml", b"x");
|
||||
assert_eq!(
|
||||
tree_hash(with_build.path()).unwrap(),
|
||||
tree_hash(without_build.path()).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_build_output_dir_is_hashed_not_skipped() {
|
||||
// The reserved dir is excluded only at the root. A nested
|
||||
// `sub/.aoe-build` is ordinary source: it must change the hash, so it
|
||||
// cannot be used to hide files from the pin.
|
||||
let without = tempfile::tempdir().unwrap();
|
||||
write(without.path(), "aoe-plugin.toml", b"x");
|
||||
let with_nested = tempfile::tempdir().unwrap();
|
||||
write(with_nested.path(), "aoe-plugin.toml", b"x");
|
||||
write(with_nested.path(), "sub/.aoe-build/hidden", b"payload");
|
||||
assert_ne!(
|
||||
tree_hash(without.path()).unwrap(),
|
||||
tree_hash(with_nested.path()).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn nested_build_output_symlink_is_rejected() {
|
||||
// A symlink under a nested (non-root) `.aoe-build` is still rejected:
|
||||
// only the root build-output dir escapes the symlink check.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "aoe-plugin.toml", b"x");
|
||||
let nested = dir.path().join("sub").join(".aoe-build");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(nested.join("real"), b"x").unwrap();
|
||||
std::os::unix::fs::symlink("real", nested.join("link")).unwrap();
|
||||
let err = tree_hash(dir.path()).unwrap_err().to_string();
|
||||
assert!(err.contains("symlink"), "got: {err}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_inside_build_output_is_not_rejected() {
|
||||
// A build like a Python venv places a symlink under the build-output
|
||||
// dir; the hash must skip it rather than hard-error, so a built tree
|
||||
// still re-derives the source hash.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "aoe-plugin.toml", b"x");
|
||||
let build = dir.path().join(BUILD_OUTPUT_DIR).join("bin");
|
||||
std::fs::create_dir_all(&build).unwrap();
|
||||
std::fs::write(build.join("real"), b"x").unwrap();
|
||||
std::os::unix::fs::symlink("real", build.join("python3")).unwrap();
|
||||
assert!(tree_hash(dir.path()).is_ok());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_is_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "real", b"x");
|
||||
std::os::unix::fs::symlink("real", dir.path().join("link")).unwrap();
|
||||
let err = tree_hash(dir.path()).unwrap_err().to_string();
|
||||
assert!(err.contains("symlink"), "got: {err}");
|
||||
}
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
//! Resolve a plugin's declared `[runtime]` into a concrete, launchable
|
||||
//! command, dispatched off the runtime kind.
|
||||
//!
|
||||
//! The host, not the plugin, decides how to turn a `RuntimeSpec` into a real
|
||||
//! program path. This module is the single place that branching lives: a
|
||||
//! `Command` runtime resolves its `argv[0]` on `PATH` or inside the plugin
|
||||
//! directory; a `ReleaseBinary` runtime points at the per-platform binary
|
||||
//! installation already placed in the plugin directory. Adding a new runtime
|
||||
//! kind later is a new match arm in [`resolve_launch`], not a rewrite of the
|
||||
//! supervisor or the transport: they only ever see a [`ResolvedLaunch`].
|
||||
//!
|
||||
//! Resolution is language-agnostic. The Python reference plugin declares a
|
||||
//! console-script entrypoint (`aoe-github-worker`) or an interpreter
|
||||
//! invocation (`python -m aoe_github_plugin.main`); a Rust/native plugin
|
||||
//! ships a `release-binary`. Both reach the worker through the same path.
|
||||
//!
|
||||
//! Filesystem and `PATH` probing go through the [`LaunchResolver`] trait so
|
||||
//! the resolution policy is unit-testable without touching the real
|
||||
//! filesystem.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use aoe_plugin_api::RuntimeSpec;
|
||||
|
||||
use crate::plugin::registry::LoadedPlugin;
|
||||
|
||||
/// Everything `std::process::Command` needs to launch a worker, computed once
|
||||
/// and free of any `RuntimeSpec` branching. The supervisor takes this, applies
|
||||
/// the sandbox backend, wires stdio, and spawns; it never re-inspects the
|
||||
/// manifest.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedLaunch {
|
||||
/// Absolute program path to execute.
|
||||
pub program: PathBuf,
|
||||
/// Arguments after the program (the manifest argv tail, or empty).
|
||||
pub args: Vec<String>,
|
||||
/// Working directory: the plugin's installed directory.
|
||||
pub cwd: PathBuf,
|
||||
/// Environment overlay applied on top of the inherited host environment.
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Why a plugin's runtime could not be resolved into a launchable command.
|
||||
/// Every variant carries the plugin id and an actionable hint, matching the
|
||||
/// project's error-with-hint style.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum LaunchError {
|
||||
#[error("plugin {plugin_id} declares no [runtime]; it has no worker to launch")]
|
||||
NoRuntime { plugin_id: String },
|
||||
|
||||
#[error("plugin {plugin_id} declares a runtime but has no installed directory")]
|
||||
NoPluginDir { plugin_id: String },
|
||||
|
||||
#[error(
|
||||
"plugin {plugin_id}: worker program {program:?} was not found on PATH. \
|
||||
Install it (for example `python3`), or declare a plugin-relative path such as `bin/{program}`."
|
||||
)]
|
||||
ProgramNotOnPath { plugin_id: String, program: String },
|
||||
|
||||
#[error(
|
||||
"plugin {plugin_id}: argv[0] {arg:?} is an absolute path. \
|
||||
Use a PATH program (such as `python3`) or a plugin-relative path (such as `bin/worker`)."
|
||||
)]
|
||||
AbsoluteArgv0 { plugin_id: String, arg: String },
|
||||
|
||||
#[error("plugin {plugin_id}: worker path {arg:?} escapes the plugin directory")]
|
||||
PathEscape { plugin_id: String, arg: String },
|
||||
|
||||
#[error(
|
||||
"plugin {plugin_id}: worker program {path} is missing. \
|
||||
Reinstall with `aoe plugin update {plugin_id}`."
|
||||
)]
|
||||
InTreeMissing { plugin_id: String, path: PathBuf },
|
||||
|
||||
#[error("plugin {plugin_id}: worker program {path} is not executable. Run `chmod +x {path}`.")]
|
||||
NotExecutable { plugin_id: String, path: PathBuf },
|
||||
|
||||
#[error(
|
||||
"plugin {plugin_id}: no prebuilt worker binary {path} for this platform ({os}-{arch}). \
|
||||
Reinstall with `aoe plugin update {plugin_id}`, or publish a release asset for this platform."
|
||||
)]
|
||||
ReleaseBinaryMissing {
|
||||
plugin_id: String,
|
||||
path: PathBuf,
|
||||
os: String,
|
||||
arch: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Indirection over `PATH` lookup and filesystem probing, so the resolution
|
||||
/// policy in [`resolve_launch`] can be exercised by unit tests with a fake
|
||||
/// that never touches the real filesystem. The real implementation is
|
||||
/// [`OsLaunchResolver`].
|
||||
pub trait LaunchResolver {
|
||||
/// Resolve a bare program name on `PATH`, returning its absolute path.
|
||||
fn which(&self, program: &str) -> Option<PathBuf>;
|
||||
/// Whether `path` exists.
|
||||
fn exists(&self, path: &Path) -> bool;
|
||||
/// Whether `path` is a regular file with an executable bit (Unix) or
|
||||
/// simply a file (non-Unix).
|
||||
fn is_executable(&self, path: &Path) -> bool;
|
||||
}
|
||||
|
||||
/// The production [`LaunchResolver`]: real `PATH` and filesystem.
|
||||
pub struct OsLaunchResolver;
|
||||
|
||||
impl LaunchResolver for OsLaunchResolver {
|
||||
fn which(&self, program: &str) -> Option<PathBuf> {
|
||||
which::which(program).ok()
|
||||
}
|
||||
|
||||
fn exists(&self, path: &Path) -> bool {
|
||||
path.exists()
|
||||
}
|
||||
|
||||
fn is_executable(&self, path: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::metadata(path)
|
||||
.map(|m| m.is_file() && (m.permissions().mode() & 0o111) != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
path.is_file()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a plugin's runtime into a launchable command.
|
||||
///
|
||||
/// The single dispatch site. A new `RuntimeSpec` variant becomes a new match
|
||||
/// arm here; nothing downstream changes. Builtins do not declare a runtime in
|
||||
/// this release, so a builtin (or any plugin with `runtime = None`) returns
|
||||
/// [`LaunchError::NoRuntime`]: it has no worker. The `aoe __plugin-worker`
|
||||
/// self-exec path for builtin workers arrives with the first builtin worker.
|
||||
pub fn resolve_launch(
|
||||
plugin: &LoadedPlugin,
|
||||
resolver: &dyn LaunchResolver,
|
||||
) -> Result<ResolvedLaunch, LaunchError> {
|
||||
let plugin_id = plugin.id().to_string();
|
||||
let runtime = plugin
|
||||
.manifest
|
||||
.runtime
|
||||
.as_ref()
|
||||
.ok_or_else(|| LaunchError::NoRuntime {
|
||||
plugin_id: plugin_id.clone(),
|
||||
})?;
|
||||
let dir = plugin
|
||||
.dir
|
||||
.as_ref()
|
||||
.ok_or_else(|| LaunchError::NoPluginDir {
|
||||
plugin_id: plugin_id.clone(),
|
||||
})?;
|
||||
|
||||
let (program, args) = match runtime {
|
||||
RuntimeSpec::Command { command, .. } => {
|
||||
resolve_command(&plugin_id, dir, command, resolver)?
|
||||
}
|
||||
RuntimeSpec::ReleaseBinary { asset, bin } => {
|
||||
let target = bin.as_deref().unwrap_or(asset.as_str());
|
||||
let path = resolve_in_tree(&plugin_id, dir, target, resolver, |path| {
|
||||
LaunchError::ReleaseBinaryMissing {
|
||||
plugin_id: plugin_id.clone(),
|
||||
path,
|
||||
os: std::env::consts::OS.to_string(),
|
||||
arch: std::env::consts::ARCH.to_string(),
|
||||
}
|
||||
})?;
|
||||
(path, Vec::new())
|
||||
}
|
||||
};
|
||||
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("AOE_PLUGIN_ID".to_string(), plugin_id);
|
||||
|
||||
Ok(ResolvedLaunch {
|
||||
program,
|
||||
args,
|
||||
cwd: dir.clone(),
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a `Command` runtime's argv into `(program, args)`.
|
||||
///
|
||||
/// `argv[0]` policy: an absolute path is rejected (it pins a host path and
|
||||
/// breaks portability); a path containing a separator is resolved relative to
|
||||
/// the plugin directory and verified executable; a bare name is resolved on
|
||||
/// `PATH` via `which` (the console-script / interpreter case).
|
||||
///
|
||||
/// Shared with the install-time build runner (`crate::plugin::install`): a
|
||||
/// build step's argv is resolved with the exact same policy, against the same
|
||||
/// plugin directory, so a step like `.venv/bin/pip` resolves once the prior
|
||||
/// step created it.
|
||||
pub(crate) fn resolve_command(
|
||||
plugin_id: &str,
|
||||
dir: &Path,
|
||||
command: &[String],
|
||||
resolver: &dyn LaunchResolver,
|
||||
) -> Result<(PathBuf, Vec<String>), LaunchError> {
|
||||
// The manifest validator guarantees a non-empty command with non-empty
|
||||
// arguments, so `split_first` cannot fail in practice; treat an empty one
|
||||
// as a missing runtime rather than panicking.
|
||||
let (head, tail) = command
|
||||
.split_first()
|
||||
.ok_or_else(|| LaunchError::NoRuntime {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
})?;
|
||||
|
||||
let program = if Path::new(head).is_absolute() {
|
||||
return Err(LaunchError::AbsoluteArgv0 {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
arg: head.clone(),
|
||||
});
|
||||
} else if head.contains('/') || head.contains('\\') {
|
||||
resolve_in_tree(plugin_id, dir, head, resolver, |path| {
|
||||
LaunchError::InTreeMissing {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
path,
|
||||
}
|
||||
})?
|
||||
} else {
|
||||
resolver
|
||||
.which(head)
|
||||
.ok_or_else(|| LaunchError::ProgramNotOnPath {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
program: head.clone(),
|
||||
})?
|
||||
};
|
||||
|
||||
Ok((program, tail.to_vec()))
|
||||
}
|
||||
|
||||
/// Resolve a plugin-relative executable path under `dir`, rejecting traversal
|
||||
/// and verifying the file exists and is executable. `missing` builds the
|
||||
/// not-found error so callers can distinguish a command in-tree miss from a
|
||||
/// release-binary platform miss.
|
||||
fn resolve_in_tree(
|
||||
plugin_id: &str,
|
||||
dir: &Path,
|
||||
rel: &str,
|
||||
resolver: &dyn LaunchResolver,
|
||||
missing: impl FnOnce(PathBuf) -> LaunchError,
|
||||
) -> Result<PathBuf, LaunchError> {
|
||||
// Reject explicit parent traversal before joining. This is defense in
|
||||
// depth: per the honest model (D8) the security boundary is not here, but
|
||||
// a relative worker path should never reach outside its own directory.
|
||||
if Path::new(rel)
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir))
|
||||
|| Path::new(rel).is_absolute()
|
||||
{
|
||||
return Err(LaunchError::PathEscape {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
arg: rel.to_string(),
|
||||
});
|
||||
}
|
||||
let candidate = dir.join(rel);
|
||||
if !resolver.exists(&candidate) {
|
||||
return Err(missing(candidate));
|
||||
}
|
||||
if !resolver.is_executable(&candidate) {
|
||||
return Err(LaunchError::NotExecutable {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
path: candidate,
|
||||
});
|
||||
}
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aoe_plugin_api::{PluginManifest, TrustLevel};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// A fake resolver: a fixed `PATH` map plus a set of existing and
|
||||
/// executable in-tree paths. No real filesystem access.
|
||||
struct FakeResolver {
|
||||
path: BTreeMap<String, PathBuf>,
|
||||
exists: HashSet<PathBuf>,
|
||||
executable: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl FakeResolver {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
path: BTreeMap::new(),
|
||||
exists: HashSet::new(),
|
||||
executable: HashSet::new(),
|
||||
}
|
||||
}
|
||||
fn on_path(mut self, name: &str, at: &str) -> Self {
|
||||
self.path.insert(name.to_string(), PathBuf::from(at));
|
||||
self
|
||||
}
|
||||
fn file(mut self, path: PathBuf, executable: bool) -> Self {
|
||||
self.exists.insert(path.clone());
|
||||
if executable {
|
||||
self.executable.insert(path);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl LaunchResolver for FakeResolver {
|
||||
fn which(&self, program: &str) -> Option<PathBuf> {
|
||||
self.path.get(program).cloned()
|
||||
}
|
||||
fn exists(&self, path: &Path) -> bool {
|
||||
self.exists.contains(path)
|
||||
}
|
||||
fn is_executable(&self, path: &Path) -> bool {
|
||||
self.executable.contains(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin(runtime: Option<&str>, dir: Option<&str>) -> LoadedPlugin {
|
||||
let runtime_toml = runtime.map(|r| format!("\n{r}\n")).unwrap_or_default();
|
||||
let manifest = PluginManifest::from_toml_str(&format!(
|
||||
r#"
|
||||
id = "acme.worker"
|
||||
name = "Worker"
|
||||
version = "1.0.0"
|
||||
api_version = 2
|
||||
capabilities = ["runtime.worker"]
|
||||
{runtime_toml}
|
||||
"#
|
||||
))
|
||||
.unwrap();
|
||||
LoadedPlugin {
|
||||
manifest,
|
||||
enabled: true,
|
||||
trust: TrustLevel::Community,
|
||||
validation: crate::plugin::registry::ValidationState::Community,
|
||||
source: Some("gh:acme/worker".into()),
|
||||
dir: dir.map(PathBuf::from),
|
||||
manifest_hash: "sha256:test".into(),
|
||||
granted: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_runtime_has_no_worker() {
|
||||
let p = plugin(None, Some("/plugins/acme.worker"));
|
||||
let err = resolve_launch(&p, &FakeResolver::new()).unwrap_err();
|
||||
assert!(matches!(err, LaunchError::NoRuntime { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_bare_name_resolves_on_path() {
|
||||
let p = plugin(
|
||||
Some("[runtime]\nkind = \"command\"\ncommand = [\"python3\", \"-m\", \"acme.main\"]\nsystem = true"),
|
||||
Some("/plugins/acme.worker"),
|
||||
);
|
||||
let resolver = FakeResolver::new().on_path("python3", "/usr/bin/python3");
|
||||
let launch = resolve_launch(&p, &resolver).unwrap();
|
||||
assert_eq!(launch.program, PathBuf::from("/usr/bin/python3"));
|
||||
assert_eq!(launch.args, vec!["-m".to_string(), "acme.main".to_string()]);
|
||||
assert_eq!(launch.cwd, PathBuf::from("/plugins/acme.worker"));
|
||||
assert_eq!(
|
||||
launch.env.get("AOE_PLUGIN_ID").map(String::as_str),
|
||||
Some("acme.worker")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_console_script_missing_on_path_fails_loudly() {
|
||||
let p = plugin(
|
||||
Some("[runtime]\nkind = \"command\"\ncommand = [\"aoe-github-worker\"]\nsystem = true"),
|
||||
Some("/plugins/acme.worker"),
|
||||
);
|
||||
let err = resolve_launch(&p, &FakeResolver::new()).unwrap_err();
|
||||
match err {
|
||||
LaunchError::ProgramNotOnPath { program, .. } => {
|
||||
assert_eq!(program, "aoe-github-worker");
|
||||
}
|
||||
other => panic!("expected ProgramNotOnPath, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_relative_path_resolves_in_plugin_dir() {
|
||||
let p = plugin(
|
||||
Some("[runtime]\nkind = \"command\"\ncommand = [\"bin/worker\"]"),
|
||||
Some("/plugins/acme.worker"),
|
||||
);
|
||||
let bin = PathBuf::from("/plugins/acme.worker/bin/worker");
|
||||
let resolver = FakeResolver::new().file(bin.clone(), true);
|
||||
let launch = resolve_launch(&p, &resolver).unwrap();
|
||||
assert_eq!(launch.program, bin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_relative_path_not_executable_fails() {
|
||||
let p = plugin(
|
||||
Some("[runtime]\nkind = \"command\"\ncommand = [\"bin/worker\"]"),
|
||||
Some("/plugins/acme.worker"),
|
||||
);
|
||||
let bin = PathBuf::from("/plugins/acme.worker/bin/worker");
|
||||
let resolver = FakeResolver::new().file(bin, false);
|
||||
let err = resolve_launch(&p, &resolver).unwrap_err();
|
||||
assert!(matches!(err, LaunchError::NotExecutable { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_absolute_argv0_rejected() {
|
||||
// `Path::is_absolute` is platform-specific: a Unix-style path is not
|
||||
// absolute on Windows (it lacks a drive/UNC prefix), so pick an
|
||||
// argv[0] that is absolute under the host's own semantics.
|
||||
let argv0 = if cfg!(windows) {
|
||||
"C:/Windows/py.exe"
|
||||
} else {
|
||||
"/usr/bin/python3"
|
||||
};
|
||||
// An absolute argv[0] never survives manifest validation, so exercise
|
||||
// the resolver directly: it still guards build-step argv, which is not
|
||||
// shape-validated up front.
|
||||
let err = resolve_command(
|
||||
"acme.worker",
|
||||
Path::new("/plugins/acme.worker"),
|
||||
&[argv0.to_string()],
|
||||
&FakeResolver::new(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, LaunchError::AbsoluteArgv0 { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_parent_traversal_rejected() {
|
||||
let p = plugin(
|
||||
Some("[runtime]\nkind = \"command\"\ncommand = [\"../escape\"]"),
|
||||
Some("/plugins/acme.worker"),
|
||||
);
|
||||
let err = resolve_launch(&p, &FakeResolver::new()).unwrap_err();
|
||||
assert!(matches!(err, LaunchError::PathEscape { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_binary_resolves_in_tree() {
|
||||
let p = plugin(
|
||||
Some("[runtime]\nkind = \"release-binary\"\nasset = \"worker-${os}-${arch}\"\nbin = \"bin/worker\""),
|
||||
Some("/plugins/acme.worker"),
|
||||
);
|
||||
let bin = PathBuf::from("/plugins/acme.worker/bin/worker");
|
||||
let resolver = FakeResolver::new().file(bin.clone(), true);
|
||||
let launch = resolve_launch(&p, &resolver).unwrap();
|
||||
assert_eq!(launch.program, bin);
|
||||
assert!(launch.args.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_binary_missing_names_platform() {
|
||||
let p = plugin(
|
||||
Some("[runtime]\nkind = \"release-binary\"\nasset = \"worker\""),
|
||||
Some("/plugins/acme.worker"),
|
||||
);
|
||||
let err = resolve_launch(&p, &FakeResolver::new()).unwrap_err();
|
||||
match err {
|
||||
LaunchError::ReleaseBinaryMissing { os, arch, .. } => {
|
||||
assert_eq!(os, std::env::consts::OS);
|
||||
assert_eq!(arch, std::env::consts::ARCH);
|
||||
}
|
||||
other => panic!("expected ReleaseBinaryMissing, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
//! `plugins.lock`: the exact resolved identity of every externally installed
|
||||
//! plugin.
|
||||
//!
|
||||
//! Lives at `<app_dir>/plugins.lock` and is TOML, matching `config.toml` and
|
||||
//! `aoe-plugin.toml`. Like `Cargo.lock` it is deterministic and merge-friendly:
|
||||
//! plugins are keyed by id (a `BTreeMap`, stable order) and no timestamps are
|
||||
//! stored. It records what was actually resolved so an install can be
|
||||
//! reproduced and an update can tell whether anything changed.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Current lockfile schema version. Bumped to 2 when `tree_hash` was added: an
|
||||
/// older aoe must refuse a v2 lock (the `lock_version > LOCK_VERSION` guard)
|
||||
/// rather than round-trip it and silently drop the integrity field.
|
||||
const LOCK_VERSION: u32 = 2;
|
||||
|
||||
/// The parsed `plugins.lock`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Lockfile {
|
||||
/// Lockfile schema version, for forward migrations.
|
||||
pub lock_version: u32,
|
||||
/// External plugins keyed by id.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub plugins: BTreeMap<String, LockedPlugin>,
|
||||
}
|
||||
|
||||
impl Default for Lockfile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
lock_version: LOCK_VERSION,
|
||||
plugins: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One external plugin's resolved identity.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LockedPlugin {
|
||||
/// Canonical source slug: `gh:owner/repo` or a local path.
|
||||
pub source: String,
|
||||
/// The ref the user asked for (branch / tag / commit), if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub requested_ref: Option<String>,
|
||||
/// The exact commit the source resolved to (GitHub sources only).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_commit: Option<String>,
|
||||
/// The plugin version from its manifest.
|
||||
pub version: String,
|
||||
/// `sha256:<hex>` of the installed manifest bytes.
|
||||
pub manifest_hash: String,
|
||||
/// `sha256:<hex>` over the source tree (see [`crate::plugin::integrity`]).
|
||||
/// Defaulted when reading a pre-v2 lock; always written going forward.
|
||||
#[serde(default)]
|
||||
pub tree_hash: String,
|
||||
/// `featured`, `community`, or (historically) `builtin`.
|
||||
pub trust: String,
|
||||
/// The release tag the worker binary was pulled from (release-binary only).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub release_tag: Option<String>,
|
||||
/// The release asset name downloaded (release-binary only).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset_name: Option<String>,
|
||||
/// `sha256:<hex>` of the downloaded asset (release-binary only).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset_sha256: Option<String>,
|
||||
}
|
||||
|
||||
impl Lockfile {
|
||||
fn path() -> Result<PathBuf> {
|
||||
Ok(crate::session::get_app_dir()?.join("plugins.lock"))
|
||||
}
|
||||
|
||||
/// Load the lockfile, returning an empty one if the file does not exist.
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::path()?;
|
||||
if !path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("reading {}", path.display()))?;
|
||||
let lockfile: Lockfile =
|
||||
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
|
||||
// Refuse a lockfile written by a newer aoe: saving it back would silently
|
||||
// drop fields this version does not know, breaking the forward-migration
|
||||
// contract. The user should upgrade rather than downgrade-corrupt it.
|
||||
if lockfile.lock_version > LOCK_VERSION {
|
||||
anyhow::bail!(
|
||||
"{} is lock_version {} but this aoe understands {}; upgrade aoe",
|
||||
path.display(),
|
||||
lockfile.lock_version,
|
||||
LOCK_VERSION
|
||||
);
|
||||
}
|
||||
Ok(lockfile)
|
||||
}
|
||||
|
||||
/// Persist the lockfile.
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = Self::path()?;
|
||||
let text = toml::to_string_pretty(self).context("serializing plugins.lock")?;
|
||||
std::fs::write(&path, text).with_context(|| format!("writing {}", path.display()))
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&LockedPlugin> {
|
||||
self.plugins.get(id)
|
||||
}
|
||||
|
||||
/// Insert or replace a plugin's lock entry.
|
||||
pub fn upsert(&mut self, id: &str, locked: LockedPlugin) {
|
||||
self.lock_version = LOCK_VERSION;
|
||||
self.plugins.insert(id.to_string(), locked);
|
||||
}
|
||||
|
||||
/// Remove a plugin's lock entry; returns whether one was present.
|
||||
pub fn remove(&mut self, id: &str) -> bool {
|
||||
self.plugins.remove(id).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_toml() {
|
||||
let mut lf = Lockfile::default();
|
||||
lf.upsert(
|
||||
"acme.widget",
|
||||
LockedPlugin {
|
||||
source: "gh:acme/widget".into(),
|
||||
requested_ref: Some("v1.0.0".into()),
|
||||
resolved_commit: Some("deadbeef".into()),
|
||||
version: "1.0.0".into(),
|
||||
manifest_hash: "sha256:abc".into(),
|
||||
tree_hash: "sha256:tree".into(),
|
||||
trust: "community".into(),
|
||||
release_tag: Some("v1.0.0".into()),
|
||||
asset_name: Some("widget-x86_64.tar.gz".into()),
|
||||
asset_sha256: Some("sha256:def".into()),
|
||||
},
|
||||
);
|
||||
let text = toml::to_string_pretty(&lf).unwrap();
|
||||
let back: Lockfile = toml::from_str(&text).unwrap();
|
||||
assert_eq!(back.lock_version, LOCK_VERSION);
|
||||
assert_eq!(
|
||||
back.plugins.get("acme.widget"),
|
||||
lf.plugins.get("acme.widget")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_reports_presence() {
|
||||
let mut lf = Lockfile::default();
|
||||
lf.upsert(
|
||||
"acme.widget",
|
||||
LockedPlugin {
|
||||
source: "/local/path".into(),
|
||||
requested_ref: None,
|
||||
resolved_commit: None,
|
||||
version: "0.1.0".into(),
|
||||
manifest_hash: "sha256:abc".into(),
|
||||
tree_hash: "sha256:tree".into(),
|
||||
trust: "community".into(),
|
||||
release_tag: None,
|
||||
asset_name: None,
|
||||
asset_sha256: None,
|
||||
},
|
||||
);
|
||||
assert!(lf.remove("acme.widget"));
|
||||
assert!(!lf.remove("acme.widget"));
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
//! Plugin core: load the compiled-in first-party plugins and expose their
|
||||
//! enabled/disabled state to every surface (CLI, TUI, web).
|
||||
//!
|
||||
//! This is the minimal core: a registry of builtin plugins you can enable or
|
||||
//! disable. The manifest types live in the `aoe-plugin-api` crate. External
|
||||
//! installs, capability grants, and the Tier 0 / Tier 1 contribution surface
|
||||
//! return in follow-up PRs.
|
||||
|
||||
pub mod auto_update;
|
||||
pub mod changelog;
|
||||
pub mod contributions;
|
||||
pub mod discover;
|
||||
pub mod featured;
|
||||
pub mod fetch;
|
||||
pub mod install;
|
||||
pub mod integrity;
|
||||
pub mod lockfile;
|
||||
pub mod registry;
|
||||
pub mod source;
|
||||
pub mod update_check;
|
||||
pub mod view;
|
||||
|
||||
// The Tier 1 worker host runs only in the `aoe serve` daemon, where the event
|
||||
// store and session storage it serves over the capability-gated API live. A
|
||||
// TUI-only build has no host, so these modules are gated with it.
|
||||
#[cfg(feature = "serve")]
|
||||
pub(crate) mod automation_policy;
|
||||
#[cfg(feature = "serve")]
|
||||
pub mod host;
|
||||
#[cfg(feature = "serve")]
|
||||
pub mod host_api;
|
||||
#[cfg(feature = "serve")]
|
||||
pub mod protocol;
|
||||
#[cfg(feature = "serve")]
|
||||
pub mod sandbox;
|
||||
#[cfg(feature = "serve")]
|
||||
pub mod session_api;
|
||||
#[cfg(feature = "serve")]
|
||||
pub mod ui_state;
|
||||
|
||||
// Launch resolution is pure (PATH / filesystem probing) and is shared by the
|
||||
// serve-only host and the always-present installer, which runs a plugin's
|
||||
// build steps with the same argv-resolution policy. It carries no host state,
|
||||
// so it is not gated.
|
||||
pub mod launch;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// Directory holding externally installed plugins, one subdir per plugin id:
|
||||
/// `<app_dir>/plugins/<id>/`.
|
||||
pub fn plugins_dir() -> anyhow::Result<PathBuf> {
|
||||
Ok(crate::session::get_app_dir()?.join("plugins"))
|
||||
}
|
||||
|
||||
pub use registry::{LoadedPlugin, PluginRegistry};
|
||||
pub use view::PluginView;
|
||||
|
||||
/// Lock recovery for the process-wide registry slot: a panic elsewhere must
|
||||
/// not poison it and take a TUI redraw / tokio task down on the next access.
|
||||
/// Recovering via `into_inner` is correct: the held data is a rebuildable
|
||||
/// cache, not partial-mutation-sensitive state.
|
||||
pub(crate) trait RwLockSafe<T> {
|
||||
fn read_safe(&self) -> std::sync::RwLockReadGuard<'_, T>;
|
||||
fn write_safe(&self) -> std::sync::RwLockWriteGuard<'_, T>;
|
||||
}
|
||||
|
||||
impl<T> RwLockSafe<T> for RwLock<T> {
|
||||
fn read_safe(&self) -> std::sync::RwLockReadGuard<'_, T> {
|
||||
self.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
fn write_safe(&self) -> std::sync::RwLockWriteGuard<'_, T> {
|
||||
self.write().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
static REGISTRY: RwLock<Option<Arc<PluginRegistry>>> = RwLock::new(None);
|
||||
|
||||
/// The process-wide plugin registry, loaded on first use from the global
|
||||
/// config. Surfaces that toggle a plugin call [`reload_registry`] after
|
||||
/// persisting the change.
|
||||
pub fn registry() -> Arc<PluginRegistry> {
|
||||
if let Some(reg) = REGISTRY.read_safe().as_ref() {
|
||||
return reg.clone();
|
||||
}
|
||||
let mut slot = REGISTRY.write_safe();
|
||||
if let Some(reg) = slot.as_ref() {
|
||||
return reg.clone();
|
||||
}
|
||||
let config = crate::session::Config::load_or_warn();
|
||||
let reg = Arc::new(PluginRegistry::load(&config));
|
||||
*slot = Some(reg.clone());
|
||||
reg
|
||||
}
|
||||
|
||||
/// Themes contributed by the active plugin set, as `(name, resolved path)`
|
||||
/// pairs. The theme registry layers these below builtins and user themes.
|
||||
pub fn active_plugin_themes() -> Vec<(String, PathBuf)> {
|
||||
let reg = registry();
|
||||
let active: Vec<&LoadedPlugin> = reg.active().collect();
|
||||
contributions::active_themes(&active)
|
||||
}
|
||||
|
||||
/// Rebuild the registry from the current on-disk config (after an
|
||||
/// enable/disable), so the change is reflected the next time any surface reads
|
||||
/// the active set.
|
||||
pub fn reload_registry() -> Arc<PluginRegistry> {
|
||||
let config = crate::session::Config::load_or_warn();
|
||||
let reg = Arc::new(PluginRegistry::load(&config));
|
||||
*REGISTRY.write_safe() = Some(reg.clone());
|
||||
reg
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
//! The plugin worker protocol: newline-delimited JSON-RPC 2.0 over the
|
||||
//! worker's stdio.
|
||||
//!
|
||||
//! A worker is the JSON-RPC client: it writes one request object per line to
|
||||
//! its stdout and reads one response object per line on its stdin. The host
|
||||
//! is the server. This is the language-agnostic wire contract; any executable
|
||||
//! that speaks it is a valid worker, which is why the host resolves and
|
||||
//! launches workers of different runtime kinds (see [`crate::plugin::launch`])
|
||||
//! through the same path.
|
||||
//!
|
||||
//! Notifications (no `id`) are accepted but produce no response. Anything the
|
||||
//! worker writes to stderr is never protocol; it is drained to the worker log.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// JSON-RPC error codes. The negative range below `-32000` is reserved by the
|
||||
/// spec for implementation-defined server errors; [`codes::FORBIDDEN`] is ours, for a
|
||||
/// method whose capability the plugin did not declare or was not granted.
|
||||
pub mod codes {
|
||||
pub const PARSE_ERROR: i64 = -32700;
|
||||
pub const INVALID_REQUEST: i64 = -32600;
|
||||
pub const METHOD_NOT_FOUND: i64 = -32601;
|
||||
pub const INVALID_PARAMS: i64 = -32602;
|
||||
pub const INTERNAL_ERROR: i64 = -32603;
|
||||
/// Capability not declared or not granted for the calling plugin.
|
||||
pub const FORBIDDEN: i64 = -32001;
|
||||
/// The request is authorized capability-wise but denied by host policy
|
||||
/// (e.g. an unattended mode without the `session.unattended` grant).
|
||||
pub const POLICY_DENIED: i64 = -32002;
|
||||
/// The request conflicts with existing state (idempotency-key reuse
|
||||
/// with a different payload).
|
||||
pub const CONFLICT: i64 = -32003;
|
||||
/// A rolling-window rate or concurrency limit was exceeded.
|
||||
pub const RATE_LIMITED: i64 = -32004;
|
||||
/// A precondition the plugin cannot fix by retrying as-is (untrusted
|
||||
/// repository, undiscovered catalog, failed mode application).
|
||||
pub const FAILED_PRECONDITION: i64 = -32005;
|
||||
/// The host cannot serve the request right now; retryable.
|
||||
pub const SERVICE_UNAVAILABLE: i64 = -32006;
|
||||
}
|
||||
|
||||
/// One inbound request from a worker. `id` is absent for a notification.
|
||||
///
|
||||
/// Every field is optional at the serde layer so that any well-formed JSON
|
||||
/// object deserializes; the JSON-RPC 2.0 envelope is then validated by
|
||||
/// [`RpcRequest::validate_envelope`]. This keeps a parse failure meaning
|
||||
/// "malformed JSON" (PARSE_ERROR) and a bad request shape meaning
|
||||
/// "invalid request" (INVALID_REQUEST), rather than conflating the two.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RpcRequest {
|
||||
#[serde(default)]
|
||||
pub jsonrpc: Option<String>,
|
||||
#[serde(default)]
|
||||
pub id: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub method: Option<String>,
|
||||
#[serde(default)]
|
||||
pub params: Value,
|
||||
}
|
||||
|
||||
impl RpcRequest {
|
||||
/// A request with no `id` is a notification: the host must not answer it.
|
||||
pub fn is_notification(&self) -> bool {
|
||||
self.id.is_none()
|
||||
}
|
||||
|
||||
/// Validate the JSON-RPC 2.0 envelope, returning the method name on success.
|
||||
/// `jsonrpc` must be exactly `"2.0"` and `method` must be present and
|
||||
/// non-empty; otherwise the request is well-formed JSON but not a valid
|
||||
/// request, which the host reports as `INVALID_REQUEST`.
|
||||
pub fn validate_envelope(&self) -> Result<&str, &'static str> {
|
||||
if self.jsonrpc.as_deref() != Some("2.0") {
|
||||
return Err("jsonrpc field must be \"2.0\"");
|
||||
}
|
||||
match self.method.as_deref() {
|
||||
Some(m) if !m.is_empty() => Ok(m),
|
||||
_ => Err("missing or empty \"method\""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One outbound response to a worker. Exactly one of `result` / `error` is set.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RpcResponse {
|
||||
pub jsonrpc: &'static str,
|
||||
pub id: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<RpcError>,
|
||||
}
|
||||
|
||||
/// A JSON-RPC error object. `data.kind` (when present) is the stable
|
||||
/// machine-readable contract; `message` is diagnostic prose and not stable.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RpcError {
|
||||
pub code: i64,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
impl RpcResponse {
|
||||
pub fn success(id: Value, result: Value) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(id: Value, code: i64, message: impl Into<String>) -> Self {
|
||||
Self::error_with_data(id, code, message, None)
|
||||
}
|
||||
|
||||
pub fn error_with_data(
|
||||
id: Value,
|
||||
code: i64,
|
||||
message: impl Into<String>,
|
||||
data: Option<Value>,
|
||||
) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result: None,
|
||||
error: Some(RpcError {
|
||||
code,
|
||||
message: message.into(),
|
||||
data,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to a single ndjson line including the trailing newline.
|
||||
pub fn to_line(&self) -> String {
|
||||
// Serializing a plain struct of JSON values cannot fail.
|
||||
let mut line = serde_json::to_string(self).unwrap_or_else(|_| {
|
||||
r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"serialize failed"}}"#
|
||||
.to_string()
|
||||
});
|
||||
line.push('\n');
|
||||
line
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse one ndjson line into a request. An empty or whitespace-only line is
|
||||
/// `Ok(None)` (skipped); malformed JSON is an error the caller reports as a
|
||||
/// parse error and treats as fatal to the worker.
|
||||
pub fn parse_request(line: &str) -> Result<Option<RpcRequest>, serde_json::Error> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
serde_json::from_str(trimmed).map(Some)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parse_request_round_trip() {
|
||||
let req = parse_request(r#"{"jsonrpc":"2.0","id":7,"method":"sessions.list","params":{}}"#)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(req.validate_envelope().unwrap(), "sessions.list");
|
||||
assert_eq!(req.id, Some(json!(7)));
|
||||
assert!(!req.is_notification());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_has_no_id() {
|
||||
let req = parse_request(r#"{"jsonrpc":"2.0","method":"events.publish","params":{}}"#)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(req.is_notification());
|
||||
assert_eq!(req.validate_envelope().unwrap(), "events.publish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_line_is_skipped() {
|
||||
assert!(parse_request(" ").unwrap().is_none());
|
||||
assert!(parse_request("").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_line_is_error() {
|
||||
assert!(parse_request("{not json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_json_with_bad_envelope_is_not_a_parse_error() {
|
||||
// Missing jsonrpc: parses fine, but the envelope is invalid.
|
||||
let req = parse_request(r#"{"method":"sessions.list"}"#)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(req.validate_envelope().is_err());
|
||||
// Wrong jsonrpc version.
|
||||
let req = parse_request(r#"{"jsonrpc":"1.0","method":"sessions.list"}"#)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(req.validate_envelope().is_err());
|
||||
// Missing method.
|
||||
let req = parse_request(r#"{"jsonrpc":"2.0"}"#).unwrap().unwrap();
|
||||
assert!(req.validate_envelope().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_lines_are_single_ndjson() {
|
||||
let ok = RpcResponse::success(json!(1), json!({"ok": true})).to_line();
|
||||
assert!(ok.ends_with('\n'));
|
||||
assert_eq!(ok.matches('\n').count(), 1);
|
||||
let parsed: Value = serde_json::from_str(ok.trim()).unwrap();
|
||||
assert_eq!(parsed["result"]["ok"], json!(true));
|
||||
assert_eq!(parsed["jsonrpc"], json!("2.0"));
|
||||
|
||||
let err = RpcResponse::error(json!(2), codes::FORBIDDEN, "nope").to_line();
|
||||
let parsed: Value = serde_json::from_str(err.trim()).unwrap();
|
||||
assert_eq!(parsed["error"]["code"], json!(codes::FORBIDDEN));
|
||||
assert!(parsed.get("result").is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
//! Plugin registry: the compiled-in first-party plugins, the externally
|
||||
//! installed ones, and each plugin's enabled / granted state.
|
||||
//!
|
||||
//! Builtin plugins are embedded from `plugins/` in this repository and are
|
||||
//! fully trusted: their capabilities are auto-granted. External plugins are
|
||||
//! loaded from `<app_dir>/plugins/<id>/`; they are community-trusted, so their
|
||||
//! contributions go live only once the user has granted the capability set the
|
||||
//! installed manifest declares (the grant is pinned to the manifest hash).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use aoe_plugin_api::{PluginManifest, TrustLevel};
|
||||
|
||||
use super::featured::FeaturedIndex;
|
||||
use super::integrity;
|
||||
use crate::session::{CapabilityGrant, Config};
|
||||
|
||||
/// How an installed plugin was validated, the finer provenance the surfaces
|
||||
/// show. `TrustLevel` (builtin vs community) stays the coarse capability-policy
|
||||
/// axis; this is the user-facing "is this safe" label.
|
||||
///
|
||||
/// `Featured` is re-derived live from the embedded index and the on-disk tree
|
||||
/// hash, never trusted from the (user-writable) lockfile: that derivation also
|
||||
/// gates the reserved-namespace lift, so it must not rest on data an attacker
|
||||
/// could edit. A featured plugin cannot ship a release-binary, so its installed
|
||||
/// tree equals its source tree and the recompute reproduces the pinned hash; it
|
||||
/// is also only run for the handful of ids the index actually names. The
|
||||
/// manifest-hash grant check still deactivates a community plugin whose
|
||||
/// manifest is tampered after install.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ValidationState {
|
||||
/// Compiled into the binary.
|
||||
Builtin,
|
||||
/// External, installed from a featured-verified source (matched the curated
|
||||
/// pin at install).
|
||||
Featured,
|
||||
/// External GitHub install, not in the featured index.
|
||||
Community,
|
||||
/// External local-directory install.
|
||||
Local,
|
||||
}
|
||||
|
||||
impl ValidationState {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ValidationState::Builtin => "builtin",
|
||||
ValidationState::Featured => "featured",
|
||||
ValidationState::Community => "community",
|
||||
ValidationState::Local => "local",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin compiled into the aoe binary.
|
||||
pub struct BuiltinPlugin {
|
||||
pub manifest_toml: &'static str,
|
||||
}
|
||||
|
||||
/// First-party plugins bundled with the binary. Deliberately minimal while the
|
||||
/// system is proven out: just the `aoe.web` dashboard marker (under `serve`).
|
||||
/// More land as each piece is verified.
|
||||
pub static BUILTINS: &[BuiltinPlugin] = &[
|
||||
// The web dashboard's management marker is present whenever the dashboard
|
||||
// is compiled in (`feature = "serve"`), so serve and release builds always
|
||||
// surface aoe.web; a TUI-only build has an empty builtin set.
|
||||
#[cfg(feature = "serve")]
|
||||
BuiltinPlugin {
|
||||
manifest_toml: include_str!("../../plugins/aoe-web/aoe-plugin.toml"),
|
||||
},
|
||||
];
|
||||
|
||||
/// Whether `id` belongs to a compiled-in builtin plugin.
|
||||
pub fn is_builtin_id(id: &str) -> bool {
|
||||
BUILTINS.iter().any(|b| {
|
||||
PluginManifest::from_toml_str(b.manifest_toml)
|
||||
.map(|m| m.id.as_str() == id)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// One loaded plugin: its manifest, trust, and enabled / granted state.
|
||||
pub struct LoadedPlugin {
|
||||
pub manifest: PluginManifest,
|
||||
/// Resolved from `Config.plugins`; defaults on.
|
||||
pub enabled: bool,
|
||||
/// Builtin (auto-granted) or community (capabilities gated).
|
||||
pub trust: TrustLevel,
|
||||
/// Finer provenance for display (builtin / featured / community / local).
|
||||
pub validation: ValidationState,
|
||||
/// Install source for an external plugin; `None` for builtins.
|
||||
pub source: Option<String>,
|
||||
/// On-disk directory for an external plugin; `None` for builtins.
|
||||
pub dir: Option<PathBuf>,
|
||||
/// `sha256:<hex>` of the installed manifest bytes (builtins: of the embedded
|
||||
/// TOML). A grant must be pinned to this exact hash to count.
|
||||
pub manifest_hash: String,
|
||||
/// Whether the user's grant covers the installed manifest's capability set.
|
||||
/// Always true for builtins.
|
||||
pub granted: bool,
|
||||
}
|
||||
|
||||
impl LoadedPlugin {
|
||||
pub fn id(&self) -> &str {
|
||||
self.manifest.id.as_str()
|
||||
}
|
||||
|
||||
pub fn builtin(&self) -> bool {
|
||||
matches!(self.trust, TrustLevel::Builtin)
|
||||
}
|
||||
|
||||
/// Whether the plugin's contributions are live: enabled, and (for community
|
||||
/// plugins) granted against the installed manifest. An ungranted or
|
||||
/// stale-grant community plugin contributes nothing until re-approved.
|
||||
pub fn active(&self) -> bool {
|
||||
self.enabled && self.granted
|
||||
}
|
||||
|
||||
/// A community plugin whose grant does not cover the installed manifest:
|
||||
/// installed but inactive, awaiting `aoe plugin update` / re-approval.
|
||||
pub fn needs_reapproval(&self) -> bool {
|
||||
!self.builtin() && !self.granted
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a stored grant covers the installed manifest: it must be pinned to
|
||||
/// the same manifest hash and include every capability the manifest declares.
|
||||
fn grant_covers(grant: &CapabilityGrant, manifest: &PluginManifest, manifest_hash: &str) -> bool {
|
||||
grant.manifest_hash == manifest_hash
|
||||
&& manifest
|
||||
.capabilities
|
||||
.iter()
|
||||
.all(|c| grant.capabilities.iter().any(|g| g == c.as_str()))
|
||||
}
|
||||
|
||||
/// The set of plugins loaded for a config, plus any load problems.
|
||||
pub struct PluginRegistry {
|
||||
plugins: Vec<LoadedPlugin>,
|
||||
load_errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginRegistry {
|
||||
pub fn load(config: &Config) -> Self {
|
||||
let mut plugins = Vec::new();
|
||||
let mut load_errors = Vec::new();
|
||||
|
||||
for builtin in BUILTINS {
|
||||
match PluginManifest::from_toml_str(builtin.manifest_toml) {
|
||||
Ok(manifest) => {
|
||||
let enabled = config
|
||||
.plugins
|
||||
.get(manifest.id.as_str())
|
||||
.map(|p| p.enabled)
|
||||
.unwrap_or(true);
|
||||
let manifest_hash =
|
||||
PluginManifest::hash_bytes(builtin.manifest_toml.as_bytes());
|
||||
plugins.push(LoadedPlugin {
|
||||
manifest,
|
||||
enabled,
|
||||
trust: TrustLevel::Builtin,
|
||||
validation: ValidationState::Builtin,
|
||||
source: None,
|
||||
dir: None,
|
||||
manifest_hash,
|
||||
granted: true,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
// A broken builtin manifest is a build defect; tested in CI.
|
||||
load_errors.push(format!("builtin manifest invalid: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let featured = FeaturedIndex::load().unwrap_or_else(|e| {
|
||||
load_errors.push(format!("reading featured plugin index: {e:#}"));
|
||||
FeaturedIndex::default()
|
||||
});
|
||||
load_external(config, &featured, &mut plugins, &mut load_errors);
|
||||
|
||||
Self {
|
||||
plugins,
|
||||
load_errors,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every loaded plugin.
|
||||
pub fn all(&self) -> &[LoadedPlugin] {
|
||||
&self.plugins
|
||||
}
|
||||
|
||||
/// Plugins whose contributions are live (enabled and granted).
|
||||
pub fn active(&self) -> impl Iterator<Item = &LoadedPlugin> {
|
||||
self.plugins.iter().filter(|p| p.active())
|
||||
}
|
||||
|
||||
pub fn get(&self, plugin_id: &str) -> Option<&LoadedPlugin> {
|
||||
self.plugins.iter().find(|p| p.id() == plugin_id)
|
||||
}
|
||||
|
||||
pub fn load_errors(&self) -> &[String] {
|
||||
&self.load_errors
|
||||
}
|
||||
}
|
||||
|
||||
/// Load external plugins from `<app_dir>/plugins/<id>/aoe-plugin.toml`. Each
|
||||
/// problem is collected as a non-fatal load error rather than aborting the set.
|
||||
/// The display provenance for an external plugin. `Featured` is verified live:
|
||||
/// the id must be in the embedded index and the on-disk tree must hash to the
|
||||
/// pin. The source-slug match is enforced at install (where the slug is
|
||||
/// canonical); here the content hash is the gate, since it is the strong check
|
||||
/// and avoids depending on how a persisted source string was canonicalized.
|
||||
fn validation_for(
|
||||
featured: &FeaturedIndex,
|
||||
id: &str,
|
||||
dir: &Path,
|
||||
source: Option<&str>,
|
||||
) -> ValidationState {
|
||||
if let Some(entry) = featured.get(id) {
|
||||
if integrity::tree_hash(dir).is_ok_and(|h| entry.verifies(&h)) {
|
||||
return ValidationState::Featured;
|
||||
}
|
||||
}
|
||||
match source {
|
||||
Some(s) if s.starts_with("gh:") => ValidationState::Community,
|
||||
_ => ValidationState::Local,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_external(
|
||||
config: &Config,
|
||||
featured: &FeaturedIndex,
|
||||
plugins: &mut Vec<LoadedPlugin>,
|
||||
load_errors: &mut Vec<String>,
|
||||
) {
|
||||
let root = match super::plugins_dir() {
|
||||
Ok(root) => root,
|
||||
Err(e) => {
|
||||
load_errors.push(format!("cannot resolve plugins dir: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let entries = match std::fs::read_dir(&root) {
|
||||
Ok(entries) => entries,
|
||||
// No plugins dir yet is normal; anything else is worth surfacing.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||
Err(e) => {
|
||||
load_errors.push(format!("reading {}: {e}", root.display()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(e) => {
|
||||
load_errors.push(format!("reading an entry in {}: {e}", root.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let dir = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
// Skip the staging scratch dirs and other dotfiles.
|
||||
if name.starts_with('.') || !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let manifest_path = dir.join("aoe-plugin.toml");
|
||||
let bytes = match std::fs::read(&manifest_path) {
|
||||
Ok(bytes) => bytes,
|
||||
// A directory without a manifest is simply not a plugin; anything
|
||||
// else (a permission error, a short read) is worth surfacing.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(e) => {
|
||||
load_errors.push(format!("reading {}: {e}", manifest_path.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let manifest = match std::str::from_utf8(&bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|t| PluginManifest::from_toml_str(t).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(manifest) => manifest,
|
||||
Err(e) => {
|
||||
load_errors.push(format!("plugin at {}: {e}", dir.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let id = manifest.id.as_str().to_string();
|
||||
|
||||
// Skip a plugin the running aoe is too old/new for. Unlike install, a
|
||||
// load-time mismatch must not be fatal: an aoe upgrade can move the host
|
||||
// outside a still-installed plugin's range, and bailing would brick
|
||||
// startup. Report it and carry on. Builtins never reach here (they load
|
||||
// from the embedded BUILTINS set, not this directory scan).
|
||||
if let Err(msg) = manifest.host_compat(env!("CARGO_PKG_VERSION")) {
|
||||
load_errors.push(format!("plugin {id:?} at {}: {msg}", dir.display()));
|
||||
continue;
|
||||
}
|
||||
|
||||
let plugin_config = config.plugins.get(&id);
|
||||
let source = plugin_config.and_then(|p| p.source.clone());
|
||||
let validation = validation_for(featured, &id, &dir, source.as_deref());
|
||||
|
||||
// A reserved namespace is only allowed for a live featured-verified
|
||||
// plugin; this is the load-time twin of the install gate, and it
|
||||
// re-derives featured status rather than trusting the lockfile.
|
||||
if manifest.id.is_reserved_namespace() && validation != ValidationState::Featured {
|
||||
load_errors.push(format!(
|
||||
"plugin {id:?} at {} uses a reserved namespace and was skipped",
|
||||
dir.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if is_builtin_id(&id) || plugins.iter().any(|p| p.id() == id) {
|
||||
load_errors.push(format!(
|
||||
"plugin {id:?} at {} collides with an existing plugin id and was skipped",
|
||||
dir.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let manifest_hash = PluginManifest::hash_bytes(&bytes);
|
||||
let enabled = plugin_config.map(|p| p.enabled).unwrap_or(true);
|
||||
let granted = plugin_config
|
||||
.and_then(|p| p.grant.as_ref())
|
||||
.map(|g| grant_covers(g, &manifest, &manifest_hash))
|
||||
.unwrap_or(false);
|
||||
|
||||
plugins.push(LoadedPlugin {
|
||||
manifest,
|
||||
enabled,
|
||||
trust: TrustLevel::Community,
|
||||
validation,
|
||||
source,
|
||||
dir: Some(dir),
|
||||
manifest_hash,
|
||||
granted,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builtin_manifests_parse_and_have_unique_ids() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for builtin in BUILTINS {
|
||||
let manifest = PluginManifest::from_toml_str(builtin.manifest_toml)
|
||||
.expect("builtin manifest must be valid");
|
||||
assert!(
|
||||
seen.insert(manifest.id.as_str().to_string()),
|
||||
"duplicate builtin id {}",
|
||||
manifest.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grant_covers_requires_matching_hash_and_caps() {
|
||||
let manifest = PluginManifest::from_toml_str(
|
||||
r#"
|
||||
id = "acme.thing"
|
||||
name = "Thing"
|
||||
version = "1.0.0"
|
||||
api_version = 2
|
||||
capabilities = ["net", "fs.read"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let hash = "sha256:abc";
|
||||
|
||||
let full = CapabilityGrant {
|
||||
manifest_hash: hash.to_string(),
|
||||
capabilities: vec!["net".into(), "fs.read".into()],
|
||||
granted_at: chrono::Utc::now(),
|
||||
};
|
||||
assert!(grant_covers(&full, &manifest, hash));
|
||||
|
||||
// Wrong hash (manifest changed since the grant): not covered.
|
||||
let stale = CapabilityGrant {
|
||||
manifest_hash: "sha256:old".to_string(),
|
||||
..full.clone()
|
||||
};
|
||||
assert!(!grant_covers(&stale, &manifest, hash));
|
||||
|
||||
// Missing a capability: not covered.
|
||||
let partial = CapabilityGrant {
|
||||
manifest_hash: hash.to_string(),
|
||||
capabilities: vec!["net".into()],
|
||||
granted_at: chrono::Utc::now(),
|
||||
};
|
||||
assert!(!grant_covers(&partial, &manifest, hash));
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
//! Sandbox backends for plugin workers.
|
||||
//!
|
||||
//! A [`SandboxBackend`] sits between a resolved launch and the actual spawn,
|
||||
//! transforming how the worker process is isolated. The honest v1 model (D8 in
|
||||
//! `docs/development/internals/plugin-system.md`): capability gating at the
|
||||
//! host API boundary stops a cooperative plugin from reaching resources it did
|
||||
//! not declare. It does NOT contain an adversarial plugin: a worker that wants
|
||||
//! to read the filesystem or open a socket can, because [`NoSandbox`] runs it
|
||||
//! as an ordinary child process. OS-level isolation (a restricted environment,
|
||||
//! landlock, `sandbox-exec`) arrives later as additional backends behind this
|
||||
//! same trait, with no change to the supervisor or the resolver.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::plugin::launch::ResolvedLaunch;
|
||||
|
||||
/// A launch after the sandbox backend has had its say. Today this is the same
|
||||
/// shape as [`ResolvedLaunch`] because [`NoSandbox`] is a pass-through, but a
|
||||
/// future backend (a container wrapper, a `sandbox-exec` profile) rewrites the
|
||||
/// program / args / env here without the supervisor knowing.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PreparedLaunch {
|
||||
pub program: PathBuf,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: PathBuf,
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// How a plugin worker process is isolated from the host. The only v1 backend
|
||||
/// is [`NoSandbox`]; the trait exists so OS-level isolation can be added later
|
||||
/// at the same call site.
|
||||
pub trait SandboxBackend: Send + Sync {
|
||||
/// A short stable name for diagnostics and the install prompt.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Transform a resolved launch into the command actually spawned.
|
||||
fn prepare(&self, launch: &ResolvedLaunch) -> anyhow::Result<PreparedLaunch>;
|
||||
}
|
||||
|
||||
/// The v1 backend: run the worker as an ordinary child process, unchanged.
|
||||
/// Honest about offering no OS-level isolation; see the module docs.
|
||||
pub struct NoSandbox;
|
||||
|
||||
impl SandboxBackend for NoSandbox {
|
||||
fn name(&self) -> &'static str {
|
||||
"none"
|
||||
}
|
||||
|
||||
fn prepare(&self, launch: &ResolvedLaunch) -> anyhow::Result<PreparedLaunch> {
|
||||
Ok(PreparedLaunch {
|
||||
program: launch.program.clone(),
|
||||
args: launch.args.clone(),
|
||||
cwd: launch.cwd.clone(),
|
||||
env: launch.env.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_sandbox_is_pass_through() {
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("AOE_PLUGIN_ID".to_string(), "acme.worker".to_string());
|
||||
let launch = ResolvedLaunch {
|
||||
program: PathBuf::from("/usr/bin/python3"),
|
||||
args: vec!["-m".into(), "acme.main".into()],
|
||||
cwd: PathBuf::from("/plugins/acme.worker"),
|
||||
env: env.clone(),
|
||||
};
|
||||
let prepared = NoSandbox.prepare(&launch).unwrap();
|
||||
assert_eq!(prepared.program, launch.program);
|
||||
assert_eq!(prepared.args, launch.args);
|
||||
assert_eq!(prepared.cwd, launch.cwd);
|
||||
assert_eq!(prepared.env, env);
|
||||
assert_eq!(NoSandbox.name(), "none");
|
||||
}
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
//! Async worker RPC handlers for the plugin capability API (#2897):
|
||||
//! `acp.capabilities.get` and `acp.capabilities.probe`. `sessions.create` and
|
||||
//! `sessions.turn.send` still route here so a granted caller gets a stable
|
||||
//! refusal rather than an unknown-method error; they are no longer served.
|
||||
//!
|
||||
//! These run on the async runtime (unlike the synchronous
|
||||
//! [`crate::plugin::host_api::dispatch`]) because the probe path awaits an ACP
|
||||
//! handshake. Capability grants come from the connection context, never the
|
||||
//! payload.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::acp::option_catalog::{AgentOptionEntry, OptionCatalog};
|
||||
use crate::acp::state::ConfigOptionCategory;
|
||||
use crate::plugin::automation_policy::{classify_mode, AutomationPolicy, ModeDecision};
|
||||
use crate::plugin::host_api::{DispatchError, PluginRpcContext};
|
||||
use crate::plugin::protocol::codes;
|
||||
use aoe_plugin_api::acp::{
|
||||
AcpAgentCapability, AcpCapabilitiesResponse, AcpModeCapability, AcpModelCapability,
|
||||
AcpThinkingCapability, ApprovalClass, CatalogStatus,
|
||||
};
|
||||
|
||||
const CAP_ACP_CAPABILITIES_READ: &str = "acp.capabilities.read";
|
||||
const CAP_ACP_CAPABILITIES_PROBE: &str = "acp.capabilities.probe";
|
||||
const CAP_SESSION_CREATE: &str = "session.create";
|
||||
const CAP_SESSION_PROMPT: &str = "session.prompt";
|
||||
|
||||
/// Everything the session RPCs need, injected into the plugin host at
|
||||
/// construction (before any worker launches).
|
||||
pub struct SessionRpcDeps {
|
||||
pub policy: Arc<AutomationPolicy>,
|
||||
}
|
||||
|
||||
/// Whether `method` belongs to this module's async dispatch.
|
||||
pub(crate) fn handles(method: &str) -> bool {
|
||||
matches!(
|
||||
method,
|
||||
"acp.capabilities.get"
|
||||
| "acp.capabilities.probe"
|
||||
| "sessions.create"
|
||||
| "sessions.turn.send"
|
||||
)
|
||||
}
|
||||
|
||||
/// The base capability a session method requires. Exposed so the host can
|
||||
/// authorize before consulting the session dependencies, keeping the authz
|
||||
/// result identical whether or not the service happens to be wired up.
|
||||
pub(crate) fn required_capability(method: &str) -> Option<&'static str> {
|
||||
match method {
|
||||
"acp.capabilities.get" => Some(CAP_ACP_CAPABILITIES_READ),
|
||||
"acp.capabilities.probe" => Some(CAP_ACP_CAPABILITIES_PROBE),
|
||||
"sessions.create" => Some(CAP_SESSION_CREATE),
|
||||
"sessions.turn.send" => Some(CAP_SESSION_PROMPT),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn dispatch(
|
||||
deps: &Arc<SessionRpcDeps>,
|
||||
ctx: &PluginRpcContext,
|
||||
method: &str,
|
||||
params: &Value,
|
||||
) -> Result<Value, DispatchError> {
|
||||
match method {
|
||||
"acp.capabilities.get" => {
|
||||
ctx.require(CAP_ACP_CAPABILITIES_READ)?;
|
||||
capabilities_get().await
|
||||
}
|
||||
"acp.capabilities.probe" => {
|
||||
ctx.require(CAP_ACP_CAPABILITIES_PROBE)?;
|
||||
capabilities_probe(params).await
|
||||
}
|
||||
"sessions.create" => {
|
||||
ctx.require(CAP_SESSION_CREATE)?;
|
||||
Err(unsupported(deps, &ctx.plugin_id, "sessions.create"))
|
||||
}
|
||||
"sessions.turn.send" => {
|
||||
ctx.require(CAP_SESSION_PROMPT)?;
|
||||
Err(unsupported(deps, &ctx.plugin_id, "sessions.turn.send"))
|
||||
}
|
||||
other => Err(DispatchError::internal(format!(
|
||||
"session_api routed unknown method {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge the static agent registry with the last advertised option catalog
|
||||
/// into the stable public DTO. Pure reads; never launches an agent.
|
||||
async fn capabilities_get() -> Result<Value, DispatchError> {
|
||||
let catalog = load_catalog().await;
|
||||
let mut ids: Vec<String> = crate::acp::AgentRegistry::with_defaults()
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
for name in catalog.agents.keys() {
|
||||
if !ids.contains(name) {
|
||||
ids.push(name.clone());
|
||||
}
|
||||
}
|
||||
ids.sort();
|
||||
|
||||
let agents = ids
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let entry = catalog.agents.get(&id);
|
||||
let (catalog_status, catalog_updated_at) = match entry {
|
||||
Some(e) => (CatalogStatus::Discovered, Some(e.updated_at.clone())),
|
||||
None => (CatalogStatus::Undiscovered, None),
|
||||
};
|
||||
let mut models: Vec<AcpModelCapability> = entry
|
||||
.map(|e| {
|
||||
choices(e, ConfigOptionCategory::Model)
|
||||
.map(|choice| AcpModelCapability {
|
||||
id: choice.value.clone(),
|
||||
display_name: choice.name.clone(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
let mut modes: Vec<AcpModeCapability> = entry
|
||||
.map(|e| {
|
||||
choices(e, ConfigOptionCategory::Mode)
|
||||
.map(|choice| AcpModeCapability {
|
||||
id: choice.value.clone(),
|
||||
display_name: choice.name.clone(),
|
||||
approval_class: match classify_mode(&id, Some(&choice.value), entry) {
|
||||
ModeDecision::Class(class) => class,
|
||||
// Advertised modes always classify; fail
|
||||
// closed if that invariant ever breaks.
|
||||
_ => ApprovalClass::Unattended,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
modes.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
let mut thinking: Vec<AcpThinkingCapability> = entry
|
||||
.map(|e| {
|
||||
choices(e, ConfigOptionCategory::ThoughtLevel)
|
||||
.map(|choice| AcpThinkingCapability {
|
||||
id: choice.value.clone(),
|
||||
display_name: choice.name.clone(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
thinking.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
AcpAgentCapability {
|
||||
// The registry has no display metadata; the id doubles as
|
||||
// the display name until it grows one.
|
||||
display_name: id.clone(),
|
||||
id,
|
||||
catalog_status,
|
||||
catalog_updated_at,
|
||||
models,
|
||||
modes,
|
||||
thinking,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::to_value(AcpCapabilitiesResponse { agents })
|
||||
.map_err(|e| DispatchError::internal(format!("serialize capabilities: {e}")))
|
||||
}
|
||||
|
||||
/// `acp.capabilities.probe`: populate the option catalog for one agent (or every
|
||||
/// currently-undiscovered registry agent when no `agent_id` is given) via a
|
||||
/// handshake-only ACP probe, then return the same shape as
|
||||
/// `acp.capabilities.get`. Each probe degrades to a no-op on failure, so a
|
||||
/// missing adapter or an agent that needs credentials the daemon lacks simply
|
||||
/// stays `Undiscovered` instead of erroring the whole call.
|
||||
async fn capabilities_probe(params: &Value) -> Result<Value, DispatchError> {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ProbeParams {
|
||||
#[serde(default)]
|
||||
agent_id: Option<String>,
|
||||
}
|
||||
|
||||
let req: ProbeParams = if params.is_null() {
|
||||
ProbeParams { agent_id: None }
|
||||
} else {
|
||||
serde_json::from_value(params.clone())
|
||||
.map_err(|e| DispatchError::invalid_params(format!("invalid probe params: {e}")))?
|
||||
};
|
||||
|
||||
let targets: Vec<String> = match req.agent_id {
|
||||
Some(id) if !id.trim().is_empty() => vec![id],
|
||||
_ => {
|
||||
let catalog = load_catalog().await;
|
||||
crate::acp::AgentRegistry::with_defaults()
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|(name, _)| name.clone())
|
||||
.filter(|name| !catalog.agents.contains_key(name))
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
|
||||
for agent in &targets {
|
||||
if let Err(e) = crate::acp::capability_probe::probe_agent(agent).await {
|
||||
tracing::warn!(target: "acp.probe", agent = %agent, error = %e, "capability probe errored");
|
||||
}
|
||||
}
|
||||
|
||||
capabilities_get().await
|
||||
}
|
||||
|
||||
fn choices(
|
||||
entry: &AgentOptionEntry,
|
||||
category: ConfigOptionCategory,
|
||||
) -> impl Iterator<Item = &crate::acp::state::ConfigOptionChoice> {
|
||||
entry
|
||||
.options
|
||||
.iter()
|
||||
.filter(move |opt| opt.category == category)
|
||||
.flat_map(|opt| opt.options.iter())
|
||||
}
|
||||
|
||||
async fn load_catalog() -> OptionCatalog {
|
||||
tokio::task::spawn_blocking(crate::acp::option_catalog::load)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Plugin-driven session creation and turn delivery are gone: the session
|
||||
/// service no longer tracks a creating plugin, so neither ownership nor
|
||||
/// create-idempotency can be enforced for a plugin caller.
|
||||
fn unsupported(deps: &Arc<SessionRpcDeps>, plugin_id: &str, method: &str) -> DispatchError {
|
||||
deps.policy.audit(
|
||||
plugin_id,
|
||||
serde_json::json!({ "op": method, "decision": "denied", "kind": "unsupported" }),
|
||||
);
|
||||
DispatchError::with_kind(
|
||||
codes::FAILED_PRECONDITION,
|
||||
"unsupported",
|
||||
format!("{method} is no longer supported"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::plugin::automation_policy::AutomationPolicy;
|
||||
|
||||
fn ctx_with(caps: &[&str]) -> PluginRpcContext {
|
||||
PluginRpcContext {
|
||||
plugin_id: "cron".to_string(),
|
||||
granted_capabilities: caps.iter().map(|c| c.to_string()).collect(),
|
||||
ui_contributions: std::collections::HashSet::new(),
|
||||
ui_generation: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_deps() -> (Arc<SessionRpcDeps>, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let policy =
|
||||
Arc::new(AutomationPolicy::open(&dir.path().join("plugin_events.db")).expect("policy"));
|
||||
(Arc::new(SessionRpcDeps { policy }), dir)
|
||||
}
|
||||
|
||||
fn kind(e: &DispatchError) -> String {
|
||||
e.data
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("kind"))
|
||||
.and_then(|k| k.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Every method refuses a caller missing its gating capability, before
|
||||
/// touching any state.
|
||||
#[tokio::test]
|
||||
async fn authz_matrix_capability_gates() {
|
||||
let (deps, _dir) = test_deps();
|
||||
let none = ctx_with(&[]);
|
||||
for method in [
|
||||
"acp.capabilities.get",
|
||||
"acp.capabilities.probe",
|
||||
"sessions.create",
|
||||
"sessions.turn.send",
|
||||
] {
|
||||
let err = dispatch(&deps, &none, method, &serde_json::json!({}))
|
||||
.await
|
||||
.expect_err("must be refused without the capability");
|
||||
assert_eq!(err.code, codes::FORBIDDEN, "{method}");
|
||||
assert_eq!(kind(&err), "capability_missing", "{method}");
|
||||
}
|
||||
// The wrong capability does not substitute for the right one.
|
||||
let wrong = ctx_with(&["session.prompt"]);
|
||||
let err = dispatch(&deps, &wrong, "sessions.create", &serde_json::json!({}))
|
||||
.await
|
||||
.expect_err("session.prompt must not grant sessions.create");
|
||||
assert_eq!(err.code, codes::FORBIDDEN);
|
||||
}
|
||||
|
||||
/// The probe RPC decodes params strictly: an unknown field is a client
|
||||
/// error, refused before any spawn work.
|
||||
#[tokio::test]
|
||||
async fn probe_rejects_unknown_params() {
|
||||
let (deps, _dir) = test_deps();
|
||||
let ctx = ctx_with(&["acp.capabilities.probe"]);
|
||||
let err = dispatch(
|
||||
&deps,
|
||||
&ctx,
|
||||
"acp.capabilities.probe",
|
||||
&serde_json::json!({ "bogus": 1 }),
|
||||
)
|
||||
.await
|
||||
.expect_err("unknown probe param must be rejected");
|
||||
assert_eq!(err.code, codes::INVALID_PARAMS);
|
||||
}
|
||||
|
||||
/// A registry-unknown `agent_id` never spawns anything (the probe bails on
|
||||
/// an unknown agent), so this stays hermetic while still exercising the RPC
|
||||
/// end to end and confirming it returns the capability catalog shape.
|
||||
#[tokio::test]
|
||||
async fn probe_unknown_agent_is_noop_and_returns_catalog() {
|
||||
let (deps, _dir) = test_deps();
|
||||
let ctx = ctx_with(&["acp.capabilities.probe"]);
|
||||
let out = dispatch(
|
||||
&deps,
|
||||
&ctx,
|
||||
"acp.capabilities.probe",
|
||||
&serde_json::json!({ "agent_id": "definitely-not-an-agent-xyz" }),
|
||||
)
|
||||
.await
|
||||
.expect("probe returns the capability catalog");
|
||||
assert!(out.get("agents").is_some());
|
||||
}
|
||||
|
||||
/// The retired session RPCs still authorize first, then refuse with a
|
||||
/// stable kind rather than an unknown-method error.
|
||||
#[tokio::test]
|
||||
async fn retired_session_rpcs_refuse_a_granted_caller() {
|
||||
let (deps, _dir) = test_deps();
|
||||
for (method, cap) in [
|
||||
("sessions.create", "session.create"),
|
||||
("sessions.turn.send", "session.prompt"),
|
||||
] {
|
||||
let err = dispatch(&deps, &ctx_with(&[cap]), method, &serde_json::json!({}))
|
||||
.await
|
||||
.expect_err("the session RPCs are no longer served");
|
||||
assert_eq!(err.code, codes::FAILED_PRECONDITION, "{method}");
|
||||
assert_eq!(kind(&err), "unsupported", "{method}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
//! Parsing an external plugin install source.
|
||||
//!
|
||||
//! A source is either a GitHub slug (`gh:owner/repo` with an optional `@ref`)
|
||||
//! or a local directory path. Parsing is pure: it does not touch the network or
|
||||
//! the filesystem, so the same parser interprets a freshly typed argument and a
|
||||
//! source string read back from config on update.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
/// Where a plugin is installed from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PluginSource {
|
||||
/// A GitHub repository, written `gh:owner/repo` with an optional `@ref`
|
||||
/// (branch, tag, or commit).
|
||||
Github {
|
||||
owner: String,
|
||||
repo: String,
|
||||
reference: Option<String>,
|
||||
},
|
||||
/// A local directory containing an `aoe-plugin.toml`.
|
||||
Local(PathBuf),
|
||||
}
|
||||
|
||||
impl PluginSource {
|
||||
/// Parse an install source argument. A `gh:` prefix selects GitHub;
|
||||
/// anything else is treated as a local path.
|
||||
pub fn parse(input: &str) -> Result<Self> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
bail!("empty plugin source");
|
||||
}
|
||||
if let Some(rest) = input.strip_prefix("gh:") {
|
||||
let (slug, reference) = match rest.split_once('@') {
|
||||
Some((slug, reference)) => {
|
||||
if reference.is_empty() {
|
||||
bail!("empty ref after '@' in {input:?}");
|
||||
}
|
||||
(slug, Some(reference.to_string()))
|
||||
}
|
||||
None => (rest, None),
|
||||
};
|
||||
let (owner, repo) = slug
|
||||
.split_once('/')
|
||||
.filter(|(o, r)| !o.is_empty() && !r.is_empty() && !r.contains('/'))
|
||||
.ok_or_else(|| anyhow::anyhow!("expected gh:owner/repo, got {input:?}"))?;
|
||||
Ok(PluginSource::Github {
|
||||
owner: owner.to_string(),
|
||||
repo: repo.to_string(),
|
||||
reference,
|
||||
})
|
||||
} else {
|
||||
Ok(PluginSource::Local(PathBuf::from(input)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical source string persisted in config and the lockfile. For
|
||||
/// GitHub this drops the `@ref` (the ref is recorded separately); for a
|
||||
/// local source it is the path.
|
||||
pub fn slug(&self) -> String {
|
||||
match self {
|
||||
PluginSource::Github { owner, repo, .. } => format!("gh:{owner}/{repo}"),
|
||||
PluginSource::Local(path) => path.display().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The requested git ref, if any. Always `None` for a local source.
|
||||
pub fn reference(&self) -> Option<&str> {
|
||||
match self {
|
||||
PluginSource::Github { reference, .. } => reference.as_deref(),
|
||||
PluginSource::Local(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The clone URL for a GitHub source. The host base defaults to
|
||||
/// `https://github.com` and is overridable via `AOE_GITHUB_CLONE_BASE` (a
|
||||
/// GitHub Enterprise host, or a local path/`file://` base in tests).
|
||||
pub fn github_clone_url(&self) -> Option<String> {
|
||||
match self {
|
||||
PluginSource::Github { owner, repo, .. } => {
|
||||
let base = std::env::var("AOE_GITHUB_CLONE_BASE")
|
||||
.unwrap_or_else(|_| "https://github.com".to_string());
|
||||
let base = base.trim_end_matches('/');
|
||||
Some(format!("{base}/{owner}/{repo}.git"))
|
||||
}
|
||||
PluginSource::Local(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_github_with_and_without_ref() {
|
||||
let s = PluginSource::parse("gh:acme/widget").unwrap();
|
||||
assert_eq!(
|
||||
s,
|
||||
PluginSource::Github {
|
||||
owner: "acme".into(),
|
||||
repo: "widget".into(),
|
||||
reference: None
|
||||
}
|
||||
);
|
||||
assert_eq!(s.slug(), "gh:acme/widget");
|
||||
assert_eq!(s.reference(), None);
|
||||
|
||||
let s = PluginSource::parse("gh:acme/widget@v1.2.3").unwrap();
|
||||
assert_eq!(s.reference(), Some("v1.2.3"));
|
||||
assert_eq!(s.slug(), "gh:acme/widget");
|
||||
assert_eq!(
|
||||
s.github_clone_url().as_deref(),
|
||||
Some("https://github.com/acme/widget.git")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_github() {
|
||||
for bad in [
|
||||
"gh:",
|
||||
"gh:acme",
|
||||
"gh:acme/",
|
||||
"gh:/widget",
|
||||
"gh:a/b/c",
|
||||
"gh:acme/widget@",
|
||||
] {
|
||||
assert!(
|
||||
PluginSource::parse(bad).is_err(),
|
||||
"{bad} should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn treats_non_gh_as_local_path() {
|
||||
let s = PluginSource::parse("/tmp/my-plugin").unwrap();
|
||||
assert_eq!(s, PluginSource::Local(PathBuf::from("/tmp/my-plugin")));
|
||||
assert_eq!(s.reference(), None);
|
||||
assert_eq!(s.github_clone_url(), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,172 +0,0 @@
|
||||
//! Update-availability checks for installed external plugins.
|
||||
//!
|
||||
//! An explicit action (CLI `aoe plugin outdated`, TUI `c`, the dashboard
|
||||
//! `GET /api/plugins/updates`), never run during the registry's offline load
|
||||
//! path. For a GitHub source it compares the lockfile's resolved commit against
|
||||
//! `git ls-remote` of the requested ref (no clone, no REST rate limit); for a
|
||||
//! local source it re-hashes the source directory against the lockfile tree
|
||||
//! hash. Builtins have nothing to update and are skipped.
|
||||
//!
|
||||
//! Limitation: a `release-binary` plugin whose GitHub release asset is replaced
|
||||
//! without a source-commit change is not detected here; `ls-remote` only sees
|
||||
//! the source tree. That asset drift is out of scope for #2365.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::lockfile::Lockfile;
|
||||
use super::source::PluginSource;
|
||||
|
||||
/// One plugin's update status, rendered identically by CLI / TUI / web.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UpdateStatus {
|
||||
pub id: String,
|
||||
pub source: String,
|
||||
/// The currently installed marker: a short commit (GitHub) or `local`.
|
||||
pub current: String,
|
||||
/// The newer marker when an update exists: a short commit for GitHub. `None`
|
||||
/// for a changed local tree (there is no commit to name) or when current.
|
||||
pub available: Option<String>,
|
||||
pub needs_update: bool,
|
||||
/// Why the check could not run for this plugin (missing lock, git absent,
|
||||
/// dead remote). Never silently treated as up-to-date.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// One installed external plugin's identity, pulled off the registry before any
|
||||
/// blocking work so nothing non-`Send` is held across an await.
|
||||
struct Target {
|
||||
id: String,
|
||||
source: String,
|
||||
}
|
||||
|
||||
/// Check every installed external plugin for an available update. Results are
|
||||
/// sorted by id; per-plugin failures land in `error`, not as a hard error.
|
||||
pub async fn outdated() -> Vec<UpdateStatus> {
|
||||
let targets: Vec<Target> = super::registry()
|
||||
.all()
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
Some(Target {
|
||||
id: p.id().to_string(),
|
||||
source: p.source.clone()?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let lock = Lockfile::load();
|
||||
let mut out = Vec::with_capacity(targets.len());
|
||||
for target in targets {
|
||||
out.push(check_one(&target, lock.as_ref()).await);
|
||||
}
|
||||
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
out
|
||||
}
|
||||
|
||||
async fn check_one(target: &Target, lock: Result<&Lockfile, &anyhow::Error>) -> UpdateStatus {
|
||||
let status = |current: String, available: Option<String>, error: Option<String>| UpdateStatus {
|
||||
id: target.id.clone(),
|
||||
source: target.source.clone(),
|
||||
needs_update: available.is_some(),
|
||||
current,
|
||||
available,
|
||||
error,
|
||||
};
|
||||
let err = |msg: String| status(String::new(), None, Some(msg));
|
||||
|
||||
let lock = match lock {
|
||||
Ok(lock) => lock,
|
||||
// A corrupt or unreadable plugins.lock must surface as itself, not be
|
||||
// misreported as a missing entry across every plugin.
|
||||
Err(e) => return err(format!("reading plugins.lock: {e:#}")),
|
||||
};
|
||||
let Some(locked) = lock.get(&target.id) else {
|
||||
return err(format!(
|
||||
"no lockfile entry for {}; reinstall to record one",
|
||||
target.id
|
||||
));
|
||||
};
|
||||
|
||||
match PluginSource::parse(&target.source) {
|
||||
Ok(source @ PluginSource::Github { .. }) => {
|
||||
let Some(url) = source.github_clone_url() else {
|
||||
return err("github source without a clone url".to_string());
|
||||
};
|
||||
let Some(current_commit) = locked.resolved_commit.clone() else {
|
||||
return err("lockfile has no resolved commit".to_string());
|
||||
};
|
||||
// A no-`@ref` install tracks the latest-release channel, so compare
|
||||
// against the latest release tag rather than the moving default
|
||||
// branch HEAD. An explicit `@ref` is compared as-is. A no-`@ref`
|
||||
// source whose repo has no release has nothing to update to.
|
||||
let reference = match source.reference() {
|
||||
Some(r) => Some(r.to_string()),
|
||||
None => match resolve_latest_release(&source).await {
|
||||
Ok(Some(tag)) => Some(tag),
|
||||
Ok(None) => return status(short(¤t_commit), None, None),
|
||||
Err(e) => return err(format!("{e:#}")),
|
||||
},
|
||||
};
|
||||
let remote = tokio::task::spawn_blocking(move || {
|
||||
super::fetch::ls_remote(&url, reference.as_deref())
|
||||
})
|
||||
.await;
|
||||
match remote {
|
||||
Ok(Ok(remote_commit)) => {
|
||||
let needs = !remote_commit.eq_ignore_ascii_case(¤t_commit);
|
||||
status(
|
||||
short(¤t_commit),
|
||||
needs.then(|| short(&remote_commit)),
|
||||
None,
|
||||
)
|
||||
}
|
||||
Ok(Err(e)) => err(format!("{e:#}")),
|
||||
Err(e) => err(format!("ls-remote task failed: {e}")),
|
||||
}
|
||||
}
|
||||
Ok(PluginSource::Local(path)) => {
|
||||
let pinned = locked.tree_hash.clone();
|
||||
let probe = path.clone();
|
||||
let rehash =
|
||||
tokio::task::spawn_blocking(move || super::integrity::tree_hash(&probe)).await;
|
||||
match rehash {
|
||||
Ok(Ok(hash)) => {
|
||||
let needs = !pinned.is_empty() && hash != pinned;
|
||||
status(
|
||||
"local".to_string(),
|
||||
needs.then(|| "modified".to_string()),
|
||||
None,
|
||||
)
|
||||
}
|
||||
Ok(Err(e)) => err(format!("re-hashing {}: {e:#}", path.display())),
|
||||
Err(e) => err(format!("hash task failed: {e}")),
|
||||
}
|
||||
}
|
||||
Err(e) => err(format!("unparseable source {:?}: {e:#}", target.source)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest stable release tag for a GitHub source, or `None` when the repo
|
||||
/// has none. Non-GitHub sources never reach this.
|
||||
async fn resolve_latest_release(source: &PluginSource) -> anyhow::Result<Option<String>> {
|
||||
match source {
|
||||
PluginSource::Github { owner, repo, .. } => {
|
||||
super::fetch::latest_release_tag(owner, repo).await
|
||||
}
|
||||
PluginSource::Local(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn short(commit: &str) -> String {
|
||||
commit.chars().take(8).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn short_truncates() {
|
||||
assert_eq!(short("abcdef0123456789"), "abcdef01");
|
||||
assert_eq!(short("abc"), "abc");
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//! The shared plugin view-model: one Rust description of a plugin that both the
|
||||
//! web dashboard (serialized over `GET /api/plugins`) and the native TUI
|
||||
//! render from, so neither re-derives the shape.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::registry::LoadedPlugin;
|
||||
|
||||
/// The manager's view of one plugin. Built by [`LoadedPlugin::view`], consumed
|
||||
/// directly by the TUI and serialized for the web (the `GET /api/plugins`
|
||||
/// contract the web TypeScript mirrors).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PluginView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
/// Lucide kebab-case identity icon name, straight from the manifest.
|
||||
pub icon: Option<String>,
|
||||
/// Resolved URL for the manifest's `icon_asset`, only set when the plugin
|
||||
/// has both an on-disk install directory (not a builtin) and an
|
||||
/// `icon_asset` path: `GET /api/plugins/{id}/icon` streams it from the
|
||||
/// install directory.
|
||||
pub icon_asset_url: Option<String>,
|
||||
pub enabled: bool,
|
||||
/// First-party builtin (compiled in) versus an externally installed plugin.
|
||||
pub builtin: bool,
|
||||
/// Validation provenance: `builtin`, `featured`, `community`, or `local`.
|
||||
pub validation: String,
|
||||
/// Install source for an external plugin (`gh:owner/repo` or a path).
|
||||
pub source: Option<String>,
|
||||
/// Capabilities the plugin's manifest declares.
|
||||
pub capabilities: Vec<String>,
|
||||
/// UI slots the plugin declares it will render into (#2366). Disclosed
|
||||
/// alongside capabilities so a surface can show the user that the plugin
|
||||
/// modifies the dashboard, even though a UI contribution needs no grant.
|
||||
pub ui_contributions: Vec<UiContributionView>,
|
||||
/// Whether the user's grant covers the installed manifest (always true for
|
||||
/// builtins).
|
||||
pub granted: bool,
|
||||
/// Installed but inactive: a community plugin awaiting capability approval.
|
||||
pub needs_reapproval: bool,
|
||||
}
|
||||
|
||||
/// A declared UI contribution, flattened for display: the kebab-case slot name
|
||||
/// and the plugin-chosen entry id.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UiContributionView {
|
||||
pub slot: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
impl LoadedPlugin {
|
||||
/// The view-model for this plugin: the single shape both UIs render from.
|
||||
pub fn view(&self) -> PluginView {
|
||||
PluginView {
|
||||
id: self.id().to_string(),
|
||||
name: self.manifest.name.clone(),
|
||||
version: self.manifest.version.clone(),
|
||||
description: self.manifest.description.clone(),
|
||||
icon: self.manifest.icon.clone(),
|
||||
icon_asset_url: (self.manifest.icon_asset.is_some() && self.dir.is_some())
|
||||
.then(|| format!("/api/plugins/{}/icon", self.id())),
|
||||
enabled: self.enabled,
|
||||
builtin: self.builtin(),
|
||||
validation: self.validation.as_str().to_string(),
|
||||
source: self.source.clone(),
|
||||
capabilities: self
|
||||
.manifest
|
||||
.capabilities
|
||||
.iter()
|
||||
.map(|c| c.as_str().to_string())
|
||||
.collect(),
|
||||
ui_contributions: self
|
||||
.manifest
|
||||
.ui
|
||||
.iter()
|
||||
.map(|u| UiContributionView {
|
||||
slot: u.slot.as_str().to_string(),
|
||||
id: u.id.clone(),
|
||||
})
|
||||
.collect(),
|
||||
granted: self.granted,
|
||||
needs_reapproval: self.needs_reapproval(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -37,15 +37,15 @@ mod platform {
|
||||
pub(crate) mod metrics;
|
||||
|
||||
/// Protocol-agnostic plumbing for supervised worker subprocesses, lifted
|
||||
/// out of `src/acp/` so the future plugin host can reuse it. Serve-gated
|
||||
/// out of `src/acp/` so any worker protocol can reuse it. Serve-gated
|
||||
/// because its only consumer today is the serve-gated `acp` module.
|
||||
#[cfg(feature = "serve")]
|
||||
pub mod worker;
|
||||
|
||||
/// On-disk registry of detached ACP worker subprocesses (pid, socket path,
|
||||
/// build version, `stored_acp_session_id`). Colocated here with the
|
||||
/// protocol-agnostic `worker` substrate it builds on, so the plugin host can
|
||||
/// reuse that substrate directly. Dependency direction is one-way: consumers
|
||||
/// protocol-agnostic `worker` substrate it builds on. Dependency direction is
|
||||
/// one-way: consumers
|
||||
/// point down to `process`, never to each other. Serve-gated to match its
|
||||
/// consumers.
|
||||
#[cfg(feature = "serve")]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Protocol-agnostic plumbing for supervised worker subprocesses.
|
||||
//!
|
||||
//! This is the neutral substrate that both `src/acp/` and the future
|
||||
//! plugin host build on: process-group signalling and liveness probes,
|
||||
//! This is the neutral substrate `src/acp/` builds on: process-group
|
||||
//! signalling and liveness probes,
|
||||
//! the on-disk layout helpers for a `<dir>/<id>.{json,sock,log,restart}`
|
||||
//! worker directory, and the runner self-inspection state machine. None
|
||||
//! of it knows about ACP, agents, or any specific worker payload; the
|
||||
@@ -410,9 +410,9 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The path builders are parameterized by an arbitrary base dir, not a
|
||||
/// hardcoded ACP one: this is what makes them reusable by the plugin
|
||||
/// host. Prove they compose against a non-ACP directory and reject bad
|
||||
/// ids regardless of dir.
|
||||
/// hardcoded ACP one, which is what makes them protocol-agnostic. Prove
|
||||
/// they compose against a non-ACP directory and reject bad ids regardless
|
||||
/// of dir.
|
||||
#[test]
|
||||
fn path_builders_use_arbitrary_dir_and_validate() {
|
||||
let dir = Path::new("/var/lib/example-workers");
|
||||
|
||||
@@ -339,7 +339,7 @@ pub async fn reconcile_acp_workers(
|
||||
// with `pending_initial_turn` whose create fast path did not deliver it
|
||||
// (spawn failure, daemon restart, adopted runner) gets its turn drained
|
||||
// here once a worker is live. Normally a no-op: pending turns exist only
|
||||
// between a plugin create and its first successful delivery.
|
||||
// between a session create and its first successful delivery.
|
||||
drain_pending_initial_turns(state).await;
|
||||
|
||||
// Drain each session's server-owned prompt queue when its turn has ended,
|
||||
|
||||
@@ -1309,7 +1309,7 @@ pub async fn acp_prompt(
|
||||
// `build_spawn_request`, which takes this very lock, so the spawn could
|
||||
// not start until this handler released it, and the handler was busy
|
||||
// burning `WORKER_READY_TIMEOUT` waiting for that spawn. Resume +
|
||||
// publish + forward still live in the shared service so the plugin host
|
||||
// publish + forward still live in the shared service so a non-HTTP caller
|
||||
// delivers turns through the same path (#2897).
|
||||
{
|
||||
let inst_lock = state.instance_lock(&id).await;
|
||||
|
||||
@@ -19,8 +19,6 @@ mod file_provenance;
|
||||
mod git;
|
||||
mod log_level;
|
||||
mod mcp;
|
||||
pub(crate) mod plugin_settings;
|
||||
pub mod plugins;
|
||||
mod projects;
|
||||
#[cfg(feature = "serve")]
|
||||
mod queue;
|
||||
@@ -47,13 +45,6 @@ pub use client_log::post_client_log;
|
||||
pub use git::{clone_repo, is_git_repo, list_branches};
|
||||
pub use log_level::{get_log_level, patch_log_level};
|
||||
pub use mcp::{drop_mcp_server, get_mcp_servers, keep_mcp_server, resolve_mcp_conflict};
|
||||
pub use plugin_settings::resolve_options;
|
||||
pub use plugins::{
|
||||
apply_plugin_update, dismiss_plugin_update, invoke_plugin_action, invoke_plugin_command,
|
||||
list_plugins, plugin_commands, plugin_details, plugin_discover, plugin_job_status,
|
||||
plugin_ui_state, plugin_update_preview, plugin_updates, preview_plugin_install,
|
||||
serve_plugin_icon, set_plugin_enabled, start_plugin_install, start_plugin_uninstall,
|
||||
};
|
||||
pub use projects::{create_project, delete_project, list_projects, update_project};
|
||||
pub use sessions::{
|
||||
attach_session_project, create_session, delete_session, delete_workspace,
|
||||
@@ -388,11 +379,6 @@ mod tests {
|
||||
include_str!("../push.rs"),
|
||||
&["subscribe", "unsubscribe", "test"],
|
||||
),
|
||||
(
|
||||
"api/plugins.rs",
|
||||
include_str!("plugins.rs"),
|
||||
&["invoke_plugin_action"],
|
||||
),
|
||||
];
|
||||
|
||||
let guard_patterns: &[&str] = &[
|
||||
@@ -442,39 +428,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A plugin pane action is forwarded to the worker (the trust boundary)
|
||||
/// and mutates no host-managed state, so it is gated on read-write mode
|
||||
/// only, never on passphrase elevation (#2454). This static check guards
|
||||
/// against a refactor re-introducing the elevation gate on the action
|
||||
/// path and re-breaking the refresh button under login. Same body-boundary
|
||||
/// walk as `every_mutating_handler_has_read_only_guard`.
|
||||
#[test]
|
||||
fn plugin_action_does_not_require_elevation() {
|
||||
let source = include_str!("plugins.rs");
|
||||
let needle = "fn invoke_plugin_action(";
|
||||
let start = source
|
||||
.find(needle)
|
||||
.expect("handler `invoke_plugin_action` not found (rename/refactor?)");
|
||||
let rest = &source[start + needle.len()..];
|
||||
let body_terminators: &[&str] = &["\npub async fn ", "\npub fn ", "\nasync fn ", "\nfn "];
|
||||
let end = body_terminators
|
||||
.iter()
|
||||
.filter_map(|t| rest.find(t))
|
||||
.min()
|
||||
.unwrap_or(rest.len());
|
||||
let body = &rest[..end];
|
||||
// `mutation_gate` bundles the elevation check; `is_elevated` /
|
||||
// `elevation_required` would mean elevation was reintroduced inline.
|
||||
for marker in ["mutation_gate", "is_elevated", "elevation_required"] {
|
||||
assert!(
|
||||
!body.contains(marker),
|
||||
"invoke_plugin_action must not elevation-gate (found `{marker}`). \
|
||||
A pane action mutates no host state; keep the read-only gate only. \
|
||||
If an action ever needs elevation, make it opt-in per action (#2454)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Companion to `every_mutating_handler_has_read_only_guard`: enforce
|
||||
/// that any mutating handler taking a typed JSON body extracts it
|
||||
/// lazily, so the read-only short-circuit can run BEFORE body shape
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
//! Host option-source resolver for plugin `dynamic_select` widgets (#2897).
|
||||
//!
|
||||
//! A `dynamic_select` names an [`OptionSource`]; the host resolves the actual
|
||||
//! choices from its own state (agent registry, ACP option catalog, project
|
||||
//! registry, session groups). The web and TUI renderers stay ignorant of
|
||||
//! where a source's data comes from: they post the source plus any
|
||||
//! `depends_on` values and render the returned `{value,label}` list. Saved
|
||||
//! ids are authoritatively revalidated at `sessions.create`, so this endpoint
|
||||
//! is advisory UI data, not an authorization surface; it still requires an
|
||||
//! authenticated dashboard session like every other `/api/*` route.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::session::settings_schema::{OptionSource, SelectOption};
|
||||
|
||||
use super::super::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ResolveOptionsRequest {
|
||||
/// The option source, in the same snake_case form the widget schema
|
||||
/// serializes (`acp_agents`, `acp_models`, ...).
|
||||
pub source: OptionSource,
|
||||
/// Values of the `depends_on` sibling fields, in declaration order. For
|
||||
/// `acp.models` / `acp.modes` the first entry is the selected agent id.
|
||||
#[serde(default)]
|
||||
pub depends: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResolveOptionsResponse {
|
||||
pub options: Vec<SelectOption>,
|
||||
}
|
||||
|
||||
/// `POST /api/plugins/{id}/settings/options/resolve`: resolve one
|
||||
/// dynamic-select source for the settings UI. The `{id}` path segment scopes
|
||||
/// the request to a plugin for auditing/consistency but does not change the
|
||||
/// result: option sources are host-global.
|
||||
pub async fn resolve_options(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::extract::Path(_plugin_id): axum::extract::Path<String>,
|
||||
req: Result<Json<ResolveOptionsRequest>, axum::extract::rejection::JsonRejection>,
|
||||
) -> impl IntoResponse {
|
||||
let Json(req) = match req {
|
||||
Ok(j) => j,
|
||||
Err(rej) => return rej.into_response(),
|
||||
};
|
||||
match resolve_option_source(&state, req.source, &req.depends).await {
|
||||
Ok(options) => Json(ResolveOptionsResponse { options }).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("option resolve failed: {e:#}"),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a dynamic-select option source to a normalized `{value,label}`
|
||||
/// list. Shared by the HTTP endpoint (web) and any in-process caller (TUI).
|
||||
pub async fn resolve_option_source(
|
||||
state: &Arc<AppState>,
|
||||
source: OptionSource,
|
||||
depends: &[String],
|
||||
) -> anyhow::Result<Vec<SelectOption>> {
|
||||
match source {
|
||||
OptionSource::AcpAgents => Ok(acp_agent_options(&state.profile).await),
|
||||
OptionSource::AcpModels => {
|
||||
Ok(catalog_options_probing(depends.first(), CatalogCategory::Model).await)
|
||||
}
|
||||
OptionSource::AcpModes => {
|
||||
Ok(catalog_options_probing(depends.first(), CatalogCategory::Mode).await)
|
||||
}
|
||||
OptionSource::Projects => project_options(&state.profile).await,
|
||||
OptionSource::Groups => Ok(group_options(state).await),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry agents whose ACP adapter is filtered by `present`, mapped to
|
||||
/// `{value,label}`. Split out so the install filter is unit-testable without
|
||||
/// depending on which adapters happen to be on the test host's PATH.
|
||||
fn installed_agent_options<'a>(
|
||||
entries: impl IntoIterator<Item = (&'a String, &'a crate::acp::AgentSpec)>,
|
||||
present: impl Fn(&str) -> bool,
|
||||
) -> Vec<SelectOption> {
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|(_, spec)| present(&spec.command))
|
||||
.map(|(name, _)| SelectOption::new(name, name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// ACP-capable agents from the static registry whose adapter binary actually
|
||||
/// resolves on this host, plus any custom ACP agents the resolved profile
|
||||
/// config declares via a valid `agent_acp_cmd`. Sorted, deduped by id (a custom
|
||||
/// entry shadowing a built-in is dropped by the dedup).
|
||||
///
|
||||
/// The registry filter mirrors `list_agents` (`acp_installed`): an agent is
|
||||
/// only offered as a choice when the host could actually launch it, so the
|
||||
/// picker never lists uninstalled harnesses (#3-plugin-cron picker fix).
|
||||
async fn acp_agent_options(profile: &str) -> Vec<SelectOption> {
|
||||
let registry = crate::acp::AgentRegistry::with_defaults();
|
||||
let mut opts = installed_agent_options(registry.list(), crate::cli::acp::command_present);
|
||||
|
||||
// Custom ACP agents live in the per-profile config; resolve the profile
|
||||
// (global -> profile, no repo) and keep entries whose command parses as a
|
||||
// valid ACP adapter. Config IO runs off the async runtime.
|
||||
let profile = profile.to_string();
|
||||
let custom = tokio::task::spawn_blocking(move || {
|
||||
let session = crate::session::profile_config::resolve_config_or_warn(&profile).session;
|
||||
let detect_as = &session.agent_detect_as;
|
||||
session
|
||||
.agent_acp_cmd
|
||||
.iter()
|
||||
.filter(|(name, cmd)| {
|
||||
!name.is_empty() && crate::acp::AgentSpec::from_acp_cmd(name, cmd).is_ok()
|
||||
})
|
||||
.map(|(name, _)| name.clone())
|
||||
// Plus custom agents that inherit a registry-backed base via
|
||||
// `agent_detect_as` (e.g. a Claude wrapper); they run in structured
|
||||
// view through the base adapter.
|
||||
.chain(
|
||||
detect_as
|
||||
.keys()
|
||||
.filter(|name| {
|
||||
!name.is_empty()
|
||||
&& crate::acp::inherited_acp_base(name, detect_as).is_some()
|
||||
})
|
||||
.cloned(),
|
||||
)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for name in custom {
|
||||
opts.push(SelectOption::new(&name, &name));
|
||||
}
|
||||
|
||||
opts.sort_by(|a, b| a.value.cmp(&b.value));
|
||||
opts.dedup_by(|a, b| a.value == b.value);
|
||||
opts
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum CatalogCategory {
|
||||
Model,
|
||||
Mode,
|
||||
}
|
||||
|
||||
/// Like [`catalog_options`], but when the selected agent's catalog has never
|
||||
/// been discovered, run a one-shot handshake probe to populate it first, so a
|
||||
/// model/mode picker self-fills on first open instead of showing empty until
|
||||
/// the agent has run a live session. The probe records into the shared option
|
||||
/// catalog, so it is effectively one spawn per agent; the cache then suppresses
|
||||
/// repeats.
|
||||
async fn catalog_options_probing(
|
||||
agent: Option<&String>,
|
||||
category: CatalogCategory,
|
||||
) -> Vec<SelectOption> {
|
||||
let first = catalog_options(agent, category);
|
||||
if !first.is_empty() {
|
||||
return first;
|
||||
}
|
||||
let Some(agent) = agent.filter(|a| !a.is_empty()) else {
|
||||
return first;
|
||||
};
|
||||
// Already discovered (this category is just genuinely empty): don't respawn.
|
||||
if crate::acp::option_catalog::load()
|
||||
.agents
|
||||
.contains_key(agent)
|
||||
{
|
||||
return first;
|
||||
}
|
||||
// Only registry agents are blind-probed; a custom agent's command can carry
|
||||
// secrets, so it stays populated only by real runs.
|
||||
if crate::acp::AgentRegistry::with_defaults()
|
||||
.get(agent)
|
||||
.is_none()
|
||||
{
|
||||
return first;
|
||||
}
|
||||
// ponytail: one handshake spawn per undiscovered agent; an agent that
|
||||
// advertises no options at all re-probes on each open (rare, cheap).
|
||||
match crate::acp::capability_probe::probe_agent(agent).await {
|
||||
Ok(true) => catalog_options(Some(agent), category),
|
||||
_ => first,
|
||||
}
|
||||
}
|
||||
|
||||
/// Model or mode choices the given agent last advertised. Empty when no agent
|
||||
/// is selected yet or the agent's catalog has not been discovered; the UI then
|
||||
/// shows an empty/"run the agent first" state, and sessions.create is the
|
||||
/// authoritative validator regardless.
|
||||
fn catalog_options(agent: Option<&String>, category: CatalogCategory) -> Vec<SelectOption> {
|
||||
let Some(agent) = agent.filter(|a| !a.is_empty()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let catalog = crate::acp::option_catalog::load();
|
||||
let Some(entry) = catalog.agents.get(agent) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let want = match category {
|
||||
CatalogCategory::Model => crate::acp::state::ConfigOptionCategory::Model,
|
||||
CatalogCategory::Mode => crate::acp::state::ConfigOptionCategory::Mode,
|
||||
};
|
||||
entry
|
||||
.options
|
||||
.iter()
|
||||
.filter(|opt| opt.category == want)
|
||||
.flat_map(|opt| opt.options.iter())
|
||||
.map(|choice| SelectOption::new(&choice.value, &choice.name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn project_options(profile: &str) -> anyhow::Result<Vec<SelectOption>> {
|
||||
let profile = profile.to_string();
|
||||
let projects =
|
||||
tokio::task::spawn_blocking(move || crate::session::projects::load_merged(&profile))
|
||||
.await??;
|
||||
Ok(projects
|
||||
.into_iter()
|
||||
.map(|p| SelectOption::new(&p.path, &p.name))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn group_options(state: &Arc<AppState>) -> Vec<SelectOption> {
|
||||
let instances = state.instances.read().await;
|
||||
let mut paths: Vec<String> = instances
|
||||
.iter()
|
||||
.filter(|i| !i.group_path.is_empty())
|
||||
.map(|i| i.group_path.clone())
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
paths.iter().map(|p| SelectOption::new(p, p)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::Instance;
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolves_agents_models_and_groups() {
|
||||
let mut a = Instance::new("one", "/tmp/p");
|
||||
a.group_path = "work/backend".to_string();
|
||||
let mut b = Instance::new("two", "/tmp/q");
|
||||
b.group_path = "work/backend".to_string();
|
||||
let state = crate::server::test_support::build_test_app_state(vec![a, b]);
|
||||
|
||||
// Agents: filtered to adapters present on this host, so the exact set
|
||||
// is environment-dependent; just assert the resolver succeeds and only
|
||||
// ever returns known registry ids (no custom agents in this profile).
|
||||
let agents = resolve_option_source(&state, OptionSource::AcpAgents, &[])
|
||||
.await
|
||||
.expect("agents");
|
||||
let registry = crate::acp::AgentRegistry::with_defaults();
|
||||
assert!(agents.iter().all(|o| registry.get(&o.value).is_some()));
|
||||
|
||||
// Models with no selected agent: empty (nothing to resolve yet).
|
||||
let models = resolve_option_source(&state, OptionSource::AcpModels, &[])
|
||||
.await
|
||||
.expect("models");
|
||||
assert!(models.is_empty());
|
||||
// Models for a registry-unknown agent: empty and hermetic. A *known*
|
||||
// undiscovered agent would trigger a live handshake probe (see
|
||||
// `catalog_options_probing`), which is not something a unit test should
|
||||
// spawn, so we assert the empty path via an id the probe declines.
|
||||
let models = resolve_option_source(
|
||||
&state,
|
||||
OptionSource::AcpModels,
|
||||
&["definitely-not-an-agent-xyz".to_string()],
|
||||
)
|
||||
.await
|
||||
.expect("models");
|
||||
assert!(models.is_empty());
|
||||
|
||||
// Groups: derived from live instances, deduped.
|
||||
let groups = resolve_option_source(&state, OptionSource::Groups, &[])
|
||||
.await
|
||||
.expect("groups");
|
||||
assert_eq!(
|
||||
groups.iter().map(|o| o.value.as_str()).collect::<Vec<_>>(),
|
||||
vec!["work/backend"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_filter_keeps_only_present_adapters() {
|
||||
let registry = crate::acp::AgentRegistry::with_defaults();
|
||||
let total = registry.list().len();
|
||||
assert!(total > 0, "registry should have default agents");
|
||||
|
||||
// No adapter present -> empty picker (the uninstalled-harness fix).
|
||||
assert!(installed_agent_options(registry.list(), |_| false).is_empty());
|
||||
|
||||
// All present -> every registry entry, one option each.
|
||||
assert_eq!(
|
||||
installed_agent_options(registry.list(), |_| true).len(),
|
||||
total
|
||||
);
|
||||
|
||||
// A predicate that matches a single command keeps only that agent.
|
||||
let (name, spec) = registry.list().into_iter().next().expect("one agent");
|
||||
let want_cmd = spec.command.clone();
|
||||
let picked = installed_agent_options(registry.list(), |cmd| cmd == want_cmd);
|
||||
assert!(picked.iter().any(|o| &o.value == name));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,7 @@ use super::AppState;
|
||||
use crate::server::auth::AuthenticatedTokenHash;
|
||||
use crate::server::auth::{handler_elevated, AuthenticatedSession, LoopbackTrusted};
|
||||
use crate::session::settings_schema::{
|
||||
clear_path, rewrite_plugin_sections, runtime_schema, strip_local_only, validate_patch,
|
||||
validate_patch_with, PatchRejection, Scope,
|
||||
clear_path, schema, strip_local_only, validate_patch, PatchRejection, Scope,
|
||||
};
|
||||
|
||||
/// Foreground state reported by one browser dashboard. This is intentionally
|
||||
@@ -296,35 +295,13 @@ pub async fn update_settings(
|
||||
// or echoed-back patch keeps its safe leaves and silently drops the
|
||||
// local-only ones (#1692). They can never reach disk from the web.
|
||||
strip_local_only(&mut body);
|
||||
// Validate every remaining leaf against the runtime schema (core plus
|
||||
// active-plugin `plugin:<id>` sections): unknown section/field -> 400, bad
|
||||
// value -> 400. `PATCH /api/settings` is already elevation-gated by the
|
||||
// auth middleware, so any field reaching here is treated as elevated.
|
||||
if let Err(rej) = validate_patch_with(&runtime_schema(), &body, Scope::Global, true) {
|
||||
// Validate every remaining leaf against the schema: unknown section/field
|
||||
// -> 400, bad value -> 400. `PATCH /api/settings` is already
|
||||
// elevation-gated by the auth middleware, so any field reaching here is
|
||||
// treated as elevated.
|
||||
if let Err(rej) = validate_patch(&body, Scope::Global, true) {
|
||||
return reject_response(rej);
|
||||
}
|
||||
// Capture which plugins this patch touches (top-level `plugin:<id>`
|
||||
// sections and their changed field keys) BEFORE the rewrite folds them
|
||||
// into `plugins.<id>.settings.*`, so we can emit `plugin.settings.changed`
|
||||
// after a successful write (#2897).
|
||||
let plugin_changes: Vec<(String, Vec<String>)> = body
|
||||
.as_object()
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter_map(|(section, value)| {
|
||||
let id = crate::session::settings_schema::section_plugin_id(section)?;
|
||||
let keys: Vec<String> = value
|
||||
.as_object()
|
||||
.map(|m| m.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
Some((id.to_string(), keys))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Fold validated `plugin:<id>` sections into their on-disk storage path
|
||||
// (`plugins.<id>.settings.*`) before the generic merge.
|
||||
rewrite_plugin_sections(&mut body);
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
crate::session::update_config(|config| -> anyhow::Result<()> {
|
||||
@@ -350,14 +327,6 @@ pub async fn update_settings(
|
||||
&app_dir,
|
||||
);
|
||||
}
|
||||
// Tell each touched plugin's worker its settings changed (#2897),
|
||||
// after the durable write. Best-effort; config.get is the fallback.
|
||||
#[cfg(feature = "serve")]
|
||||
if !plugin_changes.is_empty() {
|
||||
if let Some(host) = &state.plugin_host {
|
||||
host.emit_settings_changed(&plugin_changes).await;
|
||||
}
|
||||
}
|
||||
match serde_json::to_value(&config) {
|
||||
Ok(val) => (StatusCode::OK, Json(val)).into_response(),
|
||||
Err(e) => {
|
||||
@@ -439,13 +408,12 @@ pub async fn get_cityhall_bundle(
|
||||
/// secrets: descriptors are pure metadata (labels, widgets, validation, write
|
||||
/// policy), so this needs no elevation, only normal authentication.
|
||||
pub async fn get_settings_schema() -> Json<Vec<crate::session::settings_schema::FieldDescriptor>> {
|
||||
Json(runtime_schema())
|
||||
Json(schema())
|
||||
}
|
||||
|
||||
/// `GET /api/settings/resolved` returns every setting's effective value plus
|
||||
/// its provenance chain (user value > highest-priority plugin default > schema
|
||||
/// default for core; stored value > manifest default for plugin settings). The
|
||||
/// dashboard uses it to show where a value comes from. Pure metadata derived
|
||||
/// its provenance chain (user value, else schema default). The dashboard uses
|
||||
/// it to show where a value comes from. Pure metadata derived
|
||||
/// from the same schema the surfaces render, so only normal authentication.
|
||||
pub async fn get_settings_resolved() -> Json<Vec<crate::session::settings_schema::ResolvedSetting>>
|
||||
{
|
||||
@@ -1822,14 +1790,6 @@ pub async fn get_profile_settings(
|
||||
"logging".to_string(),
|
||||
serde_json::to_value(&global.logging)?,
|
||||
);
|
||||
// Plugin settings live in the global config (global-only at Tier 0),
|
||||
// not the profile override. Splice them in so the dashboard's plugin
|
||||
// settings render their persisted values instead of reverting to the
|
||||
// manifest default on every profile-view load (#2094).
|
||||
obj.insert(
|
||||
"plugins".to_string(),
|
||||
serde_json::to_value(&global.plugins)?,
|
||||
);
|
||||
}
|
||||
Ok::<_, anyhow::Error>(val)
|
||||
})
|
||||
|
||||
+1
-2
@@ -523,8 +523,7 @@ fn elevation_verdict(
|
||||
/// login disabled means elevation does not exist as a concept; a
|
||||
/// loopback-trusted caller is elevated per the #1168 carve-out; anyone
|
||||
/// else needs a login session elevated within the step-up window.
|
||||
/// Central so `plugins::mutation_gate` and `update_profile_settings`
|
||||
/// cannot drift apart (#2610).
|
||||
/// Central so every handler-side gate resolves elevation identically (#2610).
|
||||
pub(crate) async fn handler_elevated(
|
||||
state: &AppState,
|
||||
session: Option<&AuthenticatedSession>,
|
||||
|
||||
+5
-146
@@ -318,9 +318,8 @@ pub struct AppState {
|
||||
pub instances: Arc<RwLock<Vec<Instance>>>,
|
||||
/// Session-domain service handle sharing `instances`, `instance_locks`,
|
||||
/// `file_watch`, and the ACP supervisor
|
||||
/// with the fields on this struct, so a non-HTTP caller (the plugin
|
||||
/// host, #2897) can drive session create/turn without holding
|
||||
/// `AppState`.
|
||||
/// with the fields on this struct, so a non-HTTP caller can drive session
|
||||
/// create/turn without holding `AppState`.
|
||||
pub session_service: Arc<session_service::SessionService>,
|
||||
pub token_manager: Arc<TokenManager>,
|
||||
pub login_manager: Arc<login::LoginManager>,
|
||||
@@ -463,14 +462,6 @@ pub struct AppState {
|
||||
#[cfg(feature = "serve")]
|
||||
pub acp_supervisor:
|
||||
Arc<crate::acp::supervisor::Supervisor<crate::acp::supervisor::ChannelSink>>,
|
||||
/// The Tier 1 plugin worker host. `None` in test harnesses that do not
|
||||
/// stand up a host; `Some` in a live daemon.
|
||||
#[cfg(feature = "serve")]
|
||||
pub plugin_host: Option<Arc<crate::plugin::host::PluginHost>>,
|
||||
/// Tracks in-flight web plugin install / update / uninstall jobs so the
|
||||
/// dashboard can tail their host-side log. In-memory; see
|
||||
/// `api::plugins::PluginJobRegistry`.
|
||||
pub plugin_jobs: Arc<crate::server::api::plugins::PluginJobRegistry>,
|
||||
/// Per-browser foreground dashboard presence. Entries are keyed by a hash
|
||||
/// of the device-binding secret and expire when the browser stops sending
|
||||
/// its visibility heartbeat. Push suppression must not treat ordinary
|
||||
@@ -859,11 +850,6 @@ pub async fn start_server(config: ServerConfig<'_>) -> anyhow::Result<()> {
|
||||
supervisor.hydrate_seqs(acp_event_store.all_session_seqs());
|
||||
supervisor
|
||||
};
|
||||
// The Tier 1 plugin worker host. Opening it (the plugin event-bus database,
|
||||
// the worker log dir) is cheap and side-effect-free until workers launch,
|
||||
// which happens after the daemon is up. A failure here is logged, not fatal:
|
||||
// The session-domain service is built before the plugin host so the
|
||||
// host's session RPCs (#2897) get it by construction, never late-bound.
|
||||
let instances = Arc::new(RwLock::new(instances));
|
||||
let instance_locks = Arc::new(RwLock::new(std::collections::HashMap::new()));
|
||||
let idempotency_locks = Arc::new(RwLock::new(std::collections::HashMap::new()));
|
||||
@@ -882,48 +868,6 @@ pub async fn start_server(config: ServerConfig<'_>) -> anyhow::Result<()> {
|
||||
Arc::clone(&file_watch),
|
||||
));
|
||||
|
||||
// the daemon serves fine without plugin workers.
|
||||
// The host API includes mutating session.meta.set/cas, so a read-only
|
||||
// daemon must not run plugin workers at all: gate the host on !read_only.
|
||||
#[cfg(feature = "serve")]
|
||||
let plugin_host = if read_only {
|
||||
tracing::info!(target: "plugin.host", "plugin host disabled in read-only serve mode");
|
||||
None
|
||||
} else {
|
||||
match crate::session::get_app_dir() {
|
||||
Ok(app_dir) => {
|
||||
// Session RPCs need the automation-policy ledger; if it cannot
|
||||
// open, workers still run but session RPCs answer
|
||||
// service_unavailable (fail closed on limits, not open).
|
||||
let session_rpc = match crate::plugin::automation_policy::AutomationPolicy::open(
|
||||
&app_dir.join("plugin_events.db"),
|
||||
) {
|
||||
Ok(policy) => Some(Arc::new(crate::plugin::session_api::SessionRpcDeps {
|
||||
policy: Arc::new(policy),
|
||||
})),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "plugin.host",
|
||||
"plugin session RPCs disabled: automation policy store failed: {e:#}"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
match crate::plugin::host::PluginHost::new(&app_dir, profile, session_rpc) {
|
||||
Ok(host) => Some(host),
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "plugin.host", "plugin host disabled: {e:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "plugin.host", "plugin host disabled: {e:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve the coarse auth mode once at launch; `/api/about` reads this
|
||||
// single value.
|
||||
let auth_mode = resolve_auth_mode(&token_manager, &login_manager).await;
|
||||
@@ -1177,8 +1121,6 @@ pub async fn start_server(config: ServerConfig<'_>) -> anyhow::Result<()> {
|
||||
#[cfg(feature = "serve")]
|
||||
acp_supervisor: acp_supervisor.clone(),
|
||||
#[cfg(feature = "serve")]
|
||||
plugin_host: plugin_host.clone(),
|
||||
plugin_jobs: Arc::new(api::plugins::PluginJobRegistry::new()),
|
||||
push: push_state,
|
||||
push_enabled,
|
||||
web_config: config.web.clone(),
|
||||
@@ -1388,28 +1330,6 @@ pub async fn start_server(config: ServerConfig<'_>) -> anyhow::Result<()> {
|
||||
// callback_url on a fire-worthy transition. See #3156.
|
||||
callback::spawn_consumer(state.clone());
|
||||
|
||||
// Launch plugin workers for every active plugin that declares a runtime.
|
||||
// Non-blocking: each worker runs in its own supervised task. A daemon with
|
||||
// no community plugin workers (the common case) does nothing here.
|
||||
#[cfg(feature = "serve")]
|
||||
if let Some(host) = state.plugin_host.clone() {
|
||||
host.start(&crate::plugin::registry()).await;
|
||||
}
|
||||
|
||||
// Opt-in clean-only plugin auto-update sweep (off by default). Spawned
|
||||
// non-blocking so daemon startup never waits on git/network; freshly applied
|
||||
// updates are picked up on the next daemon restart. The plugin host (when
|
||||
// running) is passed as the notifier so a consent-needed skip surfaces as a
|
||||
// dashboard notification, not just a log line.
|
||||
let update_notifier = state
|
||||
.plugin_host
|
||||
.clone()
|
||||
.map(|h| h as std::sync::Arc<dyn crate::plugin::auto_update::UpdateNotifier>);
|
||||
crate::plugin::auto_update::spawn_if_enabled(
|
||||
&crate::session::Config::load_or_warn(),
|
||||
update_notifier,
|
||||
);
|
||||
|
||||
rate_limiter.spawn_cleanup_task(state.shutdown.clone());
|
||||
login_manager.spawn_cleanup_task(state.shutdown.clone());
|
||||
|
||||
@@ -1560,12 +1480,6 @@ pub async fn start_server(config: ServerConfig<'_>) -> anyhow::Result<()> {
|
||||
tracing::info!(target: "serve.shutdown", "received ctrl-c, shutting down");
|
||||
}
|
||||
shutdown_state.shutdown.cancel();
|
||||
// Reap plugin workers before the force-exit deadline so no worker tree
|
||||
// is left behind when the daemon stops.
|
||||
#[cfg(feature = "serve")]
|
||||
if let Some(host) = shutdown_state.plugin_host.clone() {
|
||||
host.shutdown().await;
|
||||
}
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(SHUTDOWN_GRACE).await;
|
||||
tracing::warn!(
|
||||
@@ -1766,47 +1680,6 @@ fn build_router(state: Arc<AppState>) -> Router {
|
||||
.route("/api/tips", get(api::get_tips))
|
||||
.route("/api/tips/show", post(api::set_show_tips))
|
||||
.route("/api/app-state/tip-seen", post(api::mark_tip_seen))
|
||||
// Plugin management. The enable/disable toggle gates on read-only +
|
||||
// elevation inside the handler.
|
||||
.route("/api/plugins", get(api::list_plugins))
|
||||
.route("/api/plugins/{id}/icon", get(api::serve_plugin_icon))
|
||||
.route("/api/plugins/commands", get(api::plugin_commands))
|
||||
.route(
|
||||
"/api/plugins/commands/{fqid}/invoke",
|
||||
post(api::invoke_plugin_command),
|
||||
)
|
||||
.route("/api/plugins/ui-state", get(api::plugin_ui_state))
|
||||
.route("/api/plugins/updates", get(api::plugin_updates))
|
||||
.route("/api/plugins/discover", get(api::plugin_discover))
|
||||
.route("/api/plugins/details", get(api::plugin_details))
|
||||
.route("/api/plugins/{id}/enabled", post(api::set_plugin_enabled))
|
||||
.route("/api/plugins/{id}/action", post(api::invoke_plugin_action))
|
||||
.route(
|
||||
"/api/plugins/{id}/settings/options/resolve",
|
||||
post(api::resolve_options),
|
||||
)
|
||||
.route(
|
||||
"/api/plugins/install/preview",
|
||||
post(api::preview_plugin_install),
|
||||
)
|
||||
.route("/api/plugins/install", post(api::start_plugin_install))
|
||||
.route(
|
||||
"/api/plugins/{id}/uninstall",
|
||||
post(api::start_plugin_uninstall),
|
||||
)
|
||||
.route("/api/plugins/jobs/{job_id}", get(api::plugin_job_status))
|
||||
.route(
|
||||
"/api/plugins/{id}/update/preview",
|
||||
get(api::plugin_update_preview),
|
||||
)
|
||||
.route(
|
||||
"/api/plugins/{id}/update/apply",
|
||||
post(api::apply_plugin_update),
|
||||
)
|
||||
.route(
|
||||
"/api/plugins/{id}/update/dismiss",
|
||||
post(api::dismiss_plugin_update),
|
||||
)
|
||||
.route(
|
||||
"/api/app-state/web-tour-seen",
|
||||
post(api::mark_web_tour_seen),
|
||||
@@ -2454,7 +2327,6 @@ const CITYHALL_MUTATION_ALLOW: &[(&str, &str)] = &[
|
||||
// Curated settings surfaces (the handlers field-filter / strip color-mode).
|
||||
("PATCH", "/api/profiles/{name}/settings"),
|
||||
("PATCH", "/api/theme"),
|
||||
("POST", "/api/plugins/{id}/settings/options/resolve"),
|
||||
// Ephemeral foreground-presence heartbeat. Does not mutate user data.
|
||||
("POST", "/api/presence"),
|
||||
// Per-device UI preferences / client log.
|
||||
@@ -2518,15 +2390,6 @@ const CITYHALL_MUTATION_DENY: &[(&str, &str)] = &[
|
||||
("PUT", "/api/skills/{directory}"),
|
||||
("DELETE", "/api/skills/{directory}"),
|
||||
("POST", "/api/skills/{source}/{directory}/adopt"),
|
||||
// Plugin lifecycle.
|
||||
("POST", "/api/plugins/install"),
|
||||
("POST", "/api/plugins/install/preview"),
|
||||
("POST", "/api/plugins/{id}/action"),
|
||||
("POST", "/api/plugins/{id}/enabled"),
|
||||
("POST", "/api/plugins/{id}/uninstall"),
|
||||
("POST", "/api/plugins/{id}/update/apply"),
|
||||
("POST", "/api/plugins/{id}/update/dismiss"),
|
||||
("POST", "/api/plugins/commands/{fqid}/invoke"),
|
||||
// ACP agent / worker lifecycle + config.
|
||||
("DELETE", "/api/sessions/{id}/acp"),
|
||||
("POST", "/api/sessions/{id}/acp/config-option"),
|
||||
@@ -2600,12 +2463,10 @@ async fn cityhall_gate(
|
||||
/// runtime (terminal font-size updates) and Tailwind v4 emits inline
|
||||
/// `<style>` blocks in dev. Blocking inline styles breaks xterm.js's
|
||||
/// rendered viewport.
|
||||
/// - `img-src 'self' data: https://github.com https://avatars.githubusercontent.com https://raw.githubusercontent.com`:
|
||||
/// - `img-src 'self' data: https://github.com https://avatars.githubusercontent.com`:
|
||||
/// repo-owner avatars are loaded from `github.com/{user}.png` which 302s
|
||||
/// to `avatars.githubusercontent.com`; CSP checks both URLs across the
|
||||
/// redirect, so both hosts must be allowed. `data:` covers inline icons.
|
||||
/// `raw.githubusercontent.com` serves plugin screenshots resolved by the
|
||||
/// plugin detail endpoint (#2484).
|
||||
/// - `font-src 'self'`: Geist fonts are bundled under /fonts/.
|
||||
/// - `connect-src 'self' ws: wss:`: REST + PTY WebSocket to same origin.
|
||||
/// - `frame-ancestors 'none'`: CSP-native equivalent of X-Frame-Options.
|
||||
@@ -2614,7 +2475,7 @@ async fn cityhall_gate(
|
||||
const CSP: &str = "default-src 'self'; \
|
||||
script-src 'self' 'wasm-unsafe-eval'; \
|
||||
style-src 'self' 'unsafe-inline'; \
|
||||
img-src 'self' data: https://github.com https://avatars.githubusercontent.com https://raw.githubusercontent.com; \
|
||||
img-src 'self' data: https://github.com https://avatars.githubusercontent.com; \
|
||||
font-src 'self'; \
|
||||
connect-src 'self' ws: wss:; \
|
||||
frame-ancestors 'none'; \
|
||||
@@ -5764,8 +5625,6 @@ pub mod test_support {
|
||||
acp_event_store: event_store,
|
||||
acp_control_cache,
|
||||
acp_supervisor: supervisor,
|
||||
plugin_host: None,
|
||||
plugin_jobs: Arc::new(api::plugins::PluginJobRegistry::new()),
|
||||
push: None,
|
||||
push_enabled: false,
|
||||
web_config: crate::session::config::WebConfig::default(),
|
||||
@@ -9083,7 +8942,7 @@ mod tests {
|
||||
for needle in [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'wasm-unsafe-eval'",
|
||||
"img-src 'self' data: https://github.com https://avatars.githubusercontent.com https://raw.githubusercontent.com",
|
||||
"img-src 'self' data: https://github.com https://avatars.githubusercontent.com",
|
||||
"connect-src 'self' ws: wss:",
|
||||
"frame-ancestors 'none'",
|
||||
] {
|
||||
|
||||
@@ -420,7 +420,7 @@ impl SessionService {
|
||||
/// Queue `text` (with its `attachments` refs) as the session's next turn,
|
||||
/// reusing the pending-initial-turn drain so the turn is delivered once the
|
||||
/// (resumed) worker is live. No-op when a turn is already queued (never
|
||||
/// clobber a create/plugin turn) or the session is gone. Persists so a
|
||||
/// clobber a create turn) or the session is gone. Persists so a
|
||||
/// daemon restart mid-resume still re-delivers. Used to continue a
|
||||
/// rate-limit-interrupted turn on resume (#3028).
|
||||
#[cfg(feature = "serve")]
|
||||
|
||||
@@ -188,8 +188,8 @@ pub fn export() -> Result<CityHallBundle> {
|
||||
let mut settings = empty_object();
|
||||
apply_changed_leaves(&mut settings, &baseline, ¤t);
|
||||
// `Config` has sections with no settings descriptors (`hooks`, `agents`,
|
||||
// `plugins`, ...). `validate_patch` rejects those outright, so drop them
|
||||
// here rather than shipping a document that cannot be applied.
|
||||
// ...). `validate_patch` rejects those outright, so drop them here rather
|
||||
// than shipping a document that cannot be applied.
|
||||
retain_schema_fields(&mut settings);
|
||||
// Host-specific paths (node binaries, socket paths) are meaningless in a
|
||||
// container, which is exactly what `local_only` marks.
|
||||
|
||||
+7
-174
@@ -10,6 +10,12 @@ use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// Unknown keys are dropped rather than rejected: there is no
|
||||
/// `deny_unknown_fields`, so serde ignores them on load and the re-serialize in
|
||||
/// [`update_config`] does not write them back. This is the one limit on that
|
||||
/// function's "unrelated edits survive" contract: it holds for fields this
|
||||
/// binary knows, so a key written by a newer `aoe` does not survive an older
|
||||
/// `aoe`'s save.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default = "default_profile")]
|
||||
@@ -91,20 +97,6 @@ pub struct Config {
|
||||
/// palette (Ctrl+K).
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub tools: HashMap<String, ToolSessionConfig>,
|
||||
|
||||
/// Per-plugin configuration keyed by plugin id (`[plugins."aoe.web"]`).
|
||||
/// An explicit typed map rather than a root-level flatten, so plugin
|
||||
/// enable-state survives every save without a root catch-all quietly
|
||||
/// absorbing mistyped core keys.
|
||||
///
|
||||
/// Unknown core keys are dropped rather than rejected: there is no
|
||||
/// `deny_unknown_fields`, so serde ignores them on load and the
|
||||
/// re-serialize in [`update_config`] does not write them back. This is
|
||||
/// the one limit on that function's "unrelated edits survive" contract:
|
||||
/// it holds for fields this binary knows, so a key written by a newer
|
||||
/// `aoe` does not survive an older `aoe`'s save.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub plugins: std::collections::BTreeMap<String, PluginConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -146,80 +138,10 @@ pub struct StatusRule {
|
||||
pub regex: Option<String>,
|
||||
}
|
||||
|
||||
/// Configuration for one plugin: whether it is enabled, its install source and
|
||||
/// capability grant (external plugins only), plus its schema-free persisted
|
||||
/// settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginConfig {
|
||||
/// Whether the plugin is active. A disabled plugin contributes nothing to
|
||||
/// any surface.
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Install source for an external plugin: a `gh:owner/repo[@ref]` slug or a
|
||||
/// local path. Absent for builtins, which are compiled in.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
|
||||
/// The plugin's persisted settings (`[plugins."<id>".settings]`). Kept as
|
||||
/// an opaque `toml::Table` so values survive on disk even while the plugin
|
||||
/// is disabled; the typed schema that validates and renders them lands
|
||||
/// with Tier 0 registries (#2094). The toml serializer emits scalars before
|
||||
/// subtables regardless, so field order here is for readability. Empty is
|
||||
/// omitted.
|
||||
#[serde(default, skip_serializing_if = "toml::Table::is_empty")]
|
||||
pub settings: toml::Table,
|
||||
|
||||
/// The capability grant the user approved for an external plugin, pinned to
|
||||
/// the manifest hash it was approved against. Absent until granted;
|
||||
/// builtins are auto-granted and never store one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub grant: Option<CapabilityGrant>,
|
||||
|
||||
/// An available update the user declined in-app, recorded by its content
|
||||
/// fingerprint so the popup and auto-update notification stop nagging until
|
||||
/// the next version. Cleared on any successful apply or uninstall. Absent
|
||||
/// when no update has been dismissed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dismissed_update: Option<String>,
|
||||
}
|
||||
|
||||
/// A user's approval of an external plugin's requested capabilities, pinned to
|
||||
/// the exact manifest it was approved against. When the installed manifest hash
|
||||
/// later differs (an update that changed the capability set), the grant no
|
||||
/// longer applies and the plugin's runtime contributions stay inactive until
|
||||
/// re-approved.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CapabilityGrant {
|
||||
/// `sha256:<hex>` of the manifest bytes this grant was approved against.
|
||||
pub manifest_hash: String,
|
||||
/// The capabilities the user approved.
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<String>,
|
||||
/// When the user approved the grant.
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
impl Default for PluginConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_enabled(),
|
||||
source: None,
|
||||
settings: toml::Table::new(),
|
||||
grant: None,
|
||||
dismissed_update: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for a user-defined tool session (lazygit, yazi, tig, etc.)
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ToolSessionConfig {
|
||||
@@ -1931,17 +1853,6 @@ pub struct UpdatesConfig {
|
||||
options = "auto:auto,notify:notify,off:off"
|
||||
)]
|
||||
pub update_check_mode: UpdateCheckMode,
|
||||
|
||||
/// Auto-update installed external plugins at TUI and `aoe serve` startup.
|
||||
/// Off by default. The sweep applies only updates that need no new consent;
|
||||
/// any version that changes capabilities, build steps, or UI slots is left
|
||||
/// for a manual `aoe plugin update` so its new grant is reviewed.
|
||||
// global_only: the startup sweep reads the global config
|
||||
// (`Config::load_or_warn`), so a profile/repo override would be silently
|
||||
// ignored; show it but do not offer non-global scopes.
|
||||
#[serde(default)]
|
||||
#[setting(label = "Auto-update plugins", widget = "toggle", global_only)]
|
||||
pub auto_update_plugins: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -2926,7 +2837,7 @@ impl Config {
|
||||
/// dropped. Only called on the Ok path: `Config::load` errored out already
|
||||
/// carries the load-error text, and the two failure classes are per-file
|
||||
/// mutually exclusive (no `deny_unknown_fields`, so an unknown key never
|
||||
/// fails the load). Map-keyed sections (`agents`, `tools`, `plugins`,
|
||||
/// fails the load). Map-keyed sections (`agents`, `tools`,
|
||||
/// `session.custom_agents`, `acp.acp_defaults`, ...) never flag because
|
||||
/// their keys are entries, not struct fields; nested struct-field typos
|
||||
/// inside them still do.
|
||||
@@ -3659,83 +3570,6 @@ mod tests {
|
||||
assert!(!config.worktree.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugins_table_round_trips_through_save() {
|
||||
// A plugin's enable-state must survive serialize/deserialize.
|
||||
let toml_in = r#"
|
||||
[plugins."aoe.web"]
|
||||
enabled = false
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml_in).unwrap();
|
||||
assert!(!config.plugins["aoe.web"].enabled);
|
||||
|
||||
let serialized = toml::to_string(&config).unwrap();
|
||||
let reloaded: Config = toml::from_str(&serialized).unwrap();
|
||||
assert!(!reloaded.plugins["aoe.web"].enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugins_default_empty_and_omitted_from_toml() {
|
||||
let config: Config = toml::from_str("").unwrap();
|
||||
assert!(config.plugins.is_empty());
|
||||
let serialized = toml::to_string(&config).unwrap();
|
||||
assert!(
|
||||
!serialized.contains("[plugins"),
|
||||
"empty plugins map must not serialize a stray section"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_settings_persist_even_while_disabled() {
|
||||
// Disabling a plugin hides its settings from every surface but must
|
||||
// never destroy them: the values survive a save/load round-trip.
|
||||
let toml_in = r#"
|
||||
[plugins."aoe.status"]
|
||||
enabled = false
|
||||
|
||||
[plugins."aoe.status".settings]
|
||||
poll_interval_ms = 1000
|
||||
verbose = true
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml_in).unwrap();
|
||||
let plugin = &config.plugins["aoe.status"];
|
||||
assert!(!plugin.enabled);
|
||||
assert_eq!(plugin.settings["poll_interval_ms"].as_integer(), Some(1000));
|
||||
|
||||
let serialized = toml::to_string(&config).unwrap();
|
||||
let reloaded: Config = toml::from_str(&serialized).unwrap();
|
||||
assert_eq!(
|
||||
reloaded.plugins["aoe.status"].settings["verbose"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_empty_settings_omitted_from_toml() {
|
||||
let toml_in = r#"
|
||||
[plugins."aoe.web"]
|
||||
enabled = true
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml_in).unwrap();
|
||||
assert!(config.plugins["aoe.web"].settings.is_empty());
|
||||
let serialized = toml::to_string(&config).unwrap();
|
||||
assert!(
|
||||
!serialized.contains("settings"),
|
||||
"empty plugin settings must not serialize a stray section"
|
||||
);
|
||||
}
|
||||
|
||||
// Tests for ThemeConfig
|
||||
#[test]
|
||||
fn test_theme_config_default() {
|
||||
let theme = ThemeConfig::default();
|
||||
assert_eq!(theme.name, "");
|
||||
// Freshness signal is off by default; users opt in by setting a
|
||||
// positive value via Settings -> Theme -> Idle Decay (minutes)
|
||||
// or in config.toml directly.
|
||||
assert_eq!(theme.idle_decay_minutes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_config_deserialize() {
|
||||
let toml = r#"name = "dark""#;
|
||||
@@ -4061,7 +3895,6 @@ mod tests {
|
||||
},
|
||||
updates: UpdatesConfig {
|
||||
update_check_mode: UpdateCheckMode::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
+3
-45
@@ -799,15 +799,6 @@ pub struct Instance {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub lifecycle_reservation: Option<LifecycleReservation>,
|
||||
|
||||
/// Namespaced per-session plugin data, keyed by plugin id. Each plugin
|
||||
/// owns only its own slot (`plugin_meta["<id>"]`), an opaque JSON value it
|
||||
/// reads and writes through the host API that lands with the Tier 1 host
|
||||
/// (#2095). Data for an uninstalled plugin is retained, since it is cheap
|
||||
/// and reinstalling restores the session's state. Additive: absent in
|
||||
/// older `sessions.json` rows, so no migration is needed.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub plugin_meta: std::collections::BTreeMap<String, serde_json::Value>,
|
||||
|
||||
/// An initial prompt persisted with the session at create time and not
|
||||
/// yet delivered to the agent (#2897). Written in the same
|
||||
/// `Storage::update` that creates the row, so the create request and its
|
||||
@@ -855,10 +846,9 @@ pub struct Instance {
|
||||
/// applied via `session/set_mode` after every worker (re)spawn, taking
|
||||
/// precedence over the legacy `yolo_mode` bool (which stays authoritative
|
||||
/// for sessions without an explicit mode; unification is a follow-up).
|
||||
/// Set by the plugin host session-create path after the host classified
|
||||
/// the mode; also re-asserted before each plugin-delivered turn so a
|
||||
/// mode-application failure blocks unattended prompt delivery. Additive:
|
||||
/// absent in older rows, no migration.
|
||||
/// Re-asserted before each delivered turn so a mode-application failure
|
||||
/// blocks unattended prompt delivery. Additive: absent in older rows, no
|
||||
/// migration.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub acp_mode_id: Option<String>,
|
||||
|
||||
@@ -1564,7 +1554,6 @@ impl Instance {
|
||||
trashed_at: None,
|
||||
pre_trash_project_path: None,
|
||||
lifecycle_reservation: None,
|
||||
plugin_meta: std::collections::BTreeMap::new(),
|
||||
pending_initial_turn: None,
|
||||
#[cfg(feature = "serve")]
|
||||
pending_initial_turn_attachments: Vec::new(),
|
||||
@@ -8366,37 +8355,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_meta_serde_round_trip() {
|
||||
// Empty map is omitted from disk.
|
||||
let inst = Instance::new("t", "/tmp");
|
||||
let json = serde_json::to_value(&inst).unwrap();
|
||||
assert!(
|
||||
json.get("plugin_meta").is_none(),
|
||||
"empty plugin_meta must skip serialization"
|
||||
);
|
||||
|
||||
// A plugin's namespaced slot round-trips.
|
||||
let mut set = Instance::new("t", "/tmp");
|
||||
set.plugin_meta
|
||||
.insert("aoe.status".to_string(), serde_json::json!({ "score": 3 }));
|
||||
let json = serde_json::to_value(&set).unwrap();
|
||||
let back: Instance = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back.plugin_meta["aoe.status"]["score"], 3);
|
||||
|
||||
// Rows written before the field existed deserialize to an empty map.
|
||||
let inst: Instance = serde_json::from_value(serde_json::json!({
|
||||
"id": "abc",
|
||||
"title": "t",
|
||||
"project_path": "/tmp",
|
||||
"tool": "claude",
|
||||
"status": "idle",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
}))
|
||||
.expect("deserialize without plugin_meta");
|
||||
assert!(inst.plugin_meta.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_user_action_diff_propagates_unread() {
|
||||
let pre = Instance::new("t", "/tmp");
|
||||
|
||||
+7
-9
@@ -51,9 +51,9 @@ pub use crate::status_hooks::StatusHookConfig;
|
||||
pub(crate) use capture::is_valid_session_id;
|
||||
pub use config::{
|
||||
get_update_settings, load_config, update_app_state, update_config, AgentRuntimeConfig,
|
||||
AttachMode, CapabilityGrant, ClickAction, Config, ContainerRuntimeName, DefaultTerminalMode,
|
||||
GroupByMode, PluginConfig, RowTagMode, SandboxConfig, SessionConfig, ThemeConfig,
|
||||
TmuxSettingMode, UpdatesConfig, VolumeIgnoresStrategy, WorktreeConfig,
|
||||
AttachMode, ClickAction, Config, ContainerRuntimeName, DefaultTerminalMode, GroupByMode,
|
||||
RowTagMode, SandboxConfig, SessionConfig, ThemeConfig, TmuxSettingMode, UpdatesConfig,
|
||||
VolumeIgnoresStrategy, WorktreeConfig,
|
||||
};
|
||||
pub(crate) use environment::user_shell;
|
||||
pub use environment::{validate_env_entries, validate_env_entry};
|
||||
@@ -1190,9 +1190,9 @@ mod tests {
|
||||
let dir = app_dir(&temp);
|
||||
// `session.custom_agents` is a `HashMap<String,String>` (name -> shell
|
||||
// command), so it must be an inline table, not a section. The other
|
||||
// three are the real map-key-plus-struct shapes (`agents.<name>`,
|
||||
// `tools.<name>`, `plugins.<id>`) that exercise the "user-defined
|
||||
// section with typed contents" pattern.
|
||||
// two are the real map-key-plus-struct shapes (`agents.<name>`,
|
||||
// `tools.<name>`) that exercise the "user-defined section with typed
|
||||
// contents" pattern.
|
||||
fs::write(
|
||||
dir.join("config.toml"),
|
||||
"[session]\n\
|
||||
@@ -1200,9 +1200,7 @@ mod tests {
|
||||
[agents.claude.status_map]\n\
|
||||
SessionStart = \"running\"\n\
|
||||
[tools.lazygit]\n\
|
||||
command = \"lazygit\"\n\
|
||||
[plugins.\"aoe.web\"]\n\
|
||||
enabled = true\n",
|
||||
command = \"lazygit\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ pub(super) fn validate_overrides_typecheck(overrides: &serde_json::Value) -> Res
|
||||
/// against the profile's overrides merged onto a default `Config`; a raw
|
||||
/// `serde_ignored::deserialize::<ProfileConfig>` would report nothing, since
|
||||
/// `#[serde(flatten)] overrides` absorbs every unknown key as valid JSON.
|
||||
/// Map-keyed sections (`agents`, `tools`, `plugins`, `session.custom_agents`,
|
||||
/// Map-keyed sections (`agents`, `tools`, `session.custom_agents`,
|
||||
/// `acp.acp_defaults`, ...) never flag because their keys are entries, not
|
||||
/// struct fields; nested struct-field typos inside them still do. Takes the
|
||||
/// already-loaded `ProfileConfig` so the caller does not read+parse the
|
||||
@@ -383,9 +383,6 @@ mod tests {
|
||||
let merged = merge_configs(global, &profile);
|
||||
|
||||
assert_eq!(merged.updates.update_check_mode, UpdateCheckMode::Off);
|
||||
// auto_update_plugins should retain the global default since it is
|
||||
// not overridden.
|
||||
assert!(!merged.updates.auto_update_plugins);
|
||||
assert!(merged.worktree.enabled);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,20 +19,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod merge;
|
||||
mod plugin;
|
||||
mod policy;
|
||||
mod registry;
|
||||
mod resolved;
|
||||
mod validate;
|
||||
|
||||
pub use merge::{apply_changed_leaves, clear_path, merge_json};
|
||||
pub use plugin::{
|
||||
plugin_field_descriptors, plugin_section_id, rewrite_plugin_sections, section_plugin_id,
|
||||
storage_leaf as plugin_storage_leaf, storage_value as plugin_storage_value, PLUGIN_CATEGORY,
|
||||
PLUGIN_SECTION_PREFIX,
|
||||
};
|
||||
pub use policy::{strip_local_only, validate_patch, validate_patch_with, PatchRejection, Scope};
|
||||
pub use registry::{descriptor, runtime_schema, schema};
|
||||
pub use policy::{strip_local_only, validate_patch, PatchRejection, Scope};
|
||||
pub use registry::{descriptor, schema};
|
||||
pub use resolved::{resolve, resolve_all, Candidate, ResolvedSetting, SettingSource};
|
||||
pub use validate::{validate_value, ValidationError};
|
||||
|
||||
@@ -70,27 +64,6 @@ pub enum WidgetKind {
|
||||
Select { options: Vec<SelectOption> },
|
||||
/// List of strings (volumes, env entries, ...).
|
||||
List,
|
||||
/// A select whose options the host resolves at render time from an
|
||||
/// [`OptionSource`] (API v9, #2897), optionally parameterized by sibling
|
||||
/// fields named in `depends_on`. The plugin never ships the choices.
|
||||
DynamicSelect {
|
||||
source: OptionSource,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
depends_on: Vec<String>,
|
||||
},
|
||||
/// A repeatable list of structured items (API v9, #2897). Each item is a
|
||||
/// JSON object keyed by the nested field names, carrying a stable id under
|
||||
/// `id_field`. One level deep: `fields` cannot themselves be object lists.
|
||||
ObjectList {
|
||||
id_field: String,
|
||||
fields: Vec<ObjectFieldDescriptor>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
min_items: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_items: Option<u32>,
|
||||
},
|
||||
/// A cron expression, rendered as a validated text field (API v9, #2897).
|
||||
Cron,
|
||||
/// Escape hatch: a bespoke widget keyed by `id`. The web and TUI keep a
|
||||
/// registry mapping the id to a hand-written component (e.g. the logging
|
||||
/// per-target matrix). The field stays in the schema so it is never
|
||||
@@ -98,87 +71,6 @@ pub enum WidgetKind {
|
||||
Custom { id: String },
|
||||
}
|
||||
|
||||
/// A host option source a [`WidgetKind::DynamicSelect`] draws its choices
|
||||
/// from (#2897). Mirrors `aoe_plugin_api::OptionSource`; the host resolver
|
||||
/// maps each variant to the corresponding daemon state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OptionSource {
|
||||
AcpAgents,
|
||||
AcpModels,
|
||||
AcpModes,
|
||||
Projects,
|
||||
Groups,
|
||||
}
|
||||
|
||||
impl From<aoe_plugin_api::OptionSource> for OptionSource {
|
||||
fn from(s: aoe_plugin_api::OptionSource) -> Self {
|
||||
use aoe_plugin_api::OptionSource as A;
|
||||
match s {
|
||||
A::AcpAgents => Self::AcpAgents,
|
||||
A::AcpModels => Self::AcpModels,
|
||||
A::AcpModes => Self::AcpModes,
|
||||
A::Projects => Self::Projects,
|
||||
A::Groups => Self::Groups,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One nested field of a [`WidgetKind::ObjectList`] item (#2897). A restricted
|
||||
/// mirror of [`FieldDescriptor`] whose widget cannot be another object list,
|
||||
/// so the schema is non-recursive.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ObjectFieldDescriptor {
|
||||
pub field: String,
|
||||
pub label: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub description: String,
|
||||
/// Whether the item must carry a non-empty value for this field.
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
pub widget: ObjectFieldWidget,
|
||||
pub validation: ValidationKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// The widget for an object-list item field. Deliberately a subset of
|
||||
/// [`WidgetKind`] with no object-list variant, enforcing the one-level bound
|
||||
/// in both the Rust type and the serialized schema.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ObjectFieldWidget {
|
||||
Toggle,
|
||||
Text {
|
||||
#[serde(default)]
|
||||
multiline: bool,
|
||||
#[serde(default)]
|
||||
mono: bool,
|
||||
},
|
||||
Number {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
min: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max: Option<i64>,
|
||||
},
|
||||
Select {
|
||||
options: Vec<SelectOption>,
|
||||
},
|
||||
DynamicSelect {
|
||||
source: OptionSource,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
depends_on: Vec<String>,
|
||||
},
|
||||
/// A host-resolved multi-select; the stored value is an array of chosen
|
||||
/// option values (API v11). Choices resolve like `DynamicSelect`.
|
||||
DynamicMultiSelect {
|
||||
source: OptionSource,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
depends_on: Vec<String>,
|
||||
},
|
||||
Cron,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SelectOption {
|
||||
pub value: String,
|
||||
@@ -224,28 +116,6 @@ pub enum ValidationKind {
|
||||
},
|
||||
/// Non-empty after trimming.
|
||||
NonEmptyString,
|
||||
/// Value must be a JSON string (any content, empty allowed). Used for
|
||||
/// host-resolved optional `dynamic_select` values (revalidated at
|
||||
/// `sessions.create`): enforces the type without constraining content, so a
|
||||
/// number or object cannot be smuggled in (API v9, #2897).
|
||||
#[serde(rename = "str")]
|
||||
StringValue,
|
||||
/// Value must be a JSON array whose entries are all strings (any content,
|
||||
/// empty allowed). Used for host-resolved `dynamic_multi_select` values
|
||||
/// (revalidated at `sessions.create`): enforces the array-of-strings type
|
||||
/// without constraining membership (API v11).
|
||||
#[serde(rename = "str_list")]
|
||||
StringListValue,
|
||||
/// Value must be a JSON boolean (API v9, #2897).
|
||||
#[serde(rename = "bool")]
|
||||
BoolValue,
|
||||
/// Signed inclusive integer range; either bound optional for single-sided
|
||||
/// ranges. Used for `object_list` integer fields whose declared bounds go
|
||||
/// negative, which `RangeU64` cannot express (API v9, #2897).
|
||||
RangeI64 {
|
||||
min: Option<i64>,
|
||||
max: Option<i64>,
|
||||
},
|
||||
/// Docker memory-limit grammar (`512m`, `2g`, ...). Empty allowed.
|
||||
MemoryLimit,
|
||||
/// Each list entry must be `host:container[:options]`.
|
||||
@@ -259,26 +129,6 @@ pub enum ValidationKind {
|
||||
/// (`[a-zA-Z0-9][a-zA-Z0-9_.-]*`). `host` and other namespace-sharing
|
||||
/// forms are rejected because they defeat sandbox isolation.
|
||||
Network,
|
||||
/// Value must be one of a closed set of strings. Used by plugin `select`
|
||||
/// settings so an off-menu value cannot be persisted (core selects encode
|
||||
/// their options in the widget and need no separate rule).
|
||||
OneOf {
|
||||
options: Vec<String>,
|
||||
},
|
||||
/// A 5-field cron expression (API v9, #2897). Empty is rejected; the
|
||||
/// grammar matches the plugin scheduler's `croner` dialect.
|
||||
Cron,
|
||||
/// A repeatable list of structured items (API v9, #2897). Validated
|
||||
/// recursively: item count bounds, a unique non-empty id per item under
|
||||
/// `id_field`, only declared fields, required fields present, and each
|
||||
/// field against its own descriptor. Carries the item schema so the
|
||||
/// server validates without re-deriving it from the manifest.
|
||||
ObjectList {
|
||||
id_field: String,
|
||||
fields: Vec<ObjectFieldDescriptor>,
|
||||
min_items: Option<u32>,
|
||||
max_items: Option<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// One configurable field, emitted by the `SettingsSection` derive. Owned
|
||||
@@ -306,11 +156,9 @@ pub struct FieldDescriptor {
|
||||
/// renders them after the primary fields under an "Advanced" divider.
|
||||
#[serde(default)]
|
||||
pub advanced: bool,
|
||||
/// The field's default value, shown when no value is stored yet. Core
|
||||
/// fields leave this `None` (their value always exists in the serialized
|
||||
/// `Config` via the struct's `Default`); plugin fields carry the
|
||||
/// manifest-declared default so the surfaces and the resolution chain show
|
||||
/// it before the user has saved anything.
|
||||
/// The field's default value, shown when no value is stored yet. Left
|
||||
/// `None` for every core field, whose value always exists in the serialized
|
||||
/// `Config` via the struct's `Default`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
@@ -1,470 +0,0 @@
|
||||
//! Plugin settings as virtual schema sections.
|
||||
//!
|
||||
//! A plugin's declared settings render through the same generic schema path as
|
||||
//! core settings, under a virtual section id `plugin:<id>`. The API, validation,
|
||||
//! TUI, and web all speak that flat `plugin:<id>.<key>` shape; only the disk
|
||||
//! storage differs (a plugin value lives in `plugins.<id>.settings.<key>` in the
|
||||
//! serialized `Config`). That section-id to storage-path translation is the one
|
||||
//! thing that lives here, so no consumer scatters `starts_with("plugin:")`
|
||||
//! checks of its own.
|
||||
|
||||
use aoe_plugin_api::{SettingContribution, SettingType};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{
|
||||
FieldDescriptor, ObjectFieldDescriptor, ObjectFieldWidget, OptionSource as SchemaOptionSource,
|
||||
SelectOption, ValidationKind, WebWritePolicy, WidgetKind,
|
||||
};
|
||||
|
||||
/// Prefix marking a virtual plugin settings section.
|
||||
pub const PLUGIN_SECTION_PREFIX: &str = "plugin:";
|
||||
|
||||
/// TUI category / web tab plugin settings sit under.
|
||||
pub const PLUGIN_CATEGORY: &str = "Plugins";
|
||||
|
||||
/// The virtual schema section id for a plugin's settings.
|
||||
pub fn plugin_section_id(plugin_id: &str) -> String {
|
||||
format!("{PLUGIN_SECTION_PREFIX}{plugin_id}")
|
||||
}
|
||||
|
||||
/// The plugin id if `section` is a virtual plugin settings section.
|
||||
pub fn section_plugin_id(section: &str) -> Option<&str> {
|
||||
section.strip_prefix(PLUGIN_SECTION_PREFIX)
|
||||
}
|
||||
|
||||
/// Read a plugin setting's stored value from a serialized `Config` JSON value
|
||||
/// (`plugins.<id>.settings.<field>`).
|
||||
pub fn storage_value<'a>(root: &'a Value, plugin_id: &str, field: &str) -> Option<&'a Value> {
|
||||
root.get("plugins")?
|
||||
.get(plugin_id)?
|
||||
.get("settings")?
|
||||
.get(field)
|
||||
}
|
||||
|
||||
/// The nested `Config`-shaped leaf that writes a plugin setting:
|
||||
/// `{"plugins": {"<id>": {"settings": {"<field>": leaf}}}}`.
|
||||
pub fn storage_leaf(plugin_id: &str, field: &str, leaf: Value) -> Value {
|
||||
json!({ "plugins": { plugin_id: { "settings": { field: leaf } } } })
|
||||
}
|
||||
|
||||
/// Rewrite a settings PATCH body in place: every top-level `plugin:<id>`
|
||||
/// section is folded into `plugins.<id>.settings.*`, matching on-disk storage.
|
||||
/// Core sections are left untouched. Call after validation, before merge.
|
||||
pub fn rewrite_plugin_sections(body: &mut Value) {
|
||||
let Some(obj) = body.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let plugin_keys: Vec<String> = obj
|
||||
.keys()
|
||||
.filter(|k| k.starts_with(PLUGIN_SECTION_PREFIX))
|
||||
.cloned()
|
||||
.collect();
|
||||
if plugin_keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Pull each plugin section out, remembering its id, then fold them into the
|
||||
// `plugins.<id>.settings` subtree. One mutable borrow of `obj` throughout.
|
||||
let mut sections = Vec::new();
|
||||
for key in plugin_keys {
|
||||
if let Some(section) = obj.remove(&key) {
|
||||
let id = key[PLUGIN_SECTION_PREFIX.len()..].to_string();
|
||||
sections.push((id, section));
|
||||
}
|
||||
}
|
||||
let plugins = obj
|
||||
.entry("plugins".to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
let Some(plugins) = plugins.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
for (id, section) in sections {
|
||||
let entry = plugins.entry(id).or_insert_with(|| json!({}));
|
||||
let Some(entry) = entry.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
let settings = entry
|
||||
.entry("settings".to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
if let (Some(settings), Some(section)) = (settings.as_object_mut(), section.as_object()) {
|
||||
for (k, v) in section {
|
||||
settings.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the schema descriptors for one plugin's declared settings.
|
||||
pub fn plugin_field_descriptors(
|
||||
plugin_id: &str,
|
||||
settings: &[SettingContribution],
|
||||
) -> Vec<FieldDescriptor> {
|
||||
let section = plugin_section_id(plugin_id);
|
||||
settings
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let (widget, validation) = widget_and_validation(s);
|
||||
FieldDescriptor {
|
||||
section: section.clone(),
|
||||
field: s.key.clone(),
|
||||
category: PLUGIN_CATEGORY.to_string(),
|
||||
label: if s.label.is_empty() {
|
||||
s.key.clone()
|
||||
} else {
|
||||
s.label.clone()
|
||||
},
|
||||
description: s.description.clone(),
|
||||
widget,
|
||||
// Plugin settings are not host-execution surfaces; the settings
|
||||
// PATCH endpoint is already elevation-gated by the auth layer.
|
||||
web_write: WebWritePolicy::Allow,
|
||||
// Global-only at Tier 0: a plugin setting has one value, stored
|
||||
// in the global config, no per-profile override.
|
||||
profile_overridable: false,
|
||||
validation,
|
||||
advanced: s.advanced,
|
||||
default: s
|
||||
.default
|
||||
.as_ref()
|
||||
.and_then(|t| serde_json::to_value(t).ok()),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn widget_and_validation(s: &SettingContribution) -> (WidgetKind, ValidationKind) {
|
||||
match s.value_type {
|
||||
SettingType::Bool => (WidgetKind::Toggle, ValidationKind::None),
|
||||
SettingType::String => (
|
||||
WidgetKind::Text {
|
||||
multiline: s.multiline,
|
||||
mono: false,
|
||||
},
|
||||
ValidationKind::None,
|
||||
),
|
||||
SettingType::Integer => {
|
||||
let widget = WidgetKind::Number {
|
||||
min: s.min,
|
||||
max: s.max,
|
||||
};
|
||||
// RangeU64 is the only integer gate the host has; use it when the
|
||||
// declared bounds are non-negative (the common plugin counter
|
||||
// case), otherwise leave it ungated rather than misreport a signed
|
||||
// range.
|
||||
let validation = if s.min.unwrap_or(0) >= 0 && s.max.unwrap_or(0) >= 0 {
|
||||
ValidationKind::RangeU64 {
|
||||
min: s.min.unwrap_or(0) as u64,
|
||||
max: s.max.map(|m| m as u64),
|
||||
}
|
||||
} else {
|
||||
ValidationKind::None
|
||||
};
|
||||
(widget, validation)
|
||||
}
|
||||
SettingType::Select => (
|
||||
WidgetKind::Select {
|
||||
options: s.options.iter().map(|o| SelectOption::new(o, o)).collect(),
|
||||
},
|
||||
// Gate the value against the declared options server-side so an
|
||||
// off-menu value can never reach storage.
|
||||
ValidationKind::OneOf {
|
||||
options: s.options.clone(),
|
||||
},
|
||||
),
|
||||
SettingType::DynamicSelect => (
|
||||
WidgetKind::DynamicSelect {
|
||||
// Manifest validation guarantees a source on a dynamic_select;
|
||||
// default to acp.agents defensively rather than panic.
|
||||
source: s
|
||||
.option_source
|
||||
.map(SchemaOptionSource::from)
|
||||
.unwrap_or(SchemaOptionSource::AcpAgents),
|
||||
depends_on: s.depends_on.clone(),
|
||||
},
|
||||
// Options are host-resolved and revalidated at sessions.create; the
|
||||
// settings-write gate only enforces that a chosen value is a string
|
||||
// (never a number/object smuggled past widget metadata).
|
||||
ValidationKind::StringValue,
|
||||
),
|
||||
SettingType::Cron => (WidgetKind::Cron, ValidationKind::Cron),
|
||||
SettingType::ObjectList => {
|
||||
let fields: Vec<ObjectFieldDescriptor> =
|
||||
s.fields.iter().map(object_field_descriptor).collect();
|
||||
let id_field = s.item_id_key.clone().unwrap_or_else(|| "_id".to_string());
|
||||
(
|
||||
WidgetKind::ObjectList {
|
||||
id_field: id_field.clone(),
|
||||
fields: fields.clone(),
|
||||
min_items: s.min_items,
|
||||
max_items: s.max_items,
|
||||
},
|
||||
ValidationKind::ObjectList {
|
||||
id_field,
|
||||
fields,
|
||||
min_items: s.min_items,
|
||||
max_items: s.max_items,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map one manifest object-list item field to its runtime descriptor. The
|
||||
/// widget cannot be an object list, so the mapping is total and non-recursive.
|
||||
fn object_field_descriptor(f: &aoe_plugin_api::ObjectFieldContribution) -> ObjectFieldDescriptor {
|
||||
use aoe_plugin_api::ObjectFieldType as T;
|
||||
let (widget, validation) = match f.value_type {
|
||||
T::Bool => (ObjectFieldWidget::Toggle, ValidationKind::BoolValue),
|
||||
T::String => (
|
||||
ObjectFieldWidget::Text {
|
||||
multiline: f.multiline,
|
||||
mono: false,
|
||||
},
|
||||
if f.required {
|
||||
ValidationKind::NonEmptyString
|
||||
} else {
|
||||
ValidationKind::StringValue
|
||||
},
|
||||
),
|
||||
T::Integer => (
|
||||
ObjectFieldWidget::Number {
|
||||
min: f.min,
|
||||
max: f.max,
|
||||
},
|
||||
if f.min.unwrap_or(0) >= 0 && f.max.unwrap_or(0) >= 0 {
|
||||
ValidationKind::RangeU64 {
|
||||
min: f.min.unwrap_or(0) as u64,
|
||||
max: f.max.map(|m| m as u64),
|
||||
}
|
||||
} else {
|
||||
// Bounds go negative; RangeU64 cannot express them.
|
||||
ValidationKind::RangeI64 {
|
||||
min: f.min,
|
||||
max: f.max,
|
||||
}
|
||||
},
|
||||
),
|
||||
T::Select => (
|
||||
ObjectFieldWidget::Select {
|
||||
options: f.options.iter().map(|o| SelectOption::new(o, o)).collect(),
|
||||
},
|
||||
ValidationKind::OneOf {
|
||||
options: f.options.clone(),
|
||||
},
|
||||
),
|
||||
T::DynamicSelect => (
|
||||
ObjectFieldWidget::DynamicSelect {
|
||||
source: f
|
||||
.option_source
|
||||
.map(SchemaOptionSource::from)
|
||||
.unwrap_or(SchemaOptionSource::AcpAgents),
|
||||
depends_on: f.depends_on.clone(),
|
||||
},
|
||||
// Host-resolved + revalidated at sessions.create; non-empty when
|
||||
// required, otherwise just enforce the string type.
|
||||
if f.required {
|
||||
ValidationKind::NonEmptyString
|
||||
} else {
|
||||
ValidationKind::StringValue
|
||||
},
|
||||
),
|
||||
T::DynamicMultiSelect => (
|
||||
ObjectFieldWidget::DynamicMultiSelect {
|
||||
source: f
|
||||
.option_source
|
||||
.map(SchemaOptionSource::from)
|
||||
.unwrap_or(SchemaOptionSource::Projects),
|
||||
depends_on: f.depends_on.clone(),
|
||||
},
|
||||
// Host-resolved list, revalidated at sessions.create; enforce only
|
||||
// the array-of-strings shape here.
|
||||
ValidationKind::StringListValue,
|
||||
),
|
||||
T::Cron => (ObjectFieldWidget::Cron, ValidationKind::Cron),
|
||||
};
|
||||
ObjectFieldDescriptor {
|
||||
field: f.key.clone(),
|
||||
label: if f.label.is_empty() {
|
||||
f.key.clone()
|
||||
} else {
|
||||
f.label.clone()
|
||||
},
|
||||
description: f.description.clone(),
|
||||
required: f.required,
|
||||
widget,
|
||||
validation,
|
||||
default: f
|
||||
.default
|
||||
.as_ref()
|
||||
.and_then(|t| serde_json::to_value(t).ok()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn contrib(key: &str, ty: SettingType) -> SettingContribution {
|
||||
SettingContribution {
|
||||
key: key.to_string(),
|
||||
label: String::new(),
|
||||
description: String::new(),
|
||||
value_type: ty,
|
||||
options: Vec::new(),
|
||||
min: None,
|
||||
max: None,
|
||||
default: None,
|
||||
advanced: false,
|
||||
multiline: false,
|
||||
option_source: None,
|
||||
depends_on: Vec::new(),
|
||||
fields: Vec::new(),
|
||||
item_id_key: None,
|
||||
min_items: None,
|
||||
max_items: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_id_round_trips() {
|
||||
let id = plugin_section_id("acme.kit");
|
||||
assert_eq!(id, "plugin:acme.kit");
|
||||
assert_eq!(section_plugin_id(&id), Some("acme.kit"));
|
||||
assert_eq!(section_plugin_id("acp"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptors_map_types_to_widgets() {
|
||||
let mut s_int = contrib("retries", SettingType::Integer);
|
||||
s_int.min = Some(0);
|
||||
s_int.max = Some(9);
|
||||
s_int.default = Some(toml::Value::Integer(3));
|
||||
let descs = plugin_field_descriptors(
|
||||
"acme.kit",
|
||||
&[
|
||||
contrib("on", SettingType::Bool),
|
||||
contrib("name", SettingType::String),
|
||||
s_int,
|
||||
SettingContribution {
|
||||
options: vec!["a".into(), "b".into()],
|
||||
..contrib("mode", SettingType::Select)
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_eq!(descs[0].section, "plugin:acme.kit");
|
||||
assert!(matches!(descs[0].widget, WidgetKind::Toggle));
|
||||
assert!(matches!(descs[1].widget, WidgetKind::Text { .. }));
|
||||
assert!(matches!(
|
||||
descs[2].widget,
|
||||
WidgetKind::Number {
|
||||
min: Some(0),
|
||||
max: Some(9)
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
descs[2].validation,
|
||||
ValidationKind::RangeU64 {
|
||||
min: 0,
|
||||
max: Some(9)
|
||||
}
|
||||
));
|
||||
assert_eq!(descs[2].default, Some(serde_json::json!(3)));
|
||||
assert!(matches!(descs[3].widget, WidgetKind::Select { .. }));
|
||||
// Label falls back to the key when unset.
|
||||
assert_eq!(descs[0].label, "on");
|
||||
// Global-only at Tier 0.
|
||||
assert!(!descs[0].profile_overridable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_folds_plugin_sections_into_storage() {
|
||||
let mut body = json!({
|
||||
"theme": { "idle_decay_minutes": 5 },
|
||||
"plugin:acme.kit": { "retries": 4, "mode": "fast" },
|
||||
});
|
||||
rewrite_plugin_sections(&mut body);
|
||||
assert_eq!(body["theme"]["idle_decay_minutes"], json!(5));
|
||||
assert!(body.get("plugin:acme.kit").is_none());
|
||||
assert_eq!(body["plugins"]["acme.kit"]["settings"]["retries"], json!(4));
|
||||
assert_eq!(
|
||||
body["plugins"]["acme.kit"]["settings"]["mode"],
|
||||
json!("fast")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_helpers_round_trip() {
|
||||
let mut cfg = json!({});
|
||||
let leaf = storage_leaf("acme.kit", "retries", json!(7));
|
||||
super::super::merge_json(&mut cfg, &leaf);
|
||||
assert_eq!(storage_value(&cfg, "acme.kit", "retries"), Some(&json!(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_patch_validates_then_rewrites_to_storage() {
|
||||
// A plugin section validates against runtime descriptors through the
|
||||
// same gate as core, then folds into its storage path before merge.
|
||||
let mut s_int = contrib("retries", SettingType::Integer);
|
||||
s_int.min = Some(0);
|
||||
s_int.max = Some(5);
|
||||
let descriptors = plugin_field_descriptors("acme.kit", &[s_int]);
|
||||
|
||||
let good = json!({ "plugin:acme.kit": { "retries": 4 } });
|
||||
assert!(super::super::validate_patch_with(
|
||||
&descriptors,
|
||||
&good,
|
||||
super::super::Scope::Global,
|
||||
true
|
||||
)
|
||||
.is_ok());
|
||||
|
||||
// Out-of-range is rejected by the derived RangeU64 gate.
|
||||
let bad = json!({ "plugin:acme.kit": { "retries": 9 } });
|
||||
assert!(super::super::validate_patch_with(
|
||||
&descriptors,
|
||||
&bad,
|
||||
super::super::Scope::Global,
|
||||
true
|
||||
)
|
||||
.is_err());
|
||||
|
||||
// Unknown plugin field is rejected.
|
||||
let unknown = json!({ "plugin:acme.kit": { "nope": 1 } });
|
||||
assert!(super::super::validate_patch_with(
|
||||
&descriptors,
|
||||
&unknown,
|
||||
super::super::Scope::Global,
|
||||
true
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let mut body = good;
|
||||
rewrite_plugin_sections(&mut body);
|
||||
assert_eq!(body["plugins"]["acme.kit"]["settings"]["retries"], json!(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_value_is_gated_against_options() {
|
||||
let descriptors = plugin_field_descriptors(
|
||||
"acme.kit",
|
||||
&[SettingContribution {
|
||||
options: vec!["fast".into(), "slow".into()],
|
||||
..contrib("mode", SettingType::Select)
|
||||
}],
|
||||
);
|
||||
// An on-menu value passes; an off-menu value is rejected.
|
||||
assert!(super::super::validate_patch_with(
|
||||
&descriptors,
|
||||
&json!({ "plugin:acme.kit": { "mode": "fast" } }),
|
||||
super::super::Scope::Global,
|
||||
true
|
||||
)
|
||||
.is_ok());
|
||||
assert!(super::super::validate_patch_with(
|
||||
&descriptors,
|
||||
&json!({ "plugin:acme.kit": { "mode": "turbo" } }),
|
||||
super::super::Scope::Global,
|
||||
true
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -140,18 +140,7 @@ pub fn strip_local_only(patch: &mut Value) {
|
||||
/// `local_only` policy (host-execution surfaces are removed before they get
|
||||
/// here), it only gates unknown fields, elevation, and value validity.
|
||||
pub fn validate_patch(patch: &Value, scope: Scope, elevated: bool) -> Result<(), PatchRejection> {
|
||||
validate_patch_with(&schema(), patch, scope, elevated)
|
||||
}
|
||||
|
||||
/// [`validate_patch`] against an explicit descriptor list. The server passes
|
||||
/// the runtime schema (core plus active-plugin sections) so plugin settings
|
||||
/// validate through the same gate as core fields.
|
||||
pub fn validate_patch_with(
|
||||
descriptors: &[FieldDescriptor],
|
||||
patch: &Value,
|
||||
scope: Scope,
|
||||
elevated: bool,
|
||||
) -> Result<(), PatchRejection> {
|
||||
let descriptors = &schema();
|
||||
let Some(obj) = patch.as_object() else {
|
||||
return Err(PatchRejection::Malformed("<root>".into()));
|
||||
};
|
||||
|
||||
@@ -28,22 +28,6 @@ pub fn schema() -> Vec<FieldDescriptor> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The schema as the running process sees it: the static core [`schema`] plus
|
||||
/// one virtual `plugin:<id>` section per active plugin's declared settings. The
|
||||
/// server serves this over `GET /api/settings/schema`, validates PATCHes against
|
||||
/// it, and the TUI builds its Plugins tab from it, so plugin settings render and
|
||||
/// validate through the exact same path as core settings.
|
||||
pub fn runtime_schema() -> Vec<FieldDescriptor> {
|
||||
let mut out = schema();
|
||||
for p in crate::plugin::registry().active() {
|
||||
out.extend(super::plugin::plugin_field_descriptors(
|
||||
p.id(),
|
||||
&p.manifest.settings,
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Look up a single field's descriptor by `section` and `field`.
|
||||
pub fn descriptor(section: &str, field: &str) -> Option<FieldDescriptor> {
|
||||
schema()
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
//! Settings resolution with provenance (#2094).
|
||||
//!
|
||||
//! A setting's effective value can come from more than one layer.
|
||||
//!
|
||||
//! For a **core** key at Tier 0 the effective value is the user's value (when it
|
||||
//! differs from the baseline default), else the core schema default. A plugin's
|
||||
//! `setting_defaults` override of a core key is surfaced as a *candidate* so it
|
||||
//! is observable, but it does NOT win: nothing applies it during real `Config`
|
||||
//! load or merge yet, so the running app uses the user value or the struct
|
||||
//! default. The runtime host applies these overrides for real (#2095); until
|
||||
//! then a `plugin_default` candidate is "declared, not yet in effect".
|
||||
//!
|
||||
//! For a **plugin's own** setting the effective value is the stored value, else
|
||||
//! the plugin's manifest default.
|
||||
//! A key's effective value is the user's stored value when it differs from the
|
||||
//! baseline default, else the core schema default.
|
||||
//!
|
||||
//! [`resolve`] returns the winning value, its [`SettingSource`], and every
|
||||
//! candidate that was considered, so `aoe settings explain` and
|
||||
@@ -20,22 +10,14 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{runtime_schema, section_plugin_id, FieldDescriptor};
|
||||
use super::{schema, FieldDescriptor};
|
||||
|
||||
/// Where a resolved value came from.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum SettingSource {
|
||||
/// The user's stored value (a core field changed from its default, or a
|
||||
/// stored plugin setting value).
|
||||
/// The user's stored value: a core field changed from its default.
|
||||
User,
|
||||
/// A plugin's `setting_defaults` override of a core setting. At Tier 0 this
|
||||
/// only ever appears as a candidate, never as the winning source: it is
|
||||
/// declared but not yet applied at runtime (the runtime host applies it,
|
||||
/// #2095).
|
||||
PluginDefault { plugin: String },
|
||||
/// The owning plugin's manifest default for one of its own settings.
|
||||
ManifestDefault { plugin: String },
|
||||
/// The core schema (struct) default.
|
||||
SchemaDefault,
|
||||
}
|
||||
@@ -50,7 +32,7 @@ pub struct Candidate {
|
||||
/// A setting's resolved value plus the full provenance chain.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct ResolvedSetting {
|
||||
/// Canonical key: `section.field` for core, `plugin:<id>.<key>` for plugin.
|
||||
/// Canonical key: `section.field`.
|
||||
pub key: String,
|
||||
pub value: Value,
|
||||
pub source: SettingSource,
|
||||
@@ -60,29 +42,27 @@ pub struct ResolvedSetting {
|
||||
}
|
||||
|
||||
/// Split a canonical key into `(section, field)`. The field is the last dotted
|
||||
/// segment; the section is everything before it (so `plugin:acme.kit.retries`
|
||||
/// splits into `plugin:acme.kit` + `retries`, and `acp.default_agent` into
|
||||
/// `acp` + `default_agent`).
|
||||
/// segment; the section is everything before it (so `acp.default_agent` splits
|
||||
/// into `acp` + `default_agent`).
|
||||
fn split_key(key: &str) -> Option<(&str, &str)> {
|
||||
key.rsplit_once('.')
|
||||
}
|
||||
|
||||
/// Resolve one setting by canonical key against the live config and the active
|
||||
/// plugin set. `None` if the key is not a known setting.
|
||||
/// Resolve one setting by canonical key against the live config. `None` if the
|
||||
/// key is not a known setting.
|
||||
pub fn resolve(key: &str) -> Option<ResolvedSetting> {
|
||||
let cfg = serde_json::to_value(crate::session::Config::load_or_warn()).ok()?;
|
||||
let default_cfg = serde_json::to_value(crate::session::Config::default()).ok()?;
|
||||
resolve_with(key, &cfg, &default_cfg, &runtime_schema())
|
||||
resolve_with(key, &cfg, &default_cfg, &schema())
|
||||
}
|
||||
|
||||
/// Resolve every known setting (core plus active-plugin). Used by
|
||||
/// `GET /api/settings/resolved`. Loads the config and builds the schema once
|
||||
/// for the whole set rather than per field.
|
||||
/// Resolve every known setting. Used by `GET /api/settings/resolved`. Loads the
|
||||
/// config and builds the schema once for the whole set rather than per field.
|
||||
pub fn resolve_all() -> Vec<ResolvedSetting> {
|
||||
let cfg = serde_json::to_value(crate::session::Config::load_or_warn()).unwrap_or(Value::Null);
|
||||
let default_cfg =
|
||||
serde_json::to_value(crate::session::Config::default()).unwrap_or(Value::Null);
|
||||
let schema = runtime_schema();
|
||||
let schema = schema();
|
||||
schema
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
@@ -109,11 +89,7 @@ fn resolve_with(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(plugin_id) = section_plugin_id(section) {
|
||||
Some(resolve_plugin_own(key, plugin_id, field, cfg))
|
||||
} else {
|
||||
Some(resolve_core(key, section, field, cfg, default_cfg))
|
||||
}
|
||||
Some(resolve_core(key, section, field, cfg, default_cfg))
|
||||
}
|
||||
|
||||
fn resolve_core(
|
||||
@@ -141,34 +117,11 @@ fn resolve_core(
|
||||
});
|
||||
}
|
||||
|
||||
// Plugin overrides, in active-plugin order (builtins first). At Tier 0 these
|
||||
// are recorded as candidates so they are observable, but they do NOT win:
|
||||
// nothing applies a plugin's core-default override during real Config load
|
||||
// or merge yet, so the running app uses the user value or the struct
|
||||
// default. The runtime host applies these for real (#2095). Reporting one
|
||||
// as the effective value here would misrepresent what every core consumer
|
||||
// actually reads.
|
||||
for p in crate::plugin::registry().active() {
|
||||
if let Some(tv) = p.manifest.setting_defaults.get(key) {
|
||||
if let Ok(v) = serde_json::to_value(tv) {
|
||||
candidates.push(Candidate {
|
||||
source: SettingSource::PluginDefault {
|
||||
plugin: p.id().to_string(),
|
||||
},
|
||||
value: v,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push(Candidate {
|
||||
source: SettingSource::SchemaDefault,
|
||||
value: schema_default.clone(),
|
||||
});
|
||||
|
||||
// The effective value is what the app actually uses today: the user value,
|
||||
// else the struct default. Plugin core-default overrides stay in
|
||||
// `candidates` only.
|
||||
let (source, value) = match user {
|
||||
Some(v) => (SettingSource::User, v),
|
||||
None => (SettingSource::SchemaDefault, schema_default),
|
||||
@@ -181,57 +134,6 @@ fn resolve_core(
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_plugin_own(key: &str, plugin_id: &str, field: &str, cfg: &Value) -> ResolvedSetting {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Some(v) = super::plugin_storage_value(cfg, plugin_id, field) {
|
||||
candidates.push(Candidate {
|
||||
source: SettingSource::User,
|
||||
value: v.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// The owning plugin's manifest default for this key.
|
||||
if let Some(p) = crate::plugin::registry().get(plugin_id) {
|
||||
if let Some(s) = p.manifest.settings.iter().find(|s| s.key == field) {
|
||||
if let Some(tv) = &s.default {
|
||||
if let Ok(v) = serde_json::to_value(tv) {
|
||||
candidates.push(Candidate {
|
||||
source: SettingSource::ManifestDefault {
|
||||
plugin: plugin_id.to_string(),
|
||||
},
|
||||
value: v,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
candidates.push(Candidate {
|
||||
source: SettingSource::ManifestDefault {
|
||||
plugin: plugin_id.to_string(),
|
||||
},
|
||||
value: Value::Null,
|
||||
});
|
||||
}
|
||||
|
||||
finish(key, candidates)
|
||||
}
|
||||
|
||||
fn finish(key: &str, candidates: Vec<Candidate>) -> ResolvedSetting {
|
||||
let winner = candidates.first().cloned().unwrap_or(Candidate {
|
||||
source: SettingSource::SchemaDefault,
|
||||
value: Value::Null,
|
||||
});
|
||||
ResolvedSetting {
|
||||
key: key.to_string(),
|
||||
value: winner.value,
|
||||
source: winner.source,
|
||||
candidates,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -242,10 +144,6 @@ mod tests {
|
||||
split_key("acp.default_agent"),
|
||||
Some(("acp", "default_agent"))
|
||||
);
|
||||
assert_eq!(
|
||||
split_key("plugin:acme.kit.retries"),
|
||||
Some(("plugin:acme.kit", "retries"))
|
||||
);
|
||||
assert_eq!(split_key("nodot"), None);
|
||||
}
|
||||
|
||||
@@ -257,8 +155,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn core_field_falls_back_to_schema_default() {
|
||||
// With no user override and no plugin setting_defaults, a core field
|
||||
// resolves to its schema default.
|
||||
// With no user override, a core field resolves to its schema default.
|
||||
let r = resolve("acp.default_agent").expect("known core key");
|
||||
assert_eq!(r.source, SettingSource::SchemaDefault);
|
||||
assert_eq!(
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{ObjectFieldDescriptor, ValidationKind};
|
||||
use super::ValidationKind;
|
||||
|
||||
/// A value failed validation for a field. Carries a human-readable reason the
|
||||
/// server surfaces to the client (HTTP 400).
|
||||
@@ -58,44 +58,6 @@ pub fn validate_value(kind: &ValidationKind, value: &Value) -> Result<(), Valida
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ValidationKind::StringValue => {
|
||||
value
|
||||
.as_str()
|
||||
.ok_or_else(|| ValidationError::new("expected a string"))?;
|
||||
Ok(())
|
||||
}
|
||||
ValidationKind::StringListValue => {
|
||||
let arr = value
|
||||
.as_array()
|
||||
.ok_or_else(|| ValidationError::new("expected a list of strings"))?;
|
||||
if arr.iter().all(Value::is_string) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ValidationError::new("every entry must be a string"))
|
||||
}
|
||||
}
|
||||
ValidationKind::BoolValue => {
|
||||
value
|
||||
.as_bool()
|
||||
.ok_or_else(|| ValidationError::new("expected a boolean"))?;
|
||||
Ok(())
|
||||
}
|
||||
ValidationKind::RangeI64 { min, max } => {
|
||||
let n = value
|
||||
.as_i64()
|
||||
.ok_or_else(|| ValidationError::new("expected an integer"))?;
|
||||
if let Some(min) = min {
|
||||
if n < *min {
|
||||
return Err(ValidationError::new(format!("must be at least {min}")));
|
||||
}
|
||||
}
|
||||
if let Some(max) = max {
|
||||
if n > *max {
|
||||
return Err(ValidationError::new(format!("must be at most {max}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ValidationKind::MemoryLimit => {
|
||||
let s = value
|
||||
.as_str()
|
||||
@@ -115,174 +77,9 @@ pub fn validate_value(kind: &ValidationKind, value: &Value) -> Result<(), Valida
|
||||
.ok_or_else(|| ValidationError::new("expected a string"))?;
|
||||
crate::session::validate_network_format(s).map_err(ValidationError::new)
|
||||
}
|
||||
ValidationKind::OneOf { options } => {
|
||||
let s = value
|
||||
.as_str()
|
||||
.ok_or_else(|| ValidationError::new("expected a string"))?;
|
||||
if options.iter().any(|o| o == s) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ValidationError::new(format!(
|
||||
"must be one of: {}",
|
||||
options.join(", ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
ValidationKind::Cron => {
|
||||
let s = value
|
||||
.as_str()
|
||||
.ok_or_else(|| ValidationError::new("expected a string"))?;
|
||||
validate_cron(s).map_err(ValidationError::new)
|
||||
}
|
||||
ValidationKind::ObjectList {
|
||||
id_field,
|
||||
fields,
|
||||
min_items,
|
||||
max_items,
|
||||
} => validate_object_list(value, id_field, fields, *min_items, *max_items),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an object-list value: an array of objects, item-count bounds, a
|
||||
/// unique non-empty stable id per item, only declared fields plus the id, and
|
||||
/// each present field against its own descriptor (a required field must be
|
||||
/// present). Dynamic-select membership is NOT checked here: catalogs change,
|
||||
/// and a saved id is authoritatively revalidated at `sessions.create`.
|
||||
fn validate_object_list(
|
||||
value: &Value,
|
||||
id_field: &str,
|
||||
fields: &[ObjectFieldDescriptor],
|
||||
min_items: Option<u32>,
|
||||
max_items: Option<u32>,
|
||||
) -> Result<(), ValidationError> {
|
||||
let arr = value
|
||||
.as_array()
|
||||
.ok_or_else(|| ValidationError::new("expected a list of items"))?;
|
||||
if let Some(min) = min_items {
|
||||
if (arr.len() as u32) < min {
|
||||
return Err(ValidationError::new(format!(
|
||||
"needs at least {min} item(s)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(max) = max_items {
|
||||
if (arr.len() as u32) > max {
|
||||
return Err(ValidationError::new(format!(
|
||||
"allows at most {max} item(s)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
for (i, item) in arr.iter().enumerate() {
|
||||
let obj = item
|
||||
.as_object()
|
||||
.ok_or_else(|| ValidationError::new(format!("item {i} must be an object")))?;
|
||||
let id = obj
|
||||
.get(id_field)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
ValidationError::new(format!("item {i} is missing a non-empty {id_field:?}"))
|
||||
})?;
|
||||
if !seen_ids.insert(id.to_string()) {
|
||||
return Err(ValidationError::new(format!("duplicate item id {id:?}")));
|
||||
}
|
||||
for key in obj.keys() {
|
||||
if key != id_field && !fields.iter().any(|f| &f.field == key) {
|
||||
return Err(ValidationError::new(format!(
|
||||
"item {i} has an undeclared field {key:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
for field in fields {
|
||||
match obj.get(&field.field) {
|
||||
Some(v) => validate_value(&field.validation, v).map_err(|e| {
|
||||
ValidationError::new(format!("item {i} field {:?}: {}", field.field, e.reason))
|
||||
})?,
|
||||
None if field.required => {
|
||||
return Err(ValidationError::new(format!(
|
||||
"item {i} is missing required field {:?}",
|
||||
field.field
|
||||
)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a 5-field cron expression (minute hour day-of-month month
|
||||
/// day-of-week). Each field is `*`, or a comma list of items where an item is
|
||||
/// a number, an `a-b` range, or either followed by `/step`, all within the
|
||||
/// field's inclusive bounds. Mirrors the plugin scheduler's `croner` dialect
|
||||
/// closely enough to reject garbage at settings-write time; the scheduler is
|
||||
/// the authoritative parser at run time.
|
||||
fn validate_cron(expr: &str) -> Result<(), String> {
|
||||
// day-of-week is 0-7 (both 0 and 7 are Sunday), matching croner and the
|
||||
// web-side cronValidation.ts.
|
||||
const BOUNDS: [(u32, u32); 5] = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 7)];
|
||||
let fields: Vec<&str> = expr.split_whitespace().collect();
|
||||
if fields.len() != 5 {
|
||||
return Err(format!(
|
||||
"cron must have 5 fields (got {}); e.g. \"0 9 * * 1-5\"",
|
||||
fields.len()
|
||||
));
|
||||
}
|
||||
for (field, (lo, hi)) in fields.iter().zip(BOUNDS.iter()) {
|
||||
for item in field.split(',') {
|
||||
validate_cron_item(item, *lo, *hi)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_cron_item(item: &str, lo: u32, hi: u32) -> Result<(), String> {
|
||||
if item.is_empty() {
|
||||
return Err("empty cron field item".to_string());
|
||||
}
|
||||
let (range, step) = match item.split_once('/') {
|
||||
Some((r, s)) => {
|
||||
let step: u32 = s.parse().map_err(|_| format!("invalid cron step {s:?}"))?;
|
||||
if step == 0 {
|
||||
return Err("cron step must be positive".to_string());
|
||||
}
|
||||
(r, Some(step))
|
||||
}
|
||||
None => (item, None),
|
||||
};
|
||||
// `*` (optionally with a step) covers the whole range.
|
||||
if range == "*" {
|
||||
return Ok(());
|
||||
}
|
||||
let in_bounds = |n: u32| n >= lo && n <= hi;
|
||||
match range.split_once('-') {
|
||||
Some((a, b)) => {
|
||||
let a: u32 = a.parse().map_err(|_| format!("invalid cron value {a:?}"))?;
|
||||
let b: u32 = b.parse().map_err(|_| format!("invalid cron value {b:?}"))?;
|
||||
if !in_bounds(a) || !in_bounds(b) {
|
||||
return Err(format!("cron value out of range {lo}-{hi}"));
|
||||
}
|
||||
if a > b {
|
||||
return Err(format!("cron range {a}-{b} is reversed"));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let n: u32 = range
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid cron value {range:?}"))?;
|
||||
if !in_bounds(n) {
|
||||
return Err(format!("cron value {n} out of range {lo}-{hi}"));
|
||||
}
|
||||
// A bare number with a step (e.g. `5/10`) is meaningless.
|
||||
if step.is_some() {
|
||||
return Err("cron step requires a range or *".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that `value` is a JSON array of strings and each passes `check`.
|
||||
fn validate_string_list(
|
||||
value: &Value,
|
||||
@@ -330,32 +127,6 @@ mod tests {
|
||||
assert!(validate_value(&ValidationKind::NonEmptyString, &json!("x")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_values_reject_mismatched_json() {
|
||||
// str: any string incl. empty, but never a number/object.
|
||||
assert!(validate_value(&ValidationKind::StringValue, &json!("")).is_ok());
|
||||
assert!(validate_value(&ValidationKind::StringValue, &json!("x")).is_ok());
|
||||
assert!(validate_value(&ValidationKind::StringValue, &json!(1)).is_err());
|
||||
assert!(validate_value(&ValidationKind::StringValue, &json!({})).is_err());
|
||||
// bool: only true/false.
|
||||
assert!(validate_value(&ValidationKind::BoolValue, &json!(true)).is_ok());
|
||||
assert!(validate_value(&ValidationKind::BoolValue, &json!("true")).is_err());
|
||||
// signed range, single- and double-sided.
|
||||
let signed = ValidationKind::RangeI64 {
|
||||
min: Some(-5),
|
||||
max: Some(5),
|
||||
};
|
||||
assert!(validate_value(&signed, &json!(-5)).is_ok());
|
||||
assert!(validate_value(&signed, &json!(6)).is_err());
|
||||
assert!(validate_value(&signed, &json!("3")).is_err());
|
||||
let lower_only = ValidationKind::RangeI64 {
|
||||
min: Some(-1),
|
||||
max: None,
|
||||
};
|
||||
assert!(validate_value(&lower_only, &json!(1_000)).is_ok());
|
||||
assert!(validate_value(&lower_only, &json!(-2)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_limit_grammar() {
|
||||
assert!(validate_value(&ValidationKind::MemoryLimit, &json!("512m")).is_ok());
|
||||
@@ -379,16 +150,6 @@ mod tests {
|
||||
assert!(validate_value(&ValidationKind::EnvList, &json!("notalist")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_of_membership() {
|
||||
let kind = ValidationKind::OneOf {
|
||||
options: vec!["fast".into(), "slow".into()],
|
||||
};
|
||||
assert!(validate_value(&kind, &json!("fast")).is_ok());
|
||||
assert!(validate_value(&kind, &json!("turbo")).is_err());
|
||||
assert!(validate_value(&kind, &json!(3)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_mapping_list_grammar() {
|
||||
assert!(validate_value(&ValidationKind::PortMappingList, &json!(["3000:3000"])).is_ok());
|
||||
@@ -405,117 +166,4 @@ mod tests {
|
||||
assert!(validate_value(&ValidationKind::Network, &json!("host")).is_err());
|
||||
assert!(validate_value(&ValidationKind::Network, &json!(42)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cron_grammar() {
|
||||
let ok = ["0 9 * * 1-5", "*/15 * * * *", "0 0,12 1 */2 *", "* * * * *"];
|
||||
for e in ok {
|
||||
assert!(
|
||||
validate_value(&ValidationKind::Cron, &json!(e)).is_ok(),
|
||||
"{e}"
|
||||
);
|
||||
}
|
||||
let bad = [
|
||||
"0 9 * *", // too few fields
|
||||
"0 9 * * * *", // too many fields
|
||||
"60 * * * *", // minute out of range
|
||||
"* 24 * * *", // hour out of range
|
||||
"* * 0 * *", // dom below 1
|
||||
"* * * 13 *", // month out of range
|
||||
"* * * * 8", // dow out of range (0-7 valid, 8 not)
|
||||
"5-1 * * * *", // reversed range
|
||||
"*/0 * * * *", // zero step
|
||||
"abc * * * *", // non-numeric
|
||||
];
|
||||
for e in bad {
|
||||
assert!(
|
||||
validate_value(&ValidationKind::Cron, &json!(e)).is_err(),
|
||||
"{e}"
|
||||
);
|
||||
}
|
||||
assert!(validate_value(&ValidationKind::Cron, &json!(5)).is_err());
|
||||
}
|
||||
|
||||
fn jobs_validation() -> ValidationKind {
|
||||
ValidationKind::ObjectList {
|
||||
id_field: "id".into(),
|
||||
fields: vec![
|
||||
ObjectFieldDescriptor {
|
||||
field: "agent".into(),
|
||||
label: "Agent".into(),
|
||||
description: String::new(),
|
||||
required: true,
|
||||
widget: super::super::ObjectFieldWidget::Text {
|
||||
multiline: false,
|
||||
mono: false,
|
||||
},
|
||||
validation: ValidationKind::NonEmptyString,
|
||||
default: None,
|
||||
},
|
||||
ObjectFieldDescriptor {
|
||||
field: "schedule".into(),
|
||||
label: "Schedule".into(),
|
||||
description: String::new(),
|
||||
required: true,
|
||||
widget: super::super::ObjectFieldWidget::Cron,
|
||||
validation: ValidationKind::Cron,
|
||||
default: None,
|
||||
},
|
||||
],
|
||||
min_items: Some(0),
|
||||
max_items: Some(2),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_list_structural_rules() {
|
||||
let kind = jobs_validation();
|
||||
// Happy path.
|
||||
assert!(validate_value(
|
||||
&kind,
|
||||
&json!([{"id": "a", "agent": "claude", "schedule": "0 9 * * 1-5"}])
|
||||
)
|
||||
.is_ok());
|
||||
// Missing stable id.
|
||||
assert!(validate_value(
|
||||
&kind,
|
||||
&json!([{"agent": "claude", "schedule": "* * * * *"}])
|
||||
)
|
||||
.is_err());
|
||||
// Duplicate id.
|
||||
assert!(validate_value(
|
||||
&kind,
|
||||
&json!([
|
||||
{"id": "x", "agent": "a", "schedule": "* * * * *"},
|
||||
{"id": "x", "agent": "b", "schedule": "* * * * *"}
|
||||
])
|
||||
)
|
||||
.is_err());
|
||||
// Missing required field.
|
||||
assert!(validate_value(&kind, &json!([{"id": "a", "agent": "claude"}])).is_err());
|
||||
// Undeclared field.
|
||||
assert!(validate_value(
|
||||
&kind,
|
||||
&json!([{"id": "a", "agent": "c", "schedule": "* * * * *", "bogus": 1}])
|
||||
)
|
||||
.is_err());
|
||||
// Nested field validation runs (bad cron).
|
||||
assert!(validate_value(
|
||||
&kind,
|
||||
&json!([{"id": "a", "agent": "c", "schedule": "bad"}])
|
||||
)
|
||||
.is_err());
|
||||
// max_items.
|
||||
assert!(validate_value(
|
||||
&kind,
|
||||
&json!([
|
||||
{"id": "1", "agent": "a", "schedule": "* * * * *"},
|
||||
{"id": "2", "agent": "b", "schedule": "* * * * *"},
|
||||
{"id": "3", "agent": "c", "schedule": "* * * * *"}
|
||||
])
|
||||
)
|
||||
.is_err());
|
||||
// Not an array.
|
||||
assert!(validate_value(&kind, &json!({"id": "a"})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! fences, then a markdown body, living in a per-skill directory. AoE has never
|
||||
//! had a Rust model for these; they were only bulk-copied into sandboxes
|
||||
//! (`src/session/container_config.rs`). This module is the single resolver used
|
||||
//! by the server, CLI, and plugin host.
|
||||
//! by the server and CLI.
|
||||
//!
|
||||
//! Two provenance layers, mirroring [`super::mcp_model::McpProvenance`]:
|
||||
//! host-discovered skills in each agent's own skills dir (`~/.claude/skills`,
|
||||
@@ -180,10 +180,7 @@ pub struct ReadSkill {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// A skills store operation that failed for a caller-attributable reason. The
|
||||
/// plugin host maps each variant to a JSON-RPC code: [`Self::ReadOnly`] to
|
||||
/// `FORBIDDEN`, [`Self::Io`] to `INTERNAL_ERROR`, everything else to
|
||||
/// `INVALID_PARAMS`.
|
||||
/// A skills store operation that failed for a caller-attributable reason.
|
||||
#[derive(Debug)]
|
||||
pub enum SkillError {
|
||||
/// Bad directory/agent name, unparseable content, or a name/directory
|
||||
@@ -600,11 +597,9 @@ enum Ownership {
|
||||
/// Deterministic `sha256:<hex>` over a skill package, excluding the propagation
|
||||
/// marker so a deployed copy hashes equal to the source it came from.
|
||||
///
|
||||
/// Deliberately not [`crate::plugin::integrity::tree_hash`]: that is a plugin
|
||||
/// primitive with its own domain prefix and exclusions, and it buffers whole
|
||||
/// files, which would defeat the package byte limits skills enforce. This
|
||||
/// streams and honours [`COPY_LIMITS`], matching what [`copy_tree_no_symlinks`]
|
||||
/// would accept.
|
||||
/// Streams and honours [`COPY_LIMITS`], matching what
|
||||
/// [`copy_tree_no_symlinks`] would accept, so a package that hashes here is one
|
||||
/// the copier would take.
|
||||
fn package_digest(dir: &Path) -> Result<String, SkillError> {
|
||||
let mut entries = Vec::new();
|
||||
collect_digest_entries(dir, dir, 0, &mut entries)?;
|
||||
|
||||
+4
-4
@@ -1580,7 +1580,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
// Embedded structured view: pump one daemon-side event
|
||||
// (ws frame, plugin snapshot, path roots). `next_event`
|
||||
// (ws frame, path roots). `next_event`
|
||||
// is cancel-safe (channel awaits only); the apply below
|
||||
// runs in the arm body where it can no longer be raced,
|
||||
// so a mid-replay cancellation cannot corrupt the state.
|
||||
@@ -1985,9 +1985,9 @@ impl App {
|
||||
needs_full_refresh = true;
|
||||
}
|
||||
|
||||
// Embedded structured view: expire its toast, surface queued
|
||||
// plugin notifications, and repaint on the same 120ms cadence
|
||||
// the full-screen view used so the composer caret blinks.
|
||||
// Embedded structured view: expire its toast and repaint on the
|
||||
// same 120ms cadence the full-screen view used so the composer
|
||||
// caret blinks.
|
||||
#[cfg(feature = "serve")]
|
||||
if let Some(view) = self.home.structured_preview.as_mut() {
|
||||
let toast_changed = view.tick();
|
||||
|
||||
@@ -16,7 +16,6 @@ mod intro;
|
||||
mod new_session;
|
||||
mod no_agents;
|
||||
mod permission_response;
|
||||
mod plugin_manager;
|
||||
mod profile_picker;
|
||||
mod project_session_picker;
|
||||
mod projects;
|
||||
@@ -51,7 +50,6 @@ pub(crate) use new_session::project_picker_label;
|
||||
pub use new_session::{NewSessionData, NewSessionDialog};
|
||||
pub use no_agents::{NoAgentsAction, NoAgentsDialog};
|
||||
pub use permission_response::{PermissionResponseChoice, PermissionResponseDialog};
|
||||
pub use plugin_manager::PluginManagerDialog;
|
||||
pub use profile_picker::{ProfileEntry, ProfilePickerAction, ProfilePickerDialog};
|
||||
pub use project_session_picker::ProjectSessionPickerDialog;
|
||||
pub use projects::ProjectsDialog;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-234
@@ -71,8 +71,6 @@ pub enum ActionId {
|
||||
SortPicker,
|
||||
GroupBy,
|
||||
NextWaiting,
|
||||
/// Open the plugin manager (palette only; no default chord).
|
||||
Plugins,
|
||||
/// Open the skills manager (palette only; no default chord).
|
||||
Skills,
|
||||
/// Pin or unpin the selected project header (project view only). Pinning
|
||||
@@ -218,139 +216,6 @@ pub fn resolve(key: &KeyEvent, strict: bool, ctx: &Ctx) -> Option<ActionId> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A plugin-contributed action a key resolved to: a plugin id plus the command
|
||||
/// the keybind targets. At Tier 0 there is no executor, so resolving one is
|
||||
/// inspectable (and surfaces a "needs runtime" notice) but not yet runnable;
|
||||
/// the executor lands with the runtime host (#2095).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginAction {
|
||||
pub plugin_id: String,
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
impl PluginAction {
|
||||
/// Canonical external name, `plugin.<id>.<action>`. Idempotent: a manifest
|
||||
/// keybind may already target a fully-qualified `plugin.<id>.<cmd>` command,
|
||||
/// so an action that is already canonical is returned unchanged rather than
|
||||
/// double-prefixed.
|
||||
pub fn canonical(&self) -> String {
|
||||
if self.action.starts_with("plugin.") {
|
||||
return self.action.clone();
|
||||
}
|
||||
format!("plugin.{}.{}", self.plugin_id, self.action)
|
||||
}
|
||||
}
|
||||
|
||||
/// The merged resolver's result: a core action, or a plugin action. Core always
|
||||
/// shadows a plugin binding on the same chord (core is resolved first).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResolvedAction {
|
||||
Core(ActionId),
|
||||
Plugin(PluginAction),
|
||||
}
|
||||
|
||||
/// Resolve a key across the merged core + plugin binding tables. Core bindings
|
||||
/// (the static [`BINDINGS`] table, honoring strict mode and context) are tried
|
||||
/// first and always win; only then are active plugins' declared keybinds
|
||||
/// consulted. Returns `None` if nothing claims the chord.
|
||||
pub fn resolve_action(key: &KeyEvent, strict: bool, ctx: &Ctx) -> Option<ResolvedAction> {
|
||||
if let Some(id) = resolve(key, strict, ctx) {
|
||||
return Some(ResolvedAction::Core(id));
|
||||
}
|
||||
for (chord, action) in plugin_bindings() {
|
||||
if chord_matches(&chord, key) {
|
||||
return Some(ResolvedAction::Plugin(action));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether a plugin-declared keybind string (e.g. `Ctrl+Shift+G`) matches this
|
||||
/// key event. The structured view resolves daemon-provided command keybinds
|
||||
/// through this rather than the local registry, so it parses the raw chord
|
||||
/// string here. A chord string that does not parse never matches.
|
||||
///
|
||||
/// Only the structured view (serve-gated) executes plugin commands, so this is
|
||||
/// unused in a bare-core build; gate it to avoid a dead-code warning there.
|
||||
#[cfg(feature = "serve")]
|
||||
pub fn keybind_matches(key_str: &str, key: &KeyEvent) -> bool {
|
||||
parse_chord(key_str).is_some_and(|chord| chord_matches(&chord, key))
|
||||
}
|
||||
|
||||
/// The active plugins' declared keybinds, parsed into `(chord, action)`. A
|
||||
/// keybind whose key string does not parse is skipped (its conflict-free state
|
||||
/// is surfaced by `aoe plugin info`).
|
||||
// ponytail: rebuilt per unmatched keypress; the active set is tiny and this is
|
||||
// not a hot path. Cache behind the registry generation if that ever changes.
|
||||
fn plugin_bindings() -> Vec<(Chord, PluginAction)> {
|
||||
let mut out = Vec::new();
|
||||
for p in crate::plugin::registry().active() {
|
||||
for kb in &p.manifest.keybinds {
|
||||
if let Some(chord) = parse_chord(&kb.key) {
|
||||
out.push((
|
||||
chord,
|
||||
PluginAction {
|
||||
plugin_id: p.id().to_string(),
|
||||
action: kb.command.clone(),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a key-chord string like `Ctrl+K`, `Shift+D`, `F5`, or `q` into a
|
||||
/// [`Chord`]. Supports `Ctrl`/`Shift` modifiers, single characters, and
|
||||
/// function keys. Returns `None` for anything else.
|
||||
pub fn parse_chord(s: &str) -> Option<Chord> {
|
||||
let mut ctrl = false;
|
||||
let mut shift = false;
|
||||
let mut key: Option<&str> = None;
|
||||
for tok in s.split('+').map(str::trim).filter(|t| !t.is_empty()) {
|
||||
match tok.to_ascii_lowercase().as_str() {
|
||||
"ctrl" | "control" => ctrl = true,
|
||||
"shift" => shift = true,
|
||||
// Unsupported modifiers and a second key token are rejected rather
|
||||
// than silently remapped: `Alt+K` must not collapse to a bare `k`
|
||||
// that hijacks core navigation.
|
||||
"alt" | "option" | "meta" | "super" | "cmd" => return None,
|
||||
_ if key.is_none() => key = Some(tok),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
let key = key?;
|
||||
let code = if key.len() == 1 {
|
||||
// Match the table's convention: bare letters are lowercase chars, Shift
|
||||
// is encoded as the uppercase char (terminals deliver Ctrl+k as a
|
||||
// lowercase Char with the CONTROL modifier, Shift+d as Char('D')).
|
||||
let c = key.chars().next().unwrap();
|
||||
let c = if shift {
|
||||
c.to_ascii_uppercase()
|
||||
} else {
|
||||
c.to_ascii_lowercase()
|
||||
};
|
||||
KeyCode::Char(c)
|
||||
} else {
|
||||
let n = key
|
||||
.strip_prefix(['F', 'f'])
|
||||
.and_then(|n| n.parse::<u8>().ok())?;
|
||||
KeyCode::F(n)
|
||||
};
|
||||
Some(Chord { code, ctrl })
|
||||
}
|
||||
|
||||
/// Whether a core binding already claims `chord` in either mode. Used by
|
||||
/// `aoe plugin info` to flag a plugin keybind that core shadows.
|
||||
pub fn core_shadows(chord: &Chord) -> bool {
|
||||
BINDINGS.iter().any(|b| {
|
||||
b.non_strict
|
||||
.iter()
|
||||
.chain(b.strict)
|
||||
.any(|c| c.code == chord.code && c.ctrl == chord.ctrl)
|
||||
})
|
||||
}
|
||||
|
||||
/// Human-readable label for a binding's primary chord in the given mode, e.g.
|
||||
/// `"D"`, `"Ctrl+D"`, `"F5"`. Returns `""` if the action has no binding in the
|
||||
/// requested mode (e.g. `NextWaiting` in strict).
|
||||
@@ -935,21 +800,6 @@ pub static BINDINGS: &[Binding] = &[
|
||||
}),
|
||||
},
|
||||
// Palette-only: no default chord in either mode; the manager opens from
|
||||
// the command palette (or the web Settings Plugins tab).
|
||||
Binding {
|
||||
id: ActionId::Plugins,
|
||||
non_strict: &[],
|
||||
strict: &[],
|
||||
context: Context::Always,
|
||||
help: None,
|
||||
palette: Some(PaletteMeta {
|
||||
title: "Manage plugins",
|
||||
keywords: &["plugin", "extension", "enable", "disable"],
|
||||
group: PaletteGroup::Settings,
|
||||
serve_only: false,
|
||||
}),
|
||||
},
|
||||
// Palette-only: no default chord in either mode; the manager opens from
|
||||
// the command palette (or the web Settings Skills tab).
|
||||
Binding {
|
||||
id: ActionId::Skills,
|
||||
@@ -1041,7 +891,6 @@ pub fn palette_id(id: ActionId) -> &'static str {
|
||||
ActionId::ToggleContainer => "toggle-container",
|
||||
ActionId::ToggleProjectPin => "toggle-project-pin",
|
||||
ActionId::Tips => "tips",
|
||||
ActionId::Plugins => "plugins",
|
||||
ActionId::Skills => "skills",
|
||||
ActionId::Fork => "fork",
|
||||
ActionId::AutoName => "auto-name",
|
||||
@@ -1115,88 +964,6 @@ mod tests {
|
||||
crate::session::set_favorites_first(original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_chord_handles_modifiers_and_keys() {
|
||||
assert_eq!(
|
||||
parse_chord("Ctrl+K"),
|
||||
Some(Chord {
|
||||
code: KeyCode::Char('k'),
|
||||
ctrl: true
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_chord("Shift+D"),
|
||||
Some(Chord {
|
||||
code: KeyCode::Char('D'),
|
||||
ctrl: false
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_chord("q"),
|
||||
Some(Chord {
|
||||
code: KeyCode::Char('q'),
|
||||
ctrl: false
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_chord("F5"),
|
||||
Some(Chord {
|
||||
code: KeyCode::F(5),
|
||||
ctrl: false
|
||||
})
|
||||
);
|
||||
assert_eq!(parse_chord(""), None);
|
||||
assert_eq!(parse_chord("Ctrl+"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_chord_rejects_unsupported_and_repeated_tokens() {
|
||||
// Unknown modifiers must not collapse to a bare key that hijacks core
|
||||
// navigation, and a chord may carry at most one key token.
|
||||
assert_eq!(parse_chord("Alt+K"), None);
|
||||
assert_eq!(parse_chord("Ctrl+Alt+K"), None);
|
||||
assert_eq!(parse_chord("Ctrl+K+J"), None);
|
||||
assert_eq!(parse_chord("Meta+K"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_shadows_known_core_chords() {
|
||||
// `q` is the Quit binding; an unbound chord is not shadowed.
|
||||
assert!(core_shadows(&parse_chord("q").unwrap()));
|
||||
assert!(!core_shadows(&Chord {
|
||||
code: KeyCode::Char('z'),
|
||||
ctrl: true
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_action_wraps_core_bindings() {
|
||||
// With no active plugins in the test process, the merged resolver just
|
||||
// returns the core action, wrapped as Core.
|
||||
let c = ctx();
|
||||
assert_eq!(
|
||||
resolve_action(&key('q'), false, &c),
|
||||
Some(ResolvedAction::Core(ActionId::Quit))
|
||||
);
|
||||
assert_eq!(resolve_action(&ctrl_key('z'), false, &c), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_action_canonical_is_namespaced() {
|
||||
let a = PluginAction {
|
||||
plugin_id: "acme.kit".to_string(),
|
||||
action: "do-thing".to_string(),
|
||||
};
|
||||
assert_eq!(a.canonical(), "plugin.acme.kit.do-thing");
|
||||
|
||||
// Idempotent when the manifest already targets a canonical command.
|
||||
let already = PluginAction {
|
||||
plugin_id: "acme.kit".to_string(),
|
||||
action: "plugin.acme.kit.do-thing".to_string(),
|
||||
};
|
||||
assert_eq!(already.canonical(), "plugin.acme.kit.do-thing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_strict_resolution() {
|
||||
let c = ctx();
|
||||
@@ -1380,7 +1147,7 @@ mod tests {
|
||||
#[test]
|
||||
fn fork_is_palette_only_no_chord() {
|
||||
let c = ctx();
|
||||
// No chord resolves to Fork in either mode (palette-only, like Plugins).
|
||||
// No chord resolves to Fork in either mode (palette-only).
|
||||
for ch in ['f', 'F'] {
|
||||
assert_ne!(resolve(&key(ch), false, &c), Some(ActionId::Fork));
|
||||
assert_ne!(resolve(&key(ch), true, &c), Some(ActionId::Fork));
|
||||
|
||||
+2
-42
@@ -2284,16 +2284,6 @@ impl HomeView {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(dialog) = &mut self.plugin_manager_dialog {
|
||||
match dialog.handle_key(key) {
|
||||
DialogResult::Continue => {}
|
||||
DialogResult::Cancel | DialogResult::Submit(()) => {
|
||||
self.plugin_manager_dialog = None;
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(dialog) = &mut self.skills_manager_dialog {
|
||||
match dialog.handle_key(key) {
|
||||
DialogResult::Continue => {}
|
||||
@@ -2593,22 +2583,8 @@ impl HomeView {
|
||||
has_search: !self.search_matches.is_empty(),
|
||||
project_group_selected: self.project_group_at_cursor().is_some(),
|
||||
};
|
||||
match bindings::resolve_action(&key, self.strict_hotkeys, &ctx) {
|
||||
Some(bindings::ResolvedAction::Core(id)) => return self.run_action(id, update_info),
|
||||
Some(bindings::ResolvedAction::Plugin(action)) => {
|
||||
// Tier 0 has no plugin executor; the binding resolves and is
|
||||
// inspectable, but running it waits for the runtime host (#2095).
|
||||
self.info_dialog = Some(InfoDialog::sized_to_fit(
|
||||
"Plugin action",
|
||||
&format!(
|
||||
"{} is a plugin action. Running plugin actions needs the plugin runtime, \
|
||||
which is not available yet.",
|
||||
action.canonical()
|
||||
),
|
||||
));
|
||||
return None;
|
||||
}
|
||||
None => {}
|
||||
if let Some(id) = bindings::resolve(&key, self.strict_hotkeys, &ctx) {
|
||||
return self.run_action(id, update_info);
|
||||
}
|
||||
|
||||
// Navigation / structural keys: identical in both modes, never relocate.
|
||||
@@ -2779,9 +2755,6 @@ impl HomeView {
|
||||
let profile = self.config_profile();
|
||||
self.projects_dialog = Some(ProjectsDialog::new(&profile));
|
||||
}
|
||||
ActionId::Plugins => {
|
||||
self.plugin_manager_dialog = Some(crate::tui::dialogs::PluginManagerDialog::new());
|
||||
}
|
||||
ActionId::Skills => {
|
||||
self.skills_manager_dialog = Some(crate::tui::dialogs::SkillsManagerDialog::new());
|
||||
}
|
||||
@@ -3604,19 +3577,6 @@ impl HomeView {
|
||||
fn open_serve(&mut self) {
|
||||
#[cfg(feature = "serve")]
|
||||
{
|
||||
let web_disabled = crate::plugin::registry()
|
||||
.get("aoe.web")
|
||||
.is_some_and(|p| !p.enabled);
|
||||
if web_disabled {
|
||||
self.info_dialog = Some(InfoDialog::new(
|
||||
"Web dashboard disabled",
|
||||
"The aoe.web plugin is disabled, so the web dashboard cannot \
|
||||
be served.\n\n\
|
||||
Re-enable it in Settings > Plugins (or run \
|
||||
`aoe plugin enable aoe.web`), then press R again.",
|
||||
));
|
||||
return;
|
||||
}
|
||||
self.serve_view = Some(crate::tui::dialogs::ServeView::new());
|
||||
}
|
||||
#[cfg(not(feature = "serve"))]
|
||||
|
||||
@@ -574,7 +574,6 @@ pub struct HomeView {
|
||||
pub(super) attach_project_dialog: Option<AttachProjectDialog>,
|
||||
pub(super) project_session_picker_dialog: Option<ProjectSessionPickerDialog>,
|
||||
pub(super) projects_dialog: Option<ProjectsDialog>,
|
||||
pub(super) plugin_manager_dialog: Option<crate::tui::dialogs::PluginManagerDialog>,
|
||||
pub(super) skills_manager_dialog: Option<crate::tui::dialogs::SkillsManagerDialog>,
|
||||
pub(super) command_palette: Option<CommandPaletteDialog>,
|
||||
#[cfg(feature = "serve")]
|
||||
@@ -2244,7 +2243,6 @@ impl HomeView {
|
||||
attach_project_dialog: None,
|
||||
project_session_picker_dialog: None,
|
||||
projects_dialog: None,
|
||||
plugin_manager_dialog: None,
|
||||
skills_manager_dialog: None,
|
||||
command_palette: None,
|
||||
#[cfg(feature = "serve")]
|
||||
@@ -4680,13 +4678,6 @@ impl HomeView {
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the plugin manager's in-flight discovery / update-check task.
|
||||
if let Some(dialog) = &mut self.plugin_manager_dialog {
|
||||
if dialog.tick() {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the skills manager's in-flight share.
|
||||
if let Some(dialog) = &mut self.skills_manager_dialog {
|
||||
if dialog.tick() {
|
||||
@@ -4786,7 +4777,6 @@ impl HomeView {
|
||||
|| self.project_session_picker_dialog.is_some()
|
||||
|| self.projects_dialog.is_some()
|
||||
|| self.attach_project_dialog.is_some()
|
||||
|| self.plugin_manager_dialog.is_some()
|
||||
|| self.skills_manager_dialog.is_some()
|
||||
|| self.command_palette.is_some()
|
||||
|| self.tool_picker_dialog.is_some()
|
||||
@@ -4851,7 +4841,6 @@ impl HomeView {
|
||||
|| self.project_session_picker_dialog.is_some()
|
||||
|| self.projects_dialog.is_some()
|
||||
|| self.attach_project_dialog.is_some()
|
||||
|| self.plugin_manager_dialog.is_some()
|
||||
|| self.skills_manager_dialog.is_some()
|
||||
|| self.command_palette.is_some()
|
||||
|| self.tool_picker_dialog.is_some()
|
||||
|
||||
@@ -893,7 +893,6 @@ impl HomeView {
|
||||
attach_project_dialog,
|
||||
project_session_picker_dialog,
|
||||
projects_dialog,
|
||||
plugin_manager_dialog,
|
||||
skills_manager_dialog,
|
||||
command_palette,
|
||||
tool_picker_dialog,
|
||||
@@ -1365,7 +1364,6 @@ impl HomeView {
|
||||
|| self.attach_project_dialog.is_some()
|
||||
|| self.project_session_picker_dialog.is_some()
|
||||
|| self.projects_dialog.is_some()
|
||||
|| self.plugin_manager_dialog.is_some()
|
||||
|| self.skills_manager_dialog.is_some()
|
||||
|| self.command_palette.is_some()
|
||||
|| self.send_message_dialog.is_some()
|
||||
|
||||
@@ -17,7 +17,6 @@ mod metrics_poller;
|
||||
#[cfg(feature = "serve")]
|
||||
pub(crate) mod open_url;
|
||||
#[cfg(feature = "serve")]
|
||||
pub(crate) mod plugin_ui;
|
||||
#[cfg(feature = "serve")]
|
||||
pub(crate) mod remote_home;
|
||||
pub(crate) mod responsive;
|
||||
@@ -285,12 +284,6 @@ pub async fn run(profile: &str, startup_warning: Option<String>) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Opt-in clean-only plugin auto-update sweep (off by default). Spawned
|
||||
// non-blocking so a slow remote or git never delays the TUI; applied updates
|
||||
// take effect on the next launch.
|
||||
// No notifier in the TUI: there is no plugin host / notification ring here.
|
||||
crate::plugin::auto_update::spawn_if_enabled(&crate::session::Config::load_or_warn(), None);
|
||||
|
||||
// Bail early if stdin is not a terminal. Running without a tty would
|
||||
// cause the event loop to busy-loop after the parent terminal dies.
|
||||
if !io::stdin().is_terminal() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,6 @@ use serde::Deserialize;
|
||||
|
||||
use crate::acp::client::discovery::DaemonEndpoint;
|
||||
use crate::acp::client::HttpClient;
|
||||
use crate::plugin::ui_state::UiSnapshot;
|
||||
use crate::session::config::{resolve_theme_name, resolve_theme_palette_mode};
|
||||
use crate::tui::styles::Theme;
|
||||
|
||||
@@ -54,10 +53,6 @@ pub struct RemoteHomeState {
|
||||
pub status_text: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
pub loading: bool,
|
||||
/// Latest plugin UI-state snapshot, fetched with the session list so the
|
||||
/// rows can show each session's `row-column` status (#2948). Empty until
|
||||
/// the first fetch, and after one that failed.
|
||||
pub plugin_ui: UiSnapshot,
|
||||
}
|
||||
|
||||
impl RemoteHomeState {
|
||||
@@ -69,7 +64,6 @@ impl RemoteHomeState {
|
||||
status_text: None,
|
||||
last_error: None,
|
||||
loading: true,
|
||||
plugin_ui: UiSnapshot::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,18 +223,5 @@ async fn refresh(state: &mut RemoteHomeState) {
|
||||
state.status_text = None;
|
||||
}
|
||||
}
|
||||
// Plugin row-column state rides along with the list rather than on its own
|
||||
// poll: this view has no ticker, so both refresh on open and on `r` and
|
||||
// never disagree about how stale they are. A plugin being down must not
|
||||
// replace the session list with an error page, so a failed fetch just
|
||||
// clears the cells; it is logged rather than silent, so a blank plugin
|
||||
// column is diagnosable.
|
||||
state.plugin_ui = match client.plugin_ui_state().await {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(e) => {
|
||||
tracing::debug!(target: "tui.remote_home", "plugin ui-state fetch failed: {e}");
|
||||
UiSnapshot::default()
|
||||
}
|
||||
};
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
@@ -6,69 +6,11 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::RemoteHomeState;
|
||||
use crate::plugin::ui_state::Tone;
|
||||
use crate::tui::components::truncate_to_width;
|
||||
use crate::tui::plugin_ui;
|
||||
use crate::tui::styles::{has_min_contrast, Theme};
|
||||
|
||||
const SELECTED_ROW_CONTRAST_RATIO: f32 = 3.0;
|
||||
|
||||
/// Widest a plugin's `row-column` cell may grow, in terminal cells, so a chatty
|
||||
/// plugin cannot push the project path off the row. Matches the title column's
|
||||
/// budget.
|
||||
const ROW_COLUMN_MAX_WIDTH: usize = 24;
|
||||
|
||||
/// Gap between two plugins' cells inside the same column.
|
||||
const ROW_COLUMN_GAP: &str = " ";
|
||||
|
||||
/// One plugin's `row-column` text plus the tone to paint it in.
|
||||
type RowColumnCell = (String, Option<Tone>);
|
||||
|
||||
/// A session's cells and the display width they occupy together.
|
||||
type MeasuredCells = (Vec<RowColumnCell>, usize);
|
||||
|
||||
/// One session's `row-column` cells, truncated to the shared budget, plus the
|
||||
/// display width they occupy. Separate from rendering so the column width can be
|
||||
/// computed across every listed session before any row is painted: every row
|
||||
/// pads to that one width, so a session with no cell leaves a blank of the same
|
||||
/// size instead of shifting the path column (#2948). Returns width 0 when no
|
||||
/// plugin pushed anything, which keeps the row identical to before this slot
|
||||
/// rendered at all.
|
||||
///
|
||||
/// Budgets in terminal cells, not chars: plugin text is arbitrary, and a wide
|
||||
/// glyph (an emoji status marker, CJK) paints two cells while counting as one
|
||||
/// char, which would under-measure the column and shift the path after all.
|
||||
fn row_column_cells(state: &RemoteHomeState, session_id: &str) -> MeasuredCells {
|
||||
let mut budget = ROW_COLUMN_MAX_WIDTH;
|
||||
let mut width = 0;
|
||||
let mut cells = Vec::new();
|
||||
for (text, tone) in plugin_ui::row_column_cells(&state.plugin_ui, session_id) {
|
||||
let gap = if cells.is_empty() {
|
||||
0
|
||||
} else {
|
||||
UnicodeWidthStr::width(ROW_COLUMN_GAP)
|
||||
};
|
||||
// Needs room for the gap plus at least one cell of text, else the
|
||||
// remaining plugins are dropped rather than rendered as a bare gap.
|
||||
if budget <= gap {
|
||||
break;
|
||||
}
|
||||
let text = truncate_to_width(&text, budget - gap);
|
||||
// Clamp rather than trust the helper: if it ever hands back more cells
|
||||
// than it was given, `budget -= gap + len` would underflow (panic in a
|
||||
// debug build) or wrap the budget wide open, voiding the cap this loop
|
||||
// exists to enforce.
|
||||
let len = UnicodeWidthStr::width(text.as_str()).min(budget - gap);
|
||||
width += gap + len;
|
||||
budget -= gap + len;
|
||||
cells.push((text, tone));
|
||||
}
|
||||
(cells, width)
|
||||
}
|
||||
|
||||
fn selected_row_style(style: Style, theme: &Theme) -> Style {
|
||||
let Some(fg) = style.fg else {
|
||||
return style.fg(theme.text);
|
||||
@@ -137,13 +79,6 @@ fn render_list(frame: &mut Frame, area: Rect, theme: &Theme, state: &RemoteHomeS
|
||||
frame.render_widget(para, area);
|
||||
return;
|
||||
}
|
||||
// Measure every row's plugin cells first so they all pad to one width.
|
||||
let plugin_cells: Vec<MeasuredCells> = state
|
||||
.sessions
|
||||
.iter()
|
||||
.map(|s| row_column_cells(state, &s.id))
|
||||
.collect();
|
||||
let plugin_width = plugin_cells.iter().map(|(_, w)| *w).max().unwrap_or(0);
|
||||
let items: Vec<ListItem> = state
|
||||
.sessions
|
||||
.iter()
|
||||
@@ -160,33 +95,14 @@ fn render_list(frame: &mut Frame, area: Rect, theme: &Theme, state: &RemoteHomeS
|
||||
style
|
||||
}
|
||||
};
|
||||
let mut spans = vec![
|
||||
let spans = vec![
|
||||
Span::styled(
|
||||
format!(" {:<24} ", truncate(&s.title, 24)),
|
||||
readable(title_style),
|
||||
),
|
||||
Span::styled(format!("{:<10} ", s.status), readable(status_style)),
|
||||
Span::styled(s.project_path.clone(), readable(path_style)),
|
||||
];
|
||||
if plugin_width > 0 {
|
||||
let (cells, width) = &plugin_cells[idx];
|
||||
for (i, (text, tone)) in cells.iter().enumerate() {
|
||||
if i > 0 {
|
||||
spans.push(Span::raw(ROW_COLUMN_GAP));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
text.clone(),
|
||||
readable(plugin_ui::tone_style(*tone, theme)),
|
||||
));
|
||||
}
|
||||
// Pad to the shared width, then the inter-column gap, so the
|
||||
// path starts at the same screen column on every row.
|
||||
spans.push(Span::raw(format!(
|
||||
"{:width$}{ROW_COLUMN_GAP}",
|
||||
"",
|
||||
width = plugin_width - width
|
||||
)));
|
||||
}
|
||||
spans.push(Span::styled(s.project_path.clone(), readable(path_style)));
|
||||
ListItem::new(Line::from(spans))
|
||||
})
|
||||
.collect();
|
||||
@@ -235,191 +151,6 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::acp::client::discovery::{DaemonEndpoint, Source};
|
||||
use crate::tui::remote_home::RemoteSession;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
use serde_json::json;
|
||||
|
||||
fn state_with(sessions: &[&str], entries: serde_json::Value) -> RemoteHomeState {
|
||||
let mut state = RemoteHomeState::new(DaemonEndpoint::new(
|
||||
"http://127.0.0.1:8080".to_string(),
|
||||
None,
|
||||
Source::Env,
|
||||
));
|
||||
state.loading = false;
|
||||
state.sessions = sessions
|
||||
.iter()
|
||||
.map(|id| RemoteSession {
|
||||
id: (*id).to_string(),
|
||||
title: format!("session {id}"),
|
||||
project_path: format!("/tmp/{id}"),
|
||||
status: "idle".to_string(),
|
||||
view: crate::session::View::Structured,
|
||||
})
|
||||
.collect();
|
||||
state.plugin_ui = serde_json::from_value(json!({
|
||||
"entries": entries,
|
||||
"notifications": [],
|
||||
}))
|
||||
.expect("snapshot deserializes");
|
||||
state
|
||||
}
|
||||
|
||||
fn row_column(session_id: &str, text: &str) -> serde_json::Value {
|
||||
json!({"plugin_id": "gh", "slot": "row-column", "id": "st",
|
||||
"session_id": session_id, "payload": {"text": text}})
|
||||
}
|
||||
|
||||
/// Screen column where `needle` starts on the first row carrying it. Indexes
|
||||
/// the per-cell strings from `rows`, one character per painted cell, so it is
|
||||
/// a real column: a byte offset would be skewed by the multi-byte `▸ `
|
||||
/// highlight symbol, and a character offset into the concatenated symbols
|
||||
/// would be skewed by any cell holding a multi-character cluster.
|
||||
fn column_of(painted: &[String], needle: &str) -> usize {
|
||||
let pat: Vec<char> = needle.chars().collect();
|
||||
painted
|
||||
.iter()
|
||||
.find_map(|line| {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
chars.windows(pat.len()).position(|w| w == pat.as_slice())
|
||||
})
|
||||
.unwrap_or_else(|| panic!("{needle} missing from {painted:?}"))
|
||||
}
|
||||
|
||||
/// The row text of every painted line, trailing blanks trimmed, with exactly
|
||||
/// one character per painted cell so a character index is a screen column.
|
||||
/// A cell can hold a multi-character cluster (an emoji with a presentation
|
||||
/// selector) and the trailing cell of a wide glyph holds an empty symbol, so
|
||||
/// both are folded to a single representative character.
|
||||
fn rows(state: &RemoteHomeState) -> Vec<String> {
|
||||
let theme = crate::tui::styles::load_theme_with_mode("empire", false);
|
||||
let mut terminal = Terminal::new(TestBackend::new(100, 10)).expect("terminal");
|
||||
terminal
|
||||
.draw(|f| render(f, f.area(), &theme, state))
|
||||
.expect("draw");
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
(0..buf.area.height)
|
||||
.map(|y| {
|
||||
(0..buf.area.width)
|
||||
.map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_shows_the_plugin_row_column_text() {
|
||||
let state = state_with(&["s1"], json!([row_column("s1", "CI failing")]));
|
||||
let painted = rows(&state);
|
||||
assert!(
|
||||
painted.iter().any(|l| l.contains("CI failing")),
|
||||
"{painted:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_column_pads_so_the_path_stays_aligned() {
|
||||
// s2 has no cell; both rows must start the path at the same column.
|
||||
let state = state_with(
|
||||
&["s1", "s2"],
|
||||
json!([row_column("s1", "changes requested")]),
|
||||
);
|
||||
let painted = rows(&state);
|
||||
assert_eq!(
|
||||
column_of(&painted, "/tmp/s1"),
|
||||
column_of(&painted, "/tmp/s2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_plugin_entries_reserve_no_width() {
|
||||
let painted = rows(&state_with(&["s1"], json!([])));
|
||||
// Highlight symbol (2) + title (1 + 24 + 2) + status (10 + 2), with no
|
||||
// plugin column and no gap for one.
|
||||
assert_eq!(column_of(&painted, "/tmp/s1"), 41);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_plugin_text_is_capped_so_the_path_survives() {
|
||||
let long = "x".repeat(ROW_COLUMN_MAX_WIDTH + 20);
|
||||
let state = state_with(&["s1"], json!([row_column("s1", &long)]));
|
||||
let (cells, width) = row_column_cells(&state, "s1");
|
||||
assert_eq!(width, ROW_COLUMN_MAX_WIDTH);
|
||||
assert!(cells[0].0.ends_with('…'));
|
||||
let painted = rows(&state);
|
||||
assert!(painted.iter().any(|l| l.contains("/tmp/s1")), "{painted:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_glyphs_are_budgeted_by_terminal_cells() {
|
||||
// 13 CJK chars paint 26 cells, over the 24-cell budget, though a char
|
||||
// count would have called it a comfortable fit.
|
||||
let wide = "検査失敗検査失敗検査失敗中";
|
||||
assert_eq!(wide.chars().count(), 13);
|
||||
let state = state_with(&["s1"], json!([row_column("s1", wide)]));
|
||||
let (cells, width) = row_column_cells(&state, "s1");
|
||||
assert!(width <= ROW_COLUMN_MAX_WIDTH, "{width} cells");
|
||||
assert!(cells[0].0.ends_with('…'));
|
||||
// And the painted column still lines up with a cell-less row. The four
|
||||
// CJK chars paint 8 cells, so the path starts 8 + 2 columns past the 41
|
||||
// it sits at with no plugin column; counting chars would have reserved 4
|
||||
// and left the two rows disagreeing.
|
||||
let both = state_with(&["s1", "s2"], json!([row_column("s1", "検査失敗")]));
|
||||
let painted = rows(&both);
|
||||
assert_eq!(column_of(&painted, "/tmp/s1"), 51);
|
||||
assert_eq!(column_of(&painted, "/tmp/s2"), 51);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emoji_presentation_status_text_stays_within_budget() {
|
||||
// "warning sign + VS16" is 2 cells as a cluster but its chars sum to 1,
|
||||
// which is exactly the text a CI-status plugin pushes. Budgeting per
|
||||
// char over-admitted, so this row underflowed the budget subtraction:
|
||||
// a panic in a debug build, and a wide-open cap in a release one.
|
||||
let state = state_with(
|
||||
&["s1", "s2"],
|
||||
json!([
|
||||
row_column("s1", "\u{26a0}\u{fe0f} CI failing on 5 checks"),
|
||||
row_column(
|
||||
"s2",
|
||||
"\u{2764}\u{fe0f}\u{2764}\u{fe0f} awaiting review from two people"
|
||||
)
|
||||
]),
|
||||
);
|
||||
for id in ["s1", "s2"] {
|
||||
let (cells, width) = row_column_cells(&state, id);
|
||||
assert!(width <= ROW_COLUMN_MAX_WIDTH, "{id}: {width} cells");
|
||||
let painted: usize = cells
|
||||
.iter()
|
||||
.map(|(t, _)| UnicodeWidthStr::width(t.as_str()))
|
||||
.sum::<usize>()
|
||||
+ ROW_COLUMN_GAP.len() * cells.len().saturating_sub(1);
|
||||
assert_eq!(painted, width, "{id}: measured width must match painted");
|
||||
}
|
||||
// The cap holds, so the path still renders and stays aligned.
|
||||
let painted = rows(&state);
|
||||
assert_eq!(
|
||||
column_of(&painted, "/tmp/s1"),
|
||||
column_of(&painted, "/tmp/s2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_plugin_cell_is_dropped_when_the_budget_is_spent() {
|
||||
let state = state_with(
|
||||
&["s1"],
|
||||
json!([
|
||||
row_column("s1", &"y".repeat(ROW_COLUMN_MAX_WIDTH)),
|
||||
row_column("s1", "dropped")
|
||||
]),
|
||||
);
|
||||
let (cells, width) = row_column_cells(&state, "s1");
|
||||
assert_eq!(cells.len(), 1);
|
||||
assert_eq!(width, ROW_COLUMN_MAX_WIDTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_row_style_preserves_readable_color() {
|
||||
|
||||
@@ -47,7 +47,6 @@ pub enum SettingsCategory {
|
||||
Acp,
|
||||
Diff,
|
||||
Logging,
|
||||
Plugins,
|
||||
}
|
||||
|
||||
impl SettingsCategory {
|
||||
@@ -67,7 +66,6 @@ impl SettingsCategory {
|
||||
Self::Acp => "Acp",
|
||||
Self::Diff => "Diff",
|
||||
Self::Logging => "Logging",
|
||||
Self::Plugins => "Plugins",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +88,6 @@ impl SettingsCategory {
|
||||
Self::Acp => "Acp",
|
||||
Self::Diff => "Diff",
|
||||
Self::Logging => "Logging",
|
||||
Self::Plugins => "Plugins",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,21 +409,6 @@ fn value_from_json(widget: &WidgetKind, current: Option<&Value>) -> FieldValue {
|
||||
}
|
||||
}
|
||||
WidgetKind::List => FieldValue::List(json_to_list(current)),
|
||||
// API v9 (#2897). dynamic_select and cron edit as a plain text value
|
||||
// in the TUI for now (the resolver-backed picker and the object-list
|
||||
// drill-down editor are the structured-editor follow-up); object_list
|
||||
// renders as its raw JSON array so it stays round-trippable.
|
||||
WidgetKind::DynamicSelect { .. } | WidgetKind::Cron => {
|
||||
FieldValue::Text(current.as_str().unwrap_or("").to_string())
|
||||
}
|
||||
WidgetKind::ObjectList { .. } => {
|
||||
let text = if current.is_null() {
|
||||
"[]".to_string()
|
||||
} else {
|
||||
serde_json::to_string_pretty(current).unwrap_or_else(|_| "[]".to_string())
|
||||
};
|
||||
FieldValue::Text(text)
|
||||
}
|
||||
WidgetKind::Custom { id } => custom_value_from_json(id, current),
|
||||
}
|
||||
}
|
||||
@@ -581,14 +563,6 @@ fn schema_value_to_json(
|
||||
json!(items)
|
||||
}
|
||||
}
|
||||
// API v9 (#2897): dynamic_select and cron round-trip as text;
|
||||
// object_list parses its raw-JSON text back to an array (server
|
||||
// validation rejects malformed input, so a parse failure stores null
|
||||
// and surfaces as a validation error rather than corrupting the row).
|
||||
(WidgetKind::DynamicSelect { .. } | WidgetKind::Cron, FieldValue::Text(s)) => json!(s),
|
||||
(WidgetKind::ObjectList { .. }, FieldValue::Text(s)) => {
|
||||
serde_json::from_str::<Value>(s).unwrap_or(Value::Null)
|
||||
}
|
||||
(WidgetKind::Custom { id }, value) => custom_value_to_json(id, value),
|
||||
_ => Value::Null,
|
||||
}
|
||||
@@ -704,7 +678,7 @@ pub fn build_fields_for_category(
|
||||
});
|
||||
}
|
||||
|
||||
for desc in crate::session::settings_schema::runtime_schema()
|
||||
for desc in crate::session::settings_schema::schema()
|
||||
.into_iter()
|
||||
.filter(|d| d.category == category.schema_name())
|
||||
// Global-only fields (e.g. the theme) are not profile-overridable, so
|
||||
@@ -782,19 +756,7 @@ fn build_schema_row(
|
||||
desc: &FieldDescriptor,
|
||||
ctx: &BuildCtx,
|
||||
) -> SettingField {
|
||||
// Plugin settings (`plugin:<id>` sections) live at a different storage
|
||||
// path (`plugins.<id>.settings.<field>`) and fall back to the manifest's
|
||||
// declared default when unset; core fields read their `section.field` leaf,
|
||||
// which always exists via the struct Default.
|
||||
let current = match crate::session::settings_schema::section_plugin_id(&desc.section) {
|
||||
Some(id) => crate::session::settings_schema::plugin_storage_value(
|
||||
ctx.effective_json,
|
||||
id,
|
||||
&desc.field,
|
||||
)
|
||||
.or(desc.default.as_ref()),
|
||||
None => json_at(ctx.effective_json, &desc.section, &desc.field),
|
||||
};
|
||||
let current = json_at(ctx.effective_json, &desc.section, &desc.field);
|
||||
let value = value_from_json(&desc.widget, current);
|
||||
let has_override = desc.profile_overridable && ctx.has_override(&desc.section, &desc.field);
|
||||
let inherited_display = if has_override {
|
||||
@@ -986,35 +948,13 @@ pub fn apply_field_to_config(
|
||||
};
|
||||
|
||||
match scope {
|
||||
SettingsScope::Global => {
|
||||
// Plugin settings are global-only and persist under
|
||||
// `plugins.<id>.settings`; core fields write their `section.field`.
|
||||
match crate::session::settings_schema::section_plugin_id(§ion) {
|
||||
Some(id) => set_config_plugin_path(global, id, &sub, leaf),
|
||||
None => set_config_path(global, §ion, &sub, leaf),
|
||||
}
|
||||
}
|
||||
// Plugin fields are filtered out of Profile/Repo scope (not
|
||||
// profile_overridable), so only core fields reach here.
|
||||
SettingsScope::Global => set_config_path(global, §ion, &sub, leaf),
|
||||
SettingsScope::Profile | SettingsScope::Repo => {
|
||||
set_override_path(profile, §ion, &sub, leaf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a plugin setting leaf into `plugins.<id>.settings.<field>` of the
|
||||
/// global config.
|
||||
fn set_config_plugin_path(config: &mut Config, plugin_id: &str, field: &str, leaf: Value) {
|
||||
let mut j = serde_json::to_value(&*config).unwrap_or_else(|_| json!({}));
|
||||
merge_json(
|
||||
&mut j,
|
||||
&crate::session::settings_schema::plugin_storage_leaf(plugin_id, field, leaf),
|
||||
);
|
||||
if let Ok(updated) = serde_json::from_value(j) {
|
||||
*config = updated;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a root-level (non-sectioned) config field such as `environment`.
|
||||
fn apply_root_field(
|
||||
scope: SettingsScope,
|
||||
|
||||
@@ -7,9 +7,7 @@ use tui_input::Input;
|
||||
use crate::tui::dialogs::{CustomInstructionDialog, DialogResult};
|
||||
|
||||
use super::fields::ListItemValidation;
|
||||
use super::{
|
||||
FieldValue, ListEditState, SettingsCategory, SettingsFocus, SettingsScope, SettingsView,
|
||||
};
|
||||
use super::{FieldValue, ListEditState, SettingsFocus, SettingsScope, SettingsView};
|
||||
|
||||
/// Result of handling a key event in the settings view
|
||||
pub enum SettingsAction {
|
||||
@@ -93,36 +91,6 @@ impl SettingsView {
|
||||
return SettingsAction::Continue;
|
||||
}
|
||||
|
||||
// The Plugins category hosts the plugin manager inline, with the
|
||||
// active plugins' editable settings fields beneath it. Tab toggles
|
||||
// the sub-focus between the two panes; the manager owns every key
|
||||
// while it has the sub-focus (Space stages an enable/disable, Esc
|
||||
// steps back to the category panel). With the fields sub-focused,
|
||||
// keys fall through to the normal field handling below, so plugin
|
||||
// settings edit and save exactly like core settings.
|
||||
if self.current_category() == SettingsCategory::Plugins
|
||||
&& self.focus == SettingsFocus::Fields
|
||||
{
|
||||
// Scope keys behave like on every other tab rather than being
|
||||
// swallowed by the manager: the Plugins tab is Global-only, so a
|
||||
// scope switch falls back to the new scope's first tab. Not while the manager captures input, where `[`/`{`
|
||||
// are literal text for the discovery search query.
|
||||
let scope_key = matches!(key.code, KeyCode::Char('[' | ']' | '{' | '}'))
|
||||
&& !self.plugin_manager.captures_input();
|
||||
if !scope_key {
|
||||
if key.code == KeyCode::Tab
|
||||
&& !self.fields.is_empty()
|
||||
&& !self.plugin_manager.captures_input()
|
||||
{
|
||||
self.plugins_fields_focus = !self.plugins_fields_focus;
|
||||
return SettingsAction::Continue;
|
||||
}
|
||||
if !self.plugins_fields_focus {
|
||||
return self.handle_plugins_manager_key(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normal mode
|
||||
match (key.code, key.modifiers) {
|
||||
// Close from anywhere
|
||||
@@ -413,53 +381,6 @@ impl SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Route a key to the embedded plugin manager (Plugins category). Space
|
||||
/// stages an enable/disable into this view's config; Esc/`q`
|
||||
/// (manager Cancel) returns to the category panel.
|
||||
fn handle_plugins_manager_key(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
// Space STAGES enable/disable in this view's config, like every
|
||||
// other settings row, instead of writing to disk immediately. That
|
||||
// keeps it in the Ctrl-s save flow (no surprise immediate write, no
|
||||
// file-watch flash); the row shows the pending state at once. Only
|
||||
// when the manager is not capturing input itself (a consent popup,
|
||||
// the discovery search): those own every key, Space included. Enter
|
||||
// falls through to the manager (details popup).
|
||||
if key.code == KeyCode::Char(' ') && !self.plugin_manager.captures_input() {
|
||||
if let Some(p) = self.plugin_manager.selected() {
|
||||
let id = p.id.clone();
|
||||
let enabled = !p.enabled;
|
||||
self.global_config
|
||||
.plugins
|
||||
.entry(id.clone())
|
||||
.or_default()
|
||||
.enabled = enabled;
|
||||
self.recompute_dirty();
|
||||
self.plugin_manager.set_row_enabled(&id, enabled);
|
||||
}
|
||||
return SettingsAction::Continue;
|
||||
}
|
||||
let selected_before = self.plugin_manager.selected().map(|p| p.id.clone());
|
||||
let result = match self.plugin_manager.handle_key(key) {
|
||||
DialogResult::Continue | DialogResult::Submit(()) => {
|
||||
if self.plugin_manager.take_mutated() {
|
||||
self.resync_after_plugin_mutation();
|
||||
}
|
||||
SettingsAction::Continue
|
||||
}
|
||||
DialogResult::Cancel => {
|
||||
self.focus = SettingsFocus::Categories;
|
||||
SettingsAction::Continue
|
||||
}
|
||||
};
|
||||
// Master-detail: moving the manager selection swaps which plugin's
|
||||
// settings the fields pane shows, so a selection change rebuilds the
|
||||
// (filtered) field list.
|
||||
if self.plugin_manager.selected().map(|p| p.id.clone()) != selected_before {
|
||||
self.rebuild_fields();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Drive the settings-search popup. Esc closes without changing
|
||||
/// selection; Enter jumps to the highlighted hit; up/down navigate
|
||||
/// the hit list; Ctrl+s stays reachable for saving staged edits;
|
||||
@@ -864,13 +785,6 @@ impl SettingsView {
|
||||
{
|
||||
self.focus = SettingsFocus::Fields;
|
||||
self.selected_field = idx;
|
||||
// On the Plugins tab the field list shares the right pane with
|
||||
// the plugin manager; a click on a field row must also move the
|
||||
// sub-focus there, or the keyboard would keep driving the manager
|
||||
// while the clicked field renders selected.
|
||||
if self.current_category() == SettingsCategory::Plugins {
|
||||
self.plugins_fields_focus = true;
|
||||
}
|
||||
// A click on a checkbox row toggles it in one action, like a
|
||||
// real checkbox, instead of only selecting it and waiting for
|
||||
// Space. Other field types keep select-only: their editors /
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user