mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-27 19:39:33 +01: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
|
# 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
|
## 0.9.13
|
||||||
|
|
||||||
### Patch Changes
|
### 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.
|
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
|
## 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)
|
- **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
|
- **`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`
|
- **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
|
- **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
|
- **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`.
|
- **`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` | |
|
| **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) | |
|
| **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` | |
|
| **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 |
|
| **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`.
|
★ = 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).
|
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`.
|
**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` |
|
| **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 |
|
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
|
||||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
|
| **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) |
|
| **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_*`) |
|
| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`) |
|
||||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
| **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
|
- **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)
|
- **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
|
- **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)
|
- **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 稳定性
|
- **多层空闲检测** —— 完成消息、AI 驱动的空闲检查、输出静默、token 稳定性
|
||||||
|
- **用量限额自动恢复**(*可选,默认关闭*)—— 当 Claude 因订阅用量限额而停止("You've hit your limit · resets 3pm")时,Codeman 会解析重置时间,等到限额刷新(外加 2 分钟安全缓冲)后自动关闭限额对话框并发送 `continue`,让通宵任务平稳跨过 5 小时窗口而不是停摆到早晨。可识别 Claude Code 各版本的全部限额消息格式;若仍受限会自动重试;计划在 Codeman 重启后依然生效;暂停期间会阻止重生循环,避免 `/clear` 清掉等待中的对话。在会话 Respawn 标签页顶部按会话启用
|
||||||
- **熔断器** —— 当 Claude 卡住时防止重生抖动(CLOSED → HALF_OPEN → OPEN 状态,跟踪连续无进展与重复错误)
|
- **熔断器** —— 当 Claude 卡住时防止重生抖动(CLOSED → HALF_OPEN → OPEN 状态,跟踪连续无进展与重复错误)
|
||||||
- **健康评分** —— 0–100 健康分,分项涵盖循环成功率、熔断器状态、迭代进展与卡死恢复
|
- **健康评分** —— 0–100 健康分,分项涵盖循环成功率、熔断器状态、迭代进展与卡死恢复
|
||||||
- **内置预设** —— `solo-work`(3s 空闲,60min)、`subagent-workflow`(45s,240min)、`team-lead`(90s,480min)、`ralph-todo`(8s,480min)、`overnight-autonomous`(10s,480min)
|
- **内置预设** —— `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:
|
`onRequest` hook) runs in this order:
|
||||||
|
|
||||||
1. **Localhost‑only exemptions** (always first): `POST /api/hook-event` and the QR
|
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
|
2. **Session cookie** check — a valid `codeman_session` cookie short‑circuits to
|
||||||
allow.
|
allow.
|
||||||
3. **HTTP Basic** check — correct credentials short‑circuit to allow and clear
|
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
|
with `req.ip = 127.0.0.1`**. The localhost‑only exemptions then treat those
|
||||||
requests as local:
|
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
|
`HookEventSchema`‑validated and requires a valid in‑memory `sessionId`; it can
|
||||||
drive respawn signals, SSE broadcasts, push notifications, and transcript
|
drive respawn signals, SSE broadcasts, push notifications, and transcript
|
||||||
watching — **not** arbitrary terminal input or file reads. It is a
|
watching — **not** arbitrary terminal input or file reads. ⚠️ The gate keys off
|
||||||
session‑disruption / notification‑spoofing surface, not RCE.
|
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
|
- QR `/q/` — still protected by its own short‑code brute‑force limiter
|
||||||
(10 failures / 60s against a 62⁶ space).
|
(10 failures / 60s against a 62⁶ space).
|
||||||
|
|
||||||
**Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is
|
**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
|
up — it gates everything except the (secret‑gated) hook exemption and is the
|
||||||
is the documented practice). Prefer `tailscale serve` (below), which authenticates
|
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.
|
at the tailnet layer so untrusted clients never reach the loopback port at all.
|
||||||
|
|
||||||
### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense)
|
### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense)
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.9.13",
|
"version": "0.9.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.9.13",
|
"version": "0.9.14",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"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",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"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
|
* Generates `.claude/settings.local.json` with hook definitions that POST
|
||||||
* to Codeman's `/api/hook-event` endpoint when Claude Code fires hooks.
|
* 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
|
* Uses `$CODEMAN_API_URL`, `$CODEMAN_SESSION_ID`, and `$CODEMAN_HOOK_SECRET_FILE`
|
||||||
* managed session) so the config is static per case directory.
|
* env vars (set on every managed session) so the config is static per case
|
||||||
|
* directory and free of secret values.
|
||||||
*
|
*
|
||||||
* Key exports:
|
* Key exports:
|
||||||
* - `generateHooksConfig()` — returns hooks object for settings.local.json
|
* - `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[]> } {
|
export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||||
// Read Claude Code's stdin JSON and forward it as the data field.
|
// Read Claude Code's stdin JSON and forward it as the data field.
|
||||||
// Falls back to empty object if stdin is unavailable or malformed.
|
// 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) =>
|
const curlCmd = (event: HookEventType) =>
|
||||||
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
|
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
|
||||||
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
|
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
|
||||||
`curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` +
|
`curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` +
|
||||||
`-H 'Content-Type: application/json' ` +
|
`-H 'Content-Type: application/json' ` +
|
||||||
|
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
||||||
`--data @- ` +
|
`--data @- ` +
|
||||||
`2>/dev/null || true`;
|
`2>/dev/null || true`;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import type { ClaudeMode, EffortLevel } from './types.js';
|
import type { ClaudeMode, EffortLevel } from './types.js';
|
||||||
import { isEffortLevel } from './types.js';
|
import { isEffortLevel } from './types.js';
|
||||||
import { getAugmentedPath } from './utils/index.js';
|
import { getAugmentedPath } from './utils/index.js';
|
||||||
|
import { dataPath } from './config/instance.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build Claude CLI permission flags based on the configured mode.
|
* 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_MUX: '1',
|
||||||
CODEMAN_SESSION_ID: sessionId,
|
CODEMAN_SESSION_ID: sessionId,
|
||||||
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
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_MUX: '1',
|
||||||
CODEMAN_SESSION_ID: sessionId,
|
CODEMAN_SESSION_ID: sessionId,
|
||||||
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
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 _messages: ClaudeMessage[] = [];
|
||||||
private _lineBuffer: string = '';
|
private _lineBuffer: string = '';
|
||||||
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
|
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 resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
||||||
private rejectPromise: ((reason: Error) => void) | null = null;
|
private rejectPromise: ((reason: Error) => void) | null = null;
|
||||||
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
||||||
@@ -1091,6 +1094,47 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _handleTerminalOutput(data: string): void {
|
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
|
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||||
this._terminalBuffer.append(data);
|
this._terminalBuffer.append(data);
|
||||||
this._lastActivityAt = Date.now();
|
this._lastActivityAt = Date.now();
|
||||||
@@ -1711,6 +1755,7 @@ export class Session extends EventEmitter {
|
|||||||
this._errorBuffer = '';
|
this._errorBuffer = '';
|
||||||
this._messages = [];
|
this._messages = [];
|
||||||
this._lineBuffer = '';
|
this._lineBuffer = '';
|
||||||
|
this._codexSeqCarry = '';
|
||||||
this._lastActivityAt = Date.now();
|
this._lastActivityAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -857,6 +857,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
`export CODEMAN_SESSION_ID=${sessionId}`,
|
`export CODEMAN_SESSION_ID=${sessionId}`,
|
||||||
`export CODEMAN_MUX_NAME=${muxName}`,
|
`export CODEMAN_MUX_NAME=${muxName}`,
|
||||||
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
|
`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
|
// Only unset CLAUDECODE for Claude sessions
|
||||||
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
|
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
AUTH_FAILURE_MAX,
|
AUTH_FAILURE_MAX,
|
||||||
AUTH_FAILURE_WINDOW_MS,
|
AUTH_FAILURE_WINDOW_MS,
|
||||||
} from '../../config/auth-config.js';
|
} from '../../config/auth-config.js';
|
||||||
|
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
|
||||||
|
|
||||||
// Auth session cookie name
|
// Auth session cookie name
|
||||||
export const AUTH_COOKIE_NAME = 'codeman_session';
|
export const AUTH_COOKIE_NAME = 'codeman_session';
|
||||||
@@ -28,19 +29,32 @@ interface AuthState {
|
|||||||
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
|
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
|
||||||
authFailures: StaleExpirationMap<string, number> | null;
|
authFailures: StaleExpirationMap<string, number> | null;
|
||||||
qrAuthFailures: 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.
|
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
|
||||||
* Only active when CODEMAN_PASSWORD is set.
|
* 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)
|
* @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 = {
|
const state: AuthState = {
|
||||||
authSessions: null,
|
authSessions: null,
|
||||||
authFailures: null,
|
authFailures: null,
|
||||||
qrAuthFailures: null,
|
qrAuthFailures: null,
|
||||||
|
hookSecretFailures: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||||
@@ -67,26 +81,72 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
|||||||
refreshOnGet: false,
|
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 authSessions = state.authSessions;
|
||||||
const authFailures = state.authFailures;
|
const authFailures = state.authFailures;
|
||||||
|
const hookSecretFailures = state.hookSecretFailures;
|
||||||
|
|
||||||
function sendAuthRateLimit(reply: FastifyReply, clientIp: string): void {
|
function sendAuthRateLimit(
|
||||||
const remainingMs = authFailures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
|
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));
|
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
||||||
reply.header('Retry-After', String(retryAfterSeconds));
|
reply.header('Retry-After', String(retryAfterSeconds));
|
||||||
reply.code(429).send('Too Many Requests — try again later');
|
reply.code(429).send('Too Many Requests — try again later');
|
||||||
}
|
}
|
||||||
|
|
||||||
app.addHook('onRequest', (req, reply, done) => {
|
app.addHook('onRequest', (req, reply, done) => {
|
||||||
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
|
// Hook events come from local Claude Code hooks (curl from localhost) — no
|
||||||
// Safe: validated by HookEventSchema, only triggers broadcasts.
|
// Basic-Auth credentials available. Validated downstream by HookEventSchema.
|
||||||
// Security: restrict bypass to localhost only — prevents forged hook events via tunnel/LAN.
|
//
|
||||||
|
// 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') {
|
if (req.url === '/api/hook-event' && req.method === 'POST') {
|
||||||
const ip = req.ip;
|
const ip = req.ip;
|
||||||
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
|
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
||||||
|
if (isLoopback) {
|
||||||
|
if (!getTunnelRunning()) {
|
||||||
|
// Loopback-only: unchanged behavior.
|
||||||
done();
|
done();
|
||||||
return;
|
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
|
// 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());
|
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 {
|
export function isLoopbackBindHost(host: string): boolean {
|
||||||
const normalized = host
|
const normalized = host
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
+17
-12
@@ -604,17 +604,6 @@
|
|||||||
<div class="modal-tab-content" id="respawn-tab">
|
<div class="modal-tab-content" id="respawn-tab">
|
||||||
<!-- Respawn Settings Section -->
|
<!-- Respawn Settings Section -->
|
||||||
<div class="session-respawn-section" id="sessionRespawnSection">
|
<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">
|
<div class="auto-resume-box">
|
||||||
<label class="checkbox-inline">
|
<label class="checkbox-inline">
|
||||||
<input type="checkbox" id="modalAutoResumeEnabled" onchange="app.autoSaveAutoResume()">
|
<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>
|
<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>
|
||||||
|
|
||||||
|
<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">
|
<div class="form-row">
|
||||||
<label>Duration</label>
|
<label>Duration</label>
|
||||||
<div class="duration-presets">
|
<div class="duration-presets">
|
||||||
@@ -660,7 +653,7 @@
|
|||||||
<p class="form-hint" id="presetDescriptionHint"></p>
|
<p class="form-hint" id="presetDescriptionHint"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section-header">Respawn Cycle</div>
|
<div class="form-section-header">Cycle Steps</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>1. Update Prompt</label>
|
<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>
|
<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>
|
<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>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
</div><!-- End respawn-tab -->
|
</div><!-- End respawn-tab -->
|
||||||
|
|
||||||
|
|||||||
+42
-18
@@ -36,6 +36,13 @@ html.mobile-init .file-browser-panel {
|
|||||||
html {
|
html {
|
||||||
touch-action: manipulation;
|
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 ---- */
|
/* ---- 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 {
|
.modal-tabs {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -1215,6 +1228,13 @@ html.mobile-init .file-browser-panel {
|
|||||||
max-height: 35vh;
|
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 */
|
/* Full-screen modals on phones */
|
||||||
.modal-content {
|
.modal-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1528,42 +1548,36 @@ html.mobile-init .file-browser-panel {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Duration preset buttons — grid layout, 4 columns for even spacing */
|
/* Duration preset buttons — one compact row */
|
||||||
.duration-presets {
|
.duration-presets {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
flex-wrap: wrap;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duration-preset-btn {
|
.duration-preset-btn {
|
||||||
min-height: 32px;
|
min-height: 24px;
|
||||||
padding: 0.2rem 0.25rem;
|
padding: 0.1rem 0.4rem;
|
||||||
font-size: 0.65rem;
|
font-size: 0.6rem;
|
||||||
border-radius: 5px;
|
border-radius: 4px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom duration — spans full row below */
|
|
||||||
.duration-custom {
|
.duration-custom {
|
||||||
grid-column: 1 / -1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.2rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duration-custom .duration-preset-btn {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
min-width: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration-custom-input.visible {
|
.duration-custom-input.visible {
|
||||||
flex: 1;
|
flex: 0 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duration-custom-input input {
|
.duration-custom-input input {
|
||||||
width: 100%;
|
width: 64px;
|
||||||
min-height: 28px;
|
min-height: 24px;
|
||||||
font-size: 16px; /* Prevents iOS zoom */
|
font-size: 16px; /* Prevents iOS zoom */
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Preset selector row — full-width dropdown, buttons below */
|
/* Preset selector row — full-width dropdown, buttons below */
|
||||||
@@ -1622,6 +1636,16 @@ html.mobile-init .file-browser-panel {
|
|||||||
height: 18px;
|
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 */
|
/* Respawn options row — stack if needed */
|
||||||
#sessionOptionsModal .respawn-options-row {
|
#sessionOptionsModal .respawn-options-row {
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -844,11 +844,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const newEnabled = !isActive;
|
const newEnabled = !isActive;
|
||||||
await fetch('/api/settings', {
|
const res = await fetch('/api/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ tunnelEnabled: newEnabled }),
|
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) {
|
if (newEnabled) {
|
||||||
this._showTunnelConnecting();
|
this._showTunnelConnecting();
|
||||||
// Poll tunnel status as fallback in case SSE event is missed
|
// 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`;
|
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) {
|
async _tunnelPanelToggle(enable) {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/settings', {
|
const res = await fetch('/api/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ tunnelEnabled: enable }),
|
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) {
|
if (enable) {
|
||||||
this._updateTunnelIndicator(false);
|
this._updateTunnelIndicator(false);
|
||||||
const indicator = document.getElementById('tunnelIndicator');
|
const indicator = document.getElementById('tunnelIndicator');
|
||||||
@@ -1473,7 +1507,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
|
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
|
||||||
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings;
|
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings;
|
||||||
try {
|
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
|
// Save model configuration separately
|
||||||
await this.saveModelConfigFromSettings();
|
await this.saveModelConfigFromSettings();
|
||||||
|
|||||||
@@ -4645,15 +4645,16 @@ body.solo-mode .btn-lifecycle-log {
|
|||||||
width: 60px;
|
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 {
|
.respawn-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
padding-bottom: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-top: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.respawn-header .respawn-actions {
|
.respawn-header .respawn-actions {
|
||||||
@@ -4692,6 +4693,39 @@ body.solo-mode .btn-lifecycle-log {
|
|||||||
margin-top: 0;
|
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 {
|
.auto-resume-status {
|
||||||
display: none;
|
display: none;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
(function (global) {
|
(function (global) {
|
||||||
const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/;
|
const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/;
|
||||||
const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/;
|
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) {
|
function isTerminalQueryResponse(data) {
|
||||||
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
||||||
@@ -27,6 +31,7 @@
|
|||||||
global.CodemanTerminalInput = {
|
global.CodemanTerminalInput = {
|
||||||
isTerminalQueryResponse,
|
isTerminalQueryResponse,
|
||||||
shouldSuppressTerminalQueryResponse,
|
shouldSuppressTerminalQueryResponse,
|
||||||
|
USER_SCROLL_STICKY_SUPPRESS_MS,
|
||||||
};
|
};
|
||||||
})(window);
|
})(window);
|
||||||
|
|
||||||
@@ -302,6 +307,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
(ev) => {
|
(ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
|
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
|
||||||
|
this._noteTerminalUserScroll(lines);
|
||||||
this.terminal.scrollLines(lines);
|
this.terminal.scrollLines(lines);
|
||||||
},
|
},
|
||||||
{ passive: false }
|
{ passive: false }
|
||||||
@@ -376,6 +382,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const ch = cellHeight();
|
const ch = cellHeight();
|
||||||
const lines = Math.trunc(pixelAccum / ch);
|
const lines = Math.trunc(pixelAccum / ch);
|
||||||
if (lines !== 0) {
|
if (lines !== 0) {
|
||||||
|
this._noteTerminalUserScroll(lines);
|
||||||
this.terminal.scrollLines(lines);
|
this.terminal.scrollLines(lines);
|
||||||
pixelAccum -= lines * ch;
|
pixelAccum -= lines * ch;
|
||||||
}
|
}
|
||||||
@@ -428,6 +435,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._chunkedWriteGen = 0;
|
this._chunkedWriteGen = 0;
|
||||||
this._bufferLoadSeq = 0;
|
this._bufferLoadSeq = 0;
|
||||||
this._bufferLoadOwner = null;
|
this._bufferLoadOwner = null;
|
||||||
|
this._lastUserScrollUpAt = null;
|
||||||
|
|
||||||
// Handle resize with throttling for performance
|
// Handle resize with throttling for performance
|
||||||
this._resizeTimeout = null;
|
this._resizeTimeout = null;
|
||||||
@@ -1364,6 +1372,22 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return buffer.viewportY >= buffer.baseY - 2;
|
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) {
|
batchTerminalWrite(data) {
|
||||||
// If a buffer load (chunkedTerminalWrite) is in progress, queue live events
|
// If a buffer load (chunkedTerminalWrite) is in progress, queue live events
|
||||||
// to prevent interleaving historical buffer data with live SSE data.
|
// 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.
|
// Per-frame byte budget to prevent main thread blocking.
|
||||||
// Large writes (141KB+) can freeze Chrome for 2+ minutes.
|
// 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;
|
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) {
|
if (_joinedLen <= MAX_FRAME_BYTES) {
|
||||||
this.terminal.write(joined);
|
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 bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
|
||||||
const _dt = performance.now() - _t0;
|
const _dt = performance.now() - _t0;
|
||||||
if (_dt > 100 || deferred)
|
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)`
|
`[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
|
// Sticky scroll: if user was at bottom, keep them there after new output.
|
||||||
if (this._wasAtBottomBeforeWrite) {
|
// 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();
|
this.terminal.scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,31 @@ const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
|
|||||||
const CTRL_L_PATTERN = /\x0c/g;
|
const CTRL_L_PATTERN = /\x0c/g;
|
||||||
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
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.
|
* 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
|
* 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
|
// During long thinking phases, Ink rewrites the same rows thousands of times
|
||||||
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
||||||
// the terminal appears empty when switching tabs.
|
// 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) {
|
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
|
||||||
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
|
// 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 { randomBytes } from 'node:crypto';
|
||||||
import { dataPath } from '../../config/instance.js';
|
import { dataPath } from '../../config/instance.js';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
||||||
|
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
|
||||||
import {
|
import {
|
||||||
ConfigUpdateSchema,
|
ConfigUpdateSchema,
|
||||||
SettingsUpdateSchema,
|
SettingsUpdateSchema,
|
||||||
@@ -498,6 +499,26 @@ export function registerSystemRoutes(
|
|||||||
app.put('/api/settings', async (req) => {
|
app.put('/api/settings', async (req) => {
|
||||||
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
|
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 {
|
try {
|
||||||
const dir = dirname(SETTINGS_PATH);
|
const dir = dirname(SETTINGS_PATH);
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
|
|||||||
+12
-1
@@ -41,6 +41,7 @@ import fs from 'node:fs/promises';
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { hostname as getHostname } from 'node:os';
|
import { hostname as getHostname } from 'node:os';
|
||||||
import { dataPath } from '../config/instance.js';
|
import { dataPath } from '../config/instance.js';
|
||||||
|
import { getHookSecret } from '../config/hook-secret.js';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
|
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
|
||||||
import type { ClaudeMode, SessionState } from '../types.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 authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null;
|
||||||
private authFailures: StaleExpirationMap<string, number> | null = null;
|
private authFailures: StaleExpirationMap<string, number> | null = null;
|
||||||
private qrAuthFailures: 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 pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||||
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||||
@@ -603,11 +605,12 @@ export class WebServer extends EventEmitter {
|
|||||||
registerHostGuard(this.app, () => this.getHostPolicy());
|
registerHostGuard(this.app, () => this.getHostPolicy());
|
||||||
|
|
||||||
// Auth middleware (Basic Auth + session cookies + rate limiting)
|
// 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) {
|
if (authState) {
|
||||||
this.authSessions = authState.authSessions;
|
this.authSessions = authState.authSessions;
|
||||||
this.authFailures = authState.authFailures;
|
this.authFailures = authState.authFailures;
|
||||||
this.qrAuthFailures = authState.qrAuthFailures;
|
this.qrAuthFailures = authState.qrAuthFailures;
|
||||||
|
this.hookSecretFailures = authState.hookSecretFailures;
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket support (terminal I/O — low-latency bidirectional channel)
|
// 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;
|
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}`;
|
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
|
// Start scheduled runs cleanup timer
|
||||||
this.cleanup.setInterval(
|
this.cleanup.setInterval(
|
||||||
() => {
|
() => {
|
||||||
@@ -2298,6 +2305,10 @@ export class WebServer extends EventEmitter {
|
|||||||
this.qrAuthFailures.dispose();
|
this.qrAuthFailures.dispose();
|
||||||
this.qrAuthFailures = null;
|
this.qrAuthFailures = null;
|
||||||
}
|
}
|
||||||
|
if (this.hookSecretFailures) {
|
||||||
|
this.hookSecretFailures.dispose();
|
||||||
|
this.hookSecretFailures = null;
|
||||||
|
}
|
||||||
this.activePlanOrchestrators.clear();
|
this.activePlanOrchestrators.clear();
|
||||||
this.cleaningUp.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