Compare commits

...
Author SHA1 Message Date
arkonandClaude Fable 5 6da22f0db0 chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:06:12 +02:00
Ark0N dc9c4b3bda Merge pull request #117 from aakhter/pr/cod-86-codex-frontend
fix(codex): smaller first-frame write budget + scroll-up grace for codex
2026-06-10 22:59:40 +02:00
Ark0N 1cf5c8c8ad Merge pull request #116 from aakhter/pr/cod-35-codex-polish
fix(codex): strip alt-screen + scrollback-erase from the codex byte stream
2026-06-10 22:52:02 +02:00
arkonandClaude Fable 5 7eda39e7f7 fix(codex): reassemble chunk-split sequences before the strip; mouse parity on replay
Review fixes:

- Hold back a trailing partial CSI (digit-only intro, ≤7 chars) in
  _handleTerminalOutput and prepend it to the next chunk. PTY chunk
  boundaries are arbitrary, so '\x1b[?1049h' can arrive as '\x1b[?104' +
  '9h' — the per-chunk strip misses it, xterm obeys the reassembled toggle,
  and (with the matching ?1049l stripped) stays stuck in the scrollback-less
  alt buffer until the next replay. Complete sequences are never held; the
  carry resets with the other buffers in _resetBuffers.

- Replay path now also strips mouse-tracking enables (?1000-?1007), matching
  the live strip: buffers persisted BEFORE the live strip existed can still
  carry them, and a replayed ?1006h re-hijacks the scroll wheel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:47:02 +02:00
Ark0N f0db5f827f Merge pull request #115 from aakhter/pr/cod-78-security
feat(security): hook-event auth secret + tunnel password guard
2026-06-10 22:36:47 +02:00
arkonandClaude Fable 5 aa4e1ce9cf fix(security): deliver the hook secret to hooks + isolate its rate-limit bucket
Review fixes for COD-54:

- Generated hook curl commands now present X-Codeman-Hook-Secret, read from
  the secret file AT EXECUTION TIME via $CODEMAN_HOOK_SECRET_FILE (exported
  into every managed session's env by tmux buildEnvExports / the direct-PTY
  env builders). Without this, every local hook 401'd the moment a managed
  tunnel came up — the enforcement existed but nothing presented the secret.
  Path-not-value keeps the secret off command lines and out of config files,
  and running sessions pick up a newly generated secret with no respawn;
  server.start() ensures the file exists up front.

- Hook-secret failures now count into a DEDICATED per-IP bucket
  (hookSecretFailures) instead of the shared authFailures map. Legacy
  (pre-secret) hook configs fire constantly from 127.0.0.1; counting their
  401s against the shared bucket would 429 every cookie-less loopback
  request — locking out the Basic-Auth login path (and, through a tunnel,
  every client, since tunneled traffic also arrives as 127.0.0.1).

- docs/security-architecture.md: secret-gated hook exemption, dedicated
  bucket, COD-55 refusal, and the residual caveat for EXTERNAL loopback
  proxies (user-run cloudflared / tailscale serve), which the
  managed-tunnel probe cannot see.

- test/cod54-hook-event-auth.test.ts: +3 tests — login path unaffected
  after hook-bucket exhaustion; generated hooks reference the header +
  $CODEMAN_HOOK_SECRET_FILE without embedding the value; env builders
  export the path only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:31:09 +02:00
arkonandClaude Fable 5 b8cb4670dd fix(mobile,respawn-ui): unbury the session-options modal on phones; regroup the Respawn tab
Mobile fixes (user-reported: stuck in Session Options with no way to close):
- Modals now stack at z-index 1300, above the fixed mobile/tablet header
  (z-index 1200) that was burying the modal header and its close button —
  the full-screen modal was undismissable on phones
- Duration presets collapse to one compact 24px row (was a 3-row grid)
- Hide the tab detach (open-in-new-window) button on viewports <=768px

Respawn tab regrouped so its two features read as separate options:
- New green-tinted "Respawn loop" box wraps duration, presets, cycle
  steps, and the status/Enable row — a visual sibling of the blue
  auto-resume box; includes a short explanation of the loop
- Enable/status row moved from the top of the tab to the bottom of the
  box, so it no longer reads as a modal-level confirm button
- Font sizes unified: feature titles match; step checkboxes (2./3.)
  match the step labels (1./4.); "Respawn Cycle" renamed "Cycle Steps"

CLAUDE.md: add usage-limit-patterns.ts to the Session row; app.js ~3.7K

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:37:44 +02:00
Aamer AkhterandSaqeb Akhter fe821fb679 fix(codex): smaller first-frame write budget + scroll-up grace for codex
Two render-polish fixes for codex sessions in the terminal write pipeline:

- flushPendingWrites uses a 32KB first-frame budget for codex (vs 64KB for
  other modes). Codex's TUI emits dense synchronized redraws during
  thinking/high-effort phases; a smaller first frame keeps per-frame
  xterm/WebGL stalls short and avoids multi-second main-thread blocks.

- Sticky-scroll now honours a short grace window after a manual scroll-up
  gesture (USER_SCROLL_STICKY_SUPPRESS_MS = 1500ms). High-frequency codex
  "Working (Ns)" status ticks were snapping the viewport back to the bottom
  while the user tried to read earlier output. The wheel/touch scroll
  handlers record the gesture (_noteTerminalUserScroll); flushPendingWrites
  suppresses the auto-scroll-to-bottom and restores the preserved viewport
  via scrollToLine while the grace window is active.

Adds test/terminal-flush-budget.test.ts (vm-sandbox harness over
terminal-ui.js): codex vs non-codex first-frame budget, buffer-load
ownership, and the scroll-up suppression / viewport restore.

Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
2026-06-10 13:34:45 -04:00
Aamer AkhterandSaqeb Akhter d7606366a2 fix(codex): strip alt-screen + scrollback-erase from the codex byte stream
Codex's TUI emits alternate-screen toggles (DECSET/DECRST 47/1047/1049),
scrollback-erase (CSI 3 J), and mouse-tracking enables (?1000-1007) during
startup and on every repaint. xterm.js obeys them: it switches to the
scrollback-less alternate buffer, wipes saved lines, and forwards the scroll
wheel to codex — so the user's conversation history both disappears and
becomes unreachable on each tab switch / pane refresh.

Strip these sequences in two places, leaving the visible-viewport erases
(2J / J) intact so codex can still repaint its own rows:

- Session._handleTerminalOutput: filter the live SSE/WS stream and the
  persisted terminal buffer at the source, for mode === 'codex'.
- GET /api/sessions/:id/terminal: apply the same strip to the replayed
  buffer (ALT_SCREEN_TOGGLE_PATTERN / ERASE_SCROLLBACK_PATTERN) so a
  tab-switch replay keeps full scrollback.

Adds test/codex-terminal-output.test.ts covering the strip (alt-screen and
3J removed, 2J/J preserved, Ctrl+L redraws preserved) and confirming codex
output passes through without Ink row-repair mangling.

Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
2026-06-10 13:18:45 -04:00
Aamer Akhter 42f0b28c75 feat(security): hook-event auth secret + tunnel password guard
Two hardening fixes for the public-tunnel exposure path (COD-54 / COD-55).

