Ctrl K
ComunidadGuías…

docs/module-spec.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# aibox module spec

Each aibox "module" is an independent tool, living under `tools/<name>/` in the repo, installed / updated / uninstalled / dispatched uniformly by the main CLI `aibox`.

## Manager source layout (src/aibox + the bundler)

The manager CLI ships as **one file** (`bin/aibox`, what `install.sh` downloads) but is
**maintained as many**: `src/aibox/*.sh`, concatenated in lexical order by
`scripts/bundle.sh`. `bin/aibox` is a generated artifact — CI (`bundle.sh --check`) fails
when it drifts from the sources.

- `00-head.sh` — shebang, `set -euo pipefail`, the `AIBOX_*` paths/consts (only this
  fragment may carry a shebang or top-level shell settings)
- `05-env.sh` … `90-main.sh` — one file per domain (ui/download/registry/state/proxy/
  preflight/modules/base/upgrade/status/purge/main); `90-main.sh` holds the dispatch
  guard, so sourcing `bin/aibox` from tests still never dispatches
- `tools/_shared/lib/*.sh` — the shared library used by BOTH the manager bundle and module
  hooks; `tools/_shared/common.sh` is the generated module-side include
  (`includes: [common]`) and is the same bytes concatenated into the manager, so
  "manager twin of the shared lib" no longer exists as a concept

A module may split its own library by domain (`lib.sh` + `lib-<domain>.sh`, sourced
by lib.sh, declared in `files:` and downloaded with the rest); those files are
libraries, not hooks — no shebang, no strict-mode line (same rule as `lib.sh`).

Rule: a helper that both the manager and modules need lives in `tools/_shared/lib/` — never
as a second copy inside `src/aibox/`.

### Contract capability version (`module_iface`)

The manager and a module share a CONTRACT SURFACE beyond the hook names: the
`status_info` keys, the `residue:` stanza, the `upgrade:` stanza and the hook
behaviour. Modules declare the surface they target:

```yaml
module_iface: 1     # module.yaml
```

- `AIBOX_IFACE_SUPPORTED` (shared library) is the manager's side of the contract
  and is exported to every hook, so a module can refuse politely.
- Bump the number on any **RENAME or REMOVAL** in that surface; pure additions do
  not need a bump (absent/unknown = 1, which is how pre-contract modules keep
  working).
- The manager warns ONCE per run when a module targets something newer than it
  supports, pointing at `aibox update self`; the validator WARNs when a module
  omits the field and ERRORs when it is not an integer.

### One config, read at runtime (credentials)

The provider's contract (`$AIBOX_HOME/base[-<profile>].env`) is the **only** place
credentials and endpoints live. Consumers read it — nothing keeps a second copy:

| mechanism | where | notes |
| --- | --- | --- |
| **contract export** | `base_contract_export` (shared) — the manager calls it before dispatch, so every hook/CLI/compose inherits it | shell env wins over `--env-file`, so a rotated provider secret takes effect on the next run |
| **env-file injection** | consumers pass `--env-file <profile contract>` to their compose calls | profile-aware; compose interpolates `${AIBOX_POSTGRES_*}`/`${AIBOX_REDIS_*}` with the CURRENT values |
| **service `env_file:`** | optional, for apps that read env themselves | **does not** feed `${…}` interpolation — never use it to build a URL |

Rules (validator-enforced, `S13k`):

1. a module artifact (compose/lib/install/update) must not contain a literal
   credential (`PASSWORD=<literal>` → ERROR);
2. it must not silently default one (`${AIBOX_POSTGRES_PASSWORD:-aibox}` → ERROR): a
   wrong fallback gets baked into `.env` and survives every recreate — that is exactly
   how a 5-char legacy default crash-looped an app after base rotated its secret;
3. derived strings (`base_pg_url <db>` / `base_redis_url <index>`, shared) are
   evaluated at call time, never written into a module's files;
4. `doctor` runs `contract_drift_report` over the module's own deploy `.env` and warns
   when it holds a stale copy (restarting the module picks up the contract).

### Port allocation (the aibox reserved band)

Host ports are a shared resource: pick them from a band that collides with nothing.

| band | use |
| --- | --- |
| **30000–30999** | existing service ports (new-api 30300, pi-web 30141) |
| **31000–31999** | services (UIs, APIs, proxies) — the default for new modules |
| **32000–32999** | infrastructure (PostgreSQL, Redis, …) |

Three zones are refused, each for a concrete reason:

1. **< 1024 (privileged)** — needs `CAP_NET_BIND`, and 80/443 belong to whatever real
   web server the host runs. A public port is allowed only when explicitly declared
   with a `:public` suffix on the `ports:` entry (e.g. `- 443/tcp:https:public`);
2. **common-service conventions** — 3000, 5000, 8080/8081, 8443, 8888, 8929, 9000,
   9090, 7890, 8000/8002/8003: every other tool on the box defaults to these, so a
   collision is a matter of time (and the failure looks like "some other service");
3. **Linux's ephemeral range (32768–60999)** — the kernel hands these to *outbound*
   connections first: a container binding one can fail at random, hours later. (The
   old defaults and the first profile bands sat inside it — that is why they moved.)

Rules:

- Every host-published port a module ships MUST be declared in `module.yaml` `ports:`
  (the declaration feeds `aibox status`, the port-conflict check and the validator).
  Container-internal ports (80/443/5432/6379/3000 …) are unaffected by this policy.
- Profile-derived ports are allocated from the infrastructure band by
  `tools/_shared/lib/60-profile.sh` (`profile_port`), never hand-picked.
- Changing a default only affects **fresh installs**: deployed instances pin their
  ports (`.env`, host conf) and keep them. Migrating an existing instance:
  `aibox <module> config set <PORT_KEY> <new>` then restart the stack.
- `port_policy_hint` (shared library) is the runtime side of the rule: `doctor` and
  the install-time port check explain *why* an out-of-band port is risky. The
  validator makes it a build error for new modules (`S13j`).

### Profiles and port allocation

A profile is a second instance of a module family (`aibox --profile <name> …`).
Its ports are **derived from the name** (so the same name means the same ports on
every machine) and then **registered**: `$AIBOX_HOME/ports.conf` maps
`<profile> pg=… redis=… web=…` (data, mode 600, parsed never sourced).

Derivation and registration live in `tools/_shared/lib/60-profile.sh`
(`profile_hash`, `profile_port`, `profile_register`, `profile_owner`,
`profile_conflicts`, `profile_ensure`) — base and pi-web call them; no module
carries its own copy. `profile_ensure` runs at module-lib load and **refuses to
start into a live collision** with another profile (exit `4`, precheck failed,
with the owning profile named); a registered-but-dead owner is not a conflict.

The default profile (`base`) is exempt: it keeps the module's declared ports.

### File ownership in a deploy root (managed vs state)

| kind | declared as | update behaviour |
| --- | --- | --- |
| **managed** | `files:` (shipped templates/code: compose files, conf templates) | refreshed by `install_managed_file`; if the on-disk copy differs from what the module installed (i.e. the user edited it), the edit is KEPT and the new version lands as `<name>.new` (with a warning) |
| **state** | `state_files:` (`.env`, user-owned data) | never overwritten by any hook or by `aibox update` |

Hooks must copy shipped files with `install_managed_file <src> <dst>` (shared
library), not `cp` — the manifest in `<deploy-root>/.managed.sha256` is what lets
the next update tell "our previous version" from "the user's edit". A file may
not be listed in both `files:` and `state_files:` (validator ERROR); an install
hook that touches `.env` without declaring `state_files:` gets a WARN.

### Generated README blocks (actions + config keys)

A module README's action table and config-key table are the same facts as
`module.yaml` (`actions:`/`usage:`/`env:`), so they are GENERATED:

```
<!-- BEGIN GENERATED: actions (scripts/gen-docs.sh) -->
<!-- END GENERATED: actions -->
<!-- BEGIN GENERATED: config (scripts/gen-docs.sh) -->
<!-- END GENERATED: config -->
```

`scripts/gen-docs.sh` (re)writes the blocks; `--check` is a CI gate, so a README
can no longer drift from the contract. Prose stays hand-written around them.

### State model (three layers, parsed never sourced)

| layer | where | who owns it | examples |
| --- | --- | --- | --- |
| **host** | `$AIBOX_HOME/{config,installed.sh,registry.cache,residue.conf,profiles/*.conf}` | the manager | proxy config, install markers, registry cache, residue declarations, profile hashes |
| **deployment** | `$AIBOX_HOME/apps/<name>[-<profile>]/.env`, `/etc/<name>/<name>.conf` | the module (user-editable) | ports, image pins, module config |
| **contract** | `$AIBOX_HOME/base[-<profile>].env`, `redis[-<profile>]-<module>.env`, `upgrades/<m>.state` | written by the provider, read-only for consumers | shared-PG/Redis connection facts, upgrade state |

Rules:

1. **Data is not code.** No state file is ever sourced: config, registry cache,
   clash state, profile confs and deploy `.env` files are parsed with the shared
   readers (`cfg_kv_load`, `cfg_kv_get`, `cfg_kv_load_export`) — values are
   literal, never executed (`scripts/check-sources.sh` fails the build on a
   `. "…"` of a data file).
2. **One writer per file.** Contract files are written by their provider only
   (`base start` writes `base.env`; the manager's `create` writes the per-module
   Redis env). Consumers read.
3. **Derived values are never hand-written** into docs — `aibox status` is
   the authoritative view (spec §Doc hygiene).

### Supply chain (content verification)

Module scripts are code that runs as the user, so every fetched file is checked
against `modules.SHA256SUMS` (repo root; generated by `scripts/manifest.sh`,
CI-gated with `--check` exactly like `bin/aibox`):

- `download_module` fetches the manifest from the same source as the module and
  verifies each file (module files + the shared include) before use
- mismatch → die with the file name and `AIBOX_VERIFY=0` as the explicit bypass
- missing manifest (older branch, plain mirror, no sha tool) → a loud
  "downloaded unverified" info line, never a silent success
- the bootstrap `install.sh` keeps its own optional `AIBOX_SHA256`/`AIBOX_VERIFY`
  check for `bin/aibox` itself

Contributor rule: any change under `tools/**` (hooks, libs, module.yaml, compose,
the shared library) must be followed by `scripts/manifest.sh`; CI fails otherwise.

