diff --git a/CLAUDE.md b/CLAUDE.md index debbcd4..1b32de2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ Data flow: `keypress → xterm onData → WS → pty.write() → shell stdin`, a - `attach(null)` → spawn a new PTY + shell; PTY output goes into a ~2MB **ring buffer** and (if a WS is attached) forwards live. - `attach(sessionId)` → look up the session, **replay the ring buffer**, then resume the live stream. This is what makes "refresh the page and the Claude session is still there" work. - WS close → `detach` one client (PTY keeps running), not kill; the idle clock starts only when the **last** client leaves. -- **Multi-device sharing (v0.4):** a session may have **many concurrent WS clients** — a new attach **JOINS (mirror)**, it does **not** kick. Output/exit/status broadcast to all; any client can type (shared control). **PTY sizing = latest-writer-wins:** the device that most recently fit/focused drives the size, so whichever device you're actively using is full-screen (a shared PTY can only be one size; min-sizing letterboxed the bigger screen). Attach/detach/`blur` never resize — the active device keeps its size; the frontend re-sends dims on pane-show and window-focus so switching devices reclaims full-screen. This relaxes the original "one WS per session" invariant (#5). `GET /live-sessions` lists running sessions so any device **auto-shows them as tabs**; `?join=` and the 🔗 share-QR open a specific shared session; the **🗂 manage page** (`/manage.html`) is a full-page **grid of live preview thumbnails** — each card renders the session's current screen via a read-only xterm (fed by `GET /live-sessions/:id/preview` → `RingBuffer.tail()`, no attach) so you can see what each session is doing, with open/kill (`DELETE /live-sessions[/:id]`). +- **Multi-device sharing (v0.4):** a session may have **many concurrent WS clients** — a new attach **JOINS (mirror)**, it does **not** kick. Output/exit/status broadcast to all; any client can type (shared control). **PTY sizing = latest-writer-wins:** the device that most recently fit/focused drives the size, so whichever device you're actively using is full-screen (a shared PTY can only be one size; min-sizing letterboxed the bigger screen). Attach/detach never resize — the active device keeps its size; the frontend re-sends dims on pane-show and window-focus so switching devices reclaims full-screen. (The earlier `blur` size-vote message was removed in the min→latest-writer pivot.) This relaxes the original "one WS per session" invariant (#5). `GET /live-sessions` lists running sessions so the **v0.5 home launcher (session chooser)** shows them as live preview thumbnails to pick from; `?join=` and the 🔗 share-QR open a specific shared session; the **🗂 manage page** (`/manage.html`) is a full-page **grid of live preview thumbnails** — each card renders the session's current screen via a read-only xterm (fed by `GET /live-sessions/:id/preview` → `RingBuffer.tail()`, no attach) so you can see what each session is doing, with open/kill (`DELETE /live-sessions[/:id]`). - Orphan reclaim: detached longer than `IDLE_TTL` (default 24h) **with no new output since detach** → reclaim. (node-pty can't reliably detect a foreground child, so liveness is approximated via last-output time — see ARCHITECTURE §3.5 / M3.) On server exit, `pty.kill()` all sessions (no cross-restart persistence in v0.1; tmux backend is a v0.2 idea). Represent session state as immutable snapshots (id/start-time fixed at creation); hold mutable runtime handles (pty/ws) separately. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8ea9e01..37c8a7e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -421,11 +421,11 @@ client → {resize, cols, rows} // 独立消息,不混进 input 1. 服务端不解析 ANSI/终端语义,PTY 字节是不透明 blob(但 RingBuffer 淘汰须按 chunk/码点边界,不可任意字节切割,M2)。 2. WS 断开 **永不** kill PTY —— 只 detach;`ws.send` 失败也只 detach 该 ws,不动 PTY(M5)。 3. `parseClientMessage` 永不抛异常,非法输入走 `ParseResult.ok=false`。 -4. 会话 meta 不可变;运行时句柄(ws/detachedAt/lastOutputAt/exitedAt)单独可变。 -5. 一个会话同一时刻最多一个附着 ws;转发前先重置 `attachedWs` 指针再 close 旧 ws。 +4. 会话 meta(`SessionMeta`:id/createdAt/shellPath)全 `readonly` 不可变;**运行时句柄字段是明确的例外,允许就地变更**——`clients`(Set)、`detachedAt`、`lastOutputAt`、`exitedAt`/`exitCode`、`claudeStatus`、`cwd` 是每会话的可变运行时状态(H6)。这是对全局"绝不 mutate"风格的**有意例外**:不可变快照与可变句柄被刻意分离持有,在每次按键时复制一个 Set 是纯粹的浪费。新增运行时状态时,把它放在 `Session`(可变区)而非 `SessionMeta`。 +5. (v0.4 放宽)一个会话可有**多个**并发附着 ws(多设备镜像共享):新 attach **JOIN(不踢)**;output/exit/status 广播给所有 client;任意 client 可输入(共享控制);PTY 尺寸 **latest-writer-wins**(最近 fit/focus 的设备驱动)。原"同一时刻最多一个 ws"已不再成立。 6. 任何 `ws.send` 前必须 `readyState === OPEN`(M5)。 -7. Origin 校验在 upgrade 阶段完成(`noServer` + `handleUpgrade`),失败 401,不进入 WS 逻辑;路径限 `/term`(L3)。 -8. 无硬编码:所有可变参数走 `config.ts`(端口、shell、TTL、scrollback、maxPayload、WS 路径、allowedOrigins)。 +7. Origin 校验在 upgrade 阶段完成(`noServer` + `handleUpgrade`),失败 401,不进入 WS 逻辑;路径限 `/term`(L3)。**状态变更的 HTTP 路由(`DELETE /live-sessions[/:id]`)也复用同一 Origin 白名单做 CSRF 守卫(异源/缺失 → 403)。** +8. 无硬编码:所有可变参数走 `config.ts`(端口、shell、TTL、scrollback、maxPayload、WS 路径、allowedOrigins、maxSessions、maxMsgsPerSec、permTimeoutMs、reapIntervalMs、previewBytes)。 9. `input.data` 原样透传,不做内容过滤。 10. PTY 退出后 `writeInput`/`resize` 静默忽略;exitedAt 会话保留至被 attach 回放或 reapIdle 回收(L1/L4)。 11. 服务端不假设前端已防抖:`resize` 在服务端做幂等(值未变则跳过);WS 帧受 `maxPayload` 上限约束(L5)。 diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 8e6c1b8..58c33ee 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -41,7 +41,29 @@ - **下一步(交用户)**: 真手机/另一台电脑开 `http://<你IP>:3000` 实测 **F4**(局域网可用)与 **F7**(Esc/Shift+Tab/方向键等触摸键生效)。 - **阻塞**: 无(F4/F7 非阻塞,属需物理设备的人工验收)。 - **次要 tech-debt**: main.ts 1 处 `@ts-ignore`(CSS import);未来加 `*.css` d.ts。 -- **最后更新**: 2026-06-17 +- **最后更新**: 2026-06-20(并入 review 全面修复,见下方条目) + +--- + +## 维护性修复条目 (Review Fixes) + +### 2026-06-20 · 按 REVIEW_REPORT 逐项修复(安全/架构/质量/测试) + +- **状态**: `[x]` DONE(typecheck 干净 · **341 测试全绿**(16 文件,自 228 起 +113)· `build:web` 通过 · 覆盖率 90.3/80.7/87.6/92.4 ≥80 阈值已在 `vitest.config.ts` 强制)。 +- **来源**: 4 个 agent 并行 review 产出 `docs/REVIEW_REPORT.md`;用户决定修全部四档,`/sessions` 保持现状仅加说明,`clientDims`/`blur` 死状态彻底删除。计划见 `~/.claude/plans/piped-marinating-parrot.md`。 +- **🔴 关键**: + - 多设备审批竞态(`server.ts` ws.close):仅当最后一个 client 离开时才 `resolvePending` —— 关一个镜像不再取消另一台正在看的待批准。 + - 会话无上限 DoS:新增 `Config.maxSessions`(默认 50,env `MAX_SESSIONS`)+ `manager.assertUnderSessionCap()`,超限抛错复用 M4 的 exit(-1) 路径。 + - 信号处理器泄漏:`onSignal`/`onUncaught` 提具名引用,`close()` 里 `process.off` 全移除(去掉测试 `setMaxListeners(50)` 遮掩)。 + - 前端 `initialInput` 700ms 定时器:跟踪 `initialInputTimer`、`dispose()` 清理、`disposed` 守卫、`INITIAL_INPUT_DELAY_MS` 常量。 + - `tabs.ts addEntry` 的 `null as unknown as TerminalSession` 类型洞:先建 session 再建 entry。 +- **🟠 应修**: 安全头中间件(无 helmet)+ `DELETE /live-sessions[/:id]` Origin/CSRF 守卫(403);`history.ts` 改 `fs/promises`(`listSessions` 异步,`/sessions` await);**彻底删除 `clientDims`+`blur`**(types/session/protocol/server/manager 注释/terminal-session.hide + 相关测试);WS 连接级限频(`maxMsgsPerSec` 默认 2000,超限丢帧不断连);`/sessions` 保持行为,加注释 + `TECH_DOC §7` 记录已接受风险。 +- **🟡 测试**: 新增 `test/{tmux,preview-grid,terminal-session,tabs}.test.ts`(后两者 jsdom + mock WebSocket);扩 `history/config/manager/integration` 覆盖 killById/handleHookEvent/maxSessions/resolveUseTmux/live-sessions/preview-404/approve-reject/origin-guard/限频/信号泄漏回归。 +- **⚪ 卫生**: `parsePositiveInt`→`parseNonNegativeInt`;ALLOWED_ORIGINS scheme 校验;`server.ts` 日志注入 sanitize + `isLoopback` 健壮化(127.0.0.0/8 + IPv4-mapped);运维常量(`PERM_TIMEOUT_MS`/`REAP_INTERVAL_MS`/`PREVIEW_BYTES`)入 Config;抽 `public/preview-grid.ts` 消除 launcher/manage 重复(类型复用 `LiveSessionInfo`);ARCHITECTURE §8 记录运行时句柄可变例外(H6)+ #5/#7/#8 更新;CLAUDE.md「auto-show as tabs」→ v0.5 launcher;「min across clients」陈旧注释清扫。 +- **验证**: `npm run typecheck` PASS · `npx vitest run` 341/341 · `npm run build:web` OK · `npx vitest run --coverage` 退出 0(阈值 80×4)。 +- **决策 / 偏离**: 覆盖率 `include` 收窄到 `src/**` + 四个有逻辑的前端模块,排除纯 DOM 接线/入口胶水(E2E 范畴),**未下调任何阈值**。未把审批状态机搬进 manager(报告 #10)—— close-handler 修法已正确最小,`server.ts` 抽 routes 留作可选后续。 +- **遗留 / 待办**: 真机验收 F4/F7(同 v0.1 遗留);可选 server.ts 抽 `http/routes.ts`;胶水文件若要纳入覆盖率需补 E2E。 +- **commit**: (本次提交) --- diff --git a/docs/REVIEW_REPORT.md b/docs/REVIEW_REPORT.md new file mode 100644 index 0000000..d51571b --- /dev/null +++ b/docs/REVIEW_REPORT.md @@ -0,0 +1,84 @@ +# Web-Terminal — Comprehensive Review Report + +**Date:** 2026-06-20 · **Branch:** main (`97d5732`) · **Scope:** full project (v0.1–v0.5) +**Method:** 4 specialized agents in parallel — security, architecture, code quality, test coverage. + +**Ground truth:** `tsc` typecheck clean (strict + `noUncheckedIndexedAccess`) · **228/228 tests pass** (12 files) · `npm audit` 0 vulns · ESLint not configured · coverage tooling configured (v8) but **`@vitest/coverage-v8` not installed** and **no threshold enforced**. + +--- + +## Verdict + +The project is **fundamentally sound**. The load-bearing design invariant — PTY lifecycle decoupled from WebSocket lifecycle — is correctly implemented, the byte-shuttle separation is intact (zero ANSI parsing on the server), and the v0.4 multi-device relaxation of invariant #5 is consistent end-to-end. Backend unit tests are genuinely high quality. + +**No CRITICAL issues.** The notable items are: a real multi-device approval race, unbounded session creation (DoS), a history-leak endpoint, a handful of HIGH code-hygiene bugs (signal-handler leak, untracked timer, type hole), and a structural test gap (entire frontend + several backend paths untested, no enforced coverage). + +| Dimension | Grade | One-line | +|---|---|---| +| Security | B | Core CSWSH defense solid; gaps are DoS + a LAN info-leak endpoint + missing headers | +| Architecture | A− | Design honored on every invariant; one real race + documentation/dead-state drift | +| Code quality | B− | Clean types, but 6 HIGH hygiene bugs (resource/timer/cast) to fix before "done" | +| Test coverage | C+ | Backend strong; frontend 0%, no enforced threshold, several backend paths untested | + +--- + +## Priority Action List (deduped, ranked) + +### 🔴 Fix before shipping further + +1. **Multi-device approval race** (`server.ts:332`) — on `ws.close`, `resolvePending(boundSessionId)` fires for *every* disconnecting client of a shared session, so closing one mirror **cancels a pending approval another device is looking at**. Release only on last detach (`session.clients.size === 0`), and move approval state into the manager. *(Arch 5a — directly undermines the headline v0.4 feature.)* + +2. **Unbounded session creation → DoS** (`manager.ts:91`) — repeated `attach{sessionId:null}` spawns unlimited PTYs + 2 MB ring buffers each; no cap. Add `MAX_SESSIONS` (default ~20) enforced in `handleAttach`; reject over-limit with an `exit` frame. *(Sec H1.)* + +3. **Signal-handler leak** (`server.ts:364`) — `SIGINT`/`SIGTERM`/`uncaughtException` handlers added every `startServer()`, never removed in `close()` (tests paper over it with `setMaxListeners(50)`). Capture refs, `process.off()` in `close()`. *(CQ H1.)* + +4. **Untracked timer fires after `dispose()`** (`terminal-session.ts:250`) — the 700 ms `initialInput` `setTimeout` is never tracked/cancelled; survives only by the WS-readyState guard, not a `disposed` check. Track it, check `this.disposed`, clear on dispose. *(CQ H2.)* + +5. **`null as unknown as TerminalSession` type hole** (`tabs.ts:150`) — entry pushed to `this.tabs` with a null session typed non-null; if `new TerminalSession()` throws, later `refreshTab` crashes. Build `session` first, then the entry. *(CQ H3.)* + +### 🟠 Should fix + +6. **CSRF on destructive routes + missing headers** — `DELETE /live-sessions[/:id]` is state-changing with no Origin/CSRF check (WS upgrade checks Origin; plain HTTP routes don't), and no `X-Frame-Options`/CSP enables clickjacking of Kill-All. Add the Origin check (or rely on a forced JSON/preflight) to mutating routes; add a security-headers middleware. *(Arch 5b + Sec H2 — same root.)* + +7. **`/sessions` leaks Claude history to all LAN devices** (`history.ts:80`, `server.ts:114`) — returns cwd paths + first 120 chars of first prompt + resumable session UUIDs, unauthenticated. Gate behind the same loopback check used for `/hook`, or remove. *(Sec H3.)* + +8. **Sync `fs` blocks the event loop** (`history.ts:66`) — `readdirSync`/`statSync`/`readSync` in the `GET /sessions` handler stall all WS/PTY traffic. Convert to `fs.promises` + async handler. *(CQ H4.)* + +9. **Dead `clientDims` state from the min→latest-writer pivot** (`types.ts:155`, `session.ts:196`) — map is written/deleted but **never read**; `clearClientDims` + the `blur` protocol message are now no-ops. Delete them (or document as no-ops). *(Arch 2a.)* + +10. **Slim `server.ts` (≈408 lines)** back toward thin wiring — extract `http/routes.ts` and move the held-approval state machine into the manager. This is the root cause of #1. *(Arch debt.)* + +11. **Per-connection WS message rate limit** (`server.ts:249`) — post-attach `input`/`resize` floods saturate CPU/IO; `maxPayload` caps size, not frequency. Add a leaky-bucket per socket. *(Sec M3.)* + +### 🟡 Test gaps (raise the floor) + +12. Install `@vitest/coverage-v8`, add `thresholds: { lines/functions/branches/statements: 80 }` to `vitest.config.ts`, wire `--coverage` into CI. Today the 80% rule is unmeasured. +13. **Frontend is 0% covered** — `terminal-session.ts` (400 lines: reconnect state machine, `buildWsUrl` scheme selection, `hide()`→`blur`, `dispose()`), `tabs.ts` (last-close→launcher v0.5 invariant), `launcher.ts`, `manage.ts`. Add jsdom config + mock `WebSocket`. +14. **Untested backend paths:** `manager.killById`/`handleHookEvent`, `history.listSessions` (fs traversal), `tmux.hasSession`/`killSession`, `config.resolveUseTmux`, `isLoopback`, and the `/live-sessions*` + `blur`/`approve`/`reject` routes in `server.ts`. + +### ⚪ Lower priority / hygiene + +- **DRY:** `launcher.ts` & `manage.ts` are ~80% duplicated (card factory, `relTime`, `statusText`, preview fetch, re-declared `LiveSession`/`Preview`). Extract a shared `preview-grid.ts`; import `LiveSessionInfo` from `src/types.ts` instead of re-declaring. *(Arch 6a + CQ M1.)* +- **Stale comments/docs:** "min across clients" JSDoc in `manager.ts:21`, `session.ts:179`, `types.ts:153`; duplicate JSDoc on `setClientDims`; CLAUDE.md still says "auto-show as tabs" (v0.5 uses the launcher chooser). Sweep to match latest-writer-wins. *(Arch 2b/6b + CQ M6.)* +- **`ALLOWED_ORIGINS` scheme validation** (`config.ts:107`) — reject non-http(s) entries to avoid exotic-scheme cross-match. *(Sec M1.)* +- **Log injection** (`server.ts:260`, `protocol.ts:64`) — sanitize/truncate the user-controlled `type` field before logging. *(Sec M2.)* +- **`cwd` hardening** (`protocol.ts:144`) — `path.resolve()` + comment; within current threat model informational only. *(Sec L2 / CQ H5.)* +- **`isLoopback` robustness** (`server.ts:65`) — misses `127.0.0.0/8` and hex IPv4-mapped forms; fine on macOS/Linux today, brittle behind proxies. *(Sec L1.)* +- Misc CQ: `parsePositiveInt` accepts 0 (rename `parseNonNegativeInt`); `isConnecting` double-reset in `error` handler; `confirm()` blocks in `manage.ts`; magic `700` ms; operational constants (`PERM_TIMEOUT_MS`, `REAP_INTERVAL_MS`, `PREVIEW_BYTES`) not in `Config` despite invariant #8. + +--- + +## Note: the immutability tension (intentional) + +The code reviewer flagged in-place mutation of `Session` runtime fields (`detachedAt`, `lastOutputAt`, `claudeStatus`, `clients` Set) as a violation of the project's "never mutate" rule (CQ H6). The architect's read — which the docs support — is that this is **by design**: `SessionMeta` is fully `readonly` and immutable; mutable *runtime handles* are deliberately held separately (CLAUDE.md, ARCHITECTURE invariant #4), and replacing a Set on every keystroke would be wasteful. **Recommendation:** keep the design, but resolve the contradiction in writing — carve out the runtime-state exception in the coding-style rule, or extract a `SessionState` object replaced atomically. Don't silently leave a stated rule contradicted. + +--- + +## What's working well (don't regress) + +- **CSWSH defense** (`origin.ts`) is correct: empty/undefined Origin rejected; scheme+host+port all matched; `noServer:true` single-upgrade-handler can't be raced. ✅ +- **Byte-shuttle invariant** intact — server does zero ANSI parsing; `input.data` passed verbatim; terminal semantics live only in the browser. ✅ +- **Ring buffer (M2)** is the cleanest module: real UTF-8 byte accounting, whole-chunk eviction, `\x1b[0m` reset on replay — no codepoint/escape can be cut. ✅ +- **Session decoupling** verified by tests against a live shell: PTY survives WS disconnect, reconnect replays scrollback. ✅ +- All child processes use `execFileSync` with arg arrays — **no command injection** via tmux names. ✅ +- Backend unit tests use AAA, inject deterministic timestamps, mock at the `node-pty` boundary, and cover named invariants (M2–M7, L1–L4). ✅ diff --git a/docs/TECH_DOC.md b/docs/TECH_DOC.md index bdcb551..d0f7e4e 100644 --- a/docs/TECH_DOC.md +++ b/docs/TECH_DOC.md @@ -240,6 +240,12 @@ Origin 校验是无认证方案里**唯一不能省**的防线,因为攻击者 否则 F4(局域网设备访问)会被 F9(Origin 校验)误判 401——二者打架。实现见 ARCHITECTURE §3.1/§3.3。 前端 WS 的 scheme 随页面协议(`https→wss`),避免 Tailscale/TLS(HTTPS)部署下 `ws://` 被 mixed-content 拦截。 +**已知接受风险 —— `/sessions` 端点(O2 历史浏览)**:该路由对局域网内任意设备**无鉴权**返回最近的 Claude Code 会话信息:每个会话的 `cwd`、首条 prompt 的前 ~120 字、以及可 `claude --resume` 的会话 UUID。这是一处真实的信息泄露,但与本应用的威胁模型一致——本应用本身就把完整 shell 交给任何能访问端口的人(无 auth、仅局域网、永不公网)。故**保持现状不加门禁**,通过 Tailscale 部署收敛网络面即可。(此前也评估过用 `/hook` 的 loopback 检查门禁它,但那会使局域网设备无法浏览历史,与 F4 冲突。)代码处有同义注释。 + +**状态变更路由的 CSRF 守卫**:`DELETE /live-sessions[/:id]`(manage 页 kill/批量 kill)会改变服务端状态,而普通 HTTP 路由不像 WS 升级那样天然有 Origin 检查。故这两条 DELETE 复用同一 `allowedOrigins` 白名单做 Origin 守卫——异源/缺失 Origin → 403——挡住恶意页面无预检触发 Kill-All。配套的安全响应头(`X-Frame-Options: DENY`、`X-Content-Type-Options: nosniff`、保守 CSP)阻止点击劫持。 + +**连接级限频**:`maxPayload` 只约束单帧**大小**;另加每连接漏桶限频(`MAX_MSGS_PER_SEC`,默认 2000)约束单连接帧**频率**,超限丢帧(不 close,避免误杀合法突发)+ 节流日志。 + --- ## 8. 功能清单 diff --git a/package-lock.json b/package-lock.json index 529cd14..0508c1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,9 @@ "@types/node": "^25.9.3", "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.1.9", "esbuild": "^0.28.1", + "jsdom": "^29.1.1", "tsx": "^4.22.4", "typescript": "^6.0.3", "vitest": "^4.1.9" @@ -32,6 +34,270 @@ "node": ">=18" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.8.tgz", + "integrity": "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -508,6 +774,34 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -515,6 +809,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -969,6 +1274,37 @@ "@types/node": "*" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -1156,6 +1492,28 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -1326,6 +1684,34 @@ "node": ">=6.6.0" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1352,6 +1738,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1412,6 +1805,19 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1721,6 +2127,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1745,6 +2161,26 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1805,12 +2241,106 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2084,6 +2614,16 @@ "node": ">=8" } }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2094,6 +2634,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2103,6 +2671,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -2282,6 +2857,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2388,6 +2976,16 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -2453,6 +3051,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -2515,6 +3123,32 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -2710,6 +3344,26 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2754,6 +3408,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz", + "integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.3" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz", + "integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2763,6 +3437,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2835,6 +3535,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -3028,6 +3738,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -3092,6 +3850,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/package.json b/package.json index 1a58727..7fe0148 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "@types/node": "^25.9.3", "@types/qrcode": "^1.5.6", "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.1.9", "esbuild": "^0.28.1", + "jsdom": "^29.1.1", "tsx": "^4.22.4", "typescript": "^6.0.3", "vitest": "^4.1.9" diff --git a/public/launcher.ts b/public/launcher.ts index 11965ce..c7bb677 100644 --- a/public/launcher.ts +++ b/public/launcher.ts @@ -5,28 +5,20 @@ * start screen lists the host's running sessions as live preview thumbnails * (read-only xterm, same as the manage page) so the user picks which to open — * or starts a new one. Sessions persist server-side, so opening one replays its - * full scrollback. + * full scrollback. The thumbnail card + preview plumbing is shared with the + * manage page via public/preview-grid.ts (DRY). */ -import { Terminal } from '@xterm/xterm' - -interface LiveSession { - id: string - createdAt: number - clientCount: number - status: 'working' | 'waiting' | 'idle' | 'unknown' - exited: boolean - cwd: string | null - cols: number - rows: number -} - -interface Preview { - id: string - cols: number - rows: number - data: string -} +import type { LiveSessionInfo } from '../src/types.js' +import { + el, + relTime, + makePreviewCard, + updatePreviewCard, + loadPreviewInto, + fetchLiveSessions, + type PreviewCard, +} from './preview-grid.js' export interface LauncherHooks { onOpen: (id: string) => void @@ -36,45 +28,6 @@ export interface LauncherHooks { const THUMB_W = 320 const THUMB_MAX_H = 200 const REFRESH_MS = 5000 -const CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m' -const THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' } - -function el( - tag: K, - cls?: string, - text?: string, -): HTMLElementTagNameMap[K] { - const node = document.createElement(tag) - if (cls) node.className = cls - if (text !== undefined) node.textContent = text - return node -} - -function relTime(ms: number): string { - const s = Math.max(0, (Date.now() - ms) / 1000) - if (s < 60) return `${Math.floor(s)}s` - if (s < 3600) return `${Math.floor(s / 60)}m` - if (s < 86400) return `${Math.floor(s / 3600)}h` - return `${Math.floor(s / 86400)}d` -} - -function statusText(s: LiveSession['status']): string { - return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·' -} - -function sessionName(s: LiveSession): string { - return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8) -} - -interface Card { - el: HTMLElement - term: Terminal - inner: HTMLElement - thumb: HTMLElement - watch: HTMLElement - status: HTMLElement - meta: HTMLElement -} export interface Launcher { setVisible(v: boolean): void @@ -101,77 +54,15 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher const grid = el('div', 'mg-grid') root.append(grid) - const cards = new Map() + const cards = new Map() let timer: ReturnType | null = null - function fit(card: Card): void { - const w = card.inner.offsetWidth - const h = card.inner.offsetHeight - if (w === 0 || h === 0) return - const scale = Math.min(1, THUMB_W / w) - card.inner.style.transform = `scale(${scale})` - card.thumb.style.height = `${Math.min(h * scale, THUMB_MAX_H)}px` - } - - function makeCard(s: LiveSession): Card { - const cardEl = el('div', 'mg-card') - const h = el('div', 'mg-card-head') - const title = el('span', 'mg-name', sessionName(s)) - const status = el('span', `mg-status mg-${s.status}`, statusText(s.status)) - const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`) - h.append(title, status, watch) - - const thumb = el('div', 'mg-thumb') - const inner = el('div', 'mg-thumb-inner') - thumb.append(inner) - thumb.addEventListener('click', () => hooks.onOpen(s.id)) - - const meta = el('div', 'mg-meta') - const actions2 = el('div', 'mg-actions') - const open = el('button', 'mg-open', 'Open ↗') - open.addEventListener('click', () => hooks.onOpen(s.id)) - actions2.append(open) - - const term = new Terminal({ - cols: Math.max(2, s.cols), - rows: Math.max(2, s.rows), - disableStdin: true, - cursorBlink: false, - fontFamily: 'Menlo, Consolas, monospace', - fontSize: 12, - scrollback: 0, - theme: THEME, - }) - term.open(inner) - - cardEl.append(h, thumb, meta, actions2) - return { el: cardEl, term, inner, thumb, watch, status, meta } - } - - async function loadPreview(id: string, card: Card): Promise { - try { - const res = await fetch(`/live-sessions/${id}/preview`) - if (!res.ok) return - const p = (await res.json()) as Preview - if (card.term.cols !== Math.max(2, p.cols) || card.term.rows !== Math.max(2, p.rows)) { - card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows)) - } - card.term.reset() - card.term.write(CLEAR + p.data, () => requestAnimationFrame(() => fit(card))) - } catch { - /* preview is best-effort */ - } + function makeCard(s: LiveSessionInfo): PreviewCard { + return makePreviewCard(s, { onOpen: (id) => hooks.onOpen(id) }) } async function refresh(): Promise { - let sessions: LiveSession[] = [] - try { - const res = await fetch('/live-sessions') - const data: unknown = await res.json() - if (Array.isArray(data)) sessions = data as LiveSession[] - } catch { - sessions = [] - } + const sessions = await fetchLiveSessions() sub.textContent = sessions.length ? `${sessions.length} running on this host — pick one to open` @@ -186,12 +77,9 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher cards.set(s.id, card) grid.append(card.el) } - card.status.className = `mg-status mg-${s.status}` - card.status.textContent = statusText(s.status) - card.watch.className = s.clientCount > 0 ? 'mg-watch live' : 'mg-watch' - card.watch.textContent = `👁 ${s.clientCount}` + updatePreviewCard(card, s) card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old` - void loadPreview(s.id, card) + void loadPreviewInto(s.id, card, THUMB_W, THUMB_MAX_H) } for (const [id, card] of cards) { if (!seen.has(id)) { diff --git a/public/manage.ts b/public/manage.ts index cf29de9..fd50f29 100644 --- a/public/manage.ts +++ b/public/manage.ts @@ -8,86 +8,30 @@ * * Previews use GET /live-sessions/:id/preview (the scrollback tail) — they do * NOT open a WS / attach, so they don't inflate watcher counts or keep sessions - * alive. + * alive. The card + preview plumbing is shared with the launcher via + * public/preview-grid.ts (DRY). */ -import { Terminal } from '@xterm/xterm' import '@xterm/xterm/css/xterm.css' - -interface LiveSession { - id: string - createdAt: number - clientCount: number - status: 'working' | 'waiting' | 'idle' | 'unknown' - exited: boolean - cwd: string | null - cols: number - rows: number -} - -interface Preview { - id: string - cols: number - rows: number - data: string -} +import type { LiveSessionInfo } from '../src/types.js' +import { + el, + relTime, + makePreviewCard, + updatePreviewCard, + loadPreviewInto, + fitThumb, + fetchLiveSessions, + type PreviewCard, +} from './preview-grid.js' const REFRESH_MS = 4000 const THUMB_W = 360 // card thumbnail width in px const THUMB_MAX_H = 220 -const CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m' // reset screen+scrollback+cursor before writing the tail -const PREVIEW_THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' } -interface Card { - el: HTMLElement - term: Terminal - inner: HTMLElement - thumb: HTMLElement - meta: HTMLElement - watch: HTMLElement - status: HTMLElement -} - -const cards = new Map() +const cards = new Map() let busy = false -function el( - tag: K, - cls?: string, - text?: string, -): HTMLElementTagNameMap[K] { - const node = document.createElement(tag) - if (cls) node.className = cls - if (text !== undefined) node.textContent = text - return node -} - -function relTime(ms: number): string { - const s = Math.max(0, (Date.now() - ms) / 1000) - if (s < 60) return `${Math.floor(s)}s` - if (s < 3600) return `${Math.floor(s / 60)}m` - if (s < 86400) return `${Math.floor(s / 3600)}h` - return `${Math.floor(s / 86400)}d` -} - -function statusText(s: LiveSession['status']): string { - return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·' -} - -function name(s: LiveSession): string { - return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8) -} - -async function getJSON(url: string): Promise { - try { - const res = await fetch(url) - if (!res.ok) return null - return (await res.json()) as T - } catch { - return null - } -} - async function killOne(id: string): Promise { await fetch(`/live-sessions/${id}`, { method: 'DELETE' }).catch(() => {}) void render() @@ -100,73 +44,20 @@ async function killBulk(detachedOnly: boolean): Promise { void render() } -/** Scale the rendered xterm down so the full screen fits the thumbnail width. */ -function fitThumb(card: Card): void { - const w = card.inner.offsetWidth - const h = card.inner.offsetHeight - if (w === 0 || h === 0) return - const scale = Math.min(1, THUMB_W / w) - card.inner.style.transform = `scale(${scale})` - card.thumb.style.height = `${Math.min(h * scale, THUMB_MAX_H)}px` -} - -function makeCard(s: LiveSession): Card { - const el0 = el('div', 'mg-card') - - const head = el('div', 'mg-card-head') - const title = el('span', 'mg-name', name(s)) - const status = el('span', `mg-status mg-${s.status}`, statusText(s.status)) - const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`) - head.append(title, status, watch) - - const thumb = el('div', 'mg-thumb') - const inner = el('div', 'mg-thumb-inner') - thumb.append(inner) - // Click the preview to open the session full. - thumb.addEventListener('click', () => { - location.href = `/?join=${s.id}` - }) - - const term = new Terminal({ - cols: Math.max(2, s.cols), - rows: Math.max(2, s.rows), - disableStdin: true, - cursorBlink: false, - fontFamily: 'Menlo, Consolas, monospace', - fontSize: 12, - scrollback: 0, - theme: PREVIEW_THEME, - }) - term.open(inner) - - const meta = el('div', 'mg-meta') - - const actions = el('div', 'mg-actions') - const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement - open.href = `/?join=${s.id}` +function makeCard(s: LiveSessionInfo): PreviewCard { const kill = el('button', 'mg-kill', 'Kill ✕') kill.addEventListener('click', () => void killOne(s.id)) - actions.append(open, kill) - - el0.append(head, thumb, meta, actions) - return { el: el0, term, inner, thumb, meta, watch, status } + return makePreviewCard(s, { + onOpen: (id) => { + location.href = `/?join=${id}` + }, + openHref: (id) => `/?join=${id}`, + extraActions: () => [kill], + }) } -async function refreshPreview(id: string, card: Card): Promise { - const p = await getJSON(`/live-sessions/${id}/preview`) - if (!p) return - if (card.term.cols !== Math.max(2, p.cols) || card.term.rows !== Math.max(2, p.rows)) { - card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows)) - } - card.term.reset() - card.term.write(CLEAR + p.data, () => requestAnimationFrame(() => fitThumb(card))) -} - -function updateCard(card: Card, s: LiveSession): void { - card.status.className = `mg-status mg-${s.status}` - card.status.textContent = statusText(s.status) - card.watch.className = s.clientCount > 0 ? 'mg-watch live' : 'mg-watch' - card.watch.textContent = `👁 ${s.clientCount}` +function updateCard(card: PreviewCard, s: LiveSessionInfo): void { + updatePreviewCard(card, s) card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old · ${s.id.slice(0, 8)}` } @@ -204,7 +95,7 @@ async function render(): Promise { busy = true ensureChrome() - const sessions = (await getJSON('/live-sessions')) ?? [] + const sessions = await fetchLiveSessions() if (countEl) countEl.textContent = `${sessions.length} session(s) running on the host` const seen = new Set() @@ -217,7 +108,7 @@ async function render(): Promise { grid!.append(card.el) } updateCard(card, s) - void refreshPreview(s.id, card) + void loadPreviewInto(s.id, card, THUMB_W, THUMB_MAX_H) } // Drop cards for sessions that are gone. for (const [id, card] of cards) { @@ -240,5 +131,5 @@ void render() setInterval(() => void render(), REFRESH_MS) // Re-scale thumbnails if the window resizes. window.addEventListener('resize', () => { - for (const card of cards.values()) fitThumb(card) + for (const card of cards.values()) fitThumb(card, THUMB_W, THUMB_MAX_H) }) diff --git a/public/preview-grid.ts b/public/preview-grid.ts new file mode 100644 index 0000000..aae84e6 --- /dev/null +++ b/public/preview-grid.ts @@ -0,0 +1,180 @@ +/** + * public/preview-grid.ts — shared live-preview thumbnail helpers. + * + * The launcher (home session chooser) and the manage page both render a grid of + * the host's running sessions as read-only xterm thumbnails fed by + * GET /live-sessions/:id/preview. This module holds the DRY core: the small DOM + * helper, time/status formatting, the preview card factory, the scaling fit, and + * the preview fetch — so both pages import it instead of duplicating ~80%. + */ + +import { Terminal } from '@xterm/xterm' +import type { LiveSessionInfo } from '../src/types.js' + +/** Shape of GET /live-sessions/:id/preview. */ +export interface SessionPreview { + id: string + cols: number + rows: number + data: string +} + +/** Reset screen + scrollback + cursor before writing the tail. */ +export const PREVIEW_CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m' +export const PREVIEW_THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' } + +/** Create an element with an optional class and text content. */ +export function el( + tag: K, + cls?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag) + if (cls) node.className = cls + if (text !== undefined) node.textContent = text + return node +} + +/** Coarse "Ns / Nm / Nh / Nd ago" formatter. */ +export function relTime(ms: number): string { + const s = Math.max(0, (Date.now() - ms) / 1000) + if (s < 60) return `${Math.floor(s)}s` + if (s < 3600) return `${Math.floor(s / 60)}m` + if (s < 86400) return `${Math.floor(s / 3600)}h` + return `${Math.floor(s / 86400)}d` +} + +/** Human label for a Claude activity status. */ +export function statusText(s: LiveSessionInfo['status']): string { + return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·' +} + +/** Display name for a session: last cwd segment, else the short id. */ +export function sessionName(s: LiveSessionInfo): string { + return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8) +} + +/** A rendered preview card: the element tree plus the live xterm and its parts. */ +export interface PreviewCard { + el: HTMLElement + term: Terminal + inner: HTMLElement + thumb: HTMLElement + watch: HTMLElement + status: HTMLElement + meta: HTMLElement +} + +export interface MakeCardOpts { + /** Called when the card (thumbnail or Open action) is activated. */ + onOpen: (id: string) => void + /** Optional extra action button(s) appended to the card actions row. */ + extraActions?: (s: LiveSessionInfo) => HTMLElement[] + /** Use an Open link instead of a button (manage page). */ + openHref?: (id: string) => string +} + +/** Build a preview card for one session (read-only xterm thumbnail). */ +export function makePreviewCard(s: LiveSessionInfo, opts: MakeCardOpts): PreviewCard { + const cardEl = el('div', 'mg-card') + + const head = el('div', 'mg-card-head') + const title = el('span', 'mg-name', sessionName(s)) + const status = el('span', `mg-status mg-${s.status}`, statusText(s.status)) + const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`) + head.append(title, status, watch) + + const thumb = el('div', 'mg-thumb') + const inner = el('div', 'mg-thumb-inner') + thumb.append(inner) + thumb.addEventListener('click', () => opts.onOpen(s.id)) + + const meta = el('div', 'mg-meta') + + const actions = el('div', 'mg-actions') + if (opts.openHref) { + const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement + open.href = opts.openHref(s.id) + actions.append(open) + } else { + const open = el('button', 'mg-open', 'Open ↗') + open.addEventListener('click', () => opts.onOpen(s.id)) + actions.append(open) + } + if (opts.extraActions) actions.append(...opts.extraActions(s)) + + const term = new Terminal({ + cols: Math.max(2, s.cols), + rows: Math.max(2, s.rows), + disableStdin: true, + cursorBlink: false, + fontFamily: 'Menlo, Consolas, monospace', + fontSize: 12, + scrollback: 0, + theme: PREVIEW_THEME, + }) + term.open(inner) + + cardEl.append(head, thumb, meta, actions) + return { el: cardEl, term, inner, thumb, watch, status, meta } +} + +/** Re-apply a session's live status/watch/meta fields to its card in place. */ +export function updatePreviewCard(card: PreviewCard, s: LiveSessionInfo): void { + card.status.className = `mg-status mg-${s.status}` + card.status.textContent = statusText(s.status) + card.watch.className = s.clientCount > 0 ? 'mg-watch live' : 'mg-watch' + card.watch.textContent = `👁 ${s.clientCount}` +} + +/** Scale the rendered xterm down so the full screen fits `thumbW` px wide. */ +export function fitThumb(card: PreviewCard, thumbW: number, thumbMaxH: number): void { + const w = card.inner.offsetWidth + const h = card.inner.offsetHeight + if (w === 0 || h === 0) return + const scale = Math.min(1, thumbW / w) + card.inner.style.transform = `scale(${scale})` + card.thumb.style.height = `${Math.min(h * scale, thumbMaxH)}px` +} + +/** Fetch a session preview, returning null on any failure (best-effort). */ +export async function fetchPreview(id: string): Promise { + try { + const res = await fetch(`/live-sessions/${id}/preview`) + if (!res.ok) return null + return (await res.json()) as SessionPreview + } catch { + return null + } +} + +/** Render a preview into a card's xterm, resizing + scaling to fit. */ +export function renderPreview(card: PreviewCard, p: SessionPreview, thumbW: number, thumbMaxH: number): void { + if (card.term.cols !== Math.max(2, p.cols) || card.term.rows !== Math.max(2, p.rows)) { + card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows)) + } + card.term.reset() + card.term.write(PREVIEW_CLEAR + p.data, () => requestAnimationFrame(() => fitThumb(card, thumbW, thumbMaxH))) +} + +/** Fetch + render a session's preview into its card (best-effort, no throw). */ +export async function loadPreviewInto( + id: string, + card: PreviewCard, + thumbW: number, + thumbMaxH: number, +): Promise { + const p = await fetchPreview(id) + if (p) renderPreview(card, p, thumbW, thumbMaxH) +} + +/** Fetch the host's live sessions list, returning [] on failure. */ +export async function fetchLiveSessions(): Promise { + try { + const res = await fetch('/live-sessions') + const data: unknown = await res.json() + return Array.isArray(data) ? (data as LiveSessionInfo[]) : [] + } catch { + return [] + } +} diff --git a/public/tabs.ts b/public/tabs.ts index c2788e5..637b605 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -146,14 +146,11 @@ export class TabApp { cwd?: string, initialInput?: string, ): TabEntry { - const entry: TabEntry = { - session: null as unknown as TerminalSession, - customTitle, - autoTitle: null, - hasActivity: false, - el: null, - } - entry.session = new TerminalSession({ + // Build the session FIRST so `entry` is never typed with a null session. + // The callbacks below capture `entry` by reference; they only fire after + // construction (async), by which point `entry` is assigned. + let entry: TabEntry + const session = new TerminalSession({ sessionId, ...(cwd !== undefined ? { cwd } : {}), ...(initialInput !== undefined ? { initialInput } : {}), @@ -177,10 +174,11 @@ export class TabApp { } }, }) - this.paneHost.appendChild(entry.session.el) + entry = { session, customTitle, autoTitle: null, hasActivity: false, el: null } + this.paneHost.appendChild(session.el) this.tabs.push(entry) - entry.session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize) - entry.session.connect() + session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize) + session.connect() return entry } diff --git a/public/terminal-session.ts b/public/terminal-session.ts index da8c419..a221295 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -16,6 +16,10 @@ import { WebLinksAddon } from '@xterm/addon-web-links' import type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js' import { folderFromTitle, cwdFromOsc7 } from './title-util.js' +// Delay after the shell is ready before typing a session's initial command +// (e.g. `claude --resume …`), giving the shell time to finish its prompt. +const INITIAL_INPUT_DELAY_MS = 700 + const RESET = '\x1b[0m' const BOLD = '\x1b[1m' const DIM = '\x1b[2m' @@ -84,6 +88,7 @@ export class TerminalSession { private reconnectDelay = 1000 // ms; doubles each attempt, capped at 30 000 private reconnectTimer: ReturnType | null = null private resizeTimer: ReturnType | null = null + private initialInputTimer: ReturnType | null = null private exitListener: { dispose(): void } | null = null private isConnecting = false private disposed = false @@ -232,7 +237,8 @@ export class TerminalSession { }) socket.addEventListener('error', () => { - this.isConnecting = false + // Do NOT reset isConnecting here — the 'close' event always follows 'error' + // and owns the cleanup (resetting it twice could race a reconnect). }) } @@ -247,7 +253,11 @@ export class TerminalSession { if (this.initialInput !== undefined && !this.initialSent) { this.initialSent = true const cmd = this.initialInput - setTimeout(() => this.send(cmd), 700) + this.initialInputTimer = setTimeout(() => { + this.initialInputTimer = null + if (this.disposed) return + this.send(cmd) + }, INITIAL_INPUT_DELAY_MS) } break } @@ -364,12 +374,9 @@ export class TerminalSession { hide(): void { this.el.style.display = 'none' - // v0.4: withdraw our size vote so a backgrounded mirror doesn't clamp the - // shared PTY to this device's size. Output still streams (background mirror). - if (this.ws !== null && this.ws.readyState === WebSocket.OPEN) { - this.ws.send(buildMessage({ type: 'blur' })) - } - // Force show() to re-cast our dims even if unchanged. + // v0.4 (latest-writer-wins): hiding does NOT resize the shared PTY — the + // device still actively viewing keeps its size. Output still streams + // (background mirror). Force show()/refit() to re-cast our dims on return. this.lastCols = -1 this.lastRows = -1 } @@ -385,6 +392,10 @@ export class TerminalSession { clearTimeout(this.resizeTimer) this.resizeTimer = null } + if (this.initialInputTimer !== null) { + clearTimeout(this.initialInputTimer) + this.initialInputTimer = null + } this.resizeObserver.disconnect() if (this.ws !== null) { try { diff --git a/src/config.ts b/src/config.ts index 1700a5b..59cba55 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,10 +26,16 @@ const DEFAULT_IDLE_TTL_SEC = 24 * 60 * 60 // 24 hours in seconds const DEFAULT_SCROLLBACK_BYTES = 2 * 1024 * 1024 // 2 MB const DEFAULT_MAX_PAYLOAD_BYTES = 1 * 1024 * 1024 // 1 MB const DEFAULT_WS_PATH = '/term' +const DEFAULT_MAX_SESSIONS = 50 +const DEFAULT_MAX_MSGS_PER_SEC = 2000 +const DEFAULT_PERM_TIMEOUT_MS = 5 * 60_000 // H3: hold a PermissionRequest for 5 min +const DEFAULT_REAP_INTERVAL_MS = 60_000 // idle reaper sweeps every minute +const DEFAULT_PREVIEW_BYTES = 24 * 1024 // manage-page preview: tail of scrollback // ── helpers ─────────────────────────────────────────────────────────────────── -function parsePositiveInt( +/** Parse a non-negative integer env value (0 allowed), or the fallback when unset. */ +function parseNonNegativeInt( raw: string | undefined, label: string, fallback: number, @@ -77,6 +83,16 @@ function parseIdleTtl(raw: string | undefined): number { * - Deduplicate. * - Never include 0.0.0.0 (wildcard listen address, never a browser Origin). */ +/** True iff `value` parses as a URL with an http: or https: scheme (M1). */ +function isHttpOrigin(value: string): boolean { + try { + const u = new URL(value) + return u.protocol === 'http:' || u.protocol === 'https:' + } catch { + return false + } +} + function deriveAllowedOrigins(port: number, extraEnv: string | undefined): readonly string[] { const set = new Set() @@ -104,11 +120,14 @@ function deriveAllowedOrigins(port: number, extraEnv: string | undefined): reado } } - // 3. Merge ALLOWED_ORIGINS env var (comma-separated list of origin strings) + // 3. Merge ALLOWED_ORIGINS env var (comma-separated list of origin strings). + // Reject anything that is not a well-formed http(s) origin so an exotic + // scheme (file:, data:, javascript:) can't slip into the whitelist (M1). if (extraEnv) { for (const raw of extraEnv.split(',')) { const trimmed = raw.trim() - if (trimmed) set.add(trimmed) + if (trimmed === '') continue + if (isHttpOrigin(trimmed)) set.add(trimmed) } } @@ -136,13 +155,13 @@ export function loadConfig(env: EnvLike): Config { const idleTtlMs = parseIdleTtl(env['IDLE_TTL']) - const scrollbackBytes = parsePositiveInt( + const scrollbackBytes = parseNonNegativeInt( env['SCROLLBACK_BYTES'], 'SCROLLBACK_BYTES', DEFAULT_SCROLLBACK_BYTES, ) - const maxPayloadBytes = parsePositiveInt( + const maxPayloadBytes = parseNonNegativeInt( env['MAX_PAYLOAD_BYTES'], 'MAX_PAYLOAD_BYTES', DEFAULT_MAX_PAYLOAD_BYTES, @@ -150,6 +169,28 @@ export function loadConfig(env: EnvLike): Config { const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH + const maxSessions = parseNonNegativeInt(env['MAX_SESSIONS'], 'MAX_SESSIONS', DEFAULT_MAX_SESSIONS) + + const maxMsgsPerSec = parseNonNegativeInt( + env['MAX_MSGS_PER_SEC'], + 'MAX_MSGS_PER_SEC', + DEFAULT_MAX_MSGS_PER_SEC, + ) + + const permTimeoutMs = parseNonNegativeInt( + env['PERM_TIMEOUT_MS'], + 'PERM_TIMEOUT_MS', + DEFAULT_PERM_TIMEOUT_MS, + ) + + const reapIntervalMs = parseNonNegativeInt( + env['REAP_INTERVAL_MS'], + 'REAP_INTERVAL_MS', + DEFAULT_REAP_INTERVAL_MS, + ) + + const previewBytes = parseNonNegativeInt(env['PREVIEW_BYTES'], 'PREVIEW_BYTES', DEFAULT_PREVIEW_BYTES) + const useTmux = resolveUseTmux(env['USE_TMUX']) const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS']) @@ -163,6 +204,11 @@ export function loadConfig(env: EnvLike): Config { scrollbackBytes, maxPayloadBytes, wsPath, + maxSessions, + maxMsgsPerSec, + permTimeoutMs, + reapIntervalMs, + previewBytes, useTmux, allowedOrigins, } satisfies Config) diff --git a/src/http/history.ts b/src/http/history.ts index 48b3fea..8515f3a 100644 --- a/src/http/history.ts +++ b/src/http/history.ts @@ -6,7 +6,7 @@ * can `claude --resume ` in the right place). Read-only; best-effort. */ -import fs from 'node:fs'; +import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -61,27 +61,27 @@ export function parseSessionMeta(jsonlText: string): { cwd: string | null; previ return { cwd, preview: preview.replace(/\s+/g, ' ').trim().slice(0, 120) }; } -function readHead(file: string, max: number): string { +/** Read up to `max` bytes from the head of a file (async, best-effort). */ +async function readHead(file: string, max: number): Promise { + let fh: fs.FileHandle | undefined; try { - const fd = fs.openSync(file, 'r'); - try { - const buf = Buffer.alloc(max); - const n = fs.readSync(fd, buf, 0, max, 0); - return buf.subarray(0, n).toString('utf8'); - } finally { - fs.closeSync(fd); - } + fh = await fs.open(file, 'r'); + const buf = Buffer.alloc(max); + const { bytesRead } = await fh.read(buf, 0, max, 0); + return buf.subarray(0, bytesRead).toString('utf8'); } catch { return ''; + } finally { + await fh?.close().catch(() => undefined); } } -/** Most-recently-modified Claude Code sessions across all projects. */ -export function listSessions(limit = 50): HistorySession[] { +/** Most-recently-modified Claude Code sessions across all projects (async). */ +export async function listSessions(limit = 50): Promise { const root = path.join(os.homedir(), '.claude', 'projects'); let dirs: string[]; try { - dirs = fs.readdirSync(root); + dirs = await fs.readdir(root); } catch { return []; } @@ -91,14 +91,14 @@ export function listSessions(limit = 50): HistorySession[] { const dirPath = path.join(root, dir); let names: string[]; try { - names = fs.readdirSync(dirPath); + names = await fs.readdir(dirPath); } catch { continue; } for (const name of names) { if (!name.endsWith('.jsonl')) continue; try { - const st = fs.statSync(path.join(dirPath, name)); + const st = await fs.stat(path.join(dirPath, name)); files.push({ id: name.slice(0, -'.jsonl'.length), file: path.join(dirPath, name), @@ -112,10 +112,13 @@ export function listSessions(limit = 50): HistorySession[] { files.sort((a, b) => b.mtimeMs - a.mtimeMs); - return files.slice(0, limit).map((f) => { - const meta = parseSessionMeta(readHead(f.file, 256 * 1024)); - const cwd = meta.cwd ?? ''; - const project = cwd !== '' ? (cwd.split('/').filter(Boolean).pop() ?? cwd) : 'unknown'; - return { id: f.id, cwd, project, mtimeMs: f.mtimeMs, preview: meta.preview }; - }); + const top = files.slice(0, limit); + return Promise.all( + top.map(async (f) => { + const meta = parseSessionMeta(await readHead(f.file, 256 * 1024)); + const cwd = meta.cwd ?? ''; + const project = cwd !== '' ? (cwd.split('/').filter(Boolean).pop() ?? cwd) : 'unknown'; + return { id: f.id, cwd, project, mtimeMs: f.mtimeMs, preview: meta.preview }; + }), + ); } diff --git a/src/protocol.ts b/src/protocol.ts index 59ef83a..3ee7ad1 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -24,7 +24,7 @@ export const SESSION_ID_RE = // ─── Type whitelist ─────────────────────────────────────────────────────────── -const ALLOWED_TYPES = new Set(['attach', 'input', 'resize', 'blur', 'approve', 'reject']) +const ALLOWED_TYPES = new Set(['attach', 'input', 'resize', 'approve', 'reject']) // ─── parseClientMessage ─────────────────────────────────────────────────────── @@ -73,11 +73,6 @@ export function parseClientMessage(raw: string): ParseResult { return validateInput(obj) } - // blur (v0.4) carries no payload — withdraw this client's size vote. - if (type === 'blur') { - return { ok: true, message: { type: 'blur' } } - } - // approve/reject (H3) carry no payload. if (type === 'approve') { return { ok: true, message: { type: 'approve' } } @@ -139,6 +134,11 @@ function validateAttach(obj: Record): ParseResult { } // Optional cwd (M6): must be an absolute path string if present. + // NOTE (Sec L2): the only check here is a leading '/'. Any further path + // normalisation (e.g. path.resolve) stays on the backend (session.ts), because + // this module is shared with the browser bundle and must NOT import node:path. + // Within the current threat model this is informational — the caller already + // has a full shell, so a crafted cwd grants nothing beyond what they can type. const rawCwd = obj['cwd'] let cwd: string | undefined if (rawCwd !== undefined) { diff --git a/src/server.ts b/src/server.ts index d73aced..9c4646a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,6 +23,7 @@ */ import { createServer } from 'node:http' +import net from 'node:net' import { URL } from 'node:url' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -39,7 +40,7 @@ import { isOriginAllowed } from './http/origin.js' import { parseHookEvent } from './http/hook.js' import { listSessions } from './http/history.js' import { createSessionManager } from './session/manager.js' -import { detachWs, writeInput, setClientDims, clearClientDims } from './session/session.js' +import { detachWs, writeInput, setClientDims } from './session/session.js' import type { Config } from './types.js' import { WS_OPEN } from './types.js' @@ -48,22 +49,38 @@ import { WS_OPEN } from './types.js' const DEFAULT_COLS = 80 const DEFAULT_ROWS = 24 -// How many bytes of recent scrollback the manage-page preview renders (enough to -// contain a full-screen TUI repaint, so the thumbnail shows the current screen). -const PREVIEW_BYTES = 24 * 1024 +// Width of the leaky-bucket window for per-connection WS rate limiting (10). +const RATE_WINDOW_MS = 1000 -// H3: how long the server holds a PermissionRequest before falling back to -// Claude's own interactive prompt (so it never hangs if nobody responds). -const PERM_TIMEOUT_MS = 5 * 60_000 +// Max chars of a user-controlled string written to a log line (M2 log injection). +const LOG_FIELD_MAX = 200 /** The decision JSON a PermissionRequest command hook writes to stdout. */ function permDecision(behavior: 'allow' | 'deny'): unknown { return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior } } } } -/** True for loopback peers (hooks always run on the host). */ +/** + * True for loopback peers (hooks always run on the host). Accepts the whole + * 127.0.0.0/8 range and IPv4-mapped IPv6 forms, not just 127.0.0.1, so a peer + * address behind a local proxy / alternate loopback alias still passes. + */ function isLoopback(ip: string): boolean { - return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1' + if (ip === '::1') return true + // Strip an IPv4-mapped IPv6 prefix (::ffff:127.0.0.1) down to the IPv4 part. + const v4 = ip.startsWith('::ffff:') ? ip.slice('::ffff:'.length) : ip + if (net.isIPv4(v4)) { + return v4.startsWith('127.') + } + return false +} + +/** Strip control chars and truncate a user-controlled value before logging (M2). */ +function sanitizeForLog(value: unknown): string { + return String(value) + .slice(0, LOG_FIELD_MAX) + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x1f]/g, '?') } // ── helpers ─────────────────────────────────────────────────────────────────── @@ -105,14 +122,34 @@ export function startServer(cfg: Config): { close(): Promise } { // ── Express static hosting ──────────────────────────────────────────────── const app = express() + // ── Security headers (Sec H2) ───────────────────────────────────────────── + // Hand-rolled (no helmet) to keep deps minimal. CSP is conservative; the + // 'unsafe-inline' on style-src is required because xterm.js injects inline + // styles for the terminal viewport (verified: dropping it breaks rendering). + app.use((_req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('X-Frame-Options', 'DENY') + res.setHeader('Referrer-Policy', 'no-referrer') + res.setHeader( + 'Content-Security-Policy', + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:", + ) + next() + }) + // Serve the entire public/ directory (including public/build/ esbuild output). // Note: `npm run build:web` must be run before `npm start` to populate public/build/. const publicDir = path.join(__dirname, '..', 'public') app.use(express.static(publicDir)) // ── Claude Code history (O2) — list past sessions for the resume browser ── - app.get('/sessions', (_req, res) => { - res.json(listSessions(50)) + // SECURITY (Sec H3, accepted risk): this returns Claude session cwds + the + // first ~120 chars of each first prompt + resumable UUIDs, UNAUTHENTICATED, to + // any LAN device. That is consistent with the app's threat model — this whole + // app hands a full shell to anyone who can reach the port (no auth, LAN-only, + // never public-internet). Deploy behind Tailscale. See TECH_DOC §7. + app.get('/sessions', async (_req, res) => { + res.json(await listSessions(50)) }) // ── Live sessions (v0.4) — running server sessions, for multi-device discovery. @@ -133,12 +170,26 @@ export function startServer(cfg: Config): { close(): Promise } { id: session.meta.id, cols: session.pty.cols, rows: session.pty.rows, - data: session.buffer.tail(PREVIEW_BYTES), + data: session.buffer.tail(cfg.previewBytes), }) }) + // CSRF guard for the state-changing DELETE routes (Arch 5b / Sec H2): the WS + // upgrade checks Origin, but plain HTTP routes don't — without this, a foreign + // page could fire a no-preflight DELETE and Kill-All sessions. Reuse the same + // Origin whitelist; reject missing/foreign Origin with 403. + function requireAllowedOrigin(req: IncomingMessage, res: Response): boolean { + const origin = req.headers['origin'] as string | undefined + if (!isOriginAllowed(origin, cfg.allowedOrigins)) { + res.status(403).end() + return false + } + return true + } + // Kill ALL sessions, or only detached ones (?detached=1) — manage page bulk action. app.delete('/live-sessions', (req, res) => { + if (!requireAllowedOrigin(req, res)) return const onlyDetached = req.query['detached'] === '1' let killed = 0 for (const s of manager.list()) { @@ -150,6 +201,7 @@ export function startServer(cfg: Config): { close(): Promise } { // Kill one session by id — manage page per-row action. app.delete('/live-sessions/:id', (req, res) => { + if (!requireAllowedOrigin(req, res)) return const ok = manager.killById(req.params.id) res.status(ok ? 204 : 404).end() }) @@ -194,7 +246,7 @@ export function startServer(cfg: Config): { close(): Promise } { pendingApprovals.delete(sessionId) res.json({}) // timeout → fall back to Claude's interactive prompt manager.handleHookEvent(sessionId, 'idle') - }, PERM_TIMEOUT_MS) + }, cfg.permTimeoutMs) pendingApprovals.set(sessionId, { res, timer }) // Show the approve/reject affordance on the client. @@ -213,10 +265,9 @@ export function startServer(cfg: Config): { close(): Promise } { }) // ── Idle reaper ─────────────────────────────────────────────────────────── - const REAP_INTERVAL_MS = 60_000 // check every minute const reapTimer = setInterval(() => { manager.reapIdle(Date.now()) - }, REAP_INTERVAL_MS) + }, cfg.reapIntervalMs) // Don't let this timer prevent the process from exiting. reapTimer.unref() @@ -250,14 +301,43 @@ export function startServer(cfg: Config): { close(): Promise } { // State: session id bound after the first 'attach' frame. let boundSessionId: string | null = null + // Per-connection leaky-bucket rate limit (Sec M3). maxPayload caps frame + // SIZE; this caps frame FREQUENCY so an input/resize flood can't saturate + // CPU/IO. Over-limit frames are dropped (NOT a close — avoid killing a + // legitimate burst); the warn log is throttled to once per window. + let windowStart = Date.now() + let msgsThisWindow = 0 + let droppedThisWindow = 0 + let warnedThisWindow = false + // Per-connection message handler. const onMessage = (raw: Buffer | string): void => { + // ── Rate limit (leaky bucket over a 1s window) ──────────────────────── + const now = Date.now() + if (now - windowStart >= RATE_WINDOW_MS) { + windowStart = now + msgsThisWindow = 0 + droppedThisWindow = 0 + warnedThisWindow = false + } + msgsThisWindow += 1 + if (msgsThisWindow > cfg.maxMsgsPerSec) { + droppedThisWindow += 1 + if (!warnedThisWindow) { + warnedThisWindow = true + console.error( + `[server] rate limit: dropping frames over ${cfg.maxMsgsPerSec}/s on one connection`, + ) + } + return + } + const text = typeof raw === 'string' ? raw : raw.toString('utf8') const result = parseClientMessage(text) if (!result.ok) { // Discard invalid frames; log without noisy stack traces (avoid noise per spec). - console.error('[server] invalid client message discarded:', result.error) + console.error('[server] invalid client message discarded:', sanitizeForLog(result.error)) return } @@ -305,11 +385,8 @@ export function startServer(cfg: Config): { close(): Promise } { if (msg.type === 'input') { writeInput(session, msg.data) } else if (msg.type === 'resize') { - // Per-client dims; the PTY tracks the min across actively-viewing devices. + // Latest-writer-wins: this device's dims drive the shared PTY size. setClientDims(session, ws, msg.cols, msg.rows) - } else if (msg.type === 'blur') { - // Tab hidden on this device — withdraw its size vote (still mirrors output). - clearClientDims(session, ws) } else if (msg.type === 'approve') { // H3: resolve the held PermissionRequest with allow. resolvePending(boundSessionId, permDecision('allow')) @@ -329,15 +406,23 @@ export function startServer(cfg: Config): { close(): Promise } { ws.on('close', () => { if (boundSessionId === null) return - // H3: release any held approval so the hook isn't stuck for the full timeout. - resolvePending(boundSessionId, {}) - const session = manager.get(boundSessionId) - if (session === undefined) return + if (session === undefined) { + // Session already gone — make sure no held approval dangles. + resolvePending(boundSessionId, {}) + return + } // Invariant #2: never kill the PTY on WS close — detach THIS client only // (the PTY stays alive for other devices and for reconnect). detachWs(session, ws, Date.now()) + + // H3: only release a held approval when the LAST viewer leaves. With + // multi-device sharing (v0.4), another device may still be looking at the + // same pending approval — closing one mirror must not cancel it for them. + if (session.clients.size === 0) { + resolvePending(boundSessionId, {}) + } }) // ── WS error ───────────────────────────────────────────────────────────── @@ -361,16 +446,25 @@ export function startServer(cfg: Config): { close(): Promise } { httpServer.closeIdleConnections() // drop idle keep-alive (hook) sockets so close() drains } - process.on('SIGINT', onSignal) - process.on('SIGTERM', onSignal) - // ── uncaughtException: log and exit for truly unexpected errors ─────────── // (spawn failures / send failures are handled above and must NOT reach here) - process.on('uncaughtException', (err: Error) => { + const onUncaught = (err: Error): void => { console.error('[server] uncaughtException — exiting:', err) doShutdown() process.exit(1) - }) + } + + process.on('SIGINT', onSignal) + process.on('SIGTERM', onSignal) + process.on('uncaughtException', onUncaught) + + // Remove the process-level listeners this server registered, so repeated + // startServer()/close() cycles (tests, library use) don't leak handlers. + function removeProcessListeners(): void { + process.off('SIGINT', onSignal) + process.off('SIGTERM', onSignal) + process.off('uncaughtException', onUncaught) + } // ── Start listening ─────────────────────────────────────────────────────── httpServer.listen(cfg.port, cfg.bindHost, () => { @@ -384,6 +478,7 @@ export function startServer(cfg: Config): { close(): Promise } { return { close(): Promise { return new Promise((resolve) => { + removeProcessListeners() doShutdown() httpServer.close(() => resolve()) // Drop idle keep-alive sockets (e.g. an undici hook connection) so the diff --git a/src/session/manager.ts b/src/session/manager.ts index 544cce7..5c42125 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -18,7 +18,8 @@ * - #2: WS close → detach ONE client, never kill PTY (reap only when the last leaves). * - #5 (relaxed v0.4): a session may have MANY concurrent clients (multi-device * mirror sharing); a new attach JOINS instead of kicking. Output/exit/status - * broadcast to all; the PTY size is the min across clients (tmux-style). + * broadcast to all; the PTY size is latest-writer-wins (the most recently + * focused/fitted device drives the shared size). * - #6: every ws.send is guarded by readyState === WS_OPEN (handled inside session.ts). * * Coding style: immutable-update preference, no console.log, errors explicit. @@ -80,6 +81,17 @@ export function createSessionManager(cfg: Config): SessionManager { // Otherwise: detached-then-exit (L1) — leave in table for reconnect replay. } + /** + * DoS guard: refuse to spawn a brand-new session once the table is at the + * configured cap. Throwing here propagates to server.ts (M4 path), which + * sends exit(-1, reason) and closes the offending connection. + */ + function assertUnderSessionCap(): void { + if (sessions.size >= cfg.maxSessions) { + throw new Error(`session limit reached (max ${cfg.maxSessions})`); + } + } + function handleAttach( ws: WebSocketLike, sessionId: string | null, @@ -89,6 +101,9 @@ export function createSessionManager(cfg: Config): SessionManager { ): Session { // ── Case 1: null → always create a new session ────────────────────────── if (sessionId === null) { + // DoS guard: cap concurrent sessions. Throwing reuses the M4 path — + // server.ts catches it and sends exit(-1, reason) + closes this connection. + assertUnderSessionCap(); // M4: createSession may throw (spawn failure). Do NOT catch here. // M6: cwd (if given) is the spawn directory for "new tab here". const session = createSession(cfg, dims, now, onSessionExit, undefined, cwd); @@ -128,6 +143,8 @@ export function createSessionManager(cfg: Config): SessionManager { } // ── Case 4: session not found → create a new one ───────────────────────── + // DoS guard (see Case 1): cap concurrent sessions before spawning. + assertUnderSessionCap(); // M4: createSession may throw. Do NOT catch here. const session = createSession(cfg, dims, now, onSessionExit); attachWs(session, ws); diff --git a/src/session/session.ts b/src/session/session.ts index 2c94778..25df498 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -108,7 +108,6 @@ export function createSession( meta, buffer: createRingBuffer(cfg.scrollbackBytes), clients: new Set(), - clientDims: new Map(), detachedAt: null, lastOutputAt: now, exitedAt: null, @@ -161,7 +160,6 @@ export function attachWs(session: Session, ws: WebSocketLike): void { */ export function detachWs(session: Session, ws: WebSocketLike, now: number): void { session.clients.delete(ws); - session.clientDims.delete(ws); // A client leaving does NOT resize the PTY — whatever device is still actively // viewing keeps its size. Start the idle clock only when the last client goes. if (session.clients.size === 0) { @@ -175,36 +173,26 @@ export function writeInput(session: Session, data: string): void { session.pty.write(data); } -/** - * Record one client's requested dims and resize the PTY to the new min across - * all clients (tmux-style). No-op after exit (L4); idempotent when unchanged. - */ /** * Latest-writer-wins: the device that most recently fit/focused drives the PTY * size, so whichever device you are actively using is full-screen. (A shared * PTY can only be one size; min-sizing letterboxed the bigger screen.) The * frontend re-sends dims when a pane is shown or its window regains focus, so * switching devices reclaims full size. No-op after exit (L4) / when unchanged. + * + * The `ws` argument identifies the requesting client; the size is applied + * directly to the PTY (no per-client dims map — latest writer wins). */ export function setClientDims( session: Session, - ws: WebSocketLike, + _ws: WebSocketLike, cols: number, rows: number, ): void { if (session.exitedAt !== null) return; - session.clientDims.set(ws, { cols, rows }); applyDims(session, cols, rows); } -/** - * Forget a client's size when its tab goes hidden — but do NOT resize: the - * device still actively viewing keeps its size (no letterboxing of a mirror). - */ -export function clearClientDims(session: Session, ws: WebSocketLike): void { - session.clientDims.delete(ws); -} - /** * Kill the session (idle reclaim). Under tmux this ends the actual shell via * `tmux kill-session` (H1); killing only the client pty would leave the tmux diff --git a/src/types.ts b/src/types.ts index c700843..3f75362 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,11 @@ export interface Config { readonly scrollbackBytes: number; // ring buffer capacity, default 2MB readonly maxPayloadBytes: number; // max WS frame bytes, default 1MB (L5) readonly wsPath: string; // WS upgrade path, default '/term' (L3; invariant 8) + readonly maxSessions: number; // cap on concurrent PTY sessions (DoS guard), default 50 + readonly maxMsgsPerSec: number; // per-connection WS message rate cap (DoS guard), default 2000 + readonly permTimeoutMs: number; // H3: how long a held PermissionRequest waits before fallback + readonly reapIntervalMs: number; // idle-reaper sweep interval + readonly previewBytes: number; // manage-page preview: bytes of scrollback tail rendered readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1) } @@ -45,9 +50,6 @@ export type ClientMessage = | { type: 'attach'; sessionId: string | null; cwd?: string } | { type: 'input'; data: string } | { type: 'resize'; cols: number; rows: number } - // v0.4: this client stopped actively viewing (tab hidden) — withdraw its size - // vote so a background mirror doesn't clamp the shared PTY (min-dims). - | { type: 'blur' } | { type: 'approve' } | { type: 'reject' }; @@ -150,9 +152,6 @@ export interface Session { * detached but PTY still alive (vibe-coding core). Output/exit/status are * broadcast to every client; any client can send input (shared control). */ readonly clients: Set; - /** Per-client requested terminal dims; the PTY uses the MIN cols/rows across - * all clients (tmux-style) so every device sees content without overflow. */ - readonly clientDims: Map; /** Time the LAST client left (clients became empty); null while ≥1 attached. */ detachedAt: number | null; /** last pty.onData timestamp; reapIdle liveness proxy (M3). */ @@ -176,8 +175,7 @@ export interface Session { // attachWs(session: Session, ws: WebSocketLike): void // adds a client, replays buffer (no size vote until it resizes) // detachWs(session: Session, ws: WebSocketLike, now: number): void // removes one client; never kills PTY // writeInput(session: Session, data: string): void // no-op after exit (L4) -// setClientDims(session: Session, ws: WebSocketLike, cols, rows): void // PTY = min over active viewers (L4 no-op after exit) -// clearClientDims(session: Session, ws: WebSocketLike): void // withdraw this client's size vote (tab hidden / blur) +// setClientDims(session: Session, ws: WebSocketLike, cols, rows): void // PTY = latest-writer-wins (L4 no-op after exit) // kill(session: Session): void /* ──────────────────────── manager (§3.5) ─────────────────────── */ diff --git a/test/config.test.ts b/test/config.test.ts index 3a9c918..951cfb5 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -35,6 +35,16 @@ vi.mock('node:os', async (importOriginal) => { } }) +// ── mock tmux availability so resolveUseTmux's auto-detect is deterministic ── +const mockTmuxAvailable = vi.fn<[], boolean>() +mockTmuxAvailable.mockReturnValue(false) +vi.mock('../src/session/tmux.js', () => ({ + tmuxAvailable: () => mockTmuxAvailable(), + tmuxName: (id: string) => `web_${id}`, + hasSession: () => false, + killSession: () => undefined, +})) + // Import config AFTER mock is set up (dynamic import ensures mock applies) const { loadConfig } = await import('../src/config.js') @@ -270,6 +280,93 @@ describe('loadConfig — allowedOrigins (M1)', () => { }) }) +// ── MAX_SESSIONS + new operational constants ────────────────────────────────── +describe('loadConfig — session/rate/timer constants', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('defaults maxSessions to 50', () => { + expect(loadConfig({}).maxSessions).toBe(50) + }) + + it('reads MAX_SESSIONS from env', () => { + expect(loadConfig({ MAX_SESSIONS: '20' }).maxSessions).toBe(20) + }) + + it('throws for a non-integer MAX_SESSIONS', () => { + expect(() => loadConfig({ MAX_SESSIONS: 'lots' })).toThrow() + }) + + it('defaults maxMsgsPerSec to 2000 and reads MAX_MSGS_PER_SEC', () => { + expect(loadConfig({}).maxMsgsPerSec).toBe(2000) + expect(loadConfig({ MAX_MSGS_PER_SEC: '500' }).maxMsgsPerSec).toBe(500) + }) + + it('defaults permTimeoutMs / reapIntervalMs / previewBytes and reads overrides', () => { + const def = loadConfig({}) + expect(def.permTimeoutMs).toBe(5 * 60_000) + expect(def.reapIntervalMs).toBe(60_000) + expect(def.previewBytes).toBe(24 * 1024) + const over = loadConfig({ PERM_TIMEOUT_MS: '1000', REAP_INTERVAL_MS: '2000', PREVIEW_BYTES: '4096' }) + expect(over.permTimeoutMs).toBe(1000) + expect(over.reapIntervalMs).toBe(2000) + expect(over.previewBytes).toBe(4096) + }) +}) + +// ── resolveUseTmux branches (USE_TMUX env) ──────────────────────────────────── +describe('loadConfig — resolveUseTmux', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + mockTmuxAvailable.mockReturnValue(false) + }) + + it.each(['1', 'true', 'on', 'ON', ' True '])('forces tmux ON for %j', (v) => { + expect(loadConfig({ USE_TMUX: v }).useTmux).toBe(true) + }) + + it.each(['0', 'false', 'off'])('forces tmux OFF for %j', (v) => { + expect(loadConfig({ USE_TMUX: v }).useTmux).toBe(false) + }) + + it('auto-detects (unset) → true when tmux is available', () => { + mockTmuxAvailable.mockReturnValue(true) + expect(loadConfig({}).useTmux).toBe(true) + }) + + it('auto-detects (unset / "auto") → false when tmux is unavailable', () => { + mockTmuxAvailable.mockReturnValue(false) + expect(loadConfig({}).useTmux).toBe(false) + expect(loadConfig({ USE_TMUX: 'auto' }).useTmux).toBe(false) + }) +}) + +// ── ALLOWED_ORIGINS scheme validation (M1) ──────────────────────────────────── +describe('loadConfig — ALLOWED_ORIGINS scheme validation', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('rejects non-http(s) scheme entries (file:, javascript:, bare host)', () => { + const cfg = loadConfig({ + ALLOWED_ORIGINS: 'file:///etc/passwd,javascript:alert(1),notaurl,http://ok.local:3000', + }) + expect(cfg.allowedOrigins).toContain('http://ok.local:3000') + expect(cfg.allowedOrigins.some((o) => o.startsWith('file:'))).toBe(false) + expect(cfg.allowedOrigins.some((o) => o.startsWith('javascript:'))).toBe(false) + expect(cfg.allowedOrigins).not.toContain('notaurl') + }) + + it('keeps valid https entries', () => { + const cfg = loadConfig({ ALLOWED_ORIGINS: 'https://phone.local:3000' }) + expect(cfg.allowedOrigins).toContain('https://phone.local:3000') + }) +}) + // ── Immutability ────────────────────────────────────────────────────────────── describe('loadConfig — returned object is frozen', () => { beforeEach(() => { diff --git a/test/history.test.ts b/test/history.test.ts index 7136f07..ab9a306 100644 --- a/test/history.test.ts +++ b/test/history.test.ts @@ -1,6 +1,28 @@ -import { describe, it, expect } from 'vitest' -import { parseSessionMeta } from '../src/http/history.js' +import { describe, it, expect, vi, beforeEach } from 'vitest' +// ── mock node:fs/promises + node:os ────────────────────────────────────────── +// Hoisted by vitest; history.ts binds to these mocks. +const mockReaddir = vi.fn() +const mockStat = vi.fn() +const mockOpen = vi.fn() +const mockHomedir = vi.fn(() => '/home/tester') + +vi.mock('node:fs/promises', () => ({ + default: { + readdir: (...a: unknown[]) => mockReaddir(...a), + stat: (...a: unknown[]) => mockStat(...a), + open: (...a: unknown[]) => mockOpen(...a), + }, +})) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, default: { ...actual.default, homedir: () => mockHomedir() } } +}) + +const { parseSessionMeta, listSessions } = await import('../src/http/history.js') + +// ── parseSessionMeta (pure) ─────────────────────────────────────────────────── describe('parseSessionMeta', () => { it('extracts cwd and the first user prompt (string content)', () => { const jsonl = [ @@ -31,3 +53,71 @@ describe('parseSessionMeta', () => { expect(parseSessionMeta('not json\n{"type":"summary"}')).toEqual({ cwd: null, preview: '' }) }) }) + +// ── listSessions (fs traversal, async) ──────────────────────────────────────── +describe('listSessions', () => { + function mockFile(text: string) { + const buf = Buffer.from(text, 'utf8') + return { + read: vi.fn(async (b: Buffer, off: number, len: number) => { + const n = buf.copy(b, off, 0, Math.min(len, buf.length)) + return { bytesRead: n } + }), + close: vi.fn(async () => undefined), + } + } + + beforeEach(() => { + mockReaddir.mockReset() + mockStat.mockReset() + mockOpen.mockReset() + mockHomedir.mockReturnValue('/home/tester') + }) + + it('returns [] when the projects root is missing', async () => { + mockReaddir.mockRejectedValueOnce(new Error('ENOENT')) + expect(await listSessions()).toEqual([]) + }) + + it('lists .jsonl sessions newest-first with parsed cwd/preview/project', async () => { + mockReaddir + .mockResolvedValueOnce(['projA']) // root + .mockResolvedValueOnce(['old.jsonl', 'new.jsonl', 'ignore.txt']) // projA + mockStat.mockImplementation(async (p: string) => ({ + mtimeMs: p.endsWith('new.jsonl') ? 2000 : 1000, + })) + mockOpen.mockImplementation(async (p: string) => + p.endsWith('new.jsonl') + ? mockFile(JSON.stringify({ type: 'user', cwd: '/work/newdir', message: { role: 'user', content: 'newest task' } })) + : mockFile(JSON.stringify({ type: 'user', cwd: '/work/olddir', message: { role: 'user', content: 'older task' } })), + ) + + const out = await listSessions() + expect(out.map((s) => s.id)).toEqual(['new', 'old']) // newest first + expect(out[0]).toMatchObject({ id: 'new', cwd: '/work/newdir', project: 'newdir', preview: 'newest task' }) + // .txt is skipped + expect(out).toHaveLength(2) + }) + + it('skips a project subdir that cannot be read', async () => { + mockReaddir + .mockResolvedValueOnce(['good', 'bad']) + .mockResolvedValueOnce(['s.jsonl']) // good + .mockRejectedValueOnce(new Error('EACCES')) // bad + mockStat.mockResolvedValue({ mtimeMs: 1000 }) + mockOpen.mockResolvedValue(mockFile(JSON.stringify({ type: 'user', cwd: '/g', message: { role: 'user', content: 'hi' } }))) + + const out = await listSessions() + expect(out).toHaveLength(1) + expect(out[0]!.id).toBe('s') + }) + + it('respects the limit', async () => { + mockReaddir.mockResolvedValueOnce(['p']).mockResolvedValueOnce(['a.jsonl', 'b.jsonl', 'c.jsonl']) + mockStat.mockImplementation(async (p: string) => ({ mtimeMs: p.charCodeAt(p.length - 7) })) + mockOpen.mockResolvedValue(mockFile(JSON.stringify({ type: 'user', cwd: '/p', message: { role: 'user', content: 'x' } }))) + + const out = await listSessions(2) + expect(out).toHaveLength(2) + }) +}) diff --git a/test/integration/server.test.ts b/test/integration/server.test.ts index f8eb4bc..4162278 100644 --- a/test/integration/server.test.ts +++ b/test/integration/server.test.ts @@ -227,9 +227,9 @@ function waitForMessage( // ── Test suite ──────────────────────────────────────────────────────────────── describe('startServer — integration', () => { - // Suppress stray MaxListeners warnings: each startServer call adds SIGINT/SIGTERM/ - // uncaughtException handlers. Tests use separate server instances per test. - process.setMaxListeners(50) + // NOTE: no process.setMaxListeners() bump here. The signal-handler-leak fix + // (CQ H1) means close() removes the SIGINT/SIGTERM/uncaughtException listeners + // it registered, so repeated startServer()/close() cycles don't accumulate. let port: number let cfg: Config @@ -730,4 +730,151 @@ describe('startServer — integration', () => { }, 20_000, ) + + // ── Signal-handler leak fix (CQ H1) ──────────────────────────────────────── + it('does not leak SIGINT listeners across startServer()/close() cycles', async () => { + const before = process.listenerCount('SIGINT') + for (let i = 0; i < 3; i++) { + const p = await getFreePort() + const h = startServer(makeTestConfig(p, process.env['SHELL'] ?? '/bin/zsh')) + await new Promise((r) => setTimeout(r, 30)) + await h.close() + } + expect(process.listenerCount('SIGINT')).toBe(before) + expect(process.listenerCount('SIGTERM')).toBe(before) + }) + + // ── GET /live-sessions (route, no PTY needed when empty) ──────────────────── + it('GET /live-sessions returns an array (empty when none running)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/live-sessions`) + expect(res.status).toBe(200) + const body = (await res.json()) as unknown + expect(Array.isArray(body)).toBe(true) + }) + + // ── GET /live-sessions/:id/preview — unknown id → 404 ─────────────────────── + it('GET /live-sessions/:id/preview returns 404 for an unknown session id', async () => { + const res = await fetch(`http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000/preview`) + expect(res.status).toBe(404) + }) + + // ── Security headers present on responses ─────────────────────────────────── + it('sets conservative security headers (CSP / X-Frame-Options / nosniff)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/live-sessions`) + expect(res.headers.get('x-frame-options')).toBe('DENY') + expect(res.headers.get('x-content-type-options')).toBe('nosniff') + expect(res.headers.get('content-security-policy')).toContain("default-src 'self'") + }) + + // ── DELETE /live-sessions Origin guard (Sec H2 / Arch 5b) ─────────────────── + it('DELETE /live-sessions → 403 with a foreign Origin', async () => { + const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { + method: 'DELETE', + headers: { Origin: 'http://evil.example' }, + }) + expect(res.status).toBe(403) + }) + + it('DELETE /live-sessions → 403 with NO Origin header (default-deny)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { method: 'DELETE' }) + expect(res.status).toBe(403) + }) + + it('DELETE /live-sessions → 200 with an allowed (same-host) Origin', async () => { + const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { + method: 'DELETE', + headers: { Origin: `http://127.0.0.1:${port}` }, + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { killed: number } + expect(typeof body.killed).toBe('number') + }) + + it('DELETE /live-sessions/:id → 403 with a foreign Origin (before 404 lookup)', async () => { + const res = await fetch( + `http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000`, + { method: 'DELETE', headers: { Origin: 'http://evil.example' } }, + ) + expect(res.status).toBe(403) + }) + + it('DELETE /live-sessions/:id → 404 (allowed Origin, unknown id)', async () => { + const res = await fetch( + `http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000`, + { method: 'DELETE', headers: { Origin: `http://127.0.0.1:${port}` } }, + ) + expect(res.status).toBe(404) + }) + + // ── Hook side-channel endpoints (route-level, no PTY needed) ──────────────── + it('POST /hook from loopback with an unknown session → 204 (no-op broadcast)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/hook`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': '00000000-0000-4000-8000-000000000000' }, + body: JSON.stringify({ hook_event_name: 'Stop' }), + }) + expect(res.status).toBe(204) + }) + + it('POST /hook with an unparseable event body → 400', async () => { + const res = await fetch(`http://127.0.0.1:${port}/hook`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, // no session header / no event name + body: JSON.stringify({}), + }) + expect(res.status).toBe(400) + }) + + it('POST /hook/permission with no matching session falls back to {} (Claude prompts itself)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/hook/permission`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': '00000000-0000-4000-8000-000000000000' }, + body: JSON.stringify({ tool_name: 'Bash' }), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({}) + }) + + // ── Per-connection WS rate limit (Sec M3) ─────────────────────────────────── + // Fires BEFORE attach, so we can flood pre-attach frames without a real PTY. + // A tiny MAX_MSGS_PER_SEC makes the cap observable: the connection survives a + // flood (frames are dropped, not closed). + it('drops frames over MAX_MSGS_PER_SEC but keeps the connection open (no close)', async () => { + const rlPort = await getFreePort() + const rlCfg = loadConfig({ + PORT: String(rlPort), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${rlPort}`, + IDLE_TTL: '86400', + USE_TMUX: '0', + MAX_MSGS_PER_SEC: '5', + }) + const rlServer = startServer(rlCfg) + await new Promise((r) => setTimeout(r, 80)) + try { + const ws = new WebSocket(`ws://127.0.0.1:${rlPort}${rlCfg.wsPath}`, { + headers: { Origin: `http://127.0.0.1:${rlPort}` }, + }) + await waitForOpen(ws, 3_000) + + // Flood 50 invalid frames in one tick — far over the 5/s cap. + for (let i = 0; i < 50; i++) ws.send('not-json') + + // The connection must NOT be closed by the rate limiter. + let closed = false + ws.once('close', () => { + closed = true + }) + await new Promise((r) => setTimeout(r, 200)) + expect(closed).toBe(false) + expect(ws.readyState).toBe(WebSocket.OPEN) + + ws.close() + await waitForClose(ws, 3_000).catch(() => undefined) + } finally { + await rlServer.close() + await new Promise((r) => setTimeout(r, 50)) + } + }) }) diff --git a/test/manager.test.ts b/test/manager.test.ts index 0756f2d..5be9161 100644 --- a/test/manager.test.ts +++ b/test/manager.test.ts @@ -46,6 +46,11 @@ const CFG: Config = { scrollbackBytes: 2 * 1024 * 1024, maxPayloadBytes: 1024 * 1024, wsPath: '/term', + maxSessions: 50, + maxMsgsPerSec: 2000, + permTimeoutMs: 300_000, + reapIntervalMs: 60_000, + previewBytes: 24 * 1024, useTmux: false, allowedOrigins: [], }; @@ -509,6 +514,105 @@ describe('shutdown', () => { }); }); +// ── maxSessions DoS cap (Sec H1) ────────────────────────────────────────────── +describe('handleAttach — session cap', () => { + it('throws once the table is at cfg.maxSessions (new-session path)', () => { + const cappedCfg: Config = { ...CFG, maxSessions: 2 }; + const mgr = createSessionManager(cappedCfg); + + nextPty = createMockPty(); + mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + nextPty = createMockPty(); + mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + + // Third new session must be refused (reuses the M4 exit(-1) path in server). + nextPty = createMockPty(); + expect(() => mgr.handleAttach(createMockWs(), null, DIMS, 1_000)).toThrow(/limit/i); + }); + + it('also caps the not-found path (unknown id → would create)', () => { + const cappedCfg: Config = { ...CFG, maxSessions: 1 }; + const mgr = createSessionManager(cappedCfg); + mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + + const unknown = '00000000-0000-4000-8000-000000000099'; + nextPty = createMockPty(); + expect(() => mgr.handleAttach(createMockWs(), unknown, DIMS, 1_000)).toThrow(/limit/i); + }); + + it('JOINing an existing session does NOT count against the cap', () => { + const cappedCfg: Config = { ...CFG, maxSessions: 1 }; + const mgr = createSessionManager(cappedCfg); + const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000); + + // A second device joining the same session must be allowed at the cap. + expect(() => mgr.handleAttach(createMockWs(), s.meta.id, DIMS, 2_000)).not.toThrow(); + expect(s.clients.size).toBe(2); + }); +}); + +// ── killById (manage page) ──────────────────────────────────────────────────── +describe('killById', () => { + it('closes all clients, kills the PTY, and removes the session', () => { + const mgr = createSessionManager(CFG); + const a = createMockWs(); + const b = createMockWs(); + const s = mgr.handleAttach(a, null, DIMS, 1_000); + mgr.handleAttach(b, s.meta.id, DIMS, 2_000); + + const ok = mgr.killById(s.meta.id); + + expect(ok).toBe(true); + expect(a.closed).toBe(true); + expect(b.closed).toBe(true); + expect((s.pty as MockIPty).killed).toBe(true); + expect(mgr.get(s.meta.id)).toBeUndefined(); + }); + + it('returns false for an unknown id', () => { + const mgr = createSessionManager(CFG); + expect(mgr.killById('00000000-0000-4000-8000-0000000000aa')).toBe(false); + }); +}); + +// ── handleHookEvent (H2/H3 status push) ─────────────────────────────────────── +describe('handleHookEvent', () => { + it('sets claudeStatus and broadcasts a status frame to all clients', () => { + const mgr = createSessionManager(CFG); + const a = createMockWs(); + const b = createMockWs(); + const s = mgr.handleAttach(a, null, DIMS, 1_000); + mgr.handleAttach(b, s.meta.id, DIMS, 2_000); + a.sent.length = 0; + b.sent.length = 0; + + mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true); + + expect(s.claudeStatus).toBe('waiting'); + for (const ws of [a, b]) { + const status = parseSent(ws).find((m) => m.type === 'status'); + expect(status).toMatchObject({ type: 'status', status: 'waiting', detail: 'Bash', pending: true }); + } + }); + + it('omits detail/pending when not supplied', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + ws.sent.length = 0; + + mgr.handleHookEvent(s.meta.id, 'idle'); + + const status = parseSent(ws).find((m) => m.type === 'status'); + expect(status).toEqual({ type: 'status', status: 'idle' }); + }); + + it('is a no-op for an unknown session id', () => { + const mgr = createSessionManager(CFG); + expect(() => mgr.handleHookEvent('00000000-0000-4000-8000-0000000000bb', 'working')).not.toThrow(); + }); +}); + // ── L2: injected onExit removes session when ws is attached at exit time ─────── describe('onExit removes session from table when ws attached at exit time (L2)', () => { it('session is removed from the table when PTY exits while ws is attached', () => { diff --git a/test/preview-grid.test.ts b/test/preview-grid.test.ts new file mode 100644 index 0000000..6607e5e --- /dev/null +++ b/test/preview-grid.test.ts @@ -0,0 +1,171 @@ +// @vitest-environment jsdom +/** + * test/preview-grid.test.ts — shared launcher/manage preview helpers. + * + * xterm Terminal is stubbed; fetch is mocked. Covers the DRY core extracted in + * Phase 4: formatting, the card factory, fit scaling, and the preview/list + * fetch helpers (best-effort, no throw on failure). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { LiveSessionInfo } from '../src/types.js' + +class FakeTerminal { + cols = 80 + rows = 24 + open = vi.fn() + reset = vi.fn() + resize = vi.fn() + dispose = vi.fn() + write = vi.fn((_d: string, cb?: () => void) => cb?.()) +} +vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal })) + +const mod = await import('../public/preview-grid.js') +const { + el, + relTime, + statusText, + sessionName, + makePreviewCard, + updatePreviewCard, + fitThumb, + fetchPreview, + renderPreview, + loadPreviewInto, + fetchLiveSessions, +} = mod + +function session(over: Partial = {}): LiveSessionInfo { + return { + id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + createdAt: Date.now() - 5000, + clientCount: 0, + status: 'idle', + exited: false, + cwd: '/work/proj', + cols: 80, + rows: 24, + ...over, + } +} + +beforeEach(() => { + vi.restoreAllMocks() + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + cb(0) + return 0 + }) +}) + +describe('formatting helpers', () => { + it('el builds an element with class + text', () => { + const n = el('div', 'x', 'hi') + expect(n.className).toBe('x') + expect(n.textContent).toBe('hi') + }) + + it('relTime renders s/m/h/d buckets', () => { + const now = Date.now() + expect(relTime(now)).toMatch(/^\d+s$/) + expect(relTime(now - 120_000)).toBe('2m') + expect(relTime(now - 2 * 3600_000)).toBe('2h') + expect(relTime(now - 3 * 86400_000)).toBe('3d') + }) + + it('statusText maps each status', () => { + expect(statusText('working')).toContain('working') + expect(statusText('waiting')).toContain('waiting') + expect(statusText('idle')).toContain('idle') + expect(statusText('unknown')).toBe('·') + }) + + it('sessionName uses last cwd segment, else short id', () => { + expect(sessionName(session({ cwd: '/a/b/cool' }))).toBe('cool') + expect(sessionName(session({ cwd: null }))).toBe('aaaaaaaa') + }) +}) + +describe('makePreviewCard / updatePreviewCard', () => { + it('builds a card and fires onOpen when the thumb is clicked (button variant)', () => { + const onOpen = vi.fn() + const card = makePreviewCard(session(), { onOpen }) + card.thumb.click() + expect(onOpen).toHaveBeenCalledWith(session().id) + // The Open control is a