---
name: vps-tool-installation
description: Install third-party CLIs and tools on the Hostinger VPS where /root is a read-only mount (only /root/.hermes is writable). Trigger whenever installing any binary, CLI, or agent tool on the VPS — especially when an installer fails with "Read-only file system" on $HOME paths, or when a tool hardcodes config under $HOME. Also covers agent-driven browser OAuth flows (device codes, CLI login links, API keys) where the user must complete a step in their browser.
---

# VPS Third-Party Tool Installation (read-only /root)

The VPS mounts `/root` **read-only** except `/root/.hermes`. Most installers default to `$HOME/.local/bin`, `$HOME/.<tool>`, etc. and die with `Read-only file system`. `/root/.hermes/bin` is the standard install target; secrets go in `/root/.hermes/secrets/<name>.env` (chmod 600).

## Rule 0: Never pipe-to-bash blind

Download the installer first, read where it writes, THEN run it:

```bash
curl -fsSL <url> -o /tmp/install.sh
grep -n -E "INSTALL_DIR|HOME|PREFIX" /tmp/install.sh   # find its dir logic
```

This also avoids re-triggering the pipe-to-interpreter approval prompt on retries.

## The escalation ladder (try in order)

**1. Env-var install-dir override.** Many installers honor one (`COMPOSIO_INSTALL_DIR`, `INSTALL_DIR`, etc.). Grep the script for the variable name. Caveat: the override may cover the binary but NOT the tool's runtime config dir — verify after install.

**2. PATH pre-seed trick (verified: OfficeCLI).** Many installers look for an existing install via `command -v <tool>` and install to `dirname` of the result. Exploit this:

```bash
mkdir -p /root/.hermes/bin
touch /root/.hermes/bin/<tool>          # placeholder = "existing install"
export PATH=/root/.hermes/bin:$PATH
bash /tmp/install.sh                    # "upgrades" into the writable dir
```

**3. Runtime config-dir redirects.** If the binary installs but crashes writing config to `$HOME/.<tool>`:
- Try the tool's env vars — but VERIFY; some are ignored (Composio's `COMPOSIO_DIR` did nothing).
- Quick diagnostic: `HOME=/tmp/fakehome /path/to/binary --version` — if it works with a writable HOME, the binary is fine and only the config path is the problem.

**4. Last resort: private mount namespace wrapper (verified: Composio CLI).** For hardcoded `$HOME` paths with no override, run the tool in `unshare -rm` with a bind mount onto persistent storage. Wrapper pattern:

```bash
#!/bin/bash
PERSIST_DIR=/root/.hermes/<tool>-home
BIN=/root/.hermes/<tool-dir>/<tool>
mkdir -p "$PERSIST_DIR"
exec unshare -rm bash -c "
  mkdir -p /tmp/fakehome/.<tool>
  mount --bind '$PERSIST_DIR' /tmp/fakehome/.<tool>
  export HOME=/tmp/fakehome
  exec '$BIN' \"\$@\"
" -- "$@"
```

Install wrapper at `/root/.hermes/bin/<tool>`, chmod +x. State persists in `/root/.hermes`; the read-only FS is never touched. Note: creating a symlink at `/root/.<tool>` does NOT work (read-only FS blocks symlink creation too).

## Agent-driven OAuth / CLI login flows

When a tool's auth needs Rob in a browser (device codes, `tool login` URLs, dashboard keys):

- **Start the poller BEFORE handing over the URL.** Login/session keys have short TTLs (~10 min observed). If the user completes the flow after the poller died, the key is dead weight — mint a fresh one.
- **Stale pending-session state blocks retries.** If a flow dies, delete the tool's pending-login/session file before minting a new key (Composio: `pending-login-session.json`).
- **Prefer direct API-key auth when offered** (`login --api-key`-style flags) — skips the whole browser dance. But verify the key TYPE matches what the flag expects; dashboard/onboarding pages may hand out keys for a different surface (see pitfall below).
- **Paste friction is real.** Rob sometimes can't paste from browser pages into chat. Offer alternates immediately: the page's copy button → paste into Notepad first → retype (keys are often short enough) → or a screenshot dropped into chat.
- Tell Rob to click through the ENTIRE picker/consent flow to an explicit success screen — partial completions are the common silent failure.

