From 233f85b1706a877692c513616588cc782a01955e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 10 Oct 2026 07:21:55 +0200 Subject: [PATCH] feat(power): keep the machine awake while Codeman runs, lid close included A laptop that suspends freezes every agent session until it wakes. The new opt-in setting (App Settings > System > Power, off by default, "Only on AC power" on by default) makes the server hold an OS sleep lock for exactly as long as it runs: - Linux: a logind block lock on handle-lid-switch:sleep:idle through systemd-inhibit. The lid needs the low-level handle-lid-switch lock (LidSwitchIgnoreInhibited=yes ignores a plain sleep lock), and polkit grants it only while someone is logged in to the desktop, so a refusal is reported as "denied" and retried every minute. - macOS: caffeinate -i -s -w . Lid close needs pmset disablesleep, which an optional root helper (scripts/keep-awake-macos.sh, a LaunchDaemon run from a root-owned copy) applies while the server keeps a fresh request file, undoing only a disablesleep it set itself. The lock dies with the server: systemd-inhibit runs cat on a stdin pipe, so any exit (SIGKILL included, and under the unit's KillMode=process) releases it; caffeinate exits with the pid it watches. The installer asks on laptops (default no, never under --yes), and `install.sh keep-awake` turns it on for an existing install. Status is at GET /api/system/keep-awake; a non-admin's value is dropped in multi-user mode. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/keep-awake.md | 5 + CLAUDE.md | 2 + docs/api-reference.md | 4 + docs/wiki/Installation.md | 6 + docs/wiki/Settings-Reference.md | 10 +- install.sh | 286 +++++++++++-- scripts/keep-awake-macos.sh | 62 +++ src/keep-awake-manager.ts | 402 +++++++++++++++++++ src/keep-awake.ts | 196 +++++++++ src/web/public/index.html | 21 + src/web/public/settings-ui.js | 55 +++ src/web/routes/system-routes.ts | 23 +- src/web/schemas.ts | 8 + src/web/server.ts | 11 + test/install-sh-keep-awake.test.ts | 207 ++++++++++ test/keep-awake-macos-helper.test.ts | 175 ++++++++ test/keep-awake-manager.test.ts | 270 +++++++++++++ test/keep-awake.test.ts | 116 ++++++ test/routes/system-routes-keep-awake.test.ts | 132 ++++++ 19 files changed, 1960 insertions(+), 31 deletions(-) create mode 100644 .changeset/keep-awake.md create mode 100755 scripts/keep-awake-macos.sh create mode 100644 src/keep-awake-manager.ts create mode 100644 src/keep-awake.ts create mode 100644 test/install-sh-keep-awake.test.ts create mode 100644 test/keep-awake-macos-helper.test.ts create mode 100644 test/keep-awake-manager.test.ts create mode 100644 test/keep-awake.test.ts create mode 100644 test/routes/system-routes-keep-awake.test.ts diff --git a/.changeset/keep-awake.md b/.changeset/keep-awake.md new file mode 100644 index 00000000..f3d29656 --- /dev/null +++ b/.changeset/keep-awake.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +Keep your laptop awake while Codeman runs. App Settings > System > Power has "Keep this computer awake" (off by default) and "Only on AC power" (on by default), with a live status line. On Linux it stops lid-close suspend while you are logged in to the desktop; on macOS it stops idle sleep, and lid-close sleep too with the optional root helper. The installer asks on laptops, and `install.sh keep-awake` turns it on for an existing install. diff --git a/CLAUDE.md b/CLAUDE.md index 15d32bfc..ed87d467 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -293,6 +293,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) +**Keep awake** (`keepAwakeEnabled`, SYNCED, default OFF; `keepAwakeAcOnly` default ON; pure `src/keep-awake.ts` + IO `src/keep-awake-manager.ts`, status `GET /api/system/keep-awake`): holds an OS sleep lock while the server runs, so a closed laptop keeps its agents working. ⚠️ Linux needs the LOW-level `handle-lid-switch` lock (logind's default `LidSwitchIgnoreInhibited=yes` ignores a `sleep` lock for the lid, and a user's own `sleep` lock never blocks that user's desktop suspending), and polkit grants it only while someone is logged in to the desktop: `denied` is a retried state, never an error. ⚠️ The lock must die with the server: `systemd-inhibit` runs `cat` on a stdin pipe (the unit's `KillMode=process` would orphan a `sleep infinity` holding it forever), macOS runs `caffeinate -w `. ⚠️ macOS lid close needs `pmset -a disablesleep`, applied by the optional root helper `scripts/keep-awake-macos.sh`, which `install.sh` copies to a ROOT-OWNED path (never run it from the user-writable install dir); it follows a heartbeat request file it ignores after 2 minutes, and undoes only a disablesleep it set. Reconcile from `merged` in `PUT /api/settings`; a non-admin's value is dropped in multi-user mode. Tests: `test/keep-awake*.test.ts`, `test/routes/system-routes-keep-awake.test.ts`, `test/install-sh-keep-awake.test.ts`. + **Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; pure single source `src/config/base-path.ts`, normalized to `''` or `/foo`): mounts Codeman under a sub-path behind a proxy that forwards the prefix unchanged. Few choke points: `stripBasePath()` in Fastify's `rewriteUrl` (routes stay prefix-agnostic; unprefixed requests still answer), one `onSend` hook rebasing `Location`, `renderIndexHtml` rewriting `` + injecting `window.__CODEMAN_BASE__`, and `CodemanBase.url()` (constants.js) for runtime URLs. ⚠️ Keep template asset refs RELATIVE, and route every root-absolute frontend URL (EventSource/WebSocket/`window.open`/src) through `CodemanBase.url()`. ⚠️ Web-tab proxy egress goes through `proxyPrefixFor(cap, basePath)`; ingress parsers stay base-agnostic. ⚠️ `--base-url` must ride `buildWebArgs` and `resolveServicePlan`. Tests: `test/base-path.test.ts`. → [architecture-invariants#reverse-proxy-base-path](docs/architecture-invariants.md#reverse-proxy-base-path) **Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments) diff --git a/docs/api-reference.md b/docs/api-reference.md index 0a785d11..fbe34863 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -860,6 +860,10 @@ Delivery goes through the same egress guard as web tabs (refused on the resolved `GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report. +## Keep awake + +`GET /api/system/keep-awake` reports the sleep lock behind the `keepAwakeEnabled` setting (switched through `PUT /api/settings`, together with `keepAwakeAcOnly`): `{ enabled, acOnly, platform: linux|macos|unsupported, state, onAc, lidHelper, detail }`. `state` is `off` \| `paused-battery` \| `starting` \| `active` \| `denied` (Linux refused the lock because no one is logged in to a desktop; retried every minute) \| `unavailable` \| `failed` (retried). `onAc` is `null` when unknown or not read; `lidHelper` is `installed` \| `missing` on macOS and `null` elsewhere. In multi-user mode a non-admin's `keepAwake*` values in `PUT /api/settings` are dropped (the rest of the save goes through). + ## Voice dictation Browser dictation transcribed through this server's Claude Code login, i.e. the diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 051116be..7a2afd5a 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -57,6 +57,11 @@ unattended. You can leave while it builds. What it asks you: 4. **Whether to run Codeman in the background.** Enter installs a systemd user service or a macOS LaunchAgent that starts on boot; answering no offers to start it in this terminal instead, or not at all. +5. **On a laptop only: keep it awake while Codeman runs?** Closing the lid suspends the + machine, and every agent session freezes until it wakes. Yes turns on App Settings > + System > **Power** (only while plugged in). On a Mac it also offers a small root helper, + because macOS sleeps on lid close whatever an app asks; it needs your admin password + once. The default is no, and `--yes` never turns it on. It ends on a screen with the URL (your tailnet, your network, or this machine), a QR code to scan with your phone, and the two commands you need to manage the service. @@ -75,6 +80,7 @@ install.sh uninstall # remove (offers to undo a rename it performed) install.sh tailscale # retrofit Tailscale access onto an existing install install.sh name [] # rename this machine on your tailnet (default codeman-) install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel +install.sh keep-awake # keep this machine awake while Codeman runs (macOS: adds the lid helper) ``` **Flags** answer the questions from the command line and pipe through `bash -s --`: diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index b3738246..b70d85ba 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -181,8 +181,14 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts `CLAUDE.md` template for new cases, default working directory, the image watcher, and Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs `codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office -tools with their versions and install hints (admin only in multi-user mode). In multi-user -mode, the **Users** administration entry is injected here. +tools with their versions and install hints (admin only in multi-user mode). The **Power** +group keeps the machine Codeman runs on awake while Codeman runs (`keepAwakeEnabled`, off by +default; **Only on AC power**, `keepAwakeAcOnly`, on by default), and shows what the lock is +doing right now. On Linux it blocks lid-close suspend while someone is logged in to the +desktop (the desktop's own Automatic Suspend timer is separate). On macOS it blocks idle +sleep, and lid-close sleep too once the root helper from `install.sh keep-awake` is +installed. Admin only in multi-user mode. In multi-user mode, the **Users** administration +entry is injected here. ## Session Options diff --git a/install.sh b/install.sh index 71a8a4b3..aee61c48 100755 --- a/install.sh +++ b/install.sh @@ -7,8 +7,9 @@ # # The flow: look at what is already on the machine, ask at most three # questions (how the dashboard is reached, optionally what to call this -# machine on your tailnet, whether to run Codeman as a service), then do all -# the work unattended and end on the URL, with a QR code for your phone. +# machine on your tailnet, whether to run Codeman as a service; on a laptop, +# a follow-up on keeping it awake), then do all the work unattended and end on +# the URL, with a QR code for your phone. # # Flags (each has an environment-variable twin, listed below): # --tailscale | --lan | --local How the dashboard is reached (question 1) @@ -48,6 +49,7 @@ # install.sh name [] - Rename this machine on your tailnet (default: codeman-) # install.sh status - Print the URLs, the QR code and how to manage the service # install.sh cloudflared - Install cloudflared for the in-app Cloudflare tunnel +# install.sh keep-awake - Keep this machine awake while Codeman runs (macOS: adds the lid helper) set -euo pipefail @@ -129,6 +131,18 @@ SUBCOMMAND_ARG="" # alone) | empty (no service manager here). LAUNCH_CHOICE="3" SERVICE_TYPE="" +# Follow-up to question 3, laptops only (choose_keep_awake): KEEP_AWAKE=1 +# writes keepAwakeEnabled into settings.json, KEEP_AWAKE_LID_HELPER=1 installs +# the macOS root helper that covers lid-close sleep. Empty = leave as is. +KEEP_AWAKE="" +KEEP_AWAKE_LID_HELPER="" +# Where laptop detection looks. Variables only so the test harness can point +# them at a fake tree. +POWER_SUPPLY_ROOT="/sys/class/power_supply" +LID_BUTTON_ROOT="/proc/acpi/button/lid" +KEEP_AWAKE_HELPER_DIR="/Library/Application Support/Codeman" +KEEP_AWAKE_HELPER_PLIST="/Library/LaunchDaemons/com.codeman.keepawake.plist" +KEEP_AWAKE_HELPER_LABEL="com.codeman.keepawake" # Everything the unattended steps print goes here; the terminal gets one line # per step and the tail of this file on failure. @@ -2705,6 +2719,200 @@ cloudflared_subcommand() { # Wait briefly for codeman-web.service to report active. A bad node path or a # busy port makes the unit crash within the first seconds (then sit in # activating/auto-restart), so a blind "started!" message would be a lie. +# ---------------------------------------------------------------------------- +# Keep awake (laptops): Codeman holds an OS sleep lock while it runs +# ---------------------------------------------------------------------------- +# The server does the work (src/keep-awake-manager.ts: systemd-inhibit on +# Linux, caffeinate on macOS); the installer only asks, writes the setting and, +# on macOS, installs the root helper for the one thing caffeinate cannot do: +# keep a closed MacBook awake (scripts/keep-awake-macos.sh). + +# True on a machine with its own battery or a lid. A peripheral's battery +# (scope=Device, e.g. a wireless mouse) does not make a desktop a laptop. +is_laptop() { + local os="$1" d t scope + if [[ "$os" == "macos" ]]; then + pmset -g batt 2>/dev/null | grep -q 'InternalBattery' + return + fi + if [[ -d "$LID_BUTTON_ROOT" ]] && [[ -n "$(ls -A "$LID_BUTTON_ROOT" 2>/dev/null)" ]]; then + return 0 + fi + for d in "$POWER_SUPPLY_ROOT"/*; do + [[ -f "$d/type" ]] || continue + t=$(cat "$d/type" 2>/dev/null || true) + [[ "$t" == "Battery" ]] || continue + scope=$(cat "$d/scope" 2>/dev/null || true) + [[ "$scope" == "Device" ]] && continue + return 0 + done + return 1 +} + +keep_awake_settings_file() { + printf '%s\n' "$HOME/.codeman/settings.json" +} + +# The server writes settings.json with JSON.stringify(…, null, 2). +keep_awake_enabled_now() { + grep -q '"keepAwakeEnabled": *true' "$(keep_awake_settings_file)" 2>/dev/null +} + +# Asked right after question 3, on laptops only ($2 = force skips that check, +# for `install.sh keep-awake`). The default is no, so --yes and headless runs +# never turn it on: keeping a machine awake is the owner's call. +choose_keep_awake() { + local os="$1" force="${2:-}" + KEEP_AWAKE="" + KEEP_AWAKE_LID_HELPER="" + if [[ "$force" != "force" ]]; then + is_laptop "$os" || return 0 + fi + if keep_awake_enabled_now; then + info "Keep-awake is already on (App Settings > System > Power)." + else + echo -e " ${DIM}Closing the lid suspends the machine, and every agent session freezes until it wakes.${NC}" >&2 + prompt_yes_no "Keep this machine awake while Codeman runs, even with the lid closed (only while plugged in)?" "n" || return 0 + KEEP_AWAKE="1" + fi + # The helper needs sudo, so it is only ever offered to a person at a terminal. + if [[ "$os" == "macos" ]] && [[ ! -f "$KEEP_AWAKE_HELPER_PLIST" ]] && + [[ "$NONINTERACTIVE" != "1" && "$ASSUME_YES" != "1" ]] && has_tty; then + echo -e " ${DIM}macOS sleeps on lid close whatever an app asks. A small root helper (pmset disablesleep) covers that, only while Codeman runs.${NC}" >&2 + if prompt_yes_no "Install the lid helper? (asks for your admin password once)" "y"; then + KEEP_AWAKE_LID_HELPER="1" + sudo_session_start + fi + fi + return 0 +} + +# Turn the setting on before the service starts, so its first boot already +# holds the lock. node does the JSON; a settings file that does not parse is +# left untouched. +write_keep_awake_setting() { + local file + file=$(keep_awake_settings_file) + mkdir -p "$(dirname "$file")" + if node -e ' + const fs = require("fs"); + const p = process.argv[1]; + let s = {}; + try { s = JSON.parse(fs.readFileSync(p, "utf8")); } + catch (e) { if (e.code !== "ENOENT") process.exit(2); } + if (s === null || typeof s !== "object" || Array.isArray(s)) process.exit(2); + s.keepAwakeEnabled = true; + if (s.keepAwakeAcOnly === undefined) s.keepAwakeAcOnly = true; + fs.writeFileSync(p, JSON.stringify(s, null, 2)); + ' "$file" System > Power)" + return 0 + fi + warn "Could not turn keep-awake on: $file is not valid JSON. Use App Settings > System > Power instead." + return 1 +} + +# macOS: copy the helper to a ROOT-OWNED path (a root daemon must never run a +# file the user can edit, and the install dir is the user's) and load it as a +# LaunchDaemon that runs every 20 seconds. It only acts while the server keeps +# a fresh request file in the data dir. +install_keep_awake_lid_helper() { + local src="$INSTALL_DIR/scripts/keep-awake-macos.sh" + local script="$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh" + local request="$HOME/.codeman/keep-awake-lid.pid" + local tmp + if [[ ! -f "$src" ]]; then + warn "Lid helper not found at $src; skipped." + return 1 + fi + if ! { run_as_root mkdir -p "$KEEP_AWAKE_HELPER_DIR" && + run_as_root chown root:wheel "$KEEP_AWAKE_HELPER_DIR" && + run_as_root chmod 755 "$KEEP_AWAKE_HELPER_DIR" && + run_as_root install -m 755 -o root -g wheel "$src" "$script"; }; then + warn "Could not install the lid helper (sudo refused?). Run later: install.sh keep-awake" + return 1 + fi + tmp=$(mktemp) + cat > "$tmp" << EOF + + + + + Label + $KEEP_AWAKE_HELPER_LABEL + ProgramArguments + + /bin/bash + $(xml_escape "$script") + $(xml_escape "$request") + + StartInterval + 20 + RunAtLoad + + + +EOF + run_as_root launchctl unload "$KEEP_AWAKE_HELPER_PLIST" 2>/dev/null || true + if ! run_as_root install -m 644 -o root -g wheel "$tmp" "$KEEP_AWAKE_HELPER_PLIST"; then + rm -f "$tmp" + warn "Could not write $KEEP_AWAKE_HELPER_PLIST. Run later: install.sh keep-awake" + return 1 + fi + rm -f "$tmp" + run_as_root launchctl load -w "$KEEP_AWAKE_HELPER_PLIST" 2>/dev/null || true + if run_as_root launchctl list "$KEEP_AWAKE_HELPER_LABEL" &>/dev/null; then + success "Lid helper installed (it only acts while Codeman asks it to)" + return 0 + fi + warn "The lid helper did not load. Inspect: sudo launchctl list $KEEP_AWAKE_HELPER_LABEL" + return 1 +} + +# Uninstall: undo a disablesleep only the helper set (its .owned marker, never +# an administrator's own setting), then remove the daemon and its files. +remove_keep_awake_lid_helper() { + [[ -f "$KEEP_AWAKE_HELPER_PLIST" || -d "$KEEP_AWAKE_HELPER_DIR" ]] || return 0 + run_as_root launchctl unload "$KEEP_AWAKE_HELPER_PLIST" 2>/dev/null || true + if [[ -f "$KEEP_AWAKE_HELPER_DIR/keep-awake.owned" ]]; then + run_as_root pmset -a disablesleep 0 2>/dev/null || true + fi + run_as_root rm -f "$KEEP_AWAKE_HELPER_PLIST" "$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh" \ + "$KEEP_AWAKE_HELPER_DIR/keep-awake.owned" 2>/dev/null || true + run_as_root rmdir "$KEEP_AWAKE_HELPER_DIR" 2>/dev/null || true + success "Removed the keep-awake lid helper" +} + +# Work-phase half of choose_keep_awake. Both steps are optional extras: a +# failure warns and the install carries on. +apply_keep_awake() { + if [[ "$KEEP_AWAKE" == "1" ]]; then + write_keep_awake_setting || true + fi + if [[ "$KEEP_AWAKE_LID_HELPER" == "1" ]]; then + install_keep_awake_lid_helper || true + fi + return 0 +} + +# `install.sh keep-awake`: turn it on for an existing install (no laptop check: +# asking for it is the answer), add the macOS lid helper, then restart the +# service so the running server picks it up. +keep_awake_subcommand() { + print_banner + command -v node &>/dev/null || die "node is required. Install Codeman first (run the installer without arguments)." + local os + os=$(detect_os) + choose_keep_awake "$os" force + if [[ -z "$KEEP_AWAKE" && -z "$KEEP_AWAKE_LID_HELPER" ]]; then + info "Nothing changed." + return 0 + fi + apply_keep_awake + echo "" + restart_running_service +} + verify_systemd_active() { local attempt for attempt in 1 2 3; do @@ -3032,6 +3240,7 @@ main() { choose_network_binding echo "" choose_launch_mode "$os" + choose_keep_awake "$os" echo "" # ======================================================================== @@ -3050,6 +3259,9 @@ main() { # (symlinks, PATH, launch menu) instead of silently "updating". date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete" + # Before the service starts, so its first boot already reads the setting. + apply_keep_awake + local service_ok="true" if [[ "$LAUNCH_CHOICE" == "2" ]]; then if [[ "$SERVICE_TYPE" == "launchd" ]]; then @@ -3493,6 +3705,36 @@ print_done_screen() { return 0 } +# Restart Codeman under whatever supervises it, so a new build or a changed +# setting takes effect; otherwise say how. Shared by update and keep-awake. +restart_running_service() { + local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist" + if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then + info "Restarting codeman-web service..." + systemctl --user restart codeman-web.service 2>/dev/null || true + if verify_systemd_active; then + success "codeman-web service restarted" + else + warn "codeman-web.service did not come back up." + warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e" + fi + elif [[ -f "$agent_plist" ]]; then + info "Restarting LaunchAgent..." + launchctl unload "$agent_plist" 2>/dev/null || true + launchctl load "$agent_plist" 2>/dev/null || true + success "LaunchAgent restarted" + elif [[ -f "/Library/LaunchDaemons/com.codeman.web.plist" ]]; then + # Left alone on purpose (see setup_launchd_service); it keeps running + # the previous build until its owner restarts it. + info "A system LaunchDaemon supervises Codeman; restart it to apply:" + echo -e " ${CYAN}sudo launchctl kickstart -k system/com.codeman.web${NC}" + else + echo -e " ${DIM}Restart codeman web to apply:${NC}" + echo -e " ${CYAN}codeman web --stop; codeman web -d${NC}" + fi + echo "" +} + update() { if [[ ! -d "$INSTALL_DIR/.git" ]]; then die "Codeman is not installed at $INSTALL_DIR. Run the installer first." @@ -3525,32 +3767,15 @@ update() { success "Updated to $(node -e "console.log(require('./package.json').version)")" echo "" - # Auto-restart service if running, otherwise tell the user - local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist" - if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then - info "Restarting codeman-web service..." - systemctl --user restart codeman-web.service 2>/dev/null || true - if verify_systemd_active; then - success "codeman-web service restarted" - else - warn "codeman-web.service did not come back up." - warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e" - fi - elif [[ -f "$agent_plist" ]]; then - info "Restarting LaunchAgent..." - launchctl unload "$agent_plist" 2>/dev/null || true - launchctl load "$agent_plist" 2>/dev/null || true - success "LaunchAgent restarted" - elif [[ -f "/Library/LaunchDaemons/com.codeman.web.plist" ]]; then - # Left alone on purpose (see setup_launchd_service); it keeps running - # the previous build until its owner restarts it. - info "A system LaunchDaemon supervises Codeman; restart it to run the new build:" - echo -e " ${CYAN}sudo launchctl kickstart -k system/com.codeman.web${NC}" - else - echo -e " ${DIM}Restart codeman web to use the new version:${NC}" - echo -e " ${CYAN}codeman web --stop; codeman web -d${NC}" + restart_running_service + + # The lid helper runs from a root-owned copy, so an update never reaches it + # on its own (and must not: that would need sudo here). Say so when it drifted. + if [[ -f "$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh" ]] && + ! cmp -s "$INSTALL_DIR/scripts/keep-awake-macos.sh" "$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh"; then + info "The keep-awake lid helper has a newer version. Refresh it with: install.sh keep-awake" + echo "" fi - echo "" # Reflect the service's actual binding in the closing notice. Updates # never rewrite the service files, so the existing choice is authoritative. @@ -3603,6 +3828,9 @@ uninstall() { rm -f "$agent_plist" success "Removed LaunchAgent" fi + if [[ "$(uname -s)" == "Darwin" ]]; then + remove_keep_awake_lid_helper + fi if [[ -f "$daemon_plist" ]]; then # This installer never writes a LaunchDaemon (setup_launchd_service # leaves one alone), so this one is the user's own headless-Mac setup: @@ -3721,6 +3949,7 @@ Subcommands name [] Rename this machine on your tailnet (default: codeman-) status Print the URLs, the QR code and how to manage the service cloudflared Install cloudflared for the in-app Cloudflare tunnel + keep-awake Keep this machine awake while Codeman runs (macOS: adds the lid helper) Environment: CODEMAN_NONINTERACTIVE=1, CODEMAN_INSTALL_DIR, CODEMAN_HOST, CODEMAN_PASSWORD, CODEMAN_PORT, CODEMAN_TAILSCALE=1, CODEMAN_TAILSCALE_NAME, @@ -3762,7 +3991,7 @@ parse_flags() { CODEMAN_PORT="$1"; export CODEMAN_PORT; RECONFIGURE="1" ;; --port=*) CODEMAN_PORT="${1#--port=}"; export CODEMAN_PORT; RECONFIGURE="1" ;; --help|-h) usage; exit 0 ;; - update|uninstall|tailscale|name|status|cloudflared) + update|uninstall|tailscale|name|status|cloudflared|keep-awake) [[ -z "$SUBCOMMAND" ]] || die "Only one subcommand at a time ($SUBCOMMAND and $1 given)." SUBCOMMAND="$1" ;; -*) die "Unknown option: $1 (see --help)" ;; @@ -3795,6 +4024,7 @@ case "$SUBCOMMAND" in name) setup_name_subcommand ;; status) status_subcommand ;; cloudflared) cloudflared_subcommand ;; + keep-awake) keep_awake_subcommand ;; *) # Only a COMPLETED install re-runs as a quiet update. A partial one # (clone succeeded but build/menu never finished) lacks the marker and diff --git a/scripts/keep-awake-macos.sh b/scripts/keep-awake-macos.sh new file mode 100755 index 00000000..cfeb9260 --- /dev/null +++ b/scripts/keep-awake-macos.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# keep-awake-macos.sh: root helper behind Codeman's "Keep this computer awake" on macOS. +# +# caffeinate (which the server runs itself, no root) stops idle sleep but not lid-close +# sleep. Only `pmset -a disablesleep 1` keeps a closed MacBook awake, and that is a +# machine-wide root setting with no owner process. So this helper, run by the +# com.codeman.keepawake LaunchDaemon every 20 seconds, applies it while the server asks +# for it and undoes it when the server stops asking: +# +# - The server writes its pid to the request file and rewrites it every 30 seconds +# while it wants the lid covered (setting on, and on AC when "only on AC" is on). +# - The request counts only while the file is fresh (under 2 minutes old) AND its pid +# is alive AND that process belongs to the file's owner. A crashed or hung server +# therefore releases the lid within about two minutes on its own. +# - The helper undoes only a disablesleep it set itself (tracked by the .owned file), +# so an administrator's own `pmset -a disablesleep 1` is never switched off. +# +# install.sh copies this file to a ROOT-OWNED path before the daemon runs it; never run +# it from the user-writable install directory. +# +# Usage: keep-awake-macos.sh +# Test hooks (set only by the test suite): CODEMAN_KEEPAWAKE_PMSET, CODEMAN_KEEPAWAKE_STATE_DIR. + +set -u + +REQ="${1:?usage: keep-awake-macos.sh }" +PMSET="${CODEMAN_KEEPAWAKE_PMSET:-/usr/bin/pmset}" +STATE_DIR="${CODEMAN_KEEPAWAKE_STATE_DIR:-/Library/Application Support/Codeman}" +OWNED="$STATE_DIR/keep-awake.owned" + +want=0 +# -L: refuse a symlink (root must not be steered to read some other file). +# find -mmin -2: modified within the last 2 minutes (BSD and GNU find both support it). +if [ -f "$REQ" ] && [ ! -L "$REQ" ] && [ -n "$(find "$REQ" -mmin -2 2>/dev/null)" ]; then + pid=$(head -c 32 "$REQ" 2>/dev/null | tr -dc '0-9') + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + file_uid=$(ls -ln "$REQ" 2>/dev/null | awk '{print $3}') + pid_uid=$(ps -o uid= -p "$pid" 2>/dev/null | tr -d ' ') + if [ -n "$file_uid" ] && [ "$file_uid" = "$pid_uid" ]; then + want=1 + fi + fi +fi + +current=$("$PMSET" -g 2>/dev/null | awk '/SleepDisabled/ {print $2; exit}') +[ -n "$current" ] || current=0 + +if [ "$want" = "1" ]; then + # Already 1 without our marker means an administrator set it: leave it and do not + # claim it, so releasing later never switches their setting off. + if [ "$current" != "1" ]; then + if "$PMSET" -a disablesleep 1; then + mkdir -p "$STATE_DIR" && : > "$OWNED" + fi + fi +elif [ -f "$OWNED" ]; then + if [ "$current" = "1" ]; then + "$PMSET" -a disablesleep 0 || exit 1 + fi + rm -f "$OWNED" +fi +exit 0 diff --git a/src/keep-awake-manager.ts b/src/keep-awake-manager.ts new file mode 100644 index 00000000..6df6ae23 --- /dev/null +++ b/src/keep-awake-manager.ts @@ -0,0 +1,402 @@ +/** + * @fileoverview Holds the OS sleep lock behind `keepAwakeEnabled` for exactly as long + * as Codeman runs. The decisions (which lock, when, what a failure means) are pure and + * live in `keep-awake.ts`; this module only does the IO and keeps the state current. + * + * Lifecycle rules, each load-bearing: + * + * - **The lock dies with the server, never after it.** Linux: `systemd-inhibit` runs a + * `cat` on a stdin pipe from this process, so any exit (SIGKILL included) closes the + * pipe and releases the lock even under the unit's `KillMode=process`. macOS: + * `caffeinate -w ` exits with the server. The macOS lid request file is only a + * heartbeat: the root helper ignores it once it is two minutes old. + * - **Reconcile from settings, never from a request body.** `apply()` takes the merged + * config and is idempotent, so the boot path and every `PUT /api/settings` call the + * same thing. + * - **A refusal is a state, not an error.** polkit denies the Linux lock until someone + * logs in to the desktop; that is reported as `denied` and retried, never thrown. + * - **Inert under vitest** unless a test injects its own deps, so the suite never takes + * a real lock on the machine running it. + */ + +import { spawn as nodeSpawn, execFile, type ChildProcess } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import { release } from 'node:os'; +import { join } from 'node:path'; +import { dataPath } from './config/instance.js'; +import { + MAC_LID_HEARTBEAT_MS, + MAC_LID_HELPER_PLIST, + MAC_LID_REQUEST_FILE, + LINUX_HELD_MARKER, + POWER_POLL_MS, + RETRY_MS, + classifyInhibitFailure, + isOnAcPowerLinux, + isOnAcPowerMac, + keepAwakePlatform, + linuxInhibitArgs, + macCaffeinateArgs, + shouldHoldLock, + type KeepAwakeConfig, + type KeepAwakePlatform, + type KeepAwakeStatus, + type PowerSupplyInfo, +} from './keep-awake.js'; + +/** Grace between closing the inhibitor's stdin and SIGTERM. */ +const RELEASE_GRACE_MS = 2_000; +/** Bound on captured stderr from the lock process. */ +const STDERR_CAP = 2_048; + +export interface KeepAwakeDeps { + platform: KeepAwakePlatform; + serverPid: number; + spawn: (command: string, args: string[]) => ChildProcess; + readPowerSupplies: () => Promise; + readMacBatt: () => Promise; + lidHelperInstalled: () => Promise; + writeLidRequest: (pid: number) => Promise; + removeLidRequest: () => Promise; +} + +const POWER_SUPPLY_DIR = '/sys/class/power_supply'; + +async function readSysValue(dir: string, name: string): Promise { + try { + return (await fs.readFile(join(dir, name), 'utf-8')).trim(); + } catch { + return undefined; + } +} + +async function readLinuxPowerSupplies(): Promise { + let names: string[]; + try { + names = await fs.readdir(POWER_SUPPLY_DIR); + } catch { + return []; + } + const out: PowerSupplyInfo[] = []; + for (const name of names) { + const dir = join(POWER_SUPPLY_DIR, name); + const type = await readSysValue(dir, 'type'); + if (!type) continue; + const online = await readSysValue(dir, 'online'); + out.push({ + type, + online: online === undefined ? undefined : online === '1', + status: await readSysValue(dir, 'status'), + scope: await readSysValue(dir, 'scope'), + }); + } + return out; +} + +function readPmsetBatt(): Promise { + return new Promise((resolve) => { + execFile('/usr/bin/pmset', ['-g', 'batt'], { timeout: 5_000 }, (err, stdout) => { + resolve(err ? null : String(stdout)); + }); + }); +} + +function defaultDeps(): KeepAwakeDeps { + const lidRequest = () => dataPath(MAC_LID_REQUEST_FILE); + return { + platform: keepAwakePlatform(process.platform, release()), + serverPid: process.pid, + spawn: (command, args) => nodeSpawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] }), + readPowerSupplies: readLinuxPowerSupplies, + readMacBatt: readPmsetBatt, + lidHelperInstalled: async () => { + try { + await fs.access(MAC_LID_HELPER_PLIST); + return true; + } catch { + return false; + } + }, + writeLidRequest: async (pid) => { + await fs.writeFile(lidRequest(), `${pid}\n`, { mode: 0o644 }); + }, + removeLidRequest: async () => { + await fs.rm(lidRequest(), { force: true }); + }, + }; +} + +export class KeepAwakeManager { + private readonly deps: KeepAwakeDeps; + private readonly inert: boolean; + private config: KeepAwakeConfig = { enabled: false, acOnly: true }; + private status: KeepAwakeStatus; + private child: ChildProcess | null = null; + /** Children we asked to exit; their exit is expected and must not trigger a retry. */ + private releasing = new WeakSet(); + private powerTimer: NodeJS.Timeout | null = null; + private retryTimer: NodeJS.Timeout | null = null; + private heartbeatTimer: NodeJS.Timeout | null = null; + /** Serializes reconciles: a settings PUT can land while a power poll is mid-read. */ + private chain: Promise = Promise.resolve(); + private stopped = false; + private lastLoggedState: KeepAwakeStatus['state'] = 'off'; + + constructor(deps?: KeepAwakeDeps) { + this.inert = !deps && !!process.env.VITEST; + this.deps = deps ?? defaultDeps(); + this.status = { + enabled: false, + acOnly: true, + platform: this.deps.platform, + state: 'off', + onAc: null, + lidHelper: null, + detail: null, + }; + } + + /** Current status (a copy). */ + getStatus(): KeepAwakeStatus { + return { ...this.status }; + } + + /** Reconcile to `config`. Idempotent; safe to call on every settings save. */ + apply(config: KeepAwakeConfig): Promise { + this.config = { ...config }; + this.stopped = false; + return this.enqueue(); + } + + /** Release everything and stop all timers (server shutdown). */ + async stop(): Promise { + this.stopped = true; + this.clearTimers(); + await this.chain.catch(() => {}); + await this.release(); + this.status = { ...this.status, state: 'off', detail: null }; + } + + private enqueue(): Promise { + const next = this.chain + .then(() => this.reconcile()) + .catch((err) => { + console.error('[keep-awake] reconcile failed:', err); + }); + this.chain = next; + return next; + } + + private async reconcile(): Promise { + if (this.stopped) return; + const { enabled, acOnly } = this.config; + this.status = { ...this.status, enabled, acOnly }; + + if (!enabled) { + this.clearTimers(); + await this.release(); + this.setState('off', null); + return; + } + if (this.inert) { + this.setState('unavailable', 'Disabled under the test runner.'); + return; + } + if (this.deps.platform === 'unsupported') { + this.setState('unavailable', 'Not supported on this system.'); + return; + } + + if (this.deps.platform === 'macos') { + this.status.lidHelper = (await this.deps.lidHelperInstalled()) ? 'installed' : 'missing'; + } + + const onAc = acOnly ? await this.readOnAc() : null; + this.status.onAc = onAc; + this.ensurePowerPoll(acOnly); + + if (!shouldHoldLock(this.config, onAc)) { + await this.release(); + this.setState('paused-battery', null); + return; + } + + // The lid helper is a separate mechanism from caffeinate: it only needs a fresh + // request file, so it runs whether or not caffeinate is currently up. + if (this.deps.platform === 'macos' && this.status.lidHelper === 'installed') this.startLidHeartbeat(); + + // A pending retry owns the next attempt; a live child is already the lock. + if (this.child || this.retryTimer) return; + if (this.status.state === 'unavailable') return; + this.acquire(); + } + + private async readOnAc(): Promise { + try { + if (this.deps.platform === 'linux') return isOnAcPowerLinux(await this.deps.readPowerSupplies()); + if (this.deps.platform === 'macos') { + const out = await this.deps.readMacBatt(); + return out === null ? null : isOnAcPowerMac(out); + } + } catch { + /* unknown */ + } + return null; + } + + private acquire(): void { + if (this.deps.platform === 'linux') this.acquireLinux(); + else if (this.deps.platform === 'macos') this.acquireMac(); + } + + private acquireLinux(): void { + let child: ChildProcess; + try { + child = this.deps.spawn('systemd-inhibit', linuxInhibitArgs()); + } catch (err) { + this.onSpawnError(err as NodeJS.ErrnoException); + return; + } + this.child = child; + this.setState('starting', null); + // Closing the pipe to an already-dead process emits EPIPE on the stream; unhandled, + // that would take the whole server down. + child.stdin?.on('error', () => {}); + let stderr = ''; + child.stdout?.on('data', (chunk: Buffer) => { + if (this.child === child && String(chunk).includes(LINUX_HELD_MARKER)) this.setState('active', null); + }); + child.stderr?.on('data', (chunk: Buffer) => { + if (stderr.length < STDERR_CAP) stderr += String(chunk); + }); + child.on('error', (err: NodeJS.ErrnoException) => { + if (this.child !== child) return; + this.child = null; + this.onSpawnError(err); + }); + child.on('exit', () => { + if (this.releasing.has(child) || this.child !== child) return; + this.child = null; + const { state, detail } = classifyInhibitFailure(stderr); + this.setState(state, detail); + if (state !== 'unavailable') this.scheduleRetry(); + }); + } + + private acquireMac(): void { + let child: ChildProcess; + try { + child = this.deps.spawn('/usr/bin/caffeinate', macCaffeinateArgs(this.deps.serverPid)); + } catch (err) { + this.onSpawnError(err as NodeJS.ErrnoException); + return; + } + this.child = child; + this.setState('starting', null); + child.on('spawn', () => { + if (this.child === child) this.setState('active', null); + }); + child.stdin?.on('error', () => {}); + child.on('error', (err: NodeJS.ErrnoException) => { + if (this.child !== child) return; + this.child = null; + this.onSpawnError(err); + }); + child.on('exit', (code, signal) => { + if (this.releasing.has(child) || this.child !== child) return; + this.child = null; + this.setState('failed', `caffeinate exited (${signal ?? `code ${code}`})`); + this.scheduleRetry(); + }); + } + + private onSpawnError(err: NodeJS.ErrnoException): void { + if (err.code === 'ENOENT') { + const tool = this.deps.platform === 'macos' ? 'caffeinate' : 'systemd-inhibit'; + this.setState('unavailable', `${tool} is not installed on this machine.`); + return; + } + this.setState('failed', err.message); + this.scheduleRetry(); + } + + private startLidHeartbeat(): void { + if (this.heartbeatTimer) return; + const beat = () => { + void this.deps.writeLidRequest(this.deps.serverPid).catch((err) => { + console.warn('[keep-awake] could not write the lid request file:', err); + }); + }; + beat(); + this.heartbeatTimer = setInterval(beat, MAC_LID_HEARTBEAT_MS); + this.heartbeatTimer.unref?.(); + } + + private async release(): Promise { + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + if (this.deps.platform === 'macos' && !this.inert) { + await this.deps.removeLidRequest().catch(() => {}); + } + const child = this.child; + if (!child) return; + this.child = null; + this.releasing.add(child); + // Closing stdin ends `cat` (Linux), which ends systemd-inhibit and drops the lock. + child.stdin?.end(); + if (this.deps.platform === 'macos') { + child.kill('SIGTERM'); + return; + } + const timer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); + }, RELEASE_GRACE_MS); + timer.unref?.(); + child.once('exit', () => clearTimeout(timer)); + } + + private scheduleRetry(): void { + if (this.retryTimer || this.stopped) return; + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + void this.enqueue(); + }, RETRY_MS); + this.retryTimer.unref?.(); + } + + private ensurePowerPoll(acOnly: boolean): void { + if (!acOnly) { + if (this.powerTimer) clearInterval(this.powerTimer); + this.powerTimer = null; + return; + } + if (this.powerTimer) return; + this.powerTimer = setInterval(() => void this.enqueue(), POWER_POLL_MS); + this.powerTimer.unref?.(); + } + + private clearTimers(): void { + for (const t of [this.powerTimer, this.heartbeatTimer]) if (t) clearInterval(t); + if (this.retryTimer) clearTimeout(this.retryTimer); + this.powerTimer = this.heartbeatTimer = this.retryTimer = null; + } + + private setState(state: KeepAwakeStatus['state'], detail: string | null): void { + // Log settled transitions only: a denied lock is retried every minute and would + // otherwise log `starting` + `denied` forever on a box nobody logs in to. + if (state !== 'starting' && state !== this.lastLoggedState) { + this.lastLoggedState = state; + console.log(`[keep-awake] ${state}${detail ? `: ${detail}` : ''}`); + } + this.status = { ...this.status, state, detail }; + } +} + +/** The process-wide manager. */ +export const keepAwake = new KeepAwakeManager(); diff --git a/src/keep-awake.ts b/src/keep-awake.ts new file mode 100644 index 00000000..7cf8d513 --- /dev/null +++ b/src/keep-awake.ts @@ -0,0 +1,196 @@ +/** + * @fileoverview Pure decisions behind "Keep this computer awake while Codeman runs" + * (`keepAwakeEnabled`, SYNCED, default OFF; `keepAwakeAcOnly`, default ON). + * + * A laptop that suspends freezes every agent session: nothing runs while the lid is + * closed, the phone loses its tailnet route to the dashboard, and in-flight API + * requests and ssh links usually break. The IO side (`keep-awake-manager.ts`) holds an + * OS-level sleep lock for exactly as long as Codeman runs; everything it has to decide + * lives here so the tests exercise the shipped logic. + * + * What each platform's lock can and cannot do, measured rather than assumed: + * + * - **Linux (systemd-logind).** Lid-close suspend obeys only the LOW-level + * `handle-lid-switch` lock: logind's default `LidSwitchIgnoreInhibited=yes` makes it + * ignore a plain `sleep` lock for the lid. A `sleep` lock also never blocks a suspend + * requested by the SAME user (logind skips inhibitors whose uid matches the caller), + * so the desktop's own Automatic Suspend timer keeps working; it is the user's setting + * to change. polkit grants `handle-lid-switch` and `sleep` only to a process in an + * active login session, and a systemd user service is not in one: polkit then falls + * back to the user's display session, so the lock is granted while someone is logged + * in to the desktop and DENIED at a login screen (or on a headless box). The manager + * retries a denial, so logging in later picks it up. + * - **macOS.** `caffeinate -i -s` (no root) blocks idle sleep, and system sleep while on + * AC power, but NOT lid-close sleep. Only `pmset -a disablesleep 1` keeps a closed + * MacBook awake, and that is a machine-wide root setting with no owner process, so the + * optional root helper (`scripts/keep-awake-macos.sh`, installed by `install.sh`) + * applies it while the server keeps a fresh request file, and undoes it when the file + * goes stale or disappears. + */ + +/** Settings keys this feature reads. */ +export interface KeepAwakeConfig { + /** Hold a sleep lock while Codeman runs. Opt-in: only an explicit `true` enables. */ + enabled: boolean; + /** Release the lock while the machine runs on battery. Default ON. */ + acOnly: boolean; +} + +export type KeepAwakePlatform = 'linux' | 'macos' | 'unsupported'; + +/** + * - `off`: the setting is off. + * - `paused-battery`: `acOnly` and the machine is on battery. + * - `starting`: a lock was requested and has not been confirmed yet. + * - `active`: the lock is held. + * - `denied`: Linux refused the lock (no active desktop login); retried periodically. + * - `unavailable`: this machine has no usable mechanism (no systemd-logind, WSL, …). + * - `failed`: anything else; retried periodically. + */ +export type KeepAwakeState = 'off' | 'paused-battery' | 'starting' | 'active' | 'denied' | 'unavailable' | 'failed'; + +export interface KeepAwakeStatus { + enabled: boolean; + acOnly: boolean; + platform: KeepAwakePlatform; + state: KeepAwakeState; + /** Last known power source: true = AC, false = battery, null = unknown or not read. */ + onAc: boolean | null; + /** macOS only: whether the root lid-close helper is installed. null elsewhere. */ + lidHelper: 'installed' | 'missing' | null; + /** Short reason for `denied` / `unavailable` / `failed`, else null. */ + detail: string | null; +} + +/** One entry of `/sys/class/power_supply//`. */ +export interface PowerSupplyInfo { + /** `type`: Mains, Battery, USB, UPS, Wireless, … */ + type: string; + /** `online` for adapters (1/0); undefined when the file is absent. */ + online?: boolean; + /** `status` for batteries: Charging, Discharging, Full, Not charging, Unknown. */ + status?: string; + /** `scope`: `Device` marks a peripheral's battery (a mouse), not the machine's. */ + scope?: string; +} + +/** Lock set requested from logind. Order is cosmetic; all three are block locks. */ +export const LINUX_INHIBIT_WHAT = 'handle-lid-switch:sleep:idle'; + +/** Printed by the inhibitor's child once the lock is held (systemd-inhibit execs it only then). */ +export const LINUX_HELD_MARKER = 'codeman-keep-awake-held'; + +/** Name of the request file the macOS lid helper watches, under the data dir. */ +export const MAC_LID_REQUEST_FILE = 'keep-awake-lid.pid'; + +/** The LaunchDaemon the installer writes for the macOS lid helper. */ +export const MAC_LID_HELPER_PLIST = '/Library/LaunchDaemons/com.codeman.keepawake.plist'; + +/** How often the server refreshes the macOS request file. The helper treats it as stale after 2 minutes. */ +export const MAC_LID_HEARTBEAT_MS = 30_000; + +/** How often the power source is re-read while `acOnly` is on. */ +export const POWER_POLL_MS = 30_000; + +/** Delay before retrying a denied or failed lock. */ +export const RETRY_MS = 60_000; + +/** Settings → config. Absent `keepAwakeEnabled` is OFF; absent `keepAwakeAcOnly` is ON. */ +export function resolveKeepAwakeConfig(settings: Record): KeepAwakeConfig { + return { + enabled: settings.keepAwakeEnabled === true, + acOnly: settings.keepAwakeAcOnly !== false, + }; +} + +/** + * Which mechanism applies. WSL is unsupported even though it may have systemd: the + * Windows host decides when the machine sleeps, and a lock inside the VM does nothing. + */ +export function keepAwakePlatform(platform: string, kernelRelease: string): KeepAwakePlatform { + if (platform === 'darwin') return 'macos'; + if (platform === 'linux') return /microsoft/i.test(kernelRelease) ? 'unsupported' : 'linux'; + return 'unsupported'; +} + +/** + * Linux power source from `/sys/class/power_supply`. Peripheral batteries + * (`scope=Device`) are ignored. A machine with no battery of its own runs on external + * power by definition; otherwise any online adapter means AC, adapters that are all + * offline mean battery, and a machine that lists no adapter at all falls back to the + * battery's own charging status. null when nothing conclusive is reported. + */ +export function isOnAcPowerLinux(supplies: readonly PowerSupplyInfo[]): boolean | null { + const system = supplies.filter((s) => (s.scope ?? '').toLowerCase() !== 'device'); + const batteries = system.filter((s) => s.type === 'Battery'); + if (batteries.length === 0) return true; + const adapters = system.filter((s) => s.type !== 'Battery' && s.online !== undefined); + if (adapters.some((s) => s.online)) return true; + if (adapters.length > 0) return false; + const statuses = batteries.map((b) => (b.status ?? '').toLowerCase()); + if (statuses.includes('discharging')) return false; + if (statuses.some((s) => s === 'charging' || s === 'full' || s === 'not charging')) return true; + return null; +} + +/** macOS power source from `pmset -g batt` (first line names the source). */ +export function isOnAcPowerMac(pmsetBatt: string): boolean | null { + const m = /drawing from '([^']+)'/.exec(pmsetBatt); + if (!m) return null; + if (m[1] === 'AC Power') return true; + if (m[1] === 'Battery Power' || m[1] === 'UPS Power') return false; + return null; +} + +/** + * Whether the lock should be held right now. An unknown power source counts as AC: + * it is what a desktop or VM without battery reporting looks like. + */ +export function shouldHoldLock(config: KeepAwakeConfig, onAc: boolean | null): boolean { + if (!config.enabled) return false; + if (!config.acOnly) return true; + return onAc !== false; +} + +/** + * argv for `systemd-inhibit`. The child it runs announces the lock and then becomes + * `cat` on a stdin pipe from the server: when the server exits for ANY reason (a + * SIGKILL included) the pipe closes, `cat` exits, and logind drops the lock. That + * matters because the shipped unit uses `KillMode=process`, which leaves children + * running on stop; a `sleep infinity` child would hold the lock forever. + */ +export function linuxInhibitArgs(): string[] { + return [ + `--what=${LINUX_INHIBIT_WHAT}`, + '--who=Codeman', + '--why=Keeping agent sessions running', + '--mode=block', + '/bin/sh', + '-c', + `echo ${LINUX_HELD_MARKER}; exec cat`, + ]; +} + +/** + * argv for `caffeinate`: `-i` idle sleep, `-s` system sleep on AC power. `-w` ties + * the assertion to the server's pid, so it ends when the server does, crash included. + */ +export function macCaffeinateArgs(serverPid: number): string[] { + return ['-i', '-s', '-w', String(serverPid)]; +} + +/** Classify why `systemd-inhibit` exited before the lock was confirmed. */ +export function classifyInhibitFailure(stderr: string): { state: 'denied' | 'unavailable' | 'failed'; detail: string } { + const text = stderr.trim(); + if (/access denied|not authori[sz]ed|interactive authentication required/i.test(text)) { + return { + state: 'denied', + detail: 'Linux refused the sleep lock: no one is logged in to a desktop session on this machine.', + }; + } + if (/failed to connect to (system )?bus|no such file or directory|not found|unknown (unit|object)/i.test(text)) { + return { state: 'unavailable', detail: 'systemd-logind is not reachable on this machine.' }; + } + const firstLine = text.split('\n')[0]?.slice(0, 200) || 'the inhibitor exited'; + return { state: 'failed', detail: firstLine }; +} diff --git a/src/web/public/index.html b/src/web/public/index.html index cb6d38d0..89bc30e2 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3107,6 +3107,27 @@ +
+

Power

server
+
+
+
+ Keep this computer awake + While Codeman runs, stop the machine it runs on from sleeping, including when a laptop's lid is closed, so agents keep working and stay reachable from your phone. The screen can still turn off. +
+ +
+
+
+ Only on AC power + Sleep normally on battery. A closed laptop in a bag gets hot and drains fast. +
+ +
+ +
+
+

Remote access

synced
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 275b8eff..2e72e128 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -424,6 +424,11 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn; this.applyMcpSyncVisibility(); this._applyDoctorAdminGate(); + // Keep awake: server state (an OS sleep lock), default OFF; "only on AC" default ON. + document.getElementById('appSettingsKeepAwake').checked = settings.keepAwakeEnabled === true; + document.getElementById('appSettingsKeepAwakeAcOnly').checked = settings.keepAwakeAcOnly !== false; + this._applyKeepAwakeAdminGate(); + this.loadKeepAwakeStatus(); this.loadWebhook(); // Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens). document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true; @@ -1224,6 +1229,53 @@ Object.assign(CodemanApp.prototype, { group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : ''; }, + /** + * Keep awake changes machine state, and PUT /api/settings drops a non-admin's value in + * multi-user mode, so a non-admin gets no Power group at all rather than a switch that + * silently does nothing. Also wired to `codeman:me` for the late-resolving role. + */ + _applyKeepAwakeAdminGate() { + const group = document.getElementById('keepAwakeGroup'); + if (!group) return; + const me = window.__codemanUser || {}; + group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : ''; + }, + + /** One line on what the sleep lock is doing right now (GET /api/system/keep-awake). */ + async loadKeepAwakeStatus() { + const out = document.getElementById('keepAwakeStatus'); + if (!out) return; + let s = null; + try { + const res = await this._api('/api/system/keep-awake'); + const body = res && res.ok ? await res.json() : null; + s = body?.success ? body.data : null; + } catch { /* leave hidden */ } + const text = s ? this.describeKeepAwakeStatus(s) : ''; + out.textContent = text; + out.style.display = text ? 'block' : 'none'; + }, + + /** Status → sentence. Pure; '' hides the note. */ + describeKeepAwakeStatus(s) { + switch (s.state) { + case 'off': return ''; + case 'starting': return 'Starting…'; + case 'paused-battery': return 'Paused: running on battery. It comes back when you plug in.'; + case 'unavailable': return `Not available here: ${s.detail || 'no supported sleep lock on this system.'}`; + case 'denied': return `${s.detail || 'The sleep lock was refused.'} Codeman retries every minute, so it applies once you log in.`; + case 'failed': return `Could not take the sleep lock (${s.detail || 'unknown error'}). Retrying every minute.`; + case 'active': + if (s.platform === 'macos') { + return s.lidHelper === 'installed' + ? 'Active: this Mac will not sleep while Codeman runs, even with the lid closed. Apple menu > Sleep is blocked too.' + : 'Active for idle sleep only: closing the lid still puts this Mac to sleep. To cover the lid, re-run the installer and answer yes to the lid question (asks for your admin password once).'; + } + return "Active: closing the lid will not suspend this machine while Codeman runs. Your desktop's own Automatic Suspend timer is separate and still runs: if it is on, switch it off for when the machine is plugged in."; + default: return ''; + } + }, + /** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */ async mcpSync(apply) { const out = this.$('mcpSyncResult'); @@ -2633,6 +2685,8 @@ Object.assign(CodemanApp.prototype, { agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked, agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked, workspaceHooksEnabled: document.getElementById('appSettingsWorkspaceHooks').checked, + keepAwakeEnabled: document.getElementById('appSettingsKeepAwake').checked, + keepAwakeAcOnly: document.getElementById('appSettingsKeepAwakeAcOnly').checked, claudeVoiceEnabled: document.getElementById('appSettingsClaudeVoice').checked, claudeModel: document.getElementById('appSettingsClaudeModel').value, opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked, @@ -4766,4 +4820,5 @@ document.addEventListener?.('codeman:me', () => { window.app?._applyCliManagementAdminGate?.(); window.app?._applyMcpSyncAdminGate?.(); window.app?._applyDoctorAdminGate?.(); + window.app?._applyKeepAwakeAdminGate?.(); }); diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index e2251c24..7278c9df 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -17,7 +17,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } f import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js'; -import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js'; +import { getAuthUser, isAdmin, requireAdmin, canAccessOwned } from '../route-helpers.js'; import { ConfigUpdateSchema, SettingsUpdateSchema, @@ -32,6 +32,8 @@ import { import { subagentWatcher } from '../../subagent-watcher.js'; import { imageWatcher } from '../../image-watcher.js'; import { workflowRunWatcher } from '../../workflow-run-watcher.js'; +import { keepAwake } from '../../keep-awake-manager.js'; +import { resolveKeepAwakeConfig } from '../../keep-awake.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { buildAwayDigest, @@ -1004,6 +1006,13 @@ export function registerSystemRoutes( // acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip // it before persisting so settings.json stays clean. const { acknowledgeUnauthTunnel, ...settingsToStore } = settings; + // Keep-awake is machine state (an OS sleep lock), so in multi-user mode only an + // admin changes it. A non-admin's save carries whatever value its page loaded, so + // the keys are dropped rather than refused: refusing would fail every settings save. + if (!isAdmin(req)) { + delete settingsToStore.keepAwakeEnabled; + delete settingsToStore.keepAwakeAcOnly; + } const merged = { ...existing, ...settingsToStore }; await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); @@ -1032,6 +1041,10 @@ export function registerSystemRoutes( 'Workflow run watcher' ); + // Keep-awake reconciles from `merged` like the watchers above: a partial PUT + // must not read as "turn it off". + void keepAwake.apply(resolveKeepAwakeConfig(merged)); + // Handle image watcher toggle dynamically toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => { // Re-watch all active sessions that have image watcher enabled @@ -1082,6 +1095,14 @@ export function registerSystemRoutes( } }); + // ========== Keep awake ========== + + // Status of the sleep lock behind `keepAwakeEnabled` (src/keep-awake-manager.ts). A + // plain in-memory read: the settings toggle lives in PUT /api/settings. + app.get('/api/system/keep-awake', async () => { + return { success: true, data: keepAwake.getStatus() }; + }); + // ========== Model Configuration ========== app.get('/api/execution/model-config', async () => { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index d242a625..32779cc3 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1378,6 +1378,14 @@ export const SettingsUpdateSchema = z * opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden. */ mcpSyncEnabled: z.boolean().optional(), + /** + * Keep this computer awake while Codeman runs (src/keep-awake.ts). SYNCED, default OFF: + * it is machine state (an OS sleep lock), so in multi-user mode only an admin can change + * it; a non-admin's value is dropped by PUT /api/settings. `keepAwakeAcOnly` (default ON) + * releases the lock on battery. + */ + keepAwakeEnabled: z.boolean().optional(), + keepAwakeAcOnly: z.boolean().optional(), /** * Read My Mind predictor model override. Empty/absent = the AI-checker * default (opus: prediction quality is the product and it runs only on an diff --git a/src/web/server.ts b/src/web/server.ts index bb159e16..2dbe48f1 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -111,6 +111,8 @@ import { intentStore } from '../intent-store.js'; import { AI_CHECK_MODEL } from '../config/ai-defaults.js'; import { approvalInbox } from './approval-inbox.js'; import { stopDeepSeekWeb } from '../deepseek-web-server.js'; +import { keepAwake } from '../keep-awake-manager.js'; +import { resolveKeepAwakeConfig } from '../keep-awake.js'; import { wireRespawnListeners, setupTimedRespawn, @@ -3142,6 +3144,10 @@ export class WebServer extends EventEmitter { console.log('Image watcher disabled by user settings'); } + // Keep-awake: holds an OS sleep lock while this server runs (opt-in, default OFF). + // A fresh read, like the gesture flag: it decides whether a lock is taken at all. + void keepAwake.apply(resolveKeepAwakeConfig(await this.readSettings(true))); + // Tunnel only starts when user clicks the toggle in the UI — never on boot. // Reset persisted tunnelEnabled so the UI toggle reflects actual state. if (await this.isTunnelEnabled()) { @@ -3976,6 +3982,11 @@ export class WebServer extends EventEmitter { // got wrong once. void stopDeepSeekWeb(); + // Release the sleep lock with the server, not after it. The lock would also drop on + // its own once this process exits (stdin pipe / caffeinate -w), but a graceful stop + // should not leave the macOS lid request file to go stale on its own. + await keepAwake.stop(); + // Same teardown rule: the per-endpoint llama-swap log tails are otherwise closed // only by the periodic idle sweep, whose interval is disposed just below. closeAllLlamaSwapLogTails(); diff --git a/test/install-sh-keep-awake.test.ts b/test/install-sh-keep-awake.test.ts new file mode 100644 index 00000000..d436ab1e --- /dev/null +++ b/test/install-sh-keep-awake.test.ts @@ -0,0 +1,207 @@ +/** + * @fileoverview install.sh's keep-awake follow-up, driven in a real bash where it can be + * (laptop detection over a fake sysfs, the settings.json write, the --yes default) and + * pinned statically where it cannot (the macOS root helper needs sudo and launchd). + * + * The rules: never turned on by --yes or a headless run; the setting is written before + * the service starts; the root helper runs from a ROOT-OWNED copy, never from the + * user-writable install dir; uninstall undoes only what the helper set. + * + * Port: none. + */ + +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const INSTALL_SH = fileURLToPath(new URL('../install.sh', import.meta.url)); +const SOURCE = readFileSync(INSTALL_SH, 'utf-8'); + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'install-keep-awake-')); +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +/** Source install.sh (library mode) in a real bash with HOME in the temp dir, then run `body`. */ +function drive(body: string, env: Record = {}) { + const script = ` + set -euo pipefail + export CODEMAN_INSTALL_SH_LIB=1 + . "$1" + ${body} + `; + const result = spawnSync('bash', ['-c', script, 'bash', INSTALL_SH], { + encoding: 'utf-8', + timeout: 30_000, + input: '', + env: { ...process.env, HOME: join(dir, 'home'), ...env }, + }); + return result; +} + +function fakeSupply(name: string, files: Record) { + const d = join(dir, 'ps', name); + mkdirSync(d, { recursive: true }); + for (const [k, v] of Object.entries(files)) writeFileSync(join(d, k), `${v}\n`); +} + +const laptopCheck = ` + POWER_SUPPLY_ROOT="${'$'}FAKE_PS"; LID_BUTTON_ROOT="${'$'}FAKE_LID" + if is_laptop linux; then echo laptop; else echo desktop; fi +`; + +describe('is_laptop (Linux)', () => { + const run = () => drive(laptopCheck, { FAKE_PS: join(dir, 'ps'), FAKE_LID: join(dir, 'lid') }).stdout.trim(); + + it('a machine with no battery and no lid is not a laptop', () => { + mkdirSync(join(dir, 'ps'), { recursive: true }); + fakeSupply('AC', { type: 'Mains', online: '1' }); + expect(run()).toBe('desktop'); + }); + + it("a wireless mouse's battery does not make a desktop a laptop", () => { + fakeSupply('hidpp_battery_0', { type: 'Battery', scope: 'Device', status: 'Discharging' }); + expect(run()).toBe('desktop'); + }); + + it('a system battery or a lid does', () => { + fakeSupply('BAT0', { type: 'Battery', status: 'Charging' }); + expect(run()).toBe('laptop'); + rmSync(join(dir, 'ps'), { recursive: true }); + mkdirSync(join(dir, 'lid', 'LID0'), { recursive: true }); + expect(run()).toBe('laptop'); + }); + + it('copes with a missing power_supply tree', () => { + expect(run()).toBe('desktop'); + }); +}); + +describe('write_keep_awake_setting', () => { + const settings = () => join(dir, 'home', '.codeman', 'settings.json'); + + it('creates settings.json with the setting on and AC-only on', () => { + const r = drive('write_keep_awake_setting'); + expect(r.status, r.stderr).toBe(0); + expect(JSON.parse(readFileSync(settings(), 'utf-8'))).toEqual({ keepAwakeEnabled: true, keepAwakeAcOnly: true }); + }); + + it('keeps every other key, and an explicit AC-only choice', () => { + mkdirSync(join(dir, 'home', '.codeman'), { recursive: true }); + writeFileSync(settings(), JSON.stringify({ theme: 'dark', keepAwakeAcOnly: false }, null, 2)); + expect(drive('write_keep_awake_setting').status).toBe(0); + expect(JSON.parse(readFileSync(settings(), 'utf-8'))).toEqual({ + theme: 'dark', + keepAwakeAcOnly: false, + keepAwakeEnabled: true, + }); + }); + + it('leaves a settings file that does not parse untouched', () => { + mkdirSync(join(dir, 'home', '.codeman'), { recursive: true }); + writeFileSync(settings(), '{ not json'); + const r = drive('write_keep_awake_setting || echo refused'); + expect(r.stdout).toContain('refused'); + expect(readFileSync(settings(), 'utf-8')).toBe('{ not json'); + }); + + it('is what keep_awake_enabled_now reads back', () => { + const r = drive( + 'keep_awake_enabled_now && echo before; write_keep_awake_setting >/dev/null 2>&1; keep_awake_enabled_now && echo after' + ); + expect(r.stdout.trim()).toBe('after'); + }); +}); + +describe('choose_keep_awake', () => { + it('--yes on a laptop never turns it on', () => { + fakeSupply('BAT0', { type: 'Battery', status: 'Charging' }); + const r = drive( + `POWER_SUPPLY_ROOT="$FAKE_PS"; ASSUME_YES=1 + choose_keep_awake linux + echo "keep=[$KEEP_AWAKE] helper=[$KEEP_AWAKE_LID_HELPER]"`, + { FAKE_PS: join(dir, 'ps') } + ); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toContain('keep=[] helper=[]'); + }); + + it('asks nothing on a desktop', () => { + const r = drive( + `POWER_SUPPLY_ROOT="$FAKE_PS"; LID_BUTTON_ROOT="$FAKE_PS" + choose_keep_awake linux 2>&1 + echo "keep=[$KEEP_AWAKE]"`, + { FAKE_PS: join(dir, 'nothing') } + ); + expect(r.stdout.trim()).toBe('keep=[]'); + }); + + it('only notes an existing setting, without re-asking', () => { + mkdirSync(join(dir, 'home', '.codeman'), { recursive: true }); + writeFileSync(join(dir, 'home', '.codeman', 'settings.json'), JSON.stringify({ keepAwakeEnabled: true }, null, 2)); + const r = drive(`choose_keep_awake linux force 2>&1; echo "keep=[$KEEP_AWAKE]"`); + expect(r.stdout).toContain('already on'); + expect(r.stdout).toContain('keep=[]'); + }); + + it('defaults to no in the prompt itself', () => { + const fn = SOURCE.slice(SOURCE.indexOf('choose_keep_awake() {'), SOURCE.indexOf('write_keep_awake_setting() {')); + expect(fn).toMatch(/prompt_yes_no "Keep this machine awake[^"]*" "n"/); + // The sudo-needing helper is offered only to a person at a terminal. + expect(fn).toMatch(/"\$NONINTERACTIVE" != "1" && "\$ASSUME_YES" != "1" \]\] && has_tty/); + }); +}); + +describe('install.sh keep-awake wiring', () => { + const fn = (name: string, next: string) => SOURCE.slice(SOURCE.indexOf(`${name}() {`), SOURCE.indexOf(next)); + + it('asks after question 3 and applies before the service starts', () => { + const main = fn('main', '\npreflight_detect() {'); + const ask = main.indexOf('choose_keep_awake "$os"'); + const apply = main.indexOf('apply_keep_awake'); + expect(ask).toBeGreaterThan(main.indexOf('choose_launch_mode "$os"')); + expect(ask).toBeLessThan(main.indexOf('install_or_update_repo')); + expect(apply).toBeGreaterThan(main.indexOf('run_step "Building Codeman"')); + expect(apply).toBeLessThan(main.indexOf('setup_launchd_service')); + expect(apply).toBeLessThan(main.indexOf('setup_systemd_service')); + }); + + it('runs the root helper from a root-owned copy, never from the install dir', () => { + const body = fn('install_keep_awake_lid_helper', '\nremove_keep_awake_lid_helper() {'); + expect(body).toContain('install -m 755 -o root -g wheel "$src" "$script"'); + expect(body).toContain('local script="$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh"'); + const programArgs = body.slice(body.indexOf('ProgramArguments'), body.indexOf('')); + expect(programArgs).toContain('$(xml_escape "$script")'); + expect(programArgs).not.toContain('INSTALL_DIR'); + // The request path must match what the server writes (dataPath('keep-awake-lid.pid')). + expect(body).toContain('local request="$HOME/.codeman/keep-awake-lid.pid"'); + }); + + it('uninstall removes the helper and undoes only a disablesleep it set', () => { + expect(fn('uninstall', '\nusage() {')).toContain('remove_keep_awake_lid_helper'); + const remove = fn('remove_keep_awake_lid_helper', '\napply_keep_awake() {'); + const owned = remove.indexOf('keep-awake.owned'); + expect(owned).toBeGreaterThan(-1); + expect(remove.indexOf('pmset -a disablesleep 0')).toBeGreaterThan(owned); + }); + + it('dispatches the keep-awake subcommand and documents it', () => { + expect(SOURCE).toMatch(/update\|uninstall\|tailscale\|name\|status\|cloudflared\|keep-awake\)/); + expect(SOURCE).toMatch(/\n {4}keep-awake\) {2}keep_awake_subcommand ;;/); + const header = SOURCE.slice(0, SOURCE.indexOf('set -euo pipefail')); + expect(header).toContain('install.sh keep-awake'); + expect(fn('usage', '\nparse_flags() {')).toContain('keep-awake'); + }); + + it('update and keep-awake share one restart path', () => { + expect(fn('update', '\nuninstall() {')).toContain('restart_running_service'); + expect(fn('keep_awake_subcommand', '\nverify_systemd_active() {')).toContain('restart_running_service'); + }); +}); diff --git a/test/keep-awake-macos-helper.test.ts b/test/keep-awake-macos-helper.test.ts new file mode 100644 index 00000000..e0809bc3 --- /dev/null +++ b/test/keep-awake-macos-helper.test.ts @@ -0,0 +1,175 @@ +/** + * @fileoverview Runs the real macOS lid helper (scripts/keep-awake-macos.sh) in bash + * against a stub `pmset` that records what it was asked to do. The helper runs as root + * from a LaunchDaemon, so the cases that matter are the ones where it must NOT act: a + * stale, dead, foreign or symlinked request, and an administrator's own + * `disablesleep 1`, which it must never switch off. + * + * Only the BSD/GNU-portable tools the script uses (find -mmin, ps -o uid=, ls -ln) run + * here, so this passes on Linux and macOS alike. Port: none. + */ + +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const HELPER = fileURLToPath(new URL('../scripts/keep-awake-macos.sh', import.meta.url)); + +let dir: string; +let stateDir: string; +let request: string; + +/** Stub pmset: `-g` prints the stored SleepDisabled, `-a disablesleep N` stores N and logs it. */ +function writeStubPmset(initial: '0' | '1' | null) { + const value = join(dir, 'sleepdisabled'); + if (initial !== null) writeFileSync(value, initial); + const stub = join(dir, 'pmset'); + writeFileSync( + stub, + `#!/bin/bash +if [ "$1" = "-g" ]; then + echo "System-wide power settings:" + [ -f "${value}" ] && printf ' SleepDisabled\\t\\t%s\\n' "$(cat "${value}")" + echo "Currently in use:" + echo " sleep 1" + exit 0 +fi +if [ "$1" = "-a" ] && [ "$2" = "disablesleep" ]; then + printf '%s' "$3" > "${value}" + echo "disablesleep $3" >> "${join(dir, 'pmset.log')}" + exit 0 +fi +exit 1 +` + ); + chmodSync(stub, 0o755); + return stub; +} + +function run() { + const result = spawnSync('bash', [HELPER, request], { + encoding: 'utf-8', + env: { + ...process.env, + CODEMAN_KEEPAWAKE_PMSET: join(dir, 'pmset'), + CODEMAN_KEEPAWAKE_STATE_DIR: stateDir, + }, + }); + expect(result.status, result.stderr).toBe(0); +} + +const calls = () => (existsSync(join(dir, 'pmset.log')) ? readFileSync(join(dir, 'pmset.log'), 'utf-8') : ''); +const sleepDisabled = () => readFileSync(join(dir, 'sleepdisabled'), 'utf-8'); +const owned = () => existsSync(join(stateDir, 'keep-awake.owned')); +const requestFromLiveServer = () => writeFileSync(request, `${process.pid}\n`); + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'keep-awake-helper-')); + stateDir = join(dir, 'state'); + request = join(dir, 'keep-awake-lid.pid'); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('keep-awake-macos.sh', () => { + it('does nothing without a request', () => { + writeStubPmset('0'); + run(); + expect(calls()).toBe(''); + expect(owned()).toBe(false); + }); + + it('applies disablesleep for a fresh request from a live process, then undoes it when the request goes', () => { + writeStubPmset('0'); + requestFromLiveServer(); + run(); + expect(sleepDisabled()).toBe('1'); + expect(owned()).toBe(true); + + run(); // steady state: no repeated pmset call + expect(calls()).toBe('disablesleep 1\n'); + + rmSync(request); + run(); + expect(sleepDisabled()).toBe('0'); + expect(owned()).toBe(false); + expect(calls()).toBe('disablesleep 1\ndisablesleep 0\n'); + }); + + it("never switches off an administrator's own disablesleep", () => { + writeStubPmset('1'); + requestFromLiveServer(); + run(); + expect(calls()).toBe(''); + expect(owned()).toBe(false); + rmSync(request); + run(); + expect(sleepDisabled()).toBe('1'); + expect(calls()).toBe(''); + }); + + it('treats an absent SleepDisabled line as 0', () => { + writeStubPmset(null); + requestFromLiveServer(); + run(); + expect(sleepDisabled()).toBe('1'); + expect(owned()).toBe(true); + }); + + it('re-applies after a reboot or OS update reset it, while the request stands', () => { + writeStubPmset('0'); + requestFromLiveServer(); + run(); + writeFileSync(join(dir, 'sleepdisabled'), '0'); // the reset + run(); + expect(sleepDisabled()).toBe('1'); + }); + + it('ignores a stale request (a crashed or hung server) and releases', () => { + writeStubPmset('0'); + requestFromLiveServer(); + run(); + expect(sleepDisabled()).toBe('1'); + const threeMinutesAgo = (Date.now() - 3 * 60_000) / 1000; + utimesSync(request, threeMinutesAgo, threeMinutesAgo); + run(); + expect(sleepDisabled()).toBe('0'); + expect(owned()).toBe(false); + }); + + it('ignores a request whose pid is not running', () => { + writeStubPmset('0'); + const gone = spawnSync('true').pid; + writeFileSync(request, `${gone}\n`); + run(); + expect(calls()).toBe(''); + }); + + it('ignores garbage and refuses a symlinked request', () => { + writeStubPmset('0'); + writeFileSync(request, 'not-a-pid\n'); + run(); + expect(calls()).toBe(''); + + rmSync(request); + const real = join(dir, 'elsewhere.pid'); + writeFileSync(real, `${process.pid}\n`); + symlinkSync(real, request); + run(); + expect(calls()).toBe(''); + }); +}); diff --git a/test/keep-awake-manager.test.ts b/test/keep-awake-manager.test.ts new file mode 100644 index 00000000..93baa02f --- /dev/null +++ b/test/keep-awake-manager.test.ts @@ -0,0 +1,270 @@ +/** + * @fileoverview KeepAwakeManager (src/keep-awake-manager.ts) driven through fake deps: no + * real lock is ever taken. Pins the lifecycle: a lock is confirmed before it reads as + * active, a polkit refusal is a retried state, AC-only follows the power source, a + * release closes the inhibitor's stdin (which is what drops the lock), and the macOS lid + * request file follows the lock only when the root helper is installed. + * + * Port: none. + */ + +import { EventEmitter } from 'node:events'; +import type { ChildProcess } from 'node:child_process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { KeepAwakeManager, type KeepAwakeDeps } from '../src/keep-awake-manager.js'; +import { + LINUX_HELD_MARKER, + POWER_POLL_MS, + RETRY_MS, + linuxInhibitArgs, + macCaffeinateArgs, + type PowerSupplyInfo, +} from '../src/keep-awake.js'; + +class FakeChild extends EventEmitter { + stdout = new EventEmitter(); + stderr = new EventEmitter(); + /** Like the real `cat`: closing stdin ends the process (asynchronously). */ + stdin = Object.assign(new EventEmitter(), { + end: vi.fn(() => { + queueMicrotask(() => { + if (this.exitCode !== null || this.signalCode !== null) return; + this.exitCode = 0; + this.emit('exit', 0, null); + }); + }), + }); + exitCode: number | null = null; + signalCode: string | null = null; + kill = vi.fn((signal?: string) => { + this.signalCode = signal ?? 'SIGTERM'; + this.emit('exit', null, this.signalCode); + return true; + }); + /** The process announces the lock (systemd-inhibit only execs its child once held). */ + hold() { + this.stdout.emit('data', Buffer.from(`${LINUX_HELD_MARKER}\n`)); + } + fail(stderr: string, code = 1) { + this.stderr.emit('data', Buffer.from(stderr)); + this.exitCode = code; + this.emit('exit', code, null); + } +} + +const AC: PowerSupplyInfo[] = [ + { type: 'Mains', online: true }, + { type: 'Battery', status: 'Charging' }, +]; +const BATTERY: PowerSupplyInfo[] = [ + { type: 'Mains', online: false }, + { type: 'Battery', status: 'Discharging' }, +]; + +/** Let queued reconciles (a promise chain) run to completion. */ +const settle = async () => { + for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r)); +}; + +function makeDeps(overrides: Partial = {}) { + const children: FakeChild[] = []; + let supplies = AC; + let batt = "Now drawing from 'AC Power'"; + const deps: KeepAwakeDeps = { + platform: 'linux', + serverPid: 4242, + spawn: vi.fn(() => { + const c = new FakeChild(); + children.push(c); + return c as unknown as ChildProcess; + }), + readPowerSupplies: vi.fn(async () => supplies), + readMacBatt: vi.fn(async () => batt), + lidHelperInstalled: vi.fn(async () => true), + writeLidRequest: vi.fn(async () => {}), + removeLidRequest: vi.fn(async () => {}), + ...overrides, + }; + return { + deps, + children, + setSupplies: (s: PowerSupplyInfo[]) => (supplies = s), + setBatt: (s: string) => (batt = s), + }; +} + +describe('KeepAwakeManager on Linux', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('takes the logind lock and reads active only once the lock is confirmed', async () => { + const { deps, children } = makeDeps(); + const m = new KeepAwakeManager(deps); + await m.apply({ enabled: true, acOnly: true }); + expect(deps.spawn).toHaveBeenCalledWith('systemd-inhibit', linuxInhibitArgs()); + expect(m.getStatus().state).toBe('starting'); + children[0].hold(); + expect(m.getStatus()).toMatchObject({ state: 'active', onAc: true, platform: 'linux', lidHelper: null }); + await m.stop(); + }); + + it('reports a polkit refusal as denied and retries it', async () => { + const { deps, children } = makeDeps(); + const m = new KeepAwakeManager(deps); + await m.apply({ enabled: true, acOnly: true }); + children[0].fail('Failed to inhibit: Access denied\n'); + expect(m.getStatus().state).toBe('denied'); + expect(deps.spawn).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(RETRY_MS); + await settle(); + expect(deps.spawn).toHaveBeenCalledTimes(2); + children[1].hold(); + expect(m.getStatus().state).toBe('active'); + await m.stop(); + }); + + it('turning it off closes the inhibitor stdin, which drops the lock, and does not retry', async () => { + const { deps, children } = makeDeps(); + const m = new KeepAwakeManager(deps); + await m.apply({ enabled: true, acOnly: false }); + children[0].hold(); + await m.apply({ enabled: false, acOnly: false }); + expect(children[0].stdin.end).toHaveBeenCalled(); + expect(m.getStatus().state).toBe('off'); + // The released child exiting is expected, not a failure to retry. + await settle(); + await vi.advanceTimersByTimeAsync(RETRY_MS * 2); + await settle(); + expect(deps.spawn).toHaveBeenCalledTimes(1); + expect(m.getStatus().state).toBe('off'); + }); + + it('AC-only: no lock on battery, takes it when plugged in, releases on unplug', async () => { + const { deps, children, setSupplies } = makeDeps(); + setSupplies(BATTERY); + const m = new KeepAwakeManager(deps); + await m.apply({ enabled: true, acOnly: true }); + expect(deps.spawn).not.toHaveBeenCalled(); + expect(m.getStatus()).toMatchObject({ state: 'paused-battery', onAc: false }); + + setSupplies(AC); + await vi.advanceTimersByTimeAsync(POWER_POLL_MS); + await settle(); + expect(deps.spawn).toHaveBeenCalledTimes(1); + children[0].hold(); + expect(m.getStatus().state).toBe('active'); + + setSupplies(BATTERY); + await vi.advanceTimersByTimeAsync(POWER_POLL_MS); + await settle(); + expect(children[0].stdin.end).toHaveBeenCalled(); + expect(m.getStatus().state).toBe('paused-battery'); + await m.stop(); + }); + + it('without AC-only it never reads the power source', async () => { + const { deps, setSupplies } = makeDeps(); + setSupplies(BATTERY); + const m = new KeepAwakeManager(deps); + await m.apply({ enabled: true, acOnly: false }); + expect(deps.readPowerSupplies).not.toHaveBeenCalled(); + expect(deps.spawn).toHaveBeenCalledTimes(1); + await m.stop(); + }); + + it('a machine without systemd-inhibit is unavailable, and is not retried', async () => { + const { deps } = makeDeps({ + spawn: vi.fn(() => { + throw Object.assign(new Error('spawn systemd-inhibit ENOENT'), { code: 'ENOENT' }); + }), + }); + const m = new KeepAwakeManager(deps); + await m.apply({ enabled: true, acOnly: true }); + expect(m.getStatus()).toMatchObject({ state: 'unavailable' }); + expect(m.getStatus().detail).toMatch(/systemd-inhibit is not installed/); + await vi.advanceTimersByTimeAsync(RETRY_MS * 3); + await settle(); + expect(deps.spawn).toHaveBeenCalledTimes(1); + await m.stop(); + }); + + it('stop() releases the lock and leaves nothing scheduled', async () => { + const { deps, children } = makeDeps(); + const m = new KeepAwakeManager(deps); + await m.apply({ enabled: true, acOnly: true }); + children[0].hold(); + await m.stop(); + await settle(); + expect(children[0].stdin.end).toHaveBeenCalled(); + expect(children[0].kill).not.toHaveBeenCalled(); + expect(m.getStatus().state).toBe('off'); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe('KeepAwakeManager on macOS', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('runs caffeinate tied to the server pid and keeps the lid request fresh while held', async () => { + const { deps, children } = makeDeps({ platform: 'macos' }); + const m = new KeepAwakeManager(deps); + await m.apply({ enabled: true, acOnly: true }); + expect(deps.spawn).toHaveBeenCalledWith('/usr/bin/caffeinate', macCaffeinateArgs(4242)); + children[0].emit('spawn'); + expect(m.getStatus()).toMatchObject({ state: 'active', lidHelper: 'installed', onAc: true }); + expect(deps.writeLidRequest).toHaveBeenCalledWith(4242); + + await vi.advanceTimersByTimeAsync(30_000); + expect((deps.writeLidRequest as ReturnType).mock.calls.length).toBeGreaterThanOrEqual(2); + + await m.apply({ enabled: false, acOnly: true }); + expect(deps.removeLidRequest).toHaveBeenCalled(); + expect(children[0].kill).toHaveBeenCalled(); + const writes = (deps.writeLidRequest as ReturnType).mock.calls.length; + await vi.advanceTimersByTimeAsync(120_000); + expect((deps.writeLidRequest as ReturnType).mock.calls.length).toBe(writes); + }); + + it('writes no lid request when the root helper is not installed', async () => { + const { deps, children } = makeDeps({ platform: 'macos', lidHelperInstalled: vi.fn(async () => false) }); + const m = new KeepAwakeManager(deps); + await m.apply({ enabled: true, acOnly: true }); + children[0].emit('spawn'); + expect(m.getStatus()).toMatchObject({ state: 'active', lidHelper: 'missing' }); + expect(deps.writeLidRequest).not.toHaveBeenCalled(); + await m.stop(); + }); + + it('on battery with AC-only, drops both caffeinate and the lid request', async () => { + const { deps, children, setBatt } = makeDeps({ platform: 'macos' }); + const m = new KeepAwakeManager(deps); + await m.apply({ enabled: true, acOnly: true }); + children[0].emit('spawn'); + setBatt("Now drawing from 'Battery Power'"); + await vi.advanceTimersByTimeAsync(POWER_POLL_MS); + await settle(); + expect(m.getStatus().state).toBe('paused-battery'); + expect(children[0].kill).toHaveBeenCalled(); + expect(deps.removeLidRequest).toHaveBeenCalled(); + await m.stop(); + }); +}); + +describe('the process-wide manager under vitest', () => { + it('is inert: enabling it never spawns a real lock', async () => { + const m = new KeepAwakeManager(); + await m.apply({ enabled: true, acOnly: false }); + expect(m.getStatus()).toMatchObject({ state: 'unavailable', detail: 'Disabled under the test runner.' }); + await m.stop(); + }); +}); diff --git a/test/keep-awake.test.ts b/test/keep-awake.test.ts new file mode 100644 index 00000000..6824ac00 --- /dev/null +++ b/test/keep-awake.test.ts @@ -0,0 +1,116 @@ +/** + * @fileoverview Pure decisions behind "Keep this computer awake while Codeman runs" + * (src/keep-awake.ts): settings defaults, platform choice, power-source parsing, the + * hold/release rule, the lock argv and the failure classification. + * + * Port: none (pure). + */ + +import { describe, expect, it } from 'vitest'; +import { + LINUX_HELD_MARKER, + LINUX_INHIBIT_WHAT, + classifyInhibitFailure, + isOnAcPowerLinux, + isOnAcPowerMac, + keepAwakePlatform, + linuxInhibitArgs, + macCaffeinateArgs, + resolveKeepAwakeConfig, + shouldHoldLock, +} from '../src/keep-awake.js'; + +describe('resolveKeepAwakeConfig', () => { + it('is off unless explicitly enabled, and AC-only unless explicitly not', () => { + expect(resolveKeepAwakeConfig({})).toEqual({ enabled: false, acOnly: true }); + expect(resolveKeepAwakeConfig({ keepAwakeEnabled: 'yes' })).toEqual({ enabled: false, acOnly: true }); + expect(resolveKeepAwakeConfig({ keepAwakeEnabled: true })).toEqual({ enabled: true, acOnly: true }); + expect(resolveKeepAwakeConfig({ keepAwakeEnabled: true, keepAwakeAcOnly: false })).toEqual({ + enabled: true, + acOnly: false, + }); + }); +}); + +describe('keepAwakePlatform', () => { + it('maps darwin and linux, and treats WSL as unsupported', () => { + expect(keepAwakePlatform('darwin', '24.0.0')).toBe('macos'); + expect(keepAwakePlatform('linux', '6.8.0-124-generic')).toBe('linux'); + expect(keepAwakePlatform('linux', '5.15.153.1-microsoft-standard-WSL2')).toBe('unsupported'); + expect(keepAwakePlatform('win32', '10.0.22631')).toBe('unsupported'); + }); +}); + +describe('isOnAcPowerLinux', () => { + const battery = (status: string) => ({ type: 'Battery', status }); + it('a machine with no battery of its own is on external power', () => { + expect(isOnAcPowerLinux([])).toBe(true); + expect(isOnAcPowerLinux([{ type: 'Battery', status: 'Discharging', scope: 'Device' }])).toBe(true); + }); + it('an online adapter means AC, all adapters offline means battery', () => { + expect(isOnAcPowerLinux([{ type: 'Mains', online: true }, battery('Charging')])).toBe(true); + expect(isOnAcPowerLinux([{ type: 'USB', online: true }, battery('Unknown')])).toBe(true); + expect(isOnAcPowerLinux([{ type: 'Mains', online: false }, battery('Unknown')])).toBe(false); + }); + it('falls back to the battery status when no adapter is listed', () => { + expect(isOnAcPowerLinux([battery('Discharging')])).toBe(false); + expect(isOnAcPowerLinux([battery('Full')])).toBe(true); + expect(isOnAcPowerLinux([battery('Not charging')])).toBe(true); + expect(isOnAcPowerLinux([battery('Unknown')])).toBe(null); + }); +}); + +describe('isOnAcPowerMac', () => { + it('reads the source from `pmset -g batt`', () => { + expect(isOnAcPowerMac("Now drawing from 'AC Power'\n -InternalBattery-0 (id=1)\t100%; charged;")).toBe(true); + expect(isOnAcPowerMac("Now drawing from 'Battery Power'\n -InternalBattery-0 (id=1)\t80%;")).toBe(false); + expect(isOnAcPowerMac("Now drawing from 'UPS Power'")).toBe(false); + expect(isOnAcPowerMac('')).toBe(null); + }); +}); + +describe('shouldHoldLock', () => { + it('holds only when enabled, and on battery only when not AC-only', () => { + expect(shouldHoldLock({ enabled: false, acOnly: false }, true)).toBe(false); + expect(shouldHoldLock({ enabled: true, acOnly: true }, true)).toBe(true); + expect(shouldHoldLock({ enabled: true, acOnly: true }, false)).toBe(false); + expect(shouldHoldLock({ enabled: true, acOnly: false }, false)).toBe(true); + }); + it('treats an unknown power source as AC (a desktop without battery reporting)', () => { + expect(shouldHoldLock({ enabled: true, acOnly: true }, null)).toBe(true); + }); +}); + +describe('lock argv', () => { + it('asks logind for the lid lock, not just sleep (LidSwitchIgnoreInhibited=yes ignores sleep)', () => { + const args = linuxInhibitArgs(); + expect(LINUX_INHIBIT_WHAT.split(':')).toContain('handle-lid-switch'); + expect(args).toContain(`--what=${LINUX_INHIBIT_WHAT}`); + expect(args).toContain('--mode=block'); + }); + it('ends in a cat on stdin, so the lock dies with the server', () => { + const args = linuxInhibitArgs(); + const script = args[args.length - 1]; + expect(args.slice(-3, -1)).toEqual(['/bin/sh', '-c']); + expect(script).toBe(`echo ${LINUX_HELD_MARKER}; exec cat`); + expect(script).not.toMatch(/sleep/); + }); + it('ties caffeinate to the server pid', () => { + expect(macCaffeinateArgs(4242)).toEqual(['-i', '-s', '-w', '4242']); + }); +}); + +describe('classifyInhibitFailure', () => { + it('reads a polkit refusal as denied', () => { + expect(classifyInhibitFailure('Failed to inhibit: Access denied\n').state).toBe('denied'); + expect(classifyInhibitFailure('Interactive authentication required.').state).toBe('denied'); + }); + it('reads a missing logind as unavailable', () => { + expect(classifyInhibitFailure('Failed to connect to bus: No such file or directory').state).toBe('unavailable'); + }); + it('keeps the first line of anything else', () => { + const r = classifyInhibitFailure('something odd\nmore'); + expect(r).toEqual({ state: 'failed', detail: 'something odd' }); + expect(classifyInhibitFailure('').detail).toBe('the inhibitor exited'); + }); +}); diff --git a/test/routes/system-routes-keep-awake.test.ts b/test/routes/system-routes-keep-awake.test.ts new file mode 100644 index 00000000..d21fed57 --- /dev/null +++ b/test/routes/system-routes-keep-awake.test.ts @@ -0,0 +1,132 @@ +/** + * @fileoverview Keep-awake wiring in system-routes: PUT /api/settings reconciles the sleep + * lock from the MERGED settings (a partial body never reads as "turn it off"), a + * non-admin's value is dropped in multi-user mode (it is machine state), and + * GET /api/system/keep-awake returns the manager's status in the standard envelope. + * + * Uses app.inject(); the manager is mocked, so no real lock is taken. Port: N/A. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; + +const { store, keepAwake } = vi.hoisted(() => ({ + store: { settings: {} as Record, written: [] as Record[] }, + keepAwake: { + apply: vi.fn(async () => {}), + getStatus: vi.fn(() => ({ + enabled: true, + acOnly: true, + platform: 'linux', + state: 'active', + onAc: true, + lidHelper: null, + detail: null, + })), + stop: vi.fn(async () => {}), + }, +})); + +vi.mock('node:fs/promises', () => ({ + default: { + readFile: vi.fn(async () => JSON.stringify(store.settings)), + writeFile: vi.fn(async (_path: string, data: string) => { + store.written.push(JSON.parse(data)); + }), + }, +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) }; +}); + +vi.mock('../../src/keep-awake-manager.js', () => ({ keepAwake })); + +describe('keep-awake in system routes', () => { + let harness: RouteTestHarness; + + beforeEach(() => { + store.settings = {}; + store.written = []; + keepAwake.apply.mockClear(); + delete process.env.CODEMAN_MULTIUSER; + }); + + afterEach(async () => { + delete process.env.CODEMAN_MULTIUSER; + await harness?.app.close(); + }); + + it('a partial PUT keeps the persisted keep-awake on', async () => { + harness = await createRouteTestHarness(registerSystemRoutes); + store.settings = { keepAwakeEnabled: true, keepAwakeAcOnly: false }; + const res = await harness.app.inject({ method: 'PUT', url: '/api/settings', payload: { showMonitor: true } }); + expect(res.statusCode).toBe(200); + expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: false }); + }); + + it('an explicit PUT turns it on and persists it', async () => { + harness = await createRouteTestHarness(registerSystemRoutes); + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/settings', + payload: { keepAwakeEnabled: true }, + }); + expect(res.statusCode).toBe(200); + expect(store.written.at(-1)).toMatchObject({ keepAwakeEnabled: true }); + expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: true }); + }); + + it('rejects a non-boolean value', async () => { + harness = await createRouteTestHarness(registerSystemRoutes); + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/settings', + payload: { keepAwakeEnabled: 'yes' }, + }); + expect(res.statusCode).toBe(400); + expect(keepAwake.apply).not.toHaveBeenCalled(); + }); + + it("drops a non-admin's value in multi-user mode, without failing the rest of the save", async () => { + process.env.CODEMAN_MULTIUSER = '1'; + harness = await createRouteTestHarness(registerSystemRoutes, { + authUser: { username: 'bob', role: 'user' }, + }); + store.settings = { keepAwakeEnabled: false }; + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/settings', + payload: { keepAwakeEnabled: true, keepAwakeAcOnly: false, showMonitor: true }, + }); + expect(res.statusCode).toBe(200); + const written = store.written.at(-1)!; + expect(written.keepAwakeEnabled).toBe(false); + expect('keepAwakeAcOnly' in written).toBe(false); + expect(written.showMonitor).toBe(true); + expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: false, acOnly: true }); + }); + + it('an admin can change it in multi-user mode', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + harness = await createRouteTestHarness(registerSystemRoutes, { + authUser: { username: 'root', role: 'admin' }, + }); + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/settings', + payload: { keepAwakeEnabled: true }, + }); + expect(res.statusCode).toBe(200); + expect(keepAwake.apply).toHaveBeenCalledWith({ enabled: true, acOnly: true }); + }); + + it('GET /api/system/keep-awake returns the status envelope', async () => { + harness = await createRouteTestHarness(registerSystemRoutes); + const res = await harness.app.inject({ method: 'GET', url: '/api/system/keep-awake' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ success: true, data: keepAwake.getStatus() }); + }); +});