From ad9a7f6f230ca45eb1051c349f4f553e85ff7abf Mon Sep 17 00:00:00 2001 From: OneStepAt4time Date: Sat, 11 Apr 2026 08:35:59 +0200 Subject: [PATCH 1/8] Revert "chore(main): release 0.4.0-alpha (#1632)" This reverts commit c49af976580dd58f2154857b484e8cc186991fbb. --- .release-please-manifest.json | 2 +- CHANGELOG.md | 343 ---------------------------------- package-lock.json | 4 +- package.json | 2 +- 4 files changed, 4 insertions(+), 347 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 88486080..1c7a5304 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.4.0-alpha" + ".": "0.3.2-alpha" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ca18209..adf388bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,349 +4,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.4.0-alpha](https://github.com/OneStepAt4time/aegis/compare/v0.3.2-alpha...v0.4.0-alpha) (2026-04-10) - - -### Features - -* **#599:** expose pendingQuestion in get_status and REST endpoint ([#600](https://github.com/OneStepAt4time/aegis/issues/600)) ([38fc42f](https://github.com/OneStepAt4time/aegis/commit/38fc42f7c99efc67a9b6d3636647682f9a593c39)) -* add batch session creation UI ([#312](https://github.com/OneStepAt4time/aegis/issues/312)) ([206db55](https://github.com/OneStepAt4time/aegis/commit/206db55bc1ef6d11fe146a3b992cd8af56ee189b)) -* add cursor-based replay contract for transcript endpoint ([#897](https://github.com/OneStepAt4time/aegis/issues/897)) ([d43bf23](https://github.com/OneStepAt4time/aegis/commit/d43bf23b3b282d70fca83276c588155bc5dfc0fb)) -* add docs-sync skill for TSDoc coverage audit and README auto-update ([#1044](https://github.com/OneStepAt4time/aegis/issues/1044)) ([c59826e](https://github.com/OneStepAt4time/aegis/commit/c59826e479c05d698dedba5e65e2b23e4af72183)) -* add MCP prompts — implement_issue, review_pr, debug_session — Issue [#443](https://github.com/OneStepAt4time/aegis/issues/443) ([b425f33](https://github.com/OneStepAt4time/aegis/commit/b425f33608578814058a4cae4b12fc30214e7afb)) -* add P0+P1+P2 MCP tools — kill, approve, reject, health, escape, interrupt, pane, metrics, summary, bash, command, latency, batch, pipelines, swarm — Issue [#441](https://github.com/OneStepAt4time/aegis/issues/441) ([247d936](https://github.com/OneStepAt4time/aegis/commit/247d93634650635edb55cd0644fa87290933c57c)) -* add pipeline management page ([1e905f9](https://github.com/OneStepAt4time/aegis/commit/1e905f9884394b415a5e4698a241a7d0d6648273)) -* add shared SSRF validation utility — Issue [#346](https://github.com/OneStepAt4time/aegis/issues/346) ([18b1dba](https://github.com/OneStepAt4time/aegis/commit/18b1dbaaa4b6ba0e87e4015422b9a604dece7665)) -* add tool registry for CC tool introspection ([#704](https://github.com/OneStepAt4time/aegis/issues/704)) ([#940](https://github.com/OneStepAt4time/aegis/issues/940)) ([a038ad8](https://github.com/OneStepAt4time/aegis/commit/a038ad896cb2912cf820b5bded056b13b8e0888d)) -* add webhook endpoint Zod schema — Issue [#346](https://github.com/OneStepAt4time/aegis/issues/346) ([629fdc9](https://github.com/OneStepAt4time/aegis/commit/629fdc9802d0232f44ca0afb1eb92c005cb80c4c)) -* add worktree-aware continuation metadata lookup ([#898](https://github.com/OneStepAt4time/aegis/issues/898)) ([215cc8c](https://github.com/OneStepAt4time/aegis/commit/215cc8c2e4dc2bfec14cebe50c7d0f129955862f)) -* add Zod validation schemas for API routes — Issue [#359](https://github.com/OneStepAt4time/aegis/issues/359) ([7a2ab85](https://github.com/OneStepAt4time/aegis/commit/7a2ab85fb64510fe675d67af60acad2d46b78391)) -* automated release pipeline (npm publish + GitHub Releases) ([047c6af](https://github.com/OneStepAt4time/aegis/commit/047c6afe55a409c55c39dead5906519f472f7c74)) -* automated release pipeline (npm publish + GitHub Releases) — Issue [#365](https://github.com/OneStepAt4time/aegis/issues/365) ([7fd11fe](https://github.com/OneStepAt4time/aegis/commit/7fd11fe6352253e6349b6acbdf810cbf506122f9)) -* capability handshake contract for Aegis and Claude Code ([#885](https://github.com/OneStepAt4time/aegis/issues/885)) ([e46d0eb](https://github.com/OneStepAt4time/aegis/commit/e46d0eb71510a02731b68058167ba3a923c31b61)) -* **ci:** add automatic issue labeling action (P2-P4) ([#1149](https://github.com/OneStepAt4time/aegis/issues/1149)) ([3ac98be](https://github.com/OneStepAt4time/aegis/commit/3ac98be60354ca25d289cd789460c2cdd97d490c)) -* **ci:** add Discord notification workflow for stars, issues, PRs, comments ([#1163](https://github.com/OneStepAt4time/aegis/issues/1163)) ([debe7f8](https://github.com/OneStepAt4time/aegis/commit/debe7f82c039d56cef11c3af7043a44722a95cd3)) -* commits no longer auto-bump minor. Minor/major bumps ([eaf38ed](https://github.com/OneStepAt4time/aegis/commit/eaf38ed57ec4e65fdaed06db50a3c91f8d310717)) -* commits no longer auto-bump minor. Minor/major bumps ([e66f477](https://github.com/OneStepAt4time/aegis/commit/e66f4772c6a935ec61a0a5ecb5fd51f0808b3079)) -* **dashboard:** add ALPHA badge to version display ([#1212](https://github.com/OneStepAt4time/aegis/issues/1212)) ([3fab456](https://github.com/OneStepAt4time/aegis/commit/3fab4564f5c426bdcb3e2f124fcee00cebcaa0a8)) -* dynamic permission policy API ([#700](https://github.com/OneStepAt4time/aegis/issues/700)) ([f3d4a90](https://github.com/OneStepAt4time/aegis/commit/f3d4a905dee8b164aabc42da62bf797b4678e23b)) -* dynamic permission policy API and sub-agent spawning API ([#700](https://github.com/OneStepAt4time/aegis/issues/700) [#702](https://github.com/OneStepAt4time/aegis/issues/702)) ([#943](https://github.com/OneStepAt4time/aegis/issues/943)) ([f3d4a90](https://github.com/OneStepAt4time/aegis/commit/f3d4a905dee8b164aabc42da62bf797b4678e23b)) -* enhance global metrics dashboard with 15+ metric cards and delivery rate sparklines ([#1043](https://github.com/OneStepAt4time/aegis/issues/1043)) ([cb57545](https://github.com/OneStepAt4time/aegis/commit/cb5754528ff4e76a067dfd3d215a8e1247434c5c)) -* headless question answering via PreToolUse hook ([54cf4b0](https://github.com/OneStepAt4time/aegis/commit/54cf4b01fcaec9df0753da491252aa6f01f0a263)) -* mark v2.17.4 as alpha release ([#1213](https://github.com/OneStepAt4time/aegis/issues/1213)) ([066f884](https://github.com/OneStepAt4time/aegis/commit/066f884e28ea2480c4e2fa3a2c0f55f1052ed389)) -* MCP Prompts — workflow templates for common tasks — Issue [#443](https://github.com/OneStepAt4time/aegis/issues/443) ([46f1c67](https://github.com/OneStepAt4time/aegis/commit/46f1c678a0fefde22a590ead195712b158423082)) -* MCP Resources — 4 resources for session data — Issue [#442](https://github.com/OneStepAt4time/aegis/issues/442) ([217f67f](https://github.com/OneStepAt4time/aegis/commit/217f67f95842f55b6f6f05c6adb3cf7b904302c0)) -* MCP Resources — expose session data as readable resources — Issue [#442](https://github.com/OneStepAt4time/aegis/issues/442) ([3d42e35](https://github.com/OneStepAt4time/aegis/commit/3d42e354dcac9cc8cf44db86cdcc4f124eb2bf92)) -* MCP tool completeness — expose all REST endpoints as MCP tools — Issue [#441](https://github.com/OneStepAt4time/aegis/issues/441) ([c3ab0a7](https://github.com/OneStepAt4time/aegis/commit/c3ab0a77f867ed2b5ad984b99902a0df485726bb)) -* move OpenClaw skill into repository for tracked versioning ([#543](https://github.com/OneStepAt4time/aegis/issues/543)) ([df7bf7b](https://github.com/OneStepAt4time/aegis/commit/df7bf7be05c9bfbc204e195a31b9be03d46a88a5)) -* per-session token usage tracking and cost estimation ([#1054](https://github.com/OneStepAt4time/aegis/issues/1054)) ([3d27c63](https://github.com/OneStepAt4time/aegis/commit/3d27c63c075469a52f5f0232ec0ce106d7da829c)), closes [#488](https://github.com/OneStepAt4time/aegis/issues/488) -* register 6 additional CC hook event types ([#753](https://github.com/OneStepAt4time/aegis/issues/753)) ([#964](https://github.com/OneStepAt4time/aegis/issues/964)) ([903ea9d](https://github.com/OneStepAt4time/aegis/commit/903ea9db9d7752988fc02425c542f103b8104b88)) -* register additional CC hook types ([#571](https://github.com/OneStepAt4time/aegis/issues/571)) ([#945](https://github.com/OneStepAt4time/aegis/issues/945)) ([8cd5ab8](https://github.com/OneStepAt4time/aegis/commit/8cd5ab8026acae74d8daebd33b14e082d252fd3a)) -* reset version to 0.1.0-alpha ([#1221](https://github.com/OneStepAt4time/aegis/issues/1221)) ([f1034f8](https://github.com/OneStepAt4time/aegis/commit/f1034f8b8967225b1b5686afb48f2ca7b124e0fe)), closes [#1210](https://github.com/OneStepAt4time/aegis/issues/1210) -* **resilience:** add structured error categorization and retry logic ([#701](https://github.com/OneStepAt4time/aegis/issues/701)) ([#729](https://github.com/OneStepAt4time/aegis/issues/729)) ([4b56b29](https://github.com/OneStepAt4time/aegis/commit/4b56b29ea4ecb67c8156c646bd9663cc35ca92bb)) -* REST API routes for memory bridge + session injection ([#783](https://github.com/OneStepAt4time/aegis/issues/783)) ([#957](https://github.com/OneStepAt4time/aegis/issues/957)) ([0506fd9](https://github.com/OneStepAt4time/aegis/commit/0506fd9e4664f8001bc17a077e5624f14728baed)) -* session forking — create new from parent context ([#995](https://github.com/OneStepAt4time/aegis/issues/995)) ([ebc02da](https://github.com/OneStepAt4time/aegis/commit/ebc02da9c8c3ead101876014afb4b01d8fb5a44d)) -* session memory bridge core module ([#783](https://github.com/OneStepAt4time/aegis/issues/783)) ([#951](https://github.com/OneStepAt4time/aegis/issues/951)) ([cfac2a0](https://github.com/OneStepAt4time/aegis/commit/cfac2a0416e60db109e5b9decd4dc54ca853431f)) -* session templates — save, reuse, quick-start ([#996](https://github.com/OneStepAt4time/aegis/issues/996)) ([1809971](https://github.com/OneStepAt4time/aegis/commit/1809971ecbd38cf3942126ece7ea11015ea53582)) -* terminal passthrough — merge transcript and terminal into xterm view ([#997](https://github.com/OneStepAt4time/aegis/issues/997)) ([4f519f1](https://github.com/OneStepAt4time/aegis/commit/4f519f1b3fb1389816e78c8bdefcb12d1805cb15)) -* user-controlled session TTL with presets and custom duration ([#994](https://github.com/OneStepAt4time/aegis/issues/994)) ([5d7034e](https://github.com/OneStepAt4time/aegis/commit/5d7034e56a5223a7b8e76e3dfca31d12ba08c78a)) -* validate webhook URLs in fromEnv() with Zod + SSRF checks — Issue [#346](https://github.com/OneStepAt4time/aegis/issues/346) ([f5ad1dc](https://github.com/OneStepAt4time/aegis/commit/f5ad1dc88a49b3e1d61dd7f201ad2dc9814b388d)) -* Verification Protocol — POST /v1/sessions/:id/verify ([#740](https://github.com/OneStepAt4time/aegis/issues/740)) ([#982](https://github.com/OneStepAt4time/aegis/issues/982)) ([fb2d340](https://github.com/OneStepAt4time/aegis/commit/fb2d3407be99a10a6a103953e5444f164016605c)) -* visual theme refresh — professional dark mode ([#999](https://github.com/OneStepAt4time/aegis/issues/999)) ([321aff9](https://github.com/OneStepAt4time/aegis/commit/321aff91578e77005ed83edb6cd01f351e5c546e)) - - -### Bug Fixes - -* **#582:** redact sensitive webhook headers from error logs ([9f9f614](https://github.com/OneStepAt4time/aegis/commit/9f9f614cfff1366b328ce7a9e804b55df7683951)), closes [#582](https://github.com/OneStepAt4time/aegis/issues/582) -* **#586:** clean up old EventSource on reconnect to prevent listener leak ([#609](https://github.com/OneStepAt4time/aegis/issues/609)) ([5780e64](https://github.com/OneStepAt4time/aegis/commit/5780e64e1e1eaea61b3193f006deb87cfe4fdd43)), closes [#586](https://github.com/OneStepAt4time/aegis/issues/586) -* **#587:** add error handling to Layout SSE subscription ([#608](https://github.com/OneStepAt4time/aegis/issues/608)) ([00d4933](https://github.com/OneStepAt4time/aegis/commit/00d493378ebcec0c507092f445342f1855c006d2)), closes [#587](https://github.com/OneStepAt4time/aegis/issues/587) -* **#588:** aggregate Promise.allSettled errors in webhook fire() ([#610](https://github.com/OneStepAt4time/aegis/issues/610)) ([bfc80c0](https://github.com/OneStepAt4time/aegis/commit/bfc80c0604bfa7b942b5b7dbe5cd345806e69114)), closes [#588](https://github.com/OneStepAt4time/aegis/issues/588) -* **#607:** reuse idle sessions for same workDir instead of creating duplicates ([dafa22c](https://github.com/OneStepAt4time/aegis/commit/dafa22c613f623b5a562244256829e1fa26c3826)), closes [#607](https://github.com/OneStepAt4time/aegis/issues/607) -* add 404 catch-all route, validate trustProxy for rate limiting ([#892](https://github.com/OneStepAt4time/aegis/issues/892)) ([1d0766a](https://github.com/OneStepAt4time/aegis/commit/1d0766ab88505d5e32d1f66632e50db7c0b679da)) -* add 5s grace period before marking session dead after pane exit ([#1026](https://github.com/OneStepAt4time/aegis/issues/1026)) ([#1034](https://github.com/OneStepAt4time/aegis/issues/1034)) ([d70b577](https://github.com/OneStepAt4time/aegis/commit/d70b5774340fb8270a74a89927f48e929ecbe884)) -* add auth key management page ([#966](https://github.com/OneStepAt4time/aegis/issues/966)) ([4f989a4](https://github.com/OneStepAt4time/aegis/commit/4f989a4d18e183ba4b87f66c9fa2ebcf88176b9a)) -* add catch handlers to fire-and-forget monitor operations ([#404](https://github.com/OneStepAt4time/aegis/issues/404)) ([#497](https://github.com/OneStepAt4time/aegis/issues/497)) ([3b1c36e](https://github.com/OneStepAt4time/aegis/commit/3b1c36e23dc96b57f6a6916d93c1577de144df59)) -* add CC version validation on session creation ([#564](https://github.com/OneStepAt4time/aegis/issues/564)) ([#927](https://github.com/OneStepAt4time/aegis/issues/927)) ([89a5ba5](https://github.com/OneStepAt4time/aegis/commit/89a5ba5bacfc715b13430f3c6a26bbe51d0495d6)) -* add forceConsistentCasingInFileNames and noFallthroughCasesInSwitch to root tsconfig ([#800](https://github.com/OneStepAt4time/aegis/issues/800)) ([9a3eff2](https://github.com/OneStepAt4time/aegis/commit/9a3eff2f2bbafa82ad8e425a2956b1a683deb8ba)) -* add latency metrics visualization to dashboard ([#990](https://github.com/OneStepAt4time/aegis/issues/990)) ([aaac8a0](https://github.com/OneStepAt4time/aegis/commit/aaac8a0c88df3742ddeadd2edec670114a7aadf2)) -* add missing UIStateEnum states, apply DOMPurify to all entry types ([#871](https://github.com/OneStepAt4time/aegis/issues/871)) ([9deee1b](https://github.com/OneStepAt4time/aegis/commit/9deee1bdcf306fec9bca168445147ea0b80cd160)) -* add NaN guard for ANSWER_TIMEOUT_MS parsing ([#637](https://github.com/OneStepAt4time/aegis/issues/637)) ([#833](https://github.com/OneStepAt4time/aegis/issues/833)) ([22f4656](https://github.com/OneStepAt4time/aegis/commit/22f465625b53b16130be3d1417e8c310a8ddc6c8)) -* add NaN/isFinite guards on config env var parsing — Issue [#359](https://github.com/OneStepAt4time/aegis/issues/359) ([9413006](https://github.com/OneStepAt4time/aegis/commit/94130069cd9813ffab9cadc841b0311c6079afcd)) -* add periodic cleanup for consensusRequests Map to prevent memory leak ([#1191](https://github.com/OneStepAt4time/aegis/issues/1191)) ([75c201f](https://github.com/OneStepAt4time/aegis/commit/75c201f0083f3bafd78b83e3f8a5f57aa30b9989)) -* add rate limiting to master token auth endpoint ([#924](https://github.com/OneStepAt4time/aegis/issues/924)) ([716f36a](https://github.com/OneStepAt4time/aegis/commit/716f36aeee810a68352d405adf5e9058edeeebaf)) -* add retry with jitter for pipeline stages ([#893](https://github.com/OneStepAt4time/aegis/issues/893)) ([067f1a1](https://github.com/OneStepAt4time/aegis/commit/067f1a187a6bc88c62d526a5499e1f48d38b4583)) -* add runtime type guards to ActivityStream describeEvent ([#423](https://github.com/OneStepAt4time/aegis/issues/423)) ([#493](https://github.com/OneStepAt4time/aegis/issues/493)) ([87999d3](https://github.com/OneStepAt4time/aegis/commit/87999d3c66d3d89f832f15ef3e5077ecc40a54fd)) -* add session filtering and bulk actions to dashboard ([#968](https://github.com/OneStepAt4time/aegis/issues/968)) ([08997b6](https://github.com/OneStepAt4time/aegis/commit/08997b6a961e9163f136f3900a6f0a4b8b3734fc)) -* add session screenshot capture preview ([#970](https://github.com/OneStepAt4time/aegis/issues/970)) ([b8336f5](https://github.com/OneStepAt4time/aegis/commit/b8336f5234ca386c67af9b081b1fcefd3a594db5)) -* add session slash and bash quick actions ([#967](https://github.com/OneStepAt4time/aegis/issues/967)) ([8404887](https://github.com/OneStepAt4time/aegis/commit/8404887158eea32aeaad1b0ed2ce712eb94d8874)) -* add session summary card to dashboard ([#989](https://github.com/OneStepAt4time/aegis/issues/989)) ([5ce41d4](https://github.com/OneStepAt4time/aegis/commit/5ce41d4ebae43f9fe301e03337399607861a864e)) -* add SSE connection limits ([ccd6b7c](https://github.com/OneStepAt4time/aegis/commit/ccd6b7cb6ec0c8e53fc0cc678a179b91be52b2b0)), closes [#300](https://github.com/OneStepAt4time/aegis/issues/300) -* add TTL cleanup for Telegram forum topics ([#1022](https://github.com/OneStepAt4time/aegis/issues/1022)) ([9ea4095](https://github.com/OneStepAt4time/aegis/commit/9ea409531ffdbf8b37afadc422dc8a56246e9432)) -* add v2.x to SECURITY.md supported versions ([#548](https://github.com/OneStepAt4time/aegis/issues/548)) ([37d9fe2](https://github.com/OneStepAt4time/aegis/commit/37d9fe211ee267f920b231e209d98d1a468afc35)) -* add Zod safeParse validation to all API routes — Issue [#359](https://github.com/OneStepAt4time/aegis/issues/359) ([004ebb5](https://github.com/OneStepAt4time/aegis/commit/004ebb5787c6e1f9bbff68cc95ffe30b76c42b5d)) -* add Zod schemas for getSessionMessages and getMetrics ([#407](https://github.com/OneStepAt4time/aegis/issues/407)) ([#501](https://github.com/OneStepAt4time/aegis/issues/501)) ([5272cf5](https://github.com/OneStepAt4time/aegis/commit/5272cf5ce5572e8e40667103342c2055be36cb65)) -* add Zod validation at all JSON.parse boundaries ([#410](https://github.com/OneStepAt4time/aegis/issues/410)) ([#492](https://github.com/OneStepAt4time/aegis/issues/492)) ([8197c57](https://github.com/OneStepAt4time/aegis/commit/8197c577e939fee8511dd4cd9a1b820462758ff5)) -* address dashboard accessibility defects ([#965](https://github.com/OneStepAt4time/aegis/issues/965)) ([5d53e35](https://github.com/OneStepAt4time/aegis/commit/5d53e35f8e3971de4f5aa51adbe399aa0faa0146)) -* address review issues in batch modal ([#312](https://github.com/OneStepAt4time/aegis/issues/312)) ([3f843ff](https://github.com/OneStepAt4time/aegis/commit/3f843ff161f1beb5d3d574aa3e98c4dad098c747)) -* **agent:** add consensus review request flow ([#1069](https://github.com/OneStepAt4time/aegis/issues/1069)) ([2cfad29](https://github.com/OneStepAt4time/aegis/commit/2cfad29554938383d84493308ffdf5910b788ce0)) -* align @types/node with minimum CI Node version (20) ([#793](https://github.com/OneStepAt4time/aegis/issues/793)) ([21d9e06](https://github.com/OneStepAt4time/aegis/commit/21d9e063e5803882b37a9513c112eb8f96fdd961)) -* **api:** align pagination response shape with frontend expectations ([#576](https://github.com/OneStepAt4time/aegis/issues/576)) ([b05654a](https://github.com/OneStepAt4time/aegis/commit/b05654a8974d39f6b7c4e61be0f84733bdc68532)) -* **api:** batch session ops -- stats endpoint, bulk delete, project filter ([#1056](https://github.com/OneStepAt4time/aegis/issues/1056)) ([72bc2e3](https://github.com/OneStepAt4time/aegis/commit/72bc2e3747452459dd21cc9b57170396a3b0a24d)) -* atomically check+create tmux window name ([#403](https://github.com/OneStepAt4time/aegis/issues/403)) ([#499](https://github.com/OneStepAt4time/aegis/issues/499)) ([5606a9b](https://github.com/OneStepAt4time/aegis/commit/5606a9b19daa759eef6d5dba42626573e1b87428)) -* auth bypass via broad path matching — Issue [#349](https://github.com/OneStepAt4time/aegis/issues/349) ([8bf8fde](https://github.com/OneStepAt4time/aegis/commit/8bf8fde4e07fd634c1ccdc2a141ef9b8531c07fc)) -* auth bypass via broad path matching in middleware — Issue [#349](https://github.com/OneStepAt4time/aegis/issues/349) ([a7c6146](https://github.com/OneStepAt4time/aegis/commit/a7c6146dbc15e802ce4ce55ee59d04bc0af8f403)) -* authentication on inbound Telegram messages — Issue [#348](https://github.com/OneStepAt4time/aegis/issues/348) ([8b2b41f](https://github.com/OneStepAt4time/aegis/commit/8b2b41f4bc6ad84278da6aed25547174b886aebc)) -* authentication on inbound Telegram messages — Issue [#348](https://github.com/OneStepAt4time/aegis/issues/348) ([43d37ab](https://github.com/OneStepAt4time/aegis/commit/43d37ab7685abf39b597ee3821680e47c37b6687)) -* avoid Set deletion during iteration in processedStopSignals ([#510](https://github.com/OneStepAt4time/aegis/issues/510)) ([#532](https://github.com/OneStepAt4time/aegis/issues/532)) ([de22f07](https://github.com/OneStepAt4time/aegis/commit/de22f0703db6fd0a54be28009a18ed20d448def3)) -* bound session event buffers and IP limiter maps ([#1021](https://github.com/OneStepAt4time/aegis/issues/1021)) ([82657d9](https://github.com/OneStepAt4time/aegis/commit/82657d92a363fd4e894538a85879f6e559b8f65a)) -* cache eviction, pipeline timer race, capturePane serialize ([#832](https://github.com/OneStepAt4time/aegis/issues/832), [#830](https://github.com/OneStepAt4time/aegis/issues/830), [#824](https://github.com/OneStepAt4time/aegis/issues/824)) ([#868](https://github.com/OneStepAt4time/aegis/issues/868)) ([1f458f3](https://github.com/OneStepAt4time/aegis/commit/1f458f36027950f1e931d6912832ce25a140cbd9)) -* **ci:** add explicit ClawHub login before skill publish ([#724](https://github.com/OneStepAt4time/aegis/issues/724)) ([#728](https://github.com/OneStepAt4time/aegis/issues/728)) ([bf9c714](https://github.com/OneStepAt4time/aegis/commit/bf9c714fb34cddcb50b070b6b4f1bb5db9f5460e)) -* **ci:** add lockfile-lint as devDependency ([#650](https://github.com/OneStepAt4time/aegis/issues/650)) ([e7cf146](https://github.com/OneStepAt4time/aegis/commit/e7cf146e7b14805b8197ef7d56b0f22771058fd3)) -* **ci:** add typecheck step to publish-npm job in release workflow ([#791](https://github.com/OneStepAt4time/aegis/issues/791)) ([9569ffa](https://github.com/OneStepAt4time/aegis/commit/9569ffa6eba20aebba349b9e31a750c1c3853771)) -* **ci:** install action dependencies in correct directory ([#1209](https://github.com/OneStepAt4time/aegis/issues/1209)) ([20205b4](https://github.com/OneStepAt4time/aegis/commit/20205b41b70844e0d5c466a83071373f662d4413)) -* **ci:** move audit step before build steps ([760dfbe](https://github.com/OneStepAt4time/aegis/commit/760dfbed2cc289d7eb33114426a33a9e671852da)) -* **ci:** pin clawhub to 0.9.0 in release workflow ([#651](https://github.com/OneStepAt4time/aegis/issues/651)) ([815af02](https://github.com/OneStepAt4time/aegis/commit/815af02fd94d98b9244144918148282978c39eea)) -* **ci:** remove master branch from CodeQL trigger ([#771](https://github.com/OneStepAt4time/aegis/issues/771)) ([a82109e](https://github.com/OneStepAt4time/aegis/commit/a82109e31b481de3947afd8ec06a0edd1c353b87)) -* **ci:** show star profile link and avatar in Discord notification ([#1165](https://github.com/OneStepAt4time/aegis/issues/1165)) ([b8103e6](https://github.com/OneStepAt4time/aegis/commit/b8103e646d45d9c00eee73dfdde1ce6a2ac63dae)) -* **ci:** use GITHUB_TOKEN for release-please instead of failing RELEASE_PAT ([91c3cb5](https://github.com/OneStepAt4time/aegis/commit/91c3cb56ed862c92d29a63d3c50fbc21e81e7e2c)) -* **ci:** use proper secrets syntax in release.yml if conditions ([#1351](https://github.com/OneStepAt4time/aegis/issues/1351)) ([9c52efe](https://github.com/OneStepAt4time/aegis/commit/9c52efe4fbdd139fa8692fba3cdef64dd32f2427)) -* **ci:** use RELEASE_PAT for release-please to trigger CI on PRs ([#601](https://github.com/OneStepAt4time/aegis/issues/601)) ([a86aaf1](https://github.com/OneStepAt4time/aegis/commit/a86aaf18411352f58baf8993da1c181522075ecc)) -* clamp WebSocket viewport dimensions to 1-1000 — Issue [#359](https://github.com/OneStepAt4time/aegis/issues/359) ([ffd5fa8](https://github.com/OneStepAt4time/aegis/commit/ffd5fa852c219a61a28c5ba7ee86ad01442ada5a)) -* clean up dashboard SSE subscriptions on unmount ([#1016](https://github.com/OneStepAt4time/aegis/issues/1016)) ([d7aa9c5](https://github.com/OneStepAt4time/aegis/commit/d7aa9c5b60c3ed72ce8fe251acd62c87ddc1e7a9)) -* clean up requestKeyMap entries after response to prevent memory leak ([#839](https://github.com/OneStepAt4time/aegis/issues/839)) ([#850](https://github.com/OneStepAt4time/aegis/issues/850)) ([30fe7b6](https://github.com/OneStepAt4time/aegis/commit/30fe7b69fc876afc49128ba5b0c7f4185338a45b)) -* clear all session-keyed tracking state on kill ([#1007](https://github.com/OneStepAt4time/aegis/issues/1007)) ([b0b1666](https://github.com/OneStepAt4time/aegis/commit/b0b16663c295ad24fc6a000fe5d03731d92f0473)) -* clear tracking maps on session kill to prevent memory leak ([#405](https://github.com/OneStepAt4time/aegis/issues/405)) ([#500](https://github.com/OneStepAt4time/aegis/issues/500)) ([15ec6c1](https://github.com/OneStepAt4time/aegis/commit/15ec6c1c4b0b476a8918ebe49b842256a9a46949)) -* close SSE connection on async component unmount ([#416](https://github.com/OneStepAt4time/aegis/issues/416)) ([#495](https://github.com/OneStepAt4time/aegis/issues/495)) ([3ef0ffc](https://github.com/OneStepAt4time/aegis/commit/3ef0ffced63b496a1084dc7a2c8599400eaa81f4)) -* close SSE connection on async component unmount ([#494](https://github.com/OneStepAt4time/aegis/issues/494)) ([cfdd783](https://github.com/OneStepAt4time/aegis/commit/cfdd783debc1222aed344586f50f7a03d358abce)) -* **cluster1:** version alignment + Zod 4 migration + Vitest 4 test fixes ([#526](https://github.com/OneStepAt4time/aegis/issues/526)) ([3c12f05](https://github.com/OneStepAt4time/aegis/commit/3c12f05500daca25650ede7cd4cbd8e10e935ec7)) -* command injection in hook.ts via TMUX_PANE — Issue [#347](https://github.com/OneStepAt4time/aegis/issues/347) ([2216973](https://github.com/OneStepAt4time/aegis/commit/22169736187ba181d13205186b2edd840032b121)) -* command injection in hook.ts via TMUX_PANE env var — Issue [#347](https://github.com/OneStepAt4time/aegis/issues/347) ([6bce734](https://github.com/OneStepAt4time/aegis/commit/6bce7349a50f97cffb77f8e577669aeba0f9dcba)) -* complete cursor replay contract for events and metadata ([#922](https://github.com/OneStepAt4time/aegis/issues/922)) ([1931ab0](https://github.com/OneStepAt4time/aegis/commit/1931ab0ade6feb282423e779379424c324e8958c)) -* correct clawhub publish command in release.yml ([#802](https://github.com/OneStepAt4time/aegis/issues/802)) ([5ebfbc2](https://github.com/OneStepAt4time/aegis/commit/5ebfbc21b427e9fa2b07525bc0c2d48aa9618a96)) -* correct README field name brief→prompt and update stale badges — Issue [#396](https://github.com/OneStepAt4time/aegis/issues/396) ([da18754](https://github.com/OneStepAt4time/aegis/commit/da18754452aa258664db793807fdf912f6da9c01)) -* correct README field name brief→prompt and update stale badges — Issue [#396](https://github.com/OneStepAt4time/aegis/issues/396) ([18774d3](https://github.com/OneStepAt4time/aegis/commit/18774d3dd9ad40f8debc1b984ebdac3fee58457e)) -* correct SubagentStart agent name extraction in hook Zod validation ([#768](https://github.com/OneStepAt4time/aegis/issues/768)) ([095dba1](https://github.com/OneStepAt4time/aegis/commit/095dba1e76965eafffdc2b7307a7005e5ded0297)) -* correctly report sendMessage delivery failures without masking ([#855](https://github.com/OneStepAt4time/aegis/issues/855)) ([42e1fb4](https://github.com/OneStepAt4time/aegis/commit/42e1fb4e0de6a8b50f9f491fb16c78e10e6fa8fc)) -* **correctness:** add event ID overflow guard ([#589](https://github.com/OneStepAt4time/aegis/issues/589)) ([0abeb7a](https://github.com/OneStepAt4time/aegis/commit/0abeb7a3792b1958e73e32b7fc78fb251e348412)) -* **correctness:** fix backward newline scan offset in transcript reader ([#579](https://github.com/OneStepAt4time/aegis/issues/579)) ([77b474f](https://github.com/OneStepAt4time/aegis/commit/77b474f3eee3bcb607da86b565a1cc1ba7f8f7e0)) -* dashboard dedup bounded set + stable debounce refs ([#504](https://github.com/OneStepAt4time/aegis/issues/504), [#512](https://github.com/OneStepAt4time/aegis/issues/512), [#514](https://github.com/OneStepAt4time/aegis/issues/514)) ([#535](https://github.com/OneStepAt4time/aegis/issues/535)) ([f035c50](https://github.com/OneStepAt4time/aegis/commit/f035c5040f624dd546e8fa13fd909052bad615c4)) -* dashboard type safety — PipelineInfo, BatchResult, PipelineRequest ([#669](https://github.com/OneStepAt4time/aegis/issues/669), [#670](https://github.com/OneStepAt4time/aegis/issues/670), [#671](https://github.com/OneStepAt4time/aegis/issues/671)) ([#888](https://github.com/OneStepAt4time/aegis/issues/888)) ([7cfc59c](https://github.com/OneStepAt4time/aegis/commit/7cfc59cee63a88d26a2a6a5b3dd0330ba1d0547c)) -* **dashboard:** add accessibility aria-labels and keyboard navigation ([#1185](https://github.com/OneStepAt4time/aegis/issues/1185)) ([e908dde](https://github.com/OneStepAt4time/aegis/commit/e908dde240321ed00bfcf41c9e63ea11b7f0a6f1)) -* **dashboard:** add adaptive polling backoff for pipeline pages ([#956](https://github.com/OneStepAt4time/aegis/issues/956)) ([5cb484c](https://github.com/OneStepAt4time/aegis/commit/5cb484ccf1bedd3a2b7c8518bbc33cbe3ec489a2)) -* **dashboard:** add loading/disabled state to SessionTable action buttons ([#798](https://github.com/OneStepAt4time/aegis/issues/798)) ([914d0b7](https://github.com/OneStepAt4time/aegis/commit/914d0b7f955684ac6ccfa861f50405df26f6d7b2)), closes [#645](https://github.com/OneStepAt4time/aegis/issues/645) -* **dashboard:** add missing type re-exports in types/index ([#1186](https://github.com/OneStepAt4time/aegis/issues/1186)) ([31caac9](https://github.com/OneStepAt4time/aegis/commit/31caac9c600b1423b173fd90a5532d6c969b7efe)) -* **dashboard:** add permission_denied to SSE event schema ([#1159](https://github.com/OneStepAt4time/aegis/issues/1159)) ([416d164](https://github.com/OneStepAt4time/aegis/commit/416d164278df2fb2f90270f809c438f46e5c015a)) -* **dashboard:** add schema validation for getAllSessionsHealth ([#1183](https://github.com/OneStepAt4time/aegis/issues/1183)) ([6bddb5d](https://github.com/OneStepAt4time/aegis/commit/6bddb5d7df2a2e1d74f1331c8be08cb6647156ee)) -* **dashboard:** add token to LiveTerminal WebSocket effect dependencies ([#796](https://github.com/OneStepAt4time/aegis/issues/796)) ([125954e](https://github.com/OneStepAt4time/aegis/commit/125954ec9fd0770258f628c9842f7c13269d8169)), closes [#642](https://github.com/OneStepAt4time/aegis/issues/642) -* **dashboard:** align SSE degraded state and loading error UX ([#1144](https://github.com/OneStepAt4time/aegis/issues/1144)) ([afb87f3](https://github.com/OneStepAt4time/aegis/commit/afb87f3f09b5215caca6cb20e10e9f701541cd7a)) -* **dashboard:** align SSE event schema with backend ([#1201](https://github.com/OneStepAt4time/aegis/issues/1201)) ([0cdf750](https://github.com/OneStepAt4time/aegis/commit/0cdf7503afea803d3eb00d637230477abe83713f)) -* **dashboard:** correct fork navigation route /session/ → /sessions/ ([#1155](https://github.com/OneStepAt4time/aegis/issues/1155)) ([590b692](https://github.com/OneStepAt4time/aegis/commit/590b6926028d7b79afe612e766215a0c0328fbb4)) -* **dashboard:** exclude AbortSignal from batchCreateSessions JSON body ([#784](https://github.com/OneStepAt4time/aegis/issues/784)) ([14661e2](https://github.com/OneStepAt4time/aegis/commit/14661e286beaa873e8a321c5d548b71bfa9a83a0)) -* **dashboard:** fix TranscriptViewer scroll jumps with dynamic row measurement ([#1182](https://github.com/OneStepAt4time/aegis/issues/1182)) ([ea0bacc](https://github.com/OneStepAt4time/aegis/commit/ea0bacc0a660cec6577de166209dd702b823a797)) -* **dashboard:** handle empty DELETE body in kill session ([#1200](https://github.com/OneStepAt4time/aegis/issues/1200)) ([1102bcc](https://github.com/OneStepAt4time/aegis/commit/1102bccb6436aa13e528b83759d4b19d586006f7)) -* **dashboard:** refresh overview data on SSE activity ([#1143](https://github.com/OneStepAt4time/aegis/issues/1143)) ([1fd7584](https://github.com/OneStepAt4time/aegis/commit/1fd7584cd396b1a840ca254b148b350914c94363)) -* **dashboard:** remove addToast from MetricCards fetchData dependencies ([#797](https://github.com/OneStepAt4time/aegis/issues/797)) ([4d12e98](https://github.com/OneStepAt4time/aegis/commit/4d12e9871310abd894175f45b067be3432e6e286)), closes [#644](https://github.com/OneStepAt4time/aegis/issues/644) -* **dashboard:** replace unsafe UIState cast with type-safe status mapping in PipelineDetailPage ([#1238](https://github.com/OneStepAt4time/aegis/issues/1238)) ([9783e14](https://github.com/OneStepAt4time/aegis/commit/9783e1485f8b1ce80da42efa8fc7787571c268d8)) -* **dashboard:** show aegis version with cached update check ([#1146](https://github.com/OneStepAt4time/aegis/issues/1146)) ([b9c91e3](https://github.com/OneStepAt4time/aegis/commit/b9c91e39514572768cab6fc7a240662f404a37c0)) -* **dashboard:** stabilize live update rendering and activity text ([#1145](https://github.com/OneStepAt4time/aegis/issues/1145)) ([9b7e423](https://github.com/OneStepAt4time/aegis/commit/9b7e4233bb4fb05a752145c40c4c06a20e52c63e)) -* **dashboard:** suppress 403 noise on Auth Keys page ([#1196](https://github.com/OneStepAt4time/aegis/issues/1196)) ([a5900c6](https://github.com/OneStepAt4time/aegis/commit/a5900c6c40dda72d55dadfc548e9d7c49b90ba14)) -* **dashboard:** ToolResultCard no longer classifies empty results as errors ([#795](https://github.com/OneStepAt4time/aegis/issues/795)) ([d7ed35c](https://github.com/OneStepAt4time/aegis/commit/d7ed35c242414e6bbf6026d962dae20c44567eba)), closes [#643](https://github.com/OneStepAt4time/aegis/issues/643) -* **dashboard:** validate createSession response against Zod schema ([#786](https://github.com/OneStepAt4time/aegis/issues/786)) ([aa60e92](https://github.com/OneStepAt4time/aegis/commit/aa60e9214afce14ea2f2eea65db5370ff86fed92)) -* deadlock in createWindow() serialize callback — Issue [#393](https://github.com/OneStepAt4time/aegis/issues/393) ([03505d9](https://github.com/OneStepAt4time/aegis/commit/03505d9e380c83f3065a4061a947e1c78f410671)) -* deep-merge hook settings by event instead of shallow spread ([#635](https://github.com/OneStepAt4time/aegis/issues/635)) ([#819](https://github.com/OneStepAt4time/aegis/issues/819)) ([7dcb498](https://github.com/OneStepAt4time/aegis/commit/7dcb498b1adfa189d7ea9b9d88b004a6d90e81a6)) -* detect CC process crash immediately via PID check ([#390](https://github.com/OneStepAt4time/aegis/issues/390)) ([#502](https://github.com/OneStepAt4time/aegis/issues/502)) ([e619882](https://github.com/OneStepAt4time/aegis/commit/e61988217e5d7b4efcdd795e69f884926798a149)) -* detect crashed sessions via tmux pane-exit signal ([#1020](https://github.com/OneStepAt4time/aegis/issues/1020)) ([da9f0d7](https://github.com/OneStepAt4time/aegis/commit/da9f0d7e0b332a3acb1f665b702af356aa7fa5be)) -* detect waiting_for_input session status from CC transcript on Stop hook ([#812](https://github.com/OneStepAt4time/aegis/issues/812)) ([#816](https://github.com/OneStepAt4time/aegis/issues/816)) ([af5794a](https://github.com/OneStepAt4time/aegis/commit/af5794abf31d56315766ec1ba9e10b7db8998fd1)) -* detect zombie CC processes via /proc/<pid>/stat state check ([#1032](https://github.com/OneStepAt4time/aegis/issues/1032)) ([dc44a70](https://github.com/OneStepAt4time/aegis/commit/dc44a70f2b75c349d7d7a2540501143773f8e3bf)) -* disable source maps in tsconfig to match published files ([#772](https://github.com/OneStepAt4time/aegis/issues/772)) ([ae68a9f](https://github.com/OneStepAt4time/aegis/commit/ae68a9f6f97ea44262b1f06322de89a337f785d5)) -* enforce zod v4 alignment across root and dashboard ([#1011](https://github.com/OneStepAt4time/aegis/issues/1011)) ([3bcd52a](https://github.com/OneStepAt4time/aegis/commit/3bcd52aaf1eec41a9cc02615314829fa0974e465)) -* ensure alpha suffix in version ([#1226](https://github.com/OneStepAt4time/aegis/issues/1226)) ([d0eb4c6](https://github.com/OneStepAt4time/aegis/commit/d0eb4c64dd69b3cc86d7cc56b15458191c61d25e)), closes [#1210](https://github.com/OneStepAt4time/aegis/issues/1210) -* extend paneDead grace period to 15s and add coverage tests ([#1036](https://github.com/OneStepAt4time/aegis/issues/1036)) ([505b8a0](https://github.com/OneStepAt4time/aegis/commit/505b8a0675c266d6652c49c8640920a178b70f9f)) -* fail copy-dashboard in CI when dashboard/dist is missing ([#770](https://github.com/OneStepAt4time/aegis/issues/770)) ([d33896b](https://github.com/OneStepAt4time/aegis/commit/d33896ba3b6366ff8ef272f4c55ad11772e3ec38)) -* graceful shutdown and crash recovery — Issue [#361](https://github.com/OneStepAt4time/aegis/issues/361) ([2ccbb05](https://github.com/OneStepAt4time/aegis/commit/2ccbb052acdfb4c47c8de51dc9c0d72d78d10327)) -* graceful shutdown and crash recovery gaps — Issue [#361](https://github.com/OneStepAt4time/aegis/issues/361) ([ab91263](https://github.com/OneStepAt4time/aegis/commit/ab912632bdad3f213dc6e9748824fe30a2e6e69d)) -* harden idle session acquisition mutex ([#890](https://github.com/OneStepAt4time/aegis/issues/890)) ([8979ef0](https://github.com/OneStepAt4time/aegis/commit/8979ef0b9ed75005caf065a30aab1b7d3dc544c3)) -* harden structured diagnostics channel and redaction ([#923](https://github.com/OneStepAt4time/aegis/issues/923)) ([bcbdf06](https://github.com/OneStepAt4time/aegis/commit/bcbdf067b14d068781a2b0180591ec800c24a933)) -* harden tmux crash recovery against false dead-session cleanup ([#1018](https://github.com/OneStepAt4time/aegis/issues/1018)) ([3749aa8](https://github.com/OneStepAt4time/aegis/commit/3749aa8ed2c154e09ae6b39ce0f0016348d5c0f3)) -* hook auth HMAC, env blocklist expansion, SSE rate limit dedup ([#629](https://github.com/OneStepAt4time/aegis/issues/629), [#630](https://github.com/OneStepAt4time/aegis/issues/630), [#634](https://github.com/OneStepAt4time/aegis/issues/634)) ([#914](https://github.com/OneStepAt4time/aegis/issues/914)) ([b3a2fd5](https://github.com/OneStepAt4time/aegis/commit/b3a2fd51a25549b18f0447a357d5008302269fe4)) -* hook path validation, tmux crash handling, approval regex, jsonl watcher timer ([#847](https://github.com/OneStepAt4time/aegis/issues/847), [#845](https://github.com/OneStepAt4time/aegis/issues/845), [#843](https://github.com/OneStepAt4time/aegis/issues/843), [#846](https://github.com/OneStepAt4time/aegis/issues/846)) ([#869](https://github.com/OneStepAt4time/aegis/issues/869)) ([7055b80](https://github.com/OneStepAt4time/aegis/commit/7055b80d8527853dba8bc7f34a7ff53791536a02)) -* **hooks:** add Phase 1 lifecycle events -- PermissionDenied, TaskCreated, Setup, ConfigChange, InstructionsLoaded ([#1058](https://github.com/OneStepAt4time/aegis/issues/1058)) ([7ab2a84](https://github.com/OneStepAt4time/aegis/commit/7ab2a842f54e9143ce8ebdd44132737237a23614)) -* hydrate activeSubagents arrays to Sets at load time ([#668](https://github.com/OneStepAt4time/aegis/issues/668)) ([#765](https://github.com/OneStepAt4time/aegis/issues/765)) ([b6447a6](https://github.com/OneStepAt4time/aegis/commit/b6447a625e6aa34418260e29df13c1b47a6e12ce)) -* include dashboard in npm package ([#539](https://github.com/OneStepAt4time/aegis/issues/539)) ([#545](https://github.com/OneStepAt4time/aegis/issues/545)) ([b47f63e](https://github.com/OneStepAt4time/aegis/commit/b47f63e8ee999204997b2a6dd28142b5b7aaecb5)) -* include dashboard in npm package + add types/exports/homepage/bugs fields ([#539](https://github.com/OneStepAt4time/aegis/issues/539)) ([#546](https://github.com/OneStepAt4time/aegis/issues/546)) ([6f799f1](https://github.com/OneStepAt4time/aegis/commit/6f799f1f78ed496b6f593a10bf5b2a5f9b8d83e9)) -* inject MCP_CONNECTION_NONBLOCKING in hook settings ([#931](https://github.com/OneStepAt4time/aegis/issues/931)) ([#935](https://github.com/OneStepAt4time/aegis/issues/935)) ([a4fc9ee](https://github.com/OneStepAt4time/aegis/commit/a4fc9ee6166e307a1a38b705b15d7000452ef862)) -* input validation across all API routes — Issue [#359](https://github.com/OneStepAt4time/aegis/issues/359) ([3fdf986](https://github.com/OneStepAt4time/aegis/commit/3fdf98679cd8edf8ab7c3a19a19c2c91b8a31877)) -* lock release-please to 0.1.x-alpha ([#1223](https://github.com/OneStepAt4time/aegis/issues/1223)) ([b16a734](https://github.com/OneStepAt4time/aegis/commit/b16a734c7004a775f0a92dcdd575f7b68240f180)), closes [#1210](https://github.com/OneStepAt4time/aegis/issues/1210) -* loosen flaky backoff assertions in channels test — Issue [#378](https://github.com/OneStepAt4time/aegis/issues/378) ([c1e63e0](https://github.com/OneStepAt4time/aegis/commit/c1e63e08ed038a2ec7367fefa3ac542f37dbec1f)) -* loosen flaky backoff assertions in channels test — Issue [#378](https://github.com/OneStepAt4time/aegis/issues/378) ([db3a83b](https://github.com/OneStepAt4time/aegis/commit/db3a83b30f342bc12007b78aca30ad7dfeddcb34)) -* make tmux createWindow name collision handling race-safe ([#1005](https://github.com/OneStepAt4time/aegis/issues/1005)) ([4279011](https://github.com/OneStepAt4time/aegis/commit/427901136aa9ed9158b5129c88bf778e4a66e7c4)) -* MCP kill_session 400 + extended working stall detection ([#597](https://github.com/OneStepAt4time/aegis/issues/597)) ([0426613](https://github.com/OneStepAt4time/aegis/commit/042661369de062bba62a4e181bd345e9964b3b31)) -* MCP server polish — version, auth, errors, graceful degradation — Issue [#445](https://github.com/OneStepAt4time/aegis/issues/445) ([37bc8b6](https://github.com/OneStepAt4time/aegis/commit/37bc8b6d1459fdda7e3c2b516f804f56944155d8)) -* MCP server polish — version, auth, errors, graceful degradation — Issue [#445](https://github.com/OneStepAt4time/aegis/issues/445) ([1a5def4](https://github.com/OneStepAt4time/aegis/commit/1a5def416ebccb587d4fc51296314d65f8e4dcd8)) -* MCP server polish — version, auth, errors, graceful degradation — Issue [#445](https://github.com/OneStepAt4time/aegis/issues/445) ([8bfea10](https://github.com/OneStepAt4time/aegis/commit/8bfea1050a5da125659c7fff03bd0087d2917fbb)) -* **mcp:** add state_get/state_set/state_delete tools ([#1062](https://github.com/OneStepAt4time/aegis/issues/1062)) ([1d9e361](https://github.com/OneStepAt4time/aegis/commit/1d9e36193e89499479b57d33d52b3be4425cc116)) -* memory leak fixes — event buffer cleanup, cache eviction, debounce guard ([#572](https://github.com/OneStepAt4time/aegis/issues/572)) ([13ed2c8](https://github.com/OneStepAt4time/aegis/commit/13ed2c8fe8080d81a529fd9642f958eb9a003333)) -* **memory:** scoped memory API and session-linked memories ([#1059](https://github.com/OneStepAt4time/aegis/issues/1059)) ([5052dea](https://github.com/OneStepAt4time/aegis/commit/5052deaeae5f27d0c3cb0914e5bdcc508ae843f4)) -* merge project settings into hook settings file ([c861d93](https://github.com/OneStepAt4time/aegis/commit/c861d93ebea368322316e564c3b6bd39a078b529)) -* mock assertion bugs and type errors in test files ([d17d1b1](https://github.com/OneStepAt4time/aegis/commit/d17d1b1bea58a949d65e2ed44cd160c2cef4b61c)) -* monitor stall detection edge cases — Issue [#356](https://github.com/OneStepAt4time/aegis/issues/356) ([74c3aaa](https://github.com/OneStepAt4time/aegis/commit/74c3aaa2533745d1060d559999c9d2e132a67bdf)) -* monitor stall detection edge cases — Issue [#356](https://github.com/OneStepAt4time/aegis/issues/356) ([eb0d698](https://github.com/OneStepAt4time/aegis/commit/eb0d698ebacaf9a928b6e2ba9560fb636de52bfd)) -* **monitor:** add structured diagnostics for unexpected session death ([#1051](https://github.com/OneStepAt4time/aegis/issues/1051)) ([d30fc96](https://github.com/OneStepAt4time/aegis/commit/d30fc9635c0c9535217c8aea258c0f097149871e)) -* move lastUsedAt after rate-limit check, add session mutex, remove transcript offset fallback ([#854](https://github.com/OneStepAt4time/aegis/issues/854)) ([2618e0c](https://github.com/OneStepAt4time/aegis/commit/2618e0c3d534db4136698654f776371d82acaecf)) -* move reaper notify after killSession cleanup ([#842](https://github.com/OneStepAt4time/aegis/issues/842)) ([#889](https://github.com/OneStepAt4time/aegis/issues/889)) ([c8262f3](https://github.com/OneStepAt4time/aegis/commit/c8262f3a972b1e26265eb4cd70d6e95ec199f8f2)) -* move setEnvSecure inside serialize block to prevent env var race ([#837](https://github.com/OneStepAt4time/aegis/issues/837)) ([#860](https://github.com/OneStepAt4time/aegis/issues/860)) ([a9b5089](https://github.com/OneStepAt4time/aegis/commit/a9b5089fd6bd487822de78a57ddb7749387d5af4)) -* only count 5xx and network errors as circuit breaker failures ([#638](https://github.com/OneStepAt4time/aegis/issues/638)) ([#821](https://github.com/OneStepAt4time/aegis/issues/821)) ([1c70a38](https://github.com/OneStepAt4time/aegis/commit/1c70a386d13a896f10f6b33ed5896198270b99de)) -* package hygiene for npm publishing — Issue [#364](https://github.com/OneStepAt4time/aegis/issues/364) ([0a36e7e](https://github.com/OneStepAt4time/aegis/commit/0a36e7e8ccd80cd35aa36c33e3b181bf04838ecf)) -* package hygiene for npm publishing — Issue [#364](https://github.com/OneStepAt4time/aegis/issues/364) ([3377e51](https://github.com/OneStepAt4time/aegis/commit/3377e514c09d80b68c4ea80152326aa4b12f5778)) -* **parser:** recognize CC ≥2.1.92 workspace trust dialog ([#1045](https://github.com/OneStepAt4time/aegis/issues/1045)) ([#1048](https://github.com/OneStepAt4time/aegis/issues/1048)) ([edb23f9](https://github.com/OneStepAt4time/aegis/commit/edb23f949b496d7140c765b382ce43a5d6e77e60)) -* path traversal bypass and DELETE 404 for missing sessions — Issues [#434](https://github.com/OneStepAt4time/aegis/issues/434) [#435](https://github.com/OneStepAt4time/aegis/issues/435) ([d3f21c5](https://github.com/OneStepAt4time/aegis/commit/d3f21c57ca312438510144f83384a21ca1b22c6b)) -* path traversal bypass and DELETE 404 for missing sessions — Issues [#434](https://github.com/OneStepAt4time/aegis/issues/434) [#435](https://github.com/OneStepAt4time/aegis/issues/435) ([c4ada0c](https://github.com/OneStepAt4time/aegis/commit/c4ada0cd544f33b2beb052e039bc95abd118259f)) -* **perf:** add shared tmux capture-pane cache to deduplicate reads ([#395](https://github.com/OneStepAt4time/aegis/issues/395)) ([#731](https://github.com/OneStepAt4time/aegis/issues/731)) ([3aa6111](https://github.com/OneStepAt4time/aegis/commit/3aa6111a52a3f8a675ec613b34cf3c4bded48368)) -* **perf:** align stall detection with CLAUDE_STREAM_IDLE_TIMEOUT_MS ([#392](https://github.com/OneStepAt4time/aegis/issues/392)) ([0624b65](https://github.com/OneStepAt4time/aegis/commit/0624b6529142e144299e62afbb24354772fd80b5)) -* **perf:** clear pipeline poll interval when no pipelines remain ([#578](https://github.com/OneStepAt4time/aegis/issues/578)) ([efa7269](https://github.com/OneStepAt4time/aegis/commit/efa726935cfd7838f24d80dd80099cab9fe183d1)) -* **pipeline:** add staged state metadata and stage history ([#1064](https://github.com/OneStepAt4time/aegis/issues/1064)) ([e0d7855](https://github.com/OneStepAt4time/aegis/commit/e0d7855171623077dc68bf79d71b3da45f6a496d)) -* **pipeline:** clear cleanup timers and maps on shutdown ([#1198](https://github.com/OneStepAt4time/aegis/issues/1198)) ([15b1ff7](https://github.com/OneStepAt4time/aegis/commit/15b1ff7c05ff5952b0e102f1b3796102aee320b5)), closes [#1092](https://github.com/OneStepAt4time/aegis/issues/1092) -* prepare next alpha release from develop ([#1575](https://github.com/OneStepAt4time/aegis/issues/1575)) ([866e5f9](https://github.com/OneStepAt4time/aegis/commit/866e5f9785c84330979da38019ae33251cf7e499)) -* preserve hook env vars and normalize callback host ([#981](https://github.com/OneStepAt4time/aegis/issues/981)) ([319e478](https://github.com/OneStepAt4time/aegis/commit/319e478aac7cd64581c34e26fd962f02ca6b13b8)) -* preserve hook env vars for BOM settings files ([#986](https://github.com/OneStepAt4time/aegis/issues/986)) ([a3915e8](https://github.com/OneStepAt4time/aegis/commit/a3915e82d4c425ca39ccf36de080877f7377d70d)) -* prevent double gracefulShutdown on rapid SIGINT ([#415](https://github.com/OneStepAt4time/aegis/issues/415)) ([#490](https://github.com/OneStepAt4time/aegis/issues/490)) ([f32de59](https://github.com/OneStepAt4time/aegis/commit/f32de59e5a419b87b2aabe577457504cd8b66be2)) -* prevent path traversal in workDir validation — Issue [#435](https://github.com/OneStepAt4time/aegis/issues/435) ([8e9994e](https://github.com/OneStepAt4time/aegis/commit/8e9994ecd31750d2ab40c6b97e2994000bf22154)) -* prevent path traversal in workDir validation — Issue [#435](https://github.com/OneStepAt4time/aegis/issues/435) ([e26f362](https://github.com/OneStepAt4time/aegis/commit/e26f362a42816d1fd2db2d6c8e4f9714c55f65d1)) -* prevent TOCTOU race in idle session reuse ([#857](https://github.com/OneStepAt4time/aegis/issues/857)) ([e5d2baf](https://github.com/OneStepAt4time/aegis/commit/e5d2baf52a1531e13429400264fc8689c1a87188)) -* properly handle CC normal exit in isWindowAlive ([#1042](https://github.com/OneStepAt4time/aegis/issues/1042)) ([bfc2191](https://github.com/OneStepAt4time/aegis/commit/bfc21911e0947ee34fa817d892ec867dcdd989fe)) -* quote 'on' key in release.yml (YAML 1.1 boolean bug) ([#1322](https://github.com/OneStepAt4time/aegis/issues/1322)) ([6edb4ae](https://github.com/OneStepAt4time/aegis/commit/6edb4ae28b5bf92ab6c6934c9dc5cb723e370903)) -* read version dynamically from package.json in MCP server test ([#534](https://github.com/OneStepAt4time/aegis/issues/534)) ([c4b648c](https://github.com/OneStepAt4time/aegis/commit/c4b648c09ab18577dda1185b65d61b87cf61bdb7)) -* reduce dashboard polling and memoize session rows ([#955](https://github.com/OneStepAt4time/aegis/issues/955)) ([570581d](https://github.com/OneStepAt4time/aegis/commit/570581d2a9b76bdf65e93bc18dae88f5c67132fa)) -* remove bearer token fallback in SSE — retry with backoff instead — Issue [#408](https://github.com/OneStepAt4time/aegis/issues/408) ([733f5b7](https://github.com/OneStepAt4time/aegis/commit/733f5b793d365a3f5e271816b332f033dfbe656b)) -* remove bearer token fallback in SSE — retry with backoff instead — Issue [#408](https://github.com/OneStepAt4time/aegis/issues/408) ([b51800d](https://github.com/OneStepAt4time/aegis/commit/b51800d8a85666d63096aff076b0a3701aebdb95)) -* remove ccPid zombie check that treats normal CC exit as crash ([#1040](https://github.com/OneStepAt4time/aegis/issues/1040)) ([e9e6f35](https://github.com/OneStepAt4time/aegis/commit/e9e6f356207ba22bbffd4f63a603effec919c35d)) -* remove invalid CC hook event types that crash sessions ([#1002](https://github.com/OneStepAt4time/aegis/issues/1002)) ([#1023](https://github.com/OneStepAt4time/aegis/issues/1023)) ([20ab0f6](https://github.com/OneStepAt4time/aegis/commit/20ab0f677529fafb88fa8375cb54815787d05b47)) -* remove paneDead regression from session liveness check ([#1026](https://github.com/OneStepAt4time/aegis/issues/1026)) ([#1027](https://github.com/OneStepAt4time/aegis/issues/1027)) ([8eb2f9d](https://github.com/OneStepAt4time/aegis/commit/8eb2f9d4db200561cad20aa8d3de368b5fefbdf5)) -* remove redundant includes check in swarm socket discovery ([#789](https://github.com/OneStepAt4time/aegis/issues/789)) ([307b9ae](https://github.com/OneStepAt4time/aegis/commit/307b9aef4b7e71ce6d5ad89cc71cfd37d97b7b00)) -* remove unused sessionId prop from PanePreview and ApprovalBanner ([#647](https://github.com/OneStepAt4time/aegis/issues/647)) ([#937](https://github.com/OneStepAt4time/aegis/issues/937)) ([3ab8e90](https://github.com/OneStepAt4time/aegis/commit/3ab8e9072eeaf91108c6de1301db21ec408e0b52)) -* replace `as any` cast in applyEnvOverrides with explicit string-key cases ([#762](https://github.com/OneStepAt4time/aegis/issues/762)) ([e1d5a5c](https://github.com/OneStepAt4time/aegis/commit/e1d5a5ca81194a14a272d4c3c60466721da9e3a4)) -* replace any return types with proper types in MCP server ([#577](https://github.com/OneStepAt4time/aegis/issues/577)) ([#929](https://github.com/OneStepAt4time/aegis/issues/929)) ([fc78e6a](https://github.com/OneStepAt4time/aegis/commit/fc78e6a98cb27f6617ab2d5dd4bd4b5665855de2)) -* replace silent catches with explicit suppressible-error policy ([#896](https://github.com/OneStepAt4time/aegis/issues/896)) ([e47c859](https://github.com/OneStepAt4time/aegis/commit/e47c859671c4ddc39f8e06c667da02384becff82)) -* replace sync readFileSync with async I/O in transcript scanning ([#409](https://github.com/OneStepAt4time/aegis/issues/409)) ([#496](https://github.com/OneStepAt4time/aegis/issues/496)) ([3ccddc9](https://github.com/OneStepAt4time/aegis/commit/3ccddc95de762c289d94407a28361919d390a141)) -* replace unsafe `(e as Error).message` with instanceof guard ([#763](https://github.com/OneStepAt4time/aegis/issues/763)) ([7963763](https://github.com/OneStepAt4time/aegis/commit/7963763ff3aa17f2d2e56e561d7c79cd2f7a1d02)) -* require session validation on hook endpoints — Issue [#394](https://github.com/OneStepAt4time/aegis/issues/394) ([fb76540](https://github.com/OneStepAt4time/aegis/commit/fb765406482c4d0ab14982e03fd6d18e8e83f1c7)) -* require session validation on hook endpoints — Issue [#394](https://github.com/OneStepAt4time/aegis/issues/394) ([09556cb](https://github.com/OneStepAt4time/aegis/commit/09556cbb901d90368b3ec8be40d2f4fd74509188)) -* resolve merge conflict markers in ws-terminal.ts ([530e7ce](https://github.com/OneStepAt4time/aegis/commit/530e7ce0c68a5a3a98f9526899888b1014e85e80)) -* restrict permissionMode to known enum values in validation schemas ([#756](https://github.com/OneStepAt4time/aegis/issues/756)) ([39c2521](https://github.com/OneStepAt4time/aegis/commit/39c25217d753183a330af9dc8d5f159d5c2e1616)) -* return 400 with INVALID_WORKDIR when workDir does not exist — Issue [#458](https://github.com/OneStepAt4time/aegis/issues/458) ([18022d8](https://github.com/OneStepAt4time/aegis/commit/18022d8e27dc2572ce70c809d9fe45cedb5c3ec6)) -* return 400 with INVALID_WORKDIR when workDir does not exist — Issue [#458](https://github.com/OneStepAt4time/aegis/issues/458) ([b561ffe](https://github.com/OneStepAt4time/aegis/commit/b561ffe6c06723cedf876e4ec737c6349616615d)) -* robust prompt delivery with post-send verification ([#567](https://github.com/OneStepAt4time/aegis/issues/567)) ([05478fe](https://github.com/OneStepAt4time/aegis/commit/05478fe649cd420fcd7c8533e75caed1bc752789)), closes [#561](https://github.com/OneStepAt4time/aegis/issues/561) -* **routing:** add tiered model routing module ([#1060](https://github.com/OneStepAt4time/aegis/issues/1060)) ([1e21e8d](https://github.com/OneStepAt4time/aegis/commit/1e21e8df31f78f36fb4db423ae77b4b4c81b9450)) -* sanitize permission_request content in MessageBubble to prevent XSS ([#406](https://github.com/OneStepAt4time/aegis/issues/406)) ([#498](https://github.com/OneStepAt4time/aegis/issues/498)) ([20f33f7](https://github.com/OneStepAt4time/aegis/commit/20f33f79383e9d41de89cde5bb08567b98097478)) -* security audit and dependency hardening ([8ed2a95](https://github.com/OneStepAt4time/aegis/commit/8ed2a95859d4505595d7c9ee64416e2ad7594832)) -* security audit hardening — resolve vulns, block CI on high-sev, add lockfile lint ([0bd0162](https://github.com/OneStepAt4time/aegis/commit/0bd0162b87cc95875ccb25e207a63f6bb1a43860)) -* security hardening — CORS wildcard rejection, UUID validation, input length limits ([#565](https://github.com/OneStepAt4time/aegis/issues/565)) ([db610ec](https://github.com/OneStepAt4time/aegis/commit/db610ecbda5834bb43bafee9965a61f7cd919b4a)) -* **security:** add bounds validation on WebSocket resize messages ([#581](https://github.com/OneStepAt4time/aegis/issues/581)) ([8df77c8](https://github.com/OneStepAt4time/aegis/commit/8df77c85fcdfdbddbe7a078117bbbf278eb54d14)) -* **security:** add mutex to validateSSEToken to prevent double-decrement race ([#826](https://github.com/OneStepAt4time/aegis/issues/826)) ([#861](https://github.com/OneStepAt4time/aegis/issues/861)) ([6b05a4b](https://github.com/OneStepAt4time/aegis/commit/6b05a4b7cca88fadb86c85fde677295101ab5ee1)) -* **security:** add per-session permission profiles for PreToolUse ([#1070](https://github.com/OneStepAt4time/aegis/issues/1070)) ([2155680](https://github.com/OneStepAt4time/aegis/commit/215568076beb990e5ff7d8d45bee6998a6553c04)) -* **security:** add rate limiting for batch session creation ([#583](https://github.com/OneStepAt4time/aegis/issues/583)) ([d8f66c8](https://github.com/OneStepAt4time/aegis/commit/d8f66c81bc5f90e80ae6597de441fb1b0f5daf1a)) -* **security:** cap per-IP rate-limit map at 10k entries to prevent memory exhaustion ([#844](https://github.com/OneStepAt4time/aegis/issues/844)) ([#858](https://github.com/OneStepAt4time/aegis/issues/858)) ([1927c15](https://github.com/OneStepAt4time/aegis/commit/1927c15d9eb3d24176885f66847419826e39e22a)) -* **security:** catch prior mutex rejection in generateSSEToken ([#573](https://github.com/OneStepAt4time/aegis/issues/573)) ([1ddd8f4](https://github.com/OneStepAt4time/aegis/commit/1ddd8f42ca4a2a5a266a3f6279fcd3482884e16d)) -* **security:** change default permissionMode to require approval ([#1152](https://github.com/OneStepAt4time/aegis/issues/1152)) ([f050683](https://github.com/OneStepAt4time/aegis/commit/f050683274e1d683313c1b06a6ad27a537b54493)) -* **security:** check all DNS answers and verify TOCTOU-safe IP pinning ([#829](https://github.com/OneStepAt4time/aegis/issues/829), [#831](https://github.com/OneStepAt4time/aegis/issues/831)) ([#853](https://github.com/OneStepAt4time/aegis/issues/853)) ([f0ef9e6](https://github.com/OneStepAt4time/aegis/commit/f0ef9e651353d6404c75a117b4be9d0f3f0e0b48)) -* **security:** check auth before revealing session existence in WebSocket ([#1157](https://github.com/OneStepAt4time/aegis/issues/1157)) ([7836f66](https://github.com/OneStepAt4time/aegis/commit/7836f66d1a2a8bc1f28bb733ed13391961f7c5e1)) -* **security:** detect IPv4-mapped IPv6 addresses in SSRF protection ([#621](https://github.com/OneStepAt4time/aegis/issues/621)) ([#815](https://github.com/OneStepAt4time/aegis/issues/815)) ([2ffe1ed](https://github.com/OneStepAt4time/aegis/commit/2ffe1ed1fa6a0a2fc109795b86fab66ec7384686)) -* **security:** differentiate webhook retry log levels ([#588](https://github.com/OneStepAt4time/aegis/issues/588)) ([725bb21](https://github.com/OneStepAt4time/aegis/commit/725bb2115fa4b4cbc60daba581488625405267c0)) -* **security:** enforce short-lived SSE token scope on event streams ([#1003](https://github.com/OneStepAt4time/aegis/issues/1003)) ([ed78a54](https://github.com/OneStepAt4time/aegis/commit/ed78a54cc7a4d4c977709106c47934ccb290bb06)) -* **security:** harden master token timing-safe comparison ([#1008](https://github.com/OneStepAt4time/aegis/issues/1008)) ([4dbcf4e](https://github.com/OneStepAt4time/aegis/commit/4dbcf4e2b360056c432afcd1c5db085261da8783)) -* **security:** harden runtime JSON parsing boundaries ([#1006](https://github.com/OneStepAt4time/aegis/issues/1006)) ([2eba890](https://github.com/OneStepAt4time/aegis/commit/2eba89071050d1daf1cd19b89279aeaaa5b601b2)) -* **security:** harden workDir boundary checks against traversal bypasses ([#1009](https://github.com/OneStepAt4time/aegis/issues/1009)) ([c039019](https://github.com/OneStepAt4time/aegis/commit/c0390190c6bd8551130d5e59ad99e1578abdfdef)) -* **security:** move hook secrets from query params to headers ([#1154](https://github.com/OneStepAt4time/aegis/issues/1154)) ([ab29110](https://github.com/OneStepAt4time/aegis/commit/ab29110e062e14755d98090583d3e5ed60b2ab26)) -* **security:** prevent DNS rebinding in screenshot endpoint via host-resolver-rules ([#817](https://github.com/OneStepAt4time/aegis/issues/817)) ([43998d5](https://github.com/OneStepAt4time/aegis/commit/43998d5bd042710f4554bf13baf02ccbfe0d638f)) -* **security:** prevent DNS rebinding SSRF in webhook delivery ([#822](https://github.com/OneStepAt4time/aegis/issues/822)) ([#852](https://github.com/OneStepAt4time/aegis/issues/852)) ([3a0d54d](https://github.com/OneStepAt4time/aegis/commit/3a0d54dcb45e6cf9fdc7d72f06262f608f000cdc)) -* **security:** read PPid from /proc/<pid>/status instead of stat in isAncestorPid ([#813](https://github.com/OneStepAt4time/aegis/issues/813)) ([7b49fed](https://github.com/OneStepAt4time/aegis/commit/7b49fed69ea3fd8b065ed5482e42a7b714ac0ff7)) -* **security:** redact session metadata from webhook payloads ([#827](https://github.com/OneStepAt4time/aegis/issues/827)) ([#859](https://github.com/OneStepAt4time/aegis/issues/859)) ([423d8fa](https://github.com/OneStepAt4time/aegis/commit/423d8fab65eaacfbc2c2b03d927cdec5571b6031)) -* **security:** remove dead BatchRateLimiter, fix requestKeyMap leak ([#583](https://github.com/OneStepAt4time/aegis/issues/583) follow-up) ([307ede5](https://github.com/OneStepAt4time/aegis/commit/307ede5602fb6b4b741e40f758e8b031409fd859)) -* **security:** render permission request content as inert text ([#1010](https://github.com/OneStepAt4time/aegis/issues/1010)) ([46c1514](https://github.com/OneStepAt4time/aegis/commit/46c15146d73cca9f7d915a401b43d5fe73f1ed8a)) -* **security:** replace execSync with execFileSync in killStalePortHolder ([#575](https://github.com/OneStepAt4time/aegis/issues/575)) ([5664e55](https://github.com/OneStepAt4time/aegis/commit/5664e553f81b4db3c0a946e16735ababbfb0e08c)) -* **security:** set key store file permissions to 0o600 ([#773](https://github.com/OneStepAt4time/aegis/issues/773)) ([9353244](https://github.com/OneStepAt4time/aegis/commit/9353244eb91ea8e6fd37d481a753c9b571eef89c)) -* **security:** use unpredictable tmp dir and restrictive permissions for hook settings ([#799](https://github.com/OneStepAt4time/aegis/issues/799)) ([147ccd6](https://github.com/OneStepAt4time/aegis/commit/147ccd67355b2004d90a9353c3fda15f9ed7cf2c)) -* **security:** validate template workDir at creation time ([#1125](https://github.com/OneStepAt4time/aegis/issues/1125)) ([#1161](https://github.com/OneStepAt4time/aegis/issues/1161)) ([a7b9c09](https://github.com/OneStepAt4time/aegis/commit/a7b9c09131b18e34808b5ad5e8b8e92049ae6371)) -* **security:** validate UUID format on hookSessionId header ([#580](https://github.com/OneStepAt4time/aegis/issues/580)) ([55a8c27](https://github.com/OneStepAt4time/aegis/commit/55a8c27227bfa41cae737d402096c7582805e346)) -* **session:** add resilient continuation pointer schema and TTL lifecycle ([#900](https://github.com/OneStepAt4time/aegis/issues/900)) ([#915](https://github.com/OneStepAt4time/aegis/issues/915)) ([02bb6d3](https://github.com/OneStepAt4time/aegis/commit/02bb6d3d3394a095fe724d13f4c6de59d50f6a84)) -* sessionCreated metric, xterm null guard, SSE reconnect onClose fix ([#925](https://github.com/OneStepAt4time/aegis/issues/925)) ([ecd737c](https://github.com/OneStepAt4time/aegis/commit/ecd737c399a978e384455564a110e10e4a4b21d7)) -* **session:** persist optional PRD on session creation and summary ([#1063](https://github.com/OneStepAt4time/aegis/issues/1063)) ([36c813a](https://github.com/OneStepAt4time/aegis/commit/36c813a7abbe1e30bac21af05475d269b4e4efe4)) -* shell injection vectors in tmux.ts — Issue [#358](https://github.com/OneStepAt4time/aegis/issues/358) ([237d7e5](https://github.com/OneStepAt4time/aegis/commit/237d7e5f4a2c073ba3e3f6f57a3d3bd7806121a8)) -* shell injection vectors in tmux.ts — Issue [#358](https://github.com/OneStepAt4time/aegis/issues/358) ([f70ae77](https://github.com/OneStepAt4time/aegis/commit/f70ae77928c413bbcfc0eba8dc739dc2ec5323b2)) -* **skill:** improve install flow and ClawHub release metadata ([#1066](https://github.com/OneStepAt4time/aegis/issues/1066)) ([14960e8](https://github.com/OneStepAt4time/aegis/commit/14960e880166405e77a3376fedbd769a83fbc5a4)) -* smarter paneDead check — only mark dead if session was actively working ([#1030](https://github.com/OneStepAt4time/aegis/issues/1030)) ([bb4ad3a](https://github.com/OneStepAt4time/aegis/commit/bb4ad3a9d72b048b3a0787524bd00fb91e6f7b67)) -* SSE token generation race condition — add mutex for per-key limit ([#414](https://github.com/OneStepAt4time/aegis/issues/414)) ([#487](https://github.com/OneStepAt4time/aegis/issues/487)) ([7139134](https://github.com/OneStepAt4time/aegis/commit/7139134344d6e1dd4bc108e81ab1f82fa4c4a80c)) -* SSEWriter res.end, JSONL drop logging, clock skew validation ([#825](https://github.com/OneStepAt4time/aegis/issues/825), [#823](https://github.com/OneStepAt4time/aegis/issues/823), [#828](https://github.com/OneStepAt4time/aegis/issues/828)) ([#867](https://github.com/OneStepAt4time/aegis/issues/867)) ([81816f9](https://github.com/OneStepAt4time/aegis/commit/81816f999358a7d69a91357a26fc4bb62c2a4801)) -* SSRF protection for webhook URLs and screenshot fetch — Issue [#346](https://github.com/OneStepAt4time/aegis/issues/346) ([877bb6e](https://github.com/OneStepAt4time/aegis/commit/877bb6ee47ce9a99f966b925494fe48f2b346f2f)) -* **ssrf:** block broadcast, multicast, documentation, and benchmarking IP ranges ([#775](https://github.com/OneStepAt4time/aegis/issues/775)) ([c2fcbc7](https://github.com/OneStepAt4time/aegis/commit/c2fcbc7a01826164dd4e6de96a17e8c826510707)) -* **stability:** add catch handlers for fire-and-forget PID lookup ([#574](https://github.com/OneStepAt4time/aegis/issues/574)) ([b3fd7fe](https://github.com/OneStepAt4time/aegis/commit/b3fd7feda1c50d14c5904cf4b822419fcabbe1e5)) -* **stability:** add graceful session cleanup on SIGTERM/SIGINT ([#569](https://github.com/OneStepAt4time/aegis/issues/569)) ([c06cef6](https://github.com/OneStepAt4time/aegis/commit/c06cef67bf33d90f7cda1d5bd27b37fc9c8cb23e)) -* standardize API error response envelope ([#399](https://github.com/OneStepAt4time/aegis/issues/399)) ([#953](https://github.com/OneStepAt4time/aegis/issues/953)) ([d8beb53](https://github.com/OneStepAt4time/aegis/commit/d8beb53f8eed3ca0f5e2e6b218c10580dbb90e7b)) -* strengthen capability handshake negotiation and feature gates ([#919](https://github.com/OneStepAt4time/aegis/issues/919)) ([151cf98](https://github.com/OneStepAt4time/aegis/commit/151cf9822957632625c537d56812e7ff7adfa598)) -* swarm parent matching via PID — Issue [#353](https://github.com/OneStepAt4time/aegis/issues/353) ([33de515](https://github.com/OneStepAt4time/aegis/commit/33de515fa50e5d8fd0eaa6ae612455eda62ec131)) -* swarm parent matching via PID (Issue [#353](https://github.com/OneStepAt4time/aegis/issues/353)) ([6a98760](https://github.com/OneStepAt4time/aegis/commit/6a987607eed2d41017e96dfad07fef0691389ef0)) -* systematic input validation at external boundaries (Cluster 2) ([#528](https://github.com/OneStepAt4time/aegis/issues/528)) ([d6192e7](https://github.com/OneStepAt4time/aegis/commit/d6192e781328a6819e3a17546f2939f5bf197fba)) -* tech debt sweep — type safety + build + empty catch blocks ([#515](https://github.com/OneStepAt4time/aegis/issues/515)-[#519](https://github.com/OneStepAt4time/aegis/issues/519), [#523](https://github.com/OneStepAt4time/aegis/issues/523), [#525](https://github.com/OneStepAt4time/aegis/issues/525)) ([#537](https://github.com/OneStepAt4time/aegis/issues/537)) ([c8c55f6](https://github.com/OneStepAt4time/aegis/commit/c8c55f60f77dd7d2e8fb8a23d8e1bd79fcb53f1e)) -* terminal parser edge cases — Issue [#362](https://github.com/OneStepAt4time/aegis/issues/362) ([87ac378](https://github.com/OneStepAt4time/aegis/commit/87ac3784a5d06b0721eadc986f7ac8973d0ac852)) -* terminal parser edge cases and false positives — Issue [#362](https://github.com/OneStepAt4time/aegis/issues/362) ([2859c86](https://github.com/OneStepAt4time/aegis/commit/2859c866df0ecd506450e5f85cad46fed8f2fd7f)) -* **terminal-parser:** make spinner search window configurable via named constant ([#758](https://github.com/OneStepAt4time/aegis/issues/758)) ([be7ecac](https://github.com/OneStepAt4time/aegis/commit/be7ecac8c6efd44621783da8b8f8ef0141e47632)) -* test mock assertion bugs and type errors ([44caf7f](https://github.com/OneStepAt4time/aegis/commit/44caf7fcaa576b59c01bf0d253c16d515f916fed)) -* tmux server crash recovery — health check, reconciliation, re-attach ([#602](https://github.com/OneStepAt4time/aegis/issues/602)) ([aeee38c](https://github.com/OneStepAt4time/aegis/commit/aeee38c04e0e2f1f1c36732d1a572e163550b467)), closes [#397](https://github.com/OneStepAt4time/aegis/issues/397) -* TmuxManager overhead and session creation latency — Issue [#363](https://github.com/OneStepAt4time/aegis/issues/363) ([093e25f](https://github.com/OneStepAt4time/aegis/commit/093e25fa1a5e9c9440d5d7f73a9d17b512b52401)) -* TmuxManager overhead and session creation latency — Issue [#363](https://github.com/OneStepAt4time/aegis/issues/363) ([1355d6d](https://github.com/OneStepAt4time/aegis/commit/1355d6d3386872a310136876b717552de9110740)) -* track untracked setTimeout timers in event bus and session discovery ([#834](https://github.com/OneStepAt4time/aegis/issues/834), [#835](https://github.com/OneStepAt4time/aegis/issues/835)) ([#848](https://github.com/OneStepAt4time/aegis/issues/848)) ([8ce0b9b](https://github.com/OneStepAt4time/aegis/commit/8ce0b9b57199dcb10402988d6d45fc9322f38c3f)) -* type-safe resource content access in MCP resource tests ([1a16a1d](https://github.com/OneStepAt4time/aegis/commit/1a16a1d9362ab295033b324c4f0013b4b9f68439)) -* **type-safety:** clean up globalEmitter and pending setImmediate timers on unsubscribe ([#769](https://github.com/OneStepAt4time/aegis/issues/769)) ([f2cd7e7](https://github.com/OneStepAt4time/aegis/commit/f2cd7e7b873523617f34b5890230ac1bc2c1281f)) -* **type-safety:** replace non-null assertion on getDeadLetterQueue with typeof guard ([#757](https://github.com/OneStepAt4time/aegis/issues/757)) ([57d7383](https://github.com/OneStepAt4time/aegis/commit/57d7383ad45af2d6c268b4894f377763a3cd7453)) -* **types:** remove double-cast escape hatches in production code ([#755](https://github.com/OneStepAt4time/aegis/issues/755)) ([d6f86ed](https://github.com/OneStepAt4time/aegis/commit/d6f86edfd4a65be605bbab6cc382668571d4c830)) -* **types:** replace StopHookPayload cast with Zod safeParse in hook.ts ([#1046](https://github.com/OneStepAt4time/aegis/issues/1046)) ([37dbce0](https://github.com/OneStepAt4time/aegis/commit/37dbce00e65d43b4f709e9746f609c963758144a)) -* unbounded maps and memory leaks — Issue [#357](https://github.com/OneStepAt4time/aegis/issues/357) ([b3983c3](https://github.com/OneStepAt4time/aegis/commit/b3983c30623d8ba16fd8b2b6bac5189023068926)) -* unbounded maps and memory leaks across modules — Issue [#357](https://github.com/OneStepAt4time/aegis/issues/357) ([411b138](https://github.com/OneStepAt4time/aegis/commit/411b13891cdfb7cf08c682782bb397941b07d55c)) -* update release-please manifest to reflect alpha version ([#1229](https://github.com/OneStepAt4time/aegis/issues/1229)) ([651ff23](https://github.com/OneStepAt4time/aegis/commit/651ff230dd0963d0bfcf1002f154acf58ae05cf6)), closes [#1210](https://github.com/OneStepAt4time/aegis/issues/1210) -* use bare on: in release.yml (GitHub Actions compatibility) ([#1327](https://github.com/OneStepAt4time/aegis/issues/1327)) ([45bd160](https://github.com/OneStepAt4time/aegis/commit/45bd1608b8e9e5d761b70b0fd72686bfe10c7f3c)) -* use correct aegis-bridge slug in release and skill metadata ([#806](https://github.com/OneStepAt4time/aegis/issues/806)) ([12d6640](https://github.com/OneStepAt4time/aegis/commit/12d6640f3bc1d8b2092ee98f468ac2996288f709)) -* use module-scoped nextKey counter in CreateSessionModal ([#639](https://github.com/OneStepAt4time/aegis/issues/639)) ([#928](https://github.com/OneStepAt4time/aegis/issues/928)) ([838cd03](https://github.com/OneStepAt4time/aegis/commit/838cd035972262b703b1cfea2745173b571ca3ba)) -* use npm pack to eliminate TOCTOU race in release workflow ([#649](https://github.com/OneStepAt4time/aegis/issues/649)) ([#944](https://github.com/OneStepAt4time/aegis/issues/944)) ([831fa01](https://github.com/OneStepAt4time/aegis/commit/831fa01e2f396a8730a9c38c52f2a9271fd8bbe7)) -* use plain v{version} tags instead of aegis-bridge-v{version} ([90a5e07](https://github.com/OneStepAt4time/aegis/commit/90a5e0722183af62981b8dc6bf5f97f35f4e6bce)) -* use short-lived SSE tokens ([5e32554](https://github.com/OneStepAt4time/aegis/commit/5e325540d7fbf0a490581eb6f030c56219db84ef)), closes [#297](https://github.com/OneStepAt4time/aegis/issues/297) -* use single-fd pattern in transcript reader to eliminate TOCTOU race ([#623](https://github.com/OneStepAt4time/aegis/issues/623)) ([895d248](https://github.com/OneStepAt4time/aegis/commit/895d248515e9cdba58f6c8df8667ce6736324625)) -* use timingSafeEqual for token comparison — Issue [#402](https://github.com/OneStepAt4time/aegis/issues/402) ([d401183](https://github.com/OneStepAt4time/aegis/commit/d4011837bbd24a4160d1fa5de3e81c4b05bb4d7a)) -* use timingSafeEqual for token comparison — Issue [#402](https://github.com/OneStepAt4time/aegis/issues/402) ([f1f26bd](https://github.com/OneStepAt4time/aegis/commit/f1f26bd4d19bd64f6be4709cc23851aac4ed09f0)) -* validate pipeline stage workDir for path traversal ([#631](https://github.com/OneStepAt4time/aegis/issues/631)) ([#906](https://github.com/OneStepAt4time/aegis/issues/906)) ([d715d80](https://github.com/OneStepAt4time/aegis/commit/d715d8060dc5b61994b34ee421bf129fc831d094)) -* validate port numbers in CLI with parseIntSafe — Issue [#359](https://github.com/OneStepAt4time/aegis/issues/359) ([4246d0d](https://github.com/OneStepAt4time/aegis/commit/4246d0dd7a53a73516087026a5b0c52c655ef986)) -* validate role query param in transcript endpoint ([#782](https://github.com/OneStepAt4time/aegis/issues/782)) ([eaf38ed](https://github.com/OneStepAt4time/aegis/commit/eaf38ed57ec4e65fdaed06db50a3c91f8d310717)) -* validate UUID format for session IDs and use exact workDir matching — Issue [#359](https://github.com/OneStepAt4time/aegis/issues/359) ([4e22b18](https://github.com/OneStepAt4time/aegis/commit/4e22b18fcd45261ccfc7e30df7bd249c6e95732e)) -* validateResponse throws on schema failure instead of returning unvalidated data ([#517](https://github.com/OneStepAt4time/aegis/issues/517)) ([#557](https://github.com/OneStepAt4time/aegis/issues/557)) ([df5f1cc](https://github.com/OneStepAt4time/aegis/commit/df5f1ccdf4c3e4b1533778dbb70c19708c4ddeab)) -* verify tmux window exists before returning idle session ([#636](https://github.com/OneStepAt4time/aegis/issues/636)) ([#818](https://github.com/OneStepAt4time/aegis/issues/818)) ([5a43fa1](https://github.com/OneStepAt4time/aegis/commit/5a43fa124905ba54843ea676f600b20f26fc9541)) -* WebSocket handshake auth + SSE subscription error handling ([#529](https://github.com/OneStepAt4time/aegis/issues/529)) ([f7088d2](https://github.com/OneStepAt4time/aegis/commit/f7088d20a711c4b3e24eabb320eb3354aff367f3)) -* **windows:** add platform utilities for process discovery and secure permissions ([#1073](https://github.com/OneStepAt4time/aegis/issues/1073)) ([7e10609](https://github.com/OneStepAt4time/aegis/commit/7e10609d504c413ae6b2d98a13a9053a8ead9940)) -* **windows:** normalize Claude project hash across unix/windows paths ([#1061](https://github.com/OneStepAt4time/aegis/issues/1061)) ([6735636](https://github.com/OneStepAt4time/aegis/commit/6735636fd0880d953e33ea356bb75d2a79ac3b26)) -* **windows:** resolve core blockers for env injection hooks and shutdown ([#1076](https://github.com/OneStepAt4time/aegis/issues/1076)) ([c36581f](https://github.com/OneStepAt4time/aegis/commit/c36581fcd36c688ad01ac0bef5ee884c9fd14a05)) -* **windows:** stabilize tmux/psmux parity for session lifecycle ([#1139](https://github.com/OneStepAt4time/aegis/issues/1139)) ([1bc8836](https://github.com/OneStepAt4time/aegis/commit/1bc88367deb6d040a7da86d284978f1a56f674df)) -* wrap SSE mutex await in try/finally to prevent deadlock ([#509](https://github.com/OneStepAt4time/aegis/issues/509)) ([#531](https://github.com/OneStepAt4time/aegis/issues/531)) ([1d624a0](https://github.com/OneStepAt4time/aegis/commit/1d624a0a32b72a53e58ed8bcc0aef83e2e5d22d9)) -* wrap SSE subscription in try-catch with auto-reconnect ([#721](https://github.com/OneStepAt4time/aegis/issues/721)) ([da80375](https://github.com/OneStepAt4time/aegis/commit/da80375fd8aeece353171d71dcb92494085fb1e1)) -* WS terminal security hardening ([9a49867](https://github.com/OneStepAt4time/aegis/commit/9a49867cde85f7e7a4ec675e6500f20cca4d8fb1)) - - -### Performance Improvements - -* adapt pipeline polling cadence based on SSE health ([#1014](https://github.com/OneStepAt4time/aegis/issues/1014)) ([0dc5acc](https://github.com/OneStepAt4time/aegis/commit/0dc5acc56199d4c9859dba376d6b7e259aa0c1f1)) -* **dashboard:** parallelize useSessionPolling API calls ([#1180](https://github.com/OneStepAt4time/aegis/issues/1180)) ([d67db68](https://github.com/OneStepAt4time/aegis/commit/d67db68049bcec96719e490d05638ec6eebfc10d)) -* **dashboard:** replace status-count N+1 calls with aggregated stats ([#1142](https://github.com/OneStepAt4time/aegis/issues/1142)) ([dc4acef](https://github.com/OneStepAt4time/aegis/commit/dc4acef3997d29780863dec3a949e52e3a97e4a3)) -* **dashboard:** virtualize transcript viewer render window ([#1047](https://github.com/OneStepAt4time/aegis/issues/1047)) ([837ab33](https://github.com/OneStepAt4time/aegis/commit/837ab332a65f1778be7a2523bbd0c89dad9f51db)) -* memoize SessionTable row view models ([#1015](https://github.com/OneStepAt4time/aegis/issues/1015)) ([3849b93](https://github.com/OneStepAt4time/aegis/commit/3849b932b45cbef8fe8466203bd3050a4a0b2bd8)) -* optimize LiveTerminal pane rendering and TranscriptViewer key tracking ([#933](https://github.com/OneStepAt4time/aegis/issues/933)) ([4d95e32](https://github.com/OneStepAt4time/aegis/commit/4d95e3242886b1e42577171450e8c75ab8447aa1)) -* optimize stall detection, session listing, and transcript discovery ([#932](https://github.com/OneStepAt4time/aegis/issues/932)) ([2330a71](https://github.com/OneStepAt4time/aegis/commit/2330a7149d3ed4f462c3e5da78a7cd0f75ec1842)) -* replace event buffer splice with circular queue ([#904](https://github.com/OneStepAt4time/aegis/issues/904)) ([548ec59](https://github.com/OneStepAt4time/aegis/commit/548ec59beb407afd30f3ee63ffa542c1cfd26515)) -* replace O(n) shift() with O(1) index-based pruning in IP rate limiter ([#787](https://github.com/OneStepAt4time/aegis/issues/787)) ([58a8ced](https://github.com/OneStepAt4time/aegis/commit/58a8ced1700f4b7ba29689ce0eceff4cb816111f)) -* replace sync pid file read with async I/O ([#1004](https://github.com/OneStepAt4time/aegis/issues/1004)) ([b1ac7b9](https://github.com/OneStepAt4time/aegis/commit/b1ac7b9b5e5ba1687dd47b3699285421a0effa64)) -* stream-aggregate latency in getGlobalMetrics instead of copying all samples ([#785](https://github.com/OneStepAt4time/aegis/issues/785)) ([4773b49](https://github.com/OneStepAt4time/aegis/commit/4773b492dad067beb044ff3bb62cdcdf70ff3504)) - - -### Reverts - -* undo version bump and watermark fix (npm token issue) ([4ee8f5f](https://github.com/OneStepAt4time/aegis/commit/4ee8f5f706960cce485546684b6909b3946a6b1f)) - ## [0.3.2-alpha](https://github.com/OneStepAt4time/aegis/compare/v0.3.1-alpha...v0.3.2-alpha) (2026-04-08) diff --git a/package-lock.json b/package-lock.json index 2653ad7d..0b39da52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aegis-bridge", - "version": "0.4.0-alpha", + "version": "0.3.2-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aegis-bridge", - "version": "0.4.0-alpha", + "version": "0.3.2-alpha", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index fe65edff..109e5f60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aegis-bridge", - "version": "0.4.0-alpha", + "version": "0.3.2-alpha", "type": "module", "description": "Orchestrate Claude Code sessions via API. Create, brief, monitor, refine, ship.", "main": "dist/server.js", From dbe7420f6a1c48be4c2fad01334abbf4b07826e3 Mon Sep 17 00:00:00 2001 From: OneStepAt4time Date: Sat, 11 Apr 2026 08:36:05 +0200 Subject: [PATCH 2/8] Revert "chore(release): promote develop to main after remediation wave (#1629)" This reverts commit 2c40503fbb4a069d19e9efd23d1ed74d44ceab21. --- .github/workflows/codeql.yml | 4 +- .github/workflows/release.yml | 1 - .release-please-manifest.json | 2 +- CHANGELOG.md | 345 +++ CLAUDE.md | 1 + README.md | 38 +- dashboard/src/App.tsx | 14 +- dashboard/src/store/useAuthStore.ts | 2 +- docs/adr/0017-opentelemetry-tracing.md | 104 - docs/api-reference.md | 16 - docs/architecture.md | 42 +- openapi.yaml | 72 - package-lock.json | 1940 +---------------- package.json | 9 +- src/__tests__/alerting.test.ts | 277 --- src/__tests__/audit.test.ts | 183 -- src/__tests__/auth-key-rotation-1403.test.ts | 145 -- src/__tests__/auth-rate-limiter.test.ts | 61 - .../auth-verify-endpoint-1555.test.ts | 65 +- src/__tests__/cc-version-check-564.test.ts | 6 +- .../claude-command-validation.test.ts | 126 -- src/__tests__/config.test.ts | 88 +- src/__tests__/consensus-734.test.ts | 25 + src/__tests__/container.test.ts | 124 -- src/__tests__/hook-paths-909.test.ts | 62 +- src/__tests__/input-validation.test.ts | 16 +- src/__tests__/jsonl-watcher.test.ts | 113 - src/__tests__/mcp-server.test.ts | 173 -- src/__tests__/metrics-auth-1557.test.ts | 74 - src/__tests__/metrics.test.ts | 37 +- src/__tests__/model-router-743.test.ts | 167 ++ .../path-traversal-workdir-435.test.ts | 23 +- .../permission-evaluator-742.test.ts | 2 +- ...permission-evaluator-coverage-1305.test.ts | 18 +- .../permission-evaluator-symlink-1402.test.ts | 129 -- src/__tests__/pipeline.test.ts | 316 --- src/__tests__/security-629-630-634.test.ts | 61 - src/__tests__/server-core-coverage.test.ts | 569 ----- src/__tests__/stall-detection.test.ts | 38 - src/__tests__/startup.test.ts | 45 - .../tmux-queue-recovery-1615.test.ts | 124 -- src/__tests__/tracing.test.ts | 181 -- .../url-secret-redaction-1393.test.ts | 73 - src/alerting.ts | 214 -- src/audit.ts | 318 --- src/auth.ts | 390 +++- src/config.ts | 127 +- src/container.ts | 216 -- src/diagnostics.ts | 1 - src/hook.ts | 194 +- src/hooks.ts | 18 +- src/jsonl-watcher.ts | 58 +- src/logger.ts | 4 - src/mcp-server.ts | 197 +- src/metrics.ts | 37 +- src/model-router.ts | 180 ++ src/monitor.ts | 30 - .../evaluator.ts => permission-evaluator.ts} | 64 +- src/permission-routes.ts | 10 +- src/pipeline.ts | 46 +- src/server.ts | 590 ++--- src/services/auth/AuthManager.ts | 421 ---- src/services/auth/RateLimiter.ts | 108 - src/services/auth/index.ts | 3 - src/services/auth/types.ts | 19 - src/services/permission/index.ts | 6 - src/services/permission/types.ts | 11 - src/session.ts | 9 +- src/startup.ts | 6 +- src/tmux.ts | 17 +- src/tracing.ts | 276 --- src/validation.ts | 111 +- src/verification.ts | 14 +- src/ws-terminal.ts | 2 +- vitest.config.ts | 3 + 75 files changed, 1708 insertions(+), 7903 deletions(-) delete mode 100644 docs/adr/0017-opentelemetry-tracing.md delete mode 100644 src/__tests__/alerting.test.ts delete mode 100644 src/__tests__/audit.test.ts delete mode 100644 src/__tests__/auth-key-rotation-1403.test.ts delete mode 100644 src/__tests__/auth-rate-limiter.test.ts delete mode 100644 src/__tests__/claude-command-validation.test.ts create mode 100644 src/__tests__/consensus-734.test.ts delete mode 100644 src/__tests__/container.test.ts delete mode 100644 src/__tests__/metrics-auth-1557.test.ts create mode 100644 src/__tests__/model-router-743.test.ts delete mode 100644 src/__tests__/permission-evaluator-symlink-1402.test.ts delete mode 100644 src/__tests__/server-core-coverage.test.ts delete mode 100644 src/__tests__/startup.test.ts delete mode 100644 src/__tests__/tmux-queue-recovery-1615.test.ts delete mode 100644 src/__tests__/tracing.test.ts delete mode 100644 src/__tests__/url-secret-redaction-1393.test.ts delete mode 100644 src/alerting.ts delete mode 100644 src/audit.ts delete mode 100644 src/container.ts create mode 100644 src/model-router.ts rename src/{services/permission/evaluator.ts => permission-evaluator.ts} (59%) delete mode 100644 src/services/auth/AuthManager.ts delete mode 100644 src/services/auth/RateLimiter.ts delete mode 100644 src/services/auth/index.ts delete mode 100644 src/services/auth/types.ts delete mode 100644 src/services/permission/index.ts delete mode 100644 src/services/permission/types.ts delete mode 100644 src/tracing.ts diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bcedb732..877fc225 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: CodeQL on: push: - branches: [main, develop] + branches: [main] pull_request: - branches: [main, develop] + branches: [main] schedule: - cron: "17 3 * * 1" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75beca90..ec20c59b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,7 +134,6 @@ jobs: with: node-version: '22' - name: ClawHub Login - if: ${{ secrets.CLAWHUB_TOKEN != '' }} continue-on-error: true run: npx clawhub@latest login --token ${{ secrets.CLAWHUB_TOKEN }} - name: Publish to ClawHub diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1c7a5304..88486080 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.3.2-alpha" + ".": "0.4.0-alpha" } diff --git a/CHANGELOG.md b/CHANGELOG.md index adf388bc..55338998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,351 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0-alpha](https://github.com/OneStepAt4time/aegis/compare/v0.3.2-alpha...v0.4.0-alpha) (2026-04-09) + + +### Features + +* **#599:** expose pendingQuestion in get_status and REST endpoint ([#600](https://github.com/OneStepAt4time/aegis/issues/600)) ([38fc42f](https://github.com/OneStepAt4time/aegis/commit/38fc42f7c99efc67a9b6d3636647682f9a593c39)) +* add batch session creation to CreateSessionModal ([#312](https://github.com/OneStepAt4time/aegis/issues/312)) ([a40fe97](https://github.com/OneStepAt4time/aegis/commit/a40fe9789392aa55651d932cf08cf4f4ad8a989e)) +* add batch session creation UI ([#312](https://github.com/OneStepAt4time/aegis/issues/312)) ([206db55](https://github.com/OneStepAt4time/aegis/commit/206db55bc1ef6d11fe146a3b992cd8af56ee189b)) +* add cursor-based replay contract for transcript endpoint ([#897](https://github.com/OneStepAt4time/aegis/issues/897)) ([d43bf23](https://github.com/OneStepAt4time/aegis/commit/d43bf23b3b282d70fca83276c588155bc5dfc0fb)) +* add docs-sync skill for TSDoc coverage audit and README auto-update ([#1044](https://github.com/OneStepAt4time/aegis/issues/1044)) ([c59826e](https://github.com/OneStepAt4time/aegis/commit/c59826e479c05d698dedba5e65e2b23e4af72183)) +* add MCP prompts — implement_issue, review_pr, debug_session — Issue [#443](https://github.com/OneStepAt4time/aegis/issues/443) ([b425f33](https://github.com/OneStepAt4time/aegis/commit/b425f33608578814058a4cae4b12fc30214e7afb)) +* add P0+P1+P2 MCP tools — kill, approve, reject, health, escape, interrupt, pane, metrics, summary, bash, command, latency, batch, pipelines, swarm — Issue [#441](https://github.com/OneStepAt4time/aegis/issues/441) ([247d936](https://github.com/OneStepAt4time/aegis/commit/247d93634650635edb55cd0644fa87290933c57c)) +* add pipeline management page ([1e905f9](https://github.com/OneStepAt4time/aegis/commit/1e905f9884394b415a5e4698a241a7d0d6648273)) +* add shared SSRF validation utility — Issue [#346](https://github.com/OneStepAt4time/aegis/issues/346) ([18b1dba](https://github.com/OneStepAt4time/aegis/commit/18b1dbaaa4b6ba0e87e4015422b9a604dece7665)) +* add tool registry for CC tool introspection ([#704](https://github.com/OneStepAt4time/aegis/issues/704)) ([#940](https://github.com/OneStepAt4time/aegis/issues/940)) ([a038ad8](https://github.com/OneStepAt4time/aegis/commit/a038ad896cb2912cf820b5bded056b13b8e0888d)) +* add webhook endpoint Zod schema — Issue [#346](https://github.com/OneStepAt4time/aegis/issues/346) ([629fdc9](https://github.com/OneStepAt4time/aegis/commit/629fdc9802d0232f44ca0afb1eb92c005cb80c4c)) +* add worktree-aware continuation metadata lookup ([#898](https://github.com/OneStepAt4time/aegis/issues/898)) ([215cc8c](https://github.com/OneStepAt4time/aegis/commit/215cc8c2e4dc2bfec14cebe50c7d0f129955862f)) +* add Zod validation schemas for API routes — Issue [#359](https://github.com/OneStepAt4time/aegis/issues/359) ([7a2ab85](https://github.com/OneStepAt4time/aegis/commit/7a2ab85fb64510fe675d67af60acad2d46b78391)) +* automated release pipeline (npm publish + GitHub Releases) ([047c6af](https://github.com/OneStepAt4time/aegis/commit/047c6afe55a409c55c39dead5906519f472f7c74)) +* automated release pipeline (npm publish + GitHub Releases) — Issue [#365](https://github.com/OneStepAt4time/aegis/issues/365) ([7fd11fe](https://github.com/OneStepAt4time/aegis/commit/7fd11fe6352253e6349b6acbdf810cbf506122f9)) +* capability handshake contract for Aegis and Claude Code ([#885](https://github.com/OneStepAt4time/aegis/issues/885)) ([e46d0eb](https://github.com/OneStepAt4time/aegis/commit/e46d0eb71510a02731b68058167ba3a923c31b61)) +* **ci:** add automatic issue labeling action (P2-P4) ([#1149](https://github.com/OneStepAt4time/aegis/issues/1149)) ([3ac98be](https://github.com/OneStepAt4time/aegis/commit/3ac98be60354ca25d289cd789460c2cdd97d490c)) +* **ci:** add Discord notification workflow for stars, issues, PRs, comments ([#1163](https://github.com/OneStepAt4time/aegis/issues/1163)) ([debe7f8](https://github.com/OneStepAt4time/aegis/commit/debe7f82c039d56cef11c3af7043a44722a95cd3)) +* commits no longer auto-bump minor. Minor/major bumps ([eaf38ed](https://github.com/OneStepAt4time/aegis/commit/eaf38ed57ec4e65fdaed06db50a3c91f8d310717)) +* commits no longer auto-bump minor. Minor/major bumps ([e66f477](https://github.com/OneStepAt4time/aegis/commit/e66f4772c6a935ec61a0a5ecb5fd51f0808b3079)) +* **dashboard:** add ALPHA badge to version display ([#1212](https://github.com/OneStepAt4time/aegis/issues/1212)) ([3fab456](https://github.com/OneStepAt4time/aegis/commit/3fab4564f5c426bdcb3e2f124fcee00cebcaa0a8)) +* dynamic permission policy API ([#700](https://github.com/OneStepAt4time/aegis/issues/700)) ([f3d4a90](https://github.com/OneStepAt4time/aegis/commit/f3d4a905dee8b164aabc42da62bf797b4678e23b)) +* dynamic permission policy API and sub-agent spawning API ([#700](https://github.com/OneStepAt4time/aegis/issues/700) [#702](https://github.com/OneStepAt4time/aegis/issues/702)) ([#943](https://github.com/OneStepAt4time/aegis/issues/943)) ([f3d4a90](https://github.com/OneStepAt4time/aegis/commit/f3d4a905dee8b164aabc42da62bf797b4678e23b)) +* enhance global metrics dashboard with 15+ metric cards and delivery rate sparklines ([#1043](https://github.com/OneStepAt4time/aegis/issues/1043)) ([cb57545](https://github.com/OneStepAt4time/aegis/commit/cb5754528ff4e76a067dfd3d215a8e1247434c5c)) +* headless question answering via PreToolUse hook ([54cf4b0](https://github.com/OneStepAt4time/aegis/commit/54cf4b01fcaec9df0753da491252aa6f01f0a263)) +* mark v2.17.4 as alpha release ([#1213](https://github.com/OneStepAt4time/aegis/issues/1213)) ([066f884](https://github.com/OneStepAt4time/aegis/commit/066f884e28ea2480c4e2fa3a2c0f55f1052ed389)) +* MCP Prompts — workflow templates for common tasks — Issue [#443](https://github.com/OneStepAt4time/aegis/issues/443) ([46f1c67](https://github.com/OneStepAt4time/aegis/commit/46f1c678a0fefde22a590ead195712b158423082)) +* MCP Resources — 4 resources for session data — Issue [#442](https://github.com/OneStepAt4time/aegis/issues/442) ([217f67f](https://github.com/OneStepAt4time/aegis/commit/217f67f95842f55b6f6f05c6adb3cf7b904302c0)) +* MCP Resources — expose session data as readable resources — Issue [#442](https://github.com/OneStepAt4time/aegis/issues/442) ([3d42e35](https://github.com/OneStepAt4time/aegis/commit/3d42e354dcac9cc8cf44db86cdcc4f124eb2bf92)) +* MCP tool completeness — expose all REST endpoints as MCP tools — Issue [#441](https://github.com/OneStepAt4time/aegis/issues/441) ([c3ab0a7](https://github.com/OneStepAt4time/aegis/commit/c3ab0a77f867ed2b5ad984b99902a0df485726bb)) +* move OpenClaw skill into repository for tracked versioning ([#543](https://github.com/OneStepAt4time/aegis/issues/543)) ([df7bf7b](https://github.com/OneStepAt4time/aegis/commit/df7bf7be05c9bfbc204e195a31b9be03d46a88a5)) +* per-session token usage tracking and cost estimation ([#1054](https://github.com/OneStepAt4time/aegis/issues/1054)) ([3d27c63](https://github.com/OneStepAt4time/aegis/commit/3d27c63c075469a52f5f0232ec0ce106d7da829c)), closes [#488](https://github.com/OneStepAt4time/aegis/issues/488) +* register 6 additional CC hook event types ([#753](https://github.com/OneStepAt4time/aegis/issues/753)) ([#964](https://github.com/OneStepAt4time/aegis/issues/964)) ([903ea9d](https://github.com/OneStepAt4time/aegis/commit/903ea9db9d7752988fc02425c542f103b8104b88)) +* register additional CC hook types ([#571](https://github.com/OneStepAt4time/aegis/issues/571)) ([#945](https://github.com/OneStepAt4time/aegis/issues/945)) ([8cd5ab8](https://github.com/OneStepAt4time/aegis/commit/8cd5ab8026acae74d8daebd33b14e082d252fd3a)) +* reset version to 0.1.0-alpha ([#1221](https://github.com/OneStepAt4time/aegis/issues/1221)) ([f1034f8](https://github.com/OneStepAt4time/aegis/commit/f1034f8b8967225b1b5686afb48f2ca7b124e0fe)), closes [#1210](https://github.com/OneStepAt4time/aegis/issues/1210) +* **resilience:** add structured error categorization and retry logic ([#701](https://github.com/OneStepAt4time/aegis/issues/701)) ([#729](https://github.com/OneStepAt4time/aegis/issues/729)) ([4b56b29](https://github.com/OneStepAt4time/aegis/commit/4b56b29ea4ecb67c8156c646bd9663cc35ca92bb)) +* REST API routes for memory bridge + session injection ([#783](https://github.com/OneStepAt4time/aegis/issues/783)) ([#957](https://github.com/OneStepAt4time/aegis/issues/957)) ([0506fd9](https://github.com/OneStepAt4time/aegis/commit/0506fd9e4664f8001bc17a077e5624f14728baed)) +* session forking — create new from parent context ([#995](https://github.com/OneStepAt4time/aegis/issues/995)) ([ebc02da](https://github.com/OneStepAt4time/aegis/commit/ebc02da9c8c3ead101876014afb4b01d8fb5a44d)) +* session memory bridge core module ([#783](https://github.com/OneStepAt4time/aegis/issues/783)) ([#951](https://github.com/OneStepAt4time/aegis/issues/951)) ([cfac2a0](https://github.com/OneStepAt4time/aegis/commit/cfac2a0416e60db109e5b9decd4dc54ca853431f)) +* session templates — save, reuse, quick-start ([#996](https://github.com/OneStepAt4time/aegis/issues/996)) ([1809971](https://github.com/OneStepAt4time/aegis/commit/1809971ecbd38cf3942126ece7ea11015ea53582)) +* terminal passthrough — merge transcript and terminal into xterm view ([#997](https://github.com/OneStepAt4time/aegis/issues/997)) ([4f519f1](https://github.com/OneStepAt4time/aegis/commit/4f519f1b3fb1389816e78c8bdefcb12d1805cb15)) +* user-controlled session TTL with presets and custom duration ([#994](https://github.com/OneStepAt4time/aegis/issues/994)) ([5d7034e](https://github.com/OneStepAt4time/aegis/commit/5d7034e56a5223a7b8e76e3dfca31d12ba08c78a)) +* validate webhook URLs in fromEnv() with Zod + SSRF checks — Issue [#346](https://github.com/OneStepAt4time/aegis/issues/346) ([f5ad1dc](https://github.com/OneStepAt4time/aegis/commit/f5ad1dc88a49b3e1d61dd7f201ad2dc9814b388d)) +* Verification Protocol — POST /v1/sessions/:id/verify ([#740](https://github.com/OneStepAt4time/aegis/issues/740)) ([#982](https://github.com/OneStepAt4time/aegis/issues/982)) ([fb2d340](https://github.com/OneStepAt4time/aegis/commit/fb2d3407be99a10a6a103953e5444f164016605c)) +* visual theme refresh — professional dark mode ([#999](https://github.com/OneStepAt4time/aegis/issues/999)) ([321aff9](https://github.com/OneStepAt4time/aegis/commit/321aff91578e77005ed83edb6cd01f351e5c546e)) + + +### Bug Fixes + +* **#582:** redact sensitive webhook headers from error logs ([9f9f614](https://github.com/OneStepAt4time/aegis/commit/9f9f614cfff1366b328ce7a9e804b55df7683951)), closes [#582](https://github.com/OneStepAt4time/aegis/issues/582) +* **#586:** clean up old EventSource on reconnect to prevent listener leak ([#609](https://github.com/OneStepAt4time/aegis/issues/609)) ([5780e64](https://github.com/OneStepAt4time/aegis/commit/5780e64e1e1eaea61b3193f006deb87cfe4fdd43)), closes [#586](https://github.com/OneStepAt4time/aegis/issues/586) +* **#587:** add error handling to Layout SSE subscription ([#608](https://github.com/OneStepAt4time/aegis/issues/608)) ([00d4933](https://github.com/OneStepAt4time/aegis/commit/00d493378ebcec0c507092f445342f1855c006d2)), closes [#587](https://github.com/OneStepAt4time/aegis/issues/587) +* **#588:** aggregate Promise.allSettled errors in webhook fire() ([#610](https://github.com/OneStepAt4time/aegis/issues/610)) ([bfc80c0](https://github.com/OneStepAt4time/aegis/commit/bfc80c0604bfa7b942b5b7dbe5cd345806e69114)), closes [#588](https://github.com/OneStepAt4time/aegis/issues/588) +* **#607:** reuse idle sessions for same workDir instead of creating duplicates ([dafa22c](https://github.com/OneStepAt4time/aegis/commit/dafa22c613f623b5a562244256829e1fa26c3826)), closes [#607](https://github.com/OneStepAt4time/aegis/issues/607) +* add 404 catch-all route, validate trustProxy for rate limiting ([#892](https://github.com/OneStepAt4time/aegis/issues/892)) ([1d0766a](https://github.com/OneStepAt4time/aegis/commit/1d0766ab88505d5e32d1f66632e50db7c0b679da)) +* add 5s grace period before marking session dead after pane exit ([#1026](https://github.com/OneStepAt4time/aegis/issues/1026)) ([#1034](https://github.com/OneStepAt4time/aegis/issues/1034)) ([d70b577](https://github.com/OneStepAt4time/aegis/commit/d70b5774340fb8270a74a89927f48e929ecbe884)) +* add auth key management page ([#966](https://github.com/OneStepAt4time/aegis/issues/966)) ([4f989a4](https://github.com/OneStepAt4time/aegis/commit/4f989a4d18e183ba4b87f66c9fa2ebcf88176b9a)) +* add catch handlers to fire-and-forget monitor operations ([#404](https://github.com/OneStepAt4time/aegis/issues/404)) ([#497](https://github.com/OneStepAt4time/aegis/issues/497)) ([3b1c36e](https://github.com/OneStepAt4time/aegis/commit/3b1c36e23dc96b57f6a6916d93c1577de144df59)) +* add CC version validation on session creation ([#564](https://github.com/OneStepAt4time/aegis/issues/564)) ([#927](https://github.com/OneStepAt4time/aegis/issues/927)) ([89a5ba5](https://github.com/OneStepAt4time/aegis/commit/89a5ba5bacfc715b13430f3c6a26bbe51d0495d6)) +* add forceConsistentCasingInFileNames and noFallthroughCasesInSwitch to root tsconfig ([#800](https://github.com/OneStepAt4time/aegis/issues/800)) ([9a3eff2](https://github.com/OneStepAt4time/aegis/commit/9a3eff2f2bbafa82ad8e425a2956b1a683deb8ba)) +* add latency metrics visualization to dashboard ([#990](https://github.com/OneStepAt4time/aegis/issues/990)) ([aaac8a0](https://github.com/OneStepAt4time/aegis/commit/aaac8a0c88df3742ddeadd2edec670114a7aadf2)) +* add missing UIStateEnum states, apply DOMPurify to all entry types ([#871](https://github.com/OneStepAt4time/aegis/issues/871)) ([9deee1b](https://github.com/OneStepAt4time/aegis/commit/9deee1bdcf306fec9bca168445147ea0b80cd160)) +* add NaN guard for ANSWER_TIMEOUT_MS parsing ([#637](https://github.com/OneStepAt4time/aegis/issues/637)) ([#833](https://github.com/OneStepAt4time/aegis/issues/833)) ([22f4656](https://github.com/OneStepAt4time/aegis/commit/22f465625b53b16130be3d1417e8c310a8ddc6c8)) +* add NaN/isFinite guards on config env var parsing — Issue [#359](https://github.com/OneStepAt4time/aegis/issues/359) ([9413006](https://github.com/OneStepAt4time/aegis/commit/94130069cd9813ffab9cadc841b0311c6079afcd)) +* add periodic cleanup for consensusRequests Map to prevent memory leak ([#1191](https://github.com/OneStepAt4time/aegis/issues/1191)) ([75c201f](https://github.com/OneStepAt4time/aegis/commit/75c201f0083f3bafd78b83e3f8a5f57aa30b9989)) +* add rate limiting to master token auth endpoint ([#924](https://github.com/OneStepAt4time/aegis/issues/924)) ([716f36a](https://github.com/OneStepAt4time/aegis/commit/716f36aeee810a68352d405adf5e9058edeeebaf)) +* add retry with jitter for pipeline stages ([#893](https://github.com/OneStepAt4time/aegis/issues/893)) ([067f1a1](https://github.com/OneStepAt4time/aegis/commit/067f1a187a6bc88c62d526a5499e1f48d38b4583)) +* add runtime type guards to ActivityStream describeEvent ([#423](https://github.com/OneStepAt4time/aegis/issues/423)) ([#493](https://github.com/OneStepAt4time/aegis/issues/493)) ([87999d3](https://github.com/OneStepAt4time/aegis/commit/87999d3c66d3d89f832f15ef3e5077ecc40a54fd)) +* add session filtering and bulk actions to dashboard ([#968](https://github.com/OneStepAt4time/aegis/issues/968)) ([08997b6](https://github.com/OneStepAt4time/aegis/commit/08997b6a961e9163f136f3900a6f0a4b8b3734fc)) +* add session screenshot capture preview ([#970](https://github.com/OneStepAt4time/aegis/issues/970)) ([b8336f5](https://github.com/OneStepAt4time/aegis/commit/b8336f5234ca386c67af9b081b1fcefd3a594db5)) +* add session slash and bash quick actions ([#967](https://github.com/OneStepAt4time/aegis/issues/967)) ([8404887](https://github.com/OneStepAt4time/aegis/commit/8404887158eea32aeaad1b0ed2ce712eb94d8874)) +* add session summary card to dashboard ([#989](https://github.com/OneStepAt4time/aegis/issues/989)) ([5ce41d4](https://github.com/OneStepAt4time/aegis/commit/5ce41d4ebae43f9fe301e03337399607861a864e)) +* add SSE connection limits ([ccd6b7c](https://github.com/OneStepAt4time/aegis/commit/ccd6b7cb6ec0c8e53fc0cc678a179b91be52b2b0)), closes [#300](https://github.com/OneStepAt4time/aegis/issues/300) +* add TTL cleanup for Telegram forum topics ([#1022](https://github.com/OneStepAt4time/aegis/issues/1022)) ([9ea4095](https://github.com/OneStepAt4time/aegis/commit/9ea409531ffdbf8b37afadc422dc8a56246e9432)) +* add v2.x to SECURITY.md supported versions ([#548](https://github.com/OneStepAt4time/aegis/issues/548)) ([37d9fe2](https://github.com/OneStepAt4time/aegis/commit/37d9fe211ee267f920b231e209d98d1a468afc35)) +* add Zod safeParse validation to all API routes — Issue [#359](https://github.com/OneStepAt4time/aegis/issues/359) ([004ebb5](https://github.com/OneStepAt4time/aegis/commit/004ebb5787c6e1f9bbff68cc95ffe30b76c42b5d)) +* add Zod schemas for getSessionMessages and getMetrics ([#407](https://github.com/OneStepAt4time/aegis/issues/407)) ([#501](https://github.com/OneStepAt4time/aegis/issues/501)) ([5272cf5](https://github.com/OneStepAt4time/aegis/commit/5272cf5ce5572e8e40667103342c2055be36cb65)) +* add Zod validation at all JSON.parse boundaries ([#410](https://github.com/OneStepAt4time/aegis/issues/410)) ([#492](https://github.com/OneStepAt4time/aegis/issues/492)) ([8197c57](https://github.com/OneStepAt4time/aegis/commit/8197c577e939fee8511dd4cd9a1b820462758ff5)) +* address dashboard accessibility defects ([#965](https://github.com/OneStepAt4time/aegis/issues/965)) ([5d53e35](https://github.com/OneStepAt4time/aegis/commit/5d53e35f8e3971de4f5aa51adbe399aa0faa0146)) +* address review issues in batch modal ([#312](https://github.com/OneStepAt4time/aegis/issues/312)) ([3f843ff](https://github.com/OneStepAt4time/aegis/commit/3f843ff161f1beb5d3d574aa3e98c4dad098c747)) +* **agent:** add consensus review request flow ([#1069](https://github.com/OneStepAt4time/aegis/issues/1069)) ([2cfad29](https://github.com/OneStepAt4time/aegis/commit/2cfad29554938383d84493308ffdf5910b788ce0)) +* align @types/node with minimum CI Node version (20) ([#793](https://github.com/OneStepAt4time/aegis/issues/793)) ([21d9e06](https://github.com/OneStepAt4time/aegis/commit/21d9e063e5803882b37a9513c112eb8f96fdd961)) +* **api:** align pagination response shape with frontend expectations ([#576](https://github.com/OneStepAt4time/aegis/issues/576)) ([b05654a](https://github.com/OneStepAt4time/aegis/commit/b05654a8974d39f6b7c4e61be0f84733bdc68532)) +* **api:** batch session ops -- stats endpoint, bulk delete, project filter ([#1056](https://github.com/OneStepAt4time/aegis/issues/1056)) ([72bc2e3](https://github.com/OneStepAt4time/aegis/commit/72bc2e3747452459dd21cc9b57170396a3b0a24d)) +* atomically check+create tmux window name ([#403](https://github.com/OneStepAt4time/aegis/issues/403)) ([#499](https://github.com/OneStepAt4time/aegis/issues/499)) ([5606a9b](https://github.com/OneStepAt4time/aegis/commit/5606a9b19daa759eef6d5dba42626573e1b87428)) +* auth bypass via broad path matching — Issue [#349](https://github.com/OneStepAt4time/aegis/issues/349) ([8bf8fde](https://github.com/OneStepAt4time/aegis/commit/8bf8fde4e07fd634c1ccdc2a141ef9b8531c07fc)) +* auth bypass via broad path matching in middleware — Issue [#349](https://github.com/OneStepAt4time/aegis/issues/349) ([a7c6146](https://github.com/OneStepAt4time/aegis/commit/a7c6146dbc15e802ce4ce55ee59d04bc0af8f403)) +* authentication on inbound Telegram messages — Issue [#348](https://github.com/OneStepAt4time/aegis/issues/348) ([8b2b41f](https://github.com/OneStepAt4time/aegis/commit/8b2b41f4bc6ad84278da6aed25547174b886aebc)) +* authentication on inbound Telegram messages — Issue [#348](https://github.com/OneStepAt4time/aegis/issues/348) ([43d37ab](https://github.com/OneStepAt4time/aegis/commit/43d37ab7685abf39b597ee3821680e47c37b6687)) +* avoid Set deletion during iteration in processedStopSignals ([#510](https://github.com/OneStepAt4time/aegis/issues/510)) ([#532](https://github.com/OneStepAt4time/aegis/issues/532)) ([de22f07](https://github.com/OneStepAt4time/aegis/commit/de22f0703db6fd0a54be28009a18ed20d448def3)) +* bound session event buffers and IP limiter maps ([#1021](https://github.com/OneStepAt4time/aegis/issues/1021)) ([82657d9](https://github.com/OneStepAt4time/aegis/commit/82657d92a363fd4e894538a85879f6e559b8f65a)) +* cache eviction, pipeline timer race, capturePane serialize ([#832](https://github.com/OneStepAt4time/aegis/issues/832), [#830](https://github.com/OneStepAt4time/aegis/issues/830), [#824](https://github.com/OneStepAt4time/aegis/issues/824)) ([#868](https://github.com/OneStepAt4time/aegis/issues/868)) ([1f458f3](https://github.com/OneStepAt4time/aegis/commit/1f458f36027950f1e931d6912832ce25a140cbd9)) +* **ci:** add explicit ClawHub login before skill publish ([#724](https://github.com/OneStepAt4time/aegis/issues/724)) ([#728](https://github.com/OneStepAt4time/aegis/issues/728)) ([bf9c714](https://github.com/OneStepAt4time/aegis/commit/bf9c714fb34cddcb50b070b6b4f1bb5db9f5460e)) +* **ci:** add lockfile-lint as devDependency ([#650](https://github.com/OneStepAt4time/aegis/issues/650)) ([e7cf146](https://github.com/OneStepAt4time/aegis/commit/e7cf146e7b14805b8197ef7d56b0f22771058fd3)) +* **ci:** add typecheck step to publish-npm job in release workflow ([#791](https://github.com/OneStepAt4time/aegis/issues/791)) ([9569ffa](https://github.com/OneStepAt4time/aegis/commit/9569ffa6eba20aebba349b9e31a750c1c3853771)) +* **ci:** install action dependencies in correct directory ([#1209](https://github.com/OneStepAt4time/aegis/issues/1209)) ([20205b4](https://github.com/OneStepAt4time/aegis/commit/20205b41b70844e0d5c466a83071373f662d4413)) +* **ci:** move audit step before build steps ([760dfbe](https://github.com/OneStepAt4time/aegis/commit/760dfbed2cc289d7eb33114426a33a9e671852da)) +* **ci:** pin clawhub to 0.9.0 in release workflow ([#651](https://github.com/OneStepAt4time/aegis/issues/651)) ([815af02](https://github.com/OneStepAt4time/aegis/commit/815af02fd94d98b9244144918148282978c39eea)) +* **ci:** remove master branch from CodeQL trigger ([#771](https://github.com/OneStepAt4time/aegis/issues/771)) ([a82109e](https://github.com/OneStepAt4time/aegis/commit/a82109e31b481de3947afd8ec06a0edd1c353b87)) +* **ci:** show star profile link and avatar in Discord notification ([#1165](https://github.com/OneStepAt4time/aegis/issues/1165)) ([b8103e6](https://github.com/OneStepAt4time/aegis/commit/b8103e646d45d9c00eee73dfdde1ce6a2ac63dae)) +* **ci:** use GITHUB_TOKEN for release-please instead of failing RELEASE_PAT ([91c3cb5](https://github.com/OneStepAt4time/aegis/commit/91c3cb56ed862c92d29a63d3c50fbc21e81e7e2c)) +* **ci:** use proper secrets syntax in release.yml if conditions ([#1351](https://github.com/OneStepAt4time/aegis/issues/1351)) ([9c52efe](https://github.com/OneStepAt4time/aegis/commit/9c52efe4fbdd139fa8692fba3cdef64dd32f2427)) +* **ci:** use RELEASE_PAT for release-please to trigger CI on PRs ([#601](https://github.com/OneStepAt4time/aegis/issues/601)) ([a86aaf1](https://github.com/OneStepAt4time/aegis/commit/a86aaf18411352f58baf8993da1c181522075ecc)) +* clamp WebSocket viewport dimensions to 1-1000 — Issue [#359](https://github.com/OneStepAt4time/aegis/issues/359) ([ffd5fa8](https://github.com/OneStepAt4time/aegis/commit/ffd5fa852c219a61a28c5ba7ee86ad01442ada5a)) +* clean up dashboard SSE subscriptions on unmount ([#1016](https://github.com/OneStepAt4time/aegis/issues/1016)) ([d7aa9c5](https://github.com/OneStepAt4time/aegis/commit/d7aa9c5b60c3ed72ce8fe251acd62c87ddc1e7a9)) +* clean up requestKeyMap entries after response to prevent memory leak ([#839](https://github.com/OneStepAt4time/aegis/issues/839)) ([#850](https://github.com/OneStepAt4time/aegis/issues/850)) ([30fe7b6](https://github.com/OneStepAt4time/aegis/commit/30fe7b69fc876afc49128ba5b0c7f4185338a45b)) +* clear all session-keyed tracking state on kill ([#1007](https://github.com/OneStepAt4time/aegis/issues/1007)) ([b0b1666](https://github.com/OneStepAt4time/aegis/commit/b0b16663c295ad24fc6a000fe5d03731d92f0473)) +* clear tracking maps on session kill to prevent memory leak ([#405](https://github.com/OneStepAt4time/aegis/issues/405)) ([#500](https://github.com/OneStepAt4time/aegis/issues/500)) ([15ec6c1](https://github.com/OneStepAt4time/aegis/commit/15ec6c1c4b0b476a8918ebe49b842256a9a46949)) +* close SSE connection on async component unmount ([#416](https://github.com/OneStepAt4time/aegis/issues/416)) ([#495](https://github.com/OneStepAt4time/aegis/issues/495)) ([3ef0ffc](https://github.com/OneStepAt4time/aegis/commit/3ef0ffced63b496a1084dc7a2c8599400eaa81f4)) +* close SSE connection on async component unmount ([#494](https://github.com/OneStepAt4time/aegis/issues/494)) ([cfdd783](https://github.com/OneStepAt4time/aegis/commit/cfdd783debc1222aed344586f50f7a03d358abce)) +* **cluster1:** version alignment + Zod 4 migration + Vitest 4 test fixes ([#526](https://github.com/OneStepAt4time/aegis/issues/526)) ([3c12f05](https://github.com/OneStepAt4time/aegis/commit/3c12f05500daca25650ede7cd4cbd8e10e935ec7)) +* command injection in hook.ts via TMUX_PANE — Issue [#347](https://github.com/OneStepAt4time/aegis/issues/347) ([2216973](https://github.com/OneStepAt4time/aegis/commit/22169736187ba181d13205186b2edd840032b121)) +* command injection in hook.ts via TMUX_PANE env var — Issue [#347](https://github.com/OneStepAt4time/aegis/issues/347) ([6bce734](https://github.com/OneStepAt4time/aegis/commit/6bce7349a50f97cffb77f8e577669aeba0f9dcba)) +* complete cursor replay contract for events and metadata ([#922](https://github.com/OneStepAt4time/aegis/issues/922)) ([1931ab0](https://github.com/OneStepAt4time/aegis/commit/1931ab0ade6feb282423e779379424c324e8958c)) +* correct batchCreateSessions return type to match backend ([#312](https://github.com/OneStepAt4time/aegis/issues/312)) ([54e478a](https://github.com/OneStepAt4time/aegis/commit/54e478ad9c65005c985e6d15f2ea65a67b1b239d)) +* correct clawhub publish command in release.yml ([#802](https://github.com/OneStepAt4time/aegis/issues/802)) ([5ebfbc2](https://github.com/OneStepAt4time/aegis/commit/5ebfbc21b427e9fa2b07525bc0c2d48aa9618a96)) +* correct README field name brief→prompt and update stale badges — Issue [#396](https://github.com/OneStepAt4time/aegis/issues/396) ([da18754](https://github.com/OneStepAt4time/aegis/commit/da18754452aa258664db793807fdf912f6da9c01)) +* correct README field name brief→prompt and update stale badges — Issue [#396](https://github.com/OneStepAt4time/aegis/issues/396) ([18774d3](https://github.com/OneStepAt4time/aegis/commit/18774d3dd9ad40f8debc1b984ebdac3fee58457e)) +* correct SubagentStart agent name extraction in hook Zod validation ([#768](https://github.com/OneStepAt4time/aegis/issues/768)) ([095dba1](https://github.com/OneStepAt4time/aegis/commit/095dba1e76965eafffdc2b7307a7005e5ded0297)) +* correctly report sendMessage delivery failures without masking ([#855](https://github.com/OneStepAt4time/aegis/issues/855)) ([42e1fb4](https://github.com/OneStepAt4time/aegis/commit/42e1fb4e0de6a8b50f9f491fb16c78e10e6fa8fc)) +* **correctness:** add event ID overflow guard ([#589](https://github.com/OneStepAt4time/aegis/issues/589)) ([0abeb7a](https://github.com/OneStepAt4time/aegis/commit/0abeb7a3792b1958e73e32b7fc78fb251e348412)) +* **correctness:** fix backward newline scan offset in transcript reader ([#579](https://github.com/OneStepAt4time/aegis/issues/579)) ([77b474f](https://github.com/OneStepAt4time/aegis/commit/77b474f3eee3bcb607da86b565a1cc1ba7f8f7e0)) +* dashboard dedup bounded set + stable debounce refs ([#504](https://github.com/OneStepAt4time/aegis/issues/504), [#512](https://github.com/OneStepAt4time/aegis/issues/512), [#514](https://github.com/OneStepAt4time/aegis/issues/514)) ([#535](https://github.com/OneStepAt4time/aegis/issues/535)) ([f035c50](https://github.com/OneStepAt4time/aegis/commit/f035c5040f624dd546e8fa13fd909052bad615c4)) +* dashboard type safety — PipelineInfo, BatchResult, PipelineRequest ([#669](https://github.com/OneStepAt4time/aegis/issues/669), [#670](https://github.com/OneStepAt4time/aegis/issues/670), [#671](https://github.com/OneStepAt4time/aegis/issues/671)) ([#888](https://github.com/OneStepAt4time/aegis/issues/888)) ([7cfc59c](https://github.com/OneStepAt4time/aegis/commit/7cfc59cee63a88d26a2a6a5b3dd0330ba1d0547c)) +* **dashboard:** add accessibility aria-labels and keyboard navigation ([#1185](https://github.com/OneStepAt4time/aegis/issues/1185)) ([e908dde](https://github.com/OneStepAt4time/aegis/commit/e908dde240321ed00bfcf41c9e63ea11b7f0a6f1)) +* **dashboard:** add adaptive polling backoff for pipeline pages ([#956](https://github.com/OneStepAt4time/aegis/issues/956)) ([5cb484c](https://github.com/OneStepAt4time/aegis/commit/5cb484ccf1bedd3a2b7c8518bbc33cbe3ec489a2)) +* **dashboard:** add loading/disabled state to SessionTable action buttons ([#798](https://github.com/OneStepAt4time/aegis/issues/798)) ([914d0b7](https://github.com/OneStepAt4time/aegis/commit/914d0b7f955684ac6ccfa861f50405df26f6d7b2)), closes [#645](https://github.com/OneStepAt4time/aegis/issues/645) +* **dashboard:** add missing type re-exports in types/index ([#1186](https://github.com/OneStepAt4time/aegis/issues/1186)) ([31caac9](https://github.com/OneStepAt4time/aegis/commit/31caac9c600b1423b173fd90a5532d6c969b7efe)) +* **dashboard:** add permission_denied to SSE event schema ([#1159](https://github.com/OneStepAt4time/aegis/issues/1159)) ([416d164](https://github.com/OneStepAt4time/aegis/commit/416d164278df2fb2f90270f809c438f46e5c015a)) +* **dashboard:** add schema validation for getAllSessionsHealth ([#1183](https://github.com/OneStepAt4time/aegis/issues/1183)) ([6bddb5d](https://github.com/OneStepAt4time/aegis/commit/6bddb5d7df2a2e1d74f1331c8be08cb6647156ee)) +* **dashboard:** add token to LiveTerminal WebSocket effect dependencies ([#796](https://github.com/OneStepAt4time/aegis/issues/796)) ([125954e](https://github.com/OneStepAt4time/aegis/commit/125954ec9fd0770258f628c9842f7c13269d8169)), closes [#642](https://github.com/OneStepAt4time/aegis/issues/642) +* **dashboard:** align SSE degraded state and loading error UX ([#1144](https://github.com/OneStepAt4time/aegis/issues/1144)) ([afb87f3](https://github.com/OneStepAt4time/aegis/commit/afb87f3f09b5215caca6cb20e10e9f701541cd7a)) +* **dashboard:** align SSE event schema with backend ([#1201](https://github.com/OneStepAt4time/aegis/issues/1201)) ([0cdf750](https://github.com/OneStepAt4time/aegis/commit/0cdf7503afea803d3eb00d637230477abe83713f)) +* **dashboard:** correct fork navigation route /session/ → /sessions/ ([#1155](https://github.com/OneStepAt4time/aegis/issues/1155)) ([590b692](https://github.com/OneStepAt4time/aegis/commit/590b6926028d7b79afe612e766215a0c0328fbb4)) +* **dashboard:** exclude AbortSignal from batchCreateSessions JSON body ([#784](https://github.com/OneStepAt4time/aegis/issues/784)) ([14661e2](https://github.com/OneStepAt4time/aegis/commit/14661e286beaa873e8a321c5d548b71bfa9a83a0)) +* **dashboard:** fix TranscriptViewer scroll jumps with dynamic row measurement ([#1182](https://github.com/OneStepAt4time/aegis/issues/1182)) ([ea0bacc](https://github.com/OneStepAt4time/aegis/commit/ea0bacc0a660cec6577de166209dd702b823a797)) +* **dashboard:** handle empty DELETE body in kill session ([#1200](https://github.com/OneStepAt4time/aegis/issues/1200)) ([1102bcc](https://github.com/OneStepAt4time/aegis/commit/1102bccb6436aa13e528b83759d4b19d586006f7)) +* **dashboard:** refresh overview data on SSE activity ([#1143](https://github.com/OneStepAt4time/aegis/issues/1143)) ([1fd7584](https://github.com/OneStepAt4time/aegis/commit/1fd7584cd396b1a840ca254b148b350914c94363)) +* **dashboard:** remove addToast from MetricCards fetchData dependencies ([#797](https://github.com/OneStepAt4time/aegis/issues/797)) ([4d12e98](https://github.com/OneStepAt4time/aegis/commit/4d12e9871310abd894175f45b067be3432e6e286)), closes [#644](https://github.com/OneStepAt4time/aegis/issues/644) +* **dashboard:** replace unsafe UIState cast with type-safe status mapping in PipelineDetailPage ([#1238](https://github.com/OneStepAt4time/aegis/issues/1238)) ([9783e14](https://github.com/OneStepAt4time/aegis/commit/9783e1485f8b1ce80da42efa8fc7787571c268d8)) +* **dashboard:** show aegis version with cached update check ([#1146](https://github.com/OneStepAt4time/aegis/issues/1146)) ([b9c91e3](https://github.com/OneStepAt4time/aegis/commit/b9c91e39514572768cab6fc7a240662f404a37c0)) +* **dashboard:** stabilize live update rendering and activity text ([#1145](https://github.com/OneStepAt4time/aegis/issues/1145)) ([9b7e423](https://github.com/OneStepAt4time/aegis/commit/9b7e4233bb4fb05a752145c40c4c06a20e52c63e)) +* **dashboard:** suppress 403 noise on Auth Keys page ([#1196](https://github.com/OneStepAt4time/aegis/issues/1196)) ([a5900c6](https://github.com/OneStepAt4time/aegis/commit/a5900c6c40dda72d55dadfc548e9d7c49b90ba14)) +* **dashboard:** ToolResultCard no longer classifies empty results as errors ([#795](https://github.com/OneStepAt4time/aegis/issues/795)) ([d7ed35c](https://github.com/OneStepAt4time/aegis/commit/d7ed35c242414e6bbf6026d962dae20c44567eba)), closes [#643](https://github.com/OneStepAt4time/aegis/issues/643) +* **dashboard:** validate createSession response against Zod schema ([#786](https://github.com/OneStepAt4time/aegis/issues/786)) ([aa60e92](https://github.com/OneStepAt4time/aegis/commit/aa60e9214afce14ea2f2eea65db5370ff86fed92)) +* deadlock in createWindow() serialize callback — Issue [#393](https://github.com/OneStepAt4time/aegis/issues/393) ([03505d9](https://github.com/OneStepAt4time/aegis/commit/03505d9e380c83f3065a4061a947e1c78f410671)) +* deep-merge hook settings by event instead of shallow spread ([#635](https://github.com/OneStepAt4time/aegis/issues/635)) ([#819](https://github.com/OneStepAt4time/aegis/issues/819)) ([7dcb498](https://github.com/OneStepAt4time/aegis/commit/7dcb498b1adfa189d7ea9b9d88b004a6d90e81a6)) +* detect CC process crash immediately via PID check ([#390](https://github.com/OneStepAt4time/aegis/issues/390)) ([#502](https://github.com/OneStepAt4time/aegis/issues/502)) ([e619882](https://github.com/OneStepAt4time/aegis/commit/e61988217e5d7b4efcdd795e69f884926798a149)) +* detect crashed sessions via tmux pane-exit signal ([#1020](https://github.com/OneStepAt4time/aegis/issues/1020)) ([da9f0d7](https://github.com/OneStepAt4time/aegis/commit/da9f0d7e0b332a3acb1f665b702af356aa7fa5be)) +* detect waiting_for_input session status from CC transcript on Stop hook ([#812](https://github.com/OneStepAt4time/aegis/issues/812)) ([#816](https://github.com/OneStepAt4time/aegis/issues/816)) ([af5794a](https://github.com/OneStepAt4time/aegis/commit/af5794abf31d56315766ec1ba9e10b7db8998fd1)) +* detect zombie CC processes via /proc/<pid>/stat state check ([#1032](https://github.com/OneStepAt4time/aegis/issues/1032)) ([dc44a70](https://github.com/OneStepAt4time/aegis/commit/dc44a70f2b75c349d7d7a2540501143773f8e3bf)) +* disable source maps in tsconfig to match published files ([#772](https://github.com/OneStepAt4time/aegis/issues/772)) ([ae68a9f](https://github.com/OneStepAt4time/aegis/commit/ae68a9f6f97ea44262b1f06322de89a337f785d5)) +* enforce zod v4 alignment across root and dashboard ([#1011](https://github.com/OneStepAt4time/aegis/issues/1011)) ([3bcd52a](https://github.com/OneStepAt4time/aegis/commit/3bcd52aaf1eec41a9cc02615314829fa0974e465)) +* ensure alpha suffix in version ([#1226](https://github.com/OneStepAt4time/aegis/issues/1226)) ([d0eb4c6](https://github.com/OneStepAt4time/aegis/commit/d0eb4c64dd69b3cc86d7cc56b15458191c61d25e)), closes [#1210](https://github.com/OneStepAt4time/aegis/issues/1210) +* extend paneDead grace period to 15s and add coverage tests ([#1036](https://github.com/OneStepAt4time/aegis/issues/1036)) ([505b8a0](https://github.com/OneStepAt4time/aegis/commit/505b8a0675c266d6652c49c8640920a178b70f9f)) +* fail copy-dashboard in CI when dashboard/dist is missing ([#770](https://github.com/OneStepAt4time/aegis/issues/770)) ([d33896b](https://github.com/OneStepAt4time/aegis/commit/d33896ba3b6366ff8ef272f4c55ad11772e3ec38)) +* graceful shutdown and crash recovery — Issue [#361](https://github.com/OneStepAt4time/aegis/issues/361) ([2ccbb05](https://github.com/OneStepAt4time/aegis/commit/2ccbb052acdfb4c47c8de51dc9c0d72d78d10327)) +* graceful shutdown and crash recovery gaps — Issue [#361](https://github.com/OneStepAt4time/aegis/issues/361) ([ab91263](https://github.com/OneStepAt4time/aegis/commit/ab912632bdad3f213dc6e9748824fe30a2e6e69d)) +* harden idle session acquisition mutex ([#890](https://github.com/OneStepAt4time/aegis/issues/890)) ([8979ef0](https://github.com/OneStepAt4time/aegis/commit/8979ef0b9ed75005caf065a30aab1b7d3dc544c3)) +* harden structured diagnostics channel and redaction ([#923](https://github.com/OneStepAt4time/aegis/issues/923)) ([bcbdf06](https://github.com/OneStepAt4time/aegis/commit/bcbdf067b14d068781a2b0180591ec800c24a933)) +* harden tmux crash recovery against false dead-session cleanup ([#1018](https://github.com/OneStepAt4time/aegis/issues/1018)) ([3749aa8](https://github.com/OneStepAt4time/aegis/commit/3749aa8ed2c154e09ae6b39ce0f0016348d5c0f3)) +* hook auth HMAC, env blocklist expansion, SSE rate limit dedup ([#629](https://github.com/OneStepAt4time/aegis/issues/629), [#630](https://github.com/OneStepAt4time/aegis/issues/630), [#634](https://github.com/OneStepAt4time/aegis/issues/634)) ([#914](https://github.com/OneStepAt4time/aegis/issues/914)) ([b3a2fd5](https://github.com/OneStepAt4time/aegis/commit/b3a2fd51a25549b18f0447a357d5008302269fe4)) +* hook path validation, tmux crash handling, approval regex, jsonl watcher timer ([#847](https://github.com/OneStepAt4time/aegis/issues/847), [#845](https://github.com/OneStepAt4time/aegis/issues/845), [#843](https://github.com/OneStepAt4time/aegis/issues/843), [#846](https://github.com/OneStepAt4time/aegis/issues/846)) ([#869](https://github.com/OneStepAt4time/aegis/issues/869)) ([7055b80](https://github.com/OneStepAt4time/aegis/commit/7055b80d8527853dba8bc7f34a7ff53791536a02)) +* **hooks:** add Phase 1 lifecycle events -- PermissionDenied, TaskCreated, Setup, ConfigChange, InstructionsLoaded ([#1058](https://github.com/OneStepAt4time/aegis/issues/1058)) ([7ab2a84](https://github.com/OneStepAt4time/aegis/commit/7ab2a842f54e9143ce8ebdd44132737237a23614)) +* hydrate activeSubagents arrays to Sets at load time ([#668](https://github.com/OneStepAt4time/aegis/issues/668)) ([#765](https://github.com/OneStepAt4time/aegis/issues/765)) ([b6447a6](https://github.com/OneStepAt4time/aegis/commit/b6447a625e6aa34418260e29df13c1b47a6e12ce)) +* include dashboard in npm package ([#539](https://github.com/OneStepAt4time/aegis/issues/539)) ([#545](https://github.com/OneStepAt4time/aegis/issues/545)) ([b47f63e](https://github.com/OneStepAt4time/aegis/commit/b47f63e8ee999204997b2a6dd28142b5b7aaecb5)) +* include dashboard in npm package + add types/exports/homepage/bugs fields ([#539](https://github.com/OneStepAt4time/aegis/issues/539)) ([#546](https://github.com/OneStepAt4time/aegis/issues/546)) ([6f799f1](https://github.com/OneStepAt4time/aegis/commit/6f799f1f78ed496b6f593a10bf5b2a5f9b8d83e9)) +* inject MCP_CONNECTION_NONBLOCKING in hook settings ([#931](https://github.com/OneStepAt4time/aegis/issues/931)) ([#935](https://github.com/OneStepAt4time/aegis/issues/935)) ([a4fc9ee](https://github.com/OneStepAt4time/aegis/commit/a4fc9ee6166e307a1a38b705b15d7000452ef862)) +* input validation across all API routes — Issue [#359](https://github.com/OneStepAt4time/aegis/issues/359) ([3fdf986](https://github.com/OneStepAt4time/aegis/commit/3fdf98679cd8edf8ab7c3a19a19c2c91b8a31877)) +* lock release-please to 0.1.x-alpha ([#1223](https://github.com/OneStepAt4time/aegis/issues/1223)) ([b16a734](https://github.com/OneStepAt4time/aegis/commit/b16a734c7004a775f0a92dcdd575f7b68240f180)), closes [#1210](https://github.com/OneStepAt4time/aegis/issues/1210) +* loosen flaky backoff assertions in channels test — Issue [#378](https://github.com/OneStepAt4time/aegis/issues/378) ([c1e63e0](https://github.com/OneStepAt4time/aegis/commit/c1e63e08ed038a2ec7367fefa3ac542f37dbec1f)) +* loosen flaky backoff assertions in channels test — Issue [#378](https://github.com/OneStepAt4time/aegis/issues/378) ([db3a83b](https://github.com/OneStepAt4time/aegis/commit/db3a83b30f342bc12007b78aca30ad7dfeddcb34)) +* make tmux createWindow name collision handling race-safe ([#1005](https://github.com/OneStepAt4time/aegis/issues/1005)) ([4279011](https://github.com/OneStepAt4time/aegis/commit/427901136aa9ed9158b5129c88bf778e4a66e7c4)) +* MCP kill_session 400 + extended working stall detection ([#597](https://github.com/OneStepAt4time/aegis/issues/597)) ([0426613](https://github.com/OneStepAt4time/aegis/commit/042661369de062bba62a4e181bd345e9964b3b31)) +* MCP server polish — version, auth, errors, graceful degradation — Issue [#445](https://github.com/OneStepAt4time/aegis/issues/445) ([37bc8b6](https://github.com/OneStepAt4time/aegis/commit/37bc8b6d1459fdda7e3c2b516f804f56944155d8)) +* MCP server polish — version, auth, errors, graceful degradation — Issue [#445](https://github.com/OneStepAt4time/aegis/issues/445) ([1a5def4](https://github.com/OneStepAt4time/aegis/commit/1a5def416ebccb587d4fc51296314d65f8e4dcd8)) +* MCP server polish — version, auth, errors, graceful degradation — Issue [#445](https://github.com/OneStepAt4time/aegis/issues/445) ([8bfea10](https://github.com/OneStepAt4time/aegis/commit/8bfea1050a5da125659c7fff03bd0087d2917fbb)) +* **mcp:** add state_get/state_set/state_delete tools ([#1062](https://github.com/OneStepAt4time/aegis/issues/1062)) ([1d9e361](https://github.com/OneStepAt4time/aegis/commit/1d9e36193e89499479b57d33d52b3be4425cc116)) +* memory leak fixes — event buffer cleanup, cache eviction, debounce guard ([#572](https://github.com/OneStepAt4time/aegis/issues/572)) ([13ed2c8](https://github.com/OneStepAt4time/aegis/commit/13ed2c8fe8080d81a529fd9642f958eb9a003333)) +* **memory:** scoped memory API and session-linked memories ([#1059](https://github.com/OneStepAt4time/aegis/issues/1059)) ([5052dea](https://github.com/OneStepAt4time/aegis/commit/5052deaeae5f27d0c3cb0914e5bdcc508ae843f4)) +* merge project settings into hook settings file ([c861d93](https://github.com/OneStepAt4time/aegis/commit/c861d93ebea368322316e564c3b6bd39a078b529)) +* mock assertion bugs and type errors in test files ([d17d1b1](https://github.com/OneStepAt4time/aegis/commit/d17d1b1bea58a949d65e2ed44cd160c2cef4b61c)) +* monitor stall detection edge cases — Issue [#356](https://github.com/OneStepAt4time/aegis/issues/356) ([74c3aaa](https://github.com/OneStepAt4time/aegis/commit/74c3aaa2533745d1060d559999c9d2e132a67bdf)) +* monitor stall detection edge cases — Issue [#356](https://github.com/OneStepAt4time/aegis/issues/356) ([eb0d698](https://github.com/OneStepAt4time/aegis/commit/eb0d698ebacaf9a928b6e2ba9560fb636de52bfd)) +* **monitor:** add structured diagnostics for unexpected session death ([#1051](https://github.com/OneStepAt4time/aegis/issues/1051)) ([d30fc96](https://github.com/OneStepAt4time/aegis/commit/d30fc9635c0c9535217c8aea258c0f097149871e)) +* move lastUsedAt after rate-limit check, add session mutex, remove transcript offset fallback ([#854](https://github.com/OneStepAt4time/aegis/issues/854)) ([2618e0c](https://github.com/OneStepAt4time/aegis/commit/2618e0c3d534db4136698654f776371d82acaecf)) +* move reaper notify after killSession cleanup ([#842](https://github.com/OneStepAt4time/aegis/issues/842)) ([#889](https://github.com/OneStepAt4time/aegis/issues/889)) ([c8262f3](https://github.com/OneStepAt4time/aegis/commit/c8262f3a972b1e26265eb4cd70d6e95ec199f8f2)) +* move setEnvSecure inside serialize block to prevent env var race ([#837](https://github.com/OneStepAt4time/aegis/issues/837)) ([#860](https://github.com/OneStepAt4time/aegis/issues/860)) ([a9b5089](https://github.com/OneStepAt4time/aegis/commit/a9b5089fd6bd487822de78a57ddb7749387d5af4)) +* only count 5xx and network errors as circuit breaker failures ([#638](https://github.com/OneStepAt4time/aegis/issues/638)) ([#821](https://github.com/OneStepAt4time/aegis/issues/821)) ([1c70a38](https://github.com/OneStepAt4time/aegis/commit/1c70a386d13a896f10f6b33ed5896198270b99de)) +* package hygiene for npm publishing — Issue [#364](https://github.com/OneStepAt4time/aegis/issues/364) ([0a36e7e](https://github.com/OneStepAt4time/aegis/commit/0a36e7e8ccd80cd35aa36c33e3b181bf04838ecf)) +* package hygiene for npm publishing — Issue [#364](https://github.com/OneStepAt4time/aegis/issues/364) ([3377e51](https://github.com/OneStepAt4time/aegis/commit/3377e514c09d80b68c4ea80152326aa4b12f5778)) +* **parser:** recognize CC ≥2.1.92 workspace trust dialog ([#1045](https://github.com/OneStepAt4time/aegis/issues/1045)) ([#1048](https://github.com/OneStepAt4time/aegis/issues/1048)) ([edb23f9](https://github.com/OneStepAt4time/aegis/commit/edb23f949b496d7140c765b382ce43a5d6e77e60)) +* path traversal bypass and DELETE 404 for missing sessions — Issues [#434](https://github.com/OneStepAt4time/aegis/issues/434) [#435](https://github.com/OneStepAt4time/aegis/issues/435) ([d3f21c5](https://github.com/OneStepAt4time/aegis/commit/d3f21c57ca312438510144f83384a21ca1b22c6b)) +* path traversal bypass and DELETE 404 for missing sessions — Issues [#434](https://github.com/OneStepAt4time/aegis/issues/434) [#435](https://github.com/OneStepAt4time/aegis/issues/435) ([c4ada0c](https://github.com/OneStepAt4time/aegis/commit/c4ada0cd544f33b2beb052e039bc95abd118259f)) +* **perf:** add shared tmux capture-pane cache to deduplicate reads ([#395](https://github.com/OneStepAt4time/aegis/issues/395)) ([#731](https://github.com/OneStepAt4time/aegis/issues/731)) ([3aa6111](https://github.com/OneStepAt4time/aegis/commit/3aa6111a52a3f8a675ec613b34cf3c4bded48368)) +* **perf:** align stall detection with CLAUDE_STREAM_IDLE_TIMEOUT_MS ([#392](https://github.com/OneStepAt4time/aegis/issues/392)) ([0624b65](https://github.com/OneStepAt4time/aegis/commit/0624b6529142e144299e62afbb24354772fd80b5)) +* **perf:** clear pipeline poll interval when no pipelines remain ([#578](https://github.com/OneStepAt4time/aegis/issues/578)) ([efa7269](https://github.com/OneStepAt4time/aegis/commit/efa726935cfd7838f24d80dd80099cab9fe183d1)) +* **pipeline:** add staged state metadata and stage history ([#1064](https://github.com/OneStepAt4time/aegis/issues/1064)) ([e0d7855](https://github.com/OneStepAt4time/aegis/commit/e0d7855171623077dc68bf79d71b3da45f6a496d)) +* **pipeline:** clear cleanup timers and maps on shutdown ([#1198](https://github.com/OneStepAt4time/aegis/issues/1198)) ([15b1ff7](https://github.com/OneStepAt4time/aegis/commit/15b1ff7c05ff5952b0e102f1b3796102aee320b5)), closes [#1092](https://github.com/OneStepAt4time/aegis/issues/1092) +* prepare next alpha release from develop ([#1575](https://github.com/OneStepAt4time/aegis/issues/1575)) ([866e5f9](https://github.com/OneStepAt4time/aegis/commit/866e5f9785c84330979da38019ae33251cf7e499)) +* preserve hook env vars and normalize callback host ([#981](https://github.com/OneStepAt4time/aegis/issues/981)) ([319e478](https://github.com/OneStepAt4time/aegis/commit/319e478aac7cd64581c34e26fd962f02ca6b13b8)) +* preserve hook env vars for BOM settings files ([#986](https://github.com/OneStepAt4time/aegis/issues/986)) ([a3915e8](https://github.com/OneStepAt4time/aegis/commit/a3915e82d4c425ca39ccf36de080877f7377d70d)) +* prevent double gracefulShutdown on rapid SIGINT ([#415](https://github.com/OneStepAt4time/aegis/issues/415)) ([#490](https://github.com/OneStepAt4time/aegis/issues/490)) ([f32de59](https://github.com/OneStepAt4time/aegis/commit/f32de59e5a419b87b2aabe577457504cd8b66be2)) +* prevent path traversal in workDir validation — Issue [#435](https://github.com/OneStepAt4time/aegis/issues/435) ([8e9994e](https://github.com/OneStepAt4time/aegis/commit/8e9994ecd31750d2ab40c6b97e2994000bf22154)) +* prevent path traversal in workDir validation — Issue [#435](https://github.com/OneStepAt4time/aegis/issues/435) ([e26f362](https://github.com/OneStepAt4time/aegis/commit/e26f362a42816d1fd2db2d6c8e4f9714c55f65d1)) +* prevent TOCTOU race in idle session reuse ([#857](https://github.com/OneStepAt4time/aegis/issues/857)) ([e5d2baf](https://github.com/OneStepAt4time/aegis/commit/e5d2baf52a1531e13429400264fc8689c1a87188)) +* properly handle CC normal exit in isWindowAlive ([#1042](https://github.com/OneStepAt4time/aegis/issues/1042)) ([bfc2191](https://github.com/OneStepAt4time/aegis/commit/bfc21911e0947ee34fa817d892ec867dcdd989fe)) +* quote 'on' key in release.yml (YAML 1.1 boolean bug) ([#1322](https://github.com/OneStepAt4time/aegis/issues/1322)) ([6edb4ae](https://github.com/OneStepAt4time/aegis/commit/6edb4ae28b5bf92ab6c6934c9dc5cb723e370903)) +* read version dynamically from package.json in MCP server test ([#534](https://github.com/OneStepAt4time/aegis/issues/534)) ([c4b648c](https://github.com/OneStepAt4time/aegis/commit/c4b648c09ab18577dda1185b65d61b87cf61bdb7)) +* reduce dashboard polling and memoize session rows ([#955](https://github.com/OneStepAt4time/aegis/issues/955)) ([570581d](https://github.com/OneStepAt4time/aegis/commit/570581d2a9b76bdf65e93bc18dae88f5c67132fa)) +* remove bearer token fallback in SSE — retry with backoff instead — Issue [#408](https://github.com/OneStepAt4time/aegis/issues/408) ([733f5b7](https://github.com/OneStepAt4time/aegis/commit/733f5b793d365a3f5e271816b332f033dfbe656b)) +* remove bearer token fallback in SSE — retry with backoff instead — Issue [#408](https://github.com/OneStepAt4time/aegis/issues/408) ([b51800d](https://github.com/OneStepAt4time/aegis/commit/b51800d8a85666d63096aff076b0a3701aebdb95)) +* remove ccPid zombie check that treats normal CC exit as crash ([#1040](https://github.com/OneStepAt4time/aegis/issues/1040)) ([e9e6f35](https://github.com/OneStepAt4time/aegis/commit/e9e6f356207ba22bbffd4f63a603effec919c35d)) +* remove invalid CC hook event types that crash sessions ([#1002](https://github.com/OneStepAt4time/aegis/issues/1002)) ([#1023](https://github.com/OneStepAt4time/aegis/issues/1023)) ([20ab0f6](https://github.com/OneStepAt4time/aegis/commit/20ab0f677529fafb88fa8375cb54815787d05b47)) +* remove paneDead regression from session liveness check ([#1026](https://github.com/OneStepAt4time/aegis/issues/1026)) ([#1027](https://github.com/OneStepAt4time/aegis/issues/1027)) ([8eb2f9d](https://github.com/OneStepAt4time/aegis/commit/8eb2f9d4db200561cad20aa8d3de368b5fefbdf5)) +* remove redundant includes check in swarm socket discovery ([#789](https://github.com/OneStepAt4time/aegis/issues/789)) ([307b9ae](https://github.com/OneStepAt4time/aegis/commit/307b9aef4b7e71ce6d5ad89cc71cfd37d97b7b00)) +* remove unused sessionId prop from PanePreview and ApprovalBanner ([#647](https://github.com/OneStepAt4time/aegis/issues/647)) ([#937](https://github.com/OneStepAt4time/aegis/issues/937)) ([3ab8e90](https://github.com/OneStepAt4time/aegis/commit/3ab8e9072eeaf91108c6de1301db21ec408e0b52)) +* replace `as any` cast in applyEnvOverrides with explicit string-key cases ([#762](https://github.com/OneStepAt4time/aegis/issues/762)) ([e1d5a5c](https://github.com/OneStepAt4time/aegis/commit/e1d5a5ca81194a14a272d4c3c60466721da9e3a4)) +* replace any return types with proper types in MCP server ([#577](https://github.com/OneStepAt4time/aegis/issues/577)) ([#929](https://github.com/OneStepAt4time/aegis/issues/929)) ([fc78e6a](https://github.com/OneStepAt4time/aegis/commit/fc78e6a98cb27f6617ab2d5dd4bd4b5665855de2)) +* replace silent catches with explicit suppressible-error policy ([#896](https://github.com/OneStepAt4time/aegis/issues/896)) ([e47c859](https://github.com/OneStepAt4time/aegis/commit/e47c859671c4ddc39f8e06c667da02384becff82)) +* replace sync readFileSync with async I/O in transcript scanning ([#409](https://github.com/OneStepAt4time/aegis/issues/409)) ([#496](https://github.com/OneStepAt4time/aegis/issues/496)) ([3ccddc9](https://github.com/OneStepAt4time/aegis/commit/3ccddc95de762c289d94407a28361919d390a141)) +* replace unsafe `(e as Error).message` with instanceof guard ([#763](https://github.com/OneStepAt4time/aegis/issues/763)) ([7963763](https://github.com/OneStepAt4time/aegis/commit/7963763ff3aa17f2d2e56e561d7c79cd2f7a1d02)) +* require session validation on hook endpoints — Issue [#394](https://github.com/OneStepAt4time/aegis/issues/394) ([fb76540](https://github.com/OneStepAt4time/aegis/commit/fb765406482c4d0ab14982e03fd6d18e8e83f1c7)) +* require session validation on hook endpoints — Issue [#394](https://github.com/OneStepAt4time/aegis/issues/394) ([09556cb](https://github.com/OneStepAt4time/aegis/commit/09556cbb901d90368b3ec8be40d2f4fd74509188)) +* resolve merge conflict markers in ws-terminal.ts ([530e7ce](https://github.com/OneStepAt4time/aegis/commit/530e7ce0c68a5a3a98f9526899888b1014e85e80)) +* restrict permissionMode to known enum values in validation schemas ([#756](https://github.com/OneStepAt4time/aegis/issues/756)) ([39c2521](https://github.com/OneStepAt4time/aegis/commit/39c25217d753183a330af9dc8d5f159d5c2e1616)) +* return 400 with INVALID_WORKDIR when workDir does not exist — Issue [#458](https://github.com/OneStepAt4time/aegis/issues/458) ([18022d8](https://github.com/OneStepAt4time/aegis/commit/18022d8e27dc2572ce70c809d9fe45cedb5c3ec6)) +* return 400 with INVALID_WORKDIR when workDir does not exist — Issue [#458](https://github.com/OneStepAt4time/aegis/issues/458) ([b561ffe](https://github.com/OneStepAt4time/aegis/commit/b561ffe6c06723cedf876e4ec737c6349616615d)) +* robust prompt delivery with post-send verification ([#567](https://github.com/OneStepAt4time/aegis/issues/567)) ([05478fe](https://github.com/OneStepAt4time/aegis/commit/05478fe649cd420fcd7c8533e75caed1bc752789)), closes [#561](https://github.com/OneStepAt4time/aegis/issues/561) +* **routing:** add tiered model routing module ([#1060](https://github.com/OneStepAt4time/aegis/issues/1060)) ([1e21e8d](https://github.com/OneStepAt4time/aegis/commit/1e21e8df31f78f36fb4db423ae77b4b4c81b9450)) +* sanitize permission_request content in MessageBubble to prevent XSS ([#406](https://github.com/OneStepAt4time/aegis/issues/406)) ([#498](https://github.com/OneStepAt4time/aegis/issues/498)) ([20f33f7](https://github.com/OneStepAt4time/aegis/commit/20f33f79383e9d41de89cde5bb08567b98097478)) +* security audit and dependency hardening ([8ed2a95](https://github.com/OneStepAt4time/aegis/commit/8ed2a95859d4505595d7c9ee64416e2ad7594832)) +* security audit hardening — resolve vulns, block CI on high-sev, add lockfile lint ([0bd0162](https://github.com/OneStepAt4time/aegis/commit/0bd0162b87cc95875ccb25e207a63f6bb1a43860)) +* security hardening — CORS wildcard rejection, UUID validation, input length limits ([#565](https://github.com/OneStepAt4time/aegis/issues/565)) ([db610ec](https://github.com/OneStepAt4time/aegis/commit/db610ecbda5834bb43bafee9965a61f7cd919b4a)) +* **security:** add bounds validation on WebSocket resize messages ([#581](https://github.com/OneStepAt4time/aegis/issues/581)) ([8df77c8](https://github.com/OneStepAt4time/aegis/commit/8df77c85fcdfdbddbe7a078117bbbf278eb54d14)) +* **security:** add mutex to validateSSEToken to prevent double-decrement race ([#826](https://github.com/OneStepAt4time/aegis/issues/826)) ([#861](https://github.com/OneStepAt4time/aegis/issues/861)) ([6b05a4b](https://github.com/OneStepAt4time/aegis/commit/6b05a4b7cca88fadb86c85fde677295101ab5ee1)) +* **security:** add per-session permission profiles for PreToolUse ([#1070](https://github.com/OneStepAt4time/aegis/issues/1070)) ([2155680](https://github.com/OneStepAt4time/aegis/commit/215568076beb990e5ff7d8d45bee6998a6553c04)) +* **security:** add rate limiting for batch session creation ([#583](https://github.com/OneStepAt4time/aegis/issues/583)) ([d8f66c8](https://github.com/OneStepAt4time/aegis/commit/d8f66c81bc5f90e80ae6597de441fb1b0f5daf1a)) +* **security:** cap per-IP rate-limit map at 10k entries to prevent memory exhaustion ([#844](https://github.com/OneStepAt4time/aegis/issues/844)) ([#858](https://github.com/OneStepAt4time/aegis/issues/858)) ([1927c15](https://github.com/OneStepAt4time/aegis/commit/1927c15d9eb3d24176885f66847419826e39e22a)) +* **security:** catch prior mutex rejection in generateSSEToken ([#573](https://github.com/OneStepAt4time/aegis/issues/573)) ([1ddd8f4](https://github.com/OneStepAt4time/aegis/commit/1ddd8f42ca4a2a5a266a3f6279fcd3482884e16d)) +* **security:** change default permissionMode to require approval ([#1152](https://github.com/OneStepAt4time/aegis/issues/1152)) ([f050683](https://github.com/OneStepAt4time/aegis/commit/f050683274e1d683313c1b06a6ad27a537b54493)) +* **security:** check all DNS answers and verify TOCTOU-safe IP pinning ([#829](https://github.com/OneStepAt4time/aegis/issues/829), [#831](https://github.com/OneStepAt4time/aegis/issues/831)) ([#853](https://github.com/OneStepAt4time/aegis/issues/853)) ([f0ef9e6](https://github.com/OneStepAt4time/aegis/commit/f0ef9e651353d6404c75a117b4be9d0f3f0e0b48)) +* **security:** check auth before revealing session existence in WebSocket ([#1157](https://github.com/OneStepAt4time/aegis/issues/1157)) ([7836f66](https://github.com/OneStepAt4time/aegis/commit/7836f66d1a2a8bc1f28bb733ed13391961f7c5e1)) +* **security:** detect IPv4-mapped IPv6 addresses in SSRF protection ([#621](https://github.com/OneStepAt4time/aegis/issues/621)) ([#815](https://github.com/OneStepAt4time/aegis/issues/815)) ([2ffe1ed](https://github.com/OneStepAt4time/aegis/commit/2ffe1ed1fa6a0a2fc109795b86fab66ec7384686)) +* **security:** differentiate webhook retry log levels ([#588](https://github.com/OneStepAt4time/aegis/issues/588)) ([725bb21](https://github.com/OneStepAt4time/aegis/commit/725bb2115fa4b4cbc60daba581488625405267c0)) +* **security:** enforce short-lived SSE token scope on event streams ([#1003](https://github.com/OneStepAt4time/aegis/issues/1003)) ([ed78a54](https://github.com/OneStepAt4time/aegis/commit/ed78a54cc7a4d4c977709106c47934ccb290bb06)) +* **security:** harden master token timing-safe comparison ([#1008](https://github.com/OneStepAt4time/aegis/issues/1008)) ([4dbcf4e](https://github.com/OneStepAt4time/aegis/commit/4dbcf4e2b360056c432afcd1c5db085261da8783)) +* **security:** harden runtime JSON parsing boundaries ([#1006](https://github.com/OneStepAt4time/aegis/issues/1006)) ([2eba890](https://github.com/OneStepAt4time/aegis/commit/2eba89071050d1daf1cd19b89279aeaaa5b601b2)) +* **security:** harden workDir boundary checks against traversal bypasses ([#1009](https://github.com/OneStepAt4time/aegis/issues/1009)) ([c039019](https://github.com/OneStepAt4time/aegis/commit/c0390190c6bd8551130d5e59ad99e1578abdfdef)) +* **security:** move hook secrets from query params to headers ([#1154](https://github.com/OneStepAt4time/aegis/issues/1154)) ([ab29110](https://github.com/OneStepAt4time/aegis/commit/ab29110e062e14755d98090583d3e5ed60b2ab26)) +* **security:** prevent DNS rebinding in screenshot endpoint via host-resolver-rules ([#817](https://github.com/OneStepAt4time/aegis/issues/817)) ([43998d5](https://github.com/OneStepAt4time/aegis/commit/43998d5bd042710f4554bf13baf02ccbfe0d638f)) +* **security:** prevent DNS rebinding SSRF in webhook delivery ([#822](https://github.com/OneStepAt4time/aegis/issues/822)) ([#852](https://github.com/OneStepAt4time/aegis/issues/852)) ([3a0d54d](https://github.com/OneStepAt4time/aegis/commit/3a0d54dcb45e6cf9fdc7d72f06262f608f000cdc)) +* **security:** read PPid from /proc/<pid>/status instead of stat in isAncestorPid ([#813](https://github.com/OneStepAt4time/aegis/issues/813)) ([7b49fed](https://github.com/OneStepAt4time/aegis/commit/7b49fed69ea3fd8b065ed5482e42a7b714ac0ff7)) +* **security:** redact session metadata from webhook payloads ([#827](https://github.com/OneStepAt4time/aegis/issues/827)) ([#859](https://github.com/OneStepAt4time/aegis/issues/859)) ([423d8fa](https://github.com/OneStepAt4time/aegis/commit/423d8fab65eaacfbc2c2b03d927cdec5571b6031)) +* **security:** remove dead BatchRateLimiter, fix requestKeyMap leak ([#583](https://github.com/OneStepAt4time/aegis/issues/583) follow-up) ([307ede5](https://github.com/OneStepAt4time/aegis/commit/307ede5602fb6b4b741e40f758e8b031409fd859)) +* **security:** render permission request content as inert text ([#1010](https://github.com/OneStepAt4time/aegis/issues/1010)) ([46c1514](https://github.com/OneStepAt4time/aegis/commit/46c15146d73cca9f7d915a401b43d5fe73f1ed8a)) +* **security:** replace execSync with execFileSync in killStalePortHolder ([#575](https://github.com/OneStepAt4time/aegis/issues/575)) ([5664e55](https://github.com/OneStepAt4time/aegis/commit/5664e553f81b4db3c0a946e16735ababbfb0e08c)) +* **security:** set key store file permissions to 0o600 ([#773](https://github.com/OneStepAt4time/aegis/issues/773)) ([9353244](https://github.com/OneStepAt4time/aegis/commit/9353244eb91ea8e6fd37d481a753c9b571eef89c)) +* **security:** use unpredictable tmp dir and restrictive permissions for hook settings ([#799](https://github.com/OneStepAt4time/aegis/issues/799)) ([147ccd6](https://github.com/OneStepAt4time/aegis/commit/147ccd67355b2004d90a9353c3fda15f9ed7cf2c)) +* **security:** validate template workDir at creation time ([#1125](https://github.com/OneStepAt4time/aegis/issues/1125)) ([#1161](https://github.com/OneStepAt4time/aegis/issues/1161)) ([a7b9c09](https://github.com/OneStepAt4time/aegis/commit/a7b9c09131b18e34808b5ad5e8b8e92049ae6371)) +* **security:** validate UUID format on hookSessionId header ([#580](https://github.com/OneStepAt4time/aegis/issues/580)) ([55a8c27](https://github.com/OneStepAt4time/aegis/commit/55a8c27227bfa41cae737d402096c7582805e346)) +* **session:** add resilient continuation pointer schema and TTL lifecycle ([#900](https://github.com/OneStepAt4time/aegis/issues/900)) ([#915](https://github.com/OneStepAt4time/aegis/issues/915)) ([02bb6d3](https://github.com/OneStepAt4time/aegis/commit/02bb6d3d3394a095fe724d13f4c6de59d50f6a84)) +* sessionCreated metric, xterm null guard, SSE reconnect onClose fix ([#925](https://github.com/OneStepAt4time/aegis/issues/925)) ([ecd737c](https://github.com/OneStepAt4time/aegis/commit/ecd737c399a978e384455564a110e10e4a4b21d7)) +* **session:** persist optional PRD on session creation and summary ([#1063](https://github.com/OneStepAt4time/aegis/issues/1063)) ([36c813a](https://github.com/OneStepAt4time/aegis/commit/36c813a7abbe1e30bac21af05475d269b4e4efe4)) +* shell injection vectors in tmux.ts — Issue [#358](https://github.com/OneStepAt4time/aegis/issues/358) ([237d7e5](https://github.com/OneStepAt4time/aegis/commit/237d7e5f4a2c073ba3e3f6f57a3d3bd7806121a8)) +* shell injection vectors in tmux.ts — Issue [#358](https://github.com/OneStepAt4time/aegis/issues/358) ([f70ae77](https://github.com/OneStepAt4time/aegis/commit/f70ae77928c413bbcfc0eba8dc739dc2ec5323b2)) +* **skill:** improve install flow and ClawHub release metadata ([#1066](https://github.com/OneStepAt4time/aegis/issues/1066)) ([14960e8](https://github.com/OneStepAt4time/aegis/commit/14960e880166405e77a3376fedbd769a83fbc5a4)) +* smarter paneDead check — only mark dead if session was actively working ([#1030](https://github.com/OneStepAt4time/aegis/issues/1030)) ([bb4ad3a](https://github.com/OneStepAt4time/aegis/commit/bb4ad3a9d72b048b3a0787524bd00fb91e6f7b67)) +* SSE token generation race condition — add mutex for per-key limit ([#414](https://github.com/OneStepAt4time/aegis/issues/414)) ([#487](https://github.com/OneStepAt4time/aegis/issues/487)) ([7139134](https://github.com/OneStepAt4time/aegis/commit/7139134344d6e1dd4bc108e81ab1f82fa4c4a80c)) +* SSEWriter res.end, JSONL drop logging, clock skew validation ([#825](https://github.com/OneStepAt4time/aegis/issues/825), [#823](https://github.com/OneStepAt4time/aegis/issues/823), [#828](https://github.com/OneStepAt4time/aegis/issues/828)) ([#867](https://github.com/OneStepAt4time/aegis/issues/867)) ([81816f9](https://github.com/OneStepAt4time/aegis/commit/81816f999358a7d69a91357a26fc4bb62c2a4801)) +* SSRF protection for webhook URLs and screenshot fetch — Issue [#346](https://github.com/OneStepAt4time/aegis/issues/346) ([877bb6e](https://github.com/OneStepAt4time/aegis/commit/877bb6ee47ce9a99f966b925494fe48f2b346f2f)) +* **ssrf:** block broadcast, multicast, documentation, and benchmarking IP ranges ([#775](https://github.com/OneStepAt4time/aegis/issues/775)) ([c2fcbc7](https://github.com/OneStepAt4time/aegis/commit/c2fcbc7a01826164dd4e6de96a17e8c826510707)) +* **stability:** add catch handlers for fire-and-forget PID lookup ([#574](https://github.com/OneStepAt4time/aegis/issues/574)) ([b3fd7fe](https://github.com/OneStepAt4time/aegis/commit/b3fd7feda1c50d14c5904cf4b822419fcabbe1e5)) +* **stability:** add graceful session cleanup on SIGTERM/SIGINT ([#569](https://github.com/OneStepAt4time/aegis/issues/569)) ([c06cef6](https://github.com/OneStepAt4time/aegis/commit/c06cef67bf33d90f7cda1d5bd27b37fc9c8cb23e)) +* standardize API error response envelope ([#399](https://github.com/OneStepAt4time/aegis/issues/399)) ([#953](https://github.com/OneStepAt4time/aegis/issues/953)) ([d8beb53](https://github.com/OneStepAt4time/aegis/commit/d8beb53f8eed3ca0f5e2e6b218c10580dbb90e7b)) +* strengthen capability handshake negotiation and feature gates ([#919](https://github.com/OneStepAt4time/aegis/issues/919)) ([151cf98](https://github.com/OneStepAt4time/aegis/commit/151cf9822957632625c537d56812e7ff7adfa598)) +* swarm parent matching via PID — Issue [#353](https://github.com/OneStepAt4time/aegis/issues/353) ([33de515](https://github.com/OneStepAt4time/aegis/commit/33de515fa50e5d8fd0eaa6ae612455eda62ec131)) +* swarm parent matching via PID (Issue [#353](https://github.com/OneStepAt4time/aegis/issues/353)) ([6a98760](https://github.com/OneStepAt4time/aegis/commit/6a987607eed2d41017e96dfad07fef0691389ef0)) +* systematic input validation at external boundaries (Cluster 2) ([#528](https://github.com/OneStepAt4time/aegis/issues/528)) ([d6192e7](https://github.com/OneStepAt4time/aegis/commit/d6192e781328a6819e3a17546f2939f5bf197fba)) +* tech debt sweep — type safety + build + empty catch blocks ([#515](https://github.com/OneStepAt4time/aegis/issues/515)-[#519](https://github.com/OneStepAt4time/aegis/issues/519), [#523](https://github.com/OneStepAt4time/aegis/issues/523), [#525](https://github.com/OneStepAt4time/aegis/issues/525)) ([#537](https://github.com/OneStepAt4time/aegis/issues/537)) ([c8c55f6](https://github.com/OneStepAt4time/aegis/commit/c8c55f60f77dd7d2e8fb8a23d8e1bd79fcb53f1e)) +* terminal parser edge cases — Issue [#362](https://github.com/OneStepAt4time/aegis/issues/362) ([87ac378](https://github.com/OneStepAt4time/aegis/commit/87ac3784a5d06b0721eadc986f7ac8973d0ac852)) +* terminal parser edge cases and false positives — Issue [#362](https://github.com/OneStepAt4time/aegis/issues/362) ([2859c86](https://github.com/OneStepAt4time/aegis/commit/2859c866df0ecd506450e5f85cad46fed8f2fd7f)) +* **terminal-parser:** make spinner search window configurable via named constant ([#758](https://github.com/OneStepAt4time/aegis/issues/758)) ([be7ecac](https://github.com/OneStepAt4time/aegis/commit/be7ecac8c6efd44621783da8b8f8ef0141e47632)) +* test mock assertion bugs and type errors ([44caf7f](https://github.com/OneStepAt4time/aegis/commit/44caf7fcaa576b59c01bf0d253c16d515f916fed)) +* tmux server crash recovery — health check, reconciliation, re-attach ([#602](https://github.com/OneStepAt4time/aegis/issues/602)) ([aeee38c](https://github.com/OneStepAt4time/aegis/commit/aeee38c04e0e2f1f1c36732d1a572e163550b467)), closes [#397](https://github.com/OneStepAt4time/aegis/issues/397) +* TmuxManager overhead and session creation latency — Issue [#363](https://github.com/OneStepAt4time/aegis/issues/363) ([093e25f](https://github.com/OneStepAt4time/aegis/commit/093e25fa1a5e9c9440d5d7f73a9d17b512b52401)) +* TmuxManager overhead and session creation latency — Issue [#363](https://github.com/OneStepAt4time/aegis/issues/363) ([1355d6d](https://github.com/OneStepAt4time/aegis/commit/1355d6d3386872a310136876b717552de9110740)) +* track untracked setTimeout timers in event bus and session discovery ([#834](https://github.com/OneStepAt4time/aegis/issues/834), [#835](https://github.com/OneStepAt4time/aegis/issues/835)) ([#848](https://github.com/OneStepAt4time/aegis/issues/848)) ([8ce0b9b](https://github.com/OneStepAt4time/aegis/commit/8ce0b9b57199dcb10402988d6d45fc9322f38c3f)) +* type-safe resource content access in MCP resource tests ([1a16a1d](https://github.com/OneStepAt4time/aegis/commit/1a16a1d9362ab295033b324c4f0013b4b9f68439)) +* **type-safety:** clean up globalEmitter and pending setImmediate timers on unsubscribe ([#769](https://github.com/OneStepAt4time/aegis/issues/769)) ([f2cd7e7](https://github.com/OneStepAt4time/aegis/commit/f2cd7e7b873523617f34b5890230ac1bc2c1281f)) +* **type-safety:** replace non-null assertion on getDeadLetterQueue with typeof guard ([#757](https://github.com/OneStepAt4time/aegis/issues/757)) ([57d7383](https://github.com/OneStepAt4time/aegis/commit/57d7383ad45af2d6c268b4894f377763a3cd7453)) +* **types:** remove double-cast escape hatches in production code ([#755](https://github.com/OneStepAt4time/aegis/issues/755)) ([d6f86ed](https://github.com/OneStepAt4time/aegis/commit/d6f86edfd4a65be605bbab6cc382668571d4c830)) +* **types:** replace StopHookPayload cast with Zod safeParse in hook.ts ([#1046](https://github.com/OneStepAt4time/aegis/issues/1046)) ([37dbce0](https://github.com/OneStepAt4time/aegis/commit/37dbce00e65d43b4f709e9746f609c963758144a)) +* unbounded maps and memory leaks — Issue [#357](https://github.com/OneStepAt4time/aegis/issues/357) ([b3983c3](https://github.com/OneStepAt4time/aegis/commit/b3983c30623d8ba16fd8b2b6bac5189023068926)) +* unbounded maps and memory leaks across modules — Issue [#357](https://github.com/OneStepAt4time/aegis/issues/357) ([411b138](https://github.com/OneStepAt4time/aegis/commit/411b13891cdfb7cf08c682782bb397941b07d55c)) +* update release-please manifest to reflect alpha version ([#1229](https://github.com/OneStepAt4time/aegis/issues/1229)) ([651ff23](https://github.com/OneStepAt4time/aegis/commit/651ff230dd0963d0bfcf1002f154acf58ae05cf6)), closes [#1210](https://github.com/OneStepAt4time/aegis/issues/1210) +* use bare on: in release.yml (GitHub Actions compatibility) ([#1327](https://github.com/OneStepAt4time/aegis/issues/1327)) ([45bd160](https://github.com/OneStepAt4time/aegis/commit/45bd1608b8e9e5d761b70b0fd72686bfe10c7f3c)) +* use correct aegis-bridge slug in release and skill metadata ([#806](https://github.com/OneStepAt4time/aegis/issues/806)) ([12d6640](https://github.com/OneStepAt4time/aegis/commit/12d6640f3bc1d8b2092ee98f468ac2996288f709)) +* use module-scoped nextKey counter in CreateSessionModal ([#639](https://github.com/OneStepAt4time/aegis/issues/639)) ([#928](https://github.com/OneStepAt4time/aegis/issues/928)) ([838cd03](https://github.com/OneStepAt4time/aegis/commit/838cd035972262b703b1cfea2745173b571ca3ba)) +* use npm pack to eliminate TOCTOU race in release workflow ([#649](https://github.com/OneStepAt4time/aegis/issues/649)) ([#944](https://github.com/OneStepAt4time/aegis/issues/944)) ([831fa01](https://github.com/OneStepAt4time/aegis/commit/831fa01e2f396a8730a9c38c52f2a9271fd8bbe7)) +* use plain v{version} tags instead of aegis-bridge-v{version} ([90a5e07](https://github.com/OneStepAt4time/aegis/commit/90a5e0722183af62981b8dc6bf5f97f35f4e6bce)) +* use short-lived SSE tokens ([5e32554](https://github.com/OneStepAt4time/aegis/commit/5e325540d7fbf0a490581eb6f030c56219db84ef)), closes [#297](https://github.com/OneStepAt4time/aegis/issues/297) +* use single-fd pattern in transcript reader to eliminate TOCTOU race ([#623](https://github.com/OneStepAt4time/aegis/issues/623)) ([895d248](https://github.com/OneStepAt4time/aegis/commit/895d248515e9cdba58f6c8df8667ce6736324625)) +* use timingSafeEqual for token comparison — Issue [#402](https://github.com/OneStepAt4time/aegis/issues/402) ([d401183](https://github.com/OneStepAt4time/aegis/commit/d4011837bbd24a4160d1fa5de3e81c4b05bb4d7a)) +* use timingSafeEqual for token comparison — Issue [#402](https://github.com/OneStepAt4time/aegis/issues/402) ([f1f26bd](https://github.com/OneStepAt4time/aegis/commit/f1f26bd4d19bd64f6be4709cc23851aac4ed09f0)) +* validate pipeline stage workDir for path traversal ([#631](https://github.com/OneStepAt4time/aegis/issues/631)) ([#906](https://github.com/OneStepAt4time/aegis/issues/906)) ([d715d80](https://github.com/OneStepAt4time/aegis/commit/d715d8060dc5b61994b34ee421bf129fc831d094)) +* validate port numbers in CLI with parseIntSafe — Issue [#359](https://github.com/OneStepAt4time/aegis/issues/359) ([4246d0d](https://github.com/OneStepAt4time/aegis/commit/4246d0dd7a53a73516087026a5b0c52c655ef986)) +* validate role query param in transcript endpoint ([#782](https://github.com/OneStepAt4time/aegis/issues/782)) ([eaf38ed](https://github.com/OneStepAt4time/aegis/commit/eaf38ed57ec4e65fdaed06db50a3c91f8d310717)) +* validate UUID format for session IDs and use exact workDir matching — Issue [#359](https://github.com/OneStepAt4time/aegis/issues/359) ([4e22b18](https://github.com/OneStepAt4time/aegis/commit/4e22b18fcd45261ccfc7e30df7bd249c6e95732e)) +* validateResponse throws on schema failure instead of returning unvalidated data ([#517](https://github.com/OneStepAt4time/aegis/issues/517)) ([#557](https://github.com/OneStepAt4time/aegis/issues/557)) ([df5f1cc](https://github.com/OneStepAt4time/aegis/commit/df5f1ccdf4c3e4b1533778dbb70c19708c4ddeab)) +* verify tmux window exists before returning idle session ([#636](https://github.com/OneStepAt4time/aegis/issues/636)) ([#818](https://github.com/OneStepAt4time/aegis/issues/818)) ([5a43fa1](https://github.com/OneStepAt4time/aegis/commit/5a43fa124905ba54843ea676f600b20f26fc9541)) +* WebSocket handshake auth + SSE subscription error handling ([#529](https://github.com/OneStepAt4time/aegis/issues/529)) ([f7088d2](https://github.com/OneStepAt4time/aegis/commit/f7088d20a711c4b3e24eabb320eb3354aff367f3)) +* **windows:** add platform utilities for process discovery and secure permissions ([#1073](https://github.com/OneStepAt4time/aegis/issues/1073)) ([7e10609](https://github.com/OneStepAt4time/aegis/commit/7e10609d504c413ae6b2d98a13a9053a8ead9940)) +* **windows:** normalize Claude project hash across unix/windows paths ([#1061](https://github.com/OneStepAt4time/aegis/issues/1061)) ([6735636](https://github.com/OneStepAt4time/aegis/commit/6735636fd0880d953e33ea356bb75d2a79ac3b26)) +* **windows:** resolve core blockers for env injection hooks and shutdown ([#1076](https://github.com/OneStepAt4time/aegis/issues/1076)) ([c36581f](https://github.com/OneStepAt4time/aegis/commit/c36581fcd36c688ad01ac0bef5ee884c9fd14a05)) +* **windows:** stabilize tmux/psmux parity for session lifecycle ([#1139](https://github.com/OneStepAt4time/aegis/issues/1139)) ([1bc8836](https://github.com/OneStepAt4time/aegis/commit/1bc88367deb6d040a7da86d284978f1a56f674df)) +* wrap SSE mutex await in try/finally to prevent deadlock ([#509](https://github.com/OneStepAt4time/aegis/issues/509)) ([#531](https://github.com/OneStepAt4time/aegis/issues/531)) ([1d624a0](https://github.com/OneStepAt4time/aegis/commit/1d624a0a32b72a53e58ed8bcc0aef83e2e5d22d9)) +* wrap SSE subscription in try-catch with auto-reconnect ([#721](https://github.com/OneStepAt4time/aegis/issues/721)) ([da80375](https://github.com/OneStepAt4time/aegis/commit/da80375fd8aeece353171d71dcb92494085fb1e1)) +* WS terminal security hardening ([9a49867](https://github.com/OneStepAt4time/aegis/commit/9a49867cde85f7e7a4ec675e6500f20cca4d8fb1)) + + +### Performance Improvements + +* adapt pipeline polling cadence based on SSE health ([#1014](https://github.com/OneStepAt4time/aegis/issues/1014)) ([0dc5acc](https://github.com/OneStepAt4time/aegis/commit/0dc5acc56199d4c9859dba376d6b7e259aa0c1f1)) +* **dashboard:** parallelize useSessionPolling API calls ([#1180](https://github.com/OneStepAt4time/aegis/issues/1180)) ([d67db68](https://github.com/OneStepAt4time/aegis/commit/d67db68049bcec96719e490d05638ec6eebfc10d)) +* **dashboard:** replace status-count N+1 calls with aggregated stats ([#1142](https://github.com/OneStepAt4time/aegis/issues/1142)) ([dc4acef](https://github.com/OneStepAt4time/aegis/commit/dc4acef3997d29780863dec3a949e52e3a97e4a3)) +* **dashboard:** virtualize transcript viewer render window ([#1047](https://github.com/OneStepAt4time/aegis/issues/1047)) ([837ab33](https://github.com/OneStepAt4time/aegis/commit/837ab332a65f1778be7a2523bbd0c89dad9f51db)) +* memoize SessionTable row view models ([#1015](https://github.com/OneStepAt4time/aegis/issues/1015)) ([3849b93](https://github.com/OneStepAt4time/aegis/commit/3849b932b45cbef8fe8466203bd3050a4a0b2bd8)) +* optimize LiveTerminal pane rendering and TranscriptViewer key tracking ([#933](https://github.com/OneStepAt4time/aegis/issues/933)) ([4d95e32](https://github.com/OneStepAt4time/aegis/commit/4d95e3242886b1e42577171450e8c75ab8447aa1)) +* optimize stall detection, session listing, and transcript discovery ([#932](https://github.com/OneStepAt4time/aegis/issues/932)) ([2330a71](https://github.com/OneStepAt4time/aegis/commit/2330a7149d3ed4f462c3e5da78a7cd0f75ec1842)) +* replace event buffer splice with circular queue ([#904](https://github.com/OneStepAt4time/aegis/issues/904)) ([548ec59](https://github.com/OneStepAt4time/aegis/commit/548ec59beb407afd30f3ee63ffa542c1cfd26515)) +* replace O(n) shift() with O(1) index-based pruning in IP rate limiter ([#787](https://github.com/OneStepAt4time/aegis/issues/787)) ([58a8ced](https://github.com/OneStepAt4time/aegis/commit/58a8ced1700f4b7ba29689ce0eceff4cb816111f)) +* replace sync pid file read with async I/O ([#1004](https://github.com/OneStepAt4time/aegis/issues/1004)) ([b1ac7b9](https://github.com/OneStepAt4time/aegis/commit/b1ac7b9b5e5ba1687dd47b3699285421a0effa64)) +* stream-aggregate latency in getGlobalMetrics instead of copying all samples ([#785](https://github.com/OneStepAt4time/aegis/issues/785)) ([4773b49](https://github.com/OneStepAt4time/aegis/commit/4773b492dad067beb044ff3bb62cdcdf70ff3504)) + + +### Reverts + +* undo version bump and watermark fix (npm token issue) ([4ee8f5f](https://github.com/OneStepAt4time/aegis/commit/4ee8f5f706960cce485546684b6909b3946a6b1f)) + ## [0.3.2-alpha](https://github.com/OneStepAt4time/aegis/compare/v0.3.1-alpha...v0.3.2-alpha) (2026-04-08) diff --git a/CLAUDE.md b/CLAUDE.md index 84b5ca6e..2faf51cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ src/ ├── terminal-parser.ts # Claude Code UI state detection ├── monitor.ts # Stall detection, events ├── pipeline.ts # Batch/multi-stage orchestration +├── consensus.ts # Multi-agent review ├── auth.ts # API key management └── config.ts # Configuration (AEGIS_* env vars) ``` diff --git a/README.md b/README.md index d41c70de..61cd1f3b 100644 --- a/README.md +++ b/README.md @@ -327,47 +327,12 @@ npx @onestepat4time/aegis # visit http://localhost:9100/dashboard/ --- -## Use Cases & Deployment Tiers - -Aegis serves three deployment scenarios: - -### Tier 1 — Local Orchestration -**Single developer.** Run Claude Code tasks in the background, monitor via dashboard, approve via Telegram. - -```bash -aegis -# Dashboard: http://localhost:9100/dashboard/ -# Telegram approvals while AFK -``` - -### Tier 2 — CI/CD & Team Automation -**Development teams.** Policy-based permission control, batch operations, Slack notifications. - -```bash -# Blueprint: PR Reviewer -curl -X POST http://localhost:9100/v1/pipelines \ - -H "Authorization: Bearer $TOKEN" \ - -d '{"name":"pr-reviewer","stages":[...],"permissionMode":"plan"}' -``` - -### Tier 3 — Zero-Trust Enterprise -**Banks, SaaS, regulated industries.** Docker-isolated containers, no network egress, audit-first. - -- Each task runs in an ephemeral Docker container -- No cross-container networking -- Immutable audit log for compliance -- See [Enterprise Deployment](docs/enterprise.md) for production hardening guide - -**Golden rule:** Intelligence stays outside Aegis. Aegis is a stupid-but-powerful middleware — flows, security, audit. OpenClaw (or any external orchestrator) provides the brains. - ---- - ## Security Aegis includes built-in security defaults: - **Permission mode** — `default` requires approval for dangerous operations (shell commands, file writes). Change with `permissionMode` when creating a session. -- **Hook secrets** — use `X-Hook-Secret` header (preferred). Query-param `secret` remains backward compatible by default but is deprecated. +- **Hook secrets** — webhook and hook secrets are passed via headers (not query params) to prevent log leakage. - **Auth tokens** — protect the API with `AEGIS_AUTH_TOKEN` (Bearer auth on all endpoints except `/v1/health`). - **WebSocket auth** — session existence is not revealed before authentication. @@ -387,7 +352,6 @@ Aegis includes built-in security defaults: | `AEGIS_TG_TOKEN` | — | Telegram bot token | | `AEGIS_TG_GROUP` | — | Telegram group chat ID | | `AEGIS_WEBHOOKS` | — | Webhook URLs (comma-separated) | -| `AEGIS_HOOK_SECRET_HEADER_ONLY` | false | Enforce `X-Hook-Secret` header and reject deprecated `?secret=` transport | --- diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index adb4e71a..9f2544fb 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -30,7 +30,7 @@ export default function App() { }> @@ -41,7 +41,7 @@ export default function App() { }> }> }> @@ -49,7 +49,7 @@ export default function App() { } /> }> @@ -57,7 +57,7 @@ export default function App() { } /> }> @@ -65,7 +65,7 @@ export default function App() { } /> }> @@ -73,7 +73,7 @@ export default function App() { } /> }> @@ -81,7 +81,7 @@ export default function App() { } /> }> diff --git a/dashboard/src/store/useAuthStore.ts b/dashboard/src/store/useAuthStore.ts index 3f7c006d..67d2e40c 100644 --- a/dashboard/src/store/useAuthStore.ts +++ b/dashboard/src/store/useAuthStore.ts @@ -32,7 +32,7 @@ function clearAuthState(set: (partial: Partial) => void): void { export const useAuthStore = create((set, get) => ({ token: localStorage.getItem(TOKEN_KEY), isAuthenticated: false, - isVerifying: false, + isVerifying: true, lastVerifiedAt: null, login: async (token: string): Promise => { diff --git a/docs/adr/0017-opentelemetry-tracing.md b/docs/adr/0017-opentelemetry-tracing.md deleted file mode 100644 index 55da5f98..00000000 --- a/docs/adr/0017-opentelemetry-tracing.md +++ /dev/null @@ -1,104 +0,0 @@ -# ADR-0017: OpenTelemetry Distributed Tracing - -## Status -Accepted (research spike) - -## Context - -Aegis orchestrates Claude Code sessions through a multi-layered request flow: - -``` -Fastify HTTP → SessionManager → TmuxManager → SessionMonitor -``` - -When issues occur (stuck sessions, slow prompts, delivery failures), there is no -way to correlate events across these layers. Log entries are siloed by component, -making root-cause analysis difficult. - -## Decision - -Instrument Aegis with OpenTelemetry using `@opentelemetry/sdk-node` auto-instrumentation -for Fastify + HTTP, plus manual spans for the session/tmux/monitor cycle. - -### Architecture - -``` -src/tracing.ts ← New module: SDK setup, tracer, span helpers -src/server.ts ← Initialize tracing before Fastify starts -src/config.ts ← AEGIS_OTEL_* env vars (optional) -``` - -### Configuration - -All tracing config is via environment variables (all optional): - -| Variable | Default | Description | -|---|---|---| -| `AEGIS_OTEL_ENABLED` | `false` | Enable tracing | -| `AEGIS_OTEL_SERVICE_NAME` | `aegis` | Service name in traces | -| `AEGIS_OTEL_OTLP_ENDPOINT` | `http://localhost:4318` | OTLP exporter endpoint | -| `AEGIS_OTEL_SAMPLE_RATE` | `1.0` | Head-based sampling ratio (0.0–1.0) | - -### Span taxonomy - -| Span name | Kind | Attributes | -|---|---|---| -| `session.create` | INTERNAL | `aegis.session.id`, `workDir` | -| `session.send` | INTERNAL | `aegis.session.id` | -| `session.kill` | INTERNAL | `aegis.session.id` | -| `tmux.send-keys` | INTERNAL | `aegis.tmux.window_id` | -| `tmux.capture-pane` | INTERNAL | `aegis.tmux.window_id` | -| `tmux.create-window` | INTERNAL | `aegis.tmux.window_id`, `workDir` | -| `monitor.poll` | INTERNAL | (none) | -| `monitor.stall_check` | INTERNAL | `stall_type` | -| HTTP spans | SERVER | Auto-instrumented by `@opentelemetry/instrumentation-fastify` | - -### Exporter choice: OTLP over HTTP/protobuf - -**Chosen:** `@opentelemetry/exporter-trace-otlp-http` - -**Alternatives considered:** - -| Exporter | Pros | Cons | -|---|---|---| -| **OTLP HTTP (chosen)** | Broad backend compatibility (Jaeger, Tempo, Honeycomb), no gRPC dependency | Slightly more overhead than gRPC | -| OTLP gRPC | Lower latency | Requires gRPC deps, proxy complexity | -| Jaeger exporter | Direct Jaeger support | Vendor-locked, deprecated in favor of OTLP | - -### Sampling strategy - -- **Default:** AlwaysOn (sample rate 1.0) — all traces are exported. -- **Production:** `ParentBasedSampler(TraceIdRatioBasedSampler(0.1))` — 10% of root spans, - 100% of child spans from sampled parents. This preserves request flows while reducing - volume. -- Configured via `AEGIS_OTEL_SAMPLE_RATE`. - -### Auto-instrumentation - -`@opentelemetry/auto-instrumentations-node` provides automatic spans for: -- **Fastify** — all HTTP routes get `server.request` spans with route, method, status code. -- **HTTP** — outgoing HTTP client calls (if any). -- **Disabled:** `fs` and `dns` instrumentations (too noisy for Aegis's file-heavy workload). - -### No-op when disabled - -When `AEGIS_OTEL_ENABLED` is not set, `tracing.ts` returns a no-op tracer that creates -non-recording spans with zero allocation overhead. No OTel SDK code is loaded. - -## Consequences - -### Positive -- Request flows can be correlated end-to-end in a tracing backend (Jaeger, Grafana Tempo, etc.) -- Auto-instrumented Fastify spans require zero code changes for HTTP layer tracing -- Manual span helpers make it easy to instrument the session/tmux/monitor cycle -- No-op fallback means zero overhead when tracing is off - -### Risks -- Additional dependency weight (~2MB for OTel SDK packages) -- Auto-instrumentation may add latency to hot paths (mitigated by no-op when disabled) -- OTLP exporter batches are lost if the process crashes before flush - -### Future work -- Add manual spans to `session.ts`, `tmux.ts`, and `monitor.ts` (wiring only, no behavior change) -- Add span context propagation to webhook/SSE event payloads for cross-service correlation -- Evaluate `@opentelemetry/instrumentation-async-hooks` for better async context propagation diff --git a/docs/api-reference.md b/docs/api-reference.md index 255c2803..96ee2ba2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -16,22 +16,6 @@ curl http://localhost:9100/v1/sessions For multi-key auth, see [Enterprise Deployment](./enterprise.md#authentication). -## Claude Code Hook Receiver Authentication - -`POST /v1/hooks/{eventName}` is the inbound callback endpoint used by Claude Code hooks. - -- Send the session ID via `X-Session-Id` header (or `sessionId` query fallback). -- Send the hook secret via `X-Hook-Secret` header. -- Query-param `secret` is deprecated in compatibility mode and logs a warning. -- Set `AEGIS_HOOK_SECRET_HEADER_ONLY=true` to enforce header-only secret transport and reject query-param secrets. - -```bash -curl -X POST "http://localhost:9100/v1/hooks/Stop?sessionId=" \ - -H "X-Hook-Secret: " \ - -H "Content-Type: application/json" \ - -d '{}' -``` - --- ## Core Endpoints diff --git a/docs/architecture.md b/docs/architecture.md index 4510380d..8d35e8af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,10 +8,9 @@ Aegis is built around a layered architecture: a CLI entrypoint, a Fastify HTTP s src/ ├── cli.ts # CLI entrypoint — parses args, starts server or MCP ├── startup.ts # Server bootstrap — PID file, graceful shutdown -├── container.ts # Lightweight DI container + lifecycle orchestration ├── server.ts # Fastify HTTP server — all REST routes ├── config.ts # Configuration loading from env + config file -├── auth.ts # Backward-compatible auth re-export (services/auth) +├── auth.ts # API key management and bearer token classification │ ├── session.ts # Session lifecycle — create, send, kill, state tracking ├── session-cleanup.ts # Idle session reaping and resource cleanup @@ -48,16 +47,7 @@ src/ ├── ws-terminal.ts # WebSocket terminal relay │ ├── permission-guard.ts # Permission request interception and routing -├── services/ -│ ├── auth/ -│ │ ├── AuthManager.ts # API key management and bearer token classification -│ │ ├── RateLimiter.ts # Route-level IP and auth-failure rate limiting -│ │ ├── types.ts # Auth manager and API key types -│ │ └── index.ts # Auth service exports -│ └── permission/ -│ ├── evaluator.ts # Permission profile evaluation logic -│ ├── types.ts # Permission evaluator input/output types -│ └── index.ts # Permission evaluator exports +├── permission-evaluator.ts # Permission profile evaluation logic ├── permission-request-manager.ts # Permission request queue and lifecycle ├── permission-routes.ts # REST endpoints for approve/reject/list permissions │ @@ -118,7 +108,7 @@ dashboard/ # React dashboard (served by Fastify static) | `cli.ts` | Parses CLI arguments, delegates to `server.ts` or `mcp-server.ts` | | `startup.ts` | Writes PID file, registers signal handlers, coordinates shutdown | | `config.ts` | Loads config from `aegis.config.json` and environment variables | -| `services/auth/AuthManager.ts` | Manages API keys and classifies bearer tokens for route protection | +| `auth.ts` | Manages API keys and classifies bearer tokens for route protection | ### 2. Session Management @@ -172,7 +162,7 @@ dashboard/ # React dashboard (served by Fastify static) | Module | Purpose | |---|---| | `permission-guard.ts` | Intercepts permission requests and routes to evaluator | -| `services/permission/evaluator.ts` | Evaluates permission requests against profiles | +| `permission-evaluator.ts` | Evaluates permission requests against profiles | | `permission-request-manager.ts` | Queues and tracks pending permission requests | | `permission-routes.ts` | REST endpoints: approve, reject, list pending permissions | @@ -224,7 +214,7 @@ Client (curl / MCP / Dashboard) ▼ server.ts (Fastify, port 9100) │ - ├─ services/auth/AuthManager.ts (bearer token validation) + ├─ auth.ts (bearer token validation) │ ├─ api-contracts.ts (request validation) │ @@ -245,25 +235,3 @@ server.ts (Fastify, port 9100) └─ mcp-server.ts (MCP protocol, stdio) └─ tool-registry.ts (tool dispatch) ``` - -## Service lifecycle dependency graph - -Issue #1622 introduces explicit service registration and dependency-driven startup/shutdown in `src/container.ts`. - -```text -tmuxManager - └─ sessionManager - ├─ channelManager - └─ sessionMonitor -authManager -``` - -| Service | Depends on | Startup action | Shutdown action | -|---|---|---|---| -| `tmuxManager` | — | `tmux.ensureSession()` | no-op | -| `sessionManager` | `tmuxManager` | `sessions.load()` | `sessions.save()` | -| `authManager` | — | `auth.load()` | no-op | -| `channelManager` | `sessionManager` | `channels.init(handleInbound)` | `channels.destroy()` | -| `sessionMonitor` | `tmuxManager`, `sessionManager`, `channelManager` | `monitor.start()` | `monitor.stop()` | - -Startup follows topological order from the dependency graph. Graceful shutdown runs in the reverse order with per-service timeout protection. diff --git a/openapi.yaml b/openapi.yaml index b89bb8fb..d78aa574 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -250,78 +250,6 @@ paths: message: type: string - /v1/hooks/{eventName}: - post: - operationId: receiveHookEvent - summary: Receive Claude Code hook event - tags: [Hooks] - security: [] - description: | - Inbound callback endpoint for Claude Code HTTP hooks. - - Preferred transport is `X-Hook-Secret` header authentication. - Query-param `secret` is deprecated and only accepted in compatibility mode - (`AEGIS_HOOK_SECRET_HEADER_ONLY=false`, default). When - `AEGIS_HOOK_SECRET_HEADER_ONLY=true`, any request using query-param - hook secrets is rejected. - parameters: - - name: eventName - in: path - required: true - schema: - type: string - description: Hook event name (for example Stop, PreToolUse, PermissionRequest) - - name: X-Session-Id - in: header - required: false - schema: - type: string - format: uuid - description: Session ID (preferred transport). - - name: sessionId - in: query - required: false - schema: - type: string - format: uuid - description: Session ID fallback when header transport is unavailable. - - name: X-Hook-Secret - in: header - required: false - schema: - type: string - description: Hook secret header. Required when the session has a configured hook secret. - - name: secret - in: query - required: false - deprecated: true - schema: - type: string - description: | - Deprecated query-param hook secret. - Accepted only in compatibility mode and rejected in header-only mode. - requestBody: - required: true - content: - application/json: - schema: - type: object - additionalProperties: true - responses: - '200': - description: Hook event accepted - content: - application/json: - schema: - type: object - additionalProperties: true - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - # ─── Sessions ───────────────────────────────────────────────────────────── /v1/sessions: diff --git a/package-lock.json b/package-lock.json index 0b39da52..a0581185 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,25 +1,18 @@ { "name": "aegis-bridge", - "version": "0.3.2-alpha", + "version": "0.4.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aegis-bridge", - "version": "0.3.2-alpha", + "version": "0.4.0-alpha", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", - "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^9.0.0", "@fastify/websocket": "^11.2.0", "@modelcontextprotocol/sdk": "^1.28.0", - "@opentelemetry/api": "^1.9.1", - "@opentelemetry/auto-instrumentations-node": "^0.72.0", - "@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", - "@opentelemetry/sdk-node": "^0.214.0", - "@opentelemetry/sdk-trace-base": "^2.6.1", "@tanstack/react-virtual": "^3.13.23", "@types/nodemailer": "^8.0.0", "async-mutex": "^0.5.0", @@ -426,27 +419,6 @@ "ipaddr.js": "^2.1.0" } }, - "node_modules/@fastify/rate-limit": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", - "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@lukeed/ms": "^2.0.2", - "fastify-plugin": "^5.0.0", - "toad-cache": "^3.7.0" - } - }, "node_modules/@fastify/send": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", @@ -529,37 +501,6 @@ "@shikijs/vscode-textmate": "^10.0.2" } }, - "node_modules/@grpc/grpc-js": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", - "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/proto-loader": "^0.8.0", - "@js-sdsl/ordered-map": "^4.4.2" - }, - "engines": { - "node": ">=12.10.0" - } - }, - "node_modules/@grpc/proto-loader": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", - "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", - "license": "Apache-2.0", - "dependencies": { - "lodash.camelcase": "^4.3.0", - "long": "^5.0.0", - "protobufjs": "^7.5.3", - "yargs": "^17.7.2" - }, - "bin": { - "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/@hono/node-server": { "version": "1.19.13", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", @@ -652,16 +593,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@js-sdsl/ordered-map": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", - "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" - } - }, "node_modules/@lukeed/ms": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", @@ -777,1420 +708,21 @@ "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", - "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/auto-instrumentations-node": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.72.0.tgz", - "integrity": "sha512-OmzmCENHbvnbt6U+dIj4v75FL6lV+b10Id70AL++iuGTrOeqpDyh04t51KeHN70NEHvzl+kEglcDlZqgmL0LLA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/instrumentation-amqplib": "^0.61.0", - "@opentelemetry/instrumentation-aws-lambda": "^0.66.0", - "@opentelemetry/instrumentation-aws-sdk": "^0.69.0", - "@opentelemetry/instrumentation-bunyan": "^0.59.0", - "@opentelemetry/instrumentation-cassandra-driver": "^0.59.0", - "@opentelemetry/instrumentation-connect": "^0.57.0", - "@opentelemetry/instrumentation-cucumber": "^0.30.0", - "@opentelemetry/instrumentation-dataloader": "^0.31.0", - "@opentelemetry/instrumentation-dns": "^0.57.0", - "@opentelemetry/instrumentation-express": "^0.62.0", - "@opentelemetry/instrumentation-fs": "^0.33.0", - "@opentelemetry/instrumentation-generic-pool": "^0.57.0", - "@opentelemetry/instrumentation-graphql": "^0.62.0", - "@opentelemetry/instrumentation-grpc": "^0.214.0", - "@opentelemetry/instrumentation-hapi": "^0.60.0", - "@opentelemetry/instrumentation-http": "^0.214.0", - "@opentelemetry/instrumentation-ioredis": "^0.62.0", - "@opentelemetry/instrumentation-kafkajs": "^0.23.0", - "@opentelemetry/instrumentation-knex": "^0.58.0", - "@opentelemetry/instrumentation-koa": "^0.62.0", - "@opentelemetry/instrumentation-lru-memoizer": "^0.58.0", - "@opentelemetry/instrumentation-memcached": "^0.57.0", - "@opentelemetry/instrumentation-mongodb": "^0.67.0", - "@opentelemetry/instrumentation-mongoose": "^0.60.0", - "@opentelemetry/instrumentation-mysql": "^0.60.0", - "@opentelemetry/instrumentation-mysql2": "^0.60.0", - "@opentelemetry/instrumentation-nestjs-core": "^0.60.0", - "@opentelemetry/instrumentation-net": "^0.58.0", - "@opentelemetry/instrumentation-openai": "^0.12.0", - "@opentelemetry/instrumentation-oracledb": "^0.39.0", - "@opentelemetry/instrumentation-pg": "^0.66.0", - "@opentelemetry/instrumentation-pino": "^0.60.0", - "@opentelemetry/instrumentation-redis": "^0.62.0", - "@opentelemetry/instrumentation-restify": "^0.59.0", - "@opentelemetry/instrumentation-router": "^0.58.0", - "@opentelemetry/instrumentation-runtime-node": "^0.27.0", - "@opentelemetry/instrumentation-socket.io": "^0.61.0", - "@opentelemetry/instrumentation-tedious": "^0.33.0", - "@opentelemetry/instrumentation-undici": "^0.24.0", - "@opentelemetry/instrumentation-winston": "^0.58.0", - "@opentelemetry/resource-detector-alibaba-cloud": "^0.33.4", - "@opentelemetry/resource-detector-aws": "^2.14.0", - "@opentelemetry/resource-detector-azure": "^0.22.0", - "@opentelemetry/resource-detector-container": "^0.8.5", - "@opentelemetry/resource-detector-gcp": "^0.49.0", - "@opentelemetry/resources": "^2.0.0", - "@opentelemetry/sdk-node": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.4.1", - "@opentelemetry/core": "^2.0.0" - } - }, - "node_modules/@opentelemetry/configuration": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/configuration/-/configuration-0.214.0.tgz", - "integrity": "sha512-Q+awuEwxhETwIAXuxHvIY5ZMEP0ZqvxLTi9kclrkyVJppEUXYL3Bhiw3jYrxdHYMh0Y0tVInQH9FEZ1aMinvLA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "yaml": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.1.tgz", - "integrity": "sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.214.0.tgz", - "integrity": "sha512-SwmFRwO8mi6nndzbsjPgSFg7qy1WeNHRFD+s6uCsdiUDUt3+yzI2qiHE3/ub2f37+/CbeGcG+Ugc8Gwr6nu2Aw==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/sdk-logs": "0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.214.0.tgz", - "integrity": "sha512-9qv2Tl/Hq6qc5pJCbzFJnzA0uvlb9DgM70yGJPYf3bA5LlLkRCpcn81i4JbcIH4grlQIWY6A+W7YG0LLvS1BAw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/sdk-logs": "0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-proto": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.214.0.tgz", - "integrity": "sha512-IWAVvCO1TlpotRjFmhQFz9RSfQy5BsLtDRBtptSrXZRwfyRPpuql/RMe5zdmu0Gxl3ERDFwOzOqkf3bwy7Jzcw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-logs": "0.214.0", - "@opentelemetry/sdk-trace-base": "2.6.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.214.0.tgz", - "integrity": "sha512-0NGxWHVYHgbp51SEzmsP+Hdups81eRs229STcSWHo3WO0aqY6RpJ9csxfyEtFgaNrBDv6UfOh0je4ss/ROS6XA==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-metrics": "2.6.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.214.0.tgz", - "integrity": "sha512-Tx/59RmjBgkXJ3qnsD04rpDrVWL53LU/czpgLJh+Ab98nAroe91I7vZ3uGN9mxwPS0jsZEnmqmHygVwB2vRMlA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-metrics": "2.6.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.214.0.tgz", - "integrity": "sha512-pJIcghFGhx3VSCgP5U+yZx+OMNj0t+ttnhC8IjL5Wza7vWIczctF6t3AGcVQffi2dEqX+ZHANoBwoPR8y6RMKA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-metrics": "2.6.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-prometheus": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.214.0.tgz", - "integrity": "sha512-4TGYoZKebUWVuYkV6r5wS2dUF4zH7EbWFw/Uqz1ZM1tGHQeFT9wzHGXq3iSIXMUrwu5jRdxjfMaXrYejPu2kpQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-metrics": "2.6.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.214.0.tgz", - "integrity": "sha512-FWRZ7AWoTryYhthralHkfXUuyO3l7cRsnr49WcDio1orl2a7KxT8aDZdwQtV1adzoUvZ9Gfo+IstElghCS4zfw==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.214.0.tgz", - "integrity": "sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.214.0.tgz", - "integrity": "sha512-ON0spYWb2yAdQ9b+ItNyK0c6qdtcs+0eVR4YFJkhJL7agfT8sHFg0e5EesauSRiTHPZHiDobI92k77q0lwAmqg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-zipkin": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.6.1.tgz", - "integrity": "sha512-km2/hD3inLTqtLnUAHDGz7ZP/VOyZNslrC/iN66x4jkmpckwlONW54LRPNI6fm09/musDtZga9EWsxgwnjGUlw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/instrumentation": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", - "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "import-in-the-middle": "^3.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.61.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.61.0.tgz", - "integrity": "sha512-mCKoyTGfRNisge4br0NpOFSy2Z1NnEW8hbCJdUDdJFHrPqVzc4IIBPA/vX0U+LUcQqrQvJX+HMIU0dbDRe0i0Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-aws-lambda": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.66.0.tgz", - "integrity": "sha512-ObWWLwgjMXTsvete1O78ULLEKur9GdFLR+TvGGb56Srih7ifwcWa2jsnq+4PI8k5wwHuEyxB5SlMjwkKW7rTCQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/aws-lambda": "^8.10.155" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-aws-sdk": { - "version": "0.69.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.69.0.tgz", - "integrity": "sha512-JfSp3anFL5Lx/ysQSa4MnKxvSsXSnYpgQ831Y+yNs5wJZcJC4tB+YpnKH+bU5oFdKEF59FpI6Gn5Wg2vjVpR2A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.34.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-bunyan": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-bunyan/-/instrumentation-bunyan-0.59.0.tgz", - "integrity": "sha512-XaZoIpc2U/WxE//kEyQsGuke9JezPOeeWJUkbHkZt+ojzPbYcAXZR4m9KmxSNbHu++bx1Zy3oBQ3erEXHGoDqA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "^0.214.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@types/bunyan": "1.8.11" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-cassandra-driver": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.59.0.tgz", - "integrity": "sha512-WtbENFKo4HRBwyffUEN+LSTdjDrBMyfaEYO362VVEhLoFWsFbGGXVApL7rIOhM2LjL04Oel6uJyJC6E4nvCgAA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.37.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-connect": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.57.0.tgz", - "integrity": "sha512-FMEBChnI4FLN5TE9DHwfH7QpNir1JzXno1uz/TAucVdLCyrG0jTrKIcNHt/i30A0M2AunNBCkcd8Ei26dIPKdg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/connect": "3.4.38" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-cucumber": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.30.0.tgz", - "integrity": "sha512-Zx/PXw5o6VkMRcDT+SizbBTJiWdnkivsrVeFgaT1KM14bSbBULPNms+NX6/gsgD0Mkfik3np7HjfKyvipwQ9FA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/instrumentation-dataloader": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.31.0.tgz", - "integrity": "sha512-f654tZFQXS5YeLDNb9KySrwtg7SnqZN119FauD7acBoTzuLduaiGTNz88ixcVSOOMGZ+EjJu/RFtx5klObC95g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-dns": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dns/-/instrumentation-dns-0.57.0.tgz", - "integrity": "sha512-VJ0p1y0lPhDTIT/kuSgZOG2FJceFQfFgjKCz6k0rh+MyZKwEDTqvmkZUbA8qwgWB5m3fMqttK73jWZyzQNZnTw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-express": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.62.0.tgz", - "integrity": "sha512-Tvx+vgAZKEQxU3Rx+xWLiR0mLxHwmk69/8ya04+VsV9WYh8w6Lhx5hm5yAMvo1wy0KqWgFKBLwSeo3sHCwdOww==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-fs": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.33.0.tgz", - "integrity": "sha512-sCZWXGalQ01wr3tAhSR9ucqFJ0phidpAle6/17HVjD6gN8FLmZMK/8sKxdXYHy3PbnlV1P4zeiSVFNKpbFMNLA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-generic-pool": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.57.0.tgz", - "integrity": "sha512-orhmlaK+ZIW9hKU+nHTbXrCSXZcH83AescTqmpamHRobRmYSQwRbD0a1odc0yAzuzOtxYiHiXAnpnIpaSSY7Ow==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-graphql": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.62.0.tgz", - "integrity": "sha512-3YNuLVPUxafXkH1jBAbGsKNsP3XVzcFDhCDCE3OqBwCwShlqQbLMRMFh1T/d5jaVZiGVmSsfof+ICKD2iOV8xg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-grpc": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.214.0.tgz", - "integrity": "sha512-qU7NMLuXvu+ZvX6LJWJuxfqHvUvCAexduBWnM7OFUVHnkwo/HorWa9qyDFBXEdUE2fypCcYWZkon37wv9y/lDw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "0.214.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-hapi": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.60.0.tgz", - "integrity": "sha512-aNljZKYrEa7obLAxd1bCEDxF7kzCLGXTuTJZ8lMR9rIVEjmuKBXN1gfqpm/OB//Zc2zP4iIve1jBp7sr3mQV6w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.214.0.tgz", - "integrity": "sha512-FlkDhZDRjDJDcO2LcSCtjRpkal1NJ8y0fBqBhTvfAR3JSYY2jAIj1kSS5IjmEBt4c3aWv+u/lqLuoCDrrKCSKg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/instrumentation": "0.214.0", - "@opentelemetry/semantic-conventions": "^1.29.0", - "forwarded-parse": "2.1.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-ioredis": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.62.0.tgz", - "integrity": "sha512-ZYt//zcPve8qklaZX+5Z4MkU7UpEkFRrxsf2cnaKYBitqDnsCN69CPAuuMOX6NYdW2rG9sFy7V/QWtBlP5XiNQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/redis-common": "^0.38.2", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-kafkajs": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.23.0.tgz", - "integrity": "sha512-4K+nVo+zI+aDz0Z85SObwbdixIbzS9moIuKJaYsdlzcHYnKOPtB7ya8r8Ezivy/GVIBHiKJVq4tv+BEkgOMLaQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.30.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.58.0.tgz", - "integrity": "sha512-Hc/o8fSsaWxZ8r1Yw4rNDLwTpUopTf4X32y4W6UhlHmW8Wizz8wfhgOKIelSeqFVTKBBPIDUOsQWuIMxBmu8Bw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-koa": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.62.0.tgz", - "integrity": "sha512-uVip0VuGUQXZ+vFxkKxAUNq8qNl+VFlyHDh/U6IQ8COOEDfbEchdaHnpFrMYF3psZRUuoSIgb7xOeXj00RdwDA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.36.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.58.0.tgz", - "integrity": "sha512-6grM3TdMyHzlGY1cUA+mwoPueB1F3dYKgKtZIH6jOFXqfHAByyLTc+6PFjGM9tKh52CFBJaDwodNlL/Td39z7Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-memcached": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.57.0.tgz", - "integrity": "sha512-z/a4vC+hmQn4o+NYgDlQE5DJNKH9nwtzvTOAgG1bwO1hdX+w9Nr3kd9dKRwN7e6EiQESrPCh6iiE0xwh9x1WDw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/memcached": "^2.2.6" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongodb": { - "version": "0.67.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.67.0.tgz", - "integrity": "sha512-1WJp5N1lYfHq2IhECOTewFs5Tf2NfUOwQRqs/rZdXKTezArMlucxgzAaqcgp3A3YREXopXTpXHsxZTGHjNhMdQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongoose": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.60.0.tgz", - "integrity": "sha512-8BahAZpKsOoc+lrZGb7Ofn4g3z8qtp5IxDfvAVpKXsEheQN7ONMH5djT5ihy6yf8yyeQJGS0gXFfpEAEeEHqQg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.60.0.tgz", - "integrity": "sha512-08pO8GFPEIz2zquKDGteBZDNmwketdgH8hTe9rVYgW9kCJXq1Psj3wPQGx+VaX4ZJKCfPeoLMYup9+cxHvZyVQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/mysql": "2.15.27" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.60.0.tgz", - "integrity": "sha512-m/5d3bxQALllCzezYDk/6vajh0tj5OijMMvOZGr+qN1NMXm1dzMNwyJ0gNZW7Fo3YFRyj/jJMxIw+W7d525dlw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@opentelemetry/sql-common": "^0.41.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-nestjs-core": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.60.0.tgz", - "integrity": "sha512-BZqFAoD+frnwjpb0/T4kEEQMhl2YykZch4n2MMLKAVTzTehTBBV2hZxvFF629ipS+WOGBKjCjz1dycU9QNIckQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.30.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-net": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-net/-/instrumentation-net-0.58.0.tgz", - "integrity": "sha512-NkvEqgt8etd4dwJ+KlKMBzf7SQd+TVVu5UlB1Rt8aOabZ7X3QWCnkgRzfXozAMkZJmUQ3KV4NsBI5nvmngNUdA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-openai": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-openai/-/instrumentation-openai-0.12.0.tgz", - "integrity": "sha512-HPEw6Zgk/6oMgO/azb7TuYziaU87FnaFTpd74MXqPk2YUhCcRFwT3YZywO/VQ0sjhDX/TqTPEMemTEPwuQNU4w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "^0.214.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.36.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-oracledb": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-oracledb/-/instrumentation-oracledb-0.39.0.tgz", - "integrity": "sha512-CmRiX9Khbui9CQS3ZOOmf8RfXdmwSdVJAWQUk8S/gQqlm7xwK853rsP5T1GBSqGyntM9c2En3KpgRGvmk+LCvg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.34.0", - "@types/oracledb": "6.5.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.66.0.tgz", - "integrity": "sha512-KxfLGXBb7k2ueaPJfq2GXBDXBly8P+SpR/4Mj410hhNgmQF3sCqwXvUBQxZQkDAmsdBAoenM+yV1LhtsMRamcA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.34.0", - "@opentelemetry/sql-common": "^0.41.2", - "@types/pg": "8.15.6", - "@types/pg-pool": "2.0.7" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-pino": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.60.0.tgz", - "integrity": "sha512-B36CgHiloKjkFlXkyh3qb4E/KNdnQiO6q8NqKBjYayvvZodshnvz5kPyaV+Fk0N30NwOHn/JgmO1x5tcjYtUvQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "^0.214.0", - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-redis": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.62.0.tgz", - "integrity": "sha512-y3pPpot7WzR/8JtHcYlTYsyY8g+pbFhAqbwAuG5bLPnR6v6pt1rQc0DpH0OlGP/9CZbWBP+Zhwp9yFoygf/ZXQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/redis-common": "^0.38.2", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-restify": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-restify/-/instrumentation-restify-0.59.0.tgz", - "integrity": "sha512-zQ8M7acaHR3STolma45wLqleYJdRMs+cuVtyVgHSBZusyv6FTDxQs8sGVfvitmxThUATo/xlbXSUEwEO/itgLg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-router": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-router/-/instrumentation-router-0.58.0.tgz", - "integrity": "sha512-0txTRUeQn+nDofZ0hQ1i4DuNURA7DnewfxcdmwfA0LMFNY1DZsr47vm6yfEezkii3eIGW+lubipjPYawxXYwzw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-runtime-node": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-runtime-node/-/instrumentation-runtime-node-0.27.0.tgz", - "integrity": "sha512-5S/Xd03scYSSZX3Pg6qfxIgpq2CCUIqBoJPnIgE41NM1tLiCm9zplQw6+699Uhj97mIthBHsGTwgdJCBc1vzkg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-socket.io": { - "version": "0.61.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.61.0.tgz", - "integrity": "sha512-/yhFfR/iW8nf+sgHn5KLiPauF//rTP7a/Hxcl/khgXzbVPsT1AhRvJ8HbPvNVWrJqki52ztucuEFeO00DcncyQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-tedious": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.33.0.tgz", - "integrity": "sha512-Q6WQwAD01MMTub31GlejoiFACYNw26J426wyjvU7by7fDIr2nZXNW4vhTGs7i7F0TnXBO3xN688g1tdUgYwJ5w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/tedious": "^4.0.14" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-undici": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.24.0.tgz", - "integrity": "sha512-oKzZ3uvqP17sV0EsoQcJgjEfIp0kiZRbYu/eD8p13Cbahumf8lb/xpYeNr/hfAJ4owzEtIDcGIjprfLcYbIKBQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.24.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.7.0" - } - }, - "node_modules/@opentelemetry/instrumentation-winston": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.58.0.tgz", - "integrity": "sha512-v64eFPrWG7u2xZzU/Zz/jbMIL4etoLrqGqeLyVIW2rxwzp2QriGZEk90Xt2p7Yo/WBbTnl5nuruIinhNG406IA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "^0.214.0", - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.214.0.tgz", - "integrity": "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-transformer": "0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-grpc-exporter-base": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.214.0.tgz", - "integrity": "sha512-IDP6zcyA24RhNZ289MP6eToIZcinlmirHjX8v3zKCQ2ZhPpt5cGwkN91tCth337lqHIgWcTy90uKRiX/SzALDw==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.214.0.tgz", - "integrity": "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-logs": "0.214.0", - "@opentelemetry/sdk-metrics": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1", - "protobufjs": "^7.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/propagator-b3": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-2.6.1.tgz", - "integrity": "sha512-Dvz9TA6cPqIbxolSzQ5x9br6iQlqdGhVYrm+oYc7pfJ7LaVXz8F0XIqhWbnKB5YvfZ6SUmabBUUxnvHs/9uhxA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/propagator-jaeger": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.6.1.tgz", - "integrity": "sha512-kKFMxBcjBZAC1vBch5mtZ/dJQvcAEKWga+c+q5iGgRLPIE6Mc649zEwMaCIQCzalziMJQiyUadFYMHmELB7AFw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/redis-common": { - "version": "0.38.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", - "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - } - }, - "node_modules/@opentelemetry/resource-detector-alibaba-cloud": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.33.4.tgz", - "integrity": "sha512-S07KBOB3+BHV0xjuN4sCRP7x44p2rW0ieGDzoRu1f8Sbvw9Gw4f1oL83tfXiOb0fGPVt8DF4P+39UcggHQsACA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/resources": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/resource-detector-aws": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-aws/-/resource-detector-aws-2.14.0.tgz", - "integrity": "sha512-1a0YMG6wZuLUfwkSgfe77vN60V5SmK//kM+JsQFT7dOKLyFvpN5A+TpX/eFdaqnhg89CxyF7XpKMBbg1DGv5bw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/resources": "^2.0.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/resource-detector-azure": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-azure/-/resource-detector-azure-0.22.0.tgz", - "integrity": "sha512-/cYJBFACVqPSWNFU2gdx/wh8kB98YK4dyIhWh1IU2z1iFDrLHpwVjEIS8xLazSqJDntTTqeb8GVSlUlPF3B1pg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/resources": "^2.0.0", - "@opentelemetry/semantic-conventions": "^1.37.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/resource-detector-container": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-container/-/resource-detector-container-0.8.5.tgz", - "integrity": "sha512-vWlfpiCHKWVrT/3EHgJfRLGX8ghVsEZ6CBHhJo5sAQQnwInDNcXjbBJm74Jiyqt0eg7NLeT0EfpXHCUSeYgFaA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/resources": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/resource-detector-gcp": { - "version": "0.49.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.49.0.tgz", - "integrity": "sha512-JP4wrArxUBEGUCfd4SijKJXjspVs/3/eGH6siIlaVdRwf0XLEi4lXI+MdQuWSo4L4sEUCj6igojYzsuHZiuWDA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/resources": "^2.0.0", - "gcp-metadata": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", - "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz", - "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-node": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.214.0.tgz", - "integrity": "sha512-gl2XvQBJuPjhGcw9SsnQO5qxChAPMuGRPFaD8lqtF+Cey91NgGUQ0sio2vlDFOSm3JOLzc44vL+OAfx1dXuZjg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/configuration": "0.214.0", - "@opentelemetry/context-async-hooks": "2.6.1", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/exporter-logs-otlp-grpc": "0.214.0", - "@opentelemetry/exporter-logs-otlp-http": "0.214.0", - "@opentelemetry/exporter-logs-otlp-proto": "0.214.0", - "@opentelemetry/exporter-metrics-otlp-grpc": "0.214.0", - "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", - "@opentelemetry/exporter-metrics-otlp-proto": "0.214.0", - "@opentelemetry/exporter-prometheus": "0.214.0", - "@opentelemetry/exporter-trace-otlp-grpc": "0.214.0", - "@opentelemetry/exporter-trace-otlp-http": "0.214.0", - "@opentelemetry/exporter-trace-otlp-proto": "0.214.0", - "@opentelemetry/exporter-zipkin": "2.6.1", - "@opentelemetry/instrumentation": "0.214.0", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/propagator-b3": "2.6.1", - "@opentelemetry/propagator-jaeger": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-logs": "0.214.0", - "@opentelemetry/sdk-metrics": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1", - "@opentelemetry/sdk-trace-node": "2.6.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", - "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.6.1.tgz", - "integrity": "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/context-async-hooks": "2.6.1", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", - "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/sql-common": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", - "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0" - } - }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.12", @@ -2560,21 +1092,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/aws-lambda": { - "version": "8.10.161", - "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.161.tgz", - "integrity": "sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==", - "license": "MIT" - }, - "node_modules/@types/bunyan": { - "version": "1.8.11", - "resolved": "https://registry.npmjs.org/@types/bunyan/-/bunyan-1.8.11.tgz", - "integrity": "sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2586,15 +1103,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2633,24 +1141,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/memcached": { - "version": "2.2.10", - "resolved": "https://registry.npmjs.org/@types/memcached/-/memcached-2.2.10.tgz", - "integrity": "sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/mysql": { - "version": "2.15.27", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", - "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node": { "version": "25.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", @@ -2669,44 +1159,6 @@ "@types/node": "*" } }, - "node_modules/@types/oracledb": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@types/oracledb/-/oracledb-6.5.2.tgz", - "integrity": "sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/pg": { - "version": "8.15.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", - "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, - "node_modules/@types/pg-pool": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", - "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", - "license": "MIT", - "dependencies": { - "@types/pg": "*" - } - }, - "node_modules/@types/tedious": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", - "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -3162,6 +1614,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3170,15 +1623,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -3189,15 +1633,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -3235,6 +1670,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3244,6 +1680,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3338,15 +1775,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/bintrees": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", @@ -3460,16 +1888,11 @@ "node": ">=18" } }, - "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", - "license": "MIT" - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -3491,6 +1914,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3503,6 +1927,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/content-disposition": { @@ -3614,15 +2039,6 @@ "node": ">= 8" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3711,6 +2127,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -3805,6 +2222,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4204,12 +2622,6 @@ "node": ">= 0.6" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -4378,29 +2790,6 @@ } } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4500,18 +2889,6 @@ "dev": true, "license": "ISC" }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4521,12 +2898,6 @@ "node": ">= 0.6" } }, - "node_modules/forwarded-parse": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", - "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", - "license": "MIT" - }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -4560,38 +2931,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gaxios": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", - "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4664,15 +3008,6 @@ "node": ">= 6" } }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4755,19 +3090,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -4811,21 +3133,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-in-the-middle": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.1.tgz", - "integrity": "sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4881,6 +3188,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4989,15 +3297,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -5440,18 +3739,6 @@ "node": ">=16.0.0" } }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "license": "MIT" - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, "node_modules/lru-cache": { "version": "11.2.7", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", @@ -5659,12 +3946,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/module-details-from-path": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", - "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5706,44 +3987,6 @@ "node": ">= 0.6" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/nodemailer": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", @@ -5975,37 +4218,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", - "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6101,45 +4313,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6195,30 +4368,6 @@ "node": "^16 || ^18 || >=20" } }, - "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -6377,6 +4526,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6391,19 +4541,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-in-the-middle": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", - "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "module-details-from-path": "^1.0.3" - }, - "engines": { - "node": ">=9.3.0 || >=8.10.0 <9.0.0" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -6839,6 +4976,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6853,6 +4991,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7296,15 +5435,6 @@ } } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7351,6 +5481,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -7391,19 +5522,11 @@ } } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -7413,6 +5536,7 @@ "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -7428,6 +5552,7 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -7446,6 +5571,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/package.json b/package.json index 109e5f60..3d70ddbe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aegis-bridge", - "version": "0.3.2-alpha", + "version": "0.4.0-alpha", "type": "module", "description": "Orchestrate Claude Code sessions via API. Create, brief, monitor, refine, ship.", "main": "dist/server.js", @@ -51,16 +51,9 @@ "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", - "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^9.0.0", "@fastify/websocket": "^11.2.0", "@modelcontextprotocol/sdk": "^1.28.0", - "@opentelemetry/api": "^1.9.1", - "@opentelemetry/auto-instrumentations-node": "^0.72.0", - "@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", - "@opentelemetry/sdk-node": "^0.214.0", - "@opentelemetry/sdk-trace-base": "^2.6.1", "@tanstack/react-virtual": "^3.13.23", "@types/nodemailer": "^8.0.0", "async-mutex": "^0.5.0", diff --git a/src/__tests__/alerting.test.ts b/src/__tests__/alerting.test.ts deleted file mode 100644 index 746f2444..00000000 --- a/src/__tests__/alerting.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -/** - * alerting.test.ts — Tests for the AlertManager (Issue #1418). - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -// Mock fetch globally -const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); - -import { AlertManager, type AlertType } from '../alerting.js'; - -describe('AlertManager', () => { - beforeEach(() => { - mockFetch.mockReset(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('recordFailure', () => { - it('does not fire alert when below threshold', () => { - const manager = new AlertManager({ - webhooks: ['http://localhost:9999/alerts'], - failureThreshold: 5, - cooldownMs: 60_000, - }); - - for (let i = 0; i < 4; i++) { - manager.recordFailure('session_failure', `failure ${i}`); - } - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('fires alert when threshold is reached', async () => { - mockFetch.mockResolvedValue({ ok: true, status: 200 }); - - const manager = new AlertManager({ - webhooks: ['http://localhost:9999/alerts'], - failureThreshold: 5, - cooldownMs: 60_000, - }); - - for (let i = 0; i < 5; i++) { - manager.recordFailure('session_failure', `failure ${i}`); - } - - // recordFailure fires async — advance timers and flush microtasks - await vi.advanceTimersByTimeAsync(0); - - expect(mockFetch).toHaveBeenCalledTimes(1); - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('http://localhost:9999/alerts'); - expect(options.method).toBe('POST'); - expect(options.headers['X-Aegis-Alert-Type']).toBe('session_failure'); - - const body = JSON.parse(options.body); - expect(body.event).toBe('alert'); - expect(body.type).toBe('session_failure'); - expect(body.failureCount).toBe(5); - expect(body.threshold).toBe(5); - }); - - it('does not fire alert during cooldown period', async () => { - mockFetch.mockResolvedValue({ ok: true, status: 200 }); - - const manager = new AlertManager({ - webhooks: ['http://localhost:9999/alerts'], - failureThreshold: 3, - cooldownMs: 10_000, - }); - - // Trigger first alert - for (let i = 0; i < 3; i++) { - manager.recordFailure('session_failure', `failure ${i}`); - } - await vi.advanceTimersByTimeAsync(0); - expect(mockFetch).toHaveBeenCalledTimes(1); - - // Try to trigger again immediately — should be suppressed by cooldown - for (let i = 0; i < 3; i++) { - manager.recordFailure('session_failure', `failure ${i + 3}`); - } - await vi.advanceTimersByTimeAsync(0); - expect(mockFetch).toHaveBeenCalledTimes(1); // still 1 - - // Advance past cooldown window (resets failure count) - await vi.advanceTimersByTimeAsync(10_001); - - // Need to re-accumulate failures past threshold after window reset - for (let i = 0; i < 3; i++) { - manager.recordFailure('session_failure', `failure ${i + 6}`); - } - await vi.advanceTimersByTimeAsync(0); - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it('resets failure count after cooldown window expires', async () => { - mockFetch.mockResolvedValue({ ok: true, status: 200 }); - - const manager = new AlertManager({ - webhooks: ['http://localhost:9999/alerts'], - failureThreshold: 5, - cooldownMs: 10_000, - }); - - // Record 3 failures - for (let i = 0; i < 3; i++) { - manager.recordFailure('session_failure', `failure ${i}`); - } - - // Advance past the cooldown window — count should reset - await vi.advanceTimersByTimeAsync(10_001); - - // Record 5 more failures — should trigger alert - for (let i = 0; i < 5; i++) { - manager.recordFailure('session_failure', `failure ${i + 3}`); - } - await vi.advanceTimersByTimeAsync(0); - - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it('does nothing when no webhooks are configured', () => { - const manager = new AlertManager({ - webhooks: [], - failureThreshold: 1, - cooldownMs: 1000, - }); - - manager.recordFailure('session_failure', 'should not fire'); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('tracks different alert types independently', async () => { - mockFetch.mockResolvedValue({ ok: true, status: 200 }); - - const manager = new AlertManager({ - webhooks: ['http://localhost:9999/alerts'], - failureThreshold: 2, - cooldownMs: 60_000, - }); - - manager.recordFailure('session_failure', 's1'); - manager.recordFailure('session_failure', 's2'); - manager.recordFailure('tmux_crash', 't1'); - manager.recordFailure('tmux_crash', 't2'); - await vi.advanceTimersByTimeAsync(0); - - expect(mockFetch).toHaveBeenCalledTimes(2); - - const calls = mockFetch.mock.calls.map(([_, opts]) => JSON.parse(opts.body)); - const types = calls.map(c => c.type); - expect(types).toContain('session_failure'); - expect(types).toContain('tmux_crash'); - }); - - it('fires to multiple webhooks', async () => { - mockFetch.mockResolvedValue({ ok: true, status: 200 }); - - const manager = new AlertManager({ - webhooks: [ - 'http://localhost:9999/alerts1', - 'http://localhost:9999/alerts2', - ], - failureThreshold: 1, - cooldownMs: 60_000, - }); - - manager.recordFailure('api_error_rate', 'error spike'); - await vi.advanceTimersByTimeAsync(0); - - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it('counts failed webhook deliveries', async () => { - mockFetch.mockResolvedValue({ ok: false, status: 502 }); - - const manager = new AlertManager({ - webhooks: ['http://localhost:9999/alerts'], - failureThreshold: 1, - cooldownMs: 60_000, - }); - - manager.recordFailure('session_failure', 'fail'); - await vi.advanceTimersByTimeAsync(0); - - const stats = manager.getStats(); - expect(stats.failed).toBe(1); - expect(stats.delivered).toBe(0); - }); - }); - - describe('fireTestAlert', () => { - it('returns sent=false when no webhooks configured', async () => { - const manager = new AlertManager({ webhooks: [] }); - const result = await manager.fireTestAlert(); - expect(result.sent).toBe(false); - expect(result.webhookCount).toBe(0); - }); - - it('fires a test alert webhook', async () => { - mockFetch.mockResolvedValue({ ok: true, status: 200 }); - - const manager = new AlertManager({ - webhooks: ['http://localhost:9999/alerts'], - failureThreshold: 5, - }); - - const result = await manager.fireTestAlert(); - expect(result.sent).toBe(true); - expect(result.webhookCount).toBe(1); - - expect(mockFetch).toHaveBeenCalledTimes(1); - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.detail).toContain('Test alert'); - }); - }); - - describe('getStats', () => { - it('returns empty trackers initially', () => { - const manager = new AlertManager(); - const stats = manager.getStats(); - expect(stats.delivered).toBe(0); - expect(stats.failed).toBe(0); - expect(Object.keys(stats.trackers)).toHaveLength(0); - }); - }); - - describe('reset', () => { - it('clears all tracking state', () => { - const manager = new AlertManager({ - webhooks: ['http://localhost:9999/alerts'], - failureThreshold: 10, - }); - - manager.recordFailure('session_failure', 'f1'); - manager.recordFailure('session_failure', 'f2'); - - manager.reset(); - - const stats = manager.getStats(); - expect(stats.delivered).toBe(0); - expect(stats.failed).toBe(0); - expect(Object.keys(stats.trackers)).toHaveLength(0); - }); - }); - - describe('updateConfig', () => { - it('allows updating webhooks at runtime', async () => { - mockFetch.mockResolvedValue({ ok: true, status: 200 }); - - const manager = new AlertManager({ - webhooks: [], - failureThreshold: 1, - }); - - // No webhooks — should not fire - manager.recordFailure('session_failure', 'f1'); - await vi.advanceTimersByTimeAsync(0); - expect(mockFetch).not.toHaveBeenCalled(); - - // Update config - manager.updateConfig({ - webhooks: ['http://localhost:9999/alerts'], - }); - - // Now should fire - manager.recordFailure('session_failure', 'f2'); - await vi.advanceTimersByTimeAsync(0); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/src/__tests__/audit.test.ts b/src/__tests__/audit.test.ts deleted file mode 100644 index e6c5336a..00000000 --- a/src/__tests__/audit.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * audit.test.ts — Tests for Issue #1419: Tamper-evident audit log. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { AuditLogger } from '../audit.js'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { rm, readdir, readFile, writeFile, mkdir, symlink } from 'node:fs/promises'; - -describe('AuditLogger (Issue #1419)', () => { - let audit: AuditLogger; - let tmpDir: string; - - beforeEach(async () => { - tmpDir = join(tmpdir(), `aegis-audit-${Date.now()}-${Math.random().toString(36).slice(2)}`); - audit = new AuditLogger(tmpDir); - await audit.init(); - }); - - afterEach(async () => { - try { await rm(tmpDir, { recursive: true }); } catch { /* ignore */ } - }); - - describe('log()', () => { - it('should append a record with correct fields', async () => { - const record = await audit.log('master', 'key.create', 'Test key created'); - expect(record.ts).toBeTruthy(); - expect(record.actor).toBe('master'); - expect(record.action).toBe('key.create'); - expect(record.detail).toBe('Test key created'); - expect(record.prevHash).toBe(''); - expect(record.hash).toMatch(/^[a-f0-9]{64}$/); - }); - - it('should chain hashes between consecutive records', async () => { - const r1 = await audit.log('key-1', 'session.create', 'Session started', 'sess-1'); - const r2 = await audit.log('key-1', 'session.kill', 'Session killed', 'sess-1'); - expect(r2.prevHash).toBe(r1.hash); - expect(r2.hash).not.toBe(r1.hash); - }); - - it('should include sessionId when provided', async () => { - const record = await audit.log('admin', 'session.create', 'Created', 'abc-123'); - expect(record.sessionId).toBe('abc-123'); - }); - - it('should omit sessionId when not provided', async () => { - const record = await audit.log('system', 'key.create', 'Key made'); - expect(record.sessionId).toBeUndefined(); - }); - - it('should create a date-stamped log file', async () => { - await audit.log('system', 'key.create', 'Test'); - const files = await readdir(tmpDir); - const logFiles = files.filter(f => f.startsWith('audit-') && f.endsWith('.log')); - expect(logFiles.length).toBe(1); - expect(logFiles[0]).toMatch(/^audit-\d{4}-\d{2}-\d{2}\.log$/); - }); - }); - - describe('verify()', () => { - it('should return valid=true for an untampered log', async () => { - await audit.log('master', 'key.create', 'Key 1'); - await audit.log('master', 'key.create', 'Key 2'); - const result = await audit.verify(); - expect(result.valid).toBe(true); - }); - - it('should detect a tampered record', async () => { - await audit.log('master', 'key.create', 'Original'); - // Manually tamper with the file - const files = await readdir(tmpDir); - const logFile = files.find(f => f.startsWith('audit-')); - if (!logFile) throw new Error('No log file'); - const content = await readFile(join(tmpDir, logFile), 'utf-8'); - const tampered = content.replace(/"detail":"Original"/, '"detail":"Tampered"'); - const { writeFile } = await import('node:fs/promises'); - await writeFile(join(tmpDir, logFile), tampered); - const result = await audit.verify(); - expect(result.valid).toBe(false); - expect(result.brokenAt).toBeDefined(); - }); - }); - - describe('query()', () => { - it('should return records filtered by actor', async () => { - await audit.log('admin', 'key.create', 'Admin key'); - await audit.log('viewer', 'session.create', 'Viewer session'); - const results = await audit.query({ actor: 'admin' }); - expect(results).toHaveLength(1); - expect(results[0]!.actor).toBe('admin'); - }); - - it('should return records filtered by action', async () => { - await audit.log('master', 'key.create', 'Created'); - await audit.log('master', 'key.revoke', 'Revoked'); - const results = await audit.query({ action: 'key.revoke' }); - expect(results).toHaveLength(1); - expect(results[0]!.action).toBe('key.revoke'); - }); - - it('should return records filtered by sessionId', async () => { - await audit.log('admin', 'session.create', 'S1', 's1'); - await audit.log('admin', 'session.create', 'S2', 's2'); - const results = await audit.query({ sessionId: 's1' }); - expect(results).toHaveLength(1); - }); - - it('should respect the limit parameter', async () => { - for (let i = 0; i < 10; i++) { - await audit.log('system', 'api.authenticated', `Call ${i}`); - } - const results = await audit.query({ limit: 3 }); - expect(results).toHaveLength(3); - }); - - it('should return records in reverse order when requested', async () => { - await audit.log('system', 'key.create', 'First'); - await audit.log('system', 'key.create', 'Second'); - await audit.log('system', 'key.create', 'Third'); - const results = await audit.query({ limit: 2, reverse: true }); - expect(results).toHaveLength(2); - expect(results[0]!.detail).toBe('Third'); - expect(results[1]!.detail).toBe('Second'); - }); - - it('should return empty array for no matching records', async () => { - const results = await audit.query({ actor: 'nonexistent' }); - expect(results).toHaveLength(0); - }); - }); - - describe('hash chain integrity', () => { - it('should recover last hash across restarts', async () => { - await audit.log('system', 'key.create', 'Before restart'); - const firstHash = (await audit.query({ limit: 1 }))[0]!.hash; - - // Simulate restart — create a new AuditLogger pointing to same dir - const restarted = new AuditLogger(tmpDir); - await restarted.init(); - - const newRecord = await restarted.log('system', 'key.revoke', 'After restart'); - expect(newRecord.prevHash).toBe(firstHash); - }); - }); - - describe('Issue #1618: symlink hardening', () => { - it('rejects a symlinked audit directory when symlinks are supported', async () => { - const realDir = join(tmpDir, 'real-audit'); - await mkdir(realDir, { recursive: true }); - const linkedDir = join(tmpDir, 'linked-audit'); - - try { - await symlink(realDir, linkedDir, process.platform === 'win32' ? 'junction' : 'dir'); - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code === 'EPERM' || err.code === 'EACCES') return; - throw error; - } - - const linkedAudit = new AuditLogger(linkedDir); - await expect(linkedAudit.init()).rejects.toThrow(/symlink path/); - }); - - it('rejects writes when the target audit log file is a symlink', async () => { - const date = new Date().toISOString().slice(0, 10); - const logPath = join(tmpDir, `audit-${date}.log`); - const targetPath = join(tmpDir, 'outside.log'); - await writeFile(targetPath, ''); - - try { - await symlink(targetPath, logPath, process.platform === 'win32' ? 'file' : undefined); - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code === 'EPERM' || err.code === 'EACCES') return; - throw error; - } - - await expect(audit.log('system', 'key.create', 'Symlink write should fail')).rejects.toThrow(/symlink path/); - }); - }); -}); diff --git a/src/__tests__/auth-key-rotation-1403.test.ts b/src/__tests__/auth-key-rotation-1403.test.ts deleted file mode 100644 index f9990c7a..00000000 --- a/src/__tests__/auth-key-rotation-1403.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * auth-key-rotation-1403.test.ts — Tests for Issue #1403: API key expiry + rotation. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { AuthManager, type AuthRejectReason } from '../auth.js'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { rm } from 'node:fs/promises'; - -describe('API key expiry and rotation (Issue #1403)', () => { - let auth: AuthManager; - let tmpFile: string; - - beforeEach(async () => { - tmpFile = join(tmpdir(), `aegis-1403-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); - auth = new AuthManager(tmpFile, ''); - }); - - afterEach(async () => { - try { await rm(tmpFile); } catch { /* ignore */ } - }); - - describe('validate() reason field', () => { - it('should return reason="expired" for an expired key', async () => { - const { key } = await auth.createKey('expiring', 100, 1); - const stored = (auth as unknown as { store: { keys: Array<{ expiresAt: number }> } }).store.keys[0]; - stored.expiresAt = Date.now() - 1000; - const result = auth.validate(key); - expect(result.valid).toBe(false); - expect(result.reason).toBe('expired'); - }); - - it('should return reason="invalid" for a wrong key', async () => { - await auth.createKey('my-key'); - const result = auth.validate('aegis_deadbeef'); - expect(result.valid).toBe(false); - expect(result.reason).toBe('invalid'); - }); - - it('should return reason="no_auth" when no auth configured on non-localhost', () => { - const noAuth = new AuthManager(tmpFile, ''); - noAuth.setHost('0.0.0.0'); - const result = noAuth.validate('anything'); - expect(result.valid).toBe(false); - expect(result.reason).toBe('no_auth'); - }); - - it('should not set reason when valid', async () => { - const { key } = await auth.createKey('valid-key'); - const result = auth.validate(key); - expect(result.valid).toBe(true); - expect(result.reason).toBeUndefined(); - }); - - it('should not set reason when no auth on localhost', () => { - const noAuth = new AuthManager(tmpFile, ''); - const result = noAuth.validate('anything'); - expect(result.valid).toBe(true); - expect(result.reason).toBeUndefined(); - }); - }); - - describe('rotateKey()', () => { - it('should rotate an existing key and return the new plaintext', async () => { - const { id, key: oldKey } = await auth.createKey('rotate-me'); - const rotated = await auth.rotateKey(id); - expect(rotated).not.toBeNull(); - expect(rotated!.key).not.toBe(oldKey); - expect(rotated!.key).toMatch(/^aegis_[a-f0-9]{64}$/); - expect(rotated!.id).toBe(id); - expect(rotated!.name).toBe('rotate-me'); - }); - - it('should invalidate the old key after rotation', async () => { - const { id, key: oldKey } = await auth.createKey('rotate-me'); - const rotated = await auth.rotateKey(id); - // Old key must no longer validate - expect(auth.validate(oldKey).valid).toBe(false); - // New key must validate - expect(auth.validate(rotated!.key).valid).toBe(true); - }); - - it('should preserve role from original key', async () => { - const { id } = await auth.createKey('admin-key', 100, undefined, 'admin'); - const rotated = await auth.rotateKey(id); - expect(rotated!.role).toBe('admin'); - }); - - it('should preserve rateLimit from original key', async () => { - const { id } = await auth.createKey('limited-key', 42); - const rotated = await auth.rotateKey(id); - // Validate that the new key works and the rate limit is inherited - const result = auth.validate(rotated!.key); - expect(result.valid).toBe(true); - // The stored key should still have rateLimit=42 - const stored = (auth as unknown as { store: { keys: Array<{ rateLimit: number }> } }).store.keys[0]; - expect(stored.rateLimit).toBe(42); - }); - - it('should set new expiresAt when ttlDays is provided', async () => { - const { id } = await auth.createKey('old-expiry', 100, 1); - const rotated = await auth.rotateKey(id, 30); - expect(rotated!.expiresAt).not.toBeNull(); - expect(rotated!.expiresAt).toBeGreaterThan(Date.now()); - }); - - it('should preserve existing expiresAt when ttlDays is omitted', async () => { - const { id } = await auth.createKey('keep-expiry', 100, 90); - const rotated = await auth.rotateKey(id); - // The expiresAt should still be set (not null) - expect(rotated!.expiresAt).not.toBeNull(); - }); - - it('should return null for non-existent key', async () => { - const rotated = await auth.rotateKey('nonexistent-id'); - expect(rotated).toBeNull(); - }); - - it('should persist rotated key to disk', async () => { - const { id } = await auth.createKey('persist-rotate'); - const rotated = await auth.rotateKey(id); - - // Reload from disk - const auth2 = new AuthManager(tmpFile, ''); - await auth2.load(); - - // New key should validate - expect(auth2.validate(rotated!.key).valid).toBe(true); - }); - - it('should reset rate limit counters on rotation', async () => { - const { id, key } = await auth.createKey('rate-reset', 2); - // Exhaust rate limit - auth.validate(key); - auth.validate(key); - expect(auth.validate(key).rateLimited).toBe(true); - - // Rotate - const rotated = await auth.rotateKey(id); - // New key should not be rate limited - expect(auth.validate(rotated!.key).rateLimited).toBe(false); - }); - }); -}); diff --git a/src/__tests__/auth-rate-limiter.test.ts b/src/__tests__/auth-rate-limiter.test.ts deleted file mode 100644 index 5747d13b..00000000 --- a/src/__tests__/auth-rate-limiter.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { RateLimiter } from '../services/auth/index.js'; - -describe('RateLimiter', () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it('enforces and resets auth-failure limits per IP', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const limiter = new RateLimiter(); - - for (let i = 0; i < 5; i++) { - expect(limiter.checkAuthFailRateLimit('10.0.0.1')).toBe(false); - } - expect(limiter.checkAuthFailRateLimit('10.0.0.1')).toBe(true); - - vi.setSystemTime(61_000); - expect(limiter.checkAuthFailRateLimit('10.0.0.1')).toBe(false); - }); - - it('enforces per-IP request limits with higher allowance for master token', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const limiter = new RateLimiter(); - - for (let i = 0; i < 120; i++) { - expect(limiter.checkIpRateLimit('10.0.0.2', false)).toBe(false); - } - expect(limiter.checkIpRateLimit('10.0.0.2', false)).toBe(true); - - for (let i = 0; i < 300; i++) { - expect(limiter.checkIpRateLimit('10.0.0.3', true)).toBe(false); - } - expect(limiter.checkIpRateLimit('10.0.0.3', true)).toBe(true); - }); - - it('prunes stale windows so old traffic does not keep throttling', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const limiter = new RateLimiter(); - - for (let i = 0; i < 120; i++) { - limiter.checkIpRateLimit('10.0.0.4', false); - } - expect(limiter.checkIpRateLimit('10.0.0.4', false)).toBe(true); - - for (let i = 0; i < 5; i++) { - limiter.recordAuthFailure('10.0.0.5'); - } - expect(limiter.checkAuthFailRateLimit('10.0.0.5')).toBe(true); - - vi.setSystemTime(61_000); - limiter.pruneIpRateLimits(); - limiter.pruneAuthFailLimits(); - - expect(limiter.checkIpRateLimit('10.0.0.4', false)).toBe(false); - expect(limiter.checkAuthFailRateLimit('10.0.0.5')).toBe(false); - }); -}); diff --git a/src/__tests__/auth-verify-endpoint-1555.test.ts b/src/__tests__/auth-verify-endpoint-1555.test.ts index d2b155f9..a54975fd 100644 --- a/src/__tests__/auth-verify-endpoint-1555.test.ts +++ b/src/__tests__/auth-verify-endpoint-1555.test.ts @@ -3,8 +3,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import Fastify, { type FastifyInstance } from 'fastify'; -import fastifyRateLimit from '@fastify/rate-limit'; +import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify'; import { z } from 'zod'; import { AuthManager } from '../auth.js'; import { join } from 'node:path'; @@ -15,38 +14,54 @@ const verifyTokenSchema = z.object({ token: z.string().min(1), }).strict(); +const IP_WINDOW_MS = 60_000; +const IP_LIMIT_NORMAL = 120; + const AUTH_FAIL_WINDOW_MS = 60_000; const AUTH_FAIL_MAX = 5; -const RATE_LIMIT_WINDOW = '1 minute'; -const VERIFY_ROUTE_LIMIT = { max: 60, timeWindow: RATE_LIMIT_WINDOW } as const; interface AuthFailBucket { timestamps: number[]; } +interface IpRateBucket { + entries: number[]; + start: number; +} + async function registerVerifyRoute(app: FastifyInstance, auth: AuthManager): Promise { + const ipRateLimits = new Map(); const authFailLimits = new Map(); - await app.register(fastifyRateLimit, { - global: true, - max: 600, - timeWindow: RATE_LIMIT_WINDOW, - keyGenerator: (req) => req.ip ?? 'unknown', - }); - const verifyRouteRateLimit = app.rateLimit(VERIFY_ROUTE_LIMIT); + function checkIpRateLimit(ip: string): boolean { + const now = Date.now(); + const cutoff = now - IP_WINDOW_MS; + const bucket = ipRateLimits.get(ip) || { entries: [], start: 0 }; + while (bucket.start < bucket.entries.length && bucket.entries[bucket.start]! < cutoff) { + bucket.start++; + } + if (bucket.start > bucket.entries.length >>> 1) { + bucket.entries = bucket.entries.slice(bucket.start); + bucket.start = 0; + } + bucket.entries.push(now); + ipRateLimits.set(ip, bucket); + const activeCount = bucket.entries.length - bucket.start; + return activeCount > IP_LIMIT_NORMAL; + } function checkAuthFailRateLimit(ip: string): boolean { - const now = Date.now(); - const cutoff = now - AUTH_FAIL_WINDOW_MS; + const cutoff = Date.now() - AUTH_FAIL_WINDOW_MS; const bucket = authFailLimits.get(ip) || { timestamps: [] }; bucket.timestamps = bucket.timestamps.filter(t => t >= cutoff); - bucket.timestamps.push(now); authFailLimits.set(ip, bucket); return bucket.timestamps.length > AUTH_FAIL_MAX; } function recordAuthFailure(ip: string): void { - checkAuthFailRateLimit(ip); + const bucket = authFailLimits.get(ip) || { timestamps: [] }; + bucket.timestamps.push(Date.now()); + authFailLimits.set(ip, bucket); } app.addHook('onRequest', async (req, reply) => { @@ -57,7 +72,21 @@ async function registerVerifyRoute(app: FastifyInstance, auth: AuthManager): Pro } }); - app.post('/v1/auth/verify', { preHandler: verifyRouteRateLimit }, async (req, reply) => { + const authVerifyRateLimitPreHandler = async (req: FastifyRequest, reply: FastifyReply): Promise => { + const clientIp = req.ip ?? 'unknown'; + if (checkIpRateLimit(clientIp)) { + void reply.status(429).send({ valid: false }); + return; + } + if (checkAuthFailRateLimit(clientIp)) { + void reply.status(429).send({ valid: false }); + return; + } + }; + + app.post('/v1/auth/verify', { + preHandler: authVerifyRateLimitPreHandler, + }, async (req, reply) => { const parsed = verifyTokenSchema.safeParse(req.body ?? {}); if (!parsed.success) { return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); @@ -68,10 +97,6 @@ async function registerVerifyRoute(app: FastifyInstance, auth: AuthManager): Pro } const clientIp = req.ip ?? 'unknown'; - if (checkAuthFailRateLimit(clientIp)) { - return reply.status(429).send({ valid: false }); - } - const result = auth.validate(parsed.data.token); if (result.rateLimited) { return reply.status(429).send({ valid: false }); diff --git a/src/__tests__/cc-version-check-564.test.ts b/src/__tests__/cc-version-check-564.test.ts index 14293b6f..6f504e32 100644 --- a/src/__tests__/cc-version-check-564.test.ts +++ b/src/__tests__/cc-version-check-564.test.ts @@ -69,9 +69,9 @@ describe('Issue #564: CC version validation — pure functions', () => { expect(compareSemver('3.0.0', '2.1.80')).toBe(1); }); - it('should return -1 if either version is unparseable (fails closed)', () => { - expect(compareSemver('invalid', '2.1.80')).toBe(-1); - expect(compareSemver('2.1.80', 'invalid')).toBe(-1); + it('should return 0 if either version is unparseable (fails open)', () => { + expect(compareSemver('invalid', '2.1.80')).toBe(0); + expect(compareSemver('2.1.80', 'invalid')).toBe(0); }); }); diff --git a/src/__tests__/claude-command-validation.test.ts b/src/__tests__/claude-command-validation.test.ts deleted file mode 100644 index 35f040ef..00000000 --- a/src/__tests__/claude-command-validation.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * claude-command-validation.test.ts — Tests for Issue #1393: claudeCommand RCE prevention. - */ - -import { describe, it, expect } from 'vitest'; - -// Mirror the regex from server.ts -const SAFE_COMMAND_RE = /^[a-zA-Z0-9_./@:= -]+$/; - -function isValidCommand(value: string): boolean { - return SAFE_COMMAND_RE.test(value); -} - -describe('claudeCommand validation (#1393)', () => { - describe('safe commands allowed', () => { - it('allows plain claude command', () => { - expect(isValidCommand('claude')).toBe(true); - }); - - it('allows claude with flags', () => { - expect(isValidCommand('claude --model opus')).toBe(true); - }); - - it('allows claude --print', () => { - expect(isValidCommand('claude --print')).toBe(true); - }); - - it('allows claude with permission mode', () => { - expect(isValidCommand('claude --permission-mode bypassPermissions')).toBe(true); - }); - - it('allows path-like commands', () => { - expect(isValidCommand('/usr/local/bin/claude')).toBe(true); - expect(isValidCommand('./claude')).toBe(true); - expect(isValidCommand('../claude')).toBe(true); - }); - - it('allows commands with equals signs (common in flags)', () => { - expect(isValidCommand('claude --model=opus')).toBe(true); - }); - - it('allows commands with colons (common in model names)', () => { - expect(isValidCommand('claude --model claude-opus-4-20250514')).toBe(true); - }); - - it('allows commands with at-signs', () => { - expect(isValidCommand('claude @context-file')).toBe(true); - }); - }); - - describe('dangerous commands rejected', () => { - it('rejects semicolon injection', () => { - expect(isValidCommand('evil; rm -rf /')).toBe(false); - }); - - it('rejects pipe injection', () => { - expect(isValidCommand('claude | cat /etc/passwd')).toBe(false); - }); - - it('rejects command substitution with backticks', () => { - expect(isValidCommand('claude `rm -rf /`')).toBe(false); - }); - - it('rejects dollar sign variable expansion', () => { - expect(isValidCommand('claude $(whoami)')).toBe(false); - }); - - it('rejects newline injection', () => { - expect(isValidCommand('claude\nrm -rf /')).toBe(false); - }); - - it('rejects carriage return injection', () => { - expect(isValidCommand('claude\rrm -rf /')).toBe(false); - }); - - it('rejects ampersand backgrounding', () => { - expect(isValidCommand('claude & malicious')).toBe(false); - }); - - it('rejects logical OR', () => { - expect(isValidCommand('claude || rm -rf /')).toBe(false); - }); - - it('rejects logical AND', () => { - expect(isValidCommand('claude && rm -rf /')).toBe(false); - }); - - it('rejects subshell execution', () => { - expect(isValidCommand('claude $(cat /etc/shadow)')).toBe(false); - }); - - it('rejects brace expansion', () => { - expect(isValidCommand('claude {a,b,c}')).toBe(false); - }); - - it('rejects redirection', () => { - expect(isValidCommand('claude > /tmp/pwned')).toBe(false); - expect(isValidCommand('claude < /etc/passwd')).toBe(false); - }); - - it('rejects double-ampersand (background)', () => { - expect(isValidCommand('claude && disown')).toBe(false); - }); - - it('rejects backslash continuation', () => { - expect(isValidCommand('claude \\n rm -rf /')).toBe(false); - }); - - it('rejects tab injection', () => { - expect(isValidCommand('claude\trm')).toBe(false); - }); - - it('rejects null byte', () => { - expect(isValidCommand('claude\x00rm')).toBe(false); - }); - - it('rejects parentheses (subshell)', () => { - expect(isValidCommand('(rm -rf /)')).toBe(false); - expect(isValidCommand('claude (whoami)')).toBe(false); - }); - - it('rejects curly braces', () => { - expect(isValidCommand('claude ${PATH}')).toBe(false); - }); - }); -}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index d865f134..694915d8 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { getConfig } from '../config.js'; import { testPath } from './helpers/platform.js'; @@ -17,7 +17,6 @@ describe('config', () => { }); afterEach(() => { - vi.restoreAllMocks(); // Restore env for (const key of Object.keys(process.env)) { if (key.startsWith('AEGIS_') || key.startsWith('MANUS_')) { @@ -49,7 +48,6 @@ describe('config', () => { expect(config.tgBotToken).toBe(''); expect(config.tgGroupId).toBe(''); expect(config.tgTopicTtlMs).toBe(24 * 60 * 60 * 1000); - expect(config.hookSecretHeaderOnly).toBe(false); }); }); @@ -107,12 +105,6 @@ describe('config', () => { const config = getConfig(); expect(config.tgTopicTtlMs).toBe(60000); }); - - it('overrides hook secret transport mode via AEGIS_HOOK_SECRET_HEADER_ONLY', () => { - process.env.AEGIS_HOOK_SECRET_HEADER_ONLY = 'true'; - const config = getConfig(); - expect(config.hookSecretHeaderOnly).toBe(true); - }); }); describe('MANUS_* backward compatibility', () => { @@ -181,12 +173,6 @@ describe('config', () => { const config = getConfig(); expect(config.webhooks).toEqual(['https://example.com/hook']); }); - - it('overrides hook secret transport mode via MANUS_HOOK_SECRET_HEADER_ONLY (legacy)', () => { - process.env.MANUS_HOOK_SECRET_HEADER_ONLY = 'true'; - const config = getConfig(); - expect(config.hookSecretHeaderOnly).toBe(true); - }); }); describe('AEGIS_* takes priority over MANUS_*', () => { @@ -210,13 +196,6 @@ describe('config', () => { const config = getConfig(); expect(config.tmuxSession).toBe('aegis'); }); - - it('AEGIS_HOOK_SECRET_HEADER_ONLY wins over MANUS_HOOK_SECRET_HEADER_ONLY', () => { - process.env.MANUS_HOOK_SECRET_HEADER_ONLY = 'false'; - process.env.AEGIS_HOOK_SECRET_HEADER_ONLY = 'true'; - const config = getConfig(); - expect(config.hookSecretHeaderOnly).toBe(true); - }); }); describe('numeric parsing', () => { @@ -234,71 +213,6 @@ describe('config', () => { expect(config.maxSessionAgeMs).toBe(7200000); expect(config.reaperIntervalMs).toBe(120000); }); - - it('falls back and warns when AEGIS_PORT is out of range', () => { - process.env.AEGIS_PORT = '70000'; - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const config = getConfig(); - const warnings = warnSpy.mock.calls.map(call => String(call[0])).join('\n'); - - expect(config.port).toBe(9100); - expect(warnings).toContain("AEGIS_PORT='70000'"); - expect(warnings).toContain('<= 65535'); - }); - - it('falls back and warns when numeric env value is not a strict integer', () => { - process.env.AEGIS_MAX_SESSION_AGE_MS = '3600000ms'; - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const config = getConfig(); - const warnings = warnSpy.mock.calls.map(call => String(call[0])).join('\n'); - - expect(config.maxSessionAgeMs).toBe(2 * 60 * 60 * 1000); - expect(warnings).toContain("AEGIS_MAX_SESSION_AGE_MS='3600000ms'"); - expect(warnings).toContain('expected an integer'); - }); - - it('accepts 0 for AEGIS_PIPELINE_STAGE_TIMEOUT_MS and rejects negative values', () => { - process.env.AEGIS_PIPELINE_STAGE_TIMEOUT_MS = '0'; - const zeroConfig = getConfig(); - expect(zeroConfig.pipelineStageTimeoutMs).toBe(0); - - process.env.AEGIS_PIPELINE_STAGE_TIMEOUT_MS = '-1'; - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const negativeConfig = getConfig(); - const warnings = warnSpy.mock.calls.map(call => String(call[0])).join('\n'); - - expect(negativeConfig.pipelineStageTimeoutMs).toBe(0); - expect(warnings).toContain("AEGIS_PIPELINE_STAGE_TIMEOUT_MS='-1'"); - expect(warnings).toContain('>= 0'); - }); - }); - - describe('invalid env warnings', () => { - it('warns and keeps default when AEGIS_HOOK_SECRET_HEADER_ONLY is invalid', () => { - process.env.AEGIS_HOOK_SECRET_HEADER_ONLY = 'yes'; - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const config = getConfig(); - const warnings = warnSpy.mock.calls.map(call => String(call[0])).join('\n'); - - expect(config.hookSecretHeaderOnly).toBe(false); - expect(warnings).toContain("AEGIS_HOOK_SECRET_HEADER_ONLY='yes'"); - expect(warnings).toContain('expected "true" or "false"'); - }); - - it('warns for invalid Telegram allowlist entries while keeping valid IDs', () => { - process.env.AEGIS_TG_ALLOWED_USERS = '111,abc,-5,222'; - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const config = getConfig(); - const warnings = warnSpy.mock.calls.map(call => String(call[0])).join('\n'); - - expect(config.tgAllowedUsers).toEqual([111, 222]); - expect(warnings).toContain('AEGIS_TG_ALLOWED_USERS'); - expect(warnings).toContain('abc, -5'); - }); }); describe('multiple overrides', () => { diff --git a/src/__tests__/consensus-734.test.ts b/src/__tests__/consensus-734.test.ts new file mode 100644 index 00000000..8d364518 --- /dev/null +++ b/src/__tests__/consensus-734.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { buildConsensusPrompt, mergeConsensusFindings } from '../consensus.js'; + +describe('Issue #734: consensus helpers', () => { + it('builds focus-specific reviewer prompt', () => { + const prompt = buildConsensusPrompt('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'security'); + expect(prompt).toContain('security'); + expect(prompt).toContain('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); + }); + + it('deduplicates findings across reviewers', () => { + const findings = mergeConsensusFindings([ + { reviewerId: 'r1', focusArea: 'correctness', findings: ['Bug A', 'Bug B'] }, + { reviewerId: 'r2', focusArea: 'security', findings: ['Bug B', 'Risk C'] }, + ]); + expect(findings).toEqual(['Bug A', 'Bug B', 'Risk C']); + }); + + it('ignores empty findings', () => { + const findings = mergeConsensusFindings([ + { reviewerId: 'r1', focusArea: 'performance', findings: [' ', 'Slow path'] }, + ]); + expect(findings).toEqual(['Slow path']); + }); +}); diff --git a/src/__tests__/container.test.ts b/src/__tests__/container.test.ts deleted file mode 100644 index e873c1c7..00000000 --- a/src/__tests__/container.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ServiceContainer, type LifecycleService } from '../container.js'; - -function makeLifecycle( - name: string, - events: string[], - overrides: Partial = {}, -): LifecycleService { - return { - async start(): Promise { - events.push(`start:${name}`); - }, - async stop(): Promise { - events.push(`stop:${name}`); - }, - async health(): Promise<{ healthy: boolean }> { - return { healthy: true }; - }, - ...overrides, - }; -} - -describe('ServiceContainer', () => { - it('starts in dependency order and stops in reverse dependency order', async () => { - const events: string[] = []; - const container = new ServiceContainer(); - - container.register('tmux', {}, makeLifecycle('tmux', events)); - container.register('sessions', {}, makeLifecycle('sessions', events), ['tmux']); - container.register('channels', {}, makeLifecycle('channels', events), ['sessions']); - - await container.start(['channels']); - expect(events).toEqual([ - 'start:tmux', - 'start:sessions', - 'start:channels', - ]); - - await container.stopAll(); - expect(events).toEqual([ - 'start:tmux', - 'start:sessions', - 'start:channels', - 'stop:channels', - 'stop:sessions', - 'stop:tmux', - ]); - }); - - it('fails on missing dependencies', async () => { - const container = new ServiceContainer(); - container.register('sessions', {}, makeLifecycle('sessions', []), ['tmux']); - - await expect(container.startAll()).rejects.toThrow( - 'Service "sessions" depends on unregistered service "tmux"', - ); - }); - - it('fails on circular dependencies', async () => { - const container = new ServiceContainer(); - container.register('a', {}, makeLifecycle('a', []), ['b']); - container.register('b', {}, makeLifecycle('b', []), ['a']); - - await expect(container.startAll()).rejects.toThrow('Circular service dependency'); - }); - - it('enforces startup health gate', async () => { - const container = new ServiceContainer(); - container.register('healthy', {}, makeLifecycle('healthy', [])); - container.register('unhealthy', {}, makeLifecycle('unhealthy', [], { - async health() { - return { healthy: false, details: 'simulated failure' }; - }, - })); - - await container.startAll(); - await expect(container.assertHealthy()).rejects.toThrow( - 'Service health gate failed: unhealthy (simulated failure)', - ); - }); - - it('continues shutdown when a service times out', async () => { - const events: string[] = []; - const container = new ServiceContainer(); - - container.register('fast', {}, makeLifecycle('fast', events)); - container.register('slow', {}, makeLifecycle('slow', events, { - async stop(): Promise { - events.push('stop:slow:pending'); - await new Promise(() => {}); - }, - }), ['fast']); - - await container.startAll(); - const results = await container.stopAll({ timeoutMs: 20 }); - - expect(results).toEqual([ - { name: 'slow', status: 'timeout' }, - { name: 'fast', status: 'stopped' }, - ]); - expect(events).toContain('stop:slow:pending'); - expect(events).toContain('stop:fast'); - }); - - it('rolls back newly started services when startup fails', async () => { - const events: string[] = []; - const container = new ServiceContainer(); - - container.register('tmux', {}, makeLifecycle('tmux', events)); - container.register('sessions', {}, makeLifecycle('sessions', events, { - async start(): Promise { - events.push('start:sessions'); - throw new Error('session startup failed'); - }, - }), ['tmux']); - - await expect(container.startAll()).rejects.toThrow('session startup failed'); - expect(events).toEqual([ - 'start:tmux', - 'start:sessions', - 'stop:tmux', - ]); - }); -}); diff --git a/src/__tests__/hook-paths-909.test.ts b/src/__tests__/hook-paths-909.test.ts index 8e85c5f7..8d076ee4 100644 --- a/src/__tests__/hook-paths-909.test.ts +++ b/src/__tests__/hook-paths-909.test.ts @@ -1,31 +1,17 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { buildHookCommand, assertPathNotSymlink, withLockFile } from '../hook.js'; +import { describe, it, expect } from 'vitest'; +import { buildHookCommand } from '../hook.js'; import { buildProjectSettingsPath } from '../hook-settings.js'; -import { mkdtemp, rm, writeFile, symlink } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; describe('Issue #909: hook command path normalization', () => { it('quotes and normalizes Unix paths', () => { const cmd = buildHookCommand('/tmp/aegis dist/hook.js', '/usr/local/bin/node', 'linux'); - expect(cmd).toBe("'/usr/local/bin/node' '/tmp/aegis dist/hook.js'"); - }); - - it('uses single-quoted shell escaping for Unix apostrophes', () => { - const cmd = buildHookCommand('/tmp/O\'Brien/hook.js', '/usr/local/bin/node', 'linux'); - expect(cmd).toBe("'/usr/local/bin/node' '/tmp/O'\"'\"'Brien/hook.js'"); + expect(cmd).toBe('"/usr/local/bin/node" "/tmp/aegis dist/hook.js"'); }); it('quotes and normalizes Windows paths with spaces', () => { const cmd = buildHookCommand('D:/Aegis Work/dist/hook.js', 'C:/Program Files/nodejs/node.exe', 'win32'); expect(cmd).toBe('"C:\\Program Files\\nodejs\\node.exe" "D:\\Aegis Work\\dist\\hook.js"'); }); - - it('escapes Windows variable-expansion metacharacters', () => { - const cmd = buildHookCommand('D:/Aegis/%TEMP%/dist/!hook!.js', 'C:/Program Files/nodejs/node.exe', 'win32'); - expect(cmd).toBe('"C:\\Program Files\\nodejs\\node.exe" "D:\\Aegis\\%%TEMP%%\\dist\\^!hook^!.js"'); - }); }); describe('Issue #909: hook settings path construction', () => { @@ -39,45 +25,3 @@ describe('Issue #909: hook settings path construction', () => { expect(settingsPath).toContain('D:\\Users\\dev\\My Repo\\.claude\\settings.local.json'); }); }); - -describe('Issue #1618: hook symlink and lock hardening', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'aegis-hook-1618-')); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - it('acquires and releases lock files around critical sections', () => { - const lockPath = join(tmpDir, 'map.lock'); - const result = withLockFile(lockPath, () => { - expect(existsSync(lockPath)).toBe(true); - return 'ok'; - }); - expect(result).toBe('ok'); - expect(existsSync(lockPath)).toBe(false); - }); - - it('times out if a lock file is already held', async () => { - const lockPath = join(tmpDir, 'held.lock'); - await writeFile(lockPath, 'held'); - expect(() => withLockFile(lockPath, () => 'never', 1)).toThrow(/Timed out waiting for lock/); - }); - - it('rejects symlink paths when symlink creation is permitted', async () => { - const targetFile = join(tmpDir, 'target.json'); - const linkFile = join(tmpDir, 'link.json'); - await writeFile(targetFile, '{}'); - try { - await symlink(targetFile, linkFile); - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code === 'EPERM' || err.code === 'EACCES') return; - throw error; - } - expect(() => assertPathNotSymlink(linkFile)).toThrow(/symlink path/); - }); -}); diff --git a/src/__tests__/input-validation.test.ts b/src/__tests__/input-validation.test.ts index 667b925c..5ed61190 100644 --- a/src/__tests__/input-validation.test.ts +++ b/src/__tests__/input-validation.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { authKeySchema, sendMessageSchema, @@ -231,20 +231,6 @@ describe('parseIntSafe', () => { it('returns fallback for Infinity string', () => { expect(parseIntSafe('Infinity', 99)).toBe(99); }); - it('supports strict integer parsing', () => { - expect(parseIntSafe('42x', 7, { strict: true })).toBe(7); - expect(parseIntSafe('42', 7, { strict: true })).toBe(42); - }); - it('supports inclusive min/max bounds', () => { - expect(parseIntSafe('70000', 9100, { strict: true, min: 1, max: 65535 })).toBe(9100); - expect(parseIntSafe('8080', 9100, { strict: true, min: 1, max: 65535 })).toBe(8080); - }); - it('reports parse failures through onError callback', () => { - const onError = vi.fn(); - expect(parseIntSafe('abc', 123, { strict: true, context: 'AEGIS_PORT', onError })).toBe(123); - expect(onError).toHaveBeenCalledTimes(1); - expect(onError.mock.calls[0][0]).toContain("AEGIS_PORT='abc'"); - }); }); describe('isValidUUID', () => { diff --git a/src/__tests__/jsonl-watcher.test.ts b/src/__tests__/jsonl-watcher.test.ts index 9e7e89a7..9771686c 100644 --- a/src/__tests__/jsonl-watcher.test.ts +++ b/src/__tests__/jsonl-watcher.test.ts @@ -416,117 +416,4 @@ describe('JsonlWatcher', () => { watcher.destroy(); }, TEST_TIMEOUT); - - describe('error recovery (Issue #1420)', () => { - it('re-watches the file after an fs.watch error', async () => { - const watcher = new JsonlWatcher({ - debounceMs: 50, - maxRestartAttempts: 3, - restartBaseDelayMs: 100, - }); - const sessionId = 'test-restart'; - - writeJsonl(sessionId, [ - JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello' } }), - ]); - - watcher.watch(sessionId, jsonlPath(sessionId), 0); - await settle(); - - // Simulate an fs.watch error - const entry = (watcher as unknown as { entries: Map boolean } }> }).entries.get(sessionId); - expect(entry).toBeDefined(); - entry!.fsWatcher.emit('error', new Error('watch error')); - - // Wait for the restart backoff (100ms base * 2^0 = 100ms) + settle - await settle(300); - - // The watcher should have re-watched the file - expect(watcher.isWatching(sessionId)).toBe(true); - - watcher.destroy(); - }, TEST_TIMEOUT); - - it('stops retrying after max restart attempts', async () => { - vi.useFakeTimers(); - - const watcher = new JsonlWatcher({ - debounceMs: 50, - maxRestartAttempts: 2, - restartBaseDelayMs: 100, - }); - const sessionId = 'test-max-restart'; - - writeJsonl(sessionId, [ - JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello' } }), - ]); - - watcher.watch(sessionId, jsonlPath(sessionId), 0); - - const entries = (watcher as unknown as { entries: Map boolean } }> }).entries.get(sessionId); - expect(entries).toBeDefined(); - - // First error — schedules restart at 100ms - entries!.fsWatcher.emit('error', new Error('watch error 1')); - vi.advanceTimersByTime(100); - // New watcher created — get it - const entry2 = (watcher as unknown as { entries: Map boolean } }> }).entries.get(sessionId); - expect(entry2).toBeDefined(); - - // Second error — schedules restart at 200ms - entry2!.fsWatcher.emit('error', new Error('watch error 2')); - vi.advanceTimersByTime(200); - const entry3 = (watcher as unknown as { entries: Map boolean } }> }).entries.get(sessionId); - expect(entry3).toBeDefined(); - - // Third error — exceeds maxRestartAttempts (2), should give up - entry3!.fsWatcher.emit('error', new Error('watch error 3')); - vi.advanceTimersByTime(1000); - - expect(watcher.isWatching(sessionId)).toBe(false); - - watcher.destroy(); - vi.useRealTimers(); - }, TEST_TIMEOUT); - - it('preserves byte offset across restarts', async () => { - const watcher = new JsonlWatcher({ - debounceMs: 50, - maxRestartAttempts: 3, - restartBaseDelayMs: 100, - }); - const sessionId = 'test-offset-preserve'; - - writeJsonl(sessionId, [ - JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello' } }), - ]); - - watcher.watch(sessionId, jsonlPath(sessionId), 0); - - // Register listener BEFORE writing, settle to let fs.watch initialize - const eventPromise = waitForEvent(watcher); - await settle(); - - appendJsonl(sessionId, [ - JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: 'response' } }), - ]); - - const event = await eventPromise; - const savedOffset = event.newOffset; - expect(savedOffset).toBeGreaterThan(0); - - // Simulate error to trigger restart - const entry = (watcher as unknown as { entries: Map boolean } }> }).entries.get(sessionId); - expect(entry).toBeDefined(); - entry!.fsWatcher.emit('error', new Error('watch error')); - - // Wait for restart - await settle(300); - - // Offset should be preserved after restart - expect(watcher.getOffset(sessionId)).toBe(savedOffset); - - watcher.destroy(); - }, TEST_TIMEOUT); - }); }); diff --git a/src/__tests__/mcp-server.test.ts b/src/__tests__/mcp-server.test.ts index 4e94a9aa..5471d820 100644 --- a/src/__tests__/mcp-server.test.ts +++ b/src/__tests__/mcp-server.test.ts @@ -1535,177 +1535,4 @@ describe('MCP Resources', () => { expect(text).toContain('capture_pane'); }); }); - -// ── MCP Tool Authorization tests (Issue #1407) ───────────────────── - -describe('MCP Tool Authorization (Issue #1407)', () => { - const UUID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; - - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn()); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - function mockVerifyRole(role: string): void { - (fetch as any).mockImplementation(async (url: string, opts?: RequestInit) => { - // /v1/auth/verify — role resolution - if (url.includes('/v1/auth/verify')) { - return { ok: true, json: () => Promise.resolve({ valid: true, role }) }; - } - // All other requests — success - return { ok: true, json: () => Promise.resolve({ ok: true }) }; - }); - } - - function getToolHandlerWithAuth(toolName: string, role: string): (args: any) => Promise { - mockVerifyRole(role); - const server = createMcpServer(9100, 'test-token'); - return (server as any)._registeredTools[toolName].handler; - } - - // ── Admin tools (kill_session, send_bash) ── - - it('operator cannot call kill_session', async () => { - const handler = getToolHandlerWithAuth('kill_session', 'operator'); - const result = await handler({ sessionId: UUID }); - expect(result.isError).toBe(true); - const envelope = JSON.parse(result.content[0].text); - expect(envelope.code).toBe('FORBIDDEN'); - expect(envelope.message).toContain('kill_session'); - expect(envelope.message).toContain('admin'); - expect(envelope.message).toContain('operator'); - }); - - it('viewer cannot call kill_session', async () => { - const handler = getToolHandlerWithAuth('kill_session', 'viewer'); - const result = await handler({ sessionId: UUID }); - expect(result.isError).toBe(true); - const envelope = JSON.parse(result.content[0].text); - expect(envelope.code).toBe('FORBIDDEN'); - }); - - it('admin can call kill_session', async () => { - mockVerifyRole('admin'); - const server = createMcpServer(9100, 'test-token'); - const handler = (server as any)._registeredTools.kill_session.handler; - const result = await handler({ sessionId: UUID }); - expect(result.isError).toBeFalsy(); - }); - - it('operator cannot call send_bash', async () => { - const handler = getToolHandlerWithAuth('send_bash', 'operator'); - const result = await handler({ sessionId: UUID, command: 'ls' }); - expect(result.isError).toBe(true); - const envelope = JSON.parse(result.content[0].text); - expect(envelope.code).toBe('FORBIDDEN'); - expect(envelope.message).toContain('send_bash'); - }); - - it('viewer cannot call send_bash', async () => { - const handler = getToolHandlerWithAuth('send_bash', 'viewer'); - const result = await handler({ sessionId: UUID, command: 'ls' }); - expect(result.isError).toBe(true); - const envelope = JSON.parse(result.content[0].text); - expect(envelope.code).toBe('FORBIDDEN'); - }); - - it('admin can call send_bash', async () => { - mockVerifyRole('admin'); - const server = createMcpServer(9100, 'test-token'); - const handler = (server as any)._registeredTools.send_bash.handler; - const result = await handler({ sessionId: UUID, command: 'ls' }); - expect(result.isError).toBeFalsy(); - }); - - // ── Operator tools ── - - it('viewer cannot call send_message (operator tool)', async () => { - const handler = getToolHandlerWithAuth('send_message', 'viewer'); - const result = await handler({ sessionId: UUID, text: 'hello' }); - expect(result.isError).toBe(true); - const envelope = JSON.parse(result.content[0].text); - expect(envelope.code).toBe('FORBIDDEN'); - expect(envelope.message).toContain('operator'); - }); - - it('viewer cannot call create_session (operator tool)', async () => { - const handler = getToolHandlerWithAuth('create_session', 'viewer'); - const result = await handler({ workDir: '/tmp', name: undefined, prompt: undefined }); - expect(result.isError).toBe(true); - const envelope = JSON.parse(result.content[0].text); - expect(envelope.code).toBe('FORBIDDEN'); - }); - - it('operator can call send_message', async () => { - mockVerifyRole('operator'); - const server = createMcpServer(9100, 'test-token'); - const handler = (server as any)._registeredTools.send_message.handler; - const result = await handler({ sessionId: UUID, text: 'hello' }); - expect(result.isError).toBeFalsy(); - }); - - // ── Viewer tools (all roles can access) ── - - it('viewer can call list_sessions', async () => { - mockVerifyRole('viewer'); - const server = createMcpServer(9100, 'test-token'); - (fetch as any).mockImplementation(async (url: string) => { - if (url.includes('/v1/auth/verify')) { - return { ok: true, json: () => Promise.resolve({ valid: true, role: 'viewer' }) }; - } - return { ok: true, json: () => Promise.resolve({ sessions: [], total: 0 }) }; - }); - const handler = (server as any)._registeredTools.list_sessions.handler; - const result = await handler({ status: undefined, workDir: undefined }); - expect(result.isError).toBeFalsy(); - }); - - it('viewer can call server_health', async () => { - mockVerifyRole('viewer'); - const server = createMcpServer(9100, 'test-token'); - (fetch as any).mockImplementation(async (url: string) => { - if (url.includes('/v1/auth/verify')) { - return { ok: true, json: () => Promise.resolve({ valid: true, role: 'viewer' }) }; - } - return { ok: true, json: () => Promise.resolve({ status: 'ok' }) }; - }); - const handler = (server as any)._registeredTools.server_health.handler; - const result = await handler({}); - expect(result.isError).toBeFalsy(); - }); - - // ── No auth token = admin (backward compat) ── - - it('no auth token defaults to admin and can call any tool', async () => { - (fetch as any).mockResolvedValue({ ok: true, json: () => Promise.resolve({ ok: true }) }); - const server = createMcpServer(9100); - const handler = (server as any)._registeredTools.kill_session.handler; - const result = await handler({ sessionId: UUID }); - expect(result.isError).toBeFalsy(); - }); - - // ── Role caching ── - - it('role is resolved once and cached', async () => { - let verifyCallCount = 0; - (fetch as any).mockImplementation(async (url: string) => { - if (url.includes('/v1/auth/verify')) { - verifyCallCount++; - return { ok: true, json: () => Promise.resolve({ valid: true, role: 'operator' }) }; - } - return { ok: true, json: () => Promise.resolve({ ok: true }) }; - }); - const server = createMcpServer(9100, 'test-token'); - const killHandler = (server as any)._registeredTools.kill_session.handler; - const sendBashHandler = (server as any)._registeredTools.send_bash.handler; - - // Both calls should trigger only one verify request (cached after first) - await killHandler({ sessionId: UUID }); - await sendBashHandler({ sessionId: UUID, command: 'ls' }); - expect(verifyCallCount).toBe(1); - }); -}); }); diff --git a/src/__tests__/metrics-auth-1557.test.ts b/src/__tests__/metrics-auth-1557.test.ts deleted file mode 100644 index 36ca4368..00000000 --- a/src/__tests__/metrics-auth-1557.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Tests for Issue #1557: /metrics endpoint authentication. - * - * Covers: - * 1. Unauthenticated GET /metrics → 401 - * 2. Authenticated GET /metrics (valid token) → 200 - * 3. Dedicated metrics token (AEGIS_METRICS_TOKEN) accepted - * 4. Primary token also accepted when metrics token is configured - * 5. Invalid token → 401 - */ - -import { describe, it, expect } from 'vitest'; - -describe('Issue #1557: /metrics endpoint authentication', () => { - describe('Dedicated metrics token (AEGIS_METRICS_TOKEN)', () => { - it('should reject unauthenticated /metrics requests with 401', () => { - // This mirrors the auth bypass check in server.ts setupAuth. - // urlPath is typed as string (from runtime split), not a literal. - const urlPath: string = '/metrics'; - const isPublicBypass = urlPath === '/health' - || urlPath === '/v1/health' - || urlPath === '/v1/auth/verify' - || urlPath === '/dashboard' - || urlPath.startsWith('/dashboard/'); - expect(isPublicBypass).toBe(false); - }); - - it('should accept /metrics with dedicated metrics token', () => { - // Simulates the timing-safe comparison logic for metrics token - const metricsToken: string = 'prometheus-scrape-secret'; - const bearer: string = 'prometheus-scrape-secret'; - expect(bearer).toBe(metricsToken); - }); - - it('should accept /metrics with primary auth token even when metrics token is set', () => { - const metricsToken: string = 'prometheus-scrape-secret'; - const primaryToken: string = 'primary-api-key'; - const bearer: string = primaryToken; - // The server checks: timingSafeEqual(bearer, metricsToken) || authManager.validate(bearer).valid - const metricsMatch = bearer === metricsToken; - const primaryValid = bearer === primaryToken; - expect(metricsMatch || primaryValid).toBe(true); - }); - - it('should reject /metrics with invalid token', () => { - const metricsToken: string = 'prometheus-scrape-secret'; - const primaryToken: string = 'primary-api-key'; - const bearer: string = 'wrong-token'; - const metricsMatch = bearer === metricsToken; - const primaryValid = bearer === primaryToken; - expect(metricsMatch || primaryValid).toBe(false); - }); - - it('should not bypass /metrics in no-auth localhost mode when metrics token is set', () => { - // When metricsToken is configured, /metrics auth check runs BEFORE - // the general no-auth-localhost bypass in server.ts - const metricsToken: string = 'some-token'; - const isNoAuthLocalhost = true; - const hasMetricsToken = metricsToken.length > 0; - - expect(hasMetricsToken).toBe(true); - // The metrics handler will require valid credentials regardless of localhost mode - const shouldRequireAuth = hasMetricsToken || !isNoAuthLocalhost; - expect(shouldRequireAuth).toBe(true); - }); - - it('should fall through to normal auth when no metrics token is configured', () => { - const metricsToken: string = ''; - const hasMetricsToken = metricsToken.length > 0; - expect(hasMetricsToken).toBe(false); - // Without metrics token, /metrics falls through to normal auth flow - }); - }); -}); diff --git a/src/__tests__/metrics.test.ts b/src/__tests__/metrics.test.ts index 6f34d951..737ede0c 100644 --- a/src/__tests__/metrics.test.ts +++ b/src/__tests__/metrics.test.ts @@ -2,7 +2,7 @@ * metrics.test.ts — Tests for Issue #40: metrics + usage data. */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { MetricsCollector } from '../metrics.js'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -48,41 +48,6 @@ describe('Metrics and usage data (Issue #40)', () => { expect((m.sessions as any).failed).toBe(1); }); - it('should calculate avg_duration_sec for completed sessions (#1414)', () => { - vi.useFakeTimers(); - metrics.sessionCreated('s1'); - vi.advanceTimersByTime(5000); // 5 seconds - metrics.sessionCreated('s2'); - vi.advanceTimersByTime(3000); // s2 ran 3s, s1 ran 8s total - metrics.sessionCompleted('s1'); - vi.advanceTimersByTime(4000); // s2 ran 7s total - metrics.sessionFailed('s2'); - - const m = metrics.getGlobalMetrics(0); - // s1: 8s, s2: 7s → avg = 7.5 → rounds to 8 - expect(m.sessions.avg_duration_sec).toBe(8); - vi.useRealTimers(); - }); - - it('should include active session duration in avg_duration_sec', () => { - vi.useFakeTimers(); - metrics.sessionCreated('s1'); - vi.advanceTimersByTime(10000); // 10 seconds - metrics.sessionCompleted('s1'); - metrics.sessionCreated('s2'); - vi.advanceTimersByTime(4000); // s2 active for 4s - - const m = metrics.getGlobalMetrics(1); - // s1: 10s (completed), s2: 4s (active) → avg = 7 - expect(m.sessions.avg_duration_sec).toBe(7); - vi.useRealTimers(); - }); - - it('should return 0 avg_duration_sec when no sessions exist', () => { - const m = metrics.getGlobalMetrics(0); - expect(m.sessions.avg_duration_sec).toBe(0); - }); - it('should count auto-approvals', () => { metrics.sessionCreated('s1'); metrics.approvalGranted('s1', true); diff --git a/src/__tests__/model-router-743.test.ts b/src/__tests__/model-router-743.test.ts new file mode 100644 index 00000000..02c7115e --- /dev/null +++ b/src/__tests__/model-router-743.test.ts @@ -0,0 +1,167 @@ +/** + * model-router-743.test.ts — Unit tests for Issue #743: Tiered Model Routing. + * + * Tests: scoreTaskComplexity, scoreToTier, routeTask, MODEL_TIERS. + */ + +import { describe, it, expect } from 'vitest'; +import { + scoreTaskComplexity, + scoreToTier, + routeTask, + MODEL_TIERS, + type ModelTier, +} from '../model-router.js'; + +// ── scoreToTier() ──────────────────────────────────────────────────── + +describe('Issue #743: scoreToTier boundaries', () => { + it('0 → fast', () => { expect(scoreToTier(0)).toBe('fast'); }); + it('30 → fast', () => { expect(scoreToTier(30)).toBe('fast'); }); + it('31 → standard', () => { expect(scoreToTier(31)).toBe('standard'); }); + it('50 → standard', () => { expect(scoreToTier(50)).toBe('standard'); }); + it('70 → standard', () => { expect(scoreToTier(70)).toBe('standard'); }); + it('71 → power', () => { expect(scoreToTier(71)).toBe('power'); }); + it('100 → power', () => { expect(scoreToTier(100)).toBe('power'); }); +}); + +// ── scoreTaskComplexity() keyword signals ──────────────────────────── + +describe('Issue #743: scoreTaskComplexity — keyword signals', () => { + it('security keyword raises score to power tier', () => { + const { score, reasoning } = scoreTaskComplexity( + 'fix security vulnerability in auth', + [], + '', + ); + expect(score).toBeGreaterThan(70); + expect(reasoning.some(r => r.includes('security'))).toBe(true); + }); + + it('typo keyword lowers score to fast tier', () => { + const { score, reasoning } = scoreTaskComplexity( + 'fix typo in README', + [], + '', + ); + expect(score).toBeLessThanOrEqual(30); + expect(reasoning.some(r => r.includes('typo'))).toBe(true); + }); + + it('docs label lowers score to fast tier', () => { + const { score } = scoreTaskComplexity('update changelog', ['docs'], ''); + expect(score).toBeLessThanOrEqual(20); + }); + + it('feature keyword produces standard tier', () => { + const { score } = scoreTaskComplexity('add new feature for API', [], ''); + expect(score).toBeGreaterThan(30); + expect(score).toBeLessThanOrEqual(70); + }); +}); + +describe('Issue #743: scoreTaskComplexity — label overrides', () => { + it('security label overrides to power regardless of title', () => { + const { score } = scoreTaskComplexity('update readme', ['security'], ''); + expect(score).toBeGreaterThanOrEqual(80); + }); + + it('chore label pushes to fast tier', () => { + const { score } = scoreTaskComplexity('do something important', ['chore'], ''); + expect(score).toBeLessThanOrEqual(20); + }); + + it('P0 label elevates to power tier', () => { + const { score } = scoreTaskComplexity('fix something small', ['P0'], ''); + expect(score).toBeGreaterThanOrEqual(72); + }); + + it('P1 label elevates to power tier', () => { + const { score } = scoreTaskComplexity('fix something', ['P1'], ''); + expect(score).toBeGreaterThanOrEqual(72); + }); + + it('P3 label caps at standard tier', () => { + const { score } = scoreTaskComplexity('complex migration task', ['P3'], ''); + expect(score).toBeLessThanOrEqual(55); + }); + + it('reasoning array is never empty', () => { + const { reasoning } = scoreTaskComplexity('some generic task', [], ''); + expect(reasoning.length).toBeGreaterThan(0); + }); +}); + +describe('Issue #743: scoreTaskComplexity — score clamped to 0–100', () => { + it('score never exceeds 100', () => { + const { score } = scoreTaskComplexity( + 'critical security auth vulnerability migration', + ['security', 'P0', 'critical'], + 'security auth cryptography encryption injection', + ); + expect(score).toBeLessThanOrEqual(100); + }); + + it('score never goes below 0', () => { + const { score } = scoreTaskComplexity( + 'typo docs documentation chore', + ['docs', 'chore'], + 'typo whitespace comment', + ); + expect(score).toBeGreaterThanOrEqual(0); + }); +}); + +// ── routeTask() ────────────────────────────────────────────────────── + +describe('Issue #743: routeTask() output structure', () => { + it('returns tier, model, score, reasoning', () => { + const decision = routeTask({ title: 'add new feature', labels: [], description: '' }); + expect(decision).toHaveProperty('tier'); + expect(decision).toHaveProperty('model'); + expect(decision).toHaveProperty('score'); + expect(decision).toHaveProperty('reasoning'); + expect(typeof decision.score).toBe('number'); + expect(Array.isArray(decision.reasoning)).toBe(true); + }); + + it('model matches MODEL_TIERS for the returned tier', () => { + const decision = routeTask({ title: 'fix typo in docs', labels: ['docs'] }); + expect(decision.model).toBe(MODEL_TIERS[decision.tier as ModelTier]); + }); + + it('security task routes to power tier', () => { + const decision = routeTask({ title: 'fix security injection vulnerability' }); + expect(decision.tier).toBe('power'); + }); + + it('typo fix routes to fast tier', () => { + const decision = routeTask({ title: 'fix typo in README', labels: ['docs'] }); + expect(decision.tier).toBe('fast'); + }); + + it('labels default to [] when not provided', () => { + expect(() => routeTask({ title: 'any task' })).not.toThrow(); + }); + + it('description defaults to empty string when not provided', () => { + expect(() => routeTask({ title: 'any task', labels: ['P2'] })).not.toThrow(); + }); +}); + +// ── MODEL_TIERS ────────────────────────────────────────────────────── + +describe('Issue #743: MODEL_TIERS configuration', () => { + it('has fast, standard, and power entries', () => { + expect(MODEL_TIERS).toHaveProperty('fast'); + expect(MODEL_TIERS).toHaveProperty('standard'); + expect(MODEL_TIERS).toHaveProperty('power'); + }); + + it('all tier values are non-empty strings', () => { + for (const [, model] of Object.entries(MODEL_TIERS)) { + expect(typeof model).toBe('string'); + expect(model.length).toBeGreaterThan(0); + } + }); +}); diff --git a/src/__tests__/path-traversal-workdir-435.test.ts b/src/__tests__/path-traversal-workdir-435.test.ts index bbaba48b..c1ebc498 100644 --- a/src/__tests__/path-traversal-workdir-435.test.ts +++ b/src/__tests__/path-traversal-workdir-435.test.ts @@ -9,7 +9,7 @@ * - Normal valid paths */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import path from 'node:path'; import fs from 'node:fs/promises'; import os from 'node:os'; @@ -249,27 +249,6 @@ describe('validateWorkDir — Issue #435', () => { expect(typeof result).toBe('string'); expect(await sameCanonicalPath(result as string, dir)).toBe(true); }); - - it('rejects out-of-allowlist paths before resolving target realpath', async () => { - const allowedDir = path.join(tmpBase, 'allowed-project'); - const outsideDir = path.join(tmpBase, 'outside-project'); - await fs.mkdir(allowedDir, { recursive: true }); - await fs.mkdir(outsideDir, { recursive: true }); - - const spy = vi.spyOn(fs, 'realpath'); - try { - const result = await validateWorkDir(outsideDir, [allowedDir]); - expect(isError(result, 'INVALID_WORKDIR')).toBe(true); - - const resolvedOutsideDir = path.resolve(outsideDir); - const resolvedTargetWasTouched = spy.mock.calls.some(([candidate]) => ( - typeof candidate === 'string' && samePath(candidate, resolvedOutsideDir) - )); - expect(resolvedTargetWasTouched).toBe(false); - } finally { - spy.mockRestore(); - } - }); }); // ------------------------------------------------------------------------- diff --git a/src/__tests__/permission-evaluator-742.test.ts b/src/__tests__/permission-evaluator-742.test.ts index b08b82b0..ffcb0072 100644 --- a/src/__tests__/permission-evaluator-742.test.ts +++ b/src/__tests__/permission-evaluator-742.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { evaluatePermissionProfile } from '../services/permission/index.js'; +import { evaluatePermissionProfile } from '../permission-evaluator.js'; describe('Issue #742: permission profile evaluator', () => { it('falls back to defaultBehavior when no rule matches', () => { diff --git a/src/__tests__/permission-evaluator-coverage-1305.test.ts b/src/__tests__/permission-evaluator-coverage-1305.test.ts index fc5d0e6d..ceef70e1 100644 --- a/src/__tests__/permission-evaluator-coverage-1305.test.ts +++ b/src/__tests__/permission-evaluator-coverage-1305.test.ts @@ -1,7 +1,7 @@ /** * permission-evaluator-coverage-1305.test.ts — Additional coverage tests for Issue #1305. * - * Targets uncovered branches in src/services/permission/evaluator.ts: + * Targets uncovered branches in src/permission-evaluator.ts: * - Pattern matching with JSON.stringify fallback (no command field in toolInput) * - readOnly constraint with non-write tool (should allow) * - Path constraint with empty paths array (no constraint applied) @@ -14,10 +14,10 @@ */ import { describe, it, expect } from 'vitest'; -import { join } from 'node:path'; -import { evaluatePermissionProfile, type PermissionProfile } from '../services/permission/index.js'; +import { evaluatePermissionProfile } from '../permission-evaluator.js'; +import type { PermissionProfile } from '../validation.js'; -describe('Issue #1305: permission evaluator additional coverage', () => { +describe('Issue #1305: permission-evaluator.ts additional coverage', () => { // ── Pattern matching ──────────────────────────────────────────────── describe('Pattern matching with JSON.stringify fallback', () => { @@ -521,34 +521,32 @@ describe('Issue #1305: permission evaluator additional coverage', () => { }); it('should extract path from target field in toolInput', () => { - const allowedPrefix = join(process.cwd(), 'allowed-root'); const result = evaluatePermissionProfile({ defaultBehavior: 'deny', rules: [{ tool: 'Delete', behavior: 'allow', - constraints: { paths: [allowedPrefix] }, + constraints: { paths: ['/tmp'] }, }], }, { toolName: 'Delete', - toolInput: { target: join(allowedPrefix, 'file.txt') }, + toolInput: { target: '/tmp/file.txt' }, }); expect(result.behavior).toBe('allow'); }); it('should deny when target field path is outside allowed prefixes', () => { - const allowedPrefix = join(process.cwd(), 'allowed-root'); const result = evaluatePermissionProfile({ defaultBehavior: 'allow', rules: [{ tool: 'Delete', behavior: 'allow', - constraints: { paths: [allowedPrefix] }, + constraints: { paths: ['/tmp'] }, }], }, { toolName: 'Delete', - toolInput: { target: join(process.cwd(), 'outside.txt') }, + toolInput: { target: '/etc/important' }, }); expect(result.behavior).toBe('deny'); diff --git a/src/__tests__/permission-evaluator-symlink-1402.test.ts b/src/__tests__/permission-evaluator-symlink-1402.test.ts deleted file mode 100644 index 3664949b..00000000 --- a/src/__tests__/permission-evaluator-symlink-1402.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * permission-evaluator-symlink-1402.test.ts — Symlink bypass fix for Issue #1402. - * - * Validates that isPathAllowed resolves symlinks via realpath before - * checking path prefixes, preventing symlink-based escapes outside - * the allowed directory. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, symlinkSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { evaluatePermissionProfile } from '../services/permission/index.js'; - -describe('Issue #1402: permission evaluator symlink bypass', () => { - let allowedDir: string; - let outsideDir: string; - - beforeEach(() => { - allowedDir = mkdtempSync(join(tmpdir(), 'aegis-allowed-')); - outsideDir = mkdtempSync(join(tmpdir(), 'aegis-outside-')); - }); - - afterEach(() => { - rmSync(allowedDir, { recursive: true, force: true }); - rmSync(outsideDir, { recursive: true, force: true }); - }); - - it('rejects symlink pointing outside allowed prefix', () => { - // Create a symlink inside allowedDir that points outside - const linkPath = join(allowedDir, 'escape'); - symlinkSync(outsideDir, linkPath); - - const result = evaluatePermissionProfile({ - defaultBehavior: 'deny', - rules: [{ - tool: 'Read', - behavior: 'allow', - constraints: { paths: [allowedDir] }, - }], - }, { - toolName: 'Read', - toolInput: { file_path: linkPath }, - }); - - expect(result.behavior).toBe('deny'); - expect(result.reason).toContain('path constraint'); - }); - - it('rejects symlink to outside file from allowed dir', () => { - const linkPath = join(allowedDir, 'passwd'); - const outsideFile = join(outsideDir, 'passwd'); - writeFileSync(outsideFile, 'root:x:0:0:root:/root:/bin/bash'); - symlinkSync(outsideFile, linkPath); - - const result = evaluatePermissionProfile({ - defaultBehavior: 'deny', - rules: [{ - tool: 'Read', - behavior: 'allow', - constraints: { paths: [allowedDir] }, - }], - }, { - toolName: 'Read', - toolInput: { path: linkPath }, - }); - - expect(result.behavior).toBe('deny'); - }); - - it('allows real file inside allowed prefix', () => { - const realFile = join(allowedDir, 'safe.ts'); - writeFileSync(realFile, '// safe'); - - const result = evaluatePermissionProfile({ - defaultBehavior: 'deny', - rules: [{ - tool: 'Read', - behavior: 'allow', - constraints: { paths: [allowedDir] }, - }], - }, { - toolName: 'Read', - toolInput: { file_path: realFile }, - }); - - expect(result.behavior).toBe('allow'); - }); - - it('allows symlink that resolves inside allowed prefix', () => { - const subDir = join(allowedDir, 'sub'); - mkdirSync(subDir); - const realFile = join(subDir, 'target.ts'); - writeFileSync(realFile, '// target'); - - const linkPath = join(allowedDir, 'link'); - symlinkSync(realFile, linkPath); - - const result = evaluatePermissionProfile({ - defaultBehavior: 'deny', - rules: [{ - tool: 'Read', - behavior: 'allow', - constraints: { paths: [allowedDir] }, - }], - }, { - toolName: 'Read', - toolInput: { file_path: linkPath }, - }); - - expect(result.behavior).toBe('allow'); - }); - - it('falls back to normalize for non-existent paths', () => { - const result = evaluatePermissionProfile({ - defaultBehavior: 'deny', - rules: [{ - tool: 'Write', - behavior: 'allow', - constraints: { paths: [allowedDir] }, - }], - }, { - toolName: 'Write', - toolInput: { file_path: join(allowedDir, 'new-file.ts'), content: 'x' }, - }); - - expect(result.behavior).toBe('allow'); - }); -}); diff --git a/src/__tests__/pipeline.test.ts b/src/__tests__/pipeline.test.ts index 7620ba43..ebd656a2 100644 --- a/src/__tests__/pipeline.test.ts +++ b/src/__tests__/pipeline.test.ts @@ -10,9 +10,6 @@ import { PipelineManager } from '../pipeline.js'; import type { BatchSessionSpec, PipelineConfig } from '../pipeline.js'; import type { SessionManager, SessionInfo } from '../session.js'; import type { SessionEventBus } from '../events.js'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import os from 'node:os'; // --------------------------------------------------------------------------- // Helpers: mock factories @@ -1322,105 +1319,6 @@ describe('PipelineManager', () => { expect(pipeline.stages[0].status).toBe('completed'); expect(pipeline.status).toBe('completed'); }); - - it('uses global defaultStageTimeoutMs when per-stage timeout is not set', async () => { - vi.useFakeTimers(); - - // Create manager with a global default of 90s - const defaultManager = new PipelineManager(sessions.mock, eventBus.mock, null, 90_000); - - const config: PipelineConfig = { - name: 'default-timeout', - workDir: '/app', - stages: [ - { name: 'slow', prompt: 'run slow', dependsOn: [] }, - // No stageTimeoutMs — should use global default of 90s - ], - }; - - sessions.createSession.mockResolvedValue(makeMockSession('s-slow')); - sessions.sendInitialPrompt.mockResolvedValue({ delivered: true, attempts: 1 }); - - const pipeline = await defaultManager.createPipeline(config); - - sessions.getSession.mockReturnValue(makeMockSession('s-slow', { status: 'working' })); - - // Advance past the 90s global default - vi.advanceTimersByTime(91_000); - await (defaultManager as unknown as { pollPipelines: () => Promise }).pollPipelines(); - - expect(pipeline.stages[0].status).toBe('failed'); - expect(pipeline.stages[0].error).toBe('stage_timeout'); - - await defaultManager.destroy(); - }); - - it('per-stage timeout overrides global defaultStageTimeoutMs', async () => { - vi.useFakeTimers(); - - // Global default is 30s, but per-stage is 120s - const defaultManager = new PipelineManager(sessions.mock, eventBus.mock, null, 30_000); - - const config: PipelineConfig = { - name: 'override-timeout', - workDir: '/app', - stages: [ - { name: 'slow', prompt: 'run slow', dependsOn: [], stageTimeoutMs: 120_000 }, - ], - }; - - sessions.createSession.mockResolvedValue(makeMockSession('s-slow')); - sessions.sendInitialPrompt.mockResolvedValue({ delivered: true, attempts: 1 }); - - const pipeline = await defaultManager.createPipeline(config); - - sessions.getSession.mockReturnValue(makeMockSession('s-slow', { status: 'working' })); - - // Advance past global default (30s) but not per-stage (120s) - vi.advanceTimersByTime(31_000); - await (defaultManager as unknown as { pollPipelines: () => Promise }).pollPipelines(); - - // Should still be running — per-stage timeout takes precedence - expect(pipeline.stages[0].status).toBe('running'); - - // Now advance past per-stage timeout - vi.advanceTimersByTime(90_000); - await (defaultManager as unknown as { pollPipelines: () => Promise }).pollPipelines(); - - expect(pipeline.stages[0].status).toBe('failed'); - expect(pipeline.stages[0].error).toBe('stage_timeout'); - - await defaultManager.destroy(); - }); - - it('timeout wins over idle when both are true at the same poll check', async () => { - vi.useFakeTimers(); - - // Use a very short timeout so timeout fires on the first poll - const config: PipelineConfig = { - name: 'timeout-over-idle', - workDir: '/app', - stages: [ - { name: 'slow', prompt: 'run slow', dependsOn: [], stageTimeoutMs: 3_000 }, - ], - }; - - sessions.createSession.mockResolvedValue(makeMockSession('s-slow')); - sessions.sendInitialPrompt.mockResolvedValue({ delivered: true, attempts: 1 }); - - const pipeline = await manager.createPipeline(config); - - // Session is idle AND timed out — timeout should win at the same poll - sessions.getSession.mockReturnValue(makeMockSession('s-slow', { status: 'idle' })); - - // Advance past the 3s timeout to the first poll at ~5s - vi.advanceTimersByTime(5_000); - await (manager as unknown as { pollPipelines: () => Promise }).pollPipelines(); - - // Should be failed (timeout wins over idle) - expect(pipeline.stages[0].status).toBe('failed'); - expect(pipeline.stages[0].error).toBe('stage_timeout'); - }); }); // ========================================================================= @@ -1557,218 +1455,4 @@ describe('PipelineManager', () => { expect(pipeline.stages.find(s => s.name === 'C')?.status).toBe('pending'); }); }); - - // ========================================================================= - // 12. Pipeline Persistence (#1424) - // ========================================================================= - - describe('pipeline persistence (#1424)', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = join(os.tmpdir(), `aegis-test-pipeline-${Date.now()}-${Math.random().toString(36).slice(2)}`); - await mkdir(tmpDir, { recursive: true }); - }); - - afterEach(async () => { - await import('node:fs/promises').then(fs => fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})); - }); - - it('persists running pipeline to disk on creation', async () => { - const manager = new PipelineManager(sessions.mock, eventBus.mock, tmpDir); - const config: PipelineConfig = { - name: 'persist-test', - workDir: '/app', - stages: [{ name: 'A', prompt: 'run a', dependsOn: [] }], - }; - - sessions.createSession.mockResolvedValue(makeMockSession('s-a')); - sessions.sendInitialPrompt.mockResolvedValue({ delivered: true, attempts: 1 }); - - await manager.createPipeline(config); - - const file = join(tmpDir, 'pipelines.json'); - const raw = await readFile(file, 'utf-8'); - const entries = JSON.parse(raw); - - expect(entries).toHaveLength(1); - expect(entries[0].name).toBe('persist-test'); - expect(entries[0].status).toBe('running'); - expect(entries[0]._config).toBeDefined(); - expect(entries[0]._config.stages[0].prompt).toBe('run a'); - - await manager.destroy(); - }); - - it('deletes state file when all pipelines complete', async () => { - const manager = new PipelineManager(sessions.mock, eventBus.mock, tmpDir); - const config: PipelineConfig = { - name: 'complete-test', - workDir: '/app', - stages: [{ name: 'A', prompt: 'run a', dependsOn: [] }], - }; - - sessions.createSession.mockResolvedValue(makeMockSession('s-a')); - sessions.sendInitialPrompt.mockResolvedValue({ delivered: true, attempts: 1 }); - sessions.getSession.mockReturnValue(makeMockSession('s-a', { status: 'idle' })); - - await manager.createPipeline(config); - const file = join(tmpDir, 'pipelines.json'); - - // File exists after creation - await expect(readFile(file, 'utf-8')).resolves.toBeDefined(); - - // Poll to detect completion - await (manager as unknown as { pollPipelines: () => Promise }).pollPipelines(); - - // Pipeline completed — persistPipelines should have deleted the file - await expect(readFile(file, 'utf-8')).rejects.toThrow(); - - await manager.destroy(); - }); - - it('hydrates running pipelines from disk on startup', async () => { - // First: create a pipeline and let it persist - const config: PipelineConfig = { - name: 'hydrate-test', - workDir: '/app', - stages: [ - { name: 'build', prompt: 'build it', dependsOn: [] }, - { name: 'test', prompt: 'test it', dependsOn: ['build'] }, - ], - }; - - sessions.createSession.mockResolvedValue(makeMockSession('s-build')); - sessions.sendInitialPrompt.mockResolvedValue({ delivered: true, attempts: 1 }); - - const manager1 = new PipelineManager(sessions.mock, eventBus.mock, tmpDir); - const original = await manager1.createPipeline(config); - await manager1.destroy(); - - // Second: simulate server restart — new manager hydrates from disk - sessions.getSession.mockReturnValue(makeMockSession('s-build', { status: 'working' })); - const manager2 = new PipelineManager(sessions.mock, eventBus.mock, tmpDir); - const recovered = await manager2.hydrate(tmpDir); - - expect(recovered).toBe(1); - const restored = manager2.getPipeline(original.id); - expect(restored).not.toBeNull(); - expect(restored!.name).toBe('hydrate-test'); - expect(restored!.status).toBe('running'); - expect(restored!.stages[0].status).toBe('running'); - expect(restored!.stages[1].status).toBe('pending'); - - // Config should also be restored - const configs = (manager2 as unknown as { pipelineConfigs: Map }).pipelineConfigs; - expect(configs.get(original.id)).toBeDefined(); - expect(configs.get(original.id)!.stages[1].prompt).toBe('test it'); - - await manager2.destroy(); - }); - - it('marks orphaned stages as failed during hydration', async () => { - // Persist a pipeline with a running stage - const config: PipelineConfig = { - name: 'orphan-test', - workDir: '/app', - stages: [{ name: 'A', prompt: 'run a', dependsOn: [] }], - }; - - sessions.createSession.mockResolvedValue(makeMockSession('s-gone')); - sessions.sendInitialPrompt.mockResolvedValue({ delivered: true, attempts: 1 }); - - const manager1 = new PipelineManager(sessions.mock, eventBus.mock, tmpDir); - await manager1.createPipeline(config); - await manager1.destroy(); - - // Restart: session no longer exists - sessions.getSession.mockReturnValue(null); - - const manager2 = new PipelineManager(sessions.mock, eventBus.mock, tmpDir); - const recovered = await manager2.hydrate(tmpDir); - - expect(recovered).toBe(1); - const restored = manager2.getPipeline( - (manager2.listPipelines()[0]).id, - ); - expect(restored!.status).toBe('failed'); - expect(restored!.stages[0].status).toBe('failed'); - expect(restored!.stages[0].error).toBe('Session disappeared during server restart'); - - await manager2.destroy(); - }); - - it('returns 0 when no state file exists', async () => { - const manager = new PipelineManager(sessions.mock, eventBus.mock, tmpDir); - const recovered = await manager.hydrate(tmpDir); - expect(recovered).toBe(0); - await manager.destroy(); - }); - - it('returns 0 when state file is corrupt JSON', async () => { - await writeFile(join(tmpDir, 'pipelines.json'), 'not json{{', 'utf-8'); - - const manager = new PipelineManager(sessions.mock, eventBus.mock, tmpDir); - const recovered = await manager.hydrate(tmpDir); - expect(recovered).toBe(0); - await manager.destroy(); - }); - - it('returns 0 when state file contains non-array', async () => { - await writeFile(join(tmpDir, 'pipelines.json'), '{"not": "an array"}', 'utf-8'); - - const manager = new PipelineManager(sessions.mock, eventBus.mock, tmpDir); - const recovered = await manager.hydrate(tmpDir); - expect(recovered).toBe(0); - await manager.destroy(); - }); - - it('skips malformed entries during hydration', async () => { - await writeFile(join(tmpDir, 'pipelines.json'), JSON.stringify([ - { id: 'good', name: 'good', status: 'running', stages: [{ name: 'A', status: 'pending', dependsOn: [] }], stageHistory: [], createdAt: Date.now(), currentStage: 'plan', retryCount: 0, maxRetries: 3 }, - { id: 'bad', name: 'bad' }, // missing stages array - 'not-an-object', - null, - ]), 'utf-8'); - - const manager = new PipelineManager(sessions.mock, eventBus.mock, tmpDir); - const recovered = await manager.hydrate(tmpDir); - expect(recovered).toBe(1); - expect(manager.listPipelines()).toHaveLength(1); - expect(manager.listPipelines()[0].name).toBe('good'); - await manager.destroy(); - }); - - it('does not persist when stateDir is null', async () => { - const manager = new PipelineManager(sessions.mock, eventBus.mock, null); - const config: PipelineConfig = { - name: 'no-dir', - workDir: '/app', - stages: [{ name: 'A', prompt: 'run', dependsOn: [] }], - }; - - sessions.createSession.mockResolvedValue(makeMockSession('s1')); - sessions.sendInitialPrompt.mockResolvedValue({ delivered: true, attempts: 1 }); - - await manager.createPipeline(config); - // No crash, no file written - await manager.destroy(); - }); - - it('persist is non-fatal when stateDir does not exist', async () => { - const manager = new PipelineManager(sessions.mock, eventBus.mock, '/nonexistent/path/that/does/not/exist'); - const config: PipelineConfig = { - name: 'bad-dir', - workDir: '/app', - stages: [{ name: 'A', prompt: 'run', dependsOn: [] }], - }; - - sessions.createSession.mockResolvedValue(makeMockSession('s1')); - sessions.sendInitialPrompt.mockResolvedValue({ delivered: true, attempts: 1 }); - - // Should not throw — write failure is non-fatal - await expect(manager.createPipeline(config)).resolves.toBeDefined(); - await manager.destroy(); - }); - }); }); diff --git a/src/__tests__/security-629-630-634.test.ts b/src/__tests__/security-629-630-634.test.ts index 1b247a1e..03cd7905 100644 --- a/src/__tests__/security-629-630-634.test.ts +++ b/src/__tests__/security-629-630-634.test.ts @@ -58,7 +58,6 @@ describe('Issue #629: Hook endpoint secret validation', () => { }); afterEach(async () => { - vi.restoreAllMocks(); await app.close(); }); @@ -73,72 +72,12 @@ describe('Issue #629: Hook endpoint secret validation', () => { }); it('should accept hook with valid session ID and correct secret in query param (backward compat)', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const res = await app.inject({ method: 'POST', url: `/v1/hooks/Stop?sessionId=${VALID_SESSION_ID}&secret=${VALID_SECRET}`, payload: {}, }); expect(res.statusCode).toBe(200); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('query-string hook secret is deprecated')); - }); - - it('should reject query-param hook secret in header-only mode', async () => { - const app2 = Fastify({ logger: false }); - registerHookRoutes(app2, { - sessions: createMockSessionManager(), - eventBus: new SessionEventBus(), - hookSecretHeaderOnly: true, - }); - - const res = await app2.inject({ - method: 'POST', - url: `/v1/hooks/Stop?sessionId=${VALID_SESSION_ID}&secret=${VALID_SECRET}`, - payload: {}, - }); - - expect(res.statusCode).toBe(401); - expect(res.json().error).toContain('X-Hook-Secret'); - await app2.close(); - }); - - it('should reject query-param hook secret even when header secret is present in header-only mode', async () => { - const app2 = Fastify({ logger: false }); - registerHookRoutes(app2, { - sessions: createMockSessionManager(), - eventBus: new SessionEventBus(), - hookSecretHeaderOnly: true, - }); - - const res = await app2.inject({ - method: 'POST', - url: `/v1/hooks/Stop?sessionId=${VALID_SESSION_ID}&secret=${VALID_SECRET}`, - headers: { 'x-hook-secret': VALID_SECRET }, - payload: {}, - }); - - expect(res.statusCode).toBe(401); - expect(res.json().error).toContain('X-Hook-Secret'); - await app2.close(); - }); - - it('should accept header hook secret in header-only mode', async () => { - const app2 = Fastify({ logger: false }); - registerHookRoutes(app2, { - sessions: createMockSessionManager(), - eventBus: new SessionEventBus(), - hookSecretHeaderOnly: true, - }); - - const res = await app2.inject({ - method: 'POST', - url: `/v1/hooks/Stop?sessionId=${VALID_SESSION_ID}`, - headers: { 'x-hook-secret': VALID_SECRET }, - payload: {}, - }); - - expect(res.statusCode).toBe(200); - await app2.close(); }); it('should reject hook with valid session ID but wrong secret in header', async () => { diff --git a/src/__tests__/server-core-coverage.test.ts b/src/__tests__/server-core-coverage.test.ts deleted file mode 100644 index 2c159160..00000000 --- a/src/__tests__/server-core-coverage.test.ts +++ /dev/null @@ -1,569 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { FastifyInstance } from 'fastify'; -import { mkdirSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import crypto from 'node:crypto'; -import { TmuxManager } from '../tmux.js'; - -const sandboxRoot = join(process.cwd(), '.test-scratch', `server-core-${crypto.randomUUID()}`); -const stateDir = join(sandboxRoot, 'state'); -const projectsDir = join(sandboxRoot, 'projects'); -const workDir = join(sandboxRoot, 'workdir'); - -const originalEnv: Record = { - AEGIS_STATE_DIR: process.env.AEGIS_STATE_DIR, - AEGIS_CLAUDE_PROJECTS_DIR: process.env.AEGIS_CLAUDE_PROJECTS_DIR, - AEGIS_PORT: process.env.AEGIS_PORT, - AEGIS_HOST: process.env.AEGIS_HOST, - AEGIS_AUTH_TOKEN: process.env.AEGIS_AUTH_TOKEN, -}; - -let capturedApp: FastifyInstance | null = null; -const pipelineStore = new Map>(); - -vi.mock('../startup.js', () => ({ - listenWithRetry: vi.fn(async (app: FastifyInstance) => { - capturedApp = app; - await app.ready(); - }), - writePidFile: vi.fn(async () => join(stateDir, 'aegis.pid')), - removePidFile: vi.fn(), -})); - -vi.mock('../pipeline.js', () => ({ - PipelineManager: class { - async hydrate(): Promise {} - async destroy(): Promise {} - - async batchCreate(specs: Array>): Promise<{ created: unknown[]; errors: unknown[] }> { - return { - created: specs.map((spec, i) => ({ id: `batch-${i + 1}`, ...spec })), - errors: [], - }; - } - - async createPipeline(config: Record): Promise> { - const pipeline = { id: `pipeline-${pipelineStore.size + 1}`, status: 'created', ...config }; - pipelineStore.set(String(pipeline.id), pipeline); - return pipeline; - } - - getPipeline(id: string): Record | null { - return pipelineStore.get(id) ?? null; - } - - listPipelines(): Record[] { - return [...pipelineStore.values()]; - } - }, -})); - -type FakeWindow = { - windowId: string; - windowName: string; - cwd: string; - paneCommand: string; - paneText: string; - paneDead: boolean; - panePid: number; -}; - -const fakeWindows = new Map(); -let tmuxSessionReady = false; -let nextWindowId = 1; - -function resetFakeTmuxState(): void { - fakeWindows.clear(); - tmuxSessionReady = false; - nextWindowId = 1; -} - -function normalizeWindowTarget(target: string): string { - const idx = target.indexOf(':'); - return idx >= 0 ? target.slice(idx + 1) : target; -} - -function findWindow(target: string): FakeWindow | undefined { - const normalized = normalizeWindowTarget(target); - if (normalized.startsWith('@')) { - return [...fakeWindows.values()].find(w => w.windowId === normalized); - } - return fakeWindows.get(normalized); -} - -function windowsAsTmuxRows(): string { - return [...fakeWindows.values()] - .map((w) => `${w.windowId}\t${w.windowName}\t${w.cwd}\t${w.paneCommand}\t${w.paneDead ? '1' : '0'}`) - .join('\n'); -} - -async function tmuxInternalStub(...args: string[]): Promise { - const [cmd, ...rest] = args; - - if (cmd === 'has-session') { - if (!tmuxSessionReady) throw new Error('no session'); - return ''; - } - - if (cmd === 'new-session') { - tmuxSessionReady = true; - if (!fakeWindows.has('_bridge_main')) { - fakeWindows.set('_bridge_main', { - windowId: '@0', - windowName: '_bridge_main', - cwd: workDir, - paneCommand: 'bash', - paneText: '', - paneDead: false, - panePid: 9000, - }); - } - return ''; - } - - if (cmd === 'list-sessions') { - if (!tmuxSessionReady) throw new Error('no server running'); - return 'aegis'; - } - - if (cmd === 'kill-session') { - tmuxSessionReady = false; - fakeWindows.clear(); - return ''; - } - - if (cmd === 'list-windows') { - if (!tmuxSessionReady) throw new Error('no server running'); - return windowsAsTmuxRows(); - } - - if (cmd === 'new-window') { - const name = rest[rest.indexOf('-n') + 1]!; - const cwd = rest[rest.indexOf('-c') + 1]!; - if (fakeWindows.has(name)) { - throw new Error(`duplicate window: ${name}`); - } - fakeWindows.set(name, { - windowId: `@${nextWindowId++}`, - windowName: name, - cwd, - paneCommand: 'bash', - paneText: '', - paneDead: false, - panePid: 9000 + nextWindowId, - }); - return ''; - } - - if (cmd === 'display-message') { - const target = rest[rest.indexOf('-t') + 1]!; - const win = findWindow(target); - if (!win) throw new Error(`can't find window: ${target}`); - return win.windowId; - } - - if (cmd === 'send-keys') { - const target = rest[rest.indexOf('-t') + 1]!; - const win = findWindow(target); - if (!win) throw new Error(`can't find window: ${target}`); - const literalIdx = rest.indexOf('-l'); - if (literalIdx >= 0) { - const text = rest[literalIdx + 1] ?? ''; - win.paneText = `${win.paneText}${text}`; - if (text.includes('claude') || text.includes('--session-id') || text.includes('--resume')) { - win.paneCommand = 'claude'; - win.paneText = '✻ Working…'; - } - return ''; - } - const key = rest[rest.length - 1]; - if (key === 'Enter') { - win.paneCommand = 'claude'; - win.paneText = '✻ Working…'; - } - if (key === 'C-c' || key === 'Escape') { - win.paneText = `sent:${key}`; - } - return ''; - } - - if (cmd === 'capture-pane') { - const target = rest[rest.indexOf('-t') + 1]!; - const win = findWindow(target); - return win?.paneText ?? ''; - } - - if (cmd === 'list-panes') { - const target = rest[rest.indexOf('-t') + 1]!; - const win = findWindow(target); - return win ? String(win.panePid) : ''; - } - - if (cmd === 'kill-window') { - const target = rest[rest.indexOf('-t') + 1]!; - const win = findWindow(target); - if (win) fakeWindows.delete(win.windowName); - return ''; - } - - if (cmd === 'set-option' || cmd === 'select-pane' || cmd === 'set-environment' || cmd === 'resize-pane') { - return ''; - } - - throw new Error(`unexpected tmux command in test: ${cmd}`); -} - -describe('server core coverage integration', () => { - let app: FastifyInstance; - - beforeAll(async () => { - mkdirSync(workDir, { recursive: true }); - mkdirSync(stateDir, { recursive: true }); - mkdirSync(projectsDir, { recursive: true }); - pipelineStore.clear(); - - process.env.AEGIS_STATE_DIR = stateDir; - process.env.AEGIS_CLAUDE_PROJECTS_DIR = projectsDir; - process.env.AEGIS_PORT = '19100'; - process.env.AEGIS_HOST = '127.0.0.1'; - process.env.AEGIS_AUTH_TOKEN = ''; - - resetFakeTmuxState(); - - vi.spyOn(globalThis, 'setInterval').mockImplementation((() => 0) as any); - vi.spyOn(globalThis, 'clearInterval').mockImplementation((() => undefined) as any); - vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); - - vi.spyOn(TmuxManager.prototype as any, 'tmuxInternal').mockImplementation(tmuxInternalStub as any); - vi.spyOn(TmuxManager.prototype as any, 'tmuxShellBatch').mockImplementation(async () => undefined); - vi.spyOn(TmuxManager.prototype, 'capturePaneDirect').mockImplementation(async (windowId: string) => { - const win = findWindow(windowId); - return win?.paneText ?? ''; - }); - - await import('../server.js'); - - for (let i = 0; i < 200 && !capturedApp; i++) { - await new Promise(resolve => setTimeout(resolve, 10)); - } - if (!capturedApp) { - throw new Error('server app was not captured from listenWithRetry'); - } - app = capturedApp; - }); - - afterAll(async () => { - await app?.close(); - vi.restoreAllMocks(); - - for (const [key, value] of Object.entries(originalEnv)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - - rmSync(sandboxRoot, { recursive: true, force: true }); - }); - - it('covers key REST paths using real server/session/tmux wiring', { timeout: 30_000 }, async () => { - const health = await app.inject({ method: 'GET', url: '/v1/health' }); - expect(health.statusCode).toBe(200); - - const invalidCreate = await app.inject({ method: 'POST', url: '/v1/sessions', payload: {} }); - expect(invalidCreate.statusCode).toBe(400); - - const created = await app.inject({ - method: 'POST', - url: '/v1/sessions', - payload: { - workDir, - name: 'core-session', - permissionMode: 'bypassPermissions', - claudeCommand: 'claude --print', - }, - }); - expect(created.statusCode).toBe(201); - const createdBody = created.json(); - expect(createdBody.id).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, - ); - const sessionId = createdBody.id as string; - - const list = await app.inject({ method: 'GET', url: '/v1/sessions' }); - expect(list.statusCode).toBe(200); - - const getSession = await app.inject({ method: 'GET', url: `/v1/sessions/${sessionId}` }); - expect(getSession.statusCode).toBe(200); - - const sendInvalid = await app.inject({ method: 'POST', url: `/v1/sessions/${sessionId}/send`, payload: {} }); - expect(sendInvalid.statusCode).toBe(400); - - const send = await app.inject({ - method: 'POST', - url: `/v1/sessions/${sessionId}/send`, - payload: { text: 'Summarize current status' }, - }); - expect(send.statusCode).toBe(200); - expect(send.json().delivered).toBe(true); - - const command = await app.inject({ - method: 'POST', - url: `/v1/sessions/${sessionId}/command`, - payload: { command: 'git status' }, - }); - expect(command.statusCode).toBe(200); - - const bash = await app.inject({ - method: 'POST', - url: `/v1/sessions/${sessionId}/bash`, - payload: { command: 'echo hi' }, - }); - expect(bash.statusCode).toBe(200); - - const slashCommand = await app.inject({ - method: 'POST', - url: `/v1/sessions/${sessionId}/command`, - payload: { command: '/status' }, - }); - expect(slashCommand.statusCode).toBe(200); - - const bangBash = await app.inject({ - method: 'POST', - url: `/v1/sessions/${sessionId}/bash`, - payload: { command: '!echo hi' }, - }); - expect(bangBash.statusCode).toBe(200); - - const summary = await app.inject({ method: 'GET', url: `/v1/sessions/${sessionId}/summary` }); - expect(summary.statusCode).toBe(200); - - const transcript = await app.inject({ method: 'GET', url: `/v1/sessions/${sessionId}/transcript?limit=5` }); - expect(transcript.statusCode).toBe(200); - - const healthById = await app.inject({ method: 'GET', url: `/v1/sessions/${sessionId}/health` }); - expect(healthById.statusCode).toBe(200); - - const pane = await app.inject({ method: 'GET', url: `/v1/sessions/${sessionId}/pane` }); - expect(pane.statusCode).toBe(200); - - const badRoleTranscript = await app.inject({ - method: 'GET', - url: `/v1/sessions/${sessionId}/transcript?role=bad-role`, - }); - expect(badRoleTranscript.statusCode).toBe(400); - - const badCursor = await app.inject({ - method: 'GET', - url: `/v1/sessions/${sessionId}/transcript/cursor?before_id=0`, - }); - expect(badCursor.statusCode).toBe(400); - - const permissionsInvalid = await app.inject({ - method: 'PUT', - url: `/v1/sessions/${sessionId}/permissions`, - payload: [{ source: 'aegisApi', ruleBehavior: 'invalid' }], - }); - expect(permissionsInvalid.statusCode).toBe(400); - - const permissionsUpdated = await app.inject({ - method: 'PUT', - url: `/v1/sessions/${sessionId}/permissions`, - payload: [{ source: 'aegisApi', ruleBehavior: 'allow', toolName: 'Bash' }], - }); - expect(permissionsUpdated.statusCode).toBe(200); - - const profileUpdated = await app.inject({ - method: 'PUT', - url: `/v1/sessions/${sessionId}/permission-profile`, - payload: { - defaultBehavior: 'ask', - rules: [{ tool: 'Bash', behavior: 'allow' }], - }, - }); - expect(profileUpdated.statusCode).toBe(200); - - const permissionHook = await app.inject({ - method: 'POST', - url: `/v1/sessions/${sessionId}/hooks/permission`, - payload: { tool_name: 'Bash', permission_mode: 'ask' }, - }); - expect(permissionHook.statusCode).toBe(200); - - const stopHook = await app.inject({ - method: 'POST', - url: `/v1/sessions/${sessionId}/hooks/stop`, - payload: { stop_reason: 'done' }, - }); - expect(stopHook.statusCode).toBe(200); - - const answerMissing = await app.inject({ - method: 'POST', - url: `/v1/sessions/${sessionId}/answer`, - payload: {}, - }); - expect(answerMissing.statusCode).toBe(400); - - const answerConflict = await app.inject({ - method: 'POST', - url: `/v1/sessions/${sessionId}/answer`, - payload: { questionId: 'q-1', answer: 'yes' }, - }); - expect(answerConflict.statusCode).toBe(409); - - const screenshotInvalid = await app.inject({ - method: 'POST', - url: `/v1/sessions/${sessionId}/screenshot`, - payload: { url: 'not-a-url' }, - }); - expect(screenshotInvalid.statusCode).toBe(400); - - const interrupt = await app.inject({ method: 'POST', url: `/v1/sessions/${sessionId}/interrupt`, payload: {} }); - expect(interrupt.statusCode).toBe(200); - - const escape = await app.inject({ method: 'POST', url: `/v1/sessions/${sessionId}/escape`, payload: {} }); - expect(escape.statusCode).toBe(200); - - const spawned = await app.inject({ - method: 'POST', - url: `/v1/sessions/${sessionId}/spawn`, - payload: { name: 'child-session', workDir, permissionMode: 'bypassPermissions' }, - }); - expect(spawned.statusCode).toBe(201); - const childId = spawned.json().id as string; - - const forked = await app.inject({ - method: 'POST', - url: `/v1/sessions/${sessionId}/fork`, - payload: { name: 'fork-session' }, - }); - expect(forked.statusCode).toBe(201); - const forkId = forked.json().id as string; - - const children = await app.inject({ method: 'GET', url: `/v1/sessions/${sessionId}/children` }); - expect(children.statusCode).toBe(200); - - const sessionsHealth = await app.inject({ method: 'GET', url: '/v1/sessions/health' }); - expect(sessionsHealth.statusCode).toBe(200); - - const invalidBatchCreate = await app.inject({ - method: 'POST', - url: '/v1/sessions/batch', - payload: {}, - }); - expect(invalidBatchCreate.statusCode).toBe(400); - - const batchCreate = await app.inject({ - method: 'POST', - url: '/v1/sessions/batch', - payload: { - sessions: [ - { - workDir, - prompt: 'batch prompt', - permissionMode: 'bypassPermissions', - }, - ], - }, - }); - expect(batchCreate.statusCode).toBe(201); - - const batchRateLimited = await app.inject({ - method: 'POST', - url: '/v1/sessions/batch', - payload: { - sessions: [ - { - workDir, - prompt: 'batch prompt', - permissionMode: 'bypassPermissions', - }, - ], - }, - }); - expect(batchRateLimited.statusCode).toBe(429); - - const alertsTest = await app.inject({ method: 'POST', url: '/v1/alerts/test', payload: {} }); - expect(alertsTest.statusCode).toBe(403); - - const authKeys = await app.inject({ method: 'GET', url: '/v1/auth/keys' }); - expect(authKeys.statusCode).toBe(403); - - const authVerify = await app.inject({ - method: 'POST', - url: '/v1/auth/verify', - payload: { token: 'does-not-matter' }, - }); - expect(authVerify.statusCode).toBe(200); - - const sseToken = await app.inject({ method: 'POST', url: '/v1/auth/sse-token', payload: {} }); - expect(sseToken.statusCode).toBe(201); - - const handshakeInvalid = await app.inject({ method: 'POST', url: '/v1/handshake', payload: {} }); - expect(handshakeInvalid.statusCode).toBe(400); - - const handshake = await app.inject({ - method: 'POST', - url: '/v1/handshake', - payload: { protocolVersion: '1.0.0', clientCapabilities: ['sse'] }, - }); - expect([200, 409]).toContain(handshake.statusCode); - - const handshakeIncompatible = await app.inject({ - method: 'POST', - url: '/v1/handshake', - payload: { protocolVersion: '0' }, - }); - expect(handshakeIncompatible.statusCode).toBe(409); - - const diagnosticsInvalid = await app.inject({ method: 'GET', url: '/v1/diagnostics?limit=0' }); - expect(diagnosticsInvalid.statusCode).toBe(400); - - const diagnostics = await app.inject({ method: 'GET', url: '/v1/diagnostics?limit=5' }); - expect(diagnostics.statusCode).toBe(200); - - const swarm = await app.inject({ method: 'GET', url: '/v1/swarm' }); - expect(swarm.statusCode).toBe(200); - - const invalidPipeline = await app.inject({ method: 'POST', url: '/v1/pipelines', payload: {} }); - expect(invalidPipeline.statusCode).toBe(400); - - const createdPipeline = await app.inject({ - method: 'POST', - url: '/v1/pipelines', - payload: { - name: 'core-pipeline', - workDir, - stages: [{ name: 'one', prompt: 'do work' }], - }, - }); - expect(createdPipeline.statusCode).toBe(201); - - const listPipelines = await app.inject({ method: 'GET', url: '/v1/pipelines' }); - expect(listPipelines.statusCode).toBe(200); - - const missingPipeline = await app.inject({ - method: 'GET', - url: '/v1/pipelines/11111111-1111-1111-1111-111111111111', - }); - expect(missingPipeline.statusCode).toBe(404); - - const metricsV1 = await app.inject({ method: 'GET', url: '/v1/metrics' }); - expect(metricsV1.statusCode).toBe(200); - - const metrics = await app.inject({ method: 'GET', url: '/metrics' }); - expect(metrics.statusCode).toBe(200); - - const batchDelete = await app.inject({ - method: 'DELETE', - url: '/v1/sessions/batch', - payload: { ids: [childId, forkId, sessionId] }, - }); - expect(batchDelete.statusCode).toBe(200); - - const deleted = await app.inject({ method: 'DELETE', url: `/v1/sessions/${sessionId}` }); - expect(deleted.statusCode).toBe(403); - - const missing = await app.inject({ method: 'GET', url: `/v1/sessions/${sessionId}` }); - expect(missing.statusCode).toBe(404); - }); -}); - diff --git a/src/__tests__/stall-detection.test.ts b/src/__tests__/stall-detection.test.ts index 0899e650..bf2e0d7d 100644 --- a/src/__tests__/stall-detection.test.ts +++ b/src/__tests__/stall-detection.test.ts @@ -1043,42 +1043,4 @@ describe('SessionMonitor stall detection (integration)', () => { expect(stallHas(monitor, `${session.id}:stall:jsonl`)).toBe(true); }); }); - - describe('getStallInfo (Issue #1325)', () => { - it('should return stalled: false when no stall is tracked', () => { - expect(monitor.getStallInfo('nonexistent')).toEqual({ stalled: false }); - }); - - it('should return stalled: true with types when stalls exist', () => { - const session = addSession(); - stallAdd(monitor, `${session.id}:stall:jsonl`); - stallAdd(monitor, `${session.id}:stall:thinking`); - - const info = monitor.getStallInfo(session.id); - expect(info.stalled).toBe(true); - if (info.stalled) { - expect(info.types).toContain('jsonl'); - expect(info.types).toContain('thinking'); - } - }); - - it('should return stalled: false after all stall types are cleared', () => { - const session = addSession(); - stallAdd(monitor, `${session.id}:stall:jsonl`); - - expect(monitor.getStallInfo(session.id).stalled).toBe(true); - - (monitor as any).stallDelete(session.id, 'jsonl'); - expect(monitor.getStallInfo(session.id)).toEqual({ stalled: false }); - }); - - it('should return stalled: false after removeSession', () => { - const session = addSession(); - stallAdd(monitor, `${session.id}:stall:jsonl`); - stallAdd(monitor, `${session.id}:stall:permission`); - - monitor.removeSession(session.id); - expect(monitor.getStallInfo(session.id)).toEqual({ stalled: false }); - }); - }); }); diff --git a/src/__tests__/startup.test.ts b/src/__tests__/startup.test.ts deleted file mode 100644 index cc68a496..00000000 --- a/src/__tests__/startup.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; -import { join } from 'node:path'; -import { testTmpDir } from './helpers/platform.js'; - -vi.mock('../file-utils.js', () => ({ - secureFilePermissions: vi.fn(), -})); - -import { writePidFile } from '../startup.js'; - -const mockSecureFilePermissions = vi.mocked((await import('../file-utils.js')).secureFilePermissions); - -describe('writePidFile', () => { - let stateDir: string; - - beforeEach(() => { - vi.clearAllMocks(); - mockSecureFilePermissions.mockResolvedValue(undefined); - stateDir = mkdtempSync(join(testTmpDir(), 'aegis-startup-test-')); - }); - - afterEach(() => { - rmSync(stateDir, { recursive: true, force: true }); - }); - - it('writes PID file and applies permission hardening', async () => { - const pidFilePath = await writePidFile(stateDir); - expect(pidFilePath).toBe(join(stateDir, 'aegis.pid')); - expect(readFileSync(pidFilePath, 'utf-8')).toBe(String(process.pid)); - expect(mockSecureFilePermissions).toHaveBeenCalledWith(pidFilePath); - - const perms = statSync(pidFilePath).mode & 0o777; - if (process.platform === 'win32') { - expect(perms).toBeGreaterThan(0); - } else { - expect(perms).toBe(0o600); - } - }); - - it('returns empty string if permission hardening fails', async () => { - mockSecureFilePermissions.mockRejectedValueOnce(new Error('chmod failed')); - await expect(writePidFile(stateDir)).resolves.toBe(''); - }); -}); diff --git a/src/__tests__/tmux-queue-recovery-1615.test.ts b/src/__tests__/tmux-queue-recovery-1615.test.ts deleted file mode 100644 index e2773549..00000000 --- a/src/__tests__/tmux-queue-recovery-1615.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { TmuxManager } from '../tmux.js'; - -interface QueueHarness { - queue: Promise; - serialize(fn: () => Promise): Promise; -} - -function createQueueHarness(): QueueHarness { - return new TmuxManager('queue-recovery-session', 'queue-recovery-socket') as unknown as QueueHarness; -} - -function poisonQueue(harness: QueueHarness, marker: string): void { - const poisonedQueue = Promise.reject(new Error(`poisoned-queue:${marker}`)); - void poisonedQueue.catch(() => undefined); - harness.queue = poisonedQueue; -} - -function withTimeout(promise: Promise, timeoutMs: number = 1_000): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error(`Timed out after ${timeoutMs}ms`)); - }, timeoutMs); - - promise.then( - value => { - clearTimeout(timer); - resolve(value); - }, - error => { - clearTimeout(timer); - reject(error); - }, - ); - }); -} - -function expectRejectedMessage(result: PromiseSettledResult, expected: string): void { - expect(result.status).toBe('rejected'); - if (result.status !== 'rejected') return; - const message = result.reason instanceof Error ? result.reason.message : String(result.reason); - expect(message).toContain(expected); -} - -describe('TmuxManager serialize queue rejection recovery (Issue #1615)', () => { - it('single rejection recovery: subsequent operation still runs', async () => { - const harness = createQueueHarness(); - const executed: string[] = []; - poisonQueue(harness, 'single'); - - await expect(withTimeout(harness.serialize(async () => { - executed.push('first'); - throw new Error('first failure'); - }))).rejects.toThrow('first failure'); - - await expect(withTimeout(harness.serialize(async () => { - executed.push('second'); - return 'ok'; - }))).resolves.toBe('ok'); - - expect(executed).toEqual(['first', 'second']); - }); - - it('repeated rejection recovery: queue continues after multiple failures', async () => { - const harness = createQueueHarness(); - const executed: string[] = []; - poisonQueue(harness, 'repeated'); - - await expect(withTimeout(harness.serialize(async () => { - executed.push('first'); - throw new Error('fail-1'); - }))).rejects.toThrow('fail-1'); - - await expect(withTimeout(harness.serialize(async () => { - executed.push('second'); - throw new Error('fail-2'); - }))).rejects.toThrow('fail-2'); - - await expect(withTimeout(harness.serialize(async () => { - executed.push('third'); - return 'recovered'; - }))).resolves.toBe('recovered'); - - expect(executed).toEqual(['first', 'second', 'third']); - }); - - it('mixed success/failure sequence runs in order without queue poisoning', async () => { - const harness = createQueueHarness(); - const executed: string[] = []; - poisonQueue(harness, 'mixed'); - - const operations = [ - withTimeout(harness.serialize(async () => { - executed.push('success-1'); - return 'success-1'; - })), - withTimeout(harness.serialize(async () => { - executed.push('failure-1'); - throw new Error('failure-1'); - })), - withTimeout(harness.serialize(async () => { - executed.push('success-2'); - return 'success-2'; - })), - withTimeout(harness.serialize(async () => { - executed.push('failure-2'); - throw new Error('failure-2'); - })), - withTimeout(harness.serialize(async () => { - executed.push('success-3'); - return 'success-3'; - })), - ]; - - const results = await Promise.allSettled(operations); - - expect(results[0]).toMatchObject({ status: 'fulfilled', value: 'success-1' }); - expectRejectedMessage(results[1], 'failure-1'); - expect(results[2]).toMatchObject({ status: 'fulfilled', value: 'success-2' }); - expectRejectedMessage(results[3], 'failure-2'); - expect(results[4]).toMatchObject({ status: 'fulfilled', value: 'success-3' }); - expect(executed).toEqual(['success-1', 'failure-1', 'success-2', 'failure-2', 'success-3']); - }); -}); diff --git a/src/__tests__/tracing.test.ts b/src/__tests__/tracing.test.ts deleted file mode 100644 index aad56543..00000000 --- a/src/__tests__/tracing.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * tracing.test.ts — Unit tests for OpenTelemetry tracing module. - * - * Issue #1417: OpenTelemetry tracing — research spike. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -describe('tracing', () => { - beforeEach(() => { - vi.resetModules(); - // Clean env vars - delete process.env.AEGIS_OTEL_ENABLED; - delete process.env.AEGIS_OTEL_SERVICE_NAME; - delete process.env.AEGIS_OTEL_OTLP_ENDPOINT; - delete process.env.AEGIS_OTEL_SAMPLE_RATE; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('loadTracingConfig', () => { - it('returns disabled by default', async () => { - const { loadTracingConfig } = await import('../tracing.js'); - const config = loadTracingConfig(); - expect(config.enabled).toBe(false); - }); - - it('returns enabled when AEGIS_OTEL_ENABLED=true', async () => { - process.env.AEGIS_OTEL_ENABLED = 'true'; - const { loadTracingConfig } = await import('../tracing.js'); - const config = loadTracingConfig(); - expect(config.enabled).toBe(true); - }); - - it('reads AEGIS_OTEL_SERVICE_NAME', async () => { - process.env.AEGIS_OTEL_SERVICE_NAME = 'aegis-prod'; - const { loadTracingConfig } = await import('../tracing.js'); - const config = loadTracingConfig(); - expect(config.serviceName).toBe('aegis-prod'); - }); - - it('reads AEGIS_OTEL_OTLP_ENDPOINT', async () => { - process.env.AEGIS_OTEL_OTLP_ENDPOINT = 'http://jaeger:4318'; - const { loadTracingConfig } = await import('../tracing.js'); - const config = loadTracingConfig(); - expect(config.otlpEndpoint).toBe('http://jaeger:4318'); - }); - - it('reads AEGIS_OTEL_SAMPLE_RATE', async () => { - process.env.AEGIS_OTEL_SAMPLE_RATE = '0.5'; - const { loadTracingConfig } = await import('../tracing.js'); - const config = loadTracingConfig(); - expect(config.sampleRate).toBe(0.5); - }); - - it('defaults serviceName to "aegis"', async () => { - const { loadTracingConfig } = await import('../tracing.js'); - const config = loadTracingConfig(); - expect(config.serviceName).toBe('aegis'); - }); - - it('defaults otlpEndpoint to localhost:4318', async () => { - const { loadTracingConfig } = await import('../tracing.js'); - const config = loadTracingConfig(); - expect(config.otlpEndpoint).toBe('http://localhost:4318'); - }); - - it('defaults sampleRate to 1.0', async () => { - const { loadTracingConfig } = await import('../tracing.js'); - const config = loadTracingConfig(); - expect(config.sampleRate).toBe(1.0); - }); - }); - - describe('initTracing (disabled)', () => { - it('returns no-op tracer when disabled', async () => { - const { initTracing, isTracingEnabled } = await import('../tracing.js'); - const tracer = await initTracing({ enabled: false, serviceName: 'test', otlpEndpoint: 'http://localhost:4318', sampleRate: 1.0 }); - expect(tracer).toBeDefined(); - expect(isTracingEnabled()).toBe(false); - }); - - it('no-op tracer creates non-recording spans', async () => { - const { initTracing } = await import('../tracing.js'); - const tracer = await initTracing({ enabled: false, serviceName: 'test', otlpEndpoint: 'http://localhost:4318', sampleRate: 1.0 }); - const span = tracer.startSpan('test.span'); - expect(span.isRecording()).toBe(false); - span.end(); // should not throw - }); - - it('no-op startActiveSpan does not throw', async () => { - const { initTracing } = await import('../tracing.js'); - const tracer = await initTracing({ enabled: false, serviceName: 'test', otlpEndpoint: 'http://localhost:4318', sampleRate: 1.0 }); - const result = tracer.startActiveSpan('test.active', (span) => { - expect(span.isRecording()).toBe(false); - return 42; - }); - expect(result).toBe(42); - }); - - it('getTracer returns no-op tracer when disabled', async () => { - const { initTracing, getTracer } = await import('../tracing.js'); - await initTracing({ enabled: false, serviceName: 'test', otlpEndpoint: 'http://localhost:4318', sampleRate: 1.0 }); - const tracer = getTracer(); - const span = tracer.startSpan('test'); - expect(span.isRecording()).toBe(false); - }); - }); - - describe('span helpers', () => { - it('startSessionSpan creates non-recording span when tracing is off', async () => { - const { initTracing, startSessionSpan } = await import('../tracing.js'); - await initTracing({ enabled: false, serviceName: 'test', otlpEndpoint: 'http://localhost:4318', sampleRate: 1.0 }); - const span = startSessionSpan('create', 'session-123', { workDir: '/tmp/test' }); - expect(span.isRecording()).toBe(false); - span.end(); - }); - - it('startTmuxSpan creates non-recording span when tracing is off', async () => { - const { initTracing, startTmuxSpan } = await import('../tracing.js'); - await initTracing({ enabled: false, serviceName: 'test', otlpEndpoint: 'http://localhost:4318', sampleRate: 1.0 }); - const span = startTmuxSpan('send-keys', '@0'); - expect(span.isRecording()).toBe(false); - span.end(); - }); - - it('startMonitorSpan creates non-recording span when tracing is off', async () => { - const { initTracing, startMonitorSpan } = await import('../tracing.js'); - await initTracing({ enabled: false, serviceName: 'test', otlpEndpoint: 'http://localhost:4318', sampleRate: 1.0 }); - const span = startMonitorSpan('poll'); - expect(span.isRecording()).toBe(false); - span.end(); - }); - - it('spanError does not throw on no-op span', async () => { - const { initTracing, startSessionSpan, spanError } = await import('../tracing.js'); - await initTracing({ enabled: false, serviceName: 'test', otlpEndpoint: 'http://localhost:4318', sampleRate: 1.0 }); - const span = startSessionSpan('create', 's-1'); - expect(() => spanError(span, new Error('test error'))).not.toThrow(); - span.end(); - }); - - it('spanOk does not throw on no-op span', async () => { - const { initTracing, startSessionSpan, spanOk } = await import('../tracing.js'); - await initTracing({ enabled: false, serviceName: 'test', otlpEndpoint: 'http://localhost:4318', sampleRate: 1.0 }); - const span = startSessionSpan('create', 's-1'); - expect(() => spanOk(span, 'created')).not.toThrow(); - span.end(); - }); - }); - - describe('initTracing (enabled — SDK load failure)', () => { - it('falls back to no-op when SDK import fails', async () => { - // Mock the dynamic import to throw - vi.doMock('@opentelemetry/sdk-node', () => { - throw new Error('Module not found'); - }); - - const { initTracing, isTracingEnabled } = await import('../tracing.js'); - const tracer = await initTracing({ - enabled: true, - serviceName: 'test', - otlpEndpoint: 'http://localhost:4318', - sampleRate: 1.0, - }); - - expect(tracer).toBeDefined(); - expect(isTracingEnabled()).toBe(false); - }); - }); - - describe('shutdownTracing', () => { - it('does not throw when tracing is disabled', async () => { - const { initTracing, shutdownTracing } = await import('../tracing.js'); - await initTracing({ enabled: false, serviceName: 'test', otlpEndpoint: 'http://localhost:4318', sampleRate: 1.0 }); - await expect(shutdownTracing()).resolves.not.toThrow(); - }); - }); -}); diff --git a/src/__tests__/url-secret-redaction-1393.test.ts b/src/__tests__/url-secret-redaction-1393.test.ts deleted file mode 100644 index 2615d097..00000000 --- a/src/__tests__/url-secret-redaction-1393.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Issue #1393: Hook URL ?secret= not redacted in logs. - * - * Tests that the Fastify request serializer redacts both token= and secret= - * query parameters from logged request URLs. - */ - -import { describe, it, expect } from 'vitest'; - -/** - * Mirrors the URL redaction logic in server.ts Fastify request serializer. - * If the server.ts implementation changes, this test will fail — which is - * the desired signal to update both or extract a shared utility. - */ -function redactUrlSecrets(url: string): string { - let result = url; - result = result.replace(/token=[^&]*/g, 'token=[REDACTED]'); - result = result.replace(/secret=[^&]*/g, 'secret=[REDACTED]'); - return result; -} - -describe('Issue #1393: URL secret redaction in request logs', () => { - it('redacts secret= query param', () => { - const url = '/v1/hooks/Stop?sessionId=abc-123&secret=my-hook-secret-value'; - expect(redactUrlSecrets(url)).not.toContain('my-hook-secret-value'); - expect(redactUrlSecrets(url)).toContain('secret=[REDACTED]'); - }); - - it('redacts secret= when it is the only param', () => { - const url = '/v1/hooks/Stop?secret=alone-secret'; - expect(redactUrlSecrets(url)).not.toContain('alone-secret'); - expect(redactUrlSecrets(url)).toBe('/v1/hooks/Stop?secret=[REDACTED]'); - }); - - it('redacts secret= at end of URL (no trailing &)', () => { - const url = '/v1/hooks/Stop?sessionId=abc-123&secret=trailing-secret'; - expect(redactUrlSecrets(url)).not.toContain('trailing-secret'); - }); - - it('redacts token= query param (existing behavior)', () => { - const url = '/v1/events?token=my-sse-token'; - expect(redactUrlSecrets(url)).not.toContain('my-sse-token'); - expect(redactUrlSecrets(url)).toContain('token=[REDACTED]'); - }); - - it('redacts both token= and secret= when both present', () => { - const url = '/v1/hooks/Stop?token=tok123&sessionId=abc&secret=sec456'; - const redacted = redactUrlSecrets(url); - expect(redacted).not.toContain('tok123'); - expect(redacted).not.toContain('sec456'); - expect(redacted).toContain('token=[REDACTED]'); - expect(redacted).toContain('secret=[REDACTED]'); - }); - - it('leaves URLs without secrets unchanged', () => { - const url = '/v1/sessions?page=1&limit=20'; - expect(redactUrlSecrets(url)).toBe(url); - }); - - it('leaves URL with unrelated query params unchanged', () => { - const url = '/v1/hooks/Stop?sessionId=abc-123'; - expect(redactUrlSecrets(url)).toBe(url); - }); - - it('handles URLs with no query string', () => { - const url = '/v1/sessions'; - expect(redactUrlSecrets(url)).toBe(url); - }); - - it('handles empty string', () => { - expect(redactUrlSecrets('')).toBe(''); - }); -}); diff --git a/src/alerting.ts b/src/alerting.ts deleted file mode 100644 index f7550de3..00000000 --- a/src/alerting.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * alerting.ts — Production alerting for session failures, tmux crashes, and API errors. - * - * Issue #1418: Tracks failure events and fires alert webhooks when configurable - * thresholds are exceeded. Uses a cooldown window to prevent alert fatigue. - */ - -import crypto from 'node:crypto'; -import { logger } from './logger.js'; -import { validateWebhookUrl, resolveAndCheckIp } from './ssrf.js'; - -/** Supported alert types. */ -export type AlertType = 'session_failure' | 'tmux_crash' | 'api_error_rate'; - -/** An alert event ready for delivery. */ -export interface AlertEvent { - type: AlertType; - timestamp: string; - detail: string; - /** Current count of failures in the tracking window. */ - failureCount: number; - /** Configured threshold that triggered the alert. */ - threshold: number; -} - -/** Configuration for the AlertManager. */ -export interface AlertingConfig { - /** Webhook URLs for alert notifications. */ - webhooks: string[]; - /** Number of consecutive failures before triggering an alert. */ - failureThreshold: number; - /** Cooldown period in ms between alerts for the same type. */ - cooldownMs: number; -} - -/** Per-type failure tracking state. */ -interface FailureTracker { - count: number; - windowStart: number; - lastAlertAt: number; -} - -const DEFAULT_CONFIG: AlertingConfig = { - webhooks: [], - failureThreshold: 5, - cooldownMs: 10 * 60 * 1000, -}; - -export class AlertManager { - private config: AlertingConfig; - private trackers = new Map(); - private delivered = 0; - private failed = 0; - - constructor(config: Partial = {}) { - this.config = { ...DEFAULT_CONFIG, ...config }; - } - - /** Update configuration at runtime (e.g. from config reload). */ - updateConfig(config: Partial): void { - this.config = { ...this.config, ...config }; - } - - /** Get current configuration. */ - getConfig(): Readonly { - return this.config; - } - - /** - * Record a failure event. If the failure count for the given type exceeds - * the threshold and the cooldown has elapsed, fire an alert webhook. - */ - recordFailure(type: AlertType, detail: string): void { - if (this.config.webhooks.length === 0) return; - - const now = Date.now(); - const tracker = this.getOrCreateTracker(type); - - // Reset window if older than cooldown (stale window) - const windowDuration = this.config.cooldownMs; - if (now - tracker.windowStart > windowDuration) { - tracker.count = 0; - tracker.windowStart = now; - } - - tracker.count++; - - if (tracker.count >= this.config.failureThreshold && (now - tracker.lastAlertAt) >= this.config.cooldownMs) { - tracker.lastAlertAt = now; - const event: AlertEvent = { - type, - timestamp: new Date().toISOString(), - detail, - failureCount: tracker.count, - threshold: this.config.failureThreshold, - }; - // Fire-and-forget — don't block the caller - void this.fireAlert(event).catch(e => { - logger.error({ - component: 'alerting', - operation: 'fire_alert', - errorCode: 'ALERT_DELIVERY_FAILED', - attributes: { alertType: type, error: e instanceof Error ? e.message : String(e) }, - }); - }); - } - } - - /** - * Manually fire a test alert (for POST /v1/alerts/test endpoint). - * Returns the response from the first webhook that succeeds, or throws if all fail. - */ - async fireTestAlert(): Promise<{ sent: boolean; webhookCount: number }> { - if (this.config.webhooks.length === 0) { - return { sent: false, webhookCount: 0 }; - } - const event: AlertEvent = { - type: 'session_failure', - timestamp: new Date().toISOString(), - detail: 'Test alert from POST /v1/alerts/test', - failureCount: 1, - threshold: this.config.failureThreshold, - }; - await this.fireAlert(event); - return { sent: true, webhookCount: this.config.webhooks.length }; - } - - /** Get alert statistics. */ - getStats(): { delivered: number; failed: number; trackers: Record } { - const trackers: Record = {}; - for (const [type, tracker] of this.trackers) { - trackers[type] = { count: tracker.count, lastAlertAt: tracker.lastAlertAt }; - } - return { delivered: this.delivered, failed: this.failed, trackers }; - } - - /** Reset all tracking state. */ - reset(): void { - this.trackers.clear(); - this.delivered = 0; - this.failed = 0; - } - - private getOrCreateTracker(type: AlertType): FailureTracker { - let tracker = this.trackers.get(type); - if (!tracker) { - tracker = { count: 0, windowStart: Date.now(), lastAlertAt: 0 }; - this.trackers.set(type, tracker); - } - return tracker; - } - - private async fireAlert(event: AlertEvent): Promise { - const body = JSON.stringify({ - event: 'alert', - ...event, - source: 'aegis', - }); - - const results = await Promise.allSettled( - this.config.webhooks.map(url => this.deliverToWebhook(url, body, event.type)), - ); - - for (const result of results) { - if (result.status === 'fulfilled') { - this.delivered++; - } else { - this.failed++; - } - } - - const failedCount = results.filter(r => r.status === 'rejected').length; - if (failedCount > 0) { - logger.warn({ - component: 'alerting', - operation: 'fire_alert', - errorCode: 'ALERT_PARTIAL_FAILURE', - attributes: { alertType: event.type, failed: failedCount, total: results.length }, - }); - } - } - - private async deliverToWebhook(url: string, body: string, alertType: AlertType): Promise { - const urlError = validateWebhookUrl(url); - if (urlError) { - throw new Error(`Invalid alert webhook URL: ${urlError}`); - } - - const hostname = new URL(url).hostname.replace(/^\[|\]$/g, ''); - - // DNS rebinding protection for non-localhost URLs - let fetchUrl = url; - if (hostname !== '127.0.0.1' && hostname !== '::1' && hostname !== 'localhost') { - const dnsResult = await resolveAndCheckIp(hostname); - if (dnsResult.error) { - throw new Error(`DNS check failed for alert webhook: ${dnsResult.error}`); - } - } - - const res = await fetch(fetchUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Aegis-Alert-Type': alertType, - }, - body, - signal: AbortSignal.timeout(5000), - }); - - if (!res.ok) { - throw new Error(`Alert webhook returned HTTP ${res.status}`); - } - } -} diff --git a/src/audit.ts b/src/audit.ts deleted file mode 100644 index b442e0a6..00000000 --- a/src/audit.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * audit.ts — Tamper-evident append-only audit trail. - * - * Issue #1419: SOC2/ISO 27001 compliance. - * - * Each record is chained via PBKDF2-HMAC-SHA512 hashes — the hash of record N - * includes the hash of record N-1, making retroactive edits detectable. - * Log files rotate daily and are never overwritten. - */ - -import { pbkdf2Sync } from 'node:crypto'; -import { appendFile, readFile, mkdir, readdir, lstat } from 'node:fs/promises'; -import { join, dirname } from 'node:path'; -import { existsSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { secureFilePermissions } from './file-utils.js'; - -// ── Types ────────────────────────────────────────────────────────────── - -export interface AuditRecord { - /** ISO 8601 timestamp */ - ts: string; - /** Actor key ID (or 'master' / 'system') */ - actor: string; - /** Action category (e.g. 'key.create', 'session.kill') */ - action: string; - /** Associated session ID, if applicable */ - sessionId?: string; - /** Human-readable detail */ - detail: string; - /** SHA-256 hash of previous record (hex) — empty string for first record */ - prevHash: string; - /** SHA-256 hash of this record (hex) — covers all fields except itself */ - hash: string; -} - -export type AuditAction = - | 'key.create' - | 'key.revoke' - | 'session.create' - | 'session.kill' - | 'permission.approve' - | 'permission.reject' - | 'api.authenticated'; - -export interface AuditQueryOptions { - /** Filter by actor key ID */ - actor?: string; - /** Filter by action */ - action?: AuditAction; - /** Filter by session ID */ - sessionId?: string; - /** Max records to return (default 100) */ - limit?: number; - /** Return records from newest first (default false = chronological) */ - reverse?: boolean; -} - -// ── Implementation ───────────────────────────────────────────────────── - -function dateToFileDate(d: Date): string { - return d.toISOString().slice(0, 10); // YYYY-MM-DD -} - -const AUDIT_HASH_ITERATIONS = 120_000; -const AUDIT_HASH_KEY_LENGTH = 32; -const AUDIT_HASH_DIGEST = 'sha512'; -const AUDIT_HASH_SALT_PREFIX = 'aegis-audit-chain-v2'; - -function computeHash(record: Omit): string { - const payload = `${record.ts}|${record.actor}|${record.action}|${record.sessionId ?? ''}|${record.detail}|${record.prevHash}`; - const salt = `${AUDIT_HASH_SALT_PREFIX}|${record.prevHash}`; - return pbkdf2Sync(payload, salt, AUDIT_HASH_ITERATIONS, AUDIT_HASH_KEY_LENGTH, AUDIT_HASH_DIGEST).toString('hex'); -} - -export class AuditLogger { - private logDir: string; - private lastHash: string; - private writeLock: Promise; - - constructor(logDir?: string) { - this.logDir = logDir ?? join(homedir(), '.aegis', 'audit'); - this.lastHash = ''; - this.writeLock = Promise.resolve(); - } - - private async assertNotSymlink(pathValue: string): Promise { - try { - const stats = await lstat(pathValue); - if (stats.isSymbolicLink()) { - throw new Error(`Refusing to operate on symlink path: ${pathValue}`); - } - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code === 'ENOENT') return; - throw error; - } - } - - private async assertAuditPathSafe(filePath: string): Promise { - await this.assertNotSymlink(this.logDir); - await this.assertNotSymlink(dirname(filePath)); - await this.assertNotSymlink(filePath); - } - - /** Initialize the audit logger — ensure directory exists, read last hash. */ - async init(): Promise { - await this.assertNotSymlink(this.logDir); - if (!existsSync(this.logDir)) { - await mkdir(this.logDir, { recursive: true }); - } - await this.recoverLastHash(); - } - - /** Recover the last hash from the most recent audit log file. */ - private async recoverLastHash(): Promise { - try { - const files = await readdir(this.logDir); - const logFiles = files - .filter(f => f.startsWith('audit-') && f.endsWith('.log')) - .sort() - .reverse(); - - if (logFiles.length === 0) { - this.lastHash = ''; - return; - } - - const latestFile = join(this.logDir, logFiles[0]!); - await this.assertNotSymlink(latestFile); - const content = await readFile(latestFile, 'utf-8'); - const lines = content.trim().split('\n'); - - if (lines.length === 0) { - this.lastHash = ''; - return; - } - - const lastLine = lines[lines.length - 1]!; - try { - const record = JSON.parse(lastLine) as AuditRecord; - this.lastHash = record.hash; - } catch { - // Corrupted last line — scan backwards for valid JSON - for (let i = lines.length - 1; i >= 0; i--) { - try { - const rec = JSON.parse(lines[i]!) as AuditRecord; - this.lastHash = rec.hash; - return; - } catch { - continue; - } - } - this.lastHash = ''; - } - } catch { - this.lastHash = ''; - } - } - - /** Get the file path for a given date. */ - private filePath(d: Date): string { - return join(this.logDir, `audit-${dateToFileDate(d)}.log`); - } - - /** - * Append an audit record. Serialized through a write lock to guarantee - * ordering and correct hash chaining even under concurrent callers. - */ - async log( - actor: string, - action: AuditAction, - detail: string, - sessionId?: string, - ): Promise { - let release: () => void = () => {}; - const lock = new Promise((resolve) => { release = resolve; }); - const previous = this.writeLock; - this.writeLock = lock; - - try { - await previous.catch(() => {}); - - const ts = new Date().toISOString(); - const partial: Omit = { - ts, - actor, - action, - sessionId, - detail, - prevHash: this.lastHash, - }; - const hash = computeHash(partial); - const record: AuditRecord = { ...partial, hash }; - - const line = JSON.stringify(record) + '\n'; - const file = this.filePath(new Date(ts)); - await this.assertAuditPathSafe(file); - - // Ensure directory exists (in case it was cleaned) - if (!existsSync(dirname(file))) { - await mkdir(dirname(file), { recursive: true }); - await this.assertNotSymlink(dirname(file)); - } - - // Append-only — never overwrite - await appendFile(file, line, { mode: 0o600 }); - await secureFilePermissions(file); - - this.lastHash = hash; - return record; - } finally { - release(); - } - } - - /** - * Verify the integrity of the audit log by checking the hash chain. - * Returns { valid: true } if all records chain correctly, - * or { valid: false, brokenAt: lineNumber } if a tampered record is found. - */ - async verify(): Promise<{ valid: boolean; brokenAt?: number; file?: string }> { - try { - const files = await readdir(this.logDir); - const logFiles = files - .filter(f => f.startsWith('audit-') && f.endsWith('.log')) - .sort(); - - let prevHash = ''; - let globalLineNum = 0; - - for (const file of logFiles) { - const fullPath = join(this.logDir, file); - await this.assertNotSymlink(fullPath); - const content = await readFile(fullPath, 'utf-8'); - const lines = content.trim().split('\n'); - - for (const line of lines) { - globalLineNum++; - try { - const record = JSON.parse(line) as AuditRecord; - - if (record.prevHash !== prevHash) { - return { valid: false, brokenAt: globalLineNum, file }; - } - - const expectedHash = computeHash(record); - if (record.hash !== expectedHash) { - return { valid: false, brokenAt: globalLineNum, file }; - } - - prevHash = record.hash; - } catch { - return { valid: false, brokenAt: globalLineNum, file }; - } - } - } - - return { valid: true }; - } catch { - return { valid: true }; - } - } - - /** - * Query audit records across all log files. - * Reads files in chronological order and applies filters. - */ - async query(options: AuditQueryOptions = {}): Promise { - const { - actor, - action, - sessionId, - limit = 100, - reverse = false, - } = options; - - try { - const files = await readdir(this.logDir); - const logFiles = files - .filter(f => f.startsWith('audit-') && f.endsWith('.log')) - .sort(); - - const allRecords: AuditRecord[] = []; - - for (const file of logFiles) { - const fullPath = join(this.logDir, file); - await this.assertNotSymlink(fullPath); - const content = await readFile(fullPath, 'utf-8'); - const lines = content.trim().split('\n'); - - for (const line of lines) { - try { - const record = JSON.parse(line) as AuditRecord; - - if (actor && record.actor !== actor) continue; - if (action && record.action !== action) continue; - if (sessionId && record.sessionId !== sessionId) continue; - - allRecords.push(record); - } catch { - // Skip malformed lines - continue; - } - } - } - - // Apply limit after filtering - const result = reverse - ? allRecords.slice(-limit).reverse() - : allRecords.slice(-limit); - - return result; - } catch { - return []; - } - } -} diff --git a/src/auth.ts b/src/auth.ts index 9cd04bc6..737dcfad 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,7 +1,389 @@ /** - * Compatibility re-export for legacy imports. - * Auth implementation now lives under src/services/auth/. + * auth.ts — API key management and authentication middleware. + * + * Issue #39: Multi-key auth with rate limiting. + * Keys are hashed with SHA-256 (no bcrypt dependency needed). + * Backward compatible with single authToken from config. */ -export { AuthManager, classifyBearerTokenForRoute } from './services/auth/index.js'; -export type { ApiKey, ApiKeyRole, ApiKeyStore, AuthRejectReason } from './services/auth/index.js'; +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { authStoreSchema } from './validation.js'; +import { existsSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { secureFilePermissions } from './file-utils.js'; + +export type ApiKeyRole = 'admin' | 'operator' | 'viewer'; + +export interface ApiKey { + id: string; + name: string; + hash: string; // SHA-256 hash of the key + createdAt: number; + lastUsedAt: number; + rateLimit: number; // requests per minute + expiresAt: number | null; // Unix timestamp (ms), null = never expires + role: ApiKeyRole; // RBAC role (Issue #1432) +} + +export interface ApiKeyStore { + keys: ApiKey[]; +} + +/** Rate limit state per key ID. */ +interface RateLimitBucket { + count: number; + windowStart: number; +} + +/** Short-lived SSE token for Issue #297. */ +interface SSETokenEntry { + token: string; + expiresAt: number; + used: boolean; + keyId: string; +} + +/** Default SSE token lifetime: 60 seconds. */ +const SSE_TOKEN_TTL_MS = 60_000; + +/** Max SSE tokens per bearer token to prevent abuse. */ +const SSE_TOKEN_MAX_PER_KEY = 5; + +/** #583: Minimum interval between batch creation requests per key (5 seconds). */ +const BATCH_COOLDOWN_MS = 5_000; + +/** Route-level auth policy for bearer tokens. */ +export function classifyBearerTokenForRoute( + token: string, + isSSERoute: boolean, +): 'bearer' | 'sse' | 'reject' { + if (!isSSERoute) return 'bearer'; + return token.startsWith('sse_') ? 'sse' : 'reject'; +} + +export class AuthManager { + private store: ApiKeyStore = { keys: [] }; + private rateLimits = new Map(); + private masterToken: string; + /** #297: Short-lived SSE tokens. Keyed by token string for O(1) lookup. */ + private sseTokens = new Map(); + /** Track how many SSE tokens each bearer key has outstanding. */ + private sseTokenCounts = new Map(); + /** #414: Mutex to prevent concurrent SSE token generation from exceeding per-key limits. */ + private sseMutex: Promise = Promise.resolve(); + /** #583: Last batch creation timestamp per key ID. */ + private batchRateLimits = new Map(); + /** #1080: HTTP server host binding (set after construction via setHost()). */ + private host: string = '127.0.0.1'; + + + constructor( + private keysFile: string, + masterToken: string = '', + ) { + this.masterToken = masterToken; + } + + /** #1080: Set the HTTP server host binding after construction (config.host is not available at construction time). */ + setHost(host: string): void { + this.host = host; + } + + /** #1080: Expose host binding for server.ts setupAuth() check. */ + get hostBinding(): string { + return this.host; + } + + /** #1080: Returns true when Aegis is bound to a localhost interface (127.0.0.1 or ::1). */ + get isLocalhostBinding(): boolean { + return this.host === '127.0.0.1' || this.host === '::1' || this.host === 'localhost'; + } + + /** Load keys from disk. */ + async load(): Promise { + if (existsSync(this.keysFile)) { + try { + const raw = await readFile(this.keysFile, 'utf-8'); + const parsed = authStoreSchema.safeParse(JSON.parse(raw)); + if (parsed.success) { + this.store = parsed.data; + } + } catch { /* corrupted or unreadable keys file — start fresh */ + this.store = { keys: [] }; + } + } + } + + /** Save keys to disk. */ + async save(): Promise { + const dir = dirname(this.keysFile); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } + await writeFile(this.keysFile, JSON.stringify(this.store, null, 2), { mode: 0o600 }); + await secureFilePermissions(this.keysFile); + } + + /** Create a new API key. Returns the plaintext key (only shown once). */ + async createKey( + name: string, + rateLimit = 100, + ttlDays?: number, + role: ApiKeyRole = 'viewer', + ): Promise<{ id: string; key: string; name: string; expiresAt: number | null; role: ApiKeyRole }> { + const id = randomBytes(8).toString('hex'); + const key = `aegis_${randomBytes(32).toString('hex')}`; + const hash = AuthManager.hashKey(key); + const expiresAt = ttlDays ? Date.now() + ttlDays * 86_400_000 : null; + + const apiKey: ApiKey = { + id, + name, + hash, + createdAt: Date.now(), + lastUsedAt: 0, + rateLimit, + expiresAt, + role, + }; + + this.store.keys.push(apiKey); + await this.save(); + + return { id, key, name, expiresAt, role }; + } + + /** List keys (without hashes). */ + listKeys(): Array> { + return this.store.keys.map(({ hash: _, ...rest }) => rest); + } + + /** Revoke a key by ID. */ + async revokeKey(id: string): Promise { + const idx = this.store.keys.findIndex(k => k.id === id); + if (idx === -1) return false; + this.store.keys.splice(idx, 1); + this.rateLimits.delete(id); + await this.save(); + return true; + } + + /** + * Validate a bearer token. + * Returns { valid, keyId, rateLimited } or null if no auth configured. + */ + validate(token: string): { valid: boolean; keyId: string | null; rateLimited: boolean } { + // No auth configured and no keys → allow all + if (!this.masterToken && this.store.keys.length === 0) { + // #1080: SECURITY FIX — when binding to a non-localhost interface without auth, + // reject all requests. Running Aegis on 0.0.0.0 with no auth is a critical vuln. + if (!this.isLocalhostBinding) { + return { valid: false, keyId: null, rateLimited: false }; + } + return { valid: true, keyId: null, rateLimited: false }; + } + + // Check master token (backward compat) — timing-safe comparison (#402) + if (this.masterToken && AuthManager.timingSafeStringEqual(token, this.masterToken)) { + return { valid: true, keyId: 'master', rateLimited: false }; + } + + // Check API keys + const hash = AuthManager.hashKey(token); + const key = this.store.keys.find(k => k.hash === hash); + if (!key) { + return { valid: false, keyId: null, rateLimited: false }; + } + + // #1436: Reject expired keys + if (key.expiresAt !== null && Date.now() > key.expiresAt) { + return { valid: false, keyId: null, rateLimited: false }; + } + + // Rate limiting + const bucket = this.rateLimits.get(key.id) || { count: 0, windowStart: Date.now() }; + const now = Date.now(); + const windowMs = 60_000; // 1 minute + + if (now - bucket.windowStart > windowMs) { + // New window + bucket.count = 1; + bucket.windowStart = now; + } else { + bucket.count++; + } + + this.rateLimits.set(key.id, bucket); + + if (bucket.count > key.rateLimit) { + return { valid: true, keyId: key.id, rateLimited: true }; + } + + // Issue #841: Only update lastUsedAt for accepted requests, not rate-limited ones + key.lastUsedAt = Date.now(); + + return { valid: true, keyId: key.id, rateLimited: false }; + } + + /** Issue #1432: Get the RBAC role for a key ID. Master token = admin. Unknown/null = viewer (default). */ + getRole(keyId: string | null | undefined): ApiKeyRole { + if (keyId === 'master') return 'admin'; + const key = keyId ? this.store.keys.find(k => k.id === keyId) : undefined; + return key?.role ?? 'viewer'; + } + + /** Hash a key with SHA-256. */ + static hashKey(key: string): string { + return createHash('sha256').update(key).digest('hex'); + } + + /** Constant-time equality check for secret strings. */ + private static timingSafeStringEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')); + } + + /** #583: Check and update batch rate limit for a key. Returns true if rate-limited. */ + checkBatchRateLimit(keyId: string | null): boolean { + const id = keyId ?? 'anonymous'; + const now = Date.now(); + const lastBatch = this.batchRateLimits.get(id); + if (lastBatch !== undefined && now - lastBatch < BATCH_COOLDOWN_MS) { + return true; + } + this.batchRateLimits.set(id, now); + return false; + } + + /** #398: Sweep stale rate limit buckets. Prune entries with expired windows. */ + sweepStaleRateLimits(): void { + const now = Date.now(); + const windowMs = 60_000; // 1 minute + for (const [keyId, bucket] of this.rateLimits) { + if (now - bucket.windowStart > windowMs) { + this.rateLimits.delete(keyId); + } + } + // #583: Prune expired batch rate limit entries + for (const [keyId, ts] of this.batchRateLimits) { + if (now - ts > BATCH_COOLDOWN_MS) { + this.batchRateLimits.delete(keyId); + } + } + } + + /** Check if auth is enabled (master token or any keys). */ + get authEnabled(): boolean { + return !!this.masterToken || this.store.keys.length > 0; + } + + // ── SSE Token Management (Issue #297) ──────────────────────── + + /** + * Generate a short-lived, single-use SSE token. + * The caller must already be authenticated (validated via bearer token). + * Returns the token string and its expiry timestamp. + * #414: Async with mutex to prevent concurrent calls from exceeding per-key limits. + */ + async generateSSEToken(keyId: string): Promise<{ token: string; expiresAt: number }> { + // Acquire mutex — chain onto the previous operation + let release: () => void = () => {}; + const lock = new Promise((resolve) => { release = resolve; }); + const previous = this.sseMutex; + this.sseMutex = lock; + + // #509: await + try/finally together so release() fires even if previous rejects + // #573: catch prior rejection so it doesn't propagate and block subsequent callers + try { + await previous.catch(() => {}); + + // Cleanup expired tokens first + this.cleanExpiredSSETokens(); + + // Enforce per-key limit + const current = this.sseTokenCounts.get(keyId) ?? 0; + if (current >= SSE_TOKEN_MAX_PER_KEY) { + throw new Error(`SSE token limit reached (${SSE_TOKEN_MAX_PER_KEY} outstanding)`); + } + + const token = `sse_${randomBytes(32).toString('hex')}`; + const expiresAt = Date.now() + SSE_TOKEN_TTL_MS; + + this.sseTokens.set(token, { token, expiresAt, used: false, keyId }); + this.sseTokenCounts.set(keyId, current + 1); + + return { token, expiresAt }; + } finally { + release(); + } + } + + /** + * Validate and consume a short-lived SSE token. + * Returns true if valid (and marks it as used), false otherwise. + * #826: Async with mutex to prevent concurrent validation/generation from + * racing on shared state (sseTokens, sseTokenCounts). + */ + async validateSSEToken(token: string): Promise { + // Acquire mutex — chain onto the previous operation + let release: () => void = () => {}; + const lock = new Promise((resolve) => { release = resolve; }); + const previous = this.sseMutex; + this.sseMutex = lock; + + // #573: catch prior rejection so it doesn't propagate and block subsequent callers + try { + await previous.catch(() => {}); + + const entry = this.sseTokens.get(token); + if (!entry) return false; + + // Already used + if (entry.used) { + this.sseTokens.delete(token); + return false; + } + + // Expired + if (Date.now() > entry.expiresAt) { + this.sseTokens.delete(token); + return false; + } + + // Valid — consume it + entry.used = true; + const keyId = entry.keyId; + this.sseTokens.delete(token); + // #357: Decrement outstanding count so generateSSEToken doesn't over-limit + const count = this.sseTokenCounts.get(keyId); + if (count !== undefined) { + if (count <= 1) { + this.sseTokenCounts.delete(keyId); + } else { + this.sseTokenCounts.set(keyId, count - 1); + } + } + return true; + } finally { + release(); + } + } + + /** Remove expired SSE tokens and recount per-key outstanding. */ + private cleanExpiredSSETokens(): void { + const now = Date.now(); + // Remove expired + for (const [key, entry] of this.sseTokens) { + if (now > entry.expiresAt) { + this.sseTokens.delete(key); + } + } + // Rebuild counts from surviving tokens + this.sseTokenCounts.clear(); + for (const entry of this.sseTokens.values()) { + const count = this.sseTokenCounts.get(entry.keyId) ?? 0; + this.sseTokenCounts.set(entry.keyId, count + 1); + } + } +} diff --git a/src/config.ts b/src/config.ts index d7992498..60ccfbca 100644 --- a/src/config.ts +++ b/src/config.ts @@ -65,8 +65,6 @@ export interface Config { * Empty array = all directories allowed (backward compatible). * Paths are resolved and symlink-resolved before checking. */ allowedWorkDirs: string[]; - /** Issue #1619: Require X-Hook-Secret header and reject query param secrets. */ - hookSecretHeaderOnly: boolean; /** Memory bridge: key/value store for cross-session context (default: disabled). */ memoryBridge: { enabled: boolean; persistPath?: string; reaperIntervalMs?: number }; /** Issue #884: Enable worktree-aware continuation metadata lookup (default: false). @@ -85,21 +83,6 @@ export interface Config { /** Run only critical checks: tsc + build (skip slow tests). Default: false = full. */ criticalOnly: boolean; }; - /** Issue #1557: Dedicated token for Prometheus /metrics scrape auth. - * When set, /metrics requires this token (or the primary authToken). - * When empty, /metrics falls through to normal auth (same as any other endpoint). */ - metricsToken: string; - /** Issue #1423: Default pipeline stage timeout in milliseconds. 0 = no timeout. Env: AEGIS_PIPELINE_STAGE_TIMEOUT_MS */ - pipelineStageTimeoutMs: number; - /** Production alerting (Issue #1418). */ - alerting: { - /** Webhook URLs for alert notifications (separate from general webhooks). */ - webhooks: string[]; - /** Number of consecutive failures before triggering an alert (default: 5). */ - failureThreshold: number; - /** Cooldown period in ms between alerts for the same type (default: 10 min). */ - cooldownMs: number; - }; } /** Compute stall threshold from env var or default (Issue #392). @@ -135,14 +118,10 @@ const defaults: Config = { sseMaxConnections: 100, sseMaxPerIp: 10, allowedWorkDirs: [], - hookSecretHeaderOnly: false, worktreeAwareContinuation: false, memoryBridge: { enabled: false }, worktreeSiblingDirs: [], verificationProtocol: { autoVerifyOnStop: false, criticalOnly: false }, - metricsToken: '', - pipelineStageTimeoutMs: 0, - alerting: { webhooks: [], failureThreshold: 5, cooldownMs: 10 * 60 * 1000 }, }; /** Parse CLI args for --config flag */ @@ -208,63 +187,6 @@ function expandTilde(path: string): string { return path; } -type NumericConfigEnvKey = - | 'port' - | 'maxSessionAgeMs' - | 'reaperIntervalMs' - | 'continuationPointerTtlMs' - | 'tgTopicTtlMs' - | 'sseMaxConnections' - | 'sseMaxPerIp' - | 'pipelineStageTimeoutMs'; - -const MAX_ENV_INT = Number.MAX_SAFE_INTEGER; - -const numericEnvBounds: Record = { - port: { min: 1, max: 65535 }, - maxSessionAgeMs: { min: 1, max: MAX_ENV_INT }, - reaperIntervalMs: { min: 1, max: MAX_ENV_INT }, - continuationPointerTtlMs: { min: 1, max: MAX_ENV_INT }, - tgTopicTtlMs: { min: 1, max: MAX_ENV_INT }, - sseMaxConnections: { min: 1, max: MAX_ENV_INT }, - sseMaxPerIp: { min: 1, max: MAX_ENV_INT }, - pipelineStageTimeoutMs: { min: 0, max: MAX_ENV_INT }, -}; - -function parseNumericEnvOverride( - envName: string, - rawValue: string, - fallback: number, - bounds: { min: number; max: number }, -): number { - return parseIntSafe(rawValue, fallback, { - context: envName, - strict: true, - min: bounds.min, - max: bounds.max, - onError: (message) => console.warn(`Config: ${message}`), - }); -} - -function parseTgAllowedUsers(envName: string, value: string): number[] { - const parsedUsers: number[] = []; - const invalidEntries: string[] = []; - for (const token of value.split(',')) { - const trimmed = token.trim(); - if (!trimmed) continue; - const parsed = Number(trimmed); - if (!Number.isSafeInteger(parsed) || parsed <= 0) { - invalidEntries.push(trimmed); - continue; - } - parsedUsers.push(parsed); - } - if (invalidEntries.length > 0) { - console.warn(`Config: invalid ${envName} entries ignored: ${invalidEntries.join(', ')}`); - } - return parsedUsers; -} - /** Apply environment variable overrides. * AEGIS_* vars take priority over MANUS_* (backward compat). */ @@ -274,7 +196,6 @@ function applyEnvOverrides(config: Config): Config { { aegis: 'AEGIS_PORT', manus: 'MANUS_PORT', key: 'port' }, { aegis: 'AEGIS_HOST', manus: 'MANUS_HOST', key: 'host' }, { aegis: 'AEGIS_AUTH_TOKEN', manus: 'MANUS_AUTH_TOKEN', key: 'authToken' }, - { aegis: 'AEGIS_METRICS_TOKEN', manus: 'MANUS_METRICS_TOKEN', key: 'metricsToken' }, { aegis: 'AEGIS_TMUX_SESSION', manus: 'MANUS_TMUX_SESSION', key: 'tmuxSession' }, { aegis: 'AEGIS_STATE_DIR', manus: 'MANUS_STATE_DIR', key: 'stateDir' }, { aegis: 'AEGIS_CLAUDE_PROJECTS_DIR', manus: 'MANUS_CLAUDE_PROJECTS_DIR', key: 'claudeProjectsDir' }, @@ -288,15 +209,12 @@ function applyEnvOverrides(config: Config): Config { { aegis: 'AEGIS_WEBHOOKS', manus: 'MANUS_WEBHOOKS', key: 'webhooks' }, { aegis: 'AEGIS_SSE_MAX_CONNECTIONS', manus: 'MANUS_SSE_MAX_CONNECTIONS', key: 'sseMaxConnections' }, { aegis: 'AEGIS_SSE_MAX_PER_IP', manus: 'MANUS_SSE_MAX_PER_IP', key: 'sseMaxPerIp' }, - { aegis: 'AEGIS_PIPELINE_STAGE_TIMEOUT_MS', manus: 'MANUS_PIPELINE_STAGE_TIMEOUT_MS', key: 'pipelineStageTimeoutMs' }, - { aegis: 'AEGIS_HOOK_SECRET_HEADER_ONLY', manus: 'MANUS_HOOK_SECRET_HEADER_ONLY', key: 'hookSecretHeaderOnly' }, ]; for (const { aegis, manus, key } of envMappings) { // AEGIS_* takes priority over MANUS_* const value = process.env[aegis] ?? process.env[manus]; if (value === undefined) continue; - const envName = process.env[aegis] !== undefined ? aegis : manus; switch (key) { case 'port': @@ -306,17 +224,7 @@ function applyEnvOverrides(config: Config): Config { case 'tgTopicTtlMs': case 'sseMaxConnections': case 'sseMaxPerIp': - case 'pipelineStageTimeoutMs': - config[key] = parseNumericEnvOverride(envName, value, config[key], numericEnvBounds[key]); - break; - case 'hookSecretHeaderOnly': - if (value === 'true' || value === 'false') { - config[key] = value === 'true'; - } else { - console.warn( - `Config: Invalid ${envName}='${value}' (expected "true" or "false"); using ${config[key]}`, - ); - } + config[key] = parseIntSafe(value, config[key]); break; case 'webhooks': // Support comma-separated webhooks @@ -325,12 +233,11 @@ function applyEnvOverrides(config: Config): Config { : [value]; break; case 'tgAllowedUsers': - config[key] = parseTgAllowedUsers(envName, value); + config[key] = value.split(',').map(s => Number(s.trim())).filter(n => !isNaN(n) && n > 0); break; // All remaining env-mapped keys are string-typed — assign directly. case 'host': case 'authToken': - case 'metricsToken': case 'tmuxSession': case 'stateDir': case 'claudeProjectsDir': @@ -347,35 +254,6 @@ function applyEnvOverrides(config: Config): Config { return config; } -/** Apply alerting-specific env overrides (nested config). */ -function applyAlertingEnvOverrides(config: Config): Config { - const alertWebhooksRaw = process.env.AEGIS_ALERT_WEBHOOKS ?? process.env.MANUS_ALERT_WEBHOOKS; - if (alertWebhooksRaw) { - config.alerting.webhooks = alertWebhooksRaw.includes(',') - ? alertWebhooksRaw.split(',').map(s => s.trim()) - : [alertWebhooksRaw]; - } - const alertThreshold = process.env.AEGIS_ALERT_FAILURE_THRESHOLD; - if (alertThreshold !== undefined) { - config.alerting.failureThreshold = parseNumericEnvOverride( - 'AEGIS_ALERT_FAILURE_THRESHOLD', - alertThreshold, - config.alerting.failureThreshold, - { min: 1, max: MAX_ENV_INT }, - ); - } - const alertCooldown = process.env.AEGIS_ALERT_COOLDOWN_MS; - if (alertCooldown !== undefined) { - config.alerting.cooldownMs = parseNumericEnvOverride( - 'AEGIS_ALERT_COOLDOWN_MS', - alertCooldown, - config.alerting.cooldownMs, - { min: 1, max: MAX_ENV_INT }, - ); - } - return config; -} - /** Resolve the state directory. * If ~/.aegis doesn't exist but ~/.manus does, use ~/.manus for backward compat. */ @@ -397,7 +275,6 @@ export async function loadConfig(): Promise { const fileConfig = await loadConfigFile(); let config: Config = { ...defaults, ...fileConfig }; config = applyEnvOverrides(config); - config = applyAlertingEnvOverrides(config); config = resolveStateDir(config); // Issue #349: Resolve allowedWorkDirs entries via realpath so symlink targets match if (config.allowedWorkDirs.length > 0) { diff --git a/src/container.ts b/src/container.ts deleted file mode 100644 index b416865b..00000000 --- a/src/container.ts +++ /dev/null @@ -1,216 +0,0 @@ -export interface ServiceHealth { - healthy: boolean; - details?: string; -} - -export interface LifecycleService { - start(): Promise; - stop(signal: AbortSignal): Promise; - health?(): Promise; -} - -interface ServiceRegistration { - name: string; - instance: T; - lifecycle: LifecycleService; - dependencies: string[]; -} - -export interface ServiceHealthResult extends ServiceHealth { - name: string; -} - -export interface ServiceStopResult { - name: string; - status: 'stopped' | 'timeout' | 'error'; - error?: Error; -} - -export interface ServiceStopOptions { - timeoutMs?: number; -} - -function toError(error: unknown): Error { - if (error instanceof Error) return error; - return new Error(String(error)); -} - -export class ServiceContainer { - private readonly services = new Map(); - private readonly registrationOrder: string[] = []; - private readonly started = new Set(); - private readonly startOrder: string[] = []; - - register( - name: string, - instance: T, - lifecycle: LifecycleService, - dependencies: readonly string[] = [], - ): T { - if (this.services.has(name)) { - throw new Error(`Service "${name}" is already registered`); - } - this.services.set(name, { - name, - instance, - lifecycle, - dependencies: [...dependencies], - }); - this.registrationOrder.push(name); - return instance; - } - - resolve(name: string): T { - const service = this.services.get(name); - if (!service) { - throw new Error(`Service "${name}" is not registered`); - } - return service.instance as T; - } - - async start(names: readonly string[]): Promise { - const startPlan = this.resolveStartOrder(names); - const startedNow: string[] = []; - try { - for (const name of startPlan) { - if (this.started.has(name)) continue; - const service = this.services.get(name)!; - await service.lifecycle.start(); - this.started.add(name); - this.startOrder.push(name); - startedNow.push(name); - } - return startedNow; - } catch (error) { - await this.stopServices([...startedNow].reverse(), 5_000); - throw error; - } - } - - async startAll(): Promise { - return this.start(this.registrationOrder); - } - - async checkHealth(names?: readonly string[]): Promise { - const healthPlan = names ? this.resolveStartOrder(names) : this.startOrder; - const report: ServiceHealthResult[] = []; - - for (const name of healthPlan) { - if (!this.started.has(name)) continue; - const service = this.services.get(name)!; - if (!service.lifecycle.health) { - report.push({ name, healthy: true }); - continue; - } - try { - const result = await service.lifecycle.health(); - report.push({ name, healthy: result.healthy, details: result.details }); - } catch (error) { - report.push({ - name, - healthy: false, - details: `health check failed: ${toError(error).message}`, - }); - } - } - return report; - } - - async assertHealthy(names?: readonly string[]): Promise { - const report = await this.checkHealth(names); - const unhealthy = report.filter(service => !service.healthy); - if (unhealthy.length > 0) { - const reason = unhealthy - .map(service => `${service.name}${service.details ? ` (${service.details})` : ''}`) - .join(', '); - throw new Error(`Service health gate failed: ${reason}`); - } - return report; - } - - async stopAll(options: ServiceStopOptions = {}): Promise { - const timeoutMs = Math.max(1, options.timeoutMs ?? 5_000); - const names = [...this.startOrder].reverse(); - const results = await this.stopServices(names, timeoutMs); - this.startOrder.length = 0; - this.started.clear(); - return results; - } - - private async stopServices(names: readonly string[], timeoutMs: number): Promise { - const results: ServiceStopResult[] = []; - - for (const name of names) { - if (!this.started.has(name)) continue; - const service = this.services.get(name); - if (!service) continue; - - const abortController = new AbortController(); - let timer: NodeJS.Timeout | undefined; - const stopPromise = Promise.resolve() - .then(() => service.lifecycle.stop(abortController.signal)) - .then(() => ({ status: 'stopped' as const })) - .catch((error: unknown) => ({ status: 'error' as const, error: toError(error) })); - const timeoutPromise = new Promise<{ status: 'timeout' }>((resolve) => { - timer = setTimeout(() => { - abortController.abort(); - resolve({ status: 'timeout' }); - }, timeoutMs); - }); - - const outcome = await Promise.race([stopPromise, timeoutPromise]); - if (timer) clearTimeout(timer); - - this.started.delete(name); - const startOrderIndex = this.startOrder.indexOf(name); - if (startOrderIndex >= 0) this.startOrder.splice(startOrderIndex, 1); - - if (outcome.status === 'timeout') { - results.push({ name, status: 'timeout' }); - continue; - } - - if (outcome.status === 'error') { - results.push({ name, status: 'error', error: outcome.error }); - continue; - } - - results.push({ name, status: 'stopped' }); - } - return results; - } - - private resolveStartOrder(names: readonly string[]): string[] { - const order: string[] = []; - const state = new Map(); - - const visit = (name: string, stack: string[]): void => { - const service = this.services.get(name); - if (!service) { - throw new Error(`Service "${name}" is not registered`); - } - - const currentState = state.get(name); - if (currentState === 'visited') return; - if (currentState === 'visiting') { - throw new Error(`Circular service dependency: ${[...stack, name].join(' -> ')}`); - } - - state.set(name, 'visiting'); - for (const dependency of service.dependencies) { - if (!this.services.has(dependency)) { - throw new Error(`Service "${name}" depends on unregistered service "${dependency}"`); - } - visit(dependency, [...stack, name]); - } - state.set(name, 'visited'); - order.push(name); - }; - - for (const name of names) { - visit(name, []); - } - - return order; - } -} diff --git a/src/diagnostics.ts b/src/diagnostics.ts index e5d51f53..1075380d 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -12,7 +12,6 @@ export interface DiagnosticsEvent { component: string; operation: string; sessionId?: string; - requestId?: string; errorCode?: string; timestamp: string; attributes: Record; diff --git a/src/hook.ts b/src/hook.ts index 8201385a..cb4f6750 100644 --- a/src/hook.ts +++ b/src/hook.ts @@ -16,7 +16,7 @@ * } */ -import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, lstatSync, openSync, closeSync, unlinkSync } from 'node:fs'; +import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { homedir } from 'node:os'; import { execFileSync } from 'node:child_process'; @@ -35,36 +35,14 @@ const MAP_FILE = join(BRIDGE_DIR, 'session_map.json'); const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const TMUX_PANE_RE = /^%\d+$/; const DEFAULT_POINTER_TTL_MS = 24 * 60 * 60 * 1000; -const LOCK_ACQUIRE_TIMEOUT_MS = 2_000; -const LOCK_RETRY_DELAY_MS = 25; -const COMMAND_PATH_CONTROL_CHARS_RE = /[\u0000\r\n]/; function normalizeCommandPath(pathValue: string, platform: NodeJS.Platform = process.platform): string { return platform === 'win32' ? pathValue.replace(/\//g, '\\') : pathValue.replace(/\\/g, '/'); } -function assertCommandPathSafe(pathValue: string): void { - if (COMMAND_PATH_CONTROL_CHARS_RE.test(pathValue)) { - throw new Error('Hook command paths must not contain control characters'); - } -} - function quoteCommandPath(pathValue: string, platform: NodeJS.Platform = process.platform): string { const normalized = normalizeCommandPath(pathValue, platform); - assertCommandPathSafe(normalized); - - if (platform === 'win32') { - if (normalized.includes('"')) { - throw new Error('Hook command paths must not contain double quotes on Windows'); - } - const escaped = normalized - .replace(/%/g, '%%') - .replace(/!/g, '^!'); - return `"${escaped}"`; - } - - const escaped = normalized.replace(/'/g, `'\"'\"'`); - return `'${escaped}'`; + return `"${normalized.replace(/"/g, '\\"')}"`; } /** Build a shell-safe command string that invokes hook.js with an explicit Node executable. */ @@ -76,58 +54,6 @@ export function buildHookCommand( return `${quoteCommandPath(nodeExecutable, platform)} ${quoteCommandPath(scriptPath, platform)}`; } -function sleepSync(ms: number): void { - const blocker = new Int32Array(new SharedArrayBuffer(4)); - Atomics.wait(blocker, 0, 0, ms); -} - -export function assertPathNotSymlink(pathValue: string): void { - if (!existsSync(pathValue)) return; - const stats = lstatSync(pathValue); - if (stats.isSymbolicLink()) { - throw new Error(`Refusing to operate on symlink path: ${pathValue}`); - } -} - -export function withLockFile(lockFile: string, fn: () => T, timeoutMs: number = LOCK_ACQUIRE_TIMEOUT_MS): T { - const start = Date.now(); - while (true) { - try { - const fd = openSync(lockFile, 'wx'); - try { - return fn(); - } finally { - closeSync(fd); - try { unlinkSync(lockFile); } catch { /* ignore lock cleanup errors */ } - } - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code !== 'EEXIST') throw error; - if (Date.now() - start >= timeoutMs) { - throw new Error(`Timed out waiting for lock: ${lockFile}`); - } - sleepSync(LOCK_RETRY_DELAY_MS); - } - } -} - -function writeTextAtomic(targetPath: string, content: string): void { - const parentDir = dirname(targetPath); - mkdirSync(parentDir, { recursive: true }); - assertPathNotSymlink(parentDir); - assertPathNotSymlink(targetPath); - const tmpPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`; - assertPathNotSymlink(tmpPath); - try { - writeFileSync(tmpPath, content, { mode: 0o600 }); - renameSync(tmpPath, targetPath); - } finally { - if (existsSync(tmpPath)) { - try { unlinkSync(tmpPath); } catch { /* ignore tmp cleanup errors */ } - } - } -} - function getPointerTtlMs(): number { const raw = process.env.AEGIS_CONTINUATION_POINTER_TTL_MS ?? process.env.MANUS_CONTINUATION_POINTER_TTL_MS; const parsed = raw ? Number(raw) : NaN; @@ -159,33 +85,34 @@ function handleStopEvent( payload: Record, ): void { const signalFile = join(BRIDGE_DIR, 'stop_signals.json'); - assertPathNotSymlink(BRIDGE_DIR); - withLockFile(`${signalFile}.lock`, () => { - let signals: Record = {}; - if (existsSync(signalFile)) { - const parsed = safeJsonParseSchema(readFileSync(signalFile, 'utf-8'), stopSignalsSchema, 'stop_signals.json'); - if (parsed.ok) { - signals = parsed.data; - } else { - console.warn(`${parsed.error}; starting fresh`); - } + + let signals: Record = {}; + if (existsSync(signalFile)) { + const parsed = safeJsonParseSchema(readFileSync(signalFile, 'utf-8'), stopSignalsSchema, 'stop_signals.json'); + if (parsed.ok) { + signals = parsed.data; + } else { + console.warn(`${parsed.error}; starting fresh`); } + } - const p = stopPayloadSchema.safeParse(payload); - const pd = p.success ? p.data : {}; - signals[sessionId] = { - event, - timestamp: Date.now(), - // StopFailure may include error info in the payload - error: pd.error ?? pd.message ?? null, - error_details: pd.error_details ?? null, - last_assistant_message: pd.last_assistant_message ?? null, - agent_id: pd.agent_id ?? null, - stop_reason: pd.stop_reason ?? null, - }; - - writeTextAtomic(signalFile, JSON.stringify(signals, null, 2)); - }); + const p = stopPayloadSchema.safeParse(payload); + const pd = p.success ? p.data : {}; + signals[sessionId] = { + event, + timestamp: Date.now(), + // StopFailure may include error info in the payload + error: pd.error ?? pd.message ?? null, + error_details: pd.error_details ?? null, + last_assistant_message: pd.last_assistant_message ?? null, + agent_id: pd.agent_id ?? null, + stop_reason: pd.stop_reason ?? null, + }; + + // Atomic write: write to temp file then rename (prevents partial writes on crash) + const tmpSignalFile = signalFile + '.tmp'; + writeFileSync(tmpSignalFile, JSON.stringify(signals, null, 2)); + renameSync(tmpSignalFile, signalFile); console.error(`Aegis hook: ${event} for session ${sessionId.slice(0, 8)}...`); } @@ -276,36 +203,37 @@ function main(): void { // Read-modify-write session_map mkdirSync(BRIDGE_DIR, { recursive: true }); - assertPathNotSymlink(BRIDGE_DIR); - withLockFile(`${MAP_FILE}.lock`, () => { - let sessionMap: Record = {}; - if (existsSync(MAP_FILE)) { - const parsed = safeJsonParseSchema(readFileSync(MAP_FILE, 'utf-8'), sessionMapSchema, 'session_map.json'); - if (parsed.ok) { - sessionMap = parsed.data as Record; - } else { - console.warn(`${parsed.error}; starting fresh`); - } + + let sessionMap: Record = {}; + if (existsSync(MAP_FILE)) { + const parsed = safeJsonParseSchema(readFileSync(MAP_FILE, 'utf-8'), sessionMapSchema, 'session_map.json'); + if (parsed.ok) { + sessionMap = parsed.data as Record; + } else { + console.warn(`${parsed.error}; starting fresh`); } + } - const writtenAt = Date.now(); - sessionMap[key] = { - session_id: sessionId, - cwd, - window_name: windowName || '', - transcript_path: payload.transcript_path || null, - permission_mode: payload.permission_mode || null, - agent_id: payload.agent_id || null, - source: payload.source || null, - agent_type: payload.agent_type || null, - model: payload.model || null, - written_at: writtenAt, - schema_version: 1, - expires_at: writtenAt + getPointerTtlMs(), - }; - - writeTextAtomic(MAP_FILE, JSON.stringify(sessionMap, null, 2)); - }); + const writtenAt = Date.now(); + sessionMap[key] = { + session_id: sessionId, + cwd, + window_name: windowName || '', + transcript_path: payload.transcript_path || null, + permission_mode: payload.permission_mode || null, + agent_id: payload.agent_id || null, + source: payload.source || null, + agent_type: payload.agent_type || null, + model: payload.model || null, + written_at: writtenAt, + schema_version: 1, + expires_at: writtenAt + getPointerTtlMs(), + }; + + // Atomic write: write to temp file then rename (prevents race-condition data loss) + const tmpMapFile = MAP_FILE + '.tmp'; + writeFileSync(tmpMapFile, JSON.stringify(sessionMap, null, 2)); + renameSync(tmpMapFile, MAP_FILE); console.error(`Aegis hook: mapped ${key} -> ${sessionId}`); } @@ -360,12 +288,8 @@ function install(): void { settings.hooks = hooks; - const claudeDir = join(homedir(), '.claude'); - mkdirSync(claudeDir, { recursive: true }); - assertPathNotSymlink(claudeDir); - withLockFile(`${settingsPath}.lock`, () => { - writeTextAtomic(settingsPath, JSON.stringify(settings, null, 2) + '\n'); - }); + mkdirSync(join(homedir(), '.claude'), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); console.log(`Aegis hook installed in ${settingsPath}`); } diff --git a/src/hooks.ts b/src/hooks.ts index 133ca72c..f0ccb3cf 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -21,7 +21,7 @@ import type { SessionEventBus } from './events.js'; import { isValidUUID, hookBodySchema, parseIntSafe } from './validation.js'; import type { MetricsCollector } from './metrics.js'; import type { UIState } from './terminal-parser.js'; -import { evaluatePermissionProfile } from './services/permission/index.js'; +import { evaluatePermissionProfile } from './permission-evaluator.js'; import crypto from 'node:crypto'; /** CC hook events that require a decision response. */ @@ -133,7 +133,6 @@ export interface HookRouteDeps { sessions: SessionManager; eventBus: SessionEventBus; metrics?: MetricsCollector; - hookSecretHeaderOnly?: boolean; } /** @@ -145,7 +144,7 @@ export interface HookRouteDeps { export function registerHookRoutes(app: FastifyInstance, deps: HookRouteDeps): void { app.post<{ Params: { eventName: string }; - Querystring: { sessionId?: string; secret?: string }; + Querystring: { sessionId?: string }; }>('/v1/hooks/:eventName', async (req, reply) => { const { eventName } = req.params; // Issue #349: Validate event name against known list to prevent injection @@ -167,16 +166,9 @@ export function registerHookRoutes(app: FastifyInstance, deps: HookRouteDeps): v return reply.status(404).send({ error: `Session ${sessionId} not found` }); } - const headerHookSecret = req.headers['x-hook-secret'] as string | undefined; - const queryHookSecret = req.query.secret; - const hasQueryHookSecret = queryHookSecret !== undefined; - if (deps.hookSecretHeaderOnly && hasQueryHookSecret) { - return reply.status(401).send({ error: 'Unauthorized — hook secret must be sent via X-Hook-Secret header' }); - } - if (!deps.hookSecretHeaderOnly && hasQueryHookSecret) { - console.warn(`Hooks: query-string hook secret is deprecated (session ${sessionId}, event ${eventName}); use X-Hook-Secret header`); - } - const hookSecret = headerHookSecret || queryHookSecret; + // Issue #629/#1131: Validate hook secret from X-Hook-Secret header (query param fallback) + const hookSecret = (req.headers['x-hook-secret'] as string) + || (req.query as Record)?.secret; if (session.hookSecret && !timingSafeEqual(hookSecret, session.hookSecret)) { return reply.status(401).send({ error: 'Unauthorized — invalid hook secret' }); } diff --git a/src/jsonl-watcher.ts b/src/jsonl-watcher.ts index b7c55ac6..1e6e41d4 100644 --- a/src/jsonl-watcher.ts +++ b/src/jsonl-watcher.ts @@ -6,7 +6,6 @@ * duplicate events from rapid writes. * * Issue #84: Replace JSONL polling with fs.watch. - * Issue #1420: Auto-restart watcher on fs.watch errors with exponential backoff. */ import { watch, type FSWatcher } from 'node:fs'; @@ -26,16 +25,10 @@ export interface JsonlWatcherEvent { export interface JsonlWatcherConfig { /** Debounce interval in ms to coalesce rapid writes (default: 100). */ debounceMs: number; - /** Maximum number of restart attempts before giving up (default: 5). */ - maxRestartAttempts: number; - /** Base delay in ms for exponential backoff on restart (default: 1000). */ - restartBaseDelayMs: number; } const DEFAULT_CONFIG: JsonlWatcherConfig = { debounceMs: 100, - maxRestartAttempts: 5, - restartBaseDelayMs: 1000, }; /** Watch state for a single JSONL file. */ @@ -46,10 +39,6 @@ interface WatchEntry { debounceTimer: ReturnType | null; /** Current byte offset — updated after each read. */ offset: number; - /** Issue #1420: Consecutive restart attempt count for exponential backoff. */ - restartAttempts: number; - /** Issue #1420: Pending restart timer handle. */ - restartTimer: ReturnType | null; } /** @@ -117,8 +106,8 @@ export class JsonlWatcher { fsWatcher.on('error', (err) => { console.error(`JsonlWatcher: error watching ${jsonlPath}:`, err.message); - // Issue #1420: Attempt restart with exponential backoff instead of giving up. - this.scheduleRestart(sessionId); + // Stop watching on persistent errors + this.unwatch(sessionId); }); this.entries.set(sessionId, { @@ -127,8 +116,6 @@ export class JsonlWatcher { fsWatcher, debounceTimer: null, offset: initialOffset, - restartAttempts: 0, - restartTimer: null, }); } @@ -140,9 +127,6 @@ export class JsonlWatcher { if (entry.debounceTimer) { clearTimeout(entry.debounceTimer); } - if (entry.restartTimer) { - clearTimeout(entry.restartTimer); - } entry.fsWatcher.close(); this.entries.delete(sessionId); } @@ -153,9 +137,6 @@ export class JsonlWatcher { if (entry.debounceTimer) { clearTimeout(entry.debounceTimer); } - if (entry.restartTimer) { - clearTimeout(entry.restartTimer); - } entry.fsWatcher.close(); } this.entries.clear(); @@ -188,41 +169,6 @@ export class JsonlWatcher { this.listeners.length = 0; } - /** Issue #1420: Schedule a restart with exponential backoff after an fs.watch error. */ - private scheduleRestart(sessionId: string): void { - const entry = this.entries.get(sessionId); - if (!entry) return; - - if (entry.restartAttempts >= this.config.maxRestartAttempts) { - console.error( - `JsonlWatcher: max restart attempts (${this.config.maxRestartAttempts}) reached for ${entry.jsonlPath}, giving up`, - ); - this.unwatch(sessionId); - return; - } - - const delay = this.config.restartBaseDelayMs * Math.pow(2, entry.restartAttempts); - entry.restartAttempts++; - - console.error( - `JsonlWatcher: scheduling restart attempt ${entry.restartAttempts}/${this.config.maxRestartAttempts} for ${entry.jsonlPath} in ${delay}ms`, - ); - - entry.restartTimer = setTimeout(() => { - entry.restartTimer = null; - const currentOffset = entry.offset; - // Close the broken watcher first, then re-watch from saved offset - entry.fsWatcher.close(); - this.entries.delete(sessionId); - this.watch(sessionId, entry.jsonlPath, currentOffset); - // Preserve restart counter across the re-watch - const newEntry = this.entries.get(sessionId); - if (newEntry) { - newEntry.restartAttempts = entry.restartAttempts; - } - }, delay); - } - /** Schedule a debounced read for a session. */ private scheduleRead(sessionId: string): void { const entry = this.entries.get(sessionId); diff --git a/src/logger.ts b/src/logger.ts index 34f822a7..64a0bd6b 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -13,7 +13,6 @@ export interface LogContext { component: string; operation: string; sessionId?: string; - requestId?: string; errorCode?: string; attributes?: Record; } @@ -24,7 +23,6 @@ export interface StructuredLogRecord { component: string; operation: string; sessionId?: string; - requestId?: string; errorCode?: string; attributes: Record; } @@ -75,7 +73,6 @@ export class StructuredLogger { component: ctx.component, operation: ctx.operation, sessionId: ctx.sessionId, - requestId: ctx.requestId, errorCode: ctx.errorCode, attributes, }; @@ -94,7 +91,6 @@ export class StructuredLogger { component: ctx.component, operation: ctx.operation, sessionId: ctx.sessionId, - requestId: ctx.requestId, errorCode: ctx.errorCode, timestamp, attributes, diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 117f4d0f..25f0e95c 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -72,7 +72,6 @@ interface SendMessageResponse { ok: boolean; delivered: boolean; attempts: number; - stall?: { stalled: true; types: string[] } | { stalled: false }; } interface OkResponse { @@ -103,9 +102,6 @@ interface MemoryEntryResponse { // ── Aegis REST client ─────────────────────────────────────────────── export class AegisClient { - /** Cached role resolved from /v1/auth/verify. undefined = not yet resolved. */ - private resolvedRole: string | undefined; - constructor(private baseUrl: string, private authToken?: string) {} private validateSessionId(id: string): void { @@ -114,34 +110,6 @@ export class AegisClient { } } - /** - * Resolve the RBAC role for the configured auth token. - * Calls POST /v1/auth/verify once and caches the result. - * Returns 'admin' when no auth token is configured (matching server.ts behavior). - */ - async resolveRole(): Promise { - if (this.resolvedRole !== undefined) return this.resolvedRole; - - if (!this.authToken) { - this.resolvedRole = 'admin'; - return this.resolvedRole; - } - - try { - const result = await this.request<{ valid: boolean; role?: string }>('/v1/auth/verify', { - method: 'POST', - body: JSON.stringify({ token: this.authToken }), - }); - this.resolvedRole = result.role ?? 'admin'; - } catch { - // If server is unreachable or verify fails, default to admin (no enforcement) - // to avoid breaking existing setups during upgrade. - this.resolvedRole = 'admin'; - } - - return this.resolvedRole; - } - private async request(path: string, opts?: RequestInit): Promise { const hasBody = opts?.body !== undefined; const headers: Record = { @@ -351,69 +319,6 @@ function formatToolError(e: unknown): { content: Array<{ type: 'text'; text: str }; } -// ── MCP Tool Authorization (Issue #1407) ────────────────────────────── - -/** Minimum RBAC role required to call each MCP tool. */ -const TOOL_REQUIRED_ROLE: Record = { - // viewer — read-only, no side effects - list_sessions: 'viewer', - get_status: 'viewer', - get_transcript: 'viewer', - server_health: 'viewer', - capture_pane: 'viewer', - get_session_metrics: 'viewer', - get_session_summary: 'viewer', - get_session_latency: 'viewer', - list_pipelines: 'viewer', - get_swarm: 'viewer', - state_get: 'viewer', - // operator — interactive but non-destructive - send_message: 'operator', - create_session: 'operator', - approve_permission: 'operator', - reject_permission: 'operator', - escape_session: 'operator', - interrupt_session: 'operator', - send_command: 'operator', - batch_create_sessions: 'operator', - create_pipeline: 'operator', - state_set: 'operator', - state_delete: 'operator', - // admin — destructive, requires elevated access - kill_session: 'admin', - send_bash: 'admin', -}; - -/** Numeric role levels for comparison. */ -const ROLE_LEVEL: Record = { - admin: 3, - operator: 2, - viewer: 1, -}; - -function formatAuthError(toolName: string, role: string, required: string): { content: Array<{ type: 'text'; text: string }>; isError: true } { - return { - content: [{ type: 'text' as const, text: JSON.stringify({ code: 'FORBIDDEN', message: `Tool '${toolName}' requires '${required}' role, but token has '${role}' role` } satisfies McpErrorEnvelope) }], - isError: true, - }; -} - -/** Wrap a tool handler with per-tool role authorization. */ -function withAuth( - toolName: string, - handler: (args: TArgs) => Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }>, - client: AegisClient, -): (args: TArgs) => Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> { - return async (args) => { - const role = await client.resolveRole(); - const required = TOOL_REQUIRED_ROLE[toolName]; - if (required && (ROLE_LEVEL[role] ?? 0) < (ROLE_LEVEL[required] ?? 0)) { - return formatAuthError(toolName, role, required); - } - return handler(args); - }; -} - // ── MCP Server ────────────────────────────────────────────────────── export function createMcpServer(aegisPort: number, authToken?: string): McpServer { @@ -533,7 +438,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe status: z.string().optional().describe('Filter by status (e.g., idle, working, permission_prompt)'), workDir: z.string().optional().describe('Filter by workDir substring (e.g., "my-project")'), }, - withAuth('list_sessions', async ({ status, workDir }) => { + async ({ status, workDir }) => { try { const sessions = await client.listSessions({ status, workDir }); const summary = sessions.map((s) => ({ @@ -553,7 +458,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── get_status ── @@ -563,7 +468,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe { sessionId: z.string().describe('The session ID to check'), }, - withAuth('get_status', async ({ sessionId }) => { + async ({ sessionId }) => { try { const [session, health] = await Promise.all([ client.getSession(sessionId), @@ -578,7 +483,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── get_transcript ── @@ -588,7 +493,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe { sessionId: z.string().describe('The session ID to read from'), }, - withAuth('get_transcript', async ({ sessionId }) => { + async ({ sessionId }) => { try { const transcript = await client.getTranscript(sessionId); return { @@ -600,18 +505,18 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── send_message ── server.tool( 'send_message', - 'Send a message to another Aegis session. The message is delivered via tmux send-keys with delivery verification. Returns stall information if the session is currently stalled.', + 'Send a message to another Aegis session. The message is delivered via tmux send-keys with delivery verification.', { sessionId: z.string().describe('The target session ID'), text: z.string().describe('The message text to send'), }, - withAuth('send_message', async ({ sessionId, text }) => { + async ({ sessionId, text }) => { try { const result = await client.sendMessage(sessionId, text); return { @@ -623,7 +528,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── create_session ── @@ -635,7 +540,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe name: z.string().optional().describe('Optional human-readable name for the session'), prompt: z.string().optional().describe('Optional initial prompt to send after creation'), }, - withAuth('create_session', async ({ workDir, name, prompt }) => { + async ({ workDir, name, prompt }) => { try { const session = await client.createSession({ workDir, name, prompt }); return { @@ -653,7 +558,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── kill_session ── @@ -663,7 +568,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe { sessionId: z.string().describe('The session ID to kill'), }, - withAuth('kill_session', async ({ sessionId }) => { + async ({ sessionId }) => { try { const result = await client.killSession(sessionId); return { @@ -675,7 +580,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── approve_permission ── @@ -685,7 +590,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe { sessionId: z.string().describe('The session ID with a pending permission prompt'), }, - withAuth('approve_permission', async ({ sessionId }) => { + async ({ sessionId }) => { try { const result = await client.approvePermission(sessionId); return { @@ -697,7 +602,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── reject_permission ── @@ -707,7 +612,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe { sessionId: z.string().describe('The session ID with a pending permission prompt'), }, - withAuth('reject_permission', async ({ sessionId }) => { + async ({ sessionId }) => { try { const result = await client.rejectPermission(sessionId); return { @@ -719,7 +624,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── server_health ── @@ -727,7 +632,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe 'server_health', 'Check the health and status of the Aegis server. Returns version, uptime, and session counts.', {}, - withAuth('server_health', async () => { + async () => { try { const result = await client.getServerHealth(); return { @@ -739,7 +644,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── escape_session ── @@ -749,7 +654,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe { sessionId: z.string().describe('The session ID to send escape to'), }, - withAuth('escape_session', async ({ sessionId }) => { + async ({ sessionId }) => { try { const result = await client.escapeSession(sessionId); return { @@ -761,7 +666,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── interrupt_session ── @@ -771,7 +676,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe { sessionId: z.string().describe('The session ID to interrupt'), }, - withAuth('interrupt_session', async ({ sessionId }) => { + async ({ sessionId }) => { try { const result = await client.interruptSession(sessionId); return { @@ -783,7 +688,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── capture_pane ── @@ -793,7 +698,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe { sessionId: z.string().describe('The session ID to capture'), }, - withAuth('capture_pane', async ({ sessionId }) => { + async ({ sessionId }) => { try { const result = await client.capturePane(sessionId); return { @@ -805,7 +710,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── get_session_metrics ── @@ -815,7 +720,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe { sessionId: z.string().describe('The session ID to get metrics for'), }, - withAuth('get_session_metrics', async ({ sessionId }) => { + async ({ sessionId }) => { try { const result = await client.getSessionMetrics(sessionId); return { @@ -827,7 +732,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── get_session_summary ── @@ -837,7 +742,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe { sessionId: z.string().describe('The session ID to summarize'), }, - withAuth('get_session_summary', async ({ sessionId }) => { + async ({ sessionId }) => { try { const result = await client.getSessionSummary(sessionId); return { @@ -849,7 +754,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── send_bash ── @@ -860,7 +765,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe sessionId: z.string().describe('The session ID to send the bash command to'), command: z.string().describe('The bash command to execute'), }, - withAuth('send_bash', async ({ sessionId, command }) => { + async ({ sessionId, command }) => { try { const result = await client.sendBash(sessionId, command); return { @@ -872,7 +777,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── send_command ── @@ -883,7 +788,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe sessionId: z.string().describe('The session ID to send the command to'), command: z.string().describe('The slash command to send (e.g., "help", "compact")'), }, - withAuth('send_command', async ({ sessionId, command }) => { + async ({ sessionId, command }) => { try { const result = await client.sendCommand(sessionId, command); return { @@ -895,7 +800,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── get_session_latency ── @@ -905,7 +810,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe { sessionId: z.string().describe('The session ID to get latency for'), }, - withAuth('get_session_latency', async ({ sessionId }) => { + async ({ sessionId }) => { try { const result = await client.getSessionLatency(sessionId); return { @@ -917,7 +822,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── batch_create_sessions ── @@ -929,9 +834,9 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe workDir: z.string().describe('Working directory for the session'), name: z.string().optional().describe('Optional human-readable name'), prompt: z.string().optional().describe('Optional initial prompt'), - })).min(1).max(50).describe('Array of session specifications to create (max 50)'), + })).describe('Array of session specifications to create'), }, - withAuth('batch_create_sessions', async ({ sessions: sessionSpecs }) => { + async ({ sessions: sessionSpecs }) => { try { const result = await client.batchCreateSessions(sessionSpecs); return { @@ -943,7 +848,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── list_pipelines ── @@ -951,7 +856,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe 'list_pipelines', 'List all configured pipelines in the Aegis server.', {}, - withAuth('list_pipelines', async () => { + async () => { try { const result = await client.listPipelines(); return { @@ -963,7 +868,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── create_pipeline ── @@ -976,9 +881,9 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe steps: z.array(z.object({ name: z.string().optional().describe('Step name'), prompt: z.string().describe('Prompt for this step'), - })).min(1).max(50).describe('Array of pipeline steps (max 50)'), + })).describe('Array of pipeline steps'), }, - withAuth('create_pipeline', async ({ name, workDir, steps }) => { + async ({ name, workDir, steps }) => { try { const result = await client.createPipeline({ name, workDir, steps }); return { @@ -990,7 +895,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── get_swarm ── @@ -998,7 +903,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe 'get_swarm', 'Get a snapshot of all Claude Code processes detected on the system (the "swarm").', {}, - withAuth('get_swarm', async () => { + async () => { try { const result = await client.getSwarm(); return { @@ -1010,7 +915,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── state_set ── @@ -1022,7 +927,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe value: z.string().describe('State payload as string'), ttlSeconds: z.number().int().positive().max(86400 * 30).optional().describe('Optional TTL in seconds (max 30 days)'), }, - withAuth('state_set', async ({ key, value, ttlSeconds }) => { + async ({ key, value, ttlSeconds }) => { try { const result = await client.setMemory(key, value, ttlSeconds); return { @@ -1034,7 +939,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── state_get ── @@ -1044,7 +949,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe { key: z.string().describe('State key in namespace/key format (e.g., pipeline/run-123)'), }, - withAuth('state_get', async ({ key }) => { + async ({ key }) => { try { const result = await client.getMemory(key); return { @@ -1056,7 +961,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── state_delete ── @@ -1066,7 +971,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe { key: z.string().describe('State key in namespace/key format (e.g., pipeline/run-123)'), }, - withAuth('state_delete', async ({ key }) => { + async ({ key }) => { try { const result = await client.deleteMemory(key); return { @@ -1078,7 +983,7 @@ export function createMcpServer(aegisPort: number, authToken?: string): McpServe } catch (e: unknown) { return formatToolError(e); } - }, client), + }, ); // ── MCP Prompts (Issue #443) ──────────────────────────────────────── diff --git a/src/metrics.ts b/src/metrics.ts index c309709a..0401a845 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -103,7 +103,6 @@ export class MetricsCollector { private perSession = new Map(); private latency = new Map(); - private sessionStartTimes = new Map(); private startTime = Date.now(); /** Maximum samples per latency type per session (rolling window). */ @@ -160,32 +159,20 @@ export class MetricsCollector { sessionCreated(sessionId: string): void { this.global.sessionsCreated++; sessionsCreatedTotal.inc(); - this.sessionStartTimes.set(sessionId, Date.now()); this.perSession.set(sessionId, { durationSec: 0, messages: 0, toolCalls: 0, approvals: 0, autoApprovals: 0, statusChanges: [], }); } - sessionCompleted(sessionId: string): void { + sessionCompleted(_sessionId: string): void { this.global.sessionsCompleted++; sessionsCompletedTotal.inc(); - this.finalizeSessionDuration(sessionId); } - sessionFailed(sessionId: string): void { + sessionFailed(_sessionId: string): void { this.global.sessionsFailed++; sessionsFailedTotal.inc(); - this.finalizeSessionDuration(sessionId); - } - - /** Record the final duration for a completed or failed session. */ - private finalizeSessionDuration(sessionId: string): void { - const startedAt = this.sessionStartTimes.get(sessionId); - const m = this.perSession.get(sessionId); - if (startedAt !== undefined && m) { - m.durationSec = Math.round((Date.now() - startedAt) / 1000); - } } messageReceived(sessionId: string): void { @@ -348,30 +335,12 @@ export class MetricsCollector { cleanupSession(sessionId: string): void { this.perSession.delete(sessionId); this.latency.delete(sessionId); - this.sessionStartTimes.delete(sessionId); } getGlobalMetrics(activeSessionCount: number): GlobalMetricsResponse { const avgMessages = this.global.sessionsCreated > 0 ? Math.round(this.global.totalMessages / this.global.sessionsCreated) : 0; - // Issue #1414: Calculate avg_duration_sec from per-session durations. - // Completed/failed sessions have finalized durationSec; active sessions use elapsed time. - let totalDuration = 0; - const now = Date.now(); - for (const [id, m] of this.perSession) { - if (m.durationSec > 0) { - totalDuration += m.durationSec; - } else { - const startedAt = this.sessionStartTimes.get(id); - if (startedAt !== undefined) { - totalDuration += Math.round((now - startedAt) / 1000); - } - } - } - const avgDuration = this.perSession.size > 0 - ? Math.round(totalDuration / this.perSession.size) : 0; - // Update Prometheus sessions_active gauge sessionsActive.set(activeSessionCount); @@ -388,7 +357,7 @@ export class MetricsCollector { currently_active: activeSessionCount, completed: this.global.sessionsCompleted, failed: this.global.sessionsFailed, - avg_duration_sec: avgDuration, + avg_duration_sec: 0, avg_messages_per_session: avgMessages, }, auto_approvals: this.global.autoApprovals, diff --git a/src/model-router.ts b/src/model-router.ts new file mode 100644 index 00000000..9289b6f4 --- /dev/null +++ b/src/model-router.ts @@ -0,0 +1,180 @@ +/** + * model-router.ts — Issue #743: Tiered Model Routing + * + * Scores task complexity from metadata (title, labels, description) and routes + * to the optimal model tier: fast | standard | power. + * + * Scoring (0–100): + * 0–30 → fast (cheapest, e.g. Haiku-class) + * 31–70 → standard (balanced, e.g. Sonnet-class) + * 71–100 → power (most capable, e.g. Opus-class) + * + * Concrete model names are configurable via environment variables: + * MODEL_FAST, MODEL_STANDARD, MODEL_POWER + */ + +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +export type ModelTier = 'fast' | 'standard' | 'power'; + +export interface RoutingDecision { + tier: ModelTier; + model: string; + score: number; + reasoning: string[]; +} + +/** Keyword signals mapped to model tier. First match in each tier wins. */ +const ROUTING_KEYWORDS: Record = { + power: [ + 'security', 'auth', 'authentication', 'authorization', + 'architecture', 'redesign', 'migration', 'critical', + 'vulnerability', 'injection', 'cryptography', 'encryption', + 'race condition', 'concurrency', 'breaking change', + 'permission', 'privilege', 'escalation', + ], + standard: [ + 'feature', 'enhancement', 'refactor', 'type-safety', + 'integration', 'api', 'endpoint', 'validation', + 'test', 'coverage', 'hook', 'pipeline', 'routing', + 'module', 'performance', 'optimization', + ], + fast: [ + 'typo', 'docs', 'documentation', 'label', 'rename', + 'bump', 'chore', 'formatting', 'comment', 'readme', + 'changelog', 'version', 'lint', 'whitespace', + ], +}; + +/** Default model names per tier (overridable via env vars). */ +export const MODEL_TIERS: Record = { + fast: process.env.MODEL_FAST ?? 'claude-haiku-4-5', + standard: process.env.MODEL_STANDARD ?? 'claude-sonnet-4-6', + power: process.env.MODEL_POWER ?? 'claude-opus-4-6', +}; + +/** + * Score a task 0–100 based on its metadata. + * Returns the score and a human-readable reasoning list. + */ +export function scoreTaskComplexity( + title: string, + labels: string[], + description: string, +): { score: number; reasoning: string[] } { + const reasoning: string[] = []; + let score = 35; // baseline: low-standard + + const text = `${title} ${description}`.toLowerCase(); + + // Power keywords → raise score to at least power tier threshold + for (const kw of ROUTING_KEYWORDS.power) { + if (text.includes(kw)) { + score = Math.max(score, 75); + reasoning.push(`power keyword: "${kw}"`); + break; + } + } + + // Fast keywords → lower score to at most fast tier threshold + for (const kw of ROUTING_KEYWORDS.fast) { + if (text.includes(kw)) { + score = Math.min(score, 20); + reasoning.push(`fast keyword: "${kw}"`); + break; + } + } + + // Standard keywords → minor boost (avoid staying at baseline) + if (reasoning.length === 0) { + for (const kw of ROUTING_KEYWORDS.standard) { + if (text.includes(kw)) { + score += 5; + reasoning.push(`standard keyword: "${kw}"`); + break; + } + } + } + + // Label overrides — applied after keyword signals + for (const label of labels) { + const l = label.toLowerCase(); + if (l === 'security' || l === 'critical' || l === 'breaking-change') { + score = Math.max(score, 80); + reasoning.push(`label override: "${l}" → power tier`); + } else if (l === 'docs' || l === 'documentation' || l === 'chore') { + score = Math.min(score, 20); + reasoning.push(`label override: "${l}" → fast tier`); + } + } + + // Priority labels + for (const label of labels) { + if (label === 'P0' || label === 'P1') { + score = Math.max(score, 72); + reasoning.push(`priority label: "${label}" → elevate to power`); + } else if (label === 'P3') { + score = Math.min(score, 55); + reasoning.push(`priority label: "P3" → cap at standard`); + } + } + + if (reasoning.length === 0) reasoning.push('baseline score — no keyword or label signals'); + + return { score: Math.max(0, Math.min(100, score)), reasoning }; +} + +/** Map a 0–100 score to a model tier. */ +export function scoreToTier(score: number): ModelTier { + if (score <= 30) return 'fast'; + if (score <= 70) return 'standard'; + return 'power'; +} + +/** + * Route a task to the optimal model tier and concrete model name. + * + * @example + * routeTask({ title: 'fix typo in README', labels: ['docs'] }) + * // → { tier: 'fast', model: 'claude-haiku-4-5', score: 15, reasoning: [...] } + */ +export function routeTask(opts: { + title: string; + labels?: string[]; + description?: string; +}): RoutingDecision { + const { title, labels = [], description = '' } = opts; + const { score, reasoning } = scoreTaskComplexity(title, labels, description); + const tier = scoreToTier(score); + const model = MODEL_TIERS[tier]; + return { tier, model, score, reasoning }; +} + +/** Zod schema for POST /v1/dev/route-task request body. */ +const routeTaskSchema = z.object({ + title: z.string().min(1).max(500), + labels: z.array(z.string().max(100)).max(50).optional(), + description: z.string().max(10_000).optional(), +}); + +/** + * Register the model-routing endpoint on the Fastify app. + * + * POST /v1/dev/route-task — score a task and return model recommendation. + * GET /v1/dev/model-tiers — return current model-tier configuration. + */ +export function registerModelRouterRoutes(app: FastifyInstance): void { + app.post('/v1/dev/route-task', async (req, reply) => { + const parsed = routeTaskSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return reply.status(400).send({ error: 'Invalid body', details: parsed.error.issues }); + } + const { title, labels, description } = parsed.data; + return routeTask({ title, labels, description }); + }); + + app.get('/v1/dev/model-tiers', async () => { + return { tiers: MODEL_TIERS }; + }); +} diff --git a/src/monitor.ts b/src/monitor.ts index 18e6057d..016ed42b 100644 --- a/src/monitor.ts +++ b/src/monitor.ts @@ -22,7 +22,6 @@ import { stopSignalsSchema } from './validation.js'; import { suppressedCatch } from './suppress.js'; import { logger } from './logger.js'; import { maybeInjectFault } from './fault-injection.js'; -import { type AlertManager } from './alerting.js'; export interface MonitorConfig { pollIntervalMs: number; // Base poll interval (default: 30000 — hooks are primary signal) @@ -149,18 +148,10 @@ export class SessionMonitor { /** Issue #397: Set the TmuxManager reference for tmux health checks. */ private tmux?: TmuxManager; - /** Issue #1418: Alert manager for production alerting. */ - private alertManager?: AlertManager; - setTmuxManager(tmuxManager: TmuxManager): void { this.tmux = tmuxManager; } - /** Issue #1418: Set the AlertManager for production alerting. */ - setAlertManager(alertManager: AlertManager): void { - this.alertManager = alertManager; - } - /** Issue #84: Set the JSONL watcher for fs.watch-based message detection. */ setJsonlWatcher(watcher: JsonlWatcher): void { this.jsonlWatcher = watcher; @@ -179,10 +170,6 @@ export class SessionMonitor { this.running = false; } - get isRunning(): boolean { - return this.running; - } - private async loop(): Promise { while (this.running) { try { @@ -554,9 +541,6 @@ export class SessionMonitor { this.makePayload('status.error', session, `⚠️ Claude Code error: ${errorDetail}`), ); - // Issue #1418: Report session failure to alerting - this.alertManager?.recordFailure('session_failure', - `Session "${session.windowName}" failed: ${errorDetail}`); } } else if (signal.event === 'Stop') { logger.info({ @@ -874,9 +858,6 @@ export class SessionMonitor { await this.channels.statusChange( this.makePayload('status.dead', session, detail), ); - // Issue #1418: Report dead session to alerting - this.alertManager?.recordFailure('session_failure', - `Session "${session.windowName}" died unexpectedly: ${cause}`); this.removeSession(session.id); // #262: Also remove from SessionManager so dead sessions don't linger try { @@ -921,9 +902,6 @@ export class SessionMonitor { attributes: { error: error ?? 'tmux server unavailable' }, }); this.tmuxWasDown = true; - // Issue #1418: Report tmux crash to alerting - this.alertManager?.recordFailure('tmux_crash', - `tmux server unreachable: ${error ?? 'unknown error'}`); } return; } @@ -979,14 +957,6 @@ export class SessionMonitor { // Note: processedStopSignals uses claudeSessionId:timestamp keys, not bridge sessionId. // We don't clean them here — they're small and prevent re-processing. } - - /** Return active stall types for a session, or null if not stalled. - * Used by send_message to surface stall feedback to callers. */ - getStallInfo(sessionId: string): { stalled: true; types: string[] } | { stalled: false } { - const types = this.stallNotified.get(sessionId); - if (!types || types.size === 0) return { stalled: false }; - return { stalled: true, types: [...types] }; - } } function sleep(ms: number): Promise { diff --git a/src/services/permission/evaluator.ts b/src/permission-evaluator.ts similarity index 59% rename from src/services/permission/evaluator.ts rename to src/permission-evaluator.ts index 34024396..1e030784 100644 --- a/src/services/permission/evaluator.ts +++ b/src/permission-evaluator.ts @@ -1,10 +1,15 @@ -import { existsSync, realpathSync } from 'node:fs'; -import { dirname, join, normalize, relative, resolve, sep } from 'node:path'; -import type { - PermissionEvaluationInput, - PermissionEvaluationResult, - PermissionProfile, -} from './types.js'; +import type { PermissionProfile } from './validation.js'; +import { normalize, sep } from 'node:path'; + +export interface PermissionEvaluationInput { + toolName: string; + toolInput?: Record; +} + +export interface PermissionEvaluationResult { + behavior: 'allow' | 'deny' | 'ask'; + reason: string; +} function globToRegExp(pattern: string): RegExp { const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\?/g, '.').replace(/\*/g, '.*'); @@ -26,49 +31,12 @@ function isLikelyWriteTool(toolName: string): boolean { return /write|edit|delete|rename|move|create/i.test(toolName); } -/** - * Resolve a path to its real (canonical) form, stripping any symlinks. - * Falls back to `normalize()` when the path does not exist on disk. - */ -function resolveRealPath(filePath: string): string { - const absolutePath = resolve(filePath); - try { - if (existsSync(absolutePath)) { - return normalize(realpathSync(absolutePath)); - } - } catch { - // realpathSync can throw for broken symlinks or permission issues - } - - // For non-existent paths, resolve the nearest existing ancestor via realpath - // and rebuild the original suffix. This keeps comparisons stable on platforms - // where aliases like /var -> /private/var exist (for example macOS temp dirs). - let probe = absolutePath; - while (true) { - try { - if (existsSync(probe)) { - const realAncestor = normalize(realpathSync(probe)); - const suffix = relative(probe, absolutePath); - return suffix ? normalize(join(realAncestor, suffix)) : realAncestor; - } - } catch { - // Continue walking to parent if this segment cannot be resolved. - } - - const parent = dirname(probe); - if (parent === probe) break; - probe = parent; - } - - return normalize(absolutePath); -} - function isPathAllowed(candidate: string, allowedPrefixes: string[]): boolean { - const resolvedCandidate = resolveRealPath(candidate); + const normalizedCandidate = normalize(candidate); return allowedPrefixes.some((prefix) => { - const resolvedPrefix = resolveRealPath(prefix); - return resolvedCandidate === resolvedPrefix || - resolvedCandidate.startsWith(resolvedPrefix + sep); + const normalizedPrefix = normalize(prefix); + return normalizedCandidate === normalizedPrefix || + normalizedCandidate.startsWith(normalizedPrefix + sep); }); } diff --git a/src/permission-routes.ts b/src/permission-routes.ts index bb07d84e..4f5927bf 100644 --- a/src/permission-routes.ts +++ b/src/permission-routes.ts @@ -1,7 +1,6 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import type { SessionManager } from './session.js'; import type { MetricsCollector } from './metrics.js'; -import type { AuditLogger } from './audit.js'; type PermissionAction = 'approve' | 'reject'; type IdParams = { Params: { id: string } }; @@ -14,7 +13,6 @@ function createPermissionHandler( action: PermissionAction, sessions: PermissionSessions, metrics: PermissionMetrics, - audit: AuditLogger | null, ): (req: IdRequest, reply: FastifyReply) => Promise { return async (req: IdRequest, reply: FastifyReply): Promise => { // Issue #1429: Enforce session ownership @@ -32,11 +30,6 @@ function createPermissionHandler( await sessions.reject(req.params.id); } - // #1419: Audit permission decision - if (audit) { - void audit.log(keyId ?? 'system', `permission.${action}` as `permission.${typeof action}`, `Permission ${action} for session ${req.params.id}`, req.params.id); - } - // Issue #87: Record permission response latency. const lat = sessions.getLatencyMetrics(req.params.id); if (lat !== null && lat.permission_response_ms !== null) { @@ -54,10 +47,9 @@ export function registerPermissionRoutes( app: FastifyInstance, sessions: PermissionSessions, metrics: PermissionMetrics, - audit: AuditLogger | null = null, ): void { for (const action of ['approve', 'reject'] as const) { - const handler = createPermissionHandler(action, sessions, metrics, audit); + const handler = createPermissionHandler(action, sessions, metrics); app.post(`/v1/sessions/:id/${action}`, handler); app.post(`/sessions/:id/${action}`, handler); } diff --git a/src/pipeline.ts b/src/pipeline.ts index 0b3ffefe..3296507c 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -10,7 +10,7 @@ import { type SessionEventBus } from './events.js'; import { getErrorMessage } from './validation.js'; import { shouldRetry } from './error-categories.js'; import { retryWithJitter } from './retry.js'; -import { readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { readFile, rename, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; export interface BatchSessionSpec { @@ -93,8 +93,6 @@ export class PipelineManager { private sessions: SessionManager, private eventBus?: SessionEventBus, private stateDir: string | null = null, - /** Issue #1423: Global default stage timeout in milliseconds. 0 = no timeout. */ - private defaultStageTimeoutMs: number = 0, ) {} /** Create multiple sessions in parallel. */ @@ -313,26 +311,24 @@ export class PipelineManager { continue; } - // #1423: Check for stage timeout BEFORE idle (timeout wins over idle) + if (session.status === 'idle') { + stage.status = 'completed'; + stage.completedAt = Date.now(); + this.transitionPipelineStage(pipeline, 'verify', { stageCompleted: stage.name }); + await this.persistPipelines(); // #1424: persist after stage completes + } + + // #1423: Check for stage timeout const stageConfig = this.pipelineConfigs.get(id)?.stages.find(s => s.name === stage.name); - const effectiveTimeout = stageConfig?.stageTimeoutMs ?? this.defaultStageTimeoutMs; - if (effectiveTimeout > 0 && stage.startedAt) { + if (stageConfig?.stageTimeoutMs && stage.startedAt) { const elapsed = Date.now() - stage.startedAt; - if (elapsed > effectiveTimeout) { + if (elapsed > stageConfig.stageTimeoutMs) { stage.status = 'failed'; stage.error = 'stage_timeout'; this.transitionPipelineStage(pipeline, 'fix', { stage: stage.name, reason: 'stage_timeout' }); await this.persistPipelines(); // #1424: persist after stage times out - continue; } } - - if (session.status === 'idle') { - stage.status = 'completed'; - stage.completedAt = Date.now(); - this.transitionPipelineStage(pipeline, 'verify', { stageCompleted: stage.name }); - await this.persistPipelines(); // #1424: persist after stage completes - } } // #219: Use stored original config so stage prompt/permissionMode/autoApprove/workDir are preserved @@ -341,11 +337,6 @@ export class PipelineManager { await this.advancePipeline(id, storedConfig); } - // #1424: Persist after advancePipeline may have transitioned pipeline to completed/failed - if (pipeline.status !== 'running') { - await this.persistPipelines(); - } - // #221: Clean up completed/failed pipelines after 30s to avoid memory leak // Note: advancePipeline may change status from 'running' to 'completed'/'failed' // #1092: Track cleanup timer to prevent duplicates and allow destroy() cleanup @@ -417,21 +408,13 @@ export class PipelineManager { } } - /** #1424: Persist running pipelines to disk using atomic-rename. - * When no running pipelines remain, delete the state file so hydrate() - * does not restore stale completed/failed entries on restart. */ + /** #1424: Persist running pipelines to disk using atomic-rename. */ private async persistPipelines(): Promise { if (!this.stateDir) return; // Only persist running pipelines — completed/failed are cleaned up by timers const running = Array.from(this.pipelines.values()).filter(p => p.status === 'running'); - const file = join(this.stateDir, 'pipelines.json'); - - if (running.length === 0) { - // No running pipelines — remove stale state file - try { await unlink(file); } catch { /* already gone or never created */ } - return; - } + if (running.length === 0) return; // Include config alongside pipeline state so we can restore full stage details on hydration type PersistedEntry = PipelineState & { _config?: PipelineConfig }; @@ -442,6 +425,7 @@ export class PipelineManager { return entry; }); + const file = join(this.stateDir, 'pipelines.json'); const tmpFile = `${file}.tmp`; try { await writeFile(tmpFile, JSON.stringify(entries, null, 2)); @@ -453,7 +437,7 @@ export class PipelineManager { await rename(tmpFile, file); } catch { // Rename failed — remove tmp file - try { await unlink(tmpFile); } catch { /* ignore */ } + try { await import('node:fs/promises').then(m => m.unlink(tmpFile)); } catch { /* ignore */ } } } diff --git a/src/server.ts b/src/server.ts index acc94554..3be08054 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,7 +9,6 @@ */ import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify'; -import fastifyRateLimit from '@fastify/rate-limit'; import fs from 'node:fs/promises'; import fastifyStatic from '@fastify/static'; import fastifyWebsocket from '@fastify/websocket'; @@ -42,14 +41,23 @@ import { SSEWriter } from './sse-writer.js'; import { SSEConnectionLimiter } from './sse-limiter.js'; import { PipelineManager } from './pipeline.js'; import { ToolRegistry } from './tool-registry.js'; -import { AuthManager, RateLimiter, classifyBearerTokenForRoute, type ApiKeyRole } from './services/auth/index.js'; -import { AuditLogger, type AuditAction } from './audit.js'; +import { AuthManager, classifyBearerTokenForRoute, type ApiKeyRole } from './auth.js'; import { MetricsCollector } from './metrics.js'; import { promRegistry, METRICS_CONTENT_TYPE } from './prometheus.js'; import { registerPermissionRoutes } from './permission-routes.js'; import { registerHookRoutes } from './hooks.js'; import { registerWsTerminalRoute } from './ws-terminal.js'; import { registerMemoryRoutes } from './memory-routes.js'; +import { registerModelRouterRoutes } from './model-router.js'; +import { + buildConsensusPrompt, + parseReviewOutput, + mergeConsensusFindings, + resolveConsensusRequestStatus, + type ConsensusFocusArea, + type ConsensusRequest, + type ConsensusReview, +} from './consensus.js'; import { readNewEntries } from './transcript.js'; import * as templateStore from './template-store.js'; import { SwarmMonitor } from './swarm-monitor.js'; @@ -63,9 +71,7 @@ import { MemoryBridge } from './memory-bridge.js'; import { cleanupTerminatedSessionState } from './session-cleanup.js'; import { normalizeApiErrorPayload } from './api-error-envelope.js'; import { listenWithRetry, removePidFile, writePidFile } from './startup.js'; -import { AlertManager, type AlertType } from './alerting.js'; import { isWindowsShutdownMessage, parseShutdownTimeoutMs } from './shutdown-utils.js'; -import { ServiceContainer } from './container.js'; import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, @@ -89,6 +95,21 @@ function timingSafeEqual(a: string | undefined, b: string | undefined): boolean const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const consensusRequests = new Map(); + +/** #1091: TTL for consensus request entries (1 hour) */ +const CONSENSUS_REQUEST_TTL_MS = 60 * 60 * 1000; + +/** #1091: Prune consensus requests older than the TTL to prevent unbounded memory growth. */ +function pruneConsensusRequests(): void { + const cutoff = Date.now() - CONSENSUS_REQUEST_TTL_MS; + for (const [id, request] of consensusRequests) { + if (request.createdAt < cutoff) { + consensusRequests.delete(id); + } + } +} + // ── Shared route handler types ──────────────────────────────────────── type IdParams = { Params: { id: string } }; @@ -180,9 +201,7 @@ let sseLimiter: SSEConnectionLimiter;let pipelines: PipelineManager; let toolRegistry: ToolRegistry; let auth: AuthManager; let metrics: MetricsCollector; -let auditLogger: AuditLogger | undefined; let swarmMonitor: SwarmMonitor; -let alertManager: AlertManager; // ── Inbound command handler ───────────────────────────────────────── @@ -220,17 +239,13 @@ async function handleInbound(cmd: InboundCommand): Promise { const app = Fastify({ bodyLimit: 1048576, // 1MB — Issue #349: explicit body size limit trustProxy: process.env.TRUST_PROXY === 'true', // #633: Only trust X-Forwarded-For when explicitly enabled - // Issue #1416: UUID-v4 request IDs for log correlation across components - requestIdHeader: 'x-request-id', - genReqId: () => crypto.randomUUID(), logger: { - // #230: Redact auth tokens and hook secrets from request logs - // #1393: Also redact ?secret= query param used by hook auth fallback + // #230: Redact auth tokens from request logs serializers: { req(req) { - let url = req.url ?? ''; - url = url.replace(/token=[^&]*/g, 'token=[REDACTED]'); - url = url.replace(/secret=[^&]*/g, 'secret=[REDACTED]'); + const url = req.url?.includes('token=') + ? req.url.replace(/token=[^&]*/g, 'token=[REDACTED]') + : req.url; return { method: req.method, url, @@ -241,41 +256,6 @@ const app = Fastify({ }, }); -const RATE_LIMIT_WINDOW = '1 minute'; -const RATE_LIMITS = { - global: { max: 600, timeWindow: RATE_LIMIT_WINDOW }, - health: { max: 240, timeWindow: RATE_LIMIT_WINDOW }, - metrics: { max: 240, timeWindow: RATE_LIMIT_WINDOW }, - adminAction: { max: 60, timeWindow: RATE_LIMIT_WINDOW }, - authVerify: { max: 60, timeWindow: RATE_LIMIT_WINDOW }, - authKeyWrite: { max: 60, timeWindow: RATE_LIMIT_WINDOW }, - audit: { max: 120, timeWindow: RATE_LIMIT_WINDOW }, - sessionCreate: { max: 120, timeWindow: RATE_LIMIT_WINDOW }, - expensiveRead: { max: 120, timeWindow: RATE_LIMIT_WINDOW }, -} as const; - -app.register(fastifyRateLimit, { - global: true, - keyGenerator: (req) => req.ip ?? 'unknown', - ...RATE_LIMITS.global, -}); - -function createRateLimitPreHandler(options: { max: number; timeWindow: string }) { - return async (req: FastifyRequest, reply: FastifyReply) => { - const limiter = app.rateLimit(options); - await limiter.call(app, req, reply); - }; -} - -const healthRateLimit = createRateLimitPreHandler(RATE_LIMITS.health); -const metricsRateLimit = createRateLimitPreHandler(RATE_LIMITS.metrics); -const adminActionRateLimit = createRateLimitPreHandler(RATE_LIMITS.adminAction); -const authVerifyRateLimit = createRateLimitPreHandler(RATE_LIMITS.authVerify); -const authKeyWriteRateLimit = createRateLimitPreHandler(RATE_LIMITS.authKeyWrite); -const auditRateLimit = createRateLimitPreHandler(RATE_LIMITS.audit); -const sessionCreateRateLimit = createRateLimitPreHandler(RATE_LIMITS.sessionCreate); -const expensiveReadRateLimit = createRateLimitPreHandler(RATE_LIMITS.expensiveRead); - // #1108: Decorate request with authKeyId — type-safe alternative to unsafe cast app.decorateRequest('authKeyId', null as unknown as string); @@ -297,8 +277,6 @@ app.addHook('onSend', (req, reply, payload, done) => { if (req.url?.startsWith('/v1/')) { reply.header('X-Aegis-API-Version', '1'); } - // Issue #1416: Return request ID in response header for client-side correlation - reply.header('X-Request-Id', req.id); reply.header('Referrer-Policy', 'strict-origin-when-cross-origin'); reply.header('Permissions-Policy', 'camera=(), microphone=()'); const normalizedPayload = normalizeApiErrorPayload({ @@ -311,26 +289,108 @@ app.addHook('onSend', (req, reply, payload, done) => { }); // Auth middleware setup (Issue #39: multi-key auth with rate limiting) -const rateLimiter = new RateLimiter(); +// #228: Per-IP rate limiting (applies even with master token, with higher limits) +// #622: Circular buffer — O(1) prune via index advancement instead of O(n) shift() +interface IpRateBucket { + entries: number[]; + start: number; +} +const ipRateLimits = new Map(); +const IP_WINDOW_MS = 60_000; +const IP_LIMIT_NORMAL = 120; // per minute for regular keys +const IP_LIMIT_MASTER = 300; // per minute for master token +const MAX_IP_ENTRIES = 10_000; // #844: Cap tracked IPs to prevent memory exhaustion function checkIpRateLimit(ip: string, isMaster: boolean): boolean { - return rateLimiter.checkIpRateLimit(ip, isMaster); + const now = Date.now(); + const cutoff = now - IP_WINDOW_MS; + const bucket = ipRateLimits.get(ip) || { entries: [], start: 0 }; + // O(1) prune: advance start index past expired entries + while (bucket.start < bucket.entries.length && bucket.entries[bucket.start]! < cutoff) { + bucket.start++; + } + // Compact when the leading garbage exceeds 50% of the allocated array + if (bucket.start > bucket.entries.length >>> 1) { + bucket.entries = bucket.entries.slice(bucket.start); + bucket.start = 0; + } + bucket.entries.push(now); + ipRateLimits.set(ip, bucket); + // #844: Evict oldest IPs when map exceeds cap to prevent unbounded memory growth + if (ipRateLimits.size > MAX_IP_ENTRIES) { + let oldestIp = ''; + let oldestTime = Infinity; + for (const [trackedIp, trackedBucket] of ipRateLimits) { + const lastTs = trackedBucket.entries[trackedBucket.entries.length - 1]; + if (lastTs !== undefined && lastTs < oldestTime) { + oldestTime = lastTs; + oldestIp = trackedIp; + } + } + if (oldestIp) ipRateLimits.delete(oldestIp); + } + const activeCount = bucket.entries.length - bucket.start; + const limit = isMaster ? IP_LIMIT_MASTER : IP_LIMIT_NORMAL; + return activeCount > limit; } +// #632: Auth failure rate limiting — 5 failed auth attempts per minute per IP. +interface AuthFailBucket { + timestamps: number[]; +} +const authFailLimits = new Map(); +const AUTH_FAIL_WINDOW_MS = 60_000; +const AUTH_FAIL_MAX = 5; +const MAX_AUTH_FAIL_IP_ENTRIES = 10_000; + function checkAuthFailRateLimit(ip: string): boolean { - return rateLimiter.checkAuthFailRateLimit(ip); + const now = Date.now(); + const cutoff = now - AUTH_FAIL_WINDOW_MS; + const bucket = authFailLimits.get(ip) || { timestamps: [] }; + // Prune expired entries + bucket.timestamps = bucket.timestamps.filter(t => t >= cutoff); + authFailLimits.set(ip, bucket); + if (authFailLimits.size > MAX_AUTH_FAIL_IP_ENTRIES) { + let oldestIp = ''; + let oldestTime = Infinity; + for (const [trackedIp, trackedBucket] of authFailLimits) { + const lastTs = trackedBucket.timestamps[trackedBucket.timestamps.length - 1]; + if (lastTs !== undefined && lastTs < oldestTime) { + oldestTime = lastTs; + oldestIp = trackedIp; + } + } + if (oldestIp) authFailLimits.delete(oldestIp); + } + return bucket.timestamps.length > AUTH_FAIL_MAX; } function recordAuthFailure(ip: string): void { - rateLimiter.recordAuthFailure(ip); + const now = Date.now(); + const bucket = authFailLimits.get(ip) || { timestamps: [] }; + bucket.timestamps.push(now); + authFailLimits.set(ip, bucket); } +/** #632: Prune stale auth-failure buckets. */ function pruneAuthFailLimits(): void { - rateLimiter.pruneAuthFailLimits(); + const cutoff = Date.now() - AUTH_FAIL_WINDOW_MS; + for (const [ip, bucket] of authFailLimits) { + bucket.timestamps = bucket.timestamps.filter(t => t >= cutoff); + if (bucket.timestamps.length === 0) authFailLimits.delete(ip); + } } +/** #357: Prune IPs whose timestamp arrays are entirely outside the rate-limit window. */ function pruneIpRateLimits(): void { - rateLimiter.pruneIpRateLimits(); + const cutoff = Date.now() - IP_WINDOW_MS; + for (const [ip, bucket] of ipRateLimits) { + // All timestamps are old — remove the entry entirely + const last = bucket.entries[bucket.entries.length - 1]; + if (bucket.entries.length - bucket.start === 0 || (last !== undefined && last < cutoff)) { + ipRateLimits.delete(ip); + } + } } /** #583: Track keyId per request for batch rate limiting. */ @@ -367,11 +427,9 @@ function setupAuth(authManager: AuthManager): void { if (hookSessionId) { const session = sessions.getSession(hookSessionId); if (session) { - const queryHookSecret = (req.query as Record)?.secret; - if (config.hookSecretHeaderOnly && queryHookSecret !== undefined) { - return reply.status(401).send({ error: 'Unauthorized — hook secret must be sent via X-Hook-Secret header' }); - } - const hookSecret = (req.headers['x-hook-secret'] as string) || queryHookSecret; + // Issue #629/#1131: Validate hook secret from X-Hook-Secret header (query param fallback) + const hookSecret = (req.headers['x-hook-secret'] as string) + || (req.query as Record)?.secret; if (!hookSecret || !timingSafeEqual(hookSecret, session.hookSecret)) { return reply.status(401).send({ error: 'Unauthorized — invalid hook secret' }); } @@ -385,25 +443,6 @@ function setupAuth(authManager: AuthManager): void { // Exact match: /v1/sessions/{id}/terminal if (/^\/v1\/sessions\/[^/]+\/terminal$/.test(urlPath)) return; - // Issue #1557: /metrics requires authentication. When a dedicated metrics token - // is configured (AEGIS_METRICS_TOKEN), accept either that or the primary auth token. - // This runs before the general no-auth-localhost bypass so that /metrics is always - // protected when a metrics token is set, even in dev mode. - if (urlPath === '/metrics') { - const metricsToken = config.metricsToken; - const bearer = req.headers.authorization?.startsWith('Bearer ') - ? req.headers.authorization.slice(7) - : undefined; - if (metricsToken) { - // Dedicated metrics token configured — require it or the primary token - if (bearer && (timingSafeEqual(bearer, metricsToken) || authManager.validate(bearer).valid)) { - return; // authenticated - } - return reply.status(401).send({ error: 'Unauthorized — valid Bearer token or metrics token required' }); - } - // No dedicated metrics token — fall through to normal auth flow below - } - // #1080: Only bypass auth if no credentials are configured AND server is bound to localhost. // When binding to a non-localhost interface (0.0.0.0, public IP) with no auth configured, // do NOT bypass — let validate() reject the request (it returns valid:false in this case). @@ -454,10 +493,6 @@ function setupAuth(authManager: AuthManager): void { if (!result.valid) { recordAuthFailure(clientIp); - // Issue #1403: Distinguish expired keys from invalid keys - if (result.reason === 'expired') { - return reply.status(401).send({ error: 'Unauthorized — API key has expired', code: 'KEY_EXPIRED' }); - } return reply.status(401).send({ error: 'Unauthorized — invalid API key' }); } @@ -470,11 +505,6 @@ function setupAuth(authManager: AuthManager): void { requestKeyMap.set(req.id, result.keyId ?? 'anonymous'); req.authKeyId = result.keyId; - // #1419: Audit authenticated API calls (fire-and-forget, non-blocking) - if (typeof auditLogger !== 'undefined') { - void auditLogger.log(result.keyId ?? 'anonymous', 'api.authenticated', `${req.method} ${req.url?.split('?')[0] ?? req.url}`); - } - // #228: Per-IP rate limiting (applies to all authenticated requests) // #633: Only use req.ip — trustProxy controls whether X-Forwarded-For is considered const isMaster = result.keyId === 'master'; @@ -494,10 +524,6 @@ app.addHook('onRequest', async (req, reply) => { } }); -// #1393: claudeCommand must not contain shell metacharacters — it is sent to a shell -// via tmux send-keys, so arbitrary metacharacters enable RCE for any authenticated caller. -const SAFE_COMMAND_RE = /^[a-zA-Z0-9_./@:= -]+$/; - // #226: Zod schema for session creation const createSessionSchema = z.object({ workDir: z.string().min(1), @@ -505,7 +531,7 @@ const createSessionSchema = z.object({ prompt: z.string().max(100_000).optional(), prd: z.string().max(100_000).optional(), resumeSessionId: z.string().uuid().optional(), - claudeCommand: z.string().max(500).regex(SAFE_COMMAND_RE).optional(), + claudeCommand: z.string().max(10_000).optional(), env: z.record(z.string(), z.string()).optional(), stallThresholdMs: z.number().int().positive().max(3_600_000).optional(), permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']).optional(), @@ -531,11 +557,11 @@ async function healthHandler(): Promise> { timestamp: new Date().toISOString(), }; } -app.get('/v1/health', { preHandler: healthRateLimit }, healthHandler); -app.get('/health', { preHandler: healthRateLimit }, healthHandler); +app.get('/v1/health', healthHandler); +app.get('/health', healthHandler); // Issue #1412: Prometheus metrics scrape endpoint — text/plain; version=0.0.4 -app.get('/metrics', { preHandler: metricsRateLimit }, async (req, reply) => { +app.get('/metrics', async (req, reply) => { try { const metrics = await promRegistry.metrics(); return reply @@ -547,22 +573,6 @@ app.get('/metrics', { preHandler: metricsRateLimit }, async (req, reply) => { } }); -// Issue #1418: Alert webhook validation and stats -app.post('/v1/alerts/test', { preHandler: adminActionRateLimit }, async (req, reply) => { - if (!requireRole(req, reply, 'admin', 'operator')) return; - try { - const result = await alertManager.fireTestAlert(); - if (!result.sent) { - return reply.status(200).send({ sent: false, message: 'No alert webhooks configured (set AEGIS_ALERT_WEBHOOKS)' }); - } - return reply.status(200).send(result); - } catch (e: unknown) { - return reply.status(502).send({ error: `Alert delivery failed: ${e instanceof Error ? e.message : String(e)}` }); - } -}); - -app.get('/v1/alerts/stats', async () => alertManager.getStats()); - app.post<{ Body: HandshakeRequest }>('/v1/handshake', async (req, reply) => { const parsed = handshakeRequestSchema.safeParse(req.body ?? {}); if (!parsed.success) { @@ -575,7 +585,7 @@ app.post<{ Body: HandshakeRequest }>('/v1/handshake', async (req, reply) => { // Issue #81: Swarm awareness // Issue #81: Swarm awareness — list all detected CC swarms and their teammates -app.get('/v1/swarm', { preHandler: expensiveReadRateLimit }, async () => { +app.get('/v1/swarm', async () => { const result = await swarmMonitor.scan(); return result; }); @@ -586,7 +596,21 @@ const verifyTokenSchema = z.object({ token: z.string().min(1), }).strict(); -app.post('/v1/auth/verify', { preHandler: authVerifyRateLimit }, async (req, reply) => { +const authVerifyRateLimitPreHandler = async (req: FastifyRequest, reply: FastifyReply): Promise => { + const clientIp = req.ip ?? 'unknown'; + if (checkIpRateLimit(clientIp, false)) { + void reply.status(429).send({ valid: false }); + return; + } + if (checkAuthFailRateLimit(clientIp)) { + void reply.status(429).send({ valid: false }); + return; + } +}; + +app.post('/v1/auth/verify', { + preHandler: authVerifyRateLimitPreHandler, +}, async (req, reply) => { const parsed = verifyTokenSchema.safeParse(req.body ?? {}); if (!parsed.success) { return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); @@ -596,12 +620,7 @@ app.post('/v1/auth/verify', { preHandler: authVerifyRateLimit }, async (req, rep return { valid: true, role: 'admin' }; } - // Public bootstrap endpoint: apply failed-auth IP throttling like the main auth hook. const clientIp = req.ip ?? 'unknown'; - if (checkAuthFailRateLimit(clientIp)) { - return reply.status(429).send({ valid: false }); - } - const result = auth.validate(parsed.data.token); if (result.rateLimited) { return reply.status(429).send({ valid: false }); @@ -614,7 +633,7 @@ app.post('/v1/auth/verify', { preHandler: authVerifyRateLimit }, async (req, rep return { valid: true, role: auth.getRole(result.keyId) }; }); -app.post('/v1/auth/keys', { preHandler: authKeyWriteRateLimit }, async (req, reply) => { +app.post('/v1/auth/keys', async (req, reply) => { if (!auth.authEnabled) return reply.status(403).send({ error: 'Auth is not enabled' }); // Issue #1432: Only admin keys can create new API keys if (!requireRole(req, reply, 'admin')) return; @@ -640,21 +659,6 @@ app.delete<{ Params: { id: string } }>('/v1/auth/keys/:id', async (req, reply) = return { ok: true }; }); -// Issue #1403: Rotate an API key — replaces the key hash while preserving metadata -const rotateKeySchema = z.object({ - ttlDays: z.number().int().positive().optional(), -}).strict(); - -app.post<{ Params: { id: string } }>('/v1/auth/keys/:id/rotate', async (req, reply) => { - if (!auth.authEnabled) return reply.status(403).send({ error: 'Auth is not enabled' }); - if (!requireRole(req, reply, 'admin')) return; - const parsed = rotateKeySchema.safeParse(req.body ?? {}); - if (!parsed.success) return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); - const rotated = await auth.rotateKey(req.params.id, parsed.data.ttlDays); - if (!rotated) return reply.status(404).send({ error: 'Key not found' }); - return reply.status(200).send(rotated); -}); - // #297: SSE token endpoint — generates short-lived, single-use token // to avoid exposing long-lived bearer tokens in SSE URL query params. // Issue #634: Reuse keyId from auth middleware result to avoid double-increment. @@ -673,36 +677,6 @@ app.post('/v1/auth/sse-token', async (req, reply) => { } }); -// #1419: Audit log endpoint — admin only -const auditQuerySchema = z.object({ - actor: z.string().optional(), - action: z.string().optional(), - sessionId: z.string().uuid().optional(), - limit: z.coerce.number().int().min(1).max(1000).optional(), - reverse: z.coerce.boolean().optional(), - verify: z.coerce.boolean().optional(), -}); - -app.get('/v1/audit', { preHandler: auditRateLimit }, async (req, reply) => { - if (!requireRole(req, reply, 'admin')) return; - - const parsed = auditQuerySchema.safeParse(req.query ?? {}); - if (!parsed.success) { - return reply.status(400).send({ error: 'Invalid query params', details: parsed.error.issues }); - } - - const { verify: verifyChain, action, ...rest } = parsed.data; - const queryOpts = { ...rest, action: action as AuditAction | undefined }; - - if (verifyChain) { - const result = await auditLogger!.verify(); - return { integrity: result, records: await auditLogger!.query(queryOpts) }; - } - - const records = await auditLogger!.query(queryOpts); - return { count: records.length, records }; -}); - const diagnosticsQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).optional(), }); @@ -727,8 +701,6 @@ app.get('/v1/diagnostics', async (req, reply) => { // Per-session metrics (Issue #40) app.get<{ Params: { id: string } }>('/v1/sessions/:id/metrics', async (req, reply) => { - const session = requireOwnership(req.params.id, reply, req.authKeyId); - if (!session) return; const m = metrics.getSessionMetrics(req.params.id); if (!m) return reply.status(404).send({ error: 'No metrics for this session' }); return m; @@ -738,8 +710,8 @@ app.get<{ Params: { id: string } }>('/v1/sessions/:id/metrics', async (req, repl // Issue #704: Tool usage endpoints app.get('/v1/sessions/:id/tools', async (req, reply) => { const sessionId = (req.params as { id: string }).id; - const session = requireOwnership(sessionId, reply, req.authKeyId); - if (!session) return; + const session = sessions.getSession(sessionId); + if (!session) return reply.status(404).send({ error: 'Session not found' }); // Parse JSONL on-demand for tool usage if (session.jsonlPath) { try { @@ -780,8 +752,8 @@ app.get('/v1/channels/health', async () => { // Issue #87: Per-session latency metrics app.get<{ Params: { id: string } }>('/v1/sessions/:id/latency', async (req, reply) => { const sessionId = (req.params as { id: string }).id; - const session = requireOwnership(sessionId, reply, req.authKeyId); - if (!session) return; + const session = sessions.getSession(sessionId); + if (!session) return reply.status(404).send({ error: 'Session not found' }); const realtimeLatency = sessions.getLatencyMetrics(req.params.id); const aggregatedLatency = metrics.getSessionLatency(req.params.id); @@ -914,12 +886,8 @@ app.get<{ }); // Issue #754: Session statistics endpoint -app.get('/v1/sessions/stats', async (req) => { - let all = sessions.listSessions(); - const callerKeyId = req.authKeyId; - if (callerKeyId !== 'master' && callerKeyId !== null && callerKeyId !== undefined) { - all = all.filter(s => !s.ownerKeyId || s.ownerKeyId === callerKeyId); - } +app.get('/v1/sessions/stats', async () => { + const all = sessions.listSessions(); const byStatus: Partial> = {}; for (const s of all) { byStatus[s.status] = (byStatus[s.status] ?? 0) + 1; @@ -1061,9 +1029,6 @@ async function createSessionHandler(req: FastifyRequest, reply: FastifyReply): P // Issue #625: Track session in metrics so sessionsCreated counter is accurate metrics.sessionCreated(session.id); - // #1419: Audit session creation - if (auditLogger) void auditLogger.log(req.authKeyId ?? 'system', 'session.create', `Session created: ${session.windowName} in ${safeWorkDir}`, session.id); - // Issue #46: Create Telegram topic BEFORE sending prompt. // The monitor starts polling immediately after createSession(). // If we wait for sendInitialPrompt (up to 15s), the monitor may find @@ -1101,8 +1066,8 @@ async function createSessionHandler(req: FastifyRequest, reply: FastifyReply): P return reply.status(201).send({ ...session, promptDelivery }); } -app.post('/v1/sessions', { preHandler: sessionCreateRateLimit }, createSessionHandler); -app.post('/sessions', { preHandler: sessionCreateRateLimit }, createSessionHandler); +app.post('/v1/sessions', createSessionHandler); +app.post('/sessions', createSessionHandler); // Get session (Issue #20: includes actionHints for interactive states) async function getSessionHandler(req: IdRequest, reply: FastifyReply): Promise> { @@ -1168,17 +1133,14 @@ async function sendMessageHandler(req: IdRequest, reply: FastifyReply): Promise< if (!requireOwnership(req.params.id, reply, req.authKeyId)) return; const { text } = parsed.data; try { - const stallInfo = monitor.getStallInfo(req.params.id); - const result = await sessions.sendMessage(req.params.id, text, stallInfo); + const result = await sessions.sendMessage(req.params.id, text); await channels.message({ event: 'message.user', timestamp: new Date().toISOString(), session: { id: req.params.id, name: '', workDir: '' }, detail: text, }); - const response: Record = { ok: true, delivered: result.delivered, attempts: result.attempts }; - if (result.stall) response.stall = result.stall; - return response; + return { ok: true, delivered: result.delivered, attempts: result.attempts }; } catch (e: unknown) { return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) }); } @@ -1255,6 +1217,107 @@ async function forkSessionHandler(req: ForkRequest, reply: FastifyReply): Promis app.post('/v1/sessions/:id/fork', forkSessionHandler); app.post('/sessions/:id/fork', forkSessionHandler); +type ConsensusBody = { + focusAreas?: ConsensusFocusArea[]; + reviewerCount?: number; +}; + +async function createConsensusHandler( + req: FastifyRequest<{ Params: { id: string }; Body: ConsensusBody | undefined }>, + reply: FastifyReply, +): Promise> { + const targetSessionId = req.params.id; + const target = sessions.getSession(targetSessionId); + if (!target) return reply.status(404).send({ error: 'Target session not found' }); + + const focusAreas: ConsensusFocusArea[] = (req.body?.focusAreas && req.body.focusAreas.length > 0) + ? req.body.focusAreas + : ['correctness', 'security', 'performance']; + + const reviewerCount = Math.min(5, Math.max(1, req.body?.reviewerCount ?? focusAreas.length)); + const selectedFocus: ConsensusFocusArea[] = focusAreas.slice(0, reviewerCount); + const reviewerIds: string[] = []; + + for (let i = 0; i < selectedFocus.length; i += 1) { + const focus = selectedFocus[i]; + const child = await sessions.createSession({ + workDir: target.workDir, + name: `consensus-${focus}-${targetSessionId.slice(0, 6)}`, + parentId: targetSessionId, + permissionMode: target.permissionMode, + }); + reviewerIds.push(child.id); + await sessions.sendInitialPrompt(child.id, buildConsensusPrompt(targetSessionId, focus)); + } + + const consensusId = crypto.randomUUID(); + const record: ConsensusRequest = { + id: consensusId, + targetSessionId, + reviewerIds, + focusAreas: selectedFocus, + status: 'running', + findings: [], + createdAt: Date.now(), + }; + consensusRequests.set(consensusId, record); + return reply.status(202).send(record); +} + +async function getConsensusHandler( + req: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, +): Promise { + const item = consensusRequests.get(req.params.id); + if (!item) return reply.status(404).send({ error: 'Consensus request not found' }); + + // #1422: Poll reviewer sessions and transition to completed when all finish. + if (item.status === 'running') { + let allIdle = true; + let anyFailed = false; + const reviews: ConsensusReview[] = []; + + for (let i = 0; i < item.reviewerIds.length; i += 1) { + const reviewerId = item.reviewerIds[i]; + const session = sessions.getSession(reviewerId); + + if (!session || session.status === 'error') { + anyFailed = true; + continue; + } + + if (session.status !== 'idle') { + allIdle = false; + continue; + } + + // Reviewer finished — parse its transcript for findings. + if (session.jsonlPath) { + try { + const { entries } = await readNewEntries(session.jsonlPath, 0); + const findings = parseReviewOutput(entries); + reviews.push({ reviewerId, focusArea: item.focusAreas[i], findings }); + } catch { + // Transcript read failure shouldn't block completion; record empty findings. + reviews.push({ reviewerId, focusArea: item.focusAreas[i], findings: [] }); + } + } else { + reviews.push({ reviewerId, focusArea: item.focusAreas[i], findings: [] }); + } + } + + if (allIdle || anyFailed) { + item.status = resolveConsensusRequestStatus(allIdle, anyFailed); + item.findings = mergeConsensusFindings(reviews); + } + } + + return item; +} + +app.post('/v1/sessions/:id/consensus', createConsensusHandler); +app.get('/v1/consensus/:id', getConsensusHandler); + // Issue #700: Permission policy endpoints type PermissionRequest = FastifyRequest<{ Params: { id: string }; Body: PermissionPolicy | undefined }>; async function getPermissionPolicyHandler(req: IdRequest, reply: FastifyReply): Promise> { @@ -1324,7 +1387,6 @@ registerPermissionRoutes( { recordPermissionResponse: (id: string, latencyMs: number) => metrics.recordPermissionResponse(id, latencyMs), }, - auditLogger ?? null, ); // Issue #336: Answer pending AskUserQuestion @@ -1382,8 +1444,6 @@ async function killSessionHandler(req: IdRequest, reply: FastifyReply): Promise< // reference a session that is still being destroyed. await sessions.killSession(req.params.id); eventBus.emitEnded(req.params.id, 'killed'); - // #1419: Audit session kill - if (auditLogger) void auditLogger.log(req.authKeyId ?? 'system', 'session.kill', `Session killed: ${req.params.id}`, req.params.id); await channels.sessionEnded(makePayload('session.ended', req.params.id, 'killed')); cleanupTerminatedSessionState(req.params.id, { monitor, metrics, toolRegistry }); return { ok: true }; @@ -1517,10 +1577,10 @@ async function screenshotHandler(req: IdRequest, reply: FastifyReply): Promise('/sessions/:id/screenshot', screenshotHandler); // SSE event stream (Issue #32) app.get<{ Params: { id: string } }>('/v1/sessions/:id/events', async (req, reply) => { const sessionId = (req.params as { id: string }).id; - const session = requireOwnership(sessionId, reply, req.authKeyId); - if (!session) return; + const session = sessions.getSession(sessionId); + if (!session) return reply.status(404).send({ error: 'Session not found' }); const clientIp = req.ip; const acquireResult = sseLimiter.acquire(clientIp); @@ -1822,7 +1882,7 @@ const createTemplateSchema = z.object({ sessionId: z.string().uuid().optional(), workDir: z.string().min(1).optional(), prompt: z.string().max(100_000).optional(), - claudeCommand: z.string().max(500).regex(SAFE_COMMAND_RE).optional(), + claudeCommand: z.string().max(10_000).optional(), env: z.record(z.string(), z.string()).optional(), stallThresholdMs: z.number().int().positive().max(3_600_000).optional(), permissionMode: z.enum(['default', 'bypassPermissions', 'plan', 'acceptEdits', 'dontAsk', 'auto']).optional(), @@ -1888,7 +1948,7 @@ app.post<{ Body: CreateTemplateRequest }>('/v1/templates', async (req, reply) => }); // GET /v1/templates — List all templates -app.get('/v1/templates', { preHandler: expensiveReadRateLimit }, async () => { +app.get('/v1/templates', async () => { try { return await templateStore.listTemplates(); } catch (_e: unknown) { @@ -2118,7 +2178,6 @@ async function main(): Promise { // Initialize core components with config tmux = new TmuxManager(config.tmuxSession); sessions = new SessionManager(tmux, config); - const container = new ServiceContainer(); // Memory bridge (Issue #783) if (config.memoryBridge?.enabled) { @@ -2139,73 +2198,7 @@ async function main(): Promise { // Setup auth (Issue #39: multi-key + backward compat) auth = new AuthManager(path.join(config.stateDir, 'keys.json'), config.authToken); auth.setHost(config.host); // #1080: needed for auth bypass security check - - // #1419: Initialize audit logger and wire into auth - auditLogger = new AuditLogger(path.join(config.stateDir, 'audit')); - await auditLogger.init(); - auth.setAuditLogger(auditLogger); - - // Issue #1418: Initialize production alerting - alertManager = new AlertManager(config.alerting); - if (config.alerting.webhooks.length > 0) { - console.log(`Alerting enabled: ${config.alerting.webhooks.length} webhook(s), threshold=${config.alerting.failureThreshold}`); - } - - // Wire monitor dependencies before lifecycle startup. - monitor.setEventBus(eventBus); - monitor.setTmuxManager(tmux); - monitor.setAlertManager(alertManager); - jsonlWatcher = new JsonlWatcher(); - monitor.setJsonlWatcher(jsonlWatcher); - - container.register('tmuxManager', tmux, { - start: async () => { - await tmux.ensureSession(); - }, - stop: async () => {}, - health: async () => { - const tmuxHealth = await tmux.isServerHealthy(); - return { healthy: tmuxHealth.healthy, details: tmuxHealth.error ?? undefined }; - }, - }); - container.register('sessionManager', sessions, { - start: async () => { - await sessions.load(); - }, - stop: async () => { - await sessions.save(); - }, - health: async () => ({ healthy: true, details: `sessions=${sessions.listSessions().length}` }), - }, ['tmuxManager']); - container.register('authManager', auth, { - start: async () => { - await auth.load(); - }, - stop: async () => {}, - health: async () => ({ healthy: true }), - }); - container.register('channelManager', channels, { - start: async () => { - await channels.init(handleInbound); - }, - stop: async () => { - await channels.destroy(); - }, - health: async () => ({ healthy: true, details: `channels=${channels.count}` }), - }, ['sessionManager']); - container.register('sessionMonitor', monitor, { - start: async () => { - monitor.start(); - }, - stop: async () => { - monitor.stop(); - }, - health: async () => ({ - healthy: monitor.isRunning, - details: monitor.isRunning ? 'running' : 'not running', - }), - }, ['sessionManager', 'channelManager', 'tmuxManager']); - + await auth.load(); setupAuth(auth); // Register WebSocket plugin for live terminal streaming (Issue #108) @@ -2221,7 +2214,23 @@ async function main(): Promise { await app.register(fastifyCors, { origin: corsOrigin ? corsOrigin.split(',').map(s => s.trim()) : false, }); - await container.start(['tmuxManager', 'sessionManager', 'authManager', 'channelManager']); + + // Load persisted sessions + await sessions.load(); + await tmux.ensureSession(); + + // Initialize channels + await channels.init(handleInbound); + + // Wire SSE event bus (Issue #32) + monitor.setEventBus(eventBus); + + // Issue #397: Wire TmuxManager for tmux health monitoring + monitor.setTmuxManager(tmux); + + // Issue #84: Wire JSONL watcher for fs.watch-based message detection + jsonlWatcher = new JsonlWatcher(); + monitor.setJsonlWatcher(jsonlWatcher); // Issue #488: Accumulate token usage from JSONL events into per-session metrics. jsonlWatcher.onEntries((event) => { @@ -2242,10 +2251,13 @@ async function main(): Promise { } // Register HTTP hook receiver (Issue #169, Issue #87: pass metrics for latency tracking) - registerHookRoutes(app, { sessions, eventBus, metrics, hookSecretHeaderOnly: config.hookSecretHeaderOnly }); + registerHookRoutes(app, { sessions, eventBus, metrics }); + + // Issue #743: Register model-routing endpoints + registerModelRouterRoutes(app); // Initialize pipeline manager (Issue #36, #1424) - pipelines = new PipelineManager(sessions, eventBus, config.stateDir, config.pipelineStageTimeoutMs); + pipelines = new PipelineManager(sessions, eventBus); await pipelines.hydrate(config.stateDir); // Initialize batch rate limiter (Issue #583) @@ -2264,6 +2276,8 @@ async function main(): Promise { const authFailPruneInterval = setInterval(pruneAuthFailLimits, 60_000); // #398: Sweep stale API key rate limit buckets every 5 minutes const authSweepInterval = setInterval(() => auth.sweepStaleRateLimits(), 5 * 60_000); + // #1091: Prune stale consensus requests every minute + const consensusPruneInterval = setInterval(pruneConsensusRequests, 60_000); let pidFilePath = ''; // Issue #361: Graceful shutdown handler @@ -2293,11 +2307,7 @@ async function main(): Promise { clearInterval(ipPruneInterval); clearInterval(authFailPruneInterval); clearInterval(authSweepInterval); - - // 3. Close file watchers, pipelines, and reaper - try { jsonlWatcher.destroy(); } catch (e) { console.error('Error destroying jsonlWatcher:', e); } - try { await pipelines.destroy(); } catch (e) { console.error('Error destroying pipelines:', e); } - if (memoryBridge) { try { memoryBridge.stopReaper(); } catch (e) { console.error('Error stopping memoryBridge reaper:', e); } } + clearInterval(consensusPruneInterval); // 3. Close file watchers, pipelines, and reaper try { jsonlWatcher.destroy(); } catch (e) { console.error('Error destroying jsonlWatcher:', e); } @@ -2307,16 +2317,11 @@ async function main(): Promise { // Issue #569: Kill all CC sessions and tmux windows before exit try { await killAllSessions(sessions, tmux, { monitor, metrics, toolRegistry }); } catch (e) { console.error('Error killing sessions:', e); } - // 4. Stop managed services in reverse dependency order with timeout safety - const serviceStopTimeoutMs = Math.max(1_000, Math.floor(shutdownTimeoutMs / 5)); - const serviceStops = await container.stopAll({ timeoutMs: serviceStopTimeoutMs }); - for (const stopResult of serviceStops) { - if (stopResult.status === 'timeout') { - console.error(`Service shutdown timed out: ${stopResult.name}`); - } else if (stopResult.status === 'error') { - console.error(`Service shutdown failed: ${stopResult.name}`, stopResult.error); - } - } + // 4. Destroy channels (awaits Telegram poll loop) + try { await channels.destroy(); } catch (e) { console.error('Error destroying channels:', e); } + + // 5. Save session state + try { await sessions.save(); } catch (e) { console.error('Error saving sessions:', e); } // 6. Save metrics try { await metrics.save(); } catch (e) { console.error('Error saving metrics:', e); } @@ -2345,8 +2350,8 @@ async function main(): Promise { console.error('unhandledRejection:', reason); }); - // Start monitor via dependency-aware service lifecycle. - await container.start(['sessionMonitor']); + // Start monitor + monitor.start(); // Issue #81: Start swarm monitor for agent swarm awareness swarmMonitor = new SwarmMonitor(sessions); @@ -2444,18 +2449,13 @@ toolRegistry = new ToolRegistry(); } return reply.status(404).send({ error: "Not found" }); }); - await container.assertHealthy(); await listenWithRetry(app, config.port, config.host, config.stateDir); - pidFilePath = await writePidFile(config.stateDir); + pidFilePath = writePidFile(config.stateDir); console.log(`Aegis running on http://${config.host}:${config.port}`); console.log(`Channels: ${channels.count} registered`); console.log(`State dir: ${config.stateDir}`); console.log(`Claude projects dir: ${config.claudeProjectsDir}`); - if (auth.authEnabled) { - console.log('Auth: enabled'); - } else { - console.warn('WARNING: No authentication configured — set AEGIS_AUTH_TOKEN to secure the server'); - } + if (config.authToken) console.log('Auth: Bearer token required'); } main().catch(err => { diff --git a/src/services/auth/AuthManager.ts b/src/services/auth/AuthManager.ts deleted file mode 100644 index 0740359f..00000000 --- a/src/services/auth/AuthManager.ts +++ /dev/null @@ -1,421 +0,0 @@ -/** - * AuthManager.ts — API key management and authentication middleware. - * - * Issue #39: Multi-key auth with rate limiting. - * Keys are hashed with SHA-256 (no bcrypt dependency needed). - * Backward compatible with single authToken from config. - */ - -import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { authStoreSchema } from '../../validation.js'; -import { existsSync } from 'node:fs'; -import { dirname } from 'node:path'; -import { secureFilePermissions } from '../../file-utils.js'; -import type { AuditLogger } from '../../audit.js'; -import type { ApiKey, ApiKeyRole, ApiKeyStore, AuthRejectReason } from './types.js'; - -/** Rate limit state per key ID. */ -interface RateLimitBucket { - count: number; - windowStart: number; -} - -/** Short-lived SSE token for Issue #297. */ -interface SSETokenEntry { - token: string; - expiresAt: number; - used: boolean; - keyId: string; -} - -/** Default SSE token lifetime: 60 seconds. */ -const SSE_TOKEN_TTL_MS = 60_000; - -/** Max SSE tokens per bearer token to prevent abuse. */ -const SSE_TOKEN_MAX_PER_KEY = 5; - -/** #583: Minimum interval between batch creation requests per key (5 seconds). */ -const BATCH_COOLDOWN_MS = 5_000; - -/** Route-level auth policy for bearer tokens. */ -export function classifyBearerTokenForRoute( - token: string, - isSSERoute: boolean, -): 'bearer' | 'sse' | 'reject' { - if (!isSSERoute) return 'bearer'; - return token.startsWith('sse_') ? 'sse' : 'reject'; -} - -export class AuthManager { - private store: ApiKeyStore = { keys: [] }; - private rateLimits = new Map(); - private masterToken: string; - /** #297: Short-lived SSE tokens. Keyed by token string for O(1) lookup. */ - private sseTokens = new Map(); - /** Track how many SSE tokens each bearer key has outstanding. */ - private sseTokenCounts = new Map(); - /** #414: Mutex to prevent concurrent SSE token generation from exceeding per-key limits. */ - private sseMutex: Promise = Promise.resolve(); - /** #583: Last batch creation timestamp per key ID. */ - private batchRateLimits = new Map(); - /** #1080: HTTP server host binding (set after construction via setHost()). */ - private host: string = '127.0.0.1'; - /** #1419: Audit logger — optional, injected via setAuditLogger(). */ - private audit: AuditLogger | null = null; - - - constructor( - private keysFile: string, - masterToken: string = '', - ) { - this.masterToken = masterToken; - } - - /** #1080: Set the HTTP server host binding after construction (config.host is not available at construction time). */ - setHost(host: string): void { - this.host = host; - } - - /** #1419: Inject audit logger for key lifecycle events. */ - setAuditLogger(audit: AuditLogger): void { - this.audit = audit; - } - - /** #1080: Expose host binding for server.ts setupAuth() check. */ - get hostBinding(): string { - return this.host; - } - - /** #1080: Returns true when Aegis is bound to a localhost interface (127.0.0.1 or ::1). */ - get isLocalhostBinding(): boolean { - return this.host === '127.0.0.1' || this.host === '::1' || this.host === 'localhost'; - } - - /** Load keys from disk. */ - async load(): Promise { - if (existsSync(this.keysFile)) { - try { - const raw = await readFile(this.keysFile, 'utf-8'); - const parsed = authStoreSchema.safeParse(JSON.parse(raw)); - if (parsed.success) { - this.store = parsed.data; - } - } catch { /* corrupted or unreadable keys file — start fresh */ - this.store = { keys: [] }; - } - } - } - - /** Save keys to disk. */ - async save(): Promise { - const dir = dirname(this.keysFile); - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }); - } - await writeFile(this.keysFile, JSON.stringify(this.store, null, 2), { mode: 0o600 }); - await secureFilePermissions(this.keysFile); - } - - /** Create a new API key. Returns the plaintext key (only shown once). */ - async createKey( - name: string, - rateLimit = 100, - ttlDays?: number, - role: ApiKeyRole = 'viewer', - ): Promise<{ id: string; key: string; name: string; expiresAt: number | null; role: ApiKeyRole }> { - const id = randomBytes(8).toString('hex'); - const key = `aegis_${randomBytes(32).toString('hex')}`; - const hash = AuthManager.hashKey(key); - const expiresAt = ttlDays ? Date.now() + ttlDays * 86_400_000 : null; - - const apiKey: ApiKey = { - id, - name, - hash, - createdAt: Date.now(), - lastUsedAt: 0, - rateLimit, - expiresAt, - role, - }; - - this.store.keys.push(apiKey); - await this.save(); - - // #1419: Audit key creation - if (this.audit) { - void this.audit.log('system', 'key.create', `Key created: ${name} (${id}) role=${role}`, undefined); - } - - return { id, key, name, expiresAt, role }; - } - - /** List keys (without hashes). */ - listKeys(): Array> { - return this.store.keys.map(({ hash: _, ...rest }) => rest); - } - - /** Revoke a key by ID. */ - async revokeKey(id: string): Promise { - const idx = this.store.keys.findIndex(k => k.id === id); - if (idx === -1) return false; - const revoked = this.store.keys[idx]!; - this.store.keys.splice(idx, 1); - this.rateLimits.delete(id); - await this.save(); - - // #1419: Audit key revocation - if (this.audit) { - void this.audit.log('system', 'key.revoke', `Key revoked: ${revoked.name} (${id})`, undefined); - } - - return true; - } - - /** - * Rotate a key by ID (Issue #1403). - * Generates a new plaintext key, replaces the old hash, and preserves - * name, role, rateLimit, and ttlDays. Returns the new key or null if not found. - */ - async rotateKey( - id: string, - ttlDays?: number, - ): Promise<{ id: string; key: string; name: string; expiresAt: number | null; role: ApiKeyRole } | null> { - const existing = this.store.keys.find(k => k.id === id); - if (!existing) return null; - - const newKey = `aegis_${randomBytes(32).toString('hex')}`; - const newHash = AuthManager.hashKey(newKey); - const expiresAt = ttlDays ? Date.now() + ttlDays * 86_400_000 : existing.expiresAt; - - existing.hash = newHash; - existing.expiresAt = expiresAt; - existing.createdAt = Date.now(); - existing.lastUsedAt = 0; - this.rateLimits.delete(id); - - await this.save(); - - return { id: existing.id, key: newKey, name: existing.name, expiresAt, role: existing.role }; - } - - /** - * Validate a bearer token. - * Returns { valid, keyId, rateLimited, reason }. - * When valid=false, reason indicates why (Issue #1403). - */ - validate(token: string): { valid: boolean; keyId: string | null; rateLimited: boolean; reason?: AuthRejectReason } { - // No auth configured and no keys → allow all - if (!this.masterToken && this.store.keys.length === 0) { - // #1080: SECURITY FIX — when binding to a non-localhost interface without auth, - // reject all requests. Running Aegis on 0.0.0.0 with no auth is a critical vuln. - if (!this.isLocalhostBinding) { - return { valid: false, keyId: null, rateLimited: false, reason: 'no_auth' }; - } - return { valid: true, keyId: null, rateLimited: false }; - } - - // Check master token (backward compat) — timing-safe comparison (#402) - if (this.masterToken && AuthManager.timingSafeStringEqual(token, this.masterToken)) { - return { valid: true, keyId: 'master', rateLimited: false }; - } - - // Check API keys - const hash = AuthManager.hashKey(token); - const key = this.store.keys.find(k => k.hash === hash); - if (!key) { - return { valid: false, keyId: null, rateLimited: false, reason: 'invalid' }; - } - - // #1436/#1403: Reject expired keys with specific reason - if (key.expiresAt !== null && Date.now() > key.expiresAt) { - return { valid: false, keyId: null, rateLimited: false, reason: 'expired' }; - } - - // Rate limiting - const bucket = this.rateLimits.get(key.id) || { count: 0, windowStart: Date.now() }; - const now = Date.now(); - const windowMs = 60_000; // 1 minute - - if (now - bucket.windowStart > windowMs) { - // New window - bucket.count = 1; - bucket.windowStart = now; - } else { - bucket.count++; - } - - this.rateLimits.set(key.id, bucket); - - if (bucket.count > key.rateLimit) { - return { valid: true, keyId: key.id, rateLimited: true }; - } - - // Issue #841: Only update lastUsedAt for accepted requests, not rate-limited ones - key.lastUsedAt = Date.now(); - - return { valid: true, keyId: key.id, rateLimited: false }; - } - - /** Issue #1432: Get the RBAC role for a key ID. Master token = admin. Unknown/null = viewer (default). */ - getRole(keyId: string | null | undefined): ApiKeyRole { - if (keyId === 'master') return 'admin'; - const key = keyId ? this.store.keys.find(k => k.id === keyId) : undefined; - return key?.role ?? 'viewer'; - } - - /** Hash a key with SHA-256. */ - static hashKey(key: string): string { - return createHash('sha256').update(key).digest('hex'); - } - - /** Constant-time equality check for secret strings. */ - private static timingSafeStringEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false; - return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')); - } - - /** #583: Check and update batch rate limit for a key. Returns true if rate-limited. */ - checkBatchRateLimit(keyId: string | null): boolean { - const id = keyId ?? 'anonymous'; - const now = Date.now(); - const lastBatch = this.batchRateLimits.get(id); - if (lastBatch !== undefined && now - lastBatch < BATCH_COOLDOWN_MS) { - return true; - } - this.batchRateLimits.set(id, now); - return false; - } - - /** #398: Sweep stale rate limit buckets. Prune entries with expired windows. */ - sweepStaleRateLimits(): void { - const now = Date.now(); - const windowMs = 60_000; // 1 minute - for (const [keyId, bucket] of this.rateLimits) { - if (now - bucket.windowStart > windowMs) { - this.rateLimits.delete(keyId); - } - } - // #583: Prune expired batch rate limit entries - for (const [keyId, ts] of this.batchRateLimits) { - if (now - ts > BATCH_COOLDOWN_MS) { - this.batchRateLimits.delete(keyId); - } - } - } - - /** Check if auth is enabled (master token or any keys). */ - get authEnabled(): boolean { - return !!this.masterToken || this.store.keys.length > 0; - } - - // ── SSE Token Management (Issue #297) ──────────────────────── - - /** - * Generate a short-lived, single-use SSE token. - * The caller must already be authenticated (validated via bearer token). - * Returns the token string and its expiry timestamp. - * #414: Async with mutex to prevent concurrent calls from exceeding per-key limits. - */ - async generateSSEToken(keyId: string): Promise<{ token: string; expiresAt: number }> { - // Acquire mutex — chain onto the previous operation - let release: () => void = () => {}; - const lock = new Promise((resolve) => { release = resolve; }); - const previous = this.sseMutex; - this.sseMutex = lock; - - // #509: await + try/finally together so release() fires even if previous rejects - // #573: catch prior rejection so it doesn't propagate and block subsequent callers - try { - await previous.catch(() => {}); - - // Cleanup expired tokens first - this.cleanExpiredSSETokens(); - - // Enforce per-key limit - const current = this.sseTokenCounts.get(keyId) ?? 0; - if (current >= SSE_TOKEN_MAX_PER_KEY) { - throw new Error(`SSE token limit reached (${SSE_TOKEN_MAX_PER_KEY} outstanding)`); - } - - const token = `sse_${randomBytes(32).toString('hex')}`; - const expiresAt = Date.now() + SSE_TOKEN_TTL_MS; - - this.sseTokens.set(token, { token, expiresAt, used: false, keyId }); - this.sseTokenCounts.set(keyId, current + 1); - - return { token, expiresAt }; - } finally { - release(); - } - } - - /** - * Validate and consume a short-lived SSE token. - * Returns true if valid (and marks it as used), false otherwise. - * #826: Async with mutex to prevent concurrent validation/generation from - * racing on shared state (sseTokens, sseTokenCounts). - */ - async validateSSEToken(token: string): Promise { - // Acquire mutex — chain onto the previous operation - let release: () => void = () => {}; - const lock = new Promise((resolve) => { release = resolve; }); - const previous = this.sseMutex; - this.sseMutex = lock; - - // #573: catch prior rejection so it doesn't propagate and block subsequent callers - try { - await previous.catch(() => {}); - - const entry = this.sseTokens.get(token); - if (!entry) return false; - - // Already used - if (entry.used) { - this.sseTokens.delete(token); - return false; - } - - // Expired - if (Date.now() > entry.expiresAt) { - this.sseTokens.delete(token); - return false; - } - - // Valid — consume it - entry.used = true; - const keyId = entry.keyId; - this.sseTokens.delete(token); - // #357: Decrement outstanding count so generateSSEToken doesn't over-limit - const count = this.sseTokenCounts.get(keyId); - if (count !== undefined) { - if (count <= 1) { - this.sseTokenCounts.delete(keyId); - } else { - this.sseTokenCounts.set(keyId, count - 1); - } - } - return true; - } finally { - release(); - } - } - - /** Remove expired SSE tokens and recount per-key outstanding. */ - private cleanExpiredSSETokens(): void { - const now = Date.now(); - // Remove expired - for (const [key, entry] of this.sseTokens) { - if (now > entry.expiresAt) { - this.sseTokens.delete(key); - } - } - // Rebuild counts from surviving tokens - this.sseTokenCounts.clear(); - for (const entry of this.sseTokens.values()) { - const count = this.sseTokenCounts.get(entry.keyId) ?? 0; - this.sseTokenCounts.set(entry.keyId, count + 1); - } - } -} diff --git a/src/services/auth/RateLimiter.ts b/src/services/auth/RateLimiter.ts deleted file mode 100644 index 6112705e..00000000 --- a/src/services/auth/RateLimiter.ts +++ /dev/null @@ -1,108 +0,0 @@ -interface IpRateBucket { - entries: number[]; - start: number; -} - -interface AuthFailBucket { - timestamps: number[]; -} - -const IP_WINDOW_MS = 60_000; -const IP_LIMIT_NORMAL = 120; -const IP_LIMIT_MASTER = 300; -const MAX_IP_ENTRIES = 10_000; - -const AUTH_FAIL_WINDOW_MS = 60_000; -const AUTH_FAIL_MAX = 5; -const MAX_AUTH_FAIL_IP_ENTRIES = 10_000; - -/** - * Route-level auth/IP rate limiter extracted from server.ts. - * Keeps server wiring simple while preserving existing behavior. - */ -export class RateLimiter { - private ipRateLimits = new Map(); - private authFailLimits = new Map(); - - checkIpRateLimit(ip: string, isMaster: boolean): boolean { - const now = Date.now(); - const cutoff = now - IP_WINDOW_MS; - const bucket = this.ipRateLimits.get(ip) || { entries: [], start: 0 }; - - while (bucket.start < bucket.entries.length && bucket.entries[bucket.start]! < cutoff) { - bucket.start++; - } - - if (bucket.start > bucket.entries.length >>> 1) { - bucket.entries = bucket.entries.slice(bucket.start); - bucket.start = 0; - } - - bucket.entries.push(now); - this.ipRateLimits.set(ip, bucket); - - if (this.ipRateLimits.size > MAX_IP_ENTRIES) { - let oldestIp = ''; - let oldestTime = Infinity; - for (const [trackedIp, trackedBucket] of this.ipRateLimits) { - const lastTs = trackedBucket.entries[trackedBucket.entries.length - 1]; - if (lastTs !== undefined && lastTs < oldestTime) { - oldestTime = lastTs; - oldestIp = trackedIp; - } - } - if (oldestIp) this.ipRateLimits.delete(oldestIp); - } - - const activeCount = bucket.entries.length - bucket.start; - const limit = isMaster ? IP_LIMIT_MASTER : IP_LIMIT_NORMAL; - return activeCount > limit; - } - - checkAuthFailRateLimit(ip: string): boolean { - const now = Date.now(); - const cutoff = now - AUTH_FAIL_WINDOW_MS; - const bucket = this.authFailLimits.get(ip) || { timestamps: [] }; - - bucket.timestamps = bucket.timestamps.filter((t) => t >= cutoff); - bucket.timestamps.push(now); - this.authFailLimits.set(ip, bucket); - - if (this.authFailLimits.size > MAX_AUTH_FAIL_IP_ENTRIES) { - let oldestIp = ''; - let oldestTime = Infinity; - for (const [trackedIp, trackedBucket] of this.authFailLimits) { - const lastTs = trackedBucket.timestamps[trackedBucket.timestamps.length - 1]; - if (lastTs !== undefined && lastTs < oldestTime) { - oldestTime = lastTs; - oldestIp = trackedIp; - } - } - if (oldestIp) this.authFailLimits.delete(oldestIp); - } - - return bucket.timestamps.length > AUTH_FAIL_MAX; - } - - recordAuthFailure(ip: string): void { - this.checkAuthFailRateLimit(ip); - } - - pruneAuthFailLimits(): void { - const cutoff = Date.now() - AUTH_FAIL_WINDOW_MS; - for (const [ip, bucket] of this.authFailLimits) { - bucket.timestamps = bucket.timestamps.filter((t) => t >= cutoff); - if (bucket.timestamps.length === 0) this.authFailLimits.delete(ip); - } - } - - pruneIpRateLimits(): void { - const cutoff = Date.now() - IP_WINDOW_MS; - for (const [ip, bucket] of this.ipRateLimits) { - const last = bucket.entries[bucket.entries.length - 1]; - if (bucket.entries.length - bucket.start === 0 || (last !== undefined && last < cutoff)) { - this.ipRateLimits.delete(ip); - } - } - } -} diff --git a/src/services/auth/index.ts b/src/services/auth/index.ts deleted file mode 100644 index 7a7659d9..00000000 --- a/src/services/auth/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { AuthManager, classifyBearerTokenForRoute } from './AuthManager.js'; -export { RateLimiter } from './RateLimiter.js'; -export type { ApiKey, ApiKeyRole, ApiKeyStore, AuthRejectReason } from './types.js'; diff --git a/src/services/auth/types.ts b/src/services/auth/types.ts deleted file mode 100644 index 1fe7a328..00000000 --- a/src/services/auth/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type ApiKeyRole = 'admin' | 'operator' | 'viewer'; - -export interface ApiKey { - id: string; - name: string; - hash: string; - createdAt: number; - lastUsedAt: number; - rateLimit: number; - expiresAt: number | null; - role: ApiKeyRole; -} - -export interface ApiKeyStore { - keys: ApiKey[]; -} - -/** Rejection reason when validate() returns valid=false. */ -export type AuthRejectReason = 'expired' | 'invalid' | 'no_auth'; diff --git a/src/services/permission/index.ts b/src/services/permission/index.ts deleted file mode 100644 index 1b9dd5e6..00000000 --- a/src/services/permission/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { evaluatePermissionProfile } from './evaluator.js'; -export type { - PermissionEvaluationInput, - PermissionEvaluationResult, - PermissionProfile, -} from './types.js'; diff --git a/src/services/permission/types.ts b/src/services/permission/types.ts deleted file mode 100644 index 9798bc13..00000000 --- a/src/services/permission/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type { PermissionProfile } from '../../validation.js'; - -export interface PermissionEvaluationInput { - toolName: string; - toolInput?: Record; -} - -export interface PermissionEvaluationResult { - behavior: 'allow' | 'deny' | 'ask'; - reason: string; -} diff --git a/src/session.ts b/src/session.ts index 1ab983c8..3700b837 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1085,13 +1085,8 @@ export class SessionManager { /** Send a message to a session with delivery verification. * Issue #1: Uses capture-pane to verify the prompt was delivered. * Returns delivery status for API response. - * Issue #1325: Optionally includes stall feedback when monitor is provided. */ - async sendMessage( - id: string, - text: string, - stallInfo?: { stalled: true; types: string[] } | { stalled: false }, - ): Promise<{ delivered: boolean; attempts: number; stall?: { stalled: true; types: string[] } | { stalled: false } }> { + async sendMessage(id: string, text: string): Promise<{ delivered: boolean; attempts: number }> { const session = this.state.sessions[id]; if (!session) throw new Error(`Session ${id} not found`); @@ -1104,7 +1099,7 @@ export class SessionManager { // Message was delivered — don't let a save failure mask the success } } - return stallInfo ? { ...result, stall: stallInfo } : result; + return result; } /** Send message bypassing the tmux serialize queue. diff --git a/src/startup.ts b/src/startup.ts index da92b1f5..85774c3e 100644 --- a/src/startup.ts +++ b/src/startup.ts @@ -2,14 +2,12 @@ import Fastify from 'fastify'; import fs from 'node:fs/promises'; import { writeFileSync, unlinkSync } from 'node:fs'; import path from 'node:path'; -import { secureFilePermissions } from './file-utils.js'; import { findPidOnPort, readParentPid } from './process-utils.js'; -export async function writePidFile(stateDir: string): Promise { +export function writePidFile(stateDir: string): string { try { const pidFilePath = path.join(stateDir, 'aegis.pid'); - writeFileSync(pidFilePath, String(process.pid), { mode: 0o600 }); - await secureFilePermissions(pidFilePath); + writeFileSync(pidFilePath, String(process.pid)); return pidFilePath; } catch { return ''; diff --git a/src/tmux.ts b/src/tmux.ts index c6abdc92..24317224 100644 --- a/src/tmux.ts +++ b/src/tmux.ts @@ -93,15 +93,14 @@ export class TmuxManager { /** Run `fn` sequentially after all previously-queued operations complete. */ private serialize(fn: () => Promise): Promise { - const run = this.queue.then( - () => fn(), - () => fn(), - ); - this.queue = run.then( - () => undefined, - () => undefined, - ); - return run; + let resolve!: () => void; + const next = new Promise(r => { resolve = r; }); + const prev = this.queue; + this.queue = next; + return prev.then(async () => { + try { return await fn(); } + finally { resolve(); } + }); } /** Run a tmux command and return stdout (serialized through the queue). diff --git a/src/tracing.ts b/src/tracing.ts deleted file mode 100644 index 8936ca48..00000000 --- a/src/tracing.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** - * tracing.ts — OpenTelemetry distributed tracing for Aegis. - * - * Provides request-scoped tracing across the Fastify → session → tmux → monitor flow. - * Configured via AEGIS_OTEL_* environment variables (all optional). - * - * When AEGIS_OTEL_ENABLED is not set (or "false"), tracing is a no-op — - * the tracer returns no-op spans with zero overhead. - * - * Issue #1417: Research spike — OpenTelemetry tracing. - */ - -import os from 'node:os'; -import type { Tracer, Span, SpanOptions, Context } from '@opentelemetry/api'; -import { trace, context, SpanStatusCode, SpanKind } from '@opentelemetry/api'; - -// ── No-op fallback when tracing is disabled ──────────────────────────── - -/** No-op tracer that returns no-op spans. Zero overhead when tracing is off. */ -class NoopTracerImpl implements Tracer { - readonly instrumentationScope = { name: 'aegis', version: '0.0.0' }; - - startSpan(_name: string, _options?: SpanOptions, _context?: Context): Span { - // Return a non-recording span by wrapping an invalid span context. - // trace.wrapSpanContext returns a NonRecordingSpan when the context is invalid. - const INVALID_CONTEXT = { - traceId: '00000000000000000000000000000000', - spanId: '0000000000000000', - traceFlags: 0, - }; - return trace.wrapSpanContext(INVALID_CONTEXT) as unknown as Span; - } - - startActiveSpan unknown>(name: string, options: SpanOptions | undefined, context: Context | undefined, fn: F): ReturnType; - startActiveSpan unknown>(name: string, options: SpanOptions | undefined, fn: F): ReturnType; - startActiveSpan unknown>(name: string, fn: F): ReturnType; - startActiveSpan(name: string, arg2?: unknown, arg3?: unknown, arg4?: unknown): unknown { - const span = this.startSpan(name); - const fn = typeof arg2 === 'function' ? arg2 : typeof arg3 === 'function' ? arg3 : arg4 as (...args: unknown[]) => unknown; - if (!fn) return span; - try { - return fn(span); - } finally { - span.end(); - } - } -} - -// ── Configuration ────────────────────────────────────────────────────── - -export interface TracingConfig { - /** Enable tracing (default: false) */ - enabled: boolean; - /** Service name sent to the tracing backend (default: "aegis") */ - serviceName: string; - /** OTLP endpoint URL (default: "http://localhost:4318") */ - otlpEndpoint: string; - /** Sample rate 0.0–1.0 (default: 1.0 = always sample) */ - sampleRate: number; -} - -/** Load tracing config from AEGIS_OTEL_* environment variables. */ -export function loadTracingConfig(): TracingConfig { - return { - enabled: process.env.AEGIS_OTEL_ENABLED === 'true', - serviceName: process.env.AEGIS_OTEL_SERVICE_NAME || 'aegis', - otlpEndpoint: process.env.AEGIS_OTEL_OTLP_ENDPOINT || 'http://localhost:4318', - sampleRate: parseFloat(process.env.AEGIS_OTEL_SAMPLE_RATE || '1.0'), - }; -} - -// ── Initialization ───────────────────────────────────────────────────── - -let _tracer: Tracer = new NoopTracerImpl(); -let _sdk: { shutdown: () => Promise } | null = null; -let _initialized = false; - -/** - * Initialize the OpenTelemetry SDK. - * - * This must be called before the Fastify server starts (and before any - * `@opentelemetry/auto-instrumentations-node` packages are loaded) so - * that auto-instrumentation can patch Fastify, http, etc. - * - * Returns the global Tracer instance for creating manual spans. - * - * When `config.enabled` is false, returns a no-op tracer with zero overhead. - */ -export async function initTracing(config: TracingConfig): Promise { - if (_initialized) return _tracer; - - if (!config.enabled) { - console.log('Tracing: disabled (set AEGIS_OTEL_ENABLED=true to enable)'); - _initialized = true; - return _tracer; - } - - try { - // Dynamic imports — only loads OTel SDK when tracing is enabled. - // This avoids startup cost when tracing is off. - const { NodeSDK } = await import('@opentelemetry/sdk-node'); - const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http'); - const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-base'); - const { AlwaysOnSampler, TraceIdRatioBasedSampler, ParentBasedSampler } = await import('@opentelemetry/sdk-trace-base'); - const { getNodeAutoInstrumentations } = await import('@opentelemetry/auto-instrumentations-node'); - - // Read service version from package.json - let serviceVersion = '0.0.0'; - try { - const pkg = await import('../package.json', { with: { type: 'json' } }); - serviceVersion = (pkg.default as { version?: string }).version ?? '0.0.0'; - } catch { - // package.json not available - } - - // Build the resource with service identity attributes - const resourcesModule = await import('@opentelemetry/resources'); - const semconvModule = await import('@opentelemetry/semantic-conventions'); - const resource = resourcesModule.resourceFromAttributes({ - [semconvModule.ATTR_SERVICE_NAME]: config.serviceName, - [semconvModule.ATTR_SERVICE_VERSION]: serviceVersion, - 'aegis.pid': String(process.pid), - 'aegis.node': os.hostname(), - }); - - // Sampler: AlwaysOn when sampleRate=1.0, ratio-based otherwise - const sampler = config.sampleRate >= 1.0 - ? new AlwaysOnSampler() - : new ParentBasedSampler({ - root: new TraceIdRatioBasedSampler(config.sampleRate), - }); - - // OTLP exporter (HTTP/protobuf — broadly compatible with Jaeger, Tempo, etc.) - const exporter = new OTLPTraceExporter({ - url: `${config.otlpEndpoint}/v1/traces`, - }); - - const sdk = new NodeSDK({ - resource, - sampler, - spanProcessors: [ - new BatchSpanProcessor(exporter, { - maxQueueSize: 2048, - maxExportBatchSize: 512, - scheduledDelayMillis: 5000, - }), - ], - // Auto-instrument Fastify, http, and dns lookups - instrumentations: [ - getNodeAutoInstrumentations({ - '@opentelemetry/instrumentation-fs': { enabled: false }, - '@opentelemetry/instrumentation-dns': { enabled: false }, - }), - ], - }); - - sdk.start(); - _sdk = sdk; - - // Obtain a tracer for manual spans - const api = await import('@opentelemetry/api'); - _tracer = api.trace.getTracer('aegis', serviceVersion); - - _initialized = true; - console.log(`Tracing: enabled (OTLP → ${config.otlpEndpoint}, sampler=${config.sampleRate})`); - - return _tracer; - } catch (e) { - console.error('Tracing: failed to initialize — falling back to no-op:', e); - _tracer = new NoopTracerImpl(); - _initialized = true; - return _tracer; - } -} - -/** - * Shut down the tracing SDK gracefully. - * Call this during graceful shutdown to flush pending spans. - */ -export async function shutdownTracing(): Promise { - if (!_sdk) return; - try { - await _sdk.shutdown(); - } catch { - // no-op — best effort flush - } -} - -/** Get the current tracer (may be a no-op if tracing is disabled). */ -export function getTracer(): Tracer { - return _tracer; -} - -// ── Manual span helpers ──────────────────────────────────────────────── - -/** - * Create a child span for a session operation. - * - * Usage: - * ```ts - * const span = startSessionSpan('create', sessionId, { workDir }); - * try { ... } finally { span.end(); } - * ``` - */ -export function startSessionSpan( - operation: string, - sessionId: string, - attributes?: Record, -): Span { - return _tracer.startSpan(`session.${operation}`, { - kind: SpanKind.INTERNAL, - attributes: { - 'aegis.session.id': sessionId, - ...attributes, - }, - }); -} - -/** - * Create a child span for a tmux operation. - */ -export function startTmuxSpan( - operation: string, - windowId: string, - attributes?: Record, -): Span { - return _tracer.startSpan(`tmux.${operation}`, { - kind: SpanKind.INTERNAL, - attributes: { - 'aegis.tmux.window_id': windowId, - ...attributes, - }, - }); -} - -/** - * Create a child span for a monitor operation. - */ -export function startMonitorSpan( - operation: string, - attributes?: Record, -): Span { - return _tracer.startSpan(`monitor.${operation}`, { - kind: SpanKind.INTERNAL, - attributes, - }); -} - -/** - * Record an error on a span and set its status to ERROR. - */ -export function spanError(span: Span, error: unknown): void { - const message = error instanceof Error ? error.message : String(error); - span.recordException(error instanceof Error ? error : new Error(message)); - span.setStatus({ code: SpanStatusCode.ERROR, message }); -} - -/** - * Record a success status on a span. - */ -export function spanOk(span: Span, description?: string): void { - span.setStatus({ - code: SpanStatusCode.OK, - ...(description && { message: description }), - }); -} - -/** - * Check if tracing is enabled. - */ -export function isTracingEnabled(): boolean { - return _initialized && !(_tracer instanceof NoopTracerImpl); -} - -// Re-export for type usage -export { trace, context, SpanStatusCode, SpanKind }; diff --git a/src/validation.ts b/src/validation.ts index fcd7357f..588e6fa0 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -130,7 +130,7 @@ const pipelineStageSchema = z.object({ export const pipelineSchema = z.object({ name: z.string().min(1), workDir: z.string().min(1), - stages: z.array(pipelineStageSchema).min(1).max(50), + stages: z.array(pipelineStageSchema).min(1), }).strict(); /** POST /v1/handshake */ @@ -146,74 +146,11 @@ export function clamp(value: number, min: number, max: number, fallback: number) return Math.max(min, Math.min(max, value)); } -export interface ParseIntSafeOptions { - /** Optional label for warning output (for example, env var name). */ - context?: string; - /** Minimum accepted integer value (inclusive). */ - min?: number; - /** Maximum accepted integer value (inclusive). */ - max?: number; - /** Require a strict integer string (no trailing text). */ - strict?: boolean; - /** Optional warning callback when parsing or bounds validation fails. */ - onError?: (message: string) => void; -} - -function reportParseIntSafeError( - options: ParseIntSafeOptions | undefined, - rawValue: string, - fallback: number, - reason: string, -): void { - if (!options?.onError) return; - const context = options.context ?? 'value'; - options.onError(`Invalid ${context}='${rawValue}' (${reason}); using ${fallback}`); -} - -/** Parse an env string to integer with optional strict parsing and bounds checks. */ -export function parseIntSafe( - value: string | undefined, - fallback: number, - options?: ParseIntSafeOptions, -): number { +/** Parse an env string to integer with NaN/isFinite guard. Returns fallback on failure. */ +export function parseIntSafe(value: string | undefined, fallback: number): number { if (value === undefined) return fallback; - - if (options?.strict) { - const trimmed = value.trim(); - if (!/^[+-]?\d+$/.test(trimmed)) { - reportParseIntSafeError(options, value, fallback, 'expected an integer'); - return fallback; - } - - const parsed = Number(trimmed); - if (!Number.isSafeInteger(parsed)) { - reportParseIntSafeError(options, value, fallback, 'value is outside the safe integer range'); - return fallback; - } - if (options.min !== undefined && parsed < options.min) { - reportParseIntSafeError(options, value, fallback, `must be >= ${options.min}`); - return fallback; - } - if (options.max !== undefined && parsed > options.max) { - reportParseIntSafeError(options, value, fallback, `must be <= ${options.max}`); - return fallback; - } - return parsed; - } - const parsed = parseInt(value, 10); - if (!Number.isFinite(parsed)) { - reportParseIntSafeError(options, value, fallback, 'expected an integer'); - return fallback; - } - if (options?.min !== undefined && parsed < options.min) { - reportParseIntSafeError(options, value, fallback, `must be >= ${options.min}`); - return fallback; - } - if (options?.max !== undefined && parsed > options.max) { - reportParseIntSafeError(options, value, fallback, `must be <= ${options.max}`); - return fallback; - } + if (!Number.isFinite(parsed)) return fallback; return parsed; } @@ -424,12 +361,12 @@ export function parseSemver(v: string): [number, number, number] | null { /** * Compare two semver strings. - * Returns -1 if a < b or either is unparseable (fails closed), 0 if equal, 1 if a > b. + * Returns -1 if a < b, 0 if equal or either is unparseable (fails open), 1 if a > b. */ export function compareSemver(a: string, b: string): number { const pa = parseSemver(a); const pb = parseSemver(b); - if (!pa || !pb) return -1; + if (!pa || !pb) return 0; for (let i = 0; i < 3; i++) { if (pa[i] < pb[i]) return -1; if (pa[i] > pb[i]) return 1; @@ -519,13 +456,22 @@ export async function validateWorkDir( allowedWorkDirs: readonly string[] = [], ): Promise { if (typeof workDir !== 'string') return { error: 'workDir must be a string', code: 'INVALID_WORKDIR' }; - if (workDir.includes('\0')) return { error: 'workDir must not contain null bytes', code: 'INVALID_WORKDIR' }; // Step 1: Reject path traversal in raw/mixed/decoded forms before resolution. if (containsTraversalSegment(workDir)) { return { error: 'workDir must not contain path traversal components (..)', code: 'INVALID_WORKDIR' }; } + // Step 2: Resolve to absolute path and follow symlinks. + const resolved = path.resolve(workDir); + let realPath: string; + try { + realPath = await fs.realpath(resolved); + } catch { /* path does not exist on disk */ + return { error: `workDir does not exist: ${resolved}`, code: 'INVALID_WORKDIR' }; + } + + // Step 3: Directory allowlist check. const safeDirCandidates = allowedWorkDirs.length > 0 ? allowedWorkDirs.map((dir) => path.resolve(dir)) : getDefaultSafeDirs().map((dir) => path.resolve(dir)); @@ -537,25 +483,8 @@ export async function validateWorkDir( return dir; } })); - const candidateSafeDirs = Array.from(new Set([...safeDirCandidates, ...safeDirs])); - - // Step 2: Resolve to absolute path and enforce lexical allowlist before touching the filesystem. - const resolved = path.resolve(workDir); - const preAllowed = candidateSafeDirs.some((dir) => isUnderOrEqual(resolved, dir)); - if (!preAllowed) { - return { error: 'workDir is not in the allowed directories list', code: 'INVALID_WORKDIR' }; - } - // Step 3: Follow symlinks. - let realPath: string; - try { - realPath = await fs.realpath(resolved); - } catch { /* path does not exist on disk */ - return { error: `workDir does not exist: ${resolved}`, code: 'INVALID_WORKDIR' }; - } - - // Step 4: Canonical allowlist check after symlink resolution. - const allowed = candidateSafeDirs.some((dir) => isUnderOrEqual(realPath, dir)); + const allowed = safeDirs.some((dir) => isUnderOrEqual(realPath, dir)); if (!allowed) { return { error: 'workDir is not in the allowed directories list', code: 'INVALID_WORKDIR' }; } @@ -585,7 +514,6 @@ export const configFileSchema = z.object({ sseMaxConnections: z.number().int().positive().optional(), sseMaxPerIp: z.number().int().positive().optional(), allowedWorkDirs: z.array(z.string()).optional(), - hookSecretHeaderOnly: z.boolean().optional(), worktreeAwareContinuation: z.boolean().optional(), memoryBridge: z.object({ enabled: z.boolean(), @@ -597,10 +525,5 @@ export const configFileSchema = z.object({ autoVerifyOnStop: z.boolean(), criticalOnly: z.boolean(), }).partial().optional(), - alerting: z.object({ - webhooks: z.array(z.string()).optional(), - failureThreshold: z.number().int().positive().optional(), - cooldownMs: z.number().int().positive().optional(), - }).partial().optional(), }); diff --git a/src/verification.ts b/src/verification.ts index 99771e55..5a5dbbfe 100644 --- a/src/verification.ts +++ b/src/verification.ts @@ -1,19 +1,19 @@ -import { execFile } from 'node:child_process'; +import { exec } from 'child_process'; import { promisify } from 'util'; import { join } from 'path'; import { statSync } from 'fs'; import type { VerificationResult } from './events.js'; -const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); interface RunOptions { cwd: string; timeoutMs: number; } -async function runCmd(file: string, args: string[], { cwd, timeoutMs }: RunOptions): Promise<{ stdout: string; stderr: string; exitCode: number }> { +async function runCmd(cmd: string, { cwd, timeoutMs }: RunOptions): Promise<{ stdout: string; stderr: string; exitCode: number }> { try { - const { stdout, stderr } = await execFileAsync(file, args, { cwd, timeout: Math.floor(timeoutMs / 1000), killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 }); + const { stdout, stderr } = await execAsync(cmd, { cwd, timeout: Math.floor(timeoutMs / 1000), killSignal: 'SIGKILL' }); return { stdout, stderr, exitCode: 0 }; } catch (e: unknown) { const err = e as { code?: number; stdout?: string; stderr?: string }; @@ -45,7 +45,7 @@ export async function runVerification(workDir: string, criticalOnly = false): Pr // Step 1: tsc const tscStart = Date.now(); - const tscResult = await runCmd('npx', ['tsc', '--noEmit'], { cwd: workDir, timeoutMs }); + const tscResult = await runCmd('npx tsc --noEmit', { cwd: workDir, timeoutMs }); const tscDuration = Date.now() - tscStart; const tscOk = tscResult.exitCode === 0; steps.push({ @@ -58,7 +58,7 @@ export async function runVerification(workDir: string, criticalOnly = false): Pr // Step 2: build const buildStart = Date.now(); - const buildResult = await runCmd('npm', ['run', 'build'], { cwd: workDir, timeoutMs }); + const buildResult = await runCmd('npm run build', { cwd: workDir, timeoutMs }); const buildDuration = Date.now() - buildStart; const buildOk = buildResult.exitCode === 0; steps.push({ @@ -73,7 +73,7 @@ export async function runVerification(workDir: string, criticalOnly = false): Pr let testOk = true; if (!criticalOnly) { const testStart = Date.now(); - const testResult = await runCmd('npm', ['test'], { cwd: workDir, timeoutMs: 180_000 }); + const testResult = await runCmd('npm test', { cwd: workDir, timeoutMs: 180_000 }); const testDuration = Date.now() - testStart; testOk = testResult.exitCode === 0; steps.push({ name: 'test' as const, ok: testOk, durationMs: testDuration, output: testResult.stdout.slice(0, 2000), error: testOk ? undefined : (testResult.stderr || testResult.stdout).slice(0, 2000) }); diff --git a/src/ws-terminal.ts b/src/ws-terminal.ts index 960215c7..d07eaa1a 100644 --- a/src/ws-terminal.ts +++ b/src/ws-terminal.ts @@ -23,7 +23,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import type { SessionInfo, SessionManager } from './session.js'; import type { TmuxManager } from './tmux.js'; -import type { AuthManager } from './services/auth/index.js'; +import type { AuthManager } from './auth.js'; import type WebSocket from 'ws'; import { clamp, wsInboundMessageSchema, isValidUUID } from './validation.js'; import { safeJsonParse } from './safe-json.js'; diff --git a/vitest.config.ts b/vitest.config.ts index 8b741f98..38376d92 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,9 @@ export default defineConfig({ thresholds: { lines: 70, branches: 60, functions: 70, statements: 70 }, exclude: [ 'src/startup.ts', + 'src/server.ts', + 'src/session.ts', + 'src/tmux.ts', 'src/verification.ts', 'src/screenshot.ts', 'src/hook.ts', From 5416b7675a077a23f62808a8441c5f5b6bb3ab00 Mon Sep 17 00:00:00 2001 From: Emanuele <106186915+OneStepAt4time@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:46:11 +0200 Subject: [PATCH 3/8] Potential fix for pull request finding 'CodeQL / Missing rate limiting' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- package.json | 3 ++- src/server.ts | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 3d70ddbe..a91a8c4d 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,8 @@ "fastify": "^5.8.2", "nodemailer": "^8.0.5", "prom-client": "^15.1.3", - "zod": "^4.3.6" + "zod": "^4.3.6", + "@fastify/rate-limit": "^10.3.0" }, "overrides": { "zod": "^4.3.6" diff --git a/src/server.ts b/src/server.ts index 3be08054..0ab4160c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,6 +13,7 @@ import fs from 'node:fs/promises'; import fastifyStatic from '@fastify/static'; import fastifyWebsocket from '@fastify/websocket'; import fastifyCors from '@fastify/cors'; +import fastifyRateLimit from '@fastify/rate-limit'; import { z } from 'zod'; import crypto from 'node:crypto'; import path from 'node:path'; @@ -1066,8 +1067,25 @@ async function createSessionHandler(req: FastifyRequest, reply: FastifyReply): P return reply.status(201).send({ ...session, promptDelivery }); } -app.post('/v1/sessions', createSessionHandler); -app.post('/sessions', createSessionHandler); +await app.register(fastifyRateLimit, { + global: false, +}); +app.post('/v1/sessions', { + config: { + rateLimit: { + max: 10, + timeWindow: '1 minute', + }, + }, +}, createSessionHandler); +app.post('/sessions', { + config: { + rateLimit: { + max: 10, + timeWindow: '1 minute', + }, + }, +}, createSessionHandler); // Get session (Issue #20: includes actionHints for interactive states) async function getSessionHandler(req: IdRequest, reply: FastifyReply): Promise> { From eba3221c455f808c83b991079731cf7e47594f33 Mon Sep 17 00:00:00 2001 From: Emanuele <106186915+OneStepAt4time@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:46:19 +0200 Subject: [PATCH 4/8] Potential fix for pull request finding 'CodeQL / Incomplete string escaping or encoding' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/hook.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hook.ts b/src/hook.ts index cb4f6750..a7371cc5 100644 --- a/src/hook.ts +++ b/src/hook.ts @@ -42,7 +42,8 @@ function normalizeCommandPath(pathValue: string, platform: NodeJS.Platform = pro function quoteCommandPath(pathValue: string, platform: NodeJS.Platform = process.platform): string { const normalized = normalizeCommandPath(pathValue, platform); - return `"${normalized.replace(/"/g, '\\"')}"`; + const escaped = normalized.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escaped}"`; } /** Build a shell-safe command string that invokes hook.js with an explicit Node executable. */ From 91e013ffbb3040c805cfd3c5640c5691e174b729 Mon Sep 17 00:00:00 2001 From: OneStepAt4time Date: Sat, 11 Apr 2026 08:48:30 +0200 Subject: [PATCH 5/8] fix(ci): sync lockfile with rate-limit dependency --- package-lock.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/package-lock.json b/package-lock.json index a0581185..541f290f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", + "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^9.0.0", "@fastify/websocket": "^11.2.0", "@modelcontextprotocol/sdk": "^1.28.0", @@ -419,6 +420,27 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, "node_modules/@fastify/send": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", From 7748186a42f82b012fed44674eb9ee3c24333793 Mon Sep 17 00:00:00 2001 From: OneStepAt4time Date: Sat, 11 Apr 2026 08:53:50 +0200 Subject: [PATCH 6/8] Revert "fix(ci): sync lockfile with rate-limit dependency" This reverts commit 91e013ffbb3040c805cfd3c5640c5691e174b729. --- package-lock.json | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 541f290f..a0581185 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", - "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^9.0.0", "@fastify/websocket": "^11.2.0", "@modelcontextprotocol/sdk": "^1.28.0", @@ -420,27 +419,6 @@ "ipaddr.js": "^2.1.0" } }, - "node_modules/@fastify/rate-limit": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", - "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@lukeed/ms": "^2.0.2", - "fastify-plugin": "^5.0.0", - "toad-cache": "^3.7.0" - } - }, "node_modules/@fastify/send": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", From 069a6f7af6d6e2988fe99ed67547a8bcfba35b45 Mon Sep 17 00:00:00 2001 From: OneStepAt4time Date: Sat, 11 Apr 2026 08:53:56 +0200 Subject: [PATCH 7/8] Revert "Potential fix for pull request finding 'CodeQL / Incomplete string escaping or encoding'" This reverts commit eba3221c455f808c83b991079731cf7e47594f33. --- src/hook.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/hook.ts b/src/hook.ts index a7371cc5..cb4f6750 100644 --- a/src/hook.ts +++ b/src/hook.ts @@ -42,8 +42,7 @@ function normalizeCommandPath(pathValue: string, platform: NodeJS.Platform = pro function quoteCommandPath(pathValue: string, platform: NodeJS.Platform = process.platform): string { const normalized = normalizeCommandPath(pathValue, platform); - const escaped = normalized.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - return `"${escaped}"`; + return `"${normalized.replace(/"/g, '\\"')}"`; } /** Build a shell-safe command string that invokes hook.js with an explicit Node executable. */ From b655a8b3e60d1548a646e7a8c1689d97e4c061f5 Mon Sep 17 00:00:00 2001 From: OneStepAt4time Date: Sat, 11 Apr 2026 08:53:56 +0200 Subject: [PATCH 8/8] Revert "Potential fix for pull request finding 'CodeQL / Missing rate limiting'" This reverts commit 5416b7675a077a23f62808a8441c5f5b6bb3ab00. --- package.json | 3 +-- src/server.ts | 22 ++-------------------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index a91a8c4d..3d70ddbe 100644 --- a/package.json +++ b/package.json @@ -60,8 +60,7 @@ "fastify": "^5.8.2", "nodemailer": "^8.0.5", "prom-client": "^15.1.3", - "zod": "^4.3.6", - "@fastify/rate-limit": "^10.3.0" + "zod": "^4.3.6" }, "overrides": { "zod": "^4.3.6" diff --git a/src/server.ts b/src/server.ts index 0ab4160c..3be08054 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,7 +13,6 @@ import fs from 'node:fs/promises'; import fastifyStatic from '@fastify/static'; import fastifyWebsocket from '@fastify/websocket'; import fastifyCors from '@fastify/cors'; -import fastifyRateLimit from '@fastify/rate-limit'; import { z } from 'zod'; import crypto from 'node:crypto'; import path from 'node:path'; @@ -1067,25 +1066,8 @@ async function createSessionHandler(req: FastifyRequest, reply: FastifyReply): P return reply.status(201).send({ ...session, promptDelivery }); } -await app.register(fastifyRateLimit, { - global: false, -}); -app.post('/v1/sessions', { - config: { - rateLimit: { - max: 10, - timeWindow: '1 minute', - }, - }, -}, createSessionHandler); -app.post('/sessions', { - config: { - rateLimit: { - max: 10, - timeWindow: '1 minute', - }, - }, -}, createSessionHandler); +app.post('/v1/sessions', createSessionHandler); +app.post('/sessions', createSessionHandler); // Get session (Issue #20: includes actionHints for interactive states) async function getSessionHandler(req: IdRequest, reply: FastifyReply): Promise> {