diff --git a/.docker/app/Dockerfile b/.docker/app/Dockerfile index 01e44475e..08337b2f5 100644 --- a/.docker/app/Dockerfile +++ b/.docker/app/Dockerfile @@ -178,12 +178,14 @@ RUN cd /roomote && pnpm dlx esbuild@0.25.5 packages/db/src/run-migrations.ts \ # Runtime-only dependency tree for the externals tsup does not bundle. Install # from its standalone lockfile so unrelated workspace packages stay excluded. COPY .docker/app/runtime-deps/api /runtime-deps/ -# The zod parity check guards the Fast agent's native tool runtime: it -# symlinks an on-disk zod into each generated OpenCode tool directory via -# require.resolve, which the api bundle (noExternal) cannot satisfy on its -# own. A version drifting from the workspace would silently change what the -# generated tool sources execute against. +# Version parity keeps API externals aligned with the workspace. Zod also +# guards the Fast agent's native tool runtime, which symlinks it into each +# generated OpenCode tool directory via require.resolve. RUN cd /runtime-deps && pnpm install --prod --frozen-lockfile && \ + test "$(node -p "require(require('node:path').join(require('node:path').dirname(require.resolve('dompurify')), '../package.json')).version")" = \ + "$(cd /roomote/apps/api && node -p "require(require('node:path').join(require('node:path').dirname(require.resolve('dompurify')), '../package.json')).version")" && \ + test "$(node -p "require('jsdom/package.json').version")" = \ + "$(cd /roomote/apps/api && node -p "require('jsdom/package.json').version")" && \ test "$(node -p "require('snowflake-sdk/package.json').version")" = \ "$(cd /roomote/apps/api && node -p "require('snowflake-sdk/package.json').version")" && \ test "$(node -p "require('zod/package.json').version")" = \ diff --git a/.docker/app/runtime-deps/api/package.json b/.docker/app/runtime-deps/api/package.json index 40db5798d..305563c1e 100644 --- a/.docker/app/runtime-deps/api/package.json +++ b/.docker/app/runtime-deps/api/package.json @@ -4,6 +4,8 @@ "private": true, "packageManager": "pnpm@10.29.3", "dependencies": { + "dompurify": "3.4.13", + "jsdom": "26.1.0", "snowflake-sdk": "2.4.3", "zod": "3.25.76" }, diff --git a/.docker/app/runtime-deps/api/pnpm-lock.yaml b/.docker/app/runtime-deps/api/pnpm-lock.yaml index b25ebf577..317e9b7ed 100644 --- a/.docker/app/runtime-deps/api/pnpm-lock.yaml +++ b/.docker/app/runtime-deps/api/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: .: dependencies: + dompurify: + specifier: 3.4.13 + version: 3.4.13 + jsdom: + specifier: 26.1.0 + version: 26.1.0 snowflake-sdk: specifier: 2.4.3 version: 2.4.3(asn1.js@5.4.1) @@ -20,6 +26,9 @@ importers: packages: + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@aws-crypto/sha1-browser@5.2.0': resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} @@ -211,6 +220,34 @@ packages: resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} @@ -266,6 +303,9 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@typespec/ts-http-runtime@0.3.8': resolution: {integrity: sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==} engines: {node: '>=22.0.0'} @@ -348,10 +388,18 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -361,6 +409,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + default-browser-id@5.0.1: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} @@ -377,6 +428,9 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -387,6 +441,10 @@ packages: enabled@2.0.0: resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -503,6 +561,10 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -515,6 +577,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -533,6 +599,9 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -548,6 +617,15 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -589,6 +667,9 @@ packages: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -622,6 +703,9 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + oauth4webapi@3.8.6: resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} @@ -640,6 +724,9 @@ packages: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-expression-matcher@1.6.2: resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} engines: {node: '>=14.0.0'} @@ -648,10 +735,17 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -666,6 +760,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -689,12 +787,30 @@ packages: strnum@2.4.1: resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} @@ -705,10 +821,31 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + winston-transport@4.9.0: resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} engines: {node: '>= 12.0.0'} @@ -717,19 +854,46 @@ packages: resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} engines: {node: '>= 12.0.0'} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml-naming@0.3.0: resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} engines: {node: '>=16.0.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} snapshots: + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@aws-crypto/sha1-browser@5.2.0': dependencies: '@aws-crypto/supports-web-crypto': 5.2.0 @@ -1114,6 +1278,26 @@ snapshots: '@colors/colors@1.6.0': {} + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@dabh/diagnostics@2.0.8': dependencies: '@so-ric/colorspace': 1.1.6 @@ -1189,6 +1373,9 @@ snapshots: '@types/triple-beam@1.3.5': {} + '@types/trusted-types@2.0.7': + optional: true + '@typespec/ts-http-runtime@0.3.8': dependencies: http-proxy-agent: 7.0.2 @@ -1277,12 +1464,24 @@ snapshots: dependencies: delayed-stream: 1.0.0 + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + data-uri-to-buffer@4.0.1: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + default-browser-id@5.0.1: {} default-browser@5.5.0: @@ -1294,6 +1493,10 @@ snapshots: delayed-stream@1.0.0: {} + dompurify@3.4.13: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1306,6 +1509,8 @@ snapshots: enabled@2.0.0: {} + entities@6.0.1: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -1435,6 +1640,10 @@ snapshots: dependencies: parse-passwd: 1.0.0 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -1456,6 +1665,10 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + inherits@2.0.4: {} is-docker@2.2.1: {} @@ -1466,6 +1679,8 @@ snapshots: dependencies: is-docker: 3.0.0 + is-potential-custom-element-name@1.0.1: {} + is-stream@2.0.1: {} is-unsafe@2.0.0: {} @@ -1478,6 +1693,33 @@ snapshots: dependencies: is-inside-container: 1.0.0 + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 @@ -1531,6 +1773,8 @@ snapshots: safe-stable-stringify: 2.5.0 triple-beam: 1.4.1 + lru-cache@10.4.3: {} + math-intrinsics@1.1.0: {} mime-db@1.52.0: {} @@ -1557,6 +1801,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + nwsapi@2.2.24: {} + oauth4webapi@3.8.6: {} one-time@1.0.0: @@ -1577,16 +1823,24 @@ snapshots: parse-passwd@1.0.0: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-expression-matcher@1.6.2: {} proxy-from-env@2.1.0: {} + punycode@2.3.1: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + rrweb-cssom@0.8.0: {} + run-applescript@7.1.0: {} safe-buffer@5.2.1: {} @@ -1595,6 +1849,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + semver@7.8.5: {} simple-lru-cache@0.0.2: {} @@ -1647,18 +1905,51 @@ snapshots: dependencies: anynum: 1.0.1 + symbol-tree@3.2.4: {} + text-hex@1.0.0: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + toml@3.0.0: {} + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + triple-beam@1.4.1: {} tslib@2.8.1: {} util-deprecate@1.0.2: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-streams-polyfill@3.3.3: {} + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + winston-transport@4.9.0: dependencies: logform: 2.7.0 @@ -1679,10 +1970,16 @@ snapshots: triple-beam: 1.4.1 winston-transport: 4.9.0 + ws@8.21.3: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 + xml-name-validator@5.0.0: {} + xml-naming@0.3.0: {} + xmlchars@2.2.0: {} + zod@3.25.76: {} diff --git a/apps/api/package.json b/apps/api/package.json index 07cf8d78c..911435cf9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -42,8 +42,10 @@ "@sentry/node": "10.45.0", "@trpc/client": "^11.15.0", "@trpc/server": "^11.15.0", + "dompurify": "3.4.13", "hono": "4.12.34", "jose": "^6.2.3", + "jsdom": "26.1.0", "p-map": "^7.0.4", "snowflake-sdk": "^2.4.3", "undici": "^7.29.0", diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts index 38f4681dd..421ffaa71 100644 --- a/apps/api/tsup.config.ts +++ b/apps/api/tsup.config.ts @@ -18,9 +18,14 @@ export default defineConfig({ js: `import { createRequire as __createRequire } from 'module';const require = __createRequire(import.meta.url);`, }, esbuildOptions(options) { - // Exclude native modules and their runtime tree from bundling. + // Keep runtime-only dependency trees out of the API bundle. // tsup-level `external` is ignored when `noExternal: [/.*/]` is enabled, // so this must be applied at the esbuild layer. - options.external = [...(options.external ?? []), 'snowflake-sdk']; + options.external = [ + ...(options.external ?? []), + 'dompurify', + 'jsdom', + 'snowflake-sdk', + ]; }, }); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 5ad39f4dc..5d8930f6d 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -222,6 +222,63 @@ describe('FastSessionTranscript', () => { expect(screen.getAllByText('launch_task')).toHaveLength(1); }); + it('renders trusted Fast show_widget results with the shared sandboxed preview', () => { + render( + Ready

