Skip to main content
Glama
PeckBoard

app-manager

Official
by PeckBoard
README.md
# app-manager

A Peckboard WASM plugin that lists, installs, and removes common
applications on Linux targets — the local Peckboard host and any
configured remote SSH hosts — via MCP tools. System apps come from the
distro package manager (or a vendor script); Python packages come from
pip, tracked as their own clearly-labelled namespace.

Apps outside the catalog can be **added by hand** in the dashboard: on the
local host an AI install session identifies the software — searching the web
when it doesn't know it — and installs it from an official source only. The
entries such a row is missing (what it is, its official site, its install and
remove commands) are filled in by an AI session too, with any command it
proposes held as a suggestion until you accept it. See "Adding an App by
Hand".

It ships both an MCP tool surface (catalog, target abstraction, `app_*`
tools) and an **App Manager dashboard page** reachable from the sidebar.

## Permissions

- `provide_mcp_tools` — the `app_*` tools below.
- `data_store` — target registry and install/remove job records.
- `process_exec_any` — run commands on the **local** Peckboard host. This is
  a broad permission: it lets the plugin run any bare executable on PATH as
  the Peckboard host user. In practice this plugin only ever runs its own
  catalog's static recipes (see `src/catalog.ts`) — user input is validated
  against the catalog before anything reaches a shell — but the permission
  grant itself is not narrower than that.
- `ssh` — run commands on configured **remote** targets.
- `ssh_keys` — resolve a remote target's configured vault key by id
  (`Auth::KeyRef`) without this plugin ever seeing key material, and populate
  the page's key dropdown from `peckboard_ssh_key_list` (metadata only).
- `user_authority` — serve the page's authenticated data routes under the
  signed-in user (`http.request.authed`).
- `contribute_sidebar` — the App Manager sidebar entry.
- `models_read` — the install picker's account+model catalog. Metadata only
  (ids, display names, tiers, account ids), already filtered server-side to
  thinking-capable models; never credentials or tokens.
- `session_write` — create the temporary AI install session, and the research
  session that fills a hand-added app's blanks in (`is_temp`, in the shared
  `~/peckboard-installs/app-manager` folder core registers).
- `session_dispatch` — dispatch the install or research prompt at it.
- `session_read` — poll the session's slim event tail (`{seq, kind, name}`,
  never payloads) to render progress.

