16 KiB

name description
mac-server-setup Set up and harden a remote Mac as an always-on headless server for running openclaw agents. Use when provisioning a new Mac (Mac Mini, Mac Studio, etc.) for server duty via SSH. Covers dev environment (Homebrew, nvim, tmux, node, bun, starship, gh), nvim config, server hardening (power mgmt, firewall, consumer service cleanup, Spotlight, SMB, hostname), SSH key auth, git repos, local SSH config, Signet agent platform (install, launchd, tailnet binding), and OpenClaw agent runtime (auth profiles, gateway config). Generates an idempotent setup script on the remote machine. Triggers: "set up mac server", "harden mac", "provision remote mac", "new client server setup", "mac server hardening", "openclaw server setup".

Mac Server Setup

Provision a remote Mac as a reliable headless server with dev tools and security hardening. Outputs an idempotent bash script on the target machine.

Workflow

0. SSH MCP Server Setup

Before anything else, configure an SSH MCP server so Claude Code can execute commands on the remote Mac. Add to ~/.mcp.json on the operator's local machine:

{
  "mcpServers": {
    "ssh-<name>": {
      "command": "npm",
      "args": [
        "exec", "ssh-mcp", "--",
        "--host=<tailscale-ip-or-hostname>",
        "--port=22",
        "--user=<username>",
        "--password=<password>"
      ]
    }
  }
}

For key-based auth (after SSH hardening), replace --password with:

"--privateKeyPath", "/home/<user>/.ssh/id_ed25519"

Then enable in ~/.claude/settings.local.json:

{
  "enableAllProjectMcpServers": true
}

The ssh-mcp package is from npm (npm exec ssh-mcp). It provides exec and sudo-exec tools. Note: sudo-exec requires either passwordless sudo on the remote machine or won't work.

To enable passwordless sudo on the Mac (needed for hardening):

sudo visudo -f /etc/sudoers.d/<username>

Add: <username> ALL=(ALL) NOPASSWD: ALL

Important: the MCP server has a ~1000 char command length limit. Write long scripts in chunks using cat >> with heredocs.

1. Recon

Gather remote machine state before writing anything. Run all commands in references/recon-commands.md and report findings to inform decisions.

2. Elicit Configuration

Ask the user:

  • Hostname — what to name the machine
  • Wi-Fi — keep or disable?
  • File sharing — remove SMB or keep with auth only?
  • SSH — password-only? Set up key auth?
  • Nvim config — clone from Gitea? Custom repo URL?
  • Git repos — GitHub org/account for ~/.agents, ~/., ~/.config/nvim?
  • Extra packages — beyond standard set?

3. Generate Setup Script

A reference script is bundled at scripts/setup-and-harden.sh. Copy it to the remote machine and customize hostname, paths, and SMB share name before running. The script is idempotent — safe to re-run. Structure:

Part 1 — Dev environment (details: references/dev-setup.md)

  • Homebrew PATH in .zprofile (idempotent)
  • Packages: neovim, tmux, git, starship, gh, node (brew), bun (curl)
  • Git identity (git config --global) + gh credential helper
  • Nvim config clone + config.json + dotfile symlinks
  • Nvim plugin sync via nvim --headless "+Lazy! sync" +qa
  • Shell aliases + starship init in .zshrc (idempotent)

Part 2 — Server hardening (details: references/hardening.md)

  • FileVault: disable (blocks unattended boot)
  • Auto-login: enable for server user (kcpassword + loginwindow pref)
  • Power: no sleep, auto-restart on power loss
  • App firewall: on, allow signed, stealth mode
  • SMB: disable guest access
  • Consumer services: disable 18+ via launchctl disable gui/$UID/<label> (Siri, Photos, Games, News, Weather, Tips, Maps, Find My, Home, iTunes)
  • Hostname via scutil
  • Spotlight indexing off
  • Software auto-install deferred
  • Screen Sharing (VNC) via ARD kickstart
  • Visual effects disabled (Liquid Glass, transparency, animations)

