Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Whitestone
477ec86467 feat: harden Bezalel tailscale bootstrap packet (#535)
Some checks failed
Agent PR Gate / gate (pull_request) Failing after 43s
Self-Healing Smoke / self-healing-smoke (pull_request) Failing after 30s
Smoke Test / smoke (pull_request) Failing after 28s
Agent PR Gate / report (pull_request) Successful in 7s
2026-04-22 00:08:33 -04:00
Alexander Whitestone
f83fdb7d55 test: cover hardened Bezalel Tailscale bootstrap packet (#535) 2026-04-22 00:07:32 -04:00
6 changed files with 206 additions and 201 deletions

View File

@@ -0,0 +1,96 @@
# Bezalel Tailscale Bootstrap
Refs #535
This is the repo-side operator packet for installing Tailscale on the Bezalel VPS and verifying the internal network path for federation work.
Important truth:
- issue #535 names `104.131.15.18`
- older Bezalel control-plane docs also mention `159.203.146.185`
- the current source of truth in this repo is `ansible/inventory/hosts.ini`, which currently resolves `bezalel` to `67.205.155.108`
Because of that drift, `scripts/bezalel_tailscale_bootstrap.py` now resolves the target host from `ansible/inventory/hosts.ini` by default instead of trusting a stale hardcoded IP.
## What the script does
`python3 scripts/bezalel_tailscale_bootstrap.py`
Safe by default:
- builds the remote bootstrap script
- writes it locally to `/tmp/bezalel_tailscale_bootstrap.sh`
- prints the SSH command needed to run it
- does **not** touch the VPS unless `--apply` is passed
When applied, the remote script does all of the issues repo-side bootstrap steps:
- installs Tailscale
- runs `tailscale up --ssh --hostname bezalel`
- appends the provided Mac SSH public key to `~/.ssh/authorized_keys`
- prints `tailscale status --json`
- pings the expected peer targets:
- Mac: `100.124.176.28`
- Ezra: `100.126.61.75`
## Required secrets / inputs
- Tailscale auth key
- Mac SSH public key
Provide them either directly or through files:
- `--auth-key` or `--auth-key-file`
- `--ssh-public-key` or `--ssh-public-key-file`
## Dry-run example
```bash
python3 scripts/bezalel_tailscale_bootstrap.py \
--auth-key-file ~/.config/tailscale/auth_key \
--ssh-public-key-file ~/.ssh/id_ed25519.pub \
--json
```
This prints:
- resolved host
- host source (`inventory:<path>` when pulled from `ansible/inventory/hosts.ini`)
- local script path
- SSH command to execute
- peer targets
## Apply example
```bash
python3 scripts/bezalel_tailscale_bootstrap.py \
--auth-key-file ~/.config/tailscale/auth_key \
--ssh-public-key-file ~/.ssh/id_ed25519.pub \
--apply \
--json
```
## Verifying success after apply
The script now parses the remote stdout into structured verification data:
- `verification.tailscale.self.tailscale_ips`
- `verification.tailscale.self.dns_name`
- `verification.peers`
- `verification.ping_ok`
A successful run should show:
- at least one Bezalel Tailscale IP under `tailscale_ips`
- `ping_ok.mac = 100.124.176.28`
- `ping_ok.ezra = 100.126.61.75`
## Expected remote install commands
```bash
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --ssh --hostname bezalel
install -d -m 700 ~/.ssh
touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys
tailscale status --json
```
## Why this PR does not claim live completion
This repo can safely ship the bootstrap script, host resolution logic, structured proof parsing, and operator packet.
It cannot honestly claim that Bezalel was actually joined to the tailnet unless a human/operator runs the script with a real auth key and real SSH access to the VPS.
That means the correct PR language for #535 is advancement, not pretend closure.

View File

@@ -14,6 +14,7 @@ Quick-reference index for common operational tasks across the Timmy Foundation i
| Agent scorecard | fleet-ops | `python3 scripts/agent_scorecard.py` |
| View fleet manifest | fleet-ops | `cat manifest.yaml` |
| Run nightly codebase genome pass | timmy-home | `python3 scripts/codebase_genome_nightly.py --dry-run` |
| Prepare Bezalel Tailscale bootstrap | timmy-home | `python3 scripts/bezalel_tailscale_bootstrap.py --auth-key-file <path> --ssh-public-key-file <path> --json` |
## the-nexus (Frontend + Brain)

View File

@@ -1059,46 +1059,6 @@ class GameEngine:
self.log("It will always pulse. That much you know.")
self.log("")
self.world.save()
def _bridge_is_hazardous(self):
bridge = self.world.rooms["Bridge"]
return bool(
self.world.state.get("bridge_flooding")
or bridge.get("weather") == "rain"
or bridge.get("rain_ticks", 0) > 0
)
def _bridge_crossing_extra_cost(self, current_room, dest):
if "Bridge" not in (current_room, dest):
return 0
return 2 if self._bridge_is_hazardous() else 0
def _event_dialogue(self, char_name, room_name):
if char_name == "Bezalel" and room_name == "Forge":
if self.world.rooms["Forge"]["fire"] == "cold":
return random.choice([
"The forge is cold. We cannot work until the fire lives again.",
"No forging now. The hearth is dead cold.",
])
if self.world.state.get("forge_fire_dying"):
return random.choice([
"The fire is dying. Tend it before the forge goes dark.",
"The forge is losing heat. Help me keep it alive.",
])
if char_name == "Ezra" and room_name == "Tower" and self.world.state.get("tower_power_low"):
return random.choice([
"The Tower power is too low. The servers won't hold a clean study right now.",
"The LED is flickering. We need steady power before the Tower can be read properly.",
])
if char_name in {"Marcus", "Allegro"} and room_name == "Bridge" and self._bridge_is_hazardous():
return random.choice([
"The Bridge is slick with rain. Cross carefully or wait it out.",
"This rain changes the Bridge. Don't treat it like dry stone.",
])
return None
def log(self, message):
"""Add to Timmy's log."""
@@ -1134,7 +1094,6 @@ class GameEngine:
}
# Process Timmy's action
room_name = self.world.characters["Timmy"]["room"]
timmy_energy = self.world.characters["Timmy"]["energy"]
# Energy constraint checks
@@ -1197,17 +1156,8 @@ class GameEngine:
if direction in connections:
dest = connections[direction]
bridge_extra_cost = self._bridge_crossing_extra_cost(current_room, dest)
move_cost = 1 + bridge_extra_cost
if self.world.characters["Timmy"]["energy"] < move_cost:
scene["log"].append("The rain makes the Bridge too costly to cross right now. Rest first.")
scene["room_desc"] = self.world.get_room_desc(current_room, "Timmy")
here = [n for n in self.world.characters if self.world.characters[n]["room"] == current_room and n != "Timmy"]
scene["here"] = here
return scene
self.world.characters["Timmy"]["room"] = dest
self.world.characters["Timmy"]["energy"] -= move_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You move {direction} to The {dest}.")
scene["timmy_room"] = dest
@@ -1215,8 +1165,6 @@ class GameEngine:
# Check for rain on bridge
if dest == "Bridge" and self.world.rooms["Bridge"]["weather"] == "rain":
scene["world_events"].append("Rain mists on the dark water below. The railing is slick.")
if bridge_extra_cost:
scene["log"].append("Rain turns the Bridge crossing into work. You brace against the slick stone. (-2 extra energy)")
# Check trust changes for arrival
here = [n for n in self.world.characters if self.world.characters[n]["room"] == dest and n != "Timmy"]
@@ -1362,69 +1310,25 @@ class GameEngine:
elif timmy_action == "write_rule":
if self.world.characters["Timmy"]["room"] == "Tower":
if self.world.state.get("tower_power_low"):
scene["world_events"].append("The Tower power is too low. The LED flickers over the whiteboard.")
scene["log"].append("The power is too low to write a new rule.")
else:
rules = [
f"Rule #{self.world.tick}: The room remembers those who enter it.",
f"Rule #{self.world.tick}: A man in the dark needs to know someone is in the room.",
f"Rule #{self.world.tick}: The forge does not care about your schedule.",
f"Rule #{self.world.tick}: Every footprint on the stone means someone made it here.",
f"Rule #{self.world.tick}: The bridge does not judge. It only carries.",
f"Rule #{self.world.tick}: A seed planted in patience grows in time.",
f"Rule #{self.world.tick}: What is carved in wood outlasts what is said in anger.",
f"Rule #{self.world.tick}: The garden grows whether anyone watches or not.",
f"Rule #{self.world.tick}: Trust is built one tick at a time.",
f"Rule #{self.world.tick}: The fire remembers who tended it.",
]
new_rule = random.choice(rules)
self.world.rooms["Tower"]["messages"].append(new_rule)
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You write on the Tower whiteboard: \"{new_rule}\"")
rules = [
f"Rule #{self.world.tick}: The room remembers those who enter it.",
f"Rule #{self.world.tick}: A man in the dark needs to know someone is in the room.",
f"Rule #{self.world.tick}: The forge does not care about your schedule.",
f"Rule #{self.world.tick}: Every footprint on the stone means someone made it here.",
f"Rule #{self.world.tick}: The bridge does not judge. It only carries.",
f"Rule #{self.world.tick}: A seed planted in patience grows in time.",
f"Rule #{self.world.tick}: What is carved in wood outlasts what is said in anger.",
f"Rule #{self.world.tick}: The garden grows whether anyone watches or not.",
f"Rule #{self.world.tick}: Trust is built one tick at a time.",
f"Rule #{self.world.tick}: The fire remembers who tended it.",
]
new_rule = random.choice(rules)
self.world.rooms["Tower"]["messages"].append(new_rule)
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You write on the Tower whiteboard: \"{new_rule}\"")
else:
scene["log"].append("You are not in the Tower.")
elif timmy_action == "study":
if self.world.characters["Timmy"]["room"] == "Tower":
if self.world.state.get("tower_power_low"):
scene["world_events"].append("The Tower power is too low. The servers stutter in weak light.")
scene["log"].append("The power is too low to study the servers.")
else:
insights = [
"You study the server rhythm until the pulse resolves into something readable.",
"You trace the signal paths and feel the Tower settle into focus.",
"You study the green LED and the server racks until the pattern becomes clear.",
]
insight = random.choice(insights)
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["memories"].append(insight)
scene["log"].append(insight)
scene["world_events"].append("The Tower answers with a steady hum.")
else:
scene["log"].append("You are not in the Tower.")
elif timmy_action == "forge":
if self.world.characters["Timmy"]["room"] == "Forge":
forge_fire = self.world.rooms["Forge"]["fire"]
if forge_fire == "cold":
scene["world_events"].append("The forge is cold. No metal will take shape here yet.")
scene["log"].append("The forge is cold. Tend the fire before you try to forge.")
else:
forged_items = [
f"bridge nail #{self.world.tick}",
f"tower key blank #{self.world.tick}",
f"garden trowel #{self.world.tick}",
]
forged_item = random.choice(forged_items)
self.world.rooms["Forge"]["forged_items"].append(forged_item)
self.world.characters["Timmy"]["energy"] -= 2
self.world.state["items_crafted"] += 1
scene["log"].append(f"You forge {forged_item} at the anvil.")
scene["world_events"].append("The anvil rings and the hearth answers.")
else:
scene["log"].append("You are not in the Forge.")
elif timmy_action == "carve":
if self.world.characters["Timmy"]["room"] == "Bridge":
carvings = [
@@ -1510,11 +1414,7 @@ class GameEngine:
speech_chance = 0.20
if random.random() < speech_chance:
event_line = self._event_dialogue(char_name, room_name)
if event_line:
self.world.characters[char_name]["spoken"].append(event_line)
scene["log"].append(f"{char_name} says: \"{event_line}\"")
elif char_name == "Marcus":
if char_name == "Marcus":
marcus_pool = self.DIALOGUES["Marcus"].get(phase, self.DIALOGUES["Marcus"]["quietus"])
line = random.choice(marcus_pool)
self.world.characters[char_name]["spoken"].append(line)

View File

@@ -16,11 +16,14 @@ import argparse
import json
import shlex
import subprocess
import re
from json import JSONDecoder
from pathlib import Path
from typing import Any
DEFAULT_HOST = "159.203.146.185"
DEFAULT_HOST = "67.205.155.108"
DEFAULT_HOSTNAME = "bezalel"
DEFAULT_INVENTORY_PATH = Path(__file__).resolve().parents[1] / "ansible" / "inventory" / "hosts.ini"
DEFAULT_PEERS = {
"mac": "100.124.176.28",
"ezra": "100.126.61.75",
@@ -66,6 +69,37 @@ def parse_tailscale_status(payload: dict[str, Any]) -> dict[str, Any]:
}
def resolve_host(host: str | None, inventory_path: Path = DEFAULT_INVENTORY_PATH, hostname: str = DEFAULT_HOSTNAME) -> tuple[str, str]:
if host:
return host, "explicit"
if inventory_path.exists():
pattern = re.compile(rf"^{re.escape(hostname)}\s+.*ansible_host=([^\s]+)")
for line in inventory_path.read_text().splitlines():
match = pattern.search(line.strip())
if match:
return match.group(1), f"inventory:{inventory_path}"
return DEFAULT_HOST, "default"
def parse_apply_output(stdout: str) -> dict[str, Any]:
result: dict[str, Any] = {"tailscale": None, "ping_ok": {}}
text = stdout or ""
start = text.find("{")
if start != -1:
try:
payload, _ = JSONDecoder().raw_decode(text[start:])
if isinstance(payload, dict):
result["tailscale"] = parse_tailscale_status(payload)
except Exception:
pass
for line in text.splitlines():
if line.startswith("PING_OK:"):
_, name, ip = line.split(":", 2)
result["ping_ok"][name] = ip
return result
def build_ssh_command(host: str, remote_script_path: str = "/tmp/bezalel_tailscale_bootstrap.sh") -> list[str]:
return ["ssh", host, f"bash {shlex.quote(remote_script_path)}"]
@@ -89,8 +123,9 @@ def parse_peer_args(items: list[str]) -> dict[str, str]:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Prepare or execute Tailscale bootstrap for the Bezalel VPS.")
parser.add_argument("--host", default=DEFAULT_HOST)
parser.add_argument("--host")
parser.add_argument("--hostname", default=DEFAULT_HOSTNAME)
parser.add_argument("--inventory-path", type=Path, default=DEFAULT_INVENTORY_PATH)
parser.add_argument("--auth-key", help="Tailscale auth key")
parser.add_argument("--auth-key-file", type=Path, help="Path to file containing the Tailscale auth key")
parser.add_argument("--ssh-public-key", help="SSH public key to append to authorized_keys")
@@ -116,6 +151,7 @@ def main() -> None:
auth_key = _read_secret(args.auth_key, args.auth_key_file)
ssh_public_key = _read_secret(args.ssh_public_key, args.ssh_public_key_file)
peers = parse_peer_args(args.peer)
resolved_host, host_source = resolve_host(args.host, args.inventory_path, args.hostname)
if not auth_key:
raise SystemExit("Missing Tailscale auth key. Use --auth-key or --auth-key-file.")
@@ -126,28 +162,31 @@ def main() -> None:
write_script(args.script_out, script)
payload: dict[str, Any] = {
"host": args.host,
"host": resolved_host,
"host_source": host_source,
"hostname": args.hostname,
"inventory_path": str(args.inventory_path),
"script_out": str(args.script_out),
"remote_script_path": args.remote_script_path,
"ssh_command": build_ssh_command(args.host, args.remote_script_path),
"ssh_command": build_ssh_command(resolved_host, args.remote_script_path),
"peer_targets": peers,
"applied": False,
}
if args.apply:
result = run_remote(args.host, args.remote_script_path)
result = run_remote(resolved_host, args.remote_script_path)
payload["applied"] = True
payload["exit_code"] = result.returncode
payload["stdout"] = result.stdout
payload["stderr"] = result.stderr
payload["verification"] = parse_apply_output(result.stdout)
if args.json:
print(json.dumps(payload, indent=2))
return
print("--- Bezalel Tailscale Bootstrap ---")
print(f"Host: {args.host}")
print(f"Host: {resolved_host} ({host_source})")
print(f"Local script: {args.script_out}")
print("SSH command: " + " ".join(payload["ssh_command"]))
if args.apply:

View File

@@ -2,9 +2,12 @@ from scripts.bezalel_tailscale_bootstrap import (
DEFAULT_PEERS,
build_remote_script,
build_ssh_command,
parse_apply_output,
parse_peer_args,
parse_tailscale_status,
resolve_host,
)
from pathlib import Path
def test_build_remote_script_contains_install_up_and_key_append():
@@ -78,3 +81,46 @@ def test_parse_peer_args_merges_overrides_into_defaults():
"ezra": "100.126.61.76",
"forge": "100.70.0.9",
}
def test_resolve_host_prefers_inventory_over_stale_default(tmp_path: Path):
inventory = tmp_path / "hosts.ini"
inventory.write_text(
"[fleet]\n"
"ezra ansible_host=143.198.27.163 ansible_user=root\n"
"bezalel ansible_host=67.205.155.108 ansible_user=root\n"
)
host, source = resolve_host(None, inventory)
assert host == "67.205.155.108"
assert source == f"inventory:{inventory}"
def test_parse_apply_output_extracts_status_and_ping_markers():
stdout = (
'{"Self": {"HostName": "bezalel", "DNSName": "bezalel.tailnet.ts.net", "TailscaleIPs": ["100.90.0.10"]}, '
'"Peer": {"node-1": {"HostName": "ezra", "TailscaleIPs": ["100.126.61.75"]}}}'
"\nPING_OK:mac:100.124.176.28\n"
"PING_OK:ezra:100.126.61.75\n"
)
result = parse_apply_output(stdout)
assert result["tailscale"]["self"]["tailscale_ips"] == ["100.90.0.10"]
assert result["ping_ok"] == {"mac": "100.124.176.28", "ezra": "100.126.61.75"}
def test_runbook_doc_exists_and_mentions_inventory_auth_and_peer_checks():
doc = Path("docs/BEZALEL_TAILSCALE_BOOTSTRAP.md")
assert doc.exists(), "missing docs/BEZALEL_TAILSCALE_BOOTSTRAP.md"
text = doc.read_text()
assert "ansible/inventory/hosts.ini" in text
assert "tailscale up" in text
assert "authorized_keys" in text
assert "100.124.176.28" in text
assert "100.126.61.75" in text
runbook = Path("docs/RUNBOOK_INDEX.md").read_text()
assert "Prepare Bezalel Tailscale bootstrap" in runbook
assert "scripts/bezalel_tailscale_bootstrap.py" in runbook

