diff --git a/.gitignore b/.gitignore index d713c1d..ddae542 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,27 @@ local .DS_Store nvim/plugin/ gh/ -.claude -.wrangler +.claude/ +.codex/ +.wrangler/ +homebrew/ +Brewfile.lock.json + +# Secrets and machine-local authentication +.env +.env.* +!.env.example +*.pem +*.key +*.p12 +*.secret +credentials.json +auth.json +token.txt +*-credentials +*credential-input* +.gitconfig.local +gitconfig.local +CLAUDE.local.md +AGENTS.override.md +.codex-global-state.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a701192 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +# Pre-commit hooks for this public dotfiles repository. +# Install once with: pre-commit install +# Run against all files with: pre-commit run --all-files +repos: + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.1 + hooks: + - id: gitleaks diff --git a/Brewfile b/Brewfile index e473bae..edb8a1f 100644 --- a/Brewfile +++ b/Brewfile @@ -13,6 +13,7 @@ brew "neovim" brew "mosh" brew "node" brew "postgresql@17" +brew "pre-commit" brew "py-spy" brew "pyenv" brew "tmux" diff --git a/README.md b/README.md index 022b899..6249b2a 100644 --- a/README.md +++ b/README.md @@ -86,3 +86,9 @@ session links. The first existing settings file is retained as ### Tmux Plugin Manager - Install Tmux Plugin Manager ([Github](https://github.com/tmux-plugins/tpm#tmux-plugin-manager)) - Install tmux packages with `prefix + I` + +## Headless Mac mini + +- [Setup and restart verification](docs/mac-mini-setup-guide.md) +- [Tailscale SSH architecture](docs/tailscale-ssh.md) +- [Personal/work GitHub authentication](docs/github-auth.md) diff --git a/agent-instructions.md b/agent-instructions.md index 08a891e..3777627 100644 --- a/agent-instructions.md +++ b/agent-instructions.md @@ -17,3 +17,39 @@ Create pull requests with the simple format Single-line commands or code within a sentence should be wrapped in backticks Multi-line commands or code should use fenced code blocks with triple backticks + +# Git Credential Safety + +GitHub HTTPS authentication may be routed by URL through machine-local credential +helpers. Use normal Git commands and let the configured helper supply credentials. + +- Never ask for, read, display, copy, log, or inspect a token or credential file. +- Never run `git credential fill`, `git credential get`, or an equivalent helper + command that returns a secret. +- Never extract a Git credential into `GH_TOKEN`, a command, a prompt, or a log. +- Never run `gh auth setup-git` or modify credential routing unless the user asks + for that exact change. +- Never put credentials or machine-local authentication files in a repository. + +Before a network Git operation, inspect the remote and selected routing without +reading a secret: + +```sh +remote="$(git remote get-url origin)" +git config --get-urlmatch credential.helper "$remote" +git config --get-urlmatch credential.username "$remote" +``` + +Stop and ask the user if the expected helper or username is missing. Do not bypass a +missing include or repair credentials autonomously. + +# Git Workflow Safety + +- Inspect `git status --short` before editing or staging. +- Stage explicit files; do not use `git add .`. +- Do not commit or push unless the user explicitly requests it. +- Fetch and inspect divergence before pushing. +- Never force-push unless the user explicitly approves it after the risk is stated. +- Treat a dry-run `non-fast-forward` rejection as a synchronization issue, not an + authentication failure. +- Verify commit name, email, and signing identity separately from PAT routing. diff --git a/docs/github-auth.md b/docs/github-auth.md new file mode 100644 index 0000000..f257b89 --- /dev/null +++ b/docs/github-auth.md @@ -0,0 +1,346 @@ +# GitHub authentication: scoped PATs and a signing-only SSH key + +This guide documents a least-privilege GitHub setup for a shared or unattended +development Mac. It intentionally contains placeholders instead of account names, +organization names, token values, key fingerprints, or machine-specific paths. + +The design separates three concerns: + +- **Repository access** uses fine-grained personal access tokens (PATs) over HTTPS. +- **Commit identity** uses an SSH key registered with GitHub as a signing key only. +- **GitHub CLI login** is managed separately by `gh` and does not select Git's PATs. + +The signing key grants no repository access. PATs are limited to one resource owner +and should expire on a deliberate rotation schedule. + +## Fine-grained PAT permissions + +For normal developer access, grant these three repository permissions: + +- `Contents: Read and write` +- `Pull requests: Read and write` +- `Metadata: Read-only` (automatically enabled) + +Add these optional permissions when the workflow needs them: + +- `Issues: Read and write` +- `Actions: Read and write` +- `Workflows: Read-only` +- `Checks: Read-only` + +Leave all other repository and organization permissions at **No access** unless a +specific workflow requires them. Prefer selected repositories over all repositories, +and use an expiration period that will actually be rotated, such as 90 days. + +A fine-grained PAT belongs to exactly one resource owner. Create separate tokens for +an organization and a personal account. Both tokens may authenticate the same +GitHub login; the separation is about which resource owner and repositories each +token can access. + +## Machine-local Git configuration + +Keep host-specific configuration in `~/.gitconfig.local` with mode `600`. The shared +dotfiles `gitconfig` includes it: + +```gitconfig +[include] + path = ~/.gitconfig.local +``` + +For an unattended Mac, keep work and personal PATs in separate private credential +files. The empty `helper` value resets the inherited keychain helper for that URL; +the following value selects the matching dedicated store: + +```gitconfig +[credential "https://github.com"] + helper = osxkeychain + +[credential "https://github.com/"] + helper = + helper = store --file ~/.config/git/work-credentials + username = -work + +[credential "https://github.com/"] + helper = + helper = store --file ~/.config/git/personal-credentials + username = -personal + +[user] + signingkey = ~/.ssh/id_ed25519.pub +``` + +| Routing dimension | Work | Personal | +|---|---|---| +| GitHub resource owner | `` | `` | +| HTTPS URL prefix | `github.com//` | `github.com//` | +| Local username label | `-work` | `-personal` | +| Private helper file | `work-credentials` | `personal-credentials` | +| Commit author identity | Configured separately | Configured separately | + +The filenames and username suffixes are examples. Resource-owner-specific names are +equally valid as long as the URL rule, helper path, and stored username agree. + +Both credentials are plaintext at rest and readable by processes running as that +macOS user (and by `root`). Protect `~/.config/git` with mode `700`, protect each +credential file with mode `600`, scope each PAT narrowly, and never sync or commit +either file. This trade-off avoids an interactive login-keychain unlock after an +unattended restart. The generic `osxkeychain` entry remains only as a fallback for +GitHub owners that have no more-specific rule. + +The resource-owner suffixes are local labels that make it obvious which credential +Git selected. GitHub authenticates the PAT rather than requiring the label to equal +the account login. + +The resource-owner path match is case-sensitive even though GitHub repository URLs +are case-insensitive. Preserve the owner's canonical capitalization in both the +credential subsection and clone URL. Otherwise Git falls back to the generic +credential rule and may prompt with the wrong username. + +This routing applies only to HTTPS remotes. Check `git remote -v` and explicitly +convert an SSH remote when PAT routing is intended: + +```sh +git remote set-url origin https://github.com//.git +``` + +Never commit `~/.gitconfig.local`, `~/.config/git/*-credentials`, a PAT, a keychain +export, or a private SSH key. + +## Store the PATs + +Create both private stores first: + +```sh +umask 077 +mkdir -p ~/.config/git +chmod 700 ~/.config/git +touch ~/.config/git/work-credentials ~/.config/git/personal-credentials +chmod 600 ~/.config/git/work-credentials ~/.config/git/personal-credentials +``` + +Invoke `credential-store` directly for each PAT. Enter the fields shown and finish +with a blank line. Pasting the token interactively keeps it out of shell history. + +Work token: + +```sh +git credential-store --file ~/.config/git/work-credentials store +``` + +```text +protocol=https +host=github.com +username=-work +password= + +``` + +Personal token: + +```sh +git credential-store --file ~/.config/git/personal-credentials store +``` + +```text +protocol=https +host=github.com +username=-personal +password= + +``` + +If a temporary plaintext input file already contains those four fields, redirect it +to the matching store instead of pasting: + +```sh +git credential-store --file ~/.config/git/work-credentials store \ + < /path/to/work-credential-input + +git credential-store --file ~/.config/git/personal-credentials store \ + < /path/to/personal-credential-input +``` + +Do not send a work token to the personal store or vice versa. After verification, +remove the temporary input files so the PAT does not remain in an unnecessary +second plaintext location. + +## Agent access boundary + +Codex, Claude Code, and other agents running as the same macOS user can technically +read these plaintext stores. Treat instruction files as guardrails, not as an +access-control boundary: + +- Agents may use normal `git` commands and let the configured helper supply a PAT. +- Agents must not read credential files or run `git credential fill`/`get`. +- Agents must not extract a Git PAT into `GH_TOKEN`, a command, a prompt, or a log. +- Agents must not run `gh auth setup-git` or modify credential routing unless the + user explicitly requests that exact change. +- A human should enter, rotate, and revoke PATs. Use a restricted OS account or a + secrets broker when instructions alone are not a sufficient security boundary. + +## Verify owner routing and access + +Inspect Git's routing without printing either secret: + +```sh +git config --get-urlmatch credential.helper \ + https://github.com// +git config --get-urlmatch credential.username \ + https://github.com// + +git config --get-urlmatch credential.helper \ + https://github.com//dotfiles +git config --get-urlmatch credential.username \ + https://github.com//dotfiles +``` + +The work URL must resolve to `work-credentials` and `-work`; the +personal URL must resolve to `personal-credentials` and +`-personal`. + +Verify each token against a private repository owned by that resource owner. A +public repository can succeed anonymously and is not a valid authentication test. + +```sh +git ls-remote https://github.com//.git HEAD +git ls-remote https://github.com//.git HEAD +``` + +For a public personal repository, a push dry-run tests the authenticated write path +without updating branches, tags, commits, or files on GitHub: + +```sh +git -C /path/to/personal-repo push --dry-run origin HEAD:main +``` + +A `non-fast-forward` dry-run rejection means authentication reached branch +validation but the local branch must be synchronized. Do not force-push merely to +make this check pass. + +Organization policy may leave a new fine-grained PAT pending until an owner approves +it. Approval and repository selection do not prove that the token has `Contents` +access. If clone or fetch returns `403`, test the permission directly: + +```sh +read -s PAT +printf '\n' +GH_TOKEN="$PAT" gh api -i repos///contents/ +unset PAT +``` + +`HTTP 403` together with `X-Accepted-GitHub-Permissions: contents=read` means the PAT +needs at least `Contents: Read-only`; this guide uses `Contents: Read and write` for +developer access. + +## Configure commit signing + +The PAT selected for a remote does not set commit authorship. Configure the author +name and a GitHub-verified email independently. For occasional exceptions, use +repository-local values: + +```sh +git config --local user.name '' +git config --local user.email '' +``` + +If work and personal repositories live under stable top-level directories, use +conditional includes in the machine-local config instead: + +```gitconfig +[includeIf "gitdir:~/src/work/"] + path = ~/.gitconfig.work + +[includeIf "gitdir:~/src/personal/"] + path = ~/.gitconfig.personal +``` + +Keep the corresponding identity files mode `600` and out of public dotfiles when +they contain real names, email addresses, or machine-specific key paths. A single +signing key may be used for both identities when it belongs to the same GitHub +account; separate keys are optional rather than required for PAT separation. + +Generate or reuse an Ed25519 key and load it into the macOS keychain-backed agent: + +```sh +ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 +ssh-add --apple-use-keychain ~/.ssh/id_ed25519 +``` + +Register the public key in GitHub under **Settings → SSH and GPG keys** as a +**Signing Key**, not an Authentication Key. Pinning `user.signingkey` prevents Git +from selecting a different key when the agent contains multiple identities. + +Verify a signed commit: + +```sh +git log -1 --format='%G? %GS' +``` + +`G` means Git considers the signature good. GitHub also requires the commit email to +be verified on the account before the web UI displays **Verified**. + +## Optional SSH-over-HTTPS health checks + +Repository access in this design uses HTTPS and PATs. If SSH health checks are still +useful on networks that block outbound port 22, route GitHub SSH through port 443: + +```sshconfig +Host github.com + HostName ssh.github.com + Port 443 + User git + AddKeysToAgent yes + UseKeychain yes + IdentityFile ~/.ssh/id_ed25519 +``` + +The corresponding `known_hosts` entry must be keyed to `[ssh.github.com]:443`. Keep +the key registered as signing-only; `ssh -T git@github.com` is then only a transport +and host-key check, not the repository authentication path. + +Do not add a global `url.insteadOf` rule that silently rewrites SSH GitHub URLs to +HTTPS. Convert repository remotes explicitly instead: + +```sh +git remote set-url origin https://github.com//.git +``` + +## GitHub CLI authentication + +`gh` authentication is separate from Git's resource-owner credential routing: + +```sh +gh auth login --hostname github.com --git-protocol https +gh auth status +gh api user --jq .login +``` + +`gh` maintains a hostname-level login and does not automatically choose +`work-credentials` or `personal-credentials` based on a repository URL. When both +PATs authenticate the same GitHub user, keep one explicit `gh` login for general CLI +use and let Git's URL rules handle repository transport. A human or approved secrets +broker may supply `GH_TOKEN` for an owner-specific `gh` operation; an agent must not +retrieve it from Git's credential store. + +Do not run `gh auth setup-git` when preserving the split per-owner credential-helper +design above, because it can replace the intended Git helper routing. A fine-grained +token can also appear to fail during a GitHub service incident; check GitHub Status +before changing a token that was configured correctly. + +## Restart verification + +After restarting the machine, verify both access and signing again: + +```sh +gh auth status +git ls-remote https://github.com//.git HEAD +git config --get-urlmatch credential.helper \ + https://github.com//dotfiles +git -C /path/to/personal-repo push --dry-run origin HEAD:main +stat -f '%Lp %Su %N' ~/.config/git/*-credentials +git log -1 --format='%G? %GS' +``` + +Credential files should report mode `600`. The personal dry-run may still report +`non-fast-forward` when the local branch is behind; that is a synchronization issue, +not a credential-store failure. diff --git a/docs/mac-mini-setup-guide.md b/docs/mac-mini-setup-guide.md new file mode 100644 index 0000000..8fed206 --- /dev/null +++ b/docs/mac-mini-setup-guide.md @@ -0,0 +1,371 @@ +# Headless Mac mini development host + +This guide provisions an Apple Silicon Mac mini as an unattended development host +for shell work, Codex, Claude Code, and ChatGPT mobile Remote mode. + +It uses one deliberate access architecture: + +- Tailscale SSH is the only SSH server. +- macOS Remote Login stays off. +- Tailscale starts as a root LaunchDaemon before user login. +- The Mini does not sleep and restarts after a power failure. +- Automatic login starts user-level GUI services after boot. +- Real hostnames, accounts, IP addresses, tokens, and key fingerprints remain + machine-local. + +See [tailscale-ssh.md](tailscale-ssh.md) for the detailed access and policy model and +[github-auth.md](github-auth.md) for GitHub credentials. + +## Availability and security trade-off + +Direct mobile control of a GUI application after a cold boot requires a logged-in +macOS user session. This setup therefore uses: + +- FileVault off +- Automatic login for the dedicated development account +- ChatGPT configured to open at login + +That maximizes unattended recovery but weakens protection against physical access. +Use it only when the Mini is kept in a trusted location. If physical security is +more important, enable FileVault and accept that someone must unlock the Mini after +a cold boot before GUI Remote mode becomes available. + +Tailscale SSH itself starts before GUI login and remains the recovery path. + +## 1. Initial physical setup + +Complete the first macOS setup with a display and keyboard. + +### Set stable names + +Use names that do not reveal a person, company, or location: + +```sh +sudo scutil --set HostName +sudo scutil --set LocalHostName +sudo scutil --set ComputerName '' +``` + +Changing names later can change the label shown by ChatGPT Remote and the name +advertised to Tailscale, so document the mapping before changing an established host. + +### Configure power + +```sh +sudo pmset -a sleep 0 +sudo pmset -a standby 0 +sudo pmset -a disksleep 0 +sudo pmset -a womp 1 +sudo pmset -a autorestart 1 +sudo pmset -a powernap 0 +sudo pmset -a networkoversleep 0 +``` + +Display sleep does not suspend the host and may remain enabled. Verify the effective +AC-power settings: + +```sh +pmset -g custom +``` + +Expected values include `sleep 0`, `standby 0`, `disksleep 0`, `womp 1`, and +`autorestart 1`. + +### Configure unattended login + +In **System Settings → Users & Groups**, enable automatic login for the dedicated +development account. Automatic login requires FileVault to be off. + +Verify: + +```sh +fdesetup status +defaults read /Library/Preferences/com.apple.loginwindow autoLoginUser +``` + +### Prefer Ethernet + +Use wired Ethernet for an always-on host. Wi-Fi works, but roaming, interference, +access-point maintenance, and DHCP transitions add avoidable disconnects. + +Check the active default interface: + +```sh +route -n get default | grep interface +``` + +A changing local address does not affect the stable Tailscale IP or MagicDNS name. + +## 2. Install platform tools + +Install Apple's Command Line Tools and verify them: + +```sh +xcode-select --install +xcode-select -p +clang --version +``` + +If the normal installer is unavailable, download the matching Command Line Tools +package from Apple's developer downloads site. Do not delete an existing toolchain +unless its installation is known to be corrupt. + +Install Homebrew and add it to the Apple Silicon login-shell path: + +```sh +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile +eval "$(/opt/homebrew/bin/brew shellenv)" +brew doctor +``` + +## 3. Configure Tailscale SSH + +Install the Homebrew Tailscale build and start it as a system service: + +```sh +brew install tailscale +sudo brew services start tailscale +sudo tailscale up --ssh --accept-routes +``` + +If the sandboxed Tailscale GUI build is installed, use its supported uninstaller +before configuring the Homebrew daemon. Do not leave two Tailscale daemons competing +for the same state. + +In the admin console: + +1. Give the device a neutral, stable name. +2. Disable key expiry for the unattended Mini. +3. Keep the SSH rule limited to approved identities and this destination. +4. Use `action: accept` when GUI agents require non-interactive SSH. +5. If Access Controls is locked as externally managed, edit the owning repository + or infrastructure configuration rather than the web editor. + +Verify the daemon and Tailscale SSH: + +```sh +tailscale status +tailscale ip -4 +tailscale debug prefs | grep -E 'RunSSH|WantRunning' +sudo launchctl print system/homebrew.mxcl.tailscale | + grep -E 'state =|pid =|runs =|last exit code' +``` + +After Tailscale SSH works, disable ordinary macOS Remote Login: + +```sh +sudo systemsetup -f -setremotelogin off +sudo systemsetup -getremotelogin +sudo lsof -nP -iTCP:22 -sTCP:LISTEN +``` + +The expected state is `Remote Login: Off` with no macOS port-22 listener. + +## 4. Configure the administration laptop + +Install and sign into Tailscale on the laptop. Add a machine-local SSH alias: + +```sshconfig +Host mini + HostName + User + ConnectTimeout 10 + ServerAliveInterval 30 + ServerAliveCountMax 3 +``` + +Protect the file and test both normal and privileged mappings: + +```sh +chmod 600 ~/.ssh/config +tailscale ping +ssh -o BatchMode=yes mini 'whoami; hostname' +ssh -o BatchMode=yes root@mini 'whoami; hostname' +``` + +Allow the `root` mapping in the Tailscale SSH policy only when it is needed for +administration. Do not add an SSH private key, PAT, password, or real infrastructure +identifier to the public dotfiles repository. + +## 5. Install development agents + +Install the required runtime and CLIs: + +```sh +brew install node python +npm install -g @openai/codex @anthropic-ai/claude-code +``` + +Verify from a login shell, because desktop SSH integrations launch the remote user's +login shell: + +```sh +zsh -lic 'command -v codex; codex --version; codex login status' +zsh -lic 'command -v claude; claude --version; claude auth status' +``` + +Complete each product's login flow on the Mini. Authentication and settings for a +standalone CLI belong to that Mini user account. + +### Codex desktop over SSH + +The Codex/ChatGPT desktop app on a laptop can add the `mini` SSH host and start a +Codex app server through the Mini's login shell. + +This path requires: + +- Laptop awake, online, and running the desktop app +- Tailscale running on the laptop and Mini +- `codex` available in the Mini's login-shell `PATH` + +Tasks execute against the Mini's files, tools, and remote environment. + +### ChatGPT mobile direct Remote host + +Install the ChatGPT desktop app on the Mini. In +**Settings → Connections → Control this Mac or PC**, enable Remote mode and pair the +phone using the same ChatGPT account and workspace. See OpenAI's +[Remote connections guide](https://learn.chatgpt.com/docs/remote-connections) for +the current product requirements. + +Add ChatGPT to **System Settings → General → Login Items → Open at Login**. A direct +mobile session then follows this path: + +```text +ChatGPT mobile → OpenAI relay → ChatGPT app on Mini → local Codex +``` + +Tailscale is not required on the phone or for this relay path. Keep Tailscale running +on the Mini for recovery and SSH administration. + +To avoid accidentally using the laptop bridge, select the Mini's connected-computer +entry on mobile. Verify the destination with: + +```sh +hostname +whoami +pwd +``` + +### Claude Desktop over SSH + +Claude Desktop on the laptop can use the same `mini` SSH alias. It deploys and runs +its own remote Claude Code CLI under the Mini user's home directory. + +This path requires the laptop and Mini to remain connected through Tailscale. The +standalone `claude` login on the Mini and the Claude Desktop account are separate +authentication contexts; do not assume one replaces the other. + +Claude Desktop may pause an idle remote session and reconnect later. An idle pause +is different from an SSH deployment failure. + +## 6. Configure GitHub + +Follow [github-auth.md](github-auth.md). Keep repository access and signing separate: + +- Fine-grained PATs over HTTPS for clone, fetch, and push +- Signing-only SSH key for commit signatures +- Separate PATs for separate resource owners +- Separate mode-`600` work and personal credential files for unattended access +- URL-owner rules that route each repository to the correct credential file +- Separate `gh` authentication from Git's credential helpers + +Test the work token against a private repository. For a public personal repository, +use `git push --dry-run`; an anonymous public read does not prove the personal PAT is +working. Configure commit name/email separately because PAT selection does not set +authorship. + +## 7. Optional terminal persistence + +Interactive commands launched directly under SSH normally end when the connection +dies. Use `tmux` for work that must survive laptop sleep or network transitions: + +```sh +brew install tmux +tmux new -s work +``` + +Detach with `Ctrl-b d` and return with: + +```sh +tmux attach -t work +``` + +Codex and Claude desktop-managed sessions have their own lifecycle and should not be +wrapped in a manually created `tmux` session unless their documentation explicitly +calls for it. + +## 8. Final restart test + +Restart through the already-verified Tailscale SSH path: + +```sh +ssh root@mini '/sbin/shutdown -r now' +``` + +After the Mini returns, verify from the laptop: + +```sh +tailscale ping +ssh mini 'uptime; whoami' +ssh mini 'zsh -lic "codex login status; claude --version; gh auth status"' +ssh mini 'stat -f "%Lp %Su %N" ~/.config/git/*-credentials' +ssh mini 'git config --get-urlmatch credential.helper https://github.com//' +ssh mini 'git config --get-urlmatch credential.helper https://github.com//dotfiles' +ssh root@mini '/usr/sbin/systemsetup -getremotelogin' +ssh root@mini 'lsof -nP -iTCP:22 -sTCP:LISTEN' +ssh root@mini \ + 'launchctl print system/homebrew.mxcl.tailscale | + grep -E "state =|pid =|runs =|last exit code"' +``` + +Expected results: + +- Tailscale answers and SSH connects without an interactive check. +- The console user is the configured automatic-login account. +- The Tailscale daemon has a new post-boot PID and no failed exit. +- macOS Remote Login is off and macOS has no port-22 listener. +- Codex, Claude, and both work/personal Git credential routes remain available. +- ChatGPT starts automatically. + +Finally, close the laptop's ChatGPT app and start a mobile task against the Mini's +connected-computer entry. Run `hostname; whoami; pwd`. This proves direct mobile +Remote mode survived the restart rather than falling back to the laptop's SSH bridge. + +A normal restart does not prove recovery from a power outage. If unattended power +recovery matters, perform one controlled power-loss test after backups are current. + +## Troubleshooting + +| Symptom | Check | +|---|---| +| `tailscale ping mini` says no such host | `mini` is only an SSH alias; use the full MagicDNS name or Tailscale IP | +| Tailscale is online but SSH times out | Check `RunSSH`, the externally managed SSH policy, and destination selectors | +| SSH asks for a Tailscale re-check | Replace `action: check` with approved `action: accept` policy where unattended access is intended | +| SSH works locally but not remotely | Confirm both devices are online in the same tailnet and use the Tailscale name/IP | +| Mobile Remote disappears after reboot | Confirm automatic login, ChatGPT Open at Login, and the paired account/workspace | +| Mobile Remote drops while the Mini stays awake | Prefer Ethernet and record the exact time for application/relay log review | +| Claude Desktop stalls while deploying | Verify plain `ssh mini`, remote internet access, disk space, and the deployed CLI path | +| `gh auth login` returns a transient 5xx | Check GitHub Status before changing a correctly scoped PAT | +| HTTPS clone returns `403` or prompts with the wrong username | Match the credential subsection to the resource owner's exact capitalization, confirm the expected helper and username with `git config --get-urlmatch`, verify repository approval, then test the PAT's Contents permission with the repository contents API | +| Git fetch succeeds but PAT may be missing | Test a private repository; public reads can succeed anonymously | +| Personal push dry-run returns `non-fast-forward` | Authentication reached branch validation; fetch and inspect divergence, preserve local changes, and synchronize without force-pushing | + +## Public-repository safety + +Safe to commit: + +- Generic commands +- Placeholder SSH config +- Permission names +- Architecture and verification procedures + +Keep out of the repository: + +- PATs, credential-store files, OAuth files, cookies, or keychain exports +- `~/.codex/auth.json` or Claude authentication state +- SSH private keys +- Real tailnet names, MagicDNS suffixes, Tailscale IPs, emails, or ACL identities +- Remote enrollment databases +- Machine-local `~/.ssh/config` and `~/.gitconfig.local` values diff --git a/docs/tailscale-ssh.md b/docs/tailscale-ssh.md new file mode 100644 index 0000000..3e00430 --- /dev/null +++ b/docs/tailscale-ssh.md @@ -0,0 +1,185 @@ +# Tailscale SSH for a headless Mac mini + +This guide describes the SSH access path used by the headless Mac mini. It is a +public-safe template: tailnet names, account emails, device names, Tailscale IPs, +MagicDNS suffixes, and ACL repository locations stay machine-local. + +## Architecture + +```text +Authorized tailnet device + → encrypted Tailscale connection + → tailscaled's built-in SSH server + → local macOS account +``` + +The Mini deliberately uses **Tailscale SSH only**: + +- macOS **Remote Login is off**. +- `com.openssh.sshd` is disabled and macOS has no port-22 listener. +- Tailscale authenticates the connecting tailnet identity. +- No inbound router port-forward is required. +- Local `authorized_keys` entries are not required for this path. + +This removes the second, password-or-key-authenticated SSH server that macOS Remote +Login would otherwise expose to the local network. + +## Boot-persistent daemon + +Install the Homebrew CLI build rather than relying on the sandboxed GUI build for +the SSH server: + +```sh +brew install tailscale +sudo brew services start tailscale +sudo tailscale up --ssh --accept-routes +``` + +The resulting system service is: + +```text +/Library/LaunchDaemons/homebrew.mxcl.tailscale.plist +``` + +It should run as root with `RunAtLoad` and `KeepAlive`, allowing Tailscale SSH to +return during boot before a user opens an application. + +Verify: + +```sh +sudo launchctl print system/homebrew.mxcl.tailscale | + grep -E 'state =|pid =|runs =|last exit code' + +tailscale status +tailscale debug prefs | grep -E 'RunSSH|WantRunning|CorpDNS|RouteAll' +``` + +Expected values include a running daemon, `RunSSH: true`, and +`WantRunning: true`. + +## Device enrollment and key expiry + +Complete the Tailscale login flow and enable MagicDNS in the tailnet. Record the +actual device name and MagicDNS name only in machine-local notes or SSH config. + +For an unattended server, disable device key expiry immediately after enrollment: + +1. Open the Tailscale admin console. +2. Open **Machines** and select the Mini. +3. Choose **Disable key expiry**. +4. Confirm the machine displays **Expiry disabled**. + +Disabling expiry improves availability but makes prompt device removal important if +the Mini is lost, retired, or compromised. + +## Access controls + +Keep the SSH rule least-privileged: + +- Source: only the owner or explicitly approved administration devices. +- Destination: only the Mini. +- Users: only the required local macOS account; allow `root` only when remote + administration genuinely needs it. +- Action: `accept` for unattended automation. An `action: check` rule can require + an interactive reauthorization that GUI agents cannot complete. + +If the admin console says the policy is externally managed, edit the repository or +infrastructure configuration that owns the policy. Do not loosen a general network +ACL to work around an SSH-rule problem. + +A schematic rule looks like this; substitute selectors appropriate for the tailnet: + +```json +{ + "ssh": [ + { + "action": "accept", + "src": [""], + "dst": [""], + "users": ["", "root"] + } + ] +} +``` + +The `root` mapping is optional. Confirm both allowed and denied identities when the +policy changes. + +## Client SSH config + +Keep real hostnames and usernames in `~/.ssh/config`, not in a public repository: + +```sshconfig +Host mini + HostName + User + ConnectTimeout 10 + ServerAliveInterval 30 + ServerAliveCountMax 3 +``` + +Then verify resolution and non-interactive access: + +```sh +tailscale ping +ssh -o BatchMode=yes mini 'whoami; hostname' +``` + +A local SSH alias such as `mini` is not automatically a Tailscale DNS name. +`tailscale ping mini` works only if `mini` is itself resolvable through DNS; otherwise +use the full MagicDNS name or Tailscale IP. + +## Disable macOS Remote Login + +After Tailscale SSH works, disable the ordinary macOS SSH server: + +```sh +sudo systemsetup -f -setremotelogin off +``` + +Verify locally or through an existing Tailscale SSH connection: + +```sh +sudo systemsetup -getremotelogin +sudo lsof -nP -iTCP:22 -sTCP:LISTEN +``` + +Expected results are `Remote Login: Off` and no macOS port-22 listener. Immediately +retest `ssh mini`; Tailscale SSH should continue to work. + +## Which devices need Tailscale? + +| Access path | Phone | Laptop | Mini | +|---|---:|---:|---:| +| SSH app directly from a phone | Required | Not involved | Required | +| Laptop SSH to Mini | Not involved | Required | Required | +| ChatGPT mobile direct Remote host | Not required | Not involved | Not required for that path | +| ChatGPT/Codex desktop SSH bridge | Not required | Required | Required | +| Claude Desktop SSH bridge | Not required | Required | Required | + +ChatGPT direct Remote uses its own relay, but Tailscale remains the recovery and +administration path for the Mini. + +## Reboot verification + +After every change to Tailscale, macOS login, or power configuration, run a real +restart test: + +```sh +ssh root@mini '/sbin/shutdown -r now' +``` + +After the Mini returns: + +```sh +tailscale ping +ssh mini 'uptime; whoami' +ssh root@mini '/usr/sbin/systemsetup -getremotelogin' +ssh root@mini 'lsof -nP -iTCP:22 -sTCP:LISTEN' +ssh root@mini \ + 'launchctl print system/homebrew.mxcl.tailscale | + grep -E "state =|pid =|runs =|last exit code"' +``` + +A normal restart verifies launch configuration. A controlled power-loss test is +still required to verify `autorestart=1`. diff --git a/gitconfig b/gitconfig index 9e772a9..8d0106d 100644 --- a/gitconfig +++ b/gitconfig @@ -20,7 +20,15 @@ # resident keys (YubiKey ed25519-sk, 1Password) where there may be no # .pub file on disk. The `key::` prefix is what git's source explicitly # looks for in defaultKeyCommand output. - defaultKeyCommand = sh -c 'printf "key::%s\n" "$(ssh-add -L | head -n1)"' + # + # Deliberately free of double quotes: git's config parser consumes `"` and + # expands `\n` in values, so a shell-quoted `"$(...)"` written here reaches + # sh UNQUOTED. That mattered — `ssh-add -L` prints "The agent has no + # identities." on *stdout* (not stderr) with an empty agent, and word + # splitting then ran `The` as a command. The grep filter keeps only real + # key lines, so an empty agent yields empty output and git fails cleanly + # with "user.signingKey needs to be set" instead of signing garbage. + defaultKeyCommand = sh -c 'ssh-add -L 2>/dev/null | grep -m1 -e ^ssh- -e ^sk- -e ^ecdsa- | sed -e s/^/key::/' [commit] gpgsign = true @@ -62,3 +70,9 @@ [pull] rebase = false + +[include] + # Machine-local overrides (credential helper, signing key). Kept out of the + # repo so per-host secrets/paths never propagate to other machines. Git + # silently ignores this if the file is absent, so it is safe to share. + path = ~/.gitconfig.local