Part 3 — Git repos

  • Initialize and push ~/.<client> (server config/scripts)
  • Push ~/.agents (signet identity) and ~/.config/nvim if upstream set
  • All repos use upstream as remote name

4. SSH Key Auth

Set up key-based SSH early to enable rsync file transfers. Can be done via MCP (no interactive step needed):

  1. Read operator's pubkey (~/.ssh/id_ed25519.pub)
  2. Via MCP exec: mkdir -p ~/.ssh && chmod 700 ~/.ssh
  3. Append pubkey to ~/.ssh/authorized_keys (chmod 600)
  4. Verify: ssh -o BatchMode=yes <host> echo ok from local

Password auth can stay enabled — key auth just needs to work so rsync is available for file transfers.

To optionally harden SSH later (disable password auth):

  1. Verify key login works first
  2. Edit /etc/ssh/sshd_config: PasswordAuthentication no, PermitRootLogin no, AcceptEnv TERM
  3. sudo launchctl kickstart -k system/com.openssh.sshd

5. Local SSH Config

On the operator's machine, add to ~/.ssh/config:

Host <alias>
    HostName <ip>
    User <user>
    SetEnv TERM=xterm-256color

The SetEnv TERM fixes kitty terminal + tmux over SSH.

6. Client Documentation

Write a README.md and CHANGELOG.md in the client directory (~/.<client>/). These are for the nontechnical client — keep language plain, explain the why not the how, and avoid exposing implementation details. Write locally, rsync over:

rsync -av /tmp/readme.md <host>:~/.<client>/README.md

README covers: what the server is, current state, what's next, who to contact. CHANGELOG is a dated record in plain language of each setup session.

7. Signet Agent Platform

Signet is a portable agent identity system — persistent memory, secrets vault, installable skills, and a web dashboard. The npm package is signetai. Full install guide: https://signetai.sh/skill.md

What Signet provides:

  • Background daemon (port 3850) with memory database, context injection, and session extraction pipeline
  • Web dashboard for browsing memories, config, secrets, and status
  • Encrypted secrets vault at ~/.agents/.secrets/
  • Built-in skills: /remember, /recall, /memory-debug
  • Platform connectors for Claude Code, OpenClaw, and OpenCode

All agent data lives at ~/.agents/:

  • agent.yaml — config manifest
  • AGENTS.md — operational instructions
  • SOUL.md, IDENTITY.md, USER.md — identity files
  • MEMORY.md — auto-generated working memory summary
  • memory/memories.db — SQLite database (source of truth)
  • skills/ — installed skills
  • .secrets/ — encrypted secret store

Install steps:

  1. Prerequisites: Node.js >= 18 or Bun. If neither exists, install bun: curl -fsSL https://bun.sh/install | bash
  2. Install signetai globally (only two supported methods):
    • Bun (preferred): bun add -g signetai
    • npm: npm install -g signetai
    • Never use sudo. Never clone the repo. Never use npx signet init.
  3. Install node runtime: The bin shim uses #!/usr/bin/env node, so node must be available even if bun is the primary runtime. If brew isn't installed, download the node binary directly:
    curl -fsSL https://nodejs.org/dist/v22.14.0/node-v22.14.0-darwin-arm64.tar.xz -o /tmp/node.tar.xz
    tar xf /tmp/node.tar.xz -C /tmp/
    cp /tmp/node-v22.14.0-darwin-arm64/bin/node ~/.local/bin/node
    
  4. Verify install: signet --version must succeed before proceeding.
  5. Run setup wizard: signet (no args) on first run launches the interactive wizard. It handles connectors, hooks, file generation, and skill deployment. Do NOT try to set these up manually.
  6. Bind to tailnet: By default the daemon binds to localhost. Set SIGNET_HOST=0.0.0.0 in .zshrc so the dashboard is accessible across the tailnet.
  7. Create launchd plist at ~/Library/LaunchAgents/ai.signet.daemon.plist:
    • ProgramArguments: ~/.bun/bin/bun + ~/.bun/install/global/node_modules/signetai/dist/daemon.js
    • EnvironmentVariables: SIGNET_PORT=3850, SIGNET_HOST=0.0.0.0, SIGNET_PATH=~/.agents, PATH including ~/.bun/bin and ~/.local/bin
    • RunAtLoad: true, KeepAlive: true
    • Logs to ~/.agents/logs/daemon.{out,err}.log
  8. Load: launchctl load ~/Library/LaunchAgents/ai.signet.daemon.plist
  9. Verify: lsof -i :3850 -P should show TCP *:3850 (LISTEN), and curl http://<tailscale-ip>:3850/api/status from operator machine.