View File

@@ -1,7 +1,6 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parent.parent
@@ -67,82 +66,6 @@ class TestEvenniaLocalWorldGame(unittest.TestCase):
self.assertIn("Ezra is already here.", result["log"])
self.assertIn("The servers hum steady. The green LED pulses.", result["world_events"])
def test_bridge_rain_crossing_costs_extra_energy_and_warns(self):
module = load_game_module()
dry_engine = module.GameEngine()
dry_engine.start_new_game()
dry_engine.world.update_world_state = lambda: None
dry_engine.world.characters["Timmy"]["energy"] = 10
dry_result = dry_engine.run_tick("move:south")
dry_energy = dry_engine.world.characters["Timmy"]["energy"]
rainy_engine = module.GameEngine()
rainy_engine.start_new_game()
rainy_engine.world.update_world_state = lambda: None
rainy_engine.world.characters["Timmy"]["energy"] = 10
rainy_engine.world.rooms["Bridge"]["weather"] = "rain"
rainy_engine.world.rooms["Bridge"]["rain_ticks"] = 3
rainy_engine.world.state["bridge_flooding"] = True
rainy_result = rainy_engine.run_tick("move:south")
self.assertEqual(rainy_engine.world.characters["Timmy"]["room"], "Bridge")
self.assertLess(rainy_engine.world.characters["Timmy"]["energy"], dry_energy)
self.assertTrue(
any("bridge" in line.lower() and ("rain" in line.lower() or "slick" in line.lower()) for line in rainy_result["log"] + rainy_result["world_events"]),
rainy_result,
)
def test_tower_power_low_blocks_study_and_write_rule(self):
module = load_game_module()
engine = module.GameEngine()
engine.start_new_game()
engine.world.update_world_state = lambda: None
engine.world.characters["Timmy"]["room"] = "Tower"
engine.world.characters["Timmy"]["energy"] = 10
engine.world.state["tower_power_low"] = True
rules_before = list(engine.world.rooms["Tower"]["messages"])
study_result = engine.run_tick("study")
self.assertEqual(engine.world.characters["Timmy"]["energy"], 10)
self.assertTrue(
any("power" in line.lower() and ("study" in line.lower() or "servers" in line.lower()) for line in study_result["log"] + study_result["world_events"]),
study_result,
)
write_result = engine.run_tick("write_rule")
self.assertEqual(engine.world.rooms["Tower"]["messages"], rules_before)
self.assertTrue(
any("power" in line.lower() and ("write" in line.lower() or "whiteboard" in line.lower()) for line in write_result["log"] + write_result["world_events"]),
write_result,
)
def test_cold_forge_blocks_forge_action_and_bezalel_reacts(self):
module = load_game_module()
engine = module.GameEngine()
engine.start_new_game()
engine.world.update_world_state = lambda: None
engine.npc_ai.make_choice = lambda _name: None
engine.world.characters["Timmy"]["room"] = "Forge"
engine.world.characters["Timmy"]["energy"] = 10
engine.world.characters["Bezalel"]["room"] = "Forge"
engine.world.rooms["Forge"]["fire"] = "cold"
engine.world.state["forge_fire_dying"] = True
forged_before = list(engine.world.rooms["Forge"]["forged_items"])
with patch.object(module.random, "random", return_value=0.0), patch.object(module.random, "choice", side_effect=lambda seq: seq[0]):
result = engine.run_tick("forge")
self.assertEqual(engine.world.rooms["Forge"]["forged_items"], forged_before)
self.assertTrue(
any("forge" in line.lower() and ("cold" in line.lower() or "fire" in line.lower()) for line in result["log"] + result["world_events"]),
result,
)
self.assertTrue(
any(line.startswith("Bezalel says:") and ("fire" in line.lower() or "forge" in line.lower()) for line in result["log"]),
result,
)
if __name__ == "__main__":
unittest.main()