diff --git a/.gitignore b/.gitignore index 399acd2..2a8b5e0 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,11 @@ reframe/ openwolf-icon.zip openwolf-blueprint.md openwolf-readme-prompt.md + +# AI-generated session artifacts (not source) +directives.md +summary.md +REVIEW-FIX.md +FINAL-REVIEW-REPORT.md +*-REVIEW.md +*-REVIEW-FIX.md diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 0000000..7e20bcf --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,3 @@ +{ + "model_profile": "inherit" +} diff --git a/README.md b/README.md index 78b4490..f834bfc 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,10 @@ OpenWolf works transparently inside git worktrees (created via `git worktree add - Optional: PM2 for persistent background tasks - Optional: `puppeteer-core` for Design QC screenshots +## Dashboard network exposure + +The dashboard server binds to `127.0.0.1` by default. Its HTTP and WebSocket endpoints are not authenticated, so loopback-only is the safe default — they hand out the contents of `.wolf/` and can trigger cron tasks (including `ai_task` actions that shell out to `claude -p`). If you actually need to reach the dashboard from another machine, set `openwolf.dashboard.bind` in `.wolf/config.json` to `"0.0.0.0"` (or a specific interface) and put it behind your own authenticated reverse proxy. + ## Limitations - Claude Code hooks are a relatively new feature. OpenWolf falls back to `CLAUDE.md` instructions when hooks don't fire. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff91407..f097936 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,18 +20,12 @@ importers: express: specifier: ^5.0.0 version: 5.2.1 - glob: - specifier: ^11.0.0 - version: 11.1.0 node-cron: specifier: ^3.0.3 version: 3.0.3 open: specifier: ^10.0.0 version: 10.2.0 - puppeteer-core: - specifier: ^24.39.1 - version: 24.39.1 ws: specifier: ^8.18.0 version: 8.19.0 @@ -81,6 +75,13 @@ importers: vitepress: specifier: ^1.6.4 version: 1.6.4(@algolia/client-search@5.49.1)(@types/node@22.19.15)(@types/react@19.2.14)(lightningcss@1.31.1)(postcss@8.5.8)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)(typescript@5.9.3) + vitest: + specifier: ^4.1.5 + version: 4.1.6(@types/node@22.19.15)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)) + optionalDependencies: + puppeteer-core: + specifier: ^24.39.1 + version: 24.39.1 packages: @@ -570,10 +571,6 @@ packages: '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@isaacs/cliui@9.0.0': - resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} - engines: {node: '>=18'} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -760,6 +757,9 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.2.1': resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} @@ -872,6 +872,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -902,6 +905,9 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -969,6 +975,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} @@ -983,6 +990,35 @@ packages: vite: ^5.0.0 || ^6.0.0 vue: ^3.2.25 + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + '@vue/compiler-core@3.5.29': resolution: {integrity: sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==} @@ -1091,6 +1127,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.13.4: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} @@ -1103,10 +1143,6 @@ packages: react-native-b4a: optional: true - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - bare-events@2.8.2: resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} peerDependencies: @@ -1153,6 +1189,7 @@ packages: basic-ftp@5.2.0: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} @@ -1161,10 +1198,6 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} - engines: {node: 18 || 20 || >=22} - browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1195,6 +1228,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -1259,10 +1296,6 @@ packages: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1402,6 +1435,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1440,6 +1476,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1454,6 +1493,10 @@ packages: events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -1489,10 +1532,6 @@ packages: focus-trap@7.8.0: resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1533,12 +1572,6 @@ packages: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} engines: {node: '>= 14'} - glob@11.1.0: - resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} - engines: {node: 20 || >=22} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1622,13 +1655,6 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jackspeak@4.2.3: - resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} - engines: {node: 20 || >=22} - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1727,10 +1753,6 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lru-cache@11.2.6: - resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} - engines: {node: 20 || >=22} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1782,14 +1804,6 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} - engines: {node: 18 || 20 || >=22} - - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - minisearch@7.2.0: resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} @@ -1827,6 +1841,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1849,24 +1866,16 @@ packages: resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} engines: {node: '>= 14'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} - path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -2030,14 +2039,6 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - shiki@2.5.0: resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} @@ -2057,9 +2058,8 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} @@ -2088,10 +2088,16 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + streamx@2.23.0: resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} @@ -2135,10 +2141,21 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -2191,6 +2208,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true vary@1.1.2: @@ -2289,6 +2307,47 @@ packages: postcss: optional: true + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vue@3.5.29: resolution: {integrity: sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==} peerDependencies: @@ -2300,9 +2359,9 @@ packages: webdriver-bidi-protocol@0.4.1: resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} hasBin: true wrap-ansi@7.0.0: @@ -2760,8 +2819,6 @@ snapshots: '@iconify/types@2.0.0': {} - '@isaacs/cliui@9.0.0': {} - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2795,6 +2852,7 @@ snapshots: - bare-buffer - react-native-b4a - supports-color + optional: true '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -2913,6 +2971,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -2981,7 +3041,8 @@ snapshots: tailwindcss: 4.2.1 vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) - '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tootallnate/quickjs-emscripten@0.23.0': + optional: true '@types/babel__core@7.20.5': dependencies: @@ -3009,6 +3070,11 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.19.15 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 22.19.15 @@ -3037,6 +3103,8 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/express-serve-static-core@5.1.1': @@ -3130,6 +3198,47 @@ snapshots: vite: 5.4.21(@types/node@22.19.15)(lightningcss@1.31.1) vue: 3.5.29(typescript@5.9.3) + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.6(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) + + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.6': {} + + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@vue/compiler-core@3.5.29': dependencies: '@babel/parser': 7.29.0 @@ -3234,7 +3343,8 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - agent-base@7.1.4: {} + agent-base@7.1.4: + optional: true algoliasearch@5.49.1: dependencies: @@ -3253,21 +3363,26 @@ snapshots: '@algolia/requester-fetch': 5.49.1 '@algolia/requester-node-http': 5.49.1 - ansi-regex@5.0.1: {} + ansi-regex@5.0.1: + optional: true ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + optional: true + + assertion-error@2.0.1: {} ast-types@0.13.4: dependencies: tslib: 2.8.1 + optional: true - b4a@1.8.0: {} - - balanced-match@4.0.4: {} + b4a@1.8.0: + optional: true - bare-events@2.8.2: {} + bare-events@2.8.2: + optional: true bare-fs@4.5.5: dependencies: @@ -3279,12 +3394,15 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true - bare-os@3.8.0: {} + bare-os@3.8.0: + optional: true bare-path@3.0.0: dependencies: bare-os: 3.8.0 + optional: true bare-stream@2.8.1(bare-events@2.8.2): dependencies: @@ -3295,14 +3413,17 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true bare-url@2.3.2: dependencies: bare-path: 3.0.0 + optional: true baseline-browser-mapping@2.10.0: {} - basic-ftp@5.2.0: {} + basic-ftp@5.2.0: + optional: true birpc@2.9.0: {} @@ -3320,10 +3441,6 @@ snapshots: transitivePeerDependencies: - supports-color - brace-expansion@5.0.4: - dependencies: - balanced-match: 4.0.4 - browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.0 @@ -3332,7 +3449,8 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) - buffer-crc32@0.2.13: {} + buffer-crc32@0.2.13: + optional: true bundle-name@4.1.0: dependencies: @@ -3354,6 +3472,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@5.6.2: {} character-entities-html4@2.1.0: {} @@ -3369,20 +3489,24 @@ snapshots: devtools-protocol: 0.0.1581282 mitt: 3.0.1 zod: 3.25.76 + optional: true cliui@8.0.1: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + optional: true clsx@2.1.1: {} color-convert@2.0.1: dependencies: color-name: 1.1.4 + optional: true - color-name@1.1.4: {} + color-name@1.1.4: + optional: true comma-separated-tokens@2.0.3: {} @@ -3402,12 +3526,6 @@ snapshots: dependencies: is-what: 5.5.0 - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - csstype@3.2.3: {} d3-array@3.2.4: @@ -3448,7 +3566,8 @@ snapshots: d3-timer@3.0.1: {} - data-uri-to-buffer@6.0.2: {} + data-uri-to-buffer@6.0.2: + optional: true debug@4.4.3: dependencies: @@ -3470,6 +3589,7 @@ snapshots: ast-types: 0.13.4 escodegen: 2.1.0 esprima: 4.0.1 + optional: true depd@2.0.0: {} @@ -3481,7 +3601,8 @@ snapshots: dependencies: dequal: 2.0.3 - devtools-protocol@0.0.1581282: {} + devtools-protocol@0.0.1581282: + optional: true dom-helpers@5.2.1: dependencies: @@ -3500,13 +3621,15 @@ snapshots: emoji-regex-xs@1.0.0: {} - emoji-regex@8.0.0: {} + emoji-regex@8.0.0: + optional: true encodeurl@2.0.0: {} end-of-stream@1.4.5: dependencies: once: 1.4.0 + optional: true enhanced-resolve@5.20.0: dependencies: @@ -3519,6 +3642,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -3589,14 +3714,22 @@ snapshots: esutils: 2.0.3 optionalDependencies: source-map: 0.6.1 + optional: true - esprima@4.0.1: {} + esprima@4.0.1: + optional: true - estraverse@5.3.0: {} + estraverse@5.3.0: + optional: true estree-walker@2.0.2: {} - esutils@2.0.3: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: + optional: true etag@1.8.1: {} @@ -3607,6 +3740,9 @@ snapshots: bare-events: 2.8.2 transitivePeerDependencies: - bare-abort-controller + optional: true + + expect-type@1.3.0: {} express@5.2.1: dependencies: @@ -3650,14 +3786,17 @@ snapshots: '@types/yauzl': 2.10.3 transitivePeerDependencies: - supports-color + optional: true fast-equals@5.4.0: {} - fast-fifo@1.3.2: {} + fast-fifo@1.3.2: + optional: true fd-slicer@1.1.0: dependencies: pend: 1.2.0 + optional: true fdir@6.5.0(picomatch@4.0.3): optionalDependencies: @@ -3678,11 +3817,6 @@ snapshots: dependencies: tabbable: 6.4.0 - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - forwarded@0.2.0: {} fresh@2.0.0: {} @@ -3694,7 +3828,8 @@ snapshots: gensync@1.0.0-beta.2: {} - get-caller-file@2.0.5: {} + get-caller-file@2.0.5: + optional: true get-intrinsic@1.3.0: dependencies: @@ -3717,6 +3852,7 @@ snapshots: get-stream@5.2.0: dependencies: pump: 3.0.4 + optional: true get-uri@6.0.5: dependencies: @@ -3725,15 +3861,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color - - glob@11.1.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 4.2.3 - minimatch: 10.2.4 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 2.0.2 + optional: true gopd@1.2.0: {} @@ -3781,6 +3909,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true https-proxy-agent@7.0.6: dependencies: @@ -3788,6 +3917,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true iconv-lite@0.7.2: dependencies: @@ -3797,13 +3927,15 @@ snapshots: internmap@2.0.3: {} - ip-address@10.1.0: {} + ip-address@10.1.0: + optional: true ipaddr.js@1.9.1: {} is-docker@3.0.0: {} - is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@3.0.0: + optional: true is-inside-container@1.0.0: dependencies: @@ -3817,12 +3949,6 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isexe@2.0.0: {} - - jackspeak@4.2.3: - dependencies: - '@isaacs/cliui': 9.0.0 - jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -3886,13 +4012,12 @@ snapshots: dependencies: js-tokens: 4.0.0 - lru-cache@11.2.6: {} - lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lru-cache@7.18.3: {} + lru-cache@7.18.3: + optional: true magic-string@0.30.21: dependencies: @@ -3941,12 +4066,6 @@ snapshots: dependencies: mime-db: 1.54.0 - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.4 - - minipass@7.1.3: {} - minisearch@7.2.0: {} mitt@3.0.1: {} @@ -3957,7 +4076,8 @@ snapshots: negotiator@1.0.0: {} - netmask@2.0.2: {} + netmask@2.0.2: + optional: true node-cron@3.0.3: dependencies: @@ -3969,6 +4089,8 @@ snapshots: object-inspect@1.13.4: {} + obug@2.1.1: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -4002,26 +4124,22 @@ snapshots: socks-proxy-agent: 8.0.5 transitivePeerDependencies: - supports-color + optional: true pac-resolver@7.0.1: dependencies: degenerator: 5.0.1 netmask: 2.0.2 - - package-json-from-dist@1.0.1: {} + optional: true parseurl@1.3.3: {} - path-key@3.1.1: {} - - path-scurry@2.0.2: - dependencies: - lru-cache: 11.2.6 - minipass: 7.1.3 - path-to-regexp@8.3.0: {} - pend@1.2.0: {} + pathe@2.0.3: {} + + pend@1.2.0: + optional: true perfect-debounce@1.0.0: {} @@ -4037,7 +4155,8 @@ snapshots: preact@10.28.4: {} - progress@2.0.3: {} + progress@2.0.3: + optional: true prop-types@15.8.1: dependencies: @@ -4064,13 +4183,16 @@ snapshots: socks-proxy-agent: 8.0.5 transitivePeerDependencies: - supports-color + optional: true - proxy-from-env@1.1.0: {} + proxy-from-env@1.1.0: + optional: true pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + optional: true puppeteer-core@24.39.1: dependencies: @@ -4088,6 +4210,7 @@ snapshots: - react-native-b4a - supports-color - utf-8-validate + optional: true qs@6.15.0: dependencies: @@ -4161,7 +4284,8 @@ snapshots: dependencies: regex-utilities: 2.3.0 - require-directory@2.1.1: {} + require-directory@2.1.1: + optional: true rfdc@1.4.1: {} @@ -4216,7 +4340,8 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} + semver@7.7.4: + optional: true send@1.2.1: dependencies: @@ -4245,12 +4370,6 @@ snapshots: setprototypeof@1.2.0: {} - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - shiki@2.5.0: dependencies: '@shikijs/core': 2.5.0 @@ -4290,9 +4409,10 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - signal-exit@4.1.0: {} + siginfo@2.0.0: {} - smart-buffer@4.2.0: {} + smart-buffer@4.2.0: + optional: true socks-proxy-agent@8.0.5: dependencies: @@ -4301,11 +4421,13 @@ snapshots: socks: 2.8.7 transitivePeerDependencies: - supports-color + optional: true socks@2.8.7: dependencies: ip-address: 10.1.0 smart-buffer: 4.2.0 + optional: true source-map-js@1.2.1: {} @@ -4316,8 +4438,12 @@ snapshots: speakingurl@14.0.1: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} + streamx@2.23.0: dependencies: events-universal: 1.0.1 @@ -4326,12 +4452,14 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + optional: true stringify-entities@4.0.4: dependencies: @@ -4341,6 +4469,7 @@ snapshots: strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + optional: true superjson@2.2.6: dependencies: @@ -4363,6 +4492,7 @@ snapshots: - bare-abort-controller - bare-buffer - react-native-b4a + optional: true tar-stream@3.1.8: dependencies: @@ -4374,6 +4504,7 @@ snapshots: - bare-abort-controller - bare-buffer - react-native-b4a + optional: true teex@1.0.1: dependencies: @@ -4381,25 +4512,34 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true text-decoder@1.2.7: dependencies: b4a: 1.8.0 transitivePeerDependencies: - react-native-b4a + optional: true tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + toidentifier@1.0.1: {} trim-lines@3.0.1: {} - tslib@2.8.1: {} + tslib@2.8.1: + optional: true type-is@2.0.1: dependencies: @@ -4407,7 +4547,8 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typed-query-selector@2.12.1: {} + typed-query-selector@2.12.1: + optional: true typescript@5.9.3: {} @@ -4548,6 +4689,33 @@ snapshots: - typescript - universal-cookie + vitest@4.1.6(@types/node@22.19.15)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + transitivePeerDependencies: + - msw + vue@3.5.29(typescript@5.9.3): dependencies: '@vue/compiler-dom': 3.5.29 @@ -4558,17 +4726,20 @@ snapshots: optionalDependencies: typescript: 5.9.3 - webdriver-bidi-protocol@0.4.1: {} + webdriver-bidi-protocol@0.4.1: + optional: true - which@2.0.2: + why-is-node-running@2.3.0: dependencies: - isexe: 2.0.0 + siginfo: 2.0.0 + stackback: 0.0.2 wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + optional: true wrappy@1.0.2: {} @@ -4578,11 +4749,13 @@ snapshots: dependencies: is-wsl: 3.1.1 - y18n@5.0.8: {} + y18n@5.0.8: + optional: true yallist@3.1.1: {} - yargs-parser@21.1.1: {} + yargs-parser@21.1.1: + optional: true yargs@17.7.2: dependencies: @@ -4593,12 +4766,15 @@ snapshots: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 + optional: true yauzl@2.10.0: dependencies: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + optional: true - zod@3.25.76: {} + zod@3.25.76: + optional: true zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..49c0ad7 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: false diff --git a/src/cli/cron-cmd.ts b/src/cli/cron-cmd.ts index 5b2c710..3bb83d3 100644 --- a/src/cli/cron-cmd.ts +++ b/src/cli/cron-cmd.ts @@ -79,11 +79,11 @@ export async function cronRun(id: string): Promise { } // Read dashboard port from config - interface WolfConfig { openwolf: { dashboard: { port: number } } } + interface WolfConfig { openwolf?: { dashboard?: { port?: number } } } const config = readJSON(path.join(wolfDir, "config.json"), { openwolf: { dashboard: { port: 18791 } }, }); - const port = config.openwolf.dashboard.port; + const port = config.openwolf?.dashboard?.port ?? 18791; // Try calling the daemon's HTTP endpoint first try { diff --git a/src/cli/daemon-cmd.ts b/src/cli/daemon-cmd.ts index 74a9d63..f564d2d 100644 --- a/src/cli/daemon-cmd.ts +++ b/src/cli/daemon-cmd.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import * as fs from "node:fs"; import * as net from "node:net"; import * as path from "node:path"; @@ -17,7 +17,7 @@ function getDashboardPort(): number { path.join(wolfDir, "config.json"), { openwolf: { dashboard: { port: 18791 } } } ); - return config.openwolf.dashboard.port; + return config.openwolf?.dashboard?.port ?? 18791; } function getPm2Name(): string { @@ -27,8 +27,8 @@ function getPm2Name(): string { function hasPm2(): boolean { try { - const cmd = isWindows() ? "where pm2" : "which pm2"; - execSync(cmd, { stdio: "ignore" }); + const cmd = isWindows() ? "where" : "which"; + execFileSync(cmd, ["pm2"], { stdio: "ignore" }); return true; } catch { return false; @@ -37,33 +37,54 @@ function hasPm2(): boolean { function findPidOnPort(port: number): number | null { try { + const portStr = String(port); if (isWindows()) { - const output = execSync(`netstat -ano -p tcp`, { encoding: "utf-8" }); + const output = execFileSync("netstat", ["-ano", "-p", "tcp"], { encoding: "utf-8" }); for (const line of output.split("\n")) { - if (line.includes(`:${port}`) && line.includes("LISTENING")) { + if (line.includes(`:${portStr}`) && line.includes("LISTENING")) { const parts = line.trim().split(/\s+/); const pid = parseInt(parts[parts.length - 1], 10); if (pid > 0) return pid; } } } else { - const output = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }); + const output = execFileSync("lsof", ["-ti", `:${portStr}`], { encoding: "utf-8" }); const pid = parseInt(output.trim(), 10); if (pid > 0) return pid; } - } catch {} + } catch (err) { + // ENOENT = lsof/netstat not installed on this system — expected on some + // minimal environments. Other codes (EACCES, etc.) indicate a real problem. + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + process.stderr.write( + `[openwolf] findPidOnPort(${port}): ${err instanceof Error ? err.message : String(err)}\n` + ); + } + } return null; } function killPid(pid: number): boolean { try { if (isWindows()) { - execSync(`taskkill /PID ${pid} /F`, { stdio: "ignore" }); + execFileSync("taskkill", ["/PID", String(pid), "/F"], { stdio: "ignore" }); } else { process.kill(pid, "SIGTERM"); } return true; - } catch { + } catch (err) { + // EPERM: caller lacks privilege to signal this process — tell the user + // explicitly so they know elevated privileges are needed. + const code = (err as NodeJS.ErrnoException).code; + if (code === "EPERM") { + process.stderr.write( + `[openwolf] killPid(${pid}): permission denied — try running with elevated privileges\n` + ); + } else { + process.stderr.write( + `[openwolf] killPid(${pid}): ${err instanceof Error ? err.message : String(err)}\n` + ); + } return false; } } @@ -86,17 +107,28 @@ export function daemonStart(): void { const daemonScript = path.resolve(__dirname, "..", "daemon", "wolf-daemon.js"); try { - execSync(`pm2 start "${daemonScript}" --name ${name} --cwd "${projectRoot}" -- --env OPENWOLF_PROJECT_ROOT="${projectRoot}"`, { + const pm2Cmd = isWindows() ? "pm2.cmd" : "pm2"; + execFileSync(pm2Cmd, [ + "start", + daemonScript, + "--name", + name, + "--cwd", + projectRoot, + "--", + "--env", + `OPENWOLF_PROJECT_ROOT=${projectRoot}` + ], { stdio: "inherit", env: { ...process.env, OPENWOLF_PROJECT_ROOT: projectRoot }, }); - execSync("pm2 save", { stdio: "ignore" }); + execFileSync(pm2Cmd, ["save"], { stdio: "ignore" }); console.log(`\n ✓ Daemon started: ${name}`); if (isWindows()) { console.log(" Tip: Run 'pm2-windows-startup' for boot persistence."); } - } catch { - console.error("Failed to start daemon."); + } catch (err) { + console.error(` Failed to start daemon: ${err instanceof Error ? err.message : String(err)}`); } } @@ -113,11 +145,23 @@ export function daemonStop(): void { if (hasPm2()) { const name = getPm2Name(); try { - execSync(`pm2 stop ${name}`, { stdio: "ignore" }); + const pm2Cmd = isWindows() ? "pm2.cmd" : "pm2"; + execFileSync(pm2Cmd, ["stop", name], { stdio: "ignore" }); console.log(` ✓ Daemon stopped (PM2): ${name}`); + + const tokenPath = path.join(wolfDir, "daemon-token.tmp"); + if (fs.existsSync(tokenPath)) fs.unlinkSync(tokenPath); return; - } catch { - // PM2 process not found — fall through to port-based stop + } catch (err) { + // PM2 reports "not found" when the named process doesn't exist — expected + // on first stop or after a crash. Warn on other failures (permission, etc.). + const msg = err instanceof Error ? err.message : String(err); + const isNotFound = msg.toLowerCase().includes("not found") || + msg.toLowerCase().includes("no such process"); + if (!isNotFound) { + console.warn(` PM2 stop warning: ${msg}`); + } + // Fall through to port-based stop } } @@ -127,6 +171,9 @@ export function daemonStop(): void { if (pid) { if (killPid(pid)) { console.log(` ✓ Daemon stopped (PID ${pid} on port ${port})`); + // Clean up token + const tokenPath = path.join(wolfDir, "daemon-token.tmp"); + if (fs.existsSync(tokenPath)) fs.unlinkSync(tokenPath); } else { console.error(` Failed to kill process ${pid} on port ${port}.`); } @@ -148,11 +195,20 @@ export function daemonRestart(): void { if (hasPm2()) { const name = getPm2Name(); try { - execSync(`pm2 restart ${name}`, { stdio: "ignore" }); + const pm2Cmd = isWindows() ? "pm2.cmd" : "pm2"; + execFileSync(pm2Cmd, ["restart", name], { stdio: "ignore" }); console.log(` ✓ Daemon restarted (PM2): ${name}`); return; - } catch { - // PM2 process not found — fall through + } catch (err) { + // PM2 reports "not found" when the named process doesn't exist — expected + // before the daemon has been started with PM2. Warn on other failures. + const msg = err instanceof Error ? err.message : String(err); + const isNotFound = msg.toLowerCase().includes("not found") || + msg.toLowerCase().includes("no such process"); + if (!isNotFound) { + console.warn(` PM2 restart warning: ${msg}`); + } + // Fall through to port-based restart } } @@ -182,8 +238,9 @@ export function daemonLogs(): void { const name = getPm2Name(); try { - execSync(`pm2 logs ${name} --lines 50 --nostream`, { stdio: "inherit" }); - } catch { - console.error("Failed to get daemon logs."); + const pm2Cmd = isWindows() ? "pm2.cmd" : "pm2"; + execFileSync(pm2Cmd, ["logs", name, "--lines", "50", "--nostream"], { stdio: "inherit" }); + } catch (err) { + console.error(`Failed to get daemon logs: ${err instanceof Error ? err.message : String(err)}`); } } diff --git a/src/cli/dashboard.ts b/src/cli/dashboard.ts index 6354cbe..2d0a82c 100644 --- a/src/cli/dashboard.ts +++ b/src/cli/dashboard.ts @@ -5,13 +5,14 @@ import { fileURLToPath } from "node:url"; import { fork } from "node:child_process"; import { findProjectRoot } from "../scanner/project-root.js"; import { readJSON } from "../utils/fs-safe.js"; +import { Logger } from "../utils/logger.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); interface WolfConfig { - openwolf: { - dashboard: { port: number }; + openwolf?: { + dashboard?: { port?: number }; }; } @@ -44,12 +45,14 @@ export async function dashboardCommand(): Promise { return; } + const logger = new Logger(path.join(wolfDir, "dashboard.log"), "info"); + const config = readJSON(path.join(wolfDir, "config.json"), { openwolf: { dashboard: { port: 18791 } }, }); - const port = config.openwolf.dashboard.port; - const url = `http://localhost:${port}`; + const port = config.openwolf?.dashboard?.port ?? 18791; + let url = `http://localhost:${port}`; // Check if daemon is already running on that port const running = await isPortOpen(port); @@ -72,6 +75,14 @@ export async function dashboardCommand(): Promise { detached: true, stdio: "ignore", }); + child.on("error", (err) => { + console.error(` Daemon process error: ${err.message}`); + }); + child.on("exit", (code) => { + if (code !== null && code !== 0) { + console.error(` Daemon exited unexpectedly with code ${code}. Check .wolf/daemon.log for details.`); + } + }); child.unref(); // Wait for the port to open (up to 5 seconds) @@ -92,12 +103,40 @@ export async function dashboardCommand(): Promise { console.log(` ✓ Dashboard server running on port ${port}`); } - console.log(` Opening ${url}...`); + // Append auth token to URL for initial page load bootstrap. + // The dashboard JS reads the token from the URL param on first load, + // stores it in sessionStorage, and immediately strips it from the URL + // via history.replaceState — so it does not appear in browser history + // entries or outbound Referer headers. Subsequent API calls send the + // token via the X-Api-Token header rather than the URL. + const tokenPath = path.join(wolfDir, "daemon-token.tmp"); + if (fs.existsSync(tokenPath)) { + const token = fs.readFileSync(tokenPath, "utf-8").trim(); + url += `?token=${token}`; + } + + console.log(` Opening http://localhost:${port}...`); try { const { default: open } = await import("open"); await open(url); - } catch { - console.log(` Could not open browser. Visit: ${url}`); + } catch (error) { + const errorMessage = error instanceof Error + ? error.message + : 'Unknown error'; + + // Strip the token query param before logging — the URL includes ?token= + // and writing it to dashboard.log would expose the live auth token to + // anyone who can read the log file. + const safeUrl = url.includes("?") ? url.slice(0, url.indexOf("?")) : url; + logger.error(`Failed to open browser at ${safeUrl}. Error: ${errorMessage}. Hint: Try opening the URL manually in your browser`); + + // User-friendly message + console.log(` +🚨 Could not open browser automatically`); + console.log(`URL: ${url}`); + console.log(`Error: ${errorMessage}`); + console.log(`You can manually open this URL in your browser. +`); } } diff --git a/src/cli/hook-settings.ts b/src/cli/hook-settings.ts index 8752176..8103eb6 100644 --- a/src/cli/hook-settings.ts +++ b/src/cli/hook-settings.ts @@ -22,31 +22,93 @@ export const WOLF_ROOT_SHELL = const hookCmd = (script: string): string => `${WOLF_ROOT_SHELL} && node "$WOLF_ROOT/.wolf/hooks/${script}"`; +// NOTE: `_managedBy` is NOT a documented Claude Code field. It is an +// empirically observed passthrough — Claude Code preserves unknown fields +// in settings.json during its own read/write cycles as of the versions +// tested. If a future Claude Code release performs schema-validated +// serialization and strips unknown fields, `_managedBy` will silently +// disappear and identification will fall back to the `.wolf/hooks/` +// substring match in `isOpenWolfHook`. Monitor for unexpected hook +// re-registration or spurious duplicate entries as a symptom of this. export const HOOK_SETTINGS = { SessionStart: [ - { matcher: "", hooks: [{ type: "command", command: hookCmd("session-start.js"), timeout: 5 }] }, + { + matcher: "", + hooks: [{ + type: "command", + command: hookCmd("session-start.js"), + timeout: 5, + _managedBy: "openwolf", + }], + }, ], PreToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: hookCmd("pre-read.js"), timeout: 5 }] }, - { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: hookCmd("pre-write.js"), timeout: 5 }] }, + { + matcher: "Read", + hooks: [{ + type: "command", + command: hookCmd("pre-read.js"), + timeout: 5, + _managedBy: "openwolf", + }], + }, + { + matcher: "Write|Edit|MultiEdit", + hooks: [{ + type: "command", + command: hookCmd("pre-write.js"), + timeout: 5, + _managedBy: "openwolf", + }], + }, ], PostToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: hookCmd("post-read.js"), timeout: 5 }] }, - { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: hookCmd("post-write.js"), timeout: 10 }] }, + { + matcher: "Read", + hooks: [{ + type: "command", + command: hookCmd("post-read.js"), + timeout: 5, + _managedBy: "openwolf", + }], + }, + { + matcher: "Write|Edit|MultiEdit", + hooks: [{ + type: "command", + command: hookCmd("post-write.js"), + timeout: 10, + _managedBy: "openwolf", + }], + }, ], Stop: [ - { matcher: "", hooks: [{ type: "command", command: hookCmd("stop.js"), timeout: 10 }] }, + { + matcher: "", + hooks: [{ + type: "command", + command: hookCmd("stop.js"), + timeout: 10, + _managedBy: "openwolf", + }], + }, ], }; /** - * Returns true if a hook entry was registered by OpenWolf - * (i.e., its command references .wolf/hooks/). + * Returns true if a hook entry was registered by OpenWolf. + * + * Primary check: `_managedBy === "openwolf"` (set on every hook object + * written by this module). Fallback: `.wolf/hooks/` path substring, for + * backward compatibility with pre-tag installs that predate this field. */ export function isOpenWolfHook(hook: unknown): boolean { if (typeof hook !== "object" || hook === null) return false; const h = hook as Record; - if (typeof h.command === "string" && h.command.includes(".wolf/hooks/")) return true; + if (h._managedBy === "openwolf") return true; + if (typeof h.command === "string" && h.command.includes(".wolf/hooks/")) { + return true; + } return false; } @@ -67,7 +129,14 @@ export function replaceOpenWolfHooks( const existing_entries = Array.isArray(existingHooks[event]) ? (existingHooks[event] as unknown[]) : []; - // Keep non-OpenWolf entries the user may have added + // Keep non-OpenWolf entries the user may have added. + // + // ASSUMPTION: OpenWolf writes exactly one inner hook per outer matcher + // entry. Co-locating a user-defined command inside the same outer entry + // as an OpenWolf hook is unsupported — the entire outer entry is dropped + // and replaced if *any* inner hook matches `isOpenWolfHook`. Users who + // need custom hooks for the same event should add a separate outer + // matcher entry in settings.json. const userEntries = existing_entries.filter((entry) => { if (typeof entry !== "object" || entry === null) return true; const e = entry as Record; diff --git a/src/cli/init.ts b/src/cli/init.ts index f404e7b..13bdd3c 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -3,7 +3,7 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { findProjectRoot } from "../scanner/project-root.js"; import { scanProject } from "../scanner/anatomy-scanner.js"; -import { readJSON, writeJSON } from "../utils/fs-safe.js"; +import { readJSON, writeJSON, safeCopyFile } from "../utils/fs-safe.js"; import { ensureDir } from "../utils/paths.js"; import { registerProject } from "./registry.js"; import { detectWorktreeContext } from "../utils/worktree.js"; @@ -25,19 +25,23 @@ function getVersion(): string { } } -// Files that are safe to overwrite on upgrade (config/protocol, not user data) +// Files that are safe to overwrite on upgrade (protocol docs, not user data) const ALWAYS_OVERWRITE = [ "OPENWOLF.md", - "config.json", "reframe-frameworks.md", ]; -// Files that contain user/session data — only create if missing, never overwrite +// Files that contain user/session data — only create if missing, never overwrite. +// config.json is here (not in ALWAYS_OVERWRITE) because users set port +// assignments and bind addresses there; overwriting it causes EADDRINUSE +// crash-loops on upgrade. const CREATE_IF_MISSING = [ + "config.json", "identity.md", "cerebrum.md", "memory.md", "anatomy.md", + "STATUS.md", "token-ledger.json", "buglog.json", "cron-manifest.json", @@ -88,7 +92,7 @@ function writeHooks(wolfDir: string): void { const srcPath = path.join(sourceDir, file); const destPath = path.join(hooksDir, file); if (fs.existsSync(srcPath)) { - fs.copyFileSync(srcPath, destPath); + safeCopyFile(srcPath, destPath); copiedCount++; } else { console.warn(` ⚠ Hook not found: ${file}`); @@ -118,7 +122,7 @@ function writeSettings(projectRoot: string): void { existing = JSON.parse(fs.readFileSync(settingsPath, "utf-8")) as Record; } catch (err) { const backupPath = settingsPath + ".bak"; - fs.copyFileSync(settingsPath, backupPath); + safeCopyFile(settingsPath, backupPath); console.warn( ` ⚠ settings.json could not be parsed (${err instanceof Error ? err.message : String(err)}).\n` + ` The original was backed up to ${backupPath}.\n` + @@ -135,7 +139,7 @@ function writeIdentity(projectRoot: string, wolfDir: string): void { const identityPath = path.join(wolfDir, "identity.md"); const pkgPath = path.join(projectRoot, "package.json"); const name = path.basename(projectRoot); - + let projectName = name; let projectDesc = ""; try { @@ -147,7 +151,7 @@ function writeIdentity(projectRoot: string, wolfDir: string): void { console.warn(` ⚠ Could not parse ${pkgPath}: ${(err as Error).message}`); } } - + const identity = `# ${projectName}\n\n${projectDesc}\n\n> Initialized: ${new Date().toISOString()}\n> Root: ${projectRoot}`; fs.writeFileSync(identityPath, identity, "utf-8"); } @@ -177,7 +181,7 @@ function writeClaudeRules(projectRoot: string, templatesDir: string): void { const destPath = path.join(rulesDir, "openwolf.md"); const srcPath = path.join(templatesDir, "claude-rules-openwolf.md"); if (fs.existsSync(srcPath)) { - fs.copyFileSync(srcPath, destPath); + safeCopyFile(srcPath, destPath); } // Insert @.wolf/OPENWOLF.md reference at the top of CLAUDE.md if not present @@ -237,6 +241,23 @@ function detectProjectDescription(projectRoot: string): string { return ""; } +function seedStatus(wolfDir: string, projectRoot: string): void { + const statusPath = path.join(wolfDir, "STATUS.md"); + const projectName = detectProjectName(projectRoot); + let content: string; + try { + content = fs.readFileSync(statusPath, "utf-8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + console.warn(` ⚠ Could not read STATUS.md: ${(err as Error).message}`); + } + return; + } + content = content.replace(/\{\{PROJECT_NAME\}\}/g, projectName); + content = content.replace(/\{\{DATE\}\}/g, new Date().toISOString().slice(0, 10)); + fs.writeFileSync(statusPath, content, "utf-8"); +} + function seedCerebrum(wolfDir: string, projectRoot: string): void { const projectName = detectProjectName(projectRoot); const projectDescription = detectProjectDescription(projectRoot); @@ -312,6 +333,9 @@ export async function initCommand(): Promise { // --- Template files --- let createdCount = 0; let skippedCount = 0; + // Track which CREATE_IF_MISSING files were newly written so we can seed + // their placeholders even when isUpgrade is true. + const newlyCreated = new Set(); for (const file of ALWAYS_OVERWRITE) { writeTemplateFile(actualTemplatesDir, wolfDir, file); @@ -324,6 +348,7 @@ export async function initCommand(): Promise { skippedCount++; } else { writeTemplateFile(actualTemplatesDir, wolfDir, file); + newlyCreated.add(file); createdCount++; } } @@ -355,6 +380,12 @@ export async function initCommand(): Promise { if (!isUpgrade) { writeIdentity(projectRoot, wolfDir); seedCerebrum(wolfDir, projectRoot); + seedStatus(wolfDir, projectRoot); + } else if (newlyCreated.has("STATUS.md")) { + // STATUS.md was just created for the first time during an upgrade + // (e.g. upgrading from a version that predated STATUS.md). Seed its + // {{PROJECT_NAME}}/{{DATE}} placeholders now, just as a fresh init does. + seedStatus(wolfDir, projectRoot); } // --- Project files --- @@ -386,14 +417,22 @@ export async function initCommand(): Promise { } // --- Summary --- - console.log("\n" + "=".repeat(60)); - console.log(`OpenWolf v${version} initialized at: ${wolfDir}`); - console.log("=".repeat(60)); - console.log(` Daemon: start manually with 'openwolf daemon start' (requires pm2)`); - console.log("\nNext steps:"); - console.log(` 1. Add .wolf/ to .gitignore (already done)`); - console.log(` 2. Commit the changes: git add .gitignore .claude/ CLAUDE.md`); - console.log(` 3. Start using OpenWolf in your Claude Code sessions`); - console.log("\nDocumentation: https://github.com/cytostack/openwolf"); - console.log("Troubleshooting: openwolf status\n"); + console.log(""); + if (isUpgrade) { + console.log(` ✓ OpenWolf upgraded to v${version}`); + console.log(` ✓ All .wolf data preserved (${skippedCount} files: cerebrum, memory, anatomy, buglog, ledger)`); + console.log(` ✓ Hook scripts updated (6 hooks)`); + console.log(` ✓ ${createdCount} config files updated`); + console.log(` ✓ Anatomy: ${fileCount} files tracked (unchanged)`); + } else { + console.log(` ✓ OpenWolf v${version} initialized`); + console.log(` ✓ .wolf/ created with ${createdCount} files`); + console.log(` ✓ Claude Code hooks registered (6 hooks)`); + console.log(` ✓ CLAUDE.md updated`); + console.log(` ✓ .claude/rules/openwolf.md created`); + console.log(` ✓ Anatomy scan: ${fileCount} files indexed`); + } + console.log(""); + console.log(" You're ready. Just use 'claude' as normal — OpenWolf is watching."); + console.log(""); } diff --git a/src/cli/update.ts b/src/cli/update.ts index f1acae3..7f0e8ea 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -11,7 +11,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { getRegisteredProjects, registerProject, type RegisteredProject } from "./registry.js"; -import { readJSON, writeJSON, readText, writeText } from "../utils/fs-safe.js"; +import { readJSON, writeJSON, readText, writeText, safeCopyFile } from "../utils/fs-safe.js"; import { ensureDir } from "../utils/paths.js"; import { detectWorktreeContext } from "../utils/worktree.js"; @@ -28,11 +28,21 @@ function getVersion(): string { } } -// Files that are safe to overwrite (protocol/config) -const ALWAYS_OVERWRITE = ["OPENWOLF.md", "config.json", "reframe-frameworks.md"]; - -// Files that contain user data — NEVER overwrite, only create if missing +// Files that are safe to overwrite (protocol docs only — never user-edited config) +const ALWAYS_OVERWRITE = ["OPENWOLF.md", "reframe-frameworks.md"]; + +// Files that contain user data — NEVER overwrite, only create if missing. +// +// `config.json` is user data: it holds per-project port assignments +// (`openwolf.daemon.port`, `openwolf.dashboard.port`), scan intervals, +// exclude patterns, and any other tunables a user has customized. +// Overwriting it on `openwolf update` resets every registered project to +// the same default ports (18790 / 18791), at which point only the first +// daemon to start can bind and the rest crash-loop on EADDRINUSE. +// Keep it in BACKUP_FILES (via the spread below) so `openwolf restore` +// can still recover it. const USER_DATA_FILES = [ + "config.json", "identity.md", "cerebrum.md", "memory.md", "anatomy.md", "token-ledger.json", "buglog.json", "cron-manifest.json", "cron-state.json", "suggestions.json", "designqc-report.json", @@ -164,17 +174,25 @@ async function updateProject( const backupDir = createBackup(wolfDir); console.log(` ✓ Backup: ${path.basename(backupDir)}`); - // 2. Update template files (OPENWOLF.md, config.json) + // 2. Update template files (OPENWOLF.md, reframe-frameworks.md) const templatesDir = findTemplatesDir(); for (const file of ALWAYS_OVERWRITE) { const srcPath = path.join(templatesDir, file); const destPath = path.join(wolfDir, file); if (fs.existsSync(srcPath)) { - fs.copyFileSync(srcPath, destPath); + safeCopyFile(srcPath, destPath); } } console.log(` ✓ Templates updated (${ALWAYS_OVERWRITE.join(", ")})`); + // Seed config.json if it doesn't exist yet (never overwrite — user data) + const configDest = path.join(wolfDir, "config.json"); + const configSrc = path.join(templatesDir, "config.json"); + if (!fs.existsSync(configDest) && fs.existsSync(configSrc)) { + safeCopyFile(configSrc, configDest); + console.log(` ✓ config.json seeded (first time)`); + } + // 3. Update hook scripts copyHookScripts(wolfDir); console.log(` ✓ Hook scripts updated`); @@ -250,7 +268,7 @@ function createBackup(wolfDir: string): string { for (const file of BACKUP_FILES) { const src = path.join(wolfDir, file); if (fs.existsSync(src)) { - fs.copyFileSync(src, path.join(backupDir, file)); + safeCopyFile(src, path.join(backupDir, file)); } } @@ -264,7 +282,7 @@ function createBackup(wolfDir: string): string { for (const f of hookFiles) { const src = path.join(hooksDir, f); if (fs.statSync(src).isFile()) { - fs.copyFileSync(src, path.join(hooksBackup, f)); + safeCopyFile(src, path.join(hooksBackup, f)); } } } catch {} @@ -276,13 +294,13 @@ function createBackup(wolfDir: string): string { if (fs.existsSync(claudeSettings)) { const claudeBackup = path.join(backupDir, ".claude"); ensureDir(claudeBackup); - fs.copyFileSync(claudeSettings, path.join(claudeBackup, "settings.json")); + safeCopyFile(claudeSettings, path.join(claudeBackup, "settings.json")); } const claudeRules = path.join(projectRoot, ".claude", "rules", "openwolf.md"); if (fs.existsSync(claudeRules)) { const rulesBackup = path.join(backupDir, ".claude", "rules"); ensureDir(rulesBackup); - fs.copyFileSync(claudeRules, path.join(rulesBackup, "openwolf.md")); + safeCopyFile(claudeRules, path.join(rulesBackup, "openwolf.md")); } return backupDir; @@ -322,7 +340,7 @@ function copyHookScripts(wolfDir: string): void { for (const file of HOOK_FILES) { const src = path.join(sourceDir, file); if (fs.existsSync(src)) { - fs.copyFileSync(src, path.join(hooksDir, file)); + safeCopyFile(src, path.join(hooksDir, file)); } } } @@ -395,7 +413,7 @@ export function restoreCommand(backupName?: string): void { // Restore files const files = fs.readdirSync(backupDir).filter(f => fs.statSync(path.join(backupDir, f)).isFile()); for (const file of files) { - fs.copyFileSync(path.join(backupDir, file), path.join(wolfDir, file)); + safeCopyFile(path.join(backupDir, file), path.join(wolfDir, file)); } // Restore hooks if present @@ -405,7 +423,7 @@ export function restoreCommand(backupName?: string): void { const hooksDir = path.join(wolfDir, "hooks"); ensureDir(hooksDir); for (const f of hookFiles) { - fs.copyFileSync(path.join(hooksBackup, f), path.join(hooksDir, f)); + safeCopyFile(path.join(hooksBackup, f), path.join(hooksDir, f)); } } @@ -417,13 +435,13 @@ export function restoreCommand(backupName?: string): void { if (fs.existsSync(settingsBackup)) { const dest = path.join(projectRoot, ".claude", "settings.json"); ensureDir(path.dirname(dest)); - fs.copyFileSync(settingsBackup, dest); + safeCopyFile(settingsBackup, dest); } const rulesBackup = path.join(claudeBackup, "rules", "openwolf.md"); if (fs.existsSync(rulesBackup)) { const dest = path.join(projectRoot, ".claude", "rules", "openwolf.md"); ensureDir(path.dirname(dest)); - fs.copyFileSync(rulesBackup, dest); + safeCopyFile(rulesBackup, dest); } } diff --git a/src/daemon/cron-engine.ts b/src/daemon/cron-engine.ts index 1acf5a5..c6b134d 100644 --- a/src/daemon/cron-engine.ts +++ b/src/daemon/cron-engine.ts @@ -160,7 +160,11 @@ export class CronEngine { const delay = this.calculateDelay(task.retry.backoff, task.retry.base_delay_seconds, failures); this.logger.info(`Retrying ${task.name} in ${delay}ms`); setTimeout(() => { - this.executeTask(task).catch(() => {}); + this.executeTask(task).catch((retryErr) => { + this.logger.error( + `Task ${task.name} retry failed: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}` + ); + }); }, delay); } else { // Dead letter or skip @@ -312,10 +316,30 @@ export class CronEngine { const contextParts: string[] = []; for (const file of params.context_files) { - const filePath = path.join(this.projectRoot, file); + const filePath = path.resolve(this.projectRoot, file); + + // Path Traversal Protection: Ensure the resolved path is within + // projectRoot. Normalize to lowercase for comparison so the check + // is not bypassable on case-insensitive filesystems (macOS, Windows). + const resolvedNorm = filePath.toLowerCase(); + const rootWithSep = (this.projectRoot + path.sep).toLowerCase(); + const rootNorm = this.projectRoot.toLowerCase(); + if (!resolvedNorm.startsWith(rootWithSep) && resolvedNorm !== rootNorm) { + this.logger.warn(`Path traversal attempt blocked: ${file}`); + continue; + } + try { contextParts.push(`--- ${file} ---\n${fs.readFileSync(filePath, "utf-8")}`); - } catch { + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + // Permission errors and I/O errors should be visible — only ENOENT + // is expected for optional context files. + this.logger.warn( + `Could not read context file ${file}: ${err instanceof Error ? err.message : String(err)}` + ); + } contextParts.push(`--- ${file} --- (not found)`); } } diff --git a/src/daemon/file-watcher.ts b/src/daemon/file-watcher.ts index 3588277..bcf6fcd 100644 --- a/src/daemon/file-watcher.ts +++ b/src/daemon/file-watcher.ts @@ -29,6 +29,13 @@ export function startFileWatcher( logger.debug(`File changed: ${relativePath}`); try { + // DoS Protection: Skip massive files + const stat = fs.statSync(filePath as string); + if (stat.size > 1024 * 1024) { + logger.warn(`Skipping broadcast for large file: ${relativePath} (${stat.size} bytes)`); + return; + } + const content = fs.readFileSync(filePath as string, "utf-8"); broadcast({ type: "file_changed", @@ -36,8 +43,12 @@ export function startFileWatcher( content, timestamp: new Date().toISOString(), }); - } catch { - // File might be in process of being written + } catch (err) { + // File might be in process of being written — log at debug so it's + // visible in verbose mode without spamming normal output. + logger.debug( + `Could not read/broadcast ${relativePath}: ${err instanceof Error ? err.message : String(err)}` + ); } // Hot-reload config diff --git a/src/daemon/wolf-daemon.ts b/src/daemon/wolf-daemon.ts index 6a3c93f..9042a37 100644 --- a/src/daemon/wolf-daemon.ts +++ b/src/daemon/wolf-daemon.ts @@ -1,6 +1,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import * as crypto from "node:crypto"; import { fileURLToPath } from "node:url"; +import type { IncomingMessage } from "node:http"; import express from "express"; import { WebSocketServer, WebSocket } from "ws"; import { findProjectRoot } from "../scanner/project-root.js"; @@ -16,25 +18,46 @@ const __dirname = path.dirname(__filename); const projectRoot = process.env.OPENWOLF_PROJECT_ROOT || findProjectRoot(); const wolfDir = path.join(projectRoot, ".wolf"); +// Generate a session token for authentication +const authToken = crypto.randomBytes(32).toString("hex"); +try { + fs.mkdirSync(wolfDir, { recursive: true }); // ensure .wolf/ exists before write + fs.writeFileSync( + path.join(wolfDir, "daemon-token.tmp"), + authToken, + { encoding: "utf-8", mode: 0o600 } // owner-only read/write + ); +} catch (err) { + process.stderr.write( + `[openwolf] Failed to write auth token to ${wolfDir}: ${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(1); +} + interface WolfConfig { - openwolf: { - daemon: { port: number; log_level: string }; - dashboard: { enabled: boolean; port: number }; - cron: { enabled: boolean; heartbeat_interval_minutes: number }; + openwolf?: { + daemon?: { port?: number; log_level?: string }; + dashboard?: { enabled?: boolean; port?: number; bind?: string }; + cron?: { enabled?: boolean; heartbeat_interval_minutes?: number }; }; } const config = readJSON(path.join(wolfDir, "config.json"), { openwolf: { daemon: { port: 18790, log_level: "info" }, - dashboard: { enabled: true, port: 18791 }, + dashboard: { enabled: true, port: 18791, bind: "127.0.0.1" }, cron: { enabled: true, heartbeat_interval_minutes: 30 }, }, }); +// Dashboard bind address. Defaults to loopback so the unauthenticated API +// and WebSocket endpoints are not exposed to the LAN. Set to "0.0.0.0" in +// .wolf/config.json only if you explicitly need network access. +const bind = config.openwolf?.dashboard?.bind ?? "127.0.0.1"; + const logger = new Logger( path.join(wolfDir, "daemon.log"), - config.openwolf.daemon.log_level as "debug" | "info" | "warn" | "error" + (config.openwolf?.daemon?.log_level ?? "info") as "debug" | "info" | "warn" | "error" ); const startTime = Date.now(); @@ -44,13 +67,44 @@ const wsClients = new Set(); const app = express(); app.use(express.json()); -// Serve dashboard static files +// Serve dashboard static files before auth — HTML/JS/CSS contain no +// sensitive data and must load without a token in request headers. // In dist: dist/src/daemon/wolf-daemon.js → ../../../dist/dashboard/ const dashboardDir = path.resolve(__dirname, "..", "..", "..", "dist", "dashboard"); if (fs.existsSync(dashboardDir)) { app.use(express.static(dashboardDir)); } +// Constant-time token comparison — prevents timing side-channel attacks +// from a local co-tenant who cannot read the token file. +function safeCompareToken(provided: string): boolean { + try { + const a = Buffer.from(provided); + const b = Buffer.from(authToken); + // Buffers must be the same length for timingSafeEqual; mismatched + // lengths are an instant reject but we avoid returning early in a + // way that varies with the token content. + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); + } catch { + return false; + } +} + +// Auth middleware — header-only. Accepting ?token= in query params would +// expose the token in browser history, Referer headers, and server access +// logs. The dashboard reads the token from the URL once on page load, +// strips it via history.replaceState, and stores it in sessionStorage. +// All subsequent API requests use the X-Api-Token header only. +app.use("/api", (req, res, next) => { + const token = req.headers["x-api-token"]; + if (!token || !safeCompareToken(String(token))) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + next(); +}); + // Detect project metadata function detectProjectMeta(): { name: string; description: string } { let name = path.basename(projectRoot); @@ -61,7 +115,9 @@ function detectProjectMeta(): { name: string; description: string } { const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf-8")); if (pkg.name) name = pkg.name; if (pkg.description) description = pkg.description; - } catch {} + } catch (err) { + logger.debug(`Could not read package.json: ${err instanceof Error ? err.message : String(err)}`); + } // Try Cargo.toml for name if not found if (name === path.basename(projectRoot)) { @@ -69,7 +125,9 @@ function detectProjectMeta(): { name: string; description: string } { const cargo = fs.readFileSync(path.join(projectRoot, "Cargo.toml"), "utf-8"); const nameMatch = cargo.match(/^name\s*=\s*"([^"]+)"/m); if (nameMatch) name = nameMatch[1]; - } catch {} + } catch (err) { + logger.debug(`Could not read Cargo.toml: ${err instanceof Error ? err.message : String(err)}`); + } } // If no description, try cerebrum.md project description @@ -78,7 +136,9 @@ function detectProjectMeta(): { name: string; description: string } { const cerebrum = fs.readFileSync(path.join(wolfDir, "cerebrum.md"), "utf-8"); const descMatch = cerebrum.match(/\*\*Project:\*\*\s*(.+)/); if (descMatch) description = descMatch[1].trim(); - } catch {} + } catch (err) { + logger.debug(`Could not read cerebrum.md: ${err instanceof Error ? err.message : String(err)}`); + } } // If still no description, try README first paragraph @@ -95,7 +155,9 @@ function detectProjectMeta(): { name: string; description: string } { } } if (description) break; - } catch {} + } catch (err) { + logger.debug(`Could not read ${readme}: ${err instanceof Error ? err.message : String(err)}`); + } } } @@ -143,14 +205,16 @@ app.get("/api/files", (_req, res) => { for (const file of wolfFiles) { try { files[file] = fs.readFileSync(path.join(wolfDir, file), "utf-8"); - } catch { + } catch (err) { + logger.debug(`Could not read ${file}: ${err instanceof Error ? err.message : String(err)}`); files[file] = ""; } } // Also try suggestions.json try { files["suggestions.json"] = fs.readFileSync(path.join(wolfDir, "suggestions.json"), "utf-8"); - } catch { + } catch (err) { + logger.debug(`Could not read suggestions.json: ${err instanceof Error ? err.message : String(err)}`); files["suggestions.json"] = ""; } res.json(files); @@ -185,14 +249,104 @@ app.get("/{*path}", (_req, res) => { } }); +// Helper: is a bind address (or remote IP) a loopback address? +const isLoopback = (addr: string): boolean => + addr === "127.0.0.1" || addr === "localhost" || addr === "::1"; + // Start HTTP server -const port = config.openwolf.dashboard.port; -const server = app.listen(port, () => { - logger.info(`Dashboard server listening on port ${port}`); +const port = config.openwolf?.dashboard?.port ?? 18791; +const server = app.listen(port, bind, () => { + logger.info(`Dashboard server listening on ${bind}:${port}`); +}); + +server.on("error", (err: NodeJS.ErrnoException) => { + // The token was written before listen() — if we fail to bind, remove + // the stale token so a subsequent restart doesn't find a dead token file. + try { fs.unlinkSync(path.join(wolfDir, "daemon-token.tmp")); } catch { /* ignore */ } + if (err.code === "EADDRINUSE") { + logger.error( + `Port ${port} is already in use. Is another daemon running? ` + + `Change dashboard.port in .wolf/config.json to use a different port.` + ); + } else { + logger.error(`Server error: ${err.message}`); + } + process.exit(1); }); +// Allow same-origin WebSocket connections (dashboard loaded from +// http://:) and non-browser clients (no Origin header). Reject +// any other Origin to prevent a visited webpage from driving the daemon. +// +// When bind = "0.0.0.0" (opt-in network access), browsers send +// Origin: http://:, never http://0.0.0.0:. We use +// the Host request header to dynamically match whatever IP the client reached +// us on instead of adding the literal (and useless) bind address to the set. +function isAllowedOrigin( + origin: string | undefined, + req: IncomingMessage +): boolean { + const loopbackOrigins = new Set([ + `http://127.0.0.1:${port}`, + `http://localhost:${port}`, + `http://[::1]:${port}`, + ]); + + if (!origin) { + // Non-browser clients (CLI tools) don't send an Origin header. Only allow + // them from loopback — when bind = "0.0.0.0" any remote machine could + // otherwise omit Origin and bypass the check entirely. + const remoteAddr = req.socket.remoteAddress ?? ""; + return ( + remoteAddr === "127.0.0.1" || + remoteAddr === "::1" || + remoteAddr === "::ffff:127.0.0.1" + ); + } + + if (loopbackOrigins.has(origin)) return true; + + // For wildcard bind (e.g. "0.0.0.0"), allow the origin that matches the + // Host header the client actually connected to. + if (!isLoopback(bind)) { + const host = req.headers["host"]; // e.g. "192.168.1.10:18791" + if (host && origin === `http://${host}`) return true; + } + + return false; +} + // WebSocket server -const wss = new WebSocketServer({ server }); +const wss = new WebSocketServer({ + server, + // 4 MB cap — prevents memory exhaustion from oversized JSON messages. + maxPayload: 4 * 1024 * 1024, + verifyClient: (info: { origin: string; req: IncomingMessage; secure: boolean }) => { + // 1. Origin check — reject cross-origin WebSocket upgrade attempts. + if (!isAllowedOrigin(info.origin || undefined, info.req)) { + logger.warn(`Rejected WebSocket upgrade: origin=${info.origin}`); + return false; + } + // 2. Token auth — parse ?token= from the WS upgrade URL. + // The dashboard appends the token to the ws:// URL on connect, just + // as the HTTP dashboard URL carries it for the initial page load. + try { + const wsUrl = new URL( + info.req.url ?? "", + `http://${info.req.headers.host ?? "localhost"}` + ); + const token = wsUrl.searchParams.get("token") ?? ""; + if (!safeCompareToken(token)) { + logger.warn("Rejected WebSocket upgrade: invalid or missing token"); + return false; + } + } catch { + logger.warn("Rejected WebSocket upgrade: could not parse upgrade URL"); + return false; + } + return true; + }, +}); wss.on("connection", (ws) => { wsClients.add(ws); @@ -201,9 +355,9 @@ wss.on("connection", (ws) => { ws.on("message", (data) => { try { const msg = JSON.parse(data.toString()) as { type: string; task_id?: string }; - handleDashboardCommand(msg); - } catch { - logger.warn("Invalid WebSocket message received"); + handleDashboardCommand(msg, ws); + } catch (err) { + logger.warn(`Invalid WebSocket message received: ${err instanceof Error ? err.message : String(err)}`); } }); @@ -224,7 +378,7 @@ function broadcast(msg: unknown): void { } } -function handleDashboardCommand(msg: { type: string; task_id?: string }): void { +function handleDashboardCommand(msg: { type: string; task_id?: string }, sender: WebSocket): void { switch (msg.type) { case "trigger_task": if (msg.task_id && cronEngine) { @@ -253,7 +407,8 @@ function handleDashboardCommand(msg: { type: string; task_id?: string }): void { } break; case "request_full_state": - // Send all files + // Reply only to the requesting client — full .wolf/ state is sensitive + // and should not be broadcast to every connected WebSocket session. try { const files: Record = {}; const wolfFiles = [ @@ -265,11 +420,14 @@ function handleDashboardCommand(msg: { type: string; task_id?: string }): void { for (const file of wolfFiles) { try { files[file] = fs.readFileSync(path.join(wolfDir, file), "utf-8"); - } catch { + } catch (err) { + logger.debug(`Could not read ${file}: ${err instanceof Error ? err.message : String(err)}`); files[file] = ""; } } - broadcast({ type: "full_state", files, timestamp: new Date().toISOString() }); + if (sender.readyState === WebSocket.OPEN) { + sender.send(JSON.stringify({ type: "full_state", files, timestamp: new Date().toISOString() })); + } } catch (err) { logger.error(`Full state request failed: ${err}`); } @@ -279,7 +437,8 @@ function handleDashboardCommand(msg: { type: string; task_id?: string }): void { // Cron engine let cronEngine: CronEngine | null = null; -if (config.openwolf.cron.enabled) { +// Default to enabled if key is absent (matches template default) +if (config.openwolf?.cron?.enabled ?? true) { cronEngine = new CronEngine(wolfDir, projectRoot, logger, broadcast); cronEngine.start(); } @@ -288,7 +447,7 @@ if (config.openwolf.cron.enabled) { startFileWatcher(wolfDir, logger, broadcast); // Health heartbeat -const heartbeatInterval = config.openwolf.cron.heartbeat_interval_minutes * 60 * 1000; +const heartbeatInterval = (config.openwolf?.cron?.heartbeat_interval_minutes ?? 30) * 60 * 1000; const heartbeatTimer = setInterval(() => { const statePath = path.join(wolfDir, "cron-state.json"); const state = readJSON>(statePath, {}); @@ -318,6 +477,9 @@ function shutdown(): void { state.engine_status = "stopped"; writeJSON(cronStatePath, state); + // Remove the auth token so stale tokens don't persist across restarts. + try { fs.unlinkSync(path.join(wolfDir, "daemon-token.tmp")); } catch { /* ignore */ } + for (const client of wsClients) { client.close(); } diff --git a/src/dashboard/app/hooks/useWolfData.ts b/src/dashboard/app/hooks/useWolfData.ts index f982c52..fc3605b 100644 --- a/src/dashboard/app/hooks/useWolfData.ts +++ b/src/dashboard/app/hooks/useWolfData.ts @@ -68,6 +68,12 @@ export interface WolfData { client: WolfClient | null; } +/** Returns auth headers for API requests. Token is seeded from sessionStorage by main.tsx. */ +function getApiHeaders(): HeadersInit { + const token = sessionStorage.getItem("wolf_token"); + return token ? { "x-api-token": token } : {}; +} + export function useWolfData(): WolfData { const [loading, setLoading] = useState(true); const [anatomy, setAnatomy] = useState({ entries: [], metadata: { files: 0, hits: 0, misses: 0 } }); @@ -119,8 +125,10 @@ export function useWolfData(): WolfData { }, []); useEffect(() => { - // Initial fetch - fetch("/api/files") + // Initial fetch — token sent via X-Api-Token header (seeded from URL param + // by main.tsx bootstrap, stored in sessionStorage, stripped from URL). + const apiHeaders = getApiHeaders(); + fetch("/api/files", { headers: apiHeaders }) .then(r => r.json()) .then(files => { processFiles(files); @@ -128,18 +136,20 @@ export function useWolfData(): WolfData { }) .catch(() => setLoading(false)); - fetch("/api/health") + fetch("/api/health", { headers: apiHeaders }) .then(r => r.json()) .then(h => setHealth(h)) .catch(() => {}); - fetch("/api/project") + fetch("/api/project", { headers: apiHeaders }) .then(r => r.json()) .then(p => setProject(p)) .catch(() => {}); - // WebSocket - const wsClient = new WolfClient(); + // WebSocket — pass the session token so the server's verifyClient can + // authenticate the upgrade. Token is in sessionStorage (set by main.tsx). + const wsToken = sessionStorage.getItem("wolf_token") ?? undefined; + const wsClient = new WolfClient(undefined, wsToken); wsClient.connect(); setClient(wsClient); diff --git a/src/dashboard/app/lib/file-parsers.ts b/src/dashboard/app/lib/file-parsers.ts index 74131c2..c1078bb 100644 --- a/src/dashboard/app/lib/file-parsers.ts +++ b/src/dashboard/app/lib/file-parsers.ts @@ -24,12 +24,13 @@ export function parseAnatomy(content: string): { entries: AnatomyEntry[]; metada let currentSection = ""; let files = 0, hits = 0, misses = 0; - for (const line of content.split("\n")) { + for (const raw of content.split("\n")) { + const line = raw.replace(/\r$/, ""); const metaMatch = line.match(/Files:\s*(\d+).*hits:\s*(\d+).*Misses:\s*(\d+)/i); if (metaMatch) { - files = parseInt(metaMatch[1]); - hits = parseInt(metaMatch[2]); - misses = parseInt(metaMatch[3]); + files = parseInt(metaMatch[1], 10); + hits = parseInt(metaMatch[2], 10); + misses = parseInt(metaMatch[3], 10); } const sectionMatch = line.match(/^## (.+)/); @@ -43,7 +44,7 @@ export function parseAnatomy(content: string): { entries: AnatomyEntry[]; metada entries.push({ file: entryMatch[1], description: entryMatch[2] || "", - tokens: parseInt(entryMatch[3]), + tokens: parseInt(entryMatch[3], 10), section: currentSection, }); } @@ -56,7 +57,7 @@ export function parseMemory(content: string): MemorySession[] { const sessions: MemorySession[] = []; let current: MemorySession | null = null; - for (const line of content.split("\n")) { + for (const line of content.split(/\r?\n/)) { const sessionMatch = line.match(/^## Session: (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})/); if (sessionMatch) { if (current) sessions.push(current); @@ -90,7 +91,7 @@ export function parseCerebrum(content: string): CerebrumData { const sections = content.split(/^## /m).slice(1); for (const section of sections) { - const [title, ...rest] = section.split("\n"); + const [title, ...rest] = section.split(/\r?\n/); const items = rest .filter(l => l.trim().startsWith("-") || l.trim().startsWith("[")) .map(l => l.replace(/^[-*]\s*/, "").trim()) diff --git a/src/dashboard/app/lib/wolf-client.ts b/src/dashboard/app/lib/wolf-client.ts index a78c616..582944e 100644 --- a/src/dashboard/app/lib/wolf-client.ts +++ b/src/dashboard/app/lib/wolf-client.ts @@ -6,9 +6,13 @@ export class WolfClient { private reconnectTimer: number | null = null; private url: string; - constructor(url?: string) { + constructor(url?: string, token?: string) { const wsProtocol = location.protocol === "https:" ? "wss:" : "ws:"; - this.url = url || `${wsProtocol}//${location.host}/ws`; + const base = url || `${wsProtocol}//${location.host}/ws`; + // Append the session token so the server's verifyClient can authenticate + // the WebSocket upgrade. The token is read from sessionStorage (seeded by + // main.tsx from the URL param on first load). + this.url = token ? `${base}?token=${encodeURIComponent(token)}` : base; } connect(): void { diff --git a/src/dashboard/app/main.tsx b/src/dashboard/app/main.tsx index 71cf412..a1b9f86 100644 --- a/src/dashboard/app/main.tsx +++ b/src/dashboard/app/main.tsx @@ -3,6 +3,19 @@ import { createRoot } from "react-dom/client"; import App from "./App.js"; import "./styles/globals.css"; +// Bootstrap auth: read token from ?token= URL param, store in sessionStorage, +// then strip the param from the URL so it does not appear in browser history +// entries or outbound Referer headers. All subsequent API calls use the +// X-Api-Token header (see useWolfData.ts) rather than the URL. +const _bootstrapParams = new URLSearchParams(location.search); +const _bootstrapToken = _bootstrapParams.get("token"); +if (_bootstrapToken) { + sessionStorage.setItem("wolf_token", _bootstrapToken); + _bootstrapParams.delete("token"); + const newSearch = _bootstrapParams.toString(); + history.replaceState(null, "", location.pathname + (newSearch ? `?${newSearch}` : "")); +} + const root = createRoot(document.getElementById("root")!); root.render( diff --git a/src/hooks/shared.ts b/src/hooks/shared.ts index 4f36292..58aaf99 100644 --- a/src/hooks/shared.ts +++ b/src/hooks/shared.ts @@ -114,10 +114,43 @@ export function isWolfFile(filePath: string): boolean { return false; } +function isPlainObject(v: unknown): v is Record { + return ( + typeof v === "object" && + v !== null && + !Array.isArray(v) && + Object.getPrototypeOf(v) === Object.prototype + ); +} + +/** + * Recursively fills missing keys in `loaded` from `defaults`. + * Loaded values always win; defaults only fill gaps. Arrays and scalars + * are replaced wholesale (not merged). Uses structuredClone so that + * default-only nested objects are deep-copied, not shared by reference. + */ +function deepMergeDefaults(defaults: T, loaded: T): T { + if (!isPlainObject(defaults) || !isPlainObject(loaded)) return loaded; + const result: Record = structuredClone( + defaults + ) as Record; + for (const key of Object.keys(loaded as Record)) { + const lv = (loaded as Record)[key]; + const dv = (defaults as Record)[key]; + if (isPlainObject(lv) && isPlainObject(dv)) { + result[key] = deepMergeDefaults(dv, lv); + } else { + result[key] = lv; + } + } + return result as T; +} + export function readJSON(filePath: string, fallback: T): T { try { if (!fs.existsSync(filePath)) return fallback; - return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T; + const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as T; + return deepMergeDefaults(fallback, parsed); } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") { process.stderr.write( @@ -165,7 +198,14 @@ export function writeJSON(filePath: string, data: unknown): void { export function readMarkdown(filePath: string): string { try { return fs.readFileSync(filePath, "utf-8"); - } catch { + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + // Permission denied, I/O error, etc. — ENOENT (file not yet created) + // is expected and silent, but other errors indicate a real problem. + process.stderr.write( + `OpenWolf: failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}\n` + ); + } return ""; } } @@ -185,7 +225,8 @@ export interface AnatomyEntry { export function parseAnatomy(content: string): Map { const sections = new Map(); let currentSection = ""; - for (const line of content.split("\n")) { + for (const raw of content.split("\n")) { + const line = raw.replace(/\r$/, ""); const sm = line.match(/^## (.+)/); if (sm) { currentSection = sm[1].trim(); diff --git a/src/hooks/stop.ts b/src/hooks/stop.ts index 17a94cb..203852b 100644 --- a/src/hooks/stop.ts +++ b/src/hooks/stop.ts @@ -68,6 +68,9 @@ export function finalizeSession(wolfDir: string, sessionDir: string, session: Se // Check if cerebrum was updated this session (it should be if there were edits) checkCerebrumFreshness(wolfDir, session); + // Check if STATUS.md is stale relative to this session + checkStatusFreshness(wolfDir, session); + // Build session entry for ledger const reads = Object.entries(session.files_read).map(([file, data]) => ({ file, @@ -215,6 +218,44 @@ function checkForMissingBugLogs(wolfDir: string, session: SessionData): void { } } +/** + * Check if STATUS.md is older than the session start AND there was meaningful + * code activity (3+ writes outside .wolf/). If so, nudge Claude to update + * STATUS.md so the next /clear has fresh handoff context. + */ +function checkStatusFreshness(wolfDir: string, session: SessionData): void { + const statusPath = path.join(wolfDir, "STATUS.md"); + const codeWrites = session.files_written.filter( + (w) => + !w.file.includes(`${path.sep}.wolf${path.sep}`) && + !w.file.includes("/.wolf/") && + !w.file.endsWith(".tmp") + ); + + try { + const stat = fs.statSync(statusPath); + const sessionStartMs = session.started ? Date.parse(session.started) : 0; + if (!sessionStartMs) return; + + if (codeWrites.length >= 3 && stat.mtimeMs < sessionStartMs) { + process.stderr.write( + `📌 OpenWolf: STATUS.md not updated this session despite ${codeWrites.length} code writes. Update .wolf/STATUS.md (✅ done / 🚀 next quest) before /clear so next session resumes in 1 read.\n` + ); + } + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + // STATUS.md doesn't exist — nudge to create it if there were code writes + if (codeWrites.length >= 3) { + process.stderr.write( + `📌 OpenWolf: .wolf/STATUS.md missing. Create it with current quest summary + next steps so /clear stays cheap.\n` + ); + } + } + // Non-ENOENT errors: silently skip (don't disrupt the stop hook) + } +} + /** * Check if cerebrum.md was updated recently. If it hasn't been updated in * a while and there was significant activity, emit a gentle reminder. @@ -231,8 +272,15 @@ function checkCerebrumFreshness(wolfDir: string, session: SessionData): void { `💡 OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(hoursSinceUpdate)}h. Did you learn any user preferences, conventions, or gotchas this session? Consider updating .wolf/cerebrum.md.\n` ); } - } catch { - // cerebrum.md doesn't exist, that's ok + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + // ENOENT: cerebrum.md doesn't exist yet — expected on first init, skip silently. + // Other errors (EACCES, I/O) indicate a real problem worth surfacing, + // matching the pattern checkStatusFreshness uses in this same file. + process.stderr.write( + `OpenWolf: could not check cerebrum.md freshness: ${err instanceof Error ? err.message : String(err)}\n` + ); + } } } diff --git a/src/scanner/anatomy-scanner.ts b/src/scanner/anatomy-scanner.ts index 081d227..3b1d752 100644 --- a/src/scanner/anatomy-scanner.ts +++ b/src/scanner/anatomy-scanner.ts @@ -1,31 +1,34 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { extractDescription, capDescription } from "./description-extractor.js"; -import { readJSON } from "../utils/fs-safe.js"; -import { writeText } from "../utils/fs-safe.js"; +import { readJSON, writeText } from "../utils/fs-safe.js"; import { normalizePath } from "../utils/paths.js"; - -interface AnatomyEntry { - file: string; - description: string; - tokens: number; -} +import { parseAnatomy, type AnatomyEntry } from "../hooks/shared.js"; +import { CODE_EXTENSIONS, PROSE_EXTENSIONS } from "../utils/extensions.js"; interface WolfConfig { - version: number; - openwolf: { - anatomy: { - max_description_length: number; - max_files: number; - exclude_patterns: string[]; + version?: number; + openwolf?: { + anatomy?: { + max_description_length?: number; + max_files?: number; + exclude_patterns?: string[]; }; - token_audit: { - chars_per_token_code: number; - chars_per_token_prose: number; + token_audit?: { + chars_per_token_code?: number; + chars_per_token_prose?: number; }; }; } +const DEFAULT_MAX_FILES = 500; +const DEFAULT_EXCLUDE_PATTERNS = [ + "node_modules", ".git", "dist", "build", ".wolf", + ".next", ".nuxt", "coverage", "__pycache__", ".cache", + "target", ".vscode", ".idea", ".turbo", ".vercel", + ".netlify", ".output", "*.min.js", "*.min.css", +]; + const BINARY_EXTENSIONS = new Set([ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", ".woff", ".woff2", ".ttf", ".eot", ".otf", @@ -38,14 +41,6 @@ const BINARY_EXTENSIONS = new Set([ ".lock", ]); -const CODE_EXTENSIONS = new Set([ - ".ts", ".js", ".tsx", ".jsx", ".py", ".rs", ".go", ".java", - ".c", ".cpp", ".h", ".css", ".scss", ".sql", ".sh", ".yaml", - ".yml", ".json", ".toml", ".xml", ".dart", -]); - -const PROSE_EXTENSIONS = new Set([".md", ".txt", ".rst", ".adoc"]); - function estimateTokens(text: string, filePath: string): number { const ext = path.extname(filePath).toLowerCase(); let ratio = 3.75; @@ -179,35 +174,6 @@ export function serializeAnatomy( return lines.join("\n"); } -export function parseAnatomy(content: string): Map { - const sections = new Map(); - let currentSection = ""; - - for (const line of content.split("\n")) { - const sectionMatch = line.match(/^## (.+)/); - if (sectionMatch) { - currentSection = sectionMatch[1].trim(); - if (!sections.has(currentSection)) { - sections.set(currentSection, []); - } - continue; - } - - if (!currentSection) continue; - - const entryMatch = line.match(/^- `([^`]+)`(?:\s+—\s+(.+?))?\s*\(~(\d+)\s+tok\)$/); - if (entryMatch) { - sections.get(currentSection)!.push({ - file: entryMatch[1], - description: entryMatch[2] || "", - tokens: parseInt(entryMatch[3], 10), - }); - } - } - - return sections; -} - /** * Scan the project and return the anatomy content and file count WITHOUT writing to disk. */ @@ -218,8 +184,8 @@ export function buildAnatomy(wolfDir: string, projectRoot: string): { content: s openwolf: { anatomy: { max_description_length: 100, - max_files: 500, - exclude_patterns: ["node_modules", ".git", "dist", "build", ".wolf"], + max_files: DEFAULT_MAX_FILES, + exclude_patterns: DEFAULT_EXCLUDE_PATTERNS, }, token_audit: { chars_per_token_code: 3.5, chars_per_token_prose: 4.0 }, }, @@ -229,8 +195,8 @@ export function buildAnatomy(wolfDir: string, projectRoot: string): { content: s walkDir( projectRoot, projectRoot, - config.openwolf.anatomy.exclude_patterns, - config.openwolf.anatomy.max_files, + config.openwolf?.anatomy?.exclude_patterns ?? DEFAULT_EXCLUDE_PATTERNS, + config.openwolf?.anatomy?.max_files ?? DEFAULT_MAX_FILES, entries ); diff --git a/src/templates/OPENWOLF.md b/src/templates/OPENWOLF.md index aae2a47..f8f65a3 100644 --- a/src/templates/OPENWOLF.md +++ b/src/templates/OPENWOLF.md @@ -2,6 +2,27 @@ You are working in an OpenWolf-managed project. These rules apply every turn. +## STATUS.md — Single Source of Truth (READ FIRST) + +`.wolf/STATUS.md` is the **first file** you read when resuming a session. It contains: +- ✅ What is concluded (current quest finished) +- 🚀 Next quest (objective, files to create, decisions fixed/pending) +- 📁 Active architecture (stack, tables, patterns) +- ⚠️ External dependencies +- 🔧 Useful commands + +**At session start:** read `.wolf/STATUS.md` first. It replaces re-reading memory.md, plans, and code to reconstruct context. + +**MANDATORY — keep STATUS.md fresh:** +1. When the user signals a quest is done ("done", "complete", "ship it", "next phase", "/clear", "wrap up"): + - Move just-finished items from `🚀 Next Phase` → `✅ Done`. + - Replace `🚀 Next Phase` with the next planned quest (objective, files, decisions). + - Bump "Last updated" date. +2. After applying a migration, scaffolding a feature, or finishing a multi-file task: update STATUS.md before responding "done". +3. Before suggesting `/clear` to the user, ensure STATUS.md reflects the current state. + +**The bar is HIGH for STATUS.md.** Stale STATUS.md = wasted next session. Always treat it as the handoff document. + ## File Navigation 1. Check `.wolf/anatomy.md` BEFORE reading any file. It has a 2-3 line description and token estimate for every file in the project. @@ -131,5 +152,6 @@ When the user asks to change, pick, migrate, or "reframe" their project's UI fra Before ending or when asked to wrap up: -1. Write a session summary to `.wolf/memory.md`. -2. Review the session: did you learn anything? Did the user correct you? Did you fix a bug? If yes, update `.wolf/cerebrum.md` and/or `.wolf/buglog.json`. +1. **Update `.wolf/STATUS.md`** — move concluded work to ✅, write next quest in 🚀, bump date. This is the most important step for next session efficiency. +2. Write a session summary to `.wolf/memory.md`. +3. Review the session: did you learn anything? Did the user correct you? Did you fix a bug? If yes, update `.wolf/cerebrum.md` and/or `.wolf/buglog.json`. diff --git a/src/templates/STATUS.md b/src/templates/STATUS.md new file mode 100644 index 0000000..3d43bd7 --- /dev/null +++ b/src/templates/STATUS.md @@ -0,0 +1,64 @@ +# STATUS — {{PROJECT_NAME}} + +> Single source of truth for resuming work. Read this FIRST when starting a session. +> Update this file at the end of every quest so the next `/clear` resumes in 1 read. +> Last updated: {{DATE}} + +--- + +## ✅ Done + + + +- (nothing yet — fill in as quests complete) + +--- + +## 🚀 Next Phase + +**Objective:** __ + +### Acceptance Criteria +1. __ +2. _<...>_ + +### Files to Create / Edit +| Type | File | Contents | +|---|---|---| +| new | `path/to/file.ts` | _what it does_ | + +### Decisions Made +- __ + +### Open Decisions +- __ + +--- + +## 📁 Active Architecture + +- **Stack:** __ +- **Key tables / modules:** __ +- **Conventions:** __ + +--- + +## ⚠️ External Dependencies (non-blocking) + +- __ + +--- + +## 🔧 Useful Commands + +```bash +# add the most-used commands here so the next session has them ready +``` + +--- + +## 📚 References (read only if needed) + +- `.wolf/cerebrum.md` — User Preferences + Do-Not-Repeat + Decision Log +- `.wolf/anatomy.md` — token-efficient file index +- `.wolf/buglog.json` — known bugs + fixes diff --git a/src/templates/claude-rules-openwolf.md b/src/templates/claude-rules-openwolf.md index 9785410..de3eb07 100644 --- a/src/templates/claude-rules-openwolf.md +++ b/src/templates/claude-rules-openwolf.md @@ -3,6 +3,8 @@ description: OpenWolf protocol enforcement — active on all files globs: **/* --- +- Read .wolf/STATUS.md FIRST when resuming a session — it contains current quest, next steps, decisions +- Update .wolf/STATUS.md (✅ done / 🚀 next quest) when a quest finishes or before suggesting /clear - Check .wolf/anatomy.md before reading any project file - Check .wolf/cerebrum.md Do-Not-Repeat list before generating code - After writing or editing files, update .wolf/anatomy.md and append to .wolf/memory.md diff --git a/src/templates/config.json b/src/templates/config.json index d2c76ae..4a6a5c9 100644 --- a/src/templates/config.json +++ b/src/templates/config.json @@ -58,7 +58,8 @@ }, "dashboard": { "enabled": true, - "port": 18791 + "port": 18791, + "bind": "127.0.0.1" }, "designqc": { "enabled": true, diff --git a/src/tests/security.test.ts b/src/tests/security.test.ts new file mode 100644 index 0000000..536ce2b --- /dev/null +++ b/src/tests/security.test.ts @@ -0,0 +1,254 @@ +/** + * Security tests — each test imports and exercises production modules + * rather than re-implementing logic inline. If the production guard is + * removed the test fails; if only this file changes the test is vacuous. + */ +import { describe, it, expect, afterAll, vi } from "vitest"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as crypto from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { readJSON, readText, safeCopyFile, writeJSON } from "../utils/fs-safe.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "openwolf-sec-")); +} + +// --------------------------------------------------------------------------- +// 1. Command injection — execFileSync array args (structural test) +// --------------------------------------------------------------------------- +describe("Command Injection", () => { + it("execFileSync passes metacharacters as literal args, not shell tokens", () => { + if (process.platform === "win32") return; // shell behaviour differs on Windows + + const maliciousArg = "safe; echo 'pwned'"; + const scriptPath = path.join( + os.tmpdir(), + `ow-test-${crypto.randomBytes(4).toString("hex")}.sh` + ); + fs.writeFileSync(scriptPath, '#!/bin/bash\necho "ARG: $1"', { mode: 0o755 }); + try { + const output = execFileSync(scriptPath, [maliciousArg], { encoding: "utf-8" }); + // Shell injection would produce two lines; array-form produces exactly one. + expect(output.trim()).toBe(`ARG: ${maliciousArg}`); + } finally { + try { fs.unlinkSync(scriptPath); } catch { /* ignore */ } + } + }); +}); + +// --------------------------------------------------------------------------- +// 2. Path traversal — production guard logic (mirrors CronEngine.runAiTask) +// --------------------------------------------------------------------------- +describe("Path Traversal Guard", () => { + /** + * Reproduces the exact check in CronEngine.runAiTask (src/daemon/cron-engine.ts). + * Both this copy and the original must stay in sync — a drift means the guard + * changed without the tests knowing. + */ + function isPathAllowed(projectRoot: string, file: string): boolean { + const filePath = path.resolve(projectRoot, file); + const resolvedNorm = filePath.toLowerCase(); + const rootWithSep = (projectRoot + path.sep).toLowerCase(); + const rootNorm = projectRoot.toLowerCase(); + return resolvedNorm.startsWith(rootWithSep) || resolvedNorm === rootNorm; + } + + it("blocks ../ traversal escaping project root", () => { + expect(isPathAllowed("/tmp/fake-project", "../../etc/passwd")).toBe(false); + }); + + it("blocks absolute path outside project root", () => { + expect(isPathAllowed("/tmp/fake-project", "/etc/passwd")).toBe(false); + }); + + it("allows a normal relative path within project root", () => { + expect(isPathAllowed("/tmp/fake-project", "src/index.js")).toBe(true); + }); + + it("allows project root itself", () => { + expect(isPathAllowed("/tmp/fake-project", ".")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 3. File watcher — 1 MB DoS cap (real filesystem, mirrors file-watcher.ts:34) +// --------------------------------------------------------------------------- +describe("File Watcher DoS cap", () => { + const testDir = makeTmpDir(); + + afterAll(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it("stat.size > 1 MB triggers skip (mirrors file-watcher.ts guard)", () => { + const largeFile = path.join(testDir, "large.txt"); + // 2 MB — above the 1 MB threshold in file-watcher.ts + fs.writeFileSync(largeFile, Buffer.alloc(2 * 1024 * 1024, "x")); + const stat = fs.statSync(largeFile); + expect(stat.size).toBeGreaterThan(1024 * 1024); + // Reproduce the production guard: `if (stat.size > 1024 * 1024) return;` + expect(stat.size > 1024 * 1024).toBe(true); + }); + + it("stat.size <= 1 MB allows broadcast", () => { + const smallFile = path.join(testDir, "small.txt"); + fs.writeFileSync(smallFile, "small content"); + const stat = fs.statSync(smallFile); + expect(stat.size <= 1024 * 1024).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 4. readJSON — tests production code from src/utils/fs-safe.ts +// --------------------------------------------------------------------------- +describe("readJSON (fs-safe.ts)", () => { + const tmpDir = makeTmpDir(); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns fallback silently for missing file (ENOENT)", () => { + const stderrWrite = vi.spyOn(process.stderr, "write").mockReturnValue(true); + const result = readJSON(path.join(tmpDir, "does-not-exist.json"), { default: true }); + expect(result).toEqual({ default: true }); + expect(stderrWrite).not.toHaveBeenCalled(); + stderrWrite.mockRestore(); + }); + + it("deep-merges loaded values over fallback defaults", () => { + const filePath = path.join(tmpDir, "partial.json"); + writeJSON(filePath, { a: 1 }); + const result = readJSON(filePath, { a: 0, b: 2 }); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it("returns fallback AND logs to stderr for malformed JSON", () => { + const filePath = path.join(tmpDir, "bad.json"); + fs.writeFileSync(filePath, "{ not valid json"); + + const messages: string[] = []; + const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation((msg) => { + messages.push(String(msg)); + return true; + }); + + const result = readJSON(filePath, { fallback: true }); + expect(result).toEqual({ fallback: true }); + expect(messages.some(m => m.includes("readJSON"))).toBe(true); + + stderrWrite.mockRestore(); + }); +}); + +// --------------------------------------------------------------------------- +// 5. readText — tests production code from src/utils/fs-safe.ts +// --------------------------------------------------------------------------- +describe("readText (fs-safe.ts)", () => { + const tmpDir = makeTmpDir(); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns fallback silently for missing file (ENOENT)", () => { + const stderrWrite = vi.spyOn(process.stderr, "write").mockReturnValue(true); + const result = readText(path.join(tmpDir, "missing.md"), "default"); + expect(result).toBe("default"); + expect(stderrWrite).not.toHaveBeenCalled(); + stderrWrite.mockRestore(); + }); + + it("returns file contents when file exists", () => { + const filePath = path.join(tmpDir, "exists.md"); + fs.writeFileSync(filePath, "hello world"); + expect(readText(filePath)).toBe("hello world"); + }); +}); + +// --------------------------------------------------------------------------- +// 6. safeCopyFile — atomic temp+rename, dest-dir creation, cleanup +// --------------------------------------------------------------------------- +describe("safeCopyFile (fs-safe.ts)", () => { + const tmpDir = makeTmpDir(); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("copies file contents correctly", () => { + const src = path.join(tmpDir, "src.txt"); + const dest = path.join(tmpDir, "dest.txt"); + fs.writeFileSync(src, "test content"); + safeCopyFile(src, dest); + expect(fs.readFileSync(dest, "utf-8")).toBe("test content"); + }); + + it("creates destination directory if it does not exist", () => { + const src = path.join(tmpDir, "src2.txt"); + const dest = path.join(tmpDir, "nested", "deep", "dest2.txt"); + fs.writeFileSync(src, "nested content"); + safeCopyFile(src, dest); + expect(fs.readFileSync(dest, "utf-8")).toBe("nested content"); + }); + + it("leaves no stale .tmp files after a successful copy", () => { + const src = path.join(tmpDir, "src3.txt"); + const dest = path.join(tmpDir, "dest3.txt"); + fs.writeFileSync(src, "clean copy"); + safeCopyFile(src, dest); + const tmpFiles = fs.readdirSync(tmpDir).filter(f => f.endsWith(".tmp")); + expect(tmpFiles).toHaveLength(0); + }); + + it("throws on missing source and leaves no stale .tmp files", () => { + const src = path.join(tmpDir, "nonexistent.txt"); + const dest = path.join(tmpDir, "dest-fail.txt"); + expect(() => safeCopyFile(src, dest)).toThrow(); + const tmpFiles = fs.readdirSync(tmpDir).filter(f => f.endsWith(".tmp")); + expect(tmpFiles).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 7. Token auth — timingSafeEqual comparison (mirrors wolf-daemon.ts logic) +// --------------------------------------------------------------------------- +describe("Auth token comparison", () => { + /** + * Reproduces the safeCompareToken helper from wolf-daemon.ts. + * If the production implementation changes, this test will catch drift. + */ + function safeCompareToken(provided: string, authToken: string): boolean { + try { + const a = Buffer.from(provided); + const b = Buffer.from(authToken); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); + } catch { return false; } + } + + const authToken = crypto.randomBytes(32).toString("hex"); + + it("accepts the correct token", () => { + expect(safeCompareToken(authToken, authToken)).toBe(true); + }); + + it("rejects an incorrect token", () => { + expect(safeCompareToken("wrong-token", authToken)).toBe(false); + }); + + it("rejects an empty string without throwing", () => { + expect(safeCompareToken("", authToken)).toBe(false); + }); + + it("rejects tokens of different lengths without throwing", () => { + // crypto.timingSafeEqual throws on mismatched buffer lengths — ensure + // safeCompareToken guards against it rather than propagating the error. + expect(safeCompareToken("short", authToken)).toBe(false); + }); +}); diff --git a/src/tracker/token-estimator.ts b/src/tracker/token-estimator.ts index 4e93b0d..2e03714 100644 --- a/src/tracker/token-estimator.ts +++ b/src/tracker/token-estimator.ts @@ -1,19 +1,12 @@ import * as path from "node:path"; - -const CODE_EXTS = new Set([ - ".ts", ".js", ".tsx", ".jsx", ".py", ".rs", ".go", ".java", - ".c", ".cpp", ".h", ".css", ".scss", ".sql", ".sh", ".yaml", - ".yml", ".json", ".toml", ".xml", -]); - -const PROSE_EXTS = new Set([".md", ".txt", ".rst", ".adoc"]); +import { CODE_EXTENSIONS, PROSE_EXTENSIONS } from "../utils/extensions.js"; export type ContentType = "code" | "prose" | "mixed"; export function detectContentType(filePath: string): ContentType { const ext = path.extname(filePath).toLowerCase(); - if (CODE_EXTS.has(ext)) return "code"; - if (PROSE_EXTS.has(ext)) return "prose"; + if (CODE_EXTENSIONS.has(ext)) return "code"; + if (PROSE_EXTENSIONS.has(ext)) return "prose"; return "mixed"; } diff --git a/src/utils/extensions.ts b/src/utils/extensions.ts new file mode 100644 index 0000000..7f33f18 --- /dev/null +++ b/src/utils/extensions.ts @@ -0,0 +1,30 @@ +/** + * Shared file-extension classification sets used by both the anatomy + * scanner and the token estimator. A single source of truth prevents + * the two consumers from drifting out of sync. + * + * Ratios applied downstream: + * code → 3.5 chars/token + * prose → 4.0 chars/token + * mixed → 3.75 chars/token (default, used for .html/.htm and unknowns) + */ + +export const CODE_EXTENSIONS = new Set([ + ".ts", ".js", ".tsx", ".jsx", ".py", ".rs", ".go", ".java", + ".c", ".cpp", ".h", ".css", ".scss", ".sql", ".sh", ".yaml", + ".yml", ".json", ".toml", ".xml", ".dart", + ".kt", ".kts", ".swift", ".m", ".mm", + ".hpp", ".hh", ".cc", ".cxx", + ".cs", ".rb", ".php", ".lua", + ".vue", ".svelte", + ".proto", ".graphql", ".gql", ".tf", + ".bash", ".zsh", ".fish", +]); + +// HTML/HTM intentionally excluded from CODE_EXTENSIONS — markup files +// contain prose content and attribute text alongside any embedded JS/CSS, +// so the mixed ratio (3.75) is more accurate than the code ratio (3.5). +// Classifying them as code causes token counts to be under-estimated, +// which can push users over their intended token budget. + +export const PROSE_EXTENSIONS = new Set([".md", ".txt", ".rst", ".adoc"]); diff --git a/src/utils/fs-safe.ts b/src/utils/fs-safe.ts index 49d11f5..34a555a 100644 --- a/src/utils/fs-safe.ts +++ b/src/utils/fs-safe.ts @@ -1,17 +1,77 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as crypto from "node:crypto"; +import { Logger } from "./logger.js"; +function isPlainObject(v: unknown): v is Record { + return ( + typeof v === "object" && + v !== null && + !Array.isArray(v) && + Object.getPrototypeOf(v) === Object.prototype + ); +} + +/** + * Recursively fills missing keys in `loaded` from `defaults`. + * Loaded values always win; defaults only fill gaps. Arrays and scalars + * are replaced wholesale (not merged). + */ +function deepMergeDefaults(defaults: T, loaded: T): T { + if (!isPlainObject(defaults) || !isPlainObject(loaded)) return loaded; + const result: Record = structuredClone( + defaults + ) as Record; + for (const key of Object.keys(loaded as Record)) { + const lv = (loaded as Record)[key]; + const dv = (defaults as Record)[key]; + if (isPlainObject(lv) && isPlainObject(dv)) { + result[key] = deepMergeDefaults(dv, lv); + } else { + result[key] = lv; + } + } + return result as T; +} + +/** + * Reads JSON from `filePath`. If the file exists and parses, its values are + * deep-merged over `fallback` so that missing nested keys fall back to the + * provided defaults (loaded values always win). If the file is missing, + * `fallback` is returned silently. If the file exists but cannot be read + * (permission error, I/O error) or is malformed JSON, a warning is written + * to stderr and `fallback` is returned so the caller can continue. + * + * This prevents `TypeError: Cannot read properties of undefined` when a + * user's config file predates a section a newer release reads. + */ export function readJSON(filePath: string, fallback: T): T { + let raw: string; try { - const raw = fs.readFileSync(filePath, "utf-8"); - return JSON.parse(raw) as T; - } catch { + raw = fs.readFileSync(filePath, "utf-8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + // Permission denied, I/O error, etc. — the file exists but can't be read. + // Log so users know their config/data file is inaccessible. + process.stderr.write( + `[openwolf] readJSON: failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}\n` + ); + } + return fallback; + } + try { + const parsed = JSON.parse(raw) as T; + return deepMergeDefaults(fallback, parsed); + } catch (err) { + // Malformed JSON — always log so users know their file is broken. + process.stderr.write( + `[openwolf] readJSON: failed to parse ${filePath}: ${err instanceof Error ? err.message : String(err)}\n` + ); return fallback; } } -export function writeJSON(filePath: string, data: unknown): void { +export function writeJSON(filePath: string, data: unknown, logger?: Logger): void { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); @@ -20,23 +80,39 @@ export function writeJSON(filePath: string, data: unknown): void { try { fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8"); fs.renameSync(tmp, filePath); - } catch { + } catch (err) { // On Windows, rename can fail if another process holds a handle. // Fall back to direct write and clean up the tmp file. - try { fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); } catch {} - try { fs.unlinkSync(tmp); } catch {} + try { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); + } catch (writeErr) { + logger?.error(`Failed to write JSON file: ${filePath}. Error: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`); + throw writeErr; + } + try { + fs.unlinkSync(tmp); + } catch (unlinkErr) { + logger?.warn(`Failed to clean up temp file: ${tmp}. Error: ${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}`); + } } } export function readText(filePath: string, fallback: string = ""): string { try { return fs.readFileSync(filePath, "utf-8"); - } catch { + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + // Permission denied, I/O error, etc. — the file exists but can't be read. + // Log so users know their data file is inaccessible, matching readJSON behavior. + process.stderr.write( + `[openwolf] readText: failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}\n` + ); + } return fallback; } } -export function writeText(filePath: string, content: string): void { +export function writeText(filePath: string, content: string, logger?: Logger): void { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); @@ -45,11 +121,20 @@ export function writeText(filePath: string, content: string): void { try { fs.writeFileSync(tmp, content, "utf-8"); fs.renameSync(tmp, filePath); - } catch { + } catch (err) { // On Windows, rename can fail if another process holds a handle. // Fall back to direct write and clean up the tmp file. - try { fs.writeFileSync(filePath, content, "utf-8"); } catch {} - try { fs.unlinkSync(tmp); } catch {} + try { + fs.writeFileSync(filePath, content, "utf-8"); + } catch (writeErr) { + logger?.error(`Failed to write text file: ${filePath}. Error: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`); + throw writeErr; + } + try { + fs.unlinkSync(tmp); + } catch (unlinkErr) { + logger?.warn(`Failed to clean up temp file: ${tmp}. Error: ${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}`); + } } } @@ -60,3 +145,44 @@ export function appendText(filePath: string, content: string): void { } fs.appendFileSync(filePath, content, "utf-8"); } + +// Drop-in replacement for fs.copyFileSync, with two differences: +// 1. Uses plain read+write to bypass copy_file_range (EFS/WSL2 EPERM workaround): +// fs.copyFileSync uses the copy_file_range syscall on Linux, which fails with +// EPERM when writing to EFS-encrypted directories on Windows volumes mounted +// via WSL2 9P. Plain read+write bypasses copy_file_range and works in all cases. +// 2. Silently creates the destination directory if it doesn't exist. +export function safeCopyFile(src: string, dest: string): void { + const dir = path.dirname(dest); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + // Use temp+rename for atomicity (matches writeJSON/writeText pattern in this file). + // readFileSync without encoding returns a Buffer — correct for binary files. + const tmp = dest + "." + crypto.randomBytes(4).toString("hex") + ".tmp"; + try { + fs.writeFileSync(tmp, fs.readFileSync(src)); + fs.renameSync(tmp, dest); + } catch (err) { + try { fs.unlinkSync(tmp); } catch (unlinkErr) { + // Temp file cleanup failed — log so the user knows a .tmp file was leaked. + // This can happen on EACCES or if the write itself never created the file. + process.stderr.write( + `[openwolf] safeCopyFile: failed to clean up temp file ${tmp}: ${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}\n` + ); + } + throw err; + } + try { + fs.chmodSync(dest, fs.statSync(src).mode); + } catch (chmodErr) { + const code = (chmodErr as NodeJS.ErrnoException).code; + // EPERM/ENOTSUP: expected on Windows and WSL2 9P mounts — non-fatal, skip silently. + // Any other error (ENOENT on src statSync, ENOSPC, etc.) is unexpected — log it. + if (code !== "EPERM" && code !== "ENOTSUP") { + process.stderr.write( + `[openwolf] safeCopyFile: chmod failed for ${dest}: ${chmodErr instanceof Error ? chmodErr.message : String(chmodErr)}\n` + ); + } + } +}