Important — what the daemon does automatically (do NOT replicate):

  • Extracts memories from session transcripts via LLM pipeline
  • Injects relevant context into every prompt via semantic search
  • Watches ~/.agents/ for changes and syncs to harness configs
  • Do NOT manually write to ~/.agents/memory/, call recall before every response, or manually summarize conversations

Troubleshooting:

  • Daemon won't start: signet daemon logs, lsof -i :3850
  • No memories: daemon may still be processing — extraction is async
  • Embeddings: Ollama is optional, falls back to keyword search (FTS5)
  • Skills not found: signet sync reinstalls built-in templates

Note: with KeepAlive enabled, signet stop won't work — launchd respawns the process. Use launchctl unload to fully stop.

8. OpenClaw Agent Runtime

Install and configure OpenClaw for agent operation:

  1. Install: OpenClaw is typically installed via Homebrew (/opt/homebrew/bin/openclaw).
  2. Configure: Run openclaw configure for interactive setup, or edit ~/.openclaw/openclaw.json directly.
  3. Auth setup (non-interactive): The openclaw models auth paste-token command uses interactive prompts that don't work through MCP. Instead, write files directly:
    • Write ~/.openclaw/agents/main/agent/auth-profiles.json:
      {
        "version": 1,
        "profiles": {
          "anthropic:manual": {
            "type": "token",
            "provider": "anthropic",
            "token": "<oauth-token>"
          }
        }
      }
      
    • Add auth profile to ~/.openclaw/openclaw.json under auth.profiles:
      "auth": {
        "profiles": {
          "anthropic:manual": {
            "provider": "anthropic",
            "mode": "token"
          }
        }
      }
      
    • Verify: openclaw models status should show the profile.
  4. Gateway: OpenClaw manages its own LaunchAgent (ai.openclaw.gateway). Restart with openclaw gateway restart. Check health with openclaw health.
  5. Talk to it: openclaw agent --agent main --session-id <name> --message "hello"

9. Verify

See references/verification.md for the full checklist.

Tips

  • tmux visibility: If the user has a tmux session open on the remote Mac, send commands to it via tmux send-keys so they can watch progress in real time. Prefix with brew shellenv since MCP runs a non-login shell:
    eval "$(/opt/homebrew/bin/brew shellenv)" && tmux send-keys -t 0 '<command>' Enter
    
    This gives the user live visibility into what you're doing on their machine. Use it for key moments (script execution, service restarts, verification commands) rather than every single command.

Key Constraints

  • SSH MCP servers typically can't sudo — generate script, user runs it
  • launchctl disable gui/$UID/<label> is SIP-safe and persists reboots
  • Never disable SIP
  • FileVault must be OFF for headless servers — see Gotchas
  • Keep software update auto-check, just defer auto-install
  • Add set -ga terminal-overrides ",*:Tc,*:kbs=\177" to tmux.conf for backspace fix over SSH
  • MCP command length limit (~1000 chars) — for short content, write in chunks using cat >> with heredocs. For larger files (README, docs, configs), write locally and rsync over SSH instead.
  • SSH key auth first: Set up SSH key auth early (before disabling password auth) so rsync works from the operator's machine. Add the operator's pubkey to ~/.ssh/authorized_keys via MCP exec, then verify with ssh -o BatchMode=yes <host> echo ok. This unlocks rsync for file transfer, which is vastly better than chunked heredocs through MCP.

