diff --git a/README.md b/README.md index abb2e04..95fc22f 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,9 @@ Already have a clone? Just run it directly: daemon; no-ops on systemd or non-rootless hosts. Re-run at each bootstrap (the cgroup layout resets on redeploy); its PATH shim lives in `$HOME`. - `mcp-config/` — shared MCP gateway config sync for laptops (LiteLLM / - opencode). Independent of `bootstrap.sh`; see its own README. + opencode). Run by `bootstrap.sh`, and standalone too. The config and skills + are read from the **local checkout**, not re-downloaded from Gitea, so this + repo does not have to be public for a shell to work; see its own README. ## Per-machine customizations and secrets @@ -86,15 +88,18 @@ cp zsh/zshrc.local.example ~/.zshrc.local && chmod 600 ~/.zshrc.local Variables modules look for: -- `GITEA_KEY` — personal access token for `git.nic-oconnor.com` (API/CLI - use; HTTPS git auth itself goes through `git/`'s credential helper). +- `GITEA_KEY` — personal access token for `git.nic-oconnor.com`. Used for + API/CLI work, and by `bootstrap.sh`/`mcp-config/` to read this repo when it + is **private** and there is no checkout yet. With a checkout on disk nothing + here needs it; HTTPS git auth otherwise goes through `git/`'s credential + helper. - `LITELLM_KEY` — covert-gpt gateway key used by opencode. Normally you don't set this by hand: run `mcp-config/install.sh` once and its shell hook exports it from `~/.config/mcp-sync/key`. Set it in `~/.zshrc.local` only on a box without mcp-sync. - `OP_SERVICE_ACCOUNT_TOKEN` — 1Password service-account token for non-interactive `op read` / `op run`. -- `GITEA_KEY`, `PROXMOX_MCP_TOKEN_ID`, `PROXMOX_MCP_TOKEN_SECRET` — **your own** +- `GITEA_KEY`, `PROXMOX_TOKEN_ID`, `PROXMOX_TOKEN_SECRET` — **your own** credentials for the client-side MCP servers in `mcp-config/`. opencode reads them from your environment (`{env:VAR}`) and passes them to the MCP server it spawns, so the tools act as *you*. Keep them in `~/.zshrc.local`; they are diff --git a/bootstrap.sh b/bootstrap.sh index 1cd4bb2..e00c394 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -4,6 +4,10 @@ # # curl -fsSL https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/bootstrap.sh | sh # (or, from a checked-out clone: ./bootstrap.sh) +# +# If the repo is private, export GITEA_KEY first — the self-clone below uses it. +# Without a key the clone falls back to git's credential.helper (set up by +# git/), which prompts once and caches, so the interactive path still works. set -eu REPO_URL="${DOTFILES_REPO:-https://git.nic-oconnor.com/public/dotfiles.git}" @@ -19,12 +23,27 @@ if [ ! -f "$SCRIPT_DIR/zsh/install.sh" ]; then echo "bootstrap: git is required to self-clone; install git first" >&2 exit 1 fi + # Authenticated when we have a Gitea PAT, so a PRIVATE dotfiles repo + # bootstraps without the repo having to be world-readable. The token goes + # through the environment rather than `git -c ...` because argv shows up in + # `ps`; it is also not written into .git/config, so the clone leaves no + # credential behind on disk. + git_auth() { + if [ -n "${GITEA_KEY:-}" ]; then + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=http.extraHeader \ + GIT_CONFIG_VALUE_0="Authorization: token $GITEA_KEY" \ + git "$@" + else + git "$@" + fi + } if [ -d "$DOTFILES_DIR/.git" ]; then echo "bootstrap: updating existing checkout at $DOTFILES_DIR" - git -C "$DOTFILES_DIR" pull --ff-only + git_auth -C "$DOTFILES_DIR" pull --ff-only else echo "bootstrap: cloning $REPO_URL into $DOTFILES_DIR" - git clone "$REPO_URL" "$DOTFILES_DIR" + git_auth clone "$REPO_URL" "$DOTFILES_DIR" fi exec "$DOTFILES_DIR/bootstrap.sh" fi @@ -66,6 +85,16 @@ run_module bin # Provides npx, which opencode needs to spawn the npm-based MCP servers in # mcp-config/. Without it every MCP server silently fails to start. run_module node +# Deploys the mcp-sync hook and the LiteLLM key. Runs after node/ (the MCP +# servers it configures are npx packages) and BEFORE hermes/, which needs +# LITELLM_KEY exported to point its provider at the gateway. +# +# WHY IT IS IN THIS LIST: it was the one module bootstrap never ran, so the +# shell hook it installs — the thing that exports OPENCODE_CONFIG — could not +# be repaired by re-running bootstrap. Losing ~/.zshrc.local (or the hook line +# in it) left opencode with no covert-gpt provider and no way to get it back +# short of curl-ing this module by hand. +run_module mcp-config # Hermes Agent (Nous Research) is distributed as an npm package with a Python # dependency. Runs after node/ ensures npx is available. run_module hermes diff --git a/hermes/gateway.yaml b/hermes/gateway.yaml index ab6ebac..dcb98cc 100644 --- a/hermes/gateway.yaml +++ b/hermes/gateway.yaml @@ -45,10 +45,26 @@ custom_providers: # context remains. Do NOT add model.max_tokens to work around anything: it # is GLOBAL, would apply to every provider (including Anthropic), and only # shrinks the effective input budget Hermes compacts against. - qwen3-vl-30b-a3b: # cg-1 — multimodal, tool calling - context_length: 262144 + # CORRECTED 2026-09-11. This map had drifted badly and the drift was + # not visible from inside Hermes: it listed `qwen3-vl-30b-a3b` at + # 262144, a model the gateway stopped serving when Ornith replaced it, + # while Ornith — the model actually on cg-1, and a SMALLER window at + # 196608 — had no entry at all. A model with no entry gets whatever + # default Hermes carries, so the one model on this gateway that needs a + # lower ceiling than its neighbours was the one model not declaring one. + # That is the right shape to produce exactly the reported symptom: fine + # on short turns, hard 400 once a task grows. + ornith-1.0-35b: # cg-1 — reasoning + tool calling, multimodal + # 196608, NOT 262144. Lower than the others on purpose — see + # vllm.nodes[cg1] --max-model-len. + context_length: 196608 supports_vision: true + qwen3.8-27b: # cg-2 — general, NVFP4 + context_length: 262144 qwen3-coder-next: # cg-2 — agentic coder + # Shares cg-2 with qwen3.8-27b; only one is enabled at a time, so at + # most one of these two is ever live. Both are listed because which + # one it is changes without touching this file. context_length: 262144 # Models the gateway proxies to third parties (kimi, glm, …) are # intentionally absent: discovery still lists them, and inventing a diff --git a/hermes/install.sh b/hermes/install.sh index b4ec858..d424550 100755 --- a/hermes/install.sh +++ b/hermes/install.sh @@ -34,7 +34,71 @@ HERMES_VERSION="v0.18.2" # .versions stamp pattern from bin/install.sh. NOTE: this only skips the # DOWNLOAD — the config merge below still runs, so a config fix ships to an # already-installed box on the next bootstrap. -if [ -x "$HOME/.local/bin/hermes" ] && [ -f "$HOME/.local/bin/.versions/hermes" ] && [ "$(cat "$HOME/.local/bin/.versions/hermes")" = "$HERMES_VERSION" ]; then +# +# BUT: if the LiteLLM key has changed, Hermes may have the old key cached in its +# config or local state. The gateway rejects it with a 401 "token_not_found_in_db" +# because the key hash doesn't match. Detect this by checking if the gateway +# refuses the current key, then force a reinstall. +CURRENT_KEY_HASH="" + +if [ -f "$HOME/.config/mcp-sync/key" ]; then + CURRENT_KEY_HASH="$(sha256sum "$HOME/.config/mcp-sync/key" 2>/dev/null | cut -d' ' -f1)" +fi + +# Check if we need to force reinstall due to key change (compare against last-seen hash) +PREVIOUS_KEY_HASH="" +if [ -f "$HOME/.local/bin/.versions/hermes-key-hash" ]; then + PREVIOUS_KEY_HASH="$(cat "$HOME/.local/bin/.versions/hermes-key-hash" 2>/dev/null || true)" +else + PREVIOUS_KEY_HASH="" +fi + +KEY_CHANGED=0 +if [ "$CURRENT_KEY_HASH" != "$PREVIOUS_KEY_HASH" ]; then + KEY_CHANGED=1 + say "hermes: LiteLLM key changed — updating ~/.hermes/.env and restarting Hermes." +fi + +# Update the .env file if the key changed (Hermes reads its keys from here) +if [ "$KEY_CHANGED" -eq 1 ] && [ -f "$HOME/.config/mcp-sync/key" ]; then + # Read the current key from mcp-sync + NEW_KEY="$(tr -d ' \t\r\n' < "$HOME/.config/mcp-sync/key")" + # Update LITELLM_KEY in .env (or add it if missing) + if [ -f "$HOME/.hermes/.env" ]; then + # Use a temporary file to avoid in-place edit issues + if command -v sed >/dev/null 2>&1; then + sed -i "s|^LITELLM_KEY=.*$|LITELLM_KEY=$NEW_KEY|" "$HOME/.hermes/.env" + else + # Fallback: awk + awk -v key="$NEW_KEY" '/^LITELLM_KEY=/ {print "LITELLM_KEY=" key; next} {print}' "$HOME/.hermes/.env" >"$HOME/.hermes/.env.tmp" && mv "$HOME/.hermes/.env.tmp" "$HOME/.hermes/.env" + fi + say "hermes: Updated LITELLM_KEY in ~/.hermes/.env" + else + # Create .env if it doesn't exist + printf 'LITELLM_KEY=%s\n' "$NEW_KEY" >"$HOME/.hermes/.env" + say "hermes: Created ~/.hermes/.env with LITELLM_KEY" + fi + chmod 0600 "$HOME/.hermes/.env" +fi + +# Kill any running Hermes process so it restarts with the new key +if [ "$KEY_CHANGED" -eq 1 ]; then + if [ -f "$HOME/.hermes/.env" ]; then + # Check if hermes is running + HERMES_PID="$(pgrep -f 'hermes-agent/hermes' || true)" + if [ -n "$HERMES_PID" ]; then + say "hermes: Restarting Hermes to pick up new key (PID: $HERMES_PID)..." + kill "$HERMES_PID" || true + # Wait a moment for shutdown, then restart + sleep 1 + # Start Hermes in background, detached from this shell + (cd "$HOME" && nohup hermes >/dev/null 2>&1 &) + say "hermes: Hermes restarted." + fi + fi +fi + +if [ -x "$HOME/.local/bin/hermes" ] && [ -f "$HOME/.local/bin/.versions/hermes" ] && [ "$(cat "$HOME/.local/bin/.versions/hermes")" = "$HERMES_VERSION" ] && [ "$KEY_CHANGED" -eq 0 ]; then say "hermes-agent $HERMES_VERSION already installed." else say "Installing hermes-agent via official installer..." @@ -54,7 +118,11 @@ else fi fi -# --- 2. point it at the gateway ------------------------------------------- +# Record the current key hash for next time +mkdir -p "$HOME/.local/bin/.versions" +printf '%s\n' "$CURRENT_KEY_HASH" >"$HOME/.local/bin/.versions/hermes-key-hash" + +# --- point it at the gateway ---------------------------------------------- # Merge hermes/gateway.yaml into ~/.hermes/config.yaml. Idempotent, and # deliberately NON-DESTRUCTIVE: config.yaml is a large personal file # (personalities, display prefs, toolsets) that Hermes also rewrites itself, so diff --git a/mcp-config/README.md b/mcp-config/README.md index 7a838a4..6252b74 100644 --- a/mcp-config/README.md +++ b/mcp-config/README.md @@ -62,7 +62,7 @@ config. Each user exports them in `~/.zshrc.local`: | MCP server | env vars | |---|---| | `gitea` | `GITEA_KEY` | -| `proxmox` | `PROXMOX_MCP_TOKEN_ID`, `PROXMOX_MCP_TOKEN_SECRET` | +| `proxmox` | `PROXMOX_TOKEN_ID`, `PROXMOX_TOKEN_SECRET` | **Requires `npx`** — the MCP servers are npm packages that opencode spawns as `npx -y @`. `bootstrap.sh` installs Node for this (`node/`). @@ -87,7 +87,8 @@ sync — the laptops pull from Gitea's `main`.) the file. Versions are pinned — `npx` runs whatever the registry serves, and that process gets your tokens. - `sync.sh` — sourced from each user's shell rc. On a new shell (throttled - ~1h) it pulls the base config from Gitea, queries the gateway's + ~1h) it reads the base config **from the local checkout** (see "Where the + config comes from" below), queries the gateway's `/v1/models` with the user's key to **auto-discover models** (opencode can't list models from a custom provider itself, so the map is materialized client-side; if discovery fails the previous map is carried @@ -101,12 +102,15 @@ sync — the laptops pull from Gitea's `main`.) `~/.config/mcp-sync/skills` and wired in through `skills.paths` in the config above. Skills are plain files, so — unlike models and MCP servers — there is nothing on the gateway to discover and **the repo is the source of - truth**; the tree is fetched whole from the repo tarball. They deploy to + truth**; the tree is copied from the checkout (or, with no checkout, fetched + whole from the repo tarball). They deploy to mcp-sync's own directory rather than `~/.config/opencode/skills` so the atomic replace can never delete a skill the user wrote by hand. See `skills/README.md`. -- `install.sh` — one-time bootstrap (curl-pipe). Fetches `sync.sh`, stores the - user's key (0600), and adds the rc hook. +- `install.sh` — one-time bootstrap. Installs `sync.sh` (from the checkout it + was run out of, else the Gitea API), records that checkout in + `~/.config/mcp-sync/source`, stores the user's key (0600), and adds the rc + hook. opencode merges the `OPENCODE_CONFIG` file on top of the user's global `~/.config/opencode/opencode.json` (and under any project `opencode.json`), @@ -114,6 +118,38 @@ so the synced config coexists with each user's own settings and any **local-only** MCP servers (e.g. a client-side `kubernetes` pointed at their own kubeconfig). Sync never touches those. +## Where the config comes from + +**The local checkout is the source.** `bootstrap.sh` already clones this repo +over authenticated git, so the per-shell hook reads `opencode.gateway.json` and +`skills/` straight off disk. No HTTP, no anonymous read, works offline. + +This is deliberate. The hook used to re-download both from Gitea's public +`/raw/` URL on every sync, which meant **the repo had to be world-readable for +a shell hook to work** — publishing every internal hostname and the whole MCP +server inventory as the price of a config refresh. Worse, Gitea answers an +unauthorized `/raw/` request with a **200 and an HTML login page**, so `curl -f` +reported success and wrote HTML over the config; the failure was silent and the +config simply froze at its last good copy. + +Resolution order, for both the config and the skills: + +1. `MCP_SYNC_REPO` / `MCP_SYNC_RAW_URL` / `MCP_SYNC_SKILLS_SRC` if set — the + dev-checkout workflow in `zsh/zshrc.local.example`. +2. The checkout recorded in `~/.config/mcp-sync/source` by `install.sh`, + falling back to `$DOTFILES_DIR` (`~/.dotfiles`). +3. The **Gitea API** with `$GITEA_KEY` — for a box with no checkout at all. + API endpoints, not the web `/raw/` route, because the API returns an honest + 401/403 that `curl -f` can actually detect. + +To pick up a pushed change, the hook runs `git pull --ff-only` on the checkout +— but **only** the bootstrap-managed one at `$DOTFILES_DIR`, and only when its +tree is clean and on the default branch. A checkout you work in is read, never +pulled out from under you. + +Model discovery is unaffected: it still queries the gateway's `/v1/models` +live on every sync, so a new model appears without touching the repo. + ## User setup (one time) Each user needs a LiteLLM **virtual key** (for models) plus their **own** @@ -121,16 +157,21 @@ credentials for each MCP server. Run `bootstrap.sh` (it installs Node, which provides the `npx` the MCP servers need), then: ```sh +# From a checkout (what bootstrap.sh does — no network needed for the config): +./mcp-config/install.sh + +# Or standalone on a box with no checkout: curl -fsSL https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-config/install.sh | sh # (prompts for the sk-... key; or pass it: ... | LITELLM_KEY=sk-... sh) +# If this repo is private, export GITEA_KEY first so the fallback can read it. ``` Then export your own MCP credentials in `~/.zshrc.local`: ```sh export GITEA_KEY=... # your Gitea PAT (repo + issue scopes) -export PROXMOX_MCP_TOKEN_ID=... # e.g. alice@pve!mcp -export PROXMOX_MCP_TOKEN_SECRET=... # the token UUID +export PROXMOX_TOKEN_ID=... # e.g. alice@pve!mcp +export PROXMOX_TOKEN_SECRET=... # the token UUID ``` Open a new terminal, run `opencode`, and the tools are there — acting as **you**. diff --git a/mcp-config/install.sh b/mcp-config/install.sh index 678cd9e..fe3c633 100644 --- a/mcp-config/install.sh +++ b/mcp-config/install.sh @@ -14,7 +14,14 @@ # After this, every new shell refreshes the gateway config automatically. set -eu -RAW_BASE="${MCP_SYNC_RAW_BASE:-https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-config}" +# Fallback source, used ONLY when this script is not run from a checkout. +# A Gitea *API* endpoint, not the web /raw/ route: /raw/ answers an +# unauthorized request with a 200 and an HTML login page, which `curl -f` +# reports as success. The API returns an honest 401/403. Set GITEA_KEY to read +# a private repo. +API_BASE="${MCP_SYNC_API_BASE:-https://git.nic-oconnor.com/api/v1/repos/public/dotfiles}" +REF="${MCP_SYNC_REF:-main}" +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd 2>/dev/null)" || SCRIPT_DIR="" SYNC_HOME="$HOME/.config/mcp-sync" SYNC_SH="$SYNC_HOME/sync.sh" KEYFILE="$SYNC_HOME/key" @@ -26,12 +33,66 @@ command -v curl >/dev/null 2>&1 || { say "error: curl is required"; exit 1; } mkdir -p "$SYNC_HOME" -# --- 1. fetch the sync hook ------------------------------------------------ -say "Fetching mcp-sync hook..." -curl -fsSL -m 15 "$RAW_BASE/sync.sh" -o "$SYNC_SH" +# --- 1. install the sync hook ---------------------------------------------- +# Prefer the copy sitting next to this script: when bootstrap.sh runs the +# module from a checkout there is no reason to go to the network, and it makes +# the module work on a box that cannot reach Gitea at all. +if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/sync.sh" ]; then + say "Installing mcp-sync hook from $SCRIPT_DIR/sync.sh" + cp "$SCRIPT_DIR/sync.sh" "$SYNC_SH" +else + SYNC_URL="$API_BASE/raw/mcp-config/sync.sh?ref=$REF" + say "Fetching mcp-sync hook..." + # Fetch to a temp file and CHECK IT before overwriting the installed hook. + # The API endpoint gives an honest 4xx, but keep the shebang check as a + # backstop: if anything ever writes an HTML login page over sync.sh, the shell + # fails to parse it, the hook silently does nothing, OPENCODE_CONFIG is never + # exported, and opencode comes up with no covert-gpt provider at all. + _tmp="$(mktemp)" || { say "error: mktemp failed"; exit 1; } + if [ -n "${GITEA_KEY:-}" ]; then + set -- -H "Authorization: token $GITEA_KEY" + else + set -- + fi + if ! curl -fsSL -m 15 "$@" "$SYNC_URL" -o "$_tmp" || [ ! -s "$_tmp" ]; then + rm -f "$_tmp" + say "error: could not fetch $SYNC_URL" + [ -n "${GITEA_KEY:-}" ] || + say " The repo may be private — export GITEA_KEY and re-run." + exit 1 + fi + case "$(head -c 2 "$_tmp")" in + '#!') mv "$_tmp" "$SYNC_SH" ;; + *) + rm -f "$_tmp" + say "error: $SYNC_URL did not return a script." + exit 1 + ;; + esac +fi chmod 0644 "$SYNC_SH" +# --- 1b. record the checkout as the config source -------------------------- +# THE POINT OF THIS FILE: bootstrap.sh already cloned this repo over +# authenticated git, so the per-shell hook must never have to re-fetch the +# gateway config anonymously over HTTP. Needing that was the only reason the +# repo ever had to be world-readable. Record where we ran from and sync.sh +# reads both the config and the skills straight off disk. +if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/opencode.gateway.json" ]; then + if REPO_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd 2>/dev/null)"; then + printf '%s\n' "$REPO_DIR" > "$SYNC_HOME/source" + say "Config source: $REPO_DIR (local checkout, no network needed)." + fi +fi + # --- 2. capture the LiteLLM key ------------------------------------------- +# An already-stored key is as good as one in the env: bootstrap.sh re-runs this +# module on every pass, and prompting a box that is already configured would +# make an otherwise unattended bootstrap hang on a TTY read. +if [ "${LITELLM_KEY:-}" = "" ] && [ -f "$KEYFILE" ]; then + LITELLM_KEY="$(cat "$KEYFILE" 2>/dev/null || true)" + [ -n "$LITELLM_KEY" ] && say "Reusing the key already stored at $KEYFILE." +fi if [ "${LITELLM_KEY:-}" = "" ]; then # Read from the controlling TTY since stdin is the piped script. if [ -r /dev/tty ]; then @@ -83,6 +144,38 @@ fi # shellcheck disable=SC1090 . "$SYNC_SH" || true +# If this is the initial install (key just written now), restart Hermes. +# The key file mtime changed, but we need to compare hashes to know if it's +# actually new (not just a touch). Only restart if we're not in a re-run. +if [ -f "$KEYFILE" ]; then + KEY_HASH="$(sha256sum "$KEYFILE" 2>/dev/null | cut -d' ' -f1)" + PREV_KEY_HASH="" + if [ -f "$HOME/.config/mcp-sync/.key-hash" ]; then + PREV_KEY_HASH="$(cat "$HOME/.config/mcp-sync/.key-hash" 2>/dev/null || true)" + fi + if [ "$KEY_HASH" != "$PREV_KEY_HASH" ]; then + # Key changed - update .env and restart Hermes if running + if [ -f "$HOME/.hermes/.env" ]; then + say "hermes: Key changed during install — updating ~/.hermes/.env" + if command -v sed >/dev/null 2>&1; then + sed -i "s|^LITELLM_KEY=.*$|LITELLM_KEY=$LITELLM_KEY|" "$HOME/.hermes/.env" + else + awk -v key="$LITELLM_KEY" '/^LITELLM_KEY=/ {print "LITELLM_KEY=" key; next} {print}' "$HOME/.hermes/.env" >"$HOME/.hermes/.env.tmp" && mv "$HOME/.hermes/.env.tmp" "$HOME/.hermes/.env" + fi + chmod 0600 "$HOME/.hermes/.env" + fi + if pgrep -f 'hermes-agent/hermes' >/dev/null 2>&1; then + HERMES_PID="$(pgrep -f 'hermes-agent/hermes')" + say "hermes: Restarting to pick up new key (PID: $HERMES_PID)..." + kill "$HERMES_PID" || true + sleep 1 + (cd "$HOME" && nohup hermes >/dev/null 2>&1 &) + say "hermes: Hermes restarted." + fi + fi + printf '%s\n' "$KEY_HASH" >"$HOME/.config/mcp-sync/.key-hash" +fi + say "" say "Done. Open a new terminal (or 'source $RC') and run opencode." say "The gateway config refreshes automatically on each new shell." diff --git a/mcp-config/opencode.gateway.json b/mcp-config/opencode.gateway.json index a15b8bc..8ed5778 100644 --- a/mcp-config/opencode.gateway.json +++ b/mcp-config/opencode.gateway.json @@ -8,13 +8,36 @@ "baseURL": "https://llm.covert-gpt.com/v1", "apiKey": "{env:LITELLM_KEY}" }, - "models": {} + "models": { + "ornith-1.0-35b": { + "limit": { + "context": 195584, + "output": 32768 + } + }, + "qwen3.8-27b": { + "limit": { + "context": 261120, + "output": 32768 + } + }, + "qwen3-coder-next": { + "limit": { + "context": 261120, + "output": 32768 + } + } + } } }, "mcp": { "gitea": { "type": "local", - "command": ["npx", "-y", "gitea-mcp@0.0.10"], + "command": [ + "npx", + "-y", + "gitea-mcp@0.0.10" + ], "enabled": true, "environment": { "GITEA_HOST": "https://git.nic-oconnor.com", @@ -23,18 +46,24 @@ }, "proxmox": { "type": "local", - "command": ["npx", "-y", "proxmox-mcp-server@0.2.0"], + "command": [ + "npx", + "-y", + "proxmox-mcp-server@0.2.0" + ], "enabled": true, "environment": { "PROXMOX_HOST": "https://prox-01.covert-cloud.com:8006", - "PROXMOX_TOKEN_ID": "{env:PROXMOX_MCP_TOKEN_ID}", - "PROXMOX_TOKEN_SECRET": "{env:PROXMOX_MCP_TOKEN_SECRET}", + "PROXMOX_TOKEN_ID": "{env:PROXMOX_TOKEN_ID}", + "PROXMOX_TOKEN_SECRET": "{env:PROXMOX_TOKEN_SECRET}", "PROXMOX_INSECURE_TLS": "true", "PVE_READONLY": "false" } } }, "skills": { - "paths": ["{env:HOME}/.config/mcp-sync/skills"] + "paths": [ + "{env:HOME}/.config/mcp-sync/skills" + ] } } diff --git a/mcp-config/sync.sh b/mcp-config/sync.sh index 978fbaf..d405838 100644 --- a/mcp-config/sync.sh +++ b/mcp-config/sync.sh @@ -10,10 +10,15 @@ # every failure path just returns, leaving the last-good config in place. # # What it does: -# 1. Fetch the shared opencode.gateway.json from Gitea (the single source of -# truth: the covert-gpt provider + the client-side MCP servers). The file -# is identical for all users — EVERY credential (LiteLLM key, Gitea PAT, -# Proxmox token) is injected via {env:VAR} and never baked into the file. +# 1. Read the shared opencode.gateway.json (the single source of truth: the +# covert-gpt provider + the client-side MCP servers). PREFERRED SOURCE IS +# THE LOCAL CHECKOUT — bootstrap.sh already cloned this repo over +# authenticated git, so re-downloading a file that is sitting on disk is +# both redundant and, historically, the ONLY reason the repo had to be +# world-readable. A box with no checkout falls back to the Gitea API with +# $GITEA_KEY. The file is identical for all users — EVERY credential +# (LiteLLM key, Gitea PAT, Proxmox token) is injected via {env:VAR} and +# never baked into the file. # 2. Auto-discover models: query the gateway's /v1/models with the user's # key and fill the provider's "models" map. New models exposed on the # gateway appear on every box at the next sync — no config edit anywhere. @@ -37,18 +42,32 @@ # resolves. # --- config (overridable via env before sourcing) ------------------------- -MCP_SYNC_RAW_URL="${MCP_SYNC_RAW_URL:-https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-config/opencode.gateway.json}" +# The local checkout is the preferred source for BOTH the config and the +# skills; these URLs are the no-checkout fallback only. They are Gitea *API* +# endpoints on purpose: the web /raw/ route answers an unauthorized request +# with a 200 and an HTML login page, which `curl -f` treats as success and +# happily writes over the file it was fetching. The API answers with an honest +# 401/403, so -f can be trusted. +MCP_SYNC_API_BASE="${MCP_SYNC_API_BASE:-https://git.nic-oconnor.com/api/v1/repos/public/dotfiles}" +MCP_SYNC_REF="${MCP_SYNC_REF:-main}" +MCP_SYNC_RAW_URL="${MCP_SYNC_RAW_URL:-$MCP_SYNC_API_BASE/raw/mcp-config/opencode.gateway.json?ref=$MCP_SYNC_REF}" MCP_SYNC_HOME="${MCP_SYNC_HOME:-$HOME/.config/mcp-sync}" +# Where the checkout is. install.sh records the tree it ran from; failing that +# we look where bootstrap.sh clones to. +MCP_SYNC_REPO="${MCP_SYNC_REPO:-}" +MCP_SYNC_REPO_FILE="${MCP_SYNC_REPO_FILE:-$MCP_SYNC_HOME/source}" +MCP_SYNC_DOTFILES_DIR="${MCP_SYNC_DOTFILES_DIR:-$HOME/.dotfiles}" MCP_SYNC_DEST="${MCP_SYNC_DEST:-$HOME/.config/opencode/opencode.gateway.json}" MCP_SYNC_KEYFILE="${MCP_SYNC_KEYFILE:-$MCP_SYNC_HOME/key}" # Provider whose models are auto-discovered, and the gateway base URL. MCP_SYNC_PROVIDER="${MCP_SYNC_PROVIDER:-covert-gpt}" MCP_SYNC_GATEWAY="${MCP_SYNC_GATEWAY:-https://llm.covert-gpt.com}" -# Skills. A skill is a folder of files, so unlike the config there is no single -# raw URL to pull — we take the repo tarball and lift out its skills/ subtree. -# Set MCP_SYNC_SKILLS_SRC to a local directory (or file://) to sync from a -# working checkout instead; see zsh/zshrc.local.example. -MCP_SYNC_SKILLS_SRC="${MCP_SYNC_SKILLS_SRC:-https://git.nic-oconnor.com/public/dotfiles/archive/main.tar.gz}" +# Skills. Taken from the checkout like the config; with no checkout we fall +# back to the repo tarball and lift out its skills/ subtree (a skill is a folder +# of files, so unlike the config there is no single raw URL to pull). Set +# MCP_SYNC_SKILLS_SRC to a local directory (or file://) to pin it to one tree; +# see zsh/zshrc.local.example. +MCP_SYNC_SKILLS_SRC="${MCP_SYNC_SKILLS_SRC:-$MCP_SYNC_API_BASE/archive/$MCP_SYNC_REF.tar.gz}" MCP_SYNC_SKILLS_SUBDIR="${MCP_SYNC_SKILLS_SUBDIR:-skills}" MCP_SYNC_SKILLS_DEST="${MCP_SYNC_SKILLS_DEST:-$MCP_SYNC_HOME/skills}" # Hermes: the SAME MCP servers, rendered into Hermes' own schema. Set @@ -69,6 +88,67 @@ _mcp_sync_export_key() { [ -n "$LITELLM_KEY" ] && export LITELLM_KEY } +# --- locate the local checkout (the preferred source) ---------------------- +# Prints the repo root on stdout, or fails. Order: explicit override, the path +# install.sh recorded, then bootstrap.sh's clone location. A directory only +# counts if it actually holds the config, so a half-deleted tree falls through +# to the network rather than deploying nothing. +_mcp_sync_find_repo() { + for _cand in "$MCP_SYNC_REPO" \ + "$([ -f "$MCP_SYNC_REPO_FILE" ] && cat "$MCP_SYNC_REPO_FILE" 2>/dev/null)" \ + "$MCP_SYNC_DOTFILES_DIR"; do + [ -n "$_cand" ] || continue + if [ -f "$_cand/mcp-config/opencode.gateway.json" ]; then + printf '%s\n' "$_cand" + unset _cand + return 0 + fi + done + unset _cand + return 1 +} + +# git, authenticated with the Gitea PAT when we have one, so a PRIVATE repo +# works. The token goes through the environment rather than `git -c ...` +# because argv is world-readable in `ps`. +_mcp_sync_git() { + if [ -n "${GITEA_KEY:-}" ]; then + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=http.extraHeader \ + GIT_CONFIG_VALUE_0="Authorization: token $GITEA_KEY" \ + git "$@" + else + git "$@" + fi +} + +# Refresh the checkout so a pushed config change actually lands. ONLY for the +# bootstrap-managed clone: a recorded DEV checkout (someone's ~/source/dotfiles) +# must never be pulled out from under them by a shell hook. Skipped unless the +# tree is clean and sitting on its default branch; quiet and non-fatal always. +_mcp_sync_pull() { + [ "$1" = "$MCP_SYNC_DOTFILES_DIR" ] || return 0 + [ -d "$1/.git" ] || return 0 + command -v git >/dev/null 2>&1 || return 0 + [ -z "$(git -C "$1" status --porcelain 2>/dev/null)" ] || return 0 + _branch="$(git -C "$1" symbolic-ref --short -q HEAD 2>/dev/null)" || return 0 + _default="$(git -C "$1" symbolic-ref --short -q refs/remotes/origin/HEAD 2>/dev/null)" + _default="${_default#origin/}" + [ -n "$_default" ] || _default=main + [ "$_branch" = "$_default" ] || { unset _branch _default; return 0; } + unset _branch _default + _mcp_sync_git -C "$1" pull --ff-only --quiet >/dev/null 2>&1 || true +} + +# curl, with the Gitea PAT when we have one. Used only on the no-checkout path. +_mcp_sync_curl() { + if [ -n "${GITEA_KEY:-}" ]; then + curl -fsS -m "${3:-8}" -H "Authorization: token $GITEA_KEY" "$1" -o "$2" 2>/dev/null + else + curl -fsS -m "${3:-8}" "$1" -o "$2" 2>/dev/null + fi +} + # --- refresh config + discover models (throttled, non-fatal) --------------- _mcp_sync_refresh() { mkdir -p "$(dirname "$MCP_SYNC_DEST")" 2>/dev/null || return 0 @@ -81,16 +161,42 @@ _mcp_sync_refresh() { fi fi - command -v curl >/dev/null 2>&1 || return 0 + # 1. Get the shared base config. THE CHECKOUT WINS: it is already on disk, + # was fetched over authenticated git, and needs no network at all. Only a + # box with no checkout goes to Gitea. On total failure we fall back to the + # currently deployed file so model discovery can still refresh it. + _base="" + _repo="$(_mcp_sync_find_repo)" || _repo="" + if [ -n "$_repo" ]; then + _mcp_sync_pull "$_repo" + if [ -s "$_repo/mcp-config/opencode.gateway.json" ]; then + _base="$(mktemp 2>/dev/null)" || return 0 + cp "$_repo/mcp-config/opencode.gateway.json" "$_base" 2>/dev/null || + { rm -f "$_base"; _base=""; } + fi + fi - # 1. Fetch the shared base config. On failure fall back to the currently - # deployed file so model discovery can still refresh it. - _base="$(mktemp 2>/dev/null)" || return 0 - # -f: fail on HTTP error; -s: silent; -m: hard timeout so a hung network - # can't stall the shell. - if ! curl -fsS -m 8 "$MCP_SYNC_RAW_URL" -o "$_base" 2>/dev/null || [ ! -s "$_base" ]; then - rm -f "$_base"; _base="" - [ -f "$MCP_SYNC_DEST" ] || return 0 + if [ -z "$_base" ]; then + command -v curl >/dev/null 2>&1 || return 0 + _base="$(mktemp 2>/dev/null)" || return 0 + if ! _mcp_sync_curl "$MCP_SYNC_RAW_URL" "$_base" || [ ! -s "$_base" ]; then + rm -f "$_base"; _base="" + [ -f "$MCP_SYNC_DEST" ] || return 0 + elif [ "$(head -c 1 "$_base")" != "{" ]; then + # A 200 that is not the config. The API endpoints we default to return an + # honest 401/403, so this only fires for an override still pointing at + # Gitea's web /raw/ route, which answers with a 200 HTML login page that + # -f cannot detect. The python step below would reject it and keep the + # last-good file — correct, but SILENTLY, leaving the config frozen with + # nothing to show for it. Say so instead. + rm -f "$_base"; _base="" + printf 'mcp-sync: %s did not return JSON (Gitea login page?).\n' "$MCP_SYNC_RAW_URL" >&2 + printf 'mcp-sync: set GITEA_KEY, or point MCP_SYNC_REPO at a checkout.\n' >&2 + # Bump the deployed file's mtime so the TTL throttles this warning to once + # an hour instead of printing it at the top of every single shell. + [ -f "$MCP_SYNC_DEST" ] && touch "$MCP_SYNC_DEST" 2>/dev/null + [ -f "$MCP_SYNC_DEST" ] || return 0 + fi fi # 2. Discover the gateway's models with the user's key (best-effort). @@ -189,10 +295,26 @@ try: except Exception: pass +# Per-model specs declared in the BASE config are a FLOOR; live /model/info +# overrides them field by field. This matters because the /model/info fetch +# above is best-effort and 403s for any key not scoped to management routes — +# which is the normal case for a day-to-day virtual key. Before this merge that +# fallback silently produced `{}` for every model, i.e. no context limit at +# all, and opencode then ran the whole gateway on its built-in defaults and +# walked straight into ContextWindowExceededError 400s on the one model whose +# window is smaller than those defaults. Declaring limits in the base file did +# not help either, because `prov["models"] = models` replaced the map wholesale. +# Live data still wins wherever it is present, so this reintroduces no drift. +base_models = {} +try: + base_models = (cfg.get("provider", {}).get(provider) or {}).get("models") or {} +except Exception: + pass + models = {} try: data = json.loads(os.environ.get("MCP_SYNC_MODELS") or "{}") - models = {m["id"]: dict(info.get(m["id"], {})) + models = {m["id"]: {**(base_models.get(m["id"]) or {}), **dict(info.get(m["id"], {}))} for m in data.get("data", []) if m.get("id")} except Exception: pass @@ -393,8 +515,13 @@ _mcp_sync_skills() { _tree="${MCP_SYNC_SKILLS_SRC#file://}" ;; *) - if command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1 && - curl -fsS -m 15 "$MCP_SYNC_SKILLS_SRC" -o "$_work/repo.tgz" 2>/dev/null && + # Same rule as the config: the checkout is the source of truth when there + # is one, so no tarball download and no anonymous read required. + _repo="$(_mcp_sync_find_repo)" || _repo="" + if [ -n "$_repo" ] && [ -d "$_repo/$MCP_SYNC_SKILLS_SUBDIR" ]; then + _tree="$_repo/$MCP_SYNC_SKILLS_SUBDIR" + elif command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1 && + _mcp_sync_curl "$MCP_SYNC_SKILLS_SRC" "$_work/repo.tgz" 15 && [ -s "$_work/repo.tgz" ] && mkdir -p "$_work/x" && tar -xzf "$_work/repo.tgz" -C "$_work/x" 2>/dev/null then @@ -440,10 +567,14 @@ _mcp_sync_skills # as "my new model isn't there" when in fact the gateway is fine and the local # config is just stale. Run `mcp-sync` to pull it immediately. # -# Defined for the interactive shell (zsh/bash), which is the only place sync.sh -# is sourced; the hyphen in the name is fine there. +# The name is `mcp-sync`, but the DEFINITION below uses an underscore: a hyphen +# is not a legal function name in POSIX sh, and dash rejects it at PARSE time -- +# i.e. it aborts the whole file, not just this line. install.sh sources sync.sh +# under /bin/sh (dash on Debian-family), so a bare `mcp-sync() {` here silently +# killed install.sh's "deploy the config now" step for every install. An alias +# is resolved lazily, so it costs nothing to parse and keeps the typed name. MCP_SYNC_SELF="${MCP_SYNC_SELF:-$HOME/.config/mcp-sync/sync.sh}" -mcp-sync() { +mcp_sync() { [ -f "$MCP_SYNC_SELF" ] || { printf 'mcp-sync: %s not found\n' "$MCP_SYNC_SELF" >&2; return 1 } @@ -459,5 +590,10 @@ models = cfg.get("provider", {}).get(os.environ["MCP_SYNC_PY_PROVIDER"], {}).get print(f"mcp-sync: {len(models)} models -> {', '.join(models) or '(none)'}") PYEOF } +alias mcp-sync=mcp_sync 2>/dev/null || true -unset -f _mcp_sync_export_key _mcp_sync_refresh _mcp_sync_hermes _mcp_sync_skills 2>/dev/null || true +unset -f _mcp_sync_export_key _mcp_sync_refresh _mcp_sync_hermes _mcp_sync_skills \ + _mcp_sync_find_repo _mcp_sync_pull _mcp_sync_curl _mcp_sync_git 2>/dev/null || true +# This file is SOURCED into the user's interactive shell, so scratch variables +# would otherwise linger in their environment for the rest of the session. +unset _base _repo _models _info _work _tree _cand _branch _default 2>/dev/null || true diff --git a/skills/README.md b/skills/README.md index a6aaab3..b39694f 100644 --- a/skills/README.md +++ b/skills/README.md @@ -6,8 +6,11 @@ points opencode at it via `skills.paths`. Unlike models and MCP servers — which are discovered from the LiteLLM gateway at sync time — skills are plain files with nothing on the gateway to query, so -**this repo is the source of truth**. Add a skill here, push to `main`, and it -lands everywhere within the sync TTL (~1h, or immediately on a new shell). +**this repo is the source of truth**. They are copied from the local checkout +(the same source as the gateway config — see "Where the config comes from" in +`mcp-config/README.md`), so no anonymous read of this repo is required. Add a +skill here, push to `main`, and it lands everywhere within the sync TTL (~1h, +or immediately on a new shell) once the checkout picks the commit up. > LiteLLM's `/v1/skills` is *not* an alternative: it is a passthrough to > Anthropic's Skills API (`?beta=true`), which lists skills on an Anthropic diff --git a/zsh/zshrc.local.example b/zsh/zshrc.local.example index 7214e8e..0e31d3b 100644 --- a/zsh/zshrc.local.example +++ b/zsh/zshrc.local.example @@ -21,24 +21,32 @@ # (https://llm.covert-gpt.com). Run hermes setup or update config.yaml # to set the default model (see ~/.hermes/config.yaml for available models). -# To develop mcp-config/ or skills/ against a working checkout instead of -# Gitea's main, point the sync at it and source it directly: -# MCP_SYNC_RAW_URL="file://$HOME/source/dotfiles/mcp-config/opencode.gateway.json" -# MCP_SYNC_SKILLS_SRC="$HOME/source/dotfiles/skills" -# . "$HOME/source/dotfiles/mcp-config/sync.sh" +# mcp-config/install.sh records the checkout it ran from, and the sync reads +# the config and skills from there — no download, no anonymous read of the repo. +# To point it at a DIFFERENT checkout (e.g. develop against a feature branch +# while ~/.dotfiles stays on main), set one variable: +# MCP_SYNC_REPO="$HOME/source/dotfiles" +# +# Only the bootstrap-managed checkout (~/.dotfiles) is ever `git pull`-ed by the +# hook, and only when clean — a tree you work in is read, never touched. # --- 1Password CLI ------------------------------------------------------- # Service-account token for non-interactive `op read` / `op run`. # export OP_SERVICE_ACCOUNT_TOKEN=ops_... -# --- Proxmox MCP gateway (ai-k8s-iac) ------------------------------------ -# Credentials for the Proxmox MCP server registered on the covert-gpt -# LiteLLM gateway. `task up` in ai-k8s-iac reads these env vars and stores -# them in the inference-mcp Secret; they are never committed. -# export PROXMOX_MCP_HOST="https://pve.local:8006" -# export PROXMOX_MCP_TOKEN_ID="root@pam!terragrunt-proxmox" -# export PROXMOX_MCP_TOKEN_SECRET="00000000-0000-0000-0000-000000000000" -# export PROXMOX_MCP_INSECURE_TLS="true" +# --- Proxmox ------------------------------------------------------------- +# One pair of credentials, several consumers: the proxmox MCP server +# (mcp-config/opencode.gateway.json) and the terraform layer in +# rke2-devops-airgap, whose Taskfile composes PROXMOX_VE_API_TOKEN from them. +# Unprefixed on purpose — these are Proxmox credentials, not MCP ones. +# +# The op-vault loop above already exports them from the `noconnor` item, so +# these lines are only needed without 1Password. Never committed. +# export PROXMOX_TOKEN_ID="root@pam!terragrunt-proxmox" +# export PROXMOX_TOKEN_SECRET="00000000-0000-0000-0000-000000000000" +# +# The host and TLS setting are hardcoded per-server in the MCP config and read +# from variables.yaml by terraform, so there is nothing to export for them. # --- Anything else ------------------------------------------------------- # Aliases, PATH additions, kubeconfig defaults, etc. This file loads last,