mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-21 09:29:33 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6da22f0db0 | ||
|
|
dc9c4b3bda | ||
|
|
1cf5c8c8ad | ||
|
|
7eda39e7f7 | ||
|
|
f0db5f827f | ||
|
|
aa4e1ce9cf | ||
|
|
b8cb4670dd | ||
|
|
fe821fb679 | ||
|
|
d7606366a2 | ||
|
|
42f0b28c75 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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`;
|
||||
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 · 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 — 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 · 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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user