mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(web): address self-review findings on #103 (master-safe defaults + hardening)
Make the branch genuinely master-mergeable and fix several review findings: - Defaults are now prod-safe: CODEMAN_INSTANCE defaults to '' (→ ~/.codeman, -L codeman) and the web port back to 3000, so an existing install upgrades cleanly. Port also honors a new CODEMAN_PORT env var. Run the beta isolated alongside prod with scripts/run-beta.sh (CODEMAN_INSTANCE=beta + PORT 5000). - .gitignore: anchor the root `public` symlink rule to `/public` (a bare `public` also swallowed src/web/public, silently un-staging new web assets); ignore the gesture wasm/model binaries explicitly instead. - span-displays: add a macOS-only guard (400 elsewhere instead of spawning a bash that fails invisibly); extract resolveSpanUrl() for unit testing. - server.ts: memoize asset-version stat() calls (~1s TTL) so each index render doesn't re-stat every script/link tag. - styles.css: hide the multi-monitor button in solo (detached) windows. - app.js: require two consecutive unanswered roll-calls before redocking, so a timer-throttled background popup isn't wrongly un-marked. - index.html: make the "skip to terminal" link base-href-safe (onclick scroll) so it doesn't navigate to the dashboard from a /session/:id window. - Tests: test/config/instance.test.ts, test/routes/system-span-displays.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
|
||||
|
||||
@@ -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, `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 (re-stat'd per render; 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.
|
||||
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)
|
||||
|
||||
@@ -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: `5000` on this `beta/session-detach` branch (`3000` on master). 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 |
|
||||
|------|---------|
|
||||
@@ -98,7 +98,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. **This `beta/session-detach` branch defaults to `CODEMAN_INSTANCE=beta` and port 5000** so it coexists with a prod Codeman out of the box. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* 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 (the bare `public` rule in .gitignore covers them); they are fetched here
|
||||
* at install (postinstall) and build time instead.
|
||||
* 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
|
||||
|
||||
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 "$@"
|
||||
+1
-1
@@ -483,7 +483,7 @@ program
|
||||
program
|
||||
.command('web')
|
||||
.description('Start the web interface')
|
||||
.option('-p, --port <port>', 'Port to listen on', '5000')
|
||||
.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')
|
||||
.action(async (options) => {
|
||||
|
||||
+14
-8
@@ -9,9 +9,15 @@
|
||||
*
|
||||
* 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 (this branch): `beta` → `~/.codeman-beta` + `tmux -L codeman-beta`
|
||||
* - `CODEMAN_INSTANCE=` (empty) → `~/.codeman` + `tmux -L codeman` (prod layout)
|
||||
* - `CODEMAN_INSTANCE=foo` → `~/.codeman-foo` + `tmux -L codeman-foo`
|
||||
* - 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).
|
||||
@@ -22,12 +28,12 @@ import { join } from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Instance name. Empty string = production layout (`~/.codeman`, `-L codeman`).
|
||||
* Defaults to `beta` on the beta/session-detach branch so it never collides
|
||||
* with a production Codeman. Set `CODEMAN_INSTANCE=` (empty) to opt back into
|
||||
* the production layout.
|
||||
* 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 ?? 'beta';
|
||||
export const CODEMAN_INSTANCE = process.env.CODEMAN_INSTANCE ?? '';
|
||||
|
||||
const INSTANCE_SUFFIX = CODEMAN_INSTANCE ? `-${CODEMAN_INSTANCE}` : '';
|
||||
|
||||
|
||||
+13
-2
@@ -309,6 +309,7 @@ class CodemanApp {
|
||||
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
|
||||
@@ -879,6 +880,7 @@ class CodemanApp {
|
||||
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);
|
||||
}
|
||||
@@ -965,6 +967,7 @@ class CodemanApp {
|
||||
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
|
||||
@@ -993,10 +996,18 @@ class CodemanApp {
|
||||
if (!orphans.length) return;
|
||||
this._detachPingPending = new Set(orphans);
|
||||
this._postWindowMessage({ type: 'roll-call' });
|
||||
// Live popups answer 'detached' (clearing themselves above); survivors are gone.
|
||||
// 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) this._redock(id);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -59,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">
|
||||
|
||||
@@ -963,6 +963,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,18 @@ 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
|
||||
@@ -250,17 +262,21 @@ export function registerSystemRoutes(
|
||||
// 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. Pin the host to localhost (same
|
||||
// machine) and take only a digits-only port from the Host header so nothing
|
||||
// attacker-controllable reaches the launched browser.
|
||||
const hostPort = String(req.headers.host ?? '').split(':')[1] ?? '';
|
||||
const port = /^\d+$/.test(hostPort) ? hostPort : '5000';
|
||||
const url = `http://localhost:${port}`;
|
||||
// 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'));
|
||||
|
||||
+27
-17
@@ -1021,37 +1021,47 @@ export class WebServer extends EventEmitter {
|
||||
return html;
|
||||
}
|
||||
|
||||
/** Cache-busting query for the gesture bundle: its mtime, re-read per render.
|
||||
* 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. Re-stat'ing each render means a freshly copied-in bundle is
|
||||
* picked up with no server restart. Empty string if the file is missing. */
|
||||
private gestureBundleVersion(): string {
|
||||
/** 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 p = join(__dirname, 'public', 'gesture', 'gesture-codeman.js');
|
||||
return `?v=${Math.floor(statSync(p).mtimeMs)}`;
|
||||
const v = Math.floor(statSync(absPath).mtimeMs);
|
||||
this._assetVersionMemo.set(absPath, { v, ts: now });
|
||||
return v;
|
||||
} catch {
|
||||
return '';
|
||||
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 re-stat'd per render, so
|
||||
* a changed file is picked up with no server restart. External URLs (have a
|
||||
* 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) => {
|
||||
try {
|
||||
const v = Math.floor(statSync(join(publicDir, ref)).mtimeMs);
|
||||
return `${pre}${ref}?v=${v}${post}`;
|
||||
} catch {
|
||||
return full;
|
||||
}
|
||||
const v = this.assetVersion(join(publicDir, ref));
|
||||
return v === null ? full : `${pre}${ref}?v=${v}${post}`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user