Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
SCRIPTS = preflight.sh provision.sh setup-user.sh sync-code.sh destroy.sh \
files/remote-setup.sh files/claude-notify.tmpl files/codex-notify.tmpl
files/remote-setup.sh files/claude-notify.tmpl files/codex-notify.tmpl \
files/kimi-notify.tmpl

.PHONY: check residue preflight provision setup sync all destroy

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ manage, wired for Claude Code — and rebuild the whole thing from this repo in

- **Tailnet-only.** Zero public TCP ports. Access is Tailscale SSH: your tailnet identity is the credential.
- **Hardened and self-maintaining.** UFW default-deny, key-only sshd, automatic security patches with a nightly reboot window.
- **Ready for development.** fish + starship, persistent tmux, Node/pnpm, Docker, Claude Code with configurable skills and phone notifications (and OpenAI Codex CLI, optional).
- **Ready for development.** fish + starship, persistent tmux, Node/pnpm, Docker, Claude Code with configurable skills and phone notifications (and OpenAI Codex CLI + Moonshot Kimi Code CLI, both optional — all three notify the same presence-aware way).
- **Instant previews.** `http://devbox:<port>` reaches any dev server or container on the box — even one bound to localhost.

```mermaid
Expand Down Expand Up @@ -73,6 +73,7 @@ Three logins happen in your browser and can't be scripted. Do them once per box:
| `gh auth login` | on devbox | GitHub device flow — gives the box its own revocable token |
| `claude` → login | on devbox | Claude subscription OAuth |
| `codex login` → login | on devbox | OpenAI ChatGPT OAuth — only if `INSTALL_CODEX=1` |
| `kimi login` | on devbox | Kimi device-code flow (prints a URL + code) — only if `INSTALL_KIMI=1`. Re-run `make setup` afterward to wire its notifications |
| Disable key expiry | [Tailscale admin](https://login.tailscale.com/admin/machines) → devbox → ⋯ | Keeps the node key (and thus SSH) from expiring in ~180 days |

Set up the Pushover app and account to receive notifications (keys go in `secrets.env`),
Expand Down Expand Up @@ -116,6 +117,7 @@ which fails on a headless box. Two ways to handle it, easiest first:
*not* ping on every completed turn (that floods during autonomous multi-step work), and stays
quiet while you're active in tmux. Codex (when `INSTALL_CODEX=1`) pushes the same way when it
hands a turn back — it exposes only a turn-complete event, but that's its genuine "over to you" moment.
Kimi (when `INSTALL_KIMI=1`) pushes on its `Stop` hook — the same hand-back moment — plus `StopFailure`.

## Rebuild and teardown

Expand Down
27 changes: 27 additions & 0 deletions docs/FOOTGUNS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ broke something in practice; don't re-learn them.
- **pnpm 10 blocks dependency build scripts by default** (`pnpm approve-builds`) —
a repo may need approval before native modules work.

## Kimi Code CLI (optional)

- **Two install mirrors, and the domain silently picks your login region.**
`code.kimi.com` stamps region `mainland-cn` (login OAuth host → China);
`code.kimi.ai` stamps `global`. An international subscription installed from
`.com` fails auth against the wrong host. We install from `code.kimi.ai`.
- **`config.toml` is generated by `kimi login`, and holds `default_model` +
providers.** Two consequences: (1) notification `[[hooks]]` can only be injected
*after* the one-time login, so `make setup` wires them idempotently and is meant
to be re-run post-`kimi login`; (2) never overwrite the file to add hooks —
*append* — or you drop the model config and every run dies with "No model
configured." (Learned the hard way replacing it during testing.)
- **The installer edits your shell rc to add `~/.kimi-code/bin` to PATH.** That
fights `config.fish` (which we re-copy on every setup), so we install with
`KIMI_NO_MODIFY_PATH=1` and let `config.fish` own the PATH entry — otherwise a
setup re-run drops `kimi` from PATH while the install guard skips reinstalling.
- **`Stop` is the "over to you" signal; there is no idle/needs-input event.**
The Stop payload carries only `hook_event_name`/`session_id`/`cwd` (no message).
`[[hooks]]` has no `async` field (unlike Claude's settings), so `kimi-notify`
backgrounds the curl (`setsid`) to avoid delaying every turn hand-back.

## cloud-init (learned auditing this repo)

- **`chsh` in runcmd fails for a `lock_passwd: true` user** — PAM rejects a
Expand Down Expand Up @@ -145,6 +166,12 @@ broke something in practice; don't re-learn them.
## Hetzner

- **Servers bill while powered off** — delete (after snapshot) to stop paying.
- **The 40 GB disk fills quietly, and nothing warns you** (the health timer was
removed — PR #13). A full root FS makes everything fail obscurely: builds,
`git`, and CLI installers die with write errors (curl exit 23), not "disk
full." Usual hogs on a dev box: unused Docker images/volumes (`docker system
df`; `docker system prune -a --volumes`), stale `/tmp` test dirs, and the
pnpm/npm caches. `df -h /` is the first thing to check when tools act broken.
- **Rescue mode is the break-glass**, but note: provisioned this way the box has
no usable root password (Hetzner returns one in the create response; the
script discards it and it's born expired). Console access requires a Hetzner
Expand Down
3 changes: 3 additions & 0 deletions files/config.fish
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
set -g fish_greeting ""
fish_add_path $HOME/.local/bin
# Kimi Code CLI installs here; add it only when present (see remote-setup.sh,
# which installs with KIMI_NO_MODIFY_PATH=1 so this file is the single owner).
test -d $HOME/.kimi-code/bin; and fish_add_path $HOME/.kimi-code/bin

if status is-interactive
starship init fish | source
Expand Down
56 changes: 56 additions & 0 deletions files/kimi-notify.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Kimi Code CLI hook -> Pushover push notifications. Hook JSON on stdin.
# Wired via [[hooks]] in ~/.kimi-code/config.toml for the Stop and StopFailure
# events (see remote-setup.sh). Stop fires ONCE when Kimi hands the turn back to
# you — the genuine "over to you" moment: Kimi has no idle/needs-input event, and
# Stop fires per hand-back, not per intermediate tool call (confirmed by
# capturing a real payload, which carries only hook_event_name/session_id/cwd).
# StopFailure fires when the turn dies on an error. Nothing per-tool is hooked,
# so normal use won't flood — an autonomous multi-turn run pings per hand-back
# (narrow to PermissionRequest if that ever gets noisy).
# The push is backgrounded (setsid): Kimi awaits the hook and has no async flag,
# so returning immediately keeps turn hand-back snappy. Presence-suppressed while
# you're active in tmux, exactly like the Claude/Codex hooks.
# Rendered from kimi-notify.tmpl by setup-user.sh (placeholders from secrets.env).
PO_TOKEN="__PUSHOVER_TOKEN__"
PO_USER="__PUSHOVER_USER__"
PRESENCE_WINDOW=60

payload=$(cat 2>/dev/null)
[ -z "$payload" ] && exit 0
event=$(jq -r '.hook_event_name // empty' <<<"$payload" 2>/dev/null)
proj=$(basename "$(jq -r '.cwd // "?"' <<<"$payload" 2>/dev/null)")

case "$event" in
Stop)
title="Kimi · $proj"
body="Turn complete — over to you"
sound=intermission ;;
StopFailure)
title="Kimi turn FAILED · $proj"
body="The turn ended on an error. Check the session."
sound=siren ;;
*) exit 0 ;;
esac

# Presence check: if a tmux client on this session saw input in the last
# $PRESENCE_WINDOW seconds, you're at the terminal — stay quiet.
if [ -n "$TMUX" ] && command -v tmux >/dev/null 2>&1; then
sess=$(tmux display-message -p '#{session_name}' 2>/dev/null)
last=$(tmux list-clients -t "$sess" -F '#{client_activity}' 2>/dev/null | sort -rn | head -1)
if [ -n "$last" ] && [ $(( $(date +%s) - last )) -lt "$PRESENCE_WINDOW" ]; then
exit 0
fi
fi

# The split literal keeps the render sed from rewriting this guard: a naive
# "__PUSHOVER_TOKEN__" here would be replaced by the real token, making the
# comparison always-false and silently killing every notification.
if [ -n "$PO_TOKEN" ] && [ "$PO_TOKEN" != "__PUSHOVER""_TOKEN__" ]; then
setsid curl -s -o /dev/null --max-time 5 \
--form-string "token=$PO_TOKEN" --form-string "user=$PO_USER" \
--form-string "title=$title" --form-string "message=$body" \
--form-string "priority=1" --form-string "sound=$sound" \
https://api.pushover.net/1/messages.json </dev/null >/dev/null 2>&1 &
fi
exit 0
36 changes: 36 additions & 0 deletions files/remote-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,42 @@ if [ "$(cat "$S/install-codex" 2>/dev/null)" = 1 ]; then
fi
fi

# Optional: Moonshot Kimi Code CLI (K3). Node-independent installer from the
# GLOBAL mirror (code.kimi.ai => region "global"; code.kimi.com is mainland-CN
# and would point login at the wrong OAuth host). Auth is a one-time `kimi login`
# (RFC 8628 device-code flow — headless-friendly, no localhost callback port).
if [ "$(cat "$S/install-kimi" 2>/dev/null)" = 1 ]; then
echo "== kimi code cli (optional) =="
# KIMI_NO_MODIFY_PATH: config.fish owns PATH (it adds ~/.kimi-code/bin when
# present), so the installer must not append its own line — a config.fish
# re-copy on the next setup would drop it while the install guard skips a
# reinstall, silently losing `kimi` from PATH.
[ -x ~/.kimi-code/bin/kimi ] \
|| curl -fsSL https://code.kimi.ai/kimi-code/install.sh | KIMI_NO_MODIFY_PATH=1 bash >/dev/null 2>&1 \
|| echo "WARN: kimi install failed"
install -m 700 "$S/kimi-notify" ~/.local/bin/kimi-notify
# Phone notifications: Stop (turn hand-back) + StopFailure -> Pushover, the same
# pipe as Claude/Codex. ~/.kimi-code/config.toml is written by `kimi login`, so
# it may not exist until that one-time auth — inject only when it's present and
# not already wired (idempotent; overwriting it would drop default_model +
# providers => "No model configured"). Re-run `make setup` after `kimi login`.
if [ -f ~/.kimi-code/config.toml ] && ! grep -q 'kimi-notify' ~/.kimi-code/config.toml; then
cat >> ~/.kimi-code/config.toml <<EOF

# Phone notifications (managed by devbox setup; do not duplicate).
[[hooks]]
event = "Stop"
command = "$HOME/.local/bin/kimi-notify"

[[hooks]]
event = "StopFailure"
command = "$HOME/.local/bin/kimi-notify"
EOF
elif [ ! -f ~/.kimi-code/config.toml ]; then
echo " note: run \`kimi login\`, then re-run make setup to wire notifications"
fi
fi

echo "== git identity =="
# Identity arrives as files (see setup-user.sh) so no quoting layer ever parses it.
GIT_NAME=$(cat "$S/git-name" 2>/dev/null || true)
Expand Down
5 changes: 5 additions & 0 deletions secrets.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ CLAUDE_SKILLS=
# one-time `codex login` on the box (see README).
INSTALL_CODEX=

# Also install Moonshot's Kimi Code CLI (K3) alongside Claude Code (optional; off
# by default). Set to 1 to install it (global mirror). Needs a Kimi subscription;
# auth is a one-time `kimi login` device-code flow on the box (see README).
INSTALL_KIMI=

# Regex of ~/Code paths sync-code.sh should skip (optional).
# SYNC_EXCLUDE_RE="third-party-audits|scratch"
SYNC_EXCLUDE_RE=
6 changes: 5 additions & 1 deletion setup-user.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,17 @@ sed -e "s|__PUSHOVER_TOKEN__|${PUSHOVER_TOKEN:-}|g" \
sed -e "s|__PUSHOVER_TOKEN__|${PUSHOVER_TOKEN:-}|g" \
-e "s|__PUSHOVER_USER__|${PUSHOVER_USER:-}|g" \
files/codex-notify.tmpl > "$staging/codex-notify"
sed -e "s|__PUSHOVER_TOKEN__|${PUSHOVER_TOKEN:-}|g" \
-e "s|__PUSHOVER_USER__|${PUSHOVER_USER:-}|g" \
files/kimi-notify.tmpl > "$staging/kimi-notify"
sed -e "s|__DEVBOX_NAME__|$DEVBOX_NAME|g" files/vite-hosts.fish > "$staging/vite-hosts.fish"
sed -e "s|__DEV_USER__|$DEV_USER|g" files/claude-settings.json > "$staging/claude-settings.json"
printf '%s' "${GIT_NAME:-}" > "$staging/git-name"
printf '%s' "${GIT_EMAIL:-}" > "$staging/git-email"
echo "${CLAUDE_SKILLS:-}" | tr ' \t' '\n' | grep -v '^$' > "$staging/claude-skills" || true
# Normalize the codex opt-in to 0/1 for the remote script.
# Normalize the codex/kimi opt-ins to 0/1 for the remote script.
case "${INSTALL_CODEX:-0}" in 1|true|yes|on) echo 1 ;; *) echo 0 ;; esac > "$staging/install-codex"
case "${INSTALL_KIMI:-0}" in 1|true|yes|on) echo 1 ;; *) echo 0 ;; esac > "$staging/install-kimi"

# COPYFILE_DISABLE stops macOS bsdtar from embedding AppleDouble (._*) junk.
COPYFILE_DISABLE=1 tar czf - -C "$staging" . | ssh "$DEV_USER@$DEVBOX_NAME" \
Expand Down
Loading