**Upgrading from 0.4.0 (or earlier) re-triggers the approval prompt.** The
permission set grew again (`models_read`, `session_write`,
`session_dispatch`, `session_read` for AI-session installs), so Peckboard
loads the new version inert until you approve it again in Settings →
Plugins.
**0.7.0 (apps added by hand) asks for no new permission** — the web search a
manual app's install session does is the session agent's own tool, not
something this plugin can do. It does widen what `process_exec_any` runs in
practice: see "Adding an App by Hand" below.
**0.8.0 (filling those rows' blank entries in) asks for none either**, and
narrows nothing: a command an AI session proposes is stored as a suggestion
and only becomes runnable when you accept it in the dashboard.
**0.8.1 is the dashboard on a phone** — layout only, no permission, route or
behaviour change.

## Dashboard Page

Sidebar → **App Manager** opens `/plugin-api/v1/app-manager`, served by
this plugin and framed in a sandboxed iframe (no `allow-same-origin`). It
talks only to its own authenticated routes, through the host's
postMessage fetch bridge:

| Route                                  | Purpose                                            |
| -------------------------------------- | -------------------------------------------------- |
| `GET /targets`                         | the target dropdown (local + configured remotes)   |
| `GET /ssh-keys`                        | vault key metadata for the key dropdown            |
| `GET /apps?target=`                    | distro banner + one grid row per app               |
| `GET /status?target=&app=`             | one app's live state + job progress                |
| `GET /install-options`                 | account+model picker options + stored default      |
| `POST /targets`, `POST /target-remove` | remote-target CRUD                                 |
| `POST /install`, `POST /remove`        | start an install (session/script) / remove job     |
| `GET /apps-custom`                     | the manually added app records (for the edit form) |
| `POST /apps-custom`                    | add or edit a manually added app                   |
| `POST /apps-custom-remove`             | forget one (uninstalls nothing)                    |
| `GET /deps?target=`                    | cached dependency graph, trees + reverse view      |
| `POST /deps-refresh`                   | re-resolve the graph from the package manager      |
| `GET /rdeps?target=&pkg=`              | system-wide reverse deps of one graph package      |

(all under `/api/plugin-ui/app-manager`.)

The page itself is a single HTML string (`src/page.ts`) that cannot import
anything, so every display decision — badge text, action label, job headline,
and the prose an error is rendered as — is made server-side in `src/view.ts`
and shipped as plain data. That is also what the vitest suite covers; the page
is pure DOM plumbing on top.

Notes on the shape of it:

- Target picker and SSH-key picker are `<select>` elements — never free text.
  The page never accepts or displays private key material; a target stores only
  the vault key's id.
- Installs never block the UI: `POST /install` returns a job id and the page
  polls `/status` every 2s, streaming the log tail with a running / succeeded /
  failed state.
- Removal goes through a confirmation that states plainly that it runs a
  package-manager command as root on the target.
- A target that isn't a usable Linux host renders as a refusal instead of an
  app grid; every error is a sentence, never raw JSON.

### Deep Link: `?install=`

Another plugin's page can point a person here with what it needs installed.
Graphify's install handoff opens:

```
/plugin-page/app-manager/app-manager?install=python3,pip,graphifyy&from=graphify
```

| Param     | Meaning                                                            |
| --------- | ------------------------------------------------------------------ |
| `install` | comma-separated catalog ids; unknown ids are named, never acted on |
| `from`    | optional label for the request bar ("graphify asked for these")    |
| `target`  | optional target id, honoured once on first load                    |

The page renders a request bar above the grid listing each app with its state
and, for the missing ones, that app's own Install button.

**The link only prefills.** It cannot start an install: the buttons are the
same ones the rows carry, so a local install still opens the account+model
picker and nothing runs until a person clicks. The query is parsed server-side
in `src/deeplink.ts` (ids must be catalog slugs, the list is capped, `from` is
stripped to plain words) and injected into the page as a JSON literal, so a
crafted URL can neither smuggle markup into the page nor name something the
catalog doesn't have.

### Local Target and Folder Scope

`peckboard_exec_any` pins its cwd to the caller's folder, and a **global**
sidebar page has no project or session to resolve one from. Core therefore
falls back to the app data dir when the caller holds full user authority and
carried no folder scope (see `exec_impl` in `src/plugin/host.rs` in the core
repo). An MCP tool call still refuses — its per-folder floor is what keeps a
plugin tool inside the calling session's reach.

## Targets

- `local` is always available and needs no configuration.
- Remote targets are records `{id, hostname, port, username, key_id}` kept
  in this plugin's own `data_store` (plugin data stores are hard-namespaced
  per plugin, so this plugin cannot see ssh-fleet's hosts, and vice versa).
  Only a vault key reference (`key_id`) is ever stored — never a password or
  private key. Populate the key dropdown from the `peckboard_ssh_key_list`
  host function.
- No MCP tool adds or removes a remote target: that is the dashboard page's
  job, through its own `POST /targets` / `POST /target-remove` routes on top of
  the `src/targets.ts` store functions.

## Catalog

`src/catalog.ts` is a plain data table — one entry per app, each with a
`detect` command, a `version` probe, per-package-manager install/remove
recipes, and (for apps not in any distro's repos) a `vendor` install/remove
script. Adding an app is a pure data change. Entries with
`namespace: "pip"` are **Python packages, not system apps**: one pip
recipe used on every distro, a `pip_package` name, and pip-based
detect/version probes (see "The pip Namespace" below).

Distro detection reads `/etc/os-release` on the target and maps
`ID`/`ID_LIKE` to one of `apt` (debian/ubuntu), `dnf` (fedora/rhel), `pacman`
(arch), `zypper` (suse). An unrecognised or non-Linux target is refused with
a clear message — never a guessed command.

## Adding an App by Hand

The catalog can't have everything. **+ Add app** in the dashboard creates a
row for software the catalog doesn't know (`src/customApps.ts`, stored in
this plugin's own `data_store` under `custom_apps`). The form takes the app's
name, the command that proves it is installed, and — optionally — an official
site, a note, an install command, and a remove command. Adding an app
installs nothing; it only creates the row.

**On the local host, the AI install session works out how.** The same
temporary session catalog apps use gets an extra set of rules for a manual
app (`officialSourceRules` in `src/installSession.ts`):

- **Identify the software first** — if the agent doesn't already know it, it
  searches the web for what the project is and how its own authors say to
  install it. If several projects share the name, it asks in the session
  rather than guessing.
- **Official sources only**: the project's own site or repository, this
  distribution's official repositories, or the project's own entry in an
  official registry (PyPI, npm, crates.io) or its own releases page. Never a
  third-party mirror, a re-upload, an unofficial PPA/COPR, a fork, or a
  binary linked from a blog or a search result.
- **Check the checksum or signature** when the project publishes one.
- **If no official source can be confirmed, stop and say so** — never install
  a lookalike.

Whatever the person typed (site, note, suggested command) goes in as a claim
to verify, never as truth. Success is still decided by re-running the app's
detect probe, exactly as for a catalog app — never by the agent's own account
— and the package-DB snapshot bracket still records what genuinely arrived.

**On a remote target it runs the install command you typed, verbatim.** An AI
session runs on the Peckboard host and has no path to a target's SSH
credentials, so there is nothing to work the method out remotely. Without a
stored install command the row is blocked there, and says why. This is worth
stating plainly:

> Every other command this plugin runs is a static recipe from
> `src/catalog.ts`. A manual app's `install_command` / `remove_command` are
> **user-authored shell**, run as the Peckboard host user (or over SSH on the
> chosen target). They can only be created from the authenticated dashboard,
> they are stored verbatim, and the page shows them back verbatim in a
> confirmation before the first run — but they are not catalog-restricted.

Everything the plugin _derives_ stays safe by construction: the id is a
validated slug, the probe binary must match `^[A-Za-z0-9._+-]+$` and is
shell-quoted into `command -v` anyway, and the homepage must be an `https://`
URL. A command is never assembled from those fields.

Other edges, deliberately visible:

- **Removal is never guessed.** With no `remove_command`, the row's only
  disposal action is **Forget**, which drops the entry from App Manager's
  list and uninstalls nothing — the confirmation says exactly that.
- **No dependency tree.** The graph is resolved from the catalog's own
  packages, so a manual app's row says dependencies aren't resolved for it
  rather than rendering an empty tree that would read as "no dependencies".
  Its provenance delta (what the package manager recorded during the install)
  is real and still shown.
- **Rows are badged "added by hand"**, and `app_list` marks them
  `source: "manual"`, so neither a person nor an agent reads one as a vetted
  catalog entry.
- **No MCP tool adds or forgets one** — that is the dashboard's job, the same
  as remote targets. `app_install`, `app_status`, `app_list` and `app_deps`
  all accept a manual app's id, and `app_record_details` fills a row's blank
  entries in (below) without being able to add, forget or arm anything.

Most rows added by hand start as a name and little else. Those entries are
filled in for you, by an AI session that reports back through this plugin's
own `app_record_details` tool (`src/researchSession.ts`, `src/tools.ts`).

- **On save**, a new app with blanks starts a temporary **research session**
  on the model the dashboard last installed with. It identifies the software
  under the same official-source rules as an install, **installs nothing**,
  and ends by calling `app_record_details`. With no model chosen yet nothing
  starts, the save still succeeds, and the toast says why; the row's **Fill
  in details** button runs it later with a model you pick.
- **After an install**, a manual app's install session is asked to record
  what it now knows as fact — the real binary, the project's own site, and
  the command that actually worked.

The tool is the only way findings get back, because a plugin cannot read a
session's transcript: `peckboard_session_events` is slim by design ({seq,
kind, name}, never payloads). The event tail is used for one thing here —
knowing whether the run is still going. What it recorded is read from the
record itself, so a run that ended without calling the tool is reported as
having recorded nothing, not as a success.