COD-54 — gate the /api/hook-event localhost bypass when a tunnel is up:
`cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
loopback origin, so a tunneled hook request arrives with req.ip === 127.0.0.1
and the old bare-localhost bypass would pass it unauthenticated. Now:
- tunnel running  → bypass requires a shared per-instance hook secret
  (X-Codeman-Hook-Secret header; constant-time compare) + per-IP rate limiting
- tunnel not running (loopback-only, the normal case) → unchanged, so
  already-deployed credential-less hooks keep working.
New src/config/hook-secret.ts; auth middleware takes a getTunnelRunning probe
(wired from server.ts via tunnelManager.isRunning()).

COD-55 — refuse starting the Cloudflare tunnel without auth:
enabling the tunnel publishes full terminal control to a public URL; with no
CODEMAN_PASSWORD the auth middleware is inactive and the bind guard never trips
(tunnel binds loopback). PUT /api/settings now refuses tunnelEnabled:true with a
403 (before persisting) unless CODEMAN_PASSWORD is set or
CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 is acknowledged. New
isUnauthenticatedNetworkAcknowledged() in network-auth-policy; settings-ui
surfaces the refusal as an error toast and reverts the toggle.

Scope: the always-on CSRF/Origin guard, Host-header allowlist, and
network-auth-policy itself are already upstream (#113) and not re-proposed here.

Verification: tsc, eslint, prettier, check:frontend-syntax clean; full test:ci
green (2723 passed), incl. test/cod54-hook-event-auth and
test/routes/system-routes-tunnel-guard.
2026-06-10 12:25:26 -04:00
26 changed files with 1267 additions and 65 deletions

No files matched your search

+18
View File
@@ -1,5 +1,23 @@
# aicodeman
## 0.9.14
### Patch Changes
- Security hardening for the tunnel exposure path, Codex terminal rendering fixes, and a mobile modal fix.
**Security (PR #115, COD-54/COD-55):**
- `/api/hook-event` localhost bypass is now gated while the managed Cloudflare tunnel is running: tunneled traffic arrives with a loopback source IP, so the bypass additionally requires a per-instance shared secret (`X-Codeman-Hook-Secret`, 256-bit, `~/.codeman/hook-secret`, mode 0600). Locally generated hook commands read the secret file at execution time via `$CODEMAN_HOOK_SECRET_FILE` (exported into every managed session's environment), so the value never lands on command lines or in case configs, and running sessions pick up a new secret without respawn. Failed presentations rate-limit in a dedicated per-IP bucket so misfiring legacy hooks can never lock out the Basic-Auth login path. With no tunnel running, behavior is unchanged.
- Enabling the Cloudflare tunnel now **refuses with 403** when no `CODEMAN_PASSWORD` is set (a public tunnel URL with no auth is effectively public RCE), unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly acknowledges the exposure. The settings UI surfaces the refusal as an error toast and reverts the toggle.
**Codex rendering (PRs #116, #117):**
- Alt-screen toggles (`?47/?1047/?1049`), scrollback-erase (`CSI 3 J`), and mouse-tracking enables (`?1000`–`?1007`) are stripped from the Codex byte stream (live + replay), so conversation history survives tab switches and the scroll wheel scrolls the viewport instead of being hijacked. Sequences split across PTY chunk boundaries are reassembled via a small carry before stripping, so a split `?1049h` can no longer trap xterm in the scrollback-less alt buffer.
- Smaller 32KB first-frame write budget for Codex sessions keeps dense synchronized redraws from stalling the renderer; a 1.5s grace window after a manual scroll-up suppresses sticky-scroll so high-frequency `• Working (Ns)` status ticks no longer snap the viewport back to the bottom while reading earlier output.
**Mobile:** session-options modal raised above the fixed mobile/tablet header (z-index 1300 vs 1200) so the close button is reachable on phones; Respawn tab controls regrouped.
**Docs:** security-architecture.md updated for the secret-gated hook bypass (including the external-proxy caveat) and the tunnel password guard; README documents auto-resume on usage limit.
## 0.9.13
### Patch Changes
+5 -4
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.9.13 (must match `package.json`)
**Version**: 0.9.14 (must match `package.json`)
## Project Overview
@@ -102,6 +102,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
- **Multi-CLI prefix discipline** — Codeman supports Claude Code, OpenCode, and Codex (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts` / `codex-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward all prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
- **`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs:50` (for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
@@ -129,7 +130,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts` | |
| **Frontend** | `src/web/public/app.js` (~3.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
| **Frontend** | `src/web/public/app.js` (~3.7K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` (barrel) → 15 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
@@ -173,7 +174,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
**Z-index layers**: subagent windows (1000), plan agents (1100), log viewers (2000), image popups (3000), local echo overlay (7).
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried; bug fixed in `b8cb467`), log viewers (2000), image popups (3000), local echo overlay (7).
**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`.
@@ -200,7 +201,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
| **Hook bypass** | `/api/hook-event` exempt from auth (localhost-only, schema-validated) |
| **Hook bypass** | `/api/hook-event` exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable also **refuses** without `CODEMAN_PASSWORD` unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (COD-55) |
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains) |
| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`) |
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
+1
View File
@@ -214,6 +214,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
```
- **Multi-layer idle detection** — completion messages, AI-powered idle check, output silence, token stability
- **Auto-resume on usage limit** *(opt-in, off by default)* — when Claude halts on a subscription limit ("You've hit your limit · resets 3pm"), Codeman parses the reset time, waits it out plus a 2-minute safety buffer, then dismisses the rate-limit dialog and sends `continue` — so an overnight run survives the 5-hour window instead of stalling until morning. Recognizes every Claude Code limit-message format, retries if still limited, survives Codeman restarts, and holds respawn cycles while paused so `/clear` can't wipe the waiting conversation. Enable per session at the top of the Respawn tab
- **Circuit breaker** — prevents respawn thrashing when Claude is stuck (CLOSED -> HALF_OPEN -> OPEN states, tracks consecutive no-progress and repeated errors)
- **Health scoring** — 0-100 health score with component scores for cycle success, circuit breaker state, iteration progress, and stuck recovery
- **Built-in presets** — `solo-work` (3s idle, 60min), `subagent-workflow` (45s, 240min), `team-lead` (90s, 480min), `ralph-todo` (8s, 480min), `overnight-autonomous` (10s, 480min)
+1
View File
@@ -216,6 +216,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
```
- **多层空闲检测** —— 完成消息、AI 驱动的空闲检查、输出静默、token 稳定性
- **用量限额自动恢复**(*可选,默认关闭*)—— 当 Claude 因订阅用量限额而停止("You've hit your limit · resets 3pm")时,Codeman 会解析重置时间,等到限额刷新(外加 2 分钟安全缓冲)后自动关闭限额对话框并发送 `continue`,让通宵任务平稳跨过 5 小时窗口而不是停摆到早晨。可识别 Claude Code 各版本的全部限额消息格式;若仍受限会自动重试;计划在 Codeman 重启后依然生效;暂停期间会阻止重生循环,避免 `/clear` 清掉等待中的对话。在会话 Respawn 标签页顶部按会话启用
- **熔断器** —— 当 Claude 卡住时防止重生抖动(CLOSED → HALF_OPEN → OPEN 状态,跟踪连续无进展与重复错误)
- **健康评分** —— 0–100 健康分,分项涵盖循环成功率、熔断器状态、迭代进展与卡死恢复
- **内置预设** —— `solo-work`(3s 空闲,60min)、`subagent-workflow`(45s,240min)、`team-lead`(90s,480min)、`ralph-todo`(8s,480min)、`overnight-autonomous`(10s,480min)
+22 -6
View File
@@ -124,7 +124,11 @@ loopback bind matters. The auth pipeline (`src/web/middleware/auth.ts`,
`onRequest` hook) runs in this order:
1. **Localhost‑only exemptions** (always first): `POST /api/hook-event` and the QR
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3).
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3). While the
**managed tunnel is running**, the hook‑event exemption additionally requires
the per‑instance `X-Codeman-Hook-Secret` header (COD‑54); failed presentations
are rate‑limited in a **dedicated bucket** (separate from Basic‑Auth failures)
so misfiring hooks can never lock out the login path.
2. **Session cookie** check — a valid `codeman_session` cookie short‑circuits to
allow.
3. **HTTP Basic** check — correct credentials short‑circuit to allow and clear
@@ -165,17 +169,29 @@ protection is unchanged.
with `req.ip = 127.0.0.1`**. The localhost‑only exemptions then treat those
requests as local:
- `POST /api/hook-event` — auth‑exempt for loopback. Bounded impact: it is
- `POST /api/hook-event` — auth‑exempt for loopback **only while no managed tunnel
is running**. When Codeman's own tunnel is up, the exemption requires the
per‑instance shared secret (`X-Codeman-Hook-Secret`, 256‑bit hex in
`~/.codeman/hook-secret`, mode 0600, COD‑54). Local hook commands read the
secret file at execution time (`$CODEMAN_HOOK_SECRET_FILE`, exported into every
managed session), so they keep working — tunneled internet traffic can't know
it. Even without the secret the impact is bounded: the route is
`HookEventSchema`‑validated and requires a valid in‑memory `sessionId`; it can
drive respawn signals, SSE broadcasts, push notifications, and transcript
watching — **not** arbitrary terminal input or file reads. It is a
session‑disruption / notification‑spoofing surface, not RCE.
watching — **not** arbitrary terminal input or file reads. ⚠️ The gate keys off
the **managed** tunnel — an externally run loopback proxy (your own
`cloudflared`, `tailscale serve`) is invisible to it, so the plain loopback
exemption still applies there (prefer `tailscale serve`, which authenticates at
the tailnet layer). Hook configs regenerated since COD‑54 always present the
header, so a future release can require the secret unconditionally.
- QR `/q/` — still protected by its own short‑code brute‑force limiter
(10 failures / 60s against a 62⁶ space).
**Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is
up (it does not gate the hook‑event exemption, but it gates everything else and
is the documented practice). Prefer `tailscale serve` (below), which authenticates
up — it gates everything except the (secret‑gated) hook exemption and is the
documented practice; since COD‑55 enabling the managed tunnel **refuses** to start
without it unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly
acknowledges the exposure. Prefer `tailscale serve` (below), which authenticates
at the tailnet layer so untrusted clients never reach the loopback port at all.
### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "0.9.13",
"version": "0.9.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "0.9.13",
"version": "0.9.14",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.9.13",
"version": "0.9.14",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+67
View File
@@ -0,0 +1,67 @@
/**
* @fileoverview Per-instance shared hook secret (COD-54).
*
* Claude Code hooks POST to `/api/hook-event` with no Basic-Auth credentials,
* relying on a localhost bypass in `web/middleware/auth.ts`. That bypass is safe
* for loopback-only deploys, but a `cloudflared --url http://127.0.0.1:port`
* tunnel proxies internet traffic INTO the loopback origin, so tunneled requests
* arrive with `req.ip === 127.0.0.1` and would otherwise pass the bypass and
* drive respawn/Ralph signals unauthenticated.
*
* To close that hole WITHOUT breaking the loop's own (credential-less) hook
* channel, every locally-generated hook command now presents a per-instance
* shared secret in the `X-Codeman-Hook-Secret` header. The middleware requires
* a matching secret for the bypass WHEN A TUNNEL IS RUNNING. Tunneled internet
* traffic can't know the secret; local hooks (which we generate) do.
*
* Storage mirrors the VAPID-key pattern in `push-store.ts`: a small file under
* the instance data dir (`dataPath('hook-secret')`), read-if-present /
* generate-if-missing, stable across restarts. 256 bits of hex.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { getDataDir, dataPath } from './instance.js';
/** HTTP header local hooks use to present the shared secret. */
export const HOOK_SECRET_HEADER = 'X-Codeman-Hook-Secret';
/** Number of random bytes in the secret (256 bits → 64 hex chars). */
const SECRET_BYTES = 32;
let cachedSecret: string | null = null;
/**
* Return this instance's hook secret, generating and persisting it on first use.
* Stable across restarts. Cached in-process after the first read.
*/
export function getHookSecret(): string {
if (cachedSecret) return cachedSecret;
const secretFile = dataPath('hook-secret');
if (existsSync(secretFile)) {
try {
const raw = readFileSync(secretFile, 'utf-8').trim();
if (raw) {
cachedSecret = raw;
return cachedSecret;
}
// Empty/whitespace file — fall through and regenerate.
} catch {
// Unreadable — fall through and regenerate.
}
}
const secret = randomBytes(SECRET_BYTES).toString('hex');
try {
mkdirSync(getDataDir(), { recursive: true });
// Owner-only perms — the secret gates the hook bypass.
writeFileSync(secretFile, secret, { mode: 0o600 });
} catch {
// Best-effort persistence: even if the write fails we still return a usable
// secret for this process so hooks/middleware agree within this run.
}
cachedSecret = secret;
return cachedSecret;
}
+10 -2
View File
@@ -3,8 +3,9 @@
*
* Generates `.claude/settings.local.json` with hook definitions that POST
* to Codeman's `/api/hook-event` endpoint when Claude Code fires hooks.
* Uses `$CODEMAN_API_URL` and `$CODEMAN_SESSION_ID` env vars (set on every
* managed session) so the config is static per case directory.
* Uses `$CODEMAN_API_URL`, `$CODEMAN_SESSION_ID`, and `$CODEMAN_HOOK_SECRET_FILE`
* env vars (set on every managed session) so the config is static per case
* directory and free of secret values.
*
* Key exports:
* - `generateHooksConfig()` — returns hooks object for settings.local.json
@@ -41,11 +42,18 @@ import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
// Read Claude Code's stdin JSON and forward it as the data field.
// Falls back to empty object if stdin is unavailable or malformed.
// COD-54: present the per-instance hook secret so the bypass keeps working while
// a tunnel is running. The value is read from the secret file AT EXECUTION TIME
// (path via $CODEMAN_HOOK_SECRET_FILE, set in every managed session's env), so it
// never lands in this config and rotation needs no respawn. If the var/file is
// missing the header is empty — the middleware then allows the request only on
// the plain loopback bypass (tunnel down), same as pre-secret behavior.
const curlCmd = (event: HookEventType) =>
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
`curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` +
`-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
`--data @- ` +
`2>/dev/null || true`;
+5
View File
@@ -11,6 +11,7 @@
import type { ClaudeMode, EffortLevel } from './types.js';
import { isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js';
import { dataPath } from './config/instance.js';
/**
* Build Claude CLI permission flags based on the configured mode.
@@ -113,6 +114,8 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
CODEMAN_MUX: '1',
CODEMAN_SESSION_ID: sessionId,
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
};
}
@@ -149,5 +152,7 @@ export function buildShellEnv(sessionId: string): Record<string, string | undefi
CODEMAN_MUX: '1',
CODEMAN_SESSION_ID: sessionId,
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
};
}
+45
View File
@@ -259,6 +259,9 @@ export class Session extends EventEmitter {
private _messages: ClaudeMessage[] = [];
private _lineBuffer: string = '';
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
// Codex only: trailing partial CSI held back so sequences split across PTY
// chunks can't slip past the alt-screen/scrollback strip (see _handleTerminalOutput)
private _codexSeqCarry: string = '';
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
private rejectPromise: ((reason: Error) => void) | null = null;
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
@@ -1091,6 +1094,47 @@ export class Session extends EventEmitter {
}
private _handleTerminalOutput(data: string): void {
// Codex emits sequences that wipe xterm.js scrollback, plus mouse-tracking
// enables that hijack the scroll wheel so the user can't reach scrollback:
// - \x1b[?1049h / \x1b[?47h / \x1b[?1047h: switch to the alt buffer (no
// scrollback) — \x1b[?...l switches back.
// - \x1b[3J: erase saved lines (scrollback). (\x1b[2J / \x1b[J — erase
// the visible viewport — are left intact; the TUI repaints those rows.)
// - \x1b[?1000h / 1002h / 1003h / 1005h / 1006h / 1007h: mouse-tracking
// modes (X10, button-event, any-event, UTF-8, SGR, alt-scroll). Once on,
// xterm.js forwards wheel events to codex instead of scrolling the
// viewport, so the conversation is in scrollback but unreachable.
// (Focus events at ?1004 are left alone — codeman uses them for
// active-tab detection.)
// Strip them at the source so neither the persisted buffer nor the live
// SSE/WS stream carries them, keeping everything in the main buffer with
// scrollback intact. Codex's cursor-positioned redraws overwrite only the
// cells they actually target, so the non-erased rows keep their content.
if (this.mode === 'codex') {
// Reassemble sequences split across PTY chunk boundaries first: a chunk
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
// strip below and leave xterm stuck in the scrollback-less alt buffer
// until the next buffer replay. Hold back an incomplete digit-only CSI
// tail (≤7 chars — the longest strippable intro is '\x1b[?1049') and
// prepend it to the next chunk; complete sequences are never held.
data = this._codexSeqCarry + data;
this._codexSeqCarry = '';
// eslint-disable-next-line no-control-regex
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
if (splitTail) {
this._codexSeqCarry = splitTail[0];
data = data.slice(0, -splitTail[0].length);
if (!data) return;
}
data = data
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[3J/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
}
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
@@ -1711,6 +1755,7 @@ export class Session extends EventEmitter {
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._codexSeqCarry = '';
this._lastActivityAt = Date.now();
}
+3
View File
@@ -857,6 +857,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
// Path only (not the secret value): hook curl commands cat the file at
// execution time, so the COD-54 hook secret stays off the command line.
`export CODEMAN_HOOK_SECRET_FILE="${dataPath('hook-secret')}"`,
];
// Only unset CLAUDECODE for Claude sessions
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
+68 -8
View File
@@ -19,6 +19,7 @@ import {
AUTH_FAILURE_MAX,
AUTH_FAILURE_WINDOW_MS,
} from '../../config/auth-config.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
// Auth session cookie name
export const AUTH_COOKIE_NAME = 'codeman_session';
@@ -28,19 +29,32 @@ interface AuthState {
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
authFailures: StaleExpirationMap<string, number> | null;
qrAuthFailures: StaleExpirationMap<string, number> | null;
hookSecretFailures: StaleExpirationMap<string, number> | null;
}
/**
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
* Only active when CODEMAN_PASSWORD is set.
*
* @param getTunnelRunning - returns true while a managed tunnel is active. Used
* to gate the `/api/hook-event` localhost bypass: when a tunnel is up, tunneled
* internet traffic reaches the loopback origin with `req.ip === 127.0.0.1`, so
* the bypass additionally requires the shared hook secret (COD-54). When no
* tunnel is running (loopback-only, the normal case) the plain localhost bypass
* is kept so already-deployed (pre-secret) hooks + the loop channel keep working.
* Optional; defaults to "no tunnel" (unchanged behavior) when omitted.
* @returns AuthState for lifecycle management (dispose on server stop)
*/
export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState {
export function registerAuthMiddleware(
app: FastifyInstance,
https: boolean,
getTunnelRunning: () => boolean = () => false
): AuthState {
const state: AuthState = {
authSessions: null,
authFailures: null,
qrAuthFailures: null,
hookSecretFailures: null,
};
const authPassword = process.env.CODEMAN_PASSWORD;
@@ -67,24 +81,70 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
refreshOnGet: false,
});
// Separate hook-secret failure counter (COD-54). MUST NOT share authFailures:
// legacy (pre-secret) hook configs fire constantly from 127.0.0.1, and counting
// their 401s against the shared bucket would 429 every cookie-less request from
// loopback — locking out the Basic-Auth login path (and, through a tunnel, every
// client, since tunneled traffic also arrives as 127.0.0.1).
state.hookSecretFailures = new StaleExpirationMap<string, number>({
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
const authSessions = state.authSessions;
const authFailures = state.authFailures;
const hookSecretFailures = state.hookSecretFailures;
function sendAuthRateLimit(reply: FastifyReply, clientIp: string): void {
const remainingMs = authFailures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
function sendAuthRateLimit(
reply: FastifyReply,
clientIp: string,
failures: StaleExpirationMap<string, number> = authFailures
): void {
const remainingMs = failures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
reply.header('Retry-After', String(retryAfterSeconds));
reply.code(429).send('Too Many Requests — try again later');
}
app.addHook('onRequest', (req, reply, done) => {
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
// Safe: validated by HookEventSchema, only triggers broadcasts.
// Security: restrict bypass to localhost only — prevents forged hook events via tunnel/LAN.
// Hook events come from local Claude Code hooks (curl from localhost) — no
// Basic-Auth credentials available. Validated downstream by HookEventSchema.
//
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
// `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
// loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and
// would pass. So:
// - tunnel running → bypass requires the shared hook secret (local hooks present
// it via the X-Codeman-Hook-Secret header; internet traffic can't know it),
// - tunnel not running (loopback-only, the normal case) → keep the plain
// localhost bypass so already-deployed (pre-secret) hooks + the loop's own
// credential-less hook channel keep working.
if (req.url === '/api/hook-event' && req.method === 'POST') {
const ip = req.ip;
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
done();
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
if (isLoopback) {
if (!getTunnelRunning()) {
// Loopback-only: unchanged behavior.
done();
return;
}
// Tunnel up: require the shared secret (constant-time compare).
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
const expected = Buffer.from(getHookSecret());
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
done();
return;
}
// Wrong/absent secret while tunneled — rate-limit per IP in the DEDICATED
// hook bucket (never authFailures, which would lock out the login path).
const hookIp = req.ip;
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
if (hookFailures >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, hookIp, hookSecretFailures);
return;
}
hookSecretFailures.set(hookIp, hookFailures + 1);
reply.code(401).send('Unauthorized: hook secret required');
return;
}
// Non-localhost hook requests fall through to normal auth
+10
View File
@@ -6,6 +6,16 @@ export function isExplicitlyEnabled(value: string | undefined): boolean {
return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase());
}
/**
* True when unauthenticated network exposure is acceptable: either a password is
* set (auth active) or the operator explicitly acknowledged it. Used by the
* tunnel-enable guard (COD-55) to refuse publishing an unauthenticated public URL.
*/
export function isUnauthenticatedNetworkAcknowledged(allowFlag = false): boolean {
if (process.env.CODEMAN_PASSWORD) return true;
return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
}
export function isLoopbackBindHost(host: string): boolean {
const normalized = host
.trim()
+17 -12
View File
@@ -604,17 +604,6 @@
<div class="modal-tab-content" id="respawn-tab">
<!-- Respawn Settings Section -->
<div class="session-respawn-section" id="sessionRespawnSection">
<div class="respawn-header">
<div class="session-respawn-status" id="sessionRespawnStatus">
<span class="respawn-status-indicator"></span>
<span class="respawn-status-text">Not active</span>
</div>
<div class="respawn-actions">
<button class="btn-toolbar btn-success btn-sm" onclick="app.enableRespawnFromModal()" id="modalEnableRespawnBtn">Enable</button>
<button class="btn-toolbar btn-danger btn-sm" onclick="app.stopRespawnFromModal()" id="modalStopRespawnBtn" style="display: none;">Stop</button>
</div>
</div>
<div class="auto-resume-box">
<label class="checkbox-inline">
<input type="checkbox" id="modalAutoResumeEnabled" onchange="app.autoSaveAutoResume()">
@@ -624,6 +613,10 @@
<span class="form-hint">If Claude pauses on a usage limit ("limit reached &middot; resets 3pm"), Codeman waits for the reset time and automatically continues the work. Independent of the respawn loop below.</span>
</div>
<div class="respawn-loop-box">
<div class="respawn-loop-title">Respawn loop</div>
<span class="form-hint respawn-loop-hint">One autonomous work cycle: whenever Claude goes idle, Codeman sends the update prompt, optionally runs /clear + /init, and kickstarts the next round &mdash; repeating for the chosen duration. All settings below belong to this loop; configure them, then press Enable.</span>
<div class="form-row">
<label>Duration</label>
<div class="duration-presets">
@@ -660,7 +653,7 @@
<p class="form-hint" id="presetDescriptionHint"></p>
</div>
<div class="form-section-header">Respawn Cycle</div>
<div class="form-section-header">Cycle Steps</div>
<div class="form-row">
<label>1. Update Prompt</label>
<textarea id="modalRespawnPrompt" rows="1" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;">update all the docs and CLAUDE.md</textarea>
@@ -686,6 +679,18 @@
<textarea id="modalRespawnKickstart" rows="1" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;"></textarea>
<span class="form-hint">Sent only when /init completes but Claude stays idle &middot; Auto-accept presses Enter for plan approvals and default options</span>
</div>
<div class="respawn-header">
<div class="session-respawn-status" id="sessionRespawnStatus">
<span class="respawn-status-indicator"></span>
<span class="respawn-status-text">Not active</span>
</div>
<div class="respawn-actions">
<button class="btn-toolbar btn-success btn-sm" onclick="app.enableRespawnFromModal()" id="modalEnableRespawnBtn">Enable</button>
<button class="btn-toolbar btn-danger btn-sm" onclick="app.stopRespawnFromModal()" id="modalStopRespawnBtn" style="display: none;">Stop</button>
</div>
</div>
</div><!-- End respawn-loop-box -->
</div>
</div><!-- End respawn-tab -->
+42 -18
View File
@@ -36,6 +36,13 @@ html.mobile-init .file-browser-panel {
html {
touch-action: manipulation;
}
/* No "open in new window" (detach) on phones/tablets — popped-out browser
windows aren't usable there. !important beats the hover/detached reveal
rules in styles.css */
.session-tab .tab-detach {
display: none !important;
}
}
/* ============================================================================
@@ -200,6 +207,12 @@ html.mobile-init .file-browser-panel {
/* ---- Settings Modal: Tablet Optimizations ---- */
/* Modals must stack above the fixed tablet header (z-index 1200) so the
modal header with the close button stays visible */
.modal {
z-index: 1300;
}
.modal-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
@@ -1215,6 +1228,13 @@ html.mobile-init .file-browser-panel {
max-height: 35vh;
}
/* Modals must stack above the fixed mobile header (z-index 1200), or the
modal header with the close button is buried underneath it and the
full-screen modal cannot be dismissed */
.modal {
z-index: 1300;
}
/* Full-screen modals on phones */
.modal-content {
width: 100%;
@@ -1528,42 +1548,36 @@ html.mobile-init .file-browser-panel {
border-radius: 5px;
}
/* Duration preset buttons — grid layout, 4 columns for even spacing */
/* Duration preset buttons — one compact row */
.duration-presets {
display: grid;
grid-template-columns: repeat(4, 1fr);
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
}
.duration-preset-btn {
min-height: 32px;
padding: 0.2rem 0.25rem;
font-size: 0.65rem;
border-radius: 5px;
min-height: 24px;
padding: 0.1rem 0.4rem;
font-size: 0.6rem;
border-radius: 4px;
text-align: center;
}
/* Custom duration — spans full row below */
.duration-custom {
grid-column: 1 / -1;
display: flex;
gap: 0.25rem;
gap: 0.2rem;
align-items: center;
}
.duration-custom .duration-preset-btn {
flex: 0 0 auto;
min-width: 50px;
}
.duration-custom-input.visible {
flex: 1;
flex: 0 1 auto;
}
.duration-custom-input input {
width: 100%;
min-height: 28px;
width: 64px;
min-height: 24px;
font-size: 16px; /* Prevents iOS zoom */
padding: 0.1rem 0.3rem;
}
/* Preset selector row — full-width dropdown, buttons below */
@@ -1622,6 +1636,16 @@ html.mobile-init .file-browser-panel {
height: 18px;
}
/* Respawn-loop box title matches the auto-resume title; its cycle-step
checkboxes match the step labels */
#sessionOptionsModal .respawn-loop-title {
font-size: 0.75rem;
}
#sessionOptionsModal .respawn-loop-box .checkbox-inline {
font-size: 0.65rem;
}
/* Respawn options row — stack if needed */
#sessionOptionsModal .respawn-options-row {
gap: 0.5rem;
+54 -3
View File
@@ -844,11 +844,18 @@ Object.assign(CodemanApp.prototype, {
btn.disabled = true;
try {
const newEnabled = !isActive;
await fetch('/api/settings', {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: newEnabled }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (newEnabled && (await this._handleTunnelEnableRefusal(res))) {
this._dismissTunnelConnecting();
this._updateWelcomeTunnelBtn(false);
btn.disabled = false;
return;
}
if (newEnabled) {
this._showTunnelConnecting();
// Poll tunnel status as fallback in case SSE event is missed
@@ -1148,13 +1155,40 @@ Object.assign(CodemanApp.prototype, {
return `${Math.floor(hrs / 24)}d ago`;
},
/**
* COD-55: detect the server's refusal to start an unauthenticated public tunnel.
* The PUT /api/settings route returns a 4xx with { success:false, error } when no
* CODEMAN_PASSWORD is set and the unauthenticated-network opt-in is not acknowledged.
* Shows the server's (actionable) message as an error toast.
* @param {Response|null} res - the fetch Response from the settings PUT
* @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort)
*/
async _handleTunnelEnableRefusal(res) {
if (!res || res.ok) return false;
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
try {
const body = await res.json();
if (body && body.error) message = body.error;
} catch {
/* non-JSON body — use the default message */
}
this._dismissTunnelConnecting?.();
this.showToast(message, 'error');
return true;
},
async _tunnelPanelToggle(enable) {
try {
await fetch('/api/settings', {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: enable }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (enable && (await this._handleTunnelEnableRefusal(res))) {
this.closeTunnelPanel();
return;
}
if (enable) {
this._updateTunnelIndicator(false);
const indicator = document.getElementById('tunnelIndicator');
@@ -1473,7 +1507,24 @@ Object.assign(CodemanApp.prototype, {
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings;
try {
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
const res = await this._apiPut('/api/settings', {
...serverSettings,
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
// to localStorage above still apply locally.
if (settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
this.closeAppSettings();
return;
}
// Save model configuration separately
await this.saveModelConfigFromSettings();
+38 -4
View File
@@ -4645,15 +4645,16 @@ body.solo-mode .btn-lifecycle-log {
width: 60px;
}
/* Respawn header with status and actions */
/* Respawn status + Enable/Stop row — sits at the bottom of the Respawn tab,
below the settings it acts on */
.respawn-header {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(34, 197, 94, 0.2);
}
.respawn-header .respawn-actions {
@@ -4692,6 +4693,39 @@ body.solo-mode .btn-lifecycle-log {
margin-top: 0;
}
/* Respawn loop box — groups every setting that belongs to the autonomous
respawn cycle (duration, presets, cycle prompts, enable). Green like its
Enable button; a visual sibling of the standalone auto-resume option above */
.respawn-loop-box {
margin: 0.75rem 0;
padding: 0.6rem 0.75rem;
background: rgba(34, 197, 94, 0.05);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 6px;
}
.respawn-loop-title {
font-size: 0.85rem;
font-weight: 500;
color: var(--text);
margin-bottom: 0.3rem;
}
.respawn-loop-hint {
display: block;
margin-top: 0;
margin-bottom: 0.5rem;
}
.respawn-loop-box .form-section-header {
border-bottom-color: rgba(34, 197, 94, 0.2);
}
/* Match the cycle-step checkboxes (2./3.) to the step labels (1./4.) */
.respawn-loop-box .checkbox-inline {
font-size: 0.7rem;
}
.auto-resume-status {
display: none;
font-size: 0.7rem;
+45 -3
View File
@@ -15,6 +15,10 @@
(function (global) {
const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/;
const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/;
// Grace window after a manual scroll-up gesture during which sticky-scroll is
// suppressed, so high-frequency Codex status redraws don't snap the viewport
// back to the bottom while the user is inspecting earlier output.
const USER_SCROLL_STICKY_SUPPRESS_MS = 1500;
function isTerminalQueryResponse(data) {
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
@@ -27,6 +31,7 @@
global.CodemanTerminalInput = {
isTerminalQueryResponse,
shouldSuppressTerminalQueryResponse,
USER_SCROLL_STICKY_SUPPRESS_MS,
};
})(window);
@@ -302,6 +307,7 @@ Object.assign(CodemanApp.prototype, {
(ev) => {
ev.preventDefault();
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines);
},
{ passive: false }
@@ -376,6 +382,7 @@ Object.assign(CodemanApp.prototype, {
const ch = cellHeight();
const lines = Math.trunc(pixelAccum / ch);
if (lines !== 0) {
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines);
pixelAccum -= lines * ch;
}
@@ -428,6 +435,7 @@ Object.assign(CodemanApp.prototype, {
this._chunkedWriteGen = 0;
this._bufferLoadSeq = 0;
this._bufferLoadOwner = null;
this._lastUserScrollUpAt = null;
// Handle resize with throttling for performance
this._resizeTimeout = null;
@@ -1364,6 +1372,22 @@ Object.assign(CodemanApp.prototype, {
return buffer.viewportY >= buffer.baseY - 2;
},
// Record manual scroll gestures so sticky-scroll can give an upward scroll a
// short grace window (see _hasRecentUserScrollUp). A downward scroll that
// lands back at the bottom clears the suppression immediately.
_noteTerminalUserScroll(lines) {
if (lines < 0) {
this._lastUserScrollUpAt = performance.now();
} else if (this.isTerminalAtBottom()) {
this._lastUserScrollUpAt = null;
}
},
_hasRecentUserScrollUp() {
if (typeof this._lastUserScrollUpAt !== 'number') return false;
return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS;
},
batchTerminalWrite(data) {
// If a buffer load (chunkedTerminalWrite) is in progress, queue live events
// to prevent interleaving historical buffer data with live SSE data.
@@ -1615,8 +1639,16 @@ Object.assign(CodemanApp.prototype, {
// Per-frame byte budget to prevent main thread blocking.
// Large writes (141KB+) can freeze Chrome for 2+ minutes.
const MAX_FRAME_BYTES = 65536; // 64KB budget per frame
// Codex's TUI emits dense synchronized redraws during thinking/high-effort
// phases, so it gets a smaller first frame to keep per-frame xterm/WebGL
// stalls short; other modes keep the larger 64KB budget.
const activeSession = this.activeSessionId && this.sessions ? this.sessions.get(this.activeSessionId) : null;
const MAX_FRAME_BYTES = activeSession?.mode === 'codex' ? 32768 : 65536;
let deferred = false;
// If the user recently scrolled up, remember the viewport so we can restore
// it after the write — Codex status redraws would otherwise jump it.
const preserveViewportY =
this._hasRecentUserScrollUp() && this.terminal.buffer?.active ? this.terminal.buffer.active.viewportY : null;
if (_joinedLen <= MAX_FRAME_BYTES) {
this.terminal.write(joined);
@@ -1633,6 +1665,13 @@ Object.assign(CodemanApp.prototype, {
});
}
}
if (
preserveViewportY !== null &&
this.terminal.buffer?.active?.viewportY !== preserveViewportY &&
typeof this.terminal.scrollToLine === 'function'
) {
this.terminal.scrollToLine(preserveViewportY);
}
const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
const _dt = performance.now() - _t0;
if (_dt > 100 || deferred)
@@ -1640,8 +1679,11 @@ Object.assign(CodemanApp.prototype, {
`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame / 1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen / 1024).toFixed(0)}KB)`
);
// Sticky scroll: if user was at bottom, keep them there after new output
if (this._wasAtBottomBeforeWrite) {
// Sticky scroll: if user was at bottom, keep them there after new output.
// Give manual scroll-up gestures a short grace window so high-frequency
// Codex status ticks do not snap the viewport back while the user is
// trying to inspect earlier output.
if (this._wasAtBottomBeforeWrite && !this._hasRecentUserScrollUp()) {
this.terminal.scrollToBottom();
}
+36 -1
View File
@@ -66,6 +66,31 @@ const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
const CTRL_L_PATTERN = /\x0c/g;
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
/**
* Match xterm alternate-screen mode toggles + the standalone scrollback-erase.
*
* - DECSET/DECRST 47, 1047, 1049 = enter/exit alternate screen buffer
* (1049 also saves cursor and clears the alt buffer).
* - CSI 3 J = erase saved lines (scrollback).
*
* Codex emits `\x1b[?1049h` and clear-scrollback sequences during startup and
* on repaint. xterm.js obeys them by switching to the alt buffer (no native
* scrollback) and wiping saved lines, so the user's conversation history
* disappears on every tab switch / pane refresh. Stripping these from the
* replayed byte stream keeps everything in the main buffer with scrollback
* intact. Mirrors the live-stream strip in Session._handleTerminalOutput.
*/
// eslint-disable-next-line no-control-regex
const ALT_SCREEN_TOGGLE_PATTERN = /\x1b\[\?(?:47|1047|1049)[hl]/g;
// eslint-disable-next-line no-control-regex
const ERASE_SCROLLBACK_PATTERN = /\x1b\[3J/g;
// Mouse-tracking enables (X10/button/any-event/UTF-8/SGR/alt-scroll) — once on,
// xterm.js forwards wheel events to the app instead of scrolling the viewport.
// Live streams are stripped at the source, but buffers persisted BEFORE that
// strip existed can still carry them; strip on replay for parity.
// eslint-disable-next-line no-control-regex
const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g;
/**
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate
@@ -917,7 +942,17 @@ export function registerSessionRoutes(
// During long thinking phases, Ink rewrites the same rows thousands of times
// (500KB+). Without stripping, tail mode returns only spinner frames and
// the terminal appears empty when switching tabs.
const strippedBuffer = stripInkRedrawBloat(rawBuffer);
let strippedBuffer = stripInkRedrawBloat(rawBuffer);
// Strip alt-screen toggles and scrollback-erase from codex byte streams.
// xterm.js obeys them by switching to its scrollback-less alt buffer and
// wiping saved lines, so conversation history disappears on tab switch.
if (session.mode === 'codex') {
strippedBuffer = strippedBuffer
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
.replace(ERASE_SCROLLBACK_PATTERN, '')
.replace(MOUSE_TRACKING_PATTERN, '');
}
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
+21
View File
@@ -14,6 +14,7 @@ import { execSync, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { dataPath } from '../../config/instance.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import {
ConfigUpdateSchema,
SettingsUpdateSchema,
@@ -498,6 +499,26 @@ export function registerSystemRoutes(
app.put('/api/settings', async (req) => {
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
// COD-55: enabling the Cloudflare tunnel publishes the whole app (full terminal
// control = effectively RCE) to a public *.trycloudflare.com URL. Because the
// tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and
// with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is
// unauthenticated. Refuse to start a tunnel unless auth is configured OR the
// operator has acknowledged unauthenticated-network exposure. A public tunnel is
// higher-stakes than a LAN bind, so this is REFUSE (vs the bind guard's warn).
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning() && !isUnauthenticatedNetworkAcknowledged()) {
const msg =
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
'require login, or set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 to acknowledge an ' +
'unauthenticated public tunnel.';
throw Object.assign(new Error(msg), {
statusCode: 403,
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
});
}
try {
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) {
+12 -1
View File
@@ -41,6 +41,7 @@ import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { hostname as getHostname } from 'node:os';
import { dataPath } from '../config/instance.js';
import { getHookSecret } from '../config/hook-secret.js';
import { EventEmitter } from 'node:events';
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
import type { ClaudeMode, SessionState } from '../types.js';
@@ -253,6 +254,7 @@ export class WebServer extends EventEmitter {
private authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null;
private authFailures: StaleExpirationMap<string, number> | null = null;
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
@@ -603,11 +605,12 @@ export class WebServer extends EventEmitter {
registerHostGuard(this.app, () => this.getHostPolicy());
// Auth middleware (Basic Auth + session cookies + rate limiting)
const authState = registerAuthMiddleware(this.app, this.https);
const authState = registerAuthMiddleware(this.app, this.https, () => this.tunnelManager.isRunning());
if (authState) {
this.authSessions = authState.authSessions;
this.authFailures = authState.authFailures;
this.qrAuthFailures = authState.qrAuthFailures;
this.hookSecretFailures = authState.hookSecretFailures;
}
// WebSocket support (terminal I/O — low-latency bidirectional channel)
@@ -1816,6 +1819,10 @@ export class WebServer extends EventEmitter {
this.host === '0.0.0.0' || this.host === 'localhost' || this.host === '::1' ? '127.0.0.1' : this.host;
process.env.CODEMAN_API_URL = `${protocol}://${apiHost}:${this.port}`;
// Ensure the COD-54 hook secret exists on disk before any session exports
// $CODEMAN_HOOK_SECRET_FILE — hook curls cat that path at execution time.
getHookSecret();
// Start scheduled runs cleanup timer
this.cleanup.setInterval(
() => {
@@ -2298,6 +2305,10 @@ export class WebServer extends EventEmitter {
this.qrAuthFailures.dispose();
this.qrAuthFailures = null;
}
if (this.hookSecretFailures) {
this.hookSecretFailures.dispose();
this.hookSecretFailures = null;
}
this.activePlanOrchestrators.clear();
this.cleaningUp.clear();
+186
View File
@@ -0,0 +1,186 @@
/**
* @fileoverview COD-54 — hook-event auth bypass hardening.
*
* The `/api/hook-event` localhost bypass let tunnel traffic (cloudflared
* --url http://127.0.0.1:port) reach the loopback origin with req.ip ===
* 127.0.0.1 and drive respawn/Ralph signals unauthenticated. The fix gates
* the bypass behind a shared hook secret WHEN A TUNNEL IS RUNNING, while
* keeping the plain localhost bypass for the normal loopback-only case so
* already-deployed (pre-secret) hooks and the loop's own channel keep working.
*
* Tests:
* - tunnel running + no secret → 401 (closes the hole)
* - tunnel running + bad secret → 401
* - tunnel running + good secret → not 401 (allowed)
* - tunnel NOT running + no secret → not 401 (back-compat regression guard)
* - rate limiting: rapid unauthorized hook POSTs eventually 429
*
* Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit)
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { TunnelManager } from '../src/tunnel-manager.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
const TUNNEL_UP_PORT = 3230;
const TUNNEL_DOWN_PORT = 3231;
const RATE_LIMIT_PORT = 3232;
const TEST_USER = 'admin';
const TEST_PASS = 'cod54-test-password';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
function hookBody(): string {
return JSON.stringify({ event: 'stop', sessionId: 'nonexistent-session', data: {} });
}
async function postHook(baseUrl: string, headers: Record<string, string> = {}): Promise<Response> {
return fetch(`${baseUrl}/api/hook-event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: hookBody(),
});
}
describe('COD-54 hook-event auth — tunnel running requires secret', () => {
let server: WebServer;
let baseUrl: string;
let isRunningSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
// Force the middleware's tunnel check to report "running".
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
server = new WebServer(TUNNEL_UP_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${TUNNEL_UP_PORT}`;
});
afterAll(async () => {
await server.stop();
isRunningSpy.mockRestore();
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
});
it('rejects a localhost hook POST WITHOUT the secret header (closes the tunnel hole)', async () => {
const res = await postHook(baseUrl);
expect(res.status).toBe(401);
});
it('rejects a localhost hook POST with a WRONG secret', async () => {
const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: 'wrong-secret-value' });
expect(res.status).toBe(401);
});
it('allows a localhost hook POST WITH the correct secret', async () => {
const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: getHookSecret() });
// Passes auth (may 200 with success:false for unknown session) — key is NOT 401.
expect(res.status).not.toBe(401);
});
});
describe('COD-54 hook-event auth — tunnel down keeps localhost bypass (back-compat)', () => {
let server: WebServer;
let baseUrl: string;
let isRunningSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
// Tunnel NOT running — loopback-only normal prod case.
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(false);
server = new WebServer(TUNNEL_DOWN_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${TUNNEL_DOWN_PORT}`;
});
afterAll(async () => {
await server.stop();
isRunningSpy.mockRestore();
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
});
it('still allows a localhost hook POST WITHOUT a secret (existing hooks + loop channel keep working)', async () => {
const res = await postHook(baseUrl);
expect(res.status).not.toBe(401);
});
});
describe('COD-54 hook-event auth — rate limiting', () => {
let server: WebServer;
let baseUrl: string;
let isRunningSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
// Tunnel running so unauthorized (no-secret) hook POSTs are rejected and counted.
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
server = new WebServer(RATE_LIMIT_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${RATE_LIMIT_PORT}`;
});
afterAll(async () => {
await server.stop();
isRunningSpy.mockRestore();
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
});
it('eventually returns 429 for rapid unauthorized hook POSTs', async () => {
let saw429 = false;
// A few more than the failure max to cross the threshold.
for (let i = 0; i < AUTH_FAILURE_MAX + 3; i++) {
const res = await postHook(baseUrl);
if (res.status === 429) {
saw429 = true;
expect(res.headers.get('retry-after')).toMatch(/^\d+$/);
break;
}
expect(res.status).toBe(401);
}
expect(saw429).toBe(true);
});
it('hook-secret failures do NOT lock out the Basic-Auth login path (separate bucket)', async () => {
// The previous test exhausted the hook bucket for 127.0.0.1. Legacy (pre-secret)
// hooks fire constantly, so if they shared authFailures, every cookie-less
// request from loopback would now 429 — locking out login (and, via a tunnel,
// every client). Assert the login path is unaffected:
// 1. A credential-less request still gets a 401 challenge, NOT 429.
const unauthed = await fetch(`${baseUrl}/api/status`);
expect(unauthed.status).toBe(401);
// 2. Correct Basic credentials still authenticate.
const authed = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: 'Basic ' + Buffer.from(`${TEST_USER}:${TEST_PASS}`).toString('base64') },
});
expect(authed.status).toBe(200);
});
});
describe('COD-54 secret delivery — generated hooks + session env present the secret', () => {
it('generated hook curl commands send the secret header, read from the file at exec time', async () => {
const { generateHooksConfig } = await import('../src/hooks-config.js');
const config = generateHooksConfig();
const commands = JSON.stringify(config);
// Header present, value sourced from $CODEMAN_HOOK_SECRET_FILE (not embedded).
expect(commands).toContain(HOOK_SECRET_HEADER);
expect(commands).toContain('$CODEMAN_HOOK_SECRET_FILE');
expect(commands).not.toContain(getHookSecret());
});
it('session env builders export CODEMAN_HOOK_SECRET_FILE (path only, never the value)', async () => {
const { buildClaudeEnv, buildShellEnv } = await import('../src/session-cli-builder.js');
const claudeEnv = buildClaudeEnv('test-session');
const shellEnv = buildShellEnv('test-session');
expect(claudeEnv.CODEMAN_HOOK_SECRET_FILE).toMatch(/hook-secret$/);
expect(shellEnv.CODEMAN_HOOK_SECRET_FILE).toMatch(/hook-secret$/);
expect(JSON.stringify(claudeEnv)).not.toContain(getHookSecret());
});
});
+278
View File
@@ -0,0 +1,278 @@
import { describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_ptyRows: number;
};
function handleOutput(session: Session, data: string): void {
(session as unknown as SessionInternals)._handleTerminalOutput(data);
}
describe('Codex terminal output filtering', () => {
it('keeps browser scrollback guards but skips Codeman row repair in hybrid render mode', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex', codexConfig: { renderMode: 'hybrid' } });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const hybridRedraw = '\x1b[?1049h\x1b[55;1H\x1b[2m• Working (21s)\x1b[3J\x1b[?1006h\x1b[?1049l';
handleOutput(session, hybridRedraw);
expect(emitted[0]).toBe('\x1b[55;1H\x1b[2m• Working (21s)');
expect(emitted[0]).not.toContain('\x1b[55;1H\x1b[2K');
expect(session.terminalBuffer).toBe(emitted[0]);
});
it('preserves Codex erase-display redraws used by the TUI layout engine', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
handleOutput(session, '\x1b[H\x1b[Jidle redraw');
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jidle redraw');
});
it('strips Codex scrollback erase without stripping visible-screen erase', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
handleOutput(session, '\x1b[?1049h\x1b[2Jvisible\x1b[3Jscrollback\x1b[?1049l');
expect(session.terminalBuffer).toBe('\x1b[2Jvisiblescrollback');
});
it('strips sequences split across PTY chunk boundaries (carry reassembly)', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
// '\x1b[?1049h' split mid-sequence, then '\x1b[3J' split before its final byte.
handleOutput(session, 'before\x1b[?104');
handleOutput(session, '9h\x1b[2Jafter\x1b[3');
handleOutput(session, 'Jtail');
expect(session.terminalBuffer).toBe('before\x1b[2Jaftertail');
expect(emitted).toEqual(['before', '\x1b[2Jafter', 'tail']);
});
it('emits nothing for a chunk that is only a partial CSI, and completes it next chunk', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(session, '\x1b[?100'); // pure partial — held, nothing emitted
handleOutput(session, '6h\x1b[55;1H• Working'); // completes ?1006h (stripped); rest passes
expect(emitted).toEqual(['\x1b[55;1H• Working']);
expect(session.terminalBuffer).toBe('\x1b[55;1H• Working');
});
it('preserves Codex erase-display redraw when the user pressed Ctrl+L', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
session.write('\x0c');
handleOutput(session, '\x1b[H\x1b[Jredraw after clear');
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jredraw after clear');
});
it('passes native Codex TUI prompt/status redraws through without row repair', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const bottomBandRedraw =
'\x1b[48;2;42;42;42m' +
'\x1b[60;2H\x1b[K' +
'\x1b[61;39H\x1b[K' +
'\x1b[62;2H\x1b[K' +
'\x1b[52;1H\x1b[49m\x1b[2m• \x1b[1mRunning node -e ...' +
'\x1b[60;1H\x1b[48;2;42;42;42m \r\n' +
'\x1b[1m›\x1b[0m\x1b[48;2;42;42;42m \x1b[2mUse /skills to list available skills\r\n' +
'\x1b[63;3H\x1b[49m\x1b[38;2;246;226;183mgpt-5.5 xhigh\x1b[39m' +
'\x1b[2m · \x1b[38;2;242;181;144mContext 42% left\x1b[39m' +
'\x1b[61;3H';
handleOutput(session, bottomBandRedraw);
expect(emitted[0]).not.toContain('\x1b[52;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[60;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[63;1H\x1b[2K');
expect(emitted[0]).toContain(bottomBandRedraw);
});
it('passes Codex advisory rows through without row repair', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const advisoryRedraw =
'\x1b[55;1H\x1b[2mMessages\x1b[Cto\x1b[Cbe submitted\x1b[Cafter\x1b[Cnext toolcall ' +
'(press esc to interrupt and send immediately)\x1b[56;1H';
handleOutput(session, advisoryRedraw);
expect(emitted[0]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[0]).toContain(advisoryRedraw);
});
it('does not clear Codex resume-picker rows just because an option is selected', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const resumePickerRedraw =
'\x1b[1;2H\x1b[36m\x1b[1mResume a previous session' +
'\x1b[3;2H\x1b[2mType to search Filter: \x1b[35m[Cwd]\x1b[39m\x1b[2m All' +
'\x1b[5;3H\x1b[33m\x1b[48;2;42;42;42m\x1b[1m❯ \x1b[2m22h ago ll' +
'\x1b[6;3H\x1b[2m 1d ago $kb-health' +
'\x1b[60;1H\x1b[2m──── 2 / 2 · 100% ─' +
'\x1b[61;1H enter resume esc exit ↑/↓ browse';
handleOutput(session, resumePickerRedraw);
expect(emitted[0]).not.toContain('\x1b[4;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[5;1H\x1b[2K');
expect(emitted[0]).toContain(resumePickerRedraw);
});
it('does not full-clear sparse Codex resume-picker navigation redraws', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
let sparseResumePickerRedraw = '';
for (let row = 1; row <= 51; row++) {
const col = row % 2 === 0 ? 239 : 27;
sparseResumePickerRedraw += `\x1b[${row};${col}H\x1b[K`;
}
sparseResumePickerRedraw +=
'\x1b[21;3H \x1b[2m9d ago \x1b[mreview this webex room webexteams://im?space=672465b0-4fcb-11f1-9d54-51475df86e3a\x1b[K' +
'\x1b[22;3H\x1b[33m\x1b[1m❯ \x1b[m\x1b[33m\x1b[2m9d ago \x1b[m\x1b[33mcisco hybrid mesh firewall includes support for smart switch enforcement...\x1b[K' +
'\x1b[52;229H\x1b[39m\x1b[2m8\x1b[m';
handleOutput(session, sparseResumePickerRedraw);
expect(emitted[0]).not.toContain('\x1b[H\x1b[2J');
expect(emitted[0]).toContain(sparseResumePickerRedraw);
});
it('passes Codex UI rows through when the status band moves downward', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(
session,
'\x1b[55;1H\x1b[2m• Working (1s)\x1b[56;1H\x1b[1m›\x1b[0m ask\x1b[57;3Hgpt-5.5 · Context 80% left'
);
handleOutput(
session,
'\x1b[58;1H\x1b[2m• Working (2s)\x1b[59;1H\x1b[1m›\x1b[0m ask\x1b[60;3Hgpt-5.5 · Context 79% left'
);
expect(emitted[1]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[56;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[57;1H\x1b[2K');
expect(emitted[1]).toContain('\x1b[58;1H');
expect(emitted[1]).not.toContain('\x1b[54;1H\x1b[2K');
});
it('does not full-clear the viewport for stable Codex UI rows at the same position', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const stableRedraw =
'\x1b[58;1H\x1b[2m• Working (2s)\x1b[59;1H\x1b[1m›\x1b[0m ask\x1b[60;3Hgpt-5.5 · Context 79% left';
handleOutput(session, stableRedraw);
handleOutput(session, stableRedraw.replace('2s', '3s'));
expect(emitted[1]).not.toContain('\x1b[H\x1b[2J');
});
it('passes status-only Codex Working redraw rows through', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const workingRedraw = '\x1b[55;1H\x1b[2m• Working (21s)';
handleOutput(session, workingRedraw);
expect(emitted[0]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[0]).toContain(workingRedraw);
expect(emitted[0]).not.toContain('\x1b[H\x1b[2J');
});
it('passes Codex spinner Working rows that omit elapsed time parentheses through', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 29;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const spinnerRedraw = '\x1b[24;1H\x1b[38;5;254m\x1b[1m•\x1b[CWorking\x1b[27;3H';
handleOutput(session, spinnerRedraw);
expect(emitted[0]).not.toContain('\x1b[24;1H\x1b[2K');
expect(emitted[0]).toContain(spinnerRedraw);
expect(emitted[0]).not.toContain('\x1b[H\x1b[2J');
});
it('does not treat ordinary gpt model mentions as Codex status rows', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
const outputRow = '\x1b[20;1Hnormal output comparing gpt-5 and another model';
handleOutput(session, outputRow);
expect(emitted[0]).toContain(outputRow);
expect(emitted[0]).not.toContain('\x1b[19;1H\x1b[2K');
expect(emitted[0]).not.toContain('\x1b[20;1H\x1b[2K');
});
it('does not inject row erases during partial Working spinner ticks', () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
(session as unknown as SessionInternals)._ptyRows = 63;
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(
session,
'\x1b[55;1H\x1b[2m• Working (1s)' +
'\x1b[56;1H\x1b[1m›\x1b[0m ask' +
'\x1b[57;3Hgpt-5.5 xhigh fast · codeman · Working · Context 79% left'
);
handleOutput(session, '\x1b[55;1H\x1b[2m• Working (2s)');
expect(emitted[1]).not.toContain('\x1b[55;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[56;1H\x1b[2K');
expect(emitted[1]).not.toContain('\x1b[57;1H\x1b[2K');
expect(emitted[1]).toContain('\x1b[55;1H\x1b[2m• Working (2s)');
});
});
@@ -0,0 +1,133 @@
/**
* @fileoverview COD-55 — tunnel password guard.
*
* Enabling the Cloudflare tunnel publishes the whole app (full terminal control =
* effectively RCE) to a public *.trycloudflare.com URL. When no CODEMAN_PASSWORD
* is set, requests through that URL are unauthenticated. These tests assert the
* PUT /api/settings tunnel-enable path REFUSES to start the tunnel unless a
* password is set OR the unauthenticated-network opt-in is acknowledged.
*
* Uses app.inject() — no real HTTP ports needed. Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
// Settings are written to disk via fs/promises — stub so the guard test never
// touches the real settings.json, and so we can assert "not persisted on refusal".
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => '{}'),
writeFile: vi.fn(async () => undefined),
},
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(() => true),
mkdirSync: vi.fn(),
readdirSync: vi.fn(() => []),
};
});
import fs from 'node:fs/promises';
const mockedWriteFile = vi.mocked(fs.writeFile);
/** Build a tunnelManager stub the route's ctx can use. */
function makeTunnelManager(running = false) {
return {
start: vi.fn(),
stop: vi.fn(),
isRunning: vi.fn(() => running),
getUrl: vi.fn(() => null),
getStatus: vi.fn(() => ({ running })),
};
}
describe('COD-55 tunnel password guard (PUT /api/settings tunnelEnabled)', () => {
let harness: RouteTestHarness;
let tunnel: ReturnType<typeof makeTunnelManager>;
const savedPassword = process.env.CODEMAN_PASSWORD;
const savedOptIn = process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
beforeEach(async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
vi.clearAllMocks();
mockedWriteFile.mockResolvedValue(undefined);
tunnel = makeTunnelManager(false);
// tunnelManager is null in the default mock ctx — inject our spy.
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
});
afterEach(async () => {
await harness.app.close();
if (savedPassword === undefined) delete process.env.CODEMAN_PASSWORD;
else process.env.CODEMAN_PASSWORD = savedPassword;
if (savedOptIn === undefined) delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
else process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = savedOptIn;
});
it('REFUSES tunnel-enable with no password and no opt-in (4xx, start not called)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(res.statusCode).toBeLessThan(500);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
// Message should tell the user how to fix it.
expect(body.error).toMatch(/CODEMAN_PASSWORD|CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK/);
// The tunnel must NOT have been started.
expect(tunnel.start).not.toHaveBeenCalled();
// And tunnelEnabled:true must NOT have been persisted.
expect(mockedWriteFile).not.toHaveBeenCalled();
});
it('ALLOWS tunnel-enable when CODEMAN_PASSWORD is set (start called, 200)', async () => {
process.env.CODEMAN_PASSWORD = 'hunter2';
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
});
it('ALLOWS tunnel-enable with the unauthenticated-network opt-in acknowledged (start called, 200)', async () => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = '1';
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
});
it('does not guard tunnel-disable (tunnelEnabled:false always allowed)', async () => {
tunnel = makeTunnelManager(true);
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: false },
});
expect(res.statusCode).toBe(200);
expect(tunnel.stop).toHaveBeenCalledTimes(1);
});
});
+147
View File
@@ -0,0 +1,147 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadTerminalUiHarness(mode: string) {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
window: {},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 0 },
requestAnimationFrame: (_fn: () => void) => 1,
setTimeout: (_fn: () => void) => 1,
Blob: function Blob() {},
URL: {
createObjectURL: () => 'blob:yield',
revokeObjectURL: () => {},
},
Worker: function Worker(this: any) {
this.postMessage = () => {};
},
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as any)();
const writes: string[] = [];
app.activeSessionId = 'session-1';
app.sessions = new Map([['session-1', { mode }]]);
app.pendingWrites = [];
app.writeFrameScheduled = false;
app._wasAtBottomBeforeWrite = false;
app._workerYield = () => {};
app._chunkedWriteGen = 0;
app.terminal = {
write: (data: string) => writes.push(data),
scrollToBottom: () => {},
scrollToLine: () => {},
};
return { app, writes };
}
describe('terminal flush budget', () => {
it('uses a smaller first-frame write budget for Codex output to reduce renderer stalls', () => {
const { app, writes } = loadTerminalUiHarness('codex');
app.pendingWrites.push('x'.repeat(96 * 1024));
app.flushPendingWrites();
expect(writes).toHaveLength(1);
expect(writes[0]).toHaveLength(32 * 1024);
expect(app.pendingWrites.join('')).toHaveLength(64 * 1024);
});
it('keeps the larger first-frame write budget for non-Codex terminal output', () => {
const { app, writes } = loadTerminalUiHarness('claude');
app.pendingWrites.push('x'.repeat(96 * 1024));
app.flushPendingWrites();
expect(writes).toHaveLength(1);
expect(writes[0]).toHaveLength(64 * 1024);
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
});
it('waits for xterm to process small buffer replays before completing buffer load', async () => {
const { app, writes } = loadTerminalUiHarness('codex');
let writeDone: (() => void) | undefined;
let resolved = false;
const finishBufferLoad = vi.fn();
app._finishBufferLoad = finishBufferLoad;
app.terminal.write = (data: string, callback?: () => void) => {
writes.push(data);
writeDone = callback;
};
const promise = app.chunkedTerminalWrite('fresh tmux pane frame').then(() => {
resolved = true;
});
await Promise.resolve();
expect(writes).toEqual(['fresh tmux pane frame']);
expect(writeDone).toBeTypeOf('function');
expect(resolved).toBe(false);
expect(finishBufferLoad).not.toHaveBeenCalled();
writeDone?.();
await promise;
expect(resolved).toBe(true);
expect(finishBufferLoad).toHaveBeenCalledOnce();
});
it('keeps stale buffer load owners from finishing a newer load', () => {
const { app } = loadTerminalUiHarness('codex');
app._beginBufferLoad('select-1');
app._beginBufferLoad('select-2');
expect(app._finishBufferLoad('select-1')).toBe(false);
expect(app._isLoadingBuffer).toBe(true);
expect(app._bufferLoadOwner).toBe('select-2');
expect(app._finishBufferLoad('select-2')).toBe(true);
expect(app._isLoadingBuffer).toBe(false);
expect(app._bufferLoadOwner).toBe(null);
});
it('does not snap back to bottom during Codex Working redraws right after the user scrolls up', () => {
const { app } = loadTerminalUiHarness('codex');
const scrollToBottom = vi.fn();
app.terminal.scrollToBottom = scrollToBottom;
app._wasAtBottomBeforeWrite = true;
app._lastUserScrollUpAt = 0;
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
app.flushPendingWrites();
expect(scrollToBottom).not.toHaveBeenCalled();
});
it('restores the user scroll position when Codex Working redraws move the viewport', () => {
const { app } = loadTerminalUiHarness('codex');
const buffer = { viewportY: 40, baseY: 100 };
app.terminal.buffer = { active: buffer };
app.terminal.write = vi.fn(() => {
buffer.viewportY = buffer.baseY;
});
app.terminal.scrollToLine = vi.fn((line: number) => {
buffer.viewportY = line;
});
app._wasAtBottomBeforeWrite = true;
app._lastUserScrollUpAt = 0;
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
app.flushPendingWrites();
expect(buffer.viewportY).toBe(40);
});
});