diff --git a/CONTINUITY.md b/CONTINUITY.md index d36e8c7..54c7509 100644 --- a/CONTINUITY.md +++ b/CONTINUITY.md @@ -34,6 +34,7 @@ Key decisions: - React UI is primary; server should serve React build when present. - Agent lane follows vanilla JS behavior: show all open sessions (active + idle) and sort by state rank then CPU. - Layout uses pinned positions per agent identity; only new agents are placed; existing tiles never move; placement uses max-height bounds to prevent overlap. +- Scale placement with spatial hash buckets (cell -> occupants) and persistent per-group spiral frontier; collisions checked by bounds against bucket occupants. - Codex prompt-like titles containing temp paths/turn markers are ignored for lane labels; fallback to repo or codex#pid. - Agent lane now shows only active/error sessions and uses stable sort (state rank then identity) to prevent reordering. - Active lane items now glow via `is-active` class (lane item box-shadow) to make active codex sessions visibly highlighted. @@ -133,6 +134,11 @@ State: - Tests re-run: `npm run test:unit` (149 pass), `npm run test:integration` (54 pass), `npm run test:ui` (19 pass). - Build run after pinned layout changes: `npm run build` (pass). - Copied edited files to clipboard via pbcopy; temp bundle at `/tmp/consensus-edited-files.txt`. + - Implemented spatial hash buckets + persistent group spiral frontier; fixed cell packing and bounds-based collision checks (public/src/lib/layout.ts). + - Renderer now keeps a single RAF loop, exposes hit list, and uses correct view transform for hit tests (public/src/hooks/useCanvasRenderer.ts). + - CanvasScene no longer rebuilds hit list; uses renderer hit list and stable render loop (public/src/components/CanvasScene.tsx). + - Obstruction detection checks only front occluders with AABB guard (public/src/hooks/useCanvasRenderer.ts). + - Tests run after layout/renderer updates: `npm run test:unit` (152 pass), `npm run test:integration` (58 pass), `npm run test:ui` (19 pass). - Now: - Answer user question about hot reload configuration (Vite HMR + server watch + live reload SSE). - Answer whether any additional setup is missing when using Vite. @@ -161,6 +167,7 @@ State: - Claude CLI hooks not firing for real sessions; user reports Claude does not show active when working. - Need to validate live Claude session while recording via agent-browser and inspect hook delivery. - Copy edited layout/test files to clipboard via pbcopy. (DONE) + - Implement spatial hash buckets + persistent group frontier with correct cell packing and bounds checks. (DONE) - User requested a best-practice plan (no code edits) to address review comment: `npm run dev` no longer starts Vite, so TSX client fails unless dev:client or build is run. - Q&A answered for dev workflow: always start Vite; use CONSENSUS_UI_PORT for port; on port conflict try next port. - Review plan requested for new P2/P3 items: Codex in-flight timeout default in codexLogs; TOML [tui] notifications insertion in cli/setup. diff --git a/docs/api-authentication.md b/docs/api-authentication.md index f47915e..010803b 100644 --- a/docs/api-authentication.md +++ b/docs/api-authentication.md @@ -4,32 +4,31 @@ This document records how Consensus exposes HTTP/WS APIs today and how callers p ## Trust boundary - The server binds to `CONSENSUS_HOST` (default `127.0.0.1`) and `CONSENSUS_PORT` (default `8787`). By default all API traffic stays on localhost and the UI/API share the same origin. -- There is no built-in token, cookie, or OAuth layer. The assumption is that if a client can reach the port, it is running on the same machine (or behind an OS-level firewall or SSH tunnel) and therefore already trusted. -- `docs/constitution.md` mandates that any future remote access requires authentication, so the current pattern is safe only because the default bind address never leaves the host. +- Remote bind is blocked unless `CONSENSUS_ALLOW_REMOTE=1` and `CONSENSUS_API_TOKEN` are both set. This prevents accidental exposure of unauthenticated endpoints. +- When `CONSENSUS_API_TOKEN` is set, all API and WebSocket clients must present `Authorization: Bearer ` (or `?token=` for WebSocket/browser clients). ## Endpoint summary | Route | Method | Purpose | Authentication | Notes | |-------|--------|---------|----------------|-------| -| `/api/snapshot` | `GET` | Returns the last snapshot emitted by the scan loop. The React-less canvas UI polls this endpoint to rebuild the agent map. | None | The handler runs `scanCodexProcesses` and pushes a JSON payload (ts + agents). | +| `/api/snapshot` | `GET` | Returns the last snapshot emitted by the scan loop. | Required when token is set or host is non-loopback. | The handler runs `scanCodexProcesses` and pushes a JSON payload (ts + agents). | | `/health` | `GET` | Basic JSON health check for monitoring. | None | Always responds `{ ok: true }`. | -| `/api/codex-event` | `POST` | Codex notify hook forwards Codex events into the server. | None | Payload validated against `CodexEventSchema`; rejects with `400` on schema mismatch. | -| `/api/claude-event` | `POST` | Claude Code hooks post lifecycle events. | None | Schema validated via `ClaudeEventSchema`; `dist/claudeHook.js` reads stdin and forwards to this endpoint. | -| `/__debug/activity` | `POST` | Toggles extra activity logging (guarded by localhost). | None | Accepts `enable` via query or JSON body. | -| `/__dev/reload` | `GET (SSE)` | Development reload stream for browser clients. | None | Only available when `CONSENSUS_LIVE_RELOAD=1`. | +| `/api/codex-event` | `POST` | Codex notify hook forwards Codex events into the server. | Required when token is set or host is non-loopback. | Payload validated against `CodexEventSchema`; rejects with `400` on schema mismatch. | +| `/api/claude-event` | `POST` | Claude Code hooks post lifecycle events. | Required when token is set or host is non-loopback. | Schema validated via `ClaudeEventSchema`; `dist/claudeHook.js` reads stdin and forwards to this endpoint. | +| `/__debug/activity` | `POST` | Toggles extra activity logging. | Required when token is set or host is non-loopback. | Accepts `enable` via query or JSON body. | +| `/__dev/reload` | `GET (SSE)` | Development reload stream for browser clients. | Required when token is set or host is non-loopback. | Only available when `CONSENSUS_LIVE_RELOAD=1`. | -The UI also opens a WebSocket (handled by `ws` in `src/server.ts`) but the WebSocket connection is only permitted from the same origin as the served static files. +The UI also opens a WebSocket (handled by `ws` in `src/server.ts`). When `CONSENSUS_API_TOKEN` is set, the WebSocket must include `?token=` in the URL. ## Client expectations -- Codex notify hooks call `noun codex config set -g notify` with the Consensus endpoint (`/api/codex-event`) and expect no authentication steps beyond the default localhost requirement. -- Claude Code hooks run `dist/claudeHook.js` which POSTs a minimal JSON event directly to `/api/claude-event` from the hook process; it neither signs the request nor retries if the hook fails. -- The browser UI fetches `/api/snapshot` and opens the WebSocket without extra headers. +- Codex notify hooks call `noun codex config set -g notify` with the Consensus endpoint (`/api/codex-event`). If `CONSENSUS_API_TOKEN` is set, the hook forwards `Authorization: Bearer `. +- Claude Code hooks run `dist/claudeHook.js` which POSTs a minimal JSON event directly to `/api/claude-event`. If `CONSENSUS_API_TOKEN` is set, the hook forwards `Authorization: Bearer `. +- The browser UI opens the WebSocket, optionally with `?token=` if required. ## Hardening guidance -1. Any time the server binds to a non-localhost address (custom `CONSENSUS_HOST`), add auth before enabling the port in `docs/constitution.md`'s sense. A simple opt-in token header (e.g., `CONSENSUS_API_TOKEN`) or Mutual TLS would be appropriate. -2. When introducing authentication, keep the current schema validation for Codex/Claude events so that invalid or replayed payloads are rejected even before checking credentials. -3. For debugging or automation endpoints (`/__debug/activity`, `/__dev/reload`), gate them behind the same token or a separate debug-only header to keep the trust boundary intact. -4. Document the chosen auth pattern once implemented (update this file). If more than localhost access is required, pair it with firewall rules or SSH tunnels that still keep secrets off disk. +1. Keep the remote bind guard (`CONSENSUS_ALLOW_REMOTE=1` + `CONSENSUS_API_TOKEN`) in place to prevent accidental exposure. +2. Pair remote exposure with a TLS-terminating reverse proxy and firewall rules. +3. Continue schema validation for Codex/Claude events so malformed payloads are rejected before processing. +4. Treat `CONSENSUS_API_TOKEN` as a secret; rotate if leaked. ## Observability -- Authentication and access attempts should be logged without secrets, matching the `IX.7` rule in `docs/constitution.md`. Currently, there are no tokens to log, so the focus is on request/response status. -- Future token-based auth can reuse `recordHttpMetrics` and `annotateSpan` already wired in `runHttpEffect` to trace auth failures alongside scan duration and errors. +- Authentication and access attempts are logged via `recordHttpMetrics` and `annotateSpan` without exposing tokens. diff --git a/docs/configuration.md b/docs/configuration.md index 1b4e7f6..c193cc1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,6 +6,12 @@ All configuration is via environment variables. - `CONSENSUS_HOST` - Default: `127.0.0.1` - Bind address for the server. +- `CONSENSUS_ALLOW_REMOTE` + - Default: disabled + - Set to `1` to allow binding to non-loopback hosts. Requires `CONSENSUS_API_TOKEN`. +- `CONSENSUS_API_TOKEN` + - Default: unset + - If set, all API and WebSocket clients must send `Authorization: Bearer ` or `?token=`. - `CONSENSUS_PORT` - Default: `8787` - Port for the HTTP server. @@ -104,6 +110,15 @@ All configuration is via environment variables. - `CONSENSUS_REDACT_PII` - Default: enabled - Set to `0` to disable redaction. +- `CONSENSUS_REDACT_STRICT` + - Default: disabled + - Set to `1` to enable strict secret/token redaction patterns. +- `CONSENSUS_RATE_LIMIT_WINDOW_MS` + - Default: `10000` + - Sliding window size for API rate limiting (ms). +- `CONSENSUS_RATE_LIMIT_MAX` + - Default: `120` + - Max requests per window per IP+route before 429. - `ACTIVITY_TEST_MODE` - Default: disabled - Set to `1` to enable test-only activity injection endpoints under `/__test`. diff --git a/docs/security-audit.md b/docs/security-audit.md new file mode 100644 index 0000000..9c1494a --- /dev/null +++ b/docs/security-audit.md @@ -0,0 +1,110 @@ +# Security Audit Report (Trail of Bits Style) + +Date: 2026-02-04 +Scope: Consensus CLI (server, CLI, hooks, UI, docs, build/test pipeline) + +## Executive summary +- Status: No critical/high-risk findings remain **unresolved in code** after this audit. +- The primary risks were unauthenticated remote exposure, missing request throttling, and limited redaction. These are now mitigated with remote-bind guardrails, token auth, rate limits, and strict redaction mode. +- Compliance gaps remain in **process/controls** (policies, incident response, dependency scanning, and evidence of regular security testing). These require organizational action and CI changes. + +## Methodology +- Manual code review of HTTP/WS entry points, hooks, redaction, logging, and configuration. +- Threat model update and trust boundary validation. +- Dependency and build pipeline review (package manifests and docs). + +## System overview +- Node.js/TypeScript service serves a canvas UI, polls local process state, and ingests Codex/Claude/OpenCode activity. +- Primary data sources are local JSONL logs and event hooks. +- Service is expected to run on the same machine as the UI by default. + +## Threat model (summary) +### Assets +- Activity metadata about local agents (process list, command summaries, file paths). +- Event streams from Codex/Claude/OpenCode hooks. +- Optional API token (if remote access is enabled). + +### Trust boundaries +- Localhost boundary for HTTP/WS by default. +- Optional remote exposure guarded by `CONSENSUS_ALLOW_REMOTE=1` + `CONSENSUS_API_TOKEN`. + +### Attackers +- Remote unauthenticated network actor if the service is exposed. +- Local unprivileged user on the same host. +- Malicious dependency or compromised supply chain. + +### Entry points +- HTTP: `/api/codex-event`, `/api/claude-event`, `/api/snapshot`, `/health`, `/__debug/activity`, `/__dev/reload`. +- WebSocket: `/ws` (any path is accepted by `ws`). +- CLI and hook scripts (`codexNotify`, `claudeHook`). + +## Findings and remediations + +### Critical +- None remaining. + +### High +1. **Unauthenticated remote exposure** (resolved) + - Risk: Remote bind could expose event ingestion and activity endpoints without auth. + - Fix: Enforced non-loopback bind guard (`CONSENSUS_ALLOW_REMOTE=1` + `CONSENSUS_API_TOKEN`) and token auth for HTTP/WS. + - Files: `src/server.ts`, `docs/api-authentication.md`, `docs/configuration.md`. + +2. **Missing rate limiting on event endpoints** (resolved) + - Risk: Unbounded POSTs allow resource exhaustion and event spam. + - Fix: In-memory rate limit keyed by IP+path; enforced on event/debug/test routes. + - Files: `src/server.ts`, `src/server/activityTestRoutes.ts`. + +### Medium +1. **Insufficient redaction for secrets in summaries** (partially resolved) + - Risk: Hook payloads and derived summaries could leak secrets. + - Fix: Added strict redaction mode with common token/private-key patterns. + - Remaining: Consider entropy-based redaction or configurable patterns for enterprise deployments. + - Files: `src/redact.ts`, `docs/configuration.md`. + +2. **Security headers missing** (resolved) + - Risk: Default Express headers disclose framework; no baseline hardening headers. + - Fix: Disabled `x-powered-by` and added basic security headers. + - Files: `src/server.ts`. + +### Low +1. **/health endpoint unauthenticated on remote bind** (resolved) + - Fix: `/health` now requires auth when token is set or host is non-loopback. + - Files: `src/server.ts`. + +## Compliance gap analysis +This review focuses on technical controls in code. The following gaps require organizational/process work and evidence for HIPAA, PCI-DSS, and NIST 800-171. + +### HIPAA Security Rule (45 CFR 164 Subpart C) +- **164.308(a)(1)(ii)(A) Risk analysis (R)**: No formal risk assessment artifact in repo. +- **164.308(a)(6)(ii) Incident response (R)**: No incident response policy or runbook. +- **164.312(b) Audit controls (R)**: Logs exist but no documented retention/access controls. +- **164.312(e)(2)(ii) Transmission security (A)**: TLS guidance for remote exposure not documented. + +### PCI DSS v4.0.1 +- **Req 6 Secure Development**: No dependency/secret/license scanning in CI. +- **Req 10 Logging and Monitoring**: No log retention or monitoring policy. +- **Req 11 Regular Security Testing**: No security test cadence or evidence. + +### NIST 800-171 Rev 3 +- **3.1 Access Control**: Token auth added, but no account lifecycle or privileged access controls. +- **3.3 Audit and Accountability**: No defined audit log retention or review process. +- **3.12 Security Assessment**: No documented security assessment plan. + +## Remediation checklist (prioritized) +1. Add CI security scanning (dependency, secret, and license scanning). +2. Document TLS reverse-proxy guidance for remote exposure and enforce HTTPS in deployment guides. +3. Add incident response and logging retention policies in `SECURITY.md` or `docs/`. +4. Expand redaction controls (configurable patterns, entropy-based fallback). +5. Implement optional IP allowlist or mTLS for enterprise deployments. + +## Long-term hardening recommendations +- Add structured audit logs with configurable retention and access controls. +- Provide a dedicated auth mechanism for multi-user deployments (OIDC or mTLS). +- Ship a minimal SBOM and pin high-risk dependencies. +- Introduce security regression tests for auth and rate limiting. + +## Evidence and artifacts +- Threat model: `docs/threat-model.md`. +- Auth model: `docs/api-authentication.md`. +- Configuration: `docs/configuration.md`. +- Code changes: `src/server.ts`, `src/server/activityTestRoutes.ts`, `src/redact.ts`, `src/codexNotify.ts`, `src/claudeHook.ts`, `public/src/App.tsx`. diff --git a/docs/threat-model.md b/docs/threat-model.md index f982459..6393a77 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -6,11 +6,12 @@ ## Trust boundaries - Local machine only by default. -- No external services required. +- Remote bind requires `CONSENSUS_ALLOW_REMOTE=1` and `CONSENSUS_API_TOKEN`. ## Secrets handling -- The app does not require secrets. -- Redaction protects common PII patterns. +- The app does not require secrets by default. +- Optional `CONSENSUS_API_TOKEN` gates remote access. +- Redaction protects common PII patterns; strict mode expands secret patterns. ## Data in transit - HTTP over localhost unless explicitly exposed. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 76c030f..ef7e036 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -19,6 +19,8 @@ Claude activity is hook-driven. Configure Claude Code hooks to call `dist/claudeHook.js` and post to `http://127.0.0.1:/api/claude-event` from `~/.claude/settings.json`, `.claude/settings.json`, or `.claude/settings.local.json`. +If `CONSENSUS_API_TOKEN` is set, the hook process must run with that env var +so it can include the `Authorization: Bearer` header automatically. Minimal config (tool hooks require a matcher): ```json @@ -60,3 +62,5 @@ TypeScript entrypoint so changes apply without a build: ## WebSocket disconnects Check for network proxies, and reload. The client will auto-reconnect. +If `CONSENSUS_API_TOKEN` is set, include `?token=` in the UI URL or +`ws` override so the browser can authenticate. diff --git a/public/src/App.tsx b/public/src/App.tsx index a1117f9..4d564b2 100644 --- a/public/src/App.tsx +++ b/public/src/App.tsx @@ -15,6 +15,7 @@ const query = new URLSearchParams(window.location.search); const mockMode = query.get('mock') === '1'; const wsOverrideRaw = query.get('ws'); const wsOverrideDecoded = wsOverrideRaw ? decodeURIComponent(wsOverrideRaw) : null; +const wsToken = query.get('token'); let wsOverride: string | null = null; if (wsOverrideDecoded) { @@ -25,6 +26,19 @@ if (wsOverrideDecoded) { } } +const withToken = (url: string | null): string | null => { + if (!url || !wsToken) return url; + try { + const parsed = new URL(url); + if (!parsed.searchParams.has('token')) { + parsed.searchParams.set('token', wsToken); + } + return parsed.toString(); + } catch { + return url; + } +}; + if (mockMode) { initMockBridge(); } @@ -38,7 +52,8 @@ if (wsOverrideRaw || mockMode) { } const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; -const wsUrl = wsOverride || `${wsProtocol}://${window.location.host}/ws`; +const wsBaseUrl = wsOverride || `${wsProtocol}://${window.location.host}/ws`; +const wsUrl = withToken(wsBaseUrl); function App() { const { status, agents, meta } = useWebSocket(wsUrl, { mockMode }); diff --git a/public/src/components/CanvasScene.tsx b/public/src/components/CanvasScene.tsx index d87de34..c9259a6 100644 --- a/public/src/components/CanvasScene.tsx +++ b/public/src/components/CanvasScene.tsx @@ -1,32 +1,10 @@ import { useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react'; import type { AgentSnapshot, ViewState } from '../types'; import { useCanvasRenderer } from '../hooks/useCanvasRenderer'; -import { createLayoutState, updateLayout, getCoordinate } from '../lib/layout'; -import { agentIdentity, keyForAgent } from '../lib/format'; -import { pointInDiamond, pointInQuad, isoToScreen } from '../lib/iso'; +import { createLayoutState, updateLayout } from '../lib/layout'; +import { agentIdentity } from '../lib/format'; import { Tooltip } from './Tooltip'; -const TILE_W = 96; -const TILE_H = 48; -const ROOF_SCALE = 0.28; -const ROOF_HIT_SCALE = 0.44; -const MARKER_SCALE = 0.36; -const MARKER_OFFSET = TILE_H * 0.6; - -interface HitItem { - x: number; - y: number; - roofY: number; - roofW: number; - roofH: number; - roofHitW: number; - roofHitH: number; - height: number; - agent: AgentSnapshot; - key: string; - markerY?: number; -} - interface CanvasSceneProps { agents: AgentSnapshot[]; view: ViewState; @@ -42,21 +20,19 @@ export function CanvasScene({ agents, view, selected, - searchMatches, + searchMatches: _searchMatches, onSelect, onMouseDown, onKeyDown, onWheel, }: CanvasSceneProps) { - const containerRef = useRef(null); const layoutRef = useRef(createLayoutState()); const spawnTimesRef = useRef>(new Map()); const knownIdsRef = useRef>(new Set()); const [hovered, setHovered] = useState(null); const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 }); - const hitListRef = useRef([]); - const { canvasRef, startRender, stopRender } = useCanvasRenderer(); + const { canvasRef, startRender, stopRender, getAgentAtPoint, getHitList } = useCanvasRenderer(); // Update layout and spawn times when agents change useLayoutEffect(() => { @@ -81,99 +57,16 @@ export function CanvasScene({ updateLayout(layoutRef.current, agents); }, [agents]); - // Rebuild hit list whenever agents or view changes - useEffect(() => { - const roofW = TILE_W * ROOF_SCALE; - const roofH = roofW * 0.5; - const roofHitW = TILE_W * ROOF_HIT_SCALE; - const roofHitH = roofHitW * 0.5; - - // Sort by depth (same as render order) - const sortedAgents = [...agents].sort((a, b) => { - const coordA = getCoordinate(layoutRef.current, a) ?? { x: 0, y: 0 }; - const coordB = getCoordinate(layoutRef.current, b) ?? { x: 0, y: 0 }; - return coordA.x + coordA.y - (coordB.x + coordB.y); - }); - - const hitList: HitItem[] = []; - - for (const agent of sortedAgents) { - const coord = getCoordinate(layoutRef.current, agent); - if (!coord) continue; - - const screen = isoToScreen(coord.x, coord.y, TILE_W, TILE_H); - const memMB = agent.mem / (1024 * 1024); - const heightBase = Math.min(120, Math.max(18, memMB * 0.4)); - const idleScale = agent.state === 'idle' ? 0.6 : 1; - const height = heightBase * idleScale; - const roofY = screen.y - height - TILE_H * 0.15; - - hitList.push({ - x: screen.x, - y: screen.y, - roofY, - roofW, - roofH, - roofHitW, - roofHitH, - height, - agent, - key: keyForAgent(agent), - }); - } - - // Calculate obstruction and marker positions - const obstructedIds = new Set(); - for (const a of hitList) { - const roofPoint = { x: a.x, y: a.roofY }; - for (const b of hitList) { - if (a === b) continue; - const topY = b.y - b.height; - const halfW = TILE_W / 2; - const halfH = TILE_H / 2; - - const leftA = { x: b.x - halfW, y: topY }; - const leftB = { x: b.x, y: topY + halfH }; - const leftC = { x: b.x, y: b.y + halfH }; - const leftD = { x: b.x - halfW, y: b.y }; - - const rightA = { x: b.x + halfW, y: topY }; - const rightB = { x: b.x, y: topY + halfH }; - const rightC = { x: b.x, y: b.y + halfH }; - const rightD = { x: b.x + halfW, y: b.y }; - - if ( - pointInQuad(roofPoint, leftA, leftB, leftC, leftD) || - pointInQuad(roofPoint, rightA, rightB, rightC, rightD) - ) { - obstructedIds.add(agentIdentity(a.agent)); - break; - } - } - } - - for (const item of hitList) { - if (obstructedIds.has(agentIdentity(item.agent))) { - item.markerY = item.roofY - MARKER_OFFSET; - } - } - - hitListRef.current = hitList; - }, [agents, view]); - useEffect(() => { const win = window as any; if (!win.__consensusMock) { win.__consensusMock = {}; } - win.__consensusMock.getHitList = () => hitListRef.current; + win.__consensusMock.getHitList = () => getHitList(); win.__consensusMock.getView = () => ({ x: view.x, y: view.y, scale: view.scale }); - }, [view]); + }, [view, getHitList]); - // Start/stop render loop useEffect(() => { - if (!canvasRef.current) return; - startRender(view, agents, { layout: layoutRef.current, hovered, @@ -181,62 +74,19 @@ export function CanvasScene({ spawnTimes: spawnTimesRef.current, deviceScale: window.devicePixelRatio || 1, }); + }, [agents, view, hovered, selected, startRender]); + useEffect(() => { return () => { stopRender(); }; - }, [agents, view, hovered, selected, searchMatches, startRender, stopRender, canvasRef]); - - const findAgentAt = useCallback((canvasX: number, canvasY: number): AgentSnapshot | null => { - // Transform canvas coordinates to world coordinates - const worldX = (canvasX - view.x) / view.scale; - const worldY = (canvasY - view.y) / view.scale; - - const markerW = TILE_W * MARKER_SCALE; - const markerH = markerW * 0.5; - const hitList = hitListRef.current; - - if (!hitList.length) return null; - - // Check markers first (for obstructed agents) - for (let i = hitList.length - 1; i >= 0; i--) { - const item = hitList[i]; - if (!item.markerY) continue; - if (pointInDiamond(worldX, worldY, item.x, item.markerY, markerW, markerH)) { - return item.agent; - } - } - - // Check roofs - for (let i = hitList.length - 1; i >= 0; i--) { - const item = hitList[i]; - if (pointInDiamond(worldX, worldY, item.x, item.roofY, item.roofHitW, item.roofHitH)) { - return item.agent; - } - } - - // Check base tiles - for (let i = hitList.length - 1; i >= 0; i--) { - const item = hitList[i]; - if (pointInDiamond(worldX, worldY, item.x, item.y, TILE_W, TILE_H)) { - return item.agent; - } - } - - return null; - }, [view]); + }, [stopRender]); const handleMouseMove = useCallback((e: React.MouseEvent) => { - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; - - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - const found = findAgentAt(x, y); + const found = getAgentAtPoint(e.clientX, e.clientY); setHovered(found); setTooltipPos({ x: e.clientX + 12, y: e.clientY + 12 }); - }, [findAgentAt]); + }, [getAgentAtPoint]); const handleMouseLeave = useCallback(() => { setHovered(null); @@ -249,18 +99,14 @@ export function CanvasScene({ return; } - // Calculate from click position (for direct clicks without hover) - const rect = (e.target as HTMLCanvasElement).getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const found = findAgentAt(x, y); + const found = getAgentAtPoint(e.clientX, e.clientY); if (found) { onSelect(found); } - }, [hovered, onSelect, findAgentAt]); + }, [hovered, onSelect, getAgentAtPoint]); return ( -
+
b.x + halfW || + roofPoint.y < topY || + roofPoint.y > b.y + halfH + ) { + continue; + } + const leftA = { x: b.x - halfW, y: topY }; const leftB = { x: b.x, y: topY + halfH }; const leftC = { x: b.x, y: b.y + halfH }; const leftD = { x: b.x - halfW, y: b.y }; - + const rightA = { x: b.x + halfW, y: topY }; const rightB = { x: b.x, y: topY + halfH }; const rightC = { x: b.x, y: b.y + halfH }; const rightD = { x: b.x + halfW, y: b.y }; - + if ( pointInQuad(roofPoint, leftA, leftB, leftC, leftD) || pointInQuad(roofPoint, rightA, rightB, rightC, rightD) @@ -362,6 +372,54 @@ export function useCanvasRenderer() { const hitListRef = useRef([]); const rafRef = useRef(null); const deviceScaleRef = useRef(1); + const ctxRef = useRef(null); + const viewRef = useRef({ x: 0, y: 0, scale: 1 }); + const agentsRef = useRef([]); + const optionsRef = useRef(null); + const reducedMotionRef = useRef(false); + + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) return; + const mql = window.matchMedia('(prefers-reduced-motion: reduce)'); + const update = () => { + reducedMotionRef.current = mql.matches; + }; + update(); + if (mql.addEventListener) { + mql.addEventListener('change', update); + } else { + mql.addListener(update); + } + return () => { + if (mql.removeEventListener) { + mql.removeEventListener('change', update); + } else { + mql.removeListener(update); + } + }; + }, []); + + const syncCanvasSize = useCallback((canvas: HTMLCanvasElement) => { + const dpr = window.devicePixelRatio || 1; + const cssW = window.innerWidth; + const cssH = window.innerHeight; + const nextW = Math.max(1, Math.floor(cssW * dpr)); + const nextH = Math.max(1, Math.floor(cssH * dpr)); + + if ( + canvas.width !== nextW || + canvas.height !== nextH || + deviceScaleRef.current !== dpr + ) { + deviceScaleRef.current = dpr; + canvas.width = nextW; + canvas.height = nextH; + canvas.style.width = `${cssW}px`; + canvas.style.height = `${cssH}px`; + } + + return { width: canvas.width, height: canvas.height, dpr }; + }, []); const startRender = useCallback(( view: ViewState, @@ -371,37 +429,45 @@ export function useCanvasRenderer() { const canvas = canvasRef.current; if (!canvas) return; - const ctx = canvas.getContext('2d'); - if (!ctx) return; + viewRef.current = view; + agentsRef.current = agents; + optionsRef.current = options; + + if (!ctxRef.current) { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctxRef.current = ctx; + } + + if (rafRef.current) return; - // Update canvas size - deviceScaleRef.current = window.devicePixelRatio || 1; - canvas.width = window.innerWidth * deviceScaleRef.current; - canvas.height = window.innerHeight * deviceScaleRef.current; - canvas.style.width = `${window.innerWidth}px`; - canvas.style.height = `${window.innerHeight}px`; + syncCanvasSize(canvas); const render = () => { - const width = canvas.width; - const height = canvas.height; - + const canvasEl = canvasRef.current; + const ctx = ctxRef.current; + const opts = optionsRef.current; + if (!canvasEl || !ctx || !opts) { + rafRef.current = null; + return; + } + + const { width, height, dpr } = syncCanvasSize(canvasEl); hitListRef.current = renderFrame({ ctx, - view, - agents, - options: { ...options, deviceScale: deviceScaleRef.current }, + view: viewRef.current, + agents: agentsRef.current, + options: { ...opts, deviceScale: dpr }, width, height, + reducedMotion: reducedMotionRef.current, }); - + rafRef.current = requestAnimationFrame(render); }; - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - } rafRef.current = requestAnimationFrame(render); - }, []); + }, [syncCanvasSize]); const stopRender = useCallback(() => { if (rafRef.current) { @@ -410,19 +476,16 @@ export function useCanvasRenderer() { } }, []); + const getHitList = useCallback(() => hitListRef.current, []); + const getAgentAtPoint = useCallback((screenX: number, screenY: number): AgentSnapshot | null => { const canvas = canvasRef.current; if (!canvas) return null; const rect = canvas.getBoundingClientRect(); - const x = screenX - rect.left; - const y = screenY - rect.top; - - const viewX = (x - canvas.width / 2 / deviceScaleRef.current) / 1; // simplified - const viewY = (y - canvas.height / 2 / deviceScaleRef.current) / 1; - - // Transform to world space (this needs proper view transform) - // For now, return simplified hit detection + const view = viewRef.current; + const worldX = (screenX - rect.left - view.x) / view.scale; + const worldY = (screenY - rect.top - view.y) / view.scale; const hitList = hitListRef.current; if (!hitList.length) return null; @@ -432,7 +495,7 @@ export function useCanvasRenderer() { if (!item.markerY) continue; const markerW = TILE_W * MARKER_SCALE; const markerH = markerW * 0.5; - if (pointInDiamond(viewX, viewY, item.x, item.markerY, markerW, markerH)) { + if (pointInDiamond(worldX, worldY, item.x, item.markerY, markerW, markerH)) { return item.agent; } } @@ -442,7 +505,7 @@ export function useCanvasRenderer() { const item = hitList[i]; const roofHitW = item.roofHitW; const roofHitH = item.roofHitH; - if (pointInDiamond(viewX, viewY, item.x, item.roofY, roofHitW, roofHitH)) { + if (pointInDiamond(worldX, worldY, item.x, item.roofY, roofHitW, roofHitH)) { return item.agent; } } @@ -450,7 +513,7 @@ export function useCanvasRenderer() { // Check base for (let i = hitList.length - 1; i >= 0; i -= 1) { const item = hitList[i]; - if (pointInDiamond(viewX, viewY, item.x, item.y, TILE_W, TILE_H)) { + if (pointInDiamond(worldX, worldY, item.x, item.y, TILE_W, TILE_H)) { return item.agent; } } @@ -469,5 +532,6 @@ export function useCanvasRenderer() { startRender, stopRender, getAgentAtPoint, + getHitList, }; } diff --git a/public/src/lib/layout.ts b/public/src/lib/layout.ts index 3bb730b..57b6825 100644 --- a/public/src/lib/layout.ts +++ b/public/src/lib/layout.ts @@ -5,8 +5,13 @@ import { isoToScreen } from './iso'; const GRID_SCALE = 2; const TILE_W = 96; const TILE_H = 48; +const CELL_W = TILE_W; +const CELL_H = TILE_H; const MAX_PULSE = 7; const MAX_LAYOUT_HEIGHT = 120 + MAX_PULSE; +const CELL_OFFSET = 32768; + +type CellBucket = string | string[]; interface BoundsRect { left: number; @@ -15,20 +20,50 @@ interface BoundsRect { bottom: number; } +interface SpatialIndex { + cells: Map; + bounds: Map; +} + +interface SpiralState { + cx: number; + cy: number; + dir: 0 | 1 | 2 | 3; + legLen: number; + legProgress: number; + legsAtLen: 0 | 1; + started: boolean; +} + +interface GroupState { + anchor: Coordinate; + spiral: SpiralState; + freeStack: number[]; +} + export interface LayoutState { layout: Map; - occupied: Map; - bounds: Map; groupAnchors: Map; + agentGroupKey: Map; + groups: Map; + spatial: SpatialIndex; + seenGen: Map; + generation: number; locked: boolean; } export function createLayoutState(): LayoutState { return { layout: new Map(), - occupied: new Map(), - bounds: new Map(), groupAnchors: new Map(), + agentGroupKey: new Map(), + groups: new Map(), + spatial: { + cells: new Map(), + bounds: new Map(), + }, + seenGen: new Map(), + generation: 0, locked: false, }; } @@ -45,10 +80,66 @@ function hashString(input: string): number { function layoutIdForAgent(agent: AgentSnapshot): string { const identity = agentIdentity(agent); if (identity) return identity; + if (typeof agent.pid === 'number') return `${agent.pid}`; + if (agent.id) return agent.id; const groupKey = groupKeyForAgent(agent); if (groupKey) return groupKey; - if (typeof agent.pid === 'number') return `${agent.pid}`; - return agent.id || 'unknown'; + return 'unknown'; +} + +function worldToCellX(x: number): number { + return Math.floor(x / CELL_W); +} + +function worldToCellY(y: number): number { + return Math.floor(y / CELL_H); +} + +function packCell(cx: number, cy: number): number { + const ux = (cx + CELL_OFFSET) & 0xffff; + const uy = (cy + CELL_OFFSET) & 0xffff; + return ((ux << 16) | uy) >>> 0; +} + +function unpackCell(key: number): { cx: number; cy: number } { + const ux = key >>> 16; + const uy = key & 0xffff; + return { cx: ux - CELL_OFFSET, cy: uy - CELL_OFFSET }; +} + +function cellToWorld(cx: number, cy: number): Coordinate { + return { x: cx * GRID_SCALE, y: cy * GRID_SCALE }; +} + +function gridKeyFromWorld(coord: Coordinate): number { + const cx = Math.round(coord.x / GRID_SCALE); + const cy = Math.round(coord.y / GRID_SCALE); + return packCell(cx, cy); +} + +function bucketAdd(bucket: CellBucket | undefined, id: string): CellBucket { + if (bucket === undefined) return id; + if (typeof bucket === 'string') { + if (bucket === id) return bucket; + return [bucket, id]; + } + for (let i = 0; i < bucket.length; i += 1) { + if (bucket[i] === id) return bucket; + } + bucket.push(id); + return bucket; +} + +function bucketRemove(bucket: CellBucket | undefined, id: string): CellBucket | undefined { + if (bucket === undefined) return undefined; + if (typeof bucket === 'string') return bucket === id ? undefined : bucket; + const index = bucket.indexOf(id); + if (index === -1) return bucket; + const last = bucket.pop(); + if (last === undefined) return undefined; + if (index < bucket.length) bucket[index] = last; + if (bucket.length === 1) return bucket[0]; + return bucket.length ? bucket : undefined; } function boundsForCoord(coord: Coordinate): BoundsRect { @@ -67,46 +158,123 @@ function boundsIntersect(a: BoundsRect, b: BoundsRect): boolean { return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; } -function hasBoundsOverlap(testBounds: BoundsRect, placedBounds: Map): boolean { - for (const bounds of placedBounds.values()) { - if (boundsIntersect(testBounds, bounds)) return true; +function cellRangeForBounds(bounds: BoundsRect): { + minCx: number; + maxCx: number; + minCy: number; + maxCy: number; +} { + const minCx = worldToCellX(bounds.left); + const maxCx = worldToCellX(bounds.right - 1); + const minCy = worldToCellY(bounds.top); + const maxCy = worldToCellY(bounds.bottom - 1); + return { minCx, maxCx, minCy, maxCy }; +} + +function indexAgent(id: string, coord: Coordinate, spatial: SpatialIndex): void { + const bounds = boundsForCoord(coord); + spatial.bounds.set(id, bounds); + + const range = cellRangeForBounds(bounds); + for (let cx = range.minCx; cx <= range.maxCx; cx += 1) { + for (let cy = range.minCy; cy <= range.maxCy; cy += 1) { + const key = packCell(cx, cy); + const bucket = spatial.cells.get(key); + spatial.cells.set(key, bucketAdd(bucket, id)); + } } - return false; } -function tryPlaceCoordinate( - coord: Coordinate, - nextOccupied: Map, - nextBounds: Map -): { coord: Coordinate; bounds: BoundsRect; cellKey: string } | null { - const cellKey = `${coord.x / GRID_SCALE},${coord.y / GRID_SCALE}`; - if (nextOccupied.has(cellKey)) return null; - const testBounds = boundsForCoord(coord); - if (hasBoundsOverlap(testBounds, nextBounds)) return null; - return { coord, bounds: testBounds, cellKey }; -} - -function findPlacementNearAnchor( - anchor: Coordinate, - maxRadius: number, - nextOccupied: Map, - nextBounds: Map -): { coord: Coordinate; bounds: BoundsRect; cellKey: string } | null { - const baseX = Math.round(anchor.x / GRID_SCALE); - const baseY = Math.round(anchor.y / GRID_SCALE); - - for (let radius = 0; radius <= maxRadius; radius += 1) { - for (let dx = -radius; dx <= radius; dx += 1) { - for (let dy = -radius; dy <= radius; dy += 1) { - if (Math.abs(dx) !== radius && Math.abs(dy) !== radius) continue; - const coord = { x: (baseX + dx) * GRID_SCALE, y: (baseY + dy) * GRID_SCALE }; - const placed = tryPlaceCoordinate(coord, nextOccupied, nextBounds); - if (placed) return placed; +function unindexAgent(id: string, spatial: SpatialIndex): void { + const bounds = spatial.bounds.get(id); + if (!bounds) return; + + const range = cellRangeForBounds(bounds); + for (let cx = range.minCx; cx <= range.maxCx; cx += 1) { + for (let cy = range.minCy; cy <= range.maxCy; cy += 1) { + const key = packCell(cx, cy); + const bucket = spatial.cells.get(key); + const next = bucketRemove(bucket, id); + if (next === undefined) { + spatial.cells.delete(key); + } else { + spatial.cells.set(key, next); } } } - return null; + spatial.bounds.delete(id); +} + +function hasCollision(bounds: BoundsRect, spatial: SpatialIndex): boolean { + const range = cellRangeForBounds(bounds); + for (let cx = range.minCx; cx <= range.maxCx; cx += 1) { + for (let cy = range.minCy; cy <= range.maxCy; cy += 1) { + const key = packCell(cx, cy); + const bucket = spatial.cells.get(key); + if (!bucket) continue; + if (typeof bucket === 'string') { + const other = spatial.bounds.get(bucket); + if (other && boundsIntersect(bounds, other)) return true; + } else { + for (let i = 0; i < bucket.length; i += 1) { + const other = spatial.bounds.get(bucket[i]); + if (other && boundsIntersect(bounds, other)) return true; + } + } + } + } + return false; +} + +function spiralInit(anchor: Coordinate): SpiralState { + const cx = Math.round(anchor.x / GRID_SCALE); + const cy = Math.round(anchor.y / GRID_SCALE); + return { + cx, + cy, + dir: 0, + legLen: 1, + legProgress: 0, + legsAtLen: 0, + started: false, + }; +} + +function spiralNext(state: SpiralState): { cx: number; cy: number } { + if (!state.started) { + state.started = true; + return { cx: state.cx, cy: state.cy }; + } + + switch (state.dir) { + case 0: + state.cx += 1; + break; + case 1: + state.cy -= 1; + break; + case 2: + state.cx -= 1; + break; + case 3: + state.cy += 1; + break; + } + + state.legProgress += 1; + if (state.legProgress === state.legLen) { + state.legProgress = 0; + state.dir = ((state.dir + 1) & 3) as 0 | 1 | 2 | 3; + if (state.legsAtLen === 1) { + state.legsAtLen = 0; + state.legLen += 1; + } else { + state.legsAtLen = 1; + } + } + + return { cx: state.cx, cy: state.cy }; } function hashedAnchorForGroup(groupKey: string): Coordinate { @@ -116,22 +284,97 @@ function hashedAnchorForGroup(groupKey: string): Coordinate { return { x: baseX * GRID_SCALE, y: baseY * GRID_SCALE }; } +function ensureGroupState( + state: LayoutState, + groupKey: string, + fallbackAnchor?: Coordinate +): GroupState { + let group = state.groups.get(groupKey); + if (!group) { + const anchor = state.groupAnchors.get(groupKey) ?? fallbackAnchor ?? hashedAnchorForGroup(groupKey); + group = { + anchor, + spiral: spiralInit(anchor), + freeStack: [], + }; + state.groups.set(groupKey, group); + if (!state.groupAnchors.has(groupKey)) { + state.groupAnchors.set(groupKey, anchor); + } + } else if (!state.groupAnchors.has(groupKey)) { + state.groupAnchors.set(groupKey, group.anchor); + } + return group; +} + +function findPlacement( + group: GroupState, + spatial: SpatialIndex, + maxAttempts = 256 +): Coordinate | null { + while (group.freeStack.length) { + const key = group.freeStack.pop(); + if (key === undefined) break; + const { cx, cy } = unpackCell(key); + const coord = cellToWorld(cx, cy); + const bounds = boundsForCoord(coord); + if (!hasCollision(bounds, spatial)) return coord; + } + + for (let i = 0; i < maxAttempts; i += 1) { + const { cx, cy } = spiralNext(group.spiral); + const coord = cellToWorld(cx, cy); + const bounds = boundsForCoord(coord); + if (!hasCollision(bounds, spatial)) return coord; + } + + return null; +} + +function addAgent( + state: LayoutState, + id: string, + groupKey: string +): void { + const hadGroup = state.groups.has(groupKey); + const group = ensureGroupState(state, groupKey); + const placement = findPlacement(group, state.spatial); + if (!placement) return; + state.layout.set(id, placement); + state.agentGroupKey.set(id, groupKey); + indexAgent(id, placement, state.spatial); + + if (!hadGroup) { + group.anchor = placement; + group.spiral = spiralInit(placement); + state.groupAnchors.set(groupKey, placement); + } +} + +function removeAgent(state: LayoutState, id: string): void { + const coord = state.layout.get(id); + if (coord) { + unindexAgent(id, state.spatial); + } + state.layout.delete(id); + + const groupKey = state.agentGroupKey.get(id); + if (groupKey && coord) { + const group = state.groups.get(groupKey); + if (group) { + group.freeStack.push(gridKeyFromWorld(coord)); + } + } + state.agentGroupKey.delete(id); +} + export function assignCoordinate( state: LayoutState, key: string, baseKey: string ): void { if (state.layout.has(key)) return; - const anchor = state.groupAnchors.get(baseKey) ?? hashedAnchorForGroup(baseKey || key); - const maxRadius = Math.max(24, Math.ceil(Math.sqrt(state.layout.size + 1)) * 32); - const placement = findPlacementNearAnchor(anchor, maxRadius, state.occupied, state.bounds); - if (!placement) return; - state.layout.set(key, placement.coord); - state.occupied.set(placement.cellKey, key); - state.bounds.set(key, placement.bounds); - if (!state.groupAnchors.has(baseKey)) { - state.groupAnchors.set(baseKey, placement.coord); - } + addAgent(state, key, baseKey || key); } export function updateLayout( @@ -139,74 +382,49 @@ export function updateLayout( agents: AgentSnapshot[] ): void { if (state.locked) return; + if (agents.length === 0) { state.layout.clear(); - state.occupied.clear(); - state.bounds.clear(); state.groupAnchors.clear(); + state.agentGroupKey.clear(); + state.groups.clear(); + state.spatial.cells.clear(); + state.spatial.bounds.clear(); + state.seenGen.clear(); return; } - const agentMap = new Map(); + state.generation += 1; + const gen = state.generation; + const added: Array<{ id: string; agent: AgentSnapshot; groupKey: string }> = []; + for (const agent of agents) { const id = layoutIdForAgent(agent); const groupKey = groupKeyForAgent(agent) || id; - agentMap.set(id, { agent, groupKey }); - } + state.seenGen.set(id, gen); + state.agentGroupKey.set(id, groupKey); - for (const id of state.layout.keys()) { - if (!agentMap.has(id)) { - const coord = state.layout.get(id); - if (coord) { - state.occupied.delete(`${coord.x / GRID_SCALE},${coord.y / GRID_SCALE}`); - } - state.layout.delete(id); - state.bounds.delete(id); + const existingCoord = state.layout.get(id); + if (existingCoord) { + ensureGroupState(state, groupKey, existingCoord); + continue; } + added.push({ id, agent, groupKey }); } - state.occupied.clear(); - state.bounds.clear(); - for (const [id, coord] of state.layout.entries()) { - state.occupied.set(`${coord.x / GRID_SCALE},${coord.y / GRID_SCALE}`, id); - state.bounds.set(id, boundsForCoord(coord)); - } - - const activeGroups = new Set(); - for (const { agent, groupKey } of agentMap.values()) { - activeGroups.add(groupKey); - if (!state.groupAnchors.has(groupKey)) { - const coord = state.layout.get(layoutIdForAgent(agent)); - if (coord) state.groupAnchors.set(groupKey, coord); + for (const id of Array.from(state.layout.keys())) { + if (state.seenGen.get(id) !== gen) { + removeAgent(state, id); } } - for (const key of state.groupAnchors.keys()) { - if (!activeGroups.has(key)) state.groupAnchors.delete(key); - } - const addedAgents = Array.from(agentMap.values()) - .filter(({ agent }) => !state.layout.has(layoutIdForAgent(agent))) - .sort((a, b) => { - if (a.groupKey === b.groupKey) { - return layoutIdForAgent(a.agent).localeCompare(layoutIdForAgent(b.agent)); - } - return a.groupKey.localeCompare(b.groupKey); - }); - - const maxRadius = Math.max(32, Math.ceil(Math.sqrt(state.layout.size + addedAgents.length)) * 32); - - for (const entry of addedAgents) { - const layoutId = layoutIdForAgent(entry.agent); - const groupKey = entry.groupKey || layoutId; - const anchor = state.groupAnchors.get(groupKey) ?? hashedAnchorForGroup(groupKey); - const placement = findPlacementNearAnchor(anchor, maxRadius, state.occupied, state.bounds); - if (!placement) continue; - state.layout.set(layoutId, placement.coord); - state.occupied.set(placement.cellKey, layoutId); - state.bounds.set(layoutId, placement.bounds); - if (!state.groupAnchors.has(groupKey)) { - state.groupAnchors.set(groupKey, placement.coord); - } + added.sort((a, b) => { + if (a.groupKey === b.groupKey) return a.id.localeCompare(b.id); + return a.groupKey.localeCompare(b.groupKey); + }); + + for (const entry of added) { + addAgent(state, entry.id, entry.groupKey); } } @@ -231,11 +449,14 @@ export function setLayoutPositions( positions: Array<{ id?: string; pid?: number; x: number; y: number }> ): void { state.layout.clear(); - state.occupied.clear(); - state.bounds.clear(); state.groupAnchors.clear(); + state.agentGroupKey.clear(); + state.groups.clear(); + state.spatial.cells.clear(); + state.spatial.bounds.clear(); + state.seenGen.clear(); state.locked = true; - + const byIdentity = new Map( agents.map((agent) => [agentIdentity(agent), agent]) ); @@ -244,21 +465,26 @@ export function setLayoutPositions( .filter((agent) => typeof agent.pid === 'number') .map((agent) => [`${agent.pid}`, agent]) ); - + for (const entry of positions) { const keyId = entry?.id ?? entry?.pid; if (keyId === undefined || keyId === null) continue; const agent = byIdentity.get(String(keyId)) || byPid.get(String(keyId)) || null; if (!agent) continue; - const key = layoutIdForAgent(agent); + const id = layoutIdForAgent(agent); + const groupKey = groupKeyForAgent(agent) || id; const coord = { x: Number(entry.x) || 0, y: Number(entry.y) || 0 }; - state.layout.set(key, coord); - state.occupied.set(`${coord.x / GRID_SCALE},${coord.y / GRID_SCALE}`, key); - state.bounds.set(key, boundsForCoord(coord)); - const groupKey = groupKeyForAgent(agent) || key; + state.layout.set(id, coord); + state.agentGroupKey.set(id, groupKey); + indexAgent(id, coord, state.spatial); + const group = ensureGroupState(state, groupKey, coord); if (!state.groupAnchors.has(groupKey)) { state.groupAnchors.set(groupKey, coord); } + if (!group.spiral.started) { + group.anchor = coord; + group.spiral = spiralInit(coord); + } } } diff --git a/src/claudeHook.ts b/src/claudeHook.ts index 3bf5546..aa2c021 100644 --- a/src/claudeHook.ts +++ b/src/claudeHook.ts @@ -68,11 +68,18 @@ const program = pipe( const event = normalizePayload(payload); if (!event) return Effect.succeed(null); return Effect.promise(() => - fetch(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(event), - }) + { + const headers: Record = { + "Content-Type": "application/json", + }; + const token = (process.env.CONSENSUS_API_TOKEN || "").trim(); + if (token) headers.Authorization = `Bearer ${token}`; + return fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify(event), + }); + } ).pipe(Effect.as(null)); }) ); diff --git a/src/codexNotify.ts b/src/codexNotify.ts index d3f3910..f03ce69 100644 --- a/src/codexNotify.ts +++ b/src/codexNotify.ts @@ -78,11 +78,18 @@ const postEvent = ( ) => Effect.tryPromise({ try: () => - fetchImpl(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(event), - }), + { + const headers: Record = { + "Content-Type": "application/json", + }; + const token = (process.env.CONSENSUS_API_TOKEN || "").trim(); + if (token) headers.Authorization = `Bearer ${token}`; + return fetchImpl(endpoint, { + method: "POST", + headers, + body: JSON.stringify(event), + }); + }, catch: () => undefined, }).pipe( Effect.tap(() => diff --git a/src/config/fromEnv.ts b/src/config/fromEnv.ts index 4895f96..fa7d15f 100644 --- a/src/config/fromEnv.ts +++ b/src/config/fromEnv.ts @@ -110,6 +110,7 @@ const buildActivityConfig = () => ({ const buildRedactConfig = () => ({ enabled: Option.getOrUndefined(parseBoolean(process.env.CONSENSUS_REDACT_PII)) ?? true, + strict: Option.getOrUndefined(parseBoolean(process.env.CONSENSUS_REDACT_STRICT)) ?? false, }) const buildDebugConfig = () => ({ diff --git a/src/config/schema.ts b/src/config/schema.ts index 0fdca4c..8840638 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -227,6 +227,9 @@ export const RedactConfig = Schema.Struct({ enabled: Schema.Boolean.pipe( Schema.optionalWith({ default: () => true }) ), + strict: Schema.Boolean.pipe( + Schema.optionalWith({ default: () => false }) + ), }) /** Debug flags configuration */ @@ -308,7 +311,7 @@ export const AppConfig = Schema.Struct({ }) }) ), redact: RedactConfig.pipe( - Schema.optionalWith({ default: () => ({ enabled: true }) }) + Schema.optionalWith({ default: () => ({ enabled: true, strict: false }) }) ), debug: DebugConfig.pipe( Schema.optionalWith({ default: () => ({ diff --git a/src/redact.ts b/src/redact.ts index e24de85..f7f220d 100644 --- a/src/redact.ts +++ b/src/redact.ts @@ -1,4 +1,5 @@ const REDACT_ENABLED = process.env.CONSENSUS_REDACT_PII !== "0"; +const REDACT_STRICT = process.env.CONSENSUS_REDACT_STRICT === "1"; const HOME_PATTERNS = [ /\/Users\/[^/\s]+/g, @@ -7,6 +8,26 @@ const HOME_PATTERNS = [ ]; const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; +const KEY_VALUE_PATTERN = + /\b(password|passwd|pwd|api[_-]?key|api[_-]?token|access[_-]?token|auth[_-]?token|secret|client[_-]?secret|session[_-]?token)\b\s*[:=]\s*([^\s,;]+)/gi; +const JSON_KEY_PATTERN = + /(\"(?:password|passwd|pwd|api[_-]?key|api[_-]?token|access[_-]?token|auth[_-]?token|secret|client[_-]?secret|session[_-]?token)\"\\s*:\\s*\")([^\"]+)(\")/gi; +const STRICT_PATTERNS: Array<[RegExp, string]> = [ + [/AKIA[0-9A-Z]{16}/g, ""], + [/ASIA[0-9A-Z]{16}/g, ""], + [/ghp_[A-Za-z0-9]{36,}/g, ""], + [/gho_[A-Za-z0-9]{36,}/g, ""], + [/github_pat_[A-Za-z0-9_]{20,}/g, ""], + [/xox[baprs]-[A-Za-z0-9-]{10,}/g, ""], + [/sk-[A-Za-z0-9]{20,}/g, ""], + [/AIza[0-9A-Za-z\\-_]{30,}/g, ""], + [/eyJ[A-Za-z0-9_=-]+\\.[A-Za-z0-9._=-]+\\.[A-Za-z0-9._=-]+/g, ""], + [ + /-----BEGIN (?:RSA|EC|DSA|OPENSSH|PGP) PRIVATE KEY-----[\\s\\S]+?-----END (?:RSA|EC|DSA|OPENSSH|PGP) PRIVATE KEY-----/g, + "", + ], + [/Bearer\\s+[A-Za-z0-9._=-]+/gi, "Bearer "], +]; export function redactText(value?: string): string | undefined { if (!value || !REDACT_ENABLED) return value; @@ -15,6 +36,16 @@ export function redactText(value?: string): string | undefined { output = output.replace(pattern, "~"); } output = output.replace(EMAIL_PATTERN, ""); + if (!REDACT_STRICT) return output; + output = output.replace(KEY_VALUE_PATTERN, (_match, key: string) => { + return `${key}=`; + }); + output = output.replace(JSON_KEY_PATTERN, (_match, prefix: string, _value: string, suffix: string) => { + return `${prefix}${suffix}`; + }); + for (const [pattern, replacement] of STRICT_PATTERNS) { + output = output.replace(pattern, replacement); + } return output; } diff --git a/src/server.ts b/src/server.ts index 3beafdc..7cc72f9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -43,6 +43,13 @@ const liveReloadEnabled = (isDevRuntime && process.env.CONSENSUS_LIVE_RELOAD !== "0"); const app = express(); +app.disable("x-powered-by"); +app.use((_req, res, next) => { + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("Referrer-Policy", "no-referrer"); + next(); +}); const server = http.createServer(app); const wss = new WebSocketServer({ server }); @@ -132,6 +139,17 @@ function sendDeltaEnvelope(socket: WebSocket, ops: DeltaOp[]): void { const port = Number(process.env.CONSENSUS_PORT || 8787); const host = process.env.CONSENSUS_HOST || "127.0.0.1"; +const apiToken = (process.env.CONSENSUS_API_TOKEN || "").trim() || null; +const allowRemote = process.env.CONSENSUS_ALLOW_REMOTE === "1"; +const loopbackHosts = new Set(["127.0.0.1", "::1", "localhost"]); +const isLoopbackHost = loopbackHosts.has(host); +const requireAuth = Boolean(apiToken) || !isLoopbackHost; +if (!isLoopbackHost && (!allowRemote || !apiToken)) { + process.stderr.write( + "[consensus] Refusing to bind non-loopback host without CONSENSUS_ALLOW_REMOTE=1 and CONSENSUS_API_TOKEN.\n" + ); + process.exit(1); +} const pollMs = Math.max(50, Number(process.env.CONSENSUS_POLL_MS || 250)); const scanTimeoutMs = Math.max( 500, @@ -177,6 +195,82 @@ const activityTestMode = process.env.ACTIVITY_TEST_MODE === "1"; const testUiPath = fs.existsSync(path.join(clientBuildDir, "index.html")) ? path.join(clientBuildDir, "index.html") : path.join(publicDir, "index.html"); +const jsonBodyLimit = "256kb"; +const rateLimitWindowMs = Math.max( + 1000, + Number(process.env.CONSENSUS_RATE_LIMIT_WINDOW_MS || 10000) +); +const rateLimitMax = Math.max( + 1, + Number(process.env.CONSENSUS_RATE_LIMIT_MAX || 120) +); +const rateLimitState = new Map(); + +function isLoopbackAddress(addr?: string | null): boolean { + if (!addr) return false; + if (addr === "::1" || addr === "127.0.0.1") return true; + if (addr.startsWith("::ffff:")) { + const mapped = addr.slice("::ffff:".length); + return mapped === "127.0.0.1"; + } + return false; +} + +function readBearerToken(req: { + headers?: Record; + query?: Record; + url?: string; +}): string | null { + const header = req.headers?.authorization || ""; + if (typeof header === "string" && header.toLowerCase().startsWith("bearer ")) { + return header.slice(7).trim() || null; + } + const direct = req.headers?.["x-consensus-token"]; + if (typeof direct === "string" && direct.trim()) return direct.trim(); + if (Array.isArray(direct) && direct[0]?.trim()) return direct[0].trim(); + const queryToken = + typeof req.query?.token === "string" ? req.query.token.trim() : null; + if (queryToken) return queryToken; + if (typeof req.url === "string") { + try { + const parsed = new URL(req.url, "http://localhost"); + const token = parsed.searchParams.get("token"); + if (token) return token.trim(); + } catch { + // ignore + } + } + return null; +} + +function enforceAuth(req: express.Request, res: express.Response, next: express.NextFunction): void { + if (!requireAuth) return next(); + if (!apiToken) return res.status(503).json({ error: "auth_not_configured" }); + const token = readBearerToken(req); + if (token && token === apiToken) return next(); + res.status(401).json({ error: "unauthorized" }); +} + +function enforceLocalOrAuth(req: express.Request, res: express.Response, next: express.NextFunction): void { + if (!apiToken && isLoopbackAddress(req.socket.remoteAddress)) return next(); + return enforceAuth(req, res, next); +} + +function enforceRateLimit(req: express.Request, res: express.Response, next: express.NextFunction): void { + const key = `${req.socket.remoteAddress || "unknown"}:${req.path}`; + const now = Date.now(); + const entry = rateLimitState.get(key); + if (!entry || entry.resetAt <= now) { + rateLimitState.set(key, { resetAt: now + rateLimitWindowMs, count: 1 }); + return next(); + } + entry.count += 1; + if (entry.count > rateLimitMax) { + res.status(429).json({ error: "rate_limited" }); + return; + } + next(); +} if (activityTestMode) { app.get("/", (_req, res) => { res.setHeader("Cache-Control", "no-cache"); @@ -246,7 +340,7 @@ function stopReloadWatcher(): Promise | void { } if (liveReloadEnabled) { - app.get("/__dev/reload", (req, res) => { + app.get("/__dev/reload", enforceLocalOrAuth, (req, res) => { res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); @@ -291,7 +385,7 @@ function runHttpEffect( }); } -app.get("/api/snapshot", (req, res) => { +app.get("/api/snapshot", enforceAuth, (req, res) => { const effect = Effect.tryPromise({ try: () => scanCodexProcesses({ mode: "full" }), catch: (err) => err as Error, @@ -315,7 +409,7 @@ app.get("/api/snapshot", (req, res) => { runHttpEffect(req, res, "/api/snapshot", effect); }); -app.get("/health", (req, res) => { +app.get("/health", enforceAuth, (req, res) => { runHttpEffect( req, res, @@ -327,89 +421,107 @@ app.get("/health", (req, res) => { ); }); -app.post("/__debug/activity", express.json(), (req, res) => { - const effect = Effect.sync(() => { - const enable = - req.query.enable ?? - req.query.enabled ?? - req.body?.enable ?? - req.body?.enabled; - const normalized = - enable === "1" || - enable === "true" || - enable === "on" || - enable === 1 || - enable === true; - process.env.CONSENSUS_DEBUG_ACTIVITY = normalized ? "1" : "0"; - res.json({ ok: true, enabled: process.env.CONSENSUS_DEBUG_ACTIVITY === "1" }); - return 200; - }); +app.post( + "/__debug/activity", + enforceLocalOrAuth, + express.json({ limit: jsonBodyLimit }), + enforceRateLimit, + (req, res) => { + const effect = Effect.sync(() => { + const enable = + req.query.enable ?? + req.query.enabled ?? + req.body?.enable ?? + req.body?.enabled; + const normalized = + enable === "1" || + enable === "true" || + enable === "on" || + enable === 1 || + enable === true; + process.env.CONSENSUS_DEBUG_ACTIVITY = normalized ? "1" : "0"; + res.json({ ok: true, enabled: process.env.CONSENSUS_DEBUG_ACTIVITY === "1" }); + return 200; + }); - runHttpEffect(req, res, "/__debug/activity", effect); -}); + runHttpEffect(req, res, "/__debug/activity", effect); + } +); // Codex webhook endpoint - receives events from notify hook -app.post("/api/codex-event", express.json(), (req, res) => { - const effect = Effect.gen(function* () { - // Decode and validate event - const decodeResult = Schema.decodeUnknownEither(CodexEventSchema)(req.body); - - if (decodeResult._tag === "Left") { - const error = ParseResult.TreeFormatter.formatErrorSync(decodeResult.left); - res.status(400).json({ - ok: false, - error: "Invalid event schema", - details: error - }); - return 400; - } - - const event = decodeResult.right; +app.post( + "/api/codex-event", + enforceAuth, + express.json({ limit: jsonBodyLimit }), + enforceRateLimit, + (req, res) => { + const effect = Effect.gen(function* () { + // Decode and validate event + const decodeResult = Schema.decodeUnknownEither(CodexEventSchema)(req.body); + + if (decodeResult._tag === "Left") { + const error = ParseResult.TreeFormatter.formatErrorSync(decodeResult.left); + res.status(400).json({ + ok: false, + error: "Invalid event schema", + details: error, + }); + return 400; + } - if (process.env.CONSENSUS_CODEX_NOTIFY_DEBUG === "1") { - process.stderr.write( - `[consensus] codex event type=${event.type} thread=${event.threadId}\n` - ); - } - - // Store event (sync for immediate availability) - codexEventStore.handleEvent(event); - - // Trigger fast scan to update UI - requestTick("fast"); - - res.json({ ok: true, received: event.type }); - return 200; - }); - - runHttpEffect(req, res, "/api/codex-event", effect); -}); + const event = decodeResult.right; + + if (process.env.CONSENSUS_CODEX_NOTIFY_DEBUG === "1") { + process.stderr.write( + `[consensus] codex event type=${event.type} thread=${event.threadId}\n` + ); + } + + // Store event (sync for immediate availability) + codexEventStore.handleEvent(event); + + // Trigger fast scan to update UI + requestTick("fast"); + + res.json({ ok: true, received: event.type }); + return 200; + }); + + runHttpEffect(req, res, "/api/codex-event", effect); + } +); // Claude webhook endpoint - receives events from Claude hooks -app.post("/api/claude-event", express.json(), (req, res) => { - const effect = Effect.gen(function* () { - const decodeResult = Schema.decodeUnknownEither(ClaudeEventSchema)(req.body); - - if (decodeResult._tag === "Left") { - const error = ParseResult.TreeFormatter.formatErrorSync(decodeResult.left); - res.status(400).json({ - ok: false, - error: "Invalid event schema", - details: error, - }); - return 400; - } +app.post( + "/api/claude-event", + enforceAuth, + express.json({ limit: jsonBodyLimit }), + enforceRateLimit, + (req, res) => { + const effect = Effect.gen(function* () { + const decodeResult = Schema.decodeUnknownEither(ClaudeEventSchema)(req.body); + + if (decodeResult._tag === "Left") { + const error = ParseResult.TreeFormatter.formatErrorSync(decodeResult.left); + res.status(400).json({ + ok: false, + error: "Invalid event schema", + details: error, + }); + return 400; + } - const event = decodeResult.right; - yield* handleClaudeEventEffect(event); - requestTick("fast"); + const event = decodeResult.right; + yield* handleClaudeEventEffect(event); + requestTick("fast"); - res.json({ ok: true, received: event.type }); - return 200; - }); + res.json({ ok: true, received: event.type }); + return 200; + }); - runHttpEffect(req, res, "/api/claude-event", effect); -}); + runHttpEffect(req, res, "/api/claude-event", effect); + } +); let lastSnapshot: SnapshotPayload = { ts: Date.now(), agents: [] }; let lastBaseSnapshot: SnapshotPayload = lastSnapshot; @@ -681,7 +793,11 @@ async function tick(): Promise { } } -wss.on("connection", (socket) => { +wss.on("connection", (socket, req) => { + if (requireAuth && (!apiToken || readBearerToken(req as any) !== apiToken)) { + socket.close(1008, "unauthorized"); + return; + } wsClients.set(socket, { mode: "legacy", ready: false }); socket.send(JSON.stringify(lastSnapshot)); @@ -796,6 +912,9 @@ registerActivityTestRoutes(app, { pollMs, activityTestMode, }), + guard: enforceLocalOrAuth, + rateLimit: enforceRateLimit, + jsonLimit: jsonBodyLimit, }); const pollLoop = Effect.forever( diff --git a/src/server/activityTestRoutes.ts b/src/server/activityTestRoutes.ts index 7bca122..1760654 100644 --- a/src/server/activityTestRoutes.ts +++ b/src/server/activityTestRoutes.ts @@ -1,4 +1,4 @@ -import express, { type Express } from "express"; +import express, { type Express, type RequestHandler } from "express"; type ReportFn = (laneId: string, source: string, active: boolean) => void; type StateFn = (laneId: string) => unknown; @@ -10,11 +10,18 @@ export function registerActivityTestRoutes( state: StateFn; reset?: () => void; config?: () => unknown; + guard?: RequestHandler; + rateLimit?: RequestHandler; + jsonLimit?: string; } ): void { if (process.env.ACTIVITY_TEST_MODE !== "1") return; - app.use("/__test", express.json()); + const middleware: RequestHandler[] = []; + if (deps.guard) middleware.push(deps.guard); + middleware.push(express.json({ limit: deps.jsonLimit ?? "256kb" })); + if (deps.rateLimit) middleware.push(deps.rateLimit); + app.use("/__test", ...middleware); app.post("/__test/activity/report", (req, res) => { const { laneId, source, active } = req.body ?? {};