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
' } }, + }, + 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(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