mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
fix(remote): close the wake-state leaks and the dishonest wake budget
Review follow-up on the wake-on-LAN PR (five findings, all of them about the state the feature keeps and the budgets it inherits): - Wake state is dropped by `WebServer.cleanupSession` instead of the two delete routes, so it now goes with the session on EVERY cleanup path (cron, admin, scheduled-run teardown, error paths) instead of surviving with up to 4 KB of the user's buffered keystrokes. `registerSessionRoutes` returns the registry so the server can own its lifetime without the wake-capable code living in `server.ts`; the wiring guard is updated to allow that and gains a second assertion that `server.ts` calls nothing but `drop`/`stop` on it. - `_effectiveRemote` returns before `_state`, so a LOCAL session no longer gets a wake-state entry — the input gate runs on every keystroke, so that entry used to be allocated for every session the user types in. - An input chunk larger than the 4 KB cap is dropped OUTRIGHT instead of being head-trimmed and then written as a fragment: one paste is one `input` value and was never typed character by character, so its tail is a partial command the user never sent. The drop is logged. - The manual wake button passes `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS` (40 s) like the create/attach paths, instead of inheriting the 90 s session default that the dashboard's reverse proxy cuts off at 60 s. - `RemoteWakeRegistry.stop()` aborts in-flight readiness polls (abortable sleep) and refuses new wakes, and `WebServer.stop()` calls it, so a restart during a wake no longer waits the poll out. - The banner/toast wording keys off a new `queuedInput` flag on the two SSE events, which is true only when the server actually holds bytes: browser keystrokes travel over the WebSocket, which never passes through the registry, so the wake BUTTON must not promise queued input. The failed-wake path also stops pattern-matching the error message (it re-asks the reachability route) and the WoL dialog says "admin-only" instead of "host not found" for a non-admin in multi-user mode.
This commit is contained in:
1 parent
a7f74f374f
commit
7b947fa3f1
7 files changed
+338
-74
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Architecture invariants
|
||||
|
||||
Implementation detail extracted from `CLAUDE.md` so that file stays small enough to load into every session cheaply. Most sections are the original paragraphs, verbatim, including the version history and PR references that explain _why_ each rule exists; newer ones are written here first and summarized back into `CLAUDE.md` as a short rule plus a pointer.
|
||||
Implementation detail extracted from `CLAUDE.md` so that file stays small enough to load into every session cheaply. Most sections are the original paragraphs, verbatim, including the version history and PR references that explain *why* each rule exists; newer ones are written here first and summarized back into `CLAUDE.md` as a short rule plus a pointer.
|
||||
|
||||
`CLAUDE.md` keeps the short form of each rule plus a pointer to the section here. Read the pointer first; come here when you need the mechanism, the file names, or the history behind a constraint.
|
||||
|
||||
@@ -54,8 +54,6 @@ Model is NOT a session field: it is a composition entry in the profile's config
|
||||
|
||||
### Remote SSH cases
|
||||
|
||||
**Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeMac` (magic packet built and broadcast by Codeman) or `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell, and the explicit override) lets the input route — and an explicit `POST /api/sessions/:id/wake` — wake a SLEEPING host instead of writing into a stalled ssh pane; `tmux send-keys` succeeds against a stalled pane, so the bytes used to vanish silently. The wake flow lives in `src/remote-wake.ts` and is reachable **only** from an EXPLICIT user request: `POST /api/sessions/:id/input`, that explicit wake route, and the create/attach path (`POST /api/quick-start` for a remote case, `POST /api/sessions` with `attachRemoteSession`, via `ensureHostAwake`), because "the user pressed Run on a sleeping host" is the same kind of request and the tmux probe would otherwise fail with a misleading "needs tmux installed". Everything TIMER-driven must never wake a host: the COD-108 auto-reconnect watcher, `Server.handleRemoteSessionDropped` and boot recovery have no access to the registry, or a host would be re-woken seconds after each suspend and could never stay asleep (asserted by wiring guards in `test/remote-wake.test.ts`, not just documented — including that `ensureHostAwake` is called from the HTTP route only, since `cron-service.ts` builds sessions through the shared service with nobody waiting on the answer). `GET /api/sessions/:id/reachability` only ASKS — it never wakes — and feeds the amber "host unreachable" banner (`host-wake-ui.js`) whose action is either Wake or, with no target configured, "Configure WoL" → `#wakeConfigModal` (saved via `PUT /api/remote-hosts/:id`). Detection is a throttled bare TCP probe (no ssh, no `ServerAliveInterval` — keepalives would move bytes into an idle connection every interval), input is buffered and flushed in order after `reattachRemote()` (the send-and-wait path blocks instead, as does the create path, with a shorter request budget), and the wake fields are re-read from `remote-hosts.json` on recovery AND (throttled, cached) live for a running session, because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields` + `RemoteWakeDeps.resolveRemote`). Design + invariants: `docs/remote-sessions.md` §Wake-on-LAN from user input.
|
||||
|
||||
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`.
|
||||
|
||||
### Docker cases
|
||||
@@ -106,7 +104,7 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do
|
||||
|
||||
### Session lineage lines (tab → tab it spawned)
|
||||
|
||||
**The relationship did not exist before this** (1.17.0): `SessionState` had no `parentSessionId`, `quick-start` recorded only the multi-user _human_ owner, and an agent's spawn call is plain `curl` from a tmux pane, so nothing in the request identifies the caller (`SO_PEERCRED` needs a unix socket; the API is TCP). The caller therefore supplies it — every managed pane already gets `CODEMAN_SESSION_ID` from `session-cli-builder.ts`. Two equivalent inputs, body wins: a `parentSessionId` field on `POST /api/sessions` / `POST /api/quick-start`, or the `X-Codeman-Parent-Session` header, which exists so the agent skill can set it ONCE on its shared curl invocation and have every present and future spawn recipe carry it.
|
||||
**The relationship did not exist before this** (1.17.0): `SessionState` had no `parentSessionId`, `quick-start` recorded only the multi-user *human* owner, and an agent's spawn call is plain `curl` from a tmux pane, so nothing in the request identifies the caller (`SO_PEERCRED` needs a unix socket; the API is TCP). The caller therefore supplies it — every managed pane already gets `CODEMAN_SESSION_ID` from `session-cli-builder.ts`. Two equivalent inputs, body wins: a `parentSessionId` field on `POST /api/sessions` / `POST /api/quick-start`, or the `X-Codeman-Parent-Session` header, which exists so the agent skill can set it ONCE on its shared curl invocation and have every present and future spawn recipe carry it.
|
||||
|
||||
**Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart.
|
||||
|
||||
@@ -116,6 +114,20 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do
|
||||
|
||||
⚠️ **`data-agent-id="lineage:<childId>"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute, so tagging them this way is the whole reason the arcs get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` is `overflow-x: auto`, so a tab scrolled out of the strip still HAS a rect — one lying over the logo or the header buttons; edges with an endpoint outside the strip are SKIPPED (clamping would point at a tab that is not there), and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. The incremental tab render also redraws when `_lineageEdgeCount > 0`: a badge appearing widens a tab and shifts every tab after it. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths).
|
||||
|
||||
### Auto-named sessions (first prompt → tab title)
|
||||
|
||||
**Shipped opt-in, in the prefix form, after a review round that found five ways the first cut named a tab wrong** (#376, 1.30.0). The contributed version renamed on EVERY prompt (`applyAutoName` never left the eligible state, so "fix the login bug" then "1" left the tab named **1**), fed its tracker from every write path (shell tabs renamed after each command, every Ralph and respawn tab named "Read @ralph_prompt.md and follow the instructions."), stayed in escape mode after a bare Esc until a byte in `0x40-0x7e` arrived (Esc then "fix the login bug" submitted **ix the login bug**, Esc then a CJK prompt submitted nothing and the following prompt lost its first character, Esc then digits grew the escape buffer to 19001 characters), treated the newlines inside a bracketed paste as Enter, cleared the draft on ANY CSI (including the SGR wheel reports Codeman forwards to claude ≥ 2.1.187, so "fix the " + wheel + "login bug" gave **login bug**) and on Tab (the `@` completer), and replaced the whole name, which dropped the case from the tab and reset `_nextCaseSessionStartNumber()` so every new session in the case became `w1-<case>` again. Each of those is a named rule in `session-auto-name.ts` with a test.
|
||||
|
||||
**Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w<n>-<case>` / `s<n>-<case>`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab.
|
||||
|
||||
**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` line feed never passes through the session.
|
||||
|
||||
**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes.
|
||||
|
||||
**Title and composition.** `deriveAutoSessionName()` strips CSI/control bytes, refuses slash commands by the `/^\/[a-z][a-z0-9_:-]*(\s|$)/i` shape (a path has a second slash where the whitespace should be, so `/home/me/notes.txt what is this` is a prompt) and `!` shell escapes, cuts at the first sentence terminator only past 8 code points ("e.g. fix this now" is not "e.g."), drops a trailing full stop, and caps at 72 code points on a word boundary. `composeAutoSessionName()` prepends the placeholder (`w3-myapp: fix the login redirect`) and fits the result into `MAX_SESSION_NAME_LENGTH` in UTF-16 units, the unit the rename route caps in.
|
||||
|
||||
**Opt-in, and the listener orders its checks for cost.** The prompt lands in the tab name, `mux-sessions.json`, every `session:updated` broadcast, the TUI, both home screens and `/api/search` (which matches on `sessionName`), while Read My Mind deliberately keeps prompts 0600 and out of search because prompts can carry secrets; so `autoNameSessions` is synced and default OFF, like `agentSkillEnabled`, `approvalsInboxEnabled` and `readMyMindEnabled`. The listener checks `nameSource` and derives the title BEFORE reading `settings.json`, so an already-named session costs nothing per prompt.
|
||||
|
||||
### Full-scrollback replay
|
||||
|
||||
**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection and automatic drop recovery: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell full history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, while the marker callback supplies accurate parse timing without extending the pre-existing queued-event discard window. Live output is separately one-chunk-in-flight: xterm's callback releases each 32/64 KiB write before the next is submitted, keeping the remainder in the app queue where the 128 KiB cap can observe it instead of hiding an unbounded backlog in xterm's private WriteBuffer. While WebSocket owns terminal I/O, parallel SSE terminal/output-recovery events are discarded before JSON parsing; fallback recovery is single-flight per active session so backpressure cannot start overlapping reset+replay cycles. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The capture ENDS with a cursor move back to the pane's own caret position** (`formatCursorRestore`, from the same `display-message` query the visible-frame path uses). The linear replay otherwise leaves the caret wherever the last character landed — the bottom-most row carrying text, which for an agent CLI is the status line — so the caret sat on the composer's border instead of its input line and every cursor-relative update the CLI sent afterwards was measured from the wrong row, until its next full redraw silently repaired it (that self-repair is why the report read as "it fixes itself as soon as Claude writes a line"). ⚠️ **The move is RELATIVE — up `rows - 1 - cursor_y`, then `\r`, then right `cursor_x` — never `CUP`.** `\x1b[<row>;<col>H` numbers rows from the top of the browser's screen, so it lands correctly only while the browser's row count equals `pane_height`, and nothing guarantees that: `resizeWindow` issues its tmux resize fire-and-forget and returns immediately, so a capture can be taken before a requested resize has applied, and `_onSessionNeedsRefresh` sends no resize at all. Counting up from the last replayed row anchors to the content both ends share. Restoring the cursor makes ROW ALIGNMENT load-bearing on this path: **no transform that can DELETE A LINE may run over a full-history capture**, because every deletion shifts the frame out from under the restored position. Four had accumulated — trailing blank rows stripped by `\n+$`, `stripInkRedrawBloat`, the `CLAUDE_BANNER_PATTERN` trim that cuts everything above the banner, and `LEADING_WHITESPACE_PATTERN` — each correct for a byte stream of successive frames and each wrong for a single rendered frame. ⚠️ **Those skips key on `isFullCapture`, meaning a capture actually came back — never on `?full=1` alone.** When `captureActivePaneBuffer` returns null (ENOBUFS, a timeout, a vanished pane,Line truncated
|
||||
@@ -166,7 +178,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
|
||||
|
||||
**Media is single-sourced across the two preview paths.** `VIDEO_ATTACHMENT_EXTENSIONS` / `AUDIO_ATTACHMENT_EXTENSIONS` live in `attachment-registry.ts` and are imported by `file-content`'s media classification, so a clip plays identically whether it is in the workspace or reached by id from outside it. They diverged first: the workspace path had its own inline sets and the registry allowlist had no media at all, so a video an agent wrote to `/tmp` was refused as an unsupported type while the same file inside the repo played. ⚠️ Three things have to line up for a player rather than a dead frame: the extension in the allowlist, a **real MIME entry** in `MIME_TYPES` (a `<video>` refuses to decode `application/octet-stream`, which presents as a player that renders and then does nothing), and the range-aware body (`serveRawFile` → `sendFileBody`) that makes the scrub bar work. `getAttachmentType()` returns the `video`/`audio` members of `AttachmentDetectedType` for them; the attachment card has no per-type CSS and its thumbnail falls back to the type label, since `generateFirstPageThumbnail` has no media branch and answers 204. ⚠️ The image-watcher keeps its OWN narrow detection list (`png/pdf/docx/pptx`), so this does not start popping cards for every video an agent writes.
|
||||
|
||||
⚠️ **The preview overlay must outrank the panel that launched it.** `.file-preview-overlay` sits at `z-index: 5100`, above the response viewer (5000) and its backdrop (4999); at its historical 2000 a path clicked in the chat opened the overlay _behind_ the chat, which reads as a dead link. It stays below the toast/picker band (10000+) so a "Saved" toast still lands on top.
|
||||
⚠️ **The preview overlay must outrank the panel that launched it.** `.file-preview-overlay` sits at `z-index: 5100`, above the response viewer (5000) and its backdrop (4999); at its historical 2000 a path clicked in the chat opened the overlay *behind* the chat, which reads as a dead link. It stays below the toast/picker band (10000+) so a "Saved" toast still lands on top.
|
||||
|
||||
### Filesystem path picker
|
||||
|
||||
@@ -188,7 +200,7 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
|
||||
- **Confinement is the read path's, plus write-only gates.** `findSessionOrFail` (ownership) → `validateSessionFilePath` (realpath + workspace boundary; escapes report as 404, same as reads) → sensitive-path + attachment-guard blocklists (403) → `.git/` subtree deny (403 — `.git/hooks/*` is code execution) → extension **allowlist** (400; `svg` and `env` deliberately excluded). ⚠️ **There is no `O_CREAT` anywhere in the handler** — that absence is what makes "edit-in-place only, never create" a structural property instead of a convention. Do not add a create path without treating it as a new security surface.
|
||||
- **A truncated buffer must never become an edit buffer.** The plain preview truncates to `lines` (default 500); saving such a buffer would silently delete everything past the cut, and the hash check cannot catch it (the loaded prefix hashes differently from the full file, which reads as an ordinary conflict at best). `edit=1` therefore never truncates — it 413s over `MAX_EDITABLE_BYTES` (512KB) instead — and the frontend always re-fetches with `edit=1` before swapping in the textarea, even though the preview already holds content.
|
||||
- **Concurrency is optimistic by content hash, not mtime.** The client echoes the sha256 it loaded (`baseHash`); mismatch → 409 CONFLICT (plain envelope — the error arm carries no data; the client re-fetches `edit=1` for fresh state) unless `force:true`. mtime alone is wrong: agents rewrite files within one timestamp tick.
|
||||
- **Writes are `wx` temp + `fchmod` + `fsync` + `rename` in the target's directory.** `wx` cannot follow a pre-existing symlink and `rename()` replaces (not follows) a symlink final component, which closes the validate-then-write TOCTOU window; `fchmod` because `open()`'s mode argument is masked by the umask; a symlink whose target is _inside_ the workspace is deliberately written through (validation returns the realpath). Trade-off (same as vim): the inode changes, so hardlinks keep old content.
|
||||
- **Writes are `wx` temp + `fchmod` + `fsync` + `rename` in the target's directory.** `wx` cannot follow a pre-existing symlink and `rename()` replaces (not follows) a symlink final component, which closes the validate-then-write TOCTOU window; `fchmod` because `open()`'s mode argument is masked by the umask; a symlink whose target is *inside* the workspace is deliberately written through (validation returns the realpath). Trade-off (same as vim): the inode changes, so hardlinks keep old content.
|
||||
- **Corruption guards**: NUL-sniff + UTF-8 **round-trip compare** (`Buffer.from(buf.toString('utf8'), 'utf8').equals(buf)`) refuse binary and non-UTF-8 files — decoding latin-1 yields U+FFFD replacements and writing those back destroys the original bytes. EOL is detected server-side and re-applied on save because a `<textarea>` normalizes to LF (a two-line edit of a CRLF file must not become a whole-file diff).
|
||||
- **Two size caps on the wire**: the Zod `.max()` counts UTF-16 code units (coarse pre-filter, 400) while the handler's `Buffer.byteLength` check enforces the real byte cap (413); the route sets `bodyLimit: 4MB` because JSON escaping can expand 512KB of content past Fastify's 1MB default. Error paths **throw** structured `{statusCode, body}` errors (`throwFileEditError`) rather than returning envelopes — the central preSerialization status-mapping hook is absent from the route-test harness, and 413 has no errorCode mapping at all.
|
||||
|
||||
@@ -284,7 +296,7 @@ Invariants:
|
||||
|
||||
**Terminal smart copy** (#211): `Ctrl+C` copies the selection when there is one and stays the interrupt when there isn't. Three rules keep that split honest, and breaking any of them silently costs the user their interrupt key:
|
||||
|
||||
1. The branch lives in `attachCustomKeyEventHandler` (terminal-ui.js) and the **no-selection path returns `true` with no `preventDefault()`**, so xterm still evaluates `Ctrl+C` into `0x03`. Returning `false` alone does not cancel the event either way: xterm's `_keyDown` calls the custom handler _before_ its own `cancel()`, which is exactly why the copy path calls `preventDefault()` explicitly (otherwise the browser also runs its native copy on top).
|
||||
1. The branch lives in `attachCustomKeyEventHandler` (terminal-ui.js) and the **no-selection path returns `true` with no `preventDefault()`**, so xterm still evaluates `Ctrl+C` into `0x03`. Returning `false` alone does not cancel the event either way: xterm's `_keyDown` calls the custom handler *before* its own `cancel()`, which is exactly why the copy path calls `preventDefault()` explicitly (otherwise the browser also runs its native copy on top).
|
||||
2. `copyTerminalSelection` is a registry `action` **deliberately missing from `SHORTCUT_ACTIONS`** (same trick as `command-palette`): the entry stays rebindable and disableable in App Settings, while the generic document-capture loop, which `preventDefault()`s every match it dispatches, skips it and lets the terminal handler decide.
|
||||
3. The gate is keydown-only (the custom handler also runs for `keypress`/`keyup`), and `Ctrl+Shift+C` never falls through to the PTY: an "explicit copy" chord that interrupts a running agent because the selection happened to be empty is a footgun with no upside.
|
||||
|
||||
@@ -363,9 +375,9 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
|
||||
|
||||
⚠️ **A first-hand conversation id outranks every correlation, and the correlation must never run when one exists.** `UserPromptSubmit` and `Stop` hook payloads carry `session_id` — the pane's LIVE conversation, reported from inside the CLI process — and reach Codeman addressed by that pane's own `$CODEMAN_SESSION_ID`. That binding is a fact: it never consults `workingDir`, so it cannot be claimed by a sibling pane on the same folder, by a closed tab, or by a bare `claude` in the user's terminal. `Session.claudeSessionIdIsFirstHand` gates `resolveActiveClaudeSessionIdFromHistory()` at its first line, so the number of prompts eligible for cwd-based guessing goes DOWN, never up. There is no TTL: if hooks stop arriving the last hook-supplied id is kept forever rather than falling back to guessing, which is the same rule as the paragraph below. ⚠️ **This is also the only fix for a pane driven by attaching to its tmux session directly.** `lastSubmitAt` was bumped only by `Session.write()`/`writeViaMux()`, i.e. input that flows through Codeman, so such a pane's anchor stayed 0 and the resolver returned at `if (!submitAt)` for the pane's entire life. The hook stamps it too (`markPromptSubmitted()`), so it finally means "a prompt was submitted". ⚠️ **Only a first-hand adoption may extend `Session.claudeSessionChain`** — a correlated guess writing into the pane's permanent record is precisely the bug the paragraph above describes, made durable. The chain is persisted because `/clear` is otherwise unrecoverable: once the pane moves on, the predecessor id exists nowhere else. Its tail re-pins the conversation on a RESTORED mux attach, where the launch id is a lie (the CLI never stopped and may have `/clear`ed before the restart); a NEW pane has an empty chain and keeps the launch id unchanged. ⚠️ **The hook's stdout must stay discarded, using curl's own `-o /dev/null`** (`curlCmdSilent`): Claude Code injects a `UserPromptSubmit` hook's stdout into the model's context — the CLI's own hook reference says "Exit code 0 - stdout shown to Claude" — so an undiscarded curl pastes Codeman's `{"success":true,…}` envelope into the user's own prompt on every turn. ⚠️ **A trailing `>/dev/null` does NOT work and looks like it does**: `curlCmd` already ends `… 2>/dev/null || true`, and in `pipeline || true >/dev/null` the shell binds the redirection to `true`, which never runs on the success path. An `endsWith('>/dev/null')` assertion passes on exactly that broken form, so the test asserts the `-o` flag instead. The other events feed SSE, where their stdout is harmless — hence a separate builder rather than a change to `curlCmd`. Tests: `test/hooks-config.test.ts`, `test/routes/hook-event-routes.test.ts`, `test/routes/session-routes-claude-last-response.test.ts`.
|
||||
|
||||
⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its _launch_ conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a _stale_ anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation.
|
||||
⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its *launch* conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a *stale* anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation.
|
||||
|
||||
⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per _row_ was the original bug (#169) — but the fix overshot to one card per _human turn_, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. The BRIEF view therefore does not render `text`: it asks for `?context=turn`, which returns the assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn()` in `src/web/response-viewer-transcript.ts`: the highest `turn` that has an assistant row, so a prompt queued after the answer does not blank the view), and renders them with the same numeric-`turn` continuation gate as the full view. `text` alone was the final row of a median-3-row turn — a "Done." tail with the substance in the rows before it. Readers that emit no `turn` (Codex, the pane parser, an older server) return `text` only, and the brief view falls back to one card for them. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
|
||||
⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per *row* was the original bug (#169) — but the fix overshot to one card per *human turn*, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. The BRIEF view therefore does not render `text`: it asks for `?context=turn`, which returns the assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn()` in `src/web/response-viewer-transcript.ts`: the highest `turn` that has an assistant row, so a prompt queued after the answer does not blank the view), and renders them with the same numeric-`turn` continuation gate as the full view. `text` alone was the final row of a median-3-row turn — a "Done." tail with the substance in the rows before it. Readers that emit no `turn` (Codex, the pane parser, an older server) return `text` only, and the brief view falls back to one card for them. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
|
||||
**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected.
|
||||
|
||||
### Session list layout (header strip vs. left sidebar)
|
||||
@@ -390,7 +402,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
|
||||
|
||||
**Light skins carry three extra obligations.** `color-scheme: light` on the `html[data-skin]` block, or the UA renders native `<select>` popups, date pickers and scrollbars as dark widgets on a light page. xterm `minimumContrastRatio: 4.5` (set at construction in terminal-ui.js **and** panels-ui.js for teammate terminals, and re-applied in `applyTerminalSkin()`), because CLIs emit colors chosen for dark backgrounds; dark skins keep `1` to avoid the per-cell contrast work. And `applyTerminalSkin()` must call `this._localEchoOverlay?.refreshFont()` — the zero-lag overlay **caches** the terminal fg/bg at construction, so without the refresh typed-but-not-yet-flushed text keeps the previous theme's dark backing after a live skin switch.
|
||||
|
||||
**Shared surface tokens, not literals.** Elevated/floating surfaces resolve through `--floating-bg`, `--control-bg{,-hover}`, `--control-border{,-hover}`, `--banner-bg-a/b`, `--modal-backdrop` and `--elevated-shadow`, so modals, the command palette, dropdowns, subagent/ultracode windows, file previews and the mobile sheets follow the skin instead of hardcoded near-black rgba. ⚠️ Every skin that wants its own elevated look must override these — a skin that omits `--floating-bg` silently inherits the `:root` value, which is how the OG skin's near-black modals drifted slate. `styles.css` also defines **compatibility aliases** (`--bg-primary/secondary/tertiary`, `--text-primary/secondary`, `--border-color`, `--accent-color`, `--success`, `--error`, `--danger`, `--font-mono`, `--shadow-lg`) that forward to the canonical tokens; panels written against those names previously resolved to _nothing_ (invalid at computed-value time), so the aliases are load-bearing, not cosmetic.
|
||||
**Shared surface tokens, not literals.** Elevated/floating surfaces resolve through `--floating-bg`, `--control-bg{,-hover}`, `--control-border{,-hover}`, `--banner-bg-a/b`, `--modal-backdrop` and `--elevated-shadow`, so modals, the command palette, dropdowns, subagent/ultracode windows, file previews and the mobile sheets follow the skin instead of hardcoded near-black rgba. ⚠️ Every skin that wants its own elevated look must override these — a skin that omits `--floating-bg` silently inherits the `:root` value, which is how the OG skin's near-black modals drifted slate. `styles.css` also defines **compatibility aliases** (`--bg-primary/secondary/tertiary`, `--text-primary/secondary`, `--border-color`, `--accent-color`, `--success`, `--error`, `--danger`, `--font-mono`, `--shadow-lg`) that forward to the canonical tokens; panels written against those names previously resolved to *nothing* (invalid at computed-value time), so the aliases are load-bearing, not cosmetic.
|
||||
|
||||
### Custom branding and UI language
|
||||
|
||||
@@ -423,7 +435,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
|
||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
|
||||
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
|
||||
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) |
|
||||
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`) |
|
||||
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`) |
|
||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||
|
||||
## Performance and limits
|
||||
@@ -444,7 +456,7 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina
|
||||
|
||||
⚠️ **Look in `prebuilds/<platform>-<arch>/`, not just `build/Release/`.** node-pty's loader (`lib/utils.js`) tries `build/Release`, `build/Debug`, then `prebuilds/<platform>-<arch>`, and `unixTerminal.js` derives `helperPath` from **whichever directory the native module loaded out of**. The 0.15 fix only chmodded `build/Release/spawn-helper`, which on macOS does not exist at all (the prebuild is used, so node-gyp never runs), and it resolved the path off `require.resolve('node-pty')` (= `<pkg>/lib/index.js`), producing `<pkg>/lib/build/Release/spawn-helper`, so it was a no-op on every platform.
|
||||
|
||||
The repair is a **chmod, not a rebuild**: the prebuilt binary is fine. `scripts/fix-node-pty.mjs` (also `npm run fix:node-pty`) chmods every helper it finds, then **proves** the result by actually opening a PTY, because a `require()` alone passes on a broken install (the helper is only touched at spawn time). Only if that probe still fails does it rebuild from source, and it backs `prebuilds/` up first: node-pty's install script `rmSync`s the whole prebuilds tree the moment `npm_config_build_from_source` is set and only _then_ shells out to node-gyp, so on a Mac without Xcode command line tools an unconditional rebuild leaves the install with neither a prebuilt nor a compiled binary. ⚠️ Do not reinstate a blind rebuild in `postinstall` for that reason (it also cost 30-120s on every install).
|
||||
The repair is a **chmod, not a rebuild**: the prebuilt binary is fine. `scripts/fix-node-pty.mjs` (also `npm run fix:node-pty`) chmods every helper it finds, then **proves** the result by actually opening a PTY, because a `require()` alone passes on a broken install (the helper is only touched at spawn time). Only if that probe still fails does it rebuild from source, and it backs `prebuilds/` up first: node-pty's install script `rmSync`s the whole prebuilds tree the moment `npm_config_build_from_source` is set and only *then* shells out to node-gyp, so on a Mac without Xcode command line tools an unconditional rebuild leaves the install with neither a prebuilt nor a compiled binary. ⚠️ Do not reinstate a blind rebuild in `postinstall` for that reason (it also cost 30-120s on every install).
|
||||
|
||||
`src/utils/node-pty-repair.ts` is the runtime safety net for installs that are already broken: all four `pty.spawn()` calls in `session.ts` go through `spawnPtyWithHelperRepair()`, which chmods and retries **once** on a `posix_spawnp`/`spawn-helper` error and rethrows anything else untouched, so the user never sees a dead session. A second failure rethrows with the `npm run fix:node-pty` hint attached instead of a bare "posix_spawnp failed". The repair is attempted at most once per process (no chmod storms).
|
||||
|
||||
|
||||
+110
-36
@@ -113,44 +113,32 @@ export function decideRemoteInputAction(args: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append `data` to the pending buffer, dropping the OLDEST bytes when the cap is
|
||||
* exceeded. Returns the resulting buffer. Pure.
|
||||
* Append `data` to the pending buffer, dropping the OLDEST whole chunks when the cap
|
||||
* is exceeded. Returns the resulting buffer — the SAME array reference when the chunk
|
||||
* was rejected, so the caller can tell the two apart. Pure.
|
||||
*
|
||||
* A single chunk can itself exceed the cap (one large paste is one `input` value),
|
||||
* so after whole chunks are dropped the surviving chunk's HEAD is trimmed too —
|
||||
* otherwise "bounded at 4 KB" would hold only per chunk, not per session. The trim
|
||||
* is code-point aware, so it never emits a broken multi-byte character.
|
||||
* A chunk LARGER than the cap is dropped outright rather than trimmed: one paste is
|
||||
* one `input` value, and it was never typed character by character, so delivering its
|
||||
* tail would execute a fragment of it (with the trailing carriage return, if the paste
|
||||
* had one) — a partial command the user never sent. Keeping the tail is right for
|
||||
* typing, where the newest bytes are the ones the user just produced, and wrong for a
|
||||
* chunk that arrived whole.
|
||||
*/
|
||||
export function appendBoundedPending(
|
||||
pending: string[],
|
||||
data: string,
|
||||
maxBytes = REMOTE_WAKE_PENDING_MAX_BYTES
|
||||
): string[] {
|
||||
if (Buffer.byteLength(data) > maxBytes) return pending;
|
||||
const next = [...pending, data];
|
||||
let total = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
||||
while (next.length > 1 && total > maxBytes) {
|
||||
total -= Buffer.byteLength(next[0]);
|
||||
next.shift();
|
||||
}
|
||||
if (next.length === 1) next[0] = tailWithinBytes(next[0], maxBytes);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Keep only the trailing part of `value` that fits in `maxBytes` UTF-8 bytes. Pure. */
|
||||
function tailWithinBytes(value: string, maxBytes: number): string {
|
||||
if (Buffer.byteLength(value) <= maxBytes) return value;
|
||||
const chars = [...value];
|
||||
let total = 0;
|
||||
let start = chars.length;
|
||||
while (start > 0) {
|
||||
const size = Buffer.byteLength(chars[start - 1]);
|
||||
if (total + size > maxBytes) break;
|
||||
total += size;
|
||||
start--;
|
||||
}
|
||||
return chars.slice(start).join('');
|
||||
}
|
||||
|
||||
/** The remote fields the wake flow needs. Structurally satisfied by `SessionRemote`. */
|
||||
export interface WakeableRemote {
|
||||
wakeCommand?: string;
|
||||
@@ -240,7 +228,7 @@ export interface RemoteWakeDeps {
|
||||
/** Run the resolved wake target (magic packet or host command). Resolves false on failure. */
|
||||
wake(target: NonNullable<WakeTarget>): Promise<boolean>;
|
||||
/** Poll until the woken host accepts connections again. */
|
||||
waitUntilReady(remote: WakeableRemote, opts?: { timeoutMs?: number }): Promise<boolean>;
|
||||
waitUntilReady(remote: WakeableRemote, opts?: { timeoutMs?: number; signal?: AbortSignal }): Promise<boolean>;
|
||||
/** Sleep helper (injected for tests). */
|
||||
delay(ms: number): Promise<void>;
|
||||
/** Notify the COD-108 watcher so an exhausted backoff is reset. */
|
||||
@@ -319,6 +307,14 @@ interface WakeState {
|
||||
*/
|
||||
export class RemoteWakeRegistry {
|
||||
private readonly states = new Map<string, WakeState>();
|
||||
/**
|
||||
* Aborted by {@link stop} on shutdown. Every in-flight readiness poll is holding an
|
||||
* HTTP request open (the wake route blocks on it), and Fastify's `close()` waits for
|
||||
* in-flight requests — so without this a restart during a wake sits out the full 90 s
|
||||
* budget. The same problem `sessionWaits.cancelEverything()` exists for.
|
||||
*/
|
||||
private readonly shutdown = new AbortController();
|
||||
private stopped = false;
|
||||
|
||||
constructor(private readonly deps: RemoteWakeDeps) {}
|
||||
|
||||
@@ -327,6 +323,19 @@ export class RemoteWakeRegistry {
|
||||
this.states.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every in-flight wake as failed and refuse new ones (server shutdown).
|
||||
*
|
||||
* Called from `WebServer.stop()`: an in-flight wake is awaited by a request, and the
|
||||
* server's own `app.close()` does not abort in-flight requests, so shutdown would wait
|
||||
* out the poll. Nothing is lost by failing them — the state flush happens earlier in
|
||||
* `stop()`, and the process is going away.
|
||||
*/
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
this.shutdown.abort();
|
||||
}
|
||||
|
||||
/** Whether a wake is currently in flight (diagnostics/tests). */
|
||||
isWaking(sessionId: string): boolean {
|
||||
return this.states.get(sessionId)?.waking != null;
|
||||
@@ -339,6 +348,15 @@ export class RemoteWakeRegistry {
|
||||
return state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of keys with wake state (diagnostics/tests). Pins that a LOCAL session never
|
||||
* gets an entry: the input gate runs on every keystroke, so an entry per local session
|
||||
* would be a map the size of the session list, swept only on cleanup.
|
||||
*/
|
||||
stateCount(): number {
|
||||
return this.states.size;
|
||||
}
|
||||
|
||||
/** Whether this session's host has any wake path configured at all. */
|
||||
async hasWakeTarget(session: WakeableSession): Promise<boolean> {
|
||||
return resolveWakeTarget(await this._effectiveRemote(session)) !== null;
|
||||
@@ -412,7 +430,8 @@ export class RemoteWakeRegistry {
|
||||
* send-and-wait path, where the HTTP response stays open anyway and buffering
|
||||
* would break the wait contract.
|
||||
*/
|
||||
async ensureAwake(session: WakeableSession, opts: { force?: boolean } = {}): Promise<boolean> {
|
||||
async ensureAwake(session: WakeableSession, opts: { force?: boolean; timeoutMs?: number } = {}): Promise<boolean> {
|
||||
if (this.stopped) return false;
|
||||
const remote = await this._effectiveRemote(session);
|
||||
if (!remote || !resolveWakeTarget(remote)) return true;
|
||||
const state = this._state(session.id);
|
||||
@@ -423,7 +442,10 @@ export class RemoteWakeRegistry {
|
||||
state.reachable = await this.deps.probe(remote);
|
||||
}
|
||||
if (state.reachable) return true;
|
||||
return this.wake(session);
|
||||
// The manual button is pressed from the SAME dashboard the create/attach paths are,
|
||||
// so it holds its request open under the same reverse proxy — it needs the request
|
||||
// budget, not the 90 s session default (see REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS).
|
||||
return this.wake(session, { timeoutMs: opts.timeoutMs });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -452,6 +474,7 @@ export class RemoteWakeRegistry {
|
||||
*/
|
||||
async ensureHostAwake(remote: WakeableRemote, opts: { timeoutMs?: number } = {}): Promise<HostWakeOutcome> {
|
||||
if (!resolveWakeTarget(remote)) return 'no-target';
|
||||
if (this.stopped) return 'failed';
|
||||
const state = this._state(hostWakeKey(remote.hostId));
|
||||
if (state.waking) return (await state.waking) ? 'ready' : 'failed';
|
||||
|
||||
@@ -463,9 +486,12 @@ export class RemoteWakeRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-flight wake for a host with no session (see {@link ensureHostAwake}).
|
||||
* A write into the same `waking` slot the session flow uses, so the two can never
|
||||
* run two readiness polls against one host from the same key space.
|
||||
* Single-flight wake for a host with no session (see {@link ensureHostAwake}). Uses the
|
||||
* same single-flight `waking` slot the session flow uses — but a DIFFERENT key
|
||||
* (`host:<id>` vs the session id), so a session wake and a create-path wake for the same
|
||||
* host are two independent flows rather than one shared poll. Harmless (both are
|
||||
* user-initiated and the host only wakes once), and keying them together would mean a
|
||||
* create request joining an unrelated session's wake and inheriting its budget.
|
||||
*/
|
||||
private async wakeHost(remote: WakeableRemote, opts: { timeoutMs?: number }): Promise<boolean> {
|
||||
const state = this._state(hostWakeKey(remote.hostId));
|
||||
@@ -491,7 +517,8 @@ export class RemoteWakeRegistry {
|
||||
* Single-flight wake: probe-free (the caller already knows the host is down),
|
||||
* run the wake command, poll for readiness, reattach the pane, flush the buffer.
|
||||
*/
|
||||
async wake(session: WakeableSession): Promise<boolean> {
|
||||
async wake(session: WakeableSession, opts: { timeoutMs?: number } = {}): Promise<boolean> {
|
||||
if (this.stopped) return false;
|
||||
const remote = await this._effectiveRemote(session);
|
||||
const target = resolveWakeTarget(remote);
|
||||
if (!remote || !target) return true;
|
||||
@@ -501,7 +528,7 @@ export class RemoteWakeRegistry {
|
||||
state.waking = (async (): Promise<boolean> => {
|
||||
const id = session.id;
|
||||
try {
|
||||
const ready = await this._wakeAndWait(remote, state, { sessionId: id });
|
||||
const ready = await this._wakeAndWait(remote, state, { sessionId: id, timeoutMs: opts.timeoutMs });
|
||||
if (!ready) return false;
|
||||
|
||||
const reattached = await session.reattachRemote();
|
||||
@@ -546,10 +573,17 @@ export class RemoteWakeRegistry {
|
||||
// No `sessionId` for a create-path wake: the toast handler is then the only one
|
||||
// that acts (a banner for a session that does not exist yet would have no target),
|
||||
// which is exactly the `forNewSession` distinction the UI renders.
|
||||
//
|
||||
// `queuedInput` is true only on the typing path, where bytes are actually held for
|
||||
// this session. The wake BUTTON and the send-and-wait path hold nothing, so a UI
|
||||
// that keyed "input is queued until it is back" off "a wake is running" would promise
|
||||
// something the user can disprove by typing (browser keystrokes go over the
|
||||
// WebSocket, which never passes through this registry).
|
||||
this.deps.broadcast?.('remote:hostWaking', {
|
||||
...(opts.sessionId ? { sessionId: opts.sessionId } : { forNewSession: true }),
|
||||
hostId: remote.hostId,
|
||||
label: remote.label,
|
||||
queuedInput: state.pending.length > 0,
|
||||
});
|
||||
this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind} ${forWhat}`);
|
||||
|
||||
@@ -560,7 +594,10 @@ export class RemoteWakeRegistry {
|
||||
);
|
||||
}
|
||||
|
||||
const ready = await this.deps.waitUntilReady(remote, { timeoutMs: opts.timeoutMs });
|
||||
const ready = await this.deps.waitUntilReady(remote, {
|
||||
timeoutMs: opts.timeoutMs,
|
||||
signal: this.shutdown.signal,
|
||||
});
|
||||
if (!ready) {
|
||||
this.deps.log?.(
|
||||
`[RemoteWake] ${remote.label} did not come back — ${opts.forNewSession ? 'the session was not started' : 'input stays buffered'}`
|
||||
@@ -569,6 +606,7 @@ export class RemoteWakeRegistry {
|
||||
...(opts.sessionId ? { sessionId: opts.sessionId } : { forNewSession: true }),
|
||||
hostId: remote.hostId,
|
||||
label: remote.label,
|
||||
queuedInput: state.pending.length > 0,
|
||||
});
|
||||
state.probedAt = 0;
|
||||
state.reachable = undefined;
|
||||
@@ -607,8 +645,12 @@ export class RemoteWakeRegistry {
|
||||
* promise failing in the one direction a user can observe.
|
||||
*/
|
||||
private async _effectiveRemote(session: WakeableSession): Promise<WakeableRemote | undefined> {
|
||||
// The local-session return comes FIRST, before `_state`: this runs on every input
|
||||
// chunk (`hasWakeTarget` gates the route), so allocating state here would put an
|
||||
// entry in the map for every local session the user types in — sessions the feature
|
||||
// can never apply to, and whose pending buffers would then have to be swept.
|
||||
if (!session.remote) return undefined;
|
||||
const state = this._state(session.id);
|
||||
if (!session.remote) return state.resolvedRemote;
|
||||
if (!this.deps.resolveRemote) return state.resolvedRemote ?? session.remote;
|
||||
if (state.resolvedAt !== 0 && Date.now() - state.resolvedAt < REMOTE_WAKE_RESOLVE_TTL_MS) {
|
||||
return state.resolvedRemote ?? session.remote;
|
||||
@@ -627,12 +669,22 @@ export class RemoteWakeRegistry {
|
||||
|
||||
private _enqueue(sessionId: string, data: string): void {
|
||||
const state = this._state(sessionId);
|
||||
const next = appendBoundedPending(state.pending, data);
|
||||
if (next === state.pending) {
|
||||
// Oversized chunk: dropped whole (see `appendBoundedPending`), so the buffer is
|
||||
// untouched and nothing is delivered as a fragment. Logged because the user's
|
||||
// paste is gone — the 200 the route returns cannot say so.
|
||||
this.deps.log?.(
|
||||
`[RemoteWake] dropped a ${Buffer.byteLength(data)}-byte input chunk for session ${sessionId} — over the ${REMOTE_WAKE_PENDING_MAX_BYTES}-byte wake buffer, and a truncated paste must not be delivered as a fragment`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const before = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
||||
state.pending = appendBoundedPending(state.pending, data);
|
||||
const after = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
||||
const after = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
||||
if (before + Buffer.byteLength(data) > after) {
|
||||
this.deps.log?.(`[RemoteWake] pending buffer cap reached for session ${sessionId} — oldest input dropped`);
|
||||
}
|
||||
state.pending = next;
|
||||
}
|
||||
|
||||
private async _flush(state: WakeState, session: WakeableSession): Promise<void> {
|
||||
@@ -785,7 +837,12 @@ export type WakeSocketFactory = () => WakeSocket;
|
||||
/** Poll the host until it accepts connections again, or the bound is hit. */
|
||||
export async function waitUntilRemoteReady(
|
||||
remote: WakeableRemote,
|
||||
opts: { intervalMs?: number; timeoutMs?: number; probe?: (remote: WakeableRemote) => Promise<boolean> } = {}
|
||||
opts: {
|
||||
intervalMs?: number;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
probe?: (remote: WakeableRemote) => Promise<boolean>;
|
||||
} = {}
|
||||
): Promise<boolean> {
|
||||
const intervalMs = opts.intervalMs ?? REMOTE_WAKE_READY_INTERVAL_MS;
|
||||
const timeoutMs = opts.timeoutMs ?? REMOTE_WAKE_READY_TIMEOUT_MS;
|
||||
@@ -794,12 +851,29 @@ export async function waitUntilRemoteReady(
|
||||
// Probe immediately: WoL from a warm S3 is fast (~7.5 s measured on this setup),
|
||||
// and the first poll is what turns "just woke" into a sub-interval response.
|
||||
for (;;) {
|
||||
if (opts.signal?.aborted) return false;
|
||||
if (await probe(remote)) return true;
|
||||
if (Date.now() + intervalMs > deadline) return false;
|
||||
await delay(intervalMs);
|
||||
// Abortable sleep, so a shutdown does not wait out the current interval either.
|
||||
await delayOrAbort(intervalMs, opts.signal);
|
||||
}
|
||||
}
|
||||
|
||||
/** `delay`, but it also ends the moment `signal` aborts (so cancellation is immediate). */
|
||||
function delayOrAbort(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) return delay(ms);
|
||||
if (signal.aborted) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const done = (): void => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', done);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(done, ms);
|
||||
signal.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Production wiring: all IO defaults, overridable for tests. */
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
* keeps the banner in sync while a wake is running.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp class, this.sessions, this.activeSessionId, showToast)
|
||||
* @dependency constants.js (SSE_EVENTS — the remote:hostWaking / remote:hostWakeFailed names)
|
||||
* @loadorder 12.2 — loaded after session-ui.js, before webview-tabs.js
|
||||
*/
|
||||
|
||||
const HOST_WAKE_POLL_MS = 30_000;
|
||||
@@ -45,6 +48,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
label: '',
|
||||
/** True between clicking Wake and the answer coming back. */
|
||||
waking: false,
|
||||
/**
|
||||
* True only when the server is actually holding bytes for this session (the typing
|
||||
* path buffers them). Browser keystrokes go over the WebSocket, which never passes
|
||||
* through the wake registry — so the Wake BUTTON must not claim input is queued.
|
||||
*/
|
||||
queuedInput: false,
|
||||
/** Set when the last wake attempt or poll failed. */
|
||||
error: '',
|
||||
};
|
||||
@@ -157,7 +166,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
if (detail) {
|
||||
detail.textContent = state.waking
|
||||
? 'input is queued until it is back'
|
||||
? state.queuedInput
|
||||
? 'input is queued until it is back'
|
||||
: 'waiting for the host to come back'
|
||||
: hasTarget
|
||||
? `ssh ${state.host}`
|
||||
: 'no wake-on-LAN configured';
|
||||
@@ -187,6 +198,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!state || !state.sessionId) return;
|
||||
const sessionId = state.sessionId;
|
||||
state.waking = true;
|
||||
// The button path holds nothing: whatever the user typed went into the stalled pane
|
||||
// over the WebSocket and is gone. Saying otherwise is a promise the next keystroke
|
||||
// disproves.
|
||||
state.queuedInput = false;
|
||||
state.error = '';
|
||||
this._renderHostWakeBanner();
|
||||
try {
|
||||
@@ -195,10 +210,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this._hostWake !== state || state.sessionId !== sessionId) return;
|
||||
state.waking = false;
|
||||
if (!data.success) {
|
||||
// Most likely: no wake target configured after all (the route is the authority).
|
||||
// The ROUTE is the authority on whether a target is configured, so ask it again
|
||||
// (`/reachability` reports `wakeConfigured`) rather than pattern-matching the
|
||||
// error message: the message is prose, and the code is generic (`INVALID_INPUT`
|
||||
// covers "Not a remote session" too).
|
||||
state.error = data.error || 'Wake failed';
|
||||
if (String(data.error || '').includes('No wake-on-LAN target')) state.wakeConfigured = 'none';
|
||||
this._renderHostWakeBanner();
|
||||
await this._pollHostReachability(true);
|
||||
return;
|
||||
}
|
||||
state.reachable = data.data.reachable !== false;
|
||||
@@ -217,6 +235,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Why the host could not be read. In multi-user mode `GET /api/remote-hosts` returns
|
||||
* `[]` to a non-admin, so "Remote host not found" would blame a config the user simply
|
||||
* is not allowed to see — the save is admin-only, and that is what it should say.
|
||||
*/
|
||||
_wakeConfigUnavailableMessage() {
|
||||
const me = window.__codemanUser || {};
|
||||
return me.multiUser && me.role !== 'admin' ? 'Wake-on-LAN configuration is admin-only' : 'Remote host not found';
|
||||
},
|
||||
|
||||
/** Open the small WoL dialog for the banner's host, pre-filled from the host config. */
|
||||
async openWakeConfigDialog() {
|
||||
const state = this._hostWake;
|
||||
@@ -247,6 +275,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (host && this._wakeConfigHostId === hostId) {
|
||||
mac.value = host.wakeMac || '';
|
||||
command.value = host.wakeCommand || '';
|
||||
} else if (!host && this._wakeConfigHostId === hostId && status) {
|
||||
// Say it up front rather than only when Save fails.
|
||||
status.textContent = this._wakeConfigUnavailableMessage();
|
||||
}
|
||||
} catch {
|
||||
/* The form is already usable from the session payload. */
|
||||
@@ -289,7 +320,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const listData = await listRes.json();
|
||||
const hosts = listData.success ? listData.data : [];
|
||||
const host = Array.isArray(hosts) ? hosts.find((item) => item.id === hostId) : null;
|
||||
if (!host) throw new Error('Remote host not found');
|
||||
if (!host) throw new Error(this._wakeConfigUnavailableMessage());
|
||||
// PUT takes the whole host (schema-validated), so send back everything we know and
|
||||
// only replace the wake fields. `undefined` drops the key entirely.
|
||||
const payload = {
|
||||
@@ -331,16 +362,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
// A create-path wake (the user pressed Run / Attach) has no session yet, so
|
||||
// nothing is queued behind it — the wording has to say what actually happens.
|
||||
const forNewSession = Boolean(data && data.forNewSession);
|
||||
// Only the typing path buffers bytes; the wake button and the send-and-wait path
|
||||
// hold none, and a browser keystroke never reaches the registry at all.
|
||||
const queuedInput = Boolean(data && data.queuedInput);
|
||||
// Long enough to cover the wake + attach (~10s measured on a warm S3), and it
|
||||
// is replaced by `remote:sessionReconnected` the moment the pane is back.
|
||||
this.showToast(
|
||||
forNewSession ? `Waking ${label} … the session starts when it is back` : `Waking ${label} … input is queued`,
|
||||
forNewSession
|
||||
? `Waking ${label} … the session starts when it is back`
|
||||
: queuedInput
|
||||
? `Waking ${label} … input is queued`
|
||||
: `Waking ${label} … waiting for it to come back`,
|
||||
'info',
|
||||
{ duration: 12000 }
|
||||
);
|
||||
const state = this._hostWake;
|
||||
if (!state || !data || state.sessionId !== data.sessionId) return;
|
||||
state.waking = true;
|
||||
state.queuedInput = queuedInput;
|
||||
state.error = '';
|
||||
if (data.label) state.label = data.label;
|
||||
this._renderHostWakeBanner();
|
||||
@@ -350,16 +389,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
_onRemoteHostWakeFailed(data) {
|
||||
const label = data && data.label ? data.label : 'Remote host';
|
||||
const forNewSession = Boolean(data && data.forNewSession);
|
||||
const queuedInput = Boolean(data && data.queuedInput);
|
||||
this.showToast(
|
||||
forNewSession
|
||||
? `${label} did not wake up — no session was started`
|
||||
: `${label} did not wake up — queued input is still held`,
|
||||
: queuedInput
|
||||
? `${label} did not wake up — queued input is still held`
|
||||
: `${label} did not wake up`,
|
||||
'error',
|
||||
{ duration: 15000 }
|
||||
);
|
||||
const state = this._hostWake;
|
||||
if (!state || !data || state.sessionId !== data.sessionId) return;
|
||||
state.waking = false;
|
||||
state.queuedInput = queuedInput;
|
||||
state.error = 'timeout';
|
||||
state.reachable = false;
|
||||
this._renderHostWakeBanner();
|
||||
|
||||
@@ -843,7 +843,7 @@ export function registerSessionRoutes(
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort,
|
||||
/** Test seam: inject a registry with fake IO instead of the real TCP/WoL probes. */
|
||||
options: { remoteWake?: RemoteWakeRegistry } = {}
|
||||
): void {
|
||||
): RemoteWakeRegistry {
|
||||
// Wake-on-LAN for sleeping remote hosts (see remote-wake.ts). One registry per
|
||||
// route registration (= one web server) — the same shape as the process-wide
|
||||
// `sessionWaits` singleton, but without the global.
|
||||
@@ -1350,7 +1350,8 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
remoteWake.drop(session.id);
|
||||
// Wake state is dropped by `cleanupSession` itself (server.ts), on EVERY cleanup
|
||||
// path — not here: the scheduled-run and admin paths clean up without this route.
|
||||
await ctx.cleanupSession(session.id, killMux, 'user_delete');
|
||||
return {};
|
||||
});
|
||||
@@ -1368,7 +1369,6 @@ export function registerSessionRoutes(
|
||||
|
||||
for (const id of sessionIds) {
|
||||
if (ctx.sessions.has(id)) {
|
||||
remoteWake.drop(id);
|
||||
await ctx.cleanupSession(id, true, 'user_bulk_delete');
|
||||
killed++;
|
||||
}
|
||||
@@ -1626,7 +1626,13 @@ export function registerSessionRoutes(
|
||||
'No wake-on-LAN target configured for this host (set a MAC address or a wake command)'
|
||||
);
|
||||
}
|
||||
const woke = await remoteWake.ensureAwake(session, { force: true });
|
||||
// The button is pressed from the SAME dashboard the create/attach paths are, under
|
||||
// the same reverse proxy — so it holds the request open the same way and needs the
|
||||
// same request budget, not the 90 s session default (see remote-wake.ts).
|
||||
const woke = await remoteWake.ensureAwake(session, {
|
||||
force: true,
|
||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -4898,4 +4904,10 @@ export function registerSessionRoutes(
|
||||
|
||||
return { path: filepath, filename };
|
||||
});
|
||||
|
||||
// Returned so the server can own the registry's LIFETIME (drop state when a session is
|
||||
// cleaned up on any of its paths, resolve in-flight wakes on shutdown). The wake-CAPABLE
|
||||
// code stays here: `test/remote-wake.test.ts` pins that `server.ts` calls nothing but
|
||||
// `drop`/`stop` on this handle, so no timer path can reach a wake through it.
|
||||
return remoteWake;
|
||||
}
|
||||
+21
-1
@@ -42,6 +42,7 @@ import { execSync } from 'node:child_process';
|
||||
import { hostname as getHostname } from 'node:os';
|
||||
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
||||
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
|
||||
import type { RemoteWakeRegistry } from '../remote-wake.js';
|
||||
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
|
||||
import { GLYPH, palette } from '../cli-style.js';
|
||||
import { getHookSecret } from '../config/hook-secret.js';
|
||||
@@ -268,6 +269,14 @@ export class WebServer extends EventEmitter {
|
||||
private scheduledRuns: Map<string, ScheduledRun> = new Map();
|
||||
/** Cron service (assigned in setupRoutes). */
|
||||
private cronService!: CronService;
|
||||
/**
|
||||
* Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME
|
||||
* only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would
|
||||
* re-wake a host on every timer tick (the invariant `remote-wake.ts` documents), so
|
||||
* the wiring guard in `test/remote-wake.test.ts` pins that this file calls nothing
|
||||
* but `drop`/`stop` on it.
|
||||
*/
|
||||
private remoteWake: RemoteWakeRegistry | null = null;
|
||||
private sse: SseStreamManager;
|
||||
private store = getStore();
|
||||
private tabLayouts!: TabLayoutService;
|
||||
@@ -1065,7 +1074,9 @@ export class WebServer extends EventEmitter {
|
||||
registerStatusTelemetryRoutes(this.app, ctx);
|
||||
registerSystemRoutes(this.app, ctx);
|
||||
registerCaseRoutes(this.app, ctx);
|
||||
registerSessionRoutes(this.app, ctx);
|
||||
// The registry's lifetime is the server's: it drops per-session wake state on every
|
||||
// cleanup path and resolves in-flight wakes on shutdown.
|
||||
this.remoteWake = registerSessionRoutes(this.app, ctx);
|
||||
registerRespawnRoutes(this.app, ctx);
|
||||
registerRalphRoutes(this.app, ctx);
|
||||
registerPlanRoutes(this.app, ctx);
|
||||
@@ -1456,6 +1467,11 @@ export class WebServer extends EventEmitter {
|
||||
sessionWaits.notifySignal(sessionId, 'exit');
|
||||
sessionWaits.cancelAll(sessionId);
|
||||
approvalInbox.resolveForSession(sessionId, 'session_ended');
|
||||
// Wake state goes with the session on EVERY cleanup path (delete routes, the cron
|
||||
// and admin paths, scheduled-run teardown, error paths) — that is why it lives here
|
||||
// rather than in the two delete routes, where it left an entry behind, including up
|
||||
// to 4 KB of the user's buffered keystrokes.
|
||||
this.remoteWake?.drop(sessionId);
|
||||
|
||||
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
||||
}
|
||||
@@ -3343,6 +3359,10 @@ export class WebServer extends EventEmitter {
|
||||
// response), so without this a 10-minute wait holds shutdown open.
|
||||
sessionWaits.cancelEverything();
|
||||
approvalInbox.stop();
|
||||
// Same reason as `cancelEverything` above: an in-flight wake is awaited by a request,
|
||||
// and `app.close()` (the last line of this method) does not abort in-flight requests —
|
||||
// so without this a restart during a wake waits out the readiness poll.
|
||||
this.remoteWake?.stop();
|
||||
|
||||
this.lastRecordedTokens.clear();
|
||||
|
||||
|
||||
+116
-15
@@ -20,10 +20,12 @@ import {
|
||||
RemoteWakeRegistry,
|
||||
appendBoundedPending,
|
||||
buildMagicPacket,
|
||||
createDefaultRemoteWakeDeps,
|
||||
decideRemoteInputAction,
|
||||
parseMacList,
|
||||
resolveWakeTarget,
|
||||
sendWakePackets,
|
||||
waitUntilRemoteReady,
|
||||
wakeConfigured,
|
||||
REMOTE_WAKE_PENDING_MAX_BYTES,
|
||||
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
@@ -77,21 +79,20 @@ describe('appendBoundedPending', () => {
|
||||
expect(appendBoundedPending([big], 'newest')).toEqual(['newest']);
|
||||
});
|
||||
|
||||
it('trims a single oversized chunk to the cap, keeping its TAIL', () => {
|
||||
// One large paste is one input value, so the cap has to hold WITHIN a chunk too
|
||||
// (otherwise "bounded at 4 KB" would only be true per chunk, not per session).
|
||||
it('drops an oversized chunk outright instead of delivering a fragment of it', () => {
|
||||
// One large paste is one input value and was never typed character by character, so its
|
||||
// tail is not "what the user just typed" — writing it into the pane would run a partial
|
||||
// command (with the paste's trailing carriage return, if it had one).
|
||||
const huge = 'y'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES + 100);
|
||||
const result = appendBoundedPending([], huge);
|
||||
expect(result).toEqual(['y'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES)]);
|
||||
expect(result[0].length).toBe(REMOTE_WAKE_PENDING_MAX_BYTES);
|
||||
expect(appendBoundedPending([], huge)).toEqual([]);
|
||||
// The bytes already queued are left alone, not replaced by the fragment.
|
||||
expect(appendBoundedPending(['typed'], huge)).toEqual(['typed']);
|
||||
});
|
||||
|
||||
it('trims a multi-byte tail without splitting a character', () => {
|
||||
it('measures the cap in UTF-8 bytes, so a multi-byte paste is dropped too', () => {
|
||||
const cap = 10;
|
||||
const value = 'ä'.repeat(8); // 2 bytes each → 16 bytes
|
||||
const result = appendBoundedPending([], value, cap);
|
||||
expect(Buffer.byteLength(result[0])).toBeLessThanOrEqual(cap);
|
||||
expect(result[0]).toBe('ä'.repeat(5)); // 10 bytes, no U+FFFD
|
||||
const value = 'ä'.repeat(8); // 2 bytes each → 16 bytes > cap
|
||||
expect(appendBoundedPending([], value, cap)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -460,6 +461,37 @@ describe('RemoteWakeRegistry', () => {
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(0);
|
||||
expect(h.registry.isWaking('sess-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('never keys the state map on a local session', async () => {
|
||||
// `hasWakeTarget` runs on EVERY input chunk (it is the route's gate), so allocating
|
||||
// state before the `!session.remote` return would put an entry — and later a pending
|
||||
// buffer — in the map for every local session the user types in.
|
||||
const h = harness();
|
||||
const local: WakeableSession = {
|
||||
id: 'local-1',
|
||||
remote: undefined,
|
||||
reattachRemote: h.reattachRemote,
|
||||
writeViaMux: h.writeViaMux,
|
||||
};
|
||||
expect(await h.registry.hasWakeTarget(local)).toBe(false);
|
||||
expect(await h.registry.wakeConfigured(local)).toBe('none');
|
||||
expect(h.registry.stateCount()).toBe(0);
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensureAwake hands the caller’s budget to the readiness poll (the wake button’s case)', async () => {
|
||||
// The button is pressed from the same dashboard as Run/Attach, so it must not inherit
|
||||
// the 90 s session default and get cut off by the proxy's 60 s read timeout.
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
await expect(
|
||||
h.registry.ensureAwake(h.session, { force: true, timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS })
|
||||
).resolves.toBe(true);
|
||||
expect(h.waitUntilReady).toHaveBeenCalledWith(remote, {
|
||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Host-scoped wake (session create/attach) ==========
|
||||
@@ -499,6 +531,8 @@ describe('RemoteWakeRegistry — host-scoped wake for a request that waits on it
|
||||
// 60 s, so a create-path wake must not inherit the 90 s session default.
|
||||
expect(h.waitUntilReady).toHaveBeenCalledWith(hostRemote, {
|
||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
// The shutdown signal rides along so `WebServer.stop()` can end the poll.
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(h.events).toContain('remote:hostWaking');
|
||||
});
|
||||
@@ -552,6 +586,49 @@ describe('RemoteWakeRegistry — host-scoped wake for a request that waits on it
|
||||
|
||||
await expect(h.registry.ensureHostAwake(hostRemote)).resolves.toBe('failed');
|
||||
});
|
||||
|
||||
it('stop() resolves an in-flight wake as failed, so shutdown cannot wait it out', async () => {
|
||||
// `WebServer.stop()` ends with `app.close()`, which does not abort in-flight requests:
|
||||
// without this the shutdown sits out the whole readiness poll. Real `waitUntilReady`
|
||||
// (abortable sleep) with fake probe/wake, which is the shape of a restart mid-wake.
|
||||
const registry = new RemoteWakeRegistry(
|
||||
createDefaultRemoteWakeDeps({ probe: async () => false, wake: async () => true, log: () => {} })
|
||||
);
|
||||
const pending = registry.ensureHostAwake(hostRemote, { timeoutMs: 60_000 });
|
||||
await vi.waitFor(() => expect(registry.isWaking('host:hufflepuff')).toBe(true));
|
||||
|
||||
registry.stop();
|
||||
await expect(pending).resolves.toBe('failed');
|
||||
|
||||
// ... and nothing new starts afterwards.
|
||||
await expect(registry.ensureHostAwake(hostRemote)).resolves.toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitUntilRemoteReady', () => {
|
||||
const remote: WakeableRemote = { hostId: 'h', label: 'H', host: '10.0.0.9' };
|
||||
|
||||
it('ends on abort instead of waiting out the current interval', async () => {
|
||||
const controller = new AbortController();
|
||||
const started = Date.now();
|
||||
const pending = waitUntilRemoteReady(remote, {
|
||||
intervalMs: 1_000,
|
||||
timeoutMs: 60_000,
|
||||
probe: async () => false,
|
||||
signal: controller.signal,
|
||||
});
|
||||
setTimeout(() => controller.abort(), 10);
|
||||
await expect(pending).resolves.toBe(false);
|
||||
expect(Date.now() - started).toBeLessThan(1_000);
|
||||
});
|
||||
|
||||
it('returns false immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const probe = vi.fn(async () => true);
|
||||
await expect(waitUntilRemoteReady(remote, { probe, signal: controller.signal })).resolves.toBe(false);
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Wiring guard ==========
|
||||
@@ -572,11 +649,14 @@ function walkTs(dir: string): string[] {
|
||||
}
|
||||
|
||||
describe('wake wiring guard', () => {
|
||||
it('only the input route may reach the wake registry', () => {
|
||||
it('only the route module and the server may import remote-wake', () => {
|
||||
// The auto-reconnect watcher (tmux-manager.ts), the server's dropped-session
|
||||
// handler and any boot-recovery path must NOT import remote-wake: waking there
|
||||
// re-wakes the host seconds after each suspend. Asserted, not commented.
|
||||
const allowed = new Set([join('web', 'routes', 'session-routes.ts')]);
|
||||
// handler and any boot-recovery path must NOT be able to WAKE a host: waking there
|
||||
// re-wakes the host seconds after each suspend. `web/server.ts` is allowed to hold
|
||||
// the registry for its LIFETIME only (`drop()` on session cleanup, `stop()` on
|
||||
// shutdown) — the test below pins that it never calls a waking method, which is the
|
||||
// property this import list is an approximation of.
|
||||
const allowed = new Set([join('web', 'routes', 'session-routes.ts'), join('web', 'server.ts')]);
|
||||
const importers = walkTs(SRC)
|
||||
.filter((full) => /from\s+['"][^'"]*remote-wake(\.js)?['"]/.test(readFileSync(full, 'utf-8')))
|
||||
.map((full) => relative(SRC, full));
|
||||
@@ -584,6 +664,27 @@ describe('wake wiring guard', () => {
|
||||
expect(importers.sort()).toEqual([...allowed].sort());
|
||||
});
|
||||
|
||||
it('the server only ever calls drop/stop on the registry — never a waking method', () => {
|
||||
// `server.ts` holds the registry because `cleanupSession` and `stop()` need it, and
|
||||
// those run on timers and shutdown paths. Any wake-capable call from this file is the
|
||||
// exact failure invariant #1 exists to prevent, so it is asserted here rather than
|
||||
// left to the import check above (which the field's type alone would satisfy).
|
||||
const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8');
|
||||
for (const method of [
|
||||
'wake',
|
||||
'ensureAwake',
|
||||
'ensureHostAwake',
|
||||
'handleInput',
|
||||
'checkReachable',
|
||||
'checkHostReachable',
|
||||
]) {
|
||||
expect(server).not.toContain(`remoteWake.${method}(`);
|
||||
expect(server).not.toContain(`remoteWake?.${method}(`);
|
||||
}
|
||||
expect(server).toContain('remoteWake?.drop(');
|
||||
expect(server).toContain('remoteWake?.stop(');
|
||||
});
|
||||
|
||||
it('wakes a host for a create/attach request ONLY from the HTTP route', () => {
|
||||
// The create-path wake (`ensureHostAwake`) is a USER request, so it belongs to the
|
||||
// HTTP route. `cron-service.ts` builds sessions through the shared service with
|
||||
|
||||
@@ -1950,6 +1950,8 @@ describe('session-routes', () => {
|
||||
// cut the request at 60 s while the session was still being built.
|
||||
expect(wakeWaitUntilReady).toHaveBeenCalledWith(expect.objectContaining({ hostId: 'hufflepuff' }), {
|
||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
// The shutdown signal rides along so `WebServer.stop()` can end the poll.
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
} finally {
|
||||
startShell.mockRestore();
|
||||
|
||||
Reference in new issue
Block a user