Compare commits

..
Author SHA1 Message Date
Codeman maintainer 233f85b170 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 <pid>. 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) <noreply@anthropic.com>
2026-10-10 07:21:57 +02:00
27 changed files with 1971 additions and 219 deletions
+5
View File
@@ -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.
+2
View File
@@ -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) **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) **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) **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. 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"> <p align="center">
<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> <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>
</p> </p>
#### Step 1: install it #### Step 1: install it
+4 -87
View File
@@ -87,93 +87,6 @@ the HTTP status.
Adding a new error code is non-breaking; removing or renaming one is a major change. Adding a new error code is non-breaking; removing or renaming one is a major change.
## Cron jobs
Saved jobs and their launch history are separate from the legacy `/api/scheduled`
duration-bounded loops. Use `/api/v1/cron/...` in external clients; `/api/cron/...`
is the unversioned alias. These routes use the response envelope above; the table
lists the value inside `data` on success.
| Method | Path | Request body | Response `data` |
| --- | --- | --- | --- |
| GET | `/api/v1/cron/jobs` | None | `CronJob[]` |
| POST | `/api/v1/cron/jobs` | Full job definition below | `{ job: CronJob }` |
| GET | `/api/v1/cron/jobs/:id` | None | `CronJob` |
| PUT | `/api/v1/cron/jobs/:id` | Partial job definition | `{ job: CronJob }` |
| DELETE | `/api/v1/cron/jobs/:id` | None | `{}` |
| PUT | `/api/v1/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job: CronJob }` |
| POST | `/api/v1/cron/jobs/:id/run` | None | `{ run: CronJobRun, activeAgents: number }` |
| GET | `/api/v1/cron/jobs/:id/runs` | None | `CronJobRun[]` |
| GET | `/api/v1/cron/runs` | None | `CronJobRun[]` |
### Job request fields
The create body requires `name`, `agentType`, `workingDir`, `promptMode`,
`inputMode`, `scheduleType`, `enabled`, and `concurrencyPolicy`. Additional fields
are required according to the selected prompt and schedule:
| Field | Type / validation |
| --- | --- |
| `name` | String, 1–200 characters |
| `agentType` | A supported session mode (including `shell`) |
| `workingDir` | Existing, allowed working-directory path |
| `launchCommand` | Optional single-line string, at most 2000 characters; for shell jobs |
| `promptMode` | `inline_text` or `prompt_file_path` |
| `promptText` | Required for `inline_text`; nonempty single-line string, at most 100000 characters |
| `promptFilePath` | Required for `prompt_file_path`; absolute path inside `workingDir` to a regular file, at most 1 MiB, read when the job fires |
| `inputMode` | `paste` or `typed` |
| `scheduleType` | `once`, `interval`, `daily`, or `weekly` |
| `runAt` | Required for `once`; positive integer Unix timestamp in milliseconds |
| `intervalMinutes` | Required for `interval`; integer from 1 to 525600 |
| `dailyTime` | Required for `daily`; `HH:MM` in server-local time |
| `weeklyDays` | Required for `weekly`; 1–7 weekday integers, 0 (Sunday) through 6 (Saturday) |
| `weeklyTime` | Required for `weekly`; `HH:MM` in server-local time |
| `enabled` | Boolean |
| `concurrencyPolicy` | `warn_only` or `skip_if_same_agent_running`; scheduled runs only |
| `autoClosePreviousSession` | Optional boolean, default `true`; ignored for `once` |
| `notes` | Optional string, at most 2000 characters |
`PUT /jobs/:id` accepts any subset of these fields, then validates the merged job.
When changing `promptMode` or `scheduleType`, supply the fields the new mode needs.
`Run Now` works even when the job is disabled, bypasses the scheduled concurrency
policy, and does not change the schedule. `activeAgents` counts live sessions of
the same agent type, excluding sessions created by this job.
For recurring jobs with `autoClosePreviousSession` enabled (the default), `Run Now`
also closes the previous run's session before launching, even if it is still working.
### Job and run response fields
`CronJob` contains the request fields plus server-maintained `id`, optional
`owner` (multi-user mode), `createdAt`, `updatedAt`, `lastRunAt`, `nextRunAt`,
`lastStatus`, `lastDueKey`, and optional `completedOnce`. Times are Unix
milliseconds; `lastRunAt`, `nextRunAt`, `lastStatus`, and `lastDueKey` can be `null`.
`lastDueKey` is an opaque internal duplicate-launch guard, not a stable API format.
`CronJobRun` contains `id`, `cronJobId`, nullable `sessionId` and `sessionName`,
`startedAt`, nullable `finishedAt`, `status`, optional `errorMessage`,
`triggerType` (`scheduled` or `manual_run_now`), and nullable `createdSessionUrl`.
Run times are also Unix milliseconds. Status is one of `created`,
`session_started`, `prompt_sent`, `failed`, or `skipped`.
Prompt delivery continues asynchronously after session launch, so `Run Now` can
return `session_started` before the prompt is sent. Read run history for subsequent
updates, but do not assume a terminal status will follow: if the session is closed
during the readiness wait or the server restarts before delivery, the run can remain
`session_started` indefinitely with `finishedAt: null`.
`finishedAt` refers to the launch/prompt-delivery attempt, **not completion
of the agent's task**; `prompt_sent` does not prove that the task succeeded.
In multi-user mode, list/history endpoints filter to accessible jobs. An unknown
or inaccessible job returns `NOT_FOUND`. Job creation and updates can return
`403 FORBIDDEN` for a working directory outside the owner's workspace or a shell /
launch-command job without the required privilege grant. Invalid definitions or
working directories return `INVALID_INPUT`; launch/delivery failures are recorded
on the run, so inspect its `status` and `errorMessage` even after an HTTP success.
See [Cron Jobs](wiki/Cron-Jobs.md) for the UI, scheduling, and prompt-file rules.
See the [complete cron guide](cron-guide.md) for the `cron:runCreated` and
`cron:runUpdated` SSE events.
## Long-polling (agent wait) ## Long-polling (agent wait)
Three calls block until something happens instead of answering immediately. They Three calls block until something happens instead of answering immediately. They
@@ -947,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. `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 ## Voice dictation
Browser dictation transcribed through this server's Claude Code login, i.e. the Browser dictation transcribed through this server's Claude Code login, i.e. the
+2 -2
View File
@@ -5,7 +5,7 @@ spin up a Claude (or shell / OpenCode / Codex / Antigravity / Gemini / Pi) sessi
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
Claude session in `~/proj` and tell it to update dependencies and open a PR."_ Claude session in `~/proj` and tell it to update dependencies and open a PR."_
- **UI**: the **⏰ Cron** button in the bottom toolbar → the Cron Jobs modal (`#cronModal`). - **UI**: the **⏰ Cron** button in the header → the Cron Jobs modal (`#cronModal`).
- **API**: `/api/cron/jobs*` and `/api/cron/runs`. - **API**: `/api/cron/jobs*` and `/api/cron/runs`.
- **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`, - **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`,
types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`, types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`,
@@ -24,7 +24,7 @@ Claude session in `~/proj` and tell it to update dependencies and open a PR."_
### In the browser ### In the browser
1. Click **⏰ Cron** in the bottom toolbar. 1. Click **⏰ Cron** in the header.
2. Click **+ New Job**. 2. Click **+ New Job**.
3. Fill in a **name**, pick an **agent type** and **working directory**, choose a 3. Fill in a **name**, pick an **agent type** and **working directory**, choose a
**prompt** (inline text or a file path), pick a **schedule**, and leave **prompt** (inline text or a file path), pick a **schedule**, and leave
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 KiB

