feat(mother): MCP infra — hive_nodes registry, hardened wrapper/builder, idempotent setup #161

Merged
clawdie merged 2 commits from feature/mother-mcp-infra into main 2026-06-24 10:00:54 +02:00
6 changed files with 735 additions and 8 deletions

View file

@ -0,0 +1,161 @@
# Mother Node MCP Setup
## What this is
The mother node (OSA) runs the Colibri MCP host for USB operator node
coordination. USB nodes send hardware profiles via SSH → MCP →
PostgreSQL. This directory contains the scripts and schema for that
infrastructure.
## Files
| File | Purpose |
| ------------------- | ----------------------------------------------------------------- |
| `setup-mother.sh` | Idempotent deploy — run once as root, safe to re-run |
| `mother_schema.sql` | PostgreSQL schema (hive_nodes, build_queue, audit_log + triggers) |
| `node-register-mcp` | MCP tool: receive hw-probe JSON → UPSERT into hive_nodes |
| `geodesic-dome-mcp` | MCP tool: geodesic dome wireframe + structural BOM |
| `build-colibri.sh` | MCP tool: build a colibri crate (branch-allowlisted) |
| `colibri-mcp-ssh` | SSH forced-command wrapper (allowlisted: `""`, `"tools"` only) |
## Architecture
```
USB node Mother (osa)
──────── ────────────
clawdie-hw-probe PostgreSQL (mother_hive)
│ ▲
▼ │
colibri-daemon │ INSERT / UPSERT
│ (external MCP) │
▼ │
ssh colibri@mother (no cmd) │
│ (mother-mcp key) │
▼ │
colibri-mcp-ssh ◄─ authorized_keys command="..."
│ allowlist: "" or "tools"
colibri-mcp ──► node-register-mcp ──► psql -v :'variable' (heredoc)
(parameterized UPSERT, no shell interpolation of SQL)
```
The USB's `colibri-mcp` spawns `ssh -i ~/.ssh/mother-mcp colibri@mother`
with NO remote command. SSH is restricted via `command=` + `restrict` to
run only `colibri-mcp-ssh`, which delegates to `colibri-mcp` in stdio MCP
mode. The `node_register` tool on mother receives the hw-probe JSON and
UPSERTs it into PostgreSQL using `psql -v :'variable'` quoting in a
heredoc — the JSON blob is a bound variable, never interpolated into SQL.
## Security properties
- **colibri-mcp-ssh**: allowlists `SSH_ORIGINAL_COMMAND` to `""` (stdio MCP
mode) or `"tools"` (one-shot discovery). All other values are rejected.
This prevents callers from passing arbitrary colibri-mcp flags through
the forced-command boundary.
- **build-colibri.sh**: branch is validated against an allowlist (`main`,
semver `v*` tags, plus `COLIBRI_BUILD_ALLOW_BRANCHES` colon-separated
extras). Features are validated against `^[A-Za-z0-9_,-]+$`. Cargo
args use a shell array — no unquoted string concatenation.
- **node-register-mcp**: `psql -v :'variable'` quoting in a heredoc (not
`-c`). The JSON blob is dollar-quoted by psql; shell never interpolates
it into SQL.
- **mother-mcp key**: NOT baked into images, NOT reused for Forgejo.
Lives on seed partition (`CLAWDIESEED/colibri/ssh/mother-mcp`). One key
per trust domain — blast radius is MCP-over-SSH only.
## Setup (one-time)
On mother, as root:
```sh
# Build colibri from current main first, then:
cd /home/clawdie/ai/colibri
sudo ./packaging/mother/setup-mother.sh
# The script prints the mother-mcp private key — copy it for USB nodes.
```
### What setup-mother.sh does
1. Installs colibri binaries from `target/release`
2. Installs MCP server scripts
3. jq-merges mother servers into `/usr/local/etc/colibri/external-mcp.json`
4. Creates `colibri` OS user + SSH authorized_keys with `command=` wrapper
5. Generates mother-mcp keypair (prints private key for seed placement)
6. Configures PostgreSQL peer auth for colibri on mother_hive
7. Runs `mother_schema.sql` (idempotent)
8. Updates colibri daemon env (`COLIBRI_AUTOSPAWN`, `COLIBRI_MCP_EXTERNAL_CALL`)
9. Restarts `colibri_daemon`
Idempotent — running again is a no-op.
## Key management
The mother-mcp key is NOT baked into images. It lives:
- **On mother:** `/var/db/colibri/.ssh/mother-mcp` + `.pub` (auto-generated
by setup-mother.sh)
- **On USB nodes:** placed on the seed partition at
`CLAWDIESEED/colibri/ssh/mother-mcp` (the live-seed importer picks it up
at first boot and installs it at `~/.ssh/mother-mcp`)
One key per trust domain. The mother-mcp key is not reused for Forgejo
or other services — its blast radius is limited to MCP over SSH.
## USB-side external-mcp.json
The USB node registers mother as an external MCP server. Note: NO
remote command — the forced-command wrapper handles entry:
```json
{
"servers": {
"mother": {
"command": "ssh",
"args": [
"-i", "/home/clawdie/.ssh/mother-mcp",
"-o", "StrictHostKeyChecking=accept-new",
"colibri@100.72.229.63"
],
"env": {}
}
}
}
```
The colibri-daemon spawner pipes JSON-RPC on stdin/stdout. SSH connects
with an empty remote command; the forced-command wrapper starts
`colibri-mcp` in stdio MCP mode automatically.
## Verification
```sh
# On mother: check the external MCP tools are live
colibri-mcp tools 2>&1 | grep external
# On mother: test node_register with a sample hw-probe
sudo clawdie-hw-probe 2>/dev/null | \
jq '{jsonrpc:"2.0",method:"tools/call",id:1,
params:{name:"node_register",
arguments:{hostname:"test-node",hw_profile:.}}}' | \
/usr/local/bin/node-register-mcp
# Check PostgreSQL
sudo -u colibri psql -d mother_hive -c \
"SELECT hostname, status, capabilities FROM hive_nodes;"
# From a USB node with the mother-mcp key:
ssh colibri@mother tools | grep node_register
```
## Adding a USB node
1. Copy the mother-mcp private key to the USB node's seed partition
(`CLAWDIESEED/colibri/ssh/mother-mcp`)
2. On the USB, install `external-mcp.json` as shown above
3. On the USB, install `clawdie-hw-probe` from clawdie-iso
4. Restart `colibri_daemon` — autospawn runs hw-probe and registers
the node with mother
See `docs/USB-MOTHER-MCP-CONNECTION.md` in clawdie-iso for the full
USB-side procedure.