### module.yaml readers (one implementation)

The registry dialect (scalars, flat lists, two-space maps) is parsed by the shared
library only:

| reader | use |
| --- | --- |
| `parse_yaml_module_stdin <mu>` | whole file → `AIBOX_MODULE_*` vars (manager registry loader, validator) |
| `meta_field <yaml> <field>` | one scalar, or the flat list under a field joined by spaces |
| `meta_map_value <yaml> <k> <c>` | a two-space map member (e.g. `usage.<action>`) |
| `meta_version <yaml>` | the version field — what module libs use |

Module libs MUST NOT hand-parse `module.yaml` (`sed`/`awk`/`grep`); the validator
treats it as an ERROR. Their version line is:

```bash
MODULE_VERSION="${AIBOX_MODULE_VERSION:-$(meta_version "${LIB_SELF}/module.yaml")}"
```

The manager injects `AIBOX_MODULE`, `AIBOX_MOD_DIR` and (where the dispatch already
knows it) `AIBOX_MODULE_VERSION`; the fallback keeps a hook run by hand working.

## Onboarding a new module (normative)

Adding a module = **scaffold + fill the contract + prove conformance**. Two repo tools own the
mechanics; this spec owns the rules. `tools/gitlab/` is the reference implementation onboarded
with exactly this flow.

### Tooling

| Tool | Purpose |
|------|---------|
| `scripts/new-module.sh <name> [--desc "..."] [--no-compose] [--out <dir>]` | Generates a conformant skeleton: `module.yaml` (incl. the mandatory `checks:`), `lib.sh`, the four hooks, `svc.sh`, `README.md`, `docs/DEVELOPMENT.md`, optional `docker-compose.yml` — then runs the validator on it, so a fresh scaffold PASSes out of the box |
| `scripts/validate-module.sh <name> \| --all` | The conformance gate — same rules CI enforces (this file is the rulebook, the validator is the executable form). Zero dependencies; auto-uses `yq` / `shellcheck` / bash 3.2 when present. Exit 0 ⇔ 0 ERRORs (WARNs tolerated) |

### Checklist (definition of done)

1. **Scaffold**: `scripts/new-module.sh <name>` → `tools/<name>/` skeleton (validator PASSes immediately).
2. **`module.yaml`**: real `version`/`description`/`upstream` links; `ports` declared (unique across modules — the validator detects conflicts); `deps` (strict-checked at preflight); `services: base:<component>#<name>[_usage]` when consuming the shared base; **`checks:` is MANDATORY** (§Preflight checks below); `usage:` entries for every declared action (§Per-action help).
3. **Hooks**: `install/uninstall/update/svc.sh` per §Hook contract — bash shebang, `set -euo pipefail`, idempotent, `${VAR}` braces (AGENTS.md pitfalls #1/#8), no bash-4-only syntax (pitfall #2); shared code in `lib.sh` (sourced library: no shebang/strict line).
4. **Deploy conventions**: root `$AIBOX_HOME/apps/<name>`, config under `/etc/<name>/`, named volumes only, no hardcoded credentials (§Deploy directory & config path conventions).
5. **Service modules**: `actions` containing `start` MUST provide the full lifecycle `start/stop/restart/status/logs` (+ module-specific actions like `credentials`); a `status` action with a module-owned rich view (`render_status` in `lib.sh` — the manager prefers module-owned over its generic fallback); self-starting services use the platform-native init system (AGENTS.md rule 9).
6. **Docs**: `README.md` (commands / ports / env overrides / preflight — ERROR if missing) + `docs/DEVELOPMENT.md` (upstream links, version-pin policy, design decisions, known quirks — WARN if missing).
7. **Prove**: `scripts/validate-module.sh <name>` → 0 errors; `bats tests/*.bats` green; live smoke on a docker host: `aibox install <name>` → `<name> start` → `status` → `logs` → `stop` → `uninstall`.
8. **Residue declaration**: add a `residue:` stanza to `module.yaml` —
   `paths` (extra locations; `$HOME`/`$AIBOX_HOME`/`$ETC_DIR` are expanded), `containers`,
   `volumes`, `units` (system-level unit file names), `bin` (dispatched binaries),
   `npm`, `process`. `aibox autoclean` must clean the module's leftovers AFTER the module (or
   aibox itself) is uninstalled and offline: the stanza is captured into
   `$AIBOX_HOME/residue.conf` at install/update time, and the manager derives the rest
   generically (deploy root + every profile variant, module cache, `/etc/<name>`).
   Dynamic cases override `residue_paths()` in `lib.sh` (base does). Validator WARNs when
   a module declares neither.
9. **Register in docs**: add the module row to `README.md` / `README.zh.md`; `aibox status --available` picks the module up automatically (`module.yaml` is the registry).

## Directory layout

```
tools/<name>/
├── lib.sh          # shared functions (optional; hooks source it to reuse)
├── install.sh      # required: install
├── uninstall.sh    # recommended: uninstall
├── update.sh       # optional: update
├── svc.sh          # optional: action entry point; $1=action (long-lived service; or just pass-through to a dispatched command)
└── README.md       # module docs
```

## Registering a module — via `module.yaml` (source of truth)

> As of v0.4.0, **`module.yaml` is the source of truth** for module metadata. The legacy `registry.sh` has been **removed**; `load_registry` auto-discovers `tools/*/module.yaml`:
>
> - **Local source (`file://`)**: the main CLI globs `tools/*/module.yaml` directly (no `registry.sh` needed — adding a module = create `tools/<name>/` + `module.yaml`, zero global changes).
> - **Remote source (`https://`)**: the GitHub API lists `tools/` dirs, then fetches each `module.yaml` over HTTPS. Results are cached to `~/.aibox/registry.cache` (1h TTL) to dodge the unauthenticated GitHub API rate limit (60 req/hour/IP).

Create `tools/<name>/module.yaml`:

```yaml
name: <name>                       # required. Hyphenated name.
version: x.y.z                     # required.
description: "one-line description"
platform: ""                       # optional; empty = cross-platform; darwin = macOS-only
dir: tools/<name>                  # required. repo dir
deps:                              # optional. runtime deps (command names)
  - "node:22"                      #   node:22 = major version >= 22
  - "docker@linux"                 #   @linux = check on Linux only
  - npm                            #   bare = no version/platform constraint
ports:                             # optional. ports occupied (port/proto:usage) — CI detects conflicts
  - 30141/tcp:http
files:                             # optional. extra files only; the standard 6 (module.yaml + lib.sh/install.sh/uninstall.sh/update.sh/svc.sh) are implicit — module.yaml rides in the cache so per-module help/metadata is local
  - docker-compose.yml
hooks:                             # required. hook filenames
  install: install.sh
  uninstall: uninstall.sh
  update: update.sh
  svc: svc.sh
actions:                           # optional. actions svc supports (list all)
  - start
  - stop
usage:                             # per-action help text — `aibox <module> --help` renders one
  start: "Start the service"       # line per declared action; validator WARNs on gaps
  stop: "Stop the service"         # format: <action>: "<one-line description, args hint>"
upstream:                          # optional. dev-guide links (see the upstream stanza below)
  homepage: https://...
  docs: https://...
services:                          # optional. shared-component deps (CI validates provider/component;
                                   # `aibox install` auto-installs missing providers first)
  - base:postgres#<your-db>
checks:                            # REQUIRED. preflight contract (enforced by install/update; see below)
  disk_gb: 5                       #   recommended free disk (GB); INFORMATIONAL — warns only
  domains:                         #   HOST-probed domains (git/npm/curl consumers); probed as https://<host>/
    - github.com
  docker_pull: hello-world         #   optional. DAEMON-routed pull probe (registry consumers; tiny image)
  docker_images:                   #   optional. when ALL are cached locally, domain+pull probes are skipped
    - postgres:18
  commands:                        #   optional. binaries that must exist (cmd@platform supported; no auto-install)
    - systemctl@linux
includes:                         # shared-library includes (§Shared library includes): repo-level
  - common                         #   tools/_shared/<name>.sh → cached per-module as _<name>.sh
```

Field reference: this document IS the normative schema (the historical design draft
`docs/module-system-spec.md` §2.3 is superseded).

> **History note (registry.sh):** before v0.4.0, modules were registered by appending `AIBOX_MODULE_<name>_*` shell variables to a root-level `registry.sh` (sourced directly by the CLI). That file no longer exists; the same fields now live in `module.yaml` and are parsed by a zero-dependency awk subset parser at runtime (`parse_yaml_module_stdin`), with yq validating the subset in CI. Module names with hyphens map to underscored variable keys internally (`pi-web` → `AIBOX_MODULE_pi_web_*`); that convention is unchanged.

## Hook contract

