Compare commits

..
Author SHA1 Message Date
Codeman maintainer 6234aae919 docs(readme): codeman skill GIF with the CRT tile grid and a mixed fleet
The agent skill section now shows a real run end to end: one short
prompt typed into Claude Code, the tile grid powering on with the CRT
entrance, then a DeepSeek Harness worker on a local qwen model and a
Claude Code worker powering on as new tiles, with lineage lines back to
the lead. 1280 wide, linking a 3600x2025 still.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 10:29:01 +02:00
22 changed files with 32 additions and 1961 deletions
-5
View File
@@ -1,5 +0,0 @@
---
'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.
-2
View File
@@ -293,8 +293,6 @@ 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 <pid>`. ⚠️ 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 `<base href>` + 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)
+1 -1
View File
@@ -736,7 +736,7 @@ For AI agents and automation that control Codeman without a browser: an agent th
Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
<p align="center">
<a href="docs/images/codeman-skill-20261010.png"><img src="docs/images/codeman-skill-20261010.gif" alt="A real codeman skill run: one plain-English request to a lead session, three Claude Code workers opening as new tabs, and lineage lines from the lead to every worker" width="900"></a>
<a href="docs/images/codeman-skill-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="A real codeman skill run: one short prompt typed into Claude Code, the tile grid powering on, then a DeepSeek Harness worker on a local qwen model and a Claude Code worker powering on as new tiles, with lineage lines from the lead to both" width="900"></a>
</p>
#### Step 1: install it
-4
View File
@@ -860,10 +860,6 @@ 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

-6
View File
@@ -57,11 +57,6 @@ 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.
@@ -80,7 +75,6 @@ install.sh uninstall # remove (offers to undo a rename it performed)
install.sh tailscale # retrofit Tailscale access onto an existing install
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
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 --`:
+2 -8
View File
@@ -181,14 +181,8 @@ 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). 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.
tools with their versions and install hints (admin only in multi-user mode). In multi-user
mode, the **Users** administration entry is injected here.
## Session Options
+28 -258
View File
@@ -7,9 +7,8 @@
#
# 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; 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.
# 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.
#
# Flags (each has an environment-variable twin, listed below):
# --tailscale | --lan | --local How the dashboard is reached (question 1)
@@ -49,7 +48,6 @@
# install.sh name [<n>] - Rename this machine on your tailnet (default: codeman-<hostname>)
# 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
@@ -131,18 +129,6 @@ 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.
@@ -2719,200 +2705,6 @@ 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" </dev/null; then
success "Keep-awake is on (only while plugged in; App Settings > 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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$KEEP_AWAKE_HELPER_LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>$(xml_escape "$script")</string>
<string>$(xml_escape "$request")</string>
</array>
<key>StartInterval</key>
<integer>20</integer>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
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
@@ -3240,7 +3032,6 @@ main() {
choose_network_binding
echo ""
choose_launch_mode "$os"
choose_keep_awake "$os"
echo ""
# ========================================================================
@@ -3259,9 +3050,6 @@ 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
@@ -3705,36 +3493,6 @@ 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."
@@ -3767,15 +3525,32 @@ update() {
success "Updated to $(node -e "console.log(require('./package.json').version)")"
echo ""
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 ""
# 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}"
fi
echo ""
# Reflect the service's actual binding in the closing notice. Updates
# never rewrite the service files, so the existing choice is authoritative.
@@ -3828,9 +3603,6 @@ 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:
@@ -3949,7 +3721,6 @@ Subcommands
name [<n>] Rename this machine on your tailnet (default: codeman-<hostname>)
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,
@@ -3991,7 +3762,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|keep-awake)
update|uninstall|tailscale|name|status|cloudflared)
[[ -z "$SUBCOMMAND" ]] || die "Only one subcommand at a time ($SUBCOMMAND and $1 given)."
SUBCOMMAND="$1" ;;
-*) die "Unknown option: $1 (see --help)" ;;
@@ -4024,7 +3795,6 @@ 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
-62
View File
@@ -1,62 +0,0 @@
#!/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 <request-file>
# Test hooks (set only by the test suite): CODEMAN_KEEPAWAKE_PMSET, CODEMAN_KEEPAWAKE_STATE_DIR.
set -u
REQ="${1:?usage: keep-awake-macos.sh <request-file>}"
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
-402
View File
@@ -1,402 +0,0 @@
/**
* @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 <pid>` 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<PowerSupplyInfo[]>;
readMacBatt: () => Promise<string | null>;
lidHelperInstalled: () => Promise<boolean>;
writeLidRequest: (pid: number) => Promise<void>;
removeLidRequest: () => Promise<void>;
}
const POWER_SUPPLY_DIR = '/sys/class/power_supply';
async function readSysValue(dir: string, name: string): Promise<string | undefined> {
try {
return (await fs.readFile(join(dir, name), 'utf-8')).trim();
} catch {
return undefined;
}
}
async function readLinuxPowerSupplies(): Promise<PowerSupplyInfo[]> {
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<string | null> {
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<ChildProcess>();
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<void> = 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<void> {
this.config = { ...config };
this.stopped = false;
return this.enqueue();
}
/** Release everything and stop all timers (server shutdown). */
async stop(): Promise<void> {
this.stopped = true;
this.clearTimers();
await this.chain.catch(() => {});
await this.release();
this.status = { ...this.status, state: 'off', detail: null };
}
private enqueue(): Promise<void> {
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<void> {
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<boolean | null> {
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<void> {
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();
-196
View File
@@ -1,196 +0,0 @@
/**
* @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/<name>/`. */
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<string, unknown>): 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 };
}
-21
View File
@@ -3107,27 +3107,6 @@
</div>
</div>
<div class="set-group" id="keepAwakeGroup">
<div class="set-group-head"><h4>Power</h4><span class="set-scope">server</span></div>
<div class="set-group-body">
<div class="set-row" data-search="keep awake sleep suspend standby lid close laptop notebook power caffeinate inhibit">
<div class="set-row-text">
<span class="set-row-label">Keep this computer awake</span>
<span class="set-row-desc">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.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsKeepAwake"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="keep awake battery ac power charger plugged in">
<div class="set-row-text">
<span class="set-row-label">Only on AC power</span>
<span class="set-row-desc">Sleep normally on battery. A closed laptop in a bag gets hot and drains fast.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsKeepAwakeAcOnly" checked><span class="slider"></span></label>
</div>
<div id="keepAwakeStatus" class="set-note" style="display:none" data-i18n-skip></div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Remote access</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
-55
View File
@@ -424,11 +424,6 @@ 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;
@@ -1229,53 +1224,6 @@ 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');
@@ -2685,8 +2633,6 @@ 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,
@@ -4820,5 +4766,4 @@ document.addEventListener?.('codeman:me', () => {
window.app?._applyCliManagementAdminGate?.();
window.app?._applyMcpSyncAdminGate?.();
window.app?._applyDoctorAdminGate?.();
window.app?._applyKeepAwakeAdminGate?.();
});
+1 -22
View File
@@ -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, isAdmin, requireAdmin, canAccessOwned } from '../route-helpers.js';
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
import {
ConfigUpdateSchema,
SettingsUpdateSchema,
@@ -32,8 +32,6 @@ 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,
@@ -1006,13 +1004,6 @@ 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));
@@ -1041,10 +1032,6 @@ 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
@@ -1095,14 +1082,6 @@ 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 () => {
-8
View File
@@ -1378,14 +1378,6 @@ 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
-11
View File
@@ -111,8 +111,6 @@ 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,
@@ -3144,10 +3142,6 @@ 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()) {
@@ -3982,11 +3976,6 @@ 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();
-207
View File
@@ -1,207 +0,0 @@
/**
* @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<string, string> = {}) {
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<string, string>) {
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('<key>ProgramArguments</key>'), body.indexOf('</array>'));
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');
});
});
-175
View File
@@ -1,175 +0,0 @@
/**
* @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('');
});
});
-270
View File
@@ -1,270 +0,0 @@
/**
* @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<KeepAwakeDeps> = {}) {
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<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length;
await vi.advanceTimersByTimeAsync(120_000);
expect((deps.writeLidRequest as ReturnType<typeof vi.fn>).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();
});
});
-116
View File
@@ -1,116 +0,0 @@
/**
* @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');
});
});
@@ -1,132 +0,0 @@
/**
* @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<string, unknown>, written: [] as Record<string, unknown>[] },
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<typeof import('node:fs')>();
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() });
});
});