koala is migrated to the plugin (v0.4.1). Both Macs are not, and their current state is worse than "not migrated".
What the audit found on the Macs
~/dev is a single iCloud folder shared by flamingo and iguana, so they share one copy of it. Their host-local config is separate per machine.
~/.claude/skills on iguana holds onlygsd-* directories. Zero engineering skills.
No ~/.config/crush/skills, no ~/.agents/skills, no ~/.local/share/skills checkout.
~/dev/.skills still exists there because the shared local-dev checkout is pinned at e6d3c2d, pre-extraction. A git pull in ~/dev deletes it with nothing in its place.
Neither Mac has secret-guard.sh or pre-task-ritual.sh. Those lived only in koala's ~/.claude/hooks/.
The last point is the reason this is not cosmetic. secret-guard is the control that blocks the decrypt-and-print shape behind the 2026-08-05 ntfy-token leak, the op item get shape behind 2026-07-30, and the base64-masking bypass behind 2026-06-11. Two of three machines have been running without it.
Then remove any leftover ~/.claude/skills/<name> symlinks (plugin skills do not override same-named user skills — leftovers load everything twice; bin/doctor reports this), and confirm with task doctor.
Must be done per machine: ~/.claude and ~/.local/share are host-local, not in the shared folder. The plugin install cannot be done once and shared.
Blockers
flamingo is unreachable: ssh port 22 refused from koala (Remote Login off). Needs enabling, or the steps run by hand at the keyboard.
Claude Code version unknown on both. koala is on 2.1.222 and the source: "." marketplace layout validated there. Anything older than v2.1.196 skips plugins at the marketplace root and the install will silently produce no skills — check claude --version first.
Also worth doing while there
~/dev on the Macs is a git working tree inside iCloud with two machines writing to it. Every repo in it has a gitea remote, so the remote is already the sync and iCloud adds only concurrent-writer hazards, .icloud placeholders, and the cross-root symlink breakage that caused #23. Moving ~/dev out of iCloud is tracked separately.
koala is migrated to the plugin (v0.4.1). Both Macs are not, and their current state is worse than "not migrated".
## What the audit found on the Macs
`~/dev` is a single iCloud folder shared by flamingo and iguana, so they share one copy of it. Their host-local config is separate per machine.
- `~/.claude/skills` on iguana holds **only** `gsd-*` directories. Zero engineering skills.
- No `~/.config/crush/skills`, no `~/.agents/skills`, no `~/.local/share/skills` checkout.
- `~/dev/.skills` still exists there because the shared `local-dev` checkout is pinned at `e6d3c2d`, **pre-extraction**. A `git pull` in `~/dev` deletes it with nothing in its place.
- Neither Mac has `secret-guard.sh` or `pre-task-ritual.sh`. Those lived only in koala's `~/.claude/hooks/`.
The last point is the reason this is not cosmetic. `secret-guard` is the control that blocks the decrypt-and-print shape behind the 2026-08-05 ntfy-token leak, the `op item get` shape behind 2026-07-30, and the base64-masking bypass behind 2026-06-11. Two of three machines have been running without it.
## Steps per Mac
```
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
/plugin install skills@mathias
curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash # Crush/Pi/OpenCode
```
Then remove any leftover `~/.claude/skills/<name>` symlinks (plugin skills do not override same-named user skills — leftovers load everything twice; `bin/doctor` reports this), and confirm with `task doctor`.
Must be done **per machine**: `~/.claude` and `~/.local/share` are host-local, not in the shared folder. The plugin install cannot be done once and shared.
## Blockers
- **flamingo is unreachable**: ssh port 22 refused from koala (Remote Login off). Needs enabling, or the steps run by hand at the keyboard.
- **Claude Code version unknown on both.** koala is on 2.1.222 and the `source: "."` marketplace layout validated there. Anything older than v2.1.196 skips plugins at the marketplace root and the install will silently produce no skills — check `claude --version` first.
## Also worth doing while there
`~/dev` on the Macs is a git working tree inside iCloud with two machines writing to it. Every repo in it has a gitea remote, so the remote is already the sync and iCloud adds only concurrent-writer hazards, `.icloud` placeholders, and the cross-root symlink breakage that caused #23. Moving `~/dev` out of iCloud is tracked separately.
Prerequisite added: koala firewall must allow the MCP NodePorts on the Tailscale interface
The plugin's .mcp.json now points at tailnet-direct NodePorts instead of the public d-ma.be hostnames (perf(mcp): reach brain and infra over tailnet NodePorts, v0.4.3):
brain http://koala:30330/mcp
infra http://koala:30342/mcp
This must be resolved before either Mac installs the plugin, or they will get a working skills install with dead MCP servers.
Why the change
Every MCP call was going out through claude.ai's connector infrastructure → public internet → Cloudflare → NPM on piguard → koala k3s ingress, to reach a service on the machine the session is already running on. For brain that is a two-cloud round trip to read /var/lib/supervisor/brain, a file on koala's local disk. Cloudflare rate-limits and size-caps tool results on that path, which is the symptom that prompted this.
Measured from koala:
Path
Result
brain via NodePort koala:30330
200 in 6.8 ms
infra via NodePort koala:30342
200 in 3.6 ms
brain via public edge
200 in 27.7 ms
infra also starts working again as a side effect — its only fault was an SNI/cert gap at the public edge, which the NodePort path never traverses.
The blocker, measured
From iguana (10.0.1.215, same /24 as koala at 10.0.1.20):
Target
Result
koala:22
open
koala:30330 by name
refused in 3 ms
10.0.1.20:30330 direct LAN
refused
100.109.2.126:30330 over Tailscale
refused
curl: (7) Failed to connect ... after 3 ms — fast refusal, not a timeout, and SSH to the same host answers. So this is not routing or VLAN segmentation; koala refuses these ports to everything except localhost. Being on the homelab LAN buys nothing today.
The ask
Allow 30330, 30340, 30342 inbound on the Tailscale interface only — not on 10.0.1.0/24.
Scoping to tailscale0 rather than the LAN is the point: it keeps tailnet membership as the perimeter, consistent with knowledge/homelab-network-perimeter-model. A LAN-scoped rule would grant access to anything that joins the home network and would still leave the Macs broken when they are away from home. Tailscale already negotiates a direct path between these hosts, so there is no latency cost.
30340 (gitea-mcp) is included for when that server returns to .mcp.json — it is currently removed pending the token question below.
This is infra, so it belongs with the infra agent rather than here.
Also still open for the Macs
Claude Code version on both is unknown. koala is 2.1.222 and the source: "." marketplace layout validated there; anything older than v2.1.196 skips plugins at the marketplace root and silently installs zero skills. Check claude --version before installing.
flamingo ssh is refused (Remote Login off), so it cannot be done remotely.
gitea MCP is absent from the plugin pending a decision: koala's ~/.op-env points at op://HomeLab/GITEA_MCP_TOKEN, an item orphaned by the 2026-07-30 consolidation onto gitea-mcp-shared-token in the AgentSecrets vault — which koala's 1Password service account cannot read. Either grant that access, or leave hosts without a gitea token and rely on the claude.ai connector.
## Prerequisite added: koala firewall must allow the MCP NodePorts on the Tailscale interface
The plugin's `.mcp.json` now points at tailnet-direct NodePorts instead of the public `d-ma.be` hostnames (`perf(mcp): reach brain and infra over tailnet NodePorts`, v0.4.3):
```
brain http://koala:30330/mcp
infra http://koala:30342/mcp
```
**This must be resolved before either Mac installs the plugin, or they will get a working skills install with dead MCP servers.**
### Why the change
Every MCP call was going out through claude.ai's connector infrastructure → public internet → Cloudflare → NPM on piguard → koala k3s ingress, to reach a service on the machine the session is already running on. For `brain` that is a two-cloud round trip to read `/var/lib/supervisor/brain`, a file on koala's local disk. Cloudflare rate-limits and size-caps tool results on that path, which is the symptom that prompted this.
Measured from koala:
| Path | Result |
|---|---|
| `brain` via NodePort `koala:30330` | 200 in **6.8 ms** |
| `infra` via NodePort `koala:30342` | 200 in **3.6 ms** |
| `brain` via public edge | 200 in 27.7 ms |
`infra` also starts working again as a side effect — its only fault was an SNI/cert gap at the public edge, which the NodePort path never traverses.
### The blocker, measured
From iguana (10.0.1.215, **same /24** as koala at 10.0.1.20):
| Target | Result |
|---|---|
| `koala:22` | open |
| `koala:30330` by name | **refused in 3 ms** |
| `10.0.1.20:30330` direct LAN | **refused** |
| `100.109.2.126:30330` over Tailscale | **refused** |
`curl: (7) Failed to connect ... after 3 ms` — fast refusal, not a timeout, and SSH to the same host answers. So this is not routing or VLAN segmentation; koala refuses these ports to everything except localhost. Being on the homelab LAN buys nothing today.
### The ask
Allow `30330`, `30340`, `30342` inbound **on the Tailscale interface only** — not on `10.0.1.0/24`.
Scoping to `tailscale0` rather than the LAN is the point: it keeps tailnet membership as the perimeter, consistent with `knowledge/homelab-network-perimeter-model`. A LAN-scoped rule would grant access to anything that joins the home network and would still leave the Macs broken when they are away from home. Tailscale already negotiates a direct path between these hosts, so there is no latency cost.
`30340` (gitea-mcp) is included for when that server returns to `.mcp.json` — it is currently removed pending the token question below.
This is infra, so it belongs with the infra agent rather than here.
### Also still open for the Macs
- **Claude Code version** on both is unknown. koala is 2.1.222 and the `source: "."` marketplace layout validated there; anything older than **v2.1.196** skips plugins at the marketplace root and silently installs zero skills. Check `claude --version` before installing.
- **flamingo ssh is refused** (Remote Login off), so it cannot be done remotely.
- **`gitea` MCP is absent from the plugin** pending a decision: koala's `~/.op-env` points at `op://HomeLab/GITEA_MCP_TOKEN`, an item orphaned by the 2026-07-30 consolidation onto `gitea-mcp-shared-token` in the **AgentSecrets** vault — which koala's 1Password service account cannot read. Either grant that access, or leave hosts without a gitea token and rely on the claude.ai connector.
Correction: the firewall prerequisite above is wrong — do not apply it
My previous comment asked for an iptables rule opening the MCP NodePorts on tailscale0. Disregard it. Reading koala's actual chain showed the premise was false, and the rule would have punched a hole around a deliberate control while probably not even working.
What's actually blocking
-P INPUT ACCEPT
-A INPUT ... -j KUBE-ROUTER-INPUT
-A INPUT ... -j KUBE-EXTERNAL-SERVICES
-A INPUT -j ts-input
Default policy is ACCEPT and there is no host-level reject. Nothing is filtering at the firewall layer. The RST comes from kube-proxy's KUBE-EXTERNAL-SERVICES, acting on a NetworkPolicy:
Plus default-deny-ingress in every namespace. NodePort access from another host isn't an oversight — Traefik is the only sanctioned ingress, by design. An INPUT ACCEPT rule sits upstream of where the deny happens and would not have helped.
One more constraint found: externalTrafficPolicy is unset on all three services, so it defaults to Cluster, which SNATs the source address. A NetworkPolicy ipBlock for the Tailscale CGNAT range therefore cannot match — the tailnet client IP is gone by the time the packet reaches the pod. Admitting tailnet clients to the NodePort would require flipping all three services to externalTrafficPolicy: Local first.
The prerequisite that replaces it: none, for brain and gitea
Traefik is a NodePort service in the traefik namespace (80:30081, 443:30444) — that's how NPM on piguard reaches it. Measured from iguana:
Target
Result
10.0.1.20:30081 + Host: brain-mcp.d-ma.be
401 in 7.6 ms
100.109.2.126:30081 (tailnet) same header
401 in 6.0 ms
401 is the auth challenge — the request routed correctly through Traefik to the brain service. So the sanctioned path is already reachable from other hosts, over both LAN and tailnet, with no cluster changes at all. No NetworkPolicy edit, no firewall rule, no externalTrafficPolicy change, no certificates.
The only change needed is DNS: resolve brain-mcp.d-ma.be and git-mcp.d-ma.be to koala on the tailnet, so the name never leaves for Cloudflare. Then .mcp.json uses http://brain-mcp.d-ma.be:30081/mcp.
Plain HTTP is correct here, not a downgrade: koala's ingresses are port 80 with no tls: block and there is no cert-manager — TLS terminates at NPM and the koala hop has always been plaintext. On the tailnet that hop is encrypted by WireGuard anyway.
The one genuine gap: infra-mcp has no Ingress
kubectl -n infra-mcp get ingress returns nothing. It is NodePort-only (30342), so it cannot use the Traefik path. Either create an Ingress for it, or accept it stays koala-local. Not a blocker for the Mac rollout — it simply won't be available there until an Ingress exists.
Net effect on this issue
The Mac rollout is no longer blocked on an infra change. It needs a DNS entry and a .mcp.json update, both of which are reversible and neither of which touches the security posture.
Still open, unchanged: Claude Code version check on both Macs (anything below v2.1.196 silently installs zero skills), flamingo's ssh being off, and the gitea token / AgentSecrets question.
## Correction: the firewall prerequisite above is wrong — do not apply it
My previous comment asked for an iptables rule opening the MCP NodePorts on `tailscale0`. **Disregard it.** Reading koala's actual chain showed the premise was false, and the rule would have punched a hole around a deliberate control while probably not even working.
### What's actually blocking
```
-P INPUT ACCEPT
-A INPUT ... -j KUBE-ROUTER-INPUT
-A INPUT ... -j KUBE-EXTERNAL-SERVICES
-A INPUT -j ts-input
```
Default policy is **ACCEPT** and there is no host-level reject. Nothing is filtering at the firewall layer. The RST comes from kube-proxy's `KUBE-EXTERNAL-SERVICES`, acting on a NetworkPolicy:
```yaml
# supervisor/allow-callers
from:
- namespaceSelector: { kubernetes.io/metadata.name: traefik }
- namespaceSelector: { kubernetes.io/metadata.name: monitoring }
ports: [ { port: 3300, protocol: TCP } ]
```
Plus `default-deny-ingress` in every namespace. NodePort access from another host isn't an oversight — **Traefik is the only sanctioned ingress, by design**. An `INPUT` ACCEPT rule sits upstream of where the deny happens and would not have helped.
One more constraint found: `externalTrafficPolicy` is unset on all three services, so it defaults to `Cluster`, which SNATs the source address. A NetworkPolicy `ipBlock` for the Tailscale CGNAT range therefore **cannot match** — the tailnet client IP is gone by the time the packet reaches the pod. Admitting tailnet clients to the NodePort would require flipping all three services to `externalTrafficPolicy: Local` first.
### The prerequisite that replaces it: none, for brain and gitea
Traefik is a NodePort service in the `traefik` namespace (`80:30081`, `443:30444`) — that's how NPM on piguard reaches it. Measured from iguana:
| Target | Result |
|---|---|
| `10.0.1.20:30081` + `Host: brain-mcp.d-ma.be` | **401 in 7.6 ms** |
| `100.109.2.126:30081` (tailnet) same header | **401 in 6.0 ms** |
`401` is the auth challenge — the request routed correctly through Traefik to the brain service. So the sanctioned path is **already reachable from other hosts, over both LAN and tailnet, with no cluster changes at all.** No NetworkPolicy edit, no firewall rule, no `externalTrafficPolicy` change, no certificates.
The only change needed is DNS: resolve `brain-mcp.d-ma.be` and `git-mcp.d-ma.be` to koala on the tailnet, so the name never leaves for Cloudflare. Then `.mcp.json` uses `http://brain-mcp.d-ma.be:30081/mcp`.
Plain HTTP is correct here, not a downgrade: koala's ingresses are port 80 with no `tls:` block and there is no cert-manager — TLS terminates at NPM and the koala hop has always been plaintext. On the tailnet that hop is encrypted by WireGuard anyway.
### The one genuine gap: `infra-mcp` has no Ingress
`kubectl -n infra-mcp get ingress` returns nothing. It is NodePort-only (`30342`), so it cannot use the Traefik path. Either create an Ingress for it, or accept it stays koala-local. Not a blocker for the Mac rollout — it simply won't be available there until an Ingress exists.
### Net effect on this issue
The Mac rollout is **no longer blocked on an infra change**. It needs a DNS entry and a `.mcp.json` update, both of which are reversible and neither of which touches the security posture.
Still open, unchanged: Claude Code version check on both Macs (anything below v2.1.196 silently installs zero skills), flamingo's ssh being off, and the `gitea` token / AgentSecrets question.
Consolidated — supersedes both earlier prerequisite comments
I gave three different answers on this prerequisite as each test corrected the last. This comment replaces them. Ignore the iptables rule and the split-DNS record; neither is needed.
Resolved: MCP transport (shipped in v0.6.0)
brain https://koala.tail30472e.ts.net:8443/mcp -> 127.0.0.1:30330
infra https://koala.tail30472e.ts.net:8444/mcp -> 127.0.0.1:30342
Tailscale Serve on koala terminates TLS with a genuine cert and proxies over loopback, so requests reach the NodePort from koala itself — the case default-deny-ingress + allow-callers already permits. Verified ✔ Connected for both.
What this avoids, all of which I proposed at some point and all of which turned out unnecessary:
Proposed
Why it's not needed
iptables -I INPUT ... ACCEPT on tailscale0
-P INPUT ACCEPT, no host filtering at all. The RST came from kube-proxy's KUBE-EXTERNAL-SERVICES enforcing a NetworkPolicy, upstream of where the rule would sit
externalTrafficPolicy: Local + ipBlock: 100.64.0.0/10
Only required to admit tailnet clients directly to the NodePort. Serve reaches it over loopback instead
Split-DNS record for brain-mcp.d-ma.be
MagicDNS already provides the name
cert-manager / Traefik TLS
Tailscale issues the cert
The NetworkPolicy posture is untouched. Traefik remains the only external ingress.
Remaining Mac prerequisite: accept Tailscale DNS
Serve resolves via MagicDNS, and matches on SNI — so the raw tailnet IP is not a substitute. On iguana:
Tailscale is connected there (utun0, 100.113.19.87) but is not installed as a DNS provider, so the LAN resolver answers and knows nothing about .ts.net. Per Mac:
tailscale set --accept-dns=true
or the equivalent "Use Tailscale DNS" toggle in the menu-bar app. Verify with dig koala.tail30472e.ts.net — a bare dig, not @100.100.100.100, which already works and proves nothing.
Do not enable "Override DNS servers" in the admin console to fix this: Global nameservers is empty there, so overriding would point clients at nothing.
Full Mac checklist
claude --version — must be ≥ v2.1.196; older silently installs zero skills from a marketplace-root plugin
tailscale set --accept-dns=true, verify with a bare dig
/plugin marketplace add https://git.d-ma.be/mathias/skills.git then /plugin install skills@mathias
curl -fsSL .../install.sh | bash for Crush/Pi/OpenCode
Remove any leftover ~/.claude/skills/<name> symlinks — plugin skills do not override same-named user skills, so leftovers double-load. bin/doctor reports this
task doctor → expect exit 0
flamingo still needs Remote Login enabled, or steps run at the keyboard.
Unchanged
gitea stays out of .mcp.json pending the AgentSecrets decision. infra-mcp has no Ingress, which no longer matters — Serve doesn't use one.
Durability caveat worth tracking
Serve config lives in tailscaled state on koala, not in git. If koala is rebuilt, the plugin's MCP endpoints break with no signal — the same silent-drift failure class this whole issue is about. Worth either capturing the two tailscale serve commands in the infra repo's provisioning or adding a doctor check that the endpoints answer.
## Consolidated — supersedes both earlier prerequisite comments
I gave three different answers on this prerequisite as each test corrected the last. This comment replaces them. **Ignore the iptables rule and the split-DNS record; neither is needed.**
### Resolved: MCP transport (shipped in v0.6.0)
```
brain https://koala.tail30472e.ts.net:8443/mcp -> 127.0.0.1:30330
infra https://koala.tail30472e.ts.net:8444/mcp -> 127.0.0.1:30342
```
Tailscale Serve on koala terminates TLS with a genuine cert and proxies over loopback, so requests reach the NodePort **from koala itself** — the case `default-deny-ingress` + `allow-callers` already permits. Verified `✔ Connected` for both.
What this avoids, all of which I proposed at some point and all of which turned out unnecessary:
| Proposed | Why it's not needed |
|---|---|
| `iptables -I INPUT ... ACCEPT` on `tailscale0` | `-P INPUT ACCEPT`, no host filtering at all. The RST came from kube-proxy's `KUBE-EXTERNAL-SERVICES` enforcing a NetworkPolicy, upstream of where the rule would sit |
| `externalTrafficPolicy: Local` + `ipBlock: 100.64.0.0/10` | Only required to admit tailnet clients *directly* to the NodePort. Serve reaches it over loopback instead |
| Split-DNS record for `brain-mcp.d-ma.be` | MagicDNS already provides the name |
| cert-manager / Traefik TLS | Tailscale issues the cert |
The NetworkPolicy posture is untouched. Traefik remains the only external ingress.
### Remaining Mac prerequisite: accept Tailscale DNS
Serve resolves via MagicDNS, and matches on **SNI** — so the raw tailnet IP is not a substitute. On iguana:
```
dig @100.100.100.100 koala.tail30472e.ts.net -> 100.109.2.126 ✓
dig koala.tail30472e.ts.net -> (nothing) ✗
```
Tailscale is connected there (`utun0`, `100.113.19.87`) but is not installed as a DNS provider, so the LAN resolver answers and knows nothing about `.ts.net`. Per Mac:
```sh
tailscale set --accept-dns=true
```
or the equivalent "Use Tailscale DNS" toggle in the menu-bar app. Verify with `dig koala.tail30472e.ts.net` — a bare `dig`, not `@100.100.100.100`, which already works and proves nothing.
**Do not** enable "Override DNS servers" in the admin console to fix this: Global nameservers is empty there, so overriding would point clients at nothing.
### Full Mac checklist
1. `claude --version` — **must be ≥ v2.1.196**; older silently installs zero skills from a marketplace-root plugin
2. `tailscale set --accept-dns=true`, verify with a bare `dig`
3. `/plugin marketplace add https://git.d-ma.be/mathias/skills.git` then `/plugin install skills@mathias`
4. `curl -fsSL .../install.sh | bash` for Crush/Pi/OpenCode
5. Remove any leftover `~/.claude/skills/<name>` symlinks — plugin skills do not override same-named user skills, so leftovers double-load. `bin/doctor` reports this
6. `task doctor` → expect exit 0
flamingo still needs Remote Login enabled, or steps run at the keyboard.
### Unchanged
`gitea` stays out of `.mcp.json` pending the AgentSecrets decision. `infra-mcp` has no Ingress, which no longer matters — Serve doesn't use one.
### Durability caveat worth tracking
Serve config lives in tailscaled state on koala, not in git. If koala is rebuilt, the plugin's MCP endpoints break with no signal — the same silent-drift failure class this whole issue is about. Worth either capturing the two `tailscale serve` commands in the infra repo's provisioning or adding a `doctor` check that the endpoints answer.
All three above the v2.1.196 floor, so no silent zero-skill installs.
What each host needed
flamingo — tailscale set --accept-dns=true applied cleanly (the CLI/daemon version warning is patch-level skew and harmless). Verified end to end: MagicDNS resolves and the Serve endpoint answers 401 with a valid cert, no -k.
Two pre-existing messes cleared:
36 legacy symlinks in ~/.claude/skills pointing at /Users/mathias/dev/.skills/, with zero dangling. That directory still exists because ~/dev is the iCloud folder and its local-dev checkout is pinned pre-extraction at e6d3c2d. So they resolved fine and served the pre-extraction copy of the library — stale content, no error, silently shadowing the plugin. This is the silent-stale case from #23, still live, found only because bin/doctor looks for it.
~/.config/crush/skills held real directories. install.sh refused to replace them, correctly. They turned out to be Anthropic's official skill set rather than stale copies — preserved at ~/.config/crush/skills.anthropic, and now tracked as #27, since the current one-link-per-harness design cannot serve two sources at once.
iguana — installed over ssh. Clean slate: no legacy symlinks, no pre-existing harness directories.
One outstanding item on iguana: MCP will not connect yet
CorpDNS: true is set, but MagicDNS names still do not resolve there and the Serve endpoint fails to connect. The running tailscaled is 1.98.8 against a 1.102.3 CLI — a major-version skew, and the daemon predates the DNS integration the setting relies on.
Skills are unaffected; only the MCP servers are. Fix is a Tailscale restart on iguana (GUI app restart, or sudo launchctl kickstart -k system/com.tailscale.tailscaled), then verify:
dscacheutil -q host -a name koala.tail30472e.ts.net # expect 100.109.2.126
curl -sS -o /dev/null -w '%{http_code}\n' -X POST -d '{}' https://koala.tail30472e.ts.net:8443/mcp
401 is success. Note dig is not a valid test on macOS — it reads /etc/resolv.conf and ignores the scoped resolver Tailscale installs. Use dscacheutil or the curl.
Remaining, tracked elsewhere
#27 — multi-source support, and rescuing gotth-dev from flamingo's Crush directory into this repo. It is one machine, one harness, no git, no backup.
gitea MCP still absent pending the AgentSecrets vault decision.
Serve config lives in tailscaled state on koala, not in git — if koala is rebuilt the MCP endpoints break with no signal. Worth capturing in infra provisioning or adding a doctor check.
Closing.
## Done — all three hosts installed
| Host | Claude Code | Plugin | install.sh | doctor |
|---|---|---|---|---|
| koala | 2.1.222 | ✔ v0.6.0 | 3 links | **exit 0** |
| flamingo | 2.1.231 | ✔ | 3 links | **exit 0** |
| iguana | 2.1.218 | ✔ | 3 links | **exit 0** |
All three above the v2.1.196 floor, so no silent zero-skill installs.
### What each host needed
**flamingo** — `tailscale set --accept-dns=true` applied cleanly (the CLI/daemon version warning is patch-level skew and harmless). Verified end to end: MagicDNS resolves and the Serve endpoint answers **401** with a valid cert, no `-k`.
Two pre-existing messes cleared:
- **36 legacy symlinks** in `~/.claude/skills` pointing at `/Users/mathias/dev/.skills/`, with **zero dangling**. That directory still exists because `~/dev` is the iCloud folder and its `local-dev` checkout is pinned pre-extraction at `e6d3c2d`. So they resolved fine and served the *pre-extraction* copy of the library — stale content, no error, silently shadowing the plugin. This is the silent-stale case from #23, still live, found only because `bin/doctor` looks for it.
- `~/.config/crush/skills` held real directories. `install.sh` refused to replace them, correctly. They turned out to be Anthropic's official skill set rather than stale copies — preserved at `~/.config/crush/skills.anthropic`, and now tracked as **#27**, since the current one-link-per-harness design cannot serve two sources at once.
**iguana** — installed over ssh. Clean slate: no legacy symlinks, no pre-existing harness directories.
### One outstanding item on iguana: MCP will not connect yet
`CorpDNS: true` is set, but MagicDNS names still do not resolve there and the Serve endpoint fails to connect. The running **tailscaled is 1.98.8** against a 1.102.3 CLI — a major-version skew, and the daemon predates the DNS integration the setting relies on.
Skills are unaffected; only the MCP servers are. Fix is a Tailscale restart on iguana (GUI app restart, or `sudo launchctl kickstart -k system/com.tailscale.tailscaled`), then verify:
```sh
dscacheutil -q host -a name koala.tail30472e.ts.net # expect 100.109.2.126
curl -sS -o /dev/null -w '%{http_code}\n' -X POST -d '{}' https://koala.tail30472e.ts.net:8443/mcp
```
`401` is success. Note `dig` is **not** a valid test on macOS — it reads `/etc/resolv.conf` and ignores the scoped resolver Tailscale installs. Use `dscacheutil` or the curl.
### Remaining, tracked elsewhere
- **#27** — multi-source support, and rescuing `gotth-dev` from flamingo's Crush directory into this repo. It is one machine, one harness, no git, no backup.
- `gitea` MCP still absent pending the AgentSecrets vault decision.
- Serve config lives in tailscaled state on koala, not in git — if koala is rebuilt the MCP endpoints break with no signal. Worth capturing in infra provisioning or adding a doctor check.
Closing.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
koala is migrated to the plugin (v0.4.1). Both Macs are not, and their current state is worse than "not migrated".
What the audit found on the Macs
~/devis a single iCloud folder shared by flamingo and iguana, so they share one copy of it. Their host-local config is separate per machine.~/.claude/skillson iguana holds onlygsd-*directories. Zero engineering skills.~/.config/crush/skills, no~/.agents/skills, no~/.local/share/skillscheckout.~/dev/.skillsstill exists there because the sharedlocal-devcheckout is pinned ate6d3c2d, pre-extraction. Agit pullin~/devdeletes it with nothing in its place.secret-guard.shorpre-task-ritual.sh. Those lived only in koala's~/.claude/hooks/.The last point is the reason this is not cosmetic.
secret-guardis the control that blocks the decrypt-and-print shape behind the 2026-08-05 ntfy-token leak, theop item getshape behind 2026-07-30, and the base64-masking bypass behind 2026-06-11. Two of three machines have been running without it.Steps per Mac
Then remove any leftover
~/.claude/skills/<name>symlinks (plugin skills do not override same-named user skills — leftovers load everything twice;bin/doctorreports this), and confirm withtask doctor.Must be done per machine:
~/.claudeand~/.local/shareare host-local, not in the shared folder. The plugin install cannot be done once and shared.Blockers
source: "."marketplace layout validated there. Anything older than v2.1.196 skips plugins at the marketplace root and the install will silently produce no skills — checkclaude --versionfirst.Also worth doing while there
~/devon the Macs is a git working tree inside iCloud with two machines writing to it. Every repo in it has a gitea remote, so the remote is already the sync and iCloud adds only concurrent-writer hazards,.icloudplaceholders, and the cross-root symlink breakage that caused #23. Moving~/devout of iCloud is tracked separately.Prerequisite added: koala firewall must allow the MCP NodePorts on the Tailscale interface
The plugin's
.mcp.jsonnow points at tailnet-direct NodePorts instead of the publicd-ma.behostnames (perf(mcp): reach brain and infra over tailnet NodePorts, v0.4.3):This must be resolved before either Mac installs the plugin, or they will get a working skills install with dead MCP servers.
Why the change
Every MCP call was going out through claude.ai's connector infrastructure → public internet → Cloudflare → NPM on piguard → koala k3s ingress, to reach a service on the machine the session is already running on. For
brainthat is a two-cloud round trip to read/var/lib/supervisor/brain, a file on koala's local disk. Cloudflare rate-limits and size-caps tool results on that path, which is the symptom that prompted this.Measured from koala:
brainvia NodePortkoala:30330infravia NodePortkoala:30342brainvia public edgeinfraalso starts working again as a side effect — its only fault was an SNI/cert gap at the public edge, which the NodePort path never traverses.The blocker, measured
From iguana (10.0.1.215, same /24 as koala at 10.0.1.20):
koala:22koala:30330by name10.0.1.20:30330direct LAN100.109.2.126:30330over Tailscalecurl: (7) Failed to connect ... after 3 ms— fast refusal, not a timeout, and SSH to the same host answers. So this is not routing or VLAN segmentation; koala refuses these ports to everything except localhost. Being on the homelab LAN buys nothing today.The ask
Allow
30330,30340,30342inbound on the Tailscale interface only — not on10.0.1.0/24.Scoping to
tailscale0rather than the LAN is the point: it keeps tailnet membership as the perimeter, consistent withknowledge/homelab-network-perimeter-model. A LAN-scoped rule would grant access to anything that joins the home network and would still leave the Macs broken when they are away from home. Tailscale already negotiates a direct path between these hosts, so there is no latency cost.30340(gitea-mcp) is included for when that server returns to.mcp.json— it is currently removed pending the token question below.This is infra, so it belongs with the infra agent rather than here.
Also still open for the Macs
source: "."marketplace layout validated there; anything older than v2.1.196 skips plugins at the marketplace root and silently installs zero skills. Checkclaude --versionbefore installing.giteaMCP is absent from the plugin pending a decision: koala's~/.op-envpoints atop://HomeLab/GITEA_MCP_TOKEN, an item orphaned by the 2026-07-30 consolidation ontogitea-mcp-shared-tokenin the AgentSecrets vault — which koala's 1Password service account cannot read. Either grant that access, or leave hosts without a gitea token and rely on the claude.ai connector.Correction: the firewall prerequisite above is wrong — do not apply it
My previous comment asked for an iptables rule opening the MCP NodePorts on
tailscale0. Disregard it. Reading koala's actual chain showed the premise was false, and the rule would have punched a hole around a deliberate control while probably not even working.What's actually blocking
Default policy is ACCEPT and there is no host-level reject. Nothing is filtering at the firewall layer. The RST comes from kube-proxy's
KUBE-EXTERNAL-SERVICES, acting on a NetworkPolicy:Plus
default-deny-ingressin every namespace. NodePort access from another host isn't an oversight — Traefik is the only sanctioned ingress, by design. AnINPUTACCEPT rule sits upstream of where the deny happens and would not have helped.One more constraint found:
externalTrafficPolicyis unset on all three services, so it defaults toCluster, which SNATs the source address. A NetworkPolicyipBlockfor the Tailscale CGNAT range therefore cannot match — the tailnet client IP is gone by the time the packet reaches the pod. Admitting tailnet clients to the NodePort would require flipping all three services toexternalTrafficPolicy: Localfirst.The prerequisite that replaces it: none, for brain and gitea
Traefik is a NodePort service in the
traefiknamespace (80:30081,443:30444) — that's how NPM on piguard reaches it. Measured from iguana:10.0.1.20:30081+Host: brain-mcp.d-ma.be100.109.2.126:30081(tailnet) same header401is the auth challenge — the request routed correctly through Traefik to the brain service. So the sanctioned path is already reachable from other hosts, over both LAN and tailnet, with no cluster changes at all. No NetworkPolicy edit, no firewall rule, noexternalTrafficPolicychange, no certificates.The only change needed is DNS: resolve
brain-mcp.d-ma.beandgit-mcp.d-ma.beto koala on the tailnet, so the name never leaves for Cloudflare. Then.mcp.jsonuseshttp://brain-mcp.d-ma.be:30081/mcp.Plain HTTP is correct here, not a downgrade: koala's ingresses are port 80 with no
tls:block and there is no cert-manager — TLS terminates at NPM and the koala hop has always been plaintext. On the tailnet that hop is encrypted by WireGuard anyway.The one genuine gap:
infra-mcphas no Ingresskubectl -n infra-mcp get ingressreturns nothing. It is NodePort-only (30342), so it cannot use the Traefik path. Either create an Ingress for it, or accept it stays koala-local. Not a blocker for the Mac rollout — it simply won't be available there until an Ingress exists.Net effect on this issue
The Mac rollout is no longer blocked on an infra change. It needs a DNS entry and a
.mcp.jsonupdate, both of which are reversible and neither of which touches the security posture.Still open, unchanged: Claude Code version check on both Macs (anything below v2.1.196 silently installs zero skills), flamingo's ssh being off, and the
giteatoken / AgentSecrets question.Consolidated — supersedes both earlier prerequisite comments
I gave three different answers on this prerequisite as each test corrected the last. This comment replaces them. Ignore the iptables rule and the split-DNS record; neither is needed.
Resolved: MCP transport (shipped in v0.6.0)
Tailscale Serve on koala terminates TLS with a genuine cert and proxies over loopback, so requests reach the NodePort from koala itself — the case
default-deny-ingress+allow-callersalready permits. Verified✔ Connectedfor both.What this avoids, all of which I proposed at some point and all of which turned out unnecessary:
iptables -I INPUT ... ACCEPTontailscale0-P INPUT ACCEPT, no host filtering at all. The RST came from kube-proxy'sKUBE-EXTERNAL-SERVICESenforcing a NetworkPolicy, upstream of where the rule would sitexternalTrafficPolicy: Local+ipBlock: 100.64.0.0/10brain-mcp.d-ma.beThe NetworkPolicy posture is untouched. Traefik remains the only external ingress.
Remaining Mac prerequisite: accept Tailscale DNS
Serve resolves via MagicDNS, and matches on SNI — so the raw tailnet IP is not a substitute. On iguana:
Tailscale is connected there (
utun0,100.113.19.87) but is not installed as a DNS provider, so the LAN resolver answers and knows nothing about.ts.net. Per Mac:or the equivalent "Use Tailscale DNS" toggle in the menu-bar app. Verify with
dig koala.tail30472e.ts.net— a baredig, not@100.100.100.100, which already works and proves nothing.Do not enable "Override DNS servers" in the admin console to fix this: Global nameservers is empty there, so overriding would point clients at nothing.
Full Mac checklist
claude --version— must be ≥ v2.1.196; older silently installs zero skills from a marketplace-root plugintailscale set --accept-dns=true, verify with a baredig/plugin marketplace add https://git.d-ma.be/mathias/skills.gitthen/plugin install skills@mathiascurl -fsSL .../install.sh | bashfor Crush/Pi/OpenCode~/.claude/skills/<name>symlinks — plugin skills do not override same-named user skills, so leftovers double-load.bin/doctorreports thistask doctor→ expect exit 0flamingo still needs Remote Login enabled, or steps run at the keyboard.
Unchanged
giteastays out of.mcp.jsonpending the AgentSecrets decision.infra-mcphas no Ingress, which no longer matters — Serve doesn't use one.Durability caveat worth tracking
Serve config lives in tailscaled state on koala, not in git. If koala is rebuilt, the plugin's MCP endpoints break with no signal — the same silent-drift failure class this whole issue is about. Worth either capturing the two
tailscale servecommands in the infra repo's provisioning or adding adoctorcheck that the endpoints answer.Done — all three hosts installed
All three above the v2.1.196 floor, so no silent zero-skill installs.
What each host needed
flamingo —
tailscale set --accept-dns=trueapplied cleanly (the CLI/daemon version warning is patch-level skew and harmless). Verified end to end: MagicDNS resolves and the Serve endpoint answers 401 with a valid cert, no-k.Two pre-existing messes cleared:
~/.claude/skillspointing at/Users/mathias/dev/.skills/, with zero dangling. That directory still exists because~/devis the iCloud folder and itslocal-devcheckout is pinned pre-extraction ate6d3c2d. So they resolved fine and served the pre-extraction copy of the library — stale content, no error, silently shadowing the plugin. This is the silent-stale case from #23, still live, found only becausebin/doctorlooks for it.~/.config/crush/skillsheld real directories.install.shrefused to replace them, correctly. They turned out to be Anthropic's official skill set rather than stale copies — preserved at~/.config/crush/skills.anthropic, and now tracked as #27, since the current one-link-per-harness design cannot serve two sources at once.iguana — installed over ssh. Clean slate: no legacy symlinks, no pre-existing harness directories.
One outstanding item on iguana: MCP will not connect yet
CorpDNS: trueis set, but MagicDNS names still do not resolve there and the Serve endpoint fails to connect. The running tailscaled is 1.98.8 against a 1.102.3 CLI — a major-version skew, and the daemon predates the DNS integration the setting relies on.Skills are unaffected; only the MCP servers are. Fix is a Tailscale restart on iguana (GUI app restart, or
sudo launchctl kickstart -k system/com.tailscale.tailscaled), then verify:401is success. Notedigis not a valid test on macOS — it reads/etc/resolv.confand ignores the scoped resolver Tailscale installs. Usedscacheutilor the curl.Remaining, tracked elsewhere
gotth-devfrom flamingo's Crush directory into this repo. It is one machine, one harness, no git, no backup.giteaMCP still absent pending the AgentSecrets vault decision.Closing.