Two rules bound what a session may write (`applyResearchDetails` in
`src/customApps.ts`):

1. **Blanks only.** Anything a person typed is kept, and the tool's reply
   names the values it dropped so the agent isn't left guessing. The detect
   binary counts as blank only while it is still the id-derived guess
   (`binary_derived`); a record saved before that flag existed counts as
   typed.
2. **A proposed command is not a command.** `install_command` /
   `remove_command` land in `suggested_install_command` /
   `suggested_remove_command`, which `toCatalogApp` deliberately does not
   project into a recipe — nothing can run them. The row says a suggestion is
   waiting; the edit dialog shows it verbatim with **Use this command** /
   **Discard**. Accepting it, on an authenticated dashboard route, is what
   makes it real — and it is still shown back verbatim before it first runs.

That second rule is the point of the whole design: this plugin runs a manual
app's command verbatim on the chosen target, so an agent must not be able to
arm one on its own say-so. Everything an agent writes is validated exactly as
a person's input is — an invalid binary or a non-https site comes back as a
tool error and the record is untouched.

## Installs Are Detached Jobs — and Local Installs Run in an AI Session

`app_install`/`app_remove` don't block until completion — plugin calls are
synchronous and bounded by `call_timeout_secs`, and an `ollama`/`docker`
install can run for minutes.

