mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb7d0ba565 | ||
|
|
22cb563f1e | ||
|
|
f0e13f9fc3 | ||
|
|
28c5b5c1eb | ||
|
|
af9db455ff |
@@ -1,5 +1,54 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.9.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix a latent bug where a partial settings PUT silently reset live service state, and trim the `xterm-zerolag-input` README callout.
|
||||
- **`PUT /api/settings` no longer resets watchers on a partial body.** The three `toggleService` calls (subagent watcher, workflow-run watcher, image watcher) read the raw request body with `??` defaults, so every key a caller omitted was treated as "apply the default". A body of just `{statusLineTelemetry:true}` would START the subagent watcher and STOP the workflow and image watchers, undoing the persisted config. They now resolve from `merged` (persisted settings + incoming), the same convention the `tmuxHistoryLimit` branch in that handler already used, so any PUT reconciles services to the effective stored state. Nothing triggered this in practice because every shipped client sends a full settings payload rebuilt from the DOM, but it was a trap for the next partial-update caller.
|
||||
- **Regression test**: `test/routes/system-routes-settings-partial-put.test.ts` (4 cases) pins both directions, omitted keys preserve state and explicit keys still take effect. Verified to fail against the pre-fix handler.
|
||||
- **CLAUDE.md** records the rule under "Adding Features → App setting": anything acting on a setting in that handler must resolve from `merged`, never the request body.
|
||||
- **`xterm-zerolag-input` README**: removed the links line (getcodeman.com / install one-liner / star link) from the Codeman callout above the demo GIF. The callout keeps its links in the heading and body.
|
||||
|
||||
## 1.9.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Plan-usage chip now defaults ON on desktop, plus the reworked `xterm-zerolag-input` README.
|
||||
- **Plan-usage chip defaults ON (desktop).** The `showPlanUsageLimits` chip (live 5-hour and weekly plan usage from the Claude statusline) used to be opt-in and default OFF, so most users never saw it. Desktop now defaults ON; handhelds still default OFF so the phone header stays minimal and the `mobile-header-buttons-policy` guard keeps passing. Devices with an explicitly stored preference keep whatever they chose, so nobody's OFF gets overridden.
|
||||
- **One resolver behind the chip.** Added `planUsageChipEnabled()` in settings-ui.js and routed all three call sites through it: the App Settings checkbox, the chip's visibility, and the create-time `statusLineTelemetry` flag in session-ui.js. Those three had independent `?? false` / `=== true` defaults, and a chip revealed without the telemetry flag renders `—` forever, so a default flip on one site alone would have shipped a permanently empty chip.
|
||||
- **Cron button comment corrected.** The App Settings comment claimed "Cron button defaults ON" while the code, the template (`btn-cron--hidden`) and the CSS all default it OFF. Verified against a fresh browser profile: the button is hidden and its checkbox unchecked out of the box. Comment now matches, and states why the two halves stay consistent.
|
||||
- **Docs.** CLAUDE.md, `docs/architecture-invariants.md` and `docs/usage-limits-display-plan.md` updated for the new default and the single-resolver rule; the stale `styles.css` comment claiming the server strips the chip's hidden class at render was corrected (display is per-device, so the client reveals it).
|
||||
- **`xterm-zerolag-input` README rework** (0.1.5 shipped the content; this republishes with the graphic and promo changes): replaced the misaligned 8-line keystroke-flow diagram with a two-line stock-vs-zerolag contrast, added a Codeman callout above the demo GIF with links to getcodeman.com and the repo, and rewrote the Origin section so it argues the extraction story instead of repeating the promo.
|
||||
|
||||
## 1.9.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Rewrite the `xterm-zerolag-input` package README as a value-first document and correct the drift that had accumulated against the source.
|
||||
- Added the side-by-side phone demo GIF (`docs/images/zerolag-demo-20260728.gif`) as the hero image, referenced by absolute raw URL so it renders on npmjs.com as well as GitHub. The two-phone comparison shows 0ms local echo next to a 600ms-2.7s server echo on the same session.
|
||||
- New "Why this one" comparison table, an explicit list of target use cases (SSH web clients, cloud IDEs, mobile terminals, container consoles), and a bundle-size badge (6.1 kB gzipped, measured from the ESM build).
|
||||
- Corrected the test-count badge from 78 to the actual 175 tests across 5 files, in both the package README and the Published Packages section of the root README.
|
||||
- Removed the stale "Unicode/emoji rendered at single-cell width" limitation. CJK, fullwidth forms and emoji have had double-width rendering and visual-column positioning since the wide-character fix; the honest remaining caveat (per-code-point width summing over-counts ZWJ grapheme clusters) replaces it.
|
||||
- Documented the previously undocumented public `setPrompt()` method for switching prompt strategies at runtime, and the new "Wide characters (CJK, emoji)" integration section covering the optional `Unicode11Addon` path and the built-in range-table fallback.
|
||||
- Documented `backgroundColor: 'transparent'`, corrected the `foregroundColor` default, and updated the grid-alignment math to reflect visual-column positioning rather than character index.
|
||||
|
||||
No source changes, docs only.
|
||||
|
||||
## 1.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Narrow the Run dropdown, and close the last two gaps in web-tab asset rewriting.
|
||||
|
||||
**The Run dropdown was pinned at its full width.** It capped at 300px, and the recent-session rows wanted 326px, so it always rendered at the cap and reached further across the terminal than it needed to. Now 250px, chosen as the width at which a `~/<dir>/<repo>` + timestamp row still fits whole, since identifying a session to resume is what that list is for. Three fixes were needed to make the narrower menu degrade instead of clip: the saved-URL label now has its own element, because `text-overflow` on the row button did nothing (a bare text node inside a flex container becomes an anonymous flex item that ellipsis cannot reach); `.hist-dir` got `min-width: 0`, without which a flex item refuses to shrink below its own text and pushes the date out of the box; and history rows are held to the container width, because the list's `overflow-y: auto` implicitly makes `overflow-x: auto` and let each row size to its own content and scroll sideways. Phone and tablet widths are unchanged, being set separately in `mobile.css`.
|
||||
|
||||
**A dashboard's own `/api/...` assets are relayed again.** The `Referer`-keyed 404 fallback, which rescues a root-absolute asset that no rewrite layer could reach, refused everything under `/api` outright. Dashboards commonly serve their assets from exactly that namespace, so those requests had no rescue at all. The refusal is now precise: the relay runs before the API-shaped 404, and the auth exemption refuses only paths that resolve to a REAL Codeman route, with `/ws/` and `/q/` still refused by prefix.
|
||||
|
||||
Two findings shaped that fence, both from probing Fastify rather than reading it. `hasRoute()` matches the registered PATTERN literally, so `/api/sessions/abc` reports no match against a registered `/api/sessions/:id` and would have granted an unauthenticated exemption on a live session-scoped route; `findRoute()` performs the real lookup and is what the fence uses. And `@fastify/static` is mounted at `/`, so it registers a root catch-all matching every path, which has to count as "no real route" or the fence would refuse every referer-form request and break the rescue that already worked. A root catch-all is distinguishable because it is the only route whose wildcard param comes back equal to the whole request path. The fence fails closed, and both edges are pinned in `test/webview-auth-exemption.test.ts`.
|
||||
|
||||
**`url()` inside runtime CSS is rewritten.** Measuring the fallback against a purpose-built dashboard showed one sink no relay can reach: a `<style>` element built by page script has no URL of its own, so the browser sends an EMPTY `Referer` with the image request it triggers. The injected URL shim now rewrites root-absolute `url()` in `<style>` blocks, both as markup and when a `<style>` node is inserted. Verified in Chromium: a stylesheet-only `/api/hero.png` and a runtime `<style>` `/api/late.png` both load, where both previously failed. The remaining known gap is self-navigation via `location.href`, which cannot be patched because `Location.href` is unforgeable.
|
||||
|
||||
## 1.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -74,7 +74,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.9.0 (must match `package.json`)
|
||||
**Version**: 1.9.4 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -183,7 +183,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
|
||||
|
||||
**Plan-usage chip** (statusLine telemetry, opt-in `showPlanUsageLimits`, default OFF): Codeman injects its own `statusLine.command` exporter which POSTs Claude's `rate_limits` blob to `POST /api/status-telemetry`. The exporter is identified by a marker, so it only ever adds/updates/removes a statusLine that is **ours**, never a user's hand-authored one, and it prints the footer through so the in-terminal statusline is not blanked. Claude-mode only; distinct from auto-resume, which reacts to the limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
**Plan-usage chip** (statusLine telemetry, `showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the `statusLineTelemetry` flag on session create). A chip shown without telemetry renders `—` forever. Codeman injects its own `statusLine.command` exporter which POSTs Claude's `rate_limits` blob to `POST /api/status-telemetry`. The exporter is identified by a marker, so it only ever adds/updates/removes a statusLine that is **ours**, never a user's hand-authored one, and it prints the footer through so the in-terminal statusline is not blanked. Claude-mode only; distinct from auto-resume, which reacts to the limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
|
||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||
|
||||
@@ -292,7 +292,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
- **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`. Return the `ApiResponse` envelope (`{ success: true, data }`; errors via `createErrorResponse()` with proper status code). Validate with Zod schemas in `schemas.ts`.
|
||||
- **SSE event**: Add to `src/web/sse-events.ts` + `SSE_EVENTS` in `constants.js`, emit via `broadcast()`, handle in `app.js` (`addListener(`)
|
||||
- **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()`
|
||||
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`).
|
||||
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`.
|
||||
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
|
||||
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`).
|
||||
- **New test**: Pick unique port (search `const PORT =`). Route tests use `app.inject()` (no port needed) — see `test/routes/_route-test-utils.ts`.
|
||||
|
||||
@@ -903,7 +903,7 @@ Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-stru
|
||||
|
||||
[](https://www.npmjs.com/package/xterm-zerolag-input)
|
||||
|
||||
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, configurable prompt detection, full state machine with 78 tests.
|
||||
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 6.1 kB gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 175 tests.
|
||||
|
||||
```bash
|
||||
npm install xterm-zerolag-input
|
||||
|
||||
@@ -46,7 +46,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
### Plan-usage chip (statusLine telemetry)
|
||||
|
||||
**Plan-usage chip** (statusLine telemetry, opt-in `showPlanUsageLimits`, default OFF): Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is _ours_, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (so injecting our statusLine doesn't blank the in-terminal footer). `plan-usage-latest.ts` holds the process-wide last value, replayed in the SSE init snapshot (`getLightState`) so the header chip (`#planUsageChip`, toggled by `showPlanUsageLimits` in settings-ui.js) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. **Distinct from auto-resume** (which reacts to the limit _message_; this proactively shows the live %). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`.
|
||||
**Plan-usage chip** (statusLine telemetry, `showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF): Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is _ours_, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (so injecting our statusLine doesn't blank the in-terminal footer). `plan-usage-latest.ts` holds the process-wide last value, replayed in the SSE init snapshot (`getLightState`) so the header chip (`#planUsageChip`, revealed by `planUsageChipEnabled()` in settings-ui.js, the single resolver behind the checkbox, the chip and the create-time `statusLineTelemetry` flag) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. **Distinct from auto-resume** (which reacts to the limit _message_; this proactively shows the live %). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`.
|
||||
|
||||
### Cron jobs
|
||||
|
||||
@@ -111,13 +111,13 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
|
||||
|
||||
**Origin-scoped, not path-scoped.** `/webview/<cap>/x/y` always maps to `<upstream origin>/x/y`, never `<upstream origin><saved path>/x/y`. Dashboards reference assets root-absolutely (`/public/build/app.js`), so origin-scoping is the only mapping under which those resolve; the saved URL's own path+query is used solely as what `/webview/<cap>/` itself serves.
|
||||
|
||||
**The capability, and why the auth exemption is safe.** A sandboxed iframe (no `allow-same-origin`) is OPAQUE-ORIGIN, so every request it makes is cross-site: the `SameSite=lax` `codeman_session` cookie is never attached, and writes and WS upgrades arrive with `Origin: null`, which `isAllowedRequestOrigin` rejects by design. Cookie auth therefore cannot work. `src/webview-capabilities.ts` mints a 192-bit `randomBytes` token (memory-only, so a restart invalidates every outstanding one; rolling TTL; bound to the minting user; revoked on edit/delete) which `middleware/auth.ts` recognizes via `hasValidWebviewCapability()` to skip the cookie and Origin checks. ⚠️ The **Host allowlist is never bypassed**, so DNS-rebinding protection is intact. ⚠️ There is a second, `Referer`-keyed form of the exemption for root-absolute assets that `<base href>` cannot rewrite (`fetch('/api/data')`, `import('/chunk.js')`); it is the only exemption decided by a request-supplied header, so it is fenced to **safe methods on non-`/api`, non-`/ws`, non-`/q` paths**. Without that fence a page could present a webview Referer and skip auth on `/api`. `test/webview-auth-exemption.test.ts` pins every edge.
|
||||
**The capability, and why the auth exemption is safe.** A sandboxed iframe (no `allow-same-origin`) is OPAQUE-ORIGIN, so every request it makes is cross-site: the `SameSite=lax` `codeman_session` cookie is never attached, and writes and WS upgrades arrive with `Origin: null`, which `isAllowedRequestOrigin` rejects by design. Cookie auth therefore cannot work. `src/webview-capabilities.ts` mints a 192-bit `randomBytes` token (memory-only, so a restart invalidates every outstanding one; rolling TTL; bound to the minting user; revoked on edit/delete) which `middleware/auth.ts` recognizes via `hasValidWebviewCapability()` to skip the cookie and Origin checks. ⚠️ The **Host allowlist is never bypassed**, so DNS-rebinding protection is intact. ⚠️ There is a second, `Referer`-keyed form of the exemption for root-absolute assets that `<base href>` cannot rewrite (`fetch('/api/data')`, `url(/img.png)` in a stylesheet); it is the only exemption decided by a request-supplied header, so it is fenced to **safe methods on paths that resolve to NO registered Codeman route** (`matchesRegisteredRoute`), plus a blanket refusal of `/ws/` and `/q/`. Without that fence a page could present a webview Referer and skip auth on a real API route. ⚠️ The fence uses `findRoute()`, NOT `hasRoute()`: `hasRoute` matches the registered PATTERN literally, so `/api/sessions/abc` reports false against `/api/sessions/:id` and would hand out an exemption on a live route. It also has to treat `@fastify/static`'s root catch-all (mounted at `/`, matches everything) as "no real route", which is detectable because a root catch-all is the only route whose `*` param equals the whole request path. `/api` used to be refused by prefix instead, which permanently broke dashboards serving their own assets from an `/api/...` namespace. `test/webview-auth-exemption.test.ts` pins every edge.
|
||||
|
||||
**Sandbox default.** The iframe carries `allow-scripts allow-forms allow-popups allow-downloads allow-modals` and gains `allow-same-origin` ONLY when the dashboard is explicitly `trusted`. A proxied page is served from Codeman's own origin, so granting it would let the dashboard read the Codeman document and drive the agent-spawning API. ⚠️ In **both** modes, `Authorization` and the `codeman_session` cookie are stripped before the upstream request (`buildUpstreamRequestHeaders`), because a trusted (same-origin) frame makes the browser attach Codeman's own Basic-auth header to every proxied request; forwarding it would hand `CODEMAN_PASSWORD` to the dashboard.
|
||||
|
||||
**Two things a sandboxed frame breaks that are invisible to `curl`.** Both were found only by driving a real dashboard in a real browser, and both present identically as the dashboard's own "Failed to fetch" while the page itself renders fine:
|
||||
|
||||
1. **Root-absolute URLs built at runtime.** `<base href>` only governs URLs the HTML parser resolves; `fetch('/api/data')` bypasses it and lands on Codeman's root. That is how most dashboards talk to their own backend. The `Referer`-keyed 404 fallback deliberately refuses `/api`, `/ws`, `/q` (widening it there would let a request-supplied header skip auth on Codeman's own API), so the fix is `runtimeUrlShim()`: a small script injected right after `<base>` that rebases root-absolute and same-origin-absolute URLs into the prefix. It removes the whole class inside the iframe instead of trading security for it. ⚠️ It must be injected even when the page ships its OWN `<base>` (an early return there silently breaks exactly the pages that need it most). ⚠️ **The DOM sinks are as load-bearing as `fetch`.** Patching only `fetch`/`XHR`/`WebSocket`/`EventSource` leaves `container.innerHTML = '<img src="/api/hero?slug=x">'` and `img.src = '/api/slide'` untouched, and neither of the other layers can reach those either (`<base>` never applies to root-absolute URLs, and `rewriteHtml()` only ever sees the INITIAL document, never markup built later by page script). The symptom is precise and easy to misdiagnose as an upstream fault: the dashboard's **data** loads while every **image** stays broken. So the shim also wraps `innerHTML`/`outerHTML`/`insertAdjacentHTML`, `setAttribute`/`setAttributeNS`, and the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters, with a `MutationObserver` as a last net for sinks not patched above. Every rewrite routes through the same idempotent `rw()`, which matters because unlike the server-side rewrite this one sees markup that may ALREADY be proxied (a page re-injecting its own `outerHTML` would otherwise double-prefix). The DOM half is pinned in jsdom by `test/webview-proxy.test.ts`; `curl` cannot see any of it.
|
||||
1. **Root-absolute URLs built at runtime.** `<base href>` only governs URLs the HTML parser resolves; `fetch('/api/data')` bypasses it and lands on Codeman's root. That is how most dashboards talk to their own backend. The `Referer`-keyed 404 fallback is only a rescue (it fires after every real route missed, and only when the browser sends a usable `Referer`), so the fix is `runtimeUrlShim()`: a small script injected right after `<base>` that rebases root-absolute and same-origin-absolute URLs into the prefix. It removes the whole class inside the iframe instead of trading security for it. ⚠️ It must be injected even when the page ships its OWN `<base>` (an early return there silently breaks exactly the pages that need it most). ⚠️ **The DOM sinks are as load-bearing as `fetch`.** Patching only `fetch`/`XHR`/`WebSocket`/`EventSource` leaves `container.innerHTML = '<img src="/api/hero?slug=x">'` and `img.src = '/api/slide'` untouched, and neither of the other layers can reach those either (`<base>` never applies to root-absolute URLs, and `rewriteHtml()` only ever sees the INITIAL document, never markup built later by page script). The symptom is precise and easy to misdiagnose as an upstream fault: the dashboard's **data** loads while every **image** stays broken. So the shim also wraps `innerHTML`/`outerHTML`/`insertAdjacentHTML`, `setAttribute`/`setAttributeNS`, and the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters, with a `MutationObserver` as a last net for sinks not patched above. Every rewrite routes through the same idempotent `rw()`, which matters because unlike the server-side rewrite this one sees markup that may ALREADY be proxied (a page re-injecting its own `outerHTML` would otherwise double-prefix). The DOM half is pinned in jsdom by `test/webview-proxy.test.ts`; `curl` cannot see any of it.
|
||||
2. **CORS on same-host requests.** An opaque-origin document treats EVERY request as cross-origin, including to the very host it was served from, so its `fetch`/XHR are CORS-checked and its preflights carry `Origin: null`. Static subresources (script/css/img) are NOT CORS-checked, which is why the page renders while its API calls die with an opaque `net::ERR_FAILED`. `buildProxyCorsHeaders()` echoes the origin (omitting `allow-credentials` for `null`, which browsers reject in combination), upstream `access-control-*` headers are dropped (they describe the dashboard's origin, not the frame's), and the proxy answers preflights itself rather than relaying them. ⚠️ `registerSecurityHeaders` answers EVERY `OPTIONS` with a bare 204 before routing, and its CORS block only emits headers for localhost origins, so that short-circuit **must** exempt a valid webview capability or every preflight fails. `curl` cannot reproduce any of this because curl does not enforce CORS.
|
||||
|
||||
**Rewrites, each load-bearing** (pure + unit-tested in `src/web/webview-proxy.ts`): drop `x-frame-options` and the CSP `frame-ancestors` directive (the point of the proxy); drop `content-encoding`/`content-length` because undici's `fetch` already decoded the body (forwarding them makes the browser gunzip plaintext); rewrite `Location` for same-origin redirects only, handing CROSS-origin redirects back unchanged so this never becomes an open relay; rebase `Set-Cookie` `Path` onto the prefix and drop `Domain`; inject `<base href>` and rebase root-absolute `src`/`href`/`action`. `resolveUpstreamUrl()` returns null on anything escaping the upstream origin.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Plan Usage Limits Display — Design & As-Built
|
||||
|
||||
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** Opt-in via App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`, default OFF). Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
|
||||
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
|
||||
>
|
||||
> Two surfaces from one `statusLine` callback:
|
||||
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
|
||||
|
||||
+52
-13
@@ -134,18 +134,57 @@ Two details that mattered:
|
||||
|
||||
---
|
||||
|
||||
## Deliberately NOT done: widening the `/api` referer fallback
|
||||
## Follow-up (same day): the `/api` referer fallback, done safely
|
||||
|
||||
Considered as defense in depth, and skipped. It would have to change **both**
|
||||
gates or it is useless on a password-protected instance, and the auth gate is the
|
||||
security-sensitive one: auth runs in `onRequest`, before routing, so it cannot
|
||||
tell a real Codeman API route from a 404. Dropping the `/api` fence would let a
|
||||
page holding a capability forge a `Referer` and reach Codeman's **real** API
|
||||
unauthenticated. Doing it safely means exempting only paths that match no
|
||||
registered route (via Fastify's `hasRoute`/`findRoute`), which is a separate
|
||||
change deserving its own review and its own cases in
|
||||
`test/webview-auth-exemption.test.ts`.
|
||||
Originally deferred, then implemented on request. Both gates had to move, and the
|
||||
auth one is the security-sensitive half: auth runs in `onRequest`, before routing,
|
||||
so it cannot tell a real Codeman API route from a 404, and simply dropping the
|
||||
`/api` fence would let a page holding a capability forge a `Referer` and reach
|
||||
Codeman's **real** API unauthenticated.
|
||||
|
||||
The remaining gap this leaves is narrow and documented in `docs/web-tabs.md`: a
|
||||
root-absolute `url(/img.png)` inside a stylesheet injected at runtime. Non-`/api`
|
||||
ones are already rescued by the existing referer fallback.
|
||||
What shipped:
|
||||
|
||||
- `server.ts`: `tryWebviewRefererFallback` is tried **before** the API-shaped 404.
|
||||
Reaching that handler already proves no route matched, and the relay declines
|
||||
unless the `Referer` carries a live capability, so unknown `/api` paths still
|
||||
get the envelope.
|
||||
- `middleware/auth.ts`: the `/api/` prefix refusal is replaced by
|
||||
`matchesRegisteredRoute()`, which refuses the exemption for any path that
|
||||
resolves to a real route. `/ws/` and `/q/` stay refused by prefix.
|
||||
|
||||
Two findings that decided the implementation, both established by probing Fastify
|
||||
rather than by reading its docs:
|
||||
|
||||
- **`hasRoute()` is the wrong tool and would have been a hole.** It matches the
|
||||
registered PATTERN literally, so `hasRoute({url: '/api/sessions/abc'})` returns
|
||||
false against a registered `/api/sessions/:id` and would have handed out an
|
||||
exemption on a live, session-scoped API route. `findRoute()` performs the real
|
||||
radix-tree lookup and is what the fence uses.
|
||||
- **`@fastify/static` is mounted at `/`, so it registers a root catch-all that
|
||||
matches every path.** A match on it means "heading for the 404 handler", not
|
||||
"real route", and it is distinguishable because a root catch-all is the only
|
||||
route whose `*` param comes back equal to the whole request path. Without that
|
||||
carve-out the fence would have refused every referer-form request and broken the
|
||||
rescue that already worked.
|
||||
|
||||
The fence fails closed, and `test/webview-auth-exemption.test.ts` pins both edges
|
||||
(a concrete URL onto a parametric API route stays 401; the dashboard's own
|
||||
`/api/...` namespace is served).
|
||||
|
||||
### And the CSS gap, which the fallback could NOT close
|
||||
|
||||
Testing the fallback against a purpose-built upstream showed the runtime-injected
|
||||
stylesheet case is unreachable by any relay: a `<style>` element has no URL of its
|
||||
own, so Chromium sends an **empty `Referer`** with the image request it triggers
|
||||
and there is nothing to key on. Measured directly:
|
||||
|
||||
| sink | Referer the browser sends | fixed by |
|
||||
| --- | --- | --- |
|
||||
| `url()` in a proxied `.css` | the stylesheet's proxied URL | the referer relay |
|
||||
| `url()` in a runtime `<style>` | *empty* | `rwCss()` in the shim |
|
||||
|
||||
So the shim also rewrites `url()` inside `<style>` blocks, both when they arrive as
|
||||
markup and when a `<style>` node is inserted (via the existing MutationObserver).
|
||||
|
||||
The only gap left is self-navigation via `location.href = '/x'`, which cannot be
|
||||
patched because `Location.href` is unforgeable.
|
||||
|
||||
+14
-11
@@ -103,11 +103,16 @@ layers cooperate so a dashboard talking to its own backend just works:
|
||||
proxy serves.
|
||||
3. A small injected script rebases URLs built at **runtime**, which the first two
|
||||
cannot see: `fetch('/api/data')` and `new WebSocket('/live')`, but equally
|
||||
`card.innerHTML = '<img src="/api/hero">'` and `img.src = '/api/slide'`. That
|
||||
second group is why images are covered too. A dashboard that renders its
|
||||
thumbnails from script would otherwise show all its data and none of its
|
||||
pictures, because `<base>` does not apply to root-absolute URLs and the
|
||||
attribute rewriting only ever saw the initial document.
|
||||
`card.innerHTML = '<img src="/api/hero">'`, `img.src = '/api/slide'`, and
|
||||
`url(/img.png)` inside a `<style>` the page injects. That second group is why
|
||||
images are covered too. A dashboard that renders its thumbnails from script
|
||||
would otherwise show all its data and none of its pictures, because `<base>`
|
||||
does not apply to root-absolute URLs and the attribute rewriting only ever saw
|
||||
the initial document.
|
||||
4. As a last resort, a request that still lands on Codeman's own root is relayed
|
||||
using its `Referer` to identify the dashboard. This only fires for a request
|
||||
that already missed every Codeman route, and never for one that resolves to a
|
||||
real route, which is what keeps it from being an authentication bypass.
|
||||
|
||||
On top of that, the proxy answers those requests with CORS headers. That sounds
|
||||
wrong for same-host requests, but a sandboxed iframe has an *opaque* origin, so the
|
||||
@@ -117,12 +122,10 @@ then every API call fails, which looks like the dashboard being broken.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Exotic loaders.** The three layers above cover normal `fetch`/XHR/WebSocket/
|
||||
EventSource, normal markup, and the DOM sinks a page uses to build markup at
|
||||
runtime. Something that constructs requests by an unusual route can still slip
|
||||
through. Symptom: the page renders but a panel stays empty. The known remaining
|
||||
gap is a root-absolute `url(/img.png)` inside a stylesheet the page injects at
|
||||
runtime; one under `/api` has no fallback and will 404.
|
||||
- **Exotic loaders.** The layers above cover normal `fetch`/XHR/WebSocket/
|
||||
EventSource, normal markup, the DOM sinks a page uses to build markup at runtime,
|
||||
and `url()` inside stylesheets. Something that constructs requests by an unusual
|
||||
route can still slip through. Symptom: the page renders but a panel stays empty.
|
||||
- **Root-absolute `location` navigation.** A dashboard that navigates itself with
|
||||
`location.href = '/login'` escapes the prefix, because `Location.href` is
|
||||
unforgeable and cannot be patched the way the other sinks are. A relative
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -12333,7 +12333,7 @@
|
||||
}
|
||||
},
|
||||
"packages/xterm-zerolag-input": {
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.7",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"jsdom": "^24.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.4",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# xterm-zerolag-input
|
||||
|
||||
## 0.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix a latent bug where a partial settings PUT silently reset live service state, and trim the `xterm-zerolag-input` README callout.
|
||||
- **`PUT /api/settings` no longer resets watchers on a partial body.** The three `toggleService` calls (subagent watcher, workflow-run watcher, image watcher) read the raw request body with `??` defaults, so every key a caller omitted was treated as "apply the default". A body of just `{statusLineTelemetry:true}` would START the subagent watcher and STOP the workflow and image watchers, undoing the persisted config. They now resolve from `merged` (persisted settings + incoming), the same convention the `tmuxHistoryLimit` branch in that handler already used, so any PUT reconciles services to the effective stored state. Nothing triggered this in practice because every shipped client sends a full settings payload rebuilt from the DOM, but it was a trap for the next partial-update caller.
|
||||
- **Regression test**: `test/routes/system-routes-settings-partial-put.test.ts` (4 cases) pins both directions, omitted keys preserve state and explicit keys still take effect. Verified to fail against the pre-fix handler.
|
||||
- **CLAUDE.md** records the rule under "Adding Features → App setting": anything acting on a setting in that handler must resolve from `merged`, never the request body.
|
||||
- **`xterm-zerolag-input` README**: removed the links line (getcodeman.com / install one-liner / star link) from the Codeman callout above the demo GIF. The callout keeps its links in the heading and body.
|
||||
|
||||
## 0.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Plan-usage chip now defaults ON on desktop, plus the reworked `xterm-zerolag-input` README.
|
||||
- **Plan-usage chip defaults ON (desktop).** The `showPlanUsageLimits` chip (live 5-hour and weekly plan usage from the Claude statusline) used to be opt-in and default OFF, so most users never saw it. Desktop now defaults ON; handhelds still default OFF so the phone header stays minimal and the `mobile-header-buttons-policy` guard keeps passing. Devices with an explicitly stored preference keep whatever they chose, so nobody's OFF gets overridden.
|
||||
- **One resolver behind the chip.** Added `planUsageChipEnabled()` in settings-ui.js and routed all three call sites through it: the App Settings checkbox, the chip's visibility, and the create-time `statusLineTelemetry` flag in session-ui.js. Those three had independent `?? false` / `=== true` defaults, and a chip revealed without the telemetry flag renders `—` forever, so a default flip on one site alone would have shipped a permanently empty chip.
|
||||
- **Cron button comment corrected.** The App Settings comment claimed "Cron button defaults ON" while the code, the template (`btn-cron--hidden`) and the CSS all default it OFF. Verified against a fresh browser profile: the button is hidden and its checkbox unchecked out of the box. Comment now matches, and states why the two halves stay consistent.
|
||||
- **Docs.** CLAUDE.md, `docs/architecture-invariants.md` and `docs/usage-limits-display-plan.md` updated for the new default and the single-resolver rule; the stale `styles.css` comment claiming the server strips the chip's hidden class at render was corrected (display is per-device, so the client reveals it).
|
||||
- **`xterm-zerolag-input` README rework** (0.1.5 shipped the content; this republishes with the graphic and promo changes): replaced the misaligned 8-line keystroke-flow diagram with a two-line stock-vs-zerolag contrast, added a Codeman callout above the demo GIF with links to getcodeman.com and the repo, and rewrote the Origin section so it argues the extraction story instead of repeating the promo.
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Rewrite the `xterm-zerolag-input` package README as a value-first document and correct the drift that had accumulated against the source.
|
||||
- Added the side-by-side phone demo GIF (`docs/images/zerolag-demo-20260728.gif`) as the hero image, referenced by absolute raw URL so it renders on npmjs.com as well as GitHub. The two-phone comparison shows 0ms local echo next to a 600ms-2.7s server echo on the same session.
|
||||
- New "Why this one" comparison table, an explicit list of target use cases (SSH web clients, cloud IDEs, mobile terminals, container consoles), and a bundle-size badge (6.1 kB gzipped, measured from the ESM build).
|
||||
- Corrected the test-count badge from 78 to the actual 175 tests across 5 files, in both the package README and the Published Packages section of the root README.
|
||||
- Removed the stale "Unicode/emoji rendered at single-cell width" limitation. CJK, fullwidth forms and emoji have had double-width rendering and visual-column positioning since the wide-character fix; the honest remaining caveat (per-code-point width summing over-counts ZWJ grapheme clusters) replaces it.
|
||||
- Documented the previously undocumented public `setPrompt()` method for switching prompt strategies at runtime, and the new "Wide characters (CJK, emoji)" integration section covering the optional `Unicode11Addon` path and the built-in range-table fallback.
|
||||
- Documented `backgroundColor: 'transparent'`, corrected the `foregroundColor` default, and updated the grid-alignment math to reflect visual-column positioning rather than character index.
|
||||
|
||||
No source changes, docs only.
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,45 +1,64 @@
|
||||
<p align="center">
|
||||
<h1 align="center">xterm-zerolag-input</h1>
|
||||
<p align="center">
|
||||
Instant keystroke feedback overlay for <a href="https://xtermjs.org/">xterm.js</a><br>
|
||||
<em>Eliminates perceived input latency over high-RTT connections</em>
|
||||
<strong>Make typing feel instant in <a href="https://xtermjs.org/">xterm.js</a>, no matter how far away the server is.</strong><br>
|
||||
<em>A pixel-perfect local echo overlay. Client-side only. Zero dependencies.</em>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/xterm-zerolag-input"><img src="https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e" alt="npm"></a>
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="MIT"></a>
|
||||
<img src="https://img.shields.io/badge/Dependencies-0-22c55e?style=flat-square" alt="Zero deps">
|
||||
<img src="https://img.shields.io/badge/Tests-78-22c55e?style=flat-square" alt="78 tests">
|
||||
<img src="https://img.shields.io/badge/xterm.js-v5%20%7C%20v7+-3b82f6?style=flat-square" alt="xterm.js">
|
||||
<img src="https://img.shields.io/badge/Dependencies-0-22c55e?style=flat-square" alt="Zero dependencies">
|
||||
<img src="https://img.shields.io/badge/Size-6.1%20kB%20gzip-22c55e?style=flat-square" alt="6.1 kB gzipped">
|
||||
<img src="https://img.shields.io/badge/Tests-175-22c55e?style=flat-square" alt="175 tests">
|
||||
<img src="https://img.shields.io/badge/xterm.js-v5%20%7C%20v7+-3b82f6?style=flat-square" alt="xterm.js v5 and v7+">
|
||||
</p>
|
||||
</p>
|
||||
|
||||
> ### Made for [**Codeman**](https://getcodeman.com)
|
||||
>
|
||||
> This overlay is the local echo engine of [**Codeman**](https://github.com/Ark0N/Codeman), mission control for AI coding agents: run and monitor a dozen Claude Code, Codex, OpenCode and Gemini sessions at once, watch their subagents work in live floating windows, let them run autonomously overnight, and drive all of it from your phone.
|
||||
>
|
||||
> That last part is why this library exists. The demo below is a real Codeman session on two phones.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/zerolag-demo-20260728.gif" alt="Side-by-side phones typing into the same remote session: with zerolag the text appears at 0ms, without it every keystroke waits 600ms to 2.7s for the server echo" width="900">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>Two phones, the same remote session, the same slow link.<br>
|
||||
Left: the zerolag overlay paints every keystroke at <strong>0ms</strong>. Right: stock xterm.js waits <strong>600ms to 2.7s</strong> for the server to echo it back.</em>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
## The 30-second version
|
||||
|
||||
When using xterm.js over a remote connection (SSH web clients, cloud IDEs, mobile terminals), every keystroke takes a full round-trip to the server before appearing on screen. At 100-500ms RTT, typing feels sluggish and unresponsive. Users type blind, make mistakes they can't see, and the experience feels broken.
|
||||
|
||||
## The Solution
|
||||
|
||||
`xterm-zerolag-input` renders typed characters **immediately** as a pixel-perfect DOM overlay positioned on the terminal's character grid. The overlay covers the terminal canvas at the prompt location, showing characters instantly while the server echo travels back. Once the server responds, the overlay seamlessly disappears and the real terminal text takes over.
|
||||
Over a remote connection, xterm.js shows you a character only after it has flown to the server and back. At 100-500ms RTT that reads as broken: you type ahead of the screen, you cannot see your typos, and you start pecking one key at a time to stay in sync.
|
||||
|
||||
```
|
||||
Keystroke Flow:
|
||||
┌─── DOM overlay (instant, 0ms)
|
||||
User types 'h' ─── onData('h') ───┤
|
||||
└─── Your app sends to PTY ──→ Server
|
||||
│
|
||||
Server echoes 'h' ←──────────────────────────────────────────────────┘
|
||||
│ (200-500ms RTT)
|
||||
└──→ terminal.write('h') ──→ overlay.clear()
|
||||
(server output replaces overlay — seamless transition)
|
||||
stock xterm.js keypress ─────── 300 ms ───────→ character appears
|
||||
with zerolag keypress → character appears · echo lands later, unseen
|
||||
```
|
||||
|
||||
**No changes to your backend needed.** The addon is purely client-side.
|
||||
Same keystroke, same link. The only difference is who you wait for: the server, or nobody.
|
||||
|
||||
## Origin
|
||||
`xterm-zerolag-input` paints your keystrokes **immediately**, as an absolutely-positioned DOM overlay locked to the terminal's character grid. The byte still goes to the PTY exactly as before, so nothing about your shell changes. When the server echo lands 300ms later, the overlay clears and the real terminal text takes over on the same pixels. The handoff is invisible.
|
||||
|
||||
This library was extracted from [Codeman](https://github.com/Ark0N/Codeman), mission control for AI coding agents — multi-session management, real-time agent visualization, autonomous respawn loops, and a mobile-first web UI for Claude Code, OpenCode, and Codex. The local echo system was built to make mobile and remote access feel instant, then battle-tested across thousands of hours of real usage. After 3 deep code audits, it was extracted into this standalone library with 78 tests covering every state transition.
|
||||
**No backend changes. No protocol. No server support.** It is a client-side addon that never touches the wire.
|
||||
|
||||
## Why this one
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Survives full-screen TUIs** | Ink, blessed, and friends repaint the whole screen constantly. The overlay is a separate DOM layer they cannot reach, so it does not get clobbered. |
|
||||
| **Pixel-matched to the canvas** | Each character is its own absolutely-positioned `<span>` at exact cell coordinates, so it does not drift out of the grid like normal DOM text flow. |
|
||||
| **Wide characters included** | CJK, fullwidth forms and emoji render double-width and position by visual column, using the terminal's Unicode addon when one is loaded. |
|
||||
| **Backspace that actually works** | A three-layer cascade (unsent, in-flight, already on screen) tells you exactly what to forward to the PTY, so editing works through any mix of typed, flushed and tab-completed text. |
|
||||
| **You keep control of input** | The addon never hooks `onData` for you. You decide what gets echoed and what gets forwarded, which is what makes char-at-a-time, buffered, and multi-session tab switching all possible. |
|
||||
| **Small and self-contained** | 6.1 kB gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. |
|
||||
| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 175 tests over every state transition. |
|
||||
|
||||
Built for anything that puts a terminal behind a network hop: SSH web clients, cloud IDEs, mobile terminals, Kubernetes and container consoles, remote agent dashboards, browser-based dev environments.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -47,12 +66,9 @@ This library was extracted from [Codeman](https://github.com/Ark0N/Codeman), mis
|
||||
npm install xterm-zerolag-input
|
||||
```
|
||||
|
||||
- **Zero runtime dependencies**
|
||||
- Compatible with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+)
|
||||
- Dual CJS/ESM build with full TypeScript declarations
|
||||
- Works with canvas, WebGL, and DOM renderers
|
||||
Works with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+), and with the canvas, WebGL and DOM renderers.
|
||||
|
||||
## Quick Start
|
||||
## Quick start
|
||||
|
||||
```typescript
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
@@ -61,7 +77,7 @@ import { ZerolagInputAddon } from 'xterm-zerolag-input';
|
||||
const terminal = new Terminal();
|
||||
terminal.open(document.getElementById('terminal')!);
|
||||
|
||||
// 1. Create addon with your prompt character
|
||||
// 1. Create the addon with your prompt character
|
||||
const zerolag = new ZerolagInputAddon({
|
||||
prompt: { type: 'character', char: '$', offset: 2 },
|
||||
});
|
||||
@@ -75,7 +91,7 @@ terminal.onData((data) => {
|
||||
ws.send(text + '\r');
|
||||
} else if (data === '\x7f') {
|
||||
const source = zerolag.removeChar();
|
||||
if (source === 'flushed') ws.send(data); // only backspace text already in PTY
|
||||
if (source === 'flushed') ws.send(data); // only backspace text already in the PTY
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
zerolag.addChar(data);
|
||||
}
|
||||
@@ -87,26 +103,29 @@ terminal.onWriteParsed(() => {
|
||||
});
|
||||
```
|
||||
|
||||
## Why This Is Hard
|
||||
That is the whole integration. Everything below is for tuning it.
|
||||
|
||||
Most terminal UIs can't do local echo because:
|
||||
## Why this is hard
|
||||
|
||||
1. **Buffer writes corrupt**: Frameworks like [Ink](https://github.com/vadimdemedes/ink) (React for terminals) redraw the entire screen on every state change. Writing directly to the terminal buffer gets immediately overwritten.
|
||||
Most terminal UIs cannot do local echo, for three reasons:
|
||||
|
||||
2. **Cursor position lies**: In Ink, `buffer.cursorY` reflects internal state (near the status bar), not the visible prompt. You can't trust it.
|
||||
1. **Buffer writes get corrupted.** Frameworks like [Ink](https://github.com/vadimdemedes/ink) (React for terminals) redraw the entire screen on every state change. Anything written straight into the terminal buffer is overwritten immediately.
|
||||
|
||||
3. **Font matching**: Canvas/WebGL renderers use their own text shaping. A DOM overlay must pixel-match the canvas grid — normal DOM text flow drifts due to sub-pixel glyph width differences.
|
||||
2. **Cursor position lies.** In Ink, `buffer.cursorY` reflects internal render state (often near a status bar), not the visible prompt. You cannot trust it.
|
||||
|
||||
This library solves all three by:
|
||||
- Using a **DOM overlay** that Ink can't touch (separate z-index layer)
|
||||
- **Scanning the buffer** bottom-up for the prompt character instead of trusting cursor position
|
||||
- Rendering each character as an **absolutely-positioned `<span>`** at exact cell-grid coordinates
|
||||
3. **Fonts do not line up.** Canvas and WebGL renderers do their own text shaping. A DOM overlay has to pixel-match that grid, and normal DOM text flow drifts as sub-pixel glyph widths accumulate.
|
||||
|
||||
This library answers all three:
|
||||
|
||||
- a **DOM overlay** on its own z-index layer, which Ink cannot touch
|
||||
- **bottom-up buffer scanning** for the prompt instead of trusting the cursor
|
||||
- **one absolutely-positioned `<span>` per character** at exact cell-grid coordinates
|
||||
|
||||
---
|
||||
|
||||
## Prompt Detection
|
||||
## Prompt detection
|
||||
|
||||
The addon needs to know where user input starts. It scans the terminal buffer bottom-up for the prompt. Three strategies:
|
||||
The addon needs to know where user input starts. It scans the terminal buffer bottom-up. Three strategies:
|
||||
|
||||
### Character (default)
|
||||
|
||||
@@ -118,17 +137,17 @@ The addon needs to know where user input starts. It scans the terminal buffer bo
|
||||
{ type: 'character', char: '%', offset: 2 }
|
||||
|
||||
// Fish / Starship: ❯
|
||||
{ type: 'character', char: '\u276f', offset: 2 }
|
||||
{ type: 'character', char: '❯', offset: 2 }
|
||||
|
||||
// Simple arrow: >
|
||||
{ type: 'character', char: '>', offset: 2 }
|
||||
```
|
||||
|
||||
`offset` = characters between the prompt marker and where user input begins (e.g., `"$ "` = 2).
|
||||
`offset` = characters between the prompt marker and where user input begins (`"$ "` = 2).
|
||||
|
||||
### Regex
|
||||
|
||||
For complex prompts. The `g` flag is safely stripped to prevent `lastIndex` mutation.
|
||||
For complex prompts. The `g` flag is stripped safely, so there is no `lastIndex` mutation.
|
||||
|
||||
```typescript
|
||||
{ type: 'regex', pattern: /\$\s*$/, offset: 2 }
|
||||
@@ -150,77 +169,88 @@ Full control:
|
||||
}
|
||||
```
|
||||
|
||||
### Switching prompts at runtime
|
||||
|
||||
If one terminal hosts several CLIs with different prompts, swap the strategy in place:
|
||||
|
||||
```typescript
|
||||
zerolag.setPrompt({ type: 'character', char: '❯', offset: 2 });
|
||||
```
|
||||
|
||||
`setPrompt()` clears the cached prompt position and re-renders if anything is pending, so a mode switch cannot leave the overlay pinned to the old column.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
## API reference
|
||||
|
||||
### `ZerolagInputAddon`
|
||||
|
||||
Implements xterm.js `ITerminalAddon`. The addon does **not** hook `terminal.onData()` — you wire your own input handler and call these methods. This gives you full control over which keystrokes are echoed vs forwarded.
|
||||
Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not** hook `terminal.onData()`: you wire your own handler and call these methods, which is what gives you control over which keystrokes are echoed and which are forwarded.
|
||||
|
||||
### Input
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on first keystroke. |
|
||||
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on the first keystroke. |
|
||||
| `appendText(text)` | `void` | Append multiple characters (paste). |
|
||||
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove last char. See [backspace handling](#backspace-handling). |
|
||||
| `clear()` | `void` | Clear all state, hide overlay. Call on Enter/Ctrl+C/Escape. |
|
||||
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character. See [backspace handling](#backspace-handling). |
|
||||
| `clear()` | `void` | Clear all state and hide the overlay. Call on Enter, Ctrl+C, Escape. |
|
||||
|
||||
### Backspace Handling
|
||||
### Backspace handling
|
||||
|
||||
`removeChar()` cascades through three layers and tells you what it removed:
|
||||
|
||||
| Return | Source | Your action |
|
||||
|--------|--------|-------------|
|
||||
| `'pending'` | Unsent text (never transmitted to PTY) | Do nothing |
|
||||
| `'flushed'` | Text already sent to PTY | Send `\x7f` backspace to PTY |
|
||||
| `'pending'` | Unsent text (never transmitted to the PTY) | Do nothing |
|
||||
| `'flushed'` | Text already sent to the PTY | Send `\x7f` to the PTY |
|
||||
| `false` | Nothing to remove | Do nothing |
|
||||
|
||||
The cascade: pending text first, then flushed text, then auto-detect buffer text (handles tab completion). This means backspace "just works" through any combination of typed, flushed, and tab-completed text.
|
||||
The cascade order is pending text, then flushed text, then auto-detected buffer text (which is what makes backspace work after tab completion). Backspace "just works" across any combination of typed, in-flight and completed text.
|
||||
|
||||
### Flushed Text
|
||||
### Flushed text
|
||||
|
||||
"Flushed" = sent to PTY but echo hasn't arrived yet. Happens during tab switches and tab completion.
|
||||
"Flushed" means sent to the PTY but the echo has not arrived yet. This happens during tab switches and tab completion.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `setFlushed(count, text, render?)` | Mark text as flushed. Pass `render=false` during tab-switch restore (buffer not loaded yet). |
|
||||
| `setFlushed(count, text, render?)` | Mark text as flushed. Pass `render=false` during tab-switch restore, when the buffer is not loaded yet. |
|
||||
| `getFlushed()` | Returns `{ count, text }`. |
|
||||
| `clearFlushed()` | Clear flushed state when server echo arrives. |
|
||||
| `clearFlushed()` | Clear flushed state once the server echo arrives. |
|
||||
|
||||
### Buffer Detection
|
||||
### Buffer detection
|
||||
|
||||
Scan the terminal for text that exists after the prompt but wasn't typed through the overlay.
|
||||
Finds text that exists after the prompt but was never typed through the overlay.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `detectBufferText()` | Scan and return detected text (or `null`). Sets it as flushed. Guarded: runs once per `clear()` cycle. |
|
||||
| `detectBufferText()` | Scan and return the detected text (or `null`), marking it flushed. Guarded: runs once per `clear()` cycle. |
|
||||
| `resetBufferDetection()` | Re-enable detection. |
|
||||
| `suppressBufferDetection()` | Block detection until next `clear()`. Use for sessions with UI framework text after the prompt. |
|
||||
| `undoDetection()` | Undo last detection — clears flushed state, re-enables detection. For tab completion retry. |
|
||||
| `suppressBufferDetection()` | Block detection until the next `clear()`. Use for sessions that render UI framework text after the prompt. |
|
||||
| `undoDetection()` | Undo the last detection: clears flushed state and re-enables detection. For tab-completion retries. |
|
||||
|
||||
### Rendering
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `rerender()` | Force re-render. Call after buffer reloads, screen redraws, resizes, reconnects. |
|
||||
| `refreshFont()` | Re-cache font properties from terminal. Call after font size or theme changes. |
|
||||
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. |
|
||||
| `refreshFont()` | Re-cache font and color properties from the terminal. Call after a font size or theme change. |
|
||||
|
||||
### Prompt Utilities
|
||||
### Prompt
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `findPrompt()` | Find prompt position. Returns `{ row, col }` or `null`. |
|
||||
| `readPromptText()` | Read text after prompt marker. Returns string or `null`. |
|
||||
| `setPrompt(finder)` | Replace the prompt detection strategy at runtime. |
|
||||
| `findPrompt()` | Find the prompt position. Returns `{ row, col }` or `null`. |
|
||||
| `readPromptText()` | Read the text after the prompt marker. Returns a string or `null`. |
|
||||
|
||||
### State
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `pendingText` | `string` | Unacknowledged text (read-only) |
|
||||
| `hasPending` | `boolean` | `true` if overlay has any content |
|
||||
| `state` | `ZerolagInputState` | Full snapshot: pendingText, flushedLength, flushedText, visible, promptPosition |
|
||||
| `hasPending` | `boolean` | `true` if the overlay has any content |
|
||||
| `state` | `ZerolagInputState` | Full snapshot: `pendingText`, `flushedLength`, `flushedText`, `visible`, `promptPosition` |
|
||||
|
||||
### Options
|
||||
|
||||
@@ -228,23 +258,23 @@ Scan the terminal for text that exists after the prompt but wasn't typed through
|
||||
{
|
||||
prompt?: PromptFinder, // Default: { type: 'character', char: '>', offset: 2 }
|
||||
zIndex?: number, // Default: 7
|
||||
backgroundColor?: string, // Default: from terminal theme
|
||||
foregroundColor?: string, // Default: from computed .xterm-rows style
|
||||
backgroundColor?: string, // Default: terminal theme background ('transparent' to disable)
|
||||
foregroundColor?: string, // Default: terminal theme / computed .xterm-rows style
|
||||
showCursor?: boolean, // Default: true
|
||||
cursorColor?: string, // Default: from terminal theme
|
||||
cursorColor?: string, // Default: terminal theme cursor
|
||||
scrollDebounceMs?: number, // Default: 50
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Patterns
|
||||
## Integration patterns
|
||||
|
||||
### Buffered Input (hold until Enter)
|
||||
### Buffered input (hold until Enter)
|
||||
|
||||
The quick start example above. Characters accumulate in the overlay and are sent on Enter. Best for remote shells where you want to batch input.
|
||||
The quick start above. Characters accumulate in the overlay and go out on Enter. Best for remote shells where you want to batch input.
|
||||
|
||||
### Char-at-a-Time (send immediately)
|
||||
### Char-at-a-time (send immediately)
|
||||
|
||||
```typescript
|
||||
terminal.onData((data) => {
|
||||
@@ -256,12 +286,14 @@ terminal.onData((data) => {
|
||||
ws.send(data);
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
zerolag.addChar(data);
|
||||
ws.send(data); // send immediately — overlay shows while echo travels back
|
||||
ws.send(data); // overlay shows the char while the echo travels back
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Tab Switching (multi-session)
|
||||
This is the mode that keeps shell features intact: tab completion, `Ctrl+R` history search, and readline bindings all still work, because every byte still reaches the PTY.
|
||||
|
||||
### Tab switching (multi-session)
|
||||
|
||||
```typescript
|
||||
function switchToSession(newId: string) {
|
||||
@@ -280,19 +312,19 @@ function switchToSession(newId: string) {
|
||||
const saved = savedState.get(newId);
|
||||
if (saved) zerolag.setFlushed(saved.count, saved.text, false); // silent
|
||||
|
||||
// Render after buffer loads
|
||||
// Render after the buffer loads
|
||||
terminal.write('', () => zerolag.rerender());
|
||||
}
|
||||
```
|
||||
|
||||
### Tab Completion
|
||||
### Tab completion
|
||||
|
||||
```typescript
|
||||
const baseline = zerolag.readPromptText();
|
||||
zerolag.clear();
|
||||
sendToPty('\t');
|
||||
|
||||
// After response:
|
||||
// After the response:
|
||||
zerolag.resetBufferDetection();
|
||||
const detected = zerolag.detectBufferText();
|
||||
if (detected && detected !== baseline) {
|
||||
@@ -302,7 +334,7 @@ if (detected && detected !== baseline) {
|
||||
}
|
||||
```
|
||||
|
||||
### Resize / Font / Reconnect
|
||||
### Resize, font, reconnect
|
||||
|
||||
```typescript
|
||||
fitAddon.fit();
|
||||
@@ -311,14 +343,31 @@ zerolag.rerender();
|
||||
terminal.options.fontSize = 18;
|
||||
zerolag.refreshFont();
|
||||
|
||||
function onReconnect() { zerolag.rerender(); }
|
||||
function onReconnect() {
|
||||
zerolag.rerender();
|
||||
}
|
||||
```
|
||||
|
||||
### Wide characters (CJK, emoji)
|
||||
|
||||
Wide characters work out of the box: the overlay measures each character's cell width, renders double-width spans for wide ones, and positions later characters by visual column instead of character index. Line wrapping is computed in columns too, so a wrapped Japanese or Chinese line lands on the same cells the server will use.
|
||||
|
||||
For exact Unicode 11+ widths, load xterm's Unicode addon and the overlay will defer to it:
|
||||
|
||||
```typescript
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
||||
|
||||
terminal.loadAddon(new Unicode11Addon());
|
||||
terminal.unicode.activeVersion = '11';
|
||||
```
|
||||
|
||||
Without it, a built-in range table covers Hangul, Kana, CJK Unified (including Ext A through G), fullwidth forms and the emoji planes.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
### DOM Structure
|
||||
### DOM structure
|
||||
|
||||
```
|
||||
div.xterm-screen (position: relative)
|
||||
@@ -326,53 +375,61 @@ div.xterm-screen (position: relative)
|
||||
├── div.xterm-selection (z-index: 1)
|
||||
├── div.xterm-helpers (z-index: 5)
|
||||
├── div.xterm-decoration-container (z-index: 6-7)
|
||||
└── div[zerolag overlay] (z-index: 7) ← our overlay (invisible to Ink)
|
||||
└── div[zerolag overlay] (z-index: 7) ← our overlay, invisible to Ink
|
||||
```
|
||||
|
||||
### Per-Character Grid Alignment
|
||||
### Per-character grid alignment
|
||||
|
||||
Each character is an absolutely-positioned `<span>`:
|
||||
|
||||
```
|
||||
left = charIndex * cellWidth (CSS pixels)
|
||||
top = lineIndex * cellHeight (CSS pixels)
|
||||
width = cellWidth (exact cell width)
|
||||
left = visualColumn * cellWidth (CSS pixels)
|
||||
top = lineIndex * cellHeight (CSS pixels)
|
||||
width = cellWidth * charCellWidth (1 cell, or 2 for wide characters)
|
||||
```
|
||||
|
||||
This avoids sub-pixel drift from normal DOM text flow.
|
||||
Positioning by visual column instead of letting the browser lay out text is what removes sub-pixel drift.
|
||||
|
||||
### Font Matching
|
||||
### Font matching
|
||||
|
||||
1. `fontFamily`, `fontSize`, `fontWeight` from `terminal.options`
|
||||
2. `letterSpacing` from computed style of `.xterm-rows`
|
||||
3. `-webkit-font-smoothing: antialiased` (matches canvas grayscale)
|
||||
2. `letterSpacing` from the computed style of `.xterm-rows`
|
||||
3. `-webkit-font-smoothing: antialiased` (matches canvas grayscale AA)
|
||||
4. `font-feature-settings: 'liga' 0, 'calt' 0` (no ligatures)
|
||||
5. `text-rendering: geometricPrecision`
|
||||
|
||||
### Cell Dimensions
|
||||
### Cell dimensions
|
||||
|
||||
- **xterm.js v5.x**: `terminal._core._renderService.dimensions.css.cell` (private API)
|
||||
- **xterm.js v7+**: `terminal.dimensions.css.cell` (public API, auto-detected)
|
||||
|
||||
### Prompt Column Locking
|
||||
### Prompt column locking
|
||||
|
||||
When flushed text exists, the prompt column is locked to prevent jitter from full-screen redraws. Row changes are allowed (output can scroll the prompt).
|
||||
While flushed text exists the prompt column is locked, so a full-screen redraw cannot make the overlay jitter sideways. Row changes are still allowed, because output legitimately scrolls the prompt.
|
||||
|
||||
### Scroll Awareness
|
||||
### Scroll awareness
|
||||
|
||||
Overlay hides when scrolled up (`viewportY !== baseY`). Debounced re-render when scrolling back to bottom.
|
||||
The overlay hides when the viewport is scrolled up (`viewportY !== baseY`) and re-renders, debounced, when you scroll back to the bottom.
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
## Known limitations
|
||||
|
||||
- **Canvas/WebGL font mismatch**: Minor sub-pixel differences possible. Per-character absolute positioning minimizes this.
|
||||
- **Unicode/emoji**: Multi-byte characters occupy variable cell widths — rendered at single-cell width, causing misalignment.
|
||||
- **Password prompts**: Overlay shows characters that aren't echoed. Call `clear()` when you detect no-echo mode.
|
||||
- **Prompt in output**: If `$` appears in command output, prompt detection may find the wrong position. Use regex or custom finder.
|
||||
- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character absolute positioning keeps them small.
|
||||
- **Grapheme clusters**: widths are summed per code point, so ZWJ emoji sequences (for example 👨👩👧) and combining marks can be over-counted. Single-code-point emoji and CJK are correct.
|
||||
- **Password prompts**: the overlay will happily show characters the server is not echoing. Call `clear()` when you detect a no-echo prompt.
|
||||
- **Prompt characters in output**: if your prompt marker also appears in command output, detection can latch onto the wrong line. Use a regex or a custom finder.
|
||||
|
||||
---
|
||||
|
||||
## Origin
|
||||
|
||||
[Codeman](https://getcodeman.com) needed this before anyone else did. A coding agent you drive from your phone over a tunnel is unusable if every keystroke costs a round trip.
|
||||
|
||||
So the overlay was built there, ran in production for thousands of hours, and survived three deep code audits before being pulled out into this standalone library with its tests intact. Nothing was reimplemented for the extraction: the engine here is the one Codeman ships.
|
||||
|
||||
Want the whole thing? [**getcodeman.com**](https://getcodeman.com) · [github.com/Ark0N/Codeman](https://github.com/Ark0N/Codeman)
|
||||
|
||||
## License
|
||||
|
||||
MIT — [Codeman](https://github.com/Ark0N/Codeman) Contributors
|
||||
MIT, [Codeman](https://github.com/Ark0N/Codeman) Contributors
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xterm-zerolag-input",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.7",
|
||||
"description": "Instant keystroke feedback overlay for xterm.js — eliminates perceived input latency over high-RTT connections",
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
|
||||
@@ -159,12 +159,48 @@ function hasValidWebviewCapability(req: FastifyRequest): boolean {
|
||||
// capability already implies an authenticated `POST /api/webviews/:id/open`, but
|
||||
// the exemption should stay no wider than the problem it solves.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
||||
if (url.startsWith('/api/') || url.startsWith('/ws/') || url.startsWith('/q/')) return false;
|
||||
if (url.startsWith('/ws/') || url.startsWith('/q/')) return false;
|
||||
// Anything that resolves to a REAL Codeman route is refused, which is the fence
|
||||
// that keeps this from being an auth bypass. `/api/` used to be refused by prefix
|
||||
// instead, but dashboards legitimately serve assets from their own `/api/...`
|
||||
// namespace (`<img src="/api/hero?slug=x">`), and those requests were the one
|
||||
// class the 404 relay could never rescue. See matchesRegisteredRoute.
|
||||
if (matchesRegisteredRoute(req, url)) return false;
|
||||
|
||||
const fromReferer = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
|
||||
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `url` resolves to a route Codeman actually registered.
|
||||
*
|
||||
* `hasRoute()` is the wrong tool: it matches the registered PATTERN literally, so
|
||||
* `/api/sessions/abc` reports false against a registered `/api/sessions/:id` and
|
||||
* would hand out an exemption on a live API route. `findRoute()` performs the real
|
||||
* radix-tree lookup and fills in `params`, which is what this needs.
|
||||
*
|
||||
* The one complication is `@fastify/static`, mounted at `/`, which registers a
|
||||
* root-level catch-all that matches EVERY path. A match on that means "no real
|
||||
* route, this is heading for the 404 handler", and it is distinguishable because a
|
||||
* root catch-all is the only route whose `*` param comes back equal to the entire
|
||||
* request path. `test/webview-auth-exemption.test.ts` pins both halves of that.
|
||||
*
|
||||
* Fails CLOSED: anything unexpected counts as a real route, which merely denies the
|
||||
* exemption and restores the previous behavior.
|
||||
*/
|
||||
function matchesRegisteredRoute(req: FastifyRequest, url: string): boolean {
|
||||
try {
|
||||
const found = req.server.findRoute({ method: req.method as 'GET' | 'HEAD', url });
|
||||
if (!found) return false;
|
||||
const params = found.params ?? {};
|
||||
const keys = Object.keys(params);
|
||||
const isRootCatchAll = keys.length === 1 && keys[0] === '*' && `/${params['*']}` === url;
|
||||
return !isRootCatchAll;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
|
||||
* Only active when CODEMAN_PASSWORD is set.
|
||||
|
||||
@@ -716,7 +716,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// is shared by sibling sessions, so create-with-false must not yank it
|
||||
// — see the comment in session-routes create). Disabling the setting
|
||||
// removes it via the App Settings toggle path (system-routes), not here.
|
||||
statusLineTelemetry: globalSettings.showPlanUsageLimits === true,
|
||||
statusLineTelemetry: this.planUsageChipEnabled(globalSettings),
|
||||
})
|
||||
}).then(r => r.json())
|
||||
);
|
||||
|
||||
@@ -325,9 +325,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Session Manager + Away Digest buttons default OFF; Cron button defaults ON.
|
||||
// Session Manager, Away Digest and Cron buttons all default OFF (opt-in under
|
||||
// Display → Header Displays; the Cron button also ships with btn-cron--hidden
|
||||
// in the template, so an unchecked box and a hidden button stay consistent).
|
||||
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
||||
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
||||
@@ -1798,6 +1800,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
showUltracodeAgents: false,
|
||||
ultracodeFloatingWindows: false,
|
||||
showMultiMonitorButton: false,
|
||||
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
||||
// OFF so the phone header stays minimal and the mobile-header-buttons
|
||||
// policy guard keeps passing.
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
showFileViewerButton: false,
|
||||
@@ -1889,6 +1894,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Resolved per-device state of the plan-usage chip. Desktop defaults ON,
|
||||
// handhelds default OFF (the mobile block in getDefaultSettings() sets false,
|
||||
// and the mobile-header-buttons-policy guard depends on that staying false).
|
||||
// Single source of truth for THREE call sites that must never disagree: the
|
||||
// App Settings checkbox, the chip's visibility, and the statusLineTelemetry
|
||||
// flag sent on session create. A chip shown without telemetry renders "—"
|
||||
// forever, which is exactly the drift this helper prevents.
|
||||
planUsageChipEnabled(settings = null) {
|
||||
const s = settings ?? this.loadAppSettingsFromStorage();
|
||||
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true;
|
||||
},
|
||||
|
||||
applyHeaderVisibilitySettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
@@ -1967,11 +1984,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
ultracodeBtn.classList.toggle('btn-ultracode-agents--hidden', !showUltracodeAgents);
|
||||
}
|
||||
|
||||
// Plan-usage chip — hidden by default (App Settings → Display → "Plan Usage
|
||||
// Limits"). Server renders the initial state on reload; this handles a live
|
||||
// toggle from a settings save. Marker class (base is display:inline-flex
|
||||
// !important), matching the response-viewer/multimonitor pattern.
|
||||
const showPlanUsageLimits = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
// Plan-usage chip — shown by default on desktop, OFF on handhelds (App
|
||||
// Settings → Display → "Plan Usage Limits"). The template always ships it
|
||||
// hidden because display is per-device and the server cannot know a
|
||||
// localStorage value, so THIS is what reveals it on every load as well as
|
||||
// on a live toggle. Marker class (base is display:inline-flex !important),
|
||||
// matching the response-viewer/multimonitor pattern.
|
||||
const showPlanUsageLimits = this.planUsageChipEnabled(settings);
|
||||
const planUsageChip = document.getElementById('planUsageChip');
|
||||
if (planUsageChip) {
|
||||
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
|
||||
@@ -2247,9 +2266,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
'terminalWheelLocalScrollback',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
|
||||
// can show it while mobile stays hidden. It used to sync, so an older
|
||||
// server.json may still carry `true` — drop it so the server value is NEVER
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. It
|
||||
// used to sync, so an older server.json may still carry a value — drop it
|
||||
// so the server value is NEVER
|
||||
// seeded into a device that didn't explicitly enable it (collection is handled
|
||||
// separately via the statusLineTelemetry action, not this display flag).
|
||||
delete appSettings.showPlanUsageLimits;
|
||||
|
||||
@@ -3546,7 +3546,12 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
padding: 4px;
|
||||
z-index: 1000;
|
||||
min-width: 190px;
|
||||
max-width: 300px;
|
||||
/* Every long row inside (recent-session paths, saved URL names) ellipsizes, so
|
||||
the cap is a deliberate choice rather than a fit-the-content result: the menu
|
||||
overlays the terminal and does not need to reach across it. 250 rather than a
|
||||
rounder 240 because it is the width at which the common `~/<dir>/<repo>` +
|
||||
timestamp recent-session row still fits whole, which is what that list is for. */
|
||||
max-width: 250px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.run-mode-menu.active {
|
||||
@@ -3611,8 +3616,19 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* `overflow-y: auto` computes overflow-x to auto as well, which makes this a scroll
|
||||
container and lets a row size itself to its own content. A long path therefore
|
||||
scrolled sideways instead of ellipsizing, and before the menu was narrowed it
|
||||
simply pinned the menu at its max-width. */
|
||||
.run-mode-history .run-mode-option {
|
||||
max-width: 100%;
|
||||
}
|
||||
.run-mode-option .hist-dir {
|
||||
flex: 1;
|
||||
/* A flex item's default min-width is auto, so without this the path refuses to
|
||||
shrink below its own text and pushes the date out of the menu instead of
|
||||
ellipsizing. Only visible once the menu is narrow enough to force the choice. */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -10232,9 +10248,10 @@ kbd {
|
||||
}
|
||||
|
||||
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
|
||||
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
|
||||
default via the marker class below; the server strips it at render when the
|
||||
setting is on and the client toggles it live on save. */
|
||||
live 5-hour + weekly plan limits parsed from the Claude statusline. Ships
|
||||
hidden via the marker class below because display is PER-DEVICE and the
|
||||
server cannot read localStorage; settings-ui.js reveals it on load (desktop
|
||||
default ON, handhelds OFF) and on a live toggle. */
|
||||
.header-plan-usage {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
@@ -12575,7 +12592,16 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* The label needs its own element: `text-overflow` on the button does nothing,
|
||||
because the button is a flex container and a bare text node in one becomes an
|
||||
anonymous flex item that ellipsis cannot reach. Without this a long dashboard
|
||||
name widens the whole menu instead of truncating. */
|
||||
.run-mode-web-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.run-mode-row-btn {
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -312,7 +312,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const icon = w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>';
|
||||
return `<div class="run-mode-row run-mode-row--web">
|
||||
<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${jsonId})" title="${escapeHtml(w.url)}">
|
||||
<span class="run-mode-menu-icon">${icon}</span>${name}
|
||||
<span class="run-mode-menu-icon">${icon}</span><span class="run-mode-web-name">${name}</span>
|
||||
</button>
|
||||
<button class="run-mode-row-btn run-mode-webview-edit" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})"
|
||||
title="Edit URL" aria-label="Edit ${name}">⚙</button>
|
||||
|
||||
@@ -692,20 +692,27 @@ export function registerSystemRoutes(
|
||||
await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit);
|
||||
}
|
||||
|
||||
// Service toggles resolve from `merged` (existing + incoming), NEVER from the
|
||||
// raw request body. A PARTIAL PUT omits keys it does not intend to change, and
|
||||
// reading the body directly turned every omission into "apply the default":
|
||||
// a body of just `{statusLineTelemetry:true}` would START the subagent watcher
|
||||
// (`?? true`) and STOP the workflow + image watchers (`?? false`), silently
|
||||
// undoing the user's persisted config. Reading `merged` makes any PUT reconcile
|
||||
// services to the effective stored settings instead, which also self-heals
|
||||
// drift. Same convention as the tmuxHistoryLimit block above.
|
||||
// Handle subagent tracking toggle dynamically
|
||||
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||
toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||
|
||||
// Handle ultracode/workflow run watcher toggle dynamically (default OFF).
|
||||
// Either the docked panel OR the floating windows keep the watcher running.
|
||||
toggleService(
|
||||
((settings.showUltracodeAgents as boolean) ?? false) ||
|
||||
((settings.ultracodeFloatingWindows as boolean) ?? false),
|
||||
((merged.showUltracodeAgents as boolean) ?? false) || ((merged.ultracodeFloatingWindows as boolean) ?? false),
|
||||
workflowRunWatcher,
|
||||
'Workflow run watcher'
|
||||
);
|
||||
|
||||
// Handle image watcher toggle dynamically
|
||||
toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
|
||||
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
|
||||
// Re-watch all active sessions that have image watcher enabled
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.imageWatcherEnabled) {
|
||||
|
||||
+12
-5
@@ -855,14 +855,21 @@ export class WebServer extends EventEmitter {
|
||||
// the envelope hook into a contradictory HTTP 404 {success:true,...}.
|
||||
this.app.setNotFoundHandler(async (req, reply) => {
|
||||
const notFound = `Route ${req.method}:${req.url} not found`;
|
||||
// A web-tab dashboard asking for a root-absolute asset (`fetch('/api/data')`,
|
||||
// `import('/chunk.js')`, `url(/img.png)` inside a stylesheet) lands here,
|
||||
// because `<base href>` cannot rewrite a URL built at runtime. Its Referer says
|
||||
// which dashboard to relay to. Deliberately placed on the 404 path so every
|
||||
// real Codeman route still wins.
|
||||
//
|
||||
// Tried BEFORE the API-shaped 404, because a dashboard's own assets commonly
|
||||
// live under its `/api/...` namespace and were the one class this could never
|
||||
// rescue. Reaching this handler at all already proves no Codeman route matched,
|
||||
// and the relay declines unless the Referer carries a live capability, so
|
||||
// genuinely unknown `/api` paths still get the envelope below.
|
||||
if (await tryWebviewRefererFallback(req, reply)) return reply;
|
||||
if (req.url.startsWith('/api')) {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
|
||||
}
|
||||
// A web-tab dashboard asking for a root-absolute asset (`fetch('/api/data')`,
|
||||
// `import('/chunk.js')`) lands here, because `<base href>` cannot rewrite a URL
|
||||
// built at runtime. Its Referer says which dashboard to relay to. Deliberately
|
||||
// placed on the 404 path so every real Codeman route still wins.
|
||||
if (await tryWebviewRefererFallback(req, reply)) return reply;
|
||||
return reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
|
||||
});
|
||||
|
||||
|
||||
@@ -366,11 +366,11 @@ export function buildDownstreamResponseHeaders(
|
||||
* root, where it 404s. That is not a rare shape: it is how most dashboards talk to
|
||||
* their own backend, and it presents as the dashboard's own "Failed to fetch".
|
||||
*
|
||||
* The `Referer`-keyed 404 fallback catches some of these, but deliberately NOT
|
||||
* paths under `/api`, `/ws` or `/q` (widening it there would let a request-supplied
|
||||
* header skip auth on Codeman's own API). Rewriting inside the iframe removes the
|
||||
* whole class instead of trading security for it: the page never emits a
|
||||
* root-absolute request in the first place.
|
||||
* The `Referer`-keyed 404 fallback catches some of these, but it is a rescue rather
|
||||
* than a fix (it only fires for a request that already missed every Codeman route,
|
||||
* and only when the browser sends a usable `Referer`). Rewriting inside the iframe
|
||||
* removes the whole class instead: the page never emits a root-absolute request in
|
||||
* the first place.
|
||||
*
|
||||
* ## Why the DOM sinks are patched too, not just fetch/XHR
|
||||
*
|
||||
@@ -455,6 +455,14 @@ function rwAttr(n,v){
|
||||
return A.indexOf(k)===-1?v:rw(v);
|
||||
}catch(e){return v;}
|
||||
}
|
||||
// CSS built at runtime is the one sink NO relay can rescue: a <style> element has
|
||||
// no URL of its own, so an opaque-origin document sends an EMPTY Referer with the
|
||||
// resulting image request, and the 404 fallback has nothing to key on.
|
||||
function rwCss(s){
|
||||
try{
|
||||
return String(s).replace(/url\\(\\s*(['"]?)(\\/(?!\\/)[^'")]*)\\1\\s*\\)/gi,function(m,q,u){return 'url('+q+rw(u)+q+')';});
|
||||
}catch(e){return s;}
|
||||
}
|
||||
// Each value goes through rw() rather than a blind prefix concat, because unlike
|
||||
// the server-side rewriteHtml() this runs on markup that may ALREADY be proxied
|
||||
// (a page re-injecting its own outerHTML), and rw() is the idempotent one.
|
||||
@@ -465,7 +473,8 @@ function rwHtml(s){
|
||||
.replace(/(\\s(?:src|href|action|poster|formaction|data)\\s*=\\s*")([^"]*)(")/gi,function(m,a,v,q){return a+rw(v)+q;})
|
||||
.replace(/(\\s(?:src|href|action|poster|formaction|data)\\s*=\\s*')([^']*)(')/gi,function(m,a,v,q){return a+rw(v)+q;})
|
||||
.replace(/(\\ssrcset\\s*=\\s*")([^"]*)(")/gi,function(m,a,v,q){return a+rwSet(v)+q;})
|
||||
.replace(/(\\ssrcset\\s*=\\s*')([^']*)(')/gi,function(m,a,v,q){return a+rwSet(v)+q;});
|
||||
.replace(/(\\ssrcset\\s*=\\s*')([^']*)(')/gi,function(m,a,v,q){return a+rwSet(v)+q;})
|
||||
.replace(/(<style\\b[^>]*>)([^]*?)(<\\/style>)/gi,function(m,a,b,c){return a+rwCss(b)+c;});
|
||||
}catch(e){return s;}
|
||||
}
|
||||
// Marked with __cmrw so a double injection (a page that re-runs the shim) cannot
|
||||
@@ -530,12 +539,23 @@ try{
|
||||
}
|
||||
}catch(e){}
|
||||
};
|
||||
var fixStyle=function(el){
|
||||
try{
|
||||
if(!el||el.tagName!=='STYLE')return;
|
||||
var t=el.textContent;
|
||||
if(!t||t.indexOf('url(')===-1)return;
|
||||
var n=rwCss(t);
|
||||
if(n!==t)el.textContent=n;
|
||||
}catch(e){}
|
||||
};
|
||||
var scan=function(node){
|
||||
try{
|
||||
fix(node);
|
||||
fix(node);fixStyle(node);
|
||||
if(node&&node.querySelectorAll){
|
||||
var l=node.querySelectorAll('[src],[href],[action],[poster],[data],[srcset],[formaction]');
|
||||
for(var i=0;i<l.length;i++)fix(l[i]);
|
||||
var st=node.querySelectorAll('style');
|
||||
for(var j=0;j<st.length;j++)fixStyle(st[j]);
|
||||
}
|
||||
}catch(e){}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @fileoverview PUT /api/settings must not reset service state on a PARTIAL body.
|
||||
*
|
||||
* The three service toggles (subagent watcher, workflow-run watcher, image
|
||||
* watcher) used to read the RAW REQUEST BODY with `??` defaults, so any key the
|
||||
* caller omitted was treated as "apply the default". A body of just
|
||||
* `{statusLineTelemetry:true}` therefore STARTED the subagent watcher (`?? true`)
|
||||
* and STOPPED the workflow + image watchers (`?? false`), silently undoing the
|
||||
* persisted config. Nothing triggered it in practice only because every shipped
|
||||
* client sends a full settings payload rebuilt from the DOM.
|
||||
*
|
||||
* They now resolve from `merged` (existing settings.json + incoming), so a PUT
|
||||
* reconciles services to the effective stored state. These tests pin that:
|
||||
* omitted keys preserve state, explicit keys still take effect.
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed. Port: N/A.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||
|
||||
// vi.mock factories are hoisted above module-level consts, so the stubs and the
|
||||
// persisted-settings fixture have to be built inside vi.hoisted().
|
||||
const { EXISTING_SETTINGS, subagentWatcher, imageWatcher, workflowRunWatcher } = vi.hoisted(() => {
|
||||
/** Watcher stub whose isRunning() reflects its persisted state. */
|
||||
const makeWatcher = (running: boolean) => {
|
||||
let isOn = running;
|
||||
return {
|
||||
isRunning: vi.fn(() => isOn),
|
||||
start: vi.fn(() => {
|
||||
isOn = true;
|
||||
}),
|
||||
stop: vi.fn(() => {
|
||||
isOn = false;
|
||||
}),
|
||||
getStats: vi.fn(() => ({})),
|
||||
watchSession: vi.fn(),
|
||||
getRecentRunSummaries: vi.fn(() => []),
|
||||
// The stubs are module singletons (vi.mock needs them hoisted), so a
|
||||
// start()/stop() in one test would otherwise carry into the next and make
|
||||
// its "not called" assertion pass vacuously — isRunning() already matches
|
||||
// the expected end state, so toggleService short-circuits.
|
||||
__resetRunning: () => {
|
||||
isOn = running;
|
||||
},
|
||||
};
|
||||
};
|
||||
return {
|
||||
// Persisted settings.json for these tests: two watchers ON, subagent tracking OFF.
|
||||
EXISTING_SETTINGS: { subagentTrackingEnabled: false, imageWatcherEnabled: true, showUltracodeAgents: true },
|
||||
subagentWatcher: makeWatcher(false),
|
||||
imageWatcher: makeWatcher(true),
|
||||
workflowRunWatcher: makeWatcher(true),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readFile: vi.fn(async () => JSON.stringify(EXISTING_SETTINGS)),
|
||||
writeFile: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
|
||||
});
|
||||
|
||||
vi.mock('../../src/subagent-watcher.js', () => ({ subagentWatcher }));
|
||||
vi.mock('../../src/image-watcher.js', () => ({ imageWatcher }));
|
||||
vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher }));
|
||||
|
||||
describe('PUT /api/settings — partial body must not reset service toggles', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
for (const w of [subagentWatcher, imageWatcher, workflowRunWatcher]) {
|
||||
w.start.mockClear();
|
||||
w.stop.mockClear();
|
||||
w.__resetRunning(); // running state, not just call records — see makeWatcher
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
it('leaves all three watchers alone when the body omits their keys', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
// Action-only body: the exact shape that used to flip all three watchers.
|
||||
payload: { statusLineTelemetry: true },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
// Persisted OFF and omitted — must NOT be started by the `?? true` default.
|
||||
expect(subagentWatcher.start).not.toHaveBeenCalled();
|
||||
// Persisted ON and omitted — must NOT be stopped by the `?? false` defaults.
|
||||
expect(imageWatcher.stop).not.toHaveBeenCalled();
|
||||
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still starts a watcher when the body explicitly enables it', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { subagentTrackingEnabled: true },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(subagentWatcher.start).toHaveBeenCalledTimes(1);
|
||||
// Unrelated watchers stay untouched.
|
||||
expect(imageWatcher.stop).not.toHaveBeenCalled();
|
||||
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still stops a watcher when the body explicitly disables it', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { imageWatcherEnabled: false },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(imageWatcher.stop).toHaveBeenCalledTimes(1);
|
||||
expect(subagentWatcher.start).not.toHaveBeenCalled();
|
||||
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the workflow watcher running when only one of its two keys is sent', async () => {
|
||||
// Either showUltracodeAgents OR ultracodeFloatingWindows keeps it alive, and
|
||||
// the OR must be evaluated over merged state, not over this partial body.
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { ultracodeFloatingWindows: false },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
// showUltracodeAgents is still true in settings.json, so it stays up.
|
||||
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -42,9 +42,16 @@ beforeEach(async () => {
|
||||
// Stand-ins for the real surfaces, so a reachable route means auth let it through.
|
||||
app.all('/webview/:cap/*', async () => ({ proxied: true }));
|
||||
app.all('/api/sessions', async () => ({ sensitive: true }));
|
||||
// Parametric on purpose: the exemption's fence has to resolve a CONCRETE url
|
||||
// against it, which is precisely what `hasRoute()` cannot do.
|
||||
app.all('/api/sessions/:id', async () => ({ sensitive: true }));
|
||||
app.all('/q/:token', async () => ({ qr: true }));
|
||||
app.get('/', async () => 'app shell');
|
||||
app.get('/static/app.js', async () => 'asset');
|
||||
app.get('/webviewfoo/bar', async () => 'lookalike');
|
||||
// Stand-in for @fastify/static mounted at '/', which is what actually serves
|
||||
// /static/app.js in production. It matches EVERY path, so the fence must treat a
|
||||
// root catch-all as "no real route" or the Referer form could never apply at all.
|
||||
app.get('/*', async () => 'static asset');
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
@@ -109,6 +116,21 @@ describe('the exemption applies to a live capability', () => {
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("covers the dashboard's OWN /api namespace, which no Codeman route claims", async () => {
|
||||
// A dashboard serving `<img src="/api/hero?slug=x">` from page script is the
|
||||
// case this exists for: the URL is root-absolute, so it lands on Codeman, and
|
||||
// nothing here matches a real route. Refusing it by `/api` prefix (as this once
|
||||
// did) left dashboard images permanently broken with no way to rescue them.
|
||||
for (const url of ['/api/hero?slug=x', '/api/slide?owner=o&n=01', '/api/preview']) {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url,
|
||||
headers: { referer: `http://localhost/webview/${capability}/panel` },
|
||||
});
|
||||
expect(res.statusCode, url).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the exemption does NOT widen anywhere else', () => {
|
||||
@@ -132,8 +154,8 @@ describe('the exemption does NOT widen anywhere else', () => {
|
||||
expect((await app.inject({ method: 'GET', url: '/webviewfoo/bar' })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('NEVER exempts the Codeman API, even with a valid capability in the Referer', async () => {
|
||||
// This is the hole the Referer form would open if it were not path-fenced.
|
||||
it('NEVER exempts a real Codeman API route, even with a valid capability in the Referer', async () => {
|
||||
// This is the hole the Referer form would open if it were not fenced.
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sessions',
|
||||
@@ -142,6 +164,42 @@ describe('the exemption does NOT widen anywhere else', () => {
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('NEVER exempts a PARAMETRIC API route matched by a concrete url', async () => {
|
||||
// The fence has to route `/api/sessions/abc` onto `/api/sessions/:id`. A literal
|
||||
// pattern check (`hasRoute`) reports no match here and would hand out an
|
||||
// exemption on a live, session-scoped API route.
|
||||
for (const url of ['/api/sessions/abc', '/api/sessions/abc?x=1']) {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url,
|
||||
headers: { referer: `http://localhost/webview/${capability}/panel` },
|
||||
});
|
||||
expect(res.statusCode, url).toBe(401);
|
||||
}
|
||||
});
|
||||
|
||||
it('still refuses the websocket namespace outright', async () => {
|
||||
// `/q/` is deliberately absent here: QR login is PUBLIC by its own bypass
|
||||
// (an unauthenticated device is the entire point), so it can never demonstrate
|
||||
// anything about this exemption. The `/q/` guard alongside it is belt-and-braces.
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/ws/anything',
|
||||
headers: { referer: `http://localhost/webview/${capability}/panel` },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('does not exempt an unrouted /api path without a live capability in the Referer', async () => {
|
||||
expect((await app.inject({ method: 'GET', url: '/api/hero?slug=x' })).statusCode).toBe(401);
|
||||
const stale = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/hero?slug=x',
|
||||
headers: { referer: `http://localhost/webview/${'Z'.repeat(32)}/panel` },
|
||||
});
|
||||
expect(stale.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('does not let the Referer form carry a WRITE', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -589,6 +589,41 @@ describe('runtimeUrlShim DOM sinks', () => {
|
||||
expect(imgSrc(dom)).toBe('');
|
||||
});
|
||||
|
||||
/**
|
||||
* CSS is the sink no relay can rescue: a <style> element has no URL of its own,
|
||||
* so an opaque-origin document sends an EMPTY Referer with the image request it
|
||||
* triggers, and the 404 fallback has nothing to key on. Verified in Chromium.
|
||||
*/
|
||||
it('rewrites root-absolute url() inside a <style> injected as markup', () => {
|
||||
const dom = newDom();
|
||||
dom.window.document.getElementById('box')!.innerHTML = '<style>#hero{background-image:url(/api/hero.png)}</style>';
|
||||
expect(dom.window.document.querySelector('#box style')!.textContent).toBe(
|
||||
`#hero{background-image:url(${PREFIX}api/hero.png)}`
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites url() in a <style> built with textContent, via the observer', async () => {
|
||||
const dom = newDom();
|
||||
const { document } = dom.window;
|
||||
const style = document.createElement('style');
|
||||
style.textContent = "#late{background-image:url('/api/late.png')}";
|
||||
document.head.appendChild(style);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(style.textContent).toBe(`#late{background-image:url('${PREFIX}api/late.png')}`);
|
||||
});
|
||||
|
||||
it('leaves relative, cross-origin and data: url() alone', () => {
|
||||
const dom = newDom();
|
||||
const css = [
|
||||
'a{background:url(img/rel.png)}',
|
||||
'b{background:url(https://cdn.example/z.png)}',
|
||||
'c{background:url(data:image/gif;base64,AAAA)}',
|
||||
`d{background:url(${PREFIX}api/done.png)}`,
|
||||
].join('');
|
||||
dom.window.document.getElementById('box')!.innerHTML = `<style>${css}</style>`;
|
||||
expect(dom.window.document.querySelector('#box style')!.textContent).toBe(css);
|
||||
});
|
||||
|
||||
it('is idempotent when a value passes through two layers', () => {
|
||||
const dom = newDom();
|
||||
const img = dom.window.document.createElement('img');
|
||||
|
||||
Reference in New Issue
Block a user