', + css: null, + height: 240, + textFallback: null, + }), + rawInput: { arguments: { html: '

Ready

' } }, + }, + source: 'web', + nativeSessionId: 'opencode-1', + nativeMessageId: null, + createdAt: new Date('2026-01-01T00:00:01.000Z'), + }, + ]} + />, + ); + + const iframe = screen.getByTitle('Fast status'); + expect(iframe).toHaveAttribute('sandbox', ''); + expect(iframe).toHaveAttribute('referrerpolicy', 'no-referrer'); + expect(iframe).toHaveAttribute( + 'srcdoc', + expect.stringContaining("default-src 'none'"), + ); + }); + it('cold-loads one completed tool row before an intervening kickoff', () => { render( { }); }); + it('parses trusted Fast-native show_widget results', () => { + const widget = resolveShowWidgetForToolMessage( + buildResult({ + isMcp: false, + isRoomoteNativeTool: true, + mcpServerName: null, + serverName: null, + output: JSON.stringify({ + success: true, + shown: true, + title: 'Fast status', + html: '

ready

', + css: null, + height: 280, + textFallback: null, + }), + }), + ); + + expect(widget).toMatchObject({ + title: 'Fast status', + html: '

ready

', + height: 280, + }); + }); + + it('ignores unmarked native tools named show_widget', () => { + const widget = resolveShowWidgetForToolMessage( + buildResult({ + isMcp: false, + isRoomoteNativeTool: false, + mcpServerName: null, + serverName: null, + output: JSON.stringify({ + success: true, + shown: true, + html: '