Gotchas (learned the hard way)

  • Non-login shell PATH: SSH MCP runs a non-login shell, so /opt/homebrew/bin is not on PATH. Always prefix commands with eval "$(/opt/homebrew/bin/brew shellenv)" when running brew-installed tools (tmux, gh, starship, etc.) via MCP.
  • Unicode curly quotes in share names: macOS uses ' (U+2019) not ' in default share names like "Mac's Public Folder". Never hardcode share names — parse dynamically from sharing -l output.
  • sharing -r quoting: Even with correct quotes, MCP command piping can mangle special characters. Safest approach: sharing -l | grep "^name:" | sed 's/name:[[:space:]]*//' | while read -r name; do sudo sharing -r "$name"; done
  • VNC -specifiedUsers breaks naprivs: Always use -allowAccessFor -allUsers with ARD kickstart. -specifiedUsers sets naprivs to -2147483648, causing auth failures that are hard to debug. Must also set VNC legacy mode with explicit password.
  • launchctl print-disabled output: Services show as "label" => disabled, NOT true. Use grep -c disabled to count, not grep -c true.
  • tmux send-keys quoting: When piping commands through tmux send-keys, apostrophes and special chars in arguments need careful escaping. Prefer simple commands or use MCP exec directly for complex operations.
  • HTTPS git push on headless Mac: Fails with "could not read Username: Device not configured". Fix: gh auth login then gh auth setup-git to install the credential helper.
  • Heredoc descriptions leak into content: When using cat >> file << 'DELIM' through SSH MCP, the tool's description parameter text can get appended to the delimiter line (e.g. DELIM # Write part 1), corrupting the file. Use python3 -c with string concatenation instead for multi-part file writes — it's immune to this issue.
  • bun global binaries need PATH in non-login shells: SSH MCP doesn't source .zshrc, so ~/.bun/bin isn't on PATH. Always export it: export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$HOME/.local/bin:$PATH"
  • signet bin shim needs node: Even though signetai runs on bun, the npm bin shim (bin/signet.js) has #!/usr/bin/env node. Install node alongside bun or the CLI won't start. A bare node binary in ~/.local/bin is sufficient.
  • Tailscale CLI vs app: On macOS, Tailscale.app installs but the tailscale CLI may not be in PATH. The binary lives at /Applications/Tailscale.app/Contents/MacOS/Tailscale. tailscale status works from there without needing brew or PATH changes.
  • OpenClaw interactive commands through MCP: Commands like openclaw models auth paste-token and openclaw configure use @clack/prompts which require a TTY. Piping stdin doesn't bypass the prompts cleanly. Write config files directly instead.
  • signet start hangs MCP: signet start blocks until the daemon is fully running, which can exceed the MCP SSH timeout. Background it with & disown or just let it timeout — check signet status after to confirm it started.
  • FileVault blocks unattended boot: FileVault disk encryption requires a password at the pre-boot screen BEFORE macOS loads. With FileVault on, the machine will sit at the unlock screen indefinitely after any reboot (power loss, kernel panic, update). No software fix exists — the OS isn't running yet. Disable FileVault on headless servers: sudo fdesetup disable (pass credentials via -inputplist for non-interactive use through MCP). Decryption runs in the background and the machine stays usable.
  • Auto-login requires kcpassword: Setting autoLoginUser in loginwindow prefs is not enough — macOS also needs /etc/kcpassword with the XOR-obfuscated password (key: 7d 89 52 23 d2 bc dd ea a3 b9 1f). Use python3 -c to generate it. File must be mode 600, owned by root. Auto-login cannot work while FileVault is enabled.
  • fdesetup disable needs non-interactive auth: fdesetup disable prompts for username/password interactively. Through MCP, pipe a plist via -inputplist: printf '<plist>...<key>Username</key><string>USER</string><key>Password</key><string>PASS</string>...</plist>' | sudo fdesetup disable -inputplist