+7 -15
View File
@@ -4,8 +4,8 @@ Saved, named jobs that start a session and send it a prompt on a schedule. Cron
sessions: *every weekday at 03:00, open a Claude session in `~/proj` and tell it to update sessions: *every weekday at 03:00, open a Claude session in `~/proj` and tell it to update
dependencies and open a PR.* dependencies and open a PR.*
The ⏰ **Cron** button in the bottom toolbar is opt-in. Turn it on under The ⏰ **Cron** header button is opt-in. Turn it on in
**App Settings → Header & Panels → Scheduling**. **App Settings → Header & Panels**.
## Creating a job ## Creating a job
@@ -96,19 +96,11 @@ The skip policy has the details you would want it to have:
Every fire is recorded per job, with a status: Every fire is recorded per job, with a status:
| Status | Meaning | | Status | Meaning |
| ----------------- | ----------------------------------------------------------------- | | --------- | -------------------------------------------------------------------- |
| `created` | The run record was created; the session has not started yet. | | `created` | The run started and a session was created. |
| `session_started` | The session started; prompt delivery is still pending. | | `skipped` | The concurrency policy blocked it. Not counted as a run. |
| `prompt_sent` | The prompt was sent to the session. | | `failed` | The prompt could not be resolved, or the working directory was gone. |
| `skipped` | The concurrency policy blocked it. Not counted as a run. |
| `failed` | Prompt resolution, session launch, or prompt delivery failed. |
History records whether the session started and the prompt was delivered, **not whether
the agent's task succeeded**. `prompt_sent` is not a task-completion signal.
If the session is closed during the readiness wait or the server restarts before
delivery, a run can remain `session_started` indefinitely with `finishedAt: null`;
clients must not poll forever waiting for a terminal status.
The schedule is advanced **before** the session launches, so a slow start cannot cause the The schedule is advanced **before** the session launches, so a slow start cannot cause the
same job to re-trigger. same job to re-trigger.
-26
View File
@@ -92,32 +92,6 @@ Roughly 235 handlers across 26 route modules. By domain:
Each route module documents its own endpoints in its file header. Each route module documents its own endpoints in its file header.
## Cron jobs
Saved scheduled jobs are distinct from the legacy `/api/scheduled` loops. Their
versioned endpoints are:
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/v1/cron/jobs` | List jobs |
| POST | `/api/v1/cron/jobs` | Create a job |
| GET | `/api/v1/cron/jobs/:id` | Read a job |
| PUT | `/api/v1/cron/jobs/:id` | Update a job (partial body) |
| DELETE | `/api/v1/cron/jobs/:id` | Delete a job |
| PUT | `/api/v1/cron/jobs/:id/enabled` | Enable/disable with `{ enabled: boolean }` |
| POST | `/api/v1/cron/jobs/:id/run` | Run now, without changing the schedule |
| GET | `/api/v1/cron/jobs/:id/runs` | Read a job's run history |
| GET | `/api/v1/cron/runs` | Read all accessible run history |
Responses use the envelope above: job lists/history have arrays in `data`, a
single-job GET has the job itself, create/update/enable have `{ job }`, delete has
`{}`, and Run Now has `{ run, activeAgents }`. Prompt delivery is asynchronous;
`session_started` and `prompt_sent` describe launch/delivery, not task success.
The [cron API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#cron-jobs)
lists all request and response fields, validation, and ownership restrictions.
[Creating a job](Cron-Jobs) covers the UI and schedule semantics.
## Long-polling instead of polling ## Long-polling instead of polling
Three calls block until something happens, so an agent driving Codeman from a shell can wait Three calls block until something happens, so an agent driving Codeman from a shell can wait
+6
View File
@@ -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 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 macOS LaunchAgent that starts on boot; answering no offers to start it in this terminal
instead, or not at all. 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 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. 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 tailscale # retrofit Tailscale access onto an existing install
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>) 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 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 --`: **Flags** answer the questions from the command line and pipe through `bash -s --`:
+8 -2
View File
@@ -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 `CLAUDE.md` template for new cases, default working directory, the image watcher, and
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs 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 `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 tools with their versions and install hints (admin only in multi-user mode). The **Power**
mode, the **Users** administration entry is injected here. 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 ## Session Options
+1 -1
View File
@@ -258,7 +258,7 @@ Two extras depending on the device:
| Respawn | Session Options | [Keeping Agents Running](Keeping-Agents-Running) | | Respawn | Session Options | [Keeping Agents Running](Keeping-Agents-Running) |
| Ralph | Session Options | [Autonomous Loops](Autonomous-Loops) | | Ralph | Session Options | [Autonomous Loops](Autonomous-Loops) |
| Orchestrator | Toolbar | [Autonomous Loops](Autonomous-Loops) | | Orchestrator | Toolbar | [Autonomous Loops](Autonomous-Loops) |
| Cron | Bottom toolbar ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) | | Cron | Header ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) |
| Subagents | Automatic while agents run | [Watching Agents Work](Watching-Agents-Work) | | Subagents | Automatic while agents run | [Watching Agents Work](Watching-Agents-Work) |
| Ultracode | Header (opt-in) | [Watching Agents Work](Watching-Agents-Work) | | Ultracode | Header (opt-in) | [Watching Agents Work](Watching-Agents-Work) |
| File Viewer | Header | [Working With Files](Working-With-Files) | | File Viewer | Header | [Working With Files](Working-With-Files) |
+258 -28
View File
@@ -7,8 +7,9 @@
# #
# The flow: look at what is already on the machine, ask at most three # 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 # 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 # machine on your tailnet, whether to run Codeman as a service; on a laptop,
# the work unattended and end on the URL, with a QR code for your phone. # 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): # Flags (each has an environment-variable twin, listed below):
# --tailscale | --lan | --local How the dashboard is reached (question 1) # --tailscale | --lan | --local How the dashboard is reached (question 1)
@@ -48,6 +49,7 @@
# install.sh name [<n>] - Rename this machine on your tailnet (default: codeman-<hostname>) # 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 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 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 set -euo pipefail
@@ -129,6 +131,18 @@ SUBCOMMAND_ARG=""
# alone) | empty (no service manager here). # alone) | empty (no service manager here).
LAUNCH_CHOICE="3" LAUNCH_CHOICE="3"
SERVICE_TYPE="" 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 # Everything the unattended steps print goes here; the terminal gets one line
# per step and the tail of this file on failure. # 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 # 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 # 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. # 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() { verify_systemd_active() {
local attempt local attempt
for attempt in 1 2 3; do for attempt in 1 2 3; do
@@ -3032,6 +3240,7 @@ main() {
choose_network_binding choose_network_binding
echo "" echo ""
choose_launch_mode "$os" choose_launch_mode "$os"
choose_keep_awake "$os"
echo "" echo ""
# ======================================================================== # ========================================================================
@@ -3050,6 +3259,9 @@ main() {
# (symlinks, PATH, launch menu) instead of silently "updating". # (symlinks, PATH, launch menu) instead of silently "updating".
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete" 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" local service_ok="true"
if [[ "$LAUNCH_CHOICE" == "2" ]]; then if [[ "$LAUNCH_CHOICE" == "2" ]]; then
if [[ "$SERVICE_TYPE" == "launchd" ]]; then if [[ "$SERVICE_TYPE" == "launchd" ]]; then
@@ -3493,6 +3705,36 @@ print_done_screen() {
return 0 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() { update() {
if [[ ! -d "$INSTALL_DIR/.git" ]]; then if [[ ! -d "$INSTALL_DIR/.git" ]]; then
die "Codeman is not installed at $INSTALL_DIR. Run the installer first." 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)")" success "Updated to $(node -e "console.log(require('./package.json').version)")"
echo "" echo ""
# Auto-restart service if running, otherwise tell the user 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 # The lid helper runs from a root-owned copy, so an update never reaches it
info "Restarting codeman-web service..." # on its own (and must not: that would need sudo here). Say so when it drifted.
systemctl --user restart codeman-web.service 2>/dev/null || true if [[ -f "$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh" ]] &&
if verify_systemd_active; then ! cmp -s "$INSTALL_DIR/scripts/keep-awake-macos.sh" "$KEEP_AWAKE_HELPER_DIR/keep-awake-macos.sh"; then
success "codeman-web service restarted" info "The keep-awake lid helper has a newer version. Refresh it with: install.sh keep-awake"
else echo ""
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 fi
echo ""
# Reflect the service's actual binding in the closing notice. Updates # Reflect the service's actual binding in the closing notice. Updates
# never rewrite the service files, so the existing choice is authoritative. # never rewrite the service files, so the existing choice is authoritative.
@@ -3603,6 +3828,9 @@ uninstall() {
rm -f "$agent_plist" rm -f "$agent_plist"
success "Removed LaunchAgent" success "Removed LaunchAgent"
fi fi
if [[ "$(uname -s)" == "Darwin" ]]; then
remove_keep_awake_lid_helper
fi
if [[ -f "$daemon_plist" ]]; then if [[ -f "$daemon_plist" ]]; then
# This installer never writes a LaunchDaemon (setup_launchd_service # This installer never writes a LaunchDaemon (setup_launchd_service
# leaves one alone), so this one is the user's own headless-Mac setup: # leaves one alone), so this one is the user's own headless-Mac setup:
@@ -3721,6 +3949,7 @@ Subcommands
name [<n>] Rename this machine on your tailnet (default: codeman-<hostname>) name [<n>] Rename this machine on your tailnet (default: codeman-<hostname>)
status Print the URLs, the QR code and how to manage the service status Print the URLs, the QR code and how to manage the service
cloudflared Install cloudflared for the in-app Cloudflare tunnel 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, Environment: CODEMAN_NONINTERACTIVE=1, CODEMAN_INSTALL_DIR, CODEMAN_HOST,
CODEMAN_PASSWORD, CODEMAN_PORT, CODEMAN_TAILSCALE=1, CODEMAN_TAILSCALE_NAME, CODEMAN_PASSWORD, CODEMAN_PORT, CODEMAN_TAILSCALE=1, CODEMAN_TAILSCALE_NAME,
@@ -3762,7 +3991,7 @@ parse_flags() {
CODEMAN_PORT="$1"; export CODEMAN_PORT; RECONFIGURE="1" ;; CODEMAN_PORT="$1"; export CODEMAN_PORT; RECONFIGURE="1" ;;
--port=*) CODEMAN_PORT="${1#--port=}"; export CODEMAN_PORT; RECONFIGURE="1" ;; --port=*) CODEMAN_PORT="${1#--port=}"; export CODEMAN_PORT; RECONFIGURE="1" ;;
--help|-h) usage; exit 0 ;; --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)." [[ -z "$SUBCOMMAND" ]] || die "Only one subcommand at a time ($SUBCOMMAND and $1 given)."
SUBCOMMAND="$1" ;; SUBCOMMAND="$1" ;;
-*) die "Unknown option: $1 (see --help)" ;; -*) die "Unknown option: $1 (see --help)" ;;
@@ -3795,6 +4024,7 @@ case "$SUBCOMMAND" in
name) setup_name_subcommand ;; name) setup_name_subcommand ;;
status) status_subcommand ;; status) status_subcommand ;;
cloudflared) cloudflared_subcommand ;; cloudflared) cloudflared_subcommand ;;
keep-awake) keep_awake_subcommand ;;
*) *)
# Only a COMPLETED install re-runs as a quiet update. A partial one # Only a COMPLETED install re-runs as a quiet update. A partial one
# (clone succeeded but build/menu never finished) lacks the marker and # (clone succeeded but build/menu never finished) lacks the marker and
+62
View File
@@ -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 <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
@@ -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 <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
@@ -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/<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,6 +3107,27 @@
</div> </div>
</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">
<div class="set-group-head"><h4>Remote access</h4><span class="set-scope">synced</span></div> <div class="set-group-head"><h4>Remote access</h4><span class="set-scope">synced</span></div>
<div class="set-group-body"> <div class="set-group-body">
+55
View File
@@ -424,6 +424,11 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn; document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
this.applyMcpSyncVisibility(); this.applyMcpSyncVisibility();
this._applyDoctorAdminGate(); 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(); this.loadWebhook();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens). // Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true; document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
@@ -1224,6 +1229,53 @@ Object.assign(CodemanApp.prototype, {
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : ''; 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. */ /** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
async mcpSync(apply) { async mcpSync(apply) {
const out = this.$('mcpSyncResult'); const out = this.$('mcpSyncResult');
@@ -2633,6 +2685,8 @@ Object.assign(CodemanApp.prototype, {
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked, agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked, agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked,
workspaceHooksEnabled: document.getElementById('appSettingsWorkspaceHooks').checked, workspaceHooksEnabled: document.getElementById('appSettingsWorkspaceHooks').checked,
keepAwakeEnabled: document.getElementById('appSettingsKeepAwake').checked,
keepAwakeAcOnly: document.getElementById('appSettingsKeepAwakeAcOnly').checked,
claudeVoiceEnabled: document.getElementById('appSettingsClaudeVoice').checked, claudeVoiceEnabled: document.getElementById('appSettingsClaudeVoice').checked,
claudeModel: document.getElementById('appSettingsClaudeModel').value, claudeModel: document.getElementById('appSettingsClaudeModel').value,
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked, opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
@@ -4766,4 +4820,5 @@ document.addEventListener?.('codeman:me', () => {
window.app?._applyCliManagementAdminGate?.(); window.app?._applyCliManagementAdminGate?.();
window.app?._applyMcpSyncAdminGate?.(); window.app?._applyMcpSyncAdminGate?.();
window.app?._applyDoctorAdminGate?.(); window.app?._applyDoctorAdminGate?.();
window.app?._applyKeepAwakeAdminGate?.();
}); });
+22 -1
View File
@@ -17,7 +17,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } f
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js'; import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import { isMultiUserMode } from '../../config/multiuser.js'; import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.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 { import {
ConfigUpdateSchema, ConfigUpdateSchema,
SettingsUpdateSchema, SettingsUpdateSchema,
@@ -32,6 +32,8 @@ import {
import { subagentWatcher } from '../../subagent-watcher.js'; import { subagentWatcher } from '../../subagent-watcher.js';
import { imageWatcher } from '../../image-watcher.js'; import { imageWatcher } from '../../image-watcher.js';
import { workflowRunWatcher } from '../../workflow-run-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 { getLifecycleLog } from '../../session-lifecycle-log.js';
import { import {
buildAwayDigest, buildAwayDigest,
@@ -1004,6 +1006,13 @@ export function registerSystemRoutes(
// acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip // acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip
// it before persisting so settings.json stays clean. // it before persisting so settings.json stays clean.
const { acknowledgeUnauthTunnel, ...settingsToStore } = settings; 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 }; const merged = { ...existing, ...settingsToStore };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
@@ -1032,6 +1041,10 @@ export function registerSystemRoutes(
'Workflow run watcher' '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 // Handle image watcher toggle dynamically
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => { toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
// Re-watch all active sessions that have image watcher enabled // 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 ========== // ========== Model Configuration ==========
app.get('/api/execution/model-config', async () => { app.get('/api/execution/model-config', async () => {
+8
View File
@@ -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. * opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
*/ */
mcpSyncEnabled: z.boolean().optional(), 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 * Read My Mind predictor model override. Empty/absent = the AI-checker
* default (opus: prediction quality is the product and it runs only on an * default (opus: prediction quality is the product and it runs only on an
+11
View File
@@ -111,6 +111,8 @@ import { intentStore } from '../intent-store.js';
import { AI_CHECK_MODEL } from '../config/ai-defaults.js'; import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js'; import { approvalInbox } from './approval-inbox.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js'; import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import { keepAwake } from '../keep-awake-manager.js';
import { resolveKeepAwakeConfig } from '../keep-awake.js';
import { import {
wireRespawnListeners, wireRespawnListeners,
setupTimedRespawn, setupTimedRespawn,
@@ -3142,6 +3144,10 @@ export class WebServer extends EventEmitter {
console.log('Image watcher disabled by user settings'); 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. // Tunnel only starts when user clicks the toggle in the UI — never on boot.
// Reset persisted tunnelEnabled so the UI toggle reflects actual state. // Reset persisted tunnelEnabled so the UI toggle reflects actual state.
if (await this.isTunnelEnabled()) { if (await this.isTunnelEnabled()) {
@@ -3976,6 +3982,11 @@ export class WebServer extends EventEmitter {
// got wrong once. // got wrong once.
void stopDeepSeekWeb(); 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 // 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. // only by the periodic idle sweep, whose interval is disposed just below.
closeAllLlamaSwapLogTails(); closeAllLlamaSwapLogTails();
-56
View File
@@ -1,56 +0,0 @@
/** @fileoverview Guards cron docs against drift from routes, schema, statuses, and UI entry points. */
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { CronJobSchema } from '../src/web/schemas.js';
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const reference = read('docs/api-reference.md');
const wiki = read('docs/wiki/HTTP-API.md');
const guide = read('docs/wiki/Cron-Jobs.md');
const cronSection = (text: string) => text.split('## Cron jobs\n')[1]?.split('\n## ')[0] ?? '';
describe('cron documentation', () => {
it('documents every cron route in both API references', () => {
const routes = [...read('src/web/routes/cron-routes.ts').matchAll(/app\.(get|post|put|delete)\('([^']+)'/g)];
expect(routes).toHaveLength(9);
for (const [, method, path] of routes) {
const row = `| ${method.toUpperCase()} | \`${path.replace('/api/', '/api/v1/')}\` |`;
expect(cronSection(reference)).toContain(row);
expect(cronSection(wiki)).toContain(row);
}
});
it('documents every accepted job field and validates the guide example', () => {
for (const field of Object.keys(CronJobSchema.shape)) {
expect(cronSection(reference)).toContain(`| \`${field}\` |`);
}
const example = guide.match(/-d '(\{[\s\S]*?\})'/)?.[1];
expect(example).toBeDefined();
expect(CronJobSchema.safeParse(JSON.parse(example!)).success).toBe(true);
});
it('documents all run statuses without treating prompt delivery as task success', () => {
const statusType = read('src/types/cron.ts').match(/export type CronJobRunStatus = ([^;]+);/)?.[1];
expect(statusType).toBeDefined();
for (const [, status] of statusType!.matchAll(/'([^']+)'/g)) {
expect(guide).toContain(`| \`${status}\``);
expect(cronSection(reference)).toContain(`\`${status}\``);
}
expect(guide.replace(/\s+/g, ' ')).toContain("not whether the agent's task succeeded");
expect(guide).toContain('bottom toolbar');
expect(guide).toContain('App Settings → Header & Panels → Scheduling');
});
it('keeps linked pages consistent and explains non-terminal launch history', () => {
expect(read('docs/wiki/The-Dashboard.md')).toMatch(/\| Cron\s*\| Bottom toolbar/);
expect(read('docs/cron-guide.md')).not.toMatch(/Cron\*\* (?:button )?in the header/);
for (const text of [reference, guide]) {
const normalized = text.replace(/\s+/g, ' ');
expect(normalized).toContain('session is closed during the readiness wait');
expect(normalized).toContain('server restarts before delivery');
expect(normalized).toContain('`session_started` indefinitely with `finishedAt: null`');
}
expect(reference).toContain("also closes the previous run's session before launching");
});
});
+207
View File
@@ -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<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
@@ -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('');
});
});
+270
View File
@@ -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<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
@@ -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');
});
});
@@ -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<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() });
});
});