feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)

This commit is contained in:
2026-09-29 08:25:37 +07:00
parent fcfebe44f1
commit 7ec5a7cd85
40 changed files with 3145 additions and 92 deletions
+9 -1
View File
@@ -2102,9 +2102,17 @@ Every history row carries `lane_id` (the lane whose run it belongs to) and `tool
GET /api/ai-tools Catalog + detection — { tools: [...], default }
POST /api/ai-tools/refresh Re-read the login shell's PATH, re-detect, same body
PUT /api/ai-tools/:id Body: { enabled?: boolean, default?: true } — same body
POST /api/ai-tools/:id/install Start the catalog's install job → 202 { job }
POST /api/ai-tools/:id/update Start the catalog's update job → 202 { job }
POST /api/ai-tools/update-all One job: every npm-managed tool with a newer version + every self updater
GET /api/ai-tools/job The current or last job, or null
GET /api/ai-tools/versions { [id]: { npmManaged, installed, latest, updateAvailable } }
GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the server; 204 → letter avatar
```
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, routerCapable: bool }` — flags only, never the arg arrays. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`.
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, routerCapable: bool }` — flags only, never the arg arrays. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is not under `npm prefix -g`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`.
### AI Router profiles
@@ -0,0 +1,93 @@
# AI Tools A2 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Install and update AI coding CLIs from Settings → AI Tools, keep the catalog fresh from the newest release, and show real icons.
**Architecture:** `ai-tools.js` learns two catalog fields and a third catalog tier (remote). Four small server modules own one job each: `ai-tool-jobs.js` (the one-at-a-time install/update runner), `ai-tool-versions.js` (npm-managed check + version badge), `ai-tools-sync.js` (daily remote catalog), `ai-tool-icons.js` (favicon cache). `routes/ai-tools.js` exposes them; the client polls one job endpoint.
**Tech Stack:** Node/Express (`node:test`), React + Vite + vitest.
**Spec:** `docs/superpowers/specs/2026-09-28-ai-tools-a2-design.md`
## Global Constraints
- Commands come only from catalog files; `install`/`update`/`launcher` are refused in `DASHBOARD_AI_TOOLS_DIR` files; allowed in built-in and remote (release) files.
- `package`: `^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$`; script `url` https, last two host labels equal the `homepageUrl` host's.
- Every exec is an argv (`execFile`/`spawn`), never a shell string; stdin ignored; job timeout 10 min; log cap 256 KB; one job server-wide (`409 EBUSY`).
- npm is resolved on the login shell's own PATH first (`shellPath()`), job env `PATH = shellPath() + ":" + process.env.PATH`.
- npm Update only for npm-managed tools (binary realpath under `npm prefix -g`), else `ENOTNPM`.
- Remote catalog only when release tag > running `package.json` version; `AGENTHUB_AI_TOOLS_SYNC=0|false|off` disables it; stored in `<getDataDir()>/runtime/ai-tools-remote/`.
- Icons: no SVG; ≤ 64 KB; dotted hostname, no IP literal; headers `nosniff`, `Content-Security-Policy: sandbox`; stored in `<getDataDir()>/runtime/ai-tool-icons/`.
- All writes behind `sameOriginGuard`.
- Every new/edited source file starts with the header ending `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`.
- Server tests: `npm run test:server`; client: `cd client && NODE_ENV=test npx vitest run`. Never commit or push without the user's explicit ask.
---
### Task 1: Catalog fields, trust tiers, shell PATH, repo URL
**Files:** Modify `server/lib/ai-tools.js`, `server/lib/shell-path.js`, `server/lib/update-check.js`, eight files in `server/data/ai-tools/` (§ 1 table), `server/data/ai-tools/README.md`. Tests: `server/__tests__/ai-tools.test.js`, `shell-path.test.js`, `update-check.test.js`.
**Produces:**
- Tool gains `install: {kind:"npm",package}|{kind:"script",url}|null`, `update: {kind:"npm"}|{kind:"script"}|{kind:"self",args:string[]}|null`; `source` ∈ `builtin|remote|custom`.
- `__setCatalogDirs([builtin, remote, custom])` (a two-element array keeps meaning `[builtin, custom]` for the A1 tests).
- `view()` rows gain `install`, `update`; `view().catalog` from `ai-tools-sync.catalogMeta()` (Task 3; until then `{source:"builtin"}`).
- `shellPath(): string|null` in `shell-path.js` — the last PATH the shell printed.
- `repoUrlFor(root): Promise<string|null>` in `update-check.js`.
- [ ] Failing tests: bad package name / http url / off-host url / `update:npm` without npm install / `install` in custom dir → file skipped; `launcher` allowed in remote tier; precedence custom › remote › builtin; `shellPath()` null before hydration, set after; `repoUrlFor` for a plugin-cache root uses the marketplace manifest.
- [ ] Implement; add fields to codex, gemini, qwen-code, opencode, copilot, amp, cursor, claude.
- [ ] `npm run test:server` green.
### Task 2: Jobs and versions
**Files:** Create `server/lib/ai-tool-jobs.js`, `server/lib/ai-tool-versions.js`. Tests: `server/__tests__/ai-tool-jobs.test.js`, `ai-tool-versions.test.js`.
**Produces:**
- `ai-tool-versions.js`: `npmBin(): string|null` (which("npm") over `shellPath()` then PATH); `npmManaged(id): Promise<boolean>`; `versions({fresh}): Promise<{[id]:{npmManaged,installed,latest,updateAvailable}}>` (24 h cache); `invalidate(id)`; `__setExec(fn)` seam (`fn(file,args,opts) → Promise<{stdout}>`).
- `ai-tool-jobs.js`: `start(id, action)` → job (throws `ENOTOOL|ENOINSTALL|EINSTALLED|ENOUPDATE|ENOTINSTALLED|ENOTNPM|EBUSY|ENOTHING`); `startUpdateAll()`; `current(): Job|null`; `stepsFor(id, action): Promise<Step[]>` where `Step = {argv:string[]} | {script:url}`; `__setSpawn(fn)`, `__setFetch(fn)` seams.
- Job end: hydrate, `aiTools.refresh()`, install ok ⇒ `setEnabled(id,true)`, `versions.invalidate(id)`.
- [ ] Failing tests: argv for npm install/update is `[npm,"install","-g","pkg@latest"]` with npm from the shell PATH ahead of PATH; self is `[detectedPath,...args]`; script downloads (≤1 MB, https final URL, same two-label host) and runs `["bash", tmp]` then deletes it; `EBUSY` while running; log capped at 256 KB keeping the tail; timeout ⇒ failed; EACCES ⇒ hint line; ok install ⇒ enabled; update-all steps = npm-managed with update + self updaters, `ENOTHING` when none; npm-managed by realpath under `npm prefix -g`; version parse from noisy `--version`; compare via `update-check.compareVersions`; offline ⇒ no badge.
- [ ] Implement.
- [ ] `npm run test:server` green.
### Task 3: Remote catalog sync
**Files:** Create `server/lib/ai-tools-sync.js`; modify `server/index.js` (`startBackgroundServices`: first sync 60 s after start, then every 24 h, `unref`). Test: `server/__tests__/ai-tools-sync.test.js`.
**Produces:** `syncOnce({root, fetch?}): Promise<{ok, reason?, tag?}>`; `remoteDir(): string`; `catalogMeta(): {source, tag, fetchedAt}`; `activeRemoteDir(): string|null` (null when disabled, absent, or its tag is not newer than the running version) — used by `ai-tools.load()`.
- [ ] Failing tests (fake fetch): release not newer ⇒ nothing written; newer ⇒ files validated and written atomically with `meta.json`; an invalid file is dropped, not fatal; a failing download keeps the previous set; > 200 entries / > 64 KB file refused; `AGENTHUB_AI_TOOLS_SYNC=0` ⇒ `{ok:false, reason:"disabled"}`; `activeRemoteDir()` null after the running version catches up.
- [ ] Implement (contents URL = `releaseApiUrl(repo)` with `/releases/latest` replaced by `/contents/server/data/ai-tools?ref=<tag>`; each entry's `download_url`).
- [ ] `npm run test:server` green.
### Task 4: Icons
**Files:** Create `server/lib/ai-tool-icons.js`. Test: `server/__tests__/ai-tool-icons.test.js`.
**Produces:** `iconFor(id, {fetch?}): Promise<{type, body}|null>` (cache hit, 24 h miss cache); `iconDomain(tool): string|null`.
- [ ] Failing tests: svg refused; > 64 KB refused; IP-literal / port / dotless domain ⇒ null; redirect off-domain refused; cache hit makes no second fetch; miss cached.
- [ ] Implement.
### Task 5: Routes
**Files:** Modify `server/routes/ai-tools.js`, `server/openapi-extra/ai-tools.js`, regenerate `server/openapi.yaml` (`npm run openapi` if present, else the existing generator script). Test: `server/__tests__/ai-tools-route.test.js`.
- [ ] Failing tests: `POST /:id/install` 202 + job, 400 `ENOINSTALL`, 409 `EBUSY`, cross-origin 403; `POST /:id/update` 409 `ENOTNPM`; `POST /update-all` 400 `ENOTHING`; `GET /job` null then job; `GET /versions` shape; `GET /:id/icon` headers and 404.
- [ ] Implement (error code → status map in one helper).
### Task 6: Client
**Files:** Modify `client/src/lib/api.ts`, `client/src/hooks/useAiTools.ts`, `client/src/components/settings/AiToolsSettings.tsx`, `client/src/i18n/locales/{en,vi}/settings.json` (wherever `aiTools.*` keys live). Test: `client/src/components/settings/__tests__/AiToolsSettings.test.tsx` (create or extend).
- [ ] Failing tests: Install opens a confirm dialog with `npm install -g <pkg>@latest`; confirming POSTs and renders the polled log; polling stops at `ok`; badge `v<latest> available`; `Installed outside npm` when not npm-managed; **Update tools** disabled with nothing to do; `<img>` falls back to the letter on error.
- [ ] Implement; review any screen snapshot diff before `-u`.
- [ ] `cd client && NODE_ENV=test npx vitest run` green; `npx tsc --noEmit` clean.
### Task 7: Docs + browser check
- [ ] `CLAUDE.md` marker, `AGENTS.md`, `docs/API.md`, `server/README.md`, `server/data/ai-tools/README.md`, env var list (`AGENTHUB_AI_TOOLS_SYNC`); `bash skills/file-headers/scripts/check-headers.sh` exits 0.
- [ ] Browser check on an isolated server (`DASHBOARD_DATA_DIR`, `TMUX_TMPDIR` temp, port 4850): icons render, Install dialog text, badge. No real install is run without the user's go.
@@ -0,0 +1,213 @@
# AI Tools — part A2: install, update, remote catalog, favicons
Issue: #40. Date: 2026-09-28. Status: design agreed in chat, self-reviewed,
awaiting spec review. Builds on A1
(`2026-09-28-ai-tools-a1-design.md`, whose § "A2 decisions" this refines).
## Goals
- An **Install** button on "Available to install" rows for the tools whose
install command is known; a confirm dialog shows exactly what will run; a
live log appears under the row; the tool is re-detected (and enabled) when
the job ends.
- **Update** on installed rows, a "new version" badge for npm-distributed
tools, and **Update tools** in the section header (named so, not "Update
all", because Settings already has an **Update all** for plugins).
- The catalog refreshes itself daily from the newest AgentHub release, so a
new tool or a fixed flag reaches an install without a plugin update.
- Real icons, fetched by the server, never by the browser.
## Non-goals
- Install data for every tool. A2 ships it for a verified few (§ 1); the
remote catalog can add more later.
- `sudo`, Homebrew, `uv`, `pipx`. A system-wide Node's `EACCES` is reported,
not worked around.
- Uninstall. A job surviving a server restart (it is a child of the server
and dies with it; the log is lost, the tool is re-detected on next start).
## 1. Catalog fields
Two optional fields in a catalog file:
- `install`: `{ "kind": "npm", "package": "<name>" }` or
`{ "kind": "script", "url": "https://…" }`.
- `package` matches `^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$`.
- `url` is `https:` and its host shares its last two labels with the host of
the file's `homepageUrl`. (`ponytail:` two-label compare, not a public
suffix list — wrong for `co.uk`-style hosts, none in the catalog; swap in a
PSL if one arrives.) Against a malicious remote catalog this is only a
sanity check — that file controls `homepageUrl` too; the trust is the
forge's (§ 3).
- `update`: `{ "kind": "npm" }` (needs `install.kind === "npm"`, reuses its
package), `{ "kind": "script" }` (re-runs `install.url`), or
`{ "kind": "self", "args": ["update"] }` (the tool's own updater, run as
`[<detected path>, ...args]`).
A file with a bad `install`/`update` is rejected whole, like any A1 rule. A
file from `DASHBOARD_AI_TOOLS_DIR` may not carry either field (nor `launcher`,
as in A1) — that directory is writable by anyone who can drop a file, and an
Install click would run what it names.
First batch (npm owners checked with `npm view <pkg> maintainers` on
2026-09-28; each package's `bin` matches the catalog's `detect`):
| id | install | update |
|---|---|---|
| `codex` | npm `@openai/codex` | npm |
| `gemini` | npm `@google/gemini-cli` | npm |
| `qwen-code` | npm `@qwen-code/qwen-code` | npm |
| `opencode` | npm `opencode-ai` | npm |
| `copilot` | npm `@github/copilot` | npm |
| `amp` | npm `@sourcegraph/amp` | npm |
| `cursor` | script `https://cursor.com/install` | script |
| `claude` | — (always present: it runs AgentHub) | self `["update"]` |
Antigravity gets neither until its updater is known.
## 2. Jobs — `server/lib/ai-tool-jobs.js`
- ONE job at a time, server-wide. A second request answers `409 EBUSY`.
- A job is `{ id, tool, action: "install"|"update", status:
"running"|"ok"|"failed", startedAt, endedAt, exitCode, log }`, kept in
memory; `log` is stdout+stderr, capped at 256 KB (the head is dropped).
After it ends it stays readable until the next job starts.
- The argv comes only from the catalog entry:
- npm install/update: `[<npm>, "install", "-g", "<package>@latest"]`, where
`<npm>` is resolved on the **login shell's own PATH, in the shell's
order** — not on `process.env.PATH`, where `hydrateShellPath` APPENDS the
shell's directories after the unit's, so the pinned nvm Node's npm would
still win, and `-g` there lands where the user's PATH never looks (the
tool would "install" and stay undetected). `shell-path.js` therefore keeps
the last shell PATH it read (`shellPath()`, `null` when hydration is off or
failed ⇒ fall back to `process.env.PATH`), and a job's env PATH is
`shellPath()` followed by `process.env.PATH`, so npm's own
`#!/usr/bin/env node` picks the matching Node.
- script: the server downloads `url` (https, 15 s timeout, ≤ 1 MB, the final
URL after redirects must still be https on the same two-label host) to a
0600 temp file and runs `["bash", <file>]`; the file is deleted after.
- self: `[<detected path>, ...args]`.
- `execFile`/`spawn` with an argv, never a shell; stdin is `ignore`; env is the
server's env with the hydrated `PATH`; each command runs in its own process
group (`detached`), and a 10-minute timeout sends the whole group SIGTERM,
then SIGKILL after 5 s (`failed`, `ETIMEDOUT` in the log). A step ends on
`close`, or 500 ms after `exit` — a daemon an installer left behind can hold
the pipes forever, and `close` alone would keep the job (and `EBUSY`) alive.
- On an `EACCES` in an npm job's output, the log gets one hint line: Node is
installed system-wide, use a user-level Node (nvm/fnm) or install by hand.
- When a job ends: `shell-path` hydrate + `aiTools.refresh()`; a successful
install also sets the tool `enabled: true`; the version cache is dropped
(§ 3) — and only THEN does the status leave `running`, because the client
refetches once, on the first poll that sees it finished. A failure in that
bookkeeping is appended to the log, never thrown.
## 3. Versions and the remote catalog
**npm-managed.** An installed tool counts as npm-managed only when the
realpath of its detected binary lies under `<npm prefix -g>` (read once per
versions pass with the § 2 npm). A tool installed another way — this
machine's `codex` is Codex's standalone build under
`~/.codex/packages/standalone/` — gets NO npm Update and no badge: an npm
update would install a second copy the PATH order shadows, and the badge
would never clear. Its row says `Installed outside npm` instead.
**Versions** (`update.kind === "npm"`, npm-managed tools only): installed =
first `\d+\.\d+\.\d+` in `<path> --version`; latest = `npm view <pkg>
version`. All tools in parallel, 5 s timeout each, cached in memory for 24 h,
computed lazily on the first `GET /api/ai-tools/versions` and on Refresh.
`updateAvailable` = latest > installed by the same compare `update-check.js`
uses. Offline or unparsable ⇒ no badge, no error.
**Remote catalog** (`server/lib/ai-tools-sync.js`):
- Daily (and once 60 s after start), off with `AGENTHUB_AI_TOOLS_SYNC=0`.
- Resolves the repo URL exactly as `update-check.js` does — marketplace
manifest for a plugin-cache install, the canonical git remote for a
checkout — through a new export `repoUrlFor(root)` there (built from its
existing `detectPluginInstall`/`pluginMarketplaceUrl`/`pickCanonicalRemote`),
and reads the newest release through `fetchReleaseFromUrl`.
- Only when that release is NEWER than the running build's `package.json`
version — a dev checkout ahead of the newest release must never be rolled
back to older data.
- Lists `server/data/ai-tools/` at the release tag through the forge contents
API (`/api/v1/repos/<o>/<r>/contents/server/data/ai-tools?ref=<tag>`, the
Gitea/Forgejo shape `releaseApiUrl` already builds), downloads each `*.json`
(≤ 64 KB each, ≤ 200 files), validates each with A1's `validate` at
**release** trust (may carry `launcher`, `install`, `update`), and writes the
set atomically to `<runtime dir>/ai-tools-remote/` with a `meta.json`
(`tag`, `fetchedAt`). A failed sync leaves the previous set untouched.
- Precedence per id: `DASHBOARD_AI_TOOLS_DIR` › remote › built-in. A remote set
whose `tag` is no longer newer than the running build (the user upgraded) is
ignored.
- `GET /api/ai-tools` gains `catalog: { source: "builtin"|"remote", tag,
fetchedAt }`.
## 4. Icons — `GET /api/ai-tools/:id/icon`
- Domain = the file's `faviconDomain`, else the `homepageUrl` host. It must
be a dotted hostname (`^[a-z0-9.-]+\.[a-z]{2,}$`, no IP literal, no port).
- The server fetches `https://<domain>/favicon.ico` (10 s, ≤ 256 KB — multi-resolution .ico files run past 100 KB — final URL
https on the same site — last two host labels, so `github.com` →
`github.githubassets.com` is refused but `www.` redirects pass), accepts only PNG, ICO, JPEG, GIF, WebP recognised by their magic bytes
(the Content-Type header is ignored: sites omit or mislabel it) —
**never SVG**, which would run script on the dashboard's origin if the URL
were opened directly — and caches it in `<runtime dir>/ai-tool-icons/`. A
failure is cached for 24 h as a miss.
- Served with the cached type, `X-Content-Type-Options: nosniff`,
`Content-Security-Policy: sandbox`, `Cache-Control: max-age=86400`; a miss is
`204` (a 404 would log a console error per letter avatar on every visit)
and the client keeps its letter avatar; an unknown id is `404`.
## 5. API
All writes behind `sameOriginGuard`; nothing in a body names a command.
| Route | Does |
|---|---|
| `POST /api/ai-tools/:id/install` | starts the install job → `202 {job}`; `404 ENOTOOL`, `400 ENOINSTALL` (no `install`), `409 EINSTALLED`, `409 EBUSY` |
| `POST /api/ai-tools/:id/update` | starts the update job; `400 ENOUPDATE`, `409 ENOTINSTALLED`, `409 ENOTNPM` (npm update on a tool installed outside npm), `409 EBUSY` |
| `POST /api/ai-tools/update-all` | one job that updates, in catalog order, every npm-managed tool with `updateAvailable` plus every installed tool with a `self` updater; its log concatenates each step's; `400 ENOTHING` when the list is empty |
| `GET /api/ai-tools/job` | the current or last job, or `null` |
| `GET /api/ai-tools/versions` | `{ [id]: { npmManaged, installed, latest, updateAvailable } }` for tools with `update.kind === "npm"` |
| `GET /api/ai-tools/:id/icon` | § 4 |
`GET /api/ai-tools` rows gain `install` (`{kind, package?|url?}` or null) and
`update` (`{kind}`, plus `args` for `self`, or null) so the dialog can show the exact command.
## 6. UI (`AiToolsSettings.tsx`, `useAiTools.ts`)
- Avatar: `<img src="/api/ai-tools/:id/icon">`, letter avatar on `onError`.
- Available row with `install`: **Install** → confirm dialog showing
`npm install -g <package>@latest` or `Download and run <url> with bash`,
plus the homepage link. On confirm: POST, then poll `GET /job` every 1 s
while `running`; the log renders under that row in a `<pre>` (monospace,
max-height, auto-scroll); on end the list refetches.
- Installed row with `update`: **Update** (same dialog and log) — for an npm
update only when `npmManaged`, else the `Installed outside npm` note — and a
badge `v<latest> available` when `updateAvailable`.
- Header: **Update tools** beside **Refresh**, enabled when a badge or a
`self` updater exists; its dialog lists what will run.
- Every job button is disabled while a job runs; a page opened mid-job picks
it up from `GET /job`.
## 7. Testing
Server (`npm run test:server`): field validation (bad package names, a script
off the homepage's host, `install` in `DASHBOARD_AI_TOOLS_DIR` rejected,
`update: npm` without npm install); jobs with an injected exec (argv exactly as
§ 2, npm resolved from the shell's own PATH ahead of the unit's, one-at-a-time `EBUSY`, log cap,
timeout, enable-after-install, EACCES hint); script download guards (size,
https, redirect host); version parse/compare, the 24 h cache, npm-managed by realpath under the global prefix; sync
(newer-only, precedence, atomic replace, a failed sync keeps the old set,
release-trust `launcher`); icon guards (type allowlist without SVG, size,
IP-literal domain refused, headers). Client (`npm run test:client`): Install
confirm shows the exact command, the log polls and stops, badge and **Update
tools**, avatar fallback.
## 8. Docs
`CLAUDE.md` marker, `AGENTS.md`, `docs/API.md` (six routes), `server/README.md`,
`server/data/ai-tools/README.md` (the two fields, the trust rules),
`server/openapi-extra/` + regenerated `openapi.yaml`, the env var
`AGENTHUB_AI_TOOLS_SYNC` wherever env vars are listed, and the
`update-project-docs` pass.