Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb7d0ba565 | ||
|
|
22cb563f1e | ||
|
|
f0e13f9fc3 | ||
|
|
28c5b5c1eb | ||
|
|
af9db455ff | ||
|
|
a406aef2fa | ||
|
|
77bcbc9b94 | ||
|
|
d4540c5ce6 | ||
|
|
4a83efcd48 | ||
|
|
473c57c7ca | ||
|
|
b586007f14 | ||
|
|
b388b84cc2 | ||
|
|
d13642ebce | ||
|
|
3cff98fe56 | ||
|
|
bc232e5ff3 | ||
|
|
2a7e035d2b | ||
|
|
80a88ea857 | ||
|
|
5c45d434ac | ||
|
|
390516ca3f | ||
|
|
57b6be1ed5 | ||
|
|
cbae989e02 | ||
|
|
84f47e8ee0 | ||
|
|
541d9c8131 | ||
|
|
e4ea785a28 | ||
|
|
b7a6a189f9 | ||
|
|
e063222ac2 | ||
|
|
346bc8b173 | ||
|
|
b34fcaf928 | ||
|
|
8c089a4819 | ||
|
|
f812f65a33 | ||
|
|
2667150f33 | ||
|
|
a842b091bf | ||
|
|
dae82388ed | ||
|
|
bca56b4273 |
@@ -1,5 +1,122 @@
|
||||
# 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
|
||||
|
||||
- 2667150: feat(mobile): browse and insert local file and folder paths
|
||||
|
||||
Add a root-confined filesystem picker to Link Existing and the extended mobile
|
||||
keyboard bar. Selected paths remain editable at the active prompt, supported
|
||||
images/documents/text files open in a safe inline preview, and a new one-tap
|
||||
action clears only the current unsent input without invoking `/clear`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3cff98f: Fix two multi-user scoping holes in the new filesystem path picker. `GET /api/filesystem/browse` and `GET /api/filesystem/preview` accept an optional `sessionId` that contributes the session's working directory as a browse root, but they resolved it straight off the session map without an ownership check, unlike the nine other session-scoped handlers in the same route file. A non-admin could therefore pin another user's working directory as a root simply by passing their session id, then list and preview files under it. Both endpoints now run `canAccessOwned` and report 404, which also avoids confirming that a session id exists.
|
||||
|
||||
Separately, `Home` and `CASES_DIR` were unconditional browse roots for every caller. Per-user spaces live at `<USER_SPACES_DIR>/<username>`, which is inside `homedir()`, so the `Home` root alone exposed every other user's workspace to any authenticated user. In multi-user mode a non-admin now gets only their own space plus anything explicitly listed in `CODEMAN_FILE_PICKER_ROOTS`; `/mnt/d` is no longer offered by default, since a broad host mount should be an explicit operator decision in a multi-user deployment. Admins keep the host-wide roots, and single-user mode is unchanged.
|
||||
|
||||
Both holes are regression-guarded in `test/routes/file-routes.test.ts`, verified to fail against the previous code. Multi-user mode is opt-in and off by default, so single-user installs were never affected.
|
||||
|
||||
- Web tabs: delete saved URLs from the Run dropdown, and fix images in proxied dashboards.
|
||||
|
||||
**Saved URLs are now manageable from the dropdown.** Each row under "Web / URL" gains a gear and an `x`, so a URL can be edited or deleted without first opening it as a tab. Previously the only delete path ran through the gear on an open tab, which was a dead end for a URL you no longer wanted open at all. Both controls stay permanently visible rather than hover-revealed, because the same menu is used on touch, and they get a larger hit box there. Deleting leaves the dropdown open on the remaining rows, and deleting the dashboard that is currently open also closes its tab and unmounts its frame.
|
||||
|
||||
**Runtime-injected images no longer 404.** A dashboard that renders its own markup from script (`card.innerHTML = '<img src="/api/hero?slug=x">'`, `img.src = '/api/slide'`) escaped every rewrite layer at once: `<base href>` never applies to a root-absolute URL, the server-side attribute rewrite only ever sees the initial document, and `runtimeUrlShim()` patched only `fetch`, `XMLHttpRequest`, `WebSocket` and `EventSource`. Those requests landed on Codeman's own root and 404'd, with a symptom that reads as an upstream fault: the dashboard's data loaded while every image stayed broken.
|
||||
|
||||
The shim now also covers the DOM URL sinks, so the request is never emitted in the first place and neither the `/api` fence in the 404 fallback nor the one in the auth middleware had to move. It wraps `innerHTML`, `outerHTML`, `insertAdjacentHTML` (including on `ShadowRoot`), `setAttribute`/`setAttributeNS`, and the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters on img, source, media, video poster, script, iframe, embed, track, link, anchor, area, object and form, with a `MutationObserver` as a last net for sinks not patched above. Every rewrite routes through the same idempotent helper, which matters because unlike the server-side rewrite this one sees markup that may already be proxied, and a page re-injecting its own `outerHTML` would otherwise double-prefix. Everything is defensively guarded and marked so a double injection cannot wrap an already-wrapped setter.
|
||||
|
||||
Measured against a real dashboard: 693 image elements, 0 of them under the proxy prefix and 0 of 23 in-viewport images decoded before, 693 and 23 of 23 after. Covered by a new jsdom suite over the shim's DOM half and a new frontend suite over the dropdown rows. Known remaining gaps are documented in `docs/web-tabs.md`: a root-absolute `url()` inside a stylesheet injected at runtime, and self-navigation via `location.href`, which cannot be patched because `Location.href` is unforgeable.
|
||||
|
||||
Also in this release: a value-first README overhaul pointing at getcodeman.com, and the QR-auth distribution test now uses a chi-square check instead of a max-deviation threshold that failed on random variance.
|
||||
|
||||
- bca56b4: Normalize Claude conversations in the response viewer. A Claude transcript is an append-only event log, so one logical exchange spans many JSONL rows: tool-result rows, meta/image/skill rows, compact summaries, task and team notifications, sidechains, replayed assistant snapshots, and multi-block assistant output. The viewer rendered a card per row, which produced duplicate and truncated cards that read as lost responses. Cards are now built at real human-turn boundaries, replayed assistant snapshots are deduplicated, and sidechain rows (which belong to subagents, not the main conversation) no longer leak in. An identical prompt that legitimately recurs after an assistant reply is still kept as its own turn.
|
||||
|
||||
Measured over 40 real transcripts: 3108 cards became 621, duplicate cards dropped from 74 to 8 (all of them genuinely repeated turns), no assistant text was lost, and the non-`context=full` last-response text was byte-identical on every file.
|
||||
|
||||
Also rebinds recovered sessions to their transcript. `reconcileSessions()` can recover a lost mux session as a `restored-<uuid8>` placeholder with a stale working directory, which made transcript lookup by cwd find nothing. The placeholder still carries the first eight characters of the conversation UUID, so the viewer now rebinds to the matching top-level transcript when exactly one candidate matches.
|
||||
|
||||
## 1.8.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8c089a4: Add four light UI and terminal skins: Paper Gray, Solarized Light, Catppuccin Latte, and Rosé Pine Dawn. The Skin picker now groups Light and Dark options, and each light skin ships a matching xterm ANSI palette plus `color-scheme: light` so native selects, date pickers and scrollbars stop rendering as dark OS widgets on a light page. Terminals set `minimumContrastRatio: 4.5` under a light skin (main terminal and teammate terminals both), which keeps CLI output that assumes a dark background readable, and `applyTerminalSkin()` now refreshes the zero-lag input overlay so typed-but-unflushed text does not keep the previous theme's colors.
|
||||
|
||||
Elevated surfaces (modals, command palette, dropdowns, subagent and ultracode windows, file preview, attachment tray, mobile sheets) now resolve through shared `--floating-bg` / `--control-*` / `--banner-bg-*` / `--modal-backdrop` / `--elevated-shadow` tokens instead of hardcoded near-black rgba, so they follow whichever skin is active. On the Daylight skins this lifts modals slightly off the page background; OG Codeman pins its own near-black value to keep that palette neutral.
|
||||
|
||||
Also defines twelve CSS compatibility aliases (`--bg-primary`, `--bg-secondary`, `--bg-tertiary`, `--text-primary`, `--text-secondary`, `--border-color`, `--accent-color`, `--success`, `--error`, `--danger`, `--font-mono`, `--shadow-lg`) that panels and overlays already referenced in about 79 places but which were never actually declared, so those rules silently resolved to nothing. Status badges and accent-tinted pills (search filter chips and result badges, session tab mode pills, respawn state, Ralph priority and circuit-breaker badges, tunnel and voice status, mobile case picker) no longer keep their pale light-on-dark ink under a light skin, where it measured 1.0 to 1.9:1 and made the search filter chips invisible.
|
||||
|
||||
New static regression `test/skin-themes.test.ts` guards the four-way parity between the CSS token block, the xterm palette, the pre-paint allowlist and the Settings picker.
|
||||
|
||||
## 1.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Web tabs: open dashboard URLs as tabs beside agent sessions, plus terminal link fixes.
|
||||
|
||||
**Web tabs.** The Run dropdown gains a "Web / URL" section. A saved URL renders as a tab in the same strip as Claude/Codex/Gemini sessions, with the same Alt+1-9 numbering, an icon picker, and per-device tab order. Frames stay mounted while hidden (LRU-bounded), so switching tabs never reloads a dashboard.
|
||||
|
||||
Dashboards are proxied through Codeman's own origin, because a direct iframe fails three ways at once: an HTTPS Codeman cannot embed a plain-HTTP target (mixed content, with no override at all on iOS Safari), many dashboards send `X-Frame-Options: DENY`, and Codeman's own `default-src 'self'` CSP blocks cross-origin frames. Proxying dissolves all three and leaves the production CSP unchanged. The fetch happens server-side, so a tailnet-only or localhost-only dashboard is reachable from any device that can reach Codeman.
|
||||
|
||||
The proxy is not an API surface: it authenticates on a 192-bit capability in the path (memory-only, rolling TTL, bound to the minting user, revoked on edit or delete) and is exempt from the cookie and Origin checks, because a sandboxed iframe is opaque-origin and sends neither. The Host allowlist is never bypassed. Iframes omit `allow-same-origin` unless a URL is explicitly marked trusted, and `Authorization` plus the session cookie are stripped upstream in both modes so `CODEMAN_PASSWORD` cannot leak into a dashboard. Includes an HTTP and WebSocket proxy, redirect/cookie/`<base>` rewriting, a runtime URL shim for requests built by dashboard JavaScript, and CORS handling for the opaque-origin frame. New endpoints under `/api/webviews`, storage in `~/.codeman/webviews.json`, user guide in `docs/web-tabs.md`.
|
||||
|
||||
**Terminal links no longer truncate.** Three separate cuts, each producing a link that opened the wrong target or none at all:
|
||||
- A single `&` ended the match, so every query string was cut. A WordPress edit link resolved to `?post=1479` and Claude Code's own `/login` URL was unusable. `&` is now part of a URL while `&&` remains a boundary.
|
||||
- Links wider than the terminal were cut at the row boundary. The link provider now stitches continuation rows into one logical line and maps offsets back across rows. Handles both soft wraps (emulator, `isWrapped`) and hard wraps (a program wrapping its own output and emitting a newline, as Ink does), the latter being why the `/login` URL grew longer as the window was widened.
|
||||
- Image and PDF paths were not matched at all, so pasted-screenshot paths rendered as plain text. They now link and open the file preview, which renders images inline.
|
||||
|
||||
**Also fixes** a pre-existing bug where `.toolbar`'s `backdrop-filter` created a stacking context that trapped the Run menu's z-index, letting the welcome overlay cover it: with no session open, every item in that menu (Claude Code included) was unclickable.
|
||||
|
||||
## 1.8.1
|
||||
|
||||
### Patch 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.8.1 (must match `package.json`)
|
||||
**Version**: 1.9.4 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -152,18 +152,19 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Tasks** | `src/task.ts`, `task-queue.ts`, `task-tracker.ts` | |
|
||||
| **State** | `src/state-store.ts`, `run-summary.ts`, `session-lifecycle-log.ts` | |
|
||||
| **Infra** | `src/hooks-config.ts`, `push-store`, `tunnel-manager`, `image-watcher`, `file-stream-manager`, `remote-hosts` + `remote-reconnect` (pure), `docker-hosts` + `docker-export` | Remote/docker case overlays; see Key Patterns |
|
||||
| **Web tabs** | `src/webview-store.ts`, `webview-capabilities.ts`, `src/web/webview-proxy.ts` (pure), `src/web/routes/webview-routes.ts` | Dashboard URLs as tabs; NOT a SessionMode |
|
||||
| **Search** | `src/search-service.ts` | Pure in-memory core for `GET /api/search` |
|
||||
| **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases |
|
||||
| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (20 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 23 modules + `sw.js` | See Frontend section for the load order, which is authoritative |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 19 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 20 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
|
||||
**Local packages**: `packages/xterm-zerolag-input/` (local echo overlay, single-source, see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`, hand-tracking overlay source, built via `npm run build:gesture`).
|
||||
|
||||
**Config**: `src/config/` — 16 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
**Config**: `src/config/` — 17 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
|
||||
|
||||
@@ -182,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`.
|
||||
|
||||
@@ -210,10 +211,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
|
||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||
|
||||
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
|
||||
|
||||
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a sixth `SessionMode`** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `<base>` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
|
||||
|
||||
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
|
||||
|
||||
**Away digest**: `GET /api/away-digest` aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in `web/away-digest.ts`. ⚠️ Returns `{success:true,digest}`, a legacy raw-ish shape consistent with the other raw GET handlers in `system-routes.ts`; frontend and tests read `.digest`. → [architecture-invariants#away-digest](docs/architecture-invariants.md#away-digest)
|
||||
@@ -224,7 +229,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
|
||||
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
|
||||
@@ -234,7 +239,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
|
||||
|
||||
**Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on `<html>`, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](docs/architecture-invariants.md#theme-skins)
|
||||
**Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on `<html>`, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. ⚠️ A skin is **four things that must stay in sync**, and missing any one degrades silently: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist, and the Settings picker (both in `index.html`). `test/skin-themes.test.ts` is the static guard. Light skins additionally need `color-scheme: light` and xterm `minimumContrastRatio: 4.5`, and `applyTerminalSkin()` must call the local-echo overlay's `refreshFont()` because it caches the terminal fg/bg. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](docs/architecture-invariants.md#theme-skins)
|
||||
|
||||
**Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](docs/architecture-invariants.md#foldable-settings-identity)
|
||||
|
||||
@@ -274,11 +279,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### SSE Event Registry
|
||||
|
||||
148 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync** — they are currently exactly in sync, and the backend file's `@fileoverview` carries the per-category breakdown.
|
||||
149 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync** — they are currently exactly in sync, and the backend file's `@fileoverview` carries the per-category breakdown.
|
||||
|
||||
### API Routes
|
||||
|
||||
~190 handlers across 20 route files in `src/web/routes/`: system (45), sessions (32), cases (27), files (14), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~199 handlers across 21 route files in `src/web/routes/`: system (45), sessions (32), cases (27), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
||||
|
||||
@@ -287,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`.
|
||||
@@ -296,7 +301,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `linked-cases.json`, `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-22%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 22+"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
|
||||
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
|
||||
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
|
||||
<a href="https://www.npmjs.com/package/aicodeman"><img src="https://img.shields.io/npm/v/aicodeman?style=flat-square&label=npm&color=22c55e" alt="npm version"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
|
||||
@@ -30,6 +29,19 @@
|
||||
|
||||
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, or Gemini CLI inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
||||
|
||||
Get started in one line (macOS & Linux, Windows via WSL):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://getcodeman.com/install | bash
|
||||
```
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
# Open http://localhost:3000 and start your first session
|
||||
```
|
||||
|
||||
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
|
||||
|
||||
- **One dashboard, four CLIs** - run [Claude Code, OpenCode, Codex, or Gemini](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions)
|
||||
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
|
||||
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
|
||||
@@ -46,7 +58,7 @@
|
||||
## Quick Start - Installation
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
|
||||
curl -fsSL https://getcodeman.com/install | bash
|
||||
```
|
||||
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it. A few things worth knowing:
|
||||
@@ -136,7 +148,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
<summary><strong>Windows (WSL)</strong></summary>
|
||||
|
||||
```powershell
|
||||
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
|
||||
wsl bash -c "curl -fsSL https://getcodeman.com/install | bash"
|
||||
```
|
||||
|
||||
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), or [Gemini CLI](https://github.com/google-gemini/gemini-cli)). After installing, `http://localhost:3000` is accessible from your Windows browser.
|
||||
@@ -145,6 +157,58 @@ Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/e
|
||||
|
||||
---
|
||||
|
||||
## Mobile-Optimized Web UI
|
||||
|
||||
The most responsive AI coding agent experience on any phone. Full xterm.js terminal with local echo, swipe navigation, and a touch-optimized interface designed for real remote work — not a desktop UI crammed onto a small screen.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="40%"><img src="docs/screenshots/mobile-session-keyboard-20260727.png" alt="Mobile — answering an agent's plan prompt with the keyboard accessory bar and Enter button" width="300"></td>
|
||||
<td align="center" width="60%"><img src="docs/screenshots/mobile-toolbar-enter-20260727.png" alt="Mobile toolbar: accessory bar with /init, /clear, clipboard and Esc above the Run, case, stop, Enter, voice and settings controls" width="440"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Answering prompts by touch</em></td>
|
||||
<td align="center"><em>Accessory bar + dedicated Enter button</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Terminal Apps</th>
|
||||
<th>Codeman Mobile</th>
|
||||
</tr>
|
||||
<tr><td>200-300ms input lag over remote</td><td><b>Local echo — instant feedback</b></td></tr>
|
||||
<tr><td>Tiny text, no context</td><td>Full xterm.js terminal</td></tr>
|
||||
<tr><td>No session management</td><td>Swipe between sessions</td></tr>
|
||||
<tr><td>No notifications</td><td>Push alerts for approvals and idle</td></tr>
|
||||
<tr><td>Manual reconnect</td><td>tmux persistence</td></tr>
|
||||
<tr><td>No agent visibility</td><td>Background agents in real-time</td></tr>
|
||||
<tr><td>Copy-paste slash commands</td><td>One-tap <code>/init</code>, <code>/clear</code>, <code>/compact</code></td></tr>
|
||||
<tr><td>Password typing on phone</td><td><b>QR code scan — instant auth</b></td></tr>
|
||||
</table>
|
||||
|
||||
- **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard; destructive commands require a double-press to confirm, so you never fire one by accident
|
||||
- **Dedicated Enter button** — replays the keypress through the terminal, so text buffered by local echo is flushed first rather than stranded
|
||||
- **Swipe navigation & smart keyboard handling** — swipe left/right to switch sessions; toolbar and terminal shift up when the keyboard opens (`visualViewport` API)
|
||||
- **Built for phones** — safe-area insets for notch and home indicator, 44px touch targets, bottom-sheet case picker, native momentum scrolling
|
||||
|
||||
```bash
|
||||
codeman web --https
|
||||
# Open on your phone: https://<your-ip>:3000
|
||||
```
|
||||
|
||||
> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended) — it provides a private network so you can access `http://<tailscale-ip>:3000` from your phone without TLS certificates.
|
||||
|
||||
### Secure QR Code Authentication
|
||||
|
||||
Typing passwords on a phone keyboard is miserable. Codeman replaces it with **cryptographically secure single-use QR tokens** — scan the code displayed on your desktop and your phone is authenticated instantly.
|
||||
|
||||
Each QR encodes a URL containing a 6-character short code that maps to a 256-bit secret (`crypto.randomBytes(32)`) on the server. Tokens auto-rotate every **60 seconds**, are **atomically consumed on first scan** (replays always fail), and use **hash-based `Map.get()` lookup** that leaks nothing through response timing. The short code is an opaque pointer — the real secret never appears in browser history, `Referer` headers, or Cloudflare edge logs.
|
||||
|
||||
The security design addresses all 6 critical QR auth flaws identified in ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin) (USENIX Security 2025, which found 47 of the top-100 websites vulnerable): single-use enforcement, short TTL, cryptographic randomness, server-side generation, real-time desktop notification on scan (QRLjacking detection), and IP + User-Agent session binding with manual revocation. Dual-layer rate limiting (per-IP + global) makes brute force infeasible across 62^6 = 56.8 billion possible codes. Full security analysis: [`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
---
|
||||
|
||||
## Using Codeman — A Human's Guide
|
||||
|
||||
A start-to-finish walkthrough for driving Codeman from the browser. If you just installed, this is where to begin.
|
||||
@@ -177,7 +241,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
|
||||
- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder (tab order syncs across your devices).
|
||||
- **Terminal (center)** — a real `xterm.js` terminal; full TUIs render correctly. Type directly and press **Enter** to send. `Shift+Enter` inserts a newline.
|
||||
- **Side panels** — Respawn, Ralph, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar).
|
||||
- **Side panels** — Respawn, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar).
|
||||
|
||||
### 4. Talk to the agent
|
||||
|
||||
@@ -191,7 +255,6 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
| Mode | Use it for | Where |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| **Respawn** | Long unattended runs — auto-restarts the CLI on idle/limit, with adaptive timing. Presets: `solo-work`, `overnight-autonomous`, … | Respawn tab |
|
||||
| **Ralph / Todo** | A self-driving loop that tracks a todo list and keeps working until done. | Ralph tab |
|
||||
| **Orchestrator** | Turn one goal into a phased plan and drive it to completion across agents. | Orchestrator panel |
|
||||
| **Cron** | Saved, named jobs on a schedule (`once`/`interval`/`daily`/`weekly`) that spawn a session and send a prompt when due. | ⏰ Cron button _(opt-in: App Settings → Display → Header Displays)_ |
|
||||
| **Auto-resume** | Automatically continue after a subscription rate-limit resets. | Respawn tab (top) |
|
||||
@@ -212,67 +275,23 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
|
||||
---
|
||||
|
||||
## Mobile-Optimized Web UI
|
||||
|
||||
The most responsive AI coding agent experience on any phone. Full xterm.js terminal with local echo, swipe navigation, and a touch-optimized interface designed for real remote work — not a desktop UI crammed onto a small screen.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-landing-qr.png" alt="Mobile — landing page with QR auth" width="260"></td>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-keyboard-20260727.png" alt="Mobile — answering an agent's plan prompt with the keyboard accessory bar and Enter button" width="260"></td>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-active.png" alt="Mobile — active agent session" width="260"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Landing page with QR auth</em></td>
|
||||
<td align="center"><em>Answering prompts by touch</em></td>
|
||||
<td align="center"><em>Agent working in real-time</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Terminal Apps</th>
|
||||
<th>Codeman Mobile</th>
|
||||
</tr>
|
||||
<tr><td>200-300ms input lag over remote</td><td><b>Local echo — instant feedback</b></td></tr>
|
||||
<tr><td>Tiny text, no context</td><td>Full xterm.js terminal</td></tr>
|
||||
<tr><td>No session management</td><td>Swipe between sessions</td></tr>
|
||||
<tr><td>No notifications</td><td>Push alerts for approvals and idle</td></tr>
|
||||
<tr><td>Manual reconnect</td><td>tmux persistence</td></tr>
|
||||
<tr><td>No agent visibility</td><td>Background agents in real-time</td></tr>
|
||||
<tr><td>Copy-paste slash commands</td><td>One-tap <code>/init</code>, <code>/clear</code>, <code>/compact</code></td></tr>
|
||||
<tr><td>Password typing on phone</td><td><b>QR code scan — instant auth</b></td></tr>
|
||||
</table>
|
||||
|
||||
### Secure QR Code Authentication
|
||||
|
||||
Typing passwords on a phone keyboard is miserable. Codeman replaces it with **cryptographically secure single-use QR tokens** — scan the code displayed on your desktop and your phone is authenticated instantly.
|
||||
|
||||
Each QR encodes a URL containing a 6-character short code that maps to a 256-bit secret (`crypto.randomBytes(32)`) on the server. Tokens auto-rotate every **60 seconds**, are **atomically consumed on first scan** (replays always fail), and use **hash-based `Map.get()` lookup** that leaks nothing through response timing. The short code is an opaque pointer — the real secret never appears in browser history, `Referer` headers, or Cloudflare edge logs.
|
||||
|
||||
The security design addresses all 6 critical QR auth flaws identified in ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin) (USENIX Security 2025, which found 47 of the top-100 websites vulnerable): single-use enforcement, short TTL, cryptographic randomness, server-side generation, real-time desktop notification on scan (QRLjacking detection), and IP + User-Agent session binding with manual revocation. Dual-layer rate limiting (per-IP + global) makes brute force infeasible across 62^6 = 56.8 billion possible codes. Full security analysis: [`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
### Touch-Optimized Interface
|
||||
## Zero-Lag Input Overlay
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/mobile-toolbar-enter-20260727.png" alt="Mobile toolbar: accessory bar with /init, /clear, clipboard and Esc above the Run, case, stop, Enter, voice and settings controls" width="560">
|
||||
<img src="docs/images/zerolag-demo-20260728.gif" alt="Zerolag demo: instant local echo next to 600ms-2.7s server echo, side by side on two phones" width="900">
|
||||
</p>
|
||||
|
||||
- **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard. Destructive commands (`/clear`, `/compact`) require a double-press to confirm — first tap arms the button, second tap executes — so you never fire one by accident on a bumpy commute
|
||||
- **Dedicated Enter button** — submitting is a constant need on a touch keyboard, so the phone toolbar gives it a button of its own. It replays the keypress through the terminal, so text buffered by local echo is flushed first rather than stranded. Starting a shell moves into the Run dropdown (`Terminal / Shell`), which is the rarer action
|
||||
- **Swipe navigation** — left/right on the terminal to switch sessions (80px threshold, 300ms)
|
||||
- **Smart keyboard handling** — toolbar and terminal shift up when keyboard opens (uses `visualViewport` API with 100px threshold for iOS address bar drift)
|
||||
- **Safe area support** — respects iPhone notch and home indicator via `env(safe-area-inset-*)`
|
||||
- **44px touch targets** — all buttons meet iOS Human Interface Guidelines minimum sizes
|
||||
- **Bottom sheet case picker** — slide-up modal replaces the desktop dropdown
|
||||
- **Native momentum scrolling** — `-webkit-overflow-scrolling: touch` for buttery scroll
|
||||
When accessing your coding agent remotely (VPN, Tailscale, SSH tunnel), every keystroke normally takes 200-300ms to round-trip. Codeman implements a **Mosh-inspired local echo system** that makes typing feel instant regardless of latency.
|
||||
|
||||
```bash
|
||||
codeman web --https
|
||||
# Open on your phone: https://<your-ip>:3000
|
||||
```
|
||||
A pixel-perfect DOM overlay inside xterm.js renders keystrokes at 0ms. Background forwarding silently sends every character to the PTY in 50ms debounced batches, so Tab completion, `Ctrl+R` history search, and all shell features work normally. When the server echo arrives 200-300ms later, the overlay seamlessly disappears and the real terminal text takes over — the transition is invisible.
|
||||
|
||||
> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended) — it provides a private network so you can access `http://<tailscale-ip>:3000` from your phone without TLS certificates.
|
||||
- **Ink-proof architecture** — lives as a `<span>` at z-index 7 inside `.xterm-screen`, completely immune to Ink's constant screen redraws (two previous attempts using `terminal.write()` failed because Ink corrupts injected buffer content)
|
||||
- **Font-matched rendering** — reads `fontFamily`, `fontSize`, `fontWeight`, and `letterSpacing` from xterm.js computed styles so overlay text is visually indistinguishable from real terminal output
|
||||
- **Full editing** — backspace, retype, paste (multi-char), cursor tracking, multi-line wrap when input exceeds terminal width
|
||||
- **Persistent across reconnects** — unsent input survives page reloads via localStorage
|
||||
- **Enabled by default** — works on both desktop and mobile, during idle and busy sessions
|
||||
|
||||
> Extracted as a standalone library: [`xterm-zerolag-input`](https://www.npmjs.com/package/xterm-zerolag-input) — see [Published Packages](#published-packages).
|
||||
|
||||
---
|
||||
|
||||
@@ -300,26 +319,6 @@ Multi-agent Workflow runs ("ultracode") get the same treatment: a floating run w
|
||||
|
||||
---
|
||||
|
||||
## Zero-Lag Input Overlay
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/zerolag-demo.gif" alt="Zerolag Demo — local echo vs server echo side-by-side" width="900">
|
||||
</p>
|
||||
|
||||
When accessing your coding agent remotely (VPN, Tailscale, SSH tunnel), every keystroke normally takes 200-300ms to round-trip. Codeman implements a **Mosh-inspired local echo system** that makes typing feel instant regardless of latency.
|
||||
|
||||
A pixel-perfect DOM overlay inside xterm.js renders keystrokes at 0ms. Background forwarding silently sends every character to the PTY in 50ms debounced batches, so Tab completion, `Ctrl+R` history search, and all shell features work normally. When the server echo arrives 200-300ms later, the overlay seamlessly disappears and the real terminal text takes over — the transition is invisible.
|
||||
|
||||
- **Ink-proof architecture** — lives as a `<span>` at z-index 7 inside `.xterm-screen`, completely immune to Ink's constant screen redraws (two previous attempts using `terminal.write()` failed because Ink corrupts injected buffer content)
|
||||
- **Font-matched rendering** — reads `fontFamily`, `fontSize`, `fontWeight`, and `letterSpacing` from xterm.js computed styles so overlay text is visually indistinguishable from real terminal output
|
||||
- **Full editing** — backspace, retype, paste (multi-char), cursor tracking, multi-line wrap when input exceeds terminal width
|
||||
- **Persistent across reconnects** — unsent input survives page reloads via localStorage
|
||||
- **Enabled by default** — works on both desktop and mobile, during idle and busy sessions
|
||||
|
||||
> Extracted as a standalone library: [`xterm-zerolag-input`](https://www.npmjs.com/package/xterm-zerolag-input) — see [Published Packages](#published-packages).
|
||||
|
||||
---
|
||||
|
||||
## Respawn Controller
|
||||
|
||||
The core of autonomous work. When the agent goes idle, the Respawn Controller detects it, sends a continue prompt, cycles context management commands for fresh context, and resumes — running **24+ hours** completely unattended.
|
||||
@@ -346,7 +345,7 @@ Beyond single-session respawn, the **Orchestrator** turns a high-level goal into
|
||||
- **Crash-safe** — full state persists under the `orchestrator` key in `state.json`, so it survives restarts
|
||||
- **Driven from the UI or API** — the Orchestrator panel, or `POST /api/orchestrator/start` → `/approve` → `/status` (10 endpoints)
|
||||
|
||||
> Distinct from Ralph (a single-session autonomous loop): the orchestrator coordinates multi-phase, multi-agent execution. Full design: [`docs/orchestrator-loop-architecture.md`](docs/orchestrator-loop-architecture.md).
|
||||
> Full design: [`docs/orchestrator-loop-architecture.md`](docs/orchestrator-loop-architecture.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -354,10 +353,6 @@ Beyond single-session respawn, the **Orchestrator** turns a high-level goal into
|
||||
|
||||
Run **20 parallel sessions** with full visibility — real-time xterm.js terminals at 60fps, per-session token and cost tracking, tab-based navigation, and one-click management.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/multi-session-dashboard.png" alt="Multi-Session Dashboard" width="800">
|
||||
</p>
|
||||
|
||||
### Persistent Sessions
|
||||
|
||||
Every session runs inside **tmux** — sessions survive server restarts, network drops, and machine sleep. Auto-recovery on startup with dual redundancy. Ghost session discovery finds orphaned tmux sessions. Managed sessions are environment-tagged so the agent won't kill its own session.
|
||||
@@ -392,14 +387,6 @@ The title is templated into the served HTML on first byte, so it's correct from
|
||||
|
||||
Real-time desktop alerts when sessions need attention — `permission_prompt` and `elicitation_dialog` trigger critical red tab blinks, `idle_prompt` triggers yellow blinks. Click any notification to jump directly to the affected session. Hooks auto-configured per case directory.
|
||||
|
||||
### Ralph / Todo Tracking
|
||||
|
||||
Auto-detects Ralph Loops, `<promise>` tags, TodoWrite progress (`4/9 complete`), and iteration counters (`[5/50]`) with real-time progress rings and elapsed time tracking.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/ralph-tracker-8tasks-44percent.png" alt="Ralph Loop Tracking" width="800">
|
||||
</p>
|
||||
|
||||
### Run Summary
|
||||
|
||||
Click the chart icon on any session tab to see a timeline of everything that happened — respawn cycles, token milestones, auto-compact triggers, idle/working transitions, hook events, errors, and more.
|
||||
@@ -747,7 +734,6 @@ codeman session start -d /path/to/repo # (s) start a session
|
||||
codeman session list # list sessions
|
||||
codeman session logs <id> # tail output
|
||||
codeman task add "fix the failing test" # (t) queue a task
|
||||
codeman ralph start --min-hours 8 # (r) launch the autonomous loop
|
||||
codeman attach <path> # attach a Claude hook context
|
||||
```
|
||||
|
||||
@@ -784,13 +770,6 @@ REST over Fastify — **~190 handlers across 20 route modules**, plus an SSE str
|
||||
| `POST` | `/api/sessions/:id/respawn/stop` | Stop controller |
|
||||
| `PUT` | `/api/sessions/:id/respawn/config` | Update config |
|
||||
|
||||
### Ralph / Todo
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------------- | ---------------------- |
|
||||
| `GET` | `/api/sessions/:id/ralph-state` | Get loop state + todos |
|
||||
| `POST` | `/api/sessions/:id/ralph-config` | Configure tracking |
|
||||
|
||||
### Orchestrator
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
@@ -853,7 +832,6 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph Detection["Detection Layer"]
|
||||
RT["Ralph Tracker"]
|
||||
SW["Subagent Watcher<br/><small>~/.claude/projects/*/subagents</small>"]
|
||||
TW["Team Watcher<br/><small>~/.claude/teams/*</small>"]
|
||||
end
|
||||
@@ -877,7 +855,6 @@ flowchart TB
|
||||
SM --> RC
|
||||
SM --> ORC
|
||||
SM --> SS
|
||||
S1 --> RT
|
||||
S1 --> SCR
|
||||
S2 --> SCR
|
||||
RC --> SCR
|
||||
@@ -926,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
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-22%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 22+"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
|
||||
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
|
||||
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
|
||||
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||
</p>
|
||||
@@ -32,12 +31,25 @@
|
||||
|
||||
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
||||
|
||||
一行命令即可安装(macOS 和 Linux,Windows 通过 WSL):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://getcodeman.com/install | bash
|
||||
```
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
# 打开 http://localhost:3000,开启你的第一个会话
|
||||
```
|
||||
|
||||
安装器在每次系统改动前都会先询问;重跑同一条命令即可原地更新。详见[快速开始 — 安装](#快速开始--安装)。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始 — 安装
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
|
||||
curl -fsSL https://getcodeman.com/install | bash
|
||||
```
|
||||
|
||||
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。几点须知:
|
||||
@@ -126,7 +138,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
<summary><strong>Windows(WSL)</strong></summary>
|
||||
|
||||
```powershell
|
||||
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
|
||||
wsl bash -c "curl -fsSL https://getcodeman.com/install | bash"
|
||||
```
|
||||
|
||||
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
|
||||
@@ -135,6 +147,58 @@ Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.
|
||||
|
||||
---
|
||||
|
||||
## 移动端优化的 Web UI
|
||||
|
||||
在任意手机上都能获得最跟手的 AI 编程智能体体验。完整的 xterm.js 终端、本地回显、滑动导航,以及为真正的远程办公而设计的触控优化界面 —— 而不是把桌面 UI 硬塞进小屏幕。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="40%"><img src="docs/screenshots/mobile-session-keyboard-20260727.png" alt="移动端 — 通过键盘配件栏与 Enter 按钮回答智能体的方案提示" width="300"></td>
|
||||
<td align="center" width="60%"><img src="docs/screenshots/mobile-toolbar-enter-20260727.png" alt="移动端工具栏:配件栏的 /init、/clear、剪贴板与 Esc,下方是 Run、案例、停止、Enter、语音与设置控件" width="440"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>触控回答提示</em></td>
|
||||
<td align="center"><em>配件栏 + 独立 Enter 按钮</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>普通终端 App</th>
|
||||
<th>Codeman 移动端</th>
|
||||
</tr>
|
||||
<tr><td>远程输入延迟 200–300 毫秒</td><td><b>本地回显 —— 即时反馈</b></td></tr>
|
||||
<tr><td>字小、无上下文</td><td>完整 xterm.js 终端</td></tr>
|
||||
<tr><td>无会话管理</td><td>滑动切换会话</td></tr>
|
||||
<tr><td>无通知</td><td>审批 / 空闲时推送提醒</td></tr>
|
||||
<tr><td>需手动重连</td><td>tmux 持久化</td></tr>
|
||||
<tr><td>看不到智能体</td><td>实时查看后台智能体</td></tr>
|
||||
<tr><td>斜杠命令靠复制粘贴</td><td>一键 <code>/init</code>、<code>/clear</code>、<code>/compact</code></td></tr>
|
||||
<tr><td>在手机上手打密码</td><td><b>扫二维码 —— 即时认证</b></td></tr>
|
||||
</table>
|
||||
|
||||
- **键盘配件栏** —— 在虚拟键盘上方提供 `/init`、`/clear`、`/compact` 快捷按钮;破坏性命令需双击确认,绝不误触
|
||||
- **独立的 Enter 按钮** —— 以按键方式回放,先冲刷本地回显缓冲的文本,不会让内容滞留在屏幕上
|
||||
- **滑动导航与智能键盘处理** —— 左右滑动切换会话;键盘弹出时工具栏与终端整体上移(`visualViewport` API)
|
||||
- **为手机而生** —— 刘海与 Home 指示条的安全区适配、44px 触控目标、底部抽屉式 case 选择器、原生惯性滚动
|
||||
|
||||
```bash
|
||||
codeman web --https
|
||||
# 在手机上打开:https://<你的IP>:3000
|
||||
```
|
||||
|
||||
> `localhost` 走纯 HTTP 即可。从其他设备访问时请使用 `--https`,或使用 [Tailscale](https://tailscale.com/)(推荐)—— 它提供私有网络,让你无需 TLS 证书即可从手机访问 `http://<tailscale-ip>:3000`。
|
||||
|
||||
### 安全的二维码认证
|
||||
|
||||
在手机键盘上输密码太痛苦了。Codeman 用**密码学安全的一次性二维码令牌**取而代之 —— 扫描桌面上显示的二维码,手机即刻完成认证。
|
||||
|
||||
每个二维码编码的是一个包含 6 字符短码的 URL,该短码在服务端映射到一个 256 位密钥(`crypto.randomBytes(32)`)。令牌每 **60 秒**自动轮换,**首次扫描即原子性消费**(重放永远失败),并采用**基于哈希的 `Map.get()` 查找**,不会通过响应时延泄露任何信息。短码只是一个不透明指针 —— 真正的密钥永远不会出现在浏览器历史、`Referer` 头或 Cloudflare 边缘日志中。
|
||||
|
||||
该安全设计覆盖了 ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin)(USENIX Security 2025,该研究发现 Top-100 网站中有 47 个存在漏洞)所指出的全部 6 个关键二维码认证缺陷:强制一次性使用、短 TTL、密码学随机性、服务端生成、扫描时桌面实时通知(QRLjacking 检测),以及 IP + User-Agent 会话绑定与手动吊销。双层速率限制(按 IP + 全局)使得在 62^6 = 568 亿种可能短码空间内进行暴力破解变得不可行。完整安全分析见:[`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
---
|
||||
|
||||
## 使用 Codeman —— 人类操作指南
|
||||
|
||||
从头到尾走一遍如何在浏览器里驾驭 Codeman。如果你刚装好,就从这里开始。
|
||||
@@ -167,7 +231,7 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
|
||||
|
||||
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序(标签顺序会跨设备同步)。
|
||||
- **终端(中央)** —— 真实的 `xterm.js` 终端;完整 TUI 正常渲染。直接输入并按 **Enter** 发送。`Shift+Enter` 插入换行。
|
||||
- **侧边面板** —— Respawn、Ralph、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。
|
||||
- **侧边面板** —— Respawn、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。
|
||||
|
||||
### 4. 与智能体对话
|
||||
|
||||
@@ -181,7 +245,6 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
|
||||
| 模式 | 用途 | 位置 |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| **Respawn** | 长时间无人值守运行 —— 空闲/限额时自动重启 CLI,带自适应时序。预设:`solo-work`、`overnight-autonomous` 等 | Respawn 标签页 |
|
||||
| **Ralph / Todo** | 一个自驱循环,跟踪 todo 列表并持续工作直到完成。 | Ralph 标签页 |
|
||||
| **Orchestrator** | 把一个目标变成分阶段计划,并跨多个智能体推动完成。 | 编排器面板 |
|
||||
| **Cron** | 已保存的、命名的定时任务(`once`/`interval`/`daily`/`weekly`),到期时拉起会话并发送提示。 | ⏰ Cron 按钮(可选启用:App Settings → Display → Header Displays) |
|
||||
| **Auto-resume** | 订阅限额重置后自动继续。 | Respawn 标签页(顶部) |
|
||||
@@ -202,67 +265,23 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
|
||||
|
||||
---
|
||||
|
||||
## 移动端优化的 Web UI
|
||||
|
||||
在任意手机上都能获得最跟手的 AI 编程智能体体验。完整的 xterm.js 终端、本地回显、滑动导航,以及为真正的远程办公而设计的触控优化界面 —— 而不是把桌面 UI 硬塞进小屏幕。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-landing-qr.png" alt="移动端 — 带二维码认证的登录页" width="260"></td>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-keyboard-20260727.png" alt="移动端 — 通过键盘配件栏与 Enter 按钮回答智能体的方案提示" width="260"></td>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-active.png" alt="移动端 — 活动中的智能体会话" width="260"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>带二维码认证的登录页</em></td>
|
||||
<td align="center"><em>触控回答提示</em></td>
|
||||
<td align="center"><em>智能体实时工作中</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>普通终端 App</th>
|
||||
<th>Codeman 移动端</th>
|
||||
</tr>
|
||||
<tr><td>远程输入延迟 200–300 毫秒</td><td><b>本地回显 —— 即时反馈</b></td></tr>
|
||||
<tr><td>字小、无上下文</td><td>完整 xterm.js 终端</td></tr>
|
||||
<tr><td>无会话管理</td><td>滑动切换会话</td></tr>
|
||||
<tr><td>无通知</td><td>审批 / 空闲时推送提醒</td></tr>
|
||||
<tr><td>需手动重连</td><td>tmux 持久化</td></tr>
|
||||
<tr><td>看不到智能体</td><td>实时查看后台智能体</td></tr>
|
||||
<tr><td>斜杠命令靠复制粘贴</td><td>一键 <code>/init</code>、<code>/clear</code>、<code>/compact</code></td></tr>
|
||||
<tr><td>在手机上手打密码</td><td><b>扫二维码 —— 即时认证</b></td></tr>
|
||||
</table>
|
||||
|
||||
### 安全的二维码认证
|
||||
|
||||
在手机键盘上输密码太痛苦了。Codeman 用**密码学安全的一次性二维码令牌**取而代之 —— 扫描桌面上显示的二维码,手机即刻完成认证。
|
||||
|
||||
每个二维码编码的是一个包含 6 字符短码的 URL,该短码在服务端映射到一个 256 位密钥(`crypto.randomBytes(32)`)。令牌每 **60 秒**自动轮换,**首次扫描即原子性消费**(重放永远失败),并采用**基于哈希的 `Map.get()` 查找**,不会通过响应时延泄露任何信息。短码只是一个不透明指针 —— 真正的密钥永远不会出现在浏览器历史、`Referer` 头或 Cloudflare 边缘日志中。
|
||||
|
||||
该安全设计覆盖了 ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin)(USENIX Security 2025,该研究发现 Top-100 网站中有 47 个存在漏洞)所指出的全部 6 个关键二维码认证缺陷:强制一次性使用、短 TTL、密码学随机性、服务端生成、扫描时桌面实时通知(QRLjacking 检测),以及 IP + User-Agent 会话绑定与手动吊销。双层速率限制(按 IP + 全局)使得在 62^6 = 568 亿种可能短码空间内进行暴力破解变得不可行。完整安全分析见:[`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
### 触控优化界面
|
||||
## 零延迟输入叠加层
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/mobile-toolbar-enter-20260727.png" alt="移动端工具栏:配件栏的 /init、/clear、剪贴板与 Esc,下方是 Run、案例、停止、Enter、语音与设置控件" width="560">
|
||||
<img src="docs/images/zerolag-demo-20260728.gif" alt="Zerolag 演示:两台手机并排对比,即时本地回显与 600ms-2.7s 服务端回显" width="900">
|
||||
</p>
|
||||
|
||||
- **键盘配件栏** —— 在虚拟键盘上方提供 `/init`、`/clear`、`/compact` 快捷按钮。破坏性命令(`/clear`、`/compact`)需双击确认 —— 第一次点击「上膛」,第二次点击执行 —— 这样在颠簸的通勤路上也不会误触
|
||||
- **独立的 Enter 按钮** —— 在触控键盘上提交是高频操作,因此手机工具栏为它单独设了一个按钮。它会以按键的方式回放,从而先冲刷本地回显缓冲的文本,不会让内容滞留在屏幕上。启动 Shell 这类低频操作则移入 Run 下拉菜单(`Terminal / Shell`)
|
||||
- **滑动导航** —— 在终端上左右滑动切换会话(阈值 80px,300ms)
|
||||
- **智能键盘处理** —— 键盘弹出时工具栏与终端整体上移(使用 `visualViewport` API,并对 iOS 地址栏漂移设置 100px 阈值)
|
||||
- **安全区适配** —— 通过 `env(safe-area-inset-*)` 适配 iPhone 刘海与底部 Home 指示条
|
||||
- **44px 触控目标** —— 所有按钮均满足 iOS 人机界面指南的最小尺寸
|
||||
- **底部抽屉式 case 选择器** —— 用上滑模态框替代桌面端下拉菜单
|
||||
- **原生惯性滚动** —— `-webkit-overflow-scrolling: touch`,丝滑流畅
|
||||
远程访问你的编程智能体时(VPN、Tailscale、SSH 隧道),每次按键通常需要 200–300 毫秒往返。Codeman 实现了一套**受 Mosh 启发的本地回显系统**,无论延迟多高,打字都感觉即时。
|
||||
|
||||
```bash
|
||||
codeman web --https
|
||||
# 在手机上打开:https://<你的IP>:3000
|
||||
```
|
||||
xterm.js 内部一个像素级精准的 DOM 叠加层以 0ms 渲染按键。后台转发会以 50ms 防抖批次静默地把每个字符送往 PTY,因此 Tab 补全、`Ctrl+R` 历史搜索以及所有 shell 特性都正常工作。当服务端回显在 200–300ms 后到达时,叠加层无缝消失、真实终端文本接管 —— 整个切换过程不可见。
|
||||
|
||||
> `localhost` 走纯 HTTP 即可。从其他设备访问时请使用 `--https`,或使用 [Tailscale](https://tailscale.com/)(推荐)—— 它提供私有网络,让你无需 TLS 证书即可从手机访问 `http://<tailscale-ip>:3000`。
|
||||
- **抗 Ink 架构** —— 它作为 `.xterm-screen` 内 z-index 7 的一个 `<span>` 存在,完全不受 Ink 持续重绘屏幕的影响(此前两次使用 `terminal.write()` 的尝试都失败了,因为 Ink 会破坏注入的缓冲区内容)
|
||||
- **字体匹配渲染** —— 从 xterm.js 的计算样式读取 `fontFamily`、`fontSize`、`fontWeight` 与 `letterSpacing`,使叠加层文本与真实终端输出在视觉上无法区分
|
||||
- **完整编辑** —— 退格、重打、粘贴(多字符)、光标跟踪,输入超过终端宽度时多行换行
|
||||
- **重连后持久** —— 未发送的输入通过 localStorage 在页面刷新后保留
|
||||
- **默认启用** —— 桌面端与移动端均可用,会话空闲或繁忙时都生效
|
||||
|
||||
> 已抽取为独立库:[`xterm-zerolag-input`](https://www.npmjs.com/package/xterm-zerolag-input) —— 见[已发布的包](#已发布的包)。
|
||||
|
||||
---
|
||||
|
||||
@@ -290,26 +309,6 @@ codeman web --https
|
||||
|
||||
---
|
||||
|
||||
## 零延迟输入叠加层
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/zerolag-demo.gif" alt="Zerolag 演示 —— 本地回显与服务端回显并排对比" width="900">
|
||||
</p>
|
||||
|
||||
远程访问你的编程智能体时(VPN、Tailscale、SSH 隧道),每次按键通常需要 200–300 毫秒往返。Codeman 实现了一套**受 Mosh 启发的本地回显系统**,无论延迟多高,打字都感觉即时。
|
||||
|
||||
xterm.js 内部一个像素级精准的 DOM 叠加层以 0ms 渲染按键。后台转发会以 50ms 防抖批次静默地把每个字符送往 PTY,因此 Tab 补全、`Ctrl+R` 历史搜索以及所有 shell 特性都正常工作。当服务端回显在 200–300ms 后到达时,叠加层无缝消失、真实终端文本接管 —— 整个切换过程不可见。
|
||||
|
||||
- **抗 Ink 架构** —— 它作为 `.xterm-screen` 内 z-index 7 的一个 `<span>` 存在,完全不受 Ink 持续重绘屏幕的影响(此前两次使用 `terminal.write()` 的尝试都失败了,因为 Ink 会破坏注入的缓冲区内容)
|
||||
- **字体匹配渲染** —— 从 xterm.js 的计算样式读取 `fontFamily`、`fontSize`、`fontWeight` 与 `letterSpacing`,使叠加层文本与真实终端输出在视觉上无法区分
|
||||
- **完整编辑** —— 退格、重打、粘贴(多字符)、光标跟踪,输入超过终端宽度时多行换行
|
||||
- **重连后持久** —— 未发送的输入通过 localStorage 在页面刷新后保留
|
||||
- **默认启用** —— 桌面端与移动端均可用,会话空闲或繁忙时都生效
|
||||
|
||||
> 已抽取为独立库:[`xterm-zerolag-input`](https://www.npmjs.com/package/xterm-zerolag-input) —— 见[已发布的包](#已发布的包)。
|
||||
|
||||
---
|
||||
|
||||
## 重生控制器(Respawn Controller)
|
||||
|
||||
自主工作的核心。当智能体进入空闲,重生控制器会检测到,发送继续提示,循环执行上下文管理命令以获得全新上下文,然后恢复工作 —— 可完全无人值守运行 **24 小时以上**。
|
||||
@@ -336,7 +335,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
||||
- **崩溃安全** —— 完整状态持久化在 `state.json` 的 `orchestrator` 键下,可在重启后存续
|
||||
- **可从 UI 或 API 驱动** —— 编排器面板,或 `POST /api/orchestrator/start` → `/approve` → `/status`(共 10 个端点)
|
||||
|
||||
> 与 Ralph(单会话自主循环)不同:编排器协调多阶段、多智能体执行。完整设计:[`docs/orchestrator-loop-architecture.md`](docs/orchestrator-loop-architecture.md)。
|
||||
> 完整设计:[`docs/orchestrator-loop-architecture.md`](docs/orchestrator-loop-architecture.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -344,10 +343,6 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
||||
|
||||
运行 **20 个并行会话**且全程可见 —— 60fps 的实时 xterm.js 终端、按会话的 token 与成本跟踪、基于标签的导航,以及一键管理。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/multi-session-dashboard.png" alt="多会话仪表盘" width="800">
|
||||
</p>
|
||||
|
||||
### 持久化会话
|
||||
|
||||
每个会话都运行在 **tmux** 内 —— 会话可在服务器重启、网络中断与机器休眠后存续。启动时自动恢复,具备双重冗余。幽灵会话发现机制能找到孤立的 tmux 会话。受管会话带有环境标签,因此智能体不会杀掉自己的会话。
|
||||
@@ -382,14 +377,6 @@ codeman web --title-hostname dev-box # codeman:dev-box(用于覆盖嘈
|
||||
|
||||
当会话需要关注时实时桌面提醒 —— `permission_prompt` 与 `elicitation_dialog` 触发关键的红色标签闪烁,`idle_prompt` 触发黄色闪烁。点击任意通知即可直接跳转到相关会话。Hook 按 case 目录自动配置。
|
||||
|
||||
### Ralph / Todo 跟踪
|
||||
|
||||
自动检测 Ralph 循环、`<promise>` 标签、TodoWrite 进度(`4/9 complete`)以及迭代计数器(`[5/50]`),并提供实时进度环与已用时间跟踪。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/ralph-tracker-8tasks-44percent.png" alt="Ralph 循环跟踪" width="800">
|
||||
</p>
|
||||
|
||||
### 运行摘要(Run Summary)
|
||||
|
||||
点击任意会话标签上的图表图标,即可看到所发生一切的时间线 —— 重生周期、token 里程碑、自动 compact 触发、空闲/工作切换、hook 事件、错误等等。
|
||||
@@ -737,7 +724,6 @@ codeman session start -d /path/to/repo # (s) 启动会话
|
||||
codeman session list # 列出会话
|
||||
codeman session logs <id> # 查看输出
|
||||
codeman task add "fix the failing test" # (t) 排入任务
|
||||
codeman ralph start --min-hours 8 # (r) 启动自主循环
|
||||
codeman attach <path> # 附着 Claude hook 上下文
|
||||
```
|
||||
|
||||
@@ -774,13 +760,6 @@ Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission
|
||||
| `POST` | `/api/sessions/:id/respawn/stop` | 停止控制器 |
|
||||
| `PUT` | `/api/sessions/:id/respawn/config` | 更新配置 |
|
||||
|
||||
### Ralph / Todo
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| ------ | -------------------------------- | -------------------- |
|
||||
| `GET` | `/api/sessions/:id/ralph-state` | 获取循环状态 + todos |
|
||||
| `POST` | `/api/sessions/:id/ralph-config` | 配置跟踪 |
|
||||
|
||||
### 编排器(Orchestrator)
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
@@ -843,7 +822,6 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph Detection["检测层"]
|
||||
RT["Ralph 跟踪器"]
|
||||
SW["子智能体监视器<br/><small>~/.claude/projects/*/subagents</small>"]
|
||||
TW["团队监视器<br/><small>~/.claude/teams/*</small>"]
|
||||
end
|
||||
@@ -867,7 +845,6 @@ flowchart TB
|
||||
SM --> RC
|
||||
SM --> ORC
|
||||
SM --> SS
|
||||
S1 --> RT
|
||||
S1 --> SCR
|
||||
S2 --> SCR
|
||||
RC --> SCR
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -74,6 +74,19 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
**Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups _identical_ in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals. **Codex generated artifacts** (COD-166/#150, `generated-artifact-attachments.ts`): codex-mode sessions ALSO scan (ANSI-stripped) output for `Saved to: file:///…` lines and surface those files as attachment cards with a relaxed trust policy — the allow decision runs on the **realpath-resolved** path against `os.homedir()`-anchored `~/.codex` marker dirs (symlink escapes fall back to force-confinement); gated to `mode === 'codex'` only (`source` is a REQUIRED param through the listener-deps chain — a dropped arg here silently kills the feature). Image thumbnails pass through jpg/jpeg/gif/webp.
|
||||
|
||||
### Filesystem path picker
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to `/mnt/d`), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.
|
||||
|
||||
⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews.
|
||||
|
||||
⚠️ **Ownership scoping is also not inherited, and both endpoints must do it themselves.** Two separate holes shipped in the original version and are now regression-guarded in `test/routes/file-routes.test.ts`:
|
||||
|
||||
1. The optional `sessionId` param adds that session's `workingDir` as a "Current Folder" root. It is looked up directly off `ctx.sessions`/`ctx.store` rather than through `findSessionOrFail`, so the `canAccessOwned` check has to be written out by hand. Without it a multi-user caller pins **another user's** working directory as a browse root just by passing their session id. It reports 404 rather than 403 so the endpoint does not confirm that a session id exists.
|
||||
2. `Home` and `CASES_DIR` were unconditional roots. Per-user spaces live at `<USER_SPACES_DIR>/<username>`, which is **inside `homedir()`**, so a `Home` root alone let any authenticated user browse and preview every other user's workspace. In multi-user mode a non-admin now gets only `My Space` (their own `userSpacePath`) plus anything in `CODEMAN_FILE_PICKER_ROOTS`; `/mnt/d` is dropped too, since a broad host mount should be an explicit operator decision in a multi-user deployment. Admins and single-user mode keep the host-wide set unchanged.
|
||||
|
||||
The general rule: **any new endpoint that turns a caller-supplied `sessionId` into a filesystem path is an ownership boundary**, whether or not it goes through `findSessionOrFail`.
|
||||
|
||||
### Ultracode and workflow-run visualization
|
||||
|
||||
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
|
||||
@@ -90,6 +103,31 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
|
||||
|
||||
### Web tabs
|
||||
|
||||
**Web tabs** (saved dashboard URLs rendered as tabs beside agent sessions; user guide `docs/web-tabs.md`). A webview is **NOT a sixth `SessionMode`**: no PTY, no tmux, no respawn, no idle detection. It is a separate resource (`~/.codeman/webviews.json` via `src/webview-store.ts`, types in `src/types/webview.ts`, limits in `src/config/webview-limits.ts`) that shares only the tab strip and the main content area, exactly as Docker and remote-SSH are case overlays rather than modes.
|
||||
|
||||
**Why there is a reverse proxy at all.** A direct `<iframe src="http://box:4000">` fails three independent ways in the shipped deployment: (1) prod serves `--https` behind `tailscale serve`, and browsers hard-block `http://` iframes on an HTTPS page with no override (none at all on iOS Safari); (2) Grafana/Portainer/Home-Assistant-class dashboards send `X-Frame-Options: DENY` or `frame-ancestors 'none'`; (3) our own `default-src 'self'` CSP makes `frame-src` fall back to `'self'`. Serving the dashboard through Codeman's origin dissolves all three, and as a bonus leaves the production CSP **byte-for-byte unchanged**, because `/webview/...` is already covered by `'self'`. A `direct` mode still exists for HTTPS targets that permit framing; `POST /api/webviews/probe` runs a server-side reachability + framing check and recommends which to use.
|
||||
|
||||
**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')`, `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 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.
|
||||
|
||||
**Fastify specifics.** The proxy lives in an **encapsulated plugin scope** with `removeAllContentTypeParsers()` + a `'*'` pass-through parser, so raw bodies relay byte-for-byte while the root instance keeps its JSON parsing (and keeps `text/plain` RAW, which was a real CSRF hole once). One `app.route({ method:'GET', handler, wsHandler })` serves both HTTP and the WebSocket upgrade; **HEAD must NOT be declared** on the sibling route because `exposeHeadRoutes` already derives it and the duplicate is a startup error. ⚠️ **Every exit path in the proxy handler RETURNS `reply.send(...)`**: the handler is `async`, and a bare `return` after `reply.send(stream)` resolves the handler promise to `undefined` before the stream is consumed, so Fastify answers with an EMPTY body. HTML survives that (synchronous string payload) while every streamed asset comes back zero-length, which is a genuinely confusing failure. The root-absolute fallback hangs off `setNotFoundHandler`, so real Codeman routes always win.
|
||||
|
||||
**Frontend** (`src/web/public/webview-tabs.js`, load order 12.5): web tabs render into `#sessionTabs` carrying `data-webview-id` instead of `data-id`, so every session-tab path (drag-and-drop, alerts, badges) skips them. ⚠️ `_renderSessionTabsImmediate()`'s `canIncremental` check must include the web-tab comparison: the session-only comparison is vacuously "unchanged" whenever session count is stable, most visibly at ZERO sessions (`0 === 0`), where opening a dashboard would never draw its tab. ⚠️ `isActive` for a session tab is `id === activeSessionId && !activeWebviewId`: `activeSessionId` stays set while a web tab is showing (the terminal keeps streaming underneath), so without that clause the debounced re-render lights two tabs at once. Frames stay MOUNTED while hidden (LRU-evicted past `maxLiveFrames`) so tab switching never reloads a dashboard.
|
||||
|
||||
**Pre-existing bug fixed alongside**: `.toolbar` has `backdrop-filter`, which makes it a stacking context and TRAPS `.run-mode-menu`'s `z-index: 1000` inside it. With the toolbar itself at `z-index: auto`, `.welcome-overlay` (z-index 10, inside `<main>`) painted over the popped-up Run menu, making **every** item in it unclickable whenever no session was open. `.toolbar` now carries `z-index: 20` (must stay below `.modal`'s 1000).
|
||||
|
||||
### Multi-user mode
|
||||
|
||||
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default; shipped 1.5.0 via PR #161, design `docs/multi-user-plan.md`): named users with individually scrypt-hashed passwords in `~/.codeman/users.json` (via `src/user-store.ts`: atomic 0600 write, short-TTL cache, SERIALIZED read-modify-write so a fire-and-forget `touchLastLogin` can't clobber a concurrent route write, last-admin invariants). Gated everywhere by `isMultiUserMode()` (`src/config/multiuser.ts`); when OFF, behavior is byte-identical to single-user (all scoping helpers short-circuit). ⚠️ **Not a security boundary at the agent layer** — every session still runs as the SAME OS account; this separates WORKSPACES, it does not sandbox users (Docker cases are the isolation story). Auth: a PARALLEL async branch in `middleware/auth.ts` (single-user branch untouched) verifies `username:password` against the store, mints identity-carrying cookies (`AuthSessionRecord` gains `username`/`role`/`mustChangePassword`), decorates `req.authUser` (Fastify augmentation; single-user leaves it undefined and the ownership helpers default to a synthetic admin), enforces a per-username failure bucket + the `mustChangePassword` lockbox. Ownership threads through `Session.owner` (stamped from `req.authUser`/`job.owner` at every `new Session()`, round-tripped via `MuxSession.owner` on recovery); `findSessionOrFail(ctx,id,req)` does a NOT_FOUND owner check; list endpoints + `getLightState` + SSE (`deriveSseHint` routes session-scoped events by owner, fail-closed; machine-level + host-plan telemetry admin-only) + WS + search + file-preview all filter by owner. §6.3 permission policy: non-granted users are forced to `--permission-mode auto` (via `resolveClaudeModeForUser` at all spawn sites, incl. one-shots because `buildPromptArgs` now respects the session mode), and shell mode / cron `launchCommand` require the `canBypassPermissions` grant. Cases live in per-user `~/codeman-users/<name>/cases` (`resolveCasesDir`); a non-admin's `workingDir` is realpath-confined there; host CRUD is admin-only. Admin API `src/web/routes/admin-routes.ts` (`/api/admin/users*`, one-time passwords, audit log `admin-audit.jsonl`) + self-service `/api/me` + `/api/me/password` (`me-routes.ts`); frontend `public/admin-ui.js` (identity boot, change-password modal + interceptor, admin Users tab, and the header **Admin Panel** button `#adminPanelBtn`: ships `btn-admin-panel--hidden`, revealed for admins in multi-user mode, phone-hidden via mobile.css; opens the full Admin Panel modal with user CRUD, per-user permission toggles, and case-folder list/delete via `GET/DELETE /api/admin/users/:username/cases[/:caseName]`; live-refreshes on SSE `admin:usersChanged`, wired in app.js). CLI `codeman users add|passwd|list|rm`. Per-user session cap via `sessionCapacityState`/`sessionCapacityMessage`. Tests: `test/user-store.test.ts`, `test/multiuser-auth.test.ts`, `test/ownership-scoping.test.ts`, `test/admin-routes.test.ts`, `test/admin-ui.test.ts`.
|
||||
@@ -107,7 +145,9 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
### Header button visibility (multi-monitor, response viewer, file viewer, cron)
|
||||
|
||||
**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`.
|
||||
**Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Works for Claude AND Codex sessions (#152): Codex last-responses are located via a 4-layer rollout resolution under `CODEX_HOME` (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup — tests in `test/routes/session-routes-codex-last-response.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
|
||||
**Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Works for Claude AND Codex sessions (#152): Codex last-responses are located via a 4-layer rollout resolution under `CODEX_HOME` (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup — tests in `test/routes/session-routes-codex-last-response.test.ts`.
|
||||
|
||||
⚠️ **Claude transcripts are grouped at real human-turn boundaries, not per JSONL row.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots, and multi-block assistant output. Rendering a card per row was the bug: it produced duplicate and truncated cards that looked like the viewer had lost the response. The grouping walks to the next genuine user turn and dedups replayed assistant snapshots while preserving the tool/task/skill/compact/team metadata filtering. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
|
||||
**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Display → **Header Displays** → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Display → Header Displays); cron jobs themselves are unaffected.
|
||||
|
||||
### Gesture control: the setting
|
||||
@@ -120,7 +160,13 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
### Theme skins
|
||||
|
||||
**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on `<html>`. Values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman). CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (`<head>`) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applySkin()` applies it live on save (sets `html[data-skin]` + `window.__codemanSkin`, syncs the standalone `codeman:skin` key with the settings blob, and calls terminal-ui.js `applyTerminalSkin()` to re-theme live terminals). `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
|
||||
**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on `<html>`. Dark values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman). Light values: `paper-gray`, `solarized-light`, `catppuccin-latte`, `rose-pine-dawn`. CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (`<head>`) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applySkin()` applies it live on save (sets `html[data-skin]` + `window.__codemanSkin`, syncs the standalone `codeman:skin` key with the settings blob, updates the `<meta name="theme-color">` from the resolved `--bg-dark`, and calls terminal-ui.js `applyTerminalSkin()` to re-theme live terminals). `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
|
||||
|
||||
**Adding a skin means touching four places, and each one fails silently on its own**: (1) the `html[data-skin="…"]` token block in `styles.css`; (2) the xterm ANSI palette in `CODEMAN_XTERM_THEMES` (terminal-ui.js) — a missing entry falls back to `daylight-blue`, so the terminal simply stays the wrong color; (3) the pre-paint allowlist array in `index.html` — a skin missing there is coerced to `daylight-blue` on every reload, which reads as "my theme keeps resetting"; (4) the `<select>` in App Settings. `test/skin-themes.test.ts` is the static four-way guard.
|
||||
|
||||
**Light skins carry three extra obligations.** `color-scheme: light` on the `html[data-skin]` block, or the UA renders native `<select>` popups, date pickers and scrollbars as dark widgets on a light page. xterm `minimumContrastRatio: 4.5` (set at construction in terminal-ui.js **and** panels-ui.js for teammate terminals, and re-applied in `applyTerminalSkin()`), because CLIs emit colors chosen for dark backgrounds; dark skins keep `1` to avoid the per-cell contrast work. And `applyTerminalSkin()` must call `this._localEchoOverlay?.refreshFont()` — the zero-lag overlay **caches** the terminal fg/bg at construction, so without the refresh typed-but-not-yet-flushed text keeps the previous theme's dark backing after a live skin switch.
|
||||
|
||||
**Shared surface tokens, not literals.** Elevated/floating surfaces resolve through `--floating-bg`, `--control-bg{,-hover}`, `--control-border{,-hover}`, `--banner-bg-a/b`, `--modal-backdrop` and `--elevated-shadow`, so modals, the command palette, dropdowns, subagent/ultracode windows, file previews and the mobile sheets follow the skin instead of hardcoded near-black rgba. ⚠️ Every skin that wants its own elevated look must override these — a skin that omits `--floating-bg` silently inherits the `:root` value, which is how the OG skin's near-black modals drifted slate. `styles.css` also defines **compatibility aliases** (`--bg-primary/secondary/tertiary`, `--text-primary/secondary`, `--border-color`, `--accent-color`, `--success`, `--error`, `--danger`, `--font-mono`, `--shadow-lg`) that forward to the canonical tokens; panels written against those names previously resolved to *nothing* (invalid at computed-value time), so the aliases are load-bearing, not cosmetic.
|
||||
|
||||
### Custom branding and UI language
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 28 MiB |
|
After Width: | Height: | Size: 808 KiB |
|
Before Width: | Height: | Size: 806 KiB |
|
Before Width: | Height: | Size: 894 KiB |
|
Before Width: | Height: | Size: 576 KiB |
|
Before Width: | Height: | Size: 99 KiB |
@@ -500,6 +500,16 @@ Full feature guide: [`docker-cases.md`](docker-cases.md).
|
||||
|
||||
---
|
||||
|
||||
## 10b. Web tabs (dashboard proxy)
|
||||
|
||||
A saved dashboard URL renders as a tab, served through Codeman's own origin at `/webview/<capability>/`. User guide: [`web-tabs.md`](web-tabs.md). Three properties carry the security weight:
|
||||
|
||||
- **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and 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. Edges pinned by `test/webview-auth-exemption.test.ts`.
|
||||
- **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard.
|
||||
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from).
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick reference
|
||||
|
||||
| Env / flag | Effect |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# Web tabs: two fixes (planned + implemented 2026-07-28)
|
||||
|
||||
Both found against the saved dashboard
|
||||
`https://macminis-mac-mini.tailf80371.ts.net:4000` (Bio-Hacking-Dashboard).
|
||||
Kept because the root-cause analysis of the second one is not obvious from the
|
||||
resulting diff.
|
||||
|
||||
Status: **both implemented and verified end-to-end.** The one deliberate
|
||||
non-change is recorded at the bottom.
|
||||
|
||||
---
|
||||
|
||||
## Bug 1: saved URLs could not be deleted from the Run dropdown
|
||||
|
||||
### What happened
|
||||
|
||||
The "Web / URL" section of the Run dropdown listed every saved dashboard as a
|
||||
single clickable row whose only action was "open". Deleting required opening the
|
||||
dashboard as a tab, clicking the tab's gear, then Delete in the modal, so a URL
|
||||
you no longer wanted open at all could not be removed without first opening it.
|
||||
|
||||
### What shipped
|
||||
|
||||
- `renderWebviewMenuItems()` (`src/web/public/webview-tabs.js`) now renders each
|
||||
saved URL as a `.run-mode-row--web` flex row: the open button, a gear
|
||||
(`showWebviewModal`), and an `x` (`deleteWebviewById`). Nested buttons are
|
||||
invalid HTML, hence the wrapper rather than a button inside a button.
|
||||
- `deleteWebview()` split into the modal entry point, the new row entry point
|
||||
`deleteWebviewById(id)`, and the shared `_confirmAndDeleteWebview(id)`.
|
||||
- Both side buttons call `event.stopPropagation()` so the click does not also
|
||||
open the dashboard.
|
||||
- The dropdown's outside-click handler (`session-ui.js`) closes when the click
|
||||
target is not inside `#runModeMenu`, and the row is gone by the time the delete
|
||||
resolves, so `deleteWebviewById` re-asserts `.active` on the menu. Verified in a
|
||||
browser: deleting one of several URLs leaves you looking at the rest of the list.
|
||||
- CSS in `styles.css` (`.run-mode-row--web`, `.run-mode-row-btn`) plus a larger
|
||||
touch target in `mobile.css`. The side buttons are permanently visible rather
|
||||
than hover-revealed, because this menu is used on touch.
|
||||
|
||||
No server change: `DELETE /api/webviews/:id` already existed, owner-scoped, and
|
||||
already revoked the capability and broadcast `WebviewChanged`.
|
||||
|
||||
---
|
||||
|
||||
## Bug 2: images did not load in a proxied dashboard
|
||||
|
||||
### Reproduction (before the fix)
|
||||
|
||||
```
|
||||
CAP=<from POST /api/webviews/<id>/open>
|
||||
# A) upstream direct -> 200 image/jpeg 118150
|
||||
curl -sk "https://macminis-mac-mini.tailf80371.ts.net:4000/api/hero?slug=120-minutes-in-nature"
|
||||
# B) through the proxy prefix -> 200 image/jpeg 118150
|
||||
curl -sk "https://localhost:3000/webview/$CAP/api/hero?slug=120-minutes-in-nature"
|
||||
# C) what the browser ACTUALLY requested -> 404 {"errorCode":"NOT_FOUND"}
|
||||
curl -sk -H "Referer: https://localhost:3000/webview/$CAP/" \
|
||||
"https://localhost:3000/api/hero?slug=120-minutes-in-nature"
|
||||
# D) same shape but NOT under /api -> 200 (referer fallback rescues it)
|
||||
curl -sk -H "Referer: https://localhost:3000/webview/$CAP/" "https://localhost:3000/styles.css"
|
||||
```
|
||||
|
||||
The proxy itself was fine (B). The failure was entirely about which URL the
|
||||
browser ended up requesting (C).
|
||||
|
||||
### Root cause
|
||||
|
||||
The dashboard builds its image markup at runtime with root-absolute URLs:
|
||||
`c.innerHTML = '<img class="thumb" src="/api/hero?slug=...">'`, `img.src =
|
||||
slideSrc(...)` returning `/api/slide?owner=...`, `/api/story`, `/api/video`, and a
|
||||
nested `<iframe src="/api/preview?slug=...">`.
|
||||
|
||||
All three rewrite layers missed that shape:
|
||||
|
||||
1. `<base href="/webview/<cap>/">` only affects **relative** URLs. A root-absolute
|
||||
`/api/hero` ignores the base path and resolves against Codeman's origin.
|
||||
2. `rewriteHtml()` only runs over the **initial HTML document**. This markup is
|
||||
created later by page script. (The static header `<img src="/api/logo">` DID
|
||||
work, having been rewritten at proxy time, which is why only the
|
||||
runtime-injected images were broken.)
|
||||
3. `runtimeUrlShim()` patched only `fetch`, `XMLHttpRequest.open`, `WebSocket` and
|
||||
`EventSource`, so the dashboard's **data** loaded while its **pictures** did
|
||||
not.
|
||||
|
||||
The safety net was fenced off from `/api` in two places, both deliberate:
|
||||
`server.ts`'s not-found handler returns the API-envelope 404 before reaching
|
||||
`tryWebviewRefererFallback`, and `middleware/auth.ts` refuses the Referer-form
|
||||
auth exemption for `/api/`, `/ws/`, `/q/`.
|
||||
|
||||
### What shipped
|
||||
|
||||
`runtimeUrlShim()` in `src/web/webview-proxy.ts` now also covers the DOM sinks, so
|
||||
a root-absolute `/api/...` request is never emitted in the first place and neither
|
||||
security fence had to move:
|
||||
|
||||
- `innerHTML` / `outerHTML` / `insertAdjacentHTML` (and `ShadowRoot.innerHTML`),
|
||||
- `setAttribute` / `setAttributeNS`,
|
||||
- the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters on img,
|
||||
source, media, video poster, script, iframe, embed, track, link, anchor, area,
|
||||
object and form,
|
||||
- a `MutationObserver` as a last net for any sink not patched above (it costs one
|
||||
wasted 404 per node, since the browser starts fetching on insert, so it is a net
|
||||
and not the mechanism).
|
||||
|
||||
Two details that mattered:
|
||||
|
||||
- Every rewrite routes through the existing idempotent `rw()` rather than a blind
|
||||
prefix concat. The first draft used the server-side regex shape and
|
||||
double-prefixed markup that was already proxied (a page re-injecting its own
|
||||
`outerHTML`); the jsdom test caught it.
|
||||
- Everything stays inside `try`/`catch` and is marked `__cmrw`, so a double
|
||||
injection cannot wrap an already-wrapped setter, and nothing can throw into a
|
||||
page we do not control.
|
||||
|
||||
### Verification
|
||||
|
||||
- `test/webview-proxy.test.ts` gained a jsdom `runtimeUrlShim DOM sinks` block:
|
||||
innerHTML, insertAdjacentHTML, property setters, setAttribute, srcset candidate
|
||||
lists, the MutationObserver net via an unpatched sink
|
||||
(`createContextualFragment`), idempotence, re-injected markup, empty `src`, and
|
||||
the pass-throughs (relative, cross-origin, `#hash`, `data:`). 73 tests pass.
|
||||
- End-to-end in a real browser against an isolated instance
|
||||
(`CODEMAN_INSTANCE=wvtest`, port 3151), with prod's old build as the negative
|
||||
control:
|
||||
|
||||
| | before (prod, old build) | after (fixed) |
|
||||
| --- | --- | --- |
|
||||
| images found | 693 | 693 |
|
||||
| src under the proxy prefix | 0 | 693 |
|
||||
| in-viewport images decoded | 0 / 23 | 23 / 23 |
|
||||
| sample src | `/api/hero?slug=...` | `/webview/<cap>/api/hero?slug=...` |
|
||||
|
||||
(The dashboard marks thumbs `loading="lazy"`, so only in-viewport images are
|
||||
ever fetched. All 27 proxied image responses returned 200.)
|
||||
|
||||
---
|
||||
|
||||
## Follow-up (same day): the `/api` referer fallback, done safely
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Web Tabs (dashboards as Codeman tabs)
|
||||
|
||||
Open any dashboard you run, Grafana, Uptime Kuma, Portainer, a status page on port
|
||||
4000, as a tab beside your Claude/Codex/Gemini sessions. Codeman becomes one mission
|
||||
control instead of Codeman plus a pile of browser tabs.
|
||||
|
||||
## Using it
|
||||
|
||||
1. Click the chevron next to **Run** to expand the dropdown.
|
||||
2. Under **Web / URL**, pick **Add dashboard...**
|
||||
3. Give it a name and a URL, optionally hit **Test**, then **Save**.
|
||||
|
||||
The dashboard opens as a tab immediately, and appears in the Run dropdown from then
|
||||
on. Web tabs sit in the same strip as session tabs, continue the same `Alt+1..9`
|
||||
numbering, and carry a globe icon so they never read as a running agent.
|
||||
|
||||
Closing a tab (the `x`) only closes it. The saved dashboard stays in the dropdown.
|
||||
To delete it for good, use the `x` on its **dropdown row** (the tab's own `x` is
|
||||
close, not delete). Each dropdown row also has a gear for editing, so a saved URL
|
||||
can be changed or removed without opening it first.
|
||||
|
||||
Switching tabs does **not** reload a dashboard. Frames stay alive in the background,
|
||||
so a dashboard that took a while to authenticate is still there when you come back.
|
||||
Past six live frames the least-recently-viewed one is dropped to bound memory
|
||||
(`CODEMAN_MAX_LIVE_WEBVIEW_FRAMES`).
|
||||
|
||||
## Why dashboards are proxied
|
||||
|
||||
A plain `<iframe src="http://your-box:4000">` does not work in the setup Codeman
|
||||
actually ships in, for three separate reasons:
|
||||
|
||||
| Blocker | What happens |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| **Mixed content** | Production serves HTTPS (behind `tailscale serve`). Browsers hard-block `http://` iframes on an HTTPS page, with no override, and none at all on iOS Safari. |
|
||||
| **Framing refusal** | Grafana, Portainer, Home Assistant and many others send `X-Frame-Options: DENY` or `frame-ancestors 'none'`. |
|
||||
| **Codeman's CSP** | `default-src 'self'` means `frame-src` falls back to `'self'`, so a cross-origin iframe is blocked before it starts. |
|
||||
|
||||
Serving the dashboard **through Codeman's own origin** dissolves all three. So by
|
||||
default a web tab loads `/webview/<capability>/` on Codeman, and Codeman relays to
|
||||
the dashboard: stripping the framing refusal, rewriting redirects, cookies and
|
||||
root-absolute URLs, and relaying WebSockets so live panels actually update.
|
||||
|
||||
A useful consequence: the dashboard is fetched **from the Codeman server**, so a
|
||||
tailnet-only or `localhost`-only dashboard works from any device that can reach
|
||||
Codeman, including a phone that is not on the tailnet.
|
||||
|
||||
`direct` mode (a plain cross-origin iframe) still exists and is cheaper, but it only
|
||||
works for an HTTPS dashboard that permits framing. The **Test** button probes from
|
||||
the server and tells you which mode applies.
|
||||
|
||||
## The sandbox, and when to turn it off
|
||||
|
||||
Because a proxied dashboard is served from Codeman's own address, it is
|
||||
*same-origin with Codeman* as far as the browser is concerned. Left unchecked, its
|
||||
JavaScript could read the Codeman page and call the API that spawns agents.
|
||||
|
||||
So the iframe is sandboxed **without** `allow-same-origin` by default. The page runs
|
||||
in an opaque origin: it cannot touch Codeman, and it gets no cookies or
|
||||
`localStorage` of its own.
|
||||
|
||||
Unchecking **Open sandboxed** grants `allow-same-origin`. Do that only for a
|
||||
dashboard you fully trust, and only if you need it, which in practice means a
|
||||
dashboard with its own login that stores a session in a cookie or `localStorage`.
|
||||
|
||||
Even in trusted mode, Codeman never forwards its own credentials upstream: the
|
||||
`Authorization` header and the `codeman_session` cookie are stripped on the way out,
|
||||
so `CODEMAN_PASSWORD` cannot leak into a dashboard.
|
||||
|
||||
## How the proxy authenticates
|
||||
|
||||
A sandboxed iframe is opaque-origin, so every request it makes is cross-site: the
|
||||
`SameSite=lax` session cookie is not sent, and writes and WebSocket upgrades arrive
|
||||
with `Origin: null`. Cookie auth cannot work.
|
||||
|
||||
Instead, opening a dashboard mints a **capability**: 192 bits of entropy in the URL
|
||||
path, held in memory only, with a rolling 12-hour TTL, bound to the user who minted
|
||||
it, and granting exactly one thing, relaying bytes to that one saved URL. Editing or
|
||||
deleting a dashboard revokes it, and a server restart invalidates every outstanding
|
||||
capability (tabs re-mint transparently on next click).
|
||||
|
||||
## Limits and env vars
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| ------------------------------------ | ------- | ------------------------------------------ |
|
||||
| `CODEMAN_MAX_WEBVIEWS` | 50 | Saved dashboards per owner |
|
||||
| `CODEMAN_MAX_LIVE_WEBVIEW_FRAMES` | 6 | Iframes kept mounted at once |
|
||||
| `CODEMAN_WEBVIEW_CAPABILITY_TTL_MS` | 12h | Rolling capability lifetime |
|
||||
| `CODEMAN_WEBVIEW_TIMEOUT_MS` | 30000 | Upstream request timeout |
|
||||
| `CODEMAN_WEBVIEW_PROBE_TIMEOUT_MS` | 8000 | Timeout for the Test button |
|
||||
| `CODEMAN_MAX_WEBVIEW_HTML_BYTES` | 8MB | Largest HTML document rewritten |
|
||||
| `CODEMAN_MAX_WEBVIEW_SOCKETS` | 8 | Concurrent proxied WebSockets per dashboard |
|
||||
|
||||
Saved dashboards live in `~/.codeman/webviews.json`. Which tabs you have open is
|
||||
per-device (`localStorage`), since that is workspace layout rather than config.
|
||||
|
||||
## How a dashboard's own API calls keep working
|
||||
|
||||
Worth knowing, because it is where this feature does its least obvious work. Three
|
||||
layers cooperate so a dashboard talking to its own backend just works:
|
||||
|
||||
1. `<base href>` handles relative URLs in the markup.
|
||||
2. Attribute rewriting handles root-absolute `src`/`href`/`action` in the page the
|
||||
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">'`, `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
|
||||
browser treats every one of its `fetch`/XHR calls as cross-origin even though the
|
||||
URL is on Codeman itself. Without those headers, a dashboard renders perfectly and
|
||||
then every API call fails, which looks like the dashboard being broken.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **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
|
||||
`location.href = 'login'` is fine (`<base>` covers it).
|
||||
- **Cross-origin redirects are not followed.** If a dashboard bounces to a different
|
||||
host (an external SSO provider, say), the proxy hands the redirect back unchanged
|
||||
rather than relaying it, because relaying would make this an open proxy. Use
|
||||
**Open in new tab** for those.
|
||||
- **Login-protected dashboards need trusted mode**, since a sandboxed frame has no
|
||||
cookie jar. A server-side per-dashboard cookie jar would lift this and is the
|
||||
natural next step if it becomes annoying.
|
||||
- **Not a security boundary.** The proxy reaches whatever the Codeman server can
|
||||
reach. That is not an escalation for someone who already commands
|
||||
`--dangerously-skip-permissions` agents, but in multi-user mode it does mean a
|
||||
non-admin user's dashboard is fetched from the server's network position.
|
||||
|
||||
## Where the code lives
|
||||
|
||||
| Concern | File |
|
||||
| ------------------------ | --------------------------------------- |
|
||||
| Pure rewrite helpers | `src/web/webview-proxy.ts` |
|
||||
| Routes + proxy + sockets | `src/web/routes/webview-routes.ts` |
|
||||
| Capability tokens | `src/webview-capabilities.ts` |
|
||||
| Persistence | `src/webview-store.ts` |
|
||||
| Limits | `src/config/webview-limits.ts` |
|
||||
| Frontend | `src/web/public/webview-tabs.js` |
|
||||
| Auth exemption | `src/web/middleware/auth.ts` |
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.8.1",
|
||||
"version": "1.9.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.8.1",
|
||||
"version": "1.9.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -34,6 +34,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"bin": {
|
||||
@@ -12332,7 +12333,7 @@
|
||||
}
|
||||
},
|
||||
"packages/xterm-zerolag-input": {
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.7",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"jsdom": "^24.1.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.8.1",
|
||||
"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",
|
||||
@@ -48,17 +48,28 @@
|
||||
"packages/*"
|
||||
],
|
||||
"keywords": [
|
||||
"claude",
|
||||
"claude-code",
|
||||
"claude-ai",
|
||||
"claude",
|
||||
"anthropic",
|
||||
"ai-agent",
|
||||
"automation",
|
||||
"opencode",
|
||||
"codex",
|
||||
"gemini-cli",
|
||||
"ai-agents",
|
||||
"agent",
|
||||
"session-manager",
|
||||
"self-hosted",
|
||||
"developer-tools",
|
||||
"tmux",
|
||||
"terminal",
|
||||
"xterm",
|
||||
"docker",
|
||||
"mosh",
|
||||
"local-echo",
|
||||
"web-dashboard",
|
||||
"cli",
|
||||
"llm",
|
||||
"autonomous-agent",
|
||||
"ralph-loop"
|
||||
"automation"
|
||||
],
|
||||
"author": "arkon",
|
||||
"license": "MIT",
|
||||
@@ -83,6 +94,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Limits and timeouts for web tabs (dashboards embedded as Codeman tabs).
|
||||
*
|
||||
* Every value here bounds something an untrusted-ish upstream controls: how many
|
||||
* dashboards can be saved, how long the server will wait on one, how much of a
|
||||
* response it will buffer before rewriting HTML, and how many sockets a single
|
||||
* dashboard may hold open. Env-overridable in the same style as the other config
|
||||
* modules.
|
||||
*/
|
||||
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const parsed = parseInt(process.env[name] || '', 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** Max saved webviews (per owner in multi-user mode). */
|
||||
export const MAX_WEBVIEWS = envInt('CODEMAN_MAX_WEBVIEWS', 50);
|
||||
|
||||
/**
|
||||
* Max iframes kept mounted at once. Switching tabs must not reload a dashboard,
|
||||
* so frames stay alive while hidden; past this many, the least-recently-viewed
|
||||
* frame is evicted. Consumed by the frontend via `GET /api/webviews`.
|
||||
*/
|
||||
export const MAX_LIVE_WEBVIEW_FRAMES = envInt('CODEMAN_MAX_LIVE_WEBVIEW_FRAMES', 6);
|
||||
|
||||
/** How long a minted proxy capability stays valid (rolling, refreshed on use). */
|
||||
export const WEBVIEW_CAPABILITY_TTL_MS = envInt('CODEMAN_WEBVIEW_CAPABILITY_TTL_MS', 12 * 60 * 60 * 1000);
|
||||
|
||||
/** Max concurrent capabilities held in memory before the oldest are dropped. */
|
||||
export const MAX_WEBVIEW_CAPABILITIES = 200;
|
||||
|
||||
/** Upstream request timeout for a proxied HTTP request. */
|
||||
export const WEBVIEW_UPSTREAM_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_TIMEOUT_MS', 30_000);
|
||||
|
||||
/** Shorter timeout for the editor's "Test" probe, which a human is waiting on. */
|
||||
export const WEBVIEW_PROBE_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_PROBE_TIMEOUT_MS', 8_000);
|
||||
|
||||
/**
|
||||
* Max bytes of an HTML response buffered for `<base>` injection and link
|
||||
* rewriting. Larger HTML documents stream through untouched: the rewrite is a
|
||||
* convenience, and buffering an unbounded upstream body is a memory hazard.
|
||||
*/
|
||||
export const MAX_WEBVIEW_HTML_REWRITE_BYTES = envInt('CODEMAN_MAX_WEBVIEW_HTML_BYTES', 8 * 1024 * 1024);
|
||||
|
||||
/** Max concurrent proxied WebSockets per webview (mirrors MAX_WS_PER_SESSION). */
|
||||
export const MAX_WEBVIEW_SOCKETS = envInt('CODEMAN_MAX_WEBVIEW_SOCKETS', 8);
|
||||
|
||||
/** URL path prefix the proxy is mounted at. Single source of truth. */
|
||||
export const WEBVIEW_PROXY_PREFIX = '/webview';
|
||||
@@ -9,6 +9,7 @@
|
||||
* - CleanupRegistration / CleanupResourceType — entries for the centralized CleanupManager
|
||||
* - NiceConfig / DEFAULT_NICE_CONFIG — process priority settings for `nice`/`ionice`
|
||||
* - ProcessStats — memory/CPU/child-count snapshot for resource monitoring
|
||||
* - FilesystemBrowseData — bounded path-picker directory listing returned to the web UI
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -68,6 +69,34 @@ export interface ProcessStats {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** A selectable entry returned by the filesystem path-picker API. */
|
||||
export type FilesystemPreviewKind = 'image' | 'text' | 'document';
|
||||
|
||||
export interface FilesystemBrowseEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
size?: number;
|
||||
symlink?: boolean;
|
||||
previewKind?: FilesystemPreviewKind;
|
||||
}
|
||||
|
||||
/** A named root the path picker may browse without escaping its allowlist. */
|
||||
export interface FilesystemBrowseRoot {
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Response payload for `GET /api/filesystem/browse`. */
|
||||
export interface FilesystemBrowseData {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
root: string;
|
||||
roots: FilesystemBrowseRoot[];
|
||||
entries: FilesystemBrowseEntry[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,3 +70,4 @@ export * from './update.js';
|
||||
export * from './workflow-run.js';
|
||||
export * from './search.js';
|
||||
export * from './user.js';
|
||||
export * from './webview.js';
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @fileoverview Web tab (dashboard) types.
|
||||
*
|
||||
* A "webview" is a saved URL that Codeman renders as a tab alongside agent
|
||||
* sessions: Grafana on :3000, a Uptime-Kuma on :4000, an internal status page.
|
||||
* It is deliberately NOT a sixth `SessionMode`, it has no PTY, no tmux, no
|
||||
* respawn and no idle detection. Same reasoning that keeps Docker and remote-SSH
|
||||
* as case overlays rather than modes.
|
||||
*
|
||||
* Key exports:
|
||||
* - Webview, the persisted record (`~/.codeman/webviews.json`).
|
||||
* - WebviewEmbedMode, 'proxy' (served through Codeman's origin) or 'direct'
|
||||
* (a plain cross-origin iframe, only viable for HTTPS targets that allow framing).
|
||||
* - WebviewProbe, the result of the server-side reachability/framing probe.
|
||||
* - WebviewOpenData, what `POST /api/webviews/:id/open` hands the browser.
|
||||
*
|
||||
* No I/O here. Persistence lives in `src/webview-store.ts`, capability minting in
|
||||
* `src/webview-capabilities.ts`, the proxy helpers in `src/web/webview-proxy.ts`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How the browser should embed a webview.
|
||||
*
|
||||
* - `proxy`: the iframe points at `/webview/<capability>/` on Codeman's own
|
||||
* origin and the server relays to the target. Required whenever the target is
|
||||
* plain HTTP (an HTTPS Codeman page cannot embed it: mixed content) or refuses
|
||||
* framing via `X-Frame-Options` / `frame-ancestors`.
|
||||
* - `direct`: the iframe points at the target URL itself. Cheaper, but only works
|
||||
* for HTTPS targets that permit framing, and needs the target origin added to
|
||||
* the page CSP's `frame-src`.
|
||||
*/
|
||||
export type WebviewEmbedMode = 'proxy' | 'direct';
|
||||
|
||||
/** A saved dashboard, persisted to `~/.codeman/webviews.json`. */
|
||||
export interface Webview {
|
||||
id: string;
|
||||
/** Display name shown on the tab. */
|
||||
name: string;
|
||||
/** Absolute target URL. `http:` / `https:` only, never with embedded credentials. */
|
||||
url: string;
|
||||
/** Optional single-glyph tab icon (emoji or letter). */
|
||||
icon?: string;
|
||||
/** Default embed strategy for this dashboard. */
|
||||
embedMode: WebviewEmbedMode;
|
||||
/**
|
||||
* When false (the default) the iframe is sandboxed WITHOUT `allow-same-origin`,
|
||||
* so a proxied page runs in an opaque origin and cannot read the Codeman page or
|
||||
* call its API. Setting this to true trades that isolation for the page's own
|
||||
* cookies/localStorage, only for dashboards the user fully trusts.
|
||||
*/
|
||||
trusted: boolean;
|
||||
/** Multi-user owner (username). Undefined in single-user mode. */
|
||||
owner?: string;
|
||||
createdAt: number;
|
||||
lastOpenedAt?: number;
|
||||
}
|
||||
|
||||
/** Result of the server-side probe used by the "Test" button in the editor. */
|
||||
export interface WebviewProbe {
|
||||
/** True when the server could complete an HTTP request to the target. */
|
||||
reachable: boolean;
|
||||
/** Upstream status code, when a response came back. */
|
||||
status?: number;
|
||||
/** Raw `X-Frame-Options` value, if the target sent one. */
|
||||
xFrameOptions?: string;
|
||||
/** The `frame-ancestors` directive extracted from the target's CSP, if any. */
|
||||
frameAncestors?: string;
|
||||
/** True when the target permits being framed cross-origin by this Codeman. */
|
||||
framable: boolean;
|
||||
/** Strategy the UI should default to for this URL. */
|
||||
recommendedMode: WebviewEmbedMode;
|
||||
/** Human-readable explanation of the recommendation (or the failure). */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Payload of `POST /api/webviews/:id/open`. */
|
||||
export interface WebviewOpenData {
|
||||
/** The webview being opened (echoed so the client can refresh its copy). */
|
||||
webview: Webview;
|
||||
/**
|
||||
* Same-origin path the iframe should load. Present for `proxy` mode only;
|
||||
* `direct` mode uses `webview.url` instead.
|
||||
*/
|
||||
embedUrl?: string;
|
||||
/** Epoch ms at which the capability behind `embedUrl` stops working. */
|
||||
expiresAt?: number;
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { capabilityFromProxyPath, capabilityFromReferer } from '../webview-proxy.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
|
||||
|
||||
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
|
||||
@@ -120,6 +122,85 @@ function isPasswordChangeExempt(req: FastifyRequest): boolean {
|
||||
return !url.startsWith('/api/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request carries a VALID web-tab proxy capability.
|
||||
*
|
||||
* Requests under `/webview/<cap>/` cannot authenticate the normal way. The iframe
|
||||
* rendering a dashboard is sandboxed without `allow-same-origin`, so it runs in an
|
||||
* opaque origin: every request it makes is cross-site, meaning the `SameSite=lax`
|
||||
* `codeman_session` cookie is never attached, and non-GET requests and WebSocket
|
||||
* upgrades arrive with `Origin: null`. Both the cookie check and the CSRF Origin
|
||||
* guard would therefore reject a perfectly legitimate dashboard asset load.
|
||||
*
|
||||
* The capability in the path is the credential instead: 192 bits of entropy, held
|
||||
* in memory only (a restart invalidates it), rolling TTL, bound to the user who
|
||||
* minted it through an already-authenticated `POST /api/webviews/:id/open`, and
|
||||
* granting nothing but "relay bytes to this one saved URL".
|
||||
*
|
||||
* The exemption is deliberately narrow: it requires the capability to RESOLVE, so
|
||||
* a bare `/webview/anything` reaches nothing, and a `/webviewfoo` path does not
|
||||
* match the prefix at all. The Host allowlist is NOT bypassed, so DNS-rebinding
|
||||
* protection still applies to these requests.
|
||||
*/
|
||||
function hasValidWebviewCapability(req: FastifyRequest): boolean {
|
||||
const url = (req.url ?? '').split('?')[0];
|
||||
|
||||
const fromPath = capabilityFromProxyPath(url);
|
||||
if (fromPath) return webviewCapabilities.resolve(fromPath) !== undefined;
|
||||
|
||||
// Referer form: a dashboard subresource requested with a ROOT-ABSOLUTE URL, which
|
||||
// lands on Codeman's root and is relayed by the 404 fallback. Without this the
|
||||
// asset would be rejected here, before the fallback ever runs.
|
||||
//
|
||||
// This is the only exemption decided by a header the request itself supplies, so
|
||||
// it is fenced in hard: safe methods only, and never for Codeman's own functional
|
||||
// surfaces. Without those fences a page could present a webview Referer and skip
|
||||
// auth on /api. It is not a privilege escalation even so, holding a live
|
||||
// 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('/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.
|
||||
@@ -211,6 +292,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
return;
|
||||
}
|
||||
|
||||
// Web-tab proxy, authenticated by the capability in the path, not the cookie.
|
||||
if (hasValidWebviewCapability(req)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Check session cookie first (avoids re-sending credentials on every request)
|
||||
@@ -341,6 +428,12 @@ function registerMultiUserAuthHook(
|
||||
// QR redemption path — handled by the route itself.
|
||||
if (req.url?.startsWith('/q/')) return;
|
||||
|
||||
// Web-tab proxy, authenticated by the capability in the path, not the cookie.
|
||||
// `req.authUser` stays undefined here on purpose: the proxy handler enforces
|
||||
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
|
||||
// than re-deriving it from a request that carries no credentials.
|
||||
if (hasValidWebviewCapability(req)) return;
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// 1. Cookie session (carries identity + mustChangePassword snapshot).
|
||||
@@ -466,7 +559,17 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
|
||||
reply.code(403).send('Forbidden: host not allowed');
|
||||
return;
|
||||
}
|
||||
if (!SAFE_HTTP_METHODS.has(req.method) && !isAllowedRequestOrigin(req.headers.origin, policy)) {
|
||||
// The Host allowlist above is NEVER bypassed. The Origin (CSRF) check is,
|
||||
// but only for a request carrying a valid web-tab capability: a sandboxed
|
||||
// dashboard is opaque-origin, so its form posts and uploads arrive with
|
||||
// `Origin: null`, which this guard rejects by design. The capability is the
|
||||
// credential in that case, and it is unguessable, see
|
||||
// hasValidWebviewCapability.
|
||||
if (
|
||||
!SAFE_HTTP_METHODS.has(req.method) &&
|
||||
!isAllowedRequestOrigin(req.headers.origin, policy) &&
|
||||
!hasValidWebviewCapability(req)
|
||||
) {
|
||||
reply.code(403).send('Forbidden: cross-site request blocked');
|
||||
return;
|
||||
}
|
||||
@@ -521,8 +624,17 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean): v
|
||||
}
|
||||
}
|
||||
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
// Handle CORS preflight.
|
||||
//
|
||||
// EXCEPT for the web-tab proxy, which must answer its own preflight. A
|
||||
// sandboxed dashboard iframe is opaque-origin, so it sends `Origin: null`;
|
||||
// the CORS block above only emits headers for localhost origins, so a bare
|
||||
// 204 from here carries no `Access-Control-Allow-Origin` and the browser
|
||||
// rejects the preflight. Every dashboard fetch then fails with an opaque
|
||||
// net::ERR_FAILED while the page itself renders fine (script/css/img loads
|
||||
// are not CORS-checked). Falling through lets the proxy route reply with the
|
||||
// right headers.
|
||||
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req)) {
|
||||
reply.code(204).send();
|
||||
done();
|
||||
return;
|
||||
|
||||
@@ -294,6 +294,9 @@ const _SSE_HANDLER_MAP = [
|
||||
|
||||
// Session order (global tab order sync, COD-131)
|
||||
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
[SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'],
|
||||
];
|
||||
|
||||
|
||||
@@ -821,6 +824,7 @@ class CodemanApp {
|
||||
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null).catch(() => null);
|
||||
this.loadQuickStartCases(null, settingsPromise);
|
||||
this._initRunMode();
|
||||
this.initWebviews?.();
|
||||
this.setupEventListeners();
|
||||
// Mobile: ensure button taps register even when keyboard is visible.
|
||||
// On mobile, tapping a button while the soft keyboard is up causes the
|
||||
@@ -1007,9 +1011,18 @@ class CodemanApp {
|
||||
const digitMatch = code.match(/^Digit([1-9])$/);
|
||||
if (digitMatch) {
|
||||
const idx = parseInt(digitMatch[1], 10) - 1;
|
||||
// Sessions occupy 1..N and web tabs continue from N+1, matching the
|
||||
// numbers actually painted on the tabs.
|
||||
if (idx < this.sessionOrder.length) {
|
||||
e.preventDefault();
|
||||
this.selectSession(this.sessionOrder[idx]);
|
||||
} else {
|
||||
const webIdx = idx - this.sessionOrder.length;
|
||||
const webId = (this.webviewOrder || [])[webIdx];
|
||||
if (webId) {
|
||||
e.preventDefault();
|
||||
this.openWebview(webId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3272,9 +3285,21 @@ class CodemanApp {
|
||||
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
|
||||
const currentIds = new Set(this.sessions.keys());
|
||||
|
||||
// Check if we can do incremental update (same session IDs)
|
||||
// Web tabs live in the same strip but are not in this.sessions, so they need
|
||||
// their own change check. Without it, the session-only comparison below is
|
||||
// vacuously "unchanged" whenever session count is stable — most visibly with
|
||||
// ZERO sessions (0 === 0), where opening a dashboard would never draw its tab.
|
||||
const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map(
|
||||
t => t.dataset.webviewId
|
||||
);
|
||||
const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id));
|
||||
const webTabsUnchanged =
|
||||
existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]);
|
||||
|
||||
// Check if we can do incremental update (same session IDs and same web tabs)
|
||||
const canIncremental = existingIds.size === currentIds.size &&
|
||||
[...existingIds].every(id => currentIds.has(id));
|
||||
[...existingIds].every(id => currentIds.has(id)) &&
|
||||
webTabsUnchanged;
|
||||
|
||||
if (canIncremental) {
|
||||
// Incremental update - only modify changed properties
|
||||
@@ -3282,7 +3307,12 @@ class CodemanApp {
|
||||
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||
if (!tab) continue;
|
||||
|
||||
const isActive = id === this.activeSessionId;
|
||||
// A web tab owns the active state while one is open. activeSessionId stays
|
||||
// set (the terminal keeps streaming underneath, and switching back is
|
||||
// instant): only the highlight moves. Without this the debounced render
|
||||
// re-marks the session tab active moments after a web tab was selected,
|
||||
// leaving two tabs lit at once.
|
||||
const isActive = id === this.activeSessionId && !this.activeWebviewId;
|
||||
const status = session.status || 'idle';
|
||||
const name = this.getSessionName(session);
|
||||
const taskStats = session.taskStats || { running: 0, total: 0 };
|
||||
@@ -3469,7 +3499,9 @@ class CodemanApp {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) continue; // Skip if session was removed
|
||||
|
||||
const isActive = id === this.activeSessionId;
|
||||
// See the note in the incremental path: a web tab owns the active highlight
|
||||
// while one is open, even though activeSessionId stays set.
|
||||
const isActive = id === this.activeSessionId && !this.activeWebviewId;
|
||||
const status = session.status || 'idle';
|
||||
const name = this.getSessionName(session);
|
||||
const mode = session.mode || 'claude';
|
||||
@@ -3516,6 +3548,11 @@ class CodemanApp {
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
// Web tabs (dashboard URLs) render after the session tabs, continuing the
|
||||
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
|
||||
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
|
||||
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
|
||||
// Set up drag-and-drop handlers for tab reordering
|
||||
@@ -4050,6 +4087,9 @@ class CodemanApp {
|
||||
return; // newer tab switch won
|
||||
}
|
||||
|
||||
// A session tab takes the stage back from any active web tab.
|
||||
this._hideWebviewLayer?.();
|
||||
|
||||
this._cleanupPreviousSession(sessionId);
|
||||
this.activeSessionId = sessionId;
|
||||
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
|
||||
|
||||
@@ -494,6 +494,9 @@ const SSE_EVENTS = {
|
||||
|
||||
// Session order (global tab order sync)
|
||||
SESSION_ORDER_CHANGED: 'session:orderChanged',
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
WEBVIEW_CHANGED: 'webview:changed',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -45,20 +45,20 @@
|
||||
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
|
||||
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
|
||||
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
|
||||
<script>try{var s=localStorage.getItem('codeman:skin');if(s!=='og'&&s!=='daylight-green'&&s!=='daylight-blue')s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
|
||||
<script>try{var s=localStorage.getItem('codeman:skin'),a=['og','daylight-green','daylight-blue','paper-gray','solarized-light','catppuccin-latte','rose-pine-dawn'];if(a.indexOf(s)<0)s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
|
||||
<!-- Apply the saved per-device language before first paint. The full translation
|
||||
layer loads below; setting lang/dir here prevents an English accessibility
|
||||
tree from flashing while the deferred scripts start. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var l=JSON.parse(localStorage.getItem(k)||'{}').language;l=l==='zh-CN'?'zh-CN':'en';document.documentElement.lang=l;window.__codemanLanguage=l;}catch(e){document.documentElement.lang='en';window.__codemanLanguage='en';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#11151c}
|
||||
.skeleton-header{height:40px;background:rgba(31,38,48,0.85);border-bottom:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;padding:0 12px}
|
||||
.skeleton-brand{color:#38b6f0;font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
.skeleton-header{height:40px;background:var(--glass-bg,rgba(31,38,48,0.85));border-bottom:1px solid var(--glass-border,rgba(255,255,255,0.08));display:flex;align-items:center;padding:0 12px}
|
||||
.skeleton-brand{color:var(--accent,#38b6f0);font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
|
||||
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
|
||||
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
|
||||
.skeleton-terminal{flex:1;background:#161b23}
|
||||
.skeleton-toolbar{height:42px;background:rgba(31,38,48,0.85);border-top:1px solid rgba(255,255,255,0.08)}
|
||||
.skeleton-tab{width:80px;height:24px;background:var(--control-bg,rgba(255,255,255,0.04));border-radius:6px}
|
||||
.skeleton-terminal{flex:1;background:var(--term-bg,#161b23)}
|
||||
.skeleton-toolbar{height:42px;background:var(--glass-bg,rgba(31,38,48,0.85));border-top:1px solid var(--glass-border,rgba(255,255,255,0.08))}
|
||||
.app-loaded .loading-skeleton{display:none}
|
||||
</style>
|
||||
</head>
|
||||
@@ -300,6 +300,11 @@
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Web tab layer: one iframe per open dashboard, shown in place of the
|
||||
terminal while a web tab is active. Frames stay mounted while hidden so
|
||||
switching tabs does not reload (and re-authenticate) a dashboard. -->
|
||||
<div class="webview-layer" id="webviewLayer"></div>
|
||||
|
||||
<!-- Welcome Overlay (shown when no session active) -->
|
||||
<div class="welcome-overlay" id="welcomeOverlay">
|
||||
<div class="welcome-content">
|
||||
@@ -464,6 +469,14 @@
|
||||
<span class="run-mode-dot shell"></span>Terminal / Shell
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<!-- Web tabs: dashboards open as tabs beside agent sessions. These do NOT
|
||||
set runMode: the Run button always means "start an agent". -->
|
||||
<div class="run-mode-header">Web / URL</div>
|
||||
<div class="run-mode-webviews" id="runModeWebviews"></div>
|
||||
<button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()">
|
||||
<span class="run-mode-dot web"></span>Add URL…
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<div class="run-mode-header">Recent Sessions</div>
|
||||
<div class="run-mode-history" id="runModeHistory"></div>
|
||||
</div>
|
||||
@@ -639,6 +652,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Web Tab (dashboard URL) editor -->
|
||||
<div class="modal" id="webviewModal">
|
||||
<div class="modal-backdrop" onclick="app.closeWebviewModal()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="webviewModalTitle">Add URL</h3>
|
||||
<button class="modal-close" onclick="app.closeWebviewModal()" aria-label="Close URL editor">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<label for="webviewName">Name</label>
|
||||
<input type="text" id="webviewName" placeholder="Grafana" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="webviewUrl">URL</label>
|
||||
<input type="text" id="webviewUrl" placeholder="http://100.70.56.18:4000/" autocomplete="off"
|
||||
autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">
|
||||
Reached from the Codeman server, so a tailnet or localhost address works even when
|
||||
this browser cannot see it. Plain HTTP is fine: the dashboard is proxied through
|
||||
Codeman, which is also what gets past dashboards that refuse to be embedded.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="webviewIcon">Icon</label>
|
||||
<!-- Click to pick; the field stays editable so any emoji still works. -->
|
||||
<div class="webview-icon-picker" id="webviewIconPicker" role="group" aria-label="Choose an icon"></div>
|
||||
<input type="text" id="webviewIcon" placeholder="Or paste any emoji" maxlength="8" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="webviewSandboxed" checked> Open sandboxed</label>
|
||||
<span class="form-hint">
|
||||
Recommended. A proxied dashboard is served from Codeman's own address, so unchecking
|
||||
this lets its JavaScript read this page and call the API that starts agents. Uncheck
|
||||
only for a dashboard you fully trust, or one whose own login needs cookies.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<button class="btn-secondary" onclick="app.testWebviewUrl()">Test</button>
|
||||
<span class="form-hint webview-probe-result" id="webviewProbeResult"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions webview-modal-actions">
|
||||
<button class="btn-danger" id="webviewDeleteBtn" onclick="app.deleteWebview()">Delete</button>
|
||||
<button class="btn-secondary" onclick="app.closeWebviewModal()">Cancel</button>
|
||||
<button class="btn-primary" onclick="app.saveWebview()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron Jobs Modal -->
|
||||
<div class="modal" id="cronModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCron()"></div>
|
||||
@@ -1200,9 +1263,17 @@
|
||||
<div class="settings-item settings-item-skin" title="Visual theme for this device (not synced)">
|
||||
<span class="settings-item-label">Skin</span>
|
||||
<select id="appSettingsSkin" class="form-select">
|
||||
<option value="daylight-blue">Daylight Blue</option>
|
||||
<option value="daylight-green">Daylight Green</option>
|
||||
<option value="og">OG Codeman</option>
|
||||
<optgroup label="Light">
|
||||
<option value="paper-gray">Paper Gray</option>
|
||||
<option value="solarized-light">Solarized Light</option>
|
||||
<option value="catppuccin-latte">Catppuccin Latte</option>
|
||||
<option value="rose-pine-dawn">Rosé Pine Dawn</option>
|
||||
</optgroup>
|
||||
<optgroup label="Dark">
|
||||
<option value="daylight-blue">Daylight Blue</option>
|
||||
<option value="daylight-green">Daylight Green</option>
|
||||
<option value="og">OG Codeman</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-item" id="appSettingsWebglRendererItem" title="Use the GPU-accelerated WebGL terminal renderer (desktop only). Turn off to force the DOM renderer if you hit GPU glitches. Codeman also auto-falls-back to the DOM renderer after repeated GPU stalls.">
|
||||
@@ -1972,8 +2043,11 @@
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Folder Path</label>
|
||||
<input type="text" id="linkCasePath" placeholder="/home/user/projects/my-project" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="linkCasePath" placeholder="/mnt/d/AI/my-project" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openLinkCasePathPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint">Choose an existing folder from this computer or enter its absolute path</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Remote Tab -->
|
||||
@@ -2551,6 +2625,7 @@
|
||||
<script defer src="ultracode-panel.js"></script>
|
||||
<script defer src="admin-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
<script defer src="subagent-windows.js"></script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @fileoverview Mobile keyboard accessory bar and modal focus trap.
|
||||
*
|
||||
* Defines two exports:
|
||||
* Defines three exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
|
||||
@@ -10,12 +10,15 @@
|
||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
* - PathPicker (singleton object) — Lazy server-side file/folder browser shared
|
||||
* by Link Existing and the extended mobile keyboard bar.
|
||||
*
|
||||
* - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element.
|
||||
* Saves and restores previously focused element on deactivate. Used by Ralph wizard
|
||||
* and other modal dialogs.
|
||||
*
|
||||
* @globals {object} KeyboardAccessoryBar
|
||||
* @globals {object} PathPicker
|
||||
* @globals {class} FocusTrap
|
||||
*
|
||||
* @dependency mobile-handlers.js (MobileDetection.isTouchDevice)
|
||||
@@ -26,6 +29,338 @@
|
||||
// Codeman — Keyboard accessory bar and focus trap for modals
|
||||
// Loaded after mobile-handlers.js, before app.js
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Shared Filesystem Path Picker
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const PathPicker = {
|
||||
overlay: null,
|
||||
_options: null,
|
||||
_selectedPath: '',
|
||||
_previousFocus: null,
|
||||
_keydownHandler: null,
|
||||
_loadSequence: 0,
|
||||
_previewOverlay: null,
|
||||
_previewRequestSequence: 0,
|
||||
_previewPreviousFocus: null,
|
||||
|
||||
/**
|
||||
* Open the lazy filesystem browser.
|
||||
* @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean,
|
||||
* title?: string, onSelect: (path: string) => void}} options
|
||||
*/
|
||||
open(options) {
|
||||
this.close(false);
|
||||
this._options = options;
|
||||
this._selectedPath = '';
|
||||
this._previousFocus = document.activeElement;
|
||||
this._previousFocus?.blur?.();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'path-picker-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', options.title || 'Select a path');
|
||||
overlay.innerHTML = `
|
||||
<div class="path-picker-dialog">
|
||||
<div class="path-picker-header">
|
||||
<strong class="path-picker-title"></strong>
|
||||
<button type="button" class="path-picker-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="path-picker-roots-row">
|
||||
<label for="pathPickerRoot">Location</label>
|
||||
<select id="pathPickerRoot" class="path-picker-roots"></select>
|
||||
</div>
|
||||
<div class="path-picker-nav">
|
||||
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">↑</button>
|
||||
<div class="path-picker-current" title="Current folder"></div>
|
||||
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||
</div>
|
||||
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
||||
<div class="path-picker-list" role="listbox"></div>
|
||||
<div class="path-picker-selection">
|
||||
<span class="path-picker-selection-label">Selected</span>
|
||||
<span class="path-picker-selection-value">None</span>
|
||||
</div>
|
||||
<div class="path-picker-actions">
|
||||
<button type="button" class="path-picker-current-select">Select Current Folder</button>
|
||||
<span class="path-picker-action-spacer"></span>
|
||||
<button type="button" class="path-picker-cancel">Cancel</button>
|
||||
<button type="button" class="path-picker-confirm" disabled>Select</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
this.overlay = overlay;
|
||||
overlay.querySelector('.path-picker-title').textContent = options.title || 'Select a Path';
|
||||
overlay.querySelector('.path-picker-close').addEventListener('click', () => this.close(true));
|
||||
overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true));
|
||||
overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm());
|
||||
overlay.querySelector('.path-picker-current-select').addEventListener('click', () => {
|
||||
const current = overlay.querySelector('.path-picker-current').textContent;
|
||||
if (current) this.select(current);
|
||||
});
|
||||
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
|
||||
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
|
||||
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
|
||||
if (parent) this.load(parent);
|
||||
});
|
||||
overlay.querySelector('.path-picker-roots').addEventListener('change', (event) => this.load(event.target.value));
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) this.close(true);
|
||||
});
|
||||
this._keydownHandler = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (this._previewOverlay) this.closePreview(true);
|
||||
else this.close(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', this._keydownHandler);
|
||||
document.body.appendChild(overlay);
|
||||
this.load(options.initialPath || '');
|
||||
},
|
||||
|
||||
async load(path) {
|
||||
if (!this.overlay || !this._options) return;
|
||||
const loadSequence = ++this._loadSequence;
|
||||
const list = this.overlay.querySelector('.path-picker-list');
|
||||
const status = this.overlay.querySelector('.path-picker-status');
|
||||
list.replaceChildren();
|
||||
status.textContent = 'Loading...';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (path) params.set('path', path);
|
||||
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
|
||||
try {
|
||||
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder');
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
this.render(result.data);
|
||||
} catch (error) {
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
if (path) {
|
||||
this.load('');
|
||||
return;
|
||||
}
|
||||
status.textContent = error.message || 'Failed to browse this folder';
|
||||
status.classList.add('error');
|
||||
}
|
||||
},
|
||||
|
||||
render(data) {
|
||||
const rootSelect = this.overlay.querySelector('.path-picker-roots');
|
||||
rootSelect.replaceChildren();
|
||||
for (const root of data.roots) {
|
||||
const option = document.createElement('option');
|
||||
option.value = root.path;
|
||||
option.textContent = `${root.label} — ${root.path}`;
|
||||
option.selected = data.path === root.path || data.root === root.path;
|
||||
rootSelect.appendChild(option);
|
||||
}
|
||||
|
||||
this.overlay.querySelector('.path-picker-current').textContent = data.path;
|
||||
const up = this.overlay.querySelector('.path-picker-up');
|
||||
up.dataset.parent = data.parent || '';
|
||||
up.disabled = !data.parent;
|
||||
const status = this.overlay.querySelector('.path-picker-status');
|
||||
status.classList.remove('error');
|
||||
status.textContent = data.entries.length === 0
|
||||
? 'This folder is empty'
|
||||
: `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`;
|
||||
|
||||
const list = this.overlay.querySelector('.path-picker-list');
|
||||
list.replaceChildren();
|
||||
for (const entry of data.entries) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'path-picker-item';
|
||||
if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) {
|
||||
row.classList.add('not-selectable');
|
||||
}
|
||||
row.dataset.path = entry.path;
|
||||
row.dataset.type = entry.type;
|
||||
row.setAttribute('role', 'option');
|
||||
|
||||
const open = document.createElement('button');
|
||||
open.type = 'button';
|
||||
open.className = 'path-picker-item-main';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'path-picker-item-icon';
|
||||
icon.textContent = entry.type === 'directory' ? '\uD83D\uDCC1' : '\uD83D\uDCC4';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'path-picker-item-name';
|
||||
name.textContent = entry.name;
|
||||
open.append(icon, name);
|
||||
if (entry.symlink) {
|
||||
const link = document.createElement('span');
|
||||
link.className = 'path-picker-item-link';
|
||||
link.textContent = '\u2197';
|
||||
open.appendChild(link);
|
||||
}
|
||||
if (entry.type === 'directory') {
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'path-picker-item-chevron';
|
||||
chevron.textContent = '\u203A';
|
||||
open.appendChild(chevron);
|
||||
open.addEventListener('click', () => this.load(entry.path));
|
||||
} else if (entry.previewKind) {
|
||||
const preview = document.createElement('span');
|
||||
preview.className = 'path-picker-item-preview';
|
||||
preview.textContent = '\uD83D\uDC41';
|
||||
open.appendChild(preview);
|
||||
open.title = `Preview ${entry.name}`;
|
||||
open.setAttribute('aria-label', `Preview ${entry.name}`);
|
||||
open.addEventListener('click', () => this.openPreview(entry));
|
||||
} else if (!this._options.directoriesOnly) {
|
||||
open.addEventListener('click', () => this.select(entry.path));
|
||||
} else {
|
||||
open.disabled = true;
|
||||
}
|
||||
row.appendChild(open);
|
||||
|
||||
if (entry.type === 'directory' || !this._options.directoriesOnly) {
|
||||
const choose = document.createElement('button');
|
||||
choose.type = 'button';
|
||||
choose.className = 'path-picker-item-select';
|
||||
choose.textContent = 'Choose';
|
||||
choose.addEventListener('click', () => this.select(entry.path));
|
||||
row.appendChild(choose);
|
||||
}
|
||||
list.appendChild(row);
|
||||
}
|
||||
},
|
||||
|
||||
select(path) {
|
||||
if (!this.overlay) return;
|
||||
this._selectedPath = path;
|
||||
this.overlay.querySelector('.path-picker-selection-value').textContent = path;
|
||||
this.overlay.querySelector('.path-picker-confirm').disabled = false;
|
||||
this.overlay.querySelectorAll('.path-picker-item').forEach((row) => {
|
||||
const selected = row.dataset.path === path;
|
||||
row.classList.toggle('selected', selected);
|
||||
row.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
});
|
||||
},
|
||||
|
||||
openPreview(entry) {
|
||||
this.closePreview(false);
|
||||
this._previewPreviousFocus = document.activeElement;
|
||||
const requestSequence = ++this._previewRequestSequence;
|
||||
const params = new URLSearchParams({ path: entry.path });
|
||||
if (this._options?.sessionId) params.set('sessionId', this._options.sessionId);
|
||||
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'path-preview-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', `Preview ${entry.name}`);
|
||||
overlay.innerHTML = `
|
||||
<div class="path-preview-dialog">
|
||||
<div class="path-preview-header">
|
||||
<div class="path-preview-heading">
|
||||
<strong class="path-preview-title"></strong>
|
||||
<span class="path-preview-path"></span>
|
||||
</div>
|
||||
<a class="path-preview-open" target="_blank" rel="noopener noreferrer">Open</a>
|
||||
<button type="button" class="path-preview-close" aria-label="Close preview">×</button>
|
||||
</div>
|
||||
<div class="path-preview-body"><div class="path-preview-loading">Loading preview...</div></div>
|
||||
</div>`;
|
||||
overlay.querySelector('.path-preview-title').textContent = entry.name;
|
||||
overlay.querySelector('.path-preview-path').textContent = entry.path;
|
||||
overlay.querySelector('.path-preview-open').href = previewUrl;
|
||||
overlay.querySelector('.path-preview-close').addEventListener('click', () => this.closePreview(true));
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) this.closePreview(true);
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
this._previewOverlay = overlay;
|
||||
|
||||
const body = overlay.querySelector('.path-preview-body');
|
||||
if (entry.previewKind === 'image') {
|
||||
const image = document.createElement('img');
|
||||
image.className = 'path-preview-image';
|
||||
image.alt = entry.name;
|
||||
image.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
|
||||
image.addEventListener('error', () => this.showPreviewError('Image preview failed to load'));
|
||||
image.src = previewUrl;
|
||||
body.appendChild(image);
|
||||
} else if (entry.previewKind === 'text') {
|
||||
fetch(previewUrl)
|
||||
.then(async (response) => {
|
||||
const content = await response.text();
|
||||
if (!response.ok) {
|
||||
let message = 'Text preview failed to load';
|
||||
try {
|
||||
message = JSON.parse(content).error || message;
|
||||
} catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
return content;
|
||||
})
|
||||
.then((content) => {
|
||||
if (!this._previewOverlay || requestSequence !== this._previewRequestSequence) return;
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'path-preview-text';
|
||||
pre.textContent = content;
|
||||
body.replaceChildren(pre);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (requestSequence === this._previewRequestSequence) this.showPreviewError(error.message);
|
||||
});
|
||||
} else {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.className = 'path-preview-frame';
|
||||
frame.title = entry.name;
|
||||
frame.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
|
||||
frame.src = previewUrl;
|
||||
body.appendChild(frame);
|
||||
}
|
||||
overlay.querySelector('.path-preview-close').focus();
|
||||
},
|
||||
|
||||
showPreviewError(message) {
|
||||
const body = this._previewOverlay?.querySelector('.path-preview-body');
|
||||
if (!body) return;
|
||||
const error = document.createElement('div');
|
||||
error.className = 'path-preview-error';
|
||||
error.textContent = message || 'Preview failed to load';
|
||||
body.replaceChildren(error);
|
||||
},
|
||||
|
||||
closePreview(restoreFocus = true) {
|
||||
this._previewRequestSequence += 1;
|
||||
this._previewOverlay?.remove();
|
||||
this._previewOverlay = null;
|
||||
const previousFocus = this._previewPreviousFocus;
|
||||
this._previewPreviousFocus = null;
|
||||
if (restoreFocus) previousFocus?.focus?.();
|
||||
},
|
||||
|
||||
confirm() {
|
||||
if (!this._selectedPath || !this._options) return;
|
||||
const selectedPath = this._selectedPath;
|
||||
const onSelect = this._options.onSelect;
|
||||
this.close(false);
|
||||
onSelect(selectedPath);
|
||||
},
|
||||
|
||||
close(restoreFocus = true) {
|
||||
if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler);
|
||||
this._keydownHandler = null;
|
||||
this._loadSequence += 1;
|
||||
this.closePreview(false);
|
||||
this.overlay?.remove();
|
||||
this.overlay = null;
|
||||
const previousFocus = this._previousFocus;
|
||||
this._previousFocus = null;
|
||||
this._options = null;
|
||||
this._selectedPath = '';
|
||||
if (restoreFocus) previousFocus?.focus?.();
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Mobile Keyboard Accessory Bar
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -92,6 +427,8 @@ const KeyboardAccessoryBar = {
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">📁 Path</button>
|
||||
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">⌫ All</button>
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
||||
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
|
||||
@@ -128,7 +465,7 @@ const KeyboardAccessoryBar = {
|
||||
this.handleAction(action, btn);
|
||||
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||
if (refocusActions.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
@@ -207,6 +544,12 @@ const KeyboardAccessoryBar = {
|
||||
case 'paste':
|
||||
this.pasteFromClipboard();
|
||||
break;
|
||||
case 'pick-path':
|
||||
this.pickPath();
|
||||
break;
|
||||
case 'clear-input':
|
||||
app.clearTerminalInput?.();
|
||||
break;
|
||||
case 'dismiss':
|
||||
// Blur active element to dismiss keyboard
|
||||
document.activeElement?.blur();
|
||||
@@ -265,6 +608,22 @@ const KeyboardAccessoryBar = {
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
/** Browse the active session's workspace and insert a selected path without Enter. */
|
||||
pickPath() {
|
||||
if (!app.activeSessionId) return;
|
||||
const session = app.sessions?.get(app.activeSessionId);
|
||||
PathPicker.open({
|
||||
title: 'Insert File or Folder Path',
|
||||
sessionId: app.activeSessionId,
|
||||
initialPath: session?.workingDir || '',
|
||||
directoriesOnly: false,
|
||||
onSelect: (path) => {
|
||||
app.insertTerminalText?.(path);
|
||||
setTimeout(() => app.terminal?.focus(), 100);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** Show a paste overlay for iOS compatibility.
|
||||
* Handles three input paths from one dialog:
|
||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||
|
||||
@@ -855,6 +855,17 @@ html.mobile-init .file-browser-panel {
|
||||
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Per-URL edit/delete in the Web / URL list need a real touch target, and they
|
||||
sit next to the row's own tap area, so they get sized up rather than relying
|
||||
on the 24px desktop hit box. */
|
||||
.run-mode-row-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 1rem;
|
||||
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.run-mode-webview-delete { font-size: 1.2rem; }
|
||||
|
||||
.run-mode-history {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: manipulation;
|
||||
@@ -2236,6 +2247,68 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
}
|
||||
|
||||
/* Light-skin compatibility for mobile-only chrome. These components predate
|
||||
the shared skin system and intentionally retain their original dark values
|
||||
for the three dark skins above. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.header, .toolbar, .keyboard-accessory-bar) {
|
||||
background: var(--glass-bg);
|
||||
border-color: var(--glass-border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .toolbar .btn-case-add, .accessory-btn) {
|
||||
background: var(--control-bg);
|
||||
border-color: var(--control-border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .btn-case-add:hover, .btn-case-add:active, .accessory-btn:active) {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-claude, .btn-toolbar.btn-run-gear.mode-claude) {
|
||||
background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-opencode, .btn-toolbar.btn-run-gear.mode-opencode) {
|
||||
background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-b));
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-gemini, .btn-toolbar.btn-run-gear.mode-gemini) {
|
||||
background: linear-gradient(135deg, #174ea6, #4f46e5);
|
||||
border-color: #315fc3;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
|
||||
border-left-color: var(--control-border-hover) !important;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.case-settings-popover-mobile, .mobile-case-picker-sheet) {
|
||||
background: var(--floating-bg);
|
||||
border-color: var(--control-border);
|
||||
color: var(--text);
|
||||
box-shadow: var(--elevated-shadow);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.case-settings-popover-mobile .checkbox-inline, #createCaseModal .form-row label) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .case-settings-popover-mobile .form-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .mobile-case-picker .modal-backdrop {
|
||||
background: var(--modal-backdrop);
|
||||
}
|
||||
|
||||
|
||||
/* Keyboard accessory bar + paste overlay base styles moved to styles.css
|
||||
(always loaded — covers iPad landscape where mobile.css doesn't load).
|
||||
|
||||
@@ -2267,6 +2267,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const terminal = new Terminal({
|
||||
theme: { ...window.codemanCurrentXtermTheme() },
|
||||
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.2,
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
@@ -1888,6 +1888,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
openLinkCasePathPicker() {
|
||||
const pathInput = document.getElementById('linkCasePath');
|
||||
PathPicker.open({
|
||||
title: 'Select Existing Project Folder',
|
||||
initialPath: pathInput.value.trim(),
|
||||
directoriesOnly: true,
|
||||
onSelect: (path) => {
|
||||
pathInput.value = path;
|
||||
const nameInput = document.getElementById('linkCaseName');
|
||||
if (!nameInput.value.trim()) {
|
||||
const folderName = path.split('/').filter(Boolean).pop() || '';
|
||||
if (/^[\p{L}\p{N}_-]+$/u.test(folderName)) nameInput.value = folderName;
|
||||
}
|
||||
pathInput.focus();
|
||||
pathInput.setSelectionRange(path.length, path.length);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async linkRemoteCase() {
|
||||
const name = document.getElementById('remoteCaseName').value.trim();
|
||||
const remotePath = document.getElementById('remoteCasePath').value.trim();
|
||||
|
||||
@@ -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,
|
||||
@@ -1866,6 +1871,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const skin = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||
document.documentElement.setAttribute('data-skin', skin);
|
||||
window.__codemanSkin = skin;
|
||||
const themeColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-dark').trim();
|
||||
if (themeColor) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', themeColor);
|
||||
try {
|
||||
localStorage.setItem('codeman:skin', skin);
|
||||
} catch (_e) {
|
||||
@@ -1887,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();
|
||||
@@ -1965,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);
|
||||
@@ -2245,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;
|
||||
|
||||
@@ -40,11 +40,22 @@
|
||||
og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' },
|
||||
'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selection: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
|
||||
'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selection: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
|
||||
'paper-gray': { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#ffffff', selection: 'rgba(9,105,218,0.2)', black: '#24292f', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#59636e', brightBlack: '#6e7781', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#7d4e00', brightBlue: '#0550ae', brightMagenta: '#6639ba', brightCyan: '#116b75', brightWhite: '#1f2328' },
|
||||
'solarized-light': { background: '#fdf6e3', foreground: '#586e75', cursor: '#147ba3', cursorAccent: '#fdf6e3', selection: 'rgba(38,139,210,0.2)', black: '#eee8d5', red: '#dc322f', green: '#758600', yellow: '#9b7800', blue: '#147ba3', magenta: '#d33682', cyan: '#2a9189', white: '#073642', brightBlack: '#93a1a1', brightRed: '#cb4b16', brightGreen: '#657b83', brightYellow: '#586e75', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#002b36' },
|
||||
'catppuccin-latte': { background: '#eff1f5', foreground: '#4c4f69', cursor: '#1e66f5', cursorAccent: '#ffffff', selection: 'rgba(30,102,245,0.18)', black: '#5c5f77', red: '#d20f39', green: '#3b8f2b', yellow: '#a86605', blue: '#1e66f5', magenta: '#8839ef', cyan: '#177f86', white: '#6c6f85', brightBlack: '#7c7f93', brightRed: '#b50930', brightGreen: '#2f7622', brightYellow: '#8b5604', brightBlue: '#174fbf', brightMagenta: '#6f2bc5', brightCyan: '#116b71', brightWhite: '#4c4f69' },
|
||||
'rose-pine-dawn': { background: '#faf4ed', foreground: '#575279', cursor: '#286983', cursorAccent: '#fffaf3', selection: 'rgba(40,105,131,0.2)', black: '#575279', red: '#b4637a', green: '#286983', yellow: '#96681f', blue: '#477f91', magenta: '#907aa9', cyan: '#3f7f8b', white: '#6e6a86', brightBlack: '#797593', brightRed: '#984d66', brightGreen: '#1f5266', brightYellow: '#7d5417', brightBlue: '#386b7c', brightMagenta: '#765f90', brightCyan: '#326b76', brightWhite: '#575279' },
|
||||
};
|
||||
const CODEMAN_LIGHT_SKINS = new Set(['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn']);
|
||||
function currentSkin() {
|
||||
return (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
|
||||
}
|
||||
function currentXtermTheme() {
|
||||
const skin = (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
|
||||
const skin = currentSkin();
|
||||
return CODEMAN_XTERM_THEMES[skin] || CODEMAN_XTERM_THEMES['daylight-blue'];
|
||||
}
|
||||
function currentSkinIsLight(skin = currentSkin()) {
|
||||
return CODEMAN_LIGHT_SKINS.has(skin);
|
||||
}
|
||||
|
||||
global.CodemanTerminalInput = {
|
||||
isTerminalQueryResponse,
|
||||
@@ -54,6 +65,7 @@
|
||||
};
|
||||
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||
global.codemanCurrentSkinIsLight = currentSkinIsLight;
|
||||
})(window);
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
@@ -75,6 +87,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'block',
|
||||
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||
scrollback: scrollback,
|
||||
allowTransparency: true,
|
||||
allowProposedApi: true,
|
||||
@@ -969,8 +982,66 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get line text - translateToString handles wrapped lines
|
||||
const lineText = line.translateToString(true);
|
||||
// Stitch the LOGICAL line back together.
|
||||
//
|
||||
// xterm invokes this provider per visible ROW, and translateToString returns
|
||||
// that row alone (the old comment here claimed otherwise). A URL or path
|
||||
// longer than the terminal is wide therefore matched only as far as the row
|
||||
// boundary, and the link opened a PREFIX of the real target. Walk out to both
|
||||
// ends of the continuation, match against the joined text, and map offsets
|
||||
// back to (x, y) so a link can span rows.
|
||||
//
|
||||
// Two different kinds of continuation, and handling only the first is not
|
||||
// enough:
|
||||
// 1. SOFT wrap: the emulator ran out of columns and flags the next row
|
||||
// `isWrapped`.
|
||||
// 2. HARD wrap: the program did its own wrapping and emitted a real
|
||||
// newline, so nothing is flagged. Ink does this, which is why Claude
|
||||
// Code's own `/login` URL was cut at the window edge, and why the
|
||||
// clickable part grew when the window was widened.
|
||||
// A row that fills the full width is treated as continuing into the next:
|
||||
// that is the signal a hard wrap leaves behind, and a line that genuinely
|
||||
// ended would stop short of the last column.
|
||||
const cols = self.terminal.cols;
|
||||
const rowAt = (r) => buffer.getLine(r - 1);
|
||||
const continuesPrevious = (r) => {
|
||||
if (r <= 1) return false;
|
||||
if (rowAt(r)?.isWrapped) return true;
|
||||
const prev = rowAt(r - 1);
|
||||
return !!prev && prev.translateToString(true).length >= cols;
|
||||
};
|
||||
|
||||
// Bounded so a screenful of full-width output (wide tables, box drawing)
|
||||
// cannot make every hover stitch and re-scan the entire viewport.
|
||||
const MAX_STITCHED_ROWS = 12;
|
||||
let startRow = bufferLineNumber;
|
||||
while (startRow > 1 && bufferLineNumber - startRow < MAX_STITCHED_ROWS && continuesPrevious(startRow)) {
|
||||
startRow--;
|
||||
}
|
||||
let endRow = bufferLineNumber;
|
||||
while (endRow < buffer.length && endRow - startRow < MAX_STITCHED_ROWS && continuesPrevious(endRow + 1)) {
|
||||
endRow++;
|
||||
}
|
||||
|
||||
const rowTexts = [];
|
||||
for (let r = startRow; r <= endRow; r++) {
|
||||
const row = rowAt(r);
|
||||
if (!row) break;
|
||||
// Only the final row may be trimmed. Continuation rows fill the width by
|
||||
// definition, and trimming one would shift every later offset.
|
||||
rowTexts.push(row.translateToString(r === endRow));
|
||||
}
|
||||
const lineText = rowTexts.join('');
|
||||
|
||||
/** Map an offset in the stitched text back to a 1-based terminal cell. */
|
||||
const coordAt = (index) => {
|
||||
let rest = index;
|
||||
for (let i = 0; i < rowTexts.length - 1; i++) {
|
||||
if (rest < rowTexts[i].length) return { x: rest + 1, y: startRow + i };
|
||||
rest -= rowTexts[i].length;
|
||||
}
|
||||
return { x: rest + 1, y: startRow + rowTexts.length - 1 };
|
||||
};
|
||||
|
||||
if (!lineText || !lineText.includes('/')) {
|
||||
callback(undefined);
|
||||
@@ -980,22 +1051,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
const links = [];
|
||||
|
||||
// Pattern 0: URLs (https://, http://) — matched first so they take priority
|
||||
const urlPattern = /https?:\/\/[^\s"'<>|;&)\]\x00-\x1f]+/g;
|
||||
//
|
||||
// A single `&` is PART of the URL: it separates query parameters, so excluding
|
||||
// it truncated every real query string (`?post=1479&action=edit` linked only
|
||||
// through `1479`, landing on the wrong page). `&&` is still a boundary, since
|
||||
// that is the shell operator and never appears inside a URL. A lone trailing
|
||||
// `&` is trimmed below with the other trailing punctuation.
|
||||
const urlPattern = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/g;
|
||||
|
||||
const addUrlLink = (url, matchIndex) => {
|
||||
// Strip trailing punctuation that's likely not part of the URL
|
||||
const cleaned = url.replace(/[.,;:!?)]+$/, '');
|
||||
const cleaned = url.replace(/[.,;:!?)&]+$/, '');
|
||||
const startCol = lineText.indexOf(cleaned, matchIndex);
|
||||
if (startCol === -1) return;
|
||||
|
||||
if (links.some((l) => l.range.start.x === startCol + 1)) return;
|
||||
const start = coordAt(startCol);
|
||||
const end = coordAt(startCol + cleaned.length);
|
||||
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
|
||||
|
||||
links.push({
|
||||
text: cleaned,
|
||||
range: {
|
||||
start: { x: startCol + 1, y: bufferLineNumber },
|
||||
end: { x: startCol + cleaned.length + 1, y: bufferLineNumber },
|
||||
},
|
||||
range: { start, end },
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate(_event, text) {
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
@@ -1017,31 +1093,43 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the whole tab on hover. Non-empty token + bounded reps is O(n).
|
||||
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||
|
||||
// Pattern 2: Paths with common extensions
|
||||
// Pattern 2: Paths with common extensions.
|
||||
// Image/PDF extensions are included so pasted-attachment paths
|
||||
// (`.claude-images/paste-*.png`) are clickable; they open the file preview
|
||||
// rather than the log viewer (see addLink).
|
||||
const extPattern =
|
||||
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js|png|jpe?g|gif|webp|bmp|svg|pdf))\b/g;
|
||||
|
||||
// Pattern 3: Bash() tool output
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
|
||||
/** Extensions that should open the image/document preview, not the log viewer. */
|
||||
const PREVIEW_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'pdf']);
|
||||
|
||||
const addLink = (filePath, matchIndex) => {
|
||||
const startCol = lineText.indexOf(filePath, matchIndex);
|
||||
if (startCol === -1) return;
|
||||
|
||||
const start = coordAt(startCol);
|
||||
const end = coordAt(startCol + filePath.length);
|
||||
// Skip if already have link at this position
|
||||
if (links.some((l) => l.range.start.x === startCol + 1)) return;
|
||||
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
|
||||
|
||||
links.push({
|
||||
text: filePath,
|
||||
range: {
|
||||
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
|
||||
end: { x: startCol + filePath.length + 1, y: bufferLineNumber },
|
||||
},
|
||||
range: { start, end }, // 1-based, may span wrapped rows
|
||||
decorations: {
|
||||
pointerCursor: true,
|
||||
underline: true,
|
||||
},
|
||||
activate(event, text) {
|
||||
// Tailing a PNG in the log viewer shows binary noise; the file preview
|
||||
// already renders images and PDFs inline.
|
||||
const ext = (text.split('.').pop() || '').toLowerCase();
|
||||
if (PREVIEW_EXTS.has(ext)) {
|
||||
self.openFilePreview(text, self.activeSessionId);
|
||||
return;
|
||||
}
|
||||
self.openLogViewerWindow(text, self.activeSessionId);
|
||||
},
|
||||
hover() {
|
||||
@@ -2418,6 +2506,50 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.clear();
|
||||
},
|
||||
|
||||
/** Insert editable text at the active prompt without pressing Enter. */
|
||||
insertTerminalText(text) {
|
||||
if (!this.activeSessionId || !text) return;
|
||||
if (this._localEchoEnabled && this._localEchoOverlay) {
|
||||
this._localEchoOverlay.appendText(text);
|
||||
} else {
|
||||
this.sendInput(text).catch(() => {});
|
||||
}
|
||||
this.terminal?.focus();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear only the current editable prompt. This is intentionally distinct
|
||||
* from Ctrl+L (clear display) and the agent's destructive `/clear` command.
|
||||
*/
|
||||
clearTerminalInput() {
|
||||
if (!this.activeSessionId) return;
|
||||
|
||||
if (typeof CjkInput !== 'undefined') CjkInput.clear();
|
||||
if (this._inputFlushTimeout) {
|
||||
clearTimeout(this._inputFlushTimeout);
|
||||
this._inputFlushTimeout = null;
|
||||
}
|
||||
this._pendingInput = '';
|
||||
|
||||
if (this._localEchoEnabled && this._localEchoOverlay) {
|
||||
const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' };
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(this.activeSessionId);
|
||||
this._flushedTexts?.delete(this.activeSessionId);
|
||||
if (flushed.count > 0) {
|
||||
this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
// In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U
|
||||
// is the conventional kill-line key supported by shells and agent TUIs.
|
||||
this.sendInput('\x15').catch(() => {});
|
||||
}
|
||||
|
||||
this.showToast?.('Input cleared', 'success');
|
||||
this.terminal?.focus();
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore terminal size to match web UI dimensions.
|
||||
* Use this after mobile screen attachment has squeezed the terminal.
|
||||
@@ -2846,8 +2978,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// DOM and WebGL renderers) plus a belt-and-suspenders refresh().
|
||||
applyTerminalSkin(skin) {
|
||||
const theme = { ...(window.CODEMAN_XTERM_THEMES[skin] || window.CODEMAN_XTERM_THEMES['daylight-blue']) };
|
||||
const minimumContrastRatio = window.codemanCurrentSkinIsLight(skin) ? 4.5 : 1;
|
||||
if (this.terminal) {
|
||||
this.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
this.terminal.options.theme = theme;
|
||||
// The zero-lag typing overlay caches the xterm foreground/background.
|
||||
// Refresh it on live skin changes so typed text never keeps the prior
|
||||
// theme's dark backing surface or foreground color.
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
try {
|
||||
this.terminal.refresh(0, this.terminal.rows - 1);
|
||||
} catch {}
|
||||
@@ -2855,6 +2993,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this.teammateTerminals) {
|
||||
for (const [, entry] of this.teammateTerminals) {
|
||||
if (entry && entry.terminal) {
|
||||
entry.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
entry.terminal.options.theme = { ...theme };
|
||||
try {
|
||||
entry.terminal.refresh(0, entry.terminal.rows - 1);
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* @fileoverview Web tabs: saved dashboard URLs rendered as tabs beside agent
|
||||
* sessions, so Codeman is one mission control instead of Codeman plus a pile of
|
||||
* browser tabs.
|
||||
*
|
||||
* Each open dashboard is an <iframe> inside #webviewLayer, which covers the
|
||||
* terminal while a web tab is active. Frames stay MOUNTED while hidden, because a
|
||||
* dashboard that reloads and re-authenticates on every tab switch is worse than
|
||||
* the browser tab it replaced. `maxLiveFrames` (from the server) bounds that with
|
||||
* least-recently-viewed eviction.
|
||||
*
|
||||
* Sandboxing: a proxied dashboard is served from Codeman's own origin, so the
|
||||
* iframe deliberately omits `allow-same-origin` unless the dashboard is marked
|
||||
* trusted. Without that omission the page could read this document and call the
|
||||
* API that spawns agents.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js, api-client.js, constants.js (escapeHtml)
|
||||
* @loadorder 12.5 of 16, after session-ui.js (needs the tab strip), before api-client.js
|
||||
*/
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Load the saved list and restore which tabs were open. */
|
||||
async initWebviews() {
|
||||
this.webviews = this.webviews || new Map();
|
||||
this.webviewOrder = this.webviewOrder || [];
|
||||
this.activeWebviewId = this.activeWebviewId || null;
|
||||
this._webviewMaxFrames = this._webviewMaxFrames || 6;
|
||||
this._webviewFrameLru = this._webviewFrameLru || [];
|
||||
|
||||
await this.refreshWebviews();
|
||||
|
||||
// Restore the previously open web tabs (per device: which dashboards you keep
|
||||
// open is a workspace-layout choice, not something to sync across machines).
|
||||
let saved = [];
|
||||
try {
|
||||
saved = JSON.parse(localStorage.getItem('codeman-webview-order') || '[]');
|
||||
} catch {
|
||||
saved = [];
|
||||
}
|
||||
this.webviewOrder = saved.filter((id) => this.webviews.has(id));
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
|
||||
async refreshWebviews() {
|
||||
const data = await this._apiJson('/api/webviews');
|
||||
if (!data) return;
|
||||
this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
|
||||
if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
|
||||
this.renderWebviewMenuItems();
|
||||
},
|
||||
|
||||
/** SSE: the saved list changed (possibly on another device). */
|
||||
async _onWebviewChanged(data) {
|
||||
await this.refreshWebviews();
|
||||
// A dashboard deleted elsewhere must not linger as a dead tab here.
|
||||
if (data && data.action === 'deleted' && data.id) this._removeWebviewTab(data.id);
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
|
||||
_persistWebviewOrder() {
|
||||
try {
|
||||
localStorage.setItem('codeman-webview-order', JSON.stringify(this.webviewOrder || []));
|
||||
} catch {
|
||||
/* private mode / quota, order is a convenience, never fatal */
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tab strip ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tab HTML for every OPEN web tab, appended by _fullRenderSessionTabs().
|
||||
* `startIndex` continues the Alt+N numbering after the session tabs.
|
||||
*/
|
||||
renderWebviewTabs(startIndex) {
|
||||
if (!this.webviewOrder || this.webviewOrder.length === 0) return '';
|
||||
const parts = [];
|
||||
let idx = startIndex;
|
||||
|
||||
for (const id of this.webviewOrder) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) continue;
|
||||
const isActive = id === this.activeWebviewId;
|
||||
const jsonId = escapeHtml(JSON.stringify(id));
|
||||
const icon = webview.icon ? escapeHtml(webview.icon) : '';
|
||||
|
||||
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
||||
onclick="app.handleWebviewTabClick(event, ${jsonId})"
|
||||
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
|
||||
${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
|
||||
<span class="tab-web-icon" aria-hidden="true">${icon || this._webviewGlobeIcon()}</span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
<span class="tab-name">${escapeHtml(webview.name)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">⚙</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">×</span>
|
||||
</div>`);
|
||||
idx++;
|
||||
}
|
||||
return parts.join('');
|
||||
},
|
||||
|
||||
_webviewGlobeIcon() {
|
||||
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20"/></svg>';
|
||||
},
|
||||
|
||||
handleWebviewTabClick(event, id) {
|
||||
event?.preventDefault?.();
|
||||
return this.openWebview(id);
|
||||
},
|
||||
|
||||
/** Mark exactly one tab active across BOTH tab kinds. */
|
||||
_updateActiveWebviewTab() {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
for (const tab of container.querySelectorAll('.session-tab[data-webview-id]')) {
|
||||
tab.classList.toggle('active', tab.dataset.webviewId === this.activeWebviewId);
|
||||
}
|
||||
if (this.activeWebviewId) {
|
||||
// A web tab is active, so no session tab may also look active.
|
||||
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Opening / closing ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open (or focus) a dashboard tab. Mints a fresh capability every time: they are
|
||||
* memory-only and expire, so a tab reopened after a server restart must not reuse
|
||||
* the dead URL from the previous run.
|
||||
*/
|
||||
async openWebview(id) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) return;
|
||||
|
||||
if (!this.webviewOrder.includes(id)) {
|
||||
this.webviewOrder.push(id);
|
||||
this._persistWebviewOrder();
|
||||
}
|
||||
|
||||
const data = await this._apiJson(`/api/webviews/${encodeURIComponent(id)}/open`, { method: 'POST' });
|
||||
if (!data) {
|
||||
this.showToast?.('Could not open URL', 'error');
|
||||
return;
|
||||
}
|
||||
if (data.webview) this.webviews.set(id, data.webview);
|
||||
|
||||
const src = data.embedUrl || data.webview?.url || webview.url;
|
||||
this._mountWebviewFrame(id, src, data.webview || webview);
|
||||
this.activeWebviewId = id;
|
||||
this.hideWelcome?.();
|
||||
document.querySelector('.main')?.classList.add('webview-active');
|
||||
this.renderSessionTabs();
|
||||
this._updateActiveWebviewTab();
|
||||
},
|
||||
|
||||
/** Create the frame if absent, then reveal it and hide its siblings. */
|
||||
_mountWebviewFrame(id, src, webview) {
|
||||
const layer = document.getElementById('webviewLayer');
|
||||
if (!layer) return;
|
||||
|
||||
let wrap = layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`);
|
||||
if (!wrap) {
|
||||
wrap = document.createElement('div');
|
||||
wrap.className = 'webview-frame';
|
||||
wrap.dataset.webviewId = id;
|
||||
|
||||
const frame = document.createElement('iframe');
|
||||
frame.className = 'webview-iframe';
|
||||
frame.setAttribute('title', webview.name);
|
||||
// No allow-same-origin unless explicitly trusted: a proxied page is served
|
||||
// from THIS origin, so granting it would let the dashboard read this document
|
||||
// and drive the Codeman API.
|
||||
const sandbox = ['allow-scripts', 'allow-forms', 'allow-popups', 'allow-downloads', 'allow-modals'];
|
||||
if (webview.trusted) sandbox.push('allow-same-origin');
|
||||
frame.setAttribute('sandbox', sandbox.join(' '));
|
||||
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
|
||||
frame.src = src;
|
||||
|
||||
const failure = document.createElement('div');
|
||||
failure.className = 'webview-failure';
|
||||
failure.innerHTML = this._webviewFailureHtml(id);
|
||||
|
||||
wrap.appendChild(frame);
|
||||
wrap.appendChild(failure);
|
||||
layer.appendChild(wrap);
|
||||
|
||||
// A frame that never fires `load` is the normal symptom of a refused embed or
|
||||
// an unreachable host. Show an actionable panel instead of a blank rectangle.
|
||||
const timer = setTimeout(() => wrap.classList.add('webview-frame--failed'), 8000);
|
||||
frame.addEventListener('load', () => {
|
||||
clearTimeout(timer);
|
||||
wrap.classList.remove('webview-frame--failed');
|
||||
});
|
||||
}
|
||||
|
||||
this._touchWebviewFrame(id);
|
||||
for (const other of layer.querySelectorAll('.webview-frame')) {
|
||||
other.classList.toggle('active', other.dataset.webviewId === id);
|
||||
}
|
||||
// Chart libraries measure on resize; a frame revealed from display:none needs the nudge.
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
||||
},
|
||||
|
||||
_webviewFailureHtml(id) {
|
||||
const jsonId = escapeHtml(JSON.stringify(id));
|
||||
return `<div class="webview-failure-inner">
|
||||
<h3>This URL did not load</h3>
|
||||
<p>It may be unreachable from the Codeman server, or it may refuse to be embedded.</p>
|
||||
<div class="webview-failure-actions">
|
||||
<button class="btn-secondary" onclick="app.reloadWebview(${jsonId})">Reload</button>
|
||||
<button class="btn-secondary" onclick="app.openWebviewExternal(${jsonId})">Open in new tab</button>
|
||||
<button class="btn-secondary" onclick="app.showWebviewModal(${jsonId})">Edit</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
/** Least-recently-viewed eviction so N open dashboards cannot pin N live pages. */
|
||||
_touchWebviewFrame(id) {
|
||||
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
|
||||
this._webviewFrameLru.push(id);
|
||||
const layer = document.getElementById('webviewLayer');
|
||||
if (!layer) return;
|
||||
while (this._webviewFrameLru.length > this._webviewMaxFrames) {
|
||||
const evict = this._webviewFrameLru.shift();
|
||||
if (evict === this.activeWebviewId) continue;
|
||||
layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(evict)}"]`)?.remove();
|
||||
}
|
||||
},
|
||||
|
||||
reloadWebview(id) {
|
||||
const target = id || this.activeWebviewId;
|
||||
if (!target) return;
|
||||
document
|
||||
.getElementById('webviewLayer')
|
||||
?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(target)}"]`)
|
||||
?.remove();
|
||||
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== target);
|
||||
return this.openWebview(target);
|
||||
},
|
||||
|
||||
openWebviewExternal(id) {
|
||||
const webview = this.webviews.get(id || this.activeWebviewId);
|
||||
if (webview) window.open(webview.url, '_blank', 'noopener');
|
||||
},
|
||||
|
||||
closeWebviewTab(id) {
|
||||
this._removeWebviewTab(id);
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
|
||||
_removeWebviewTab(id) {
|
||||
this.webviewOrder = (this.webviewOrder || []).filter((x) => x !== id);
|
||||
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
|
||||
this._persistWebviewOrder();
|
||||
document
|
||||
.getElementById('webviewLayer')
|
||||
?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`)
|
||||
?.remove();
|
||||
|
||||
if (this.activeWebviewId === id) {
|
||||
this.activeWebviewId = null;
|
||||
const next = this.webviewOrder[0];
|
||||
if (next) {
|
||||
this.openWebview(next);
|
||||
} else {
|
||||
this._hideWebviewLayer();
|
||||
// Fall back to whatever session was last shown, or the welcome screen.
|
||||
if (this.activeSessionId) this._updateActiveTabImmediate(this.activeSessionId);
|
||||
else this.showWelcome?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Called by selectSession(): a session tab takes the stage back from a web tab. */
|
||||
_hideWebviewLayer() {
|
||||
if (!this.activeWebviewId && !document.querySelector('.main.webview-active')) return;
|
||||
this.activeWebviewId = null;
|
||||
document.querySelector('.main')?.classList.remove('webview-active');
|
||||
for (const frame of document.querySelectorAll('#webviewLayer .webview-frame')) {
|
||||
frame.classList.remove('active');
|
||||
}
|
||||
this._updateActiveWebviewTab();
|
||||
},
|
||||
|
||||
// ── Run-menu entries ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Saved dashboards listed inside the Run dropdown, under "Web / URL".
|
||||
*
|
||||
* Each row carries its own edit and delete buttons. Without them the only way to
|
||||
* change or remove a saved URL was to open it as a tab first and go through the
|
||||
* tab's gear, which is a dead end for a URL you no longer want open at all.
|
||||
*/
|
||||
renderWebviewMenuItems() {
|
||||
const container = document.getElementById('runModeWebviews');
|
||||
if (!container) return;
|
||||
const list = [...(this.webviews?.values() || [])];
|
||||
if (list.length === 0) {
|
||||
container.innerHTML = '<div class="run-mode-empty">No URLs yet</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = list
|
||||
.map((w) => {
|
||||
const jsonId = escapeHtml(JSON.stringify(w.id));
|
||||
const name = escapeHtml(w.name);
|
||||
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><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>
|
||||
<button class="run-mode-row-btn run-mode-webview-delete" onclick="event.stopPropagation(); app.deleteWebviewById(${jsonId})"
|
||||
title="Delete URL" aria-label="Delete ${name}">×</button>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
|
||||
openWebviewFromMenu(id) {
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
return this.openWebview(id);
|
||||
},
|
||||
|
||||
// ── Icon picker ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Common dashboard/service glyphs. The text field stays open for anything else. */
|
||||
_webviewIconChoices() {
|
||||
return ['📊', '📈', '🖥️', '🎛️', '📡', '🐳', '🗄️', '🔒', '🌐', '📁', '🧪', '🧬', '⚡', '🔔', '📝', '🎧'];
|
||||
},
|
||||
|
||||
_renderWebviewIconPicker(selected) {
|
||||
const picker = document.getElementById('webviewIconPicker');
|
||||
if (!picker) return;
|
||||
picker.innerHTML = this._webviewIconChoices()
|
||||
.map(
|
||||
(icon) =>
|
||||
`<button type="button" class="webview-icon-choice${icon === selected ? ' selected' : ''}"
|
||||
onclick="app.pickWebviewIcon(${escapeHtml(JSON.stringify(icon))})"
|
||||
aria-label="Use ${escapeHtml(icon)} as the icon">${escapeHtml(icon)}</button>`
|
||||
)
|
||||
.join('');
|
||||
},
|
||||
|
||||
/** Clicking the selected icon again clears it, so there is a way back to no icon. */
|
||||
pickWebviewIcon(icon) {
|
||||
const field = document.getElementById('webviewIcon');
|
||||
if (!field) return;
|
||||
field.value = field.value === icon ? '' : icon;
|
||||
this._renderWebviewIconPicker(field.value);
|
||||
},
|
||||
|
||||
// ── Editor modal ──────────────────────────────────────────────────────────
|
||||
|
||||
showWebviewModal(id) {
|
||||
const modal = document.getElementById('webviewModal');
|
||||
if (!modal) return;
|
||||
const webview = id ? this.webviews.get(id) : null;
|
||||
this._editingWebviewId = webview ? webview.id : null;
|
||||
|
||||
document.getElementById('webviewModalTitle').textContent = webview ? 'Edit URL' : 'Add URL';
|
||||
this._renderWebviewIconPicker(webview?.icon || '');
|
||||
document.getElementById('webviewName').value = webview?.name || '';
|
||||
document.getElementById('webviewUrl').value = webview?.url || '';
|
||||
document.getElementById('webviewIcon').value = webview?.icon || '';
|
||||
document.getElementById('webviewSandboxed').checked = !webview?.trusted;
|
||||
document.getElementById('webviewProbeResult').textContent = '';
|
||||
document.getElementById('webviewDeleteBtn').style.display = webview ? '' : 'none';
|
||||
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
modal.classList.add('active');
|
||||
document.getElementById('webviewName').focus();
|
||||
},
|
||||
|
||||
closeWebviewModal() {
|
||||
document.getElementById('webviewModal')?.classList.remove('active');
|
||||
this._editingWebviewId = null;
|
||||
},
|
||||
|
||||
/** Server-side probe: it runs from the network position the proxy will use. */
|
||||
async testWebviewUrl() {
|
||||
const url = document.getElementById('webviewUrl').value.trim();
|
||||
const out = document.getElementById('webviewProbeResult');
|
||||
if (!url) {
|
||||
out.textContent = 'Enter a URL first.';
|
||||
return;
|
||||
}
|
||||
out.textContent = 'Testing...';
|
||||
const probe = await this._apiJson('/api/webviews/probe', { method: 'POST', body: { url } });
|
||||
if (!probe) {
|
||||
out.textContent = 'Test failed (invalid URL?).';
|
||||
return;
|
||||
}
|
||||
out.textContent = probe.reachable
|
||||
? `Reachable (HTTP ${probe.status}). ${probe.reason}`
|
||||
: `Not reachable. ${probe.reason}`;
|
||||
out.className = 'form-hint webview-probe-result ' + (probe.reachable ? 'ok' : 'bad');
|
||||
},
|
||||
|
||||
async saveWebview() {
|
||||
const name = document.getElementById('webviewName').value.trim();
|
||||
const url = document.getElementById('webviewUrl').value.trim();
|
||||
const icon = document.getElementById('webviewIcon').value.trim();
|
||||
const trusted = !document.getElementById('webviewSandboxed').checked;
|
||||
if (!name || !url) {
|
||||
this.showToast?.('Name and URL are required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// `icon: undefined` rather than null, the schema uses .optional(), which
|
||||
// rejects an explicit null on the wire.
|
||||
const body = { name, url, icon: icon || undefined, trusted };
|
||||
const editing = this._editingWebviewId;
|
||||
const data = editing
|
||||
? await this._apiJson(`/api/webviews/${encodeURIComponent(editing)}`, { method: 'PATCH', body })
|
||||
: await this._apiJson('/api/webviews', { method: 'POST', body });
|
||||
|
||||
if (!data) {
|
||||
this.showToast?.('Could not save (check the URL)', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refreshWebviews();
|
||||
this.closeWebviewModal();
|
||||
if (editing) {
|
||||
// The capability was revoked server-side by the edit, so a mounted frame is
|
||||
// now pointing at a dead URL. Remount it.
|
||||
if (this.webviewOrder.includes(editing)) this.reloadWebview(editing);
|
||||
} else {
|
||||
this.openWebview(data.id);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteWebview() {
|
||||
const id = this._editingWebviewId;
|
||||
if (!id) return;
|
||||
if (await this._confirmAndDeleteWebview(id)) this.closeWebviewModal();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete straight from a Run-dropdown row, without opening the editor first.
|
||||
*
|
||||
* The dropdown's outside-click handler closes the menu when the click target is
|
||||
* not inside it, and by the time the delete resolves this row is gone, so the
|
||||
* menu is re-asserted open: deleting one of several saved URLs should leave you
|
||||
* looking at the rest of the list.
|
||||
*/
|
||||
async deleteWebviewById(id) {
|
||||
if (!id) return;
|
||||
if (!(await this._confirmAndDeleteWebview(id))) return;
|
||||
document.getElementById('runModeMenu')?.classList.add('active');
|
||||
},
|
||||
|
||||
/** Shared by the row button and the editor modal. @returns true when deleted. */
|
||||
async _confirmAndDeleteWebview(id) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!confirm(`Delete "${webview?.name || id}"?`)) return false;
|
||||
const res = await this._apiDelete(`/api/webviews/${encodeURIComponent(id)}`);
|
||||
if (!res || !res.ok) {
|
||||
this.showToast?.('Could not delete URL', 'error');
|
||||
return false;
|
||||
}
|
||||
this._removeWebviewTab(id);
|
||||
this.webviews.delete(id);
|
||||
this.renderWebviewMenuItems();
|
||||
this.renderSessionTabs();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -4,9 +4,17 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import { basename as pathBasename, join } from 'node:path';
|
||||
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import type {
|
||||
ApiResponse,
|
||||
FilesystemBrowseData,
|
||||
FilesystemBrowseEntry,
|
||||
FilesystemBrowseRoot,
|
||||
FilesystemPreviewKind,
|
||||
} from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
import {
|
||||
@@ -22,12 +30,21 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { canAccessOwned, findSessionOrFail, getAuthUser, validateSessionFilePath } from '../route-helpers.js';
|
||||
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
|
||||
import {
|
||||
CASES_DIR,
|
||||
canAccessOwned,
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
parseBody,
|
||||
validateSessionFilePath,
|
||||
} from '../route-helpers.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema } from '../schemas.js';
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
@@ -45,8 +62,14 @@ const MIME_TYPES: Record<string, string> = {
|
||||
txt: 'text/plain',
|
||||
};
|
||||
|
||||
function sanitizeDownloadName(fileName: string): string {
|
||||
return fileName.replace(/["\\\r\n]/g, '_');
|
||||
function buildContentDisposition(disposition: 'inline' | 'attachment', fileName: string): string {
|
||||
const cleaned = fileName.replace(/["\\\r\n]/g, '_');
|
||||
const fallback = cleaned.replace(/[^\x20-\x7e]/g, '_') || 'file';
|
||||
const encoded = encodeURIComponent(cleaned).replace(
|
||||
/['()*]/g,
|
||||
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||
@@ -92,13 +115,12 @@ async function serveRawFile(
|
||||
return;
|
||||
}
|
||||
const content = createReadStream(resolvedPath);
|
||||
const safeName = sanitizeDownloadName(fileName);
|
||||
if (download || extension === 'svg') {
|
||||
reply.header(
|
||||
'Content-Type',
|
||||
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
||||
);
|
||||
reply.header('Content-Disposition', `attachment; filename="${safeName}"`);
|
||||
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
|
||||
reply.header('Content-Length', stat.size);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendRawStream(reply, content);
|
||||
@@ -106,7 +128,7 @@ async function serveRawFile(
|
||||
}
|
||||
|
||||
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
||||
reply.header('Content-Disposition', `inline; filename="${safeName}"`);
|
||||
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
||||
reply.header('Content-Length', stat.size);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendRawStream(reply, content);
|
||||
@@ -194,7 +216,10 @@ async function serveConvertedPreview(
|
||||
|
||||
const content = await fs.readFile(previewPath);
|
||||
reply.header('Content-Type', 'application/pdf');
|
||||
reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`);
|
||||
reply.header(
|
||||
'Content-Disposition',
|
||||
buildContentDisposition('inline', getPreviewPdfDownloadName(fileName, extension))
|
||||
);
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
reply.header('Content-Length', content.length);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
@@ -260,6 +285,170 @@ type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPa
|
||||
attachmentId?: string;
|
||||
};
|
||||
|
||||
const FILESYSTEM_PICKER_ENTRY_LIMIT = 500;
|
||||
const FILESYSTEM_TEXT_PREVIEW_LIMIT = 2 * 1024 * 1024;
|
||||
const FILESYSTEM_BINARY_PREVIEW_LIMIT = 50 * 1024 * 1024;
|
||||
const FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
|
||||
const FILESYSTEM_TEXT_PREVIEW_EXTENSIONS = new Set(['md', 'txt', 'json']);
|
||||
const FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS = new Set(['pdf', 'docx', 'pptx']);
|
||||
|
||||
function isPathWithinRoot(root: string, candidate: string): boolean {
|
||||
const rel = relative(root, candidate);
|
||||
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
||||
}
|
||||
|
||||
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
|
||||
return roots
|
||||
.filter((root) => isPathWithinRoot(root.path, candidate))
|
||||
.sort((a, b) => b.path.length - a.path.length)[0];
|
||||
}
|
||||
|
||||
function containsHiddenPickerSegment(root: string, candidate: string): boolean {
|
||||
const rel = relative(root, candidate);
|
||||
return rel !== '' && rel.split(sep).some((segment) => segment.startsWith('.'));
|
||||
}
|
||||
|
||||
function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | undefined {
|
||||
const extension = extname(fileName).slice(1).toLowerCase();
|
||||
if (FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image';
|
||||
if (FILESYSTEM_TEXT_PREVIEW_EXTENSIONS.has(extension)) return 'text';
|
||||
if (FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS.has(extension)) return 'document';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
|
||||
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
|
||||
// The shared sensitive-path matcher describes file locations such as
|
||||
// ~/.ssh/<key>. Probe a child path as well so the directory itself cannot be
|
||||
// opened and used to enumerate those filenames.
|
||||
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
|
||||
}
|
||||
|
||||
function extraConfiguredPickerRoots(): Array<{ label: string; path: string }> {
|
||||
const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
|
||||
if (!extraRoots) return [];
|
||||
return extraRoots
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.map((path, index) => ({ label: `Configured ${index + 1}`, path }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse roots for the requesting identity.
|
||||
*
|
||||
* Single-user mode (and multi-user admins) get the host-wide set. ⚠️ A regular
|
||||
* multi-user user must NOT: per-user spaces live at `<USER_SPACES_DIR>/<name>`,
|
||||
* which is *inside* `homedir()`, so handing out a `Home` root would let any
|
||||
* authenticated user browse and preview every other user's workspace. The
|
||||
* shared `CASES_DIR` leaks the same way, and `/mnt/d` is a broad host mount
|
||||
* that a multi-user deployment should not expose by default. Operators who
|
||||
* genuinely want a shared area can still name it in `CODEMAN_FILE_PICKER_ROOTS`,
|
||||
* which stays an explicit opt-in in both modes.
|
||||
*/
|
||||
function configuredFilesystemPickerRoots(req: FastifyRequest): Array<{ label: string; path: string }> {
|
||||
const user = getAuthUser(req);
|
||||
if (isMultiUserMode() && user.role !== 'admin') {
|
||||
return [{ label: 'My Space', path: userSpacePath(user.username) }, ...extraConfiguredPickerRoots()];
|
||||
}
|
||||
return [
|
||||
{ label: 'Home', path: homedir() },
|
||||
{ label: 'Codeman Cases', path: CASES_DIR },
|
||||
{ label: 'WSL D:', path: '/mnt/d' },
|
||||
...extraConfiguredPickerRoots(),
|
||||
];
|
||||
}
|
||||
|
||||
async function resolveFilesystemPickerRoots(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
req: FastifyRequest,
|
||||
sessionId?: string
|
||||
): Promise<FilesystemBrowseRoot[]> {
|
||||
const candidates = configuredFilesystemPickerRoots(req);
|
||||
if (sessionId) {
|
||||
const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId);
|
||||
// ⚠️ Ownership must be checked here, exactly as `findSessionOrFail` does for
|
||||
// the other session-scoped handlers in this file. Without it a multi-user
|
||||
// caller could pin ANOTHER user's `workingDir` as a browse root just by
|
||||
// passing their sessionId. Report not-found rather than forbidden so the
|
||||
// endpoint does not confirm that a session id exists.
|
||||
if (!session || !canAccessOwned(getAuthUser(req), (session as { owner?: string }).owner)) {
|
||||
throw Object.assign(new Error(`Session ${sessionId} not found`), {
|
||||
statusCode: 404,
|
||||
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
|
||||
});
|
||||
}
|
||||
candidates.unshift({ label: 'Current Folder', path: session.workingDir });
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
const roots: FilesystemBrowseRoot[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
if (!isAbsolute(candidate.path)) continue;
|
||||
try {
|
||||
const resolved = realpathSync(candidate.path);
|
||||
if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue;
|
||||
const stat = await fs.stat(resolved);
|
||||
if (!stat.isDirectory()) continue;
|
||||
seen.add(resolved);
|
||||
roots.push({ label: candidate.label, path: resolved });
|
||||
} catch {
|
||||
// Optional roots (for example /mnt/d on non-WSL hosts) are omitted.
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
type ResolvedFilesystemPickerPath = {
|
||||
candidatePath: string;
|
||||
resolvedPath: string;
|
||||
roots: FilesystemBrowseRoot[];
|
||||
matchingRoot: FilesystemBrowseRoot;
|
||||
blockedTrees: readonly string[];
|
||||
};
|
||||
|
||||
function throwFilesystemPickerError(statusCode: number, code: ApiErrorCode, message: string): never {
|
||||
throw Object.assign(new Error(message), {
|
||||
statusCode,
|
||||
body: createErrorResponse(code, message),
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveFilesystemPickerPath(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
req: FastifyRequest,
|
||||
requestedPath: string | undefined,
|
||||
sessionId?: string
|
||||
): Promise<ResolvedFilesystemPickerPath> {
|
||||
const roots = await resolveFilesystemPickerRoots(ctx, req, sessionId);
|
||||
if (roots.length === 0) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
|
||||
}
|
||||
|
||||
const fallbackRoot =
|
||||
roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0];
|
||||
const candidatePath = resolve(requestedPath ?? fallbackRoot.path);
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = realpathSync(candidatePath);
|
||||
} catch {
|
||||
throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `Path not found: ${candidatePath}`);
|
||||
}
|
||||
|
||||
const matchingRoot = findMatchingPickerRoot(roots, resolvedPath);
|
||||
if (!matchingRoot) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
|
||||
}
|
||||
if (containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Hidden paths are not available in the file picker');
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
return { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees: guard.blockedTrees };
|
||||
}
|
||||
|
||||
function appendDownloadFlag(url: string): string {
|
||||
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
|
||||
}
|
||||
@@ -375,6 +564,179 @@ async function buildExternalAttachmentRouteItem(
|
||||
}
|
||||
|
||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
|
||||
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
|
||||
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
|
||||
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
|
||||
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
|
||||
ctx,
|
||||
req,
|
||||
requestedPath,
|
||||
sessionId
|
||||
);
|
||||
|
||||
if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked');
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The browse path must be a directory');
|
||||
}
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
|
||||
}
|
||||
|
||||
let dirEntries;
|
||||
try {
|
||||
dirEntries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
||||
} catch {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder cannot be read');
|
||||
}
|
||||
|
||||
dirEntries.sort((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||
if (!a.isDirectory() && b.isDirectory()) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const entries: FilesystemBrowseEntry[] = [];
|
||||
let truncated = false;
|
||||
for (const entry of dirEntries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const visiblePath = join(candidatePath, entry.name);
|
||||
let targetPath: string;
|
||||
try {
|
||||
targetPath = realpathSync(visiblePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetRoot = findMatchingPickerRoot(roots, targetPath);
|
||||
if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
|
||||
|
||||
let type: FilesystemBrowseEntry['type'];
|
||||
let size: number | undefined;
|
||||
const symlink = entry.isSymbolicLink();
|
||||
if (entry.isDirectory()) {
|
||||
type = 'directory';
|
||||
} else if (entry.isFile()) {
|
||||
type = 'file';
|
||||
} else if (symlink) {
|
||||
try {
|
||||
const targetStat = await fs.stat(targetPath);
|
||||
type = targetStat.isDirectory() ? 'directory' : 'file';
|
||||
if (type === 'file') size = targetStat.size;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
|
||||
if (type === 'file' && size === undefined) {
|
||||
try {
|
||||
size = (await fs.stat(targetPath)).size;
|
||||
} catch {
|
||||
// The path is still selectable even when a size lookup races a change.
|
||||
}
|
||||
}
|
||||
entries.push({
|
||||
name: entry.name,
|
||||
path: visiblePath,
|
||||
type,
|
||||
size,
|
||||
symlink: symlink || undefined,
|
||||
previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const parentCandidate = resolve(candidatePath, '..');
|
||||
let parent: string | null = null;
|
||||
if (candidatePath !== matchingRoot.path) {
|
||||
try {
|
||||
const resolvedParent = realpathSync(parentCandidate);
|
||||
if (isPathWithinRoot(matchingRoot.path, resolvedParent)) parent = parentCandidate;
|
||||
} catch {
|
||||
// A concurrently removed parent simply disables upward navigation.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: candidatePath,
|
||||
parent,
|
||||
root: matchingRoot.path,
|
||||
roots,
|
||||
entries,
|
||||
truncated,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Inline preview for files selected through the root-confined filesystem picker.
|
||||
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
|
||||
const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
|
||||
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
|
||||
ctx,
|
||||
req,
|
||||
requestedPath,
|
||||
sessionId
|
||||
);
|
||||
if (isBlockedPickerPath(resolvedPath, blockedTrees)) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked');
|
||||
}
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(resolvedPath);
|
||||
} catch {
|
||||
throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `File not found: ${candidatePath}`);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'The preview path must be a file');
|
||||
}
|
||||
|
||||
const fileName = pathBasename(candidatePath);
|
||||
const extension = extname(fileName).slice(1).toLowerCase();
|
||||
const previewKind = getFilesystemPreviewKind(fileName);
|
||||
if (!previewKind) {
|
||||
throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'This file type cannot be previewed');
|
||||
}
|
||||
const sizeLimit = previewKind === 'text' ? FILESYSTEM_TEXT_PREVIEW_LIMIT : FILESYSTEM_BINARY_PREVIEW_LIMIT;
|
||||
if (stat.size > sizeLimit) {
|
||||
throwFilesystemPickerError(
|
||||
413,
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large to preview (${Math.ceil(stat.size / 1024 / 1024)}MB limit: ${sizeLimit / 1024 / 1024}MB)`
|
||||
);
|
||||
}
|
||||
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
if (previewKind === 'text') {
|
||||
const content = await fs.readFile(resolvedPath, 'utf8');
|
||||
reply.type('text/plain; charset=utf-8').send(content);
|
||||
return;
|
||||
}
|
||||
if (extension === 'docx' || extension === 'pptx') {
|
||||
await serveConvertedPreview(reply, resolvedPath, fileName, extension);
|
||||
return;
|
||||
}
|
||||
await serveRawFile(reply, resolvedPath, fileName, extension);
|
||||
});
|
||||
|
||||
// File tree listing
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
@@ -22,3 +22,4 @@ export { registerSearchRoutes } from './search-routes.js';
|
||||
export { registerMeRoutes } from './me-routes.js';
|
||||
export { registerAdminRoutes } from './admin-routes.js';
|
||||
export { registerWsRoutes } from './ws-routes.js';
|
||||
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
||||
|
||||
@@ -1016,6 +1016,180 @@ export function registerSessionRoutes(
|
||||
return candidateSid;
|
||||
}
|
||||
|
||||
interface ClaudeResponseMessage {
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
interface ClaudeTranscriptEntry {
|
||||
type?: string;
|
||||
timestamp?: string;
|
||||
isMeta?: boolean;
|
||||
isSidechain?: boolean;
|
||||
isCompactSummary?: boolean;
|
||||
message?: { content?: unknown };
|
||||
}
|
||||
|
||||
function extractClaudeText(content: unknown, separator: string): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter(
|
||||
(block): block is { type: string; text: string } =>
|
||||
!!block &&
|
||||
typeof block === 'object' &&
|
||||
(block as { type?: string }).type === 'text' &&
|
||||
typeof (block as { text?: string }).text === 'string'
|
||||
)
|
||||
.map((block) => block.text)
|
||||
.join(separator);
|
||||
}
|
||||
|
||||
function isClaudeSyntheticUserMessage(entry: ClaudeTranscriptEntry, text: string): boolean {
|
||||
if (entry.isMeta || entry.isCompactSummary) return true;
|
||||
return /^(?:<local-command|<command-name>|<task-notification>|<system-reminder>|<teammate-message\b|Another Claude session sent a message:|Base directory for this skill:)/i.test(
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude writes one logical turn as many JSONL rows: text, thinking and tool
|
||||
* blocks share message ids, while tool results are represented as user rows.
|
||||
* Build viewer cards from real user boundaries instead of treating every row
|
||||
* as a separate chat message.
|
||||
*/
|
||||
function parseClaudeResponseTranscript(
|
||||
content: string,
|
||||
full: boolean
|
||||
): { text: string; timestamp: string; messages?: ClaudeResponseMessage[] } {
|
||||
let lastText = '';
|
||||
let lastTimestamp = '';
|
||||
const messages: ClaudeResponseMessage[] = [];
|
||||
let currentUserFragments = new Set<string>();
|
||||
let currentAssistantFragments = new Set<string>();
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
if (!line) continue;
|
||||
let entry: ClaudeTranscriptEntry;
|
||||
try {
|
||||
entry = JSON.parse(line) as ClaudeTranscriptEntry;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// Sidechains belong to agents/forks, not the main conversation. Meta user
|
||||
// rows include repeated image dimensions and other UI-generated context.
|
||||
if (entry.isSidechain) continue;
|
||||
|
||||
if (entry.type === 'user') {
|
||||
const text = extractClaudeText(entry.message?.content, '\n').trim();
|
||||
// A tool_result block has no text block and naturally drops out here.
|
||||
if (!text || isClaudeSyntheticUserMessage(entry, text)) continue;
|
||||
if (!full) continue;
|
||||
|
||||
const previous = messages.at(-1);
|
||||
if (previous?.role === 'user') {
|
||||
// Claude can replay the initial user row while restoring a transcript.
|
||||
// Only collapse duplicates within the same unanswered user turn; the
|
||||
// same prompt after an assistant response remains a legitimate turn.
|
||||
if (currentUserFragments.has(text)) continue;
|
||||
previous.text += `\n\n${text}`;
|
||||
currentUserFragments.add(text);
|
||||
} else {
|
||||
messages.push({ role: 'user', text, timestamp: entry.timestamp });
|
||||
currentUserFragments = new Set([text]);
|
||||
}
|
||||
currentAssistantFragments.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type !== 'assistant') continue;
|
||||
const text = extractClaudeText(entry.message?.content, '\n\n').trim();
|
||||
if (!text) continue;
|
||||
lastText = text;
|
||||
lastTimestamp = entry.timestamp || '';
|
||||
if (!full) continue;
|
||||
|
||||
const previous = messages.at(-1);
|
||||
if (previous?.role === 'assistant') {
|
||||
// Replayed snapshots sometimes repeat an identical text block. Distinct
|
||||
// progress/final blocks are kept, but remain inside one Claude card.
|
||||
if (currentAssistantFragments.has(text)) continue;
|
||||
previous.text += `\n\n${text}`;
|
||||
previous.timestamp = entry.timestamp || previous.timestamp;
|
||||
currentAssistantFragments.add(text);
|
||||
} else {
|
||||
messages.push({ role: 'assistant', text, timestamp: entry.timestamp });
|
||||
currentAssistantFragments = new Set([text]);
|
||||
}
|
||||
currentUserFragments.clear();
|
||||
}
|
||||
|
||||
return full ? { text: lastText, timestamp: lastTimestamp, messages } : { text: lastText, timestamp: lastTimestamp };
|
||||
}
|
||||
|
||||
/** Locate a top-level Claude transcript, including recovered tmux sessions. */
|
||||
async function findClaudeTranscript(
|
||||
projectsDir: string,
|
||||
conversationId: string,
|
||||
codemanSessionId: string
|
||||
): Promise<{ sessionId: string; path: string } | null> {
|
||||
let projectDirs: import('node:fs').Dirent[];
|
||||
try {
|
||||
projectDirs = await fs.readdir(projectsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeIds = [...new Set([conversationId, codemanSessionId])].filter((value) => /^[a-zA-Z0-9._-]+$/.test(value));
|
||||
for (const candidateId of safeIds) {
|
||||
for (const projectDir of projectDirs) {
|
||||
if (!projectDir.isDirectory()) continue;
|
||||
const jsonlPath = join(projectsDir, projectDir.name, `${candidateId}.jsonl`);
|
||||
try {
|
||||
const stat = await fs.stat(jsonlPath);
|
||||
if (stat.isFile()) return { sessionId: candidateId, path: jsonlPath };
|
||||
} catch {
|
||||
/* continue */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If mux-sessions.json was lost or stale, reconcileSessions() historically
|
||||
// recovered `codeman-40568a29` as `restored-40568a29` and used the server cwd.
|
||||
// The tmux name still carries the first eight UUID characters, which safely
|
||||
// reconnects the viewer when exactly one matching top-level transcript exists.
|
||||
const restoredMatch = /^restored-([a-f0-9]{8,})$/i.exec(codemanSessionId);
|
||||
if (!restoredMatch) return null;
|
||||
const fragment = restoredMatch[1].toLowerCase();
|
||||
const candidates: Array<{ sessionId: string; path: string; mtimeMs: number }> = [];
|
||||
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
|
||||
|
||||
for (const projectDir of projectDirs) {
|
||||
if (!projectDir.isDirectory()) continue;
|
||||
const dirPath = join(projectsDir, projectDir.name);
|
||||
let files: import('node:fs').Dirent[];
|
||||
try {
|
||||
files = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const file of files) {
|
||||
if (!file.isFile() || !file.name.endsWith('.jsonl')) continue;
|
||||
const candidateId = file.name.slice(0, -'.jsonl'.length);
|
||||
if (!candidateId.toLowerCase().startsWith(fragment) || !uuidPattern.test(candidateId)) continue;
|
||||
const path = join(dirPath, file.name);
|
||||
const stat = await fs.stat(path).catch(() => null);
|
||||
if (stat) candidates.push({ sessionId: candidateId, path, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
}
|
||||
|
||||
const candidateIds = new Set(candidates.map((candidate) => candidate.sessionId));
|
||||
if (candidateIds.size !== 1) return null;
|
||||
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
app.get('/api/sessions/:id/last-response', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
@@ -1045,106 +1219,30 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// The Claude conversation ID (used as JSONL filename)
|
||||
const query = req.query as { context?: string };
|
||||
const claudeSessionId = session.claudeSessionId || session.id;
|
||||
let transcriptText = '';
|
||||
let transcriptTimestamp = '';
|
||||
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
|
||||
if (!transcript) {
|
||||
return query.context === 'full' ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
}
|
||||
|
||||
if (transcript.sessionId !== session.claudeSessionId && transcript.sessionId !== session.id) {
|
||||
session.adoptClaudeSessionId(transcript.sessionId);
|
||||
if (session.docker) {
|
||||
void persistDockerCaseClaudeSessionId(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
session.docker.containerName,
|
||||
transcript.sessionId
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
|
||||
try {
|
||||
const content = await fs.readFile(jsonlPath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
// Search from end for last assistant message with text
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const blocks = Array.isArray(entry.message.content)
|
||||
? entry.message.content
|
||||
: [{ type: 'text', text: String(entry.message.content) }];
|
||||
const textBlocks = blocks
|
||||
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
|
||||
.map((b: { type: string; text?: string }) => b.text);
|
||||
if (textBlocks.length > 0) {
|
||||
transcriptText = textBlocks.join('\n\n');
|
||||
transcriptTimestamp = entry.timestamp || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
if (transcriptText) break; // Found it, stop scanning directories
|
||||
} catch {
|
||||
// File doesn't exist in this project dir, continue
|
||||
}
|
||||
}
|
||||
const content = await fs.readFile(transcript.path, 'utf8');
|
||||
return parseClaudeResponseTranscript(content, query.context === 'full');
|
||||
} catch {
|
||||
// projects dir doesn't exist
|
||||
return query.context === 'full' ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
}
|
||||
|
||||
// If ?context=full, return all user+assistant messages for conversation view
|
||||
const query = req.query as { context?: string };
|
||||
if (query.context === 'full' && transcriptText) {
|
||||
const allMessages: Array<{ role: string; text: string; timestamp?: string }> = [];
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
|
||||
try {
|
||||
const content = await fs.readFile(jsonlPath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'user' && entry.message?.content) {
|
||||
const text =
|
||||
typeof entry.message.content === 'string'
|
||||
? entry.message.content
|
||||
: (entry.message.content as Array<{ type: string; text?: string }>)
|
||||
.filter((b) => b.type === 'text' && b.text)
|
||||
.map((b) => b.text)
|
||||
.join('\n');
|
||||
// Skip system/command messages
|
||||
if (text && !text.startsWith('<local-command') && !text.startsWith('<command-name>')) {
|
||||
allMessages.push({ role: 'user', text, timestamp: entry.timestamp });
|
||||
}
|
||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const blocks = Array.isArray(entry.message.content)
|
||||
? entry.message.content
|
||||
: [{ type: 'text', text: String(entry.message.content) }];
|
||||
const text = blocks
|
||||
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
|
||||
.map((b: { type: string; text?: string }) => b.text)
|
||||
.join('\n\n');
|
||||
if (text) {
|
||||
allMessages.push({ role: 'assistant', text, timestamp: entry.timestamp });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (allMessages.length > 0) break;
|
||||
} catch {
|
||||
/* continue */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { text: transcriptText, timestamp: transcriptTimestamp, messages: allMessages };
|
||||
}
|
||||
|
||||
return {
|
||||
text: transcriptText,
|
||||
timestamp: transcriptTimestamp,
|
||||
};
|
||||
});
|
||||
|
||||
function isCodexInjectedContext(text: string): boolean {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
/**
|
||||
* @fileoverview Web tabs: saved dashboard URLs, plus the reverse proxy that makes
|
||||
* them embeddable.
|
||||
*
|
||||
* Two distinct surfaces live here, and the split matters:
|
||||
*
|
||||
* 1. `/api/webviews/*`, ordinary authenticated CRUD, owner-scoped like every
|
||||
* other resource, returning the `ApiResponse` envelope.
|
||||
* 2. `/webview/:cap/*`, the proxy. NOT an API surface. It authenticates on an
|
||||
* unguessable capability in the path instead of Codeman's session cookie, and
|
||||
* is correspondingly exempt from the cookie and Origin checks in
|
||||
* `middleware/auth.ts`. See `src/webview-capabilities.ts` for why a cookie
|
||||
* cannot work here (sandboxed iframes are opaque-origin, so their requests are
|
||||
* cross-site and arrive with `Origin: null`).
|
||||
*
|
||||
* The proxy is registered inside an ENCAPSULATED plugin scope with its own
|
||||
* catch-all content-type parser. Fastify scopes parsers to the plugin that
|
||||
* registers them, which is what lets the proxy forward raw request bodies
|
||||
* upstream while the rest of the app keeps its JSON parsing (and, critically,
|
||||
* keeps `text/plain` raw, auto-parsing that was a real CSRF hole once).
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/webviews
|
||||
* POST /api/webviews
|
||||
* PATCH /api/webviews/:id
|
||||
* DELETE /api/webviews/:id
|
||||
* POST /api/webviews/probe
|
||||
* POST /api/webviews/:id/open
|
||||
* ALL /webview/:cap/* (+ WebSocket upgrade on GET)
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { WebSocket as WsClient } from 'ws';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
MAX_LIVE_WEBVIEW_FRAMES,
|
||||
MAX_WEBVIEWS,
|
||||
MAX_WEBVIEW_HTML_REWRITE_BYTES,
|
||||
MAX_WEBVIEW_SOCKETS,
|
||||
WEBVIEW_PROBE_TIMEOUT_MS,
|
||||
WEBVIEW_PROXY_PREFIX,
|
||||
WEBVIEW_UPSTREAM_TIMEOUT_MS,
|
||||
} from '../../config/webview-limits.js';
|
||||
import { readWebviews, writeWebviews } from '../../webview-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import type { Webview, WebviewOpenData, WebviewProbe } from '../../types.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { canAccessOwned, getAuthUser, ownerFor, parseBody } from '../route-helpers.js';
|
||||
import { WebviewCreateSchema, WebviewProbeSchema, WebviewUpdateSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort } from '../ports/index.js';
|
||||
import {
|
||||
buildDownstreamResponseHeaders,
|
||||
buildProxyCorsHeaders,
|
||||
buildUpstreamRequestHeaders,
|
||||
capabilityFromReferer,
|
||||
extractFrameAncestors,
|
||||
isFramableCrossOrigin,
|
||||
isHtmlContentType,
|
||||
parseWebviewUrl,
|
||||
proxyPrefixFor,
|
||||
resolveUpstreamUrl,
|
||||
rewriteHtml,
|
||||
upstreamWebSocketUrl,
|
||||
} from '../webview-proxy.js';
|
||||
|
||||
/**
|
||||
* Resolved per call rather than captured at module load. `getDataDir()` reads
|
||||
* `CODEMAN_DATA_DIR` each time, so a lazy lookup keeps tests writing to a temp dir
|
||||
* instead of the developer's real `~/.codeman/webviews.json`.
|
||||
*/
|
||||
function configDir(): string {
|
||||
return getDataDir();
|
||||
}
|
||||
|
||||
/** Live proxied WebSockets per webview id, so one dashboard cannot exhaust the socket budget. */
|
||||
const socketCounts = new Map<string, number>();
|
||||
|
||||
interface ProxyParams {
|
||||
cap: string;
|
||||
'*'?: string;
|
||||
}
|
||||
|
||||
/** Serialize webview mutations: read-modify-write on a shared JSON file otherwise races. */
|
||||
let writeChain: Promise<unknown> = Promise.resolve();
|
||||
function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
|
||||
const next = writeChain.then(async () => {
|
||||
const list = await readWebviews(configDir());
|
||||
return fn(list);
|
||||
});
|
||||
// Keep the chain alive even if this link rejects, or every later write deadlocks.
|
||||
writeChain = next.catch(() => undefined);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort): void {
|
||||
registerCrudRoutes(app, ctx);
|
||||
registerProxyRoutes(app);
|
||||
}
|
||||
|
||||
// ───────────────────────────── CRUD ─────────────────────────────
|
||||
|
||||
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void {
|
||||
app.get('/api/webviews', async (req) => {
|
||||
const user = getAuthUser(req);
|
||||
const all = await readWebviews(configDir());
|
||||
const webviews = all.filter((w) => canAccessOwned(user, w.owner));
|
||||
return { success: true, data: { webviews, maxLiveFrames: MAX_LIVE_WEBVIEW_FRAMES } };
|
||||
});
|
||||
|
||||
app.post('/api/webviews', async (req, reply) => {
|
||||
const input = parseBody(WebviewCreateSchema, req.body);
|
||||
const owner = ownerFor(req);
|
||||
const user = getAuthUser(req);
|
||||
|
||||
const created = await withWebviews(async (list) => {
|
||||
const mine = list.filter((w) => canAccessOwned(user, w.owner));
|
||||
if (mine.length >= MAX_WEBVIEWS) return null;
|
||||
|
||||
const webview: Webview = {
|
||||
id: randomUUID(),
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
icon: input.icon,
|
||||
// Proxy is the safe default: it is the only mode that works for a plain-HTTP
|
||||
// dashboard on an HTTPS Codeman, which is the common case.
|
||||
embedMode: input.embedMode ?? 'proxy',
|
||||
trusted: input.trusted ?? false,
|
||||
owner,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
list.push(webview);
|
||||
await writeWebviews(configDir(), list);
|
||||
return webview;
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `Webview limit reached (max ${MAX_WEBVIEWS})`));
|
||||
}
|
||||
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id });
|
||||
return { success: true, data: created };
|
||||
});
|
||||
|
||||
app.patch<{ Params: { id: string } }>('/api/webviews/:id', async (req, reply) => {
|
||||
const input = parseBody(WebviewUpdateSchema, req.body);
|
||||
const user = getAuthUser(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const updated = await withWebviews(async (list) => {
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index === -1) return 'not-found' as const;
|
||||
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
|
||||
|
||||
const next: Webview = { ...list[index], ...input };
|
||||
list[index] = next;
|
||||
await writeWebviews(configDir(), list);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (updated === 'not-found') {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
|
||||
}
|
||||
if (updated === 'forbidden') {
|
||||
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
|
||||
}
|
||||
|
||||
// Any edit invalidates the outstanding capability. Otherwise a token minted
|
||||
// against the OLD url keeps proxying to it after the user repointed the tab.
|
||||
webviewCapabilities.revokeWebview(id);
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'updated', id });
|
||||
return { success: true, data: updated };
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/webviews/:id', async (req, reply) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await withWebviews(async (list) => {
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index === -1) return 'not-found' as const;
|
||||
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
|
||||
list.splice(index, 1);
|
||||
await writeWebviews(configDir(), list);
|
||||
return 'deleted' as const;
|
||||
});
|
||||
|
||||
if (result === 'not-found') {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
|
||||
}
|
||||
if (result === 'forbidden') {
|
||||
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
|
||||
}
|
||||
|
||||
webviewCapabilities.revokeWebview(id);
|
||||
socketCounts.delete(id);
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'deleted', id });
|
||||
return { success: true, data: { id } };
|
||||
});
|
||||
|
||||
/**
|
||||
* Reachability + framing probe for the editor's "Test" button.
|
||||
*
|
||||
* Runs from the SERVER, which is the network position the proxy will use, so a
|
||||
* green result here means the proxy will actually work. Never throws upstream
|
||||
* failures at the caller: an unreachable dashboard is a normal answer, not a 500.
|
||||
*/
|
||||
app.post('/api/webviews/probe', async (req) => {
|
||||
const { url } = parseBody(WebviewProbeSchema, req.body);
|
||||
return { success: true, data: await probeUrl(url) };
|
||||
});
|
||||
|
||||
/**
|
||||
* Mint the capability the iframe will load. Separate from GET /api/webviews so a
|
||||
* capability exists only for dashboards actually opened, and so the TTL clock
|
||||
* starts on open rather than on page load.
|
||||
*/
|
||||
app.post<{ Params: { id: string } }>('/api/webviews/:id/open', async (req, reply) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const webview = await withWebviews(async (list) => {
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index === -1) return 'not-found' as const;
|
||||
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
|
||||
list[index] = { ...list[index], lastOpenedAt: Date.now() };
|
||||
await writeWebviews(configDir(), list);
|
||||
return list[index];
|
||||
});
|
||||
|
||||
if (webview === 'not-found') {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
|
||||
}
|
||||
if (webview === 'forbidden') {
|
||||
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
|
||||
}
|
||||
|
||||
// Direct mode has no capability to mint: the iframe loads the real URL.
|
||||
if (webview.embedMode === 'direct') {
|
||||
const data: WebviewOpenData = { webview };
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
const capability = webviewCapabilities.mint(webview.id, webview.owner);
|
||||
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability) };
|
||||
return { success: true, data };
|
||||
});
|
||||
}
|
||||
|
||||
async function probeUrl(url: string): Promise<WebviewProbe> {
|
||||
const target = parseWebviewUrl(url);
|
||||
if (!target) {
|
||||
return {
|
||||
reachable: false,
|
||||
framable: false,
|
||||
recommendedMode: 'proxy',
|
||||
reason: 'Invalid URL',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(target.href, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(WEBVIEW_PROBE_TIMEOUT_MS),
|
||||
});
|
||||
// The body is irrelevant to the probe; release the socket rather than leak it.
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
|
||||
const xFrameOptions = response.headers.get('x-frame-options') ?? undefined;
|
||||
const csp = response.headers.get('content-security-policy') ?? undefined;
|
||||
const frameAncestors = extractFrameAncestors(csp);
|
||||
const framable = isFramableCrossOrigin(xFrameOptions, csp);
|
||||
const isHttp = target.protocol === 'http:';
|
||||
|
||||
// Direct embedding is only viable for an HTTPS target that permits framing:
|
||||
// an HTTPS Codeman page cannot embed http:// at all (mixed content).
|
||||
const recommendedMode = !isHttp && framable ? 'direct' : 'proxy';
|
||||
const reason = isHttp
|
||||
? 'Plain HTTP: an HTTPS Codeman page cannot embed it directly, so it is proxied.'
|
||||
: framable
|
||||
? 'Reachable and allows framing: can be embedded directly.'
|
||||
: 'Reachable but refuses framing, so it is proxied.';
|
||||
|
||||
return {
|
||||
reachable: true,
|
||||
status: response.status,
|
||||
xFrameOptions,
|
||||
frameAncestors,
|
||||
framable,
|
||||
recommendedMode,
|
||||
reason,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
reachable: false,
|
||||
framable: false,
|
||||
recommendedMode: 'proxy',
|
||||
reason: `Server could not reach it: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── Proxy ─────────────────────────────
|
||||
|
||||
function registerProxyRoutes(app: FastifyInstance): void {
|
||||
app.register(async (scope) => {
|
||||
// Encapsulated to this plugin only. The proxy must relay request bodies
|
||||
// BYTE-FOR-BYTE, so every parser is replaced with a pass-through that hands
|
||||
// back the raw stream. Doing this on the root instance would break JSON
|
||||
// routes and un-fix the text/plain CSRF hardening.
|
||||
scope.removeAllContentTypeParsers();
|
||||
scope.addContentTypeParser('*', (_req, payload, done) => done(null, payload));
|
||||
|
||||
// A single GET route serving both roles: `handler` for normal requests,
|
||||
// `wsHandler` for upgrades. Registering them as two routes on one URL would
|
||||
// collide.
|
||||
scope.route<{ Params: ProxyParams }>({
|
||||
method: 'GET',
|
||||
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
|
||||
handler: proxyHttp,
|
||||
wsHandler: proxyWebSocket,
|
||||
});
|
||||
|
||||
// HEAD is deliberately absent: Fastify's `exposeHeadRoutes` already derives a
|
||||
// HEAD route from the GET above, and declaring it again is a startup error.
|
||||
scope.route<{ Params: ProxyParams }>({
|
||||
method: ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
|
||||
handler: proxyHttp,
|
||||
});
|
||||
|
||||
// `/webview/<cap>` with no trailing slash: redirect rather than serve, so the
|
||||
// browser's notion of the base path ends in `/` and relative URLs in the
|
||||
// dashboard's HTML resolve inside the prefix instead of one level above it.
|
||||
scope.get<{ Params: { cap: string } }>(`${WEBVIEW_PROXY_PREFIX}/:cap`, (req, reply) => {
|
||||
return reply.redirect(proxyPrefixFor(req.params.cap), 302);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve a capability to its live webview record, or null. */
|
||||
async function lookupCapability(capability: string): Promise<Webview | null> {
|
||||
const record = webviewCapabilities.resolve(capability);
|
||||
if (!record) return null;
|
||||
const list = await readWebviews(configDir());
|
||||
const webview = list.find((w) => w.id === record.webviewId);
|
||||
if (!webview) return null;
|
||||
// The capability is bound to the identity that minted it; an ownership change
|
||||
// on the record must not leave a stale token working.
|
||||
if (webview.owner !== record.owner) return null;
|
||||
return webview;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠ Every exit path RETURNS `reply.send(...)`.
|
||||
*
|
||||
* This handler is `async`, and Fastify resolves an async handler's promise as the
|
||||
* response. `reply.send(stream)` followed by a bare `return` resolves to
|
||||
* `undefined` before the stream has been consumed, and Fastify then answers with
|
||||
* an EMPTY body: HTML (a synchronously-set string payload) survives it, every
|
||||
* streamed asset comes back zero-length. Returning the reply is what tells Fastify
|
||||
* the response is already owned by this handler.
|
||||
*/
|
||||
function proxyHttp(req: FastifyRequest<{ Params: ProxyParams }>, reply: FastifyReply): Promise<FastifyReply> {
|
||||
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy one request to the dashboard behind `cap`, serving `wildcard` as the
|
||||
* upstream path. Split out from the route handler so the 404 fallback (which has
|
||||
* no route params) can reuse it.
|
||||
*/
|
||||
async function proxyRequest(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
cap: string,
|
||||
wildcard: string
|
||||
): Promise<FastifyReply> {
|
||||
const webview = await lookupCapability(cap);
|
||||
if (!webview) {
|
||||
return reply.code(403).type('text/plain').send('Forbidden: unknown or expired webview capability');
|
||||
}
|
||||
|
||||
// CORS is required even though the URL is on this host: a sandboxed dashboard is
|
||||
// opaque-origin, so its fetch/XHR are cross-origin requests. See
|
||||
// buildProxyCorsHeaders.
|
||||
const cors = buildProxyCorsHeaders(
|
||||
typeof req.headers.origin === 'string' ? req.headers.origin : undefined,
|
||||
typeof req.headers['access-control-request-headers'] === 'string'
|
||||
? req.headers['access-control-request-headers']
|
||||
: undefined
|
||||
);
|
||||
|
||||
// Answer the preflight here rather than relaying it: the dashboard has no reason
|
||||
// to know it is being framed, and most would reject an unexpected `Origin: null`.
|
||||
if (req.method === 'OPTIONS' && req.headers['access-control-request-method']) {
|
||||
for (const [key, value] of Object.entries(cors)) reply.header(key, value);
|
||||
return reply.code(204).send();
|
||||
}
|
||||
|
||||
const queryStart = req.url.indexOf('?');
|
||||
const search = queryStart === -1 ? '' : req.url.slice(queryStart);
|
||||
const upstream = resolveUpstreamUrl(webview.url, wildcard, search);
|
||||
if (!upstream) {
|
||||
return reply.code(400).type('text/plain').send('Bad Request: path escapes the dashboard origin');
|
||||
}
|
||||
|
||||
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
||||
const headers = buildUpstreamRequestHeaders(req.headers, upstream, {
|
||||
forwardCookies: webview.trusted,
|
||||
sessionCookieName: AUTH_COOKIE_NAME,
|
||||
refererPath: typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap) : undefined,
|
||||
});
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(upstream.href, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: hasBody ? (req.body as Readable) : undefined,
|
||||
// Required by undici whenever the body is a stream.
|
||||
...(hasBody ? { duplex: 'half' } : {}),
|
||||
// Redirects are rewritten into the proxy prefix instead of followed, so the
|
||||
// browser's URL stays inside the frame and relative assets keep resolving.
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(WEBVIEW_UPSTREAM_TIMEOUT_MS),
|
||||
} as RequestInit);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return reply.code(502).type('text/plain').send(`Dashboard unreachable: ${message}`);
|
||||
}
|
||||
|
||||
const secureContext = req.protocol === 'https';
|
||||
const {
|
||||
headers: outHeaders,
|
||||
setCookie,
|
||||
csp,
|
||||
} = buildDownstreamResponseHeaders(
|
||||
response.headers as unknown as Iterable<[string, string]>,
|
||||
response.headers.getSetCookie(),
|
||||
cap,
|
||||
upstream,
|
||||
secureContext
|
||||
);
|
||||
|
||||
reply.code(response.status);
|
||||
for (const [key, value] of Object.entries(outHeaders)) reply.header(key, value);
|
||||
// After the upstream headers, so ours win: an upstream ACAO would name the
|
||||
// dashboard's own origin, not the opaque origin this frame actually has.
|
||||
for (const [key, value] of Object.entries(cors)) reply.header(key, value);
|
||||
for (const cookie of setCookie) reply.header('set-cookie', cookie);
|
||||
|
||||
// registerSecurityHeaders already stamped Codeman's own `default-src 'self'`
|
||||
// policy on this reply during onRequest. Left in place it breaks essentially
|
||||
// every dashboard (inline scripts, CDN assets), so it is replaced by the
|
||||
// upstream's own policy, or removed when the upstream had none.
|
||||
if (csp) reply.header('content-security-policy', csp);
|
||||
else reply.removeHeader('content-security-policy');
|
||||
|
||||
if (!response.body || req.method === 'HEAD') {
|
||||
return reply.send();
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? undefined;
|
||||
const declaredLength = Number(response.headers.get('content-length') ?? '0');
|
||||
const rewritable = isHtmlContentType(contentType) && declaredLength <= MAX_WEBVIEW_HTML_REWRITE_BYTES;
|
||||
|
||||
if (rewritable) {
|
||||
// Buffer only HTML, only under the cap: `<base>` injection needs the whole
|
||||
// document, and buffering an unbounded upstream body is a memory hazard.
|
||||
const html = await response.text();
|
||||
return reply.send(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap) : html);
|
||||
}
|
||||
|
||||
return reply.send(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort handler for a dashboard asset requested with a ROOT-ABSOLUTE URL.
|
||||
*
|
||||
* `<base href>` fixes relative URLs and the HTML rewrite fixes `src`/`href`/`action`
|
||||
* attributes, but neither can reach a URL built at runtime: `fetch('/api/data')`,
|
||||
* `import('/chunk.js')`, `url(/img.png)` inside a stylesheet. Those arrive at
|
||||
* Codeman's root and 404.
|
||||
*
|
||||
* The `Referer` identifies which dashboard asked, so the request can be routed to
|
||||
* the right upstream. Wiring it into the 404 handler rather than a catch-all route
|
||||
* is what keeps it contained: every real Codeman route matches first, and this only
|
||||
* ever sees requests that were going to fail anyway.
|
||||
*
|
||||
* @returns true when the request was handled (caller must not also reply).
|
||||
*/
|
||||
export async function tryWebviewRefererFallback(req: FastifyRequest, reply: FastifyReply): Promise<boolean> {
|
||||
// Safe methods only. A write arriving here has already lost its raw body to the
|
||||
// root instance's JSON parser, so it could not be relayed faithfully anyway.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
||||
|
||||
const capability = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
|
||||
if (!capability) return false;
|
||||
if (!webviewCapabilities.resolve(capability)) return false;
|
||||
|
||||
const path = req.url.split('?')[0].replace(/^\//, '');
|
||||
await proxyRequest(req, reply, capability, path);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Turn a proxy-side Referer back into the upstream path it corresponds to. */
|
||||
function stripProxyPrefix(referer: string, capability: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
const prefix = proxyPrefixFor(capability);
|
||||
if (!url.pathname.startsWith(prefix)) return undefined;
|
||||
return `/${url.pathname.slice(prefix.length)}${url.search}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── WebSocket ───────────────────────────
|
||||
|
||||
/**
|
||||
* Relay a WebSocket through to the dashboard.
|
||||
*
|
||||
* Live dashboards (Grafana, Home Assistant, Uptime Kuma) push over WebSocket, so
|
||||
* without this leg they load but their realtime panels stay permanently empty.
|
||||
*
|
||||
* The upgrade is guarded on the capability, NOT on `Origin`: a sandboxed iframe is
|
||||
* opaque-origin, so its upgrade arrives with `Origin: null`. The host allowlist
|
||||
* still applies (it runs in the global onRequest hook), so DNS-rebinding
|
||||
* protection is unaffected.
|
||||
*/
|
||||
function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyParams }>): void {
|
||||
const { cap } = req.params;
|
||||
|
||||
void (async () => {
|
||||
const webview = await lookupCapability(cap);
|
||||
if (!webview) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const live = socketCounts.get(webview.id) ?? 0;
|
||||
if (live >= MAX_WEBVIEW_SOCKETS) {
|
||||
socket.close(4008, 'Too many connections');
|
||||
return;
|
||||
}
|
||||
|
||||
const wildcard = req.params['*'] ?? '';
|
||||
const queryStart = req.url.indexOf('?');
|
||||
const search = queryStart === -1 ? '' : req.url.slice(queryStart);
|
||||
const upstream = resolveUpstreamUrl(webview.url, wildcard, search);
|
||||
if (!upstream) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
socketCounts.set(webview.id, live + 1);
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const count = socketCounts.get(webview.id) ?? 1;
|
||||
if (count <= 1) socketCounts.delete(webview.id);
|
||||
else socketCounts.set(webview.id, count - 1);
|
||||
};
|
||||
|
||||
const protocols = req.headers['sec-websocket-protocol'];
|
||||
const upstreamSocket = new WsClient(
|
||||
upstreamWebSocketUrl(upstream),
|
||||
protocols ? String(protocols).split(/,\s*/) : [],
|
||||
{
|
||||
headers: {
|
||||
origin: upstream.origin,
|
||||
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
|
||||
},
|
||||
handshakeTimeout: WEBVIEW_UPSTREAM_TIMEOUT_MS,
|
||||
}
|
||||
);
|
||||
|
||||
// Buffer anything the browser sends before the upstream handshake completes,
|
||||
// rather than dropping it: a client that sends a subscribe frame immediately
|
||||
// would otherwise sit connected and silent forever.
|
||||
const pending: Array<Buffer | string> = [];
|
||||
let upstreamOpen = false;
|
||||
|
||||
upstreamSocket.on('open', () => {
|
||||
upstreamOpen = true;
|
||||
for (const message of pending) upstreamSocket.send(message);
|
||||
pending.length = 0;
|
||||
});
|
||||
|
||||
socket.on('message', (data: Buffer, isBinary: boolean) => {
|
||||
const payload = isBinary ? data : data.toString();
|
||||
if (upstreamOpen) upstreamSocket.send(payload);
|
||||
else if (pending.length < 64) pending.push(payload);
|
||||
});
|
||||
|
||||
upstreamSocket.on('message', (data: Buffer, isBinary: boolean) => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(isBinary ? data : data.toString());
|
||||
});
|
||||
|
||||
// Paired close in both directions, so neither side is left half-open.
|
||||
const closeBoth = (code?: number, reason?: string) => {
|
||||
release();
|
||||
// Codes outside 3000-4999 (and 1000/1001) are not valid to send onward.
|
||||
const safeCode = code && code >= 3000 && code <= 4999 ? code : 1000;
|
||||
if (socket.readyState === socket.OPEN) socket.close(safeCode, reason);
|
||||
if (upstreamSocket.readyState === WsClient.OPEN || upstreamSocket.readyState === WsClient.CONNECTING) {
|
||||
upstreamSocket.close(safeCode, reason);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||
upstreamSocket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||
socket.on('error', () => closeBoth());
|
||||
upstreamSocket.on('error', () => {
|
||||
release();
|
||||
if (socket.readyState === socket.OPEN) socket.close(1011, 'Upstream error');
|
||||
});
|
||||
})();
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { z } from 'zod';
|
||||
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
|
||||
import { isValidWebviewUrl } from './webview-proxy.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_BYTES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
@@ -49,6 +50,39 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
|
||||
message: 'Invalid path: must be absolute, no shell metacharacters or traversal',
|
||||
});
|
||||
|
||||
/**
|
||||
* Filesystem picker paths are never interpolated into a shell command, so legal
|
||||
* filename characters such as spaces, quotes, and parentheses are accepted.
|
||||
* Containment and symlink resolution are enforced by the route after parsing.
|
||||
*/
|
||||
const filesystemPickerPathSchema = z
|
||||
.string()
|
||||
.max(4096)
|
||||
.refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), {
|
||||
message: 'Path must be an absolute filesystem path',
|
||||
})
|
||||
.refine((p) => !p.split('/').includes('..'), { message: 'Path traversal is not allowed' });
|
||||
|
||||
/** Query validation for the lazy, allowlisted filesystem path picker. */
|
||||
export const FilesystemBrowseQuerySchema = z.object({
|
||||
path: filesystemPickerPathSchema.optional(),
|
||||
sessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Query validation for a single allowlisted path-picker file preview. */
|
||||
export const FilesystemPreviewQuerySchema = z.object({
|
||||
path: filesystemPickerPathSchema,
|
||||
sessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ========== Env Var Allowlist ==========
|
||||
|
||||
/** Allowlisted env var key prefixes */
|
||||
@@ -1184,3 +1218,42 @@ export const SearchQuerySchema = z.object({
|
||||
),
|
||||
limit: z.coerce.number().int().min(1).max(60).optional(),
|
||||
});
|
||||
|
||||
// ========== Web Tabs (dashboard URLs) ==========
|
||||
|
||||
/**
|
||||
* A dashboard URL. `isValidWebviewUrl` rejects anything that is not plain
|
||||
* http/https, anything carrying embedded credentials, and anything without a
|
||||
* hostname. See `src/web/webview-proxy.ts` for why each of those matters.
|
||||
*/
|
||||
const webviewUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'URL is required')
|
||||
.max(2000, 'URL too long (max 2000 chars)')
|
||||
.refine(isValidWebviewUrl, {
|
||||
message: 'Invalid URL: must be http(s), with a hostname and no embedded credentials',
|
||||
});
|
||||
|
||||
const WebviewBaseSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required').max(60, 'Name too long (max 60 chars)'),
|
||||
url: webviewUrlSchema,
|
||||
/** A single glyph shown on the tab. Bounded generously: one emoji can be several code units. */
|
||||
icon: z.string().max(8).optional(),
|
||||
embedMode: z.enum(['proxy', 'direct']).optional(),
|
||||
/**
|
||||
* Opt out of the iframe sandbox. Defaults to false: a proxied page is served
|
||||
* from Codeman's own origin, so `allow-same-origin` would let it read this page
|
||||
* and call the API that spawns agents.
|
||||
*/
|
||||
trusted: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** POST /api/webviews */
|
||||
export const WebviewCreateSchema = WebviewBaseSchema;
|
||||
|
||||
/** PATCH /api/webviews/:id, partial update. */
|
||||
export const WebviewUpdateSchema = WebviewBaseSchema.partial();
|
||||
|
||||
/** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */
|
||||
export const WebviewProbeSchema = z.object({ url: webviewUrlSchema });
|
||||
|
||||
@@ -160,6 +160,8 @@ import {
|
||||
registerMeRoutes,
|
||||
registerAdminRoutes,
|
||||
registerWsRoutes,
|
||||
registerWebviewRoutes,
|
||||
tryWebviewRefererFallback,
|
||||
} from './routes/index.js';
|
||||
import { CronService } from '../cron/cron-service.js';
|
||||
|
||||
@@ -851,13 +853,24 @@ export class WebServer extends EventEmitter {
|
||||
// Stable-contract 404 for unknown /api routes — without this, Fastify's
|
||||
// default not-found payload {message,error,statusCode} would be wrapped by
|
||||
// the envelope hook into a contradictory HTTP 404 {success:true,...}.
|
||||
this.app.setNotFoundHandler((req, reply) => {
|
||||
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')) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
|
||||
return;
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
|
||||
}
|
||||
reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
|
||||
return reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
|
||||
});
|
||||
|
||||
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them.
|
||||
@@ -925,6 +938,7 @@ export class WebServer extends EventEmitter {
|
||||
registerMeRoutes(this.app, ctx);
|
||||
registerAdminRoutes(this.app, ctx);
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
registerWebviewRoutes(this.app, ctx);
|
||||
|
||||
// Cron: build the service from the same context, recompute
|
||||
// due times for any persisted jobs, then expose it to its routes.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||
* Both files MUST be kept in sync.
|
||||
*
|
||||
* 148 event constants organized by category:
|
||||
* 149 event constants organized by category:
|
||||
* - **Core** (1): init
|
||||
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||
@@ -30,6 +30,7 @@
|
||||
* - **Cases** (4): created, linked, deleted, order-changed
|
||||
* - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated
|
||||
* - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged
|
||||
* - **Web tabs** (1): webview:changed
|
||||
*
|
||||
* Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`)
|
||||
*
|
||||
@@ -413,6 +414,11 @@ export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const
|
||||
/** Global session tab order changed (synced across devices). COD-131. */
|
||||
export const SessionOrderChanged = 'session:orderChanged' as const;
|
||||
|
||||
/** A saved web tab (dashboard URL) was created, updated or deleted.
|
||||
* Payload: `{ action: 'created' | 'updated' | 'deleted', id }`. The client
|
||||
* re-fetches the list rather than patching from the payload. */
|
||||
export const WebviewChanged = 'webview:changed' as const;
|
||||
|
||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -615,4 +621,7 @@ export const SseEvent = {
|
||||
|
||||
// Session order (global tab order sync)
|
||||
SessionOrderChanged,
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
WebviewChanged,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* @fileoverview Pure helpers for the web-tab reverse proxy. No I/O, no Fastify.
|
||||
*
|
||||
* The proxy exists because an iframe pointing straight at a dashboard cannot work
|
||||
* in the deployment that matters: prod serves HTTPS (behind `tailscale serve`), so
|
||||
* a plain-HTTP dashboard is hard-blocked as mixed content; many dashboards also
|
||||
* refuse framing outright via `X-Frame-Options` / `frame-ancestors`; and Codeman's
|
||||
* own CSP (`default-src 'self'`) blocks cross-origin frames anyway. Serving the
|
||||
* dashboard through Codeman's own origin dissolves all three at once, and keeps
|
||||
* the production CSP byte-for-byte unchanged because `/webview/...` is `'self'`.
|
||||
*
|
||||
* ## Origin-scoped, not path-scoped
|
||||
*
|
||||
* `/webview/<cap>/x/y` always maps to `<upstream origin>/x/y`, never to
|
||||
* `<upstream origin><saved path>/x/y`. Dashboards reference assets with
|
||||
* root-absolute paths (`/public/build/app.js`), so origin-scoping is the only
|
||||
* mapping under which those resolve. The saved URL's own path+query is used for
|
||||
* exactly one thing: what `/webview/<cap>/` itself serves (the landing page).
|
||||
*
|
||||
* ## What gets rewritten, and why each one is load-bearing
|
||||
*
|
||||
* - `x-frame-options` / CSP `frame-ancestors`: dropped, else the browser refuses
|
||||
* to render the frame. This is the whole point of the proxy.
|
||||
* - `content-encoding` / `content-length`: dropped, because undici's `fetch`
|
||||
* already decoded the body. Forwarding them makes the browser try to gunzip
|
||||
* plaintext.
|
||||
* - `authorization` + the `codeman_session` cookie: stripped on the way OUT. In
|
||||
* trusted mode the iframe is same-origin, so the browser attaches Codeman's own
|
||||
* Basic-auth header and session cookie to every proxied request. Forwarding
|
||||
* those would hand CODEMAN_PASSWORD to the dashboard.
|
||||
* - `Location` and `Set-Cookie`: remapped into the proxy path, else a redirect or
|
||||
* a login cookie escapes the prefix and lands on Codeman's root.
|
||||
* - `<base href>` + root-absolute attribute rewriting: relative and `/`-rooted
|
||||
* URLs in the HTML resolve back through the proxy instead of hitting Codeman.
|
||||
*
|
||||
* No `X-Forwarded-*` is sent deliberately: apps that honor it generate absolute
|
||||
* URLs against Codeman's root, which would bypass the `/webview/<cap>/` prefix
|
||||
* that everything else here works to preserve.
|
||||
*/
|
||||
|
||||
import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js';
|
||||
|
||||
/** Headers that are per-connection and must never be relayed in either direction. */
|
||||
const HOP_BY_HOP = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'proxy-connection',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Request headers dropped on the way to the upstream. `authorization` and `cookie`
|
||||
* carry Codeman's own credentials on a same-origin (trusted) frame; `host`,
|
||||
* `content-length` and `accept-encoding` are recomputed by undici.
|
||||
*/
|
||||
const DROP_REQUEST_HEADERS = new Set([
|
||||
...HOP_BY_HOP,
|
||||
'host',
|
||||
'content-length',
|
||||
'accept-encoding',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'origin',
|
||||
'referer',
|
||||
'x-codeman-hook-secret',
|
||||
]);
|
||||
|
||||
/** Response headers dropped on the way back to the browser. */
|
||||
const DROP_RESPONSE_HEADERS = new Set([
|
||||
...HOP_BY_HOP,
|
||||
'content-encoding',
|
||||
'content-length',
|
||||
'x-frame-options',
|
||||
'content-security-policy-report-only',
|
||||
// Cross-origin isolation headers describe the UPSTREAM's origin policy; applied
|
||||
// to a frame on Codeman's origin they only produce blocked-resource surprises.
|
||||
'cross-origin-opener-policy',
|
||||
'cross-origin-embedder-policy',
|
||||
'cross-origin-resource-policy',
|
||||
'set-cookie',
|
||||
'location',
|
||||
'content-security-policy',
|
||||
// The upstream's CORS answer describes ITS origin; the frame asking is
|
||||
// opaque-origin on ours, so ours must replace it (see buildProxyCorsHeaders).
|
||||
'access-control-allow-origin',
|
||||
'access-control-allow-credentials',
|
||||
'access-control-allow-methods',
|
||||
'access-control-allow-headers',
|
||||
'access-control-expose-headers',
|
||||
'access-control-max-age',
|
||||
]);
|
||||
|
||||
/** The same-origin path prefix an iframe loads for a given capability. */
|
||||
export function proxyPrefixFor(capability: string): string {
|
||||
return `${WEBVIEW_PROXY_PREFIX}/${capability}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a user-supplied dashboard URL.
|
||||
*
|
||||
* Rejects everything that is not plain `http:`/`https:`, anything carrying
|
||||
* embedded credentials (they would be silently forwarded and logged), and
|
||||
* anything without a hostname. Returns the normalized `URL` or null.
|
||||
*/
|
||||
export function parseWebviewUrl(raw: string): URL | null {
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
if (url.username !== '' || url.password !== '') return null;
|
||||
if (!url.hostname) return null;
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Convenience predicate for Zod refinements. */
|
||||
export function isValidWebviewUrl(raw: string): boolean {
|
||||
return parseWebviewUrl(raw) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a proxy request path to its upstream URL.
|
||||
*
|
||||
* `wildcard` is Fastify's `*` param: the path after `/webview/<cap>/`, without a
|
||||
* leading slash. An empty wildcard means the landing page, which is the saved
|
||||
* URL's own path and query.
|
||||
*
|
||||
* Returns null when the result would escape the upstream origin (a `..` chain, a
|
||||
* protocol-relative `//evil.com` wildcard, or an absolute URL smuggled into the
|
||||
* path). That check is what keeps this from being an open proxy.
|
||||
*/
|
||||
export function resolveUpstreamUrl(savedUrl: string, wildcard: string, search: string): URL | null {
|
||||
const base = parseWebviewUrl(savedUrl);
|
||||
if (!base) return null;
|
||||
|
||||
if (wildcard === '' || wildcard === '/') {
|
||||
const landing = new URL(base.pathname + (search || base.search), base.origin);
|
||||
return landing.origin === base.origin ? landing : null;
|
||||
}
|
||||
|
||||
// A wildcard starting with `//` would parse as protocol-relative and jump host.
|
||||
const path = wildcard.startsWith('/') ? wildcard : `/${wildcard}`;
|
||||
if (path.startsWith('//')) return null;
|
||||
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(path + (search || ''), base.origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return target.origin === base.origin ? target : null;
|
||||
}
|
||||
|
||||
/** Extract the capability from a `/webview/<cap>/...` pathname, or null. */
|
||||
export function capabilityFromProxyPath(pathname: string): string | null {
|
||||
if (typeof pathname !== 'string') return null;
|
||||
const prefix = `${WEBVIEW_PROXY_PREFIX}/`;
|
||||
if (!pathname.startsWith(prefix)) return null;
|
||||
const rest = pathname.slice(prefix.length);
|
||||
const slash = rest.indexOf('/');
|
||||
const cap = slash === -1 ? rest : rest.slice(0, slash);
|
||||
return /^[A-Za-z0-9_-]{16,128}$/.test(cap) ? cap : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the capability a `Referer` belongs to. Backs the 404 fallback that
|
||||
* catches root-absolute asset requests (`/static/app.js`) which `<base>` cannot fix.
|
||||
*/
|
||||
export function capabilityFromReferer(referer: string | undefined): string | null {
|
||||
if (!referer) return null;
|
||||
try {
|
||||
return capabilityFromProxyPath(new URL(referer).pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the `frame-ancestors` directive from a CSP, preserving the rest. */
|
||||
export function stripFrameAncestors(csp: string): string {
|
||||
return csp
|
||||
.split(';')
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d !== '' && !/^frame-ancestors\b/i.test(d))
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/** The `frame-ancestors` directive value from a CSP, or undefined. */
|
||||
export function extractFrameAncestors(csp: string | undefined): string | undefined {
|
||||
if (!csp) return undefined;
|
||||
for (const directive of csp.split(';')) {
|
||||
const trimmed = directive.trim();
|
||||
if (/^frame-ancestors\b/i.test(trimmed)) {
|
||||
return trimmed.slice('frame-ancestors'.length).trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a target permits being framed by a different origin, judged from its
|
||||
* `X-Frame-Options` and CSP. Used only to recommend proxy vs direct mode in the
|
||||
* editor; the proxy path works either way.
|
||||
*/
|
||||
export function isFramableCrossOrigin(xFrameOptions: string | undefined, csp: string | undefined): boolean {
|
||||
const xfo = xFrameOptions?.trim().toLowerCase();
|
||||
if (xfo === 'deny' || xfo === 'sameorigin') return false;
|
||||
const ancestors = extractFrameAncestors(csp)?.toLowerCase();
|
||||
if (ancestors === undefined) return true;
|
||||
if (ancestors.includes("'none'")) return false;
|
||||
// 'self' alone means same-origin only, which a cross-origin embed is not.
|
||||
if (ancestors === "'self'") return false;
|
||||
return ancestors.includes('*') || ancestors.includes('http');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite an upstream `Location` into the proxy path.
|
||||
*
|
||||
* Same-origin redirects (relative or absolute) are remapped so the browser stays
|
||||
* inside the frame. Cross-origin redirects are returned unchanged rather than
|
||||
* proxied: relaying them would turn this into an open proxy for any host the
|
||||
* upstream chooses to name.
|
||||
*/
|
||||
export function rewriteLocation(location: string, requestUrl: URL, capability: string): string {
|
||||
let resolved: URL;
|
||||
try {
|
||||
resolved = new URL(location, requestUrl);
|
||||
} catch {
|
||||
return location;
|
||||
}
|
||||
if (resolved.origin !== requestUrl.origin) return location;
|
||||
const suffix = resolved.pathname.replace(/^\//, '');
|
||||
return `${proxyPrefixFor(capability)}${suffix}${resolved.search}${resolved.hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite an upstream `Set-Cookie` so it applies to the proxy path only.
|
||||
*
|
||||
* `Domain` is dropped (the cookie now belongs to Codeman's host), `Path` is
|
||||
* rebased onto the proxy prefix so two dashboards cannot collide on a shared
|
||||
* cookie name, and `Secure` is dropped when Codeman itself is serving plain HTTP
|
||||
* in dev, where a Secure cookie would simply be discarded.
|
||||
*/
|
||||
export function rewriteSetCookie(cookie: string, capability: string, secureContext: boolean): string {
|
||||
const parts = cookie.split(';');
|
||||
const out: string[] = [parts[0]];
|
||||
let sawPath = false;
|
||||
|
||||
for (const raw of parts.slice(1)) {
|
||||
const attr = raw.trim();
|
||||
const lower = attr.toLowerCase();
|
||||
if (lower.startsWith('domain=')) continue;
|
||||
if (lower === 'secure' && !secureContext) continue;
|
||||
if (lower.startsWith('path=')) {
|
||||
sawPath = true;
|
||||
const value = attr.slice('path='.length);
|
||||
const suffix = value.replace(/^\//, '');
|
||||
out.push(`Path=${proxyPrefixFor(capability)}${suffix}`);
|
||||
continue;
|
||||
}
|
||||
out.push(attr);
|
||||
}
|
||||
|
||||
if (!sawPath) out.push(`Path=${proxyPrefixFor(capability)}`);
|
||||
return out.join('; ');
|
||||
}
|
||||
|
||||
/** Drop named cookies from a `Cookie` request header, returning undefined if none remain. */
|
||||
export function filterCookieHeader(cookie: string | undefined, drop: string[]): string | undefined {
|
||||
if (!cookie) return undefined;
|
||||
const dropSet = new Set(drop.map((n) => n.toLowerCase()));
|
||||
const kept = cookie
|
||||
.split(';')
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c !== '' && !dropSet.has(c.slice(0, c.indexOf('=')).trim().toLowerCase()));
|
||||
return kept.length > 0 ? kept.join('; ') : undefined;
|
||||
}
|
||||
|
||||
/** Build the header set sent upstream, from the browser's request headers. */
|
||||
export function buildUpstreamRequestHeaders(
|
||||
incoming: Record<string, string | string[] | undefined>,
|
||||
upstream: URL,
|
||||
opts: { forwardCookies: boolean; sessionCookieName: string; refererPath?: string }
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
const lower = key.toLowerCase();
|
||||
if (DROP_REQUEST_HEADERS.has(lower)) continue;
|
||||
if (value === undefined) continue;
|
||||
headers[lower] = Array.isArray(value) ? value.join(', ') : value;
|
||||
}
|
||||
|
||||
if (opts.forwardCookies) {
|
||||
const raw = incoming['cookie'];
|
||||
const cookie = filterCookieHeader(Array.isArray(raw) ? raw.join('; ') : raw, [opts.sessionCookieName]);
|
||||
if (cookie) headers['cookie'] = cookie;
|
||||
}
|
||||
|
||||
// Present as if the browser were talking to the dashboard directly. Apps that
|
||||
// check Origin on writes (CSRF defenses) need this to match their own origin.
|
||||
headers['origin'] = upstream.origin;
|
||||
headers['referer'] = opts.refererPath ? new URL(opts.refererPath, upstream.origin).href : upstream.href;
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the response headers sent to the browser.
|
||||
*
|
||||
* Also returns the CSP to apply: the upstream's, minus `frame-ancestors`. Callers
|
||||
* MUST set (or explicitly clear) this, because `registerSecurityHeaders` has
|
||||
* already stamped Codeman's own `default-src 'self'` policy onto the reply, and
|
||||
* that policy would break virtually every dashboard.
|
||||
*/
|
||||
export function buildDownstreamResponseHeaders(
|
||||
upstreamHeaders: Iterable<[string, string]>,
|
||||
/**
|
||||
* Upstream `Set-Cookie` values, already separated. Passed in rather than read
|
||||
* from `upstreamHeaders` because iterating a `Headers` object JOINS duplicate
|
||||
* set-cookie values into one comma-separated string, which cannot be split back
|
||||
* apart reliably (Expires dates contain commas). Callers use
|
||||
* `response.headers.getSetCookie()`.
|
||||
*/
|
||||
setCookies: string[],
|
||||
capability: string,
|
||||
requestUrl: URL,
|
||||
secureContext: boolean
|
||||
): { headers: Record<string, string>; setCookie: string[]; csp: string | null } {
|
||||
const headers: Record<string, string> = {};
|
||||
let csp: string | null = null;
|
||||
|
||||
for (const [key, value] of upstreamHeaders) {
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === 'content-security-policy') {
|
||||
const stripped = stripFrameAncestors(value);
|
||||
csp = stripped === '' ? null : stripped;
|
||||
continue;
|
||||
}
|
||||
if (lower === 'location') {
|
||||
headers['location'] = rewriteLocation(value, requestUrl, capability);
|
||||
continue;
|
||||
}
|
||||
if (DROP_RESPONSE_HEADERS.has(lower)) continue;
|
||||
headers[lower] = value;
|
||||
}
|
||||
|
||||
const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext));
|
||||
|
||||
return { headers, setCookie, csp };
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny script injected at the top of every proxied document, rewriting
|
||||
* ROOT-ABSOLUTE URLs built at runtime so they stay inside the proxy prefix.
|
||||
*
|
||||
* `<base href>` only governs URLs the HTML parser resolves. A dashboard that calls
|
||||
* `fetch('/api/data')` bypasses it entirely and the request lands on Codeman's own
|
||||
* 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 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
|
||||
*
|
||||
* A dashboard that renders `container.innerHTML = '<img src="/api/hero?slug=x">'`
|
||||
* or `img.src = '/api/slide?n=01'` produces exactly the same root-absolute request,
|
||||
* and NONE of the other layers can reach it: `<base>` does not apply to
|
||||
* root-absolute URLs at all, and `rewriteHtml()` only ever sees the initial
|
||||
* document, not markup built later by page script. The visible symptom is very
|
||||
* specific and easy to misread: the dashboard's DATA loads (reads go through
|
||||
* `fetch`, which was already patched) while every IMAGE stays broken. So the same
|
||||
* `rw()` is applied to `innerHTML`/`outerHTML`/`insertAdjacentHTML`, to
|
||||
* `setAttribute`, and to the `src`/`href`/`srcset`/... property setters, with a
|
||||
* `MutationObserver` as a last net for any sink not patched above (that one costs a
|
||||
* wasted 404 per node, since the browser starts fetching on insert, so it is a net
|
||||
* and not the mechanism).
|
||||
*
|
||||
* Runs before any page script because it is injected immediately after `<base>`.
|
||||
* Only same-origin, non-prefixed, root-absolute URLs are touched; relative URLs
|
||||
* (already handled by `<base>`) and cross-origin URLs are passed through. Every
|
||||
* rewrite is idempotent, so a value that passes through two layers is unchanged by
|
||||
* the second.
|
||||
*/
|
||||
export function runtimeUrlShim(prefix: string): string {
|
||||
// Kept dependency-free and defensive: it runs inside a page we do not control,
|
||||
// and a throw here would break the dashboard rather than fix it.
|
||||
return `<script>(function(){try{
|
||||
var P=${JSON.stringify(prefix)};
|
||||
function rw(u){
|
||||
try{
|
||||
if(u==null)return u;
|
||||
if(typeof u!=='string'){
|
||||
if(typeof URL!=='undefined'&&u instanceof URL)return rw(u.href);
|
||||
return u;
|
||||
}
|
||||
if(u.indexOf(P)===0)return u;
|
||||
if(u.charAt(0)==='/'&&u.charAt(1)!=='/')return P+u.slice(1);
|
||||
if(/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(u)||u.indexOf('//')===0){
|
||||
var a=new URL(u,location.href);
|
||||
if(a.host===location.host&&a.pathname.indexOf(P)!==0){
|
||||
a.pathname=P+a.pathname.replace(/^\\//,'');
|
||||
return a.href;
|
||||
}
|
||||
}
|
||||
return u;
|
||||
}catch(e){return u;}
|
||||
}
|
||||
var of=window.fetch;
|
||||
if(of)window.fetch=function(i,o){
|
||||
try{
|
||||
if(typeof Request!=='undefined'&&i instanceof Request)return of.call(this,new Request(rw(i.url),i),o);
|
||||
return of.call(this,rw(i),o);
|
||||
}catch(e){return of.call(this,i,o);}
|
||||
};
|
||||
if(window.XMLHttpRequest&&XMLHttpRequest.prototype.open){
|
||||
var oo=XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open=function(m,u){
|
||||
var a=[].slice.call(arguments);a[1]=rw(u);return oo.apply(this,a);
|
||||
};
|
||||
}
|
||||
['WebSocket','EventSource'].forEach(function(k){
|
||||
var C=window[k];if(!C)return;
|
||||
function W(u,p){return p===undefined?new C(rw(u)):new C(rw(u),p);}
|
||||
W.prototype=C.prototype;
|
||||
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];});
|
||||
window[k]=W;
|
||||
});
|
||||
var A=['src','href','action','poster','data','formaction','srcset'];
|
||||
function rwSet(v){
|
||||
try{
|
||||
return String(v).split(',').map(function(p){
|
||||
var t=p.trim();if(!t)return t;
|
||||
var i=t.search(/\\s/);
|
||||
return i===-1?rw(t):rw(t.slice(0,i))+t.slice(i);
|
||||
}).join(', ');
|
||||
}catch(e){return v;}
|
||||
}
|
||||
function rwAttr(n,v){
|
||||
try{
|
||||
if(v==null)return v;
|
||||
var k=String(n).toLowerCase();
|
||||
if(k==='srcset')return rwSet(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.
|
||||
function rwHtml(s){
|
||||
try{
|
||||
if(typeof s!=='string')return s;
|
||||
return 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(/(<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
|
||||
// wrap an already-wrapped setter and rewrite twice.
|
||||
function patchProp(C,prop,conv){
|
||||
try{
|
||||
if(!C||!C.prototype)return;
|
||||
var d=Object.getOwnPropertyDescriptor(C.prototype,prop);
|
||||
if(!d||!d.set||d.set.__cmrw)return;
|
||||
var s=d.set;
|
||||
var ns=function(v){var w=v;try{w=conv(v);}catch(e){}return s.call(this,w);};
|
||||
ns.__cmrw=1;
|
||||
Object.defineProperty(C.prototype,prop,{get:d.get,set:ns,configurable:true,enumerable:d.enumerable});
|
||||
}catch(e){}
|
||||
}
|
||||
function patchHtmlProp(O,prop){
|
||||
try{
|
||||
if(!O)return;
|
||||
var d=Object.getOwnPropertyDescriptor(O,prop);
|
||||
if(!d||!d.set||d.set.__cmrw)return;
|
||||
var s=d.set;
|
||||
var ns=function(v){return s.call(this,rwHtml(v));};
|
||||
ns.__cmrw=1;
|
||||
Object.defineProperty(O,prop,{get:d.get,set:ns,configurable:true,enumerable:d.enumerable});
|
||||
}catch(e){}
|
||||
}
|
||||
function patchFn(O,name,wrap){
|
||||
try{
|
||||
var f=O&&O[name];
|
||||
if(typeof f!=='function'||f.__cmrw)return;
|
||||
var nf=wrap(f);nf.__cmrw=1;O[name]=nf;
|
||||
}catch(e){}
|
||||
}
|
||||
[['HTMLImageElement','src'],['HTMLImageElement','srcset'],['HTMLSourceElement','src'],
|
||||
['HTMLSourceElement','srcset'],['HTMLMediaElement','src'],['HTMLVideoElement','poster'],
|
||||
['HTMLScriptElement','src'],['HTMLIFrameElement','src'],['HTMLEmbedElement','src'],
|
||||
['HTMLTrackElement','src'],['HTMLLinkElement','href'],['HTMLAnchorElement','href'],
|
||||
['HTMLAreaElement','href'],['HTMLObjectElement','data'],['HTMLFormElement','action']
|
||||
].forEach(function(p){patchProp(window[p[0]],p[1],p[1]==='srcset'?rwSet:rw);});
|
||||
var EP=window.Element&&window.Element.prototype;
|
||||
patchHtmlProp(EP,'innerHTML');
|
||||
patchHtmlProp(EP,'outerHTML');
|
||||
patchHtmlProp(window.ShadowRoot&&window.ShadowRoot.prototype,'innerHTML');
|
||||
patchFn(EP,'insertAdjacentHTML',function(f){return function(p,h){return f.call(this,p,rwHtml(h));};});
|
||||
patchFn(EP,'setAttribute',function(f){return function(n,v){return f.call(this,n,rwAttr(n,v));};});
|
||||
patchFn(EP,'setAttributeNS',function(f){return function(ns,n,v){
|
||||
var k=String(n==null?'':n),i=k.indexOf(':');
|
||||
return f.call(this,ns,n,rwAttr(i===-1?k:k.slice(i+1),v));
|
||||
};});
|
||||
// Last net: anything inserted by a sink not patched above still gets corrected.
|
||||
// setAttribute below is the patched one, so this stays idempotent and terminates.
|
||||
try{
|
||||
var doc=window.document,MO=window.MutationObserver;
|
||||
if(MO&&doc&&doc.documentElement){
|
||||
var fix=function(el){
|
||||
try{
|
||||
if(!el||el.nodeType!==1||!el.hasAttribute)return;
|
||||
for(var i=0;i<A.length;i++){
|
||||
var n=A[i];if(!el.hasAttribute(n))continue;
|
||||
var c=el.getAttribute(n),x=rwAttr(n,c);
|
||||
if(x!=null&&x!==c)el.setAttribute(n,x);
|
||||
}
|
||||
}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);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){}
|
||||
};
|
||||
new MO(function(ms){
|
||||
for(var i=0;i<ms.length;i++){
|
||||
var m=ms[i];
|
||||
if(m.type==='attributes')fix(m.target);
|
||||
else for(var j=0;j<m.addedNodes.length;j++)scan(m.addedNodes[j]);
|
||||
}
|
||||
}).observe(doc.documentElement,{subtree:true,childList:true,attributes:true,attributeFilter:A});
|
||||
}
|
||||
}catch(e){}
|
||||
}catch(e){}})();</script>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject `<base href="/webview/<cap>/">` plus the runtime URL shim, and rebase
|
||||
* root-absolute `src`/`href`/`action` attributes, which `<base>` alone does not
|
||||
* affect.
|
||||
*
|
||||
* Three layers, because no single one is sufficient: `<base>` for parser-resolved
|
||||
* relative URLs, attribute rewriting for root-absolute markup, and the shim for
|
||||
* URLs built at runtime.
|
||||
*/
|
||||
export function rewriteHtml(html: string, capability: string): string {
|
||||
const prefix = proxyPrefixFor(capability);
|
||||
|
||||
// Fresh regexes per call: module-level /g patterns carry `lastIndex` between calls.
|
||||
const rebased = html
|
||||
.replace(/(\s(?:src|href|action)\s*=\s*")\/(?!\/)/gi, `$1${prefix}`)
|
||||
.replace(/(\s(?:src|href|action)\s*=\s*')\/(?!\/)/gi, `$1${prefix}`);
|
||||
|
||||
// A page that ships its own <base> keeps it (overriding it would break the
|
||||
// author's intent), but it STILL needs the shim, which is the layer that
|
||||
// catches runtime-built URLs. So only the base tag is conditional.
|
||||
const injected = (/<base\b/i.test(rebased) ? '' : `<base href="${prefix}">`) + runtimeUrlShim(prefix);
|
||||
|
||||
const headMatch = /<head\b[^>]*>/i.exec(rebased);
|
||||
if (headMatch) {
|
||||
const at = headMatch.index + headMatch[0].length;
|
||||
return rebased.slice(0, at) + injected + rebased.slice(at);
|
||||
}
|
||||
const htmlMatch = /<html\b[^>]*>/i.exec(rebased);
|
||||
if (htmlMatch) {
|
||||
const at = htmlMatch.index + htmlMatch[0].length;
|
||||
return rebased.slice(0, at) + injected + rebased.slice(at);
|
||||
}
|
||||
return injected + rebased;
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS headers for a proxied response.
|
||||
*
|
||||
* Non-obvious but load-bearing: a SANDBOXED iframe (no `allow-same-origin`) runs
|
||||
* in an OPAQUE origin, so every `fetch`/XHR it makes is a cross-origin request even
|
||||
* though the URL is on this very host, and the browser requires CORS headers to
|
||||
* hand back the response. Without this, a dashboard renders fine (script/css/img
|
||||
* loads are not CORS-checked) while every one of its API calls fails with an opaque
|
||||
* `net::ERR_FAILED` and the page shows its own "failed to load" state. `curl`
|
||||
* cannot reproduce it, because curl does not enforce CORS.
|
||||
*
|
||||
* The origin is echoed rather than `*` so credentialed requests still work in
|
||||
* trusted mode. `null` (the opaque-origin case) is echoed as-is, but WITHOUT
|
||||
* `allow-credentials`, which browsers reject in combination.
|
||||
*
|
||||
* This grants nothing extra: the URL is already gated by the capability, and only
|
||||
* a document that was handed the capability can construct these requests.
|
||||
*/
|
||||
export function buildProxyCorsHeaders(origin: string | undefined, requestedHeaders?: string): Record<string, string> {
|
||||
if (!origin) return {};
|
||||
const headers: Record<string, string> = {
|
||||
'access-control-allow-origin': origin,
|
||||
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS',
|
||||
'access-control-allow-headers': requestedHeaders && requestedHeaders.trim() !== '' ? requestedHeaders : '*',
|
||||
'access-control-expose-headers': '*',
|
||||
'access-control-max-age': '600',
|
||||
vary: 'Origin',
|
||||
};
|
||||
// `Access-Control-Allow-Credentials: true` alongside a `null` origin is rejected
|
||||
// by browsers; a sandboxed frame sends no credentials anyway.
|
||||
if (origin !== 'null' && origin !== '*') headers['access-control-allow-credentials'] = 'true';
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** Whether a content-type identifies HTML worth rewriting. */
|
||||
export function isHtmlContentType(contentType: string | undefined): boolean {
|
||||
if (!contentType) return false;
|
||||
const type = contentType.split(';')[0].trim().toLowerCase();
|
||||
return type === 'text/html' || type === 'application/xhtml+xml';
|
||||
}
|
||||
|
||||
/** Map an upstream http(s) URL to its ws(s) equivalent for the WebSocket leg. */
|
||||
export function upstreamWebSocketUrl(target: URL): string {
|
||||
const ws = new URL(target.href);
|
||||
ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return ws.href;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @fileoverview Capability tokens for the web-tab proxy.
|
||||
*
|
||||
* The proxy cannot authenticate on Codeman's session cookie. A sandboxed iframe
|
||||
* (no `allow-same-origin`) runs in an OPAQUE origin, so every request it makes is
|
||||
* cross-site: the `SameSite=lax` `codeman_session` cookie is not sent, and its
|
||||
* non-GET requests and WebSocket upgrades arrive with `Origin: null`, which the
|
||||
* host guard rejects by design.
|
||||
*
|
||||
* So `/webview/:cap/*` authenticates on an unguessable capability minted by an
|
||||
* already-authenticated `POST /api/webviews/:id/open`. Properties that make this
|
||||
* safe to exempt from the cookie/Origin checks:
|
||||
*
|
||||
* - 128 bits of `randomBytes` entropy, base64url, never derived from anything.
|
||||
* - Held in memory only. A restart invalidates every outstanding capability.
|
||||
* - Rolling TTL: refreshed on use, expired after inactivity.
|
||||
* - Bound to the minting user, so multi-user ownership survives the exemption.
|
||||
* - Grants exactly one thing: relaying bytes to that one saved URL. It reaches no
|
||||
* session, no file, no API surface.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { StaleExpirationMap } from './utils/index.js';
|
||||
import { MAX_WEBVIEW_CAPABILITIES, WEBVIEW_CAPABILITY_TTL_MS } from './config/webview-limits.js';
|
||||
|
||||
export interface WebviewCapabilityRecord {
|
||||
webviewId: string;
|
||||
/** Username that minted it (multi-user); undefined in single-user mode. */
|
||||
owner?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export class WebviewCapabilityStore {
|
||||
private readonly capabilities: StaleExpirationMap<string, WebviewCapabilityRecord>;
|
||||
/** Reverse index so re-opening a webview reuses its capability instead of leaking one per click. */
|
||||
private readonly byWebview = new Map<string, string>();
|
||||
|
||||
constructor(ttlMs: number = WEBVIEW_CAPABILITY_TTL_MS) {
|
||||
this.capabilities = new StaleExpirationMap<string, WebviewCapabilityRecord>({
|
||||
ttlMs,
|
||||
refreshOnGet: true,
|
||||
onExpire: (_token, record) => {
|
||||
const current = this.byWebview.get(record.webviewId);
|
||||
if (current !== undefined) this.byWebview.delete(record.webviewId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Mint (or reuse) a capability for a webview. Returns the token. */
|
||||
mint(webviewId: string, owner?: string): string {
|
||||
const existing = this.byWebview.get(webviewId);
|
||||
if (existing) {
|
||||
const record = this.capabilities.get(existing);
|
||||
// Reuse only while the record is live AND still belongs to the same identity.
|
||||
if (record && record.owner === owner) return existing;
|
||||
this.capabilities.delete(existing);
|
||||
this.byWebview.delete(webviewId);
|
||||
}
|
||||
|
||||
// Bound growth: a client that never reuses tokens must not grow this forever.
|
||||
if (this.capabilities.size >= MAX_WEBVIEW_CAPABILITIES) this.capabilities.cleanup();
|
||||
|
||||
const token = randomBytes(24).toString('base64url');
|
||||
this.capabilities.set(token, { webviewId, owner, createdAt: Date.now() });
|
||||
this.byWebview.set(webviewId, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Resolve a capability, refreshing its TTL. Returns undefined when unknown or expired. */
|
||||
resolve(token: string): WebviewCapabilityRecord | undefined {
|
||||
if (!token) return undefined;
|
||||
return this.capabilities.get(token);
|
||||
}
|
||||
|
||||
/** Revoke every capability for a webview (called on delete/edit). */
|
||||
revokeWebview(webviewId: string): void {
|
||||
const token = this.byWebview.get(webviewId);
|
||||
if (token) {
|
||||
this.capabilities.delete(token);
|
||||
this.byWebview.delete(webviewId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Revoke every capability minted by a user (called on logout / user deletion). */
|
||||
revokeOwner(owner: string): void {
|
||||
for (const [webviewId, token] of [...this.byWebview]) {
|
||||
const record = this.capabilities.peek(token);
|
||||
if (record?.owner === owner) {
|
||||
this.capabilities.delete(token);
|
||||
this.byWebview.delete(webviewId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.capabilities.size;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.capabilities.dispose();
|
||||
this.byWebview.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-wide capability store.
|
||||
*
|
||||
* A singleton rather than an injected dependency because two unrelated layers must
|
||||
* agree on it: the proxy routes that mint and consume capabilities, and the auth
|
||||
* middleware, which has to recognize a valid capability to know that a
|
||||
* `/webview/...` request is legitimately exempt from the cookie and Origin checks.
|
||||
* Threading a store through the auth middleware's construction just to answer that
|
||||
* one question would be worse. The map's cleanup timer is `unref`'d, so holding
|
||||
* this at module scope does not keep the process alive.
|
||||
*/
|
||||
export const webviewCapabilities = new WebviewCapabilityStore();
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @fileoverview Persistence for web tabs (saved dashboard URLs).
|
||||
*
|
||||
* Stores `Webview` records in `~/.codeman/webviews.json`, following the same
|
||||
* read-array / write-array shape as `src/remote-hosts.ts`. Deliberately dumb: no
|
||||
* caching, no watchers. The list is small (bounded by MAX_WEBVIEWS) and is read
|
||||
* on demand by the route handlers.
|
||||
*
|
||||
* The file lives under the instance data dir, so a beta instance started with a
|
||||
* distinct CODEMAN_INSTANCE keeps its own dashboards.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { Webview } from './types.js';
|
||||
|
||||
const WEBVIEWS_FILE = 'webviews.json';
|
||||
|
||||
export function webviewsPath(configDir: string): string {
|
||||
return join(configDir, WEBVIEWS_FILE);
|
||||
}
|
||||
|
||||
export async function readWebviews(configDir: string): Promise<Webview[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(webviewsPath(configDir), 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as Webview[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeWebviews(configDir: string, webviews: Webview[]): Promise<void> {
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
await fs.writeFile(webviewsPath(configDir), JSON.stringify(webviews, null, 2));
|
||||
}
|
||||
@@ -79,6 +79,61 @@ describe('terminal link-provider regexes (shipped source)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('urlPattern keeps query strings whole (a single & is part of the URL)', () => {
|
||||
// Excluding `&` truncated every real query string: a WordPress edit link
|
||||
// resolved to `?post=1479` and opened the wrong page, and Claude Code's OAuth
|
||||
// login URL (many `&` params) was not clickable at all.
|
||||
const url = shippedPattern('urlPattern');
|
||||
const strip = (u: string) => u.replace(/[.,;:!?)&]+$/, '');
|
||||
const cases: Array<[string, string]> = [
|
||||
[
|
||||
'updated in place: https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
|
||||
'https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
|
||||
],
|
||||
[
|
||||
'open https://claude.ai/oauth/authorize?code=true&client_id=abc&scope=user%3Ainference&state=xyz',
|
||||
'https://claude.ai/oauth/authorize?code=true&client_id=abc&scope=user%3Ainference&state=xyz',
|
||||
],
|
||||
['see https://x.com/a?b=1&c=2&d=3 ok', 'https://x.com/a?b=1&c=2&d=3'],
|
||||
// A lone trailing & is punctuation, not part of the target.
|
||||
['trailing https://x.com/a?b=1& next', 'https://x.com/a?b=1'],
|
||||
];
|
||||
for (const [line, want] of cases) {
|
||||
url.lastIndex = 0;
|
||||
const m = url.exec(line);
|
||||
expect(m, line).not.toBeNull();
|
||||
expect(strip(m![0]), line).toBe(want);
|
||||
}
|
||||
});
|
||||
|
||||
it('urlPattern still stops at the shell && operator', () => {
|
||||
// `&&` never appears inside a URL, so it must remain a boundary or a link
|
||||
// would swallow the next command.
|
||||
const url = shippedPattern('urlPattern');
|
||||
for (const line of ['curl https://x.com/api && echo done', 'curl https://x.com/api&&echo done']) {
|
||||
url.lastIndex = 0;
|
||||
expect(url.exec(line)![0], line).toBe('https://x.com/api');
|
||||
}
|
||||
});
|
||||
|
||||
it('extPattern links pasted image/PDF attachment paths', () => {
|
||||
// `.claude-images/paste-*.png` is what Codeman writes for a pasted screenshot;
|
||||
// without image extensions the path rendered as plain, unclickable text.
|
||||
const ext = shippedPattern('extPattern');
|
||||
const cases = [
|
||||
'/home/arkon/default/claudeman/.claude-images/paste-1785164958410-d11eb7d0.png',
|
||||
'/tmp/shot.jpeg',
|
||||
'/opt/app/report.pdf',
|
||||
'/home/a/diagram.svg',
|
||||
];
|
||||
for (const path of cases) {
|
||||
ext.lastIndex = 0;
|
||||
const m = ext.exec(`see ${path} here`);
|
||||
expect(m, path).not.toBeNull();
|
||||
expect(m![1], path).toBe(path);
|
||||
}
|
||||
});
|
||||
|
||||
it('cmdPattern arg group cannot match empty tokens (the exponential trigger)', () => {
|
||||
// structural guard: the dangerous construct is an empty-matchable token
|
||||
// inside a repeated group — `[^\s\/]*\s+` repeated. Check the pattern
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @fileoverview Fast VM/static regressions for the shared filesystem picker and
|
||||
* extended mobile keyboard actions. No browser or real server required.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const keyboardSource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
|
||||
const terminalSource = readFileSync(resolve('src/web/public/terminal-ui.js'), 'utf8');
|
||||
const sessionSource = readFileSync(resolve('src/web/public/session-ui.js'), 'utf8');
|
||||
const indexSource = readFileSync(resolve('src/web/public/index.html'), 'utf8');
|
||||
|
||||
function loadTerminalMixin() {
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> };
|
||||
const cjkClear = vi.fn();
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
CodemanApp: FakeCodemanApp,
|
||||
CjkInput: { clear: cjkClear },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
document: { addEventListener: vi.fn() },
|
||||
});
|
||||
vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
|
||||
return { mixin: FakeCodemanApp.prototype, cjkClear };
|
||||
}
|
||||
|
||||
const terminalHarness = loadTerminalMixin();
|
||||
|
||||
function loadKeyboardModule() {
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
sessions: new Map([['session-1', { workingDir: '/mnt/d/AI' }]]),
|
||||
terminal: { focus: vi.fn() },
|
||||
clearTerminalInput: vi.fn(),
|
||||
insertTerminalText: vi.fn(),
|
||||
sendInput: vi.fn(),
|
||||
};
|
||||
const context = vm.createContext({
|
||||
app,
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
URLSearchParams,
|
||||
fetch: vi.fn(),
|
||||
document: {},
|
||||
setTimeout: (fn: () => void) => {
|
||||
fn();
|
||||
return 1;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
});
|
||||
vm.runInContext(
|
||||
`${keyboardSource}\nglobalThis.__bar = KeyboardAccessoryBar; globalThis.__picker = PathPicker;`,
|
||||
context
|
||||
);
|
||||
return {
|
||||
app,
|
||||
bar: (context as unknown as { __bar: { handleAction(action: string): void } }).__bar,
|
||||
picker: (context as unknown as { __picker: { open: ReturnType<typeof vi.fn> } }).__picker,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mobile filesystem picker actions', () => {
|
||||
it('keeps clear-input separate from the destructive /clear command', () => {
|
||||
const { app, bar } = loadKeyboardModule();
|
||||
bar.handleAction('clear-input');
|
||||
|
||||
expect(app.clearTerminalInput).toHaveBeenCalledOnce();
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
expect(keyboardSource).toContain('data-action="clear-input"');
|
||||
expect(keyboardSource).toContain('data-action="clear" title="/clear"');
|
||||
});
|
||||
|
||||
it('opens at the active working directory and inserts the selected path without Enter', () => {
|
||||
const { app, bar, picker } = loadKeyboardModule();
|
||||
picker.open = vi.fn();
|
||||
|
||||
bar.handleAction('pick-path');
|
||||
|
||||
expect(picker.open).toHaveBeenCalledOnce();
|
||||
const options = picker.open.mock.calls[0][0];
|
||||
expect(options).toMatchObject({
|
||||
sessionId: 'session-1',
|
||||
initialPath: '/mnt/d/AI',
|
||||
directoriesOnly: false,
|
||||
});
|
||||
options.onSelect('/mnt/d/AI/project/file.ts');
|
||||
expect(app.insertTerminalText).toHaveBeenCalledWith('/mnt/d/AI/project/file.ts');
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wires Link Existing to the shared folder-only picker', () => {
|
||||
expect(indexSource).toContain('onclick="app.openLinkCasePathPicker()"');
|
||||
expect(indexSource).toContain('id="linkCasePath"');
|
||||
expect(sessionSource).toContain('openLinkCasePathPicker()');
|
||||
expect(sessionSource).toContain('directoriesOnly: true');
|
||||
});
|
||||
|
||||
it('keeps Choose separate from safe inline file preview', () => {
|
||||
expect(keyboardSource).toContain('openPreview(entry)');
|
||||
expect(keyboardSource).toContain('/api/filesystem/preview?');
|
||||
expect(keyboardSource).toContain("entry.previewKind === 'image'");
|
||||
expect(keyboardSource).toContain("entry.previewKind === 'text'");
|
||||
expect(keyboardSource).toContain("choose.textContent = 'Choose'");
|
||||
expect(keyboardSource).toContain('pre.textContent = content');
|
||||
});
|
||||
|
||||
it('inserts a selected path into the editable local-echo prompt without sending it', () => {
|
||||
const appendText = vi.fn();
|
||||
const sendInput = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: { appendText },
|
||||
terminal: { focus },
|
||||
sendInput,
|
||||
};
|
||||
|
||||
terminalHarness.mixin.insertTerminalText.call(app, '/mnt/d/AI/project');
|
||||
|
||||
expect(appendText).toHaveBeenCalledWith('/mnt/d/AI/project');
|
||||
expect(sendInput).not.toHaveBeenCalled();
|
||||
expect(focus).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('clears pending and already-flushed prompt text without invoking /clear', () => {
|
||||
const clear = vi.fn();
|
||||
const suppressBufferDetection = vi.fn();
|
||||
const sendInput = vi.fn(() => Promise.resolve());
|
||||
const showToast = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
_inputFlushTimeout: null,
|
||||
_pendingInput: 'pending text',
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: {
|
||||
getFlushed: () => ({ count: 4, text: 'sent' }),
|
||||
clear,
|
||||
suppressBufferDetection,
|
||||
},
|
||||
_flushedOffsets: new Map([['session-1', 4]]),
|
||||
_flushedTexts: new Map([['session-1', 'sent']]),
|
||||
sendInput,
|
||||
showToast,
|
||||
terminal: { focus },
|
||||
};
|
||||
|
||||
terminalHarness.mixin.clearTerminalInput.call(app);
|
||||
|
||||
expect(app._pendingInput).toBe('');
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
expect(suppressBufferDetection).toHaveBeenCalledOnce();
|
||||
expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(4));
|
||||
expect(sendInput).not.toHaveBeenCalledWith('/clear');
|
||||
expect(app._flushedOffsets.size).toBe(0);
|
||||
expect(app._flushedTexts.size).toBe(0);
|
||||
expect(showToast).toHaveBeenCalledWith('Input cleared', 'success');
|
||||
expect(focus).toHaveBeenCalledOnce();
|
||||
expect(terminalHarness.cjkClear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses Ctrl+U to clear the TUI-owned prompt when local echo is disabled', () => {
|
||||
const sendInput = vi.fn(() => Promise.resolve());
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
_inputFlushTimeout: null,
|
||||
_pendingInput: '',
|
||||
_localEchoEnabled: false,
|
||||
_localEchoOverlay: null,
|
||||
sendInput,
|
||||
showToast: vi.fn(),
|
||||
terminal: { focus: vi.fn() },
|
||||
};
|
||||
|
||||
terminalHarness.mixin.clearTerminalInput.call(app);
|
||||
|
||||
expect(sendInput).toHaveBeenCalledWith('\x15');
|
||||
});
|
||||
});
|
||||
@@ -250,6 +250,9 @@ describe('QR Token Manager (unit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Must match the alphabet in `generateShortCode` (tunnel-manager.ts). */
|
||||
const BASE62_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
describe('Short code distribution (bias check)', () => {
|
||||
it('should produce roughly uniform character distribution', () => {
|
||||
// Generate 6000 codes (36000 chars) and check distribution
|
||||
@@ -265,17 +268,36 @@ describe('Short code distribution (bias check)', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Expected count per char: 36000 / 62 ≈ 580.6
|
||||
const expected = 36000 / 62;
|
||||
let maxDeviation = 0;
|
||||
for (const [, count] of charCounts) {
|
||||
const deviation = Math.abs(count - expected) / expected;
|
||||
maxDeviation = Math.max(maxDeviation, deviation);
|
||||
// Chi-square goodness-of-fit against a uniform base62 alphabet.
|
||||
//
|
||||
// This deliberately does NOT assert on the max per-character deviation.
|
||||
// That statistic is the maximum of 62 correlated near-normal cells, so its
|
||||
// tail is fat: with n=36000 the per-cell relative SD is ~4.1%, which puts a
|
||||
// 15% bound at |z| ~ 3.65 and, taken as a max over 62 cells, fails on a
|
||||
// perfectly uniform generator about 1.6% of the time. Measured over 3000
|
||||
// simulated runs: 48 spurious failures. That is the flake.
|
||||
//
|
||||
// Chi-square is the right tool for "is this multinomial uniform", and its
|
||||
// threshold is derivable rather than eyeballed. df = 62 - 1 = 61, so under
|
||||
// the null E[X²] = 61 and SD = sqrt(2*61) ~ 11.05; the Wilson-Hilferty
|
||||
// approximation puts the p = 1e-6 critical value at ~129. Rounding to 130
|
||||
// gives a false-positive rate around one run in a million.
|
||||
//
|
||||
// Power is unaffected. Dropping rejection sampling reintroduces modulo bias
|
||||
// (256 % 62 = 8, so the first 8 characters draw 5 chances per 256 instead
|
||||
// of 4, ~25% overrepresented), which scores X² ~ 237. Simulated: 3000 clean
|
||||
// runs peaked at 104, while 200 biased runs bottomed out at 174.5, so the
|
||||
// threshold sits in a wide empty gap between the two.
|
||||
const alphabetSize = 62;
|
||||
const expected = 36000 / alphabetSize;
|
||||
let chiSquare = 0;
|
||||
for (let i = 0; i < alphabetSize; i++) {
|
||||
const count = charCounts.get(BASE62_ALPHABET[i]) ?? 0;
|
||||
chiSquare += (count - expected) ** 2 / expected;
|
||||
}
|
||||
|
||||
// With rejection sampling, deviation should be < 15% (generous)
|
||||
// Without rejection sampling (modulo bias), first 6 chars would be ~25% overrepresented
|
||||
expect(maxDeviation).toBeLessThan(0.15);
|
||||
expect(charCounts.size).toBe(alphabetSize);
|
||||
expect(chiSquare).toBeLessThan(130);
|
||||
|
||||
tm.stopTokenRotation();
|
||||
});
|
||||
|
||||
@@ -20,18 +20,28 @@ export interface RouteTestHarness {
|
||||
* @param registerFn - The route registration function (e.g., registerSessionRoutes).
|
||||
* Uses `any` for ctx parameter because route functions expect typed port intersections
|
||||
* that MockRouteContext satisfies structurally but not nominally.
|
||||
* @param ctxOptions - Optional overrides for the mock context
|
||||
* @param ctxOptions - Optional overrides for the mock context. `authUser` stands
|
||||
* in for what the auth middleware would attach in multi-user mode; without it
|
||||
* `getAuthUser()` falls back to a synthetic admin, which passes every
|
||||
* ownership check and would make a scoping test pass vacuously.
|
||||
*/
|
||||
export async function createRouteTestHarness(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
registerFn: (app: FastifyInstance, ctx: any) => void,
|
||||
ctxOptions?: { sessionId?: string }
|
||||
ctxOptions?: { sessionId?: string; authUser?: { username: string; role: 'admin' | 'user' } }
|
||||
): Promise<RouteTestHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
// Register cookie plugin — some routes access req.cookies
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
if (ctxOptions?.authUser) {
|
||||
const authUser = ctxOptions.authUser;
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = createMockRouteContext(ctxOptions);
|
||||
|
||||
registerFn(app, ctx);
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
import { ApiErrorCode } from '../../src/types.js';
|
||||
|
||||
// Mock fs/promises for file operations
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readdir: vi.fn(async () => []),
|
||||
readFile: vi.fn(async () => 'file content'),
|
||||
stat: vi.fn(async () => ({ size: 100, isFile: () => true })),
|
||||
stat: vi.fn(async () => ({ size: 100, isFile: () => true, isDirectory: () => true })),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -55,13 +56,301 @@ describe('file-routes', () => {
|
||||
// Default: realpathSync returns the path unchanged
|
||||
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||
// Default stat
|
||||
mockedStat.mockResolvedValue({ size: 100, isFile: () => true } as never);
|
||||
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never);
|
||||
mockedReadFile.mockImplementation(async (path) =>
|
||||
String(path).endsWith('settings.json') ? ('{}' as never) : ('file content' as never)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ========== GET /api/filesystem/browse ==========
|
||||
|
||||
describe('GET /api/filesystem/browse', () => {
|
||||
it('lists the active session folder lazily with directories first', async () => {
|
||||
mockedReaddir.mockResolvedValueOnce([
|
||||
{
|
||||
name: 'notes.txt',
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
},
|
||||
{
|
||||
name: 'src',
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const path = harness.ctx._session.workingDir;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.path).toBe(path);
|
||||
expect(body.data.roots[0]).toEqual({ label: 'Current Folder', path });
|
||||
expect(
|
||||
body.data.entries.map((entry: { name: string; type: string; previewKind?: string }) => [
|
||||
entry.name,
|
||||
entry.type,
|
||||
entry.previewKind,
|
||||
])
|
||||
).toEqual([
|
||||
['src', 'directory', undefined],
|
||||
['notes.txt', 'file', 'text'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects paths outside the configured roots', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?path=${encodeURIComponent('/tmp/not-an-allowed-root')}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('does not expose hidden entries or symlinks that escape the allowed roots', async () => {
|
||||
const root = harness.ctx._session.workingDir;
|
||||
mockedReaddir.mockResolvedValueOnce([
|
||||
{
|
||||
name: '.secret',
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
},
|
||||
{
|
||||
name: 'outside-link',
|
||||
isDirectory: () => false,
|
||||
isFile: () => false,
|
||||
isSymbolicLink: () => true,
|
||||
},
|
||||
] as never);
|
||||
mockedRealpathSync.mockImplementation((path: string) =>
|
||||
path === `${root}/outside-link` ? ('/etc/shadow' as never) : (path as never)
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).data.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown session scope', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/filesystem/browse?sessionId=missing-session',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND });
|
||||
});
|
||||
|
||||
it('rejects direct navigation into a hidden descendant', async () => {
|
||||
const hidden = `${harness.ctx._session.workingDir}/.git`;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(hidden)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Multi-user scoping for the filesystem picker ==========
|
||||
//
|
||||
// The picker is a SECOND file-serving surface and does not inherit the
|
||||
// attachment guard's ownership scoping, so both of its endpoints have to do
|
||||
// it themselves. Two distinct holes are covered here:
|
||||
// 1. `sessionId` was used without an owner check, so any user could pin
|
||||
// another user's workingDir as a browse root.
|
||||
// 2. `Home` and `CASES_DIR` were unconditional roots, and per-user spaces
|
||||
// live INSIDE homedir(), so Home alone exposed every other user's files.
|
||||
describe('filesystem picker multi-user scoping', () => {
|
||||
const SPACES = '/tmp/codeman-test-user-spaces';
|
||||
let prevMultiUser: string | undefined;
|
||||
let prevSpaces: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
prevMultiUser = process.env.CODEMAN_MULTIUSER;
|
||||
prevSpaces = process.env.CODEMAN_USER_SPACES_DIR;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
process.env.CODEMAN_USER_SPACES_DIR = SPACES;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = prevMultiUser;
|
||||
if (prevSpaces === undefined) delete process.env.CODEMAN_USER_SPACES_DIR;
|
||||
else process.env.CODEMAN_USER_SPACES_DIR = prevSpaces;
|
||||
});
|
||||
|
||||
const harnessAs = (role: 'admin' | 'user', username: string) =>
|
||||
createRouteTestHarness(registerFileRoutes, { authUser: { username, role } });
|
||||
|
||||
it('404s a browse scoped to another user session instead of adopting its folder', async () => {
|
||||
const scoped = await harnessAs('user', 'bob');
|
||||
scoped.ctx._session.owner = 'alice';
|
||||
try {
|
||||
const res = await scoped.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?sessionId=${scoped.ctx._sessionId}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND });
|
||||
// The decisive part: alice's folder must not have leaked in as a root.
|
||||
expect(res.body).not.toContain(scoped.ctx._session.workingDir);
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('404s a preview scoped to another user session', async () => {
|
||||
const scoped = await harnessAs('user', 'bob');
|
||||
scoped.ctx._session.owner = 'alice';
|
||||
try {
|
||||
const res = await scoped.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${scoped.ctx._sessionId}&path=${encodeURIComponent(
|
||||
`${scoped.ctx._session.workingDir}/notes.md`
|
||||
)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('confines a regular user to their own space, never Home or the shared cases dir', async () => {
|
||||
const scoped = await harnessAs('user', 'bob');
|
||||
try {
|
||||
mockedReaddir.mockResolvedValueOnce([] as never);
|
||||
const res = await scoped.app.inject({ method: 'GET', url: '/api/filesystem/browse' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.roots).toEqual([{ label: 'My Space', path: `${SPACES}/bob` }]);
|
||||
expect(body.data.path).toBe(`${SPACES}/bob`);
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to browse another user's space by absolute path", async () => {
|
||||
const scoped = await harnessAs('user', 'bob');
|
||||
try {
|
||||
const res = await scoped.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?path=${encodeURIComponent(`${SPACES}/alice/cases`)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the host-wide roots for a multi-user admin', async () => {
|
||||
const scoped = await harnessAs('admin', 'root');
|
||||
try {
|
||||
mockedReaddir.mockResolvedValueOnce([] as never);
|
||||
const res = await scoped.app.inject({ method: 'GET', url: '/api/filesystem/browse' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const labels = JSON.parse(res.body).data.roots.map((root: { label: string }) => root.label);
|
||||
expect(labels).toContain('Home');
|
||||
expect(labels).not.toContain('My Space');
|
||||
} finally {
|
||||
await scoped.app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/filesystem/preview ==========
|
||||
|
||||
describe('GET /api/filesystem/preview', () => {
|
||||
it('serves Markdown as inert plain text inside the active session root', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/notes.md`;
|
||||
mockedReadFile.mockImplementation(async (candidate) =>
|
||||
candidate === path ? ('# Safe heading\n<script>alert(1)</script>' as never) : ('{}' as never)
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/plain');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(res.body).toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
it('rejects unsupported file types', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/archive.exe`;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects hidden files even when requested directly', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/.env`;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a preview symlink whose real path escapes every allowed root', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/outside.png`;
|
||||
mockedRealpathSync.mockImplementation((candidate: string) =>
|
||||
candidate === path ? ('/etc/shadow' as never) : (candidate as never)
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('caps text previews at 2MB', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/large.txt`;
|
||||
mockedStat.mockImplementation(async (candidate) =>
|
||||
candidate === path
|
||||
? ({ size: 2 * 1024 * 1024 + 1, isFile: () => true, isDirectory: () => false } as never)
|
||||
: ({ size: 100, isFile: () => true, isDirectory: () => true } as never)
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(413);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/files ==========
|
||||
|
||||
describe('GET /api/sessions/:id/files', () => {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @fileoverview Claude transcript normalization tests for the response viewer.
|
||||
*
|
||||
* Uses app.inject() with a temporary HOME; no real ports or user transcripts.
|
||||
* Port: N/A
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
|
||||
interface LocalHarness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
async function createEnvelopeHarness(): Promise<LocalHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx);
|
||||
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api') || payload === null || typeof payload !== 'object') {
|
||||
return done(null, payload);
|
||||
}
|
||||
const response = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (response.success === false) {
|
||||
if (reply.statusCode === 200 && typeof response.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(response.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (response.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
const userEntry = (text: string, extras: Record<string, unknown> = {}) => ({
|
||||
type: 'user',
|
||||
timestamp: '2026-07-21T00:00:00Z',
|
||||
message: { content: [{ type: 'text', text }] },
|
||||
...extras,
|
||||
});
|
||||
|
||||
const assistantEntry = (text: string, timestamp: string) => ({
|
||||
type: 'assistant',
|
||||
timestamp,
|
||||
message: { content: [{ type: 'text', text }] },
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/last-response (claude)', () => {
|
||||
let harness: LocalHarness;
|
||||
let testHome: string;
|
||||
let previousHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
testHome = mkdtempSync(join(tmpdir(), 'codeman-claude-rv-'));
|
||||
previousHome = process.env.HOME;
|
||||
process.env.HOME = testHome;
|
||||
harness = await createEnvelopeHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
function writeTranscript(sessionId: string, entries: unknown[]): void {
|
||||
const projectDir = join(testHome, '.claude', 'projects', '-workspace');
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(join(projectDir, `${sessionId}.jsonl`), entries.map((entry) => JSON.stringify(entry)).join('\n'));
|
||||
}
|
||||
|
||||
async function getLastResponse(sessionId: string, full = false) {
|
||||
const response = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/last-response${full ? '?context=full' : ''}`,
|
||||
});
|
||||
return { response, body: JSON.parse(response.body) };
|
||||
}
|
||||
|
||||
it('recovers a placeholder tmux session by UUID prefix and groups JSONL fragments into turns', async () => {
|
||||
const restoredId = 'restored-40568a29';
|
||||
const conversationId = '40568a29-d4eb-4eb6-b671-8401428e4f39';
|
||||
const session = harness.ctx._session as typeof harness.ctx._session & {
|
||||
claudeSessionId: string;
|
||||
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
harness.ctx.sessions.delete(session.id);
|
||||
session.id = restoredId;
|
||||
session.mode = 'claude';
|
||||
session.workingDir = '/wrong/recovered/cwd';
|
||||
session.claudeSessionId = restoredId;
|
||||
session.adoptClaudeSessionId = vi.fn((newId: string) => {
|
||||
session.claudeSessionId = newId;
|
||||
});
|
||||
harness.ctx.sessions.set(restoredId, session);
|
||||
|
||||
writeTranscript(conversationId, [
|
||||
userEntry('first prompt'),
|
||||
userEntry('first prompt'), // restore replay before any assistant output
|
||||
{ type: 'assistant', message: { content: [{ type: 'thinking', thinking: 'hidden' }] } },
|
||||
assistantEntry('Checking the files.', '2026-07-21T00:00:01Z'),
|
||||
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tool-1' }] } },
|
||||
{ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tool-1' }] } },
|
||||
assistantEntry('Checking the files.', '2026-07-21T00:00:02Z'), // replayed snapshot
|
||||
assistantEntry('The first result is ready.', '2026-07-21T00:00:03Z'),
|
||||
userEntry('[Image dimensions generated by the CLI]', { isMeta: true }),
|
||||
userEntry('<command-name>/status</command-name>'),
|
||||
userEntry('Another Claude session sent a message: <teammate-message>done</teammate-message>'),
|
||||
userEntry('<task-notification>background agent completed</task-notification>'),
|
||||
userEntry('This session is being continued from a previous conversation', { isCompactSummary: true }),
|
||||
userEntry('second prompt'),
|
||||
assistantEntry('First half.', '2026-07-21T00:00:04Z'),
|
||||
{ ...assistantEntry('sidechain text', '2026-07-21T00:00:05Z'), isSidechain: true },
|
||||
assistantEntry('Second half.', '2026-07-21T00:00:06Z'),
|
||||
]);
|
||||
|
||||
const full = await getLastResponse(restoredId, true);
|
||||
expect(full.response.statusCode).toBe(200);
|
||||
expect(full.body.data).toEqual({
|
||||
text: 'Second half.',
|
||||
timestamp: '2026-07-21T00:00:06Z',
|
||||
messages: [
|
||||
{ role: 'user', text: 'first prompt', timestamp: '2026-07-21T00:00:00Z' },
|
||||
{
|
||||
role: 'assistant',
|
||||
text: 'Checking the files.\n\nThe first result is ready.',
|
||||
timestamp: '2026-07-21T00:00:03Z',
|
||||
},
|
||||
{ role: 'user', text: 'second prompt', timestamp: '2026-07-21T00:00:00Z' },
|
||||
{ role: 'assistant', text: 'First half.\n\nSecond half.', timestamp: '2026-07-21T00:00:06Z' },
|
||||
],
|
||||
});
|
||||
expect(session.adoptClaudeSessionId).toHaveBeenCalledWith(conversationId);
|
||||
|
||||
const brief = await getLastResponse(restoredId);
|
||||
expect(brief.body.data).toEqual({ text: 'Second half.', timestamp: '2026-07-21T00:00:06Z' });
|
||||
});
|
||||
|
||||
it('keeps an identical user prompt when it occurs again after an assistant response', async () => {
|
||||
const sessionId = harness.ctx._session.id;
|
||||
const session = harness.ctx._session as typeof harness.ctx._session & {
|
||||
claudeSessionId: string;
|
||||
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
session.claudeSessionId = sessionId;
|
||||
session.adoptClaudeSessionId = vi.fn();
|
||||
writeTranscript(sessionId, [
|
||||
userEntry('continue'),
|
||||
assistantEntry('First answer.', '2026-07-21T00:00:01Z'),
|
||||
userEntry('continue'),
|
||||
assistantEntry('Second answer.', '2026-07-21T00:00:02Z'),
|
||||
]);
|
||||
|
||||
const { body } = await getLastResponse(sessionId, true);
|
||||
expect(body.data.messages.map((message: { role: string; text: string }) => [message.role, message.text])).toEqual([
|
||||
['user', 'continue'],
|
||||
['assistant', 'First answer.'],
|
||||
['user', 'continue'],
|
||||
['assistant', 'Second answer.'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* CRUD + capability behaviour for /api/webviews.
|
||||
*
|
||||
* Uses app.inject() (no port) against a temp CODEMAN_DATA_DIR, so nothing touches
|
||||
* the developer's real ~/.codeman/webviews.json.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { registerWebviewRoutes } from '../../src/web/routes/webview-routes.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { webviewCapabilities } from '../../src/webview-capabilities.js';
|
||||
import { capabilityFromProxyPath } from '../../src/web/webview-proxy.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
let tmpDir: string;
|
||||
let savedDataDir: string | undefined;
|
||||
const broadcasts: Array<{ event: string; data: unknown }> = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-webviews-'));
|
||||
savedDataDir = process.env.CODEMAN_DATA_DIR;
|
||||
process.env.CODEMAN_DATA_DIR = tmpDir;
|
||||
broadcasts.length = 0;
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
// The proxy route declares a wsHandler, so the plugin must be present.
|
||||
await app.register(fastifyWebsocket);
|
||||
registerWebviewRoutes(app, {
|
||||
broadcast: (event: string, data: unknown) => broadcasts.push({ event, data }),
|
||||
} as never);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
if (savedDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
|
||||
else process.env.CODEMAN_DATA_DIR = savedDataDir;
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
const create = (payload: Record<string, unknown>) => app.inject({ method: 'POST', url: '/api/webviews', payload });
|
||||
|
||||
describe('GET /api/webviews', () => {
|
||||
it('starts empty and reports the frame budget the client must honour', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/webviews' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.webviews).toEqual([]);
|
||||
expect(typeof body.data.maxLiveFrames).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webviews', () => {
|
||||
it('creates a dashboard that defaults to proxied and sandboxed', async () => {
|
||||
const res = await create({ name: 'Grafana', url: 'http://127.0.0.1:4000/' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const w = res.json().data;
|
||||
// Proxy + untrusted are the safe defaults and must not drift.
|
||||
expect(w.embedMode).toBe('proxy');
|
||||
expect(w.trusted).toBe(false);
|
||||
expect(w.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('broadcasts the change so other devices re-fetch', async () => {
|
||||
await create({ name: 'G', url: 'http://127.0.0.1:4000/' });
|
||||
expect(broadcasts.map((b) => b.event)).toContain('webview:changed');
|
||||
});
|
||||
|
||||
it('persists across a fresh read of the store', async () => {
|
||||
await create({ name: 'G', url: 'http://127.0.0.1:4000/' });
|
||||
const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].name).toBe('G');
|
||||
});
|
||||
|
||||
it('rejects URLs that are not plain http(s)', async () => {
|
||||
for (const url of ['javascript:alert(1)', 'file:///etc/passwd', 'data:text/html,x']) {
|
||||
const res = await create({ name: 'bad', url });
|
||||
expect(res.statusCode, url).toBe(400);
|
||||
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects URLs carrying embedded credentials', async () => {
|
||||
const res = await create({ name: 'bad', url: 'http://user:pass@host:4000/' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('requires a name', async () => {
|
||||
expect((await create({ url: 'http://127.0.0.1:4000/' })).statusCode).toBe(400);
|
||||
expect((await create({ name: ' ', url: 'http://127.0.0.1:4000/' })).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/webviews/:id', () => {
|
||||
it('updates fields and revokes the outstanding capability', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const opened = await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` });
|
||||
const cap = capabilityFromProxyPath(opened.json().data.embedUrl)!;
|
||||
expect(webviewCapabilities.resolve(cap)).toBeDefined();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/webviews/${id}`,
|
||||
payload: { url: 'http://127.0.0.1:4001/' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.url).toBe('http://127.0.0.1:4001/');
|
||||
// A token minted against the OLD url must not survive the repoint.
|
||||
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('404s an unknown id', async () => {
|
||||
const res = await app.inject({ method: 'PATCH', url: '/api/webviews/nope', payload: { name: 'x' } });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('still validates the URL on update', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const res = await app.inject({ method: 'PATCH', url: `/api/webviews/${id}`, payload: { url: 'file:///etc' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/webviews/:id', () => {
|
||||
it('removes it and revokes its capability', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const opened = await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` });
|
||||
const cap = capabilityFromProxyPath(opened.json().data.embedUrl)!;
|
||||
|
||||
expect((await app.inject({ method: 'DELETE', url: `/api/webviews/${id}` })).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews).toEqual([]);
|
||||
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('404s an unknown id', async () => {
|
||||
expect((await app.inject({ method: 'DELETE', url: '/api/webviews/nope' })).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webviews/:id/open', () => {
|
||||
it('mints a same-origin embed path for a proxied dashboard', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const data = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data;
|
||||
expect(data.embedUrl).toMatch(/^\/webview\/[A-Za-z0-9_-]{16,}\/$/);
|
||||
expect(capabilityFromProxyPath(data.embedUrl)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns no embed path in direct mode, where the iframe uses the real URL', async () => {
|
||||
const id = (await create({ name: 'G', url: 'https://ok.example/', embedMode: 'direct' })).json().data.id;
|
||||
const data = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data;
|
||||
expect(data.embedUrl).toBeUndefined();
|
||||
expect(data.webview.url).toBe('https://ok.example/');
|
||||
});
|
||||
|
||||
it('reuses the capability across repeated opens instead of leaking one per click', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const first = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data.embedUrl;
|
||||
const second = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data.embedUrl;
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('records lastOpenedAt', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` });
|
||||
const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
|
||||
expect(typeof list[0].lastOpenedAt).toBe('number');
|
||||
});
|
||||
|
||||
it('404s an unknown id', async () => {
|
||||
expect((await app.inject({ method: 'POST', url: '/api/webviews/nope/open' })).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy route', () => {
|
||||
it('refuses an unknown or expired capability', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${'Z'.repeat(32)}/` });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('redirects the prefix without a trailing slash, so relative URLs resolve inside it', async () => {
|
||||
const cap = 'Y'.repeat(32);
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${cap}` });
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe(`/webview/${cap}/`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webviews/probe', () => {
|
||||
it('reports an unreachable target as a normal answer, not a 500', async () => {
|
||||
// Port 1 is reserved and refuses instantly.
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webviews/probe',
|
||||
payload: { url: 'http://127.0.0.1:1/' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const probe = res.json().data;
|
||||
expect(probe.reachable).toBe(false);
|
||||
expect(probe.recommendedMode).toBe('proxy');
|
||||
});
|
||||
|
||||
it('rejects an invalid URL up front', async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webviews/probe', payload: { url: 'file:///etc' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @fileoverview Static and VM regressions for Codeman UI/xterm skin parity.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const indexSource = readFileSync(resolve('src/web/public/index.html'), 'utf8');
|
||||
const stylesSource = readFileSync(resolve('src/web/public/styles.css'), 'utf8');
|
||||
const mobileStylesSource = readFileSync(resolve('src/web/public/mobile.css'), 'utf8');
|
||||
const terminalSource = readFileSync(resolve('src/web/public/terminal-ui.js'), 'utf8');
|
||||
|
||||
const LIGHT_SKINS = ['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn'] as const;
|
||||
|
||||
function hexRgb(hex: string): [number, number, number] {
|
||||
const normalized = hex.replace('#', '');
|
||||
if (!/^[0-9a-f]{6}$/i.test(normalized)) throw new Error(`Expected six-digit hex color, got ${hex}`);
|
||||
return [0, 2, 4].map((offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
|
||||
function luminance(hex: string): number {
|
||||
const channels = hexRgb(hex).map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
}
|
||||
|
||||
function contrastRatio(first: string, second: string): number {
|
||||
const [lighter, darker] = [luminance(first), luminance(second)].sort((a, b) => b - a);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function loadTerminalThemes() {
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> };
|
||||
const document = { documentElement: { dataset: { skin: 'paper-gray' } } };
|
||||
const window: Record<string, unknown> = {};
|
||||
vm.runInNewContext(terminalSource, { CodemanApp: FakeCodemanApp, document, window }, { filename: 'terminal-ui.js' });
|
||||
return {
|
||||
mixin: FakeCodemanApp.prototype,
|
||||
themes: window.CODEMAN_XTERM_THEMES as Record<string, Record<string, string>>,
|
||||
isLight: window.codemanCurrentSkinIsLight as (skin?: string) => boolean,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Codeman light skins', () => {
|
||||
const terminal = loadTerminalThemes();
|
||||
|
||||
it('keeps the picker, pre-paint allowlist, CSS, and xterm palette in sync', () => {
|
||||
for (const skin of LIGHT_SKINS) {
|
||||
expect(indexSource).toContain(`value="${skin}"`);
|
||||
expect(indexSource).toContain(`'${skin}'`);
|
||||
expect(stylesSource).toContain(`html[data-skin="${skin}"]`);
|
||||
expect(stylesSource).toMatch(new RegExp(`html\\[data-skin="${skin}"\\] \\{[\\s\\S]*?color-scheme: light;`));
|
||||
expect(terminal.themes[skin]).toBeDefined();
|
||||
expect(terminal.isLight(skin)).toBe(true);
|
||||
}
|
||||
expect(terminal.isLight('daylight-blue')).toBe(false);
|
||||
});
|
||||
|
||||
it('provides readable dark-on-light terminal foregrounds', () => {
|
||||
for (const skin of LIGHT_SKINS) {
|
||||
const theme = terminal.themes[skin];
|
||||
expect(contrastRatio(theme.background, theme.foreground), skin).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('switches live terminals between light and dark contrast policies', () => {
|
||||
const main = { options: {} as Record<string, unknown>, rows: 24, refresh: vi.fn() };
|
||||
const teammate = { options: {} as Record<string, unknown>, rows: 12, refresh: vi.fn() };
|
||||
const refreshFont = vi.fn();
|
||||
const app = {
|
||||
terminal: main,
|
||||
teammateTerminals: new Map([['agent-1', { terminal: teammate }]]),
|
||||
_localEchoOverlay: { refreshFont },
|
||||
};
|
||||
|
||||
terminal.mixin.applyTerminalSkin.call(app, 'paper-gray');
|
||||
expect(main.options.minimumContrastRatio).toBe(4.5);
|
||||
expect(teammate.options.minimumContrastRatio).toBe(4.5);
|
||||
expect((main.options.theme as Record<string, string>).background).toBe('#f6f8fa');
|
||||
expect(refreshFont).toHaveBeenCalledTimes(1);
|
||||
|
||||
terminal.mixin.applyTerminalSkin.call(app, 'daylight-blue');
|
||||
expect(main.options.minimumContrastRatio).toBe(1);
|
||||
expect(teammate.options.minimumContrastRatio).toBe(1);
|
||||
expect((main.options.theme as Record<string, string>).background).toBe('#161b23');
|
||||
expect(refreshFont).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('themes stateful input and response surfaces instead of pinning dark colors', () => {
|
||||
expect(stylesSource).toContain('background: var(--bg-input);\n color: var(--text);');
|
||||
expect(stylesSource).toMatch(/#cjkInput \{[\s\S]*?background: var\(--bg-input\);[\s\S]*?color: var\(--text\);/);
|
||||
expect(stylesSource).toMatch(/\.response-viewer \{[\s\S]*?background: var\(--floating-bg\);/);
|
||||
expect(stylesSource).toMatch(/\.response-viewer-body pre \{[\s\S]*?background: var\(--bg-dark\);/);
|
||||
expect(stylesSource).toMatch(/\.response-viewer-body pre code \{[\s\S]*?color: var\(--text\);/);
|
||||
expect(stylesSource).toMatch(/\.file-preview-body \{[\s\S]*?background: var\(--bg-dark\);/);
|
||||
});
|
||||
|
||||
it('uses skin variables for the pre-paint skeleton and native controls', () => {
|
||||
expect(indexSource).toContain('background:var(--term-bg,#161b23)');
|
||||
expect(indexSource).toContain('background:var(--glass-bg,rgba(31,38,48,0.85))');
|
||||
expect(stylesSource).toContain('color-scheme: light;');
|
||||
expect(stylesSource).toContain('background: var(--floating-bg);');
|
||||
expect(mobileStylesSource).toContain(':is(.header, .toolbar, .keyboard-accessory-bar)');
|
||||
expect(mobileStylesSource).toContain(':is(.case-settings-popover-mobile, .mobile-case-picker-sheet)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* The web-tab proxy is exempt from Codeman's cookie auth and its cross-site Origin
|
||||
* guard, because a sandboxed dashboard iframe is opaque-origin: it sends no session
|
||||
* cookie and its writes arrive with `Origin: null`. The capability in the path is
|
||||
* the credential instead.
|
||||
*
|
||||
* That exemption is the security-sensitive part of this feature, so these tests pin
|
||||
* its EDGES: it must apply to a live capability and to nothing else. A regression
|
||||
* here would be an unauthenticated hole into an agent-spawning API.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { registerAuthMiddleware, registerHostGuard, registerSecurityHeaders } from '../src/web/middleware/auth.js';
|
||||
import { webviewCapabilities } from '../src/webview-capabilities.js';
|
||||
import type { HostPolicy } from '../src/web/network-auth-policy.js';
|
||||
|
||||
const POLICY: HostPolicy = { allowedHosts: [], allowLan: true };
|
||||
const PASSWORD = 'test-password';
|
||||
|
||||
let app: FastifyInstance;
|
||||
let capability: string;
|
||||
let savedPassword: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
savedPassword = process.env.CODEMAN_PASSWORD;
|
||||
// The middleware reads this at registration time; auth is inert without it.
|
||||
process.env.CODEMAN_PASSWORD = PASSWORD;
|
||||
|
||||
capability = webviewCapabilities.mint('webview-under-test', undefined);
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
// Same order as server.ts (host guard → auth → security headers), so hook
|
||||
// interactions are exercised for real. The OPTIONS short-circuit lives in
|
||||
// registerSecurityHeaders and is part of what these tests pin.
|
||||
registerHostGuard(app, () => POLICY);
|
||||
registerAuthMiddleware(app, false);
|
||||
registerSecurityHeaders(app, false);
|
||||
|
||||
// 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('/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();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
webviewCapabilities.revokeWebview('webview-under-test');
|
||||
if (savedPassword === undefined) delete process.env.CODEMAN_PASSWORD;
|
||||
else process.env.CODEMAN_PASSWORD = savedPassword;
|
||||
});
|
||||
|
||||
describe('the exemption applies to a live capability', () => {
|
||||
it('lets an unauthenticated GET through on the proxy path', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${capability}/static/app.js` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('lets a write through despite Origin: null, which a sandboxed iframe always sends', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/webview/${capability}/login`,
|
||||
headers: { origin: 'null' },
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('lets a CORS preflight reach the proxy instead of the global 204 short-circuit', async () => {
|
||||
// registerSecurityHeaders answers every OPTIONS with a bare 204, which carries
|
||||
// no Access-Control-Allow-Origin for the `null` origin a sandboxed frame sends.
|
||||
// The proxy must get the chance to answer with real CORS headers, or every
|
||||
// dashboard fetch fails its preflight.
|
||||
const res = await app.inject({
|
||||
method: 'OPTIONS',
|
||||
url: `/webview/${capability}/api/stats`,
|
||||
headers: { origin: 'null', 'access-control-request-method': 'GET' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200); // reached the stand-in route, not the 204 hook
|
||||
});
|
||||
|
||||
it('still short-circuits OPTIONS everywhere else', async () => {
|
||||
// Authenticated, because the auth hook runs before the security-headers hook
|
||||
// and would otherwise 401 first. With credentials the 204 short-circuit is
|
||||
// reached, proving it is intact for every non-webview path.
|
||||
const res = await app.inject({
|
||||
method: 'OPTIONS',
|
||||
url: '/api/sessions',
|
||||
headers: {
|
||||
origin: 'null',
|
||||
'access-control-request-method': 'GET',
|
||||
authorization: 'Basic ' + Buffer.from(`admin:${PASSWORD}`).toString('base64'),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(204);
|
||||
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('serves a root-absolute asset when the Referer identifies the dashboard', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/static/app.js',
|
||||
headers: { referer: `http://localhost/webview/${capability}/panel` },
|
||||
});
|
||||
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', () => {
|
||||
it('rejects an unauthenticated request with no capability at all', async () => {
|
||||
expect((await app.inject({ method: 'GET', url: '/static/app.js' })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: 'GET', url: '/' })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a well-formed but UNKNOWN capability', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${'Z'.repeat(32)}/x` });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a revoked capability immediately', async () => {
|
||||
webviewCapabilities.revokeWebview('webview-under-test');
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${capability}/x` });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('does not match a lookalike prefix', async () => {
|
||||
expect((await app.inject({ method: 'GET', url: '/webviewfoo/bar' })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
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',
|
||||
headers: { referer: `http://localhost/webview/${capability}/panel` },
|
||||
});
|
||||
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',
|
||||
url: '/static/app.js',
|
||||
headers: { referer: `http://localhost/webview/${capability}/panel`, origin: 'null' },
|
||||
payload: {},
|
||||
});
|
||||
// Blocked as cross-site by the Origin guard, or as unauthenticated. Either is fine;
|
||||
// what matters is that it is not 200.
|
||||
expect(res.statusCode).not.toBe(200);
|
||||
});
|
||||
|
||||
it('still blocks a genuinely cross-site write to the API', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
headers: { origin: 'https://evil.example' },
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticated access is unaffected', () => {
|
||||
const basic = 'Basic ' + Buffer.from(`admin:${PASSWORD}`).toString('base64');
|
||||
|
||||
it('normal Basic auth still reaches the app', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/', headers: { authorization: basic } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('a wrong password is still rejected', async () => {
|
||||
const wrong = 'Basic ' + Buffer.from('admin:nope').toString('base64');
|
||||
expect((await app.inject({ method: 'GET', url: '/', headers: { authorization: wrong } })).statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @fileoverview Frontend test for the "Web / URL" rows in the Run dropdown
|
||||
* (webview-tabs.js).
|
||||
*
|
||||
* A saved URL used to render as a single open-button, so the ONLY way to remove one
|
||||
* was to open it as a tab and go through the tab's gear, which is a dead end for a
|
||||
* URL you no longer want open. These pin the per-row edit/delete affordance and the
|
||||
* delete path behind it, because a UI affordance is exactly the kind of thing a
|
||||
* later render refactor drops silently.
|
||||
*
|
||||
* Builds a JSDOM window in-test under the default node env, same shape as
|
||||
* test/admin-ui.test.ts. Do NOT declare a per-file jsdom environment: it
|
||||
* externalizes node:fs under vite and the readFileSync calls below stop working.
|
||||
* ⚠ Do not name that directive in a comment either, vitest matches the string
|
||||
* anywhere in the file.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const CONSTANTS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
|
||||
const WEBVIEW_TABS = readFileSync(new URL('../src/web/public/webview-tabs.js', import.meta.url), 'utf-8');
|
||||
|
||||
interface AppLike {
|
||||
webviews: Map<string, { id: string; name: string; url: string; icon?: string }>;
|
||||
webviewOrder: string[];
|
||||
activeWebviewId: string | null;
|
||||
renderWebviewMenuItems(): void;
|
||||
renderSessionTabs(): void;
|
||||
deleteWebviewById(id: string): Promise<void>;
|
||||
_confirmAndDeleteWebview(id: string): Promise<boolean>;
|
||||
_removeWebviewTab(id: string): void;
|
||||
_apiDelete(path: string): Promise<{ ok: boolean } | null>;
|
||||
showToast?: (msg: string, kind: string) => void;
|
||||
showWebviewModal(id?: string): void;
|
||||
}
|
||||
|
||||
function boot(deleteOk = true) {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><body>
|
||||
<div class="run-mode-menu active" id="runModeMenu">
|
||||
<div class="run-mode-webviews" id="runModeWebviews"></div>
|
||||
</div>
|
||||
<div id="sessionTabs"></div>
|
||||
<div id="webviewLayer"></div>
|
||||
</body>`,
|
||||
{ url: 'http://localhost/', runScripts: 'outside-only' }
|
||||
);
|
||||
const win = dom.window as unknown as Window &
|
||||
typeof globalThis & { app: AppLike; CodemanApp: new () => AppLike; confirm: () => boolean };
|
||||
|
||||
// webview-tabs.js is a prototype mixin, so it needs the class it extends plus the
|
||||
// escapeHtml global from constants.js. Everything else it touches is stubbed.
|
||||
// One eval, not three: lexical declarations in a global eval do not survive into
|
||||
// the next one, and the class must be a window property for the same reason.
|
||||
(win as unknown as { eval: (s: string) => void }).eval(
|
||||
['window.CodemanApp = class CodemanApp {};', CONSTANTS, WEBVIEW_TABS].join('\n')
|
||||
);
|
||||
|
||||
const deleted: string[] = [];
|
||||
const app = new win.CodemanApp();
|
||||
app.webviews = new Map([
|
||||
['id-a', { id: 'id-a', name: 'Bio Dashboard', url: 'https://box.ts.net:4000', icon: '📈' }],
|
||||
['id-b', { id: 'id-b', name: 'Grafana', url: 'http://127.0.0.1:3000/d/x' }],
|
||||
]);
|
||||
app.webviewOrder = [];
|
||||
app.activeWebviewId = null;
|
||||
app.renderSessionTabs = () => {};
|
||||
app._apiDelete = async (path: string) => {
|
||||
deleted.push(path);
|
||||
return deleteOk ? { ok: true } : { ok: false };
|
||||
};
|
||||
win.app = app;
|
||||
win.confirm = () => true;
|
||||
app.renderWebviewMenuItems();
|
||||
return { dom, win, app, deleted };
|
||||
}
|
||||
|
||||
const rows = (win: Window) => win.document.querySelectorAll('#runModeWebviews .run-mode-row--web');
|
||||
|
||||
describe('Run dropdown Web/URL rows', () => {
|
||||
it('gives every saved URL an open, edit and delete control', () => {
|
||||
const { win } = boot();
|
||||
expect(rows(win)).toHaveLength(2);
|
||||
expect(win.document.querySelectorAll('#runModeWebviews .run-mode-option--web')).toHaveLength(2);
|
||||
expect(win.document.querySelectorAll('#runModeWebviews .run-mode-webview-edit')).toHaveLength(2);
|
||||
expect(win.document.querySelectorAll('#runModeWebviews .run-mode-webview-delete')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('escapes the name and url rather than interpolating them raw', () => {
|
||||
const { win, app } = boot();
|
||||
const name = '<img src=x onerror=alert(1)>';
|
||||
const url = 'https://h/"onmouseover="x';
|
||||
app.webviews.set('id-x', { id: 'id-x', name, url });
|
||||
app.renderWebviewMenuItems();
|
||||
|
||||
// Assert on the DOM, not on innerHTML: attribute serialization does not
|
||||
// re-escape `<`, so a string check reads as a breakout when there is none.
|
||||
expect(win.document.querySelectorAll('#runModeWebviews img')).toHaveLength(0);
|
||||
const row = rows(win)[2];
|
||||
expect(row.querySelector('.run-mode-option--web')!.textContent).toContain(name);
|
||||
expect(row.querySelector('.run-mode-option--web')!.getAttribute('title')).toBe(url);
|
||||
expect(row.querySelector('.run-mode-webview-delete')!.getAttribute('aria-label')).toBe(`Delete ${name}`);
|
||||
});
|
||||
|
||||
it('stops the delete click from also opening the dashboard', () => {
|
||||
const { win, app } = boot();
|
||||
let opened = 0;
|
||||
(app as unknown as { openWebviewFromMenu: () => void }).openWebviewFromMenu = () => {
|
||||
opened++;
|
||||
};
|
||||
const del = win.document.querySelector<HTMLElement>('#runModeWebviews .run-mode-webview-delete')!;
|
||||
expect(del.getAttribute('onclick')).toContain('event.stopPropagation()');
|
||||
del.click();
|
||||
expect(opened).toBe(0);
|
||||
});
|
||||
|
||||
it('deletes server-side and drops the row, leaving the menu open', async () => {
|
||||
const { win, app, deleted } = boot();
|
||||
app._removeWebviewTab = () => {};
|
||||
await app.deleteWebviewById('id-a');
|
||||
expect(deleted).toEqual(['/api/webviews/id-a']);
|
||||
expect(app.webviews.has('id-a')).toBe(false);
|
||||
expect(rows(win)).toHaveLength(1);
|
||||
// Deleting one of several URLs should leave you looking at the rest of the list.
|
||||
expect(win.document.getElementById('runModeMenu')!.classList.contains('active')).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing when the confirm is declined', async () => {
|
||||
const { win, app, deleted } = boot();
|
||||
win.confirm = () => false;
|
||||
await app.deleteWebviewById('id-a');
|
||||
expect(deleted).toEqual([]);
|
||||
expect(app.webviews.has('id-a')).toBe(true);
|
||||
expect(rows(win)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps the row and warns when the server refuses the delete', async () => {
|
||||
const { win, app } = boot(false);
|
||||
const toast = vi.fn();
|
||||
app.showToast = toast;
|
||||
app._removeWebviewTab = () => {};
|
||||
await app.deleteWebviewById('id-a');
|
||||
expect(toast).toHaveBeenCalledWith('Could not delete URL', 'error');
|
||||
expect(app.webviews.has('id-a')).toBe(true);
|
||||
expect(rows(win)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('still renders the empty state when nothing is saved', () => {
|
||||
const { win, app } = boot();
|
||||
app.webviews.clear();
|
||||
app.renderWebviewMenuItems();
|
||||
expect(rows(win)).toHaveLength(0);
|
||||
expect(win.document.querySelector('.run-mode-empty')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,652 @@
|
||||
/**
|
||||
* Pure helpers behind the web-tab reverse proxy (src/web/webview-proxy.ts).
|
||||
*
|
||||
* These cover the rewrites that make an un-embeddable dashboard embeddable, and
|
||||
* the containment checks that keep the proxy from becoming an open relay.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import {
|
||||
buildDownstreamResponseHeaders,
|
||||
buildProxyCorsHeaders,
|
||||
buildUpstreamRequestHeaders,
|
||||
capabilityFromProxyPath,
|
||||
capabilityFromReferer,
|
||||
extractFrameAncestors,
|
||||
filterCookieHeader,
|
||||
isFramableCrossOrigin,
|
||||
isHtmlContentType,
|
||||
isValidWebviewUrl,
|
||||
parseWebviewUrl,
|
||||
proxyPrefixFor,
|
||||
resolveUpstreamUrl,
|
||||
rewriteHtml,
|
||||
rewriteLocation,
|
||||
rewriteSetCookie,
|
||||
runtimeUrlShim,
|
||||
stripFrameAncestors,
|
||||
upstreamWebSocketUrl,
|
||||
} from '../src/web/webview-proxy.js';
|
||||
|
||||
const CAP = 'A'.repeat(32);
|
||||
const PREFIX = `/webview/${CAP}/`;
|
||||
|
||||
describe('parseWebviewUrl', () => {
|
||||
it('accepts plain http and https', () => {
|
||||
expect(parseWebviewUrl('http://127.0.0.1:4000/')?.origin).toBe('http://127.0.0.1:4000');
|
||||
expect(parseWebviewUrl('https://dash.example.com/grafana')?.origin).toBe('https://dash.example.com');
|
||||
});
|
||||
|
||||
it('rejects non-http schemes', () => {
|
||||
for (const url of ['javascript:alert(1)', 'file:///etc/passwd', 'data:text/html,x', 'ftp://host/x']) {
|
||||
expect(parseWebviewUrl(url), url).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects embedded credentials, which would be forwarded and logged', () => {
|
||||
expect(parseWebviewUrl('http://user:pass@host:4000/')).toBeNull();
|
||||
expect(parseWebviewUrl('http://user@host:4000/')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects garbage and empty input', () => {
|
||||
expect(parseWebviewUrl('')).toBeNull();
|
||||
expect(parseWebviewUrl('not a url')).toBeNull();
|
||||
expect(isValidWebviewUrl('http://ok.example')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveUpstreamUrl', () => {
|
||||
const saved = 'http://127.0.0.1:4000/grafana/d/abc?theme=dark';
|
||||
|
||||
it('serves the saved path+query for the landing page', () => {
|
||||
expect(resolveUpstreamUrl(saved, '', '')?.href).toBe('http://127.0.0.1:4000/grafana/d/abc?theme=dark');
|
||||
});
|
||||
|
||||
it('is ORIGIN-scoped, not path-scoped, so root-absolute assets resolve', () => {
|
||||
// The saved /grafana/d/abc path must NOT be prepended, or /public/x.js 404s.
|
||||
expect(resolveUpstreamUrl(saved, 'public/build/app.js', '')?.href).toBe(
|
||||
'http://127.0.0.1:4000/public/build/app.js'
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the query string through', () => {
|
||||
expect(resolveUpstreamUrl(saved, 'api/data', '?from=now-6h')?.href).toBe(
|
||||
'http://127.0.0.1:4000/api/data?from=now-6h'
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to leave the upstream origin', () => {
|
||||
// Protocol-relative would jump host; traversal would climb out.
|
||||
expect(resolveUpstreamUrl(saved, '/evil.com/x', '')?.origin).toBe('http://127.0.0.1:4000');
|
||||
expect(resolveUpstreamUrl(saved, '//evil.com/x', '')).toBeNull();
|
||||
const climbed = resolveUpstreamUrl(saved, '../../../../etc/passwd', '');
|
||||
expect(climbed?.origin).toBe('http://127.0.0.1:4000');
|
||||
});
|
||||
|
||||
it('returns null for an unusable saved url', () => {
|
||||
expect(resolveUpstreamUrl('javascript:alert(1)', 'x', '')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('capability extraction', () => {
|
||||
it('reads the capability out of a proxy path', () => {
|
||||
expect(capabilityFromProxyPath(`${PREFIX}static/app.js`)).toBe(CAP);
|
||||
expect(capabilityFromProxyPath(PREFIX)).toBe(CAP);
|
||||
expect(capabilityFromProxyPath(`/webview/${CAP}`)).toBe(CAP);
|
||||
});
|
||||
|
||||
it('does not match a lookalike prefix', () => {
|
||||
expect(capabilityFromProxyPath('/webviewfoo/bar')).toBeNull();
|
||||
expect(capabilityFromProxyPath('/api/webviews')).toBeNull();
|
||||
expect(capabilityFromProxyPath('/')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects capabilities of implausible shape', () => {
|
||||
expect(capabilityFromProxyPath('/webview/short/x')).toBeNull();
|
||||
expect(capabilityFromProxyPath('/webview/has spaces here and more/x')).toBeNull();
|
||||
expect(capabilityFromProxyPath('/webview/../../etc/x')).toBeNull();
|
||||
});
|
||||
|
||||
it('reads it from a Referer for the root-absolute asset fallback', () => {
|
||||
expect(capabilityFromReferer(`https://box.ts.net${PREFIX}page`)).toBe(CAP);
|
||||
expect(capabilityFromReferer('https://box.ts.net/')).toBeNull();
|
||||
expect(capabilityFromReferer('not a url')).toBeNull();
|
||||
expect(capabilityFromReferer(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSP handling', () => {
|
||||
it('strips frame-ancestors and keeps every other directive', () => {
|
||||
const csp = "default-src 'self'; frame-ancestors 'none'; script-src 'unsafe-inline'";
|
||||
expect(stripFrameAncestors(csp)).toBe("default-src 'self'; script-src 'unsafe-inline'");
|
||||
});
|
||||
|
||||
it('leaves a policy without frame-ancestors alone', () => {
|
||||
expect(stripFrameAncestors("default-src 'self'")).toBe("default-src 'self'");
|
||||
});
|
||||
|
||||
it('does not confuse a similarly-named directive', () => {
|
||||
expect(stripFrameAncestors("frame-src 'self'; frame-ancestors 'none'")).toBe("frame-src 'self'");
|
||||
});
|
||||
|
||||
it('extracts the directive value for the probe', () => {
|
||||
expect(extractFrameAncestors("default-src 'self'; frame-ancestors https://a.com")).toBe('https://a.com');
|
||||
expect(extractFrameAncestors("default-src 'self'")).toBeUndefined();
|
||||
expect(extractFrameAncestors(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFramableCrossOrigin', () => {
|
||||
it('honours X-Frame-Options', () => {
|
||||
expect(isFramableCrossOrigin('DENY', undefined)).toBe(false);
|
||||
expect(isFramableCrossOrigin('sameorigin', undefined)).toBe(false);
|
||||
expect(isFramableCrossOrigin(undefined, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats frame-ancestors 'none' and 'self' as not cross-origin framable", () => {
|
||||
expect(isFramableCrossOrigin(undefined, "frame-ancestors 'none'")).toBe(false);
|
||||
expect(isFramableCrossOrigin(undefined, "frame-ancestors 'self'")).toBe(false);
|
||||
});
|
||||
|
||||
it('allows a wildcard or explicit host', () => {
|
||||
expect(isFramableCrossOrigin(undefined, 'frame-ancestors *')).toBe(true);
|
||||
expect(isFramableCrossOrigin(undefined, 'frame-ancestors https://codeman.example')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteLocation', () => {
|
||||
const requestUrl = new URL('http://127.0.0.1:4000/login');
|
||||
|
||||
it('maps a root-absolute redirect into the proxy prefix', () => {
|
||||
expect(rewriteLocation('/dashboard?x=1', requestUrl, CAP)).toBe(`${PREFIX}dashboard?x=1`);
|
||||
});
|
||||
|
||||
it('maps a same-origin absolute redirect', () => {
|
||||
expect(rewriteLocation('http://127.0.0.1:4000/home', requestUrl, CAP)).toBe(`${PREFIX}home`);
|
||||
});
|
||||
|
||||
it('leaves a CROSS-origin redirect alone rather than relaying it', () => {
|
||||
// Relaying would make this an open proxy for any host the upstream names.
|
||||
expect(rewriteLocation('https://evil.example/x', requestUrl, CAP)).toBe('https://evil.example/x');
|
||||
});
|
||||
|
||||
it('preserves the hash', () => {
|
||||
expect(rewriteLocation('/panel#row2', requestUrl, CAP)).toBe(`${PREFIX}panel#row2`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteSetCookie', () => {
|
||||
it('rebases Path onto the proxy prefix and drops Domain', () => {
|
||||
const out = rewriteSetCookie('sid=abc; Path=/; Domain=dash.local; HttpOnly', CAP, true);
|
||||
expect(out).toContain('sid=abc');
|
||||
expect(out).toContain(`Path=${PREFIX}`);
|
||||
expect(out).not.toContain('Domain');
|
||||
expect(out).toContain('HttpOnly');
|
||||
});
|
||||
|
||||
it('adds a scoped Path when the upstream sent none', () => {
|
||||
expect(rewriteSetCookie('sid=abc; HttpOnly', CAP, true)).toContain(`Path=${PREFIX}`);
|
||||
});
|
||||
|
||||
it('drops Secure when Codeman itself is serving plain HTTP', () => {
|
||||
// A Secure cookie over http is silently discarded by the browser.
|
||||
expect(rewriteSetCookie('sid=abc; Path=/; Secure', CAP, false)).not.toMatch(/secure/i);
|
||||
expect(rewriteSetCookie('sid=abc; Path=/; Secure', CAP, true)).toMatch(/Secure/);
|
||||
});
|
||||
|
||||
it('keeps a nested upstream path under the prefix', () => {
|
||||
expect(rewriteSetCookie('sid=abc; Path=/admin', CAP, true)).toContain(`Path=${PREFIX}admin`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterCookieHeader', () => {
|
||||
it("removes Codeman's own session cookie and keeps the dashboard's", () => {
|
||||
expect(filterCookieHeader('codeman_session=SECRET; dash=1; other=2', ['codeman_session'])).toBe('dash=1; other=2');
|
||||
});
|
||||
|
||||
it('returns undefined when nothing survives', () => {
|
||||
expect(filterCookieHeader('codeman_session=SECRET', ['codeman_session'])).toBeUndefined();
|
||||
expect(filterCookieHeader(undefined, ['codeman_session'])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUpstreamRequestHeaders', () => {
|
||||
const upstream = new URL('http://127.0.0.1:4000/panel');
|
||||
|
||||
it('NEVER forwards Codeman credentials to the dashboard', () => {
|
||||
const headers = buildUpstreamRequestHeaders(
|
||||
{ authorization: 'Basic CODEMANCREDS', cookie: 'codeman_session=SECRET; dash=1', accept: '*/*' },
|
||||
upstream,
|
||||
{ forwardCookies: false, sessionCookieName: 'codeman_session' }
|
||||
);
|
||||
expect(headers.authorization).toBeUndefined();
|
||||
expect(headers.cookie).toBeUndefined();
|
||||
expect(headers.accept).toBe('*/*');
|
||||
});
|
||||
|
||||
it('forwards the dashboard cookies but strips the session cookie in trusted mode', () => {
|
||||
const headers = buildUpstreamRequestHeaders({ cookie: 'codeman_session=SECRET; dash=1' }, upstream, {
|
||||
forwardCookies: true,
|
||||
sessionCookieName: 'codeman_session',
|
||||
});
|
||||
expect(headers.cookie).toBe('dash=1');
|
||||
expect(headers.authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('presents Origin/Referer as if the browser talked to the dashboard directly', () => {
|
||||
const headers = buildUpstreamRequestHeaders({ origin: 'https://codeman.local' }, upstream, {
|
||||
forwardCookies: false,
|
||||
sessionCookieName: 'codeman_session',
|
||||
});
|
||||
expect(headers.origin).toBe('http://127.0.0.1:4000');
|
||||
expect(headers.referer).toBe('http://127.0.0.1:4000/panel');
|
||||
});
|
||||
|
||||
it('drops hop-by-hop and recomputed headers', () => {
|
||||
const headers = buildUpstreamRequestHeaders(
|
||||
{ host: 'codeman.local', connection: 'keep-alive', 'transfer-encoding': 'chunked', 'content-length': '5' },
|
||||
upstream,
|
||||
{ forwardCookies: false, sessionCookieName: 'codeman_session' }
|
||||
);
|
||||
expect(headers.host).toBeUndefined();
|
||||
expect(headers.connection).toBeUndefined();
|
||||
expect(headers['transfer-encoding']).toBeUndefined();
|
||||
expect(headers['content-length']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDownstreamResponseHeaders', () => {
|
||||
const requestUrl = new URL('http://127.0.0.1:4000/panel');
|
||||
const build = (entries: Array<[string, string]>, cookies: string[] = []) =>
|
||||
buildDownstreamResponseHeaders(entries, cookies, CAP, requestUrl, true);
|
||||
|
||||
it('strips the framing refusal, which is the whole point of the proxy', () => {
|
||||
const { headers } = build([
|
||||
['x-frame-options', 'DENY'],
|
||||
['content-type', 'text/html'],
|
||||
]);
|
||||
expect(headers['x-frame-options']).toBeUndefined();
|
||||
expect(headers['content-type']).toBe('text/html');
|
||||
});
|
||||
|
||||
it('drops content-encoding/length because undici already decoded the body', () => {
|
||||
// Forwarding these makes the browser try to gunzip plaintext.
|
||||
const { headers } = build([
|
||||
['content-encoding', 'gzip'],
|
||||
['content-length', '1234'],
|
||||
]);
|
||||
expect(headers['content-encoding']).toBeUndefined();
|
||||
expect(headers['content-length']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the upstream CSP minus frame-ancestors, and null when there was none', () => {
|
||||
expect(build([['content-security-policy', "default-src 'self'; frame-ancestors 'none'"]]).csp).toBe(
|
||||
"default-src 'self'"
|
||||
);
|
||||
expect(build([['content-type', 'text/css']]).csp).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites Location and Set-Cookie', () => {
|
||||
const { headers, setCookie } = build([['location', '/next']], ['sid=1; Path=/']);
|
||||
expect(headers.location).toBe(`${PREFIX}next`);
|
||||
expect(setCookie).toHaveLength(1);
|
||||
expect(setCookie[0]).toContain(`Path=${PREFIX}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteHtml', () => {
|
||||
it('injects <base> immediately after <head>', () => {
|
||||
const out = rewriteHtml('<html><head><title>x</title></head><body></body></html>', CAP);
|
||||
expect(out).toContain(`<head><base href="${PREFIX}">`);
|
||||
});
|
||||
|
||||
it('falls back to <html>, then to the very start, for malformed documents', () => {
|
||||
expect(rewriteHtml('<html><body>hi</body></html>', CAP)).toContain(`<html><base href="${PREFIX}">`);
|
||||
const bare = rewriteHtml('just text', CAP);
|
||||
expect(bare.startsWith(`<base href="${PREFIX}">`)).toBe(true);
|
||||
expect(bare.endsWith('just text')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not add a second <base> when the page already has one', () => {
|
||||
const out = rewriteHtml('<html><head><base href="/x/"></head></html>', CAP);
|
||||
expect(out.match(/<base/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still injects the runtime shim when the page ships its own <base>', () => {
|
||||
// The shim is the only layer that catches runtime-built URLs, so an early
|
||||
// return on an existing <base> would silently break those pages.
|
||||
const out = rewriteHtml('<html><head><base href="/x/"></head></html>', CAP);
|
||||
expect(out).toContain('<script>');
|
||||
expect(out).toContain(PREFIX);
|
||||
});
|
||||
|
||||
it('injects the shim into every rewritten document', () => {
|
||||
expect(rewriteHtml('<html><head></head></html>', CAP)).toContain('<script>');
|
||||
expect(rewriteHtml('just text', CAP)).toContain('<script>');
|
||||
});
|
||||
|
||||
it('rebases root-absolute src/href/action, which <base> cannot fix', () => {
|
||||
const out = rewriteHtml(
|
||||
`<head></head><body><script src="/static/app.js"></script><link href='/s.css'><form action="/login"></form></body>`,
|
||||
CAP
|
||||
);
|
||||
expect(out).toContain(`src="${PREFIX}static/app.js"`);
|
||||
expect(out).toContain(`href='${PREFIX}s.css'`);
|
||||
expect(out).toContain(`action="${PREFIX}login"`);
|
||||
});
|
||||
|
||||
it('leaves protocol-relative and absolute URLs alone', () => {
|
||||
const out = rewriteHtml('<head></head><script src="//cdn.example/x.js"></script><img src="https://a/b.png">', CAP);
|
||||
expect(out).toContain('src="//cdn.example/x.js"');
|
||||
expect(out).toContain('src="https://a/b.png"');
|
||||
});
|
||||
|
||||
it('is stable across repeated calls (no shared regex lastIndex)', () => {
|
||||
const html = '<head></head><script src="/a.js"></script>';
|
||||
expect(rewriteHtml(html, CAP)).toBe(rewriteHtml(html, CAP));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProxyCorsHeaders', () => {
|
||||
it('echoes the opaque origin a sandboxed frame sends', () => {
|
||||
// Without this the browser rejects every dashboard fetch with an opaque
|
||||
// net::ERR_FAILED, while the page itself renders fine.
|
||||
const h = buildProxyCorsHeaders('null');
|
||||
expect(h['access-control-allow-origin']).toBe('null');
|
||||
expect(h.vary).toBe('Origin');
|
||||
});
|
||||
|
||||
it('omits allow-credentials for a null origin, which browsers reject together', () => {
|
||||
expect(buildProxyCorsHeaders('null')['access-control-allow-credentials']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows credentials for a real origin (trusted mode)', () => {
|
||||
const h = buildProxyCorsHeaders('https://codeman.local');
|
||||
expect(h['access-control-allow-origin']).toBe('https://codeman.local');
|
||||
expect(h['access-control-allow-credentials']).toBe('true');
|
||||
});
|
||||
|
||||
it('echoes requested headers on a preflight', () => {
|
||||
expect(buildProxyCorsHeaders('null', 'content-type, x-token')['access-control-allow-headers']).toBe(
|
||||
'content-type, x-token'
|
||||
);
|
||||
expect(buildProxyCorsHeaders('null')['access-control-allow-headers']).toBe('*');
|
||||
});
|
||||
|
||||
it('emits nothing when the request carries no Origin', () => {
|
||||
expect(buildProxyCorsHeaders(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtimeUrlShim', () => {
|
||||
const shim = runtimeUrlShim(PREFIX);
|
||||
const body = shim.replace(/^<script>/, '').replace(/<\/script>$/, '');
|
||||
|
||||
it('emits a parseable script', () => {
|
||||
expect(shim.startsWith('<script>')).toBe(true);
|
||||
expect(shim.endsWith('</script>')).toBe(true);
|
||||
expect(() => new Function(body)).not.toThrow();
|
||||
});
|
||||
|
||||
it('contains no bare </script> that would close the tag early', () => {
|
||||
expect(/<\/script>/i.test(body)).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Execute the shim against a fake window and return the patched globals, so the
|
||||
* rewrite logic is tested for real rather than by reading the source.
|
||||
*/
|
||||
function runShim(host = 'codeman.local') {
|
||||
const calls: string[] = [];
|
||||
const win: Record<string, unknown> = {
|
||||
fetch: (input: unknown) => {
|
||||
calls.push(String(typeof input === 'object' && input ? (input as { url: string }).url : input));
|
||||
return Promise.resolve();
|
||||
},
|
||||
XMLHttpRequest: function () {} as unknown as { prototype: Record<string, unknown> },
|
||||
WebSocket: class {
|
||||
url: string;
|
||||
constructor(u: string) {
|
||||
this.url = u;
|
||||
calls.push(u);
|
||||
}
|
||||
},
|
||||
EventSource: class {
|
||||
url: string;
|
||||
constructor(u: string) {
|
||||
this.url = u;
|
||||
calls.push(u);
|
||||
}
|
||||
},
|
||||
};
|
||||
(win.XMLHttpRequest as { prototype: Record<string, unknown> }).prototype = {
|
||||
open(_m: string, u: string) {
|
||||
calls.push(u);
|
||||
},
|
||||
};
|
||||
const location = { href: `https://${host}${PREFIX}page`, host };
|
||||
new Function('window', 'location', 'URL', 'Request', `with (window) { ${body} }`)(win, location, URL, undefined);
|
||||
return { win, calls };
|
||||
}
|
||||
|
||||
it('rewrites a ROOT-ABSOLUTE fetch, the case <base> cannot reach', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)('/api/carousel/job?id=1');
|
||||
expect(calls[0]).toBe(`${PREFIX}api/carousel/job?id=1`);
|
||||
});
|
||||
|
||||
it('leaves relative URLs alone (<base> already handles them)', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)('api/data');
|
||||
expect(calls[0]).toBe('api/data');
|
||||
});
|
||||
|
||||
it('does not double-prefix an already-proxied URL', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)(`${PREFIX}api/data`);
|
||||
expect(calls[0]).toBe(`${PREFIX}api/data`);
|
||||
});
|
||||
|
||||
it('leaves cross-origin URLs alone', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)('https://cdn.example/lib.js');
|
||||
expect(calls[0]).toBe('https://cdn.example/lib.js');
|
||||
});
|
||||
|
||||
it('rewrites a same-origin ABSOLUTE URL built from location', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)('https://codeman.local/api/data');
|
||||
expect(calls[0]).toBe(`https://codeman.local${PREFIX}api/data`);
|
||||
});
|
||||
|
||||
it('patches XMLHttpRequest.open', () => {
|
||||
const { win, calls } = runShim();
|
||||
const xhr = win.XMLHttpRequest as { prototype: { open: (m: string, u: string) => void } };
|
||||
xhr.prototype.open.call({}, 'GET', '/api/data');
|
||||
expect(calls[0]).toBe(`${PREFIX}api/data`);
|
||||
});
|
||||
|
||||
it('patches WebSocket and EventSource', () => {
|
||||
const { win, calls } = runShim();
|
||||
new (win.WebSocket as new (u: string) => unknown)('/live');
|
||||
new (win.EventSource as new (u: string) => unknown)('/events');
|
||||
expect(calls).toEqual([`${PREFIX}live`, `${PREFIX}events`]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The DOM half of the shim, run in a real jsdom document rather than the fake
|
||||
* window above, because these patches ARE DOM behavior: what matters is the URL the
|
||||
* browser would end up requesting after `innerHTML = ...`, not whether some
|
||||
* function got wrapped.
|
||||
*
|
||||
* The bug being pinned: a dashboard rendering `<img src="/api/hero?slug=x">` from
|
||||
* page script escapes `<base>` (which never applies to root-absolute URLs) and
|
||||
* escapes `rewriteHtml()` (which only sees the initial document), so every image
|
||||
* 404s on Codeman's own root while the dashboard's fetch-driven data loads fine.
|
||||
*
|
||||
* Node environment on purpose, like test/markdown-sanitizer.test.ts: a per-file
|
||||
* `@vitest-environment jsdom` externalizes node builtins under vite.
|
||||
*/
|
||||
describe('runtimeUrlShim DOM sinks', () => {
|
||||
const body = runtimeUrlShim(PREFIX)
|
||||
.replace(/^<script>/, '')
|
||||
.replace(/<\/script>$/, '');
|
||||
|
||||
function newDom() {
|
||||
const dom = new JSDOM('<!doctype html><html><head></head><body><div id="box"></div></body></html>', {
|
||||
url: `https://codeman.local${PREFIX}page`,
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
dom.window.eval(body);
|
||||
return dom;
|
||||
}
|
||||
|
||||
/** src of the first <img> in #box, as the attribute the browser would fetch. */
|
||||
function imgSrc(dom: JSDOM): string | null {
|
||||
return dom.window.document.querySelector('#box img')!.getAttribute('src');
|
||||
}
|
||||
|
||||
it('rewrites a root-absolute img src injected via innerHTML', () => {
|
||||
const dom = newDom();
|
||||
dom.window.document.getElementById('box')!.innerHTML =
|
||||
'<img class="thumb" loading="lazy" src="/api/hero?slug=x" alt="">';
|
||||
expect(imgSrc(dom)).toBe(`${PREFIX}api/hero?slug=x`);
|
||||
});
|
||||
|
||||
it('rewrites single-quoted markup and insertAdjacentHTML too', () => {
|
||||
const dom = newDom();
|
||||
dom.window.document.getElementById('box')!.insertAdjacentHTML('beforeend', "<img src='/api/logo'>");
|
||||
expect(imgSrc(dom)).toBe(`${PREFIX}api/logo`);
|
||||
});
|
||||
|
||||
it('rewrites the img.src property setter', () => {
|
||||
const dom = newDom();
|
||||
const img = new dom.window.Image();
|
||||
img.src = '/api/slide?owner=o&n=01';
|
||||
expect(img.getAttribute('src')).toBe(`${PREFIX}api/slide?owner=o&n=01`);
|
||||
});
|
||||
|
||||
it('rewrites setAttribute and media src/poster', () => {
|
||||
const dom = newDom();
|
||||
const video = dom.window.document.createElement('video');
|
||||
video.setAttribute('src', '/api/video?owner=o');
|
||||
video.poster = '/thumb.png';
|
||||
expect(video.getAttribute('src')).toBe(`${PREFIX}api/video?owner=o`);
|
||||
expect(video.getAttribute('poster')).toBe(`${PREFIX}thumb.png`);
|
||||
});
|
||||
|
||||
it('rewrites every candidate in a srcset, leaving cross-origin ones alone', () => {
|
||||
const dom = newDom();
|
||||
const img = dom.window.document.createElement('img');
|
||||
img.setAttribute('srcset', '/a.png 1x, /b.png 2x, https://cdn.example/c.png 3x');
|
||||
expect(img.getAttribute('srcset')).toBe(`${PREFIX}a.png 1x, ${PREFIX}b.png 2x, https://cdn.example/c.png 3x`);
|
||||
});
|
||||
|
||||
it('leaves relative, cross-origin, hash, data: and already-proxied URLs untouched', () => {
|
||||
const dom = newDom();
|
||||
const box = dom.window.document.getElementById('box')!;
|
||||
box.innerHTML = [
|
||||
'<img id="rel" src="api/rel.png">',
|
||||
'<img id="cross" src="https://cdn.example/z.png">',
|
||||
'<img id="data" src="data:image/gif;base64,AAAA">',
|
||||
`<img id="done" src="${PREFIX}api/hero">`,
|
||||
'<a id="hash" href="#top">t</a>',
|
||||
].join('');
|
||||
const at = (id: string, attr: string) => dom.window.document.getElementById(id)!.getAttribute(attr);
|
||||
expect(at('rel', 'src')).toBe('api/rel.png');
|
||||
expect(at('cross', 'src')).toBe('https://cdn.example/z.png');
|
||||
expect(at('data', 'src')).toBe('data:image/gif;base64,AAAA');
|
||||
expect(at('done', 'src')).toBe(`${PREFIX}api/hero`);
|
||||
expect(at('hash', 'href')).toBe('#top');
|
||||
});
|
||||
|
||||
it('catches a node built through an UNPATCHED sink via the MutationObserver net', async () => {
|
||||
const dom = newDom();
|
||||
const { document } = dom.window;
|
||||
// createContextualFragment parses markup without going through innerHTML or
|
||||
// setAttribute, so only the observer can fix this one.
|
||||
const frag = document.createRange().createContextualFragment('<img id="net" src="/api/net.png">');
|
||||
expect(frag.querySelector('img')!.getAttribute('src')).toBe('/api/net.png');
|
||||
document.getElementById('box')!.appendChild(frag);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(document.getElementById('net')!.getAttribute('src')).toBe(`${PREFIX}api/net.png`);
|
||||
});
|
||||
|
||||
it('does not double-prefix when a page re-injects its own markup', () => {
|
||||
const dom = newDom();
|
||||
const box = dom.window.document.getElementById('box')!;
|
||||
box.innerHTML = '<img src="/api/hero">';
|
||||
const roundTrip = box.innerHTML;
|
||||
box.innerHTML = roundTrip;
|
||||
expect(imgSrc(dom)).toBe(`${PREFIX}api/hero`);
|
||||
});
|
||||
|
||||
it('leaves an empty src empty, the "no image for this row" case', () => {
|
||||
const dom = newDom();
|
||||
dom.window.document.getElementById('box')!.innerHTML = '<img class="thumb" src="" alt="">';
|
||||
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');
|
||||
img.src = '/api/hero';
|
||||
img.setAttribute('src', img.getAttribute('src')!);
|
||||
expect(img.getAttribute('src')).toBe(`${PREFIX}api/hero`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('misc helpers', () => {
|
||||
it('identifies HTML content types, parameters included', () => {
|
||||
expect(isHtmlContentType('text/html; charset=utf-8')).toBe(true);
|
||||
expect(isHtmlContentType('application/xhtml+xml')).toBe(true);
|
||||
expect(isHtmlContentType('application/json')).toBe(false);
|
||||
expect(isHtmlContentType(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('maps http(s) to ws(s) for the socket leg', () => {
|
||||
expect(upstreamWebSocketUrl(new URL('http://h:4000/live'))).toBe('ws://h:4000/live');
|
||||
expect(upstreamWebSocketUrl(new URL('https://h/live'))).toBe('wss://h/live');
|
||||
});
|
||||
|
||||
it('builds the iframe prefix', () => {
|
||||
expect(proxyPrefixFor(CAP)).toBe(PREFIX);
|
||||
});
|
||||
});
|
||||