sync: auto-sync from GURU-5070 at 2026-06-06 15:46:17

Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-06-06 15:46:17
This commit is contained in:
2026-06-06 15:46:22 -07:00
parent f75405506e
commit 34fa93b361
3 changed files with 210 additions and 9 deletions

View File

@@ -30,6 +30,11 @@ param(
$ErrorActionPreference = 'Stop'
$u = $env:USERPROFILE
# Decode native (git) stdout as UTF-8 so captured patch text is not mangled, and give
# us a UTF-8 (no BOM) encoding for writing patches `git apply` can actually parse.
try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) } catch {}
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
function Save($src,$dst){
if (Test-Path -LiteralPath $src) {
$p = Split-Path $dst -Parent; if (-not (Test-Path $p)) { New-Item -ItemType Directory -Force -Path $p | Out-Null }
@@ -81,6 +86,37 @@ if (Test-Path "$ClaudeToolsRoot\.claude\state") { Copy-Item "$ClaudeToolsRoot\.c
Copy-Item "$ClaudeToolsRoot\.claude\bootstrap\*.ps1" "$root\bootstrap\" -Force -ErrorAction SilentlyContinue
Copy-Item "$ClaudeToolsRoot\.claude\bootstrap\RESTORE.md" "$root\bootstrap\" -Force -ErrorAction SilentlyContinue
# --- at-risk local WIP: stashes + untracked diffs that are on NO remote ---
# Written as UTF-8 (no BOM, LF) so restore-at-risk-work.ps1 / `git apply` can parse them.
# (Earlier ad-hoc captures used PowerShell `>` redirection = UTF-16, which git apply
# rejects with "No valid patches in input" - hence the explicit byte-level write here.)
$awRoot = "$root\at-risk-work"
function Save-RepoStashes($repo,$label){
if (-not (Test-Path "$repo\.git")) { return }
$marks = @(& git -C $repo stash list --format='%gd' 2>$null)
if (-not $marks) { return }
$dir = "$awRoot\$label"; New-Item -ItemType Directory -Force -Path $dir | Out-Null
$base = (& git -C $repo rev-parse HEAD 2>$null)
[System.IO.File]::WriteAllText("$dir\BASE-COMMIT.txt", "$base`n", $Utf8NoBom)
for ($i=0; $i -lt $marks.Count; $i++) {
$files = @(& git -C $repo stash show --name-only "stash@{$i}" 2>$null)
$slug = if ($files.Count) { ([IO.Path]::GetFileNameWithoutExtension($files[0])) -replace '[^\w\-]','_' } else { "stash$i" }
$lines = @(& git -C $repo --no-pager stash show -p "stash@{$i}" 2>$null)
[System.IO.File]::WriteAllText("$dir\stash$i-$slug.patch", (($lines -join "`n") + "`n"), $Utf8NoBom)
Write-Host "[OK] at-risk stash: $label stash@{$i} -> stash$i-$slug.patch"
}
}
Save-RepoStashes "$ClaudeToolsRoot\projects\msp-tools\guru-rmm" 'guru-rmm'
Save-RepoStashes "$ClaudeToolsRoot\projects\msp-tools\guru-connect" 'guru-connect'
# untracked working diffs (e.g. tmp-*.diff) that aren't committed anywhere
$gcRepo = "$ClaudeToolsRoot\projects\msp-tools\guru-connect"
if (Test-Path $gcRepo) {
Get-ChildItem $gcRepo -Filter 'tmp-*.diff' -File -ErrorAction SilentlyContinue | ForEach-Object {
$dir = "$awRoot\guru-connect"; New-Item -ItemType Directory -Force -Path $dir | Out-Null
Copy-Item $_.FullName "$dir\$($_.Name)" -Force; Write-Host "[OK] at-risk untracked diff: guru-connect\$($_.Name)"
}
}
# --- manifests ---
$m = "$root\manifests"
$tools = 'node','npm','claude','gemini','grok','ollama','py','git','gh','jq','sops','age','cargo','rustc','code','op'

View File

@@ -15,6 +15,18 @@
Non-destructive and re-runnable. If a patch won't apply cleanly (submodule moved on),
it is reported and the .patch file is left in place for manual `git apply --3way`.
ROBUSTNESS NOTES (why this is not just `git apply <file>`):
* Patch files may have been written by PowerShell redirection (UTF-16 LE/BE w/ BOM).
`git apply` only understands UTF-8/ASCII and otherwise reports
"No valid patches in input". Get-Utf8PatchPath normalizes any encoding to a
UTF-8 (no BOM) temp copy before applying.
* git writes progress/errors to stderr; capturing that with `2>&1` while
$ErrorActionPreference='Stop' turns it into a *terminating* error (PS 5.1
NativeCommandError) that aborts the whole bootstrap. Invoke-Git captures
output without that trap and returns the real exit code.
* If the submodule still has stashes, the WIP almost certainly survived the reset.
Re-applying would create DUPLICATE stashes, so we skip and report instead.
.PARAMETER BundlePath Recovery bundle root (auto-detect F:\ then E:\).
.PARAMETER ClaudeToolsRoot Default D:\claudetools.
#>
@@ -22,6 +34,35 @@
param([string]$BundlePath,[string]$ClaudeToolsRoot='D:\claudetools')
$ErrorActionPreference='Stop'
# Read a patch regardless of encoding (UTF-16 LE/BE +/- BOM, UTF-8 +/- BOM) and return
# the path to a normalized UTF-8 (no BOM) temp copy that `git apply` can parse.
function Get-Utf8PatchPath($path){
$bytes = [System.IO.File]::ReadAllBytes($path)
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) { $text = [System.Text.Encoding]::Unicode.GetString($bytes,2,$bytes.Length-2) }
elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) { $text = [System.Text.Encoding]::BigEndianUnicode.GetString($bytes,2,$bytes.Length-2) }
elseif ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $text = [System.Text.Encoding]::UTF8.GetString($bytes,3,$bytes.Length-3) }
else {
# No BOM: detect UTF-16 LE without BOM by counting interleaved NUL bytes in the head.
$nul = 0; $n = [Math]::Min(64,$bytes.Length)
for ($i=0; $i -lt $n; $i++) { if ($bytes[$i] -eq 0) { $nul++ } }
if ($nul -gt 8) { $text = [System.Text.Encoding]::Unicode.GetString($bytes) }
else { $text = [System.Text.Encoding]::UTF8.GetString($bytes) }
}
$text = $text -replace "`r`n","`n" # normalize to LF so git apply is happy
$tmp = [System.IO.Path]::GetTempFileName()
[System.IO.File]::WriteAllText($tmp, $text, (New-Object System.Text.UTF8Encoding($false)))
return $tmp
}
# Run git without letting native stderr (under $ErrorActionPreference='Stop') become a
# terminating error. Returns [pscustomobject]@{ Code; Output }.
function Invoke-Git([string[]]$GitArgs){
$old = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
try { $out = (& git @GitArgs 2>&1 | Out-String); $code = $LASTEXITCODE }
finally { $ErrorActionPreference = $old }
[pscustomobject]@{ Code = $code; Output = ($out).Trim() }
}
if (-not $BundlePath) { foreach ($d in 'F:','E:','D:') { if (Test-Path "$d\claudetools-recovery\at-risk-work") { $BundlePath="$d\claudetools-recovery"; break } } }
$aw = "$BundlePath\at-risk-work"
if (-not $BundlePath -or -not (Test-Path $aw)) { Write-Host "[INFO] no at-risk-work folder found in bundle - nothing to restore"; return }
@@ -32,20 +73,29 @@ function Have-Git($repo){ Test-Path "$repo\.git" }
# ---- guru-rmm stashes ----
$rmm = "$ClaudeToolsRoot\projects\msp-tools\guru-rmm"
if ((Test-Path "$aw\guru-rmm") -and (Have-Git $rmm)) {
if (git -C $rmm status --porcelain) {
$existing = (Invoke-Git @('-C',$rmm,'stash','list')).Output
if ($existing) {
Write-Host "[SKIP] guru-rmm already has stashes (local WIP survived the reset) - not re-applying to avoid duplicates:" -ForegroundColor Yellow
Write-Host $existing
Write-Host " Bundle patches remain in $aw\guru-rmm; apply by hand if you really need them." -ForegroundColor Yellow
}
elseif ((Invoke-Git @('-C',$rmm,'status','--porcelain')).Output) {
Write-Host "[WARN] guru-rmm working tree is dirty; skipping auto-restore to avoid mixing changes. Apply patches in $aw\guru-rmm manually." -ForegroundColor Yellow
} else {
# highest N first so stash0 lands at stash@{0}
$patches = Get-ChildItem "$aw\guru-rmm" -Filter '*.patch' | Sort-Object Name -Descending
foreach ($p in $patches) {
$check = git -C $rmm apply --check --3way $p.FullName 2>&1
if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] won't apply cleanly, left for manual restore: $($p.Name) ($check)" -ForegroundColor Yellow; continue }
git -C $rmm apply --3way $p.FullName 2>$null
git -C $rmm stash push -u -m "restored WIP: $($p.BaseName)" 2>$null | Out-Null
Write-Host "[OK] re-stashed guru-rmm: $($p.BaseName)" -ForegroundColor Green
$u8 = Get-Utf8PatchPath $p.FullName
try {
$chk = Invoke-Git @('-C',$rmm,'apply','--check','--3way',$u8)
if ($chk.Code -ne 0) { Write-Host "[WARN] won't apply cleanly, left for manual restore: $($p.Name) ($($chk.Output))" -ForegroundColor Yellow; continue }
Invoke-Git @('-C',$rmm,'apply','--3way',$u8) | Out-Null
Invoke-Git @('-C',$rmm,'stash','push','-u','-m',"restored WIP: $($p.BaseName)") | Out-Null
Write-Host "[OK] re-stashed guru-rmm: $($p.BaseName)" -ForegroundColor Green
} finally { Remove-Item $u8 -Force -ErrorAction SilentlyContinue }
}
Write-Host "[INFO] guru-rmm stashes now:" -ForegroundColor Cyan
git -C $rmm stash list
Write-Host (Invoke-Git @('-C',$rmm,'stash','list')).Output
}
}
@@ -53,7 +103,11 @@ if ((Test-Path "$aw\guru-rmm") -and (Have-Git $rmm)) {
$gc = "$ClaudeToolsRoot\projects\msp-tools\guru-connect"
$diff = "$aw\guru-connect\tmp-spec018.diff"
if ((Test-Path $diff) -and (Test-Path $gc)) {
Copy-Item $diff "$gc\tmp-spec018.diff" -Force
Write-Host "[OK] guru-connect\tmp-spec018.diff restored (untracked working file - 'git apply --3way tmp-spec018.diff' to apply it)" -ForegroundColor Green
if (Test-Path "$gc\tmp-spec018.diff") {
Write-Host "[SKIP] guru-connect\tmp-spec018.diff already present in repo (survived the reset) - not overwriting." -ForegroundColor Yellow
} else {
Copy-Item $diff "$gc\tmp-spec018.diff" -Force
Write-Host "[OK] guru-connect\tmp-spec018.diff restored (untracked working file - 'git apply --3way tmp-spec018.diff' to apply it)" -ForegroundColor Green
}
}
Write-Host "[DONE] at-risk WIP restore" -ForegroundColor Cyan

View File

@@ -0,0 +1,111 @@
## User
- **User:** Mike Swanson (mike)
- **Machine:** GURU-5070
- **Role:** admin
## Session Summary
Started from a `/doctor` report flagging the `ticktick` MCP server as failed ("Connection closed"). Diagnosed it as a missing-dependency crash, not an OAuth issue: the local stdio server `mcp-servers/ticktick/ticktick_mcp.py` is launched with bare `python` (Python 3.12) and that interpreter was missing `httpx` and (behind a try/except) `mcp`. Installed both into 3.12, created `mcp-servers/ticktick/requirements.txt`, and verified the server starts clean. The server now shows "Pending approval" (normal trust prompt) instead of crashing.
Mike then stated a hard requirement: git must never sit at an interactive credential prompt — his objection to Git for Windows is the Git Credential Manager popups that hang automation, not the tooling itself. Discovered a live symptom: a backgrounded Gitea Agent push was hung on a GCM prompt (commit unpushed). Stopped it, pushed via the vault Gitea API token, then built a durable, fleet-wide non-interactive auth solution: `setup-git-auth.sh` (primes the `store` credential helper from the vault token, scoped per-repo by origin host, only seizing the helper from GCM `manager`), a backgrounded `SessionStart` hook, and `GIT_TERMINAL_PROMPT=0` / `GCM_INTERACTIVE=Never` in `settings.json` env. Corrected an earlier wrong memory (had attributed the dislike to the bash/path-mangling layer). All subsequent pushes this session used native `git.exe` via PowerShell with zero prompts.
Mike disclosed the machine was recently reinstalled and asked to install anything missing. Found the reinstall installed Python deps into only one of the machine's two interpreters (`py`/3.14 for vault+scripts vs `python`/3.12 for MCP servers), which is exactly why ticktick (3.12) and `vault get-field` (3.14, missing PyYAML) were both broken. Installed PyYAML into `py`, `websocket-client` into `py` (cdp.py), and persisted `grok` to the User PATH. Ollama `list` came back empty, but the 5 expected models (47.8 GB) were intact on `D:\OllamaModels` — the tray app just needed a few seconds to hydrate; restarting it loaded all 5. No 48 GB re-download. Then amended the recovery script `windows-bootstrap.ps1` (Phase 7 both interpreters + pyyaml/websocket-client; Phase 3 persist grok PATH; Phase 6 prime git auth; Phase 8 full model set + hydration guard) and documented the gotchas in `machines/guru-5070.md`.
Finally, Mike asked for help managing Tailscale for a tech-inept two-machine client. Recommended one tailnet per client (never merge into ACG's own), MSP holds Admin, devices enrolled as tagged nodes via pre-auth keys pushed from GuruRMM. Authored `wiki/patterns/tailscale-client-management.md` + `tailscale-client-enroll.ps1` (idempotent unattended Windows MSI install + tagged auth-key join). Scanned GuruRMM for the client (Robert Wolkin): found client `Wolkin, Robert`/site `Main` with 3 online Win11 Home agents. Mike clarified scope: connect RSW-Laptop -> front for file + printer sharing; DESKTOP-V1JT1SE is Bob's personal machine (out of scope). Created the `robert-wolkin` client stub, then added a reusable "files + printer over Tailscale (Windows)" section (SMB over the tailnet, the 445-firewall-on-Tailscale-interface gotcha, local-account auth on Home, MagicDNS FQDN, Point-and-Print via RMM, Taildrive alternative).
## Key Decisions
- **ticktick fix is dependency install, not OAuth.** "Connection closed" on a local stdio MCP server = process crash before handshake. `/mcp` authenticate would not have helped.
- **Non-interactive git auth via repo-local `store` helper, not global change.** Kept global GCM untouched for other repos; scoped the credential to each repo's actual origin host. `GIT_TERMINAL_PROMPT=0` enforced via committed settings.json so even an unprimed machine fails fast instead of hanging.
- **Conservative helper override.** `setup-git-auth.sh` only switches the helper to `store` when it is empty or GCM `manager`, so a Mac osxkeychain setup is left alone — safe to run fleet-wide via SessionStart hook.
- **Two-interpreter Python installs in bootstrap.** Root cause of the reinstall breakage; de-dupe by `sys.executable` so a single install isn't run twice.
- **Did not re-download Ollama models.** Confirmed manifests + 47.8 GB on `D:\OllamaModels`; an empty `ollama list` right after login is a hydration-timing artifact, not "models gone."
- **One tailnet per client.** Isolation, billing, offboarding, IdP blast radius. Never merge a client into ACG's tailnet or share one tailnet across clients.
- **SMB over Tailscale for Wolkin (not Taildrive).** A shared printer forces SMB regardless, so use SMB for both files and printer; no subnet router because files/printer live on a node.
- **Session log to root** (`session-logs/`) — multi-topic (machine maintenance + git infra + a client), no single article implied; wiki articles were hand-authored inline during the session.
## Problems Encountered
- **ticktick MCP "Connection closed":** missing `httpx` then `mcp` in Python 3.12. Installed both; server starts clean.
- **Gitea Agent background push hung:** GCM credential prompt invisible in background. Stopped the agent (TaskStop), pushed with the vault API token, then made auth non-interactive permanently.
- **`git credential approve` rejected input:** PowerShell piping mangled the multiline credential ("missing protocol field"). Wrote directly to `~/.git-credentials` idempotently instead.
- **PowerShell commit failed on embedded double quotes:** `"see each other"` in a commit message split into stray pathspecs (PS 5.1 native-arg quoting bug). Used `git commit -F <msgfile>` / avoided embedded quotes.
- **`vault get-field` returned empty:** `yaml-query.py` (run via `py`/3.14) missing PyYAML. Installed pyyaml; added a `get`+grep fallback to setup-git-auth.sh.
- **Ollama `list` empty despite 47.8 GB on disk:** tray app server hydration delay (also the app vs CLI env). Restarted; all 5 models loaded. Documented as a bootstrap Phase 8 guard.
- **GuruRMM showed 3 agents, Mike expected 2:** clarified DESKTOP-V1JT1SE is Bob's personal machine, out of Tailscale scope.
## Configuration Changes
Created:
- `mcp-servers/ticktick/requirements.txt``httpx>=0.28`, `mcp>=1.27`
- `.claude/scripts/setup-git-auth.sh` — non-interactive git auth primer
- `.claude/memory/feedback_git_noninteractive_auth.md` — feedback memory (replaced the deleted `feedback_avoid_git_for_windows.md`)
- `wiki/patterns/tailscale-client-management.md` — MSP Tailscale pattern
- `wiki/patterns/tailscale-client-enroll.ps1` — GuruRMM enrollment script
- `wiki/clients/robert-wolkin.md` — client stub
Modified:
- `.claude/agents/gitea.md` — "Non-interactive auth" guidance block
- `.claude/settings.json``env` (GIT_TERMINAL_PROMPT=0, GCM_INTERACTIVE=Never) + SessionStart hook for setup-git-auth.sh
- `.claude/bootstrap/windows-bootstrap.ps1` — Phases 3, 6, 7, 8 amended
- `.claude/machines/guru-5070.md` — Known issues (interpreter split, ollama hydration, grok PATH, git auth)
- `.claude/memory/MEMORY.md` — index pointer
- `wiki/index.md` — Patterns + Clients rows
Deleted:
- `.claude/memory/feedback_avoid_git_for_windows.md` (superseded, never committed)
Machine state (not in repo):
- Python 3.12 (`python`): installed `httpx`, `mcp`
- Python 3.14 (`py`): installed `pyyaml`, `websocket-client`
- User PATH: appended `~\.grok\bin` (also `~\.local\bin`, `%APPDATA%\npm`)
- `~/.git-credentials`: two store entries (internal + public Gitea hosts); stale `%3a3000` entry removed
- Ollama: restarted; 5 models loaded from `D:\OllamaModels`
## Credentials & Secrets
- **Gitea shared push account:** `azcomputerguru`, API token used for non-interactive push. Vault: `services/gitea.sops.yaml` field `credentials.api.api-token` (also `credentials.password`). Token cached locally in `C:\Users\guru\.git-credentials` (plaintext, local-only, scoped to `172.16.3.20:3000` + `git.azcomputerguru.com`).
- **GuruRMM API admin:** vault `infrastructure/gururmm-server.sops.yaml` fields `credentials.gururmm-api.admin-email` / `admin-password`.
- No new secrets created. Tailscale auth key for Wolkin not yet generated (pending Mike standing up the tailnet).
## Infrastructure & Servers
- **Gitea (internal):** `http://172.16.3.20:3000` (origin for claudetools push this session). Public: `https://git.azcomputerguru.com` (vault repo origin).
- **GuruRMM API:** `http://172.16.3.30:3001` (JWT auth).
- **MariaDB:** `172.16.3.30:3306` (firewall opened to 172.16.0.0/22 per incoming coord message; not exercised this session).
- **GURU-5070 interpreters:** `py` -> Python 3.14 (`C:\Users\guru\AppData\Local\Programs\Python\...`); `python` -> Python 3.12.
- **Ollama:** `OLLAMA_MODELS=D:\OllamaModels`, `OLLAMA_HOST=0.0.0.0:11434`. Models: nomic-embed-text, qwen3:8b, qwen3:14b, codestral:22b, qwen3.6:latest.
### Robert Wolkin (GuruRMM)
- Client: `Wolkin, Robert` | Site: `Main` | site_id `2bb05f85-9fc8-4a7e-a5e5-ffe0c46431ac`
- Agents (Win 11 Home 25H2 build 26200, agent v0.6.57, all online 2026-06-06):
- DESKTOP-V1JT1SE — `30f6af79-ab19-4ed3-9ebc-71b2bffc2d27` — Bob's personal machine (OUT of Tailscale scope)
- RSW-Laptop — `043fd673-35a2-4d3d-8f91-ed73ce70cc1e` — Tailscale node (connects to front)
- front — `877d311a-4b24-462c-97b1-d2a0f7730a71` — Tailscale node (file + printer host)
## Commands & Outputs
- ticktick crash: `python ticktick_mcp.py` -> `ModuleNotFoundError: No module named 'httpx'`, then `[ERROR] MCP package not installed`.
- Installs: `python -m pip install httpx`; `python -m pip install mcp`; `py -m pip install pyyaml`; `py -m pip install websocket-client`.
- Non-interactive push pattern: `$env:GIT_TERMINAL_PROMPT='0'; git -C D:\ClaudeTools push origin main` (succeeds silently; PS 5.1 wraps git stderr as NativeCommandError on success — trust `$LASTEXITCODE`).
- Prime creds: append `http://azcomputerguru:<token>@172.16.3.20:3000` to `~/.git-credentials` + repo-local `credential.helper=store`.
- Ollama fix: stop `ollama`/`ollama app`, start `ollama app.exe`, wait ~10s -> `ollama list` shows 5 models.
- GuruRMM lookup: `POST /api/auth/login` then `GET /api/agents`, filter `.client_name=="Wolkin, Robert"`.
- Firewall rule (planned for `front`): `New-NetFirewallRule -DisplayName "Tailscale SMB (files+print)" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 445 -RemoteAddress 100.64.0.0/10`.
## Pending / Incomplete Tasks
- **ticktick MCP:** restart session or `/mcp` reconnect to approve (currently "Pending approval"). Crash resolved.
- **grok:** on persistent User PATH but not callable in THIS already-running session; fine in new shells.
- **Tailscale (Wolkin):** Mike to stand up the tailnet on Robert's account, assign himself Admin, enable MagicDNS, set `tag:wolkin` ACL, generate reusable+pre-approved tagged auth key. Then: vault the key at `clients/robert-wolkin/tailscale-authkey.sops.yaml`, enroll RSW-Laptop + front via the enroll script, push post-connect SMB config.
- **OPEN ITEM (blocks printer step):** confirm whether the printer is USB-attached to `front` (Windows print share over SMB) or a separate network printer (install by IP on laptop, or subnet router on front).
- **Offered, not yet done:** stage the 4 RMM jobs (firewall + share/account on front; drive map + printer on laptop); wire enroll script into a GuruRMM script library vs ad-hoc.
- **Pre-existing WIP** (not from this session): `.claude/bootstrap/backup-to-bundle.ps1`, `restore-at-risk-work.ps1` (modified), `projects/msp-tools/guru-connect` (untracked) — swept by sync.sh `add -A`.
## Reference Information
- Commits (origin/main, pushed): `f3a175e` ticktick requirements; `9ff5a9f0` gitea agent auth docs; `162145b5` git-auth automation; `fd30af6a` bootstrap amendments + machine doc; `8d7e3805` tailscale pattern + script; `5c7e196b` wolkin stub; `32e71a13` wolkin RMM detail + scope; `f7540550` SMB files+printer pattern.
- Vault paths: `services/gitea.sops.yaml` (`credentials.api.api-token`), `infrastructure/gururmm-server.sops.yaml` (`credentials.gururmm-api.*`).
- Tailscale docs: subnet routers `tailscale.com/docs/features/subnet-routers`; kernel-vs-netstack `/docs/reference/kernel-vs-userspace-routers`; Windows MSI `/docs/install/windows/msi`; run unattended `/docs/how-to/run-unattended`; auth keys `/kb/1085/auth-keys`; firewall ports `/kb/1082/firewall-ports`; Taildrive `/docs/features/taildrive`.
- Tailscale MSI silent props: `TS_UNATTENDEDMODE=always`, `TS_NOLAUNCH`, `TS_LOGINURL`, `INSTALLDIR` (no auth-key property — use `tailscale up --authkey`).
- Wiki: `wiki/patterns/tailscale-client-management.md`, `wiki/patterns/tailscale-client-enroll.ps1`, `wiki/clients/robert-wolkin.md`.