mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
Merge remote-tracking branch 'origin/master' into cod-28-security-public-assets
This commit is contained in:
+11
-1
@@ -53,7 +53,17 @@ scripts/remotion/out/
|
||||
# Artifacts that should not be tracked
|
||||
test-results/
|
||||
tmp/
|
||||
public
|
||||
# Root `public` (a symlink to scripts/remotion/public — local artifact). ANCHORED
|
||||
# with a leading slash so it does NOT also match src/web/public (a bare `public`
|
||||
# would swallow the whole web UI source dir and silently un-stage any new asset
|
||||
# added there). No trailing slash so it still matches the symlink, not just dirs.
|
||||
/public
|
||||
|
||||
# Opt-in gesture overlay runtime assets: large MediaPipe wasm + model (~27 MB)
|
||||
# fetched at build/install by scripts/fetch-gesture-assets.mjs, kept out of git.
|
||||
# (The gesture bundle itself, gesture-codeman.js, IS tracked.)
|
||||
src/web/public/gesture/wasm/
|
||||
src/web/public/gesture/*.task
|
||||
|
||||
# Claude Code plan tracking
|
||||
plan.json
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Session detach/undock, opt-in gesture-control overlay, multi-monitor spanning, new App-Settings toggles, and asset cache-busting.
|
||||
- **Session detach/undock + instance isolation (#103):** Detach a session into its own solo (popup) window from the tab strip. Adds multi-instance isolation primitives in `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`) keyed off `CODEMAN_INSTANCE`, so a beta can run side-by-side with prod without discovering/attaching to prod's live tmux sessions or clobbering its `state.json`. `CODEMAN_INSTANCE` defaults to the production layout (`~/.codeman`, `-L codeman`, port 3000), so master installs are unaffected. Adds `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`). The legacy `~/.claudeman` migration is now scoped to the default instance only. Hardened detach edge cases. Tests: `test/config/instance.test.ts`.
|
||||
- **Gesture-control overlay (Phase 5, opt-in via `CODEMAN_GESTURE=1`):** Camera hand-tracking overlay (self-hosted MediaPipe — wasm + model fetched at install/build via `scripts/fetch-gesture-assets.mjs` rather than committed). `CODEMAN_GESTURE=1` makes the feature _available_ (CSP widening + `/gesture/` assets + `window.__codemanGestureAvailable`); the per-user **Gesture Control (beta)** toggle (App Settings → Display → Input, default OFF) is the actual on/off and reloads the page to inject/remove the bundle. Dashboard-only (not solo popups). Labeled "(beta)" (#109).
|
||||
- **Multi-monitor button:** Header button (opt-in via App Settings → Display → Header Displays) that POSTs `/api/system/span-displays` to spawn `scripts/span-codeman.sh` — a maximized browser `--app` window sized to the union of all displays, so the gesture layer's floating panels can drag across the physical monitor seam. Tests: `test/routes/system-span-displays.test.ts`.
|
||||
- **New App-Settings toggles (#105):** Gesture control and the multi-monitor button are both opt-in (default OFF), with live show/hide on save.
|
||||
- **Asset cache-busting:** `renderIndexHtml` appends `?v=<mtime>` to every same-origin `.js`/`.css` reference; `index.html` is served `no-cache`, so a normal reload picks up edited modules/styles without a hard refresh. Tests: `test/render-index-html.test.ts`.
|
||||
- **Gesture Control toggle placement:** the toggle now lives inside the existing **Input** settings section (alongside Local Echo / CJK Input / Extended Keyboard Bar) instead of a duplicate "Input" section; only the toggle itself is hidden when `CODEMAN_GESTURE=1` is unset, leaving the rest of the section intact.
|
||||
- **Service env:** `scripts/codeman-web.service` now sets `CODEMAN_GESTURE=1` so the gesture feature is available on the local install (still gated behind the default-OFF per-user toggle).
|
||||
- **Docs:** CLAUDE.md updated for the orchestrator loop, multi-monitor/span-displays, cache-busting, gesture/multi-monitor toggles, and structural-count fixes.
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -30,7 +30,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
2. **Frontend changes**: Use Playwright to load the page and assert the UI renders correctly. Use `waitUntil: 'domcontentloaded'` (not `networkidle` — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values
|
||||
3. **Only after verification passes**, proceed with COM
|
||||
|
||||
The production server caches static files for 1 year (`maxAge: '1y'` in `server.ts`). After deploying frontend changes, users may need a hard refresh (Ctrl+Shift+R) to see updates.
|
||||
The production server caches static files for 1 year, `immutable` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=<mtime>` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an *absolute* URL or from JS rather than a `<script>/<link>` tag, it won't be auto-busted.
|
||||
|
||||
## COM Shorthand (Deployment)
|
||||
|
||||
@@ -56,7 +56,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 0.8.1 (must match `package.json`)
|
||||
**Version**: 0.8.2 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -72,7 +72,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
## Additional Commands
|
||||
|
||||
`npm run dev` = dev server. Default port: `3000`. Commands not in Quick Reference:
|
||||
`npm run dev` = dev server. Default port: `3000` (override with `--port` or the `CODEMAN_PORT` env var). To run this beta isolated alongside a prod Codeman, use `scripts/run-beta.sh` (sets `CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`). Commands not in Quick Reference:
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
@@ -99,6 +99,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
- **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) 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 both prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
|
||||
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
|
||||
- **`xterm-zerolag-input` is duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
|
||||
- **Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`.
|
||||
|
||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||
|
||||
@@ -121,14 +122,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~3.1K 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.4K 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) → 14 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large file (>50KB). 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`.
|
||||
|
||||
**Local package**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; copy embedded in `app.js`.
|
||||
|
||||
**Config**: `src/config/` — 9 files. Import from specific files, not barrel.
|
||||
**Config**: `src/config/` — 10 files. Import from specific files, not barrel.
|
||||
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
|
||||
|
||||
@@ -145,6 +146,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||
|
||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`.
|
||||
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
|
||||
@@ -159,6 +162,10 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Z-index layers**: subagent windows (1000), plan agents (1100), 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`.
|
||||
|
||||
**Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Display → **Input** (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature *available* on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` via `readSettings(true)` — the `true` forces a **fresh** read (bypassing the 2s `_settingsCache`), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected).
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font).
|
||||
@@ -182,7 +189,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### API Routes
|
||||
|
||||
~130 handlers across 15 route files in `src/web/routes/`: system (36), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~130 handlers across 15 route files in `src/web/routes/`: system (37, incl. `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
## Adding Features
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -32,6 +32,9 @@ run('chmod dist/index.js', 'chmod +x dist/index.js');
|
||||
// 2. Copy static assets (clean first to remove stale hashed files from previous builds)
|
||||
run('clean public', 'rm -rf dist/web/public');
|
||||
run('prepare dirs', 'mkdir -p dist/web dist/templates dist/web/public/vendor');
|
||||
// Fetch the opt-in gesture overlay's MediaPipe wasm + model into src/ (idempotent,
|
||||
// non-fatal, kept out of git) so the copy below carries them into dist/.
|
||||
run('gesture assets', 'node scripts/fetch-gesture-assets.mjs');
|
||||
run('copy web assets', 'cp -r src/web/public dist/web/');
|
||||
run('copy template', 'cp src/templates/case-template.md dist/templates/');
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ KillMode=process
|
||||
Environment=NODE_ENV=production
|
||||
Environment=HOME=/home/arkon
|
||||
Environment=NODE_COMPILE_CACHE=/home/arkon/.codeman/compile-cache
|
||||
# Make the gesture-control overlay AVAILABLE (CSP widening + /gesture/ assets +
|
||||
# window.__codemanGestureAvailable). The actual on/off stays the per-user
|
||||
# `gestureControlEnabled` toggle in App Settings → Display → Input (default OFF).
|
||||
Environment=CODEMAN_GESTURE=1
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @fileoverview Fetch the gesture-overlay runtime assets (MediaPipe wasm + the
|
||||
* gesture-recognizer model) into src/web/public/gesture/ so Codeman can serve
|
||||
* them same-origin (a browser content-blocker otherwise blocks the public CDNs
|
||||
* and the overlay fails to start). These are large binaries (~27 MB) kept OUT of
|
||||
* git (ignored explicitly via `src/web/public/gesture/wasm/` + `*.task` in
|
||||
* .gitignore); they are fetched here at install (postinstall) and build time.
|
||||
*
|
||||
* Idempotent: skips files already present. Non-fatal: the gesture overlay is
|
||||
* opt-in (CODEMAN_GESTURE=1), so a fetch failure only warns — it must not break
|
||||
* `npm install` / `npm run build`. The build then copies src/web/public into
|
||||
* dist/ as usual, so prod gets these too.
|
||||
*
|
||||
* The @mediapipe/tasks-vision version MUST match the one bundled into the gesture
|
||||
* overlay (Ark0N/codeman-gesture-control) so the wasm loader matches its JS API.
|
||||
*/
|
||||
import { mkdirSync, existsSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const GESTURE = join(__dirname, '..', 'src', 'web', 'public', 'gesture');
|
||||
const WASM = join(GESTURE, 'wasm');
|
||||
|
||||
const MP_VERSION = '0.10.21'; // keep in sync with the gesture overlay's @mediapipe/tasks-vision
|
||||
const WASM_BASE = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MP_VERSION}/wasm`;
|
||||
const MODEL_URL =
|
||||
'https://storage.googleapis.com/mediapipe-models/gesture_recognizer/gesture_recognizer/float16/1/gesture_recognizer.task';
|
||||
|
||||
const ASSETS = [
|
||||
{ url: `${WASM_BASE}/vision_wasm_internal.js`, path: join(WASM, 'vision_wasm_internal.js') },
|
||||
{ url: `${WASM_BASE}/vision_wasm_internal.wasm`, path: join(WASM, 'vision_wasm_internal.wasm') },
|
||||
{ url: `${WASM_BASE}/vision_wasm_nosimd_internal.js`, path: join(WASM, 'vision_wasm_nosimd_internal.js') },
|
||||
{ url: `${WASM_BASE}/vision_wasm_nosimd_internal.wasm`, path: join(WASM, 'vision_wasm_nosimd_internal.wasm') },
|
||||
{ url: MODEL_URL, path: join(GESTURE, 'gesture_recognizer.task') },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
mkdirSync(WASM, { recursive: true });
|
||||
let fetched = 0;
|
||||
let skipped = 0;
|
||||
for (const a of ASSETS) {
|
||||
if (existsSync(a.path) && statSync(a.path).size > 0) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const res = await fetch(a.url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${a.url}`);
|
||||
writeFileSync(a.path, Buffer.from(await res.arrayBuffer()));
|
||||
fetched++;
|
||||
}
|
||||
console.log(`[gesture] MediaPipe assets ready (${fetched} fetched, ${skipped} cached) → ${GESTURE}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// Non-fatal: opt-in feature. Warn and exit 0 so install/build still succeed.
|
||||
console.warn(`[gesture] could not fetch MediaPipe assets — overlay disabled until fetched: ${err.message}`);
|
||||
});
|
||||
@@ -312,6 +312,20 @@ if (isGlobalInstall) {
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 4b. Fetch gesture-overlay runtime assets (MediaPipe wasm + model) for dev mode
|
||||
// (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal.
|
||||
// Large binaries kept out of git; the build copies them into dist/.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
if (!isGlobalInstall) {
|
||||
try {
|
||||
execSync(`node "${join(import.meta.dirname, 'fetch-gesture-assets.mjs')}"`, { stdio: 'inherit' });
|
||||
} catch {
|
||||
// Non-fatal — the gesture overlay is opt-in.
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 5. Install git pre-commit hook (format check)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# run-beta.sh — launch a BETA Codeman isolated from a production instance.
|
||||
#
|
||||
# Codeman's data dir (~/.codeman) and tmux socket (-L codeman) are process-wide
|
||||
# and shared by every instance on the machine. The code now DEFAULTS to that
|
||||
# production layout on port 3000 (safe for master / existing installs), so a beta
|
||||
# build no longer isolates itself automatically — this wrapper opts it in:
|
||||
#
|
||||
# CODEMAN_INSTANCE=beta → data dir ~/.codeman-beta + tmux socket codeman-beta
|
||||
# CODEMAN_PORT=5000 → listen on 5000 instead of 3000
|
||||
#
|
||||
# Result: the beta runs side-by-side with prod and can never discover/attach to
|
||||
# prod's live tmux sessions or clobber prod's state.json. Override either var to
|
||||
# run additional named instances, e.g. CODEMAN_INSTANCE=foo CODEMAN_PORT=5050.
|
||||
#
|
||||
# Usage: ./scripts/run-beta.sh [extra `codeman web` flags]
|
||||
# Build first (the beta runs the compiled dist): npm run build
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export CODEMAN_INSTANCE="${CODEMAN_INSTANCE:-beta}"
|
||||
export CODEMAN_PORT="${CODEMAN_PORT:-5000}"
|
||||
|
||||
DIST="$(cd "$(dirname "$0")/.." && pwd)/dist/index.js"
|
||||
if [ ! -f "$DIST" ]; then
|
||||
echo "dist not found at $DIST — run 'npm run build' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting beta Codeman: instance='$CODEMAN_INSTANCE' (~/.codeman-$CODEMAN_INSTANCE, -L codeman-$CODEMAN_INSTANCE) on port $CODEMAN_PORT"
|
||||
exec node "$DIST" web "$@"
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# span-codeman.sh — open a Codeman window stretched across ALL displays, so that
|
||||
# in-page floating session panels can be dragged from one physical monitor to
|
||||
# the other. Spawned by the header "multi-monitor" button (POST
|
||||
# /api/system/span-displays), or run by hand at the desk.
|
||||
#
|
||||
# ── PREREQUISITE (one-time, manual) ──────────────────────────────────────────
|
||||
# System Settings → Desktop & Dock → turn OFF "Displays have separate Spaces",
|
||||
# then LOG OUT and back in. Until you do, macOS keeps every window on a single
|
||||
# display and this script's window will clamp to one monitor instead of spanning.
|
||||
# (Equivalent CLI: `defaults write com.apple.spaces spans-displays -bool true`,
|
||||
# still needs a re-login. Revert with `-bool false`.)
|
||||
#
|
||||
# Why a maximized --app window and not fullscreen: browser fullscreen is
|
||||
# per-display and will NOT span. We size a windowed app to the union of all
|
||||
# displays instead. macOS only.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
URL="${1:-http://localhost:5000}"
|
||||
|
||||
# Union rect of all displays in top-left-origin points — exactly what Chromium's
|
||||
# --window-position/--window-size expect. Finder's desktop window bounds already
|
||||
# encloses every monitor (and handles a monitor placed left/above via a negative
|
||||
# origin), so no per-display math or coordinate flipping is needed.
|
||||
bounds=$(osascript -e 'tell application "Finder" to get bounds of window of desktop')
|
||||
X=$(echo "$bounds" | awk -F', *' '{print $1}')
|
||||
Y=$(echo "$bounds" | awk -F', *' '{print $2}')
|
||||
R=$(echo "$bounds" | awk -F', *' '{print $3}')
|
||||
B=$(echo "$bounds" | awk -F', *' '{print $4}')
|
||||
W=$((R - X))
|
||||
H=$((B - Y))
|
||||
echo "Display union: position ${X},${Y} size ${W}x${H}"
|
||||
|
||||
# Pick a Chromium-family browser. Brave leads the list — plain Google Chrome
|
||||
# bounced when launched this way on the desk machine (created its profile then
|
||||
# exited without a window). Force a specific one with, e.g.,
|
||||
# BROWSER="Google Chrome" ./span-codeman.sh
|
||||
app="${BROWSER:-}"
|
||||
if [ -z "$app" ]; then
|
||||
for c in "Brave Browser" "Google Chrome" "Google Chrome Beta" "Chromium" "Microsoft Edge"; do
|
||||
[ -x "/Applications/$c.app/Contents/MacOS/$c" ] && app="$c" && break
|
||||
done
|
||||
fi
|
||||
bin="/Applications/$app.app/Contents/MacOS/$app"
|
||||
[ -n "$app" ] && [ -x "$bin" ] || { echo "No Chrome-family browser found (BROWSER='$app')" >&2; exit 1; }
|
||||
|
||||
# A dedicated, PER-BROWSER profile forces a FRESH instance — an already-running
|
||||
# browser would hand the URL to itself and silently ignore the geometry flags.
|
||||
# Per-browser so a Chrome-made profile can't confuse Brave (or vice-versa).
|
||||
slug=$(echo "$app" | tr '[:upper:] ' '[:lower:]-')
|
||||
profile="$HOME/.codeman-gesture-$slug"
|
||||
|
||||
echo "Browser: $bin"
|
||||
echo "URL: $URL"
|
||||
|
||||
# Detach so the caller (terminal / web server) isn't blocked for the window's life.
|
||||
nohup "$bin" \
|
||||
--app="$URL" \
|
||||
--user-data-dir="$profile" \
|
||||
--window-position="${X},${Y}" \
|
||||
--window-size="${W},${H}" \
|
||||
--no-first-run \
|
||||
--no-default-browser-check \
|
||||
>/dev/null 2>&1 &
|
||||
|
||||
echo "Launched spanning window (pid $!)."
|
||||
echo "If it filled only one monitor, the 'separate Spaces' prerequisite above"
|
||||
echo "isn't active yet — toggle it off, log out/in, and re-run."
|
||||
+12
-4
@@ -483,21 +483,29 @@ program
|
||||
program
|
||||
.command('web')
|
||||
.description('Start the web interface')
|
||||
.option('-p, --port <port>', 'Port to listen on', '3000')
|
||||
.option('-H, --host <host>', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1')
|
||||
.option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000')
|
||||
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
||||
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
|
||||
.option(
|
||||
'--allow-unauthenticated-network',
|
||||
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
|
||||
)
|
||||
.action(async (options) => {
|
||||
const { startWebServer } = await import('./web/server.js');
|
||||
const host = options.host;
|
||||
const port = parseInt(options.port, 10);
|
||||
const https = !!options.https;
|
||||
const titleHostname = options.titleHostname;
|
||||
const allowUnauthenticatedNetwork = !!options.allowUnauthenticatedNetwork;
|
||||
const protocol = https ? 'https' : 'http';
|
||||
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
||||
|
||||
console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
console.log(chalk.cyan(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
|
||||
try {
|
||||
const server = await startWebServer(port, https, false, titleHostname);
|
||||
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://localhost:${port}`));
|
||||
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork);
|
||||
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://${displayHost}:${port}`));
|
||||
if (https) {
|
||||
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @fileoverview Per-instance isolation: data directory + tmux socket.
|
||||
*
|
||||
* Codeman keeps all runtime state under `~/.codeman` and runs its tmux sessions
|
||||
* on a dedicated socket (`tmux -L codeman`). Both are PROCESS-WIDE and SHARED by
|
||||
* every Codeman instance on the machine — so a second instance pointed at the
|
||||
* same socket will discover and attach to the first instance's live sessions,
|
||||
* and two instances sharing `~/.codeman/state.json` will clobber each other.
|
||||
*
|
||||
* To let a beta build coexist with a production one, this module derives both
|
||||
* the data dir and the tmux socket from a single "instance" name:
|
||||
* - default (unset/empty) → `~/.codeman` + `tmux -L codeman` (prod layout)
|
||||
* - `CODEMAN_INSTANCE=beta` → `~/.codeman-beta` + `tmux -L codeman-beta`
|
||||
* - `CODEMAN_INSTANCE=foo` → `~/.codeman-foo` + `tmux -L codeman-foo`
|
||||
*
|
||||
* The DEFAULT is the production layout so this is safe to ship to master: an
|
||||
* existing install keeps reading `~/.codeman`. To run a beta ALONGSIDE prod,
|
||||
* launch it with `CODEMAN_INSTANCE=beta` (and a distinct port, see below) —
|
||||
* `scripts/run-beta.sh` does both. The port is unrelated to the instance and is
|
||||
* set separately via `--port` / `CODEMAN_PORT` (see `src/cli.ts`).
|
||||
*
|
||||
* Individual overrides still win: `CODEMAN_DATA_DIR` (absolute data dir) and
|
||||
* `CODEMAN_TMUX_SOCKET` (socket name, validated in tmux-manager).
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Instance name. Empty string (the default) = production layout (`~/.codeman`,
|
||||
* `-L codeman`), so this is safe on master and existing installs are untouched.
|
||||
* Set `CODEMAN_INSTANCE=beta` (e.g. via `scripts/run-beta.sh`) to run an
|
||||
* isolated beta alongside prod.
|
||||
*/
|
||||
export const CODEMAN_INSTANCE = process.env.CODEMAN_INSTANCE ?? '';
|
||||
|
||||
const INSTANCE_SUFFIX = CODEMAN_INSTANCE ? `-${CODEMAN_INSTANCE}` : '';
|
||||
|
||||
/** Default tmux socket for this instance. `CODEMAN_TMUX_SOCKET` still overrides. */
|
||||
export const DEFAULT_TMUX_SOCKET = `codeman${INSTANCE_SUFFIX}`;
|
||||
|
||||
let _ensured = false;
|
||||
|
||||
/**
|
||||
* Absolute path to this instance's data directory (created on first use). All
|
||||
* persisted state (`state.json`, `mux-sessions.json`, settings, push keys,
|
||||
* lifecycle log, screenshots, certs, …) lives here.
|
||||
*/
|
||||
export function getDataDir(): string {
|
||||
const dir = process.env.CODEMAN_DATA_DIR || join(homedir(), `.codeman${INSTANCE_SUFFIX}`);
|
||||
if (!_ensured) {
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
_ensured = true;
|
||||
} catch {
|
||||
/* best-effort; individual writers also mkdir as needed */
|
||||
}
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Join one or more segments onto this instance's data directory. */
|
||||
export function dataPath(...segments: string[]): string {
|
||||
return join(getDataDir(), ...segments);
|
||||
}
|
||||
+2
-2
@@ -8,12 +8,12 @@
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import webpush from 'web-push';
|
||||
import type { VapidKeys, PushSubscriptionRecord } from './types.js';
|
||||
import { Debouncer } from './utils/index.js';
|
||||
import { getDataDir } from './config/instance.js';
|
||||
|
||||
const DATA_DIR = join(homedir(), '.codeman');
|
||||
const DATA_DIR = getDataDir();
|
||||
const KEYS_FILE = join(DATA_DIR, 'push-keys.json');
|
||||
const SUBS_FILE = join(DATA_DIR, 'push-subscriptions.json');
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
|
||||
import { appendFile, readFile, writeFile } from 'node:fs/promises';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname } from 'node:path';
|
||||
import type { LifecycleEventType, LifecycleEntry } from './types.js';
|
||||
import { dataPath } from './config/instance.js';
|
||||
|
||||
const MAX_LINES = 10_000;
|
||||
const TRIM_TO = 8_000;
|
||||
@@ -22,7 +22,7 @@ export class SessionLifecycleLog {
|
||||
private writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(filePath?: string) {
|
||||
this.filePath = filePath || join(homedir(), '.codeman', 'session-lifecycle.jsonl');
|
||||
this.filePath = filePath || dataPath('session-lifecycle.jsonl');
|
||||
const dir = dirname(this.filePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
|
||||
+6
-3
@@ -39,6 +39,7 @@ import {
|
||||
TokenUsageEntry,
|
||||
} from './types.js';
|
||||
import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js';
|
||||
import { dataPath, CODEMAN_INSTANCE } from './config/instance.js';
|
||||
|
||||
/** Debounce delay for batching state writes (ms) */
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
@@ -89,8 +90,10 @@ export class StateStore {
|
||||
private _saveInFlight: Promise<void> | null = null;
|
||||
|
||||
constructor(filePath?: string) {
|
||||
// Migrate legacy data directory (~/.claudeman → ~/.codeman)
|
||||
if (!filePath) {
|
||||
// Migrate legacy data directory (~/.claudeman → ~/.codeman). Default (prod)
|
||||
// instance only — a named instance (e.g. beta) must never touch the shared
|
||||
// ~/.codeman / ~/codeman-cases layout, preserving instance isolation.
|
||||
if (!filePath && !CODEMAN_INSTANCE) {
|
||||
const legacyDir = join(homedir(), '.claudeman');
|
||||
const newDir = join(homedir(), '.codeman');
|
||||
if (existsSync(legacyDir) && !existsSync(newDir)) {
|
||||
@@ -105,7 +108,7 @@ export class StateStore {
|
||||
}
|
||||
}
|
||||
|
||||
this.filePath = filePath || join(homedir(), '.codeman', 'state.json');
|
||||
this.filePath = filePath || dataPath('state.json');
|
||||
this.ralphStatePath = this.filePath.replace('.json', '-inner.json');
|
||||
this.state = this.load();
|
||||
this.state.config.stateFilePath = this.filePath;
|
||||
|
||||
+6
-5
@@ -28,8 +28,8 @@ import { promisify } from 'node:util';
|
||||
const execAsync = promisify(exec);
|
||||
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
import { writeFile, rename } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath, DEFAULT_TMUX_SOCKET } from './config/instance.js';
|
||||
import {
|
||||
ProcessStats,
|
||||
PersistedRespawnConfig,
|
||||
@@ -92,7 +92,7 @@ export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
|
||||
const IS_TEST_MODE = !!process.env.VITEST;
|
||||
|
||||
/** Path to persisted mux session metadata */
|
||||
const MUX_SESSIONS_FILE = join(homedir(), '.codeman', 'mux-sessions.json');
|
||||
const MUX_SESSIONS_FILE = dataPath('mux-sessions.json');
|
||||
|
||||
/** Regex to validate tmux session names (only allow safe characters) */
|
||||
const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/;
|
||||
@@ -103,8 +103,9 @@ const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
|
||||
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
|
||||
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
|
||||
|
||||
/** Dedicated tmux socket for new Codeman-owned sessions. */
|
||||
const DEFAULT_CODEMAN_TMUX_SOCKET = 'codeman';
|
||||
/** Dedicated tmux socket for new Codeman-owned sessions (instance-scoped:
|
||||
* `codeman` for prod, `codeman-beta` on the beta branch). */
|
||||
const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET;
|
||||
|
||||
/** Regex to validate tmux socket names passed to `tmux -L`. */
|
||||
const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||||
|
||||
+30
-12
@@ -8,7 +8,7 @@
|
||||
* - CORS (localhost only)
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { StaleExpirationMap } from '../../utils/index.js';
|
||||
import type { AuthSessionRecord } from '../ports/auth-port.js';
|
||||
@@ -69,6 +69,13 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
const authSessions = state.authSessions;
|
||||
const authFailures = state.authFailures;
|
||||
|
||||
function sendAuthRateLimit(reply: FastifyReply, clientIp: string): void {
|
||||
const remainingMs = authFailures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
||||
reply.header('Retry-After', String(retryAfterSeconds));
|
||||
reply.code(429).send('Too Many Requests — try again later');
|
||||
}
|
||||
|
||||
app.addHook('onRequest', (req, reply, done) => {
|
||||
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
|
||||
// Safe: validated by HookEventSchema, only triggers broadcasts.
|
||||
@@ -90,13 +97,6 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Rate limit: reject if too many failed attempts from this IP
|
||||
const failures = authFailures.get(clientIp) ?? 0;
|
||||
if (failures >= AUTH_FAILURE_MAX) {
|
||||
reply.code(429).send('Too Many Requests — try again later');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check session cookie first (avoids re-sending credentials on every request)
|
||||
// Use get() instead of has() so refreshOnGet extends the TTL on active sessions
|
||||
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
||||
@@ -140,6 +140,13 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit only requests that failed to authenticate on this attempt.
|
||||
const failures = authFailures.get(clientIp) ?? 0;
|
||||
if (failures >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, clientIp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth failed — track failure count
|
||||
authFailures.set(clientIp, failures + 1);
|
||||
|
||||
@@ -154,13 +161,24 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
* Register security headers and CORS middleware on every response.
|
||||
*/
|
||||
export function registerSecurityHeaders(app: FastifyInstance, https: boolean): void {
|
||||
// Gesture-control overlay (opt-in via CODEMAN_GESTURE=1) runs MediaPipe, which
|
||||
// needs WebAssembly eval (script-src) and blob workers (worker-src). Its wasm
|
||||
// runtime + model are self-hosted under /gesture/ (same-origin, covered by
|
||||
// 'self'), so no CDN connect-src entries are needed. OFF by default so the
|
||||
// production CSP is byte-for-byte unchanged.
|
||||
const gesture = process.env.CODEMAN_GESTURE === '1';
|
||||
const scriptSrc =
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net" + (gesture ? " 'wasm-unsafe-eval'" : '');
|
||||
const connectSrc = "connect-src 'self' wss://api.deepgram.com";
|
||||
const workerSrc = gesture ? "; worker-src 'self' blob:" : '';
|
||||
const csp =
|
||||
`default-src 'self'; ${scriptSrc}; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; ` +
|
||||
`img-src 'self' data: blob:; ${connectSrc}; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'${workerSrc}`;
|
||||
|
||||
app.addHook('onRequest', (req, reply, done) => {
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.header('X-Frame-Options', 'SAMEORIGIN');
|
||||
reply.header(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self' wss://api.deepgram.com; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'"
|
||||
);
|
||||
reply.header('Content-Security-Policy', csp);
|
||||
if (https) {
|
||||
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
const EXPLICIT_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
|
||||
export function isExplicitlyEnabled(value: string | undefined): boolean {
|
||||
return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function isLoopbackBindHost(host: string): boolean {
|
||||
const normalized = host
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\[(.*)\]$/, '$1');
|
||||
if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') {
|
||||
return true;
|
||||
}
|
||||
if (isIP(normalized) === 4 && normalized.startsWith('127.')) {
|
||||
return true;
|
||||
}
|
||||
return normalized.startsWith('::ffff:127.');
|
||||
}
|
||||
+295
-1
@@ -295,6 +295,22 @@ class CodemanApp {
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
this.activeSessionId = null;
|
||||
|
||||
// ── Session detach / undock (beta) ───────────────────────────────────
|
||||
// A "solo window" is a popped-out browser window showing exactly one
|
||||
// session. Detected from the /session/:id URL path (robust even if a cached
|
||||
// service-worker shell loads), with the server-injected global as a fallback.
|
||||
this.soloSessionId = this._detectSoloSessionId();
|
||||
this.isSoloWindow = !!this.soloSessionId;
|
||||
this.detachedSessions = new Set(); // dashboard-side: ids currently popped out
|
||||
this.detachedWindows = new Map(); // dashboard-side: id -> WindowProxy
|
||||
this._detachWatchTimers = new Map(); // dashboard-side: id -> setInterval handle
|
||||
this.windowChannel = null; // BroadcastChannel for cross-window sync
|
||||
this._redockGrace = new Map(); // id -> timer: deferred redock (debounces popup reloads)
|
||||
this._detachPingPending = null; // Set of ids awaiting a liveness answer
|
||||
this._detachLivenessTimer = null; // periodic reconcile of channel-only detached windows
|
||||
this._detachOrphanStrikes = new Map(); // id -> consecutive unanswered roll-calls (redock at 2)
|
||||
|
||||
this._initGeneration = 0; // dedup concurrent handleInit calls
|
||||
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
||||
this._selectGeneration = 0; // cancel stale selectSession loads
|
||||
@@ -544,6 +560,11 @@ class CodemanApp {
|
||||
init() {
|
||||
// Initialize mobile detection first (adds device classes to body)
|
||||
MobileDetection.init();
|
||||
// Detach/undock: open the cross-window sync channel; if this is a solo
|
||||
// (popped-out) window, apply its minimal chrome immediately so the tab
|
||||
// strip never flashes before handleInit selects the target session.
|
||||
this._initWindowChannel();
|
||||
if (this.isSoloWindow) document.body.classList.add('solo-mode');
|
||||
// Initialize mobile handlers
|
||||
KeyboardHandler.init();
|
||||
SwipeHandler.init();
|
||||
@@ -776,6 +797,256 @@ class CodemanApp {
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// Session detach / undock (beta/session-detach)
|
||||
//
|
||||
// Each detached window is just another normal client of the same session:
|
||||
// the server already fans one PTY's output out to N SSE/WS clients and merges
|
||||
// input from all of them, so a popped-out window is live with no extra server
|
||||
// plumbing. The dashboard tracks which sessions are out, marks their tabs, and
|
||||
// re-docks when the window closes. A BroadcastChannel keeps state in sync
|
||||
// across windows (and survives a dashboard reload via roll-call).
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Resolve the solo session id from the URL path (preferred) or the
|
||||
* server-injected global (fallback). Returns null for the normal dashboard. */
|
||||
_detectSoloSessionId() {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && typeof window.__CODEMAN_SOLO__ === 'string' && window.__CODEMAN_SOLO__) {
|
||||
return window.__CODEMAN_SOLO__;
|
||||
}
|
||||
const m = location.pathname.match(/^\/session\/([^/]+)\/?$/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop a session out into its own browser window. SINGLE, idempotent entry
|
||||
* point: the tab's pop-out icon calls this, and a future gesture layer
|
||||
* ("pinch to drop") calls the exact same method — so keep it cheap and
|
||||
* side-effect-light. Calling it again for an already-open window just raises
|
||||
* that window.
|
||||
* @param {string} id session id
|
||||
*/
|
||||
detachSession(id) {
|
||||
if (this.isSoloWindow) return; // a solo window can't spawn more
|
||||
if (!this.sessions.has(id)) return;
|
||||
// Already detached → raise the existing popup instead of opening (or
|
||||
// reloading) another. Mirrors the tab-click path: after a dashboard reload
|
||||
// we hold no WindowProxy ref, so this raises via the channel rather than
|
||||
// re-running window.open (which would reload the popup's terminal). Returns
|
||||
// false only when we owned a now-closed window (re-dock + fall through to
|
||||
// genuinely re-open below).
|
||||
if (this.detachedSessions.has(id) && this._raiseDetached(id)) return;
|
||||
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
|
||||
let win = null;
|
||||
try { win = window.open('/session/' + encodeURIComponent(id), 'codeman-session-' + id, features); } catch {}
|
||||
if (!win) {
|
||||
this.showToast?.('Pop-out blocked — allow popups for this site to detach a session', 'error');
|
||||
return;
|
||||
}
|
||||
this.detachedWindows.set(id, win);
|
||||
this._markDetached(id, true);
|
||||
this._watchDetachedWindow(id, win);
|
||||
this._postWindowMessage({ type: 'detached', id });
|
||||
try { win.focus(); } catch {}
|
||||
}
|
||||
|
||||
/** Raise the popup for an already-detached session. Returns true if the raise
|
||||
* was handled (caller should stop); false if we owned a now-closed window and
|
||||
* re-docked it (caller should fall through to inline / re-open). Unifies the
|
||||
* pop-out icon and tab-click paths so neither reloads a live popup. */
|
||||
_raiseDetached(id) {
|
||||
const win = this.detachedWindows.get(id);
|
||||
if (win && !win.closed) { try { win.focus(); } catch {} return true; }
|
||||
if (win && win.closed) { this._redock(id); return false; } // owned ref dead → redock + fall through
|
||||
// No local ref (dashboard reloaded): assume alive and raise via the channel.
|
||||
// A liveness ping (or the popup's own unload) heals the badge if it's gone.
|
||||
this._postWindowMessage({ type: 'focus-request', id });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Re-dock a session: close its window (which re-docks via its unload
|
||||
* announcement) and clear dashboard state now. */
|
||||
redockSession(id) {
|
||||
const win = this.detachedWindows.get(id);
|
||||
if (win && !win.closed) { try { win.close(); } catch {} }
|
||||
this._postWindowMessage({ type: 'close-request', id });
|
||||
this._redock(id);
|
||||
}
|
||||
|
||||
/** Clear all dashboard-side detached state/timers for a session. */
|
||||
_redock(id) {
|
||||
const t = this._detachWatchTimers.get(id);
|
||||
if (t) { clearInterval(t); this._detachWatchTimers.delete(id); }
|
||||
this._cancelPendingRedock(id);
|
||||
this._detachOrphanStrikes.delete(id);
|
||||
this.detachedWindows.delete(id);
|
||||
this._markDetached(id, false);
|
||||
}
|
||||
|
||||
/** Defer a channel-driven redock briefly. A popup *reload* emits 'redocked'
|
||||
* then re-announces 'detached'; the grace window lets that re-announce cancel
|
||||
* the redock, so a reload doesn't blip the dashboard badge. A real close
|
||||
* leaves the redock unanswered and it fires. */
|
||||
_scheduleRedock(id) {
|
||||
if (this._redockGrace.has(id)) return;
|
||||
const timer = setTimeout(() => { this._redockGrace.delete(id); this._redock(id); }, 1500);
|
||||
this._redockGrace.set(id, timer);
|
||||
}
|
||||
|
||||
_cancelPendingRedock(id) {
|
||||
const t = this._redockGrace.get(id);
|
||||
if (t) { clearTimeout(t); this._redockGrace.delete(id); }
|
||||
}
|
||||
|
||||
/** Toggle the "detached" marker on a tab (immediate DOM update + state set).
|
||||
* Full re-renders re-apply the class from this.detachedSessions. */
|
||||
_markDetached(id, on) {
|
||||
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
|
||||
const container = this.$('sessionTabs');
|
||||
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||
if (tab) tab.classList.toggle('detached', on);
|
||||
}
|
||||
|
||||
/** Poll a window we opened; when it closes, re-dock its tab. This is the
|
||||
* primary (reliable) close-detection path for windows this tab opened. */
|
||||
_watchDetachedWindow(id, win) {
|
||||
const prev = this._detachWatchTimers.get(id);
|
||||
if (prev) clearInterval(prev);
|
||||
const timer = setInterval(() => {
|
||||
if (!win || win.closed) {
|
||||
clearInterval(timer);
|
||||
this._detachWatchTimers.delete(id);
|
||||
this._redock(id);
|
||||
}
|
||||
}, 800);
|
||||
this._detachWatchTimers.set(id, timer);
|
||||
}
|
||||
|
||||
/** Open the cross-window BroadcastChannel and wire role-specific handlers. */
|
||||
_initWindowChannel() {
|
||||
if (typeof BroadcastChannel === 'undefined') return;
|
||||
try { this.windowChannel = new BroadcastChannel('codeman-windows'); }
|
||||
catch { this.windowChannel = null; return; }
|
||||
this.windowChannel.onmessage = (e) => this._onWindowMessage(e.data);
|
||||
if (this.isSoloWindow) {
|
||||
// Announce presence so the dashboard marks this session's tab detached —
|
||||
// even if this window was opened directly by URL rather than window.open.
|
||||
this._postWindowMessage({ type: 'detached', id: this.soloSessionId });
|
||||
// On close, tell the dashboard to re-dock. pagehide is the reliable signal
|
||||
// on modern browsers; beforeunload is a belt-and-suspenders fallback.
|
||||
const announceClose = () => this._postWindowMessage({ type: 'redocked', id: this.soloSessionId });
|
||||
window.addEventListener('pagehide', announceClose);
|
||||
window.addEventListener('beforeunload', announceClose);
|
||||
} else {
|
||||
// Dashboard: ask any already-open solo windows to re-announce themselves
|
||||
// (covers a dashboard reload while popups remain open), then keep
|
||||
// reconciling so a popup that died WITHOUT a 'redocked' (hard kill / crash)
|
||||
// eventually un-marks its tab.
|
||||
this._postWindowMessage({ type: 'roll-call' });
|
||||
this._startDetachLiveness();
|
||||
}
|
||||
}
|
||||
|
||||
_postWindowMessage(msg) {
|
||||
try { if (this.windowChannel) this.windowChannel.postMessage(msg); } catch {}
|
||||
}
|
||||
|
||||
_onWindowMessage(msg) {
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
if (this.isSoloWindow) {
|
||||
// Roll-call has no id (broadcast to all) — answer before the id filter.
|
||||
if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; }
|
||||
if (msg.id !== this.soloSessionId) return;
|
||||
if (msg.type === 'close-request') { try { window.close(); } catch {} }
|
||||
else if (msg.type === 'focus-request') { try { window.focus(); } catch {} }
|
||||
return;
|
||||
}
|
||||
// Dashboard side.
|
||||
if (msg.type === 'detached' && msg.id) {
|
||||
this._cancelPendingRedock(msg.id); // a re-announce (e.g. popup reload) cancels a deferred redock
|
||||
this._detachPingPending?.delete(msg.id); // and proves liveness for this tick
|
||||
this._detachOrphanStrikes.delete(msg.id); // any answer clears accumulated misses
|
||||
this._markDetached(msg.id, true);
|
||||
} else if (msg.type === 'redocked' && msg.id) {
|
||||
this._scheduleRedock(msg.id); // defer: a popup reload fires redocked→detached; grace avoids a badge blip
|
||||
} else if (msg.type === 'detach-request' && msg.id) {
|
||||
// Future gesture hook: another window asks the dashboard to detach a tab.
|
||||
this.detachSession(msg.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Dashboard: periodically reconcile detached tabs we hold no window ref for
|
||||
* (e.g. after a dashboard reload). Owned windows are covered by the
|
||||
* win.closed poll; channel-only ones can only be checked by asking them to
|
||||
* re-announce and re-docking any that stay silent. */
|
||||
_startDetachLiveness() {
|
||||
if (this._detachLivenessTimer) return;
|
||||
this._detachLivenessTimer = setInterval(() => this._pingDetached(), 5000);
|
||||
}
|
||||
|
||||
_pingDetached() {
|
||||
const orphans = [];
|
||||
for (const id of this.detachedSessions) {
|
||||
const win = this.detachedWindows.get(id);
|
||||
if (!win) orphans.push(id); // channel-only — must verify via re-announce
|
||||
else if (win.closed) this._redock(id); // owned & closed — heal now
|
||||
}
|
||||
if (!orphans.length) return;
|
||||
this._detachPingPending = new Set(orphans);
|
||||
this._postWindowMessage({ type: 'roll-call' });
|
||||
// Live popups answer 'detached' (clearing themselves above); survivors stay in
|
||||
// the pending set. Redock only after TWO consecutive unanswered roll-calls — a
|
||||
// backgrounded popup is timer-throttled and may miss a single 1.2s window, and
|
||||
// we don't want to wrongly un-mark a still-open tab. A later answer resets the
|
||||
// strike count (see _onWindowMessage).
|
||||
setTimeout(() => {
|
||||
if (!this._detachPingPending) return;
|
||||
for (const id of this._detachPingPending) {
|
||||
const strikes = (this._detachOrphanStrikes.get(id) || 0) + 1;
|
||||
if (strikes >= 2) { this._detachOrphanStrikes.delete(id); this._redock(id); }
|
||||
else this._detachOrphanStrikes.set(id, strikes);
|
||||
}
|
||||
this._detachPingPending = null;
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
/** Solo window: select the target session and apply minimal single-session
|
||||
* chrome. Called from handleInit once the session list has loaded. */
|
||||
_applySoloMode() {
|
||||
document.body.classList.add('solo-mode');
|
||||
const session = this.sessions.get(this.soloSessionId);
|
||||
if (!session) { this._showSoloSessionGone(); return; }
|
||||
// Force re-select (handleInit cleared terminal state above).
|
||||
this.activeSessionId = null;
|
||||
this.selectSession(this.soloSessionId);
|
||||
const name = this.getSessionName(session) || 'Session';
|
||||
const titleEl = document.getElementById('soloSessionTitle');
|
||||
if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; }
|
||||
const redock = document.getElementById('soloRedockBtn');
|
||||
if (redock) redock.style.display = '';
|
||||
document.title = name + ' — Codeman';
|
||||
if (this.notificationManager) this.notificationManager.originalTitle = document.title;
|
||||
// Neutralize the dashboard-only brand click in a solo window.
|
||||
const logo = document.querySelector('.header-brand .logo');
|
||||
if (logo) logo.onclick = (e) => { e.preventDefault(); };
|
||||
}
|
||||
|
||||
/** Solo window: the target session is gone (never existed, or ended while
|
||||
* this window was open). Show a friendly terminal state. */
|
||||
_showSoloSessionGone() {
|
||||
document.body.classList.add('solo-mode');
|
||||
if (document.querySelector('.solo-gone-overlay')) return;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'solo-gone-overlay';
|
||||
el.innerHTML = '<h2>Session unavailable</h2>'
|
||||
+ '<p>This session has ended or is no longer available.</p>'
|
||||
+ '<button class="btn-primary" onclick="window.close()">Close window</button>';
|
||||
document.body.appendChild(el);
|
||||
document.title = 'Session ended — Codeman';
|
||||
}
|
||||
|
||||
connectSSE() {
|
||||
// Check if browser is offline
|
||||
if (!navigator.onLine) {
|
||||
@@ -929,6 +1200,12 @@ class CodemanApp {
|
||||
|
||||
_onSessionDeleted(data) {
|
||||
if (this._wsSessionId === data.id) this._disconnectWs();
|
||||
// Solo window whose session just ended → show the "unavailable" state.
|
||||
if (this.isSoloWindow && data.id === this.soloSessionId) {
|
||||
this._showSoloSessionGone();
|
||||
}
|
||||
// Dashboard: a detached session ended → clear its detached state/timers.
|
||||
if (this.detachedSessions.has(data.id)) this._redock(data.id);
|
||||
this._cleanupSessionData(data.id);
|
||||
if (this.activeSessionId === data.id) {
|
||||
this.activeSessionId = null;
|
||||
@@ -1949,6 +2226,14 @@ class CodemanApp {
|
||||
// Reset activeSessionId so selectSession doesn't early-return.
|
||||
// Guard: skip if a newer handleInit has already started (race between loadState + SSE init).
|
||||
if (gen !== this._initGeneration) return;
|
||||
|
||||
// Solo (detached) window: always show exactly the target session, ignoring
|
||||
// the dashboard's "restore last active" logic.
|
||||
if (this.isSoloWindow) {
|
||||
this._applySoloMode();
|
||||
return;
|
||||
}
|
||||
|
||||
const previousActiveId = this.activeSessionId;
|
||||
this.activeSessionId = null;
|
||||
if (this.sessionOrder.length > 0) {
|
||||
@@ -2182,19 +2467,21 @@ class CodemanApp {
|
||||
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
||||
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${this.detachedSessions.has(id) ? ' detached' : ''}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||
</span>
|
||||
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
|
||||
</span>
|
||||
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
|
||||
${subagentBadge}
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession('${escapeHtml(id)}')" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
||||
</div>`);
|
||||
_tabIdx++;
|
||||
@@ -2525,6 +2812,13 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
async selectSession(sessionId) {
|
||||
// If this session is popped out into its own window, raise that window
|
||||
// instead of showing it inline (focus-on-click for detached tabs).
|
||||
if (!this.isSoloWindow && this.detachedSessions.has(sessionId)) {
|
||||
// Raise the popup instead of showing inline. If we owned a now-closed
|
||||
// window, _raiseDetached re-docks and returns false so we fall through.
|
||||
if (this._raiseDetached(sessionId)) return;
|
||||
}
|
||||
if (this.activeSessionId === sessionId) return;
|
||||
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
|
||||
// programmatic focus() within the user-gesture call stack (e.g. tab click).
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,6 +2,10 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!-- Resolve all relative assets against the site root so the same shell can be
|
||||
served at /session/:id (detached single-session window) without 404ing
|
||||
on relative <script>/<link> URLs. Must precede the first resource tag. -->
|
||||
<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="description" content="Claude Code session manager with web interface">
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
@@ -55,7 +59,9 @@
|
||||
<div class="skeleton-toolbar"></div>
|
||||
</div>
|
||||
<!-- Skip link for keyboard users -->
|
||||
<a href="#terminalContainer" class="skip-link">Skip to terminal</a>
|
||||
<!-- onclick scrolls/focuses directly: with <base href="/"> a bare href="#..." would
|
||||
navigate to /#... (the dashboard) from a /session/:id solo window. -->
|
||||
<a href="#terminalContainer" class="skip-link" onclick="event.preventDefault(); var t=document.getElementById('terminalContainer'); if(t){t.scrollIntoView(); var f=t.querySelector('textarea,[tabindex]'); (f||t).focus&&(f||t).focus();}">Skip to terminal</a>
|
||||
<div class="app">
|
||||
<!-- Compact Header with Session Tabs -->
|
||||
<header class="header">
|
||||
@@ -67,7 +73,11 @@
|
||||
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
|
||||
</div>
|
||||
|
||||
<!-- Detached single-session window title (shown only in solo mode) -->
|
||||
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
|
||||
|
||||
<div class="header-right">
|
||||
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">⊞</button>
|
||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||
<span class="tunnel-dot"></span>
|
||||
</button>
|
||||
@@ -97,7 +107,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-response-viewer-header" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications">
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
@@ -922,6 +933,16 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item settings-item-multiline" id="appSettingsGestureControlItem" title="Enable the camera hand-tracking gesture overlay (applied on reload). The instance must run with CODEMAN_GESTURE=1.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Gesture Control (beta)</span>
|
||||
<span class="settings-item-desc">Camera hand-tracking overlay (applied on reload)</span>
|
||||
</div>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsGestureControl">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Header Displays Section -->
|
||||
<div class="settings-section-header">Header Displays</div>
|
||||
@@ -960,6 +981,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the multi-monitor button in the header (opens Codeman spanned across all displays)">
|
||||
<span class="settings-item-label">Multi-monitor Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowMultiMonitorButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar Section -->
|
||||
<div class="settings-section-header">Tab Bar</div>
|
||||
|
||||
@@ -3129,6 +3129,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.notificationManager?.toggleDrawer();
|
||||
},
|
||||
|
||||
// Open a Codeman window stretched across all displays (multi-monitor mode).
|
||||
// The server spawns scripts/span-codeman.sh, which launches a fresh, spanning
|
||||
// browser --app window so in-page floating panels can cross the monitor seam.
|
||||
async launchMultiMonitor() {
|
||||
try {
|
||||
const res = await fetch('/api/system/span-displays', { method: 'POST' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok && data.success) {
|
||||
this.showToast('Opening Codeman across all displays…', 'success');
|
||||
} else {
|
||||
this.showToast(data.error || 'Could not open spanning window', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Could not open spanning window: ' + (err?.message || err), 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// Alias for showToast
|
||||
toast(message, type = 'info') {
|
||||
return this.showToast(message, type);
|
||||
|
||||
@@ -312,6 +312,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
|
||||
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
|
||||
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
// can't promise something that won't work.
|
||||
const gestureItem = document.getElementById('appSettingsGestureControlItem');
|
||||
if (gestureItem) gestureItem.style.display = window.__codemanGestureAvailable ? '' : 'none';
|
||||
document.getElementById('appSettingsGestureControl').checked = settings.gestureControlEnabled ?? defaults.gestureControlEnabled ?? false;
|
||||
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
|
||||
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
|
||||
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
|
||||
@@ -1107,6 +1115,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async saveAppSettings() {
|
||||
// Gesture overlay is injected at page render (server-side), so a change to it
|
||||
// only takes effect on reload — remember the prior value to decide below.
|
||||
const _prevGestureEnabled = (this.loadAppSettingsFromStorage().gestureControlEnabled ?? false) === true;
|
||||
const settings = {
|
||||
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
|
||||
defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(),
|
||||
@@ -1121,6 +1132,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
|
||||
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
|
||||
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
||||
@@ -1273,6 +1286,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
this.closeAppSettings();
|
||||
|
||||
// The gesture overlay is injected at page render (server reads
|
||||
// gestureControlEnabled from settings.json), so a change only takes effect on
|
||||
// reload. Reload when it actually changed — the server PUT above already
|
||||
// persisted the new value.
|
||||
if (settings.gestureControlEnabled !== _prevGestureEnabled) {
|
||||
this.showToast(
|
||||
settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…',
|
||||
'info'
|
||||
);
|
||||
setTimeout(() => location.reload(), 400);
|
||||
}
|
||||
},
|
||||
|
||||
// Load model configuration from server for the settings modal
|
||||
@@ -1374,6 +1399,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
showProjectInsights: false,
|
||||
showFileBrowser: false,
|
||||
showSubagents: false,
|
||||
showMultiMonitorButton: false,
|
||||
// Input
|
||||
gestureControlEnabled: false,
|
||||
// Feature toggles - keep tracking on even on mobile
|
||||
subagentTrackingEnabled: true,
|
||||
subagentActiveTabOnly: true, // Only show subagents for active tab
|
||||
@@ -1445,13 +1473,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
lifecycleBtn.style.display = showLifecycleLog ? '' : 'none';
|
||||
}
|
||||
|
||||
// Hide notification bell when notifications are disabled
|
||||
const notifEnabled = this.notificationManager?.preferences?.enabled ?? true;
|
||||
// Multi-monitor button — hidden by default (App Settings → Display → "Header
|
||||
// Displays"). The server renders the correct initial state on every reload;
|
||||
// this handles a live toggle from a settings save (no reload). Toggle the
|
||||
// marker class (matches the server-side reveal) rather than an inline style.
|
||||
const showMultiMonitorButton = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
const multiMonitorBtn = document.querySelector('.btn-multimonitor');
|
||||
if (multiMonitorBtn) {
|
||||
multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton);
|
||||
}
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
const notifBtn = document.querySelector('.btn-notifications');
|
||||
if (notifBtn) {
|
||||
notifBtn.style.display = notifEnabled ? '' : 'none';
|
||||
notifBtn.style.display = 'none';
|
||||
}
|
||||
// Close the drawer if notifications got disabled while it's open
|
||||
const notifEnabled = this.notificationManager?.preferences?.enabled ?? true;
|
||||
if (!notifEnabled) {
|
||||
const drawer = document.getElementById('notifDrawer');
|
||||
if (drawer) drawer.classList.remove('open');
|
||||
|
||||
@@ -846,6 +846,13 @@ body {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Multi-monitor header button: hidden by default (opt-in via App Settings →
|
||||
Display → "Header Displays"). The server strips this class at render when the
|
||||
setting is on; the client toggles it live on save. */
|
||||
.btn-multimonitor--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn-icon-header.btn-settings {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -905,6 +912,97 @@ body {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* ===== Session detach / undock (beta/session-detach) ===================== */
|
||||
|
||||
/* Pop-out (detach) icon on each tab — mirrors .tab-gear reveal-on-hover. */
|
||||
.session-tab .tab-detach {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
padding: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: opacity 0.15s, width 0.15s, padding 0.15s;
|
||||
}
|
||||
.session-tab:hover .tab-detach {
|
||||
opacity: 1;
|
||||
width: auto;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
.session-tab .tab-detach:hover {
|
||||
color: var(--accent-hover);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* A tab whose session is popped out into its own window. */
|
||||
.session-tab.detached {
|
||||
opacity: 0.72;
|
||||
}
|
||||
.session-tab.detached .tab-detach {
|
||||
/* Keep the pop-out icon visible while detached as a re-focus affordance. */
|
||||
opacity: 1;
|
||||
width: auto;
|
||||
padding: 0 0.3rem;
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.session-tab .tab-detached-badge {
|
||||
display: none;
|
||||
align-items: center;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 4px;
|
||||
margin-left: 4px;
|
||||
border-radius: 3px;
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.session-tab.detached .tab-detached-badge {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* ===== Solo (detached single-session) window chrome ===================== */
|
||||
body.solo-mode .session-tabs,
|
||||
body.solo-mode .header-system-stats,
|
||||
body.solo-mode .header-tokens,
|
||||
body.solo-mode .btn-notifications,
|
||||
body.solo-mode .btn-multimonitor,
|
||||
body.solo-mode .btn-lifecycle-log {
|
||||
display: none !important;
|
||||
}
|
||||
.solo-session-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* "Session unavailable" overlay for a solo window whose session has ended. */
|
||||
.solo-gone-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 5000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
background: rgba(9, 9, 11, 0.92);
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.solo-gone-overlay h2 { margin: 0; font-size: 18px; }
|
||||
.solo-gone-overlay p { margin: 0; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
/* Mode indicator on session tab */
|
||||
.session-tab .tab-mode {
|
||||
font-size: 0.6rem;
|
||||
|
||||
@@ -16,10 +16,12 @@ import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.j
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import type { SessionPort } from './ports/session-port.js';
|
||||
import type { EventPort } from './ports/event-port.js';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
|
||||
// Shared path constants used across route modules
|
||||
// Shared path constants used across route modules. CASES_DIR (project folders)
|
||||
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
|
||||
export const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
export const SETTINGS_PATH = join(homedir(), '.codeman', 'settings.json');
|
||||
export const SETTINGS_PATH = dataPath('settings.json');
|
||||
|
||||
/**
|
||||
* Validates that a path component doesn't escape the base directory.
|
||||
|
||||
@@ -17,8 +17,9 @@ import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
|
||||
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
@@ -151,7 +152,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// Save the linked case
|
||||
linkedCases[name] = expandedPath;
|
||||
try {
|
||||
const codemanDir = join(homedir(), '.codeman');
|
||||
const codemanDir = getDataDir();
|
||||
if (!existsSync(codemanDir)) {
|
||||
mkdirSync(codemanDir, { recursive: true });
|
||||
}
|
||||
@@ -206,7 +207,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
|
||||
|
||||
try {
|
||||
const dir = join(homedir(), '.codeman');
|
||||
const dir = getDataDir();
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join } from 'node:path';
|
||||
import { basename as pathBasename, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import fs from 'node:fs/promises';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
@@ -278,7 +279,6 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
ico: 'image/x-icon',
|
||||
bmp: 'image/bmp',
|
||||
mp4: 'video/mp4',
|
||||
@@ -292,19 +292,21 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
};
|
||||
|
||||
const content = await fs.readFile(resolvedPath);
|
||||
if (download === 'true') {
|
||||
const rawBasename = filePath!.split('/').pop() || 'download';
|
||||
// Sanitize filename for Content-Disposition header (prevent header injection)
|
||||
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
|
||||
const rawBasename = filePath!.split('/').pop() || 'download';
|
||||
// Sanitize filename for Content-Disposition header (prevent header injection)
|
||||
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
|
||||
if (download === 'true' || ext === 'svg') {
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Type': ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${basename}"`,
|
||||
'Content-Length': content.length,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
reply.raw.end(content);
|
||||
return;
|
||||
}
|
||||
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.send(content);
|
||||
} catch (err) {
|
||||
reply
|
||||
@@ -380,4 +382,108 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
const closed = fileStreamManager.closeStream(streamId);
|
||||
return { success: closed };
|
||||
});
|
||||
// Session-scoped file download.
|
||||
// Uses the same realpath-based workspace boundary as file preview/raw routes;
|
||||
// the sensitive-path blocklist remains defense-in-depth, not the primary boundary.
|
||||
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$/,
|
||||
];
|
||||
|
||||
function isSensitivePath(absPath: string): boolean {
|
||||
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath));
|
||||
}
|
||||
|
||||
app.get('/api/download', async (req, reply) => {
|
||||
const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string };
|
||||
|
||||
if (!filePath) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing sessionId parameter'));
|
||||
return;
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, sessionId);
|
||||
const validated = validateSessionFilePath(session.workingDir, filePath);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
}
|
||||
const { resolvedPath } = validated;
|
||||
|
||||
// Check sensitive path blocklist
|
||||
if (isSensitivePath(resolvedPath)) {
|
||||
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
|
||||
if (!stat.isFile()) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is not a file'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 50MB size limit
|
||||
const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
|
||||
if (stat.size > MAX_DOWNLOAD_SIZE) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > 50MB limit)`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const mimeTypes: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
pdf: 'application/pdf',
|
||||
json: 'application/json',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
csv: 'text/csv',
|
||||
xml: 'application/xml',
|
||||
zip: 'application/zip',
|
||||
gz: 'application/gzip',
|
||||
tar: 'application/x-tar',
|
||||
};
|
||||
|
||||
const filename = pathBasename(resolvedPath);
|
||||
const content = await fs.readFile(resolvedPath);
|
||||
// Bypass Fastify compression — write directly to raw response
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': content.length,
|
||||
});
|
||||
reply.raw.end(content);
|
||||
return;
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,9 +54,10 @@ import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
||||
import { RunSummaryTracker } from '../../run-summary.js';
|
||||
|
||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||
import { dataPath } from '../../config/instance.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
|
||||
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { totalmem, freemem, loadavg, cpus } from 'node:os';
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { dataPath } from '../../config/instance.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
||||
import {
|
||||
ConfigUpdateSchema,
|
||||
@@ -41,7 +43,7 @@ import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
|
||||
// Maximum screenshot upload size (10MB)
|
||||
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
||||
// Screenshots directory
|
||||
const SCREENSHOTS_DIR = join(homedir(), '.codeman', 'screenshots');
|
||||
const SCREENSHOTS_DIR = dataPath('screenshots');
|
||||
|
||||
/** Cached CPU count — doesn't change at runtime */
|
||||
const CPU_COUNT = cpus().length;
|
||||
@@ -92,12 +94,24 @@ function getSystemStats(): {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL the spanning browser window should open, pinned to localhost.
|
||||
* Takes only a digits-only port from the (untrusted) Host header so nothing
|
||||
* attacker-controllable reaches the launched browser; falls back to the default
|
||||
* port when the header is absent/odd. Exported for unit testing.
|
||||
*/
|
||||
export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3000'): string {
|
||||
const hostPort = String(hostHeader ?? '').split(':')[1] ?? '';
|
||||
const port = /^\d+$/.test(hostPort) ? hostPort : fallbackPort;
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
export function registerSystemRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||
): void {
|
||||
const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json');
|
||||
const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json');
|
||||
const windowStatesPath = dataPath('subagent-window-states.json');
|
||||
const parentMapPath = dataPath('subagent-parents.json');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// System Status & Health
|
||||
@@ -239,6 +253,40 @@ export function registerSystemRoutes(
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Multi-monitor: span Codeman across all displays
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Spawn scripts/span-codeman.sh, which opens a fresh, maximized browser --app
|
||||
// window sized to the union of all displays — so in-page floating session
|
||||
// panels can be dragged across the physical monitor seam. macOS only; needs
|
||||
// the one-time "Displays have separate Spaces" OFF prerequisite (see script).
|
||||
app.post('/api/system/span-displays', async (req, reply) => {
|
||||
// macOS only: the launcher uses osascript + Finder desktop bounds and Chrome
|
||||
// --app geometry flags. Fail clearly elsewhere instead of spawning a bash
|
||||
// that errors out invisibly (the toast would otherwise lie "Opening…").
|
||||
if (process.platform !== 'darwin') {
|
||||
return reply
|
||||
.code(400)
|
||||
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Multi-monitor spanning is only supported on macOS.'));
|
||||
}
|
||||
// Resolve the bundled launcher relative to this module (works from src/ and dist/).
|
||||
const scriptPath = join(dirname(fileURLToPath(import.meta.url)), '../../../scripts/span-codeman.sh');
|
||||
if (!existsSync(scriptPath)) {
|
||||
return reply.code(500).send(createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'span-codeman.sh not found'));
|
||||
}
|
||||
// Point the spanning window at THIS server (localhost + sanitized port).
|
||||
const url = resolveSpanUrl(req.headers.host);
|
||||
try {
|
||||
const child = spawn('bash', [scriptPath, url], { detached: true, stdio: 'ignore' });
|
||||
child.on('error', (err) => app.log.error({ err }, 'span-displays launch failed'));
|
||||
child.unref();
|
||||
return { success: true, url };
|
||||
} catch (err) {
|
||||
return reply.code(500).send(createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err)));
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CLI Integrations (OpenCode)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -293,6 +293,9 @@ export const SettingsUpdateSchema = z
|
||||
showProjectInsights: z.boolean().optional(),
|
||||
showFileBrowser: z.boolean().optional(),
|
||||
showSubagents: z.boolean().optional(),
|
||||
showMultiMonitorButton: z.boolean().optional(),
|
||||
// Input
|
||||
gestureControlEnabled: z.boolean().optional(),
|
||||
// Claude CLI settings
|
||||
claudeMode: z.string().max(50).optional(),
|
||||
allowedTools: z.string().max(2000).optional(),
|
||||
|
||||
+166
-27
@@ -36,10 +36,11 @@ import fastifyMultipart from '@fastify/multipart';
|
||||
import { startPasteImageGc } from './paste-image-gc.js';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { homedir, hostname as getHostname } from 'node:os';
|
||||
import { hostname as getHostname } from 'node:os';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, type BackgroundTask } from '../session.js';
|
||||
import type { ClaudeMode, SessionState } from '../types.js';
|
||||
@@ -101,6 +102,7 @@ import { SseEvent } from './sse-events.js';
|
||||
import type { ScheduledRun } from './ports/index.js';
|
||||
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
|
||||
import { installRouteErrorHandler } from './route-error-handler.js';
|
||||
import { isExplicitlyEnabled, isLoopbackBindHost } from './network-auth-policy.js';
|
||||
import {
|
||||
registerPushRoutes,
|
||||
registerTeamRoutes,
|
||||
@@ -146,7 +148,7 @@ import {
|
||||
* Certs are stored in ~/.codeman/certs/ and reused across restarts.
|
||||
*/
|
||||
function getOrCreateSelfSignedCert(): { key: string; cert: string } {
|
||||
const certsDir = join(homedir(), '.codeman', 'certs');
|
||||
const certsDir = dataPath('certs');
|
||||
const keyPath = join(certsDir, 'server.key');
|
||||
const certPath = join(certsDir, 'server.crt');
|
||||
|
||||
@@ -190,6 +192,7 @@ export class WebServer extends EventEmitter {
|
||||
private sse: SseStreamManager;
|
||||
private store = getStore();
|
||||
private port: number;
|
||||
private host: string;
|
||||
private https: boolean;
|
||||
private testMode: boolean;
|
||||
private mux: TerminalMultiplexer;
|
||||
@@ -231,6 +234,10 @@ export class WebServer extends EventEmitter {
|
||||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||
private readonly titleHostname: string;
|
||||
private readonly windowTitle: string;
|
||||
private readonly indexHtmlTemplate: string;
|
||||
private readonly allowUnauthenticatedNetwork: boolean;
|
||||
private _pasteImageGcStop: (() => void) | null = null;
|
||||
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
|
||||
private teamWatcherHandlers: {
|
||||
@@ -239,15 +246,22 @@ export class WebServer extends EventEmitter {
|
||||
teamRemoved: (config: unknown) => void;
|
||||
taskUpdated: (data: unknown) => void;
|
||||
} | null = null;
|
||||
private readonly titleHostname: string;
|
||||
private readonly windowTitle: string;
|
||||
private readonly indexHtmlTemplate: string;
|
||||
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false, titleHostname?: string) {
|
||||
constructor(
|
||||
port: number = 3000,
|
||||
https: boolean = false,
|
||||
testMode: boolean = false,
|
||||
host: string = '127.0.0.1',
|
||||
titleHostname?: string,
|
||||
allowUnauthenticatedNetwork: boolean = false
|
||||
) {
|
||||
super();
|
||||
this.setMaxListeners(0);
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.https = https;
|
||||
this.testMode = testMode;
|
||||
this.allowUnauthenticatedNetwork =
|
||||
allowUnauthenticatedNetwork || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
|
||||
this.titleHostname = titleHostname || getHostname();
|
||||
this.windowTitle = `codeman:${this.titleHostname}`;
|
||||
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
|
||||
@@ -557,10 +571,30 @@ export class WebServer extends EventEmitter {
|
||||
// Security headers + CORS
|
||||
registerSecurityHeaders(this.app, this.https);
|
||||
this.app.get('/', async (_req, reply) => {
|
||||
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml());
|
||||
return reply
|
||||
.header('Cache-Control', 'no-cache')
|
||||
.type('text/html; charset=utf-8')
|
||||
.send(await this.renderIndexHtml());
|
||||
});
|
||||
this.app.get('/index.html', async (_req, reply) => {
|
||||
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml());
|
||||
return reply
|
||||
.header('Cache-Control', 'no-cache')
|
||||
.type('text/html; charset=utf-8')
|
||||
.send(await this.renderIndexHtml());
|
||||
});
|
||||
// Detached single-session window (undock). Serves the same SPA shell but
|
||||
// flags the client into "solo mode" for one session. Auth applies normally
|
||||
// (the popup carries the dashboard's cookie on navigation). We serve 200
|
||||
// even for an unknown id — the client renders a friendly "session
|
||||
// unavailable" state, which also covers a session that ends while its
|
||||
// detached window is still open. Registered before the static plugin so the
|
||||
// explicit route wins over the '/' static prefix.
|
||||
this.app.get('/session/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
return reply
|
||||
.header('Cache-Control', 'no-cache')
|
||||
.type('text/html; charset=utf-8')
|
||||
.send(await this.renderIndexHtml(id));
|
||||
});
|
||||
// Service worker must never be cached — browsers check for SW updates on navigation
|
||||
this.app.get('/sw.js', async (_req, reply) => {
|
||||
@@ -980,11 +1014,100 @@ export class WebServer extends EventEmitter {
|
||||
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
||||
}
|
||||
|
||||
private renderIndexHtml(): string {
|
||||
return this.indexHtmlTemplate.replace(
|
||||
private async renderIndexHtml(soloSessionId?: string): Promise<string> {
|
||||
let html = this.indexHtmlTemplate.replace(
|
||||
'<title>Codeman</title>',
|
||||
`<title>${escapeHtmlText(this.windowTitle)}</title>`
|
||||
);
|
||||
// Cache-bust same-origin module scripts + stylesheets so a normal reload
|
||||
// always serves the latest (static assets carry a 1-year immutable cache).
|
||||
html = this.cacheBustAssets(html);
|
||||
// Per-user App-Settings flags, read server-side so the page renders in the
|
||||
// right initial state on every normal reload (the client apply* functions
|
||||
// only run on save). Read FRESH (bypass the 2s cache): a setting toggled
|
||||
// moments ago triggers a reload here, and the cached value would render the
|
||||
// pre-toggle state (e.g. the gesture bundle wouldn't inject until a 2nd
|
||||
// reload). Skipped for solo popups (their header differs).
|
||||
const settings: Record<string, unknown> = soloSessionId ? {} : await this.readSettings(true);
|
||||
// Multi-monitor header button: carries the `btn-multimonitor--hidden` class
|
||||
// in the template by default (App Settings → Display → "Header Displays");
|
||||
// reveal by stripping that class when the user enabled it. Matching a unique
|
||||
// class token (not user-facing copy) keeps this robust against template edits.
|
||||
if (settings.showMultiMonitorButton === true) {
|
||||
html = html.replace(' btn-multimonitor--hidden', '');
|
||||
}
|
||||
// Detached single-session ("solo") window: inject the target session id so
|
||||
// the client can enter solo mode even if a (network-first) service worker
|
||||
// later serves a cached shell. The client primarily detects solo mode from
|
||||
// the /session/:id URL path; this global is a belt-and-suspenders fallback.
|
||||
// The id is gated to JSON + <-escaped so it can't break out of the inline
|
||||
// <script> (ids are UUIDs in practice, but defense-in-depth is cheap).
|
||||
if (soloSessionId) {
|
||||
const safeId = JSON.stringify(soloSessionId).replace(/</g, '\\u003c');
|
||||
html = html.replace('</head>', `<script>window.__CODEMAN_SOLO__=${safeId};</script>\n</head>`);
|
||||
}
|
||||
// Gesture-control overlay (Phase 5): dashboard only (not solo popups, which
|
||||
// have no tab strip). `CODEMAN_GESTURE=1` makes the feature *available* on
|
||||
// this instance (it also widens CSP + serves the assets); the per-user
|
||||
// `gestureControlEnabled` setting (App Settings → Input, default OFF) is the
|
||||
// actual on/off. We expose `__codemanGestureAvailable` so the settings UI can
|
||||
// show the toggle only when the feature is available, and inject the bundle
|
||||
// (served same-origin from /gesture/, so 'self' covers it) only when enabled.
|
||||
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
|
||||
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
||||
if (settings.gestureControlEnabled === true) {
|
||||
const v = this.gestureBundleVersion();
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
`<script type="module" src="/gesture/gesture-codeman.js${v}"></script>\n</head>`
|
||||
);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/** mtime memo for asset cache-busting (keyed by absolute path). A full index
|
||||
* render does one stat per script/link tag (~25-30); without this each `/`,
|
||||
* `/index.html` and `/session/:id` hit would re-stat them all. A 1s TTL keeps
|
||||
* a burst of renders cheap while still picking up an edited/redeployed file
|
||||
* within a second (no server restart needed). */
|
||||
private _assetVersionMemo = new Map<string, { v: number; ts: number }>();
|
||||
private assetVersion(absPath: string): number | null {
|
||||
const now = Date.now();
|
||||
const hit = this._assetVersionMemo.get(absPath);
|
||||
if (hit && now - hit.ts < 1000) return hit.v;
|
||||
try {
|
||||
const v = Math.floor(statSync(absPath).mtimeMs);
|
||||
this._assetVersionMemo.set(absPath, { v, ts: now });
|
||||
return v;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cache-busting query for the gesture bundle: its mtime (memoized, see
|
||||
* assetVersion). The bundle is served from /gesture/ with a 1-year cache, so
|
||||
* without a version that changes on redeploy the browser would keep running a
|
||||
* stale bundle forever. Empty string if the file is missing. */
|
||||
private gestureBundleVersion(): string {
|
||||
const v = this.assetVersion(join(__dirname, 'public', 'gesture', 'gesture-codeman.js'));
|
||||
return v === null ? '' : `?v=${v}`;
|
||||
}
|
||||
|
||||
/** Append ?v=<mtime> to every same-origin .js/.css reference in the page so a
|
||||
* normal reload always serves the latest. Codeman's static assets are sent
|
||||
* with `Cache-Control: max-age=1y, immutable` and the script/link tags carry
|
||||
* no version, so without this an edited module (panels-ui.js, styles.css, …)
|
||||
* stays cached until a manual hard refresh. mtime is memoized (1s TTL) so a
|
||||
* changed file is picked up with no server restart. External URLs (have a
|
||||
* `:` scheme), already-versioned refs (have a `?`), and refs with no matching
|
||||
* file on disk are left untouched. */
|
||||
private cacheBustAssets(html: string): string {
|
||||
const publicDir = join(__dirname, 'public');
|
||||
return html.replace(/(\s(?:src|href)=")([^"?:]+\.(?:js|css))(")/g, (full, pre, ref, post) => {
|
||||
const v = this.assetVersion(join(publicDir, ref));
|
||||
return v === null ? full : `${pre}${ref}?v=${v}${post}`;
|
||||
});
|
||||
}
|
||||
|
||||
private async setupSessionListeners(session: Session): Promise<void> {
|
||||
@@ -1088,7 +1211,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Helper to get custom CLAUDE.md template path from settings
|
||||
private async getDefaultClaudeMdPath(): Promise<string | undefined> {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
@@ -1106,13 +1229,16 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Read ~/.codeman/settings.json once and return the parsed object.
|
||||
// Cached for 2s to avoid redundant reads during session creation bursts.
|
||||
// The settings PUT route writes the file without invalidating this cache, so
|
||||
// callers that must observe a just-saved value (e.g. renderIndexHtml on a
|
||||
// post-save reload) pass forceFresh=true to bypass the cache.
|
||||
private _settingsCache: { data: Record<string, unknown>; ts: number } | null = null;
|
||||
private async readSettings(): Promise<Record<string, unknown>> {
|
||||
private async readSettings(forceFresh = false): Promise<Record<string, unknown>> {
|
||||
const now = Date.now();
|
||||
if (this._settingsCache && now - this._settingsCache.ts < 2000) {
|
||||
if (!forceFresh && this._settingsCache && now - this._settingsCache.ts < 2000) {
|
||||
return this._settingsCache.data;
|
||||
}
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const data = JSON.parse(content) as Record<string, unknown>;
|
||||
@@ -1533,6 +1659,13 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD && !this.allowUnauthenticatedNetwork) {
|
||||
throw new Error(
|
||||
'Refusing to start Codeman on a non-loopback host without CODEMAN_PASSWORD. ' +
|
||||
'Set CODEMAN_PASSWORD or explicitly allow unauthenticated network access.'
|
||||
);
|
||||
}
|
||||
|
||||
await this.setupRoutes();
|
||||
|
||||
const lifecycleLog = getLifecycleLog();
|
||||
@@ -1559,19 +1692,23 @@ export class WebServer extends EventEmitter {
|
||||
this._eventLoopMonitor = startEventLoopMonitor();
|
||||
}
|
||||
|
||||
await this.app.listen({ port: this.port, host: '0.0.0.0' });
|
||||
await this.app.listen({ port: this.port, host: this.host });
|
||||
const protocol = this.https ? 'https' : 'http';
|
||||
console.log(`Codeman web interface running at ${protocol}://localhost:${this.port}`);
|
||||
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
|
||||
console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`);
|
||||
|
||||
// Security warning: server binds to 0.0.0.0 (all interfaces) — warn if no auth configured
|
||||
if (!process.env.CODEMAN_PASSWORD) {
|
||||
if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD && this.allowUnauthenticatedNetwork) {
|
||||
console.warn('\n⚠ WARNING: No CODEMAN_PASSWORD set — server is accessible without authentication.');
|
||||
console.warn(' Anyone on your network can access and control Claude sessions.');
|
||||
console.warn(' Set CODEMAN_PASSWORD environment variable to enable auth.\n');
|
||||
console.warn(
|
||||
' This was explicitly allowed by --allow-unauthenticated-network or CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK.\n'
|
||||
);
|
||||
}
|
||||
|
||||
// Set API URL for child processes (MCP server, spawned sessions)
|
||||
process.env.CODEMAN_API_URL = `${protocol}://localhost:${this.port}`;
|
||||
const apiHost =
|
||||
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}`;
|
||||
|
||||
// Start scheduled runs cleanup timer
|
||||
this.cleanup.setInterval(
|
||||
@@ -1619,7 +1756,7 @@ export class WebServer extends EventEmitter {
|
||||
// Tunnel only starts when user clicks the toggle in the UI — never on boot.
|
||||
// Reset persisted tunnelEnabled so the UI toggle reflects actual state.
|
||||
if (await this.isTunnelEnabled()) {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
@@ -1640,7 +1777,7 @@ export class WebServer extends EventEmitter {
|
||||
* Check if subagent tracking is enabled in settings (default: true)
|
||||
*/
|
||||
private async isSubagentTrackingEnabled(): Promise<boolean> {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
@@ -1658,7 +1795,7 @@ export class WebServer extends EventEmitter {
|
||||
* Check if image watcher is enabled in settings (default: false)
|
||||
*/
|
||||
private async isImageWatcherEnabled(): Promise<boolean> {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
@@ -1676,7 +1813,7 @@ export class WebServer extends EventEmitter {
|
||||
* Check if Cloudflare tunnel is enabled in settings (default: false)
|
||||
*/
|
||||
private async isTunnelEnabled(): Promise<boolean> {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
@@ -2063,9 +2200,11 @@ export async function startWebServer(
|
||||
port: number = 3000,
|
||||
https: boolean = false,
|
||||
testMode: boolean = false,
|
||||
titleHostname?: string
|
||||
host: string = '127.0.0.1',
|
||||
titleHostname?: string,
|
||||
allowUnauthenticatedNetwork: boolean = false
|
||||
): Promise<WebServer> {
|
||||
const server = new WebServer(port, https, testMode, titleHostname);
|
||||
const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork);
|
||||
await server.start();
|
||||
return server;
|
||||
}
|
||||
|
||||
+135
-38
@@ -3,26 +3,60 @@
|
||||
* 1. Timing-safe password comparison (timingSafeEqual)
|
||||
* 2. Hook event endpoint restricted to localhost
|
||||
* 3. Session cookie TTL refresh on access
|
||||
* 4. Startup warning when no password configured
|
||||
* 4. Startup fails closed when network-bound without auth
|
||||
* 5. SSE client limit enforcement
|
||||
* 6. Logout endpoint invalidates session
|
||||
* 7. Settings schema rejects unknown fields
|
||||
*
|
||||
* Port: 3160 (auth tests), 3161 (no-auth tests)
|
||||
* Port: 3160 (auth tests), 3161 (loopback no-auth tests), 3162 (network override tests)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const AUTH_PORT = 3160;
|
||||
const NOAUTH_PORT = 3161;
|
||||
const NETWORK_OVERRIDE_PORT = 3162;
|
||||
const AUTH_RATE_LIMIT_PORT = 3220;
|
||||
const TEST_USER = 'admin';
|
||||
const TEST_PASS = 'test-password-12345';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
function basicAuthHeader(user: string, pass: string): string {
|
||||
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
}
|
||||
|
||||
async function startAuthServer(port: number): Promise<{ server: WebServer; baseUrl: string }> {
|
||||
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
||||
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||
const server = new WebServer(port, false, true);
|
||||
await server.start();
|
||||
return { server, baseUrl: `http://localhost:${port}` };
|
||||
}
|
||||
|
||||
async function getSessionCookie(baseUrl: string): Promise<string> {
|
||||
const res = await fetch(`${baseUrl}/api/status`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
expect(setCookie).toBeTruthy();
|
||||
const cookieMatch = setCookie!.match(/codeman_session=([^;]+)/);
|
||||
expect(cookieMatch).toBeTruthy();
|
||||
return `codeman_session=${cookieMatch![1]}`;
|
||||
}
|
||||
|
||||
async function exhaustAuthFailures(baseUrl: string, prefix: string): Promise<void> {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await fetch(`${baseUrl}/api/status`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, `${prefix}-${i}`) },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Auth Security', () => {
|
||||
let server: WebServer;
|
||||
let baseUrl: string;
|
||||
@@ -154,29 +188,63 @@ describe('Auth Security', () => {
|
||||
});
|
||||
|
||||
describe('Rate Limiting', () => {
|
||||
it('should block after too many failed attempts', async () => {
|
||||
// Send 10 failed attempts
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await fetch(`${baseUrl}/api/status`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-' + i) },
|
||||
});
|
||||
}
|
||||
let rateServer: WebServer;
|
||||
let rateBaseUrl: string;
|
||||
|
||||
// 11th attempt should be rate-limited
|
||||
const res = await fetch(`${baseUrl}/api/status`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-again') },
|
||||
});
|
||||
expect(res.status).toBe(429);
|
||||
beforeEach(async () => {
|
||||
({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer(AUTH_RATE_LIMIT_PORT));
|
||||
});
|
||||
|
||||
it('should rate-limit even with correct credentials after lockout', async () => {
|
||||
// After being rate-limited, even correct credentials should fail
|
||||
const res = await fetch(`${baseUrl}/api/status`, {
|
||||
afterEach(async () => {
|
||||
await rateServer.stop();
|
||||
});
|
||||
|
||||
it('should rate-limit wrong credentials after too many failed attempts', async () => {
|
||||
await exhaustAuthFailures(rateBaseUrl, 'cod21-wrong');
|
||||
|
||||
const res = await fetch(`${rateBaseUrl}/api/status`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-again') },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get('retry-after')).toMatch(/^\d+$/);
|
||||
});
|
||||
|
||||
it('should allow an existing valid session cookie during auth failure lockout', async () => {
|
||||
const cookie = await getSessionCookie(rateBaseUrl);
|
||||
await exhaustAuthFailures(rateBaseUrl, 'cod21-cookie');
|
||||
|
||||
const res = await fetch(`${rateBaseUrl}/api/status`, {
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should allow correct credentials to recover from auth failure lockout', async () => {
|
||||
await exhaustAuthFailures(rateBaseUrl, 'cod21-recover');
|
||||
|
||||
const res = await fetch(`${rateBaseUrl}/api/status`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||
});
|
||||
// Rate limit is per-IP and the previous test used the same IP
|
||||
// This test verifies rate limiting isn't bypassed by correct creds
|
||||
expect(res.status).toBe(429);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('set-cookie')).toContain('codeman_session=');
|
||||
});
|
||||
|
||||
it('should clear failed attempt count after correct credentials recover access', async () => {
|
||||
await exhaustAuthFailures(rateBaseUrl, 'cod21-clear');
|
||||
|
||||
const recoveryRes = await fetch(`${rateBaseUrl}/api/status`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||
});
|
||||
expect(recoveryRes.status).toBe(200);
|
||||
|
||||
const wrongAfterRecovery = await fetch(`${rateBaseUrl}/api/status`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-after-recovery') },
|
||||
});
|
||||
|
||||
expect(wrongAfterRecovery.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,7 +288,7 @@ describe('Settings Schema Security', () => {
|
||||
|
||||
it('should enforce tunnelEnabled as boolean', () => {
|
||||
const result = SettingsUpdateSchema.safeParse({
|
||||
tunnelEnabled: 'yes', // truthy string — should be rejected
|
||||
tunnelEnabled: 'yes', // truthy string — should be rejected
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
@@ -264,40 +332,69 @@ describe('Settings Schema Security', () => {
|
||||
expect(validResult.success).toBe(true);
|
||||
|
||||
const invalidResult = SettingsUpdateSchema.safeParse({
|
||||
nice: { enabled: true, niceValue: 100 }, // Out of range
|
||||
nice: { enabled: true, niceValue: 100 }, // Out of range
|
||||
});
|
||||
expect(invalidResult.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('No-Auth Server Warning', () => {
|
||||
describe('No-Auth Server Startup Policy', () => {
|
||||
let server: WebServer;
|
||||
let consoleWarnSpy: string[] = [];
|
||||
const originalWarn = console.warn;
|
||||
|
||||
beforeAll(async () => {
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
consoleWarnSpy = [];
|
||||
console.warn = (...args: unknown[]) => {
|
||||
consoleWarnSpy.push(args.map(String).join(' '));
|
||||
};
|
||||
server = new WebServer(NOAUTH_PORT, false, true);
|
||||
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
|
||||
server = new WebServer(NOAUTH_PORT, false, true, '127.0.0.1');
|
||||
await server.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
console.warn = originalWarn;
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it('should warn when no CODEMAN_PASSWORD is set', () => {
|
||||
const hasWarning = consoleWarnSpy.some(msg => msg.includes('No CODEMAN_PASSWORD set'));
|
||||
expect(hasWarning).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow requests without auth when no password configured', async () => {
|
||||
it('allows loopback requests without auth when no password is configured', async () => {
|
||||
const res = await fetch(`http://localhost:${NOAUTH_PORT}/api/status`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects non-loopback startup without a password or explicit override', async () => {
|
||||
const networkServer = new WebServer(0, false, true, '0.0.0.0');
|
||||
|
||||
await expect(networkServer.start()).rejects.toThrow(/CODEMAN_PASSWORD/);
|
||||
|
||||
await networkServer.stop();
|
||||
});
|
||||
|
||||
it('allows non-loopback startup when CODEMAN_PASSWORD is configured', async () => {
|
||||
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
||||
const networkServer = new WebServer(0, false, true, '0.0.0.0');
|
||||
|
||||
await networkServer.start();
|
||||
await networkServer.stop();
|
||||
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
});
|
||||
|
||||
it('allows non-loopback startup with the explicit unauthenticated-network override', async () => {
|
||||
const networkServer = new WebServer(NETWORK_OVERRIDE_PORT, false, true, '0.0.0.0', undefined, true);
|
||||
|
||||
await networkServer.start();
|
||||
const res = await fetch(`http://localhost:${NETWORK_OVERRIDE_PORT}/api/status`);
|
||||
expect(res.status).toBe(200);
|
||||
await networkServer.stop();
|
||||
});
|
||||
|
||||
it('allows non-loopback startup with the explicit unauthenticated-network env override', async () => {
|
||||
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = 'true';
|
||||
const networkServer = new WebServer(0, false, true, '0.0.0.0');
|
||||
|
||||
await networkServer.start();
|
||||
await networkServer.stop();
|
||||
|
||||
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
|
||||
});
|
||||
});
|
||||
|
||||
+34
-16
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { program } from '../src/cli.js';
|
||||
|
||||
describe('CLI Command Parsing', () => {
|
||||
describe('Command Structure', () => {
|
||||
@@ -72,11 +73,11 @@ describe('CLI Command Parsing', () => {
|
||||
];
|
||||
|
||||
const findCommand = (name: string): Command | undefined => {
|
||||
return commands.find(c => c.name === name || c.aliases.includes(name));
|
||||
return commands.find((c) => c.name === name || c.aliases.includes(name));
|
||||
};
|
||||
|
||||
const findSubcommand = (parent: Command, name: string): Command | undefined => {
|
||||
return parent.subcommands?.find(c => c.name === name || c.aliases.includes(name));
|
||||
return parent.subcommands?.find((c) => c.name === name || c.aliases.includes(name));
|
||||
};
|
||||
|
||||
it('should find commands by name', () => {
|
||||
@@ -103,7 +104,7 @@ describe('CLI Command Parsing', () => {
|
||||
});
|
||||
|
||||
it('should have descriptions for all commands', () => {
|
||||
commands.forEach(cmd => {
|
||||
commands.forEach((cmd) => {
|
||||
expect(cmd.description).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -267,13 +268,13 @@ describe('CLI Command Parsing', () => {
|
||||
});
|
||||
|
||||
it('should have defaults for web flags', () => {
|
||||
webFlags.forEach(flag => {
|
||||
webFlags.forEach((flag) => {
|
||||
expect(flag.default).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have defaults for tui flags', () => {
|
||||
tuiFlags.forEach(flag => {
|
||||
tuiFlags.forEach((flag) => {
|
||||
expect(flag.default).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -322,7 +323,7 @@ describe('CLI Command Parsing', () => {
|
||||
help += `${description}\n`;
|
||||
if (options.length > 0) {
|
||||
help += '\nOptions:\n';
|
||||
options.forEach(opt => {
|
||||
options.forEach((opt) => {
|
||||
help += ` ${opt}\n`;
|
||||
});
|
||||
}
|
||||
@@ -345,6 +346,15 @@ describe('CLI Command Parsing', () => {
|
||||
expect(help).toContain('--host');
|
||||
});
|
||||
|
||||
it('documents the unauthenticated network override in real web command help', () => {
|
||||
const webCommand = program.commands.find((command) => command.name() === 'web');
|
||||
expect(webCommand).toBeDefined();
|
||||
|
||||
const help = webCommand!.helpInformation();
|
||||
expect(help).toContain('--allow-unauthenticated-network');
|
||||
expect(help).toMatch(/without\s+CODEMAN_PASSWORD/);
|
||||
});
|
||||
|
||||
it('should format properly', () => {
|
||||
const help = generateHelp('test', 'Test command', ['--flag']);
|
||||
const lines = help.split('\n');
|
||||
@@ -474,22 +484,27 @@ describe('CLI Output Formatting', () => {
|
||||
}
|
||||
|
||||
const formatRow = (values: string[], columns: Column[]): string => {
|
||||
return values.map((val, i) => {
|
||||
const width = columns[i]?.width || 10;
|
||||
return val.padEnd(width).substring(0, width);
|
||||
}).join(' ');
|
||||
return values
|
||||
.map((val, i) => {
|
||||
const width = columns[i]?.width || 10;
|
||||
return val.padEnd(width).substring(0, width);
|
||||
})
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const formatTable = (headers: string[], rows: string[][], widths: number[]): string => {
|
||||
const columns = headers.map((h, i) => ({ header: h, width: widths[i] }));
|
||||
const headerRow = formatRow(headers, columns);
|
||||
const separator = columns.map(c => '-'.repeat(c.width)).join(' ');
|
||||
const dataRows = rows.map(row => formatRow(row, columns));
|
||||
const separator = columns.map((c) => '-'.repeat(c.width)).join(' ');
|
||||
const dataRows = rows.map((row) => formatRow(row, columns));
|
||||
return [headerRow, separator, ...dataRows].join('\n');
|
||||
};
|
||||
|
||||
it('should format single row', () => {
|
||||
const columns = [{ header: 'ID', width: 10 }, { header: 'Status', width: 8 }];
|
||||
const columns = [
|
||||
{ header: 'ID', width: 10 },
|
||||
{ header: 'Status', width: 8 },
|
||||
];
|
||||
const row = formatRow(['123', 'active'], columns);
|
||||
expect(row).toBe('123 active ');
|
||||
});
|
||||
@@ -503,7 +518,10 @@ describe('CLI Output Formatting', () => {
|
||||
it('should format complete table', () => {
|
||||
const table = formatTable(
|
||||
['ID', 'Status'],
|
||||
[['1', 'active'], ['2', 'idle']],
|
||||
[
|
||||
['1', 'active'],
|
||||
['2', 'idle'],
|
||||
],
|
||||
[5, 8]
|
||||
);
|
||||
expect(table).toContain('ID');
|
||||
@@ -587,7 +605,7 @@ describe('CLI Output Formatting', () => {
|
||||
});
|
||||
|
||||
it('should format normal costs with 2 decimals', () => {
|
||||
expect(formatCost(1.50)).toBe('$1.50');
|
||||
expect(formatCost(1.5)).toBe('$1.50');
|
||||
expect(formatCost(0.05)).toBe('$0.05');
|
||||
});
|
||||
|
||||
@@ -633,7 +651,7 @@ describe('CLI Output Formatting', () => {
|
||||
|
||||
describe('List Formatting', () => {
|
||||
const formatList = (items: string[], bullet: string = '-'): string => {
|
||||
return items.map(item => `${bullet} ${item}`).join('\n');
|
||||
return items.map((item) => `${bullet} ${item}`).join('\n');
|
||||
};
|
||||
|
||||
const formatNumberedList = (items: string[]): string => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Per-instance isolation (src/config/instance.ts): the data dir + tmux socket
|
||||
* derive from CODEMAN_INSTANCE, defaulting to the production layout so the
|
||||
* feature branch is safe to merge to master.
|
||||
*
|
||||
* instance.ts reads env at module load, so each case re-imports it via
|
||||
* vi.resetModules() under a controlled env. node:fs mkdirSync is mocked so
|
||||
* getDataDir() never creates real directories on the test machine.
|
||||
*
|
||||
* Port: N/A (no server).
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
vi.mock('node:fs', async (orig) => {
|
||||
const actual = await orig<typeof import('node:fs')>();
|
||||
return { ...actual, mkdirSync: vi.fn() };
|
||||
});
|
||||
|
||||
const ENV_KEYS = ['CODEMAN_INSTANCE', 'CODEMAN_DATA_DIR'] as const;
|
||||
const ORIG: Record<string, string | undefined> = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
||||
|
||||
async function load(env: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {}) {
|
||||
vi.resetModules();
|
||||
for (const k of ENV_KEYS) {
|
||||
const v = env[k];
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
return import('../../src/config/instance.js');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (ORIG[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = ORIG[k];
|
||||
}
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('config/instance', () => {
|
||||
it('defaults to the production layout when CODEMAN_INSTANCE is unset', async () => {
|
||||
const m = await load({ CODEMAN_INSTANCE: undefined, CODEMAN_DATA_DIR: undefined });
|
||||
expect(m.CODEMAN_INSTANCE).toBe('');
|
||||
expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman');
|
||||
expect(m.getDataDir()).toBe(join(homedir(), '.codeman'));
|
||||
expect(m.dataPath('state.json')).toBe(join(homedir(), '.codeman', 'state.json'));
|
||||
});
|
||||
|
||||
it('treats an explicitly-empty CODEMAN_INSTANCE as the production layout', async () => {
|
||||
const m = await load({ CODEMAN_INSTANCE: '', CODEMAN_DATA_DIR: undefined });
|
||||
expect(m.CODEMAN_INSTANCE).toBe('');
|
||||
expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman');
|
||||
expect(m.getDataDir()).toBe(join(homedir(), '.codeman'));
|
||||
});
|
||||
|
||||
it('scopes BOTH the data dir and the tmux socket for a named instance', async () => {
|
||||
const m = await load({ CODEMAN_INSTANCE: 'beta', CODEMAN_DATA_DIR: undefined });
|
||||
expect(m.CODEMAN_INSTANCE).toBe('beta');
|
||||
expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman-beta');
|
||||
expect(m.getDataDir()).toBe(join(homedir(), '.codeman-beta'));
|
||||
expect(m.dataPath('mux-sessions.json')).toBe(join(homedir(), '.codeman-beta', 'mux-sessions.json'));
|
||||
});
|
||||
|
||||
it('supports an arbitrary instance name', async () => {
|
||||
const m = await load({ CODEMAN_INSTANCE: 'foo', CODEMAN_DATA_DIR: undefined });
|
||||
expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman-foo');
|
||||
expect(m.getDataDir()).toBe(join(homedir(), '.codeman-foo'));
|
||||
});
|
||||
|
||||
it('CODEMAN_DATA_DIR overrides the derived data dir (socket still instance-scoped)', async () => {
|
||||
const m = await load({ CODEMAN_INSTANCE: 'beta', CODEMAN_DATA_DIR: '/tmp/codeman-test-xyz' });
|
||||
expect(m.getDataDir()).toBe('/tmp/codeman-test-xyz');
|
||||
expect(m.dataPath('a', 'b')).toBe(join('/tmp/codeman-test-xyz', 'a', 'b'));
|
||||
// Socket is derived from the instance name, not the data dir override.
|
||||
expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman-beta');
|
||||
});
|
||||
});
|
||||
@@ -5,16 +5,22 @@ import { PORTS, KEYBOARD, SELECTORS, BODY_CLASSES, WAIT } from './helpers/consta
|
||||
import { createTestServer, stopTestServer } from './helpers/server.js';
|
||||
import { createDevicePage, getBrowser, closeAllBrowsers } from './helpers/browser.js';
|
||||
import {
|
||||
showKeyboard, hideKeyboard,
|
||||
showKeyboardViaCDP, hideKeyboardViaCDP,
|
||||
showKeyboardViaMock, hideKeyboardViaMock,
|
||||
showKeyboardViaDOM, hideKeyboardViaDOM,
|
||||
showKeyboard,
|
||||
hideKeyboard,
|
||||
showKeyboardViaCDP,
|
||||
hideKeyboardViaCDP,
|
||||
showKeyboardViaMock,
|
||||
hideKeyboardViaMock,
|
||||
showKeyboardViaDOM,
|
||||
hideKeyboardViaDOM,
|
||||
setupViewportMock,
|
||||
} from './helpers/keyboard-sim.js';
|
||||
import { getCDP, setVisualViewportHeight } from './helpers/cdp.js';
|
||||
import {
|
||||
assertHasClass, assertNotHasClass,
|
||||
assertVisible, assertHidden,
|
||||
assertHasClass,
|
||||
assertNotHasClass,
|
||||
assertVisible,
|
||||
assertHidden,
|
||||
getCSSProperty,
|
||||
} from './helpers/assertions.js';
|
||||
import { REPRESENTATIVE_DEVICES } from './devices.js';
|
||||
@@ -167,9 +173,7 @@ describe('Virtual Keyboard', () => {
|
||||
const success = await showKeyboardViaMock(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
|
||||
expect(success).toBe(true);
|
||||
|
||||
const hasClass = await page.evaluate(() =>
|
||||
document.body.classList.contains('keyboard-visible'),
|
||||
);
|
||||
const hasClass = await page.evaluate(() => document.body.classList.contains('keyboard-visible'));
|
||||
expect(hasClass).toBe(true);
|
||||
} finally {
|
||||
await context.close();
|
||||
@@ -280,12 +284,13 @@ describe('Virtual Keyboard', () => {
|
||||
expect(mainPadding).toBe('');
|
||||
});
|
||||
|
||||
it('accessory bar has 7 action buttons', async () => {
|
||||
const count = await page.evaluate(() => {
|
||||
const buttons = document.querySelectorAll('.keyboard-accessory-bar [data-action]');
|
||||
return buttons.length;
|
||||
it('accessory bar has the simple-mode action buttons', async () => {
|
||||
const actions = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
|
||||
(button) => (button as HTMLElement).dataset.action
|
||||
);
|
||||
});
|
||||
expect(count).toBe(7);
|
||||
expect(actions).toEqual(['scroll-up', 'scroll-down', 'init', 'clear', 'paste', 'dismiss']);
|
||||
});
|
||||
|
||||
it('double-tap confirm on /clear button', async () => {
|
||||
@@ -321,27 +326,6 @@ describe('Virtual Keyboard', () => {
|
||||
expect(text).toBe('Tap again');
|
||||
});
|
||||
|
||||
it('double-tap confirm on /compact button', async () => {
|
||||
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
|
||||
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
|
||||
|
||||
await page.evaluate(`
|
||||
if (typeof app !== 'undefined') app.activeSessionId = 'test-session';
|
||||
`);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const btn = document.querySelector('[data-action="compact"]') as HTMLElement;
|
||||
btn?.click();
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
const confirming = await page.evaluate(() => {
|
||||
const btn = document.querySelector('[data-action="compact"]');
|
||||
return btn?.classList.contains('confirming') ?? false;
|
||||
});
|
||||
expect(confirming).toBe(true);
|
||||
});
|
||||
|
||||
it('double-tap expires after 2s', async () => {
|
||||
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
|
||||
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isExplicitlyEnabled, isLoopbackBindHost } from '../src/web/network-auth-policy.js';
|
||||
|
||||
describe('network auth policy', () => {
|
||||
it.each(['localhost', '127.0.0.1', '127.42.0.9', '::1', '[::1]', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1'])(
|
||||
'treats %s as loopback',
|
||||
(host) => {
|
||||
expect(isLoopbackBindHost(host)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['0.0.0.0', '192.168.1.10', '10.0.0.1', 'example.com', '::', '[::]', '::ffff:192.168.1.10'])(
|
||||
'treats %s as non-loopback',
|
||||
(host) => {
|
||||
expect(isLoopbackBindHost(host)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['1', 'true', 'TRUE', ' yes ', 'on'])('treats %s as an explicit opt-in', (value) => {
|
||||
expect(isExplicitlyEnabled(value)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([undefined, '', '0', 'false', 'no', 'off', 'enabled'])('does not treat %s as an explicit opt-in', (value) => {
|
||||
expect(isExplicitlyEnabled(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1256,7 +1256,7 @@ describe('Operation Lightspeed', () => {
|
||||
await Promise.all(ids.map((id) => deleteSession(baseUrl, id)));
|
||||
});
|
||||
|
||||
it('should correctly filter SSE under concurrent session lifecycle', async () => {
|
||||
it('should broadcast lifecycle events while filtering concurrent session terminal streams', async () => {
|
||||
// Create 2 sessions
|
||||
const target = await createSession(baseUrl);
|
||||
const other = await createSession(baseUrl);
|
||||
@@ -1309,15 +1309,21 @@ describe('Operation Lightspeed', () => {
|
||||
|
||||
const events = parseSSEEvents(receivedData);
|
||||
|
||||
// Should see target's rename but not other's events
|
||||
const targetUpdated = events.find((e) => e.event === 'session:updated' && (e.data as any).id === target);
|
||||
// session:updated is a lifecycle event broadcast to all clients; the
|
||||
// subscription filter applies only to high-volume terminal streams.
|
||||
const updatedEvents = events.filter((e) => e.event === 'session:updated');
|
||||
const targetUpdated = updatedEvents.find((e) => (e.data as any).id === target);
|
||||
expect(targetUpdated).toBeDefined();
|
||||
|
||||
// Should NOT see other's events
|
||||
const otherEvents = events.filter(
|
||||
(e) => ((e.data as any)?.id === other || (e.data as any)?.sessionId === other) && e.event !== 'init'
|
||||
const otherLifecycleEvents = events.filter(
|
||||
(e) => e.event !== 'init' && e.event !== 'session:terminal' && (e.data as any)?.id === other
|
||||
);
|
||||
expect(otherEvents.length).toBe(0);
|
||||
expect(otherLifecycleEvents.length).toBeGreaterThan(0);
|
||||
|
||||
const otherTerminalEvents = events.filter(
|
||||
(e) => e.event === 'session:terminal' && (e.data as any)?.sessionId === other
|
||||
);
|
||||
expect(otherTerminalEvents.length).toBe(0);
|
||||
|
||||
await deleteSession(baseUrl, target);
|
||||
});
|
||||
|
||||
+130
-98
@@ -19,24 +19,24 @@ const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
// Thresholds (ms)
|
||||
const THRESHOLDS = {
|
||||
PAGE_LOAD: 3000, // Full page load including JS init
|
||||
DOMContentLoaded: 1500, // HTML parsed
|
||||
SSE_CONNECT: 2000, // SSE EventSource open
|
||||
TAB_CREATE_API: 200, // POST /api/sessions response
|
||||
TAB_RENDER: 300, // Tab element appears in DOM
|
||||
TAB_SWITCH: 400, // Tab click to active class applied (includes tmux session creation)
|
||||
TERMINAL_INIT: 500, // xterm.js instance created for tab
|
||||
INPUT_ROUNDTRIP: 500, // Keystroke sent via API → acknowledged
|
||||
SETTINGS_OPEN: 300, // Settings modal visible
|
||||
SETTINGS_CLOSE: 200, // Settings modal hidden
|
||||
PAGE_LOAD: 3000, // Full page load including JS init
|
||||
DOMContentLoaded: 1500, // Browser nav timing through deferred script execution
|
||||
SSE_CONNECT: 2000, // SSE EventSource open
|
||||
TAB_CREATE_API: 200, // POST /api/sessions response
|
||||
TAB_RENDER: 300, // Tab element appears in DOM
|
||||
TAB_SWITCH: 400, // Tab click to active class applied (includes tmux session creation)
|
||||
TERMINAL_INIT: 500, // xterm.js instance created for tab
|
||||
INPUT_ROUNDTRIP: 500, // Keystroke sent via API → acknowledged
|
||||
SETTINGS_OPEN: 300, // Settings modal visible
|
||||
SETTINGS_CLOSE: 200, // Settings modal hidden
|
||||
SESSION_OPTIONS_OPEN: 300, // Session options modal visible
|
||||
SESSION_OPTIONS_TAB: 200, // Modal tab switch
|
||||
SESSION_OPTIONS_TAB: 200, // Modal tab switch
|
||||
SUBAGENT_WINDOW_OPEN: 400, // Subagent window rendered
|
||||
SUBAGENT_WINDOW_CLOSE: 200,
|
||||
BULK_TAB_CREATE: 3000, // Create 10 sessions
|
||||
BULK_TAB_SWITCH_AVG: 300, // Average per-tab switch across 10 tabs (includes buffer loads)
|
||||
MEMORY_HEAP_MB: 200, // Max JS heap after heavy load
|
||||
BUFFER_LOAD_16KB: 500, // Load a 16KB terminal buffer
|
||||
BULK_TAB_CREATE: 3000, // Create 10 sessions
|
||||
BULK_TAB_SWITCH_AVG: 300, // Average per-tab switch across 10 tabs (includes buffer loads)
|
||||
MEMORY_HEAP_MB: 200, // Max JS heap after heavy load
|
||||
BUFFER_LOAD_16KB: 500, // Load a 16KB terminal buffer
|
||||
};
|
||||
|
||||
let server: WebServer;
|
||||
@@ -88,6 +88,25 @@ async function measure(fn: () => Promise<void>): Promise<number> {
|
||||
return performance.now() - start;
|
||||
}
|
||||
|
||||
type BrowserNavigationTiming = {
|
||||
domInteractive: number;
|
||||
domContentLoadedEventEnd: number;
|
||||
loadEventEnd: number;
|
||||
};
|
||||
|
||||
/** Get the browser's own navigation timing, excluding Playwright harness overhead. */
|
||||
async function getBrowserNavigationTiming(page: Page): Promise<BrowserNavigationTiming> {
|
||||
return page.evaluate(() => {
|
||||
const entry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
|
||||
if (!entry) throw new Error('Navigation timing entry not available');
|
||||
return {
|
||||
domInteractive: entry.domInteractive,
|
||||
domContentLoadedEventEnd: entry.domContentLoadedEventEnd,
|
||||
loadEventEnd: entry.loadEventEnd,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Get JS heap size in MB (Chromium only) */
|
||||
async function getHeapMB(page: Page): Promise<number> {
|
||||
const metrics = await page.evaluate(() => {
|
||||
@@ -123,12 +142,15 @@ describe('Page load performance', () => {
|
||||
it('DOMContentLoaded fires within threshold', async () => {
|
||||
({ context, page } = await freshPage());
|
||||
|
||||
const timing = await measure(async () => {
|
||||
const wallTiming = await measure(async () => {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
const navigationTiming = await getBrowserNavigationTiming(page);
|
||||
|
||||
console.log(`[page load] DOMContentLoaded: ${timing.toFixed(0)}ms`);
|
||||
expect(timing).toBeLessThan(THRESHOLDS.DOMContentLoaded);
|
||||
console.log(
|
||||
`[page load] DOMContentLoaded: ${navigationTiming.domContentLoadedEventEnd.toFixed(0)}ms (wall ${wallTiming.toFixed(0)}ms)`
|
||||
);
|
||||
expect(navigationTiming.domContentLoadedEventEnd).toBeLessThan(THRESHOLDS.DOMContentLoaded);
|
||||
});
|
||||
|
||||
it('full app initialization completes within threshold', async () => {
|
||||
@@ -157,7 +179,7 @@ describe('Page load performance', () => {
|
||||
const dot = document.getElementById('connectionDot');
|
||||
return dot?.classList.contains('connected') || indicator.style.display === 'none';
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -225,7 +247,7 @@ describe('Session tab creation', () => {
|
||||
await page.waitForFunction(
|
||||
(expected: number) => document.querySelectorAll('.session-tab').length > expected,
|
||||
tabCountBefore,
|
||||
{ timeout: 3000 },
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -250,7 +272,7 @@ describe('Session tab creation', () => {
|
||||
if (!container) return false;
|
||||
return container.querySelector('.xterm-screen') !== null;
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -277,7 +299,7 @@ describe('Tab switching performance', () => {
|
||||
await page.waitForFunction(
|
||||
(count: number) => document.querySelectorAll('.session-tab').length >= count,
|
||||
sessionIds.length,
|
||||
{ timeout: 5000 },
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
// Select first tab
|
||||
await page.locator(`.session-tab[data-id="${sessionIds[0]}"]`).click();
|
||||
@@ -303,7 +325,7 @@ describe('Tab switching performance', () => {
|
||||
await page.waitForFunction(
|
||||
(id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'),
|
||||
targetId,
|
||||
{ timeout: 2000 },
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
});
|
||||
timings.push(timing);
|
||||
@@ -325,7 +347,7 @@ describe('Tab switching performance', () => {
|
||||
await page.waitForFunction(
|
||||
(id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'),
|
||||
targetId,
|
||||
{ timeout: 2000 },
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
});
|
||||
timings.push(timing);
|
||||
@@ -362,11 +384,9 @@ describe('Bulk tab operations', () => {
|
||||
sessionIds.push(id);
|
||||
}
|
||||
// Wait for all tabs to render
|
||||
await page.waitForFunction(
|
||||
(count: number) => document.querySelectorAll('.session-tab').length >= count,
|
||||
10,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await page.waitForFunction((count: number) => document.querySelectorAll('.session-tab').length >= count, 10, {
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`[bulk create] 10 sessions: ${timing.toFixed(0)}ms`);
|
||||
@@ -383,7 +403,7 @@ describe('Bulk tab operations', () => {
|
||||
await page.waitForFunction(
|
||||
(id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'),
|
||||
targetId,
|
||||
{ timeout: 2000 },
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
});
|
||||
timings.push(timing);
|
||||
@@ -497,7 +517,10 @@ describe('Settings modal performance', () => {
|
||||
|
||||
it('closes within threshold', async () => {
|
||||
// Make sure it's open
|
||||
const isOpen = await page.locator('#appSettingsModal.active').isVisible().catch(() => false);
|
||||
const isOpen = await page
|
||||
.locator('#appSettingsModal.active')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
if (!isOpen) {
|
||||
await page.locator('.btn-settings').click();
|
||||
await page.waitForSelector('#appSettingsModal.active', { timeout: 2000 });
|
||||
@@ -506,10 +529,9 @@ describe('Settings modal performance', () => {
|
||||
const timing = await measure(async () => {
|
||||
// Press Escape to close
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('#appSettingsModal')?.classList.contains('active'),
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
await page.waitForFunction(() => !document.querySelector('#appSettingsModal')?.classList.contains('active'), {
|
||||
timeout: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`[settings] close: ${timing.toFixed(0)}ms`);
|
||||
@@ -528,10 +550,9 @@ describe('Settings modal performance', () => {
|
||||
// Close
|
||||
const closeTime = await measure(async () => {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('#appSettingsModal')?.classList.contains('active'),
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
await page.waitForFunction(() => !document.querySelector('#appSettingsModal')?.classList.contains('active'), {
|
||||
timeout: 2000,
|
||||
});
|
||||
});
|
||||
timings.push(openTime + closeTime);
|
||||
}
|
||||
@@ -575,7 +596,10 @@ describe('Session options modal performance', () => {
|
||||
|
||||
it('tab switching within modal is instant', async () => {
|
||||
// Ensure modal is open
|
||||
const isOpen = await page.locator('#sessionOptionsModal.active').isVisible().catch(() => false);
|
||||
const isOpen = await page
|
||||
.locator('#sessionOptionsModal.active')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
if (!isOpen) {
|
||||
await page.locator(`.session-tab[data-id="${sessionId}"] .tab-gear`).click();
|
||||
await page.waitForSelector('#sessionOptionsModal.active', { timeout: 2000 });
|
||||
@@ -590,7 +614,7 @@ describe('Session options modal performance', () => {
|
||||
await page.waitForFunction(
|
||||
(t: string) => document.querySelector(`[data-tab="${t}"]`)?.classList.contains('active'),
|
||||
tab,
|
||||
{ timeout: 1000 },
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
timings.push(timing);
|
||||
@@ -634,27 +658,32 @@ describe('Subagent window simulation', () => {
|
||||
}, sessionId);
|
||||
|
||||
const timing = await measure(async () => {
|
||||
await page.evaluate(({ agentId, cSessionId }: { agentId: string; cSessionId: string }) => {
|
||||
const app = (window as unknown as {
|
||||
app: {
|
||||
subagents: Map<string, Record<string, unknown>>;
|
||||
openSubagentWindow: (id: string) => void;
|
||||
}
|
||||
}).app;
|
||||
// Inject fake agent data
|
||||
app.subagents.set(agentId, {
|
||||
agentId,
|
||||
sessionId: cSessionId,
|
||||
status: 'active',
|
||||
description: 'Performance test agent',
|
||||
startedAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
toolCallCount: 0,
|
||||
entryCount: 0,
|
||||
fileSize: 0,
|
||||
});
|
||||
app.openSubagentWindow(agentId);
|
||||
}, { agentId: 'perf-agent-1', cSessionId: claudeSessionId });
|
||||
await page.evaluate(
|
||||
({ agentId, cSessionId }: { agentId: string; cSessionId: string }) => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
subagents: Map<string, Record<string, unknown>>;
|
||||
openSubagentWindow: (id: string) => void;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
// Inject fake agent data
|
||||
app.subagents.set(agentId, {
|
||||
agentId,
|
||||
sessionId: cSessionId,
|
||||
status: 'active',
|
||||
description: 'Performance test agent',
|
||||
startedAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
toolCallCount: 0,
|
||||
entryCount: 0,
|
||||
fileSize: 0,
|
||||
});
|
||||
app.openSubagentWindow(agentId);
|
||||
},
|
||||
{ agentId: 'perf-agent-1', cSessionId: claudeSessionId }
|
||||
);
|
||||
await page.waitForSelector('.subagent-window', { timeout: 3000 });
|
||||
});
|
||||
|
||||
@@ -679,7 +708,7 @@ describe('Subagent window simulation', () => {
|
||||
const el = document.getElementById('subagent-window-perf-agent-1');
|
||||
return el && el.style.display === 'none';
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -698,33 +727,35 @@ describe('Subagent window simulation', () => {
|
||||
|
||||
const timing = await measure(async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.evaluate(({ idx, cSessionId }: { idx: number; cSessionId: string }) => {
|
||||
const agentId = `perf-multi-agent-${idx}`;
|
||||
const app = (window as unknown as {
|
||||
app: {
|
||||
subagents: Map<string, Record<string, unknown>>;
|
||||
openSubagentWindow: (id: string) => void;
|
||||
}
|
||||
}).app;
|
||||
app.subagents.set(agentId, {
|
||||
agentId,
|
||||
sessionId: cSessionId,
|
||||
status: 'active',
|
||||
description: `Perf agent ${idx}`,
|
||||
startedAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
toolCallCount: 0,
|
||||
entryCount: 0,
|
||||
fileSize: 0,
|
||||
});
|
||||
app.openSubagentWindow(agentId);
|
||||
}, { idx: i, cSessionId: claudeSessionId });
|
||||
await page.evaluate(
|
||||
({ idx, cSessionId }: { idx: number; cSessionId: string }) => {
|
||||
const agentId = `perf-multi-agent-${idx}`;
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
subagents: Map<string, Record<string, unknown>>;
|
||||
openSubagentWindow: (id: string) => void;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
app.subagents.set(agentId, {
|
||||
agentId,
|
||||
sessionId: cSessionId,
|
||||
status: 'active',
|
||||
description: `Perf agent ${idx}`,
|
||||
startedAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
toolCallCount: 0,
|
||||
entryCount: 0,
|
||||
fileSize: 0,
|
||||
});
|
||||
app.openSubagentWindow(agentId);
|
||||
},
|
||||
{ idx: i, cSessionId: claudeSessionId }
|
||||
);
|
||||
}
|
||||
// Wait for all 5
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.subagent-window').length >= 5,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await page.waitForFunction(() => document.querySelectorAll('.subagent-window').length >= 5, { timeout: 5000 });
|
||||
});
|
||||
|
||||
const windowCount = await page.locator('.subagent-window').count();
|
||||
@@ -772,7 +803,7 @@ describe('SSE event throughput', () => {
|
||||
await page.waitForFunction(
|
||||
(count: number) => document.querySelectorAll('.session-tab').length >= count,
|
||||
sessionIds.length,
|
||||
{ timeout: 5000 },
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
@@ -797,7 +828,7 @@ describe('SSE event throughput', () => {
|
||||
await page.waitForFunction(
|
||||
(count: number) => document.querySelectorAll('.session-tab').length >= count,
|
||||
sessionIds.length,
|
||||
{ timeout: 5000 },
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
const start = performance.now();
|
||||
@@ -813,7 +844,7 @@ describe('SSE event throughput', () => {
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
@@ -880,11 +911,9 @@ describe('Memory usage under load', () => {
|
||||
const id = await createSession(page, `perf-mem-${i}`);
|
||||
sessionIds.push(id);
|
||||
}
|
||||
await page.waitForFunction(
|
||||
(count: number) => document.querySelectorAll('.session-tab').length >= count,
|
||||
10,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await page.waitForFunction((count: number) => document.querySelectorAll('.session-tab').length >= count, 10, {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Switch through all tabs
|
||||
for (const id of sessionIds) {
|
||||
@@ -895,10 +924,13 @@ describe('Memory usage under load', () => {
|
||||
const heapAfter = await getHeapMB(page);
|
||||
const heapGrowth = heapAfter - heapBefore;
|
||||
|
||||
console.log(`[memory] before: ${heapBefore.toFixed(1)}MB, after: ${heapAfter.toFixed(1)}MB, growth: ${heapGrowth.toFixed(1)}MB`);
|
||||
console.log(
|
||||
`[memory] before: ${heapBefore.toFixed(1)}MB, after: ${heapAfter.toFixed(1)}MB, growth: ${heapGrowth.toFixed(1)}MB`
|
||||
);
|
||||
|
||||
// Heap should stay under absolute limit
|
||||
if (heapAfter > 0) { // memory API may not be available
|
||||
if (heapAfter > 0) {
|
||||
// memory API may not be available
|
||||
expect(heapAfter).toBeLessThan(THRESHOLDS.MEMORY_HEAP_MB);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -47,7 +47,8 @@ interface PushPayload {
|
||||
|
||||
function makeServerWithHost(host: string): WebServer {
|
||||
// Constructor only assigns fields — no network/disk activity until start().
|
||||
const server = new WebServer(0, false, true, host);
|
||||
// 4th arg is the bind host; the title hostname is the 5th arg.
|
||||
const server = new WebServer(0, false, true, '127.0.0.1', host);
|
||||
// Stub push store: one subscription with all events enabled.
|
||||
const fakeSub = {
|
||||
endpoint: 'https://push.example.com/abc',
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('Ralph Integration Tests', () => {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/non-existent-id`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(404);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
@@ -136,7 +136,8 @@ describe('Ralph Integration Tests', () => {
|
||||
// Verify session is gone
|
||||
const getRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`);
|
||||
const getData = await getRes.json();
|
||||
expect(getData.error).toBe('Session not found');
|
||||
expect(getRes.status).toBe(404);
|
||||
expect(getData.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should create shell session', async () => {
|
||||
@@ -191,7 +192,7 @@ describe('Ralph Integration Tests', () => {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/fake-session/ralph-state`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(404);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toContain('not found');
|
||||
});
|
||||
@@ -359,7 +360,7 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('INVALID_INPUT');
|
||||
});
|
||||
@@ -372,7 +373,7 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(404);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
@@ -395,7 +396,7 @@ describe('Ralph Integration Tests', () => {
|
||||
createdSessions.push(createData.sessionId);
|
||||
|
||||
// Wait for session to be ready
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
@@ -422,7 +423,7 @@ describe('Ralph Integration Tests', () => {
|
||||
createdSessions.push(createData.sessionId);
|
||||
|
||||
// Wait for session to be ready
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
@@ -431,7 +432,7 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('INVALID_INPUT');
|
||||
});
|
||||
@@ -472,7 +473,7 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(404);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
@@ -497,7 +498,7 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('INVALID_INPUT');
|
||||
});
|
||||
@@ -536,7 +537,7 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(404);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
@@ -561,7 +562,7 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('INVALID_INPUT');
|
||||
});
|
||||
@@ -626,7 +627,7 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(404);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
@@ -907,7 +908,7 @@ describe('Ralph Integration Tests', () => {
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(404);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
@@ -991,7 +992,7 @@ describe('Ralph Integration Tests', () => {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/fake-session/output`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(404);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
@@ -1021,7 +1022,7 @@ describe('Ralph Integration Tests', () => {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/fake-session/terminal`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(404);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* WebServer.renderIndexHtml — server-side gating of the index shell:
|
||||
* - multi-monitor button reveal (stable class-marker, not brittle copy match)
|
||||
* - solo (/session/:id) global injection + escaping, and settings skipped
|
||||
* - gesture overlay availability vs. enablement (CODEMAN_GESTURE + setting)
|
||||
* - settings read FRESH so a post-save reload doesn't render stale state
|
||||
*
|
||||
* WebServer's constructor only assigns fields (no port bind), so we construct it
|
||||
* directly, swap in a tiny indexHtmlTemplate, and stub readSettings to avoid disk.
|
||||
*
|
||||
* Port: N/A (no server start).
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const TEMPLATE = [
|
||||
'<head>',
|
||||
'<title>Codeman</title>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
'<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" aria-label="Open Codeman across all displays"></button>',
|
||||
'</body>',
|
||||
].join('\n');
|
||||
|
||||
function makeServer(settings: Record<string, unknown> = {}) {
|
||||
const server = new WebServer(0, false, true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(server as any).indexHtmlTemplate = TEMPLATE;
|
||||
const readSettings = vi.fn(async () => settings);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(server as any).readSettings = readSettings;
|
||||
return { server, readSettings };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const render = (server: WebServer, solo?: string): Promise<string> => (server as any).renderIndexHtml(solo);
|
||||
|
||||
const ORIG_GESTURE = process.env.CODEMAN_GESTURE;
|
||||
afterEach(() => {
|
||||
if (ORIG_GESTURE === undefined) delete process.env.CODEMAN_GESTURE;
|
||||
else process.env.CODEMAN_GESTURE = ORIG_GESTURE;
|
||||
});
|
||||
|
||||
describe('WebServer.renderIndexHtml', () => {
|
||||
it('keeps the multi-monitor button hidden by default and reads settings FRESH', async () => {
|
||||
const { server, readSettings } = makeServer({});
|
||||
const html = await render(server);
|
||||
expect(html).toContain('btn-multimonitor--hidden');
|
||||
// forceFresh=true — fixes the post-save reload race against the 2s cache.
|
||||
expect(readSettings).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('reveals the multi-monitor button when showMultiMonitorButton is set', async () => {
|
||||
const { server } = makeServer({ showMultiMonitorButton: true });
|
||||
const html = await render(server);
|
||||
expect(html).not.toContain('btn-multimonitor--hidden');
|
||||
expect(html).toContain('btn-multimonitor"'); // class list still present, only the marker stripped
|
||||
});
|
||||
|
||||
it('injects the solo global and skips settings for a /session/:id window', async () => {
|
||||
const { server, readSettings } = makeServer({ showMultiMonitorButton: true });
|
||||
const html = await render(server, 'sess-123');
|
||||
expect(html).toContain('window.__CODEMAN_SOLO__="sess-123"');
|
||||
expect(readSettings).not.toHaveBeenCalled();
|
||||
// Solo skips settings, so the button is NOT revealed even though the setting is on.
|
||||
expect(html).toContain('btn-multimonitor--hidden');
|
||||
});
|
||||
|
||||
it('escapes the solo id so it cannot break out of the inline <script>', async () => {
|
||||
const { server } = makeServer({});
|
||||
const html = await render(server, 'a</script><b>');
|
||||
expect(html).not.toContain('</script><b>');
|
||||
expect(html).toContain('\\u003c');
|
||||
});
|
||||
|
||||
it('exposes gesture availability but injects the bundle only when enabled', async () => {
|
||||
process.env.CODEMAN_GESTURE = '1';
|
||||
let { server } = makeServer({ gestureControlEnabled: false });
|
||||
let html = await render(server);
|
||||
expect(html).toContain('window.__codemanGestureAvailable=true');
|
||||
expect(html).not.toContain('gesture-codeman.js');
|
||||
|
||||
({ server } = makeServer({ gestureControlEnabled: true }));
|
||||
html = await render(server);
|
||||
expect(html).toContain('window.__codemanGestureAvailable=true');
|
||||
expect(html).toContain('gesture-codeman.js');
|
||||
});
|
||||
|
||||
it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => {
|
||||
delete process.env.CODEMAN_GESTURE;
|
||||
const { server } = makeServer({ gestureControlEnabled: true });
|
||||
const html = await render(server);
|
||||
expect(html).not.toContain('__codemanGestureAvailable');
|
||||
expect(html).not.toContain('gesture-codeman.js');
|
||||
});
|
||||
});
|
||||
@@ -305,6 +305,22 @@ describe('file-routes', () => {
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
});
|
||||
|
||||
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
|
||||
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
|
||||
mockedReadFile.mockResolvedValue(content as never);
|
||||
mockedStat.mockResolvedValue({ size: content.length } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=malicious.svg`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('application/octet-stream');
|
||||
expect(res.headers['content-disposition']).toContain('attachment; filename="malicious.svg"');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
|
||||
it('rejects path traversal in raw file serving', async () => {
|
||||
mockedRealpathSync.mockReturnValue('/etc/shadow' as never);
|
||||
|
||||
@@ -363,4 +379,63 @@ describe('file-routes', () => {
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/download ==========
|
||||
|
||||
describe('GET /api/download', () => {
|
||||
it('requires a sessionId to scope downloads', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/download?path=${encodeURIComponent('/tmp/test-workdir/report.txt')}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('downloads files scoped to the session working directory', async () => {
|
||||
const content = Buffer.from('download content');
|
||||
mockedReadFile.mockResolvedValue(content as never);
|
||||
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=report.txt`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-disposition']).toContain('filename="report.txt"');
|
||||
expect(res.body).toBe('download content');
|
||||
});
|
||||
|
||||
it('rejects absolute paths outside the session working directory', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent('/var/log/app.log')}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects symlink targets that escape the session working directory', async () => {
|
||||
mockedRealpathSync.mockReturnValue('/tmp/outside-workdir/link.log' as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(
|
||||
'/tmp/test-workdir/link.log'
|
||||
)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('blocks sensitive files even when they are inside the session working directory', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=.env`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* POST /api/system/span-displays (multi-monitor launcher) + resolveSpanUrl.
|
||||
*
|
||||
* The route shells out to scripts/span-codeman.sh, so we mock child_process.spawn
|
||||
* to avoid actually opening a browser (and to assert the sanitized URL passed to
|
||||
* it). process.platform is overridden per-case so the macOS-only guard is tested
|
||||
* deterministically regardless of where the suite runs.
|
||||
*
|
||||
* Port: N/A (app.inject).
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
|
||||
const spawnMock = vi.hoisted(() => vi.fn(() => ({ on: vi.fn(), unref: vi.fn() })));
|
||||
vi.mock('node:child_process', async (orig) => {
|
||||
const actual = await orig<typeof import('node:child_process')>();
|
||||
return { ...actual, spawn: spawnMock };
|
||||
});
|
||||
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerSystemRoutes, resolveSpanUrl } from '../../src/web/routes/system-routes.js';
|
||||
|
||||
const REAL_PLATFORM = process.platform;
|
||||
function setPlatform(p: NodeJS.Platform) {
|
||||
Object.defineProperty(process, 'platform', { value: p, configurable: true });
|
||||
}
|
||||
afterEach(() => {
|
||||
setPlatform(REAL_PLATFORM);
|
||||
spawnMock.mockClear();
|
||||
});
|
||||
|
||||
describe('resolveSpanUrl', () => {
|
||||
it('takes a digits-only port from the Host header, pinned to localhost', () => {
|
||||
expect(resolveSpanUrl('localhost:5000')).toBe('http://localhost:5000');
|
||||
// Hostname is discarded — always localhost (same machine).
|
||||
expect(resolveSpanUrl('attacker.example.com:3000')).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('falls back to the default port for missing / non-numeric ports', () => {
|
||||
expect(resolveSpanUrl(undefined)).toBe('http://localhost:3000');
|
||||
expect(resolveSpanUrl('localhost')).toBe('http://localhost:3000');
|
||||
expect(resolveSpanUrl('localhost:99;rm -rf /')).toBe('http://localhost:3000');
|
||||
expect(resolveSpanUrl('localhost:80abc')).toBe('http://localhost:3000');
|
||||
expect(resolveSpanUrl('x', '5000')).toBe('http://localhost:5000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/system/span-displays', () => {
|
||||
it('returns 400 (macOS-only) on non-darwin and never spawns', async () => {
|
||||
setPlatform('linux');
|
||||
const { app } = await createRouteTestHarness(registerSystemRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/system/span-displays' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(res.json().error).toMatch(/macOS/i);
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('spawns the launcher with the sanitized localhost URL on darwin', async () => {
|
||||
setPlatform('darwin');
|
||||
const { app } = await createRouteTestHarness(registerSystemRoutes);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/system/span-displays',
|
||||
headers: { host: 'localhost:5000' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ success: true, url: 'http://localhost:5000' });
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
const [cmd, args] = spawnMock.mock.calls[0] as [string, string[]];
|
||||
expect(cmd).toBe('bash');
|
||||
expect(args[0]).toMatch(/span-codeman\.sh$/);
|
||||
expect(args[1]).toBe('http://localhost:5000');
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -32,36 +32,38 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const indexHtmlPath = join(__dirname, '..', 'src', 'web', 'public', 'index.html');
|
||||
const rawTemplate = readFileSync(indexHtmlPath, 'utf-8');
|
||||
|
||||
function render(host?: string): string {
|
||||
const server = new WebServer(0, false, true, host);
|
||||
return (server as unknown as { renderIndexHtml: () => string }).renderIndexHtml();
|
||||
async function render(host?: string): Promise<string> {
|
||||
// 4th arg is the bind host; the title hostname is the 5th arg.
|
||||
const server = new WebServer(0, false, true, '127.0.0.1', host);
|
||||
// renderIndexHtml is async (it reads settings.json for the gesture bundle).
|
||||
return (server as unknown as { renderIndexHtml: () => Promise<string> }).renderIndexHtml();
|
||||
}
|
||||
|
||||
describe('WebServer index.html <title> templating (#82)', () => {
|
||||
it('substitutes the bare <title>Codeman</title> with codeman:<host>', () => {
|
||||
const html = render('laptop');
|
||||
it('substitutes the bare <title>Codeman</title> with codeman:<host>', async () => {
|
||||
const html = await render('laptop');
|
||||
expect(html).toContain('<title>codeman:laptop</title>');
|
||||
expect(html).not.toContain('<title>Codeman</title>');
|
||||
});
|
||||
|
||||
it('defaults to os.hostname() when no titleHostname is supplied', () => {
|
||||
const html = render();
|
||||
it('defaults to os.hostname() when no titleHostname is supplied', async () => {
|
||||
const html = await render();
|
||||
const expected = `<title>codeman:${osHostname()}</title>`;
|
||||
expect(html).toContain(expected);
|
||||
});
|
||||
|
||||
it('treats an empty-string titleHostname as "not supplied" and falls back to os.hostname()', () => {
|
||||
it('treats an empty-string titleHostname as "not supplied" and falls back to os.hostname()', async () => {
|
||||
// CLI normally guarantees a non-empty string, but the constructor's
|
||||
// `titleHostname || getHostname()` guard makes empty fall through —
|
||||
// pin that behavior so a future refactor doesn't accidentally ship
|
||||
// a `<title>codeman:</title>` to users.
|
||||
const html = render('');
|
||||
const html = await render('');
|
||||
expect(html).toMatch(/<title>codeman:.+<\/title>/);
|
||||
expect(html).not.toContain('<title>codeman:</title>');
|
||||
});
|
||||
|
||||
it('HTML-escapes < > & in the hostname so it cannot break out of the title tag', () => {
|
||||
const html = render('<script>alert(1)</script>');
|
||||
it('HTML-escapes < > & in the hostname so it cannot break out of the title tag', async () => {
|
||||
const html = await render('<script>alert(1)</script>');
|
||||
expect(html).toContain('<title>codeman:<script>alert(1)</script></title>');
|
||||
// The raw closing </title> from the injected payload must NOT appear
|
||||
// outside the actual title element — escape-then-substitute prevents
|
||||
@@ -69,16 +71,18 @@ describe('WebServer index.html <title> templating (#82)', () => {
|
||||
expect(html).not.toContain('<script>alert(1)</script></title>');
|
||||
});
|
||||
|
||||
it('escapes an ampersand without double-encoding existing entities', () => {
|
||||
it('escapes an ampersand without double-encoding existing entities', async () => {
|
||||
// The escaper replaces & first, then < and >. A hostname that already
|
||||
// contains a literal `&` should render as `&` once, not `&amp;`.
|
||||
const html = render('a&b');
|
||||
const html = await render('a&b');
|
||||
expect(html).toContain('<title>codeman:a&b</title>');
|
||||
expect(html).not.toContain('&amp;');
|
||||
});
|
||||
|
||||
it('only substitutes the <title> tag — the rest of the template is byte-for-byte identical', () => {
|
||||
const html = render('laptop');
|
||||
it('only substitutes the <title> tag — the rest of the template is identical (modulo asset cache-busting)', async () => {
|
||||
// renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin
|
||||
// .js/.css refs; strip them so the title remains the only other change.
|
||||
const html = (await render('laptop')).replace(/(\.(?:js|css))\?v=[^"]*/g, '$1');
|
||||
const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
|
||||
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
|
||||
expect(html.startsWith(beforeTitle)).toBe(true);
|
||||
@@ -88,8 +92,8 @@ describe('WebServer index.html <title> templating (#82)', () => {
|
||||
expect(html.length - rawTemplate.length).toBe(expectedDelta);
|
||||
});
|
||||
|
||||
it('replaces the <title> placeholder exactly once', () => {
|
||||
const html = render('laptop');
|
||||
it('replaces the <title> placeholder exactly once', async () => {
|
||||
const html = await render('laptop');
|
||||
// Defense against a future regression where the template gains a
|
||||
// second `<title>Codeman</title>` (e.g. inside a <noscript>) and only
|
||||
// the first gets templated — would leave a stale literal in the served
|
||||
@@ -100,9 +104,9 @@ describe('WebServer index.html <title> templating (#82)', () => {
|
||||
expect(occurrencesOfOld).toBe(0);
|
||||
});
|
||||
|
||||
it('two WebServer instances on different hostnames render distinct titles', () => {
|
||||
const htmlA = render('host-a');
|
||||
const htmlB = render('host-b');
|
||||
it('two WebServer instances on different hostnames render distinct titles', async () => {
|
||||
const htmlA = await render('host-a');
|
||||
const htmlB = await render('host-b');
|
||||
expect(htmlA).toContain('<title>codeman:host-a</title>');
|
||||
expect(htmlB).toContain('<title>codeman:host-b</title>');
|
||||
expect(htmlA).not.toContain('host-b');
|
||||
|
||||
+5
-1
@@ -6,11 +6,15 @@
|
||||
* This means tests CANNOT kill, create, or interact with real tmux
|
||||
* sessions regardless of what the test code does.
|
||||
*
|
||||
* This setup file only handles mock/timer cleanup between tests.
|
||||
* This setup file strips shell-level auth configuration that can leak from a
|
||||
* running Codeman instance, then handles mock/timer cleanup between tests.
|
||||
*/
|
||||
|
||||
import { afterEach, vi } from 'vitest';
|
||||
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
|
||||
Reference in New Issue
Block a user