Files
yvgude--lean-ctx/docs/dev/addon-bootstrap-engine.md
wehub-resource-sync 26382a7ac6
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
JetBrains Plugin / Actionlint (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (rust) (push) Waiting to run
JetBrains Plugin / Validation (push) Waiting to run
JetBrains Plugin / Build (push) Waiting to run
JetBrains Plugin / Test (push) Blocked by required conditions
Security Check / Security Scan (push) Waiting to run
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

193 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Addon Bootstrap Engine — Phase 2 (implemented)
> Status: **implemented** in `rust/src/core/addons/bootstrap.rs` (+ manifest,
> policy, store, CLI wiring). This document is the design of record for the
> GitLab epic *Addon Bootstrap Engine* (`root/lean-ctx#1105`, subtasks
> `#1106``#1110`). Where the shipped behaviour refines the original plan it is
> noted inline.
## Problem
Today `lean-ctx addon add` is **declarative**: it appends a
`[[gateway.servers]]` entry to the global config and records the install — it
never fetches or installs a package. That is sufficient for **ephemeral
runners** (`npx`, `uvx`), which download and execute their package lazily on the
first tool call. So `repomix` and `serena` already install on add.
It is **not** sufficient for tools that need a real, one-time bootstrap before a
runnable command exists:
| Tool | Why a runner can't do it |
|-----------|----------------------------------------------------------------------|
| Headroom | Ships as `headroom-ai[all]`; `uvx --from` breaks on its entry points — needs `uv tool install`. |
| Graphify | `uv tool install "graphifyy[mcp]"` **and** a pre-built `graph.json`. |
| Cognee | Clone + `uv sync`; no single-command runner. |
| Letta | `npm i -g` plus a long-running server instance. |
For these the registry stays **listed** (homepage + manual instructions). The
bootstrap engine closes that gap: a declarative `[install]` block that lean-ctx
can execute idempotently, with a clean uninstall path and the same security bar
as the rest of the addon system.
## Goals / non-goals
**Goals**
- One declarative `[install]` block per addon, pinned and auditable.
- Idempotent install + reliable uninstall (no orphaned global packages).
- Reuse the existing trust/audit pipeline — no new ad-hoc shell-outs.
- Keep Phase-1 behaviour unchanged when no `[install]` block is present.
**Non-goals**
- No arbitrary script execution (no `curl | sh`, no inline shell).
- No secret provisioning (Mem0 / Claude-Context keys stay the user's job; the
engine only documents and validates the required env names).
- No new network fetch of the registry itself (still compiled-in / signed override).
## The `[install]` manifest block
```toml
[install]
manager = "uv" # one of: pip | uv | cargo | npm | brew | dotnet
package = "headroom-ai[mcp]" # the package spec the manager understands
version = "0.27.0" # MANDATORY exact pin (no ranges, no "latest")
bin = "headroom" # binary the [mcp] command expects (PATH idempotency)
# verify = ["headroom", "--version"]# optional argv probe; exit 0 ⇒ already installed
```
Rules (enforced by `AddonInstall::validate()`, called from `manifest.validate()`):
- `manager` ∈ a fixed allowlist (`uv`/`pip`/`cargo`/`npm`/`brew`/`dotnet`). Each manager
maps to a **fixed argv template** the engine owns — the manifest never supplies
raw shell.
- `version` is required and must be an exact pin (empty / `latest` / `*` are
rejected).
- `package`, `version`, `bin` and every `verify` element are rejected if they
contain shell metacharacters (`| ; & $ \` > <`, newlines) — defence-in-depth,
since the engine never uses a shell.
- **Idempotency check** (shipped refinement): `verify` is *optional*. With no
`verify`, the engine checks whether `bin` resolves on `PATH`; `verify` is an
escape hatch (argv, exit 0 ⇒ installed) for tools whose presence needs a
deeper probe.
- The block is only meaningful together with a runnable `[mcp]` block whose
`command` is produced by the install (e.g. `headroom`).
### Manager → argv templates (engine-owned)
| `manager` | install argv | uninstall argv |
|-----------|--------------------------------------------------|----------------------------------|
| `uv` | `uv tool install {package}=={version}` | `uv tool uninstall {base}` |
| `pip` | `pip install --user {package}=={version}` | `pip uninstall -y {base}` |
| `cargo` | `cargo install {base} --version {version}` | `cargo uninstall {base}` |
| `npm` | `npm install -g {package}@{version}` | `npm rm -g {base}` |
| `brew` | `brew install {package}` (formula carries the pin, e.g. `node@22`) | `brew uninstall {base}` |
| `dotnet` | `dotnet tool install --global {package} --version {version}` | `dotnet tool uninstall --global {base}` |
`{base}` is `{package}` with extras and any inline version stripped
(`headroom-ai[mcp]` → `headroom-ai`), keeping an npm scope intact
(`@scope/pkg`). The manifest chooses a manager + package + pin; it **cannot**
influence the flags or inject extra argv. Every value is passed as a *discrete*
argv element via `std::process::Command` — no shell, no interpolation.
## Install lifecycle
The executor lives in `addons/bootstrap.rs` (`ensure_installed` / `uninstall`)
and is orchestrated by the CLI (`cli/addon_cmd.rs`) *after* consent and *before*
the health probe. The core `addons/install.rs` stays pure — it only persists the
receipt — so its unit tests never spawn a process.
```mermaid
flowchart TD
add["addon add <name>"] --> has{has [install]?}
has -- no --> wire["wire [[gateway.servers]] (Phase 1)"]
has -- yes --> gate["bootstrap gate: validate + consent"]
gate --> present{already present? (verify argv)}
present -- yes --> wire
present -- no --> run["run engine-owned install argv (pinned)"]
run --> verify["run verify argv → must exit 0"]
verify -- ok --> record["record install receipt (manager, package, version, bin)"]
record --> wire
verify -- fail --> rollback["best-effort uninstall + abort, no wiring"]
```
- **Idempotency**: check presence *first* (`verify` argv, else `bin` on `PATH`);
if already satisfied, skip the manager entirely and just wire. Re-running `add`
is safe and reports `Already installed — skipped`.
- **Pre-flight**: when an install *is* needed, verify the manager itself exists
(`LEANCTX_BOOTSTRAP_<MGR>` override path, else the bare name on `PATH`) before
spawning it. A missing manager fails with an install hint
(`install uv → https://docs.astral.sh/uv/…`) instead of a raw spawn error, and
the `add` disclosure shows a `requires: <manager> on PATH — ✓/✗` line up front
so the gap is visible *before* consent.
- **Receipt**: `<data_dir>/addons/installed.json` carries an `install` record
(manager, package, version, bin) — content-only, no timestamps, so it stays
determinism-friendly (#498). `remove` reads it to uninstall.
- **Uninstall**: `addon remove` runs the manager's uninstall argv for packages
*this engine installed* (tracked by receipt) — never something the user had
already. It is best-effort: a failed uninstall logs a note but never blocks the
unwire that already succeeded.
- **Failure**: a non-zero manager exit aborts `add` before anything is wired. A
clean install whose `bin` is not yet on `PATH` is a non-fatal warning (a PATH
setup issue, not a failed install), and the subsequent health probe still
guards a truly broken wiring.
## Security gates
The bootstrap surface is gated at four layers (shipped):
- **Structural validation** (`AddonInstall::validate()`, hard error): unknown
manager, missing/floating version, or shell metacharacters in
`package`/`version`/`bin`/`verify` reject the manifest. Because it runs from
`manifest.validate()`, every path is covered — `addon add`, `addon audit`,
`from_path`, and the registry validator (so a bad block fails CI's
`bundled_registry_passes_security_validator`).
- **Capability coherence**: a declared `[install]` block makes
`trust::wiring_uses_network` return `true`, so an addon that *also* declares
`[capabilities] network = "none"` trips the existing `cap_net_underdeclared`
audit — same gate as the `npx`/`uvx` runner case.
- **Consent**: the `add` preview prints the **exact** install + uninstall argv,
the manager, the package and the pin *before anything runs*, then requires the
standard yes/no (`--yes` to skip in CI). `add` itself is the user's explicit,
consented action.
- **Policy floor**: `addons.allow_bootstrap` (global-only). Default **on** — the
whole point is that `add` installs — but a team that forbids local
package-manager execution sets it to `false`, and `policy::gate` refuses any
`[install]` addon before a single command runs.
## What this unlocks — and the honest migration status
The engine is generic across all five managers. Registry entries flip to
install-on-add **only when the tool actually ships a clean, pinned,
runnable-out-of-the-box MCP server** — never with fabricated wiring.
| Tool | Status | Why |
|-----------|---------------|-----|
| **Headroom** | ✅ migrated | `uv tool install "headroom-ai[mcp]"` (pinned `0.27.0`) → `headroom mcp serve`; a local, secret-free stdio MCP server. The flagship install-on-add. |
| Graphify | listed | Package installs cleanly (`graphifyy[mcp]`), but its MCP server needs a **pre-built `graph.json`** (`python -m graphify.serve graph.json`) — no out-of-the-box server to probe. |
| Cognee | listed | MCP server needs a **repo clone + `uv sync`** (upstream issue #1815); no working pinned one-liner yet. |
| Letta | listed | A pinned `letta-mcp-server` package exists, but the server needs `LETTA_API_KEY` + a Letta backend to start — key-gated, not one-click. |
Mem0 and Claude-Context likewise remain key-gated: the engine *could* install
their package, but cannot provision `MEM0_API_KEY` / `OPENAI_API_KEY` + Milvus —
those stay documented prerequisites. Each tool above flips to installable with a
**one-line registry change** (an `[install]` + `[mcp]` block) the moment upstream
ships a clean server — no further engine work.
## Rollout — done
1. ✅ `[install]` parsing + validator gates.
2. ✅ Install/uninstall executor (`bootstrap.rs`), gated by `addons.allow_bootstrap`.
3. ✅ Headroom migrated; the bundled registry stays green on
`bundled_registry_passes_security_validator`. Graphify/Cognee/Letta wait on a
clean upstream MCP server (see table) rather than shipping broken wiring.
## Operational notes & open questions
- **Manager path override** (shipped): set `LEANCTX_BOOTSTRAP_<MANAGER>` (e.g.
`LEANCTX_BOOTSTRAP_UV=/opt/uv`) to pin the exact manager binary for locked-down
environments; otherwise the manager is resolved from `PATH`.
- Open: per-manager cache/location detection for richer "already present" checks
(today: `verify` argv, else `bin` on `PATH`).
- Partly shipped: `add` now pre-flights the manager's existence (with an install
hint); a full `doctor` sweep over *already-installed* addons is still open.
- Open: Windows support for the manager templates (the executable probe already
falls back to "is a file" off-unix; argv templates assume POSIX managers).