mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 05:29:42 +02:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eda95adaa9 | ||
|
|
3f2c23cb0f | ||
|
|
f7ce8e4767 | ||
|
|
f1c64994ad | ||
|
|
12c8e080c1 | ||
|
|
9893a7f64a | ||
|
|
5b2da424a1 | ||
|
|
aa84447899 | ||
|
|
a0e1a2e33b | ||
|
|
6da22f0db0 | ||
|
|
dc9c4b3bda | ||
|
|
1cf5c8c8ad | ||
|
|
7eda39e7f7 | ||
|
|
f0db5f827f | ||
|
|
aa4e1ce9cf | ||
|
|
b8cb4670dd | ||
|
|
fe821fb679 | ||
|
|
d7606366a2 | ||
|
|
42f0b28c75 |
@@ -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 |
|
||||||
|
|||||||
@@ -5,11 +5,7 @@
|
|||||||
<h2 align="center">Mission control for AI coding agents</h2>
|
<h2 align="center">Mission control for AI coding agents</h2>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<em>Claude Code • OpenCode • Codex — One Dashboard • Any Device</em>
|
<em>Claude Code • OpenCode • Codex • Terminal - One Dashboard • Any Device</em>
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<strong>English</strong> • <a href="README.zh-CN.md">简体中文</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -20,6 +16,10 @@
|
|||||||
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
|
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>English</strong> • <a href="README.zh-CN.md">简体中文</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/subagent-demo.gif" alt="Codeman — parallel subagent visualization" width="900">
|
<img src="docs/images/subagent-demo.gif" alt="Codeman — parallel subagent visualization" width="900">
|
||||||
</p>
|
</p>
|
||||||
@@ -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)
|
||||||
@@ -304,7 +320,37 @@ injected from API JSON (`innerHTML`), not via `file-raw`, so they are unaffected
|
|||||||
`/api/download` additionally refuses a blocklist of sensitive paths
|
`/api/download` additionally refuses a blocklist of sensitive paths
|
||||||
(`/etc/shadow`, `~/.ssh/`, `.env`, `*credentials*`, `.aws/credentials`, …). This
|
(`/etc/shadow`, `~/.ssh/`, `.env`, `*credentials*`, `.aws/credentials`, …). This
|
||||||
is **defense‑in‑depth, not the primary boundary** — the realpath containment is
|
is **defense‑in‑depth, not the primary boundary** — the realpath containment is
|
||||||
the control.
|
the control. The blocklist patterns are shared (`src/web/sensitive-path.ts`) with
|
||||||
|
the attachment guard below.
|
||||||
|
|
||||||
|
### External attachments (registry) & the magic‑link trust boundary
|
||||||
|
|
||||||
|
Live external attachments (`src/attachment-registry.ts`) mint an `att_<uuid>` id
|
||||||
|
for a host file so browser requests carry the id, never an absolute path. Serving
|
||||||
|
is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, 50 MB cap,
|
||||||
|
`nosniff`) and re‑resolves the symlink + re‑checks the **attachment guard**
|
||||||
|
(`src/config/attachment-guard.ts`: the shared sensitive‑path blocklist **plus**
|
||||||
|
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
|
||||||
|
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
|
||||||
|
routes, attachments are intentionally **cross‑workspace** — so the effective gate
|
||||||
|
is the blocklist + a 6‑extension allowlist (`png/pdf/docx/pptx/md/txt`), not
|
||||||
|
realpath containment.
|
||||||
|
|
||||||
|
Two registration paths, with **different trust**:
|
||||||
|
|
||||||
|
- **Explicit `POST /api/sessions/:id/attachments`** (and `codeman attach`, which
|
||||||
|
POSTs directly inside a managed session) — a deliberate, Origin‑guarded HTTP
|
||||||
|
request. Allowed cross‑workspace (subject to the guard). This is the supported
|
||||||
|
path for codeman‑publish and the `~/.codeman` review‑card loop.
|
||||||
|
- **Terminal `codeman://attach?path=…` magic links** — scanned passively from
|
||||||
|
session output. Terminal output is **attacker‑influenceable** (a prompt‑injected
|
||||||
|
session can print an arbitrary path), and registration here is server‑side with
|
||||||
|
no Origin gate and broadcasts the `rawUrl` over SSE to all clients. This path is
|
||||||
|
therefore **force‑confined to the session workspace** (`forceWorkspaceConfinement`
|
||||||
|
in `registerExternalAttachment`, wired in `WebServer.registerAttachment`),
|
||||||
|
regardless of the global confine setting — a passive magic link cannot expose a
|
||||||
|
file outside the session's own workspace. Cross‑workspace attach must go through
|
||||||
|
the explicit POST path above.
|
||||||
|
|
||||||
### SSE log‑tail route — intentional extra read roots
|
### SSE log‑tail route — intentional extra read roots
|
||||||
|
|
||||||
|
|||||||
Generated
+9
-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": [
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"@fastify/static": "^9.1.3",
|
"@fastify/static": "^9.1.3",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-serialize": "^0.14.0",
|
||||||
"@xterm/addon-unicode11": "^0.9.0",
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
@@ -4525,6 +4526,12 @@
|
|||||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-serialize": {
|
||||||
|
"version": "0.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
|
||||||
|
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@xterm/addon-unicode11": {
|
"node_modules/@xterm/addon-unicode11": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
|
||||||
|
|||||||
+2
-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",
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
"@fastify/static": "^9.1.3",
|
"@fastify/static": "^9.1.3",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-serialize": "^0.14.0",
|
||||||
"@xterm/addon-unicode11": "^0.9.0",
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ run('copy template', 'cp src/templates/case-template.md dist/templates/');
|
|||||||
run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/');
|
run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/');
|
||||||
run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
|
run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
|
||||||
run('xterm-addon-fit', 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
|
run('xterm-addon-fit', 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
|
||||||
|
run('xterm-addon-serialize', 'npx esbuild node_modules/@xterm/addon-serialize/lib/addon-serialize.js --minify --outfile=dist/web/public/vendor/xterm-addon-serialize.min.js');
|
||||||
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
|
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
|
||||||
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
|
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
|
||||||
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
|
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ if (isGlobalInstall) {
|
|||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const xtermDir = join(require.resolve('@xterm/xterm'), '..', '..');
|
const xtermDir = join(require.resolve('@xterm/xterm'), '..', '..');
|
||||||
const fitDir = join(require.resolve('@xterm/addon-fit'), '..', '..');
|
const fitDir = join(require.resolve('@xterm/addon-fit'), '..', '..');
|
||||||
|
const serializeDir = join(require.resolve('@xterm/addon-serialize'), '..', '..');
|
||||||
const webglDir = join(require.resolve('@xterm/addon-webgl'), '..', '..');
|
const webglDir = join(require.resolve('@xterm/addon-webgl'), '..', '..');
|
||||||
const unicode11Dir = join(require.resolve('@xterm/addon-unicode11'), '..', '..');
|
const unicode11Dir = join(require.resolve('@xterm/addon-unicode11'), '..', '..');
|
||||||
const vendorDir = join(srcDir, 'web', 'public', 'vendor');
|
const vendorDir = join(srcDir, 'web', 'public', 'vendor');
|
||||||
@@ -264,12 +265,14 @@ if (isGlobalInstall) {
|
|||||||
try {
|
try {
|
||||||
execSync(`npx esbuild "${join(xtermDir, 'lib', 'xterm.js')}" --minify --outfile="${join(vendorDir, 'xterm.min.js')}"`, { stdio: 'pipe' });
|
execSync(`npx esbuild "${join(xtermDir, 'lib', 'xterm.js')}" --minify --outfile="${join(vendorDir, 'xterm.min.js')}"`, { stdio: 'pipe' });
|
||||||
execSync(`npx esbuild "${join(fitDir, 'lib', 'addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
|
execSync(`npx esbuild "${join(fitDir, 'lib', 'addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
|
||||||
|
execSync(`npx esbuild "${join(serializeDir, 'lib', 'addon-serialize.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-serialize.min.js')}"`, { stdio: 'pipe' });
|
||||||
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
|
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
|
||||||
console.log(colors.green('✓ xterm vendor files copied to src/web/public/vendor/'));
|
console.log(colors.green('✓ xterm vendor files copied to src/web/public/vendor/'));
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback: copy unminified
|
// Fallback: copy unminified
|
||||||
copyFileSync(join(xtermDir, 'lib', 'xterm.js'), join(vendorDir, 'xterm.min.js'));
|
copyFileSync(join(xtermDir, 'lib', 'xterm.js'), join(vendorDir, 'xterm.min.js'));
|
||||||
copyFileSync(join(fitDir, 'lib', 'addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
|
copyFileSync(join(fitDir, 'lib', 'addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
|
||||||
|
copyFileSync(join(serializeDir, 'lib', 'addon-serialize.js'), join(vendorDir, 'xterm-addon-serialize.min.js'));
|
||||||
copyFileSync(join(unicode11Dir, 'lib', 'addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
|
copyFileSync(join(unicode11Dir, 'lib', 'addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
|
||||||
console.log(colors.green('✓ xterm vendor files copied') + colors.dim(' (unminified — esbuild not available)'));
|
console.log(colors.green('✓ xterm vendor files copied') + colors.dim(' (unminified — esbuild not available)'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Parses terminal magic links that request attachment cards.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isAbsolute } from 'node:path';
|
||||||
|
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||||
|
|
||||||
|
const MAGIC_LINK_RE = /codeman:\/\/attach\?([^\s<>"']+)/g;
|
||||||
|
|
||||||
|
export function parseAttachmentMagicLinks(data: string): string[] {
|
||||||
|
const results: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const match of data.matchAll(MAGIC_LINK_RE)) {
|
||||||
|
const query = trimTrailingPunctuation(match[1] || '');
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(query);
|
||||||
|
const filePath = params.get('path');
|
||||||
|
if (!filePath || !isAbsolute(filePath)) continue;
|
||||||
|
const extension = filePath.split('.').pop()?.toLowerCase() || '';
|
||||||
|
if (!isSupportedAttachmentExtension(extension)) continue;
|
||||||
|
if (seen.has(filePath)) continue;
|
||||||
|
seen.add(filePath);
|
||||||
|
results.push(filePath);
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed terminal text. Magic links are advisory.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTrailingPunctuation(value: string): string {
|
||||||
|
return value.replace(/[),.;:]+$/g, '');
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview In-memory attachment registry for live external document references.
|
||||||
|
*
|
||||||
|
* Session-local files keep using the existing workspace-scoped file routes. This
|
||||||
|
* registry is only for explicit, live external attachments that need a stable ID
|
||||||
|
* so browser requests never contain arbitrary absolute paths.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { realpathSync } from 'node:fs';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { basename, extname, isAbsolute } from 'node:path';
|
||||||
|
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from './config/attachment-guard.js';
|
||||||
|
import { validateSessionFilePath } from './web/route-helpers.js';
|
||||||
|
import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js';
|
||||||
|
|
||||||
|
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set(['png', 'pdf', 'docx', 'pptx', 'md', 'txt']);
|
||||||
|
|
||||||
|
export type AttachmentSource = 'detected' | 'external';
|
||||||
|
|
||||||
|
export interface AttachmentRecord {
|
||||||
|
attachmentId: string;
|
||||||
|
sessionId: string;
|
||||||
|
filePath: string;
|
||||||
|
fileName: string;
|
||||||
|
extension: string;
|
||||||
|
attachmentType: AttachmentDetectedType;
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
timestamp: number;
|
||||||
|
source: AttachmentSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentRegistrationResult extends AttachmentDetectedEvent {
|
||||||
|
attachmentId: string;
|
||||||
|
source: AttachmentSource;
|
||||||
|
rawUrl: string;
|
||||||
|
previewUrl: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AttachmentRegistrationError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly statusCode: number = 400
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-session attachment cap. Bounds memory against a client (or a
|
||||||
|
* prompt-injected magic-link flood) registering unbounded distinct paths. */
|
||||||
|
const MAX_ATTACHMENTS_PER_SESSION = 200;
|
||||||
|
|
||||||
|
class AttachmentRegistry {
|
||||||
|
private recordsBySession = new Map<string, Map<string, AttachmentRecord>>();
|
||||||
|
|
||||||
|
register(record: AttachmentRecord): void {
|
||||||
|
let records = this.recordsBySession.get(record.sessionId);
|
||||||
|
if (!records) {
|
||||||
|
records = new Map();
|
||||||
|
this.recordsBySession.set(record.sessionId, records);
|
||||||
|
}
|
||||||
|
records.set(record.attachmentId, record);
|
||||||
|
// Evict oldest (insertion-order) entries beyond the cap.
|
||||||
|
while (records.size > MAX_ATTACHMENTS_PER_SESSION) {
|
||||||
|
const oldest = records.keys().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
records.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(sessionId: string, attachmentId: string): AttachmentRecord | undefined {
|
||||||
|
return this.recordsBySession.get(sessionId)?.get(attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
findByFilePath(sessionId: string, filePath: string): AttachmentRecord | undefined {
|
||||||
|
const records = this.recordsBySession.get(sessionId);
|
||||||
|
if (!records) return undefined;
|
||||||
|
for (const record of records.values()) {
|
||||||
|
if (record.filePath === filePath) return record;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSession(sessionId: string): void {
|
||||||
|
this.recordsBySession.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const attachmentRegistry = new AttachmentRegistry();
|
||||||
|
|
||||||
|
export function isSupportedAttachmentExtension(extension: string): boolean {
|
||||||
|
return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentType(extension: string): AttachmentDetectedType {
|
||||||
|
const normalized = extension.toLowerCase().replace(/^\./, '');
|
||||||
|
if (normalized === 'png') return 'image';
|
||||||
|
if (normalized === 'pdf') return 'pdf';
|
||||||
|
if (normalized === 'pptx') return 'presentation';
|
||||||
|
if (normalized === 'md') return 'markdown';
|
||||||
|
if (normalized === 'txt') return 'text';
|
||||||
|
return 'document';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAttachmentRoutes(
|
||||||
|
sessionId: string,
|
||||||
|
attachmentId: string
|
||||||
|
): {
|
||||||
|
rawUrl: string;
|
||||||
|
previewUrl: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
} {
|
||||||
|
const encodedId = encodeURIComponent(attachmentId);
|
||||||
|
return {
|
||||||
|
rawUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/raw`,
|
||||||
|
previewUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/preview`,
|
||||||
|
thumbnailUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/thumbnail`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFileThumbnailRoute(sessionId: string, relativePath: string): string {
|
||||||
|
return `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(relativePath)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachmentRecordToEvent(record: AttachmentRecord): AttachmentRegistrationResult {
|
||||||
|
const routes = buildAttachmentRoutes(record.sessionId, record.attachmentId);
|
||||||
|
return {
|
||||||
|
sessionId: record.sessionId,
|
||||||
|
filePath: record.fileName,
|
||||||
|
relativePath: '',
|
||||||
|
fileName: record.fileName,
|
||||||
|
extension: record.extension,
|
||||||
|
attachmentType: record.attachmentType,
|
||||||
|
timestamp: record.timestamp,
|
||||||
|
size: record.size,
|
||||||
|
attachmentId: record.attachmentId,
|
||||||
|
source: record.source,
|
||||||
|
...routes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for {@link registerExternalAttachment}. */
|
||||||
|
export interface RegisterExternalAttachmentOptions {
|
||||||
|
/**
|
||||||
|
* The registering session's working directory. Required to enforce workspace
|
||||||
|
* confinement — either when the global mode is enabled
|
||||||
|
* (`attachmentConfineToWorkspace` / `CODEMAN_ATTACHMENT_CONFINE`) or when
|
||||||
|
* {@link forceWorkspaceConfinement} is set for this call.
|
||||||
|
*/
|
||||||
|
sessionWorkingDir?: string;
|
||||||
|
/**
|
||||||
|
* Force workspace confinement for THIS registration regardless of the global
|
||||||
|
* setting. Used by the terminal-output `codeman://attach` magic-link scanner:
|
||||||
|
* terminal output is attacker-influenceable (a prompt-injected session can
|
||||||
|
* print an arbitrary path), so passive magic links may only reference files
|
||||||
|
* inside the session workspace. Deliberate cross-workspace attachment still
|
||||||
|
* works through the explicit, Origin-guarded `POST /attachments` route and the
|
||||||
|
* `codeman attach` CLI (which POSTs directly when a session id is known).
|
||||||
|
*/
|
||||||
|
forceWorkspaceConfinement?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerExternalAttachment(
|
||||||
|
sessionId: string,
|
||||||
|
requestedPath: string,
|
||||||
|
options: RegisterExternalAttachmentOptions = {}
|
||||||
|
): Promise<AttachmentRegistrationResult> {
|
||||||
|
if (!requestedPath || !isAbsolute(requestedPath)) {
|
||||||
|
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedPath: string;
|
||||||
|
try {
|
||||||
|
resolvedPath = realpathSync(requestedPath);
|
||||||
|
} catch {
|
||||||
|
throw new AttachmentRegistrationError('Attachment file not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
|
||||||
|
// path before doing anything else.
|
||||||
|
const guard = await loadAttachmentGuardConfig();
|
||||||
|
|
||||||
|
if (guard.confineToWorkspace || options.forceWorkspaceConfinement) {
|
||||||
|
// Workspace-confined: the file MUST resolve inside the session's workspace.
|
||||||
|
// Applies when the global strict mode is on (opt-in, default OFF) OR when
|
||||||
|
// the caller forces it for this registration (the magic-link scanner — see
|
||||||
|
// forceWorkspaceConfinement). Strictly more restrictive than the blocklist.
|
||||||
|
const workingDir = options.sessionWorkingDir;
|
||||||
|
if (!workingDir || !validateSessionFilePath(workingDir, resolvedPath)) {
|
||||||
|
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocklist (DEFAULT, also applied alongside confinement as defense in
|
||||||
|
// depth): pre-populated secret locations + the /root and /etc trees + any
|
||||||
|
// operator-configured extra trees. Symlinks are already resolved above.
|
||||||
|
// Cross-workspace attachment of non-blocked files stays allowed, so
|
||||||
|
// codeman-publish and the ~/.codeman review loop keep working.
|
||||||
|
if (isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
|
||||||
|
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = extname(resolvedPath).toLowerCase().replace(/^\./, '');
|
||||||
|
if (!isSupportedAttachmentExtension(extension)) {
|
||||||
|
throw new AttachmentRegistrationError('Unsupported attachment type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await fs.stat(resolvedPath);
|
||||||
|
if (typeof stat.isFile === 'function' && !stat.isFile()) {
|
||||||
|
throw new AttachmentRegistrationError('Attachment path is not a file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = attachmentRegistry.findByFilePath(sessionId, resolvedPath);
|
||||||
|
if (existing) {
|
||||||
|
existing.size = stat.size;
|
||||||
|
existing.mtimeMs = stat.mtimeMs ?? 0;
|
||||||
|
existing.timestamp = Date.now();
|
||||||
|
return attachmentRecordToEvent(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record: AttachmentRecord = {
|
||||||
|
attachmentId: `att_${randomUUID()}`,
|
||||||
|
sessionId,
|
||||||
|
filePath: resolvedPath,
|
||||||
|
fileName: basename(resolvedPath),
|
||||||
|
extension,
|
||||||
|
attachmentType: getAttachmentType(extension),
|
||||||
|
size: stat.size,
|
||||||
|
mtimeMs: stat.mtimeMs ?? 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
source: 'external',
|
||||||
|
};
|
||||||
|
attachmentRegistry.register(record);
|
||||||
|
return attachmentRecordToEvent(record);
|
||||||
|
}
|
||||||
+93
@@ -10,11 +10,17 @@
|
|||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { createRequire } from 'module';
|
import { createRequire } from 'module';
|
||||||
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { isAbsolute } from 'node:path';
|
||||||
|
import { dataPath } from './config/instance.js';
|
||||||
import { getSessionManager } from './session-manager.js';
|
import { getSessionManager } from './session-manager.js';
|
||||||
import { getTaskQueue } from './task-queue.js';
|
import { getTaskQueue } from './task-queue.js';
|
||||||
import { getRalphLoop } from './ralph-loop.js';
|
import { getRalphLoop } from './ralph-loop.js';
|
||||||
import { getStore } from './state-store.js';
|
import { getStore } from './state-store.js';
|
||||||
import { getErrorMessage } from './types.js';
|
import { getErrorMessage } from './types.js';
|
||||||
|
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const pkg = require('../package.json') as { version: string };
|
const pkg = require('../package.json') as { version: string };
|
||||||
@@ -23,6 +29,93 @@ const program = new Command();
|
|||||||
|
|
||||||
program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version);
|
program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version);
|
||||||
|
|
||||||
|
function makeAttachmentMagicLink(filePath: string): string {
|
||||||
|
return `codeman://attach?path=${encodeURIComponent(filePath)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCodemanEnv(): Record<string, string> {
|
||||||
|
const envPath = dataPath('.env');
|
||||||
|
try {
|
||||||
|
const text = readFileSync(envPath, 'utf-8');
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const rawLine of text.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||||
|
if (!match) continue;
|
||||||
|
let value = match[2].trim();
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
result[match[1]] = value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise<boolean> {
|
||||||
|
const envFile = readCodemanEnv();
|
||||||
|
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
|
||||||
|
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
|
||||||
|
const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl);
|
||||||
|
const body = JSON.stringify({ path: filePath });
|
||||||
|
const transport = url.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const headers: Record<string, string | number> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
};
|
||||||
|
if (password) {
|
||||||
|
headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = transport.request(
|
||||||
|
{
|
||||||
|
protocol: url.protocol,
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port,
|
||||||
|
method: 'POST',
|
||||||
|
path: `${url.pathname}${url.search}`,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
res.resume();
|
||||||
|
res.on('end', () => resolve(Boolean(res.statusCode && res.statusCode >= 200 && res.statusCode < 300)));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on('error', () => resolve(false));
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('attach <path>')
|
||||||
|
.description('Show an attachment card for a local file')
|
||||||
|
.option('-s, --session <id>', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)')
|
||||||
|
.option('--url <url>', 'Codeman API URL (defaults to CODEMAN_API_URL or https://127.0.0.1:3000)')
|
||||||
|
.action(async (filePath, options) => {
|
||||||
|
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
||||||
|
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
|
||||||
|
console.error(chalk.red('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = options.session || process.env.CODEMAN_SESSION_ID;
|
||||||
|
const apiUrl = options.url || process.env.CODEMAN_API_URL || 'https://127.0.0.1:3000';
|
||||||
|
if (sessionId && (await postAttachment(apiUrl, sessionId, filePath))) {
|
||||||
|
console.log(chalk.green('✓ Attachment card requested'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(makeAttachmentMagicLink(filePath));
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Session Commands ============
|
// ============ Session Commands ============
|
||||||
|
|
||||||
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Attachment path-guard configuration (COD-53).
|
||||||
|
*
|
||||||
|
* Governs which host files may be registered as cross-workspace attachments
|
||||||
|
* and served to the browser. Two operator-facing knobs, both with safe
|
||||||
|
* defaults:
|
||||||
|
*
|
||||||
|
* 1. **Blocked-path blocklist (DEFAULT, configurable).** Pre-populated with the
|
||||||
|
* shared secret-location blocklist (`isSensitivePath`) PLUS the directory
|
||||||
|
* trees `/root` and `/etc` (anything under them is blocked). The operator
|
||||||
|
* EXTENDS — never shrinks — this set with additional absolute directory
|
||||||
|
* trees via the settings key `attachmentBlockedPaths: string[]` and/or the
|
||||||
|
* env var `CODEMAN_ATTACHMENT_BLOCKED_PATHS` (comma-separated).
|
||||||
|
*
|
||||||
|
* 2. **Workspace confinement (OPTIONAL, default OFF).** When enabled, an
|
||||||
|
* attachment must resolve INSIDE the registering session's workingDir
|
||||||
|
* (reusing `validateSessionFilePath` containment semantics). This is
|
||||||
|
* strictly more restrictive than the blocklist and breaks intentional
|
||||||
|
* cross-workspace attachment (codeman-publish, the ~/.codeman review-card
|
||||||
|
* loop), so it is OFF by default. Toggle via settings
|
||||||
|
* `attachmentConfineToWorkspace: boolean` and/or env
|
||||||
|
* `CODEMAN_ATTACHMENT_CONFINE` (`1`/`true`).
|
||||||
|
*
|
||||||
|
* All paths passed to the predicates here MUST be absolute and symlink-resolved
|
||||||
|
* (realpath) by the caller, mirroring `isSensitivePath`'s contract.
|
||||||
|
*
|
||||||
|
* @module config/attachment-guard
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sep } from 'node:path';
|
||||||
|
import { isSensitivePath } from '../web/sensitive-path.js';
|
||||||
|
import { readJsonConfig, SETTINGS_PATH } from '../web/route-helpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directory trees blocked by default, IN ADDITION to the secret-location
|
||||||
|
* blocklist in `isSensitivePath`. Anything resolving under one of these trees
|
||||||
|
* is rejected. Pre-populated with the root account home and the system config
|
||||||
|
* tree (which already partially overlaps `isSensitivePath`'s `/etc/shadow`
|
||||||
|
* etc., but here we block the WHOLE tree).
|
||||||
|
*/
|
||||||
|
export const DEFAULT_BLOCKED_TREES: readonly string[] = ['/root', '/etc'];
|
||||||
|
|
||||||
|
/** Settings key carrying extra blocked directory trees (extends the defaults). */
|
||||||
|
export const ATTACHMENT_BLOCKED_PATHS_SETTING = 'attachmentBlockedPaths';
|
||||||
|
|
||||||
|
/** Settings key carrying the workspace-confinement toggle. */
|
||||||
|
export const ATTACHMENT_CONFINE_SETTING = 'attachmentConfineToWorkspace';
|
||||||
|
|
||||||
|
/** Resolved attachment-guard configuration. */
|
||||||
|
export interface AttachmentGuardConfig {
|
||||||
|
/** Pre-populated default trees PLUS any operator extras. */
|
||||||
|
blockedTrees: string[];
|
||||||
|
/** Whether attachments must resolve inside the session workspace. */
|
||||||
|
confineToWorkspace: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes a tree prefix: trim, drop trailing separators (but keep root). */
|
||||||
|
function normalizeTree(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
// Strip trailing slashes so '/etc/' and '/etc' behave the same; never reduce
|
||||||
|
// a bare separator to empty.
|
||||||
|
const stripped = trimmed.replace(/[/\\]+$/, '');
|
||||||
|
return stripped || trimmed[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if `absPath` (absolute, symlink-resolved) is the tree itself or
|
||||||
|
* lives under it. Uses path-separator-aware matching so `/etc` does NOT block
|
||||||
|
* an unrelated `/etcetera/notes.md`.
|
||||||
|
*/
|
||||||
|
export function isUnderTree(absPath: string, tree: string): boolean {
|
||||||
|
const t = normalizeTree(tree);
|
||||||
|
if (!t) return false;
|
||||||
|
if (absPath === t) return true;
|
||||||
|
return absPath.startsWith(t.endsWith(sep) ? t : t + sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses the comma-separated env override into a list of normalized trees. */
|
||||||
|
function parseEnvBlockedTrees(): string[] {
|
||||||
|
const raw = process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
|
||||||
|
if (!raw) return [];
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map(normalizeTree)
|
||||||
|
.filter((t) => t.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses the env confinement toggle (`1`/`true`/`yes`/`on`, case-insensitive). */
|
||||||
|
function parseEnvConfine(): boolean | undefined {
|
||||||
|
const raw = process.env.CODEMAN_ATTACHMENT_CONFINE;
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
return /^(1|true|yes|on)$/i.test(raw.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the effective attachment-guard config by merging the pre-populated
|
||||||
|
* defaults with settings.json and env overrides. Env wins over settings for the
|
||||||
|
* confinement toggle; blocked-tree extras from BOTH sources are unioned on top
|
||||||
|
* of the defaults (operators can only EXTEND, never shrink, the blocked set).
|
||||||
|
*/
|
||||||
|
export async function loadAttachmentGuardConfig(): Promise<AttachmentGuardConfig> {
|
||||||
|
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||||
|
|
||||||
|
const settingsTrees = Array.isArray(settings[ATTACHMENT_BLOCKED_PATHS_SETTING])
|
||||||
|
? (settings[ATTACHMENT_BLOCKED_PATHS_SETTING] as unknown[])
|
||||||
|
.filter((v): v is string => typeof v === 'string')
|
||||||
|
.map(normalizeTree)
|
||||||
|
.filter((t) => t.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const blockedTrees = Array.from(new Set([...DEFAULT_BLOCKED_TREES, ...settingsTrees, ...parseEnvBlockedTrees()]));
|
||||||
|
|
||||||
|
const envConfine = parseEnvConfine();
|
||||||
|
const settingsConfine = settings[ATTACHMENT_CONFINE_SETTING] === true;
|
||||||
|
const confineToWorkspace = envConfine ?? settingsConfine;
|
||||||
|
|
||||||
|
return { blockedTrees, confineToWorkspace };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attachment-specific blocklist check. Builds on the shared `isSensitivePath`
|
||||||
|
* base (secret locations, shared with `/api/download`) and ADDS the configured
|
||||||
|
* directory trees (`/root`, `/etc`, plus operator extras). `absPath` must be
|
||||||
|
* absolute and symlink-resolved.
|
||||||
|
*
|
||||||
|
* NOTE: this is intentionally a SUPERSET of `isSensitivePath` so `/api/download`
|
||||||
|
* behavior is NOT changed — only attachment registration/serving uses this.
|
||||||
|
*/
|
||||||
|
export function isBlockedAttachmentPath(absPath: string, blockedTrees: readonly string[]): boolean {
|
||||||
|
if (isSensitivePath(absPath)) return true;
|
||||||
|
return blockedTrees.some((tree) => isUnderTree(absPath, tree));
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
|
||||||
|
|||||||
+55
-8
@@ -12,7 +12,7 @@ import { EventEmitter } from 'node:events';
|
|||||||
import { watch, type FSWatcher } from 'chokidar';
|
import { watch, type FSWatcher } from 'chokidar';
|
||||||
import { basename, extname, relative } from 'node:path';
|
import { basename, extname, relative } from 'node:path';
|
||||||
import { statSync } from 'node:fs';
|
import { statSync } from 'node:fs';
|
||||||
import type { ImageDetectedEvent } from './types.js';
|
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
|
||||||
import { KeyedDebouncer } from './utils/index.js';
|
import { KeyedDebouncer } from './utils/index.js';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
@@ -20,7 +20,13 @@ import { KeyedDebouncer } from './utils/index.js';
|
|||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
/** Supported image file extensions (lowercase) */
|
/** Supported image file extensions (lowercase) */
|
||||||
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
// PNG stays on the image-popup path: it's the dominant screenshot format and the
|
||||||
|
// frontend only wires the `image:detected` popup today. The attachment-card UI
|
||||||
|
// that would consume `attachment:detected` for images is out of scope for this
|
||||||
|
// PR, so routing PNG to it would silently break the dropped-screenshot popup.
|
||||||
|
const IMAGE_POPUP_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
||||||
|
const ATTACHMENT_EXTENSIONS = new Set(['.pdf', '.docx', '.pptx']);
|
||||||
|
const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]);
|
||||||
|
|
||||||
/** Time to wait for file writes to stabilize (ms) */
|
/** Time to wait for file writes to stabilize (ms) */
|
||||||
const STABILITY_THRESHOLD_MS = 500;
|
const STABILITY_THRESHOLD_MS = 500;
|
||||||
@@ -166,8 +172,8 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
}
|
}
|
||||||
const ext = extname(path).toLowerCase();
|
const ext = extname(path).toLowerCase();
|
||||||
// Don't ignore directories (needed for watching to work)
|
// Don't ignore directories (needed for watching to work)
|
||||||
// Ignore files that aren't images
|
// Ignore files that aren't previewable images/documents
|
||||||
return ext !== '' && !IMAGE_EXTENSIONS.has(ext);
|
return ext !== '' && !DETECTED_FILE_EXTENSIONS.has(ext);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,15 +235,16 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a new file being detected.
|
* Handle a new file being detected.
|
||||||
* Verifies it's an image and emits the detection event.
|
* Verifies it's a previewable image/document and emits the detection event.
|
||||||
*/
|
*/
|
||||||
private handleNewFile(sessionId: string, filePath: string): void {
|
private handleNewFile(sessionId: string, filePath: string): void {
|
||||||
const ext = extname(filePath).toLowerCase();
|
const ext = extname(filePath).toLowerCase();
|
||||||
|
|
||||||
// Double-check it's an image extension
|
// Double-check it's a supported extension
|
||||||
if (!IMAGE_EXTENSIONS.has(ext)) {
|
if (!DETECTED_FILE_EXTENSIONS.has(ext)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const isAttachment = ATTACHMENT_EXTENSIONS.has(ext);
|
||||||
|
|
||||||
// Burst limit: skip if too many images detected for this session in a short window
|
// Burst limit: skip if too many images detected for this session in a short window
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -259,7 +266,11 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
// Debounce rapid file creation (e.g., multiple screenshots quickly)
|
// Debounce rapid file creation (e.g., multiple screenshots quickly)
|
||||||
this.fileDeb.schedule(filePath, () => {
|
this.fileDeb.schedule(filePath, () => {
|
||||||
this.fileToSession.delete(filePath);
|
this.fileToSession.delete(filePath);
|
||||||
this.emitImageDetected(sessionId, filePath);
|
if (isAttachment) {
|
||||||
|
this.emitAttachmentDetected(sessionId, filePath);
|
||||||
|
} else {
|
||||||
|
this.emitImageDetected(sessionId, filePath);
|
||||||
|
}
|
||||||
// Increment burst count on actual emission (not on detection)
|
// Increment burst count on actual emission (not on detection)
|
||||||
const b = this.burstTrackers.get(sessionId);
|
const b = this.burstTrackers.get(sessionId);
|
||||||
if (b) b.count++;
|
if (b) b.count++;
|
||||||
@@ -294,6 +305,42 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
|
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit the attachment:detected event with file metadata.
|
||||||
|
*/
|
||||||
|
private emitAttachmentDetected(sessionId: string, filePath: string): void {
|
||||||
|
try {
|
||||||
|
const stat = statSync(filePath);
|
||||||
|
const fileName = basename(filePath);
|
||||||
|
const workingDir = this.sessionDirs.get(sessionId);
|
||||||
|
const relativePath = workingDir ? relative(workingDir, filePath) : fileName;
|
||||||
|
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
|
||||||
|
|
||||||
|
const event: AttachmentDetectedEvent = {
|
||||||
|
sessionId,
|
||||||
|
filePath,
|
||||||
|
relativePath,
|
||||||
|
fileName,
|
||||||
|
extension,
|
||||||
|
attachmentType: this.getAttachmentType(extension),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
size: stat.size,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.emit('attachment:detected', event);
|
||||||
|
} catch (error) {
|
||||||
|
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAttachmentType(extension: string): AttachmentDetectedType {
|
||||||
|
if (extension === 'png') return 'image';
|
||||||
|
if (extension === 'pdf') return 'pdf';
|
||||||
|
if (extension === 'docx') return 'document';
|
||||||
|
if (extension === 'pptx') return 'presentation';
|
||||||
|
return 'document';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance for convenience
|
// Export singleton instance for convenience
|
||||||
|
|||||||
@@ -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'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import {
|
|||||||
import { SessionAutoOps } from './session-auto-ops.js';
|
import { SessionAutoOps } from './session-auto-ops.js';
|
||||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||||
import { SessionTaskCache } from './session-task-cache.js';
|
import { SessionTaskCache } from './session-task-cache.js';
|
||||||
|
import { parseAttachmentMagicLinks } from './attachment-magic.js';
|
||||||
|
|
||||||
export type { BackgroundTask } from './task-tracker.js';
|
export type { BackgroundTask } from './task-tracker.js';
|
||||||
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
||||||
@@ -259,6 +260,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
|
||||||
@@ -308,6 +312,9 @@ export class Session extends EventEmitter {
|
|||||||
private _parentAgentId: string | null = null;
|
private _parentAgentId: string | null = null;
|
||||||
private _childAgentIds: string[] = [];
|
private _childAgentIds: string[] = [];
|
||||||
|
|
||||||
|
// Bounded dedup set for terminal attachment magic-links already requested.
|
||||||
|
private _attachmentMagicSeen = new Set<string>();
|
||||||
|
|
||||||
// Nice prioritying configuration
|
// Nice prioritying configuration
|
||||||
private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG };
|
private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG };
|
||||||
|
|
||||||
@@ -1091,6 +1098,61 @@ 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, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan terminal output for `codeman://attach?path=...` magic links and emit
|
||||||
|
// an attachmentRequested event for each newly-seen absolute path. The web
|
||||||
|
// server turns these into registered attachment cards.
|
||||||
|
const attachmentPaths = parseAttachmentMagicLinks(data);
|
||||||
|
for (const attachmentPath of attachmentPaths) {
|
||||||
|
if (this._attachmentMagicSeen.has(attachmentPath)) continue;
|
||||||
|
this._attachmentMagicSeen.add(attachmentPath);
|
||||||
|
if (this._attachmentMagicSeen.size > 200) {
|
||||||
|
const oldest = this._attachmentMagicSeen.values().next().value;
|
||||||
|
if (oldest) this._attachmentMagicSeen.delete(oldest);
|
||||||
|
}
|
||||||
|
this.emit('attachmentRequested', { sessionId: this.id, path: attachmentPath, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
// 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 +1773,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -426,7 +426,12 @@ export function formatPaneSnapshot(
|
|||||||
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
|
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
|
||||||
): string {
|
): string {
|
||||||
const cols = Math.max(1, geometry.cols);
|
const cols = Math.max(1, geometry.cols);
|
||||||
const paintCols = Math.max(1, cols - 1);
|
// Paint the full pane width. Earlier this dropped the rightmost column
|
||||||
|
// (cols - 1) out of caution about last-column autowrap, but every painted
|
||||||
|
// row is immediately followed by an absolute cursor-position CSI (the next
|
||||||
|
// row's `\x1b[r;1H`, or the final cursor move), which cancels xterm's
|
||||||
|
// pending-wrap state before any further glyph — so the last column is safe.
|
||||||
|
const paintCols = cols;
|
||||||
const rows = Math.max(1, geometry.rows);
|
const rows = Math.max(1, geometry.rows);
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (let row = 0; row < Math.min(lines.length, rows); row++) {
|
for (let row = 0; row < Math.min(lines.length, rows); row++) {
|
||||||
@@ -857,6 +862,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');
|
||||||
|
|||||||
+37
-1
@@ -7,13 +7,14 @@
|
|||||||
* - ActiveBashTool — a live bash command with extracted file paths and status
|
* - ActiveBashTool — a live bash command with extracted file paths and status
|
||||||
* - ActiveBashToolStatus — 'running' | 'completed'
|
* - ActiveBashToolStatus — 'running' | 'completed'
|
||||||
* - ImageDetectedEvent — screenshot/image file detection trigger for UI popup
|
* - ImageDetectedEvent — screenshot/image file detection trigger for UI popup
|
||||||
|
* - AttachmentDetectedEvent — document/image file detection trigger for attachment cards
|
||||||
*
|
*
|
||||||
* Cross-domain relationships:
|
* Cross-domain relationships:
|
||||||
* - ActiveBashTool.sessionId links to SessionState.id (session domain)
|
* - ActiveBashTool.sessionId links to SessionState.id (session domain)
|
||||||
* - ImageDetectedEvent.sessionId links to SessionState.id (session domain)
|
* - ImageDetectedEvent.sessionId links to SessionState.id (session domain)
|
||||||
*
|
*
|
||||||
* Both types are in-memory only (not persisted). Broadcast via SSE events
|
* Both types are in-memory only (not persisted). Broadcast via SSE events
|
||||||
* `subagent:tool_call` and `image:detected`. Parsed by BashToolParser
|
* `subagent:tool_call`, `image:detected`, and `attachment:detected`. Parsed by BashToolParser
|
||||||
* (`src/bash-tool-parser.ts`).
|
* (`src/bash-tool-parser.ts`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -61,3 +62,38 @@ export interface ImageDetectedEvent {
|
|||||||
/** File size in bytes */
|
/** File size in bytes */
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AttachmentDetectedType = 'image' | 'pdf' | 'document' | 'presentation' | 'markdown' | 'text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event emitted when a new previewable attachment file is detected in a session's
|
||||||
|
* working directory. Used to render a compact attachment card in the web UI.
|
||||||
|
*/
|
||||||
|
export interface AttachmentDetectedEvent {
|
||||||
|
/** Codeman session ID where the attachment was detected */
|
||||||
|
sessionId: string;
|
||||||
|
/** Full path to the detected attachment file */
|
||||||
|
filePath: string;
|
||||||
|
/** Path relative to the session's working directory (for file-raw/file-preview endpoints) */
|
||||||
|
relativePath: string;
|
||||||
|
/** Attachment file name (basename) */
|
||||||
|
fileName: string;
|
||||||
|
/** Lowercase extension without a leading dot */
|
||||||
|
extension: string;
|
||||||
|
/** Viewer category used by the web UI */
|
||||||
|
attachmentType: AttachmentDetectedType;
|
||||||
|
/** Timestamp when the attachment was detected */
|
||||||
|
timestamp: number;
|
||||||
|
/** File size in bytes */
|
||||||
|
size: number;
|
||||||
|
/** Registered attachment id for explicit live external attachments */
|
||||||
|
attachmentId?: string;
|
||||||
|
/** Source of the attachment card request */
|
||||||
|
source?: 'detected' | 'external';
|
||||||
|
/** Raw file route for explicit attachments */
|
||||||
|
rawUrl?: string;
|
||||||
|
/** Inline preview route for explicit attachments */
|
||||||
|
previewUrl?: string;
|
||||||
|
/** First-page thumbnail route for card previews */
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,24 +81,70 @@ 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';
|
||||||
done();
|
if (isLoopback) {
|
||||||
|
if (!getTunnelRunning()) {
|
||||||
|
// Loopback-only: unchanged behavior.
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Tunnel up: require the shared secret (constant-time compare).
|
||||||
|
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
|
||||||
|
const expected = Buffer.from(getHookSecret());
|
||||||
|
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Wrong/absent secret while tunneled — rate-limit per IP in the DEDICATED
|
||||||
|
// hook bucket (never authFailures, which would lock out the login path).
|
||||||
|
const hookIp = req.ip;
|
||||||
|
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
|
||||||
|
if (hookFailures >= AUTH_FAILURE_MAX) {
|
||||||
|
sendAuthRateLimit(reply, hookIp, hookSecretFailures);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hookSecretFailures.set(hookIp, hookFailures + 1);
|
||||||
|
reply.code(401).send('Unauthorized: hook secret required');
|
||||||
return;
|
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()
|
||||||
|
|||||||
+171
-4
@@ -580,6 +580,7 @@ class CodemanApp {
|
|||||||
const _kbSettings = this.loadAppSettingsFromStorage();
|
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||||
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||||
this.applyHeaderVisibilitySettings();
|
this.applyHeaderVisibilitySettings();
|
||||||
|
this.applySkin();
|
||||||
this.applyTabWrapSettings();
|
this.applyTabWrapSettings();
|
||||||
this.applyMonitorVisibility();
|
this.applyMonitorVisibility();
|
||||||
// Remove mobile-init class now that JS has applied visibility settings.
|
// Remove mobile-init class now that JS has applied visibility settings.
|
||||||
@@ -637,6 +638,7 @@ class CodemanApp {
|
|||||||
// Load server-stored settings (async, re-applies visibility after load)
|
// Load server-stored settings (async, re-applies visibility after load)
|
||||||
this.loadAppSettingsFromServer(settingsPromise).then(() => {
|
this.loadAppSettingsFromServer(settingsPromise).then(() => {
|
||||||
this.applyHeaderVisibilitySettings();
|
this.applyHeaderVisibilitySettings();
|
||||||
|
this.applySkin();
|
||||||
this.applyTabWrapSettings();
|
this.applyTabWrapSettings();
|
||||||
this.applyMonitorVisibility();
|
this.applyMonitorVisibility();
|
||||||
});
|
});
|
||||||
@@ -2123,6 +2125,7 @@ class CodemanApp {
|
|||||||
this.ralphStates.clear();
|
this.ralphStates.clear();
|
||||||
this.terminalBuffers.clear();
|
this.terminalBuffers.clear();
|
||||||
this.terminalBufferCache.clear();
|
this.terminalBufferCache.clear();
|
||||||
|
this._xtermSnapshots?.clear();
|
||||||
this.projectInsights.clear();
|
this.projectInsights.clear();
|
||||||
this.teams.clear();
|
this.teams.clear();
|
||||||
this.teamTasks.clear();
|
this.teamTasks.clear();
|
||||||
@@ -2913,7 +2916,107 @@ class CodemanApp {
|
|||||||
* terminal write queue, IME composition, and local echo flush.
|
* terminal write queue, IME composition, and local echo flush.
|
||||||
* @param {string} newSessionId - The session being switched TO.
|
* @param {string} newSessionId - The session being switched TO.
|
||||||
*/
|
*/
|
||||||
|
_isUsableXtermSnapshot(snapshot) {
|
||||||
|
if (!snapshot || typeof snapshot !== 'string' || snapshot.length < 8) return false;
|
||||||
|
const visibleText = snapshot
|
||||||
|
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||||
|
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
||||||
|
.replace(/\x1b[()][0-2A-Z]/g, '')
|
||||||
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||||
|
.trim();
|
||||||
|
return visibleText.length >= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist one xterm snapshot to localStorage, bounded to a fixed key budget
|
||||||
|
* regardless of how many sessions are live, and resilient to quota errors.
|
||||||
|
* The previous inline version only pruned snapshots for sessions that no
|
||||||
|
* longer existed AND pruned only after a successful setItem — so once the
|
||||||
|
* quota filled (e.g. >10 live sessions at the 20-session target) the write
|
||||||
|
* threw before the prune could run, permanently disabling persistence.
|
||||||
|
*/
|
||||||
|
_persistXtermSnapshot(key, snapshot) {
|
||||||
|
const PREFIX = 'codeman-xs-';
|
||||||
|
const MAX_KEYS = 10;
|
||||||
|
const others = () => Object.keys(localStorage).filter((k) => k.startsWith(PREFIX) && k !== key);
|
||||||
|
try {
|
||||||
|
// Evict down to the budget before writing a NEW key, dead sessions first
|
||||||
|
// then oldest. (Overwriting an existing key doesn't grow the key count.)
|
||||||
|
if (localStorage.getItem(key) === null) {
|
||||||
|
const live = new Set(Array.from(this.sessions?.keys?.() || []));
|
||||||
|
const pool = others().sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(live.has(a.slice(PREFIX.length))) - Number(live.has(b.slice(PREFIX.length)))
|
||||||
|
);
|
||||||
|
while (pool.length >= MAX_KEYS) localStorage.removeItem(pool.shift());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, snapshot);
|
||||||
|
} catch (_quota) {
|
||||||
|
// Quota exceeded: drop other snapshots one at a time and retry so a full
|
||||||
|
// quota can't permanently disable persistence.
|
||||||
|
for (const victim of others()) {
|
||||||
|
localStorage.removeItem(victim);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, snapshot);
|
||||||
|
return;
|
||||||
|
} catch (_again) {
|
||||||
|
/* keep evicting */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try { localStorage.removeItem(key); } catch {}
|
||||||
|
}
|
||||||
|
} catch (_unavailable) {
|
||||||
|
/* localStorage unavailable (Safari private mode / disabled) — in-memory only */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_cleanupPreviousSession(newSessionId) {
|
_cleanupPreviousSession(newSessionId) {
|
||||||
|
// Snapshot the OUTGOING session's xterm rendered state (viewport + scrollback +
|
||||||
|
// colors/attrs) before the terminal gets cleared/reset. Lets us restore the
|
||||||
|
// exact view on switch-back rather than replaying codex's byte stream, which
|
||||||
|
// drops earlier conversation from each TUI redraw and ends up showing only
|
||||||
|
// the latest (idle) frame.
|
||||||
|
// Shell sessions are never restored from a snapshot (restore is gated on
|
||||||
|
// mode !== 'shell'), so skip the serialize() + cache slot + localStorage
|
||||||
|
// quota for them. Unknown/undefined mode still snapshots, matching restore.
|
||||||
|
const outgoingSession = this.activeSessionId ? this.sessions?.get?.(this.activeSessionId) : null;
|
||||||
|
if (
|
||||||
|
this.activeSessionId &&
|
||||||
|
outgoingSession?.mode !== 'shell' &&
|
||||||
|
this._serializeAddon &&
|
||||||
|
this._xtermSnapshots
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const snapshot = this._serializeAddon.serialize({ scrollback: 1000 });
|
||||||
|
if (this._isUsableXtermSnapshot(snapshot)) {
|
||||||
|
// Delete-before-set so re-touching a session moves it to the end of
|
||||||
|
// the Map's insertion order — otherwise eviction is FIFO and can drop
|
||||||
|
// the most-recently-used session instead of the least.
|
||||||
|
this._xtermSnapshots.delete(this.activeSessionId);
|
||||||
|
this._xtermSnapshots.set(this.activeSessionId, snapshot);
|
||||||
|
// Cap in-memory snapshot cache at 20 entries; evict oldest on overflow.
|
||||||
|
if (this._xtermSnapshots.size > 20) {
|
||||||
|
const oldest = this._xtermSnapshots.keys().next().value;
|
||||||
|
this._xtermSnapshots.delete(oldest);
|
||||||
|
}
|
||||||
|
// Persist to localStorage so the snapshot survives tab discard /
|
||||||
|
// browser reload (Chrome discards inactive tabs after idle periods,
|
||||||
|
// wiping in-memory state). Cap per-snapshot at 256KB; codex
|
||||||
|
// buffer-replay produces a visual mess of stacked banner redraws when
|
||||||
|
// no snapshot exists, so persistence matters more here than for claude.
|
||||||
|
if (snapshot.length < 256 * 1024) {
|
||||||
|
this._persistXtermSnapshot(`codeman-xs-${this.activeSessionId}`, snapshot);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._xtermSnapshots.delete(this.activeSessionId);
|
||||||
|
try { localStorage.removeItem(`codeman-xs-${this.activeSessionId}`); } catch {}
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
/* Serialize failed — fall back to server buffer replay */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Close WebSocket for previous session (new one opens after buffer load)
|
// Close WebSocket for previous session (new one opens after buffer load)
|
||||||
this._disconnectWs();
|
this._disconnectWs();
|
||||||
|
|
||||||
@@ -3014,6 +3117,8 @@ class CodemanApp {
|
|||||||
if (this.activeSessionId === sessionId && !forceReload) return;
|
if (this.activeSessionId === sessionId && !forceReload) return;
|
||||||
if (this.activeSessionId === sessionId && forceReload) {
|
if (this.activeSessionId === sessionId && forceReload) {
|
||||||
this.terminalBufferCache?.delete(sessionId);
|
this.terminalBufferCache?.delete(sessionId);
|
||||||
|
this._xtermSnapshots?.delete(sessionId);
|
||||||
|
try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
|
||||||
this._clearTimer('syncWaitTimeout');
|
this._clearTimer('syncWaitTimeout');
|
||||||
this.pendingWrites = [];
|
this.pendingWrites = [];
|
||||||
this.writeFrameScheduled = false;
|
this.writeFrameScheduled = false;
|
||||||
@@ -3134,7 +3239,57 @@ class CodemanApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// xterm snapshot restore: if we have a serialized xterm state from a
|
||||||
|
// previous visit to this session, restore the user's exact prior view
|
||||||
|
// (viewport + scrollback + colors) for an instant first paint. For codex
|
||||||
|
// this is also a correctness fix — its byte-stream replay shows only the
|
||||||
|
// latest TUI frame (the idle welcome banner) because codex doesn't include
|
||||||
|
// earlier conversation in its current redraw. For claude/opencode/gemini
|
||||||
|
// the replay is already complete, so the snapshot is purely a faster,
|
||||||
|
// scroll-preserving first paint before the canonical fetch reconciles.
|
||||||
|
//
|
||||||
|
// Try in-memory first (fast); fall back to localStorage so snapshots
|
||||||
|
// survive tab discards / browser reloads.
|
||||||
|
let snapshot = this._xtermSnapshots?.get(sessionId);
|
||||||
|
if (snapshot && !this._isUsableXtermSnapshot(snapshot)) {
|
||||||
|
this._xtermSnapshots?.delete(sessionId);
|
||||||
|
snapshot = null;
|
||||||
|
}
|
||||||
|
if (!snapshot) {
|
||||||
|
try {
|
||||||
|
const persisted = localStorage.getItem(`codeman-xs-${sessionId}`);
|
||||||
|
if (persisted && this._isUsableXtermSnapshot(persisted)) {
|
||||||
|
snapshot = persisted;
|
||||||
|
// Hoist into in-memory cache for next time (delete-before-set keeps
|
||||||
|
// the Map in LRU order so the just-used session isn't evicted first).
|
||||||
|
this._xtermSnapshots?.delete(sessionId);
|
||||||
|
this._xtermSnapshots?.set(sessionId, persisted);
|
||||||
|
} else if (persisted) {
|
||||||
|
localStorage.removeItem(`codeman-xs-${sessionId}`);
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
/* localStorage unavailable — proceed without snapshot */
|
||||||
|
}
|
||||||
|
}
|
||||||
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
|
const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
|
||||||
|
let restoredSnapshot = false;
|
||||||
|
if (snapshot && !sessionIsBusy && session?.mode !== 'shell') {
|
||||||
|
_crashDiag.log(`SNAPSHOT_RESTORE: ${(snapshot.length/1024).toFixed(0)}KB`);
|
||||||
|
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||||
|
this._resetTerminalForReplay();
|
||||||
|
await new Promise((resolve) => this.terminal.write(snapshot, resolve));
|
||||||
|
if (this._isStaleSelect(selectGen)) {
|
||||||
|
this._clearTerminalLoadState(sessionId, selectGen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.scrollToLastNonEmptyLine();
|
||||||
|
_crashDiag.log('SNAPSHOT_RESTORE_DONE');
|
||||||
|
// Snapshot restore is only first paint. Inactive tabs intentionally
|
||||||
|
// unsubscribe from high-volume terminal output, so they can miss bytes
|
||||||
|
// emitted while away. Keep going and replace the snapshot with the
|
||||||
|
// canonical live tmux pane frame from /terminal.
|
||||||
|
restoredSnapshot = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Instant cache restore for IDLE sessions only.
|
// Instant cache restore for IDLE sessions only.
|
||||||
// For busy sessions, the cache is always stale — writing it first causes a
|
// For busy sessions, the cache is always stale — writing it first causes a
|
||||||
@@ -3142,7 +3297,8 @@ class CodemanApp {
|
|||||||
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
||||||
// buffer once for a single clean transition.
|
// buffer once for a single clean transition.
|
||||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||||
if (cachedBuffer && !sessionIsBusy) {
|
let clearedForBusy = false;
|
||||||
|
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) {
|
||||||
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
||||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||||
this._resetTerminalForReplay();
|
this._resetTerminalForReplay();
|
||||||
@@ -3156,6 +3312,7 @@ class CodemanApp {
|
|||||||
} else if (sessionIsBusy) {
|
} else if (sessionIsBusy) {
|
||||||
// Clear stale content immediately — fresh buffer is being fetched
|
// Clear stale content immediately — fresh buffer is being fetched
|
||||||
this._resetTerminalForReplay();
|
this._resetTerminalForReplay();
|
||||||
|
clearedForBusy = true;
|
||||||
_crashDiag.log('CACHE_SKIP_BUSY');
|
_crashDiag.log('CACHE_SKIP_BUSY');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3186,9 +3343,11 @@ class CodemanApp {
|
|||||||
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
|
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
|
||||||
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
|
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
|
||||||
// very visible, causing the terminal to flash blank then repaint.
|
// very visible, causing the terminal to flash blank then repaint.
|
||||||
// Busy sessions skip cache restore and clear the terminal before fetching,
|
// A snapshot restore or a busy-clear leaves the terminal showing
|
||||||
// so they must replay the fetched buffer even when it matches cache.
|
// something other than the cache, so the fetched buffer must be
|
||||||
const needsRewrite = sessionIsBusy || data.terminalBuffer !== cachedBuffer;
|
// replayed even when it byte-matches the cache.
|
||||||
|
const needsRewrite =
|
||||||
|
restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer;
|
||||||
if (needsRewrite) {
|
if (needsRewrite) {
|
||||||
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
|
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
|
||||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||||
@@ -3364,6 +3523,8 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
this.terminalBuffers.delete(sessionId);
|
this.terminalBuffers.delete(sessionId);
|
||||||
this.terminalBufferCache.delete(sessionId);
|
this.terminalBufferCache.delete(sessionId);
|
||||||
|
this._xtermSnapshots?.delete(sessionId);
|
||||||
|
try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
|
||||||
|
|
||||||
this._flushedOffsets?.delete(sessionId);
|
this._flushedOffsets?.delete(sessionId);
|
||||||
this._flushedTexts?.delete(sessionId);
|
this._flushedTexts?.delete(sessionId);
|
||||||
@@ -3537,6 +3698,12 @@ class CodemanApp {
|
|||||||
this.terminalBuffers.clear();
|
this.terminalBuffers.clear();
|
||||||
this.terminalBufferCache.clear();
|
this.terminalBufferCache.clear();
|
||||||
this.terminalLoadStates.clear();
|
this.terminalLoadStates.clear();
|
||||||
|
this._xtermSnapshots?.clear();
|
||||||
|
try {
|
||||||
|
for (const k of Object.keys(localStorage)) {
|
||||||
|
if (k.startsWith('codeman-xs-')) localStorage.removeItem(k);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||||
this.respawnStatus = {};
|
this.respawnStatus = {};
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ const SSE_EVENTS = {
|
|||||||
|
|
||||||
// Images
|
// Images
|
||||||
IMAGE_DETECTED: 'image:detected',
|
IMAGE_DETECTED: 'image:detected',
|
||||||
|
ATTACHMENT_DETECTED: 'attachment:detected',
|
||||||
|
|
||||||
// Tunnel
|
// Tunnel
|
||||||
TUNNEL_STARTED: 'tunnel:started',
|
TUNNEL_STARTED: 'tunnel:started',
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+44
-22
@@ -8,7 +8,7 @@
|
|||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<meta name="description" content="Claude Code session manager with web interface">
|
<meta name="description" content="Claude Code session manager with web interface">
|
||||||
<meta name="theme-color" content="#0a0a0a">
|
<meta name="theme-color" content="#11151c">
|
||||||
<meta name="google" content="notranslate">
|
<meta name="google" content="notranslate">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
@@ -30,21 +30,28 @@
|
|||||||
'defer' preserves execution order (xterm loads before fit addon). -->
|
'defer' preserves execution order (xterm loads before fit addon). -->
|
||||||
<script defer src="vendor/xterm.min.js"></script>
|
<script defer src="vendor/xterm.min.js"></script>
|
||||||
<script defer src="vendor/xterm-addon-fit.min.js"></script>
|
<script defer src="vendor/xterm-addon-fit.min.js"></script>
|
||||||
|
<!-- SerializeAddon: snapshots xterm state (viewport + scrollback + attrs) for
|
||||||
|
per-session restore on tab switches. Lets codex tabs survive switch-away
|
||||||
|
without codeman having to replay codex's byte stream (which loses earlier
|
||||||
|
conversation because codex's TUI redraws drop it from the viewport). -->
|
||||||
|
<script defer src="vendor/xterm-addon-serialize.min.js"></script>
|
||||||
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
|
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
|
||||||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||||||
<script defer src="vendor/xterm-zerolag-input.js"></script>
|
<script defer src="vendor/xterm-zerolag-input.js"></script>
|
||||||
<script defer src="vendor/marked.min.js"></script>
|
<script defer src="vendor/marked.min.js"></script>
|
||||||
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
|
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
|
||||||
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
|
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
|
||||||
|
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
|
||||||
|
<script>try{var s=localStorage.getItem('codeman:skin');if(s!=='og'&&s!=='daylight-green'&&s!=='daylight-blue')s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
|
||||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||||
<style>
|
<style>
|
||||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#09090b}
|
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#11151c}
|
||||||
.skeleton-header{height:40px;background:rgba(19,19,22,0.85);border-bottom:1px solid rgba(255,255,255,0.06);display:flex;align-items:center;padding:0 12px}
|
.skeleton-header{height:40px;background:rgba(31,38,48,0.85);border-bottom:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;padding:0 12px}
|
||||||
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:700;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
|
.skeleton-brand{color:#38b6f0;font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
|
||||||
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
|
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
|
||||||
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
|
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
|
||||||
.skeleton-terminal{flex:1;background:#0d0d0d}
|
.skeleton-terminal{flex:1;background:#161b23}
|
||||||
.skeleton-toolbar{height:42px;background:rgba(19,19,22,0.85);border-top:1px solid rgba(255,255,255,0.06)}
|
.skeleton-toolbar{height:42px;background:rgba(31,38,48,0.85);border-top:1px solid rgba(255,255,255,0.08)}
|
||||||
.app-loaded .loading-skeleton{display:none}
|
.app-loaded .loading-skeleton{display:none}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -173,8 +180,8 @@
|
|||||||
<svg viewBox="0 0 36 36" class="ralph-ring-svg">
|
<svg viewBox="0 0 36 36" class="ralph-ring-svg">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="ralphGradientMini" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="ralphGradientMini" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" stop-color="#3b82f6" />
|
<stop offset="0%" stop-color="#3ec8ee" />
|
||||||
<stop offset="100%" stop-color="#22c55e" />
|
<stop offset="100%" stop-color="#2b8fd9" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<circle class="ralph-ring-bg" cx="18" cy="18" r="15.9" />
|
<circle class="ralph-ring-bg" cx="18" cy="18" r="15.9" />
|
||||||
@@ -219,8 +226,8 @@
|
|||||||
<svg viewBox="0 0 100 100" class="ralph-ring-svg-large">
|
<svg viewBox="0 0 100 100" class="ralph-ring-svg-large">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="ralphGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="ralphGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" stop-color="#3b82f6" />
|
<stop offset="0%" stop-color="#3ec8ee" />
|
||||||
<stop offset="100%" stop-color="#22c55e" />
|
<stop offset="100%" stop-color="#2b8fd9" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<circle class="ralph-ring-track" cx="50" cy="50" r="42" />
|
<circle class="ralph-ring-track" cx="50" cy="50" r="42" />
|
||||||
@@ -604,17 +611,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 +620,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 +660,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 +686,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 -->
|
||||||
|
|
||||||
@@ -908,6 +920,16 @@
|
|||||||
<!-- Display Tab -->
|
<!-- Display Tab -->
|
||||||
<div class="modal-tab-content" id="settings-display">
|
<div class="modal-tab-content" id="settings-display">
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
|
<!-- Appearance Section -->
|
||||||
|
<div class="settings-section-header">Appearance</div>
|
||||||
|
<div class="settings-item settings-item-skin" title="Visual theme for this device (not synced)">
|
||||||
|
<span class="settings-item-label">Skin</span>
|
||||||
|
<select id="appSettingsSkin" class="form-select">
|
||||||
|
<option value="daylight-blue">Daylight Blue</option>
|
||||||
|
<option value="daylight-green">Daylight Green</option>
|
||||||
|
<option value="og">OG Codeman</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<!-- Input Section -->
|
<!-- Input Section -->
|
||||||
<div class="settings-section-header">Input</div>
|
<div class="settings-section-header">Input</div>
|
||||||
<div class="settings-item settings-item-multiline" title="Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections.">
|
<div class="settings-item settings-item-multiline" title="Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections.">
|
||||||
|
|||||||
+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;
|
||||||
|
|||||||
@@ -1550,29 +1550,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
theme: {
|
theme: { ...window.codemanCurrentXtermTheme() },
|
||||||
background: '#0d0d0d',
|
|
||||||
foreground: '#e0e0e0',
|
|
||||||
cursor: '#e0e0e0',
|
|
||||||
cursorAccent: '#0d0d0d',
|
|
||||||
selection: 'rgba(255, 255, 255, 0.3)',
|
|
||||||
black: '#0d0d0d',
|
|
||||||
red: '#ff6b6b',
|
|
||||||
green: '#51cf66',
|
|
||||||
yellow: '#ffd43b',
|
|
||||||
blue: '#339af0',
|
|
||||||
magenta: '#cc5de8',
|
|
||||||
cyan: '#22b8cf',
|
|
||||||
white: '#e0e0e0',
|
|
||||||
brightBlack: '#495057',
|
|
||||||
brightRed: '#ff8787',
|
|
||||||
brightGreen: '#69db7c',
|
|
||||||
brightYellow: '#ffe066',
|
|
||||||
brightBlue: '#5c7cfa',
|
|
||||||
brightMagenta: '#da77f2',
|
|
||||||
brightCyan: '#66d9e8',
|
|
||||||
brightWhite: '#ffffff',
|
|
||||||
},
|
|
||||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false;
|
document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false;
|
||||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
|
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
|
||||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||||
|
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||||
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false;
|
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false;
|
||||||
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
|
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
|
||||||
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
|
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
|
||||||
@@ -844,11 +845,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 +1156,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');
|
||||||
@@ -1343,6 +1378,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||||
|
skin: document.getElementById('appSettingsSkin').value,
|
||||||
// Claude CLI settings
|
// Claude CLI settings
|
||||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||||||
@@ -1457,6 +1493,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
// Apply header visibility immediately
|
// Apply header visibility immediately
|
||||||
this.applyHeaderVisibilitySettings();
|
this.applyHeaderVisibilitySettings();
|
||||||
|
this.applySkin();
|
||||||
this.applyTabWrapSettings();
|
this.applyTabWrapSettings();
|
||||||
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
|
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
|
||||||
this.applyMonitorVisibility();
|
this.applyMonitorVisibility();
|
||||||
@@ -1471,9 +1508,26 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
// Save to server (includes notification prefs for cross-browser persistence)
|
// Save to server (includes notification prefs for cross-browser persistence)
|
||||||
// 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, skin: _skin, ...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();
|
||||||
@@ -1613,6 +1667,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
ralphTrackerEnabled: false,
|
ralphTrackerEnabled: false,
|
||||||
tabTwoRows: false,
|
tabTwoRows: false,
|
||||||
cjkInputEnabled: false,
|
cjkInputEnabled: false,
|
||||||
|
skin: 'daylight-blue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Desktop defaults - rely on ?? operators in apply functions
|
// Desktop defaults - rely on ?? operators in apply functions
|
||||||
@@ -1650,6 +1705,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Apply the chosen skin live: sets the html[data-skin] attribute, syncs BOTH
|
||||||
|
// localStorage locations (the standalone 'codeman:skin' key the pre-paint head
|
||||||
|
// script reads + the app-settings blob field written by saveAppSettingsToStorage),
|
||||||
|
// updates window.__codemanSkin, and re-themes any live terminals.
|
||||||
|
applySkin() {
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
const defaults = this.getDefaultSettings();
|
||||||
|
const skin = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||||
|
document.documentElement.setAttribute('data-skin', skin);
|
||||||
|
window.__codemanSkin = skin;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('codeman:skin', skin);
|
||||||
|
} catch (_e) {
|
||||||
|
/* private mode */
|
||||||
|
}
|
||||||
|
if (typeof this.applyTerminalSkin === 'function') this.applyTerminalSkin(skin);
|
||||||
|
},
|
||||||
|
|
||||||
applyHeaderVisibilitySettings() {
|
applyHeaderVisibilitySettings() {
|
||||||
const settings = this.loadAppSettingsFromStorage();
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
const defaults = this.getDefaultSettings();
|
const defaults = this.getDefaultSettings();
|
||||||
@@ -1893,6 +1966,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'showLifecycleLog', 'showResponseViewer',
|
'showLifecycleLog', 'showResponseViewer',
|
||||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||||
|
'skin',
|
||||||
]);
|
]);
|
||||||
// Merge settings: non-display keys always sync from server,
|
// Merge settings: non-display keys always sync from server,
|
||||||
// display keys only seed from server when localStorage has no value
|
// display keys only seed from server when localStorage has no value
|
||||||
|
|||||||
+411
-30
@@ -1,36 +1,65 @@
|
|||||||
/* Codeman - Terminal-First UI */
|
/* Codeman - Terminal-First UI */
|
||||||
|
|
||||||
|
/* Self-hosted UI + mono fonts (Carbon Aurora · Daylight) — served from /fonts,
|
||||||
|
no external CDN, CSP-safe (font-src 'self'). Variable woff2 cover all weights. */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Manrope';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400 800;
|
||||||
|
src: url('fonts/manrope-variable.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400 700;
|
||||||
|
src: url('fonts/jetbrains-mono-variable.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-dark: #09090b;
|
/* Carbon Aurora · Daylight — deep-but-not-black slate, surfaces step UP into the light */
|
||||||
--bg-card: #131316;
|
--bg-dark: #11151c;
|
||||||
--bg-input: #1a1a1f;
|
--bg-card: #1b222c;
|
||||||
--bg-hover: #1f1f26;
|
--bg-input: #202833;
|
||||||
--border: #232329;
|
--bg-hover: #2a323d;
|
||||||
--border-light: #2e2e38;
|
--border: #2b333f;
|
||||||
--text: #ececf0;
|
--border-light: #3a4350;
|
||||||
--text-dim: #8b8b97;
|
--text: #f3f6fa;
|
||||||
--text-muted: #52525e;
|
--text-dim: #98a2b1;
|
||||||
--accent: #3b82f6;
|
--text-muted: #717b8c;
|
||||||
--accent-hover: #60a5fa;
|
/* SINGLE cohesive accent — optimistic emerald → teal (replaces the old blue) */
|
||||||
--green: #22c55e;
|
--accent: #38b6f0;
|
||||||
--yellow: #eab308;
|
--accent-hover: #3ec8ee;
|
||||||
--red: #ef4444;
|
/* calmer secondary green for non-primary pills/dots so Run + ring stay focal */
|
||||||
|
--green: #44b993;
|
||||||
|
/* warm, quiet "working" amber (no second saturated hue) */
|
||||||
|
--yellow: #f0c25a;
|
||||||
|
--red: #e06363;
|
||||||
|
/* Daylight accent extras — referenced by the v1.0 override block at end of file */
|
||||||
|
--accent-d: #2487c9;
|
||||||
|
--emerald: #3ec8ee;
|
||||||
|
--teal: #2b8fd9;
|
||||||
|
--accent-soft: #44b993;
|
||||||
|
--accent-ink: #04223a;
|
||||||
|
--warm: #e8c79a;
|
||||||
|
--ring-glow: 0 0 12px -2px rgba(56, 182, 240, 0.55);
|
||||||
--header-height: 36px;
|
--header-height: 36px;
|
||||||
--toolbar-height: 42px;
|
--toolbar-height: 42px;
|
||||||
--glass-bg: rgba(19, 19, 22, 0.85);
|
--glass-bg: rgba(31, 38, 48, 0.85);
|
||||||
--glass-border: rgba(255, 255, 255, 0.06);
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
--subtle-shadow: 0 -1px 3px rgba(0, 0, 0, 0.3), 0 -4px 16px rgba(0, 0, 0, 0.15);
|
--subtle-shadow: 0 -1px 3px rgba(0, 0, 0, 0.3), 0 -4px 16px rgba(0, 0, 0, 0.15);
|
||||||
--btn-radius: 6px;
|
--btn-radius: 6px;
|
||||||
--transition-smooth: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
--transition-smooth: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
/* Session color palette */
|
/* Session color palette — retoned toward the Daylight system */
|
||||||
--session-red: #ef4444;
|
--session-red: #e06363;
|
||||||
--session-orange: #f97316;
|
--session-orange: #e8a45c;
|
||||||
--session-yellow: #eab308;
|
--session-yellow: #f0c25a;
|
||||||
--session-green: #22c55e;
|
--session-green: #44b993;
|
||||||
--session-blue: #3b82f6;
|
--session-blue: #2b8fd9;
|
||||||
--session-purple: #a855f7;
|
--session-purple: #a98fe0;
|
||||||
--session-pink: #ec4899;
|
--session-pink: #e07db0;
|
||||||
|
|
||||||
/* Safe area insets for notched devices (iPhone X+, Dynamic Island) */
|
/* Safe area insets for notched devices (iPhone X+, Dynamic Island) */
|
||||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||||
@@ -40,6 +69,78 @@
|
|||||||
|
|
||||||
/* Touch target minimum (iOS Human Interface Guidelines) */
|
/* Touch target minimum (iOS Human Interface Guidelines) */
|
||||||
--touch-target-min: 44px;
|
--touch-target-min: 44px;
|
||||||
|
|
||||||
|
/* UI font + accent/gradient/hover tokens referenced by the v1.0 override block.
|
||||||
|
These :root values equal the daylight-blue palette so the no-attribute fallback
|
||||||
|
matches current behavior. */
|
||||||
|
--ui-font: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--accent-rgb: 56, 182, 240;
|
||||||
|
--accent-grad-a: #3ec8ee;
|
||||||
|
--accent-grad-b: #2b8fd9;
|
||||||
|
--run-hover-a: #5cc4f5;
|
||||||
|
--run-hover-b: #3aa3e2;
|
||||||
|
--gear-hover: #2a93d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Skin: Daylight Blue (current default) ===== */
|
||||||
|
html[data-skin="daylight-blue"] {
|
||||||
|
--bg-dark: #11151c; --bg-card: #1b222c; --bg-input: #202833; --bg-hover: #2a323d;
|
||||||
|
--border: #2b333f; --border-light: #3a4350;
|
||||||
|
--text: #f3f6fa; --text-dim: #98a2b1; --text-muted: #717b8c;
|
||||||
|
--accent: #38b6f0; --accent-hover: #3ec8ee;
|
||||||
|
--term-bg: #161b23;
|
||||||
|
--green: #44b993; --yellow: #f0c25a; --red: #e06363;
|
||||||
|
--glass-bg: rgba(31, 38, 48, 0.85); --glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--accent-d: #2487c9; --emerald: #3ec8ee; --teal: #2b8fd9;
|
||||||
|
--accent-soft: #44b993; --accent-ink: #04223a;
|
||||||
|
--accent-rgb: 56, 182, 240;
|
||||||
|
--accent-grad-a: #3ec8ee; --accent-grad-b: #2b8fd9;
|
||||||
|
--run-hover-a: #5cc4f5; --run-hover-b: #3aa3e2; --gear-hover: #2a93d0;
|
||||||
|
--ring-glow: 0 0 12px -2px rgba(56, 182, 240, 0.55);
|
||||||
|
--session-red: #e06363; --session-orange: #e8a45c; --session-yellow: #f0c25a;
|
||||||
|
--session-green: #44b993; --session-blue: #2b8fd9; --session-purple: #a98fe0; --session-pink: #e07db0;
|
||||||
|
--ui-font: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Skin: Daylight Green ===== */
|
||||||
|
html[data-skin="daylight-green"] {
|
||||||
|
--bg-dark: #11151c; --bg-card: #1b222c; --bg-input: #202833; --bg-hover: #2a323d;
|
||||||
|
--border: #2b333f; --border-light: #3a4350;
|
||||||
|
--text: #f3f6fa; --text-dim: #98a2b1; --text-muted: #717b8c;
|
||||||
|
--accent: #2fd3aa; --accent-hover: #34d8a0;
|
||||||
|
--term-bg: #161b23;
|
||||||
|
--green: #44b993; --yellow: #f0c25a; --red: #e06363;
|
||||||
|
--glass-bg: rgba(31, 38, 48, 0.85); --glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--accent-d: #19a987; --emerald: #34d8a0; --teal: #2bcbbb;
|
||||||
|
--accent-soft: #44b993; --accent-ink: #062019;
|
||||||
|
--accent-rgb: 47, 211, 170;
|
||||||
|
--accent-grad-a: #34d8a0; --accent-grad-b: #2bcbbb;
|
||||||
|
--run-hover-a: #3ee0aa; --run-hover-b: #34d4c4; --gear-hover: #1cbd97;
|
||||||
|
--ring-glow: 0 0 12px -2px rgba(47, 211, 170, 0.55);
|
||||||
|
--session-red: #e06363; --session-orange: #e8a45c; --session-yellow: #f0c25a;
|
||||||
|
--session-green: #44b993; --session-blue: #2bcbbb; --session-purple: #a98fe0; --session-pink: #e07db0;
|
||||||
|
--ui-font: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Skin: OG Codeman (original look — no Daylight polish, system font) ===== */
|
||||||
|
html[data-skin="og"] {
|
||||||
|
--bg-dark: #09090b; --bg-card: #131316; --bg-input: #1a1a1f; --bg-hover: #1f1f26;
|
||||||
|
--border: #232329; --border-light: #2e2e38;
|
||||||
|
--text: #ececf0; --text-dim: #8b8b97; --text-muted: #52525e;
|
||||||
|
--accent: #3b82f6; --accent-hover: #60a5fa;
|
||||||
|
--term-bg: #0d0d0d;
|
||||||
|
--green: #22c55e; --yellow: #eab308; --red: #ef4444;
|
||||||
|
--glass-bg: rgba(19, 19, 22, 0.85); --glass-border: rgba(255, 255, 255, 0.06);
|
||||||
|
/* daylight-only extras left at sane neutral defaults (unused — override block does not apply to og) */
|
||||||
|
--accent-d: #3b82f6; --emerald: #3b82f6; --teal: #2563eb;
|
||||||
|
--accent-soft: #22c55e; --accent-ink: #0b1220;
|
||||||
|
--accent-rgb: 59, 130, 246;
|
||||||
|
--accent-grad-a: #3b82f6; --accent-grad-b: #2563eb;
|
||||||
|
--run-hover-a: #60a5fa; --run-hover-b: #3b82f6; --gear-hover: #2563eb;
|
||||||
|
--ring-glow: 0 0 12px -2px rgba(59, 130, 246, 0.55);
|
||||||
|
--session-red: #ef4444; --session-orange: #f97316; --session-yellow: #eab308;
|
||||||
|
--session-green: #22c55e; --session-blue: #3b82f6; --session-purple: #a855f7; --session-pink: #ec4899;
|
||||||
|
--ui-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -129,7 +230,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: var(--ui-font);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -2097,7 +2198,7 @@ body.solo-mode .btn-lifecycle-log {
|
|||||||
|
|
||||||
.terminal-container {
|
.terminal-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: #0d0d0d;
|
background: var(--term-bg, #161b23);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
/* Performance: isolate layout and paint operations.
|
/* Performance: isolate layout and paint operations.
|
||||||
@@ -3828,6 +3929,22 @@ body.solo-mode .btn-lifecycle-log {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Skin picker: full-width row (not squeezed into a half-width grid cell) with an
|
||||||
|
auto-width select, so the "Skin" label keeps its space instead of truncating. */
|
||||||
|
.settings-item-skin {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.settings-item-skin .settings-item-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.settings-item-skin .form-select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 320px;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-item-multiline {
|
.settings-item-multiline {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -4645,15 +4762,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 +4810,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;
|
||||||
@@ -9023,3 +9174,233 @@ body.touch-device.cjk-input-visible .main {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
=== v1.0 Carbon Aurora · Daylight overrides ===
|
||||||
|
Single appended block — trivially removable. Retones the hardcoded
|
||||||
|
component colors that don't flow through :root: the active session
|
||||||
|
tab (#00FF66), the banners' literal gradients, the Ralph Material
|
||||||
|
palette, the Run/welcome/stop gradient buttons, the panels' glass,
|
||||||
|
and the welcome title. Calms heavy glows; the ONLY real saturation/
|
||||||
|
glow is reserved for the Run button + the progress ring.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
html:not([data-skin="og"]) {
|
||||||
|
/* ---- Brand wordmark: emerald, calm (kill the blue glow) ---- */
|
||||||
|
.logo:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
text-shadow: none;
|
||||||
|
filter: brightness(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Active session tab: crisp emerald (replaces neon #00FF66) ---- */
|
||||||
|
.session-tab.active {
|
||||||
|
background: rgba(var(--accent-rgb), 0.13) !important;
|
||||||
|
border: 1px solid var(--accent) !important;
|
||||||
|
color: var(--text) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.session-tab:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.06);
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.2);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.session-tab .tab-number {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.session-tab.active .tab-number {
|
||||||
|
background: rgba(var(--accent-rgb), 0.2) !important;
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.45) !important;
|
||||||
|
color: var(--accent-hover) !important;
|
||||||
|
}
|
||||||
|
/* per-color active tints: calm the glow, keep the identity hue as a quiet border */
|
||||||
|
.session-tab.active[data-color] {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
/* tab status dots: quiet, no halo */
|
||||||
|
.session-tab .tab-status.idle,
|
||||||
|
.session-tab .tab-status.busy {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.tab-glow { animation: none; box-shadow: none; }
|
||||||
|
|
||||||
|
/* ---- Timer banner: emerald-teal, flat ---- */
|
||||||
|
.timer-banner {
|
||||||
|
background: linear-gradient(90deg, rgba(31, 38, 48, 0.92), rgba(22, 27, 35, 0.92));
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
.timer-value { color: var(--accent); }
|
||||||
|
.timer-progress-fill { background: linear-gradient(90deg, var(--accent-grad-a), var(--accent-grad-b)); }
|
||||||
|
|
||||||
|
/* ---- Respawn banner: lifted slate, calm green hairline ---- */
|
||||||
|
.respawn-banner {
|
||||||
|
background: linear-gradient(90deg, rgba(31, 38, 48, 0.9), rgba(22, 27, 35, 0.9));
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
.respawn-countdown-timer .respawn-timer-value,
|
||||||
|
.action-type { color: var(--accent); }
|
||||||
|
.respawn-timer-progress { background: var(--accent); }
|
||||||
|
.respawn-action-log { border-left-color: rgba(var(--accent-rgb), 0.18); }
|
||||||
|
.respawn-action-log::-webkit-scrollbar-thumb { background: rgba(var(--accent-rgb), 0.28); }
|
||||||
|
|
||||||
|
/* ---- Ralph panel: replace the blue->green ring gradient + Material badges ---- */
|
||||||
|
.ralph-ring-text {
|
||||||
|
background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
.ralph-ring-bg, .ralph-ring-track { stroke: rgba(255, 255, 255, 0.1); }
|
||||||
|
/* Ralph status badges: collapse the off-token Material palette onto the system */
|
||||||
|
.ralph-status-badge { color: var(--text-dim); }
|
||||||
|
.ralph-status-dot { box-shadow: none; }
|
||||||
|
.ralph-info { background: rgba(var(--accent-rgb), 0.05); }
|
||||||
|
|
||||||
|
/* ---- Bottom toolbar + neutral buttons ---- */
|
||||||
|
.toolbar { box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.22), 0 -4px 14px rgba(0, 0, 0, 0.14); }
|
||||||
|
.btn-toolbar {
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
border-color: rgba(255, 255, 255, 0.09);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.btn-toolbar:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.14);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- RUN button: THE focal saturated accent (flat emerald->teal fill) ---- */
|
||||||
|
.btn-toolbar.btn-run,
|
||||||
|
.btn-toolbar.btn-run.mode-claude {
|
||||||
|
background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
font-weight: 700;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-toolbar.btn-run:hover,
|
||||||
|
.btn-toolbar.btn-run.mode-claude:hover {
|
||||||
|
background: linear-gradient(135deg, var(--run-hover-a), var(--run-hover-b));
|
||||||
|
box-shadow: 0 0 14px -2px rgba(var(--accent-rgb), 0.5);
|
||||||
|
}
|
||||||
|
.btn-toolbar.btn-run.mode-opencode {
|
||||||
|
background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-b));
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
.btn-toolbar.btn-run.mode-opencode:hover { box-shadow: 0 0 14px -2px rgba(var(--accent-rgb), 0.45); }
|
||||||
|
.btn-toolbar.btn-run.mode-codex {
|
||||||
|
background: linear-gradient(135deg, var(--accent-grad-b), #5cc6e8);
|
||||||
|
border-color: var(--accent-grad-b);
|
||||||
|
color: #061c20;
|
||||||
|
}
|
||||||
|
.btn-toolbar.btn-run.mode-codex:hover { box-shadow: 0 0 14px -2px rgba(43, 203, 187, 0.45); }
|
||||||
|
.btn-toolbar.btn-run-gear {
|
||||||
|
background: var(--accent-d);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
.btn-toolbar.btn-run-gear:hover { background: var(--gear-hover); box-shadow: none; }
|
||||||
|
.run-mode-dot.claude { background: var(--accent); }
|
||||||
|
.run-mode-dot.opencode { background: var(--accent-soft); }
|
||||||
|
.run-mode-dot.codex { background: var(--accent-grad-b); }
|
||||||
|
|
||||||
|
/* ---- Shell button: quiet neutral with a calm green tint ---- */
|
||||||
|
.btn-toolbar.btn-shell {
|
||||||
|
background: rgba(68, 185, 147, 0.1);
|
||||||
|
border: 1px solid rgba(68, 185, 147, 0.28);
|
||||||
|
color: var(--green);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-toolbar.btn-shell:hover {
|
||||||
|
background: rgba(68, 185, 147, 0.16);
|
||||||
|
border-color: rgba(68, 185, 147, 0.4);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Stop button: quiet neutral control (no rose glow) ---- */
|
||||||
|
.btn-toolbar.btn-stop {
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-toolbar.btn-stop:hover {
|
||||||
|
background: rgba(224, 99, 99, 0.14);
|
||||||
|
border-color: rgba(224, 99, 99, 0.4);
|
||||||
|
color: #f0a0a0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-toolbar.btn-stop.confirming {
|
||||||
|
background: linear-gradient(135deg, #5a4a1a, #6b5620);
|
||||||
|
border-color: rgba(240, 194, 90, 0.6);
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Case selector + add ---- */
|
||||||
|
.toolbar-select {
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
border-color: rgba(255, 255, 255, 0.09);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.toolbar-select:hover { background: rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.14); }
|
||||||
|
.toolbar-select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.25);
|
||||||
|
}
|
||||||
|
.btn-case-add { color: var(--accent-soft); }
|
||||||
|
|
||||||
|
/* ---- Header system stats: cohesive accent meters ---- */
|
||||||
|
.stat-bar-mem { background: linear-gradient(90deg, var(--accent), var(--accent-hover)); }
|
||||||
|
.header-tokens.clickable:hover { background: rgba(var(--accent-rgb), 0.16); }
|
||||||
|
|
||||||
|
/* ---- Monitor + Subagents panels: lifted frosted slate ---- */
|
||||||
|
.monitor-panel,
|
||||||
|
.subagents-panel {
|
||||||
|
background: rgba(31, 38, 48, 0.95);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Subagent progress ring: emerald, calm glow ---- */
|
||||||
|
.subagent-ring-progress,
|
||||||
|
.subagent-ring circle.progress { stroke: var(--accent); }
|
||||||
|
|
||||||
|
/* ---- PROGRESS RING (Ralph large): the one place a soft glow lives ---- */
|
||||||
|
.ralph-ring-fill { filter: drop-shadow(0 0 6px rgba(var(--accent-rgb), 0.45)); }
|
||||||
|
|
||||||
|
/* ---- Welcome overlay: emerald title + Daylight buttons ---- */
|
||||||
|
.welcome-title {
|
||||||
|
background: linear-gradient(135deg, var(--accent-grad-a) 0%, var(--accent-grad-b) 50%, var(--accent-grad-a) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.welcome-btn-claude {
|
||||||
|
background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
.welcome-btn-claude:hover { box-shadow: 0 0 28px -4px rgba(var(--accent-rgb), 0.3); }
|
||||||
|
.welcome-btn-opencode {
|
||||||
|
background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-b));
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.4);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
.welcome-btn-opencode:hover { box-shadow: 0 0 28px -4px rgba(var(--accent-rgb), 0.25); }
|
||||||
|
.welcome-btn-tunnel {
|
||||||
|
background: linear-gradient(135deg, rgba(46, 55, 67, 0.9), rgba(56, 66, 79, 0.9));
|
||||||
|
border-color: var(--border-light);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.welcome-btn-tunnel:hover { box-shadow: none; }
|
||||||
|
.welcome-btn-tunnel.active {
|
||||||
|
background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-a));
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Focus rings (driven by --accent, now emerald) stay accessible ---- */
|
||||||
|
:focus-visible { outline-color: var(--accent); }
|
||||||
|
}
|
||||||
|
|||||||
+102
-26
@@ -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);
|
||||||
@@ -24,10 +28,27 @@
|
|||||||
return isTerminalQueryResponse(data);
|
return isTerminalQueryResponse(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-skin xterm.js palettes. The 'daylight-blue' object equals the legacy hardcoded
|
||||||
|
// theme, so default behavior is unchanged. Shared at module scope and exported on the
|
||||||
|
// global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals,
|
||||||
|
// a separate IIFE) can read the current skin's palette.
|
||||||
|
const CODEMAN_XTERM_THEMES = {
|
||||||
|
og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' },
|
||||||
|
'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selection: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
|
||||||
|
'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selection: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
|
||||||
|
};
|
||||||
|
function currentXtermTheme() {
|
||||||
|
const skin = (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
|
||||||
|
return CODEMAN_XTERM_THEMES[skin] || CODEMAN_XTERM_THEMES['daylight-blue'];
|
||||||
|
}
|
||||||
|
|
||||||
global.CodemanTerminalInput = {
|
global.CodemanTerminalInput = {
|
||||||
isTerminalQueryResponse,
|
isTerminalQueryResponse,
|
||||||
shouldSuppressTerminalQueryResponse,
|
shouldSuppressTerminalQueryResponse,
|
||||||
|
USER_SCROLL_STICKY_SUPPRESS_MS,
|
||||||
};
|
};
|
||||||
|
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||||
|
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||||
})(window);
|
})(window);
|
||||||
|
|
||||||
Object.assign(CodemanApp.prototype, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
@@ -42,29 +63,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
|
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
|
||||||
|
|
||||||
this.terminal = new Terminal({
|
this.terminal = new Terminal({
|
||||||
theme: {
|
theme: { ...window.codemanCurrentXtermTheme() },
|
||||||
background: '#0d0d0d',
|
|
||||||
foreground: '#e0e0e0',
|
|
||||||
cursor: '#e0e0e0',
|
|
||||||
cursorAccent: '#0d0d0d',
|
|
||||||
selection: 'rgba(255, 255, 255, 0.3)',
|
|
||||||
black: '#0d0d0d',
|
|
||||||
red: '#ff6b6b',
|
|
||||||
green: '#51cf66',
|
|
||||||
yellow: '#ffd43b',
|
|
||||||
blue: '#339af0',
|
|
||||||
magenta: '#cc5de8',
|
|
||||||
cyan: '#22b8cf',
|
|
||||||
white: '#e0e0e0',
|
|
||||||
brightBlack: '#495057',
|
|
||||||
brightRed: '#ff8787',
|
|
||||||
brightGreen: '#69db7c',
|
|
||||||
brightYellow: '#ffe066',
|
|
||||||
brightBlue: '#5c7cfa',
|
|
||||||
brightMagenta: '#da77f2',
|
|
||||||
brightCyan: '#66d9e8',
|
|
||||||
brightWhite: '#ffffff',
|
|
||||||
},
|
|
||||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
||||||
// Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line)
|
// Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line)
|
||||||
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
|
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
|
||||||
@@ -79,6 +78,23 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.fitAddon = new FitAddon.FitAddon();
|
this.fitAddon = new FitAddon.FitAddon();
|
||||||
this.terminal.loadAddon(this.fitAddon);
|
this.terminal.loadAddon(this.fitAddon);
|
||||||
|
|
||||||
|
// SerializeAddon: lets us snapshot the xterm rendered state (viewport +
|
||||||
|
// scrollback + colors/attrs) when switching away from a tab and restore
|
||||||
|
// it on switch-back. Needed primarily for codex tabs — codex's TUI drops
|
||||||
|
// earlier conversation from its current frame, so replaying the server
|
||||||
|
// byte buffer on tab-switch shows only the latest (idle) frame. The
|
||||||
|
// snapshot captures what the user was actually looking at.
|
||||||
|
this._xtermSnapshots = new Map(); // Map<sessionId, serialized-string>
|
||||||
|
if (typeof SerializeAddon !== 'undefined') {
|
||||||
|
try {
|
||||||
|
this._serializeAddon = new SerializeAddon.SerializeAddon();
|
||||||
|
this.terminal.loadAddon(this._serializeAddon);
|
||||||
|
} catch (_e) {
|
||||||
|
/* SerializeAddon failed — snapshot/restore disabled, fallback to buffer-fetch */
|
||||||
|
this._serializeAddon = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof Unicode11Addon !== 'undefined') {
|
if (typeof Unicode11Addon !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
|
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
|
||||||
@@ -302,6 +318,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 +393,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 +446,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 +1383,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 +1650,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 +1676,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 +1690,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2123,4 +2176,27 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('dirDisplay').textContent = value || 'No directory';
|
document.getElementById('dirDisplay').textContent = value || 'No directory';
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Re-theme all live xterm terminals (main + teammate) to the given skin's palette.
|
||||||
|
// Uses the xterm v5+ live setter (full object assignment triggers a repaint for both
|
||||||
|
// DOM and WebGL renderers) plus a belt-and-suspenders refresh().
|
||||||
|
applyTerminalSkin(skin) {
|
||||||
|
const theme = { ...(window.CODEMAN_XTERM_THEMES[skin] || window.CODEMAN_XTERM_THEMES['daylight-blue']) };
|
||||||
|
if (this.terminal) {
|
||||||
|
this.terminal.options.theme = theme;
|
||||||
|
try {
|
||||||
|
this.terminal.refresh(0, this.terminal.rows - 1);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (this.teammateTerminals) {
|
||||||
|
for (const [, entry] of this.teammateTerminals) {
|
||||||
|
if (entry && entry.terminal) {
|
||||||
|
entry.terminal.options.theme = { ...theme };
|
||||||
|
try {
|
||||||
|
entry.terminal.refresh(0, entry.terminal.rows - 1);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+206
-22
@@ -3,16 +3,163 @@
|
|||||||
* Provides directory listing, file content preview, raw file serving, and tail streaming.
|
* Provides directory listing, file content preview, raw file serving, and tail streaming.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||||
import { basename as pathBasename, join } from 'node:path';
|
import { basename as pathBasename, join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||||
|
import {
|
||||||
|
AttachmentRegistrationError,
|
||||||
|
attachmentRegistry,
|
||||||
|
registerExternalAttachment,
|
||||||
|
type AttachmentRecord,
|
||||||
|
} from '../../attachment-registry.js';
|
||||||
|
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||||
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
||||||
import type { SessionPort } from '../ports/index.js';
|
import { isSensitivePath } from '../sensitive-path.js';
|
||||||
|
import { SseEvent } from '../sse-events.js';
|
||||||
|
import type { EventPort, SessionPort } from '../ports/index.js';
|
||||||
|
|
||||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif',
|
||||||
|
webp: 'image/webp',
|
||||||
|
ico: 'image/x-icon',
|
||||||
|
bmp: 'image/bmp',
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
json: 'application/json',
|
||||||
|
md: 'text/markdown',
|
||||||
|
txt: 'text/plain',
|
||||||
|
};
|
||||||
|
|
||||||
|
function sanitizeDownloadName(fileName: string): string {
|
||||||
|
return fileName.replace(/["\\\r\n]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||||
|
const headers = reply.getHeaders();
|
||||||
|
reply.hijack();
|
||||||
|
|
||||||
|
for (const [name, value] of Object.entries(headers)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
reply.raw.setHeader(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content.on('error', (err) => {
|
||||||
|
if (reply.raw.headersSent) {
|
||||||
|
reply.raw.destroy(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.raw.statusCode = 500;
|
||||||
|
reply.raw.end('Failed to read file');
|
||||||
|
});
|
||||||
|
content.pipe(reply.raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveRawFile(
|
||||||
|
reply: FastifyReply,
|
||||||
|
resolvedPath: string,
|
||||||
|
fileName: string,
|
||||||
|
extension: string,
|
||||||
|
download?: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const stat = await fs.stat(resolvedPath);
|
||||||
|
const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
|
||||||
|
if (stat.size > MAX_RAW_ATTACHMENT_SIZE) {
|
||||||
|
reply
|
||||||
|
.code(413)
|
||||||
|
.send(
|
||||||
|
createErrorResponse(
|
||||||
|
ApiErrorCode.INVALID_INPUT,
|
||||||
|
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_ATTACHMENT_SIZE / 1024 / 1024}MB limit)`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = createReadStream(resolvedPath);
|
||||||
|
const safeName = sanitizeDownloadName(fileName);
|
||||||
|
if (download || extension === 'svg') {
|
||||||
|
reply.header(
|
||||||
|
'Content-Type',
|
||||||
|
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
||||||
|
);
|
||||||
|
reply.header('Content-Disposition', `attachment; filename="${safeName}"`);
|
||||||
|
reply.header('Content-Length', stat.size);
|
||||||
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
|
sendRawStream(reply, content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
||||||
|
reply.header('Content-Disposition', `inline; filename="${safeName}"`);
|
||||||
|
reply.header('Content-Length', stat.size);
|
||||||
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
|
sendRawStream(reply, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAttachmentOr404(
|
||||||
|
reply: FastifyReply,
|
||||||
|
sessionId: string,
|
||||||
|
attachmentId: string
|
||||||
|
): AttachmentRecord | undefined {
|
||||||
|
const record = attachmentRegistry.get(sessionId, attachmentId);
|
||||||
|
if (!record) {
|
||||||
|
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Attachment not found'));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COD-53 defense-in-depth: refuse to stream a record whose underlying path is
|
||||||
|
* blocked by the active attachment-guard policy, even though registration
|
||||||
|
* already blocks them. Guards against records that predate the guard or were
|
||||||
|
* crafted to point at a sensitive file. Resolves symlinks before the check so a
|
||||||
|
* record pointing at a symlink that now resolves to a sensitive target is also
|
||||||
|
* caught; if the path can't be resolved (deleted/unreadable) the check still
|
||||||
|
* runs on the stored path. When workspace confinement is enabled it additionally
|
||||||
|
* rejects any record outside the session workspace. Returns true (and sends a
|
||||||
|
* 403) when blocked.
|
||||||
|
*/
|
||||||
|
async function resolveServableAttachmentPath(
|
||||||
|
reply: FastifyReply,
|
||||||
|
record: AttachmentRecord,
|
||||||
|
sessionWorkingDir?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
let pathToCheck = record.filePath;
|
||||||
|
let resolved = false;
|
||||||
|
try {
|
||||||
|
pathToCheck = realpathSync(record.filePath);
|
||||||
|
resolved = true;
|
||||||
|
} catch {
|
||||||
|
// Fall back to the stored (already realpath-resolved at registration) path.
|
||||||
|
}
|
||||||
|
|
||||||
|
const guard = await loadAttachmentGuardConfig();
|
||||||
|
|
||||||
|
const blocked =
|
||||||
|
isBlockedAttachmentPath(pathToCheck, guard.blockedTrees) ||
|
||||||
|
isBlockedAttachmentPath(record.filePath, guard.blockedTrees) ||
|
||||||
|
(guard.confineToWorkspace && (!sessionWorkingDir || !validateSessionFilePath(sessionWorkingDir, pathToCheck)));
|
||||||
|
|
||||||
|
if (blocked) {
|
||||||
|
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Serve the freshly-resolved path, not the stored one: if a path component
|
||||||
|
// became a symlink after registration, the guard checked the resolved target
|
||||||
|
// but streaming record.filePath would follow the symlink to a swapped file.
|
||||||
|
return resolved ? pathToCheck : record.filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
|
||||||
// File tree listing
|
// File tree listing
|
||||||
app.get('/api/sessions/:id/files', async (req) => {
|
app.get('/api/sessions/:id/files', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
@@ -315,6 +462,59 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== Live external attachments =====
|
||||||
|
// Register an explicit, live external file (absolute host path) as an
|
||||||
|
// attachment with a stable id so browser requests never carry arbitrary
|
||||||
|
// paths. Registration enforces the COD-53 attachment-guard policy. Serving is
|
||||||
|
// by id via the /raw route below; document previews/thumbnails and the
|
||||||
|
// attachment-history list are layered on separately.
|
||||||
|
app.post('/api/sessions/:id/attachments', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
const body = (req.body || {}) as { path?: string };
|
||||||
|
|
||||||
|
if (!body.path || typeof body.path !== 'string') {
|
||||||
|
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = await registerExternalAttachment(id, body.path, { sessionWorkingDir: session.workingDir });
|
||||||
|
ctx.broadcast(SseEvent.AttachmentDetected, event);
|
||||||
|
return { success: true, data: event };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AttachmentRegistrationError) {
|
||||||
|
reply.code(err.statusCode).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, err.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return reply
|
||||||
|
.code(500)
|
||||||
|
.send(
|
||||||
|
createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to register attachment: ${getErrorMessage(err)}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve the raw bytes of a registered attachment by id. Re-checks the
|
||||||
|
// attachment-guard policy on every request (defense-in-depth) before streaming.
|
||||||
|
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
|
||||||
|
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||||
|
const { download } = req.query as { download?: string };
|
||||||
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||||
|
if (!record) return;
|
||||||
|
const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir);
|
||||||
|
if (!servePath) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true');
|
||||||
|
} catch (err) {
|
||||||
|
reply
|
||||||
|
.code(500)
|
||||||
|
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Stream file content via tail -f (SSE endpoint)
|
// Stream file content via tail -f (SSE endpoint)
|
||||||
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
@@ -387,24 +587,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
|||||||
});
|
});
|
||||||
// Session-scoped file download.
|
// Session-scoped file download.
|
||||||
// Uses the same realpath-based workspace boundary as file preview/raw routes;
|
// Uses the same realpath-based workspace boundary as file preview/raw routes;
|
||||||
// the sensitive-path blocklist remains defense-in-depth, not the primary boundary.
|
// the shared sensitive-path blocklist (../sensitive-path.js, also used by the
|
||||||
const SENSITIVE_PATTERNS: RegExp[] = [
|
// attachment guard) remains defense-in-depth, not the primary boundary.
|
||||||
/^\/etc\/shadow$/,
|
|
||||||
/^\/etc\/gshadow$/,
|
|
||||||
/^\/etc\/master\.passwd$/,
|
|
||||||
new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
|
|
||||||
/\/\.env$/,
|
|
||||||
/\/\.env\./,
|
|
||||||
/\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
|
|
||||||
/\/\.aws\/credentials$/,
|
|
||||||
/\/\.gcloud\/credentials\.db$/,
|
|
||||||
/\/\.docker\/config\.json$/,
|
|
||||||
];
|
|
||||||
|
|
||||||
function isSensitivePath(absPath: string): boolean {
|
|
||||||
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/api/download', async (req, reply) => {
|
app.get('/api/download', async (req, reply) => {
|
||||||
const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string };
|
const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string };
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -907,7 +932,24 @@ export function registerSessionRoutes(
|
|||||||
const query = req.query as { tail?: string };
|
const query = req.query as { tail?: string };
|
||||||
const session = findSessionOrFail(ctx, id);
|
const session = findSessionOrFail(ctx, id);
|
||||||
|
|
||||||
const rawBuffer = session.terminalBuffer;
|
// Prepend the live tmux pane buffer so tab-switch replay shows the current
|
||||||
|
// on-screen frame, not just the accumulated byte history. This matters for
|
||||||
|
// TUI modes (codex/opencode) that repaint only their latest frame: the
|
||||||
|
// accumulated buffer alone replays as the idle banner. We clear the viewport
|
||||||
|
// (`\x1b[H\x1b[2J`) between the history and the live pane so they don't
|
||||||
|
// overlap. `captureActivePaneBuffer` is a no-op ('') under test mode and
|
||||||
|
// returns null when unavailable, in which case we fall back to history.
|
||||||
|
const muxName = session.muxName;
|
||||||
|
const liveMuxBuffer =
|
||||||
|
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
||||||
|
? ctx.mux.captureActivePaneBuffer(muxName)
|
||||||
|
: null;
|
||||||
|
const rawBuffer =
|
||||||
|
liveMuxBuffer !== null && liveMuxBuffer.length > 0
|
||||||
|
? session.terminalBufferLength > 0
|
||||||
|
? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}`
|
||||||
|
: liveMuxBuffer
|
||||||
|
: session.terminalBuffer;
|
||||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||||||
const fullSize = rawBuffer.length;
|
const fullSize = rawBuffer.length;
|
||||||
let truncated = false;
|
let truncated = false;
|
||||||
@@ -917,7 +959,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)) {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Shared sensitive-path blocklist.
|
||||||
|
*
|
||||||
|
* A small defense-in-depth blocklist of absolute paths that must never be
|
||||||
|
* served to the browser regardless of how the path was obtained (workspace
|
||||||
|
* download, cross-workspace attachment registration, raw/preview serving).
|
||||||
|
*
|
||||||
|
* This is intentionally a BLOCKLIST, not a workspace-confinement check:
|
||||||
|
* cross-workspace attachment is a supported feature (codeman-publish skill +
|
||||||
|
* the automated review-card loop attaching files under ~/.codeman/), so a
|
||||||
|
* strict session-workspace boundary would break legitimate use. The blocklist
|
||||||
|
* rejects well-known secret locations (system password files, SSH keys, cloud
|
||||||
|
* credentials, dotenv files) while leaving ordinary cross-workspace files
|
||||||
|
* attachable.
|
||||||
|
*
|
||||||
|
* Callers MUST resolve symlinks (realpath) BEFORE calling isSensitivePath so a
|
||||||
|
* symlink pointing at a sensitive target is also caught.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
const SENSITIVE_PATTERNS: RegExp[] = [
|
||||||
|
/^\/etc\/shadow$/,
|
||||||
|
/^\/etc\/gshadow$/,
|
||||||
|
/^\/etc\/master\.passwd$/,
|
||||||
|
new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
|
||||||
|
/\/\.env$/,
|
||||||
|
/\/\.env\./,
|
||||||
|
/\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
|
||||||
|
/\/\.aws\/credentials$/,
|
||||||
|
/\/\.gcloud\/credentials\.db$/,
|
||||||
|
/\/\.docker\/config\.json$/,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given ABSOLUTE, symlink-resolved path matches the
|
||||||
|
* sensitive-file blocklist and must not be served to the browser.
|
||||||
|
*/
|
||||||
|
export function isSensitivePath(absPath: string): boolean {
|
||||||
|
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath));
|
||||||
|
}
|
||||||
+44
-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';
|
||||||
@@ -59,6 +60,7 @@ import {
|
|||||||
type SubagentToolResult,
|
type SubagentToolResult,
|
||||||
} from '../subagent-watcher.js';
|
} from '../subagent-watcher.js';
|
||||||
import { imageWatcher } from '../image-watcher.js';
|
import { imageWatcher } from '../image-watcher.js';
|
||||||
|
import { attachmentRegistry, registerExternalAttachment } from '../attachment-registry.js';
|
||||||
import { TranscriptWatcher } from '../transcript-watcher.js';
|
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||||||
import { TeamWatcher } from '../team-watcher.js';
|
import { TeamWatcher } from '../team-watcher.js';
|
||||||
import { TunnelManager } from '../tunnel-manager.js';
|
import { TunnelManager } from '../tunnel-manager.js';
|
||||||
@@ -109,6 +111,7 @@ import {
|
|||||||
type PersistedRespawnConfig,
|
type PersistedRespawnConfig,
|
||||||
type NiceConfig,
|
type NiceConfig,
|
||||||
type ImageDetectedEvent,
|
type ImageDetectedEvent,
|
||||||
|
type AttachmentDetectedEvent,
|
||||||
DEFAULT_NICE_CONFIG,
|
DEFAULT_NICE_CONFIG,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import {
|
import {
|
||||||
@@ -247,12 +250,14 @@ export class WebServer extends EventEmitter {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
private imageWatcherHandlers: {
|
private imageWatcherHandlers: {
|
||||||
detected: (event: ImageDetectedEvent) => void;
|
detected: (event: ImageDetectedEvent) => void;
|
||||||
|
attachmentDetected: (event: AttachmentDetectedEvent) => void;
|
||||||
error: (error: Error, sessionId?: string) => void;
|
error: (error: Error, sessionId?: string) => void;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
private tunnelManager: TunnelManager = new TunnelManager();
|
private tunnelManager: TunnelManager = new TunnelManager();
|
||||||
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;
|
||||||
@@ -434,12 +439,15 @@ export class WebServer extends EventEmitter {
|
|||||||
// Store handlers for cleanup on shutdown
|
// Store handlers for cleanup on shutdown
|
||||||
this.imageWatcherHandlers = {
|
this.imageWatcherHandlers = {
|
||||||
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
|
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
|
||||||
|
attachmentDetected: (event: AttachmentDetectedEvent) =>
|
||||||
|
this.broadcast(SseEvent.AttachmentDetected, { ...event, source: event.source || 'detected' }),
|
||||||
error: (error: Error, sessionId?: string) => {
|
error: (error: Error, sessionId?: string) => {
|
||||||
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
imageWatcher.on('image:detected', this.imageWatcherHandlers.detected);
|
imageWatcher.on('image:detected', this.imageWatcherHandlers.detected);
|
||||||
|
imageWatcher.on('attachment:detected', this.imageWatcherHandlers.attachmentDetected);
|
||||||
imageWatcher.on('image:error', this.imageWatcherHandlers.error);
|
imageWatcher.on('image:error', this.imageWatcherHandlers.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +457,7 @@ export class WebServer extends EventEmitter {
|
|||||||
private cleanupImageWatcherListeners(): void {
|
private cleanupImageWatcherListeners(): void {
|
||||||
if (this.imageWatcherHandlers) {
|
if (this.imageWatcherHandlers) {
|
||||||
imageWatcher.off('image:detected', this.imageWatcherHandlers.detected);
|
imageWatcher.off('image:detected', this.imageWatcherHandlers.detected);
|
||||||
|
imageWatcher.off('attachment:detected', this.imageWatcherHandlers.attachmentDetected);
|
||||||
imageWatcher.off('image:error', this.imageWatcherHandlers.error);
|
imageWatcher.off('image:error', this.imageWatcherHandlers.error);
|
||||||
this.imageWatcherHandlers = null;
|
this.imageWatcherHandlers = null;
|
||||||
}
|
}
|
||||||
@@ -603,11 +612,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)
|
||||||
@@ -1064,6 +1074,8 @@ export class WebServer extends EventEmitter {
|
|||||||
session.removeAllListeners();
|
session.removeAllListeners();
|
||||||
// Close any active file streams for this session
|
// Close any active file streams for this session
|
||||||
fileStreamManager.closeSessionStreams(sessionId);
|
fileStreamManager.closeSessionStreams(sessionId);
|
||||||
|
// Drop live external attachment registrations for this session
|
||||||
|
attachmentRegistry.clearSession(sessionId);
|
||||||
// Stop watching for images in this session's directory
|
// Stop watching for images in this session's directory
|
||||||
imageWatcher.unwatchSession(sessionId);
|
imageWatcher.unwatchSession(sessionId);
|
||||||
// Clean up pasted images directory for this session
|
// Clean up pasted images directory for this session
|
||||||
@@ -1249,9 +1261,32 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
getStore: () => this.store,
|
getStore: () => this.store,
|
||||||
|
registerAttachment: (id: string, filePath: string) => this.registerAttachment(id, filePath),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a terminal-requested external file as a live attachment and
|
||||||
|
* broadcast it. Triggered by the session's `attachmentRequested` event
|
||||||
|
* (codeman://attach magic links). Because terminal output is
|
||||||
|
* attacker-influenceable (a prompt-injected session can print an arbitrary
|
||||||
|
* `codeman://attach?path=` link), the scanned path is FORCE-confined to the
|
||||||
|
* session workspace — passive magic links can't expose arbitrary host files.
|
||||||
|
* Deliberate cross-workspace attachment goes through the explicit,
|
||||||
|
* Origin-guarded `POST /attachments` route (and `codeman attach`, which POSTs
|
||||||
|
* directly inside a managed session). Registration also enforces the COD-53
|
||||||
|
* blocklist as defense-in-depth.
|
||||||
|
*/
|
||||||
|
private async registerAttachment(sessionId: string, filePath: string): Promise<void> {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
const event = await registerExternalAttachment(sessionId, filePath, {
|
||||||
|
sessionWorkingDir: session.workingDir,
|
||||||
|
forceWorkspaceConfinement: true,
|
||||||
|
});
|
||||||
|
this.broadcast(SseEvent.AttachmentDetected, event);
|
||||||
|
}
|
||||||
|
|
||||||
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
||||||
wireRespawnListeners(sessionId, controller, this.buildRespawnWiringDeps());
|
wireRespawnListeners(sessionId, controller, this.buildRespawnWiringDeps());
|
||||||
}
|
}
|
||||||
@@ -1816,6 +1851,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 +2337,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();
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface SessionListenerRefs {
|
|||||||
bashToolStart: (tool: ActiveBashTool) => void;
|
bashToolStart: (tool: ActiveBashTool) => void;
|
||||||
bashToolEnd: (tool: ActiveBashTool) => void;
|
bashToolEnd: (tool: ActiveBashTool) => void;
|
||||||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||||
|
attachmentRequested: (event: { path: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
|
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
|
||||||
@@ -77,10 +78,11 @@ interface SessionListenerDeps {
|
|||||||
removeSessionListenerRefs(sessionId: string): void;
|
removeSessionListenerRefs(sessionId: string): void;
|
||||||
cleanupRespawnOnExit(sessionId: string): void;
|
cleanupRespawnOnExit(sessionId: string): void;
|
||||||
getStore(): import('../state-store.js').StateStore;
|
getStore(): import('../state-store.js').StateStore;
|
||||||
|
registerAttachment(sessionId: string, filePath: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates all 25 session listener handlers, capturing dependencies via closure.
|
* Creates all 26 session listener handlers, capturing dependencies via closure.
|
||||||
* Call `attachSessionListeners()` after to wire them to the session.
|
* Call `attachSessionListeners()` after to wire them to the session.
|
||||||
*/
|
*/
|
||||||
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
|
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
|
||||||
@@ -355,6 +357,13 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
|||||||
bashToolsUpdate: (tools: ActiveBashTool[]) => {
|
bashToolsUpdate: (tools: ActiveBashTool[]) => {
|
||||||
deps.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
|
deps.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Registers an explicit attachment card requested by terminal magic text. */
|
||||||
|
attachmentRequested: (event: { path: string }) => {
|
||||||
|
deps.registerAttachment(session.id, event.path).catch((err) => {
|
||||||
|
console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err);
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +397,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
|||||||
session.on('bashToolStart', refs.bashToolStart);
|
session.on('bashToolStart', refs.bashToolStart);
|
||||||
session.on('bashToolEnd', refs.bashToolEnd);
|
session.on('bashToolEnd', refs.bashToolEnd);
|
||||||
session.on('bashToolsUpdate', refs.bashToolsUpdate);
|
session.on('bashToolsUpdate', refs.bashToolsUpdate);
|
||||||
|
session.on('attachmentRequested', refs.attachmentRequested);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detach all listeners from a session (prevents memory leaks from closure references). */
|
/** Detach all listeners from a session (prevents memory leaks from closure references). */
|
||||||
@@ -420,4 +430,5 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
|||||||
session.off('bashToolStart', refs.bashToolStart);
|
session.off('bashToolStart', refs.bashToolStart);
|
||||||
session.off('bashToolEnd', refs.bashToolEnd);
|
session.off('bashToolEnd', refs.bashToolEnd);
|
||||||
session.off('bashToolsUpdate', refs.bashToolsUpdate);
|
session.off('bashToolsUpdate', refs.bashToolsUpdate);
|
||||||
|
session.off('attachmentRequested', refs.attachmentRequested);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,8 @@ export const TunnelQrAuthUsed = 'tunnel:qrAuthUsed' as const;
|
|||||||
|
|
||||||
/** New image file detected (e.g. screenshot upload). */
|
/** New image file detected (e.g. screenshot upload). */
|
||||||
export const ImageDetected = 'image:detected' as const;
|
export const ImageDetected = 'image:detected' as const;
|
||||||
|
/** New document/image attachment detected in a session working directory. */
|
||||||
|
export const AttachmentDetected = 'attachment:detected' as const;
|
||||||
|
|
||||||
// ─── Hooks ───────────────────────────────────────────────────────────────────
|
// ─── Hooks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -479,6 +481,7 @@ export const SseEvent = {
|
|||||||
|
|
||||||
// Image
|
// Image
|
||||||
ImageDetected,
|
ImageDetected,
|
||||||
|
AttachmentDetected,
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
HookIdlePrompt,
|
HookIdlePrompt,
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Session } from '../src/session.js';
|
||||||
|
import { parseAttachmentMagicLinks } from '../src/attachment-magic.js';
|
||||||
|
|
||||||
|
describe('attachment magic links', () => {
|
||||||
|
it('extracts absolute paths from codeman attach magic URLs', () => {
|
||||||
|
const links = parseAttachmentMagicLinks(
|
||||||
|
'Preview this: codeman://attach?path=%2Fmnt%2Fc%2FDecks%2FBoard%20Update.pptx'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(links).toEqual(['/mnt/c/Decks/Board Update.pptx']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores duplicate links in one terminal chunk', () => {
|
||||||
|
const links = parseAttachmentMagicLinks(
|
||||||
|
[
|
||||||
|
'codeman://attach?path=/tmp/report.pdf',
|
||||||
|
'codeman://attach?path=/tmp/report.pdf',
|
||||||
|
'codeman://attach?path=/tmp/brief.docx',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(links).toEqual(['/tmp/report.pdf', '/tmp/brief.docx']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts markdown and plain-text magic paths', () => {
|
||||||
|
const links = parseAttachmentMagicLinks(
|
||||||
|
['codeman://attach?path=/tmp/notes.md', 'codeman://attach?path=/tmp/run.txt'].join('\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(links).toEqual(['/tmp/notes.md', '/tmp/run.txt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects relative or unsupported magic paths', () => {
|
||||||
|
const links = parseAttachmentMagicLinks(
|
||||||
|
[
|
||||||
|
'codeman://attach?path=relative.pdf',
|
||||||
|
'codeman://attach?path=/tmp/archive.zip',
|
||||||
|
'codeman://attach?path=/tmp/deck.pptx',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(links).toEqual(['/tmp/deck.pptx']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits attachmentRequested from raw terminal output', () => {
|
||||||
|
const session = new Session({ id: 'session-attach-test', workingDir: '/tmp', mode: 'codex' });
|
||||||
|
const requested: string[] = [];
|
||||||
|
session.on('attachmentRequested', (event: { path: string }) => requested.push(event.path));
|
||||||
|
|
||||||
|
(session as unknown as { _handleTerminalOutput(data: string): void })._handleTerminalOutput(
|
||||||
|
'codeman://attach?path=%2Ftmp%2Fdeck.pptx'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(requested).toEqual(['/tmp/deck.pptx']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,94 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
// Structural tests for the xterm snapshot/replay slice (COD-81). app.js has no
|
||||||
|
// bundler and is hard to drive through a real DOM, so — following the repo's
|
||||||
|
// existing pattern for app.js — these assert the source structure that makes
|
||||||
|
// the snapshot first-paint correct rather than executing it.
|
||||||
|
describe('xterm snapshot/replay (codex tab-switch)', () => {
|
||||||
|
const appSource = () => readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||||
|
|
||||||
|
it('rejects blank xterm snapshots before saving or restoring them', () => {
|
||||||
|
const source = appSource();
|
||||||
|
const helper = source.indexOf('_isUsableXtermSnapshot(snapshot)');
|
||||||
|
const save = source.indexOf('this._xtermSnapshots.set(this.activeSessionId, snapshot)');
|
||||||
|
const restore = source.indexOf('SNAPSHOT_RESTORE:', save);
|
||||||
|
const restoreBlock = source.slice(save, restore);
|
||||||
|
|
||||||
|
expect(helper).toBeGreaterThan(-1);
|
||||||
|
// The save is gated on a usability check immediately above it.
|
||||||
|
const usabilityGate = source.lastIndexOf('if (this._isUsableXtermSnapshot(snapshot))', save);
|
||||||
|
expect(usabilityGate).toBeGreaterThan(-1);
|
||||||
|
expect(usabilityGate).toBeLessThan(save);
|
||||||
|
// …and so is each restore path (in-memory + persisted).
|
||||||
|
expect(restoreBlock).toContain('if (snapshot && !this._isUsableXtermSnapshot(snapshot))');
|
||||||
|
expect(restoreBlock).toContain('persisted && this._isUsableXtermSnapshot(persisted)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares the snapshot-restore flag before selectSession uses it', () => {
|
||||||
|
const source = appSource();
|
||||||
|
const selectStart = source.indexOf('async selectSession(sessionId, options = {})');
|
||||||
|
const declaration = source.indexOf('let restoredSnapshot = false;', selectStart);
|
||||||
|
const snapshotBranch = source.indexOf("if (snapshot && !sessionIsBusy && session?.mode !== 'shell')", selectStart);
|
||||||
|
const rewriteDecision = source.indexOf(
|
||||||
|
'restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer',
|
||||||
|
selectStart
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selectStart).toBeGreaterThan(-1);
|
||||||
|
expect(declaration).toBeGreaterThan(selectStart);
|
||||||
|
expect(declaration).toBeLessThan(snapshotBranch);
|
||||||
|
expect(declaration).toBeLessThan(rewriteDecision);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses xterm snapshots as first paint but still fetches the canonical terminal frame', () => {
|
||||||
|
const source = appSource();
|
||||||
|
const snapshotRestore = source.indexOf('SNAPSHOT_RESTORE:');
|
||||||
|
const cacheRestore = source.indexOf('Instant cache restore', snapshotRestore);
|
||||||
|
const fetchStart = source.indexOf("FETCH_START'", snapshotRestore);
|
||||||
|
const needsRewrite = source.indexOf('const needsRewrite', fetchStart);
|
||||||
|
const snapshotBlock = source.slice(snapshotRestore, cacheRestore);
|
||||||
|
const postSnapshotRestore = source.slice(snapshotRestore, needsRewrite + 160);
|
||||||
|
|
||||||
|
expect(snapshotRestore).toBeGreaterThan(-1);
|
||||||
|
expect(cacheRestore).toBeGreaterThan(snapshotRestore);
|
||||||
|
expect(fetchStart).toBeGreaterThan(cacheRestore);
|
||||||
|
expect(needsRewrite).toBeGreaterThan(fetchStart);
|
||||||
|
// Snapshot restore must NOT short-circuit the canonical fetch.
|
||||||
|
expect(snapshotBlock).not.toContain('this._finishBufferLoad();');
|
||||||
|
expect(postSnapshotRestore).toContain('restoredSnapshot');
|
||||||
|
expect(postSnapshotRestore).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces replay after clearing a busy tab even when the fetched frame matches cache', () => {
|
||||||
|
const source = appSource();
|
||||||
|
const cacheRestore = source.indexOf('Instant cache restore');
|
||||||
|
const busyClear = source.indexOf('CACHE_SKIP_BUSY', cacheRestore);
|
||||||
|
const needsRewrite = source.indexOf('const needsRewrite', busyClear);
|
||||||
|
const replayBlock = source.slice(cacheRestore, needsRewrite + 160);
|
||||||
|
|
||||||
|
expect(cacheRestore).toBeGreaterThan(-1);
|
||||||
|
expect(busyClear).toBeGreaterThan(cacheRestore);
|
||||||
|
expect(needsRewrite).toBeGreaterThan(busyClear);
|
||||||
|
expect(replayBlock).toContain('clearedForBusy');
|
||||||
|
expect(replayBlock).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the SerializeAddon and keeps a per-session snapshot map', () => {
|
||||||
|
const terminalSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||||
|
expect(terminalSource).toContain('this._xtermSnapshots = new Map()');
|
||||||
|
expect(terminalSource).toContain('new SerializeAddon.SerializeAddon()');
|
||||||
|
expect(terminalSource).toContain('this.terminal.loadAddon(this._serializeAddon)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evicts the in-memory snapshot cache and persists with a bounded localStorage budget', () => {
|
||||||
|
const source = appSource();
|
||||||
|
// In-memory cache is LRU-bounded…
|
||||||
|
expect(source).toContain('if (this._xtermSnapshots.size > 20)');
|
||||||
|
// …per-snapshot localStorage writes are size-capped…
|
||||||
|
expect(source).toContain('snapshot.length < 256 * 1024');
|
||||||
|
// …and the persisted key set is pruned of dead sessions.
|
||||||
|
expect(source).toContain("k.startsWith('codeman-xs-')");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
+42
-12
@@ -145,7 +145,7 @@ describe('ImageWatcher', () => {
|
|||||||
// ========== Image Detection ==========
|
// ========== Image Detection ==========
|
||||||
|
|
||||||
describe('image detection', () => {
|
describe('image detection', () => {
|
||||||
it('should emit image:detected for .png files', () => {
|
it('should emit image:detected (popup) for .png files', () => {
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
watcher.on('image:detected', handler);
|
watcher.on('image:detected', handler);
|
||||||
|
|
||||||
@@ -161,7 +161,37 @@ describe('ImageWatcher', () => {
|
|||||||
expect(event.fileName).toBe('screenshot.png');
|
expect(event.fileName).toBe('screenshot.png');
|
||||||
expect(event.filePath).toBe('/home/user/project/screenshot.png');
|
expect(event.filePath).toBe('/home/user/project/screenshot.png');
|
||||||
expect(event.relativePath).toBe('screenshot.png');
|
expect(event.relativePath).toBe('screenshot.png');
|
||||||
expect(event.size).toBe(2048);
|
});
|
||||||
|
|
||||||
|
it('should not emit attachment:detected for .png (stays on the popup path)', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
watcher.on('attachment:detected', handler);
|
||||||
|
|
||||||
|
watcher.watchSession('session-1', '/home/user/project');
|
||||||
|
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['report.pdf', 'pdf'],
|
||||||
|
['brief.docx', 'document'],
|
||||||
|
['deck.pptx', 'presentation'],
|
||||||
|
])('should emit attachment:detected for %s files', (fileName, attachmentType) => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
watcher.on('attachment:detected', handler);
|
||||||
|
|
||||||
|
watcher.watchSession('session-1', '/home/user/project');
|
||||||
|
mockWatchers.get('/home/user/project')!.emit('add', `/home/user/project/${fileName}`);
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handler.mock.calls[0][0]).toMatchObject({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
fileName,
|
||||||
|
attachmentType,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit for .jpg files', () => {
|
it('should emit for .jpg files', () => {
|
||||||
@@ -250,10 +280,10 @@ describe('ImageWatcher', () => {
|
|||||||
watcher.on('image:detected', handler);
|
watcher.on('image:detected', handler);
|
||||||
|
|
||||||
watcher.watchSession('session-1', '/home/user/project');
|
watcher.watchSession('session-1', '/home/user/project');
|
||||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/assets/img.png');
|
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/assets/img.jpg');
|
||||||
vi.advanceTimersByTime(300);
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
expect(handler.mock.calls[0][0].relativePath).toBe('assets/img.png');
|
expect(handler.mock.calls[0][0].relativePath).toBe('assets/img.jpg');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,11 +298,11 @@ describe('ImageWatcher', () => {
|
|||||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||||
|
|
||||||
// Rapid adds of the same file
|
// Rapid adds of the same file
|
||||||
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
|
chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg');
|
||||||
vi.advanceTimersByTime(100); // not yet past debounce
|
vi.advanceTimersByTime(100); // not yet past debounce
|
||||||
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
|
chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg');
|
||||||
vi.advanceTimersByTime(100);
|
vi.advanceTimersByTime(100);
|
||||||
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
|
chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg');
|
||||||
vi.advanceTimersByTime(300); // now past debounce from last emit
|
vi.advanceTimersByTime(300); // now past debounce from last emit
|
||||||
|
|
||||||
// Should only emit once (the last debounced one)
|
// Should only emit once (the last debounced one)
|
||||||
@@ -286,8 +316,8 @@ describe('ImageWatcher', () => {
|
|||||||
watcher.watchSession('session-1', '/home/user/project');
|
watcher.watchSession('session-1', '/home/user/project');
|
||||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||||
|
|
||||||
chokidarWatcher.emit('add', '/home/user/project/a.png');
|
chokidarWatcher.emit('add', '/home/user/project/a.jpg');
|
||||||
chokidarWatcher.emit('add', '/home/user/project/b.png');
|
chokidarWatcher.emit('add', '/home/user/project/b.jpg');
|
||||||
vi.advanceTimersByTime(300);
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
expect(handler).toHaveBeenCalledTimes(2);
|
expect(handler).toHaveBeenCalledTimes(2);
|
||||||
@@ -306,7 +336,7 @@ describe('ImageWatcher', () => {
|
|||||||
|
|
||||||
// Emit 25 unique images in quick succession
|
// Emit 25 unique images in quick succession
|
||||||
for (let i = 0; i < 25; i++) {
|
for (let i = 0; i < 25; i++) {
|
||||||
chokidarWatcher.emit('add', `/home/user/project/img${i}.png`);
|
chokidarWatcher.emit('add', `/home/user/project/img${i}.jpg`);
|
||||||
vi.advanceTimersByTime(250); // past debounce, within burst window
|
vi.advanceTimersByTime(250); // past debounce, within burst window
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +353,7 @@ describe('ImageWatcher', () => {
|
|||||||
|
|
||||||
// Fill up burst limit
|
// Fill up burst limit
|
||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 20; i++) {
|
||||||
chokidarWatcher.emit('add', `/home/user/project/img${i}.png`);
|
chokidarWatcher.emit('add', `/home/user/project/img${i}.jpg`);
|
||||||
vi.advanceTimersByTime(250);
|
vi.advanceTimersByTime(250);
|
||||||
}
|
}
|
||||||
expect(handler).toHaveBeenCalledTimes(20);
|
expect(handler).toHaveBeenCalledTimes(20);
|
||||||
@@ -332,7 +362,7 @@ describe('ImageWatcher', () => {
|
|||||||
vi.advanceTimersByTime(11_000);
|
vi.advanceTimersByTime(11_000);
|
||||||
|
|
||||||
// Should accept new images
|
// Should accept new images
|
||||||
chokidarWatcher.emit('add', '/home/user/project/new.png');
|
chokidarWatcher.emit('add', '/home/user/project/new.jpg');
|
||||||
vi.advanceTimersByTime(300);
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
expect(handler).toHaveBeenCalledTimes(21);
|
expect(handler).toHaveBeenCalledTimes(21);
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview COD-53 — attachment path-traversal / sensitive-file guard.
|
||||||
|
*
|
||||||
|
* Verifies the sensitive-path blocklist is enforced at:
|
||||||
|
* - attachment registration (POST /api/sessions/:id/attachments)
|
||||||
|
* - raw / preview / thumbnail serving (defense-in-depth against a record that
|
||||||
|
* was crafted or registered before the guard existed)
|
||||||
|
* while still allowing legitimate cross-workspace attachment (codeman-publish
|
||||||
|
* skill + the ~/.codeman review-card loop) to succeed.
|
||||||
|
*
|
||||||
|
* Uses app.inject() — no real HTTP ports needed.
|
||||||
|
* Port: N/A (app.inject doesn't open ports)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||||
|
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||||
|
|
||||||
|
// Mock fs/promises for file operations
|
||||||
|
vi.mock('node:fs/promises', () => ({
|
||||||
|
default: {
|
||||||
|
readdir: vi.fn(async () => []),
|
||||||
|
readFile: vi.fn(async () => 'file content'),
|
||||||
|
writeFile: vi.fn(async () => undefined),
|
||||||
|
stat: vi.fn(async () => ({ size: 100, isFile: () => true, mtimeMs: 1 })),
|
||||||
|
mkdir: vi.fn(async () => undefined),
|
||||||
|
mkdtemp: vi.fn(async () => '/tmp/codeman-preview-test'),
|
||||||
|
rename: vi.fn(async () => undefined),
|
||||||
|
rm: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock realpathSync for symlink resolution (identity by default)
|
||||||
|
vi.mock('node:fs', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
realpathSync: vi.fn((p: string) => p),
|
||||||
|
createReadStream: vi.fn(() => Readable.from([Buffer.from('file content')])),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/file-stream-manager.js', () => ({
|
||||||
|
fileStreamManager: {
|
||||||
|
createStream: vi.fn(async () => ({ success: true, streamId: 'stream-1' })),
|
||||||
|
closeStream: vi.fn(() => true),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { createReadStream, realpathSync } from 'node:fs';
|
||||||
|
import {
|
||||||
|
attachmentRegistry,
|
||||||
|
registerExternalAttachment,
|
||||||
|
type AttachmentRecord,
|
||||||
|
} from '../../src/attachment-registry.js';
|
||||||
|
|
||||||
|
const mockedStat = vi.mocked(fs.stat);
|
||||||
|
const mockedRealpathSync = vi.mocked(realpathSync);
|
||||||
|
const mockedCreateReadStream = vi.mocked(createReadStream);
|
||||||
|
|
||||||
|
describe('file-routes attachment path guard (COD-53)', () => {
|
||||||
|
let harness: RouteTestHarness;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
harness = await createRouteTestHarness(registerFileRoutes);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
attachmentRegistry.clearSession('test-session-1');
|
||||||
|
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||||
|
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, mtimeMs: 1 } as never);
|
||||||
|
mockedCreateReadStream.mockReturnValue(Readable.from([Buffer.from('file content')]) as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.app.close();
|
||||||
|
attachmentRegistry.clearSession(harness.ctx._sessionId);
|
||||||
|
// Reset attachment-guard env knobs so one test can't leak into the next.
|
||||||
|
delete process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
|
||||||
|
delete process.env.CODEMAN_ATTACHMENT_CONFINE;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== BLOCK: registration rejects a sensitive path =====
|
||||||
|
|
||||||
|
it('rejects registering a .env file that carries a supported extension', async () => {
|
||||||
|
// A dotenv-style secret file named with a supported extension still leaks
|
||||||
|
// secrets; the blocklist's /\.env\./ pattern catches `.env.<ext>`.
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: '/home/someone/project/.env.txt' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects registering an SSH key path even with a supported extension', async () => {
|
||||||
|
const sshTxt = `${homedir()}/.ssh/id_rsa.txt`;
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: sshTxt },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects registering a sensitive path that a symlink resolves to', async () => {
|
||||||
|
// The requested path looks innocent (.md) but realpath resolves it to an SSH key dir.
|
||||||
|
mockedRealpathSync.mockReturnValue(`${homedir()}/.ssh/known_hosts.md` as never);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: '/home/someone/project/innocent.md' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== BLOCK (defense-in-depth): raw serving rejects a sensitive record =====
|
||||||
|
|
||||||
|
it('refuses to serve raw bytes for a record whose path is sensitive', async () => {
|
||||||
|
// Simulate a record that was registered before the guard existed (or crafted).
|
||||||
|
const record: AttachmentRecord = {
|
||||||
|
attachmentId: 'att_sensitive',
|
||||||
|
sessionId: harness.ctx._sessionId,
|
||||||
|
filePath: `${homedir()}/.ssh/id_rsa.txt`,
|
||||||
|
fileName: 'id_rsa.txt',
|
||||||
|
extension: 'txt',
|
||||||
|
attachmentType: 'text',
|
||||||
|
size: 100,
|
||||||
|
mtimeMs: 1,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
source: 'external',
|
||||||
|
};
|
||||||
|
attachmentRegistry.register(record);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments/att_sensitive/raw`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== PRESERVE: legitimate cross-workspace attachment still works =====
|
||||||
|
|
||||||
|
it('still registers a normal cross-workspace file (codeman-publish / loop review card)', async () => {
|
||||||
|
mockedStat.mockResolvedValue({ size: 512, isFile: () => true, mtimeMs: 5 } as never);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: `${homedir()}/.codeman/jira-autoloop-questions.md` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.fileName).toBe('jira-autoloop-questions.md');
|
||||||
|
expect(body.data.extension).toBe('md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still registers an arbitrary project-dir file (WSL path)', async () => {
|
||||||
|
mockedStat.mockResolvedValue({ size: 4096, isFile: () => true, mtimeMs: 5 } as never);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: '/mnt/c/decks/board-update.pdf' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.fileName).toBe('board-update.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still serves raw bytes for a legitimately registered cross-workspace file', async () => {
|
||||||
|
const content = Buffer.from('# notes');
|
||||||
|
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||||
|
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
|
||||||
|
|
||||||
|
const registerRes = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: `${homedir()}/.codeman/review-card.md` },
|
||||||
|
});
|
||||||
|
const attachmentId = JSON.parse(registerRes.body).data.attachmentId;
|
||||||
|
|
||||||
|
const rawRes = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${attachmentId}/raw`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rawRes.statusCode).toBe(200);
|
||||||
|
expect(rawRes.headers['content-type']).toBe('text/markdown');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== BLOCK (broadened defaults): /root and /etc trees =====
|
||||||
|
|
||||||
|
it('rejects registering a file anywhere under /root by default', async () => {
|
||||||
|
// /root is the root account home — blocked as a whole tree by default,
|
||||||
|
// even for an ordinary-looking note with a supported extension.
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: '/root/secret-notes.md' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects registering a file anywhere under /etc by default', async () => {
|
||||||
|
// The whole /etc tree is blocked by default (not just /etc/shadow).
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: '/etc/codeman/config-dump.txt' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block a lookalike sibling dir like /etcetera (separator-aware)', async () => {
|
||||||
|
// The /etc tree block must be path-separator-aware so an unrelated
|
||||||
|
// /etcetera/... path is NOT caught by accident.
|
||||||
|
mockedStat.mockResolvedValue({ size: 10, isFile: () => true, mtimeMs: 5 } as never);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: '/etcetera/notes.md' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== CONFIG: extend the blocked set via env =====
|
||||||
|
|
||||||
|
it('rejects a path added via the extra-blocked-paths config', async () => {
|
||||||
|
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = '/srv/secrets,/data/private';
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: '/srv/secrets/keys.pdf' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still allows a normal path NOT in the configured blocked set', async () => {
|
||||||
|
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = '/srv/secrets,/data/private';
|
||||||
|
mockedStat.mockResolvedValue({ size: 20, isFile: () => true, mtimeMs: 5 } as never);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: '/srv/public/report.pdf' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.fileName).toBe('report.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== CONFINEMENT MODE ON (opt-in) =====
|
||||||
|
|
||||||
|
it('confinement ON: rejects a file OUTSIDE the session workspace', async () => {
|
||||||
|
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
|
||||||
|
// Mock session workspace is /tmp/test-workdir; this file resolves elsewhere.
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: '/home/someone/elsewhere/report.pdf' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confinement ON: allows a file INSIDE the session workspace', async () => {
|
||||||
|
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
|
||||||
|
// Mock session workspace is /tmp/test-workdir (see MockSession).
|
||||||
|
const insidePath = '/tmp/test-workdir/docs/report.pdf';
|
||||||
|
mockedRealpathSync.mockReturnValue(insidePath as never);
|
||||||
|
mockedStat.mockResolvedValue({ size: 30, isFile: () => true, mtimeMs: 5 } as never);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: insidePath },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.fileName).toBe('report.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== CONFINEMENT OFF (default) regression: legit cross-workspace attach =====
|
||||||
|
|
||||||
|
it('confinement OFF (default): legit cross-workspace attach still succeeds', async () => {
|
||||||
|
// No CODEMAN_ATTACHMENT_CONFINE set → default OFF. A ~/.codeman review-card
|
||||||
|
// file lives OUTSIDE the /tmp/test-workdir session workspace and must still
|
||||||
|
// attach (protects codeman-publish + the loop's review-card channel).
|
||||||
|
mockedStat.mockResolvedValue({ size: 64, isFile: () => true, mtimeMs: 5 } as never);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: `${homedir()}/.codeman/jira-autoloop-questions.md` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.fileName).toBe('jira-autoloop-questions.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Magic-link scan path: FORCED workspace confinement =====
|
||||||
|
// The terminal-output `codeman://attach` scanner registers with
|
||||||
|
// forceWorkspaceConfinement: true so a prompt-injected session printing an
|
||||||
|
// arbitrary path can't expose a host file, even though global confine is OFF.
|
||||||
|
describe('forced workspace confinement (magic-link scan path)', () => {
|
||||||
|
it('rejects an out-of-workspace path even when global confinement is OFF', async () => {
|
||||||
|
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||||
|
mockedStat.mockResolvedValue({ size: 10, isFile: () => true, mtimeMs: 1 } as never);
|
||||||
|
await expect(
|
||||||
|
registerExternalAttachment('test-session-mlc', '/home/someone/secret/report.pdf', {
|
||||||
|
sessionWorkingDir: '/tmp/test-workdir',
|
||||||
|
forceWorkspaceConfinement: true,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ statusCode: 403 });
|
||||||
|
attachmentRegistry.clearSession('test-session-mlc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an in-workspace path on the forced path', async () => {
|
||||||
|
const inside = '/tmp/test-workdir/sub/report.pdf';
|
||||||
|
mockedRealpathSync.mockReturnValue(inside as never);
|
||||||
|
mockedStat.mockResolvedValue({ size: 10, isFile: () => true, mtimeMs: 1 } as never);
|
||||||
|
const event = await registerExternalAttachment('test-session-mlc', inside, {
|
||||||
|
sessionWorkingDir: '/tmp/test-workdir',
|
||||||
|
forceWorkspaceConfinement: true,
|
||||||
|
});
|
||||||
|
expect(event.fileName).toBe('report.pdf');
|
||||||
|
attachmentRegistry.clearSession('test-session-mlc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -441,6 +441,39 @@ describe('session-routes', () => {
|
|||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
expect(body.success).toBe(false);
|
expect(body.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prepends the live tmux pane buffer (cleared) before the byte history', async () => {
|
||||||
|
harness.ctx._session.terminalBuffer = 'history-bytes';
|
||||||
|
harness.ctx.mux.captureActivePaneBuffer = vi.fn(() => 'LIVE-PANE-FRAME');
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const buf = JSON.parse(res.body).data.terminalBuffer as string;
|
||||||
|
// history, then a viewport clear, then the live pane frame
|
||||||
|
expect(buf).toContain('history-bytes');
|
||||||
|
expect(buf).toContain('\x1b[H\x1b[2J');
|
||||||
|
expect(buf).toContain('LIVE-PANE-FRAME');
|
||||||
|
expect(buf.indexOf('history-bytes')).toBeLessThan(buf.indexOf('LIVE-PANE-FRAME'));
|
||||||
|
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the byte history when no live pane buffer is available', async () => {
|
||||||
|
harness.ctx._session.terminalBuffer = 'history-only';
|
||||||
|
// Empty string (the test-mode return) and null both mean "no live frame".
|
||||||
|
harness.ctx.mux.captureActivePaneBuffer = vi.fn(() => '');
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const buf = JSON.parse(res.body).data.terminalBuffer as string;
|
||||||
|
expect(buf).toContain('history-only');
|
||||||
|
expect(buf).not.toContain('\x1b[H\x1b[2J');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== POST /api/sessions/:id/run ==========
|
// ========== POST /api/sessions/:id/run ==========
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+24
-10
@@ -164,10 +164,24 @@ describe('TmuxManager (unit)', () => {
|
|||||||
cursorY: 1,
|
cursorY: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(snapshot).toBe(`\x1b[1;1H${'x'.repeat(9)}\x1b[2;1Hnext line\x1b[2;3H`);
|
// Full pane width is painted (10 cols); autowrap is avoided by the
|
||||||
|
// absolute cursor positioning, not by dropping the last column.
|
||||||
|
expect(snapshot).toBe(`\x1b[1;1H${'x'.repeat(10)}\x1b[2;1Hnext line\x1b[2;3H`);
|
||||||
expect(snapshot).not.toContain('\n');
|
expect(snapshot).not.toContain('\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves the rightmost column of each captured row', () => {
|
||||||
|
const snapshot = formatPaneSnapshot(['abcd'], {
|
||||||
|
cols: 4,
|
||||||
|
rows: 1,
|
||||||
|
cursorX: 0,
|
||||||
|
cursorY: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Previously truncated to cols - 1 ('abc'); the full width is now kept.
|
||||||
|
expect(snapshot).toBe('\x1b[1;1Habcd\x1b[1;1H');
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves SGR color while stripping non-style pane controls', () => {
|
it('preserves SGR color while stripping non-style pane controls', () => {
|
||||||
const snapshot = formatPaneSnapshot(['\x1b[32mgreen\x1b[0m\x1b[2K\x1b[10;20Htail'], {
|
const snapshot = formatPaneSnapshot(['\x1b[32mgreen\x1b[0m\x1b[2K\x1b[10;20Htail'], {
|
||||||
cols: 40,
|
cols: 40,
|
||||||
@@ -190,18 +204,18 @@ describe('TmuxManager (unit)', () => {
|
|||||||
cursorY: 0,
|
cursorY: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(snapshot).toBe('\x1b[1;1H\x1b[31mabc\x1b[0m\x1b[1;1H');
|
expect(snapshot).toBe('\x1b[1;1H\x1b[31mabcd\x1b[0m\x1b[1;1H');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not let full-width glyphs cross the paint boundary', () => {
|
it('does not let full-width glyphs cross the paint boundary', () => {
|
||||||
const snapshot = formatPaneSnapshot(['abc\u754cdef'], {
|
// cols 5 = 'abc' (3) + full-width \u754c (2) fits exactly; with cols 4 the
|
||||||
cols: 5,
|
// wide glyph would straddle the boundary and is dropped.
|
||||||
rows: 1,
|
expect(formatPaneSnapshot(['abc\u754cdef'], { cols: 5, rows: 1, cursorX: 0, cursorY: 0 })).toBe(
|
||||||
cursorX: 0,
|
'\x1b[1;1Habc\u754c\x1b[1;1H'
|
||||||
cursorY: 0,
|
);
|
||||||
});
|
expect(formatPaneSnapshot(['abc\u754cdef'], { cols: 4, rows: 1, cursorX: 0, cursorY: 0 })).toBe(
|
||||||
|
'\x1b[1;1Habc\x1b[1;1H'
|
||||||
expect(snapshot).toBe('\x1b[1;1Habc\x1b[1;1H');
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps combining marks attached without consuming a terminal column', () => {
|
it('keeps combining marks attached without consuming a terminal column', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user