## Pitfalls

- **Key-type mismatch looks like auth failure.** A key can be 100% valid yet 401 everywhere because it's minted for a different surface (Composio `ck_` keys: valid on the MCP endpoint, rejected by `composio login --user-api-key`). When a fresh key 401s twice, stop retrying the same path — probe what the key actually works against before assuming user error.
- **Ubuntu's repo Node.js (v18) is too old for current MCP-ecosystem npm packages** (mcp-remote, supergateway): their undici dependency crashes at startup with `ReferenceError: File is not defined` (the `File` global landed in Node 20). Fix is NodeSource, which is RO-/root-safe because apt owns the install: `curl -fsSL https://deb.nodesource.com/setup_20.x | bash -` then `apt-get install -y nodejs` → v20.x (verified 2026-08-06, v20.20.2). Install npm packages project-locally (`mkdir /root/.hermes/<tool> && cd` + `npm install <pkg>` with `NPM_CONFIG_CACHE=/root/.hermes/.npm-cache`) rather than `-g`, then point a systemd unit at `node node_modules/.bin/<pkg>`.
- **Broad OAuth scopes trigger user anxiety.** When a consent screen asks for read/write/delete, address it head-on: scope grant is the ceiling not the plan, name the revocation paths (provider permissions page + tool dashboard), give ONE recommendation. Rob accepted full Gmail scopes on those terms with "you'll watch it" — honor that by gating destructive calls per his explicit ask.
- Approval prompts on `curl | bash` and destructive commands can time out — don't auto-retry blocked commands; surface and move on.
- **apt dependencies for headless Chromium (Playwright, Puppeteer).** A fresh VPS lacks the 12+ shared libraries Chromium needs (`libatk1.0-0`, `libcups2`, `libdrm2`, `libxkbcommon0`, `libxcomposite1`, `libxdamage1`, `libxrandr2`, `libgbm1`, `libpango-1.0-0`, `libcairo2`, `libasound2t64`, `libnspr4`, `libnss3`). Install them in one shot: `apt-get install -y --no-install-recommends libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2t64`. Note: on Ubuntu 24.04+, `libasound2` was renamed to `libasound2t64` — using the old name fails with "no installation candidate."
- **npm global install on read-only /root.** npm refuses to write `/root/.npmrc` with EROFS. Workaround: set HOME to a writable temp dir AND override the prefix in one command: `mkdir -p /tmp/npm-home && HOME=/tmp/npm-home npm config set prefix /root/.hermes/npm-global && HOME=/tmp/npm-home npm install -g <pkg>`. Both the config write and the global install need HOME set; setting prefix alone isn't enough.
- **Playwright browsers cache on read-only /root.** Playwright defaults to `~/.cache/ms-playwright` for Chromium binaries. On this VPS, `/root/.cache` is on the read-only mount. Override at install and every runtime invocation: `PLAYWRIGHT_BROWSERS_PATH=/root/.hermes/playwright-browsers playwright install chromium` and export the same env var before any Python script that imports playwright. Verified 2026-08-15: Chromium 151 installed to `/root/.hermes/playwright-browsers/chromium_headless_shell-1234/`.
- **Chromium shared-library dependencies on minimal Ubuntu.** After `playwright install`, the headless shell may fail with `libatk-1.0.so.0: cannot open shared object file`. Install the full set in one shot: `apt-get install -y --no-install-recommends libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2t64`. Note: on Ubuntu 24.04+, `libasound2` was renamed to `libasound2t64` — using the old name fails with "no installation candidate."

## Related

- Composio-specific connection mechanics (MCP-over-HTTP, the working path): skill `composio-mcp-ops`.
- VPS filesystem layout and secrets conventions: memory notes; skillclaw-ops for that stack.