124
packaging/mother/build-colibri.sh Executable file
View file

@ -0,0 +1,124 @@
#!/bin/sh
# build-colibri MCP tool — build a colibri crate from an allowed git branch.
#
# Accepts a JSON-RPC tools/call request on stdin, builds the requested
# crate, and returns the result as a JSON content block to stdout.
#
# Expected input (MCP tools/call):
# {"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"build_colibri","arguments":{"crate":"colibri-daemon","branch":"main","features":"","release":"true"}}}
#
# Parameters:
# crate — workspace member to build (default: colibri-daemon)
# branch — git ref to build (allowlisted: main, v* tags; default: main)
# features — optional comma-separated feature names (validated: ^[A-Za-z0-9_,-]*$)
# release — "true" for --release (default), anything else for debug
#
# Security:
# - Branch is validated against an allowlist (main + semver tags +
# COLIBRI_BUILD_ALLOW_BRANCHES env). Arbitrary branch RCE via
# build.rs / proc-macros is the threat model.
# - Features are validated against ^[A-Za-z0-9_,-]*$ to prevent cargo
# flag injection (e.g. --config … smuggled through a feature name).
# - Cargo args use a shell array with proper quoting throughout.
#
# Output: MCP JSON-RPC response with text content block.
set -eu
REPO="/home/clawdie/ai/colibri"
CRATE_DEFAULT="colibri-daemon"
BRANCH_DEFAULT="main"
# ── helpers ───────────────────────────────────────────────────────────
die() {
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-1,"message":"%s"}}\n' \
"$ID" "$(printf '%s' "$1" | sed 's/"/\\"/g')"
exit 1
}
# ── parse request ─────────────────────────────────────────────────────
INPUT=$(cat)
ID=$(echo "$INPUT" | jq -r '.id // "1"')
CRATE=$(echo "$INPUT" | jq -r '.params.arguments.crate // "'"$CRATE_DEFAULT"'"')
BRANCH=$(echo "$INPUT" | jq -r '.params.arguments.branch // "'"$BRANCH_DEFAULT"'"')
FEATURES=$(echo "$INPUT" | jq -r '.params.arguments.features // ""')
RELEASE=$(echo "$INPUT" | jq -r '.params.arguments.release // "true"')
# ── validate branch (allowlist) ───────────────────────────────────────
# Allow: main, any tag matching v<digits> (semver), plus operator overrides.
# The -w flag is a FreeBSD grep extension; on Linux use grep -E.
valid_branch() {
local b="$1"
[ "$b" = "main" ] && return 0
# semver tags: v0.12.0, v1.2.3, v0.2.42, etc.
printf '%s' "$b" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._-]+)?$' && return 0
# Operator-configured extra branches (colon-separated).
if [ -n "${COLIBRI_BUILD_ALLOW_BRANCHES:-}" ]; then
local saved_IFS="$IFS"
IFS=':'
for allowed in $COLIBRI_BUILD_ALLOW_BRANCHES; do
[ "$b" = "$allowed" ] && { IFS="$saved_IFS"; return 0; }
done
IFS="$saved_IFS"
fi
return 1
}
if ! valid_branch "$BRANCH"; then
die "branch not in allowlist: ${BRANCH}"
fi
# ── validate features ─────────────────────────────────────────────────
if [ -n "$FEATURES" ]; then
if ! printf '%s' "$FEATURES" | grep -Eq '^[A-Za-z0-9_,-]+$'; then
die "features must match ^[A-Za-z0-9_,-]+$"
fi
fi
# ── validate crate (workspace member name only) ───────────────────────
# Prevent path traversal via crate name.
if ! printf '%s' "$CRATE" | grep -Eq '^[a-z][a-z0-9_-]+$'; then
die "crate must match ^[a-z][a-z0-9_-]+$"
fi
# ── build ─────────────────────────────────────────────────────────────
START_TS=$(date +%s)
cd "$REPO"
git fetch origin 2>&1 || true
if ! git checkout "$BRANCH" 2>&1; then
die "git checkout failed: ${BRANCH}"
fi
# If BRANCH is a local branch (not a detached tag), fast-forward to the fetched
# remote so we build current upstream code, not a stale local copy.
if git show-ref --verify --quiet "refs/heads/${BRANCH}"; then
git merge --ff-only "origin/${BRANCH}" 2>&1 || true
fi
COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_LOG=$(mktemp)
trap 'rm -f "$BUILD_LOG"' EXIT
# Build cargo args as an array (no unquoted string concatenation).
set -- -p "$CRATE"
[ "$RELEASE" = "true" ] && set -- "$@" --release
[ -n "$FEATURES" ] && set -- "$@" --features "$FEATURES"
if cargo build "$@" >"$BUILD_LOG" 2>&1; then
DURATION=$(( $(date +%s) - START_TS ))
BINARY=$(find target -maxdepth 3 -type f -name "$CRATE" -perm -111 | grep -v '/deps/' | head -1)
SIZE=$(stat -f '%z' "$BINARY" 2>/dev/null || echo "0")
tail -5 "$BUILD_LOG" >&2
printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"{\\"success\\":true,\\"binary\\":\\"%s\\",\\"duration_s\\":%s,\\"branch\\":\\"%s\\",\\"commit\\":\\"%s\\",\\"size_bytes\\":%s}"}]}}\n' \
"$ID" "$BINARY" "$DURATION" "$BRANCH" "$COMMIT" "$SIZE"
else
DURATION=$(( $(date +%s) - START_TS ))
ERROR=$(tail -20 "$BUILD_LOG" | head -10 | tr '\n' ' ' | sed 's/"/\\"/g')
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-1,"message":"build failed in %ss: %s"}}\n' \
"$ID" "$DURATION" "$ERROR"
fi