**Local installs (`app_install` on the `local` target) run through a
TEMPORARY AI SESSION** instead of a detached script:

1. The user picks the **account and model** in the dashboard (a `<select>`
   fed by `peckboard_list_models` — thinking-capable models only, filtered
   server-side; the chosen id is validated against that same catalog before
   anything is created, and persisted as the default for next time).
2. The plugin takes the BEFORE package-DB snapshot, creates a temp session
   (`Install <app>`, `is_temp`, in `~/peckboard-installs/app-manager`) on
   that model, and dispatches an install prompt that mirrors the core
   install-session rules — including `sudo -A` so root steps raise the
   masked askpass dialog in the session tab.
3. `app_status` polls the session's **slim event tail** (`{seq, kind,
name}` — core never exposes event payloads to plugins), so the page
   shows tool-level activity plus an "Open install session" link. It is
   deliberately NOT a log; the real conversation lives in the session tab.
4. When the run ends (`agent-end` — emitted for completed and crashed runs
   alike), the plugin takes the AFTER snapshot and decides success by
   re-running the app's **detect probe** — never by trusting the agent's
   own account. A session that vanishes before its run ends (temp tab
   closed, cleared, killed) lands the job in a clear **failed** state with
   an "unknown" note — never a bogus empty delta recorded as success.

**Remote installs and every removal stay deterministic scripts.** An AI
session runs on the Peckboard host and has no path to a remote target's
SSH credentials; and removal is destructive, so a scripted `apt remove` is
preferred over an agent. Those paths keep the original shape:

1. The catalog recipe is wrapped and launched with `nohup sh -c '...' >
<logfile> 2>&1 &`, its PID captured.
2. A job record `{id, target_id, app_id, action, pid, logfile, status}` is
   written to the `data_store`; the tool call returns the job id
   immediately.
3. `app_status` polls: checks whether the pid is still alive and tails the
   logfile. The wrapped script also appends a `PECKBOARD_EXIT:<code>`
   sentinel line on completion, so `app_status` can tell success from
   failure without waiting on the process itself.

Session jobs reuse the same job records with `kind: "session"` plus the
session id, event cursor, and bounded activity lines (`src/jobs.ts`,
`src/installSession.ts`).

## Install Provenance

What an install _genuinely_ added is recorded by bracketing it with
package-database snapshots — never by parsing installer output or asking
an agent. Script installs take both snapshots inside the same detached
script; AI-session installs take them in plugin code around the session's
lifetime (before the prompt is dispatched, after the run ends). Either
way:

    snapshot(before) → install → snapshot(after) → delta = added packages

Snapshots dump `name + version` per line (`dpkg-query -W`, `rpm -qa --qf`,
`pacman -Q`) into `/tmp` files next to the job's logfile, through the same
target abstraction as everything else (`src/exec.ts`). When a poll first
observes the job's terminal state, the two files are read and deleted in
one exec and the delta becomes a record in the `installs` collection
(`src/provenance.ts`), keyed `<target_id>:<app_id>` — a re-install
supersedes, a successful remove deletes. `app_list` and the dashboard
surface it: the app row notes its package-DB version next to the probed
binary version, and the packages that arrived with it render as a
secondary "Installed with …" line, each with its version.

Honest edges, deliberately visible:

- **Vendor `curl | sh` installers** (claude, cursor-agent, ollama) never
  touch the package database. Their rows say so — "not tracked by the
  package manager" — instead of showing an empty list that would read as
  "no dependencies". Prerequisites such an installer does `apt-get