untrusted

', + }), + }), + ); + + expect(widget).toBeNull(); + }); + it('ignores in-progress rawInput and only renders successful tool results', () => { const widget = resolveShowWidgetForToolMessage( buildCall({ diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/show-widget-tool-result.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/show-widget-tool-result.ts index 66ff06c88..9c8aaab66 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/show-widget-tool-result.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/show-widget-tool-result.ts @@ -84,6 +84,15 @@ function isRoomoteMcpServer( return getMcpServerName(data) === ROOMOTE_MCP_SERVER_NAME; } +function isTrustedRoomoteWidgetTool( + data: AcpToolCallUiMessage['data'] | AcpToolResultUiMessage['data'], +): boolean { + return ( + (data.isMcp === true && isRoomoteMcpServer(data)) || + (data.isMcp === false && data.isRoomoteNativeTool === true) + ); +} + function clampWidgetHeight(height: unknown): number { if (typeof height !== 'number' || !Number.isFinite(height)) { return SHOW_WIDGET_DEFAULT_HEIGHT; @@ -139,7 +148,7 @@ function isSettledToolResult( export function resolveShowWidgetForToolMessage( msg: AcpToolCallUiMessage | AcpToolResultUiMessage, ): ShowWidgetPayload | null { - if (msg.data.isMcp !== true || !isRoomoteMcpServer(msg.data)) { + if (!isTrustedRoomoteWidgetTool(msg.data)) { return null; } diff --git a/apps/worker/package.json b/apps/worker/package.json index 6de6f357e..7ed11d942 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -32,12 +32,10 @@ "@trpc/server": "^11.15.0", "chokidar": "^4.0.3", "commander": "^14.0.2", - "dompurify": "3.4.13", "execa": "9.6.1", "hono": "4.12.34", "http-proxy": "^1.18.1", "ignore": "^7.0.5", - "jsdom": "26.1.0", "jsonwebtoken": "^9.0.3", "mime-types": "^2.1.35", "node-pty": "^1.1.0", @@ -53,7 +51,6 @@ "@roomote/config-eslint": "workspace:^", "@roomote/config-typescript": "workspace:^", "@types/http-proxy": "^1.17.17", - "@types/jsdom": "21.1.7", "@types/jsonwebtoken": "^9.0.10", "@types/mime-types": "^2.1.4", "@types/node": "^24.10.13", diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts index 04cee66e8..9a20ae3ce 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts @@ -3,6 +3,11 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { z } from 'zod'; +import { + SHOW_WIDGET_FIXED_CANVAS_GUIDANCE, + SHOW_WIDGET_HEIGHT_DESCRIPTION, + SHOW_WIDGET_THEME_GUIDANCE, +} from '@roomote/cloud-agents/show-widget'; import { MANAGE_CUSTOM_AUTOMATIONS_TOOL } from '@roomote/types'; const thisFilePath = fileURLToPath(import.meta.url); @@ -355,10 +360,9 @@ describe('roomote MCP tool descriptions', () => { expect(tool.config.description).toContain( 'Do not use it for ordinary prose', ); - expect(tool.config.description).toContain('rw-card'); - expect(tool.config.description).toContain('`--rw-*` theme variables'); + expect(tool.config.description).toContain(SHOW_WIDGET_THEME_GUIDANCE); expect(tool.config.description).toContain( - 'Keep widgets compact enough to fit without scrolling', + SHOW_WIDGET_FIXED_CANVAS_GUIDANCE, ); expect(tool.config.description).toContain( 'HTML, CSS, and inline SVG are displayed in a sandboxed iframe', @@ -374,8 +378,8 @@ describe('roomote MCP tool descriptions', () => { expect(getInputSchemaField(tool, 'css').description).toContain( '--rw-surface', ); - expect(getInputSchemaField(tool, 'height').description).toContain( - 'without a vertical scrollbar', + expect(getInputSchemaField(tool, 'height').description).toBe( + SHOW_WIDGET_HEIGHT_DESCRIPTION, ); expect(getInputSchemaField(tool, 'textFallback').description).toContain( 'originating chat surface', diff --git a/apps/worker/src/mcp/roomote-mcp-server/index.ts b/apps/worker/src/mcp/roomote-mcp-server/index.ts index ddd77eee8..950e7bc89 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/index.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/index.ts @@ -48,7 +48,12 @@ import { handleUpdateEnvironment, } from './create-environment.js'; import { handleRequestEnvironmentVariables } from './request-environment-variables.js'; -import { handleShowWidget } from './show-widget.js'; +import { + handleShowWidget, + SHOW_WIDGET_FIXED_CANVAS_GUIDANCE, + SHOW_WIDGET_HEIGHT_DESCRIPTION, + SHOW_WIDGET_THEME_GUIDANCE, +} from './show-widget.js'; import { handleSendChatReply } from './send-chat-reply.js'; import { handleRelayFastAgentChatReply } from './relay-fast-agent-chat-reply.js'; import { @@ -165,9 +170,10 @@ roomoteMcpServer.registerTool( 'Use it when a structured or visual presentation is clearer than plain text, or to demonstrate how something would look. ' + 'Examples include mock UI, status cards, tables, annotated plans, and other visual examples. ' + 'HTML, CSS, and inline SVG are displayed in a sandboxed iframe with scripts disabled and network requests blocked. ' + - 'Prefer semantic HTML with the built-in widget classes (`rw-card`, `rw-stack`, `rw-row`, `rw-grid`, `rw-stat`, `rw-badge`, `rw-callout`, `rw-muted`) so the widget follows the host task theme. ' + - 'For custom CSS, use the provided `--rw-*` theme variables instead of hard-coded colors; omit css when the built-in styles are sufficient. ' + - 'Keep widgets compact enough to fit without scrolling: use concise labels and a small number of cards, rows, or table entries, and choose a height that fully fits the expected content. Use ordinary prose or an artifact for long content. ' + + SHOW_WIDGET_THEME_GUIDANCE + + ' ' + + SHOW_WIDGET_FIXED_CANVAS_GUIDANCE + + ' ' + 'Do not use it for ordinary prose or collecting user input; use request_user_input when you need answers. ' + 'Optional textFallback is delivered to the originating chat surface (Slack/Teams/Telegram/Discord) when the task was started from chat.', inputSchema: { @@ -184,12 +190,7 @@ roomoteMcpServer.registerTool( .describe( 'Optional extra CSS injected after the built-in widget defaults. Prefer --rw-background, --rw-surface, --rw-surface-muted, --rw-text, --rw-text-muted, --rw-border, --rw-primary, --rw-accent, --rw-success, --rw-warning, and --rw-danger instead of hard-coded colors.', ), - height: z - .number() - .optional() - .describe( - 'Optional widget iframe height in pixels (clamped to 120-800; default 320). Choose the smallest height that fully fits the expected content without a vertical scrollbar.', - ), + height: z.number().optional().describe(SHOW_WIDGET_HEIGHT_DESCRIPTION), textFallback: z .string() .optional() diff --git a/apps/worker/src/mcp/roomote-mcp-server/show-widget.ts b/apps/worker/src/mcp/roomote-mcp-server/show-widget.ts index a7a73adce..4f45f3ad1 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/show-widget.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/show-widget.ts @@ -1,264 +1,26 @@ -import type { DOMPurify } from 'dompurify'; +import { + prepareShowWidget, + type ShowWidgetInput, +} from '@roomote/cloud-agents/show-widget'; -import { catchError, errorResult, successResult } from './tool-result.js'; +import { errorResult, successResult } from './tool-result.js'; import type { ToolResult } from './types.js'; -const SHOW_WIDGET_MAX_HTML_CHARS = 100_000; -const SHOW_WIDGET_MAX_CSS_CHARS = 50_000; -const SHOW_WIDGET_MAX_TITLE_CHARS = 200; -const SHOW_WIDGET_MAX_TEXT_FALLBACK_CHARS = 4_000; -const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; -export const SHOW_WIDGET_DEFAULT_HEIGHT = 320; -export const SHOW_WIDGET_MIN_HEIGHT = 120; -export const SHOW_WIDGET_MAX_HEIGHT = 800; - -type ShowWidgetInput = { - html: string; - title?: string; - css?: string; - height?: number; - textFallback?: string; -}; - -type ShowWidgetSuccess = { - success: true; - shown: true; - title: string | null; - html: string; - css: string | null; - height: number; - textFallback: string | null; -}; - -function asTrimmedString(value: unknown): string | null { - if (typeof value !== 'string') { - return null; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -let purifierPromise: Promise | null = null; - -async function getPurifier(): Promise { - if (purifierPromise) { - return purifierPromise; - } - - purifierPromise = Promise.all([import('dompurify'), import('jsdom')]).then( - ([{ default: createDOMPurify }, { JSDOM }]) => { - const purifier = createDOMPurify(new JSDOM('').window); - - purifier.addHook('uponSanitizeAttribute', (node, data) => { - const name = data.attrName.toLowerCase(); - const value = String(data.attrValue ?? '') - .trim() - .toLowerCase(); - - if ( - name.startsWith('on') || - name === 'srcdoc' || - name === 'formaction' || - name === 'xlink:href' - ) { - data.keepAttr = false; - return; - } - - if (node.namespaceURI === SVG_NAMESPACE) { - if (name === 'href' && !value.startsWith('#')) { - data.keepAttr = false; - return; - } - - const withoutLocalReferences = value.replace( - /url\(\s*(['"]?)#[^'"()\s]+\1\s*\)/gi, - '', - ); - if (/url\s*\(/i.test(withoutLocalReferences)) { - data.keepAttr = false; - return; - } - } - - if ( - name === 'href' || - name === 'src' || - name === 'poster' || - name === 'action' || - name === 'srcset' - ) { - if ( - value.startsWith('http:') || - value.startsWith('https:') || - value.startsWith('//') || - value.startsWith('javascript:') || - value.startsWith('vbscript:') || - value.startsWith('data:') || - value.startsWith('blob:') - ) { - data.keepAttr = false; - } - } - }); - - return purifier; - }, - ); - - return purifierPromise; -} - -/** - * Sanitize model HTML with a parser-backed allowlist (DOMPurify + JSDOM). - * Regex multi-pass deletion is intentionally avoided because nested tags can - * reconstitute blocked markup across passes. - */ -export async function sanitizeWidgetHtml(html: string): Promise { - const purifier = await getPurifier(); - - return purifier.sanitize(html, { - USE_PROFILES: { html: true, svg: true }, - FORBID_TAGS: [ - 'script', - 'iframe', - 'object', - 'embed', - 'form', - 'base', - 'meta', - 'link', - 'style', - 'math', - 'foreignobject', - 'image', - 'use', - 'animate', - 'animatecolor', - 'animatemotion', - 'animatetransform', - 'filter', - 'set', - 'mpath', - 'noscript', - 'template', - 'video', - 'audio', - 'source', - 'track', - 'portal', - 'frame', - 'frameset', - 'applet', - ], - ALLOW_DATA_ATTR: false, - ADD_FORBID_CONTENTS: ['script', 'style'], - }); -} - -/** - * Allow only local stylesheet declarations. Strip network-capable constructs - * and any markup that could break out of a