View file

@ -0,0 +1,32 @@
#!/bin/sh
# SSH forced-command wrapper for mother MCP entrypoint.
#
# SSH's authorized_keys command="..." restriction replaces the client's
# command with this script and stores the original in $SSH_ORIGINAL_COMMAND.
#
# Allowlist:
# "" → colibri-mcp in stdio MCP mode (persistent JSON-RPC channel)
# "tools" → colibri-mcp tools (one-shot discovery, debugging)
# everything else → rejected with exit 1
#
# Why: the wrapper's job is to constrain what callers can do through the
# SSH forced-command boundary. Without an allowlist, the caller can pass
# any colibri-mcp subcommand or flag — including ones not yet written.
#
# Installed by setup-mother.sh into /usr/local/bin/.
# Referenced from: ~/.ssh/authorized_keys command="/usr/local/bin/colibri-mcp-ssh"
set -eu
case "${SSH_ORIGINAL_COMMAND:-}" in
"")
exec /usr/local/bin/colibri-mcp
;;
"tools")
exec /usr/local/bin/colibri-mcp tools
;;
*)
printf '{"jsonrpc":"2.0","id":null,"error":{"code":-1,"message":"rejected: %s"}}\n' \
"$(printf '%s' "${SSH_ORIGINAL_COMMAND}" | sed 's/"/\\"/g')" >&2
exit 1
;;
esac