install` DO land in the delta and are listed normally.
- **A failed snapshot** (unsupported package manager, permission denied,
  truncated output) degrades to an explicit "unknown", never to a
  silently-empty delta.
- The record is provenance — "arrived during this job" — not a dependency
  graph: a shared library is attributed to whichever app's install pulled
  it in first. Real dependency edges must come from the package manager.

## Dependency Graph

Provenance answers "what arrived during this job"; the dependency graph
answers "what does this app require right now" — and the edges are
**queried from the package manager itself** (`apt-cache depends`,
`rpm -qR` + `--whatprovides`, `pacman -Qi`), never inferred from the
install delta. The two live in separate `data_store` collections
(`installs` vs `depgraphs`) and never overwrite each other.

It is a DAG, not a tree: install git and node and both depend on
`libssl3` — that node has two parents. The plugin honours that:

- A shared dependency appears under **every** app that requires it,
  flagged `shared`, instead of being attributed to whichever app's
  install pulled it in first.
- The remove confirmation states removal impact with **autoremove
  semantics**: only packages nothing else still depends on are listed as
  "would become unneeded"; a shared dependency another app needs is
  explicitly shown as kept, so the UI never contradicts what the package
  manager would actually do.

Cost control: resolution is seeded from the installed catalog apps'
packages plus the provenance delta set, expanded breadth-first **one
batched exec per level**, depth-limited (default 2, max 4, configurable
per refresh request) and capped at 600 nodes with a visible _truncated_
marker. The graph refreshes when an install/remove job settles and on
the explicit "Refresh dependencies" button — rendering only ever reads
the cached snapshot, which carries an `at` timestamp because dependency
sets drift with upgrades.

The dashboard grows a slim bar under the distro banner: resolution
state + refresh button, plus a reverse view — pick a library from the
dropdown and see which catalog apps require it, with an optional
system-wide `rdepends` query on demand (the package name is validated
against the stored graph before it goes anywhere near a shell). Each
installed app row gains a collapsed "Dependencies" toggle: name +
version + kind (app / library / binary) per node, shared nodes marked,
the app's own binaries listed under its root. The `app_deps` MCP tool
returns the same payload.

Honest limits, stated in the UI rather than papered over:

- **Vendor `curl | sh` installs** (claude, cursor-agent, ollama) never
  enter the package database, so they have **no dependency edges at
  all**. Their rows say "not tracked by the package manager" — never an
  empty tree that would read as "no dependencies".
- **pip/Python packages live in their own section.** They are a different
  namespace from distro packages, so they are never merged into the system
  graph's nodes/edges — see "The pip Namespace" below.
- `kind` is a display heuristic (catalog apps are "app"; `lib`-named
  packages and `.so` capabilities are "library"; everything else renders
  "binary"), and on rpm systems capabilities resolve to their first
  provider.

## The pip Namespace

pip packages are **not** dpkg/rpm/pacman packages: they live in pip's own
database, are invisible to the snapshot bracket above, and must never be
confused with system packages. The plugin treats them as a separate,
explicitly-labelled namespace:

- **Catalog**: `namespace: "pip"` entries (today: `graphifyy`, the package
  the graphify plugin's tools need) install with one pip recipe on every
  distro — `PIP_BREAK_SYSTEM_PACKAGES=1 python3 -m pip install --user
