feat/tailscale-vault-autojoin #128

Merged
clawdie merged 2 commits from feat/tailscale-vault-autojoin into main 2026-06-24 10:02:13 +02:00
5 changed files with 93 additions and 26 deletions

View file

@ -515,8 +515,8 @@ check_release_gate() {
# A baked mother SSH private key must never reach a publicly hosted release # A baked mother SSH private key must never reach a publicly hosted release
# image. Fail fast here so a release build aborts in seconds; the image # image. Fail fast here so a release build aborts in seconds; the image
# assembly step also refuses to copy it, as defense in depth. # assembly step also refuses to copy it, as defense in depth.
if [ -f "/home/clawdie/.ssh/osa-mother-2026" ]; then if [ -f "/home/clawdie/.ssh/mother-mcp" ]; then
echo "ERROR: mother SSH key present on build host (/home/clawdie/.ssh/osa-mother-2026) — refuse to bake it into a release image. Remove it, or build with BUILD_CHANNEL=dev." echo "ERROR: mother SSH key present on build host (/home/clawdie/.ssh/mother-mcp) — refuse to bake it into a release image. Remove it, or build with BUILD_CHANNEL=dev."
_release_errors=$(( _release_errors + 1 )) _release_errors=$(( _release_errors + 1 ))
fi fi
@ -1736,13 +1736,13 @@ EOF
# Pre-stage mother connectivity key if present on the build host. # Pre-stage mother connectivity key if present on the build host.
# Lets the live USB node SSH into the mother server (osa) without # Lets the live USB node SSH into the mother server (osa) without
# manual key exchange. Public key is already in mother authorized_keys. # manual key exchange. Public key is already in mother authorized_keys.
_mother_key_src="/home/clawdie/.ssh/osa-mother-2026" _mother_key_src="/home/clawdie/.ssh/mother-mcp"
if [ -f "${_mother_key_src}" ]; then if [ -f "${_mother_key_src}" ]; then
[ "${BUILD_CHANNEL}" = "release" ] && { echo "ERROR: refusing to bake mother SSH key into a release image"; exit 1; } [ "${BUILD_CHANNEL}" = "release" ] && { echo "ERROR: refusing to bake mother SSH key into a release image"; exit 1; }
mkdir -p "${MOUNT_POINT}/home/clawdie/.ssh" mkdir -p "${MOUNT_POINT}/home/clawdie/.ssh"
cp "${_mother_key_src}" "${MOUNT_POINT}/home/clawdie/.ssh/osa-mother-2026" cp "${_mother_key_src}" "${MOUNT_POINT}/home/clawdie/.ssh/mother-mcp"
chmod 0600 "${MOUNT_POINT}/home/clawdie/.ssh/osa-mother-2026" chmod 0600 "${MOUNT_POINT}/home/clawdie/.ssh/mother-mcp"
echo " Staged mother SSH key for USB→mother connectivity." echo " Staged mother SSH key for USB→mother connectivity."
fi fi
chmod 0755 \ chmod 0755 \

View file

@ -64,6 +64,7 @@ Clawdie USB. Derived from the OSA security audit on 2026-06-23.
| COLIBRI_MCP_EXTERNAL_CALL | `grep EXTERNAL_CALL /usr/local/etc/colibri/provider.env` | `COLIBRI_MCP_EXTERNAL_CALL=1` | | COLIBRI_MCP_EXTERNAL_CALL | `grep EXTERNAL_CALL /usr/local/etc/colibri/provider.env` | `COLIBRI_MCP_EXTERNAL_CALL=1` |
| geodesic-dome-mcp installed | `ls /usr/local/bin/geodesic-dome-mcp` | exists, executable | | geodesic-dome-mcp installed | `ls /usr/local/bin/geodesic-dome-mcp` | exists, executable |
| build-colibri.sh installed | `ls /usr/local/bin/build-colibri.sh` | exists, executable | | build-colibri.sh installed | `ls /usr/local/bin/build-colibri.sh` | exists, executable |
| node-register-mcp installed | `ls /usr/local/bin/node-register-mcp` | exists, executable |
| colibri-mcp-ssh wrapper | `ls /usr/local/bin/colibri-mcp-ssh` | exists, executable | | colibri-mcp-ssh wrapper | `ls /usr/local/bin/colibri-mcp-ssh` | exists, executable |
## OSA-specific exceptions (production) ## OSA-specific exceptions (production)

View file

@ -200,12 +200,11 @@ HW_JSON=$(sudo clawdie-hw-probe 2>/dev/null)
# 2. View what would be sent to mother # 2. View what would be sent to mother
echo "$HW_JSON" | python3.11 -m json.tool | head -15 echo "$HW_JSON" | python3.11 -m json.tool | head -15
# 3. Send to mother via MCP (once node_register tool exists on mother) # 3. Send to mother via MCP (node_register tool: packaging/mother/node-register-mcp)
# For now: manual insert via SSH to mother # The 0.12 daemon collects hw-probe at autospawn time and passes it to agents
echo "$HW_JSON" | ssh m0th3r 'cat - | sudo -u postgres psql -d mother_hive \ # via CLAWDIE_HW_PROFILE env var. For manual testing, pipe JSON-RPC directly:
-c "INSERT INTO usb_nodes (hostname, hw_profile, status) \ printf '{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"node_register","arguments":{"hostname":"%s","hw_profile":%s}}}\n' \
VALUES ('\''$(hostname)'\'', '\''$(cat)'\''::jsonb, '\''online'\'') \ "$(hostname)" "$HW_JSON" | ssh m0th3r 'cat - | /usr/local/bin/node-register-mcp'
ON CONFLICT (hostname) DO UPDATE SET hw_profile = EXCLUDED.hw_profile, last_seen = now();"'
# 4. Verify on mother # 4. Verify on mother
ssh m0th3r 'sudo -u postgres psql -d mother_hive \ ssh m0th3r 'sudo -u postgres psql -d mother_hive \
@ -219,8 +218,9 @@ Once the daemon is restarted with `COLIBRI_AUTOSPAWN=YES` and
`COLIBRI_AUTOSPAWN_BINARY=zot`: `COLIBRI_AUTOSPAWN_BINARY=zot`:
1. **zot autospawns** — DeepSeek API key from seed partition 1. **zot autospawns** — DeepSeek API key from seed partition
2. **zot's first action**: runs `clawdie-hw-probe`, captures JSON 2. **Daemon collects hw-probe** — runs `clawdie-hw-probe` at spawn time, passes result
3. **zot calls mother**: `colibri_external_mcp_call_tool(server="m0th3r", tool="n0d3_r3g1st3r", arguments={hw_profile: ...})` to zot as `CLAWDIE_HW_PROFILE` env var (zot doesn't run hw-probe itself in 0.12)
3. **zot reads env var and calls mother**: `colibri_external_mcp_call_tool(server="m0th3r", tool="node_register", arguments={hostname:..., hw_profile:...})`
4. **Mother stores** the hardware profile in PostgreSQL 4. **Mother stores** the hardware profile in PostgreSQL
5. **Capability trigger fires** — derives `has_gpu`, `ram_gb`, `cpu_cores`, `geodesic_dome_mcp` etc. 5. **Capability trigger fires** — derives `has_gpu`, `ram_gb`, `cpu_cores`, `geodesic_dome_mcp` etc.
6. **Mother returns capabilities** — zot now knows what this node can do 6. **Mother returns capabilities** — zot now knows what this node can do

View file

@ -149,11 +149,12 @@ derived capabilities (`has_gpu`, `ram_gb`, `cpu_cores`, etc.).
The operator places `mother-mcp` and `mother-mcp.pub` in the seed dir, The operator places `mother-mcp` and `mother-mcp.pub` in the seed dir,
reboots, and the key is automatically installed. reboots, and the key is automatically installed.
2. **Autospawn integration**: After autospawn (zot starts), the agent's first 2. **Autospawn integration**: In 0.12, the daemon collects `clawdie-hw-probe`
action should be to run `clawdie-hw-probe` and call at autospawn time and passes it to the agent as `CLAWDIE_HW_PROFILE` env var.
The agent's first action should read that env var and call
`colibri_external_mcp_call_tool(server="mother", tool="node_register")`. `colibri_external_mcp_call_tool(server="mother", tool="node_register")`.
This requires a `node_register` MCP tool on mother (not yet implemented — The `node_register` MCP tool is now implemented at
currently only `geodesic-dome` and `mother-build` are registered). `packaging/mother/node-register-mcp`.
3. **Tailscale dependency**: The USB needs Tailscale to be up before MCP works. 3. **Tailscale dependency**: The USB needs Tailscale to be up before MCP works.
This is already the case — Tailscale auth is part of the bootstrap flow. This is already the case — Tailscale auth is part of the bootstrap flow.
@ -161,12 +162,11 @@ derived capabilities (`has_gpu`, `ram_gb`, `cpu_cores`, etc.).
## Implementation priority ## Implementation priority
| Step | Effort | Blocked by | | Step | Effort | Blocked by |
| ---------------------------------------- | ----------------- | -------------- | | ------------------------------- | ----------------- | -------------- | --- | --------------------------------------- | ----------------- | --------- |
| Step 1: SSH key + config | Manual (one-time) | Nothing | | Step 1: SSH key + config | Manual (one-time) | Nothing |
| Step 2: Enable external MCP | 1 line | Nothing | | Step 2: Enable external MCP | 1 line | Nothing |
| Step 3: external-mcp.json | 10 lines JSON | Nothing | | Step 3: external-mcp.json | 10 lines JSON | Nothing |
| Step 4: Install hw-probe | Copy file | 0.12 branch | | Step 4: Install hw-probe | Copy file | 0.12 branch |
| Step 5: Restart + verify | 2 commands | Steps 1-4 | | Step 5: Restart + verify | 2 commands | Steps 1-4 |
| Step 6: Seed partition auto-key | Code change | 0.12 ISO build | | Step 6: Seed partition auto-key | Code change | 0.12 ISO build | \n | Step 7: Install node_register on mother | Copy + psql grant | Steps 1-5 |
| Step 7: node_register MCP tool on mother | New MCP tool | Step 6 |

View file

@ -0,0 +1,66 @@
#!/bin/sh
# node-register MCP tool — register a USB node's hardware profile in PostgreSQL.
#
# Accepts a JSON-RPC tools/call request on stdin, inserts the hw_profile into
# mother_hive.usb_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.
#
# Expected input:
# {"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"node_register","arguments":{"hostname":"clawdie-usb","hw_profile":{...}}}}
#
# Output on success:
# {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"registered\":true,\"hostname\":\"clawdie-usb\",\"capabilities\":{...}}"}]}}
#
# PostgreSQL access: peer auth for the 'colibri' OS user. The operator must run
# once on mother (as postgres):
# CREATE ROLE colibri WITH LOGIN;
# GRANT CONNECT ON DATABASE mother_hive TO colibri;
# GRANT INSERT, UPDATE ON usb_nodes TO colibri;
# GRANT USAGE ON SEQUENCE usb_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 // ""')
HW_PROFILE=$(echo "$INPUT" | jq -c '.params.arguments.hw_profile // {}')
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
# Escape for psql string literal: double any single quotes, wrap in E'...'
HW_ESCAPED=$(printf '%s' "$HW_PROFILE" | sed "s/'/''/g")
HOST_ESCAPED=$(printf '%s' "$HOSTNAME" | sed "s/'/''/g")
# Insert/update. The derive_capabilities() trigger fires on INSERT and
# UPDATE OF hw_profile, so capabilities are always fresh.
SQL="INSERT INTO usb_nodes (hostname, hw_profile, status, last_seen)
VALUES (E'${HOST_ESCAPED}', E'${HW_ESCAPED}'::jsonb, 'online', now())
ON CONFLICT (hostname) DO UPDATE
SET hw_profile = EXCLUDED.hw_profile,
status = 'online',
last_seen = now()"
RESULT=$(psql -d "$DB" -tAc "BEGIN; $SQL; SELECT json_build_object(
'registered', true,
'hostname', hostname,
'capabilities', capabilities
) FROM usb_nodes WHERE hostname = E'${HOST_ESCAPED}'; COMMIT;" 2>&1) || {
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
printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"%s"}]}}\n' \
"$ID" "$(printf '%s' "$RESULT" | sed 's/"/\\"/g')"