View file

@ -1,6 +1,29 @@
CREATE TABLE IF NOT EXISTS usb_nodes ( -- Mother hive schema. Idempotent: safe to re-run (setup-mother.sh applies it).
--
-- A "node" is any host that has joined the hive and reports a hardware
-- profile — live-USB, disk-installed FreeBSD, a Linux box, a VPS, or the
-- mother itself. The boot/provisioning medium is an attribute (node_type),
-- not the table's identity.
-- Migration: usb_nodes → hive_nodes (rename in place; preserves data + the
-- owning sequence). Runs only on hosts created under the old name; a no-op on
-- fresh installs and on already-migrated hosts.
DO $$
BEGIN
IF to_regclass('public.usb_nodes') IS NOT NULL
AND to_regclass('public.hive_nodes') IS NULL THEN
ALTER TABLE usb_nodes RENAME TO hive_nodes;
IF to_regclass('public.usb_nodes_id_seq') IS NOT NULL THEN
ALTER SEQUENCE usb_nodes_id_seq RENAME TO hive_nodes_id_seq;
END IF;
END IF;
END $$;
CREATE TABLE IF NOT EXISTS hive_nodes (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
hostname TEXT NOT NULL UNIQUE, hostname TEXT NOT NULL UNIQUE,
-- Provisioning medium: live-usb | disk | vps | mother | unknown.
node_type TEXT NOT NULL DEFAULT 'unknown',
last_seen TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
first_seen TIMESTAMPTZ NOT NULL DEFAULT now(), first_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
freebsd_version JSONB, freebsd_version JSONB,
@ -10,12 +33,17 @@ CREATE TABLE IF NOT EXISTS usb_nodes (
tags TEXT[] DEFAULT '{}', tags TEXT[] DEFAULT '{}',
last_cap_sync TIMESTAMPTZ last_cap_sync TIMESTAMPTZ
); );
CREATE INDEX IF NOT EXISTS idx_nodes_status ON usb_nodes (status); -- Add node_type to tables migrated from the old usb_nodes definition.
CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON usb_nodes (last_seen DESC); ALTER TABLE hive_nodes ADD COLUMN IF NOT EXISTS node_type TEXT NOT NULL DEFAULT 'unknown';
CREATE INDEX IF NOT EXISTS idx_nodes_cap_has_gpu ON usb_nodes ((capabilities->>'has_gpu'));
CREATE INDEX IF NOT EXISTS idx_nodes_status ON hive_nodes (status);
CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON hive_nodes (last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_nodes_type ON hive_nodes (node_type);
CREATE INDEX IF NOT EXISTS idx_nodes_cap_has_gpu ON hive_nodes ((capabilities->>'has_gpu'));
CREATE TABLE IF NOT EXISTS build_queue ( CREATE TABLE IF NOT EXISTS build_queue (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
node_id INTEGER REFERENCES usb_nodes(id), node_id INTEGER REFERENCES hive_nodes(id),
crate TEXT NOT NULL DEFAULT 'colibri-daemon', crate TEXT NOT NULL DEFAULT 'colibri-daemon',
branch TEXT NOT NULL DEFAULT 'main', branch TEXT NOT NULL DEFAULT 'main',
release BOOLEAN NOT NULL DEFAULT true, release BOOLEAN NOT NULL DEFAULT true,
@ -31,11 +59,12 @@ CREATE TABLE IF NOT EXISTS build_queue (
error_log TEXT error_log TEXT
); );
CREATE INDEX IF NOT EXISTS idx_build_status ON build_queue (status, priority DESC, queued_at); CREATE INDEX IF NOT EXISTS idx_build_status ON build_queue (status, priority DESC, queued_at);
CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
event_ts TIMESTAMPTZ NOT NULL DEFAULT now(), event_ts TIMESTAMPTZ NOT NULL DEFAULT now(),
event_type TEXT NOT NULL, event_type TEXT NOT NULL,
node_id INTEGER REFERENCES usb_nodes(id), node_id INTEGER REFERENCES hive_nodes(id),
build_id INTEGER REFERENCES build_queue(id), build_id INTEGER REFERENCES build_queue(id),
details JSONB details JSONB
); );
@ -79,7 +108,7 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_derive_capabilities ON usb_nodes; DROP TRIGGER IF EXISTS trg_derive_capabilities ON hive_nodes;
CREATE TRIGGER trg_derive_capabilities CREATE TRIGGER trg_derive_capabilities
BEFORE INSERT OR UPDATE OF hw_profile ON usb_nodes BEFORE INSERT OR UPDATE OF hw_profile ON hive_nodes
FOR EACH ROW EXECUTE FUNCTION derive_capabilities(); FOR EACH ROW EXECUTE FUNCTION derive_capabilities();

View file

@ -0,0 +1,88 @@
#!/bin/sh
# node-register — MCP tool: register a hive node's hardware profile in PostgreSQL.
#
# A node is any host that joined the hive (live-usb, disk, vps, mother), keyed
# by hostname. Accepts a JSON-RPC tools/call request on stdin, UPSERTs the
# hw_profile into mother_hive.hive_nodes, and returns the result to stdout. The
# derive_capabilities() trigger auto-computes has_gpu, gpu_vendor,
# can_run_local_llm, has_wifi, etc. on INSERT/UPDATE.
#
# Expected input:
# {"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"node_register","arguments":{"hostname":"clawdie-node","node_type":"live-usb","hw_profile":{...}}}}
#
# Output on success:
# {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"registered\":true,\"hostname\":\"clawdie-node\",\"capabilities\":{...}}"}]}}
#
# Security: psql :'variable' substitution expands to a safely single-quoted SQL
# string literal — no shell interpolation touches the JSON blobs.
#
# PostgreSQL access: peer auth for the 'colibri' OS user. The operator must
# run once as postgres (or setup-mother.sh does it):
# CREATE ROLE colibri WITH LOGIN;
# GRANT CONNECT ON DATABASE mother_hive TO colibri;
# GRANT INSERT, UPDATE ON hive_nodes TO colibri;
# GRANT USAGE ON SEQUENCE hive_nodes_id_seq TO colibri;
set -eu
DB="mother_hive"
# Read JSON-RPC from stdin
INPUT=$(cat)
ID=$(echo "$INPUT" | jq -r '.id // "1"')
HOSTNAME=$(echo "$INPUT" | jq -r '.params.arguments.hostname // ""')
NODE_TYPE=$(echo "$INPUT" | jq -r '.params.arguments.node_type // "unknown"')
HW_PROFILE=$(echo "$INPUT" | jq -c '.params.arguments.hw_profile // {}')
# node_type is a small enum; validate so a bad value can't land an odd row.
case "$NODE_TYPE" in
live-usb|disk|vps|mother|unknown) ;;
*) NODE_TYPE="unknown" ;;
esac
if [ -z "$HOSTNAME" ]; then
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-1,"message":"missing required argument: hostname"}}\n' "$ID"
exit 1
fi
if [ "$HW_PROFILE" = "{}" ]; then
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-1,"message":"missing required argument: hw_profile"}}\n' "$ID"
exit 1
fi
# Pass the JSON blobs via psql -v with :'variable' quoting. :'variable'
# expands to a safely single-quoted SQL string literal (backslashes, quotes,
# and newlines inside the JSON are escaped), so untrusted input cannot break
# out of the literal — no shell interpolation ever touches the SQL.
# NOTE: a heredoc (not -c) is required — psql only expands :'variable' in SQL
# read from stdin / -f, not from -c.
# ON_ERROR_STOP=1 makes psql exit non-zero on a SQL error; without it psql
# would continue past a failed statement and exit 0, hiding failures. stderr is
# folded into RESULT so the error branch can report what went wrong.
RESULT=$(psql -d "$DB" -tA -v ON_ERROR_STOP=1 \
-v hostname="$HOSTNAME" -v node_type="$NODE_TYPE" -v hw_profile="$HW_PROFILE" 2>&1 <<'PSQL'
BEGIN;
INSERT INTO hive_nodes (hostname, node_type, hw_profile, status, last_seen)
VALUES (:'hostname', :'node_type', (:'hw_profile')::jsonb, 'online', now())
ON CONFLICT (hostname) DO UPDATE
SET node_type = EXCLUDED.node_type,
hw_profile = EXCLUDED.hw_profile,
status = 'online',
last_seen = now();
SELECT json_build_object(
'registered', true,
'hostname', hostname,
'node_type', node_type,
'capabilities', capabilities
) FROM hive_nodes WHERE hostname = :'hostname';
COMMIT;
PSQL
) || {
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-1,"message":"psql failed: %s"}}\n' \
"$ID" "$(printf '%s' "$RESULT" | sed 's/"/\\"/g' | tr '\n' ' ')"
exit 1
}
# RESULT is the JSON object from json_build_object (one line).
# Escape double quotes for JSON embedding in the MCP text field.
printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"%s"}]}}\n' \
"$ID" "$(printf '%s' "$RESULT" | sed 's/"/\\"/g')"

293
packaging/mother/setup-mother.sh Executable file
View file

@ -0,0 +1,293 @@
#!/bin/sh
# setup-mother.sh — idempotent mother-node MCP setup.
#
# Deploys the mother MCP infrastructure on a FreeBSD host (osa or equivalent).
# Run as root. Safe to re-run: every step is idempotent.
#
# What it does:
# 1. Installs colibri binaries (colibri-mcp, colibri-daemon, colibri) from
# the prebuilt artifact dir (default: ../target/release).
# 2. Installs MCP server scripts (geodesic-dome-mcp, build-colibri.sh,
# node-register-mcp, colibri-mcp-ssh).
# 3. jq-merges mother servers into external-mcp.json (preserves existing).
# 4. Creates the colibri OS user if missing, sets up SSH authorized_keys
# for the mother-mcp key (command="/usr/local/bin/colibri-mcp-ssh").
# 5. Configures PostgreSQL peer auth for the colibri user on mother_hive.
# Generates the mother-mcp keypair if absent, prints the private key for
# placement on a node's seed partition.
# 5. Applies the schema (create + usb_nodes→hive_nodes migration), then
# configures PostgreSQL peer auth + grants for the colibri user.
# 6. Updates colibri daemon env (COLIBRI_AUTOSPAWN, COLIBRI_MCP_EXTERNAL_CALL).
# 7. Reloads colibri_daemon (or starts it).
#
# Idempotency: every step checks before acting. Running twice is a no-op.
#
# Usage:
# sudo ./setup-mother.sh # default artifact dir
# sudo COLIBRI_ARTIFACT_DIR=/path ./setup-mother.sh
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ARTIFACT_DIR="${COLIBRI_ARTIFACT_DIR:-${SCRIPT_DIR}/../../target/release}"
COST_MODE="${COLIBRI_COST_MODE:-smart}"
MCP_CONFIG="/usr/local/etc/colibri/external-mcp.json"
COLIBRI_HOME="/var/db/colibri"
SSH_DIR="${COLIBRI_HOME}/.ssh"
PROVIDER_ENV="/usr/local/etc/colibri/provider.env"
echo "=== mother MCP setup ==="
echo " artifact dir : ${ARTIFACT_DIR}"
echo " cost mode : ${COST_MODE}"
echo ""
# ── helpers ───────────────────────────────────────────────────────────
idempotent() {
local desc="$1" fn="$2"
printf " %-50s" "${desc}..."
eval "$fn" && echo "OK" || { echo "FAIL"; exit 1; }
}
ensure_user() {
if id colibri >/dev/null 2>&1; then
echo "(already exists)"
return 0
fi
pw useradd colibri -d "${COLIBRI_HOME}" -s /usr/sbin/nologin
}
ensure_dir() {
mkdir -p "$1"
chown colibri:colibri "$1"
chmod 750 "$1"
}
# ── 1. colibri binaries ───────────────────────────────────────────────
echo "--- 1. colibri binaries ---"
idempotent "colibri-mcp" '
if [ -x "$ARTIFACT_DIR/colibri-mcp" ]; then
install -m 755 "$ARTIFACT_DIR/colibri-mcp" /usr/local/bin/colibri-mcp
else
echo "MISSING: $ARTIFACT_DIR/colibri-mcp — build colibri first"; exit 1
fi
'
idempotent "colibri-daemon" '
if [ -x "$ARTIFACT_DIR/colibri-daemon" ]; then
install -m 755 "$ARTIFACT_DIR/colibri-daemon" /usr/local/bin/colibri-daemon
else
echo "MISSING: $ARTIFACT_DIR/colibri-daemon"; exit 1
fi
'
idempotent "colibri (CLI)" '
if [ -x "$ARTIFACT_DIR/colibri" ]; then
install -m 755 "$ARTIFACT_DIR/colibri" /usr/local/bin/colibri
else
echo "MISSING: $ARTIFACT_DIR/colibri"; exit 1
fi
'
# ── 2. MCP server scripts ─────────────────────────────────────────────
echo "--- 2. MCP server scripts ---"
idempotent "colibri-mcp-ssh wrapper" '
install -m 755 "$SCRIPT_DIR/colibri-mcp-ssh" /usr/local/bin/colibri-mcp-ssh
'
idempotent "geodesic-dome-mcp" '
install -m 755 "$SCRIPT_DIR/geodesic-dome-mcp" /usr/local/bin/geodesic-dome-mcp
'
idempotent "build-colibri.sh" '
install -m 755 "$SCRIPT_DIR/build-colibri.sh" /usr/local/bin/build-colibri.sh
'
idempotent "node-register-mcp" '
install -m 755 "$SCRIPT_DIR/node-register-mcp" /usr/local/bin/node-register-mcp
'
# ── 3. external-mcp.json (jq-merge, preserve existing) ────────────────
echo "--- 3. external-mcp.json ---"
MOTHER_SERVERS='{
"mother-build": {
"command": "/usr/local/bin/build-colibri.sh",
"args": [],
"env": {}
},
"geodesic-dome": {
"command": "/usr/local/bin/geodesic-dome-mcp",
"args": [],
"env": {}
},
"node-register": {
"command": "/usr/local/bin/node-register-mcp",
"args": [],
"env": {}
}
}'
mkdir -p "$(dirname "$MCP_CONFIG")"
if [ -f "$MCP_CONFIG" ]; then
# Preserve servers we don't own; overwrite our three with latest.
TMP_MCP=$(mktemp)
jq --argjson ours "$MOTHER_SERVERS" '.servers = (.servers // {}) * $ours' \
"$MCP_CONFIG" > "$TMP_MCP"
mv "$TMP_MCP" "$MCP_CONFIG"
echo " merged node-register + geodesic-dome + mother-build"
else
printf '{"servers": %s}\n' "$MOTHER_SERVERS" | jq . > "$MCP_CONFIG"
echo " created"
fi
chmod 644 "$MCP_CONFIG"
# ── 4. colibri user + SSH ────────────────────────────────────────────
echo "--- 4. colibri user + SSH ---"
idempotent "colibri user" 'ensure_user'
idempotent "colibri home" 'ensure_dir "$COLIBRI_HOME"'
idempotent "colibri .ssh" 'ensure_dir "$SSH_DIR"'
idempotent "mother-mcp keypair" '
if [ -f "${SSH_DIR}/mother-mcp" ]; then
echo "(already exists)"
else
ssh-keygen -t ed25519 -f "${SSH_DIR}/mother-mcp" \
-C "mother-mcp-$(date +%Y%m%d)" -N "" >/dev/null 2>&1
chown colibri:colibri "${SSH_DIR}/mother-mcp" "${SSH_DIR}/mother-mcp.pub"
chmod 600 "${SSH_DIR}/mother-mcp"
chmod 644 "${SSH_DIR}/mother-mcp.pub"
fi
'
idempotent "authorized_keys command= wrapper" '
PUBKEY=$(cat "${SSH_DIR}/mother-mcp.pub")
AUTH_FILE="${SSH_DIR}/authorized_keys"
EXPECTED="command=\"/usr/local/bin/colibri-mcp-ssh\",restrict $PUBKEY"
if [ -f "$AUTH_FILE" ] && grep -qF "$PUBKEY" "$AUTH_FILE" 2>/dev/null; then
echo "(already present)"
else
printf "command=\"/usr/local/bin/colibri-mcp-ssh\",restrict %s\n" "$PUBKEY" >> "$AUTH_FILE"
chown colibri:colibri "$AUTH_FILE"
chmod 600 "$AUTH_FILE"
fi
'
# Print the private key for seed placement
echo ""
echo " >>> mother-mcp private key (copy to USB seed partition) <<<"
echo " ==========================================================="
cat "${SSH_DIR}/mother-mcp"
echo " ==========================================================="
echo ""
# ── 5. PostgreSQL peer auth ──────────────────────────────────────────
echo "--- 5. PostgreSQL peer auth ---"
idempotent "DB role colibri" '
su - postgres -c "psql -tAc \"DO \\\$\\\$ BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '\''colibri'\'') THEN
CREATE ROLE colibri WITH LOGIN;
END IF;
END \\\$\\\$;\"" 2>&1
'
# Apply the schema BEFORE granting on its tables (fresh installs have no tables
# until now; this also runs the usb_nodes → hive_nodes migration). Pipe the file
# via stdin so the postgres user need not read it from the repo checkout, and
# stop on the first error so a failed migration is not silently skipped.
idempotent "mother schema (create + migrate)" '
su - postgres -c "psql -d mother_hive -v ON_ERROR_STOP=1 -f -" \
< "$SCRIPT_DIR/mother_schema.sql" 2>&1 | tail -1
'
idempotent "GRANT CONNECT on mother_hive" '
su - postgres -c "psql -d mother_hive -tAc \
\"GRANT CONNECT ON DATABASE mother_hive TO colibri;\"" 2>&1
'
idempotent "GRANT INSERT,UPDATE on hive_nodes" '
su - postgres -c "psql -d mother_hive -tAc \
\"GRANT INSERT, UPDATE ON hive_nodes TO colibri;
GRANT USAGE ON SEQUENCE hive_nodes_id_seq TO colibri;\"" 2>&1
'
idempotent "GRANT INSERT on audit_log" '
su - postgres -c "psql -d mother_hive -tAc \
\"GRANT INSERT ON audit_log TO colibri;
GRANT USAGE ON SEQUENCE audit_log_id_seq TO colibri;\"" 2>&1
'
# Configure pg_hba.conf for peer auth. Locate the real file (path varies by
# PostgreSQL major/pkg) instead of hardcoding it.
PG_HBA=$(su - postgres -c "psql -tAc 'SHOW hba_file;'" 2>/dev/null | tr -d '[:space:]')
if [ -n "$PG_HBA" ] && [ -f "$PG_HBA" ]; then
PEER_LINE="local mother_hive colibri peer"
if grep -qF "$PEER_LINE" "$PG_HBA"; then
echo " pg_hba peer auth: (already present)"
else
# Prepend: pg_hba is first-match, so this specific rule must precede any
# generic 'local all all ...' line. cat-redirect preserves owner/perms.
TMP_HBA=$(mktemp)
{ printf '%s\n' "$PEER_LINE"; cat "$PG_HBA"; } > "$TMP_HBA"
cat "$TMP_HBA" > "$PG_HBA"
rm -f "$TMP_HBA"
service postgresql reload >/dev/null 2>&1 || true
echo " pg_hba peer auth: prepended + reloaded"
fi
else
echo " pg_hba: could not locate hba_file — configure peer auth manually"
fi
# ── 6. daemon env update ──────────────────────────────────────────────
echo "--- 6. daemon env ---"
mkdir -p /usr/local/etc/colibri
if [ -f "$PROVIDER_ENV" ]; then
# Update COLIBRI_AUTOSPAWN_PI → COLIBRI_AUTOSPAWN (0.12 rename)
if grep -q 'COLIBRI_AUTOSPAWN_PI' "$PROVIDER_ENV"; then
sed -i '' 's/COLIBRI_AUTOSPAWN_PI/COLIBRI_AUTOSPAWN/' "$PROVIDER_ENV"
echo " renamed COLIBRI_AUTOSPAWN_PI → COLIBRI_AUTOSPAWN"
fi
# Ensure external calls are enabled
if ! grep -q 'COLIBRI_MCP_EXTERNAL_CALL' "$PROVIDER_ENV"; then
echo 'COLIBRI_MCP_EXTERNAL_CALL="1"' >> "$PROVIDER_ENV"
echo " added COLIBRI_MCP_EXTERNAL_CALL=1"
else
echo " COLIBRI_MCP_EXTERNAL_CALL already present"
fi
# Set cost mode if missing
if ! grep -q 'COLIBRI_COST_MODE' "$PROVIDER_ENV"; then
echo "COLIBRI_COST_MODE=\"${COST_MODE}\"" >> "$PROVIDER_ENV"
echo " added COLIBRI_COST_MODE=${COST_MODE}"
fi
else
cat > "$PROVIDER_ENV" << ENVEOF
COLIBRI_AUTOSPAWN=YES
COLIBRI_MCP_EXTERNAL_CALL="1"
COLIBRI_COST_MODE="${COST_MODE}"
ENVEOF
chmod 600 "$PROVIDER_ENV"
echo " created provider.env"
fi
# ── 7. reload daemon ──────────────────────────────────────────────────
echo "--- 7. colibri daemon ---"
if service colibri_daemon status >/dev/null 2>&1; then
service colibri_daemon restart >/dev/null 2>&1 || true
echo " restarted"
else
service colibri_daemon start >/dev/null 2>&1 || true
echo " started"
fi
# ── verify ────────────────────────────────────────────────────────────
echo ""
echo "=== setup complete ==="
echo ""
echo "Verification:"
echo " colibri-mcp tools | grep external"
echo " sudo -u colibri psql -d mother_hive -c \"SELECT hostname, node_type, status FROM hive_nodes;\""
echo " ssh colibri@localhost tools"
echo ""
echo "To add a node: copy the mother-mcp private key above to the node's"
echo "seed partition (CLAWDIESEED/colibri/ssh/mother-mcp) or install it"
echo "manually at ~/.ssh/mother-mcp on the node."