- aibox downloads the files listed in `files` (plus the standard 6 — module.yaml + the 5 hooks — implicitly) to `~/.aibox/modules/<name>/`, then invokes them as `bash <dest>/<hook>.sh [args]`.
- Hooks can `source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"` to reuse shared functions.
- aibox injects these environment variables:

  | Variable | Description |
  | --- | --- |
  | `AIBOX_HOME` | aibox state dir |
  | `AIBOX_MODULE` | current module name |
  | `AIBOX_RAW` | repo raw base URL (may be a `file://` local source) |
  | `AIBOX_BIN_DIR` | main CLI install dir; module install paths start here |
  | `AIBOX_PROXY_URL` | effective proxy URL; empty when disabled. May come from aibox config or pre-existing env |
  | `AIBOX_NO_PROXY` | comma-separated addresses that bypass the proxy |
  | `AIBOX_PROXY_ENABLED` | `1` enabled / `0` disabled |

  Standard vars `http_proxy` / `https_proxy` / `all_proxy` / `no_proxy` are also exported, **lowercase and uppercase both** — curl only honors lowercase `http_proxy` (it ignores uppercase `HTTP_PROXY`), while apt-style tools only honor uppercase; the two sets differ in practice. See [Proxy](#proxy) below.

  The color vars `C_RST` / `C_DIM` / `C_BOLD` / `C_GRN` / `C_YEL` / `C_RED` / `C_CYA` are also exported (empty when piped or `NO_COLOR=1`). A module's `log`/`warn`/`ok`/`die` should use `${C_*:-}` fallbacks and match the manager's output system — plain `log` (no prefix), symbol-prefixed `warn`/`ok`/`die` (⚠/✓/✗, two-space gap) — no per-module color setup is needed (single source of truth in `bin/aibox`).
- `install.sh` installs the module itself (paths are self-determined, e.g. pi-web writes a launchd plist).
- `svc.sh`: `$1` = action, rest pass through.
  - **`svc.sh` is an "action entry point", NOT "must be a daemon".** Long-lived-service modules (e.g. pi-web) implement `start/stop/restart/status/logs/diagnose`. Dispatch-only modules (e.g. openmaic) can just pass through: `exec <dispatched-cmd> "$1" "$@"`, where the action set is that command's subcommands. The contract is "the file named by `hooks.svc` receives `(action, args...)`" — nothing more.
- Start install paths from `${AIBOX_BIN_DIR:-$HOME/.local/bin}` and expose a module-specific override (deploy hosts often want `/usr/local/bin`). The manager resolves `AIBOX_BIN_DIR` with the bootstrap's precedence (explicit → `~/.local/bin` when already in `PATH` → an in-PATH writable system dir from `AIBOX_SYSTEM_BIN_DIRS` → `~/.local/bin`), so module CLIs land where the manager lives.
- Platform differences: warn, don't hard-block. Install is usually cross-platform; the real limit is reported by the script at execution time, which is less false-positive-prone than blocking at install.

### Data-purge contract (`AIBOX_PURGE_DATA`), self uninstall & residue purge

Data deletion is ONE flag across the CLI: **`--purge`**.

```text
aibox uninstall <module> [--purge]     tear down one module; --purge also deletes its DATA
aibox autoclean [<module>...|self] [--apply] [--stop] [--yes]
                                       after-the-fact residue scan/cleanup (see below)
aibox uninstall self [--purge] [--yes] remove the manager; --purge = cascade full teardown
```

- `aibox uninstall self` (default): removes ONLY the manager — `$AIBOX_BIN_DIR/aibox`,
  `$AIBOX_HOME` state and the marked `# aibox` rc PATH block. **`apps/` is preserved** so
  surviving deployments stay manageable; module services and data are KEPT and the summary
  prints the teardown paths. Confirm gate: TTY asks; non-interactive requires `--yes`.
- `aibox uninstall self --purge`: cascade — runs every installed (module, profile)'s
  `uninstall.sh` with `AIBOX_PURGE_DATA=1` (services + data via the hooks), drops `apps/`,
  then removes the manager + rc block. One command, composed from existing semantics.
- `aibox uninstall <m> --purge`: single-module equivalent (hook with `AIBOX_PURGE_DATA=1`
  - the manager sweeps `apps/<m>`).
- Hooks run from the module cache (`~/.aibox/modules/<m>/uninstall.sh`) — no registry
  fetch, so uninstall works offline.

**Hook obligations** (`uninstall.sh` receives `AIBOX_MODULE`, `AIBOX_PROFILE`,
`AIBOX_PURGE_DATA`):

- Default (`AIBOX_PURGE_DATA` unset/0): stop the service and remove the module's own
  *program* artifacts (compose files, binaries, units). **Data is preserved** — volumes,
  deploy roots, `/etc/<name>` — print the exact manual-removal commands.
- `AIBOX_PURGE_DATA=1`: ALSO delete the module's *data*: docker volumes, deploy-root
  contents, `/etc/<name>` config, scheduled units. The module knows its own volume
  names/paths — the manager never guesses them.
- Missing binaries must not short-circuit the hook (purge/retention of the deploy root
  still runs): no early `exit 0` before the data branch.

After the hook, `--purge` runs two manager-side sweeps the hook contract never covered:

- declared docker `networks:` — the project bridges a crashed `compose down` can lose a
  race over (live-caught: a crash-looping container left `app_default` behind), and
- declared docker `images:` — built/pulled *program* artifacts, not data (a built image
  has no hook owner; live-caught: a purged stack left ~2G of images plus its build cache)

Both apply the reclamation proofs: declared pattern + no container references (+ an image
another installed module claims is kept). `--purge` then VERIFIES instead of trusting the
flag: the module is rescanned and any remaining item is reported with
`aibox autoclean <module> --apply`.

### Residue cleanup (`aibox autoclean`)

`aibox autoclean` must remove what modules (or aibox itself) left behind **after** they are
uninstalled and **offline**. The knowledge therefore belongs to the module:

1. `module.yaml` `residue:` stanza (declarative: `paths`/`containers`/`volumes`/`networks`/`images`/`units`/`bin`/`npm`/`process`),
   or a `residue_paths()` override in `lib.sh` for dynamic cases (base: per-profile env files).
   Pattern semantics: `containers`/`volumes`/`networks` match the docker object NAME,
   `images` matches the `repo:tag` REF (so `^app-openmaic:` is an image, `:16$` pins a tag);
   all are EREs, matched with `grep -E`
2. captured into `$AIBOX_HOME/residue.conf` at install/update time (plain KEY=VALUE, never sourced)
   — this is what survives `aibox uninstall <module>` and a stale registry cache
3. the manager derives the rest generically: `$AIBOX_HOME/apps/<name>` + every named profile's
   variant, `$AIBOX_HOME/modules/<name>`, `/etc/<name>`, the declared `bin`

There is no per-module residue map in the manager any more (that map was module-internal
knowledge living in the manager — every new path needed a manager edit). The list of
modules the "scan everything" path visits is DERIVED as well (module cache dirs +
installed markers + the registry cache + `residue.conf` keys), so a newly added module
is scanned without touching the manager.

Two invariants guard the scan:

- **Installed modules are never touched** (any profile): their deploy roots, module
  cache, `/etc/<name>`, containers, volumes, networks and images ARE their live
  deployment — skipping them is what keeps `autoclean --apply` from demolishing a
  running stack. They are listed as skipped with a pointer to
  `aibox uninstall <module> --purge`.
- **`aibox uninstall self --purge` ends with the reclamation half** (before the manager
  itself goes away): docker-level debris loses its owner with the cascade, and after
  the next step nobody is left to prove and reclaim it.

aibox autoclean                          # dry-run scan: categorized residue report (default)
aibox autoclean <module>... --apply      # clean specific modules' residue
aibox autoclean self --apply             # the manager's own residue (bin, state, rc block)
aibox autoclean --apply [--stop] [--yes] # everything; --stop authorizes stopping RUNNING
                                     # containers/processes (refused by default)
```

Rescue when aibox itself is already deleted (single file — offline, zero deps):

```bash
curl -fsSL https://raw.githubusercontent.com/lichengwu/aibox/main/bin/aibox -o /tmp/aibox
bash /tmp/aibox autoclean --apply
```

Safety: deletion targets come only from the embedded map + existence checks; RUNNING
containers (and their volumes) and live processes are refused without `--stop`; `--apply`
in a non-interactive shell requires `--yes`.

## Deploy directory & config path conventions

Modules fall into two classes, **judged by "is there runtime data that lives and dies with the deploy instance"**:

- **Install-type** (e.g. `pi-web`): only drops things into `${AIBOX_BIN_DIR}` and hands the service to the platform
  (the launchd plist must go in `~/Library/LaunchAgents`, logs in `~/Library/Logs`).
  **No deploy root** — stick to platform-conventional locations; forcing them under `apps/` breaks platform conventions.
- **Deploy-type** (e.g. `openmaic`, `windmill`): besides the CLI, drops runtime data on the target
  (compose files, `.env`, data volumes, backups, locks, logs). **Use the paths below — don't invent your own.**

### Deploy root: `$AIBOX_HOME/apps/<name>`

```sh
APPS_ROOT="${AIBOX_APPS_ROOT:-${AIBOX_HOME:-$HOME/.aibox}/apps}"
BASE_DIR="${<MODULE>_BASE_DIR:-$APPS_ROOT/<name>}"
```

> ⚠️ When expanding, **don't drop the `.aibox` segment**. Writing `${AIBOX_HOME:-$HOME}` resolves to
> `$HOME/apps/<name>` instead of `$HOME/.aibox/apps/<name>` — the script doesn't error, but the path is wrong
> (this was hit in practice; caught only by a test asserting the exact path).
> A safer form is `${AIBOX_HOME:-${HOME:+$HOME/.aibox}}`: if `HOME` is empty, the whole thing is empty and a guard errors out.

**The same expression works on macOS and Linux — no platform branch.** Resolved values:

| Platform | User | Deploy dir |
| --- | --- | --- |
| macOS | normal user | `~/.aibox/apps/<name>` |
| Linux | root | `/root/.aibox/apps/<name>` |

Before switching roots, verify these three constraints in order (the first two are empirical findings, not style):

1. **Must be in the container-runtime default share list.** Docker Desktop (macOS) by default only shares `/Users`,
   `/Volumes`, `/private`, `/tmp`, `/var/folders` — putting the deploy dir under `/opt` makes compose **relative mounts**
   (e.g. `- ./Caddyfile:/etc/caddy/Caddyfile`) fail with `Mounts denied`. This is unrelated to privileges; `sudo` won't fix it.
2. **The identity running daily commands must be able to write.** Lock files, logs, backups all live in the deploy dir, and **every command writes** — putting it under a path that needs privilege escalation means even `status` (read-only) needs sudo, i.e. unusable.
   (By contrast, config is "write once, read daily", so it's allowed under `/etc`.)
3. **Centralized but not mixed namespaces.** Under `$AIBOX_HOME` lets aibox enumerate/audit/clean uniformly
   (`$AIBOX_HOME/apps/*`), but **you must use a sub-dir like `apps/`** to keep it separate from
   `$AIBOX_HOME/modules/<name>/` (the downloaded hook scripts) — otherwise the two share a name and differ by one level, making accidental deletion likely.

> ⚠️ `apps/` holds **deploy instances**, not "aibox state": their lifecycle outlasts aibox itself.

### Mandatory corollaries (skip any and you get an incident)

- **`aibox uninstall self` must be fail-safe.** The default removes ONLY the manager (binary +
  state + rc block) and KEEPS module services/data — `apps/` survives so running deployments stay
  manageable, and the summary prints the teardown paths (`aibox uninstall <m> --purge`,
  `aibox autoclean --apply`). `--purge` cascades: every installed (module, profile)'s hook with
  `AIBOX_PURGE_DATA=1`, then `apps/`, then the manager. Confirm gate: TTY asks; non-interactive
  without `--yes` exits non-zero and changes nothing ("dangerous ops never run silently").
- **systemd / launchd units must bake the resolved absolute paths in explicitly**, e.g.
  `Environment="AIBOX_HOME=/root/.aibox"`.
  **Empirically: there is no `HOME` variable in a systemd system service** (`systemd-run /usr/bin/env` only outputs `USER=root`).
  Any path derived from `$HOME` resolves to empty in the service context → points at the wrong dir.
- **Fail when unresolvable, don't construct paths.** If `HOME` is empty and no explicit value is set, fail and exit,
  rather than producing a path like `/.aibox/apps/<name>`.
- **Module uninstall deletes only the module's own body**; `apps/<name>` and config files are retained (they belong to "this deployment").

### Config path: `/etc/<name>/<name>.conf` (same name cross-platform)

| | Config dir | Deploy dir |
| --- | --- | --- |
| Write frequency | write once, read daily | **every command writes** |
| Location | `/etc/<name>/` | `$AIBOX_HOME/apps/<name>` |
| Allowed under privileged path | yes (privilege once at install) | no (daily commands would all need sudo) |

- Permissions: dir `755 root:root`, file **`644`** — **writes only happen at install** (can escalate),
  **reads happen on every command** (can't escalate). **So don't put credentials here.**
- **Seeded** by `install.sh` (from aibox global settings); policy is **supplement, don't overwrite**; the CLI **reads but never writes**.
- Don't use `/opt/<name>.conf`: `/opt`'s convention is "one package, one dir"; a lone config file has unclear ownership,
  and at uninstall you wouldn't know whether to delete it.
- When the config file doesn't exist, built-in defaults apply — behavior is identical to before its introduction.

> Current module status:
>
> - `openmaic` **aligned** (deploy root `$AIBOX_HOME/apps/openmaic`, config `/etc/openmaic`);
> - `windmill` **aligned** (deploy root `$AIBOX_HOME/apps/windmill`, config `/etc/windmill/windmill.conf`; CLI bash-3.2 compatible, scheduled tasks via systemd units);
> - `clash` **aligned** (deploy root `$AIBOX_HOME/apps/clash`; no `/etc` config — the subscription contains a token, so state/config/pool.yaml are all mode 600 under the deploy root; the mihomo binary is downloaded from GitHub by the install hook);
> - `pi-web` is install-type — no deploy root, paths stay platform-conventional.

## Dependency contract (consuming the shared `base`)

`base` is the shared PostgreSQL 18 + Redis 7 provider. Everything about linking a module to
it is normative here, because every one of these rules was violated by the 2026-09 review
and each violation was silent (wrong instance, shared keyspace, no rollback path).

**Linking (profile-aware — never hardcode paths).** The connection file is
`$AIBOX_HOME/base.env` for the default profile and `$AIBOX_HOME/base-<profile>.env` for
named ones. Use the shared helpers (`includes: [common]` is mandatory for any module that
consumes base):

```bash
base_env_file        # the profile's connection file
base_env_check       # version check + actionable failure (call before starting)
base_pg_container    # aibox-base[-<profile>]-postgres
base_network_name    # from the env file, else the derived name
ensure_shared_base   # idempotent "provider is up" (starts it when down)
ensure_shared_db <db>          # idempotent CREATE DATABASE (self-heal on every start)
ensure_shared_redis_db <m> [n] # allocate/read the module's Redis slot range
redis_env_file <module>        # AIBOX_REDIS_DB / AIBOX_REDIS_SLOTS for compose --env-file
```

**The `base.env` contract** (written by `base start`, mode 600):

| Key group | Keys | Notes |
| --- | --- | --- |
| contract | `AIBOX_BASE_ENV_VERSION`, `AIBOX_BASE_PROFILE`, `AIBOX_BASE_MODULE_VERSION`, `AIBOX_BASE_READY` | consumers validate the version (`base_env_check`); keys are ADDITIVE only — a rename/removal bumps `AIBOX_BASE_ENV_VERSION` in BOTH `base/lib.sh` and the helpers |
| postgres | `AIBOX_POSTGRES_HOST/PORT/USER/PASSWORD` | `HOST` is the container name, `PORT` the container-internal 5432 (consumers are on the same network) |
| redis | `AIBOX_REDIS_HOST/PORT/PASSWORD` | Redis runs WITH auth; the password is generated once (or operator-set) and never rotated silently |
| network | `AIBOX_BASE_NETWORK` | profile-derived; consumers must take it from here (not from a default) |

`AIBOX_BASE_READY=1` is written only after `pg_isready` + an authenticated Redis `PING`
succeed — `base start` waits (bounded, `AIBOX_BASE_READY_TIMEOUT`) instead of returning the
moment `compose up -d` does.

**Redis isolation.** One logical DB per module (`base create redis <module> [slots]` →
`apps/<root>/redis-dbs.conf` registry + the module's `redis-env-file`). A bare `base:redis`
declaration allocates nothing and is a validator error. `dify` reserves 3 consecutive slots
(logic DB / celery broker / agent).

**Deploy root is profile-scoped.** `deploy_root()` must end with `$(profile_suffix)`:
two profiles sharing one deploy directory means the second install overwrites the first
one's `.env`/compose (ports, images, DB name). The default profile keeps the unsuffixed
path, so existing deployments do not move.

**Resource declarations are contracts.** `services: base:postgres#<db>` must match what the
module actually connects to (`/…/<db>` in its compose/CLI); the validator cross-checks.
Declare only what you use (a spurious `base:redis` starts the provider for nothing).

**Reverse direction.** The manager lists installed dependents (`_base_dependents`) and gates
`base stop`, `uninstall base` and `purge base` behind a named warning; purge also scans
`apps/<name>-<profile>` roots for every profile.

**base's own lifecycle.** `aibox base dump|restore` (whole-cluster `pg_dumpall` + Redis
snapshot) and `aibox base upgrade [--check|--pg <tag>|--redis <tag>|--rollback]`: the image
pins live in the deploy root's `.env` (never in the module cache, which an update
overwrites); the verb dumps first, rewrites the pin, recreates through the readiness gate,
rolls the pin back on failure (exit 10, or 20 when the rollback is unhealthy too) and writes
the SAME `$AIBOX_HOME/upgrades/base.state` the manager uses, so `aibox status base` and
`aibox upgrade base --rollback` work with it.

## Proxy

Users can set a global proxy via `aibox proxy set <url>` (see the repo README). Modules consume it two ways:

**1. Via env vars (the common case — do nothing)**

Hooks are child processes of aibox; the proxy is already exported by the parent, so calling `curl` / `git` / `npm` just works.

**2. Persisted into the module's own config (required for cross-machine / cross-time)**

If the module's dispatched command will network on **another machine** or **when aibox isn't present** — e.g. the `openmaic`-dispatched CLI runs `openmaic upgrade` on the deploy host to pull source — env vars can't cross that boundary, so **the module must write the proxy into its own config file** in the install/update hook. See `sync_proxy_to_conf` in `tools/openmaic/lib.sh`.

Caveats:

- When no proxy is configured, `AIBOX_PROXY_URL` is empty and `AIBOX_PROXY_ENABLED=0`; the module should **skip gracefully** rather than erroring or writing an empty value.
- Don't assume the proxy is HTTP. The value may be `socks5://host:port`; **pass it through wholesale**, don't prepend `http://`.
- When writing the config file, remember to redact — the proxy URL may contain `user:pass@`; don't log it in cleartext (see `mask_url`).

## Download source pools (per-family acceleration)

**Pattern** (applies to EVERY download aibox performs): maintain a pool of
mainstream accelerated sources per family, **probe them concurrently with a
real download through the real channel**, rank by measured speed, serve from
the fastest, and **fail over down the ranking until every reachable source is
tried** — then (and only then) fail. DIRECT always races: healthy networks keep
zero-overhead defaults. User-configured mirrors join as candidates (raced, not
pinned). Mirrors that serve divergent/corrupt content are EXCLUDED from the
shipped pools (measured: ghproxy.link/ghproxy.cn truncated/wrong-size; tencent
node-dist index diverges).

### Family inventory

| family | consumer | mechanism (where) | shipped pool (live-verified) |
| --- | --- | --- | --- |
| npm | pi-web install/update | `npm_registry_pick` + `npm_install_global` (tools/pi-web/lib.sh) — concurrent tarball-throughput probe + wall-clock watchdog failover | registry.npmjs.org, registry.npmmirror.com, mirrors.cloud.tencent.com/npm, mirrors.huaweicloud.com/repository/npm |
| GitHub raw/api/releases | manager registry + module download + self-update + upgrade mappings | `gh_pool_fetch` (bin/aibox) — race + per-family file cache (TTL) + ghapi pseudo-candidate | direct, gh-proxy.com, ghproxy.net, CLASH_MIRROR/AIBOX_GH_MIRROR |
| GitHub (bootstrap) | install.sh (bin/aibox, SHA256SUMS) | `fetch_pool` (install.sh, bootstrap-local) — first-success race | direct + the same mirrors + AIBOX_GH_MIRROR |
| GitHub releases (~20MB) | clash mihomo download | `clash_gh_get` + `clash_rank_candidates` + resumable `download_mihomo` (tools/clash/lib.sh) — bounded rate probes of the actual asset, probe partial seeds the resume | direct + the same mirrors; CLASH_MIRROR joins |
| docker.io images | dify / gitlab / base compose pulls | `docker_pool_prepull` (each module's lib.sh) — direct daemon probe (healthy → zero overhead); dead → rank mirrors by concurrent hello-world pulls, pre-pull + `docker tag` (mirrors proxy identical digests) | docker.1ms.run, docker.m.daocloud.io, dockerproxy.net, hub.rat.dev (tencent-family excluded) |
| docker.io images (dispatched) | openmaic compose pulls (deploy host) | `docker_pool_prepull` hooked into its CLI's `compose()` on `up*` (tools/openmaic/cli/openmaic) — same mechanism | the same 4 mirrors |
| node dist | install_dep's nvm branch (node:22 deps) | `_node_dist_pick` (bin/aibox) — concurrent bounded probes of the REAL index nvm fetches; winner exported as NVM_NODEJS_ORG_MIRROR | nodejs.org, npmmirror.com/mirrors/node, mirrors.aliyun.com/nodejs-release (≥100KB size floor — a 404 page is small AND fast) |
| ghcr + docker.io (images) | windmill | `auto_ghcr_mirror` + `WM_HUB_MIRROR` (tools/windmill/cli/windmill) — throughput probe (layer growth), persist to .env, failure-driven re-probe | ghcr.nju.edu.cn, ghcr.dockerproxy.net — the in-repo PRECEDENT that seeded this pattern |
| npm (inside docker build) | openmaic render-service | baked `--registry=npmmirror` Dockerfile patch (tools/openmaic/cli) — predates this pattern | registry.npmmirror.com |

### Knobs (per family)

| family | env vars |
| --- | --- |
| npm | `AIBOX_NPM_REGISTRY` (pin), `AIBOX_NPM_REGISTRIES` (list), `AIBOX_NPM_TIMEOUT` (watchdog), `AIBOX_NPM_PROBE_TIMEOUT` |
| GitHub (manager) | `AIBOX_GH_POOL` (list; `direct` = off), `AIBOX_GH_MIRROR`/`CLASH_MIRROR` (user mirror), `AIBOX_GH_POOL_TIMEOUT`, `AIBOX_GH_POOL_TTL` (ranking cache) |
| GitHub (clash) | `CLASH_MIRROR`, `CLASH_TAG_TIMEOUT`, `CLASH_PROBE_TIME`, `CLASH_DOWNLOAD_TIMEOUT`, `CLASH_DOWNLOAD_ATTEMPTS` (per source) |
| docker.io | `AIBOX_DOCKER_POOL` (list; `direct` = off), `AIBOX_DOCKER_MIRROR` (user mirror), `AIBOX_DOCKER_FORCE_POOL` (skip the direct probe), `AIBOX_DOCKER_PROBE_TIMEOUT`, `AIBOX_DOCKER_MIRROR_PROBE_TIMEOUT`, `AIBOX_DOCKER_PULL_TIMEOUT` |
| node dist | `AIBOX_NODE_POOL` (list; `direct` = off), `AIBOX_NODE_MIRROR`, `AIBOX_NODE_PROBE_TIMEOUT` |
| ghcr (windmill) | `WM_GHCR_MIRROR`, `WM_GHCR_CANDIDATES`, `WM_GHCR_PROBE`, `WM_HUB_MIRROR` |

### Static preflight gates superseded by runtime pools

Where a module's runtime pool handles the family, the STATIC preflight gate
would false-fail exactly the mirror-saved networks, so it is removed (the deps
check still hard-gates the tool's existence):

- pi-web: no `domains: registry.npmjs.org` — `npm_registry_pick` probes at install time
- clash: no `domains: api.github.com/github.com` — `clash_gh_get`/`clash_rank_candidates` handle it
- dify / gitlab / base: no `docker_pull: hello-world` — `docker_pool_prepull` probes the daemon's direct route and falls back to the mirror pool; the upgrade engine's pre-pull no longer aborts on direct failure (warn + continue; health gate + auto-rollback remain the safety net)

### Boundaries (deliberately NOT pooled)

- the clash **subscription URL** — the user's own provider, fetched by mihomo (plain-curl probes 403 by design)
- `cr.weaviate.io` (dify's vector store) — no mainstream mirror proxies it; direct-only, documented
- openmaic's in-build npm — already accelerated via its Dockerfile patch

## Configuration (`env:` stanza + `config` action)

**Model — "seed at install, store after"**: the deploy's store is the single source of truth; environment variables are install-time seeds only. `PI_WEB_PASSWORD=x aibox update` takes effect ONCE and is written into the store; afterwards plain restarts use the stored value.

**Declaration (`module.yaml env:`)** — the CLI-discoverable config surface, same flat-map parser subset as `checks:`/`usage:`:

```yaml
env:
  PI_WEB_PASSWORD: "random — HTTP Basic Auth password (user pi) [secret]"
  PI_WEB_BIND: "0.0.0.0 — listen address"
  AIBOX_NPM_TIMEOUT: "240 — npm watchdog seconds [knob]"
```

Value shape: `default — description [flags]`. Flags: `secret` (masked in `config` listings; `config get` returns the plaintext) and `knob` (env-only runtime knob — declared for discovery, NOT persisted, excluded from the `config` view).

**Store (per module type)**:

| type | store | write | apply |
| --- | --- | --- | --- |
| compose | `apps/<m>/.env` | `cfg_kv_set` (shared helper) | `aibox <m> restart` (compose up -d recreates) |
| service-defined (pi-web) | plist `EnvironmentVariables` / unit `Environment=` | regenerate the whole definition (`write_service` — the single writer) | `restart` = bootout + bootstrap (RE-READS the definition; the old kickstart -k did not apply changes — live-caught) |
| CLI (windmill) | `/etc/<m>/<m>.conf` | `cfg_kv_set` | next invocation (immediate) |
| state (clash) | — | — | exempt: subscription/node state has its own action semantics |

**User surface**: `aibox <m> config` (list: value + masked secrets + defaults), `config get KEY` (plaintext single value, script-friendly), `config set KEY VALUE` (writes the store; interactively offers the apply, non-interactive prints the apply command), `config unset KEY` (back to the declared default). `aibox <m> --help` renders the declared keys (offline, local-first).

**Key mapping**: declared keys are the aibox-facing names; the store may carry the app's runtime names (pi-web: `PI_WEB_BIND` → plist `PI_WEB_HOSTNAME`, `PI_WEB_PORT` → `PORT`). The module maps them in its store read/write helpers — one user-facing name per knob.

**Shared helpers** (`tools/_shared/common.sh`): `cfg_kv_get/set/unset` (KEY=value stores: comments, order and mode preserved; idempotent), `cfg_env_declare` (module.yaml env: → tab-separated declaration lines), `cfg_secret_p`/`cfg_mask`, `cfg_action` (the generic `config` action implementation — a compose module's `config)` is one call), `cfg_confirm_apply` (default-Y, non-interactive declines to a hint).

**Validator (S17d)**: env entry format ERROR; every declared key documented in README (WARN); every README env-table key declared (WARN — the discoverability gap this closes); declared keys referenced in module code (WARN).

## Per-action help (`usage:` stanza)

Every module declares a `usage:` map in `module.yaml` — one entry per declared action. `aibox <module> --help` (and bare `aibox <module>`, `help`, `-h`) renders a fixed-column action table from this stanza; `aibox <module> <action> --help` renders the single action's usage block. Both are local-first (module cache → registry, no network when installed).

```yaml
actions:
  - start
  - credentials
usage:
  start: "Start the stack (docker compose up -d)"
  credentials: "Show admin + database credentials"
```

Format rules:

- One line per action: `<action>: "<description>"`. Args hints go FIRST, separated by ` — `: `"<node-name> — switch the active node"` — the action-level help splits on that separator to build `usage: aibox clash select <node-name>`.
- The **validator WARNs** when a declared action has no `usage:` entry (the table still renders, just without a description).
- The stanza is a flat two-space-indented map (same parser subset as `checks:`) — no nesting.
- Hyphenated action keys (`use-external`) are supported; the registry parser normalizes hyphens to underscores in variable names (`usage_use_external`), and the help renderer reads the cached `module.yaml` directly.
- An action with no usage entry (or a typo'd action name) + `--help` falls back to the module table — never a dead end.

## Shared library includes (`includes:` stanza)

Infrastructure code that every module needs (output helpers `log/warn/ok/info/die` + the docker.io download source pool) lives ONCE in the repo at `tools/_shared/common.sh`. A module declares it, and the downloader ships it into the module cache:

```yaml
includes:
  - common
```

- **Repo**: single source (`tools/_shared/common.sh`) — a pool fix or helper change lands once, not in N copies.
- **Cache**: each module dir gets its own copy (`~/.aibox/modules/<name>/_common.sh`) — modules stay **self-contained per-directory** (the dispatched-at-any-time constraint is unchanged; nothing depends on the aibox process).
- **lib.sh** resolves both layouts:

```bash
LIB_SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_COMMON="${LIB_SELF}/_common.sh"                # cache layout (aibox install)
[ -f "${LIB_COMMON}" ] || LIB_COMMON="${LIB_SELF}/../_shared/common.sh"  # repo layout (direct exec / bats)
# shellcheck disable=SC1091
. "${LIB_COMMON}"
```

Rules:

- The include is fetched FIRST in `download_module` (before hooks) so a failed fetch never leaves the cache half-updated.
- **Consuming the shared base? Guard the ACTION, not just the install.** A module whose `services:` declares `base:*` must call `ensure_shared_base` (from `_common.sh`) in every action that needs the provider UP (`start`/`restart`) — it is idempotent (silent when the base is already up) and starts the base when it is down. Dying with "first: aibox base start" makes the user run two commands for one intent (live-caught on `aibox xiaozhi start`). The manager runs the same ensure before dispatching `start`/`restart` (covering the resource-less `base:redis` form, which used to be skipped), so both entry points are safe.
- **Guard missing primitives with `require_docker` (or a named check), never a raw shell error.** Any action that shells out to docker must call `require_docker` (provided by `_common.sh`) in its wrapper — it dies with `docker CLI not found — this action needs it`. A bare `docker …` in a deep lib function surfaced as `tools/base/lib.sh: line 138: docker: command not found` (exit 127) with no hint about what the action needs (live-caught on `aibox base status`). Same rule for every external the action cannot work without (the manager has `require_curl` for the same reason).
- Dispatched CLIs that run standalone on deploy hosts (openmaic/windmill `cli/<name>`) **cannot** use includes — they keep their own copies by design (pitfall #4: env vars and sources don't cross process/host boundaries).
- New shared libraries follow the same shape: `tools/_shared/<name>.sh` + `includes: [<name>]`; validator checks the entry resolves to an existing file.

## User command → hook mapping

| User command | Hook |
| --- | --- |
| `aibox install <name>` | `install.sh` |
| `aibox uninstall <name>` | `uninstall.sh` |
| `aibox update <name>` | `update.sh` (manager re-fetches module scripts first) |
| `aibox upgrade <name>` | manager engine — locates the deploy .env via the module's `lib.sh deploy_root`, snapshots the data when the DB is knowable, rewrites image keys, then invokes `svc.sh start`; `--check` / `--rollback` / `--history` / `--no-backup` (see §Component upgrades) |
| `aibox <name> <action>` | `svc.sh <action>` |

## Component upgrades (`aibox upgrade <module>` — upstream-driven, floor vs live)

**Problem**: the repo pins component versions (compose `${VAR:-pinned}`, `module.yaml`), so a
component release would otherwise wait for an aibox release. **Split the two concerns**:

- **Install floor** (repo-owned, tested, reproducible): `module.yaml` + compose `${VAR:-pinned}`
  defaults + `checks.docker_images`. Fresh installs always land on the floor.
- **Live version** (deployment-owned, floats independently of aibox): the deploy `.env`
  image keys (`DIFY_API_IMAGE`, `GITLAB_IMAGE`, …). Compose interpolation makes them authoritative
  for running containers; `aibox update` never clobbers the `.env`.

`aibox upgrade <module> [--check] [--to <version>] [--yes]` floats the live version to a newer
upstream release **without any aibox release**:

1. **Resolve** the target: `github-release` → `/releases/latest` of the declared repo;
   `dockerhub-tags` → the tags API filtered by `tag_pattern`, max by dotted-version compare.
   Fetches honor the run's proxy and fall back to the configured `CLASH_MIRROR`/`AIBOX_GH_MIRROR`.
2. **Guardrail**: auto-latest refuses to cross a **major** version (migration/data risk) —
   cross-major needs an explicit `--to`. EXCEPTION: modules providing a multi-hop path
   (see below) — the hop sequence IS the migration-safe path.
3. **Multi-hop path (optional, gitlab-style)**: when the module's cached `lib.sh` defines
   `upgrade_stops()` (same implicit-function contract as `render_status`), the engine
   computes the required-upgrade-stops sequence between current and target
   (`_upgrade_path_compute`), resolves each intermediate stop's **latest patch** from the
   tags API, and executes one hop at a time — pull → per-hop `.env` backup → rewrite →
   `svc.sh start` (health gate) → next hop. Failure rolls back to the PREVIOUS hop
   (exit `20`). `--check` prints the whole hop table. Knob:
   `AIBOX_UPGRADE_HOP_SETTLE=<seconds>` (extra background-migrations wait).
4. **Pairing, not guessing**: when the app is multi-image, `mapping_url` points at the
   upstream compose **at the target tag** (the `<VER>` placeholder is substituted); each
   declared image key's tag is extracted from it. Dify's sandbox/plugin-daemon/agent-backend
   pairing thus always matches what the target release itself ships.
5. **Fail fast**: every new image is `docker pull`ed BEFORE anything is touched (daemon egress
   probed the same way preflight does; failure → exit `4`, nothing changed).
6. **Atomic-ish apply**: `.env` → `.env.bak.<ts>.<pid>` backup; ONLY the declared image keys are
   rewritten (missing keys appended, mode preserved); containers recreated via the module's own
   `svc.sh start` (which health-waits per its normal contract). The pid suffix keeps two runs in
   the same second from sharing a path (a rollback in that window would clobber its own point).
7. **Recorded rollback point**: every phase is recorded in `$AIBOX_HOME/upgrades/<module>.state`
   (+ `<module>.log`): `from`, `to`, `ts`, `envbak`, `databak`, `status`
   (`started`/`ok`/`rolled-back`/`manual`) and `live` (the version the RUNNING app reports after
   the upgrade, read from the module's `status_info`). `--history` prints it; `--rollback`
   restores the recorded pin and swaps the point (undo-the-undo); the status's detail view
   shows `upgrade`/`rollback` rows from the same file (local, zero network).
8. **Data snapshot when knowable**: a module whose `services:` declares `base:postgres#<db>`
   consumes the shared base, so the engine dumps that database (`base.env` carries the container
   and user) to `$AIBOX_HOME/upgrades/<module>-db-<ts>.sql.gz` BEFORE moving the version;
   `--no-backup` skips it. The restore recipe is printed, never auto-run. Modules with no shared
   DB say so explicitly (“rollback restores the version pin only”) — schema migrations are
   usually one-way, so `--rollback` cannot promise data safety there.
9. **Verified auto-rollback, spec-aligned exit codes**: a failed health check restores the
   backup, recreates, and then CHECKS the result — healthy → exit `10` (“failed, rolled back”);
   still broken → exit `20` (“manual intervention needed”) with the pin path + retry command.
   The pre-0.16 flow claimed “rolled back” without verifying and always returned `20`.

**Manual rollback**: `aibox upgrade <module> --rollback [--yes]` goes back to the recorded
`from` version (it needs no resolution network), and `aibox upgrade <module> --history` lists
the transitions + the live versions. The module's installed-marker version stays the MODULE
version — the app version lives in the state file (overwriting the marker made `aibox update`
report bogus transitions on its next run).

The current live version is reported by `--check` (which also prints the recorded rollback
point) and by `status` (the module's `status_info` prints `version=…` from its own probe).
A downgrade (`--to <older>`) is allowed but warned: data migrations are usually one-way, so the
recorded data snapshot is the honest way back.

### `upgrade:` stanza (module.yaml, flat shape — parser-compatible like `checks:`)

```yaml
upgrade:
  source: github-release          # github-release | dockerhub-tags
  repo: langgenius/dify            # github repo, or dockerhub repository
  # Optional: upstream compose at the target tag — the image-tag pairing source.
  # <VER> is substituted with the target version.
  mapping_url: https://raw.githubusercontent.com/langgenius/dify/<VER>/docker/docker-compose.yaml
  tag_pattern: '^[0-9]+\.[0-9]+\.[0-9]+-ce\.0$'   # dockerhub-tags: stable-tag filter
  images:                          # .env key = upstream image prefix — the engine APPENDS the
                                   # resolved version to the whole value. Classic form ends with
                                   # ':' (…dify-api: → :1.17.2); upstreams that prefix their
                                   # tags use a tag prefix (…xiaozhi-esp32-server:server_ →
                                   # :server_0.9.7 — upstream tags git v0.9.7 as server_0.9.7)
    - DIFY_API_IMAGE=langgenius/dify-api:
```

Rules:

- **Opt-in**: no stanza → `aibox upgrade <module>` declines cleanly. Modules whose images
  already float by design (base's `postgres:18`) or that own their own upgrade path
  (windmill/openmaic CLIs, npm-based pi-web) don't need one.
- **Infra images stay on the floor** (DB/Redis/vector-store): float them manually in the `.env`
  only when you accept the data-compat implications (PG major upgrades need migration steps).
- Declaring `upgrade:` opts into the standard deploy-root `.env` contract
  (§Deploy directory) — the engine locates it via the module's own `lib.sh deploy_root()`.
- `aibox upgrade dify --to 1.18.0` works WITHOUT any resolution network (direct pin) — the
  escape hatch when registries are unreachable from the host.

### Docker source selector (docker.io / ghcr.io acceleration)

One selector concept, per-family transports, shared state. Applies to every
Docker-touching download: **image pulls** (compose `up`/`update`, family PULL
+ family GHCR in `tools/_shared/common.sh`) and **version resolution**
(`aibox upgrade`'s dockerhub-tags + the status's async update probes,
family TAGS in `tools/_shared/lib/32-docker-tags.sh`; the bundler injects it
into `bin/aibox`, so the manager and module hooks run the SAME code).

Strict priority (user-pinned):

1. **① the default address** — the official route: `docker pull` direct
   (PULL/GHCR: bounded probe; the daemon inherently tries its own
   `registry-mirrors` first — those LOCAL addresses come free) /
   `hub.docker.com` API direct (TAGS, bounded).
2. **② local addresses** — the user knobs (`AIBOX_DOCKER_MIRROR`,
   `AIBOX_GHCR_MIRROR`) and, for TAGS, the daemon's configured
   registry-mirrors (auto-discovered via `docker info`).
3. **③ the acceleration pool** — engaged ONLY when ①② time out or die.
   PULL: concurrent bounded hello-world pulls rank by speed, fastest-first
   per-source failover; TAGS: same race on the ACTUAL repo; GHCR: the images
   are big (0.7-1.5GB) — no racing, the winning mirror is STICKY (recorded,
   leads until it fails).

Ranking state: `$AIBOX_HOME/dockerpool.cache` (lines `FAMILY<TAB>token…`,
mode 600, TTL `AIBOX_DOCKER_POOL_TTL` 600s). Token `direct` = the official
route is known good; its ABSENCE = known dead within the TTL (skip the
timeout tax — mirrors die AND revive, networks change; dockerproxy.net
measured swinging within one day). All-fail → invalidate → re-resolve
(self-heal). The manager and `common.sh` use the SAME file/grammar: the cache
file is shared, and the code that reads it is one implementation in
`tools/_shared/lib/30-dockerio.sh` which `scripts/bundle.sh` injects into
`bin/aibox` (module caches get the same fragment as `_common.sh`).

Default pool (live-verified DIRECT, no proxy, 2026-09-23; multi-source
authoritative: 1panel status monitor / juejin measured / DaoCloud docs;
per-CHANNEL capability diverges — probes prune, nothing hardcoded):

```text
PULL+TAGS  docker.1ms.run hub.rat.dev docker.1panel.live hub.1panel.dev
           proxy.vvvv.ee docker.m.daocloud.io hub3.nat.tf hub4.nat.tf
           docker.367231.xyz docker.apiba.cn
GHCR       ghcr.nju.edu.cn ghcr.1ms.run
```

Knobs: `AIBOX_DOCKER_POOL` (list override; `direct|none|off` disables both
channels), `AIBOX_DOCKER_MIRROR` (user mirror, priority),
`AIBOX_DOCKER_TAGS_TIMEOUT` (8), `AIBOX_DOCKER_POOL_TTL` (600),
`AIBOX_GHCR_POOL` / `AIBOX_GHCR_MIRROR` (ghcr family), the per-family probe
timeouts. Examples of known-but-swinging mirrors (dockerproxy.net) are
documented, NOT shipped.

## Exit code convention

Hooks and dispatched CLIs should follow unified exit codes so automation (`aibox <module> <action>; echo $?`) can depend on them stably. `aibox` itself uses `die` → exit `1`.

| Code | Meaning | Example |
| --- | --- | --- |
| 0 | success | — |
| 1 | runtime error (general) | command execution failed |
| 2 | usage error / declined non-interactively | bad args; a dangerous op wasn't confirmed in a non-interactive env |
| 3 | dependency missing | platform/command not satisfied (e.g. deploy command on non-Linux) |
| 4 | precheck failed | config missing, image unpullable |
| 10 | upgrade failed, rolled back | — |
| 20 | manual intervention needed | health check failed; not ready after rollback |
| 30 | service not ready | container started but didn't pass health check |
| 40 | concurrency conflict | couldn't acquire the lock (another op is running) |
| 50 | user cancelled | interactive confirmation chose "no" |

Conventions:

- `aibox <module> <action>` returns the svc hook's exit code to the caller as-is (`AIBOX_MODULE=... bash "$svc"`).
- Dangerous ops **decline by default in non-interactive environments** and return `2`, never executing silently (see [Interactive confirmation](#interactive-confirmation) below).
- Dispatch-type modules' (openmaic/windmill) CLI bodies already implement this scheme; new modules should align.

## Interactive confirmation

Irreversible ops (uninstalling a deploy, wiping data volumes, teardown) must be interactively confirmed, following a unified pattern:

- **Dangerous ops use `confirm "<prompt>"`**: requires typing `yes` to proceed; honors `--yes`/`-y` (`ASSUME_YES=1`, skip confirmation) and `--dry-run` (show only, don't execute). openmaic/windmill's `confirm` already does this.
- **Soft choices use `ask_yn "<prompt>" [y|n]`**: default `n` (decline) or `y` (accept); **non-interactive environments (`[ -t 0 ]` false) always take the conservative default** with a warning.
- The main CLI's `ask_confirm` (`bin/aibox`) is the soft-choice, default-decline, non-interactive-returns-`1` variant. `aibox uninstall self` uses it for the confirm gate (non-interactive requires `--yes`).
- **Never silently execute a dangerous op**: non-interactive + no `--yes` returns `2` instead of `0`, so scripts and CI can notice.

### The two-gate uninstall model (`aibox uninstall <module>`)

Every destructive verb follows the same shape; module uninstall is the reference:

1. **Gate 1 — the operation itself**: `ask_confirm "Uninstall <m>? (what it does; what is kept)"` — `[y/N]`, default decline; `--yes` skips; non-interactive without `--yes` → exit `2`, nothing runs.
2. **Gate 2 — data cleanup, asked INLINE**: `ask_confirm "Also DELETE the data? …"` — the answer feeds `AIBOX_PURGE_DATA` into the SAME hook invocation (one run carries the whole decision). `--purge` = explicit intent (skips the question); non-interactive without `--purge` keeps data (the safe default) and prints the residue hint.

The data question being inline removes the dead round-trip the old flow forced: `aibox uninstall <m>` then `aibox uninstall <m> --purge` — the latter warns "not installed" (the hook contract needs the installed state); the correct post-uninstall cleanup is `aibox autoclean <m>`. Module hooks print that guidance.

Gate coverage: uninstall (2 gates) · uninstall self (confirm per teardown scope) · purge `--apply` (confirm + dry-run default + an inline "stop RUNNING containers?" question — `--stop` pre-answers yes; non-interactive skips them with a single hint, never a partial-stopping state) · upgrade (confirm) · proxy set (confirm) · dispatched CLIs' `confirm` (windmill destroy etc.).

## Preflight checks (mandatory for every module)

Install/update is **gated** by a preflight check (`preflight_module` in `bin/aibox`). Every module —
existing and future — MUST declare a `checks:` section in `module.yaml`; CI enforces both the
section's presence and its field formats.

### What gets checked (in order)

| Check | Source | Semantics |
| ------- | -------- | ----------- |
| deps | `deps:` field | strict: missing after an auto-install attempt → **FAIL** (the old warn-and-continue behavior is gone). Auto-install is bounded (`AIBOX_PM_TIMEOUT`, default 600s — announce + heartbeat + kill-tree on timeout) and **deduplicated by package set** (docker/docker-compose and node/npm share one PM invocation); `AIBOX_NO_AUTO_DEPS=1` disables it entirely |
| commands | `checks.commands` | binary must exist (`cmd@platform` supported; no auto-install — these are OS facilities like `systemctl@linux` / `launchctl@darwin`) |
| disk | `checks.disk_gb` | free space at `$AIBOX_HOME`'s filesystem ≥ N GB → else **WARN** (informational: never blocks install/update) |
| domains | `checks.domains` | each probed as `https://<host>/` via the **host's** curl/egress; any HTTP response (even 401/404) = reachable, connection failure = not. All must pass |
| docker pull | `checks.docker_pull` | **daemon-routed** probe: `docker pull <tiny image>` proves the daemon's actual registry path (its mirrors/proxy differ from the host's). Skipped when docker is absent (deps reports that) |
| docker images | `checks.docker_images` | when **all** refs exist locally, the domain AND pull probes are skipped (offline restart/install works) |
| services | `services:` field | recursive: missing providers are **auto-installed FIRST** (`aibox install <m>` resolves the declared chain before its own preflight — cycle-guarded, same profile/flags inherit; a failed provider aborts the target; `AIBOX_NO_AUTO_DEPS=1` restores the manual gate); provider installed but its stack not running → **base's own preflight** runs — passes → install proceeds (`ensure_services` auto-starts it); fails → **FAIL**. When the provider cannot be started here, install ends with a ⚠ block naming the fix (`aibox base start`) and still exits 0 by default — `AIBOX_STRICT_SERVICES=1` opts into a non-zero exit for scripting. **At action time** `aibox <m> start`/`restart` runs the same ensure first (quiet when the base is already up) and the module hook calls `ensure_shared_base`, so a down provider is started instead of demanding a second command. Entry forms: `base:<component>#<resource>` (start + create) and `base:<component>` (start only — no resource to create) |
| services_optional | `services_optional:` field | **NOT gated** — documentation of a deploy-time user toggle (e.g. dify `DIFY_SHARED_BASE=1`): the entry format is validated (provider form), but install/update never require the provider. Two consumption modes exist: hard (`services:` — the module cannot run without the shared component) and opt-in (`services_optional:` — standalone by default, joins the shared base only when the user flips the deploy env knob) |

### Host vs daemon probe semantics (pick the right channel)

`domains:` probes run through the **host's** curl with aibox's egress (and the route fallback
below applies). The **docker daemon has its own egress** — Docker Desktop's VM network,
`/etc/docker/daemon.json` `registry-mirrors`, daemon-level proxy settings — none of which aibox's
proxy/clash configuration touches. Measured reality: a macOS host where `curl https://registry-1.docker.io/v2/`
times out on every host route while `docker pull` succeeds in 8s. Therefore:

- Registries consumed by **host tools** (npm/git/curl downloads) → declare in `domains:`.
- Registries consumed by the **docker daemon** (image pulls/builds) → declare `docker_pull: <tiny image>`
  (e.g. `hello-world`, 5.8KB) instead; on failure the hint points at daemon-side config, not `aibox proxy`.
- Exception: `ghcr.io` stays a host-probed domain for windmill — no canonical tiny ghcr image exists
  for a pull probe, and on typical Linux deploy hosts daemon and host share egress.

### Network failure → automatic route fallback

When domains are unreachable via the current egress, the engine tries the **configured**
alternatives in order — `direct` (bypass proxies), `clash` (the clash pool's mixed port, if a
clash state exists), `mirror` (GitHub-family domains via a gh-proxy-style URL-prefix mirror when
`CLASH_MIRROR`/`AIBOX_GH_MIRROR` is set — this solves the bootstrap paradox: on CN networks
github.com may be unreachable while the clash module that would fix it downloads FROM GitHub),
`proxy` (the static `AIBOX_PROXY_URL`, even when disabled) — and **adopts the
first route that makes ALL failed domains reachable**, for this run only. The warning tells you how
to make it permanent (`aibox clash on` / `aibox proxy on` / `aibox proxy off`); the mirror route
adopts nothing (modules honoring `CLASH_MIRROR` just work). Nothing is tried
that isn't already configured; global config is never changed silently.

Bootstrap pattern on a blocked network (before clash exists):

```bash
CLASH_MIRROR=https://gh-proxy.com aibox install clash   # preflight passes via the mirror route
aibox clash set <subscription-url> && aibox clash on    # real egress from here on
```

### CLI surface

The manager's verbs are fixed; each one answers `--help` (the same block as
`aibox help <verb>`, exit 0), and **every module action is reachable as
`aibox <module> <action>`, never as a manager verb**:

```bash
aibox check self               # environment: egress route, core domains (raw.githubusercontent/api.github), docker, node/npm, disk
aibox check <module>           # that module's full preflight (usable before installing)
aibox install <module> [--skip-checks]
aibox update  <module> [--skip-checks] [--all] [--restart|--no-restart]
aibox upgrade <module> [--check|--rollback|--history] [--to <ver>] [--no-backup]
aibox status [--available] [<module>]
AIBOX_SKIP_CHECKS=1 aibox install <module>   # script-friendly bypass
AIBOX_CHECK_TIMEOUT=3 aibox check <module>   # per-probe timeout (default 8s)
```

`--skip-checks` bypasses the WHOLE preflight (deps included) — for air-gapped installs
with pre-staged dependencies; whatever is genuinely missing then fails later at the hook.
Without it a failed preflight aborts install/update **before** any hook runs: hard
requirements (deps, commands, services) → exit `3`; failing checks (domains, pull) → exit `4`;
`checks.disk_gb` is informational → warn only:
exit `4` with the `--skip-checks` hint.

**Action-surface conventions** (validator-enforced, so every module feels the same):

| Convention | Rule |
| --- | --- |
| lifecycle | a module declaring `start` MUST declare `stop`, `restart`, `status`, `logs` |
| `status` | MUST exist for service modules (the rich view; `status` is the alias) |
| `doctor` | MUST exist: the standard diagnostic. Default = the shared `module_doctor` (`_common.sh`) — deps + docker daemon + the module's `status_info` state + declared port listeners; exit `0` healthy / `3` dep missing / `30` not ready. A module with deeper domain checks (clash) may implement its own `doctor` as long as it covers those four |
| dispatch CLIs | a module that passes actions through to its own CLI (openmaic `up`/`down`, windmill) MUST alias `start`/`stop`/`restart` onto that CLI's spelling, so `aibox <module> start` works everywhere |
| usage errors | hooks die with `usage_die` (exit `2`), never `die`, for `Usage:`/`unknown action:`/`unknown option` |

### Per-module check matrix (current modules)

| module | disk_gb | domains (host-probed) | docker_pull (daemon-probed) | docker_images | commands | services |
| -------- | :---: | --------- | --------- | --------------- | ---------- | ---------- |
| base | 5 | — | — (docker.io pool at start) | postgres:18, redis:7 | — | — |
| clash | 1 | — (GitHub source pool) | — | — | — | — |
| dify | 10 | — | — (docker.io pool at start) | langgenius 1.17.1 set + postgres/redis/weaviate (offline-restart short-circuit) | — | — |
| gitlab | 15 | — | — (docker.io pool at start) | gitlab/gitlab-ce:19.2.6-ce.0 | — | — |
| openmaic | 20 | github.com | hello-world | — | — | base:postgres#openmaic |
| pi-web | 2 | — (npm registry pool) | — | — | launchctl@darwin, systemctl@linux | — |
| windmill | 12 | ghcr.io (its CLI auto-probes ghcr mirrors) | — | — | — | base:postgres#windmill, base:redis |

Notes: entries marked "pool" have their static gate REMOVED — the runtime download
source pool (see §Download source pools) probes the real channel and fails over,
so a static direct-egress gate would false-fail exactly the mirror-saved networks.
The clash **subscription URL** is user-supplied and fetched by mihomo with its own
UA — plain-curl probes return 403 by design, so it is deliberately not a checked
domain (same class: not poolable). Disk floors
match measured reality (windmill images ≈ 9.5G live; openmaic's render-service Chromium build is
heavy) and windmill's own `doctor` contract (≥ 12G).

## Dependency declaration & auto-check

Modules declare a `deps` field in `module.yaml` (command names, list); `aibox install` auto-checks and installs by platform if missing:

- **Format**: `command` / `command@platform` (check on that platform only) / `command:version` (major-version constraint)
- **Examples**: `docker@linux docker-compose git` (docker on Linux only), `node:22 npm` (node 22+), `python3`
- **When**: `aibox install/update <module>` runs deps as part of the **preflight** (strict — missing after an auto-install attempt aborts the operation; see §Preflight checks). `--skip-checks` bypasses.
- **Platform filter**: a `platform=darwin` module skips the check on non-darwin; `@platform`-tagged deps are skipped off-target (just a hint)
- **Auto-install** (`install_dep`):
  - Lightweight / has a package manager → install (macOS brew / Linux apt/yum/dnf)
  - Needs sudo (apt/yum) or GUI interaction (Docker Desktop) → **print the manual command**, never silently sudo
  - node reuses nvm (pi-web pattern)
- **Unknown deps**: print a manual-install hint

## Design tradeoffs

- **Registry uses a shell-sourceable format, not JSON**: zero runtime dependencies, compatible with macOS bash 3.2; the main CLI sources it directly, no `jq`/`python`. (Now via `module.yaml` + an awk subset parser; yq validates the subset in CI.)
- **Module scripts are cached on disk**: aibox downloads module scripts to `~/.aibox/modules/<name>/` before executing; hooks reuse `lib.sh`; `svc.sh` pass-through doesn't re-fetch.
- **Platform is self-reported by the module**: a `platform=darwin` module only warns on non-macOS (the module's own error at runtime is clearer).

## module.yaml (source of truth)

> **`registry.sh` is gone.** `load_registry` discovers modules from `tools/*/module.yaml` (local source globs directly; remote source uses the GitHub API + caches with TTL). **Adding a module = create `tools/<name>/` + `module.yaml`; local source needs zero global changes.**

### module.yaml fields (normative schema: docs/module-spec.md §Registering a module)

name/version/description/platform/dir/deps/ports/files/hooks/actions/upstream/status.

### ports field (port/proto:usage)

CI `port-conflict` checks port+proto uniqueness (spec §3). The `aibox status` overview lists the assignment table + does an lsof listen probe.

### status_info() interface (spec §4.4)

Each module's `lib.sh` implements `status_info()`, outputting key=value lines.
Called by `aibox status` / `aibox <module> status`.

| key | meaning | notes |
| --- | --- | --- |
| `endpoint` | the service URL | annotated `(stopped — aibox <module> start)` when the state is stopped |
| `credential` | auth hint | rendered as `auth:` |
| `version` | deployed app version | the keyline header segment (cyan); first token feeds the async updates comparison; `version=` is REQUIRED (validator S19) |
| `state` | **machine-readable service state** — one of `ok` / `starting` / `stopped` / `na` | the overview renders the composite icon: `✓` ok (green) · `⚠` starting (yellow) · `○` stopped (dim) · `na` = no marker (CLI-type modules — openmaic/windmill: their state is a remote deploy's, `aibox <module> status` is the real view). The probe is the module's own (pg_isready / http_up / docker health) — local-only, so the local-first rule holds. Stale caches without `state=` fall back to the declared-port listening heuristic. |
| `health` | human detail line | free text: probe verdict, starting hint, or the copy-paste probe command |
| `log` | log location | rendered as `log:` |

Example (new-api):

```bash
status_info() {
  …
  if container_running; then
    if api_up "${port}"; then echo "state=ok"; echo "health=ok (api answers on :${port})"
    else echo "state=starting"; echo "health=starting (container up, api not ready yet)"; fi
  else
    echo "state=stopped"; echo "health=stopped"
  fi
}
```

### Status template (keyline)

Spec source: `docs/superpowers/specs/2026-09-23-status-app-version-keyline-design.md`.

Every status surface (module rich view via `render_status`, manager
overview, manager detail view) renders the SAME keyline template, from shared
helpers in `tools/_shared/common.sh` (`status_header` / `status_row` /
`status_module_row` / `status_rule` / `status_secheader`; the bundler injects the same
fragment into bin/aibox, so both views run identical code):

```text
pi-web 0.9.3 · ✓ running
────────────────────────────────────────────
  service    launchd · pid 38243
  endpoint   http://127.0.0.1:30141 · HTTP 307 ✓
  auth       pi / ai-coding
  log        ~/Library/Logs/pi-web.log
  module     1.3.5 · ~/.aibox/modules/pi-web/
```

Rules:

- **Two versions, two places**: the **app version** (deployed software: npm
  package / image tag / kernel tag / dispatched CLI) is the cyan header
  segment, reported by `status_info`'s `version=` and, in rich views, an
  `app_version()` helper; the **module version** (aibox packaging,
  module.yaml `version:`) is the SUNK last row — whole row dim. Never show
  the module version where the app version is expected.
- `status_header <name> <appver> <state>`: appver `""` omits the segment;
  states `ok|running` → `✓`, `starting` → `⚠`, `stopped` → `○`,
  `na`/`""` → no segment. Manager overview headers show the icon only.
- `status_row <label> <value>`: ASCII label ≤10 chars in a `%-10s` grid, NO
  colon; values verbatim — never byte-truncate (CJK stays ragged-right,
  pitfall #6).
- Rule width: TTY → `tput cols` clamped [40,72]; non-TTY → 64. `─` literals
  are complete characters, never sliced.
- `health=` merges into the endpoint row (`<url> · <health>`); `state=stopped`
  appends the dim `(stopped — aibox <module> start)` hint instead.
- `status`/`status` actions render the keyline view only; raw
  launchctl/systemctl/lsof dumps belong to `diagnose`.
- New modules: the scaffolder emits the skeleton (app_version TODO +
  status_info with `version=` + render_status via status_header);
  the validator WARNs on gaps (S18/S19).

### Doc hygiene: dynamic values live in the status (normative)

Ports, container names, env-file paths and credentials are **derived** (profile suffix, hash,
per-instance override), so a doc that hardcodes them goes stale the moment anyone uses a named
profile — and the README is part of the module contract (validator checks it). Rule:

- State the **default-profile default** if it helps orientation, and label it as such.
- Point readers at the authoritative view: **`aibox status <module>`** (endpoint · provider
  containers · env file · config-key count · upgrade/rollback state), `aibox status` (overview
  + the port table + listeners) or `aibox <module> config list` for knobs.
- Never present a derived value as the value (the validator WARNs when a module whose ports are
  profile-derived documents numeric ports without any `aibox status` pointer).
- The same applies to the status itself: expose derived values there (base prints
  `containers`/`env`) rather than only in prose.

### DB naming convention (shared base, spec §5.4)

Single DB: `<module>`; multiple DBs: `<module>_<usage>`. `aibox base create postgres <module> [usage]` creates the DB.

### base module (spec §5)

`tools/base/` ships shared PG18+Redis7 (compose + create). Deploy-type modules connect to the shared instance (.env DATABASE_URL + compose override network, see `tools/openmaic/docker-compose.shared.yml`).

### hooks field parsing

The awk parser strips the parent prefix for `hooks:` nesting (`hooks.install` → `AIBOX_MODULE_<name>_install`, compatible with `module_field`); upstream/status keep the prefix (`_upstream_homepage`, `_status_hint`).

## First-run preparation (`start` must make it run)

`aibox install <module>` is deliberately LIGHT and repeatable: it caches the module, places the
CLI/compose/.env, and ensures the shared base — it never performs a heavy application deploy.
The verb whose CONTRACT is "make it run" is `aibox <module> start`, so preparation lives there:

```yaml
first_run: install                     # the action `start` invokes when the app is absent
first_run_note: "clone + build (~1h)"  # printed before the (possibly long) step
```

- The shared helper `module_ensure_deployed <module> <module.yaml> <artifact-path> [cmd]`
  checks the artifact, prints `first run: … — running: …` plus the note, runs the prepare
  command, and re-checks the artifact (exit `30` = not ready when it is skipped or fails).
- `--no-prepare` (or `AIBOX_NO_PREPARE=1`) refuses the heavy step and prints the command
  instead — for scripts, CI and "just show me the state" workflows.
- `status_info` should report `state=stopped` + a `health=` line naming the deploy command when
  the app is not deployed yet (never pretend to be live).
- Modules whose app arrives as a published image / npm package / binary need no `first_run`:
  `start` already does everything (that is why 8 of 9 modules worked after `install` while
  openmaic — the only source-build module — did not).
- `aibox install <module>` prints `Next: aibox <module> start` (plus the note above), so the
  sequence is stated rather than implied.