<pkg>` — into the **user site**: no root, nothing outside `$HOME`. The
  env var lifts PEP 668's externally-managed refusal on modern distros and
  is ignored by older pips. `python3` and `pip` themselves are ordinary
  system catalog entries (and `python3` deliberately has **no remove
  recipe** — removing the system Python can dismantle the OS).
- **Probes are pip's own**: presence via `pip show <pkg>`, versions via
  `pip list --format=freeze`, dependency edges via `pip show`'s
  `Requires:` / `Required-by:` lines. Never via the distro package DB.
- **Provenance**: a pip install records `method: "pip"` and
  `tracking: "pip"` (`package_tracking: "pip"` on the MCP surface) — the
  snapshot bracket is deliberately skipped, so an unrelated background
  distro change can never be attributed to a pip app.
- **Dependency view**: pip packages ride along on a dependency refresh as
  their own "Python packages (pip)" block (`pip_packages` in the
  `app_deps` payload), never merged into the system graph's nodes/edges.
  A host without pip just leaves the block empty.
- **UI**: pip rows and entries carry a distinct `pip` badge.

One honest limit: the plugin only tracks pip's **user/system site** for
the target's `python3 -m pip`. Virtualenvs are invisible — in particular,
the graphify plugin's legacy self-install into a folder-root
`.graphify-venv/` is neither seen nor managed here.

## sudo

Recipes that need root use `sudo -A`, matching the core convention (see
`src/service/askpass.rs` and `web/src/utils/installSession.ts`).

- **AI-session installs** (local): the agent runs `sudo -A` inside a real
  session, so the askpass bridge works — the password prompt appears as a
  masked dialog in the session tab (the dashboard flags it as "waiting for
  your answer" and links there).
- **Script installs/removals**: a plugin's own exec calls do not have the
  askpass bridge wired in, so `sudo -A` fails cleanly with sudo's own
  stderr (e.g. "a password is required") rather than hanging — that
  message shows up in the job's log tail via `app_status`.

## Build

```bash
./build.sh
# or: npm install && npm run build
```

Requires `extism-js` on PATH. Output: `dist/plugin.wasm`. Copy it to
`<dataDir>/plugins/app-manager.wasm` (the file stem is the plugin id)
and approve it in Settings → Plugins.

## Renamed from linux-app-manager

Through 0.2.0 this plugin shipped as `linux-app-manager`, and the wasm file
stem is the plugin id. If an older copy is still staged, **delete
`<dataDir>/plugins/linux-app-manager.wasm` when you stage
`app-manager.wasm`** — two staged copies declare the same `app_*` tool
names, and core silently drops whichever set loads second. Core migrates the
plugin's stored data (configured remote targets, job records) from the old
plugin id to `app-manager` automatically at startup, provided the new id has
no data yet.

## Test

```bash
npm test
```