diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml
index 5909ff06b..21ba3519a 100644
--- a/.github/workflows/deploy-website.yml
+++ b/.github/workflows/deploy-website.yml
@@ -5,6 +5,9 @@ on:
branches: [main]
paths:
- 'packages/website/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'patches/**'
- '.github/workflows/deploy-website.yml'
workflow_dispatch:
diff --git a/package-lock.json b/package-lock.json
index 4499138bc..b80a6c87f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6720,6 +6720,51 @@
"resolved": "packages/website",
"link": true
},
+ "node_modules/@peculiar/asn1-schema": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
+ "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
+ "license": "MIT",
+ "dependencies": {
+ "asn1js": "^3.0.6",
+ "pvtsutils": "^1.3.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-schema/node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/@peculiar/json-schema": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz",
+ "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@peculiar/webcrypto": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz",
+ "integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.3.8",
+ "@peculiar/json-schema": "^1.1.12",
+ "pvtsutils": "^1.3.5",
+ "tslib": "^2.6.2",
+ "webcrypto-core": "^1.8.0"
+ },
+ "engines": {
+ "node": ">=10.12.0"
+ }
+ },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -8086,6 +8131,47 @@
"integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==",
"license": "CC0-1.0"
},
+ "node_modules/@sphereon/isomorphic-webcrypto": {
+ "version": "2.5.0-rn-crypto.2",
+ "resolved": "https://registry.npmjs.org/@sphereon/isomorphic-webcrypto/-/isomorphic-webcrypto-2.5.0-rn-crypto.2.tgz",
+ "integrity": "sha512-Vr5KcPTQ6LbKyCOLEe6DTPxMXeESYBDw/gqbjyynUtqmNtBz6dcccP3HpM9TjlwqP8c/uPEtnrxA9V81/FH8HA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/webcrypto": "^1.5.0",
+ "asmcrypto.js": "^2.3.2",
+ "b64-lite": "^1.4.0",
+ "b64u-lite": "^1.1.0",
+ "cipher-base": "^1.0.4",
+ "create-hash": "^1.2.0",
+ "inherits": "^2.0.4",
+ "md5.js": "^1.3.5",
+ "randomfill": "^1.0.4",
+ "ripemd160": "^2.0.2",
+ "sha.js": "^2.4.11",
+ "str2buf": "^1.3.0",
+ "webcrypto-shim": "^0.1.7"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "expo-crypto": "*",
+ "msrcrypto": "^1.5.8",
+ "react-native-securerandom": "^1.0.1"
+ },
+ "peerDependenciesMeta": {
+ "expo": {
+ "optional": true
+ },
+ "expo-crypto": {
+ "optional": true
+ },
+ "msrcrypt": {
+ "optional": true
+ },
+ "react-native-securerandom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@@ -10661,6 +10747,12 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
+ "node_modules/asmcrypto.js": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-2.3.2.tgz",
+ "integrity": "sha512-3FgFARf7RupsZETQ1nHnhLUUvpcttcCq1iZCaVAbJZbCZ5VNRrNyvpDyHTOb0KC3llFcsyOT/a99NZcCbeiEsA==",
+ "license": "MIT"
+ },
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
@@ -10671,6 +10763,26 @@
"safer-buffer": "~2.1.0"
}
},
+ "node_modules/asn1js": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz",
+ "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "pvtsutils": "^1.3.6",
+ "pvutils": "^1.1.3",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/asn1js/node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/assert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
@@ -10794,6 +10906,24 @@
}
}
},
+ "node_modules/b64-lite": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/b64-lite/-/b64-lite-1.4.0.tgz",
+ "integrity": "sha512-aHe97M7DXt+dkpa8fHlCcm1CnskAHrJqEfMI0KN7dwqlzml/aUe1AGt6lk51HzrSfVD67xOso84sOpr+0wIe2w==",
+ "license": "MIT",
+ "dependencies": {
+ "base-64": "^0.1.0"
+ }
+ },
+ "node_modules/b64u-lite": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/b64u-lite/-/b64u-lite-1.1.0.tgz",
+ "integrity": "sha512-929qWGDVCRph7gQVTC6koHqQIpF4vtVaSbwLltFQo44B1bYUquALswZdBKFfrJCPEnsCOvWkJsPdQYZ/Ukhw8A==",
+ "license": "MIT",
+ "dependencies": {
+ "b64-lite": "^1.4.0"
+ }
+ },
"node_modules/babel-dead-code-elimination": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz",
@@ -11042,6 +11172,11 @@
}
}
},
+ "node_modules/base-64": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz",
+ "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA=="
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -11688,6 +11823,20 @@
"node": ">=8"
}
},
+ "node_modules/cipher-base": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz",
+ "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "safe-buffer": "^5.2.1",
+ "to-buffer": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/clean-stack": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz",
@@ -12084,6 +12233,19 @@
"node": ">= 0.10"
}
},
+ "node_modules/create-hash": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
+ "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
+ "license": "MIT",
+ "dependencies": {
+ "cipher-base": "^1.0.1",
+ "inherits": "^2.0.1",
+ "md5.js": "^1.3.4",
+ "ripemd160": "^2.0.1",
+ "sha.js": "^2.4.0"
+ }
+ },
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -16118,6 +16280,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/hash-base": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz",
+ "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "readable-stream": "^2.3.8",
+ "safe-buffer": "^5.2.1",
+ "to-buffer": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -17022,7 +17199,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
- "dev": true,
"license": "MIT"
},
"node_modules/isbot": {
@@ -18213,6 +18389,17 @@
"is-buffer": "~1.1.6"
}
},
+ "node_modules/md5.js": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
+ "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==",
+ "license": "MIT",
+ "dependencies": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -19005,6 +19192,13 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/msrcrypto": {
+ "version": "1.5.8",
+ "resolved": "https://registry.npmjs.org/msrcrypto/-/msrcrypto-1.5.8.tgz",
+ "integrity": "sha512-ujZ0TRuozHKKm6eGbKHfXef7f+esIhEckmThVnz7RNyiOJd7a6MXj2JGBoL9cnPDW+JMG16MoTUh5X+XXjI66Q==",
+ "license": "Apache-2.0",
+ "peer": true
+ },
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
@@ -20456,6 +20650,12 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
@@ -20573,6 +20773,30 @@
"node": ">=6"
}
},
+ "node_modules/pvtsutils": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
+ "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/pvtsutils/node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/pvutils": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
+ "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -20817,6 +21041,25 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/randombytes": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "node_modules/randomfill": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz",
+ "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==",
+ "license": "MIT",
+ "dependencies": {
+ "randombytes": "^2.0.5",
+ "safe-buffer": "^5.1.0"
+ }
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -21224,6 +21467,18 @@
"react-native": "*"
}
},
+ "node_modules/react-native-securerandom": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/react-native-securerandom/-/react-native-securerandom-1.0.1.tgz",
+ "integrity": "sha512-ibuDnd3xi17HyD5CkilOXGPFpS9Z1oifjyHFwUl8NMzcQcpruM0ZX8ytr3A4rCeAsaBHjz69r78Xgd6vUswv1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "*"
+ },
+ "peerDependencies": {
+ "react-native": "*"
+ }
+ },
"node_modules/react-native-svg": {
"version": "15.14.0",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.14.0.tgz",
@@ -21587,6 +21842,33 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/readable-stream/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/readable-stream/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -22007,6 +22289,19 @@
"node": "*"
}
},
+ "node_modules/ripemd160": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz",
+ "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==",
+ "license": "MIT",
+ "dependencies": {
+ "hash-base": "^3.1.2",
+ "inherits": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/rollup": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz",
@@ -22495,6 +22790,26 @@
"node": ">=10"
}
},
+ "node_modules/sha.js": {
+ "version": "2.4.12",
+ "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz",
+ "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==",
+ "license": "(MIT AND BSD-3-Clause)",
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "safe-buffer": "^5.2.1",
+ "to-buffer": "^1.2.0"
+ },
+ "bin": {
+ "sha.js": "bin.js"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
@@ -23307,6 +23622,12 @@
"npm": ">=6"
}
},
+ "node_modules/str2buf": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/str2buf/-/str2buf-1.3.0.tgz",
+ "integrity": "sha512-xIBmHIUHYZDP4HyoXGHYNVmxlXLXDrtFHYT0eV6IOdEj3VO9ccaF1Ejl9Oq8iFjITllpT8FhaXb4KsNmw+3EuA==",
+ "license": "MIT"
+ },
"node_modules/stream-buffers": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz",
@@ -23337,6 +23658,21 @@
"node": ">=4"
}
},
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/string_decoder/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -24002,6 +24338,20 @@
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
"license": "BSD-3-Clause"
},
+ "node_modules/to-buffer": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
+ "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==",
+ "license": "MIT",
+ "dependencies": {
+ "isarray": "^2.0.5",
+ "safe-buffer": "^5.2.1",
+ "typed-array-buffer": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -24312,7 +24662,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
"integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.3",
@@ -24737,6 +25086,12 @@
"which-typed-array": "^1.1.2"
}
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -25133,6 +25488,31 @@
"node": ">= 14"
}
},
+ "node_modules/webcrypto-core": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz",
+ "integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.3.13",
+ "@peculiar/json-schema": "^1.1.12",
+ "asn1js": "^3.0.5",
+ "pvtsutils": "^1.3.5",
+ "tslib": "^2.7.0"
+ }
+ },
+ "node_modules/webcrypto-core/node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/webcrypto-shim": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/webcrypto-shim/-/webcrypto-shim-0.1.7.tgz",
+ "integrity": "sha512-JAvAQR5mRNRxZW2jKigWMjCMkjSdmP5cColRP1U/pTg69VgHXEi1orv5vVpJ55Zc5MIaPc1aaurzd9pjv2bveg==",
+ "license": "MIT"
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -26286,8 +26666,10 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
+ "@sphereon/isomorphic-webcrypto": "^2.5.0-rn-crypto.2",
"@tanstack/react-query": "^5.90.11",
"@tauri-apps/api": "^2.9.1",
+ "base64-js": "^1.5.1",
"buffer": "^6.0.3",
"expo": "^54.0.18",
"expo-audio": "~1.0.13",
@@ -26330,6 +26712,7 @@
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
+ "react-native-securerandom": "^1.0.1",
"react-native-svg": "^15.14.0",
"react-native-unistyles": "^3.0.15",
"react-native-web": "~0.21.0",
@@ -26439,6 +26822,8 @@
"name": "@paseo/relay",
"version": "0.1.0",
"dependencies": {
+ "@sphereon/isomorphic-webcrypto": "^2.5.0-rn-crypto.2",
+ "base64-js": "^1.5.1",
"ws": "^8.14.2"
},
"devDependencies": {
diff --git a/package.json b/package.json
index 19dc4c33b..786bf4dd1 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,6 @@
"web": "npm run web --workspace=@paseo/app",
"desktop": "npm run dev --workspace=@paseo/desktop",
"desktop:build": "npm run build --workspace=@paseo/desktop",
- "postinstall": "patch-package",
"cli": "npx tsx packages/cli/src/index.js"
},
"devDependencies": {
diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts
index 8e37e6a6f..0c8e3b1e2 100644
--- a/packages/app/e2e/global-setup.ts
+++ b/packages/app/e2e/global-setup.ts
@@ -185,6 +185,7 @@ export default async function globalSetup() {
process.env.E2E_DAEMON_PORT = String(port);
process.env.E2E_RELAY_PORT = String(relayPort);
process.env.E2E_RELAY_SESSION_ID = offer.sessionId;
+ process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
process.env.E2E_METRO_PORT = String(metroPort);
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
diff --git a/packages/app/e2e/relay-fallback-connect.spec.ts b/packages/app/e2e/relay-fallback-connect.spec.ts
index c2d3fec58..61e2f61f1 100644
--- a/packages/app/e2e/relay-fallback-connect.spec.ts
+++ b/packages/app/e2e/relay-fallback-connect.spec.ts
@@ -3,8 +3,11 @@ import { test, expect } from './fixtures';
test('connects via relay when direct endpoints fail', async ({ page }) => {
const relayPort = process.env.E2E_RELAY_PORT;
const sessionId = process.env.E2E_RELAY_SESSION_ID;
- if (!relayPort || !sessionId) {
- throw new Error('E2E_RELAY_PORT or E2E_RELAY_SESSION_ID is not set (expected from globalSetup).');
+ const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
+ if (!relayPort || !sessionId || !daemonPublicKeyB64) {
+ throw new Error(
+ 'E2E_RELAY_PORT, E2E_RELAY_SESSION_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
+ );
}
const nowIso = new Date().toISOString();
@@ -14,6 +17,7 @@ test('connects via relay when direct endpoints fail', async ({ page }) => {
id: 'relay-only-daemon',
label: 'relay-daemon',
endpoints: [relayEndpoint],
+ daemonPublicKeyB64,
relay: { endpoint: relayEndpoint, sessionId },
metadata: null,
createdAt: nowIso,
diff --git a/packages/app/metro.config.js b/packages/app/metro.config.js
index 86f1dc22d..456a5ec4e 100644
--- a/packages/app/metro.config.js
+++ b/packages/app/metro.config.js
@@ -5,6 +5,7 @@ const path = require("path");
const projectRoot = __dirname;
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
+const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
const config = getDefaultConfig(projectRoot);
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
@@ -13,7 +14,7 @@ config.resolver.resolveRequest = (context, moduleName, platform) => {
const origin = context.originModulePath;
if (
origin &&
- origin.startsWith(serverSrcRoot) &&
+ (origin.startsWith(serverSrcRoot) || origin.startsWith(relaySrcRoot)) &&
moduleName.endsWith(".js")
) {
const tsModuleName = moduleName.replace(/\.js$/, ".ts");
diff --git a/packages/app/package.json b/packages/app/package.json
index 7bac97b3d..911154623 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -17,7 +17,8 @@
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "expo export --platform web",
- "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app"
+ "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app",
+ "postinstall": "patch-package --patch-dir ../../patches"
},
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
@@ -41,8 +42,10 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
+ "@sphereon/isomorphic-webcrypto": "^2.5.0-rn-crypto.2",
"@tanstack/react-query": "^5.90.11",
"@tauri-apps/api": "^2.9.1",
+ "base64-js": "^1.5.1",
"buffer": "^6.0.3",
"expo": "^54.0.18",
"expo-audio": "~1.0.13",
@@ -85,6 +88,7 @@
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
+ "react-native-securerandom": "^1.0.1",
"react-native-svg": "^15.14.0",
"react-native-unistyles": "^3.0.15",
"react-native-web": "~0.21.0",
diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx
index 6e61e2c04..f1b062432 100644
--- a/packages/app/src/components/agent-input-area.tsx
+++ b/packages/app/src/components/agent-input-area.tsx
@@ -7,11 +7,10 @@ import {
} from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import { ArrowUp, AudioLines, MicOff, Square, Pencil } from "lucide-react-native";
+import { ArrowUp, Square, Pencil } from "lucide-react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { useVoice } from "@/contexts/voice-context";
import { useIsFocused } from "@react-navigation/native";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { generateMessageId } from "@/types/stream";
@@ -94,8 +93,6 @@ export function AgentInputArea({
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
- const { isVoiceMode, isMuted: isVoiceMuted, toggleMute: toggleVoiceMute } = useVoice();
-
const [internalInput, setInternalInput] = useState("");
const userInput = value ?? internalInput;
const setUserInput = onChangeText ?? setInternalInput;
@@ -573,35 +570,6 @@ export function AgentInputArea({
/>
)}
- {/* Voice quick mute indicator */}
- {isVoiceMode && (
-
-
- {isVoiceMuted ? (
-
- ) : (
-
- )}
-
- {isVoiceMuted ? "Voice muted" : "Voice on"}
-
-
-
- )}
-
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
({
buttonDisabled: {
opacity: 0.5,
},
- voiceIndicatorRow: {
- flexDirection: "row",
- justifyContent: "flex-end",
- },
- voiceIndicatorPill: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- paddingHorizontal: theme.spacing[3],
- height: 32,
- borderRadius: theme.borderRadius.full,
- backgroundColor: theme.colors.surface2,
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- },
- voiceIndicatorPillMuted: {
- backgroundColor: theme.colors.palette.red[600],
- borderColor: theme.colors.palette.red[800],
- },
- voiceIndicatorText: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.xs,
- fontWeight: theme.fontWeight.medium,
- },
- voiceIndicatorTextMuted: {
- color: theme.colors.surface0,
- },
queueContainer: {
flexDirection: "column",
gap: theme.spacing[2],
diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx
index 3418ddfdb..303ed00c7 100644
--- a/packages/app/src/components/agent-stream-view.tsx
+++ b/packages/app/src/components/agent-stream-view.tsx
@@ -52,6 +52,8 @@ import { ToolCallSheetProvider } from "./tool-call-sheet";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
+import { VoiceCompactIndicator } from "./voice-compact-indicator";
+import { useVoice } from "@/contexts/voice-context";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -59,6 +61,23 @@ const isToolSequenceItem = (item?: StreamItem) =>
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
const STREAM_ITEM_LOG_MIN_COUNT = 200;
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
+
+function collectAssistantTurnContent(
+ flatListData: StreamItem[],
+ startIndex: number
+): string {
+ const messages: string[] = [];
+ for (let i = startIndex; i < flatListData.length; i++) {
+ const currentItem = flatListData[i];
+ if (currentItem.kind === "user_message") {
+ break;
+ }
+ if (currentItem.kind === "assistant_message") {
+ messages.push(currentItem.text);
+ }
+ }
+ return messages.reverse().join("\n\n");
+}
export interface AgentStreamViewProps {
agentId: string;
serverId?: string;
@@ -77,6 +96,7 @@ export function AgentStreamView({
const flatListRef = useRef>(null);
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
+ const { isVoiceMode } = useVoice();
const [isNearBottom, setIsNearBottom] = useState(true);
const hasScrolledInitially = useRef(false);
const hasAutoScrolledOnce = useRef(false);
@@ -362,26 +382,6 @@ export function AgentStreamView({
[handleInlinePathPress, agent.cwd, flatListData]
);
- const collectTurnContent = useCallback(
- (index: number) => {
- const messages: string[] = [];
- // Walk backwards (older items) from current index
- // In inverted list: index+1 is the item above (older in time)
- for (let i = index; i < flatListData.length; i++) {
- const currentItem = flatListData[i];
- if (currentItem.kind === "user_message") {
- break;
- }
- if (currentItem.kind === "assistant_message") {
- messages.push(currentItem.text);
- }
- }
- // Messages are collected newest-first, reverse for chronological order
- return messages.reverse().join("\n\n");
- },
- [flatListData]
- );
-
const renderStreamItem = useCallback(
({ item, index }: ListRenderItemInfo) => {
const content = renderStreamItemContent(item, index);
@@ -391,30 +391,13 @@ export function AgentStreamView({
const gapBelow = getGapBelow(item, index);
- // Check if this is the end of a turn (before a user message or end of stream when not running)
- // In inverted list: index-1 is the next item (newer in time)
- const nextItem = flatListData[index - 1];
- const isEndOfTurn =
- item.kind !== "user_message" &&
- (nextItem?.kind === "user_message" ||
- (nextItem === undefined && agent.status !== "running"));
-
- const getContent = () => collectTurnContent(index);
-
return (
{content}
- {isEndOfTurn ? : null}
);
},
- [
- getGapBelow,
- renderStreamItemContent,
- flatListData,
- agent.status,
- collectTurnContent,
- ]
+ [getGapBelow, renderStreamItemContent]
);
const pendingPermissionItems = useMemo(
@@ -494,15 +477,33 @@ export function AgentStreamView({
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
const showWorkingIndicator = agent.status === "running";
+ const newestItem = flatListData[0] ?? null;
+ const latestTurnContent = useMemo(() => {
+ if (flatListData.length === 0) {
+ return "";
+ }
+ return collectAssistantTurnContent(flatListData, 0);
+ }, [flatListData]);
+ const showCopyButton =
+ agent.status !== "running" &&
+ newestItem?.kind === "assistant_message" &&
+ latestTurnContent.trim().length > 0;
+ const showBottomBar = showWorkingIndicator || showCopyButton || isVoiceMode;
const listHeaderComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0;
const hasHeadItems = streamHead && streamHead.length > 0;
- if (!hasPermissions && !showWorkingIndicator && !hasHeadItems) {
+ if (!hasPermissions && !showBottomBar && !hasHeadItems) {
return null;
}
+ const leftContent = showWorkingIndicator ? (
+
+ ) : showCopyButton ? (
+ latestTurnContent} />
+ ) : null;
+
return (
-
+ {showBottomBar ? (
+
+ {leftContent}
+
+ {isVoiceMode ? : null}
+
) : null}
@@ -553,14 +557,27 @@ export function AgentStreamView({
streamHead,
renderStreamItemContent,
tightGap,
+ showBottomBar,
+ showCopyButton,
+ latestTurnContent,
+ isVoiceMode,
]);
const flatListExtraData = useMemo(
() => ({
pendingPermissionCount: pendingPermissionItems.length,
showWorkingIndicator,
+ showBottomBar,
+ showCopyButton,
+ isVoiceMode,
}),
- [pendingPermissionItems.length, showWorkingIndicator]
+ [
+ pendingPermissionItems.length,
+ showWorkingIndicator,
+ showBottomBar,
+ showCopyButton,
+ isVoiceMode,
+ ]
);
// FlatList's ListHeaderComponent renders at the *bottom* when inverted.
@@ -1178,11 +1195,23 @@ const stylesheet = StyleSheet.create((theme) => ({
listHeaderContent: {
gap: theme.spacing[3],
},
- workingIndicatorWrapper: {
- alignItems: "flex-start",
+ bottomBarWrapper: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
paddingLeft: 3,
+ paddingRight: 3,
paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[2],
+ gap: theme.spacing[2],
+ },
+ bottomBarLeft: {
+ flex: 1,
+ alignItems: "flex-start",
+ },
+ bottomBarRight: {
+ flexShrink: 0,
+ alignItems: "flex-end",
},
workingIndicatorBubble: {
flexDirection: "row",
diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx
index 5ef241307..bd2c96967 100644
--- a/packages/app/src/components/dictation-controls.tsx
+++ b/packages/app/src/components/dictation-controls.tsx
@@ -179,6 +179,7 @@ export function DictationOverlay({
isDetecting
isSpeaking={false}
orientation="horizontal"
+ color={theme.colors.palette.white}
/>
({
container: {
alignSelf: "flex-start",
padding: theme.spacing[2],
- marginLeft: theme.spacing[4],
},
iconColor: {
color: theme.colors.foregroundMuted,
diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx
index a896a7d13..a6b2d9383 100644
--- a/packages/app/src/components/multi-daemon-session-host.tsx
+++ b/packages/app/src/components/multi-daemon-session-host.tsx
@@ -4,12 +4,13 @@ import {
type HostProfile,
} from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
-import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
+import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, extractHostPortFromWebSocketUrl } from "@/utils/daemon-endpoints";
import { useEffect, useMemo, useRef, useState } from "react";
function buildCandidateUrls(daemon: HostProfile): string[] {
const endpoints = daemon.endpoints ?? [];
const sessionId = daemon.relay?.sessionId ?? null;
+ const relayEndpoint = daemon.relay?.endpoint ?? null;
const lastKnownGood =
typeof daemon.metadata?.lastKnownGoodEndpoint === "string"
? (daemon.metadata.lastKnownGoodEndpoint as string)
@@ -20,38 +21,26 @@ function buildCandidateUrls(daemon: HostProfile): string[] {
if (!out.includes(url)) out.push(url);
};
- if (lastKnownGood) {
+ const isLastKnownRelay = !!relayEndpoint && lastKnownGood === relayEndpoint;
+ const directEndpoints = endpoints;
+
+ if (sessionId && relayEndpoint && isLastKnownRelay) {
+ push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
+ } else if (lastKnownGood) {
push(buildDaemonWebSocketUrl(lastKnownGood));
}
- for (const endpoint of endpoints) {
+ for (const endpoint of directEndpoints) {
push(buildDaemonWebSocketUrl(endpoint));
}
- if (sessionId) {
- if (lastKnownGood) {
- push(buildRelayWebSocketUrl({ endpoint: lastKnownGood, sessionId }));
- }
- for (const endpoint of endpoints) {
- push(buildRelayWebSocketUrl({ endpoint, sessionId }));
- }
+ if (sessionId && relayEndpoint) {
+ push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
}
return out;
}
-function extractEndpointFromUrl(url: string): string | null {
- try {
- const parsed = new URL(url);
- const host = parsed.hostname;
- const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80;
- const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
- return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
- } catch {
- return null;
- }
-}
-
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { connectionStates } = useDaemonConnections();
const { updateDaemon } = useDaemonRegistry();
@@ -86,9 +75,15 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
useEffect(() => {
if (!connection) return;
- if (status === "online") {
- if (pendingMetadataWriteRef.current) return;
- const endpoint = extractEndpointFromUrl(activeUrl);
+ if (status === "online") {
+ if (pendingMetadataWriteRef.current) return;
+ const endpoint = (() => {
+ try {
+ return extractHostPortFromWebSocketUrl(activeUrl);
+ } catch {
+ return null;
+ }
+ })();
if (!endpoint) return;
if (daemon.metadata?.lastKnownGoodEndpoint === endpoint) return;
@@ -123,7 +118,12 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
]);
return (
-
+
{null}
);
diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx
index c7485576e..4c9953e51 100644
--- a/packages/app/src/components/sliding-sidebar.tsx
+++ b/packages/app/src/components/sliding-sidebar.tsx
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState, useEffect } from "react";
-import { View, Pressable, Text, Platform, Modal, Alert } from "react-native";
+import { View, Pressable, Text, Platform, Alert } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
@@ -23,6 +23,7 @@ import { useSidebarAgentSections } from "@/hooks/use-sidebar-agent-sections";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
+import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
const DESKTOP_SIDEBAR_WIDTH = 320;
const SIDEBAR_AGENT_LIMIT = 15;
@@ -351,31 +352,31 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
-
-
-
-
- Choose a host
- {voiceEligibleHosts.map((entry) => (
- handleSelectVoiceHost(entry.daemon.id)}
- >
- {entry.daemon.label}
-
- ))}
-
- Cancel
+
+ {voiceEligibleHosts.map((entry) => (
+ handleSelectVoiceHost(entry.daemon.id)}
+ >
+ {entry.daemon.label}
-
+ ))}
+
+ Cancel
+
-
+
);
}
@@ -460,6 +461,32 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
+
+
+
+ {voiceEligibleHosts.map((entry) => (
+ handleSelectVoiceHost(entry.daemon.id)}
+ >
+ {entry.daemon.label}
+
+ ))}
+
+ Cancel
+
+
+
);
}
@@ -547,33 +574,10 @@ const styles = StyleSheet.create((theme) => ({
footerButtonTextHovered: {
color: theme.colors.foreground,
},
- hostPickerOverlay: {
- flex: 1,
- justifyContent: "center",
- alignItems: "center",
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- padding: theme.spacing[4],
- },
- hostPickerBackdrop: {
- ...StyleSheet.absoluteFillObject,
- },
- hostPickerContainer: {
- width: "100%",
- maxWidth: 360,
- backgroundColor: theme.colors.surface0,
- borderRadius: theme.borderRadius["2xl"],
- padding: theme.spacing[4],
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
+ hostPickerList: {
gap: theme.spacing[2],
},
- hostPickerTitle: {
- fontSize: theme.fontSize.base,
- fontWeight: theme.fontWeight.semibold,
- color: theme.colors.foreground,
- marginBottom: theme.spacing[2],
- },
- hostPickerButton: {
+ hostPickerOption: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
@@ -581,12 +585,11 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
- hostPickerButtonText: {
+ hostPickerOptionText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
hostPickerCancel: {
- marginTop: theme.spacing[2],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx
index 9ec0ccc05..7def41aaf 100644
--- a/packages/app/src/components/ui/dropdown-menu.tsx
+++ b/packages/app/src/components/ui/dropdown-menu.tsx
@@ -208,7 +208,7 @@ export function DropdownMenuTrigger({
collapsable={false}
disabled={disabled}
onPress={handlePress}
- style={({ pressed, hovered }) => {
+ style={({ pressed, hovered = false }) => {
if (typeof style === "function") {
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
}
diff --git a/packages/app/src/components/voice-compact-indicator.tsx b/packages/app/src/components/voice-compact-indicator.tsx
new file mode 100644
index 000000000..2db9ef2c1
--- /dev/null
+++ b/packages/app/src/components/voice-compact-indicator.tsx
@@ -0,0 +1,75 @@
+import { Pressable, View } from "react-native";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { Mic, MicOff } from "lucide-react-native";
+import { VolumeMeter } from "@/components/volume-meter";
+import { useVoice } from "@/contexts/voice-context";
+
+export function VoiceCompactIndicator() {
+ const { theme } = useUnistyles();
+ const { isVoiceMode, volume, isMuted, isDetecting, isSpeaking, toggleMute } =
+ useVoice();
+
+ if (!isVoiceMode) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ {isMuted ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ container: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ paddingLeft: theme.spacing[3],
+ paddingRight: theme.spacing[1],
+ height: 32,
+ borderRadius: theme.borderRadius.full,
+ backgroundColor: theme.colors.surface2,
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.border,
+ },
+ containerMuted: {
+ backgroundColor: theme.colors.palette.red[600],
+ borderWidth: 0,
+ },
+ meterContainer: {
+ justifyContent: "center",
+ },
+ muteButton: {
+ width: 28,
+ height: 28,
+ borderRadius: theme.borderRadius.full,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: "transparent",
+ borderWidth: 0,
+ },
+}));
diff --git a/packages/app/src/components/voice-panel.tsx b/packages/app/src/components/voice-panel.tsx
index 0043eaad3..2b07edcf7 100644
--- a/packages/app/src/components/voice-panel.tsx
+++ b/packages/app/src/components/voice-panel.tsx
@@ -1,6 +1,6 @@
-import { View, Text, Pressable } from "react-native";
+import { View, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import { MicOff, Square, AudioLines } from "lucide-react-native";
+import { MicOff, Square } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter";
import { useVoice } from "@/contexts/voice-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
@@ -20,55 +20,47 @@ export function VoicePanel() {
const activeHost = activeServerId ? connectionStates.get(activeServerId) ?? null : null;
const hostLabel = activeHost?.daemon.label ?? null;
- const hostStatus = activeHost?.status ?? null;
+ const hostSuffix = hostLabel ? ` (${hostLabel})` : "";
return (
-
-
-
- Voice
-
-
- {hostLabel ? `Host: ${hostLabel}` : "Host: unknown"}
- {hostStatus ? ` (${hostStatus})` : ""}
-
-
-
-
-
-
-
-
-
-
+
+
-
+
- void stopVoice()}
- accessibilityRole="button"
- accessibilityLabel="Stop voice mode"
- style={[styles.iconButton, styles.iconButtonStop]}
- >
-
-
+
+
+
+
+
+ void stopVoice()}
+ accessibilityRole="button"
+ accessibilityLabel={`Stop voice mode${hostSuffix}`}
+ style={[styles.iconButton, styles.iconButtonStop]}
+ >
+
+
+
);
@@ -82,35 +74,20 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
- paddingVertical: theme.spacing[3],
+ paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
- gap: theme.spacing[3],
},
- headerRow: {
+ contentRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
},
- titleRow: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- flexShrink: 0,
- },
- titleText: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.sm,
- fontWeight: theme.fontWeight.semibold,
- },
- hostText: {
+ meterContainer: {
flex: 1,
- textAlign: "right",
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.xs,
- },
- meterRow: {
justifyContent: "center",
+ alignItems: "flex-start",
+ paddingLeft: theme.spacing[1],
},
actionsRow: {
flexDirection: "row",
@@ -130,7 +107,7 @@ const styles = StyleSheet.create((theme) => ({
},
iconButtonMuted: {
backgroundColor: theme.colors.palette.red[500],
- borderColor: theme.colors.palette.red[600],
+ borderWidth: 0,
},
iconButtonStop: {
backgroundColor: theme.colors.palette.red[600],
diff --git a/packages/app/src/components/volume-meter.tsx b/packages/app/src/components/volume-meter.tsx
index 6dab61d88..dd55dd98b 100644
--- a/packages/app/src/components/volume-meter.tsx
+++ b/packages/app/src/components/volume-meter.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useRef } from "react";
+import { useEffect } from "react";
import { View } from "react-native";
import ReanimatedAnimated, {
useSharedValue,
@@ -17,16 +17,29 @@ interface VolumeMeterProps {
isDetecting?: boolean;
isSpeaking?: boolean;
orientation?: "vertical" | "horizontal";
+ variant?: "default" | "compact";
+ color?: string;
}
-export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSpeaking = false, orientation = "vertical" }: VolumeMeterProps) {
+export function VolumeMeter({
+ volume,
+ isMuted = false,
+ isDetecting = false,
+ isSpeaking = false,
+ orientation = "vertical",
+ variant = "default",
+ color,
+}: VolumeMeterProps) {
const { theme } = useUnistyles();
+ const isCompact = variant === "compact";
// Base dimensions
- const LINE_SPACING = 8;
- const LINE_WIDTH = 8;
- const MAX_HEIGHT = orientation === "horizontal" ? 30 : 50;
- const MIN_HEIGHT = orientation === "horizontal" ? 12 : 20;
+ const LINE_SPACING = isCompact ? 6 : 8;
+ const LINE_WIDTH = isCompact ? 6 : 8;
+ const MAX_HEIGHT =
+ orientation === "horizontal" ? (isCompact ? 18 : 30) : isCompact ? 32 : 50;
+ const MIN_HEIGHT =
+ orientation === "horizontal" ? (isCompact ? 8 : 12) : isCompact ? 14 : 20;
// Create shared values for 3 dots unconditionally
const line1Height = useSharedValue(MIN_HEIGHT);
@@ -127,8 +140,9 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
}
}, [volume, isMuted]);
- const lineColor = "#FFFFFF";
- const containerHeight = orientation === "horizontal" ? 60 : 100;
+ const lineColor = color ?? theme.colors.foreground;
+ const containerHeight =
+ orientation === "horizontal" ? (isCompact ? 32 : 60) : isCompact ? 64 : 100;
// Create animated styles unconditionally at top level
const line1Style = useAnimatedStyle(() => {
diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx
index bae50952c..900aa0b10 100644
--- a/packages/app/src/contexts/daemon-registry-context.tsx
+++ b/packages/app/src/contexts/daemon-registry-context.tsx
@@ -2,7 +2,6 @@ import { createContext, useCallback, useContext } from "react";
import type { ReactNode } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { z } from "zod";
import {
buildDaemonWebSocketUrl,
decodeOfferFragmentPayload,
@@ -10,6 +9,10 @@ import {
extractHostPortFromWebSocketUrl,
normalizeHostPort,
} from "@/utils/daemon-endpoints";
+import {
+ ConnectionOfferV1Schema,
+ type ConnectionOfferV1,
+} from "@server/shared/connection-offer";
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
@@ -41,15 +44,6 @@ type CreateHostInput = {
type UpdateHostInput = Partial>;
-const ConnectionOfferV1Schema = z.object({
- v: z.literal(1),
- sessionId: z.string().min(1),
- endpoints: z.array(z.string().min(1)).min(1),
- daemonPublicKeyB64: z.string().min(1),
-});
-
-export type ConnectionOfferV1 = z.infer;
-
interface DaemonRegistryContextValue {
daemons: HostProfile[];
isLoading: boolean;
@@ -201,7 +195,16 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const existing = readDaemons();
const now = new Date().toISOString();
const normalizedEndpoints = offer.endpoints.map((endpoint) => normalizeHostPort(endpoint));
- const relayEndpoint = normalizedEndpoints[normalizedEndpoints.length - 1];
+ let relayEndpoint: string | null = null;
+ if (offer.relay?.endpoint) {
+ relayEndpoint = normalizeHostPort(offer.relay.endpoint);
+ } else if (offer.relay === undefined && normalizedEndpoints.length > 0) {
+ // Back-compat: older offers encoded relay endpoint as the last entry.
+ relayEndpoint = normalizedEndpoints[normalizedEndpoints.length - 1];
+ }
+ const relay = relayEndpoint
+ ? { endpoint: relayEndpoint, sessionId: offer.sessionId }
+ : null;
const matchIndex = existing.findIndex((daemon) => daemon.daemonPublicKeyB64 === offer.daemonPublicKeyB64);
if (matchIndex !== -1) {
@@ -209,7 +212,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
...existing[matchIndex],
daemonPublicKeyB64: offer.daemonPublicKeyB64,
endpoints: normalizedEndpoints,
- relay: { endpoint: relayEndpoint, sessionId: offer.sessionId },
+ relay,
updatedAt: now,
};
const next = [...existing];
@@ -223,7 +226,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
label: deriveLabelFromEndpoint(normalizedEndpoints[0] ?? "Unnamed Host"),
endpoints: normalizedEndpoints,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
- relay: { endpoint: relayEndpoint, sessionId: offer.sessionId },
+ relay,
createdAt: now,
updatedAt: now,
metadata: null,
diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx
index 520cdf21e..b11d37e0b 100644
--- a/packages/app/src/contexts/session-context.tsx
+++ b/packages/app/src/contexts/session-context.tsx
@@ -258,6 +258,7 @@ interface SessionProviderProps {
children: ReactNode;
serverUrl: string;
serverId: string;
+ daemonPublicKeyB64?: string;
}
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
@@ -268,9 +269,10 @@ export function SessionProvider({
children,
serverUrl,
serverId,
+ daemonPublicKeyB64,
}: SessionProviderProps) {
const queryClient = useQueryClient();
- const client = useDaemonClient(serverUrl);
+ const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 });
const [connectionSnapshot, setConnectionSnapshot] =
useState(() =>
mapConnectionState(client.getConnectionState(), client.lastError)
diff --git a/packages/app/src/hooks/use-daemon-client.ts b/packages/app/src/hooks/use-daemon-client.ts
index fac963f69..27313ebc6 100644
--- a/packages/app/src/hooks/use-daemon-client.ts
+++ b/packages/app/src/hooks/use-daemon-client.ts
@@ -9,7 +9,14 @@ function runDaemonRequest(label: string, promise: Promise): void {
});
}
-export function useDaemonClient(url: string): DaemonClient {
+type DaemonClientOptions = {
+ daemonPublicKeyB64?: string;
+};
+
+export function useDaemonClient(
+ url: string,
+ options: DaemonClientOptions = {}
+): DaemonClient {
const client = useMemo(
() => {
const tauriTransportFactory = createTauriWebSocketTransportFactory();
@@ -19,9 +26,15 @@ export function useDaemonClient(url: string): DaemonClient {
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
+ e2ee: options.daemonPublicKeyB64
+ ? {
+ enabled: true,
+ daemonPublicKeyB64: options.daemonPublicKeyB64,
+ }
+ : undefined,
});
},
- [url]
+ [options.daemonPublicKeyB64, url]
);
useEffect(() => {
diff --git a/packages/app/src/polyfills/crypto.ts b/packages/app/src/polyfills/crypto.ts
index 81678babe..e327e99fc 100644
--- a/packages/app/src/polyfills/crypto.ts
+++ b/packages/app/src/polyfills/crypto.ts
@@ -7,16 +7,44 @@ declare global {
}
export function polyfillCrypto(): void {
- if (typeof globalThis.crypto === "undefined") {
- (globalThis as any).crypto = {};
+ let webcrypto: Crypto | null = null;
+ try {
+ // Prefer the React Native entrypoint to avoid the browser shim.
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const rnModule = require("@sphereon/isomorphic-webcrypto/src/react-native");
+ webcrypto = (rnModule?.default ?? rnModule) as Crypto;
+ } catch {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const fallbackModule = require("@sphereon/isomorphic-webcrypto");
+ webcrypto = (fallbackModule?.default ?? fallbackModule) as Crypto;
}
- if (typeof globalThis.crypto.randomUUID !== "function") {
+ const existing = (globalThis as any).crypto as Crypto | null | undefined;
+ let target = existing;
+ if (!target || typeof (target as Crypto).subtle === "undefined") {
+ target = (webcrypto && typeof webcrypto === "object" ? (webcrypto as Crypto) : undefined) ?? ({} as Crypto);
+ (globalThis as any).crypto = target;
+ }
+
+ const ensureSecure = (globalThis as any).crypto?.ensureSecure as
+ | (() => Promise)
+ | undefined;
+ if (typeof ensureSecure === "function") {
+ void ensureSecure();
+ }
+
+ if (typeof (globalThis as any).crypto?.randomUUID !== "function") {
+ if (!globalThis.crypto) {
+ (globalThis as any).crypto = {} as Crypto;
+ }
globalThis.crypto.randomUUID = () =>
ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`;
}
- if (typeof globalThis.crypto.getRandomValues !== "function") {
+ if (typeof (globalThis as any).crypto?.getRandomValues !== "function") {
+ if (!globalThis.crypto) {
+ (globalThis as any).crypto = {} as Crypto;
+ }
globalThis.crypto.getRandomValues = (array: T): T => {
return ExpoCrypto.getRandomValues(array as any) as T;
};
diff --git a/packages/app/src/utils/daemon-endpoints.ts b/packages/app/src/utils/daemon-endpoints.ts
index 34f134b40..f3885b5b8 100644
--- a/packages/app/src/utils/daemon-endpoints.ts
+++ b/packages/app/src/utils/daemon-endpoints.ts
@@ -1,9 +1,22 @@
import { Buffer } from "buffer";
+import {
+ buildDaemonWebSocketUrl,
+ buildRelayWebSocketUrl as buildSharedRelayWebSocketUrl,
+ deriveLabelFromEndpoint,
+ extractHostPortFromWebSocketUrl,
+ normalizeHostPort,
+ parseHostPort,
+ type HostPortParts,
+} from "@server/shared/daemon-endpoints";
-export type HostPortParts = {
- host: string;
- port: number;
- isIpv6: boolean;
+export type { HostPortParts };
+
+export {
+ buildDaemonWebSocketUrl,
+ deriveLabelFromEndpoint,
+ extractHostPortFromWebSocketUrl,
+ normalizeHostPort,
+ parseHostPort,
};
function decodeBase64UrlToUtf8(input: string): string {
@@ -17,98 +30,6 @@ export function decodeOfferFragmentPayload(encoded: string): unknown {
return JSON.parse(json) as unknown;
}
-export function parseHostPort(input: string): HostPortParts {
- const trimmed = input.trim();
- if (!trimmed) {
- throw new Error("Host is required");
- }
-
- // IPv6: [::1]:6767
- if (trimmed.startsWith("[")) {
- const match = trimmed.match(/^\[([^\]]+)\]:(\d{1,5})$/);
- if (!match) {
- throw new Error("Invalid host:port (expected [::1]:6767)");
- }
- const host = match[1].trim();
- const port = Number(match[2]);
- if (!host) throw new Error("Host is required");
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
- throw new Error("Port must be between 1 and 65535");
- }
- return { host, port, isIpv6: true };
- }
-
- const match = trimmed.match(/^(.+):(\d{1,5})$/);
- if (!match) {
- throw new Error("Invalid host:port (expected localhost:6767)");
- }
- const host = match[1].trim();
- const port = Number(match[2]);
- if (!host) throw new Error("Host is required");
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
- throw new Error("Port must be between 1 and 65535");
- }
- return { host, port, isIpv6: false };
-}
-
-export function normalizeHostPort(input: string): string {
- const { host, port, isIpv6 } = parseHostPort(input);
- if (isIpv6) {
- return `[${host}]:${port}`;
- }
- return `${host}:${port}`;
-}
-
-export function extractHostPortFromWebSocketUrl(wsUrl: string): string {
- const parsed = new URL(wsUrl);
- if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
- throw new Error("Invalid WebSocket URL protocol");
- }
- if (parsed.pathname.replace(/\/+$/, "") !== "/ws") {
- throw new Error("Invalid WebSocket URL (expected /ws path)");
- }
-
- const host = parsed.hostname;
- const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80;
- if (!host) {
- throw new Error("Invalid WebSocket URL (missing hostname)");
- }
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
- throw new Error("Invalid WebSocket URL (invalid port)");
- }
-
- const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
- const hostPort = isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
- return hostPort;
-}
-
-function shouldUseSecureWebSocket(port: number): boolean {
- return port === 443;
-}
-
-export function buildDaemonWebSocketUrl(endpoint: string): string {
- const { host, port, isIpv6 } = parseHostPort(endpoint);
- const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
- const hostPart = isIpv6 ? `[${host}]` : host;
- return `${protocol}://${hostPart}:${port}/ws`;
-}
-
export function buildRelayWebSocketUrl(params: { endpoint: string; sessionId: string }): string {
- const { host, port, isIpv6 } = parseHostPort(params.endpoint);
- const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
- const hostPart = isIpv6 ? `[${host}]` : host;
- const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
- url.searchParams.set("session", params.sessionId);
- url.searchParams.set("role", "client");
- return url.toString();
+ return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
}
-
-export function deriveLabelFromEndpoint(endpoint: string): string {
- try {
- const { host } = parseHostPort(endpoint);
- return host || "Unnamed Host";
- } catch {
- return "Unnamed Host";
- }
-}
-
diff --git a/packages/relay/package.json b/packages/relay/package.json
index 31205c40a..a6b1dfc18 100644
--- a/packages/relay/package.json
+++ b/packages/relay/package.json
@@ -5,6 +5,7 @@
"type": "module",
"exports": {
".": "./src/index.ts",
+ "./e2ee": "./src/e2ee.ts",
"./node": "./src/node-adapter.ts",
"./cloudflare": "./src/cloudflare-adapter.ts"
},
@@ -14,6 +15,8 @@
"test:watch": "vitest"
},
"dependencies": {
+ "@sphereon/isomorphic-webcrypto": "^2.5.0-rn-crypto.2",
+ "base64-js": "^1.5.1",
"ws": "^8.14.2"
},
"devDependencies": {
diff --git a/packages/relay/src/base64.ts b/packages/relay/src/base64.ts
new file mode 100644
index 000000000..f50222276
--- /dev/null
+++ b/packages/relay/src/base64.ts
@@ -0,0 +1,12 @@
+import { fromByteArray, toByteArray } from "base64-js";
+
+export function arrayBufferToBase64(buffer: ArrayBuffer): string {
+ return fromByteArray(new Uint8Array(buffer));
+}
+
+export function base64ToArrayBuffer(base64: string): ArrayBuffer {
+ const bytes = toByteArray(base64);
+ const out = new Uint8Array(bytes.byteLength);
+ out.set(bytes);
+ return out.buffer;
+}
diff --git a/packages/relay/src/crypto.ts b/packages/relay/src/crypto.ts
index 8176b0e34..6d1a2e2b0 100644
--- a/packages/relay/src/crypto.ts
+++ b/packages/relay/src/crypto.ts
@@ -1,3 +1,4 @@
+///
/**
* E2EE crypto primitives using WebCrypto API.
*
@@ -6,6 +7,8 @@
* - AES-256-GCM for authenticated encryption
*/
+import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
+
const ECDH_ALGORITHM = { name: "ECDH", namedCurve: "P-256" };
const AES_ALGORITHM = { name: "AES-GCM", length: 256 };
const IV_LENGTH = 12;
@@ -127,23 +130,3 @@ export async function decrypt(
return plaintext;
}
}
-
-// --- Helpers ---
-
-function arrayBufferToBase64(buffer: ArrayBuffer): string {
- const bytes = new Uint8Array(buffer);
- let binary = "";
- for (let i = 0; i < bytes.byteLength; i++) {
- binary += String.fromCharCode(bytes[i]);
- }
- return btoa(binary);
-}
-
-function base64ToArrayBuffer(base64: string): ArrayBuffer {
- const binary = atob(base64);
- const bytes = new Uint8Array(binary.length);
- for (let i = 0; i < binary.length; i++) {
- bytes[i] = binary.charCodeAt(i);
- }
- return bytes.buffer;
-}
diff --git a/packages/relay/src/e2ee.ts b/packages/relay/src/e2ee.ts
new file mode 100644
index 000000000..a26e97fab
--- /dev/null
+++ b/packages/relay/src/e2ee.ts
@@ -0,0 +1,7 @@
+export {
+ createClientChannel,
+ createDaemonChannel,
+ EncryptedChannel,
+} from "./encrypted-channel.js";
+export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";
+
diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts
index 03e9d1fea..23ac7a7b5 100644
--- a/packages/relay/src/encrypted-channel.ts
+++ b/packages/relay/src/encrypted-channel.ts
@@ -1,3 +1,4 @@
+///
/**
* Encrypted channel that wraps a WebSocket-like transport.
*
@@ -13,6 +14,7 @@ import {
encrypt,
decrypt,
} from "./crypto.js";
+import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
export interface Transport {
send(data: string | ArrayBuffer): void;
@@ -85,19 +87,28 @@ export async function createDaemonChannel(
reject(new Error("Handshake timeout"));
}, 10000);
+ const bufferedMessages: Array = [];
+
transport.onmessage = async (data) => {
try {
- if (typeof data !== "string") {
- throw new Error("Expected string hello message");
- }
+ const helloText =
+ typeof data === "string" ? data : new TextDecoder().decode(data);
- const msg = JSON.parse(data) as HelloMessage;
+ const msg = JSON.parse(helloText) as HelloMessage;
if (msg.type !== "hello" || !msg.key) {
throw new Error("Invalid hello message");
}
clearTimeout(timeout);
+ // Buffer any subsequent messages that arrive while we're doing async
+ // WebCrypto work to derive the shared key. Without this, it's possible
+ // for the next message (already encrypted) to be misinterpreted as a
+ // second hello, causing the handshake to fail.
+ transport.onmessage = (next) => {
+ bufferedMessages.push(next);
+ };
+
const clientPublicKey = await importPublicKey(msg.key);
const sharedKey = await deriveSharedKey(
daemonKeyPair.privateKey,
@@ -108,6 +119,10 @@ export async function createDaemonChannel(
channel.setState("open");
events.onopen?.();
+ for (const buffered of bufferedMessages) {
+ transport.onmessage?.(buffered);
+ }
+
resolve(channel);
} catch (error) {
clearTimeout(timeout);
@@ -193,23 +208,3 @@ export class EncryptedChannel {
return this.state === "open";
}
}
-
-// --- Helpers ---
-
-function arrayBufferToBase64(buffer: ArrayBuffer): string {
- const bytes = new Uint8Array(buffer);
- let binary = "";
- for (let i = 0; i < bytes.byteLength; i++) {
- binary += String.fromCharCode(bytes[i]);
- }
- return btoa(binary);
-}
-
-function base64ToArrayBuffer(base64: string): ArrayBuffer {
- const binary = atob(base64);
- const bytes = new Uint8Array(binary.length);
- for (let i = 0; i < binary.length; i++) {
- bytes[i] = binary.charCodeAt(i);
- }
- return bytes.buffer;
-}
diff --git a/packages/relay/src/node-adapter.ts b/packages/relay/src/node-adapter.ts
index f20b5e489..b8d79be74 100644
--- a/packages/relay/src/node-adapter.ts
+++ b/packages/relay/src/node-adapter.ts
@@ -72,15 +72,11 @@ export function createRelayServer(config: NodeRelayServerConfig): RelayServer {
const sessionId = url.searchParams.get("session")!;
const role = url.searchParams.get("role") as ConnectionRole;
- const connection = wrapWebSocket(ws);
+ const connection = wrapWebSocket(ws, role);
relay.addConnection(sessionId, role, connection);
- ws.on("message", (data) => {
- const message =
- data instanceof Buffer
- ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
- : String(data);
- relay.forward(sessionId, role, message as string | ArrayBuffer);
+ ws.on("message", (data, isBinary) => {
+ relay.forward(sessionId, role, normalizeWsMessageForRelay(data, isBinary));
});
ws.on("close", () => {
@@ -120,9 +116,9 @@ export function createRelayServer(config: NodeRelayServerConfig): RelayServer {
};
}
-function wrapWebSocket(ws: NodeWebSocket): RelayConnection {
+function wrapWebSocket(ws: NodeWebSocket, role: ConnectionRole): RelayConnection {
return {
- role: "server",
+ role,
send: (data) => {
if (ws.readyState === NodeWebSocket.OPEN) {
ws.send(data);
@@ -133,3 +129,52 @@ function wrapWebSocket(ws: NodeWebSocket): RelayConnection {
},
};
}
+
+function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
+ const out = new Uint8Array(buffer.byteLength);
+ out.set(buffer);
+ return out.buffer;
+}
+
+function normalizeWsMessageForRelay(data: unknown, isBinary: boolean): string | ArrayBuffer {
+ if (isBinary) {
+ return normalizeWsBinaryMessage(data);
+ }
+ return normalizeWsTextMessage(data);
+}
+
+function normalizeWsBinaryMessage(data: unknown): ArrayBuffer {
+ if (data instanceof ArrayBuffer) {
+ return data;
+ }
+ return bufferToArrayBuffer(bufferFromWsData(data));
+}
+
+function normalizeWsTextMessage(data: unknown): string {
+ if (typeof data === "string") {
+ return data;
+ }
+ return bufferFromWsData(data).toString("utf8");
+}
+
+function bufferFromWsData(data: unknown): Buffer {
+ if (Buffer.isBuffer(data)) return data;
+
+ if (Array.isArray(data)) {
+ return Buffer.concat(data.map(bufferFromWsData));
+ }
+
+ if (data instanceof ArrayBuffer) {
+ return Buffer.from(data);
+ }
+
+ if (ArrayBuffer.isView(data)) {
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
+ }
+
+ if (typeof data === "string") {
+ return Buffer.from(data, "utf8");
+ }
+
+ return Buffer.from(String(data), "utf8");
+}
diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts
index 652bd27bd..b333dacb8 100644
--- a/packages/server/src/client/daemon-client.ts
+++ b/packages/server/src/client/daemon-client.ts
@@ -53,6 +53,12 @@ import type {
AgentSessionConfig,
} from "../server/agent/agent-sdk-types.js";
import { getAgentProviderDefinition } from "../server/agent/provider-manifest.js";
+import {
+ createClientChannel,
+ type EncryptedChannel,
+ type Transport as RelayTransport,
+} from "@paseo/relay/e2ee";
+import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js";
export interface Logger {
debug(obj: object, msg?: string): void;
@@ -146,6 +152,10 @@ export type DaemonClientConfig = {
transportFactory?: DaemonTransportFactory;
webSocketFactory?: WebSocketFactory;
logger?: Logger;
+ e2ee?: {
+ enabled?: boolean;
+ daemonPublicKeyB64?: string;
+ };
reconnect?: {
enabled?: boolean;
baseDelayMs?: number;
@@ -328,11 +338,27 @@ export class DaemonClient {
try {
this.cleanupTransport();
- const transportFactory =
+ const baseTransportFactory =
this.config.transportFactory ??
createWebSocketTransportFactory(
this.config.webSocketFactory ?? defaultWebSocketFactory
);
+ const shouldUseRelayE2ee =
+ this.config.e2ee?.enabled === true &&
+ isRelayClientWebSocketUrl(this.config.url);
+
+ let transportFactory = baseTransportFactory;
+ if (shouldUseRelayE2ee) {
+ const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64;
+ if (!daemonPublicKeyB64) {
+ throw new Error("daemonPublicKeyB64 is required for relay E2EE");
+ }
+ transportFactory = createRelayE2eeTransportFactory({
+ baseFactory: baseTransportFactory,
+ daemonPublicKeyB64,
+ logger: this.logger,
+ });
+ }
const transport = transportFactory({ url: this.config.url, headers });
this.transport = transport;
@@ -2549,6 +2575,202 @@ function createWebSocketTransportFactory(
};
}
+function createRelayE2eeTransportFactory(args: {
+ baseFactory: DaemonTransportFactory;
+ daemonPublicKeyB64: string;
+ logger: Logger;
+}): DaemonTransportFactory {
+ return ({ url, headers }) => {
+ const base = args.baseFactory({ url, headers });
+ return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger);
+ };
+}
+
+function createEncryptedTransport(
+ base: DaemonTransport,
+ daemonPublicKeyB64: string,
+ logger: Logger
+): DaemonTransport {
+ let channel: EncryptedChannel | null = null;
+ let opened = false;
+ let closed = false;
+
+ const openHandlers = new Set<() => void>();
+ const closeHandlers = new Set<(event?: unknown) => void>();
+ const errorHandlers = new Set<(event?: unknown) => void>();
+ const messageHandlers = new Set<(data: unknown) => void>();
+
+ const emitOpen = () => {
+ if (opened || closed) return;
+ opened = true;
+ for (const handler of openHandlers) {
+ try {
+ handler();
+ } catch {
+ // no-op
+ }
+ }
+ };
+
+ const emitClose = (event?: unknown) => {
+ if (closed) return;
+ closed = true;
+ for (const handler of closeHandlers) {
+ try {
+ handler(event);
+ } catch {
+ // no-op
+ }
+ }
+ };
+
+ const emitError = (event?: unknown) => {
+ if (closed) return;
+ for (const handler of errorHandlers) {
+ try {
+ handler(event);
+ } catch {
+ // no-op
+ }
+ }
+ };
+
+ const emitMessage = (data: unknown) => {
+ if (closed) return;
+ for (const handler of messageHandlers) {
+ try {
+ handler(data);
+ } catch {
+ // no-op
+ }
+ }
+ };
+
+ const relayTransport: RelayTransport = {
+ send: (data) => {
+ if (typeof data === "string") {
+ base.send(data);
+ return;
+ }
+ if (data instanceof ArrayBuffer) {
+ if (typeof TextDecoder !== "undefined") {
+ base.send(new TextDecoder().decode(data));
+ return;
+ }
+ if (typeof Buffer !== "undefined") {
+ base.send(Buffer.from(data).toString("utf8"));
+ return;
+ }
+ base.send(String(data));
+ return;
+ }
+ base.send(String(data));
+ },
+ close: (code?: number, reason?: string) => base.close(code, reason),
+ onmessage: null,
+ onclose: null,
+ onerror: null,
+ };
+
+ const startHandshake = async () => {
+ try {
+ channel = await createClientChannel(relayTransport, daemonPublicKeyB64, {
+ onopen: emitOpen,
+ onmessage: (data) => emitMessage(data),
+ onclose: (code, reason) => emitClose({ code, reason }),
+ onerror: (error) => emitError(error),
+ });
+ } catch (error) {
+ logger.warn({ err: error }, "relay_e2ee_handshake_failed");
+ emitError(error);
+ base.close(1011, "E2EE handshake failed");
+ }
+ };
+
+ base.onOpen(() => {
+ void startHandshake();
+ });
+ base.onMessage((event) => {
+ relayTransport.onmessage?.(extractRelayMessageData(event));
+ });
+ base.onClose((event) => {
+ const record = event as { code?: number; reason?: string } | undefined;
+ relayTransport.onclose?.(record?.code ?? 0, record?.reason ?? "");
+ emitClose(event);
+ });
+ base.onError((event) => {
+ relayTransport.onerror?.(
+ event instanceof Error ? event : new Error(String(event))
+ );
+ emitError(event);
+ });
+
+ return {
+ send: (data) => {
+ if (!channel) {
+ throw new Error("Encrypted channel not ready");
+ }
+ void channel.send(data).catch((error) => {
+ emitError(error);
+ });
+ },
+ close: (code?: number, reason?: string) => {
+ if (channel) {
+ channel.close(code, reason);
+ } else {
+ base.close(code, reason);
+ }
+ emitClose({ code, reason });
+ },
+ onMessage: (handler) => {
+ messageHandlers.add(handler);
+ return () => messageHandlers.delete(handler);
+ },
+ onOpen: (handler) => {
+ openHandlers.add(handler);
+ if (opened) {
+ try {
+ handler();
+ } catch {
+ // no-op
+ }
+ }
+ return () => openHandlers.delete(handler);
+ },
+ onClose: (handler) => {
+ closeHandlers.add(handler);
+ if (closed) {
+ try {
+ handler();
+ } catch {
+ // no-op
+ }
+ }
+ return () => closeHandlers.delete(handler);
+ },
+ onError: (handler) => {
+ errorHandlers.add(handler);
+ return () => errorHandlers.delete(handler);
+ },
+ };
+}
+
+function extractRelayMessageData(event: unknown): string | ArrayBuffer {
+ const raw =
+ event && typeof event === "object" && "data" in event
+ ? (event as { data: unknown }).data
+ : event;
+ if (typeof raw === "string") return raw;
+ if (raw instanceof ArrayBuffer) return raw;
+ if (ArrayBuffer.isView(raw)) {
+ const view = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
+ const out = new Uint8Array(view.byteLength);
+ out.set(view);
+ return out.buffer;
+ }
+ return String(raw ?? "");
+}
+
function bindWsHandler(
ws: WebSocketLike,
event: "open" | "close" | "error" | "message",
diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts
index aea52e118..ba51f3dc5 100644
--- a/packages/server/src/server/agent/agent-storage.test.ts
+++ b/packages/server/src/server/agent/agent-storage.test.ts
@@ -162,6 +162,32 @@ describe("AgentStorage", () => {
expect(updatedRecord?.lastStatus).toBe("running");
});
+ test("applySnapshot preserves archivedAt (soft-delete) status", async () => {
+ const agentId = "agent-archived";
+ await storage.applySnapshot(
+ createManagedAgent({
+ id: agentId,
+ lifecycle: "idle",
+ })
+ );
+
+ const archivedAt = "2025-01-03T00:00:00.000Z";
+ const recordBeforeArchive = await storage.get(agentId);
+ expect(recordBeforeArchive).not.toBeNull();
+ await storage.upsert({ ...recordBeforeArchive!, archivedAt });
+
+ await storage.applySnapshot(
+ createManagedAgent({
+ id: agentId,
+ lifecycle: "running",
+ updatedAt: new Date("2025-01-04T00:00:00.000Z"),
+ })
+ );
+
+ const recordAfterSnapshot = await storage.get(agentId);
+ expect(recordAfterSnapshot?.archivedAt).toBe(archivedAt);
+ });
+
test("stores titles independently of snapshots", async () => {
await storage.applySnapshot(
createManagedAgent({
diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts
index a29d00bd5..c405b0885 100644
--- a/packages/server/src/server/agent/agent-storage.ts
+++ b/packages/server/src/server/agent/agent-storage.ts
@@ -230,6 +230,13 @@ export class AgentStorage {
? options?.internal
: (agent.internal ?? existing?.internal),
});
+
+ // Preserve soft-delete/archive status across snapshot flushes.
+ // `archivedAt` is not part of the ManagedAgent snapshot, so a naive projection
+ // would wipe it during normal persistence (including on daemon restart).
+ if (existing && existing.archivedAt !== undefined) {
+ record.archivedAt = existing.archivedAt;
+ }
await this.upsert(record);
}
diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts
index 0b8796bbd..80d2918d4 100644
--- a/packages/server/src/server/bootstrap.ts
+++ b/packages/server/src/server/bootstrap.ts
@@ -53,6 +53,7 @@ import {
createConnectionOfferV1,
encodeOfferToFragmentUrl,
} from "./connection-offer.js";
+import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { printPairingQrIfEnabled } from "./pairing-qr.js";
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
import { getOrCreateServerId } from "./server-id.js";
@@ -103,10 +104,11 @@ export interface PaseoDaemon {
export async function createPaseoDaemon(
config: PaseoDaemonConfig,
rootLogger: Logger
- ): Promise {
+): Promise {
const logger = rootLogger.child({ module: "bootstrap" });
const serverId = getOrCreateServerId(config.paseoHome, { logger });
const connectionSessionId = serverId;
+ const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
let relayTransport: RelayTransportController | null = null;
const staticDir = config.staticDir;
@@ -463,13 +465,13 @@ export async function createPaseoDaemon(
const endpoints = buildOfferEndpoints({
listenHost: listenTarget.host,
port: listenTarget.port,
- relayEnabled,
- relayEndpoint,
});
const offer = await createConnectionOfferV1({
sessionId: connectionSessionId,
endpoints,
+ daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
+ relay: relayEnabled ? { endpoint: relayEndpoint } : null,
});
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
@@ -483,6 +485,7 @@ export async function createPaseoDaemon(
attachSocket: (ws) => wsServer.attachExternalSocket(ws),
relayEndpoint,
sessionId: connectionSessionId,
+ daemonKeyPair: daemonKeyPair.keyPair,
});
}
} else {
diff --git a/packages/server/src/server/connection-offer.ts b/packages/server/src/server/connection-offer.ts
index 41b03c57d..540b1b76e 100644
--- a/packages/server/src/server/connection-offer.ts
+++ b/packages/server/src/server/connection-offer.ts
@@ -1,28 +1,18 @@
import os from "node:os";
-import { webcrypto } from "node:crypto";
-import { z } from "zod";
-export const ConnectionOfferV1Schema = z.object({
- v: z.literal(1),
- sessionId: z.string().min(1),
- endpoints: z.array(z.string().min(1)).min(1),
- daemonPublicKeyB64: z.string().min(1),
-});
-
-export type ConnectionOfferV1 = z.infer;
+import {
+ ConnectionOfferV1Schema,
+ type ConnectionOfferV1,
+} from "../shared/connection-offer.js";
type BuildOfferEndpointsArgs = {
listenHost: string;
port: number;
- relayEnabled: boolean;
- relayEndpoint: string;
};
export function buildOfferEndpoints({
listenHost,
port,
- relayEnabled,
- relayEndpoint,
}: BuildOfferEndpointsArgs): string[] {
const endpoints: string[] = [];
@@ -42,24 +32,21 @@ export function buildOfferEndpoints({
endpoints.push(`localhost:${port}`);
endpoints.push(`127.0.0.1:${port}`);
- if (relayEnabled) {
- endpoints.push(relayEndpoint);
- }
-
return dedupePreserveOrder(endpoints);
}
export async function createConnectionOfferV1(args: {
sessionId: string;
endpoints: string[];
+ daemonPublicKeyB64: string;
+ relay?: { endpoint: string } | null;
}): Promise {
- const daemonPublicKeyB64 = await generateDaemonPublicKeyB64();
-
return ConnectionOfferV1Schema.parse({
v: 1,
sessionId: args.sessionId,
endpoints: args.endpoints,
- daemonPublicKeyB64,
+ daemonPublicKeyB64: args.daemonPublicKeyB64,
+ relay: args.relay ?? null,
});
}
@@ -90,17 +77,6 @@ function getPrimaryLanIp(): string | null {
return null;
}
-async function generateDaemonPublicKeyB64(): Promise {
- const keyPair = await webcrypto.subtle.generateKey(
- { name: "ECDH", namedCurve: "P-256" },
- true,
- ["deriveBits"]
- );
-
- const raw = await webcrypto.subtle.exportKey("raw", keyPair.publicKey);
- return Buffer.from(new Uint8Array(raw)).toString("base64");
-}
-
function dedupePreserveOrder(values: string[]): string[] {
const out: string[] = [];
const seen = new Set();
diff --git a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts
index 82f9617c5..d0ddc9388 100644
--- a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts
@@ -89,6 +89,7 @@ describe("ConnectionOfferV1 (daemon E2E)", () => {
sessionId: string;
endpoints: string[];
daemonPublicKeyB64: string;
+ relay?: { endpoint: string } | null;
};
expect(offer.v).toBe(1);
@@ -97,7 +98,8 @@ describe("ConnectionOfferV1 (daemon E2E)", () => {
expect(Array.isArray(offer.endpoints)).toBe(true);
expect(offer.endpoints).toContain(`192.168.1.12:${daemon.port}`);
expect(offer.endpoints).toContain(`localhost:${daemon.port}`);
- expect(offer.endpoints).toContain("relay.paseo.sh:443");
+ expect(offer.endpoints).not.toContain("relay.paseo.sh:443");
+ expect(offer.relay?.endpoint).toBe("relay.paseo.sh:443");
expect(typeof offer.daemonPublicKeyB64).toBe("string");
expect(offer.daemonPublicKeyB64.length).toBeGreaterThan(0);
expect(() => Buffer.from(offer.daemonPublicKeyB64, "base64")).not.toThrow();
@@ -167,9 +169,11 @@ describe("ConnectionOfferV1 (daemon E2E)", () => {
const offer = decodeOfferFromFragmentUrl(offerUrl) as {
endpoints: string[];
+ relay?: { endpoint: string } | null;
};
expect(offer.endpoints).not.toContain("relay.paseo.sh:443");
+ expect(offer.relay).toBe(null);
expect(offer.endpoints).toContain(`localhost:${port}`);
expect(offer.endpoints).toContain(`192.168.1.12:${port}`);
} catch (err) {
diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts
index 7199053b7..30837c251 100644
--- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts
@@ -5,6 +5,7 @@ import { Writable } from "node:stream";
import net from "node:net";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
+import { createClientChannel, type Transport } from "@paseo/relay/e2ee";
import { createRelayServer } from "@paseo/relay/node";
function createCapturingLogger() {
@@ -34,7 +35,10 @@ function parseOfferUrlFromLogs(lines: string[]): string {
throw new Error(`pairing_offer log not found. saw ${lines.length} lines`);
}
-function decodeOfferFromFragmentUrl(url: string): { sessionId: string } {
+function decodeOfferFromFragmentUrl(url: string): {
+ sessionId: string;
+ daemonPublicKeyB64: string;
+} {
const marker = "#offer=";
const idx = url.indexOf(marker);
if (idx === -1) {
@@ -42,9 +46,10 @@ function decodeOfferFromFragmentUrl(url: string): { sessionId: string } {
}
const encoded = url.slice(idx + marker.length);
const json = Buffer.from(encoded, "base64url").toString("utf8");
- const offer = JSON.parse(json) as { sessionId?: string };
+ const offer = JSON.parse(json) as { sessionId?: string; daemonPublicKeyB64?: string };
if (!offer.sessionId) throw new Error("offer.sessionId missing");
- return { sessionId: offer.sessionId };
+ if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
+ return { sessionId: offer.sessionId, daemonPublicKeyB64: offer.daemonPublicKeyB64 };
}
async function getAvailablePort(): Promise {
@@ -62,7 +67,7 @@ async function getAvailablePort(): Promise {
});
}
-describe("Relay transport (plaintext) - daemon E2E", () => {
+describe("Relay transport (E2EE) - daemon E2E", () => {
test(
"daemon connects to relay and client ping/pong works through relay",
async () => {
@@ -82,7 +87,7 @@ describe("Relay transport (plaintext) - daemon E2E", () => {
try {
const offerUrl = parseOfferUrlFromLogs(lines);
- const { sessionId } = decodeOfferFromFragmentUrl(offerUrl);
+ const { sessionId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
const ws = new WebSocket(
`ws://127.0.0.1:${relayPort}/ws?session=${encodeURIComponent(
@@ -96,31 +101,63 @@ describe("Relay transport (plaintext) - daemon E2E", () => {
reject(new Error("timed out waiting for pong"));
}, 20000);
- ws.on("open", () => {
- ws.send(JSON.stringify({ type: "ping" }));
- });
+ const transport: Transport = {
+ send: (data) => ws.send(data),
+ close: (code?: number, reason?: string) => ws.close(code, reason),
+ onmessage: null,
+ onclose: null,
+ onerror: null,
+ };
ws.on("message", (data) => {
+ transport.onmessage?.(typeof data === "string" ? data : data.toString());
+ });
+ ws.on("close", (code, reason) => {
+ transport.onclose?.(code, reason.toString());
+ });
+ ws.on("error", (err) => {
+ transport.onerror?.(err);
+ });
+
+ ws.on("open", async () => {
try {
- const parsed = JSON.parse(data.toString());
- if (parsed && typeof parsed === "object" && "type" in parsed && (parsed as any).type === "pong") {
- clearTimeout(timeout);
- resolve(parsed);
- ws.close();
- }
+ const channel = await createClientChannel(
+ transport,
+ daemonPublicKeyB64,
+ {
+ onmessage: (data) => {
+ clearTimeout(timeout);
+ try {
+ const payload =
+ typeof data === "string" ? JSON.parse(data) : data;
+ resolve(payload);
+ } catch (err) {
+ reject(err);
+ } finally {
+ ws.close();
+ }
+ },
+ onerror: (err) => {
+ clearTimeout(timeout);
+ reject(err);
+ },
+ }
+ );
+ await channel.send(JSON.stringify({ type: "ping" }));
} catch (err) {
clearTimeout(timeout);
reject(err);
}
});
-
- ws.on("error", (err) => {
- clearTimeout(timeout);
- reject(err);
- });
});
expect(received).toEqual({ type: "pong" });
+ } catch (err) {
+ const tail = lines.slice(-50).join("");
+ // Only prints on failure to help diagnose relay handshake issues.
+ // eslint-disable-next-line no-console
+ console.error("daemon logs (tail):\n", tail);
+ throw err;
} finally {
await daemon.close();
await relay.stop();
diff --git a/packages/server/src/server/daemon-keypair.ts b/packages/server/src/server/daemon-keypair.ts
new file mode 100644
index 000000000..bfed06f3d
--- /dev/null
+++ b/packages/server/src/server/daemon-keypair.ts
@@ -0,0 +1,73 @@
+///
+import { webcrypto } from "node:crypto";
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { z } from "zod";
+import type pino from "pino";
+
+const KeyPairSchema = z.object({
+ v: z.literal(1),
+ publicKeyJwk: z.record(z.any()),
+ privateKeyJwk: z.record(z.any()),
+});
+
+type StoredKeyPair = z.infer;
+
+const KEYPAIR_FILENAME = "daemon-keypair.json";
+const ECDH_ALGORITHM = { name: "ECDH", namedCurve: "P-256" };
+
+export type DaemonKeyPairBundle = {
+ keyPair: CryptoKeyPair;
+ publicKeyB64: string;
+};
+
+export async function loadOrCreateDaemonKeyPair(
+ paseoHome: string,
+ logger?: pino.Logger
+): Promise {
+ const log = logger?.child({ module: "daemon-keypair" });
+ const filePath = path.join(paseoHome, KEYPAIR_FILENAME);
+
+ if (existsSync(filePath)) {
+ try {
+ const raw = readFileSync(filePath, "utf8");
+ const parsed = KeyPairSchema.parse(JSON.parse(raw)) as StoredKeyPair;
+ const [publicKey, privateKey] = await Promise.all([
+ webcrypto.subtle.importKey("jwk", parsed.publicKeyJwk, ECDH_ALGORITHM, true, []),
+ webcrypto.subtle.importKey("jwk", parsed.privateKeyJwk, ECDH_ALGORITHM, true, [
+ "deriveBits",
+ ]),
+ ]);
+ const publicKeyB64 = await exportPublicKeyB64(publicKey);
+ log?.info({ filePath }, "Loaded daemon keypair");
+ return { keyPair: { publicKey, privateKey }, publicKeyB64 };
+ } catch (error) {
+ log?.warn({ err: error, filePath }, "Failed to load daemon keypair, regenerating");
+ }
+ }
+
+ const keyPair = (await webcrypto.subtle.generateKey(ECDH_ALGORITHM, true, [
+ "deriveBits",
+ ])) as CryptoKeyPair;
+ const [publicKeyJwk, privateKeyJwk, publicKeyB64] = await Promise.all([
+ webcrypto.subtle.exportKey("jwk", keyPair.publicKey),
+ webcrypto.subtle.exportKey("jwk", keyPair.privateKey),
+ exportPublicKeyB64(keyPair.publicKey),
+ ]);
+
+ const payload: StoredKeyPair = {
+ v: 1,
+ publicKeyJwk: publicKeyJwk as Record,
+ privateKeyJwk: privateKeyJwk as Record,
+ };
+
+ writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
+ log?.info({ filePath }, "Saved daemon keypair");
+
+ return { keyPair, publicKeyB64 };
+}
+
+async function exportPublicKeyB64(publicKey: CryptoKey): Promise {
+ const raw = await webcrypto.subtle.exportKey("raw", publicKey);
+ return Buffer.from(new Uint8Array(raw)).toString("base64");
+}
diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts
index eb3c684c7..ca8c22093 100644
--- a/packages/server/src/server/relay-transport.ts
+++ b/packages/server/src/server/relay-transport.ts
@@ -1,22 +1,40 @@
+///
+import { EventEmitter } from "node:events";
import WebSocket from "ws";
import type pino from "pino";
+import {
+ createDaemonChannel,
+ type EncryptedChannel,
+ type Transport as RelayTransport,
+} from "@paseo/relay/e2ee";
+import { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js";
type RelayTransportOptions = {
logger: pino.Logger;
- attachSocket: (ws: WebSocket) => Promise;
+ attachSocket: (ws: RelaySocketLike) => Promise;
relayEndpoint: string; // "host:port"
sessionId: string;
+ daemonKeyPair?: CryptoKeyPair;
};
export type RelayTransportController = {
stop: () => Promise;
};
+type RelaySocketLike = {
+ readyState: number;
+ send: (data: string) => void;
+ close: (code?: number, reason?: string) => void;
+ on: (event: "message" | "close" | "error", listener: (...args: any[]) => void) => void;
+ once: (event: "close" | "error", listener: (...args: any[]) => void) => void;
+};
+
export function startRelayTransport({
logger,
attachSocket,
relayEndpoint,
sessionId,
+ daemonKeyPair,
}: RelayTransportOptions): RelayTransportController {
const relayLogger = logger.child({ module: "relay-transport" });
@@ -44,7 +62,11 @@ export function startRelayTransport({
const connect = (): void => {
if (stopped) return;
- const url = buildRelayWebSocketUrl(relayEndpoint, sessionId, "server");
+ const url = buildRelayWebSocketUrl({
+ endpoint: relayEndpoint,
+ sessionId,
+ role: "server",
+ });
const socket = new WebSocket(url);
ws = socket;
@@ -56,7 +78,11 @@ export function startRelayTransport({
if (attached) return;
attached = true;
- void attachSocket(socket);
+ if (daemonKeyPair) {
+ void attachEncryptedSocket(socket, daemonKeyPair, relayLogger, attachSocket);
+ } else {
+ void attachSocket(socket);
+ }
});
socket.on("close", (code, reason) => {
@@ -90,46 +116,140 @@ export function startRelayTransport({
return { stop };
}
-function buildRelayWebSocketUrl(
- relayEndpoint: string,
- sessionId: string,
- role: "server" | "client"
-): string {
- const { host, port } = parseHostPort(relayEndpoint);
- const protocol = port === 443 ? "wss" : "ws";
- return `${protocol}://${host}:${port}/ws?session=${encodeURIComponent(
- sessionId
- )}&role=${role}`;
+async function attachEncryptedSocket(
+ socket: WebSocket,
+ daemonKeyPair: CryptoKeyPair,
+ logger: pino.Logger,
+ attachSocket: (ws: RelaySocketLike) => Promise
+): Promise {
+ try {
+ const relayTransport = createRelayTransportAdapter(socket);
+ const emitter = new EventEmitter();
+ const channel = await createDaemonChannel(relayTransport, daemonKeyPair, {
+ onmessage: (data) => emitter.emit("message", data),
+ onclose: (code, reason) => emitter.emit("close", code, reason),
+ onerror: (error) => {
+ logger.warn({ err: error }, "relay_e2ee_error");
+ emitter.emit("error", error);
+ },
+ });
+ const encryptedSocket = createEncryptedSocket(channel, emitter);
+ await attachSocket(encryptedSocket);
+ } catch (error) {
+ logger.warn({ err: error }, "relay_e2ee_handshake_failed");
+ try {
+ socket.close(1011, "E2EE handshake failed");
+ } catch {
+ // ignore
+ }
+ }
}
-function parseHostPort(input: string): { host: string; port: number } {
- const trimmed = input.trim();
+function createRelayTransportAdapter(socket: WebSocket): RelayTransport {
+ const relayTransport: RelayTransport = {
+ send: (data) => socket.send(data),
+ close: (code?: number, reason?: string) => socket.close(code, reason),
+ onmessage: null,
+ onclose: null,
+ onerror: null,
+ };
- if (trimmed.startsWith("[")) {
- const endIdx = trimmed.indexOf("]");
- if (endIdx === -1) {
- throw new Error(`Invalid relay endpoint: ${input}`);
- }
- const host = trimmed.slice(1, endIdx);
- const rest = trimmed.slice(endIdx + 1);
- if (!rest.startsWith(":")) {
- throw new Error(`Invalid relay endpoint: ${input}`);
- }
- const port = Number.parseInt(rest.slice(1), 10);
- if (!Number.isFinite(port)) {
- throw new Error(`Invalid relay port: ${input}`);
- }
- return { host: `[${host}]`, port };
- }
+ socket.on("message", (data, isBinary) => {
+ relayTransport.onmessage?.(normalizeMessageData(data, isBinary));
+ });
+ socket.on("close", (code, reason) => {
+ relayTransport.onclose?.(code, reason.toString());
+ });
+ socket.on("error", (err) => {
+ relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err)));
+ });
- const idx = trimmed.lastIndexOf(":");
- if (idx === -1) {
- throw new Error(`Invalid relay endpoint (expected host:port): ${input}`);
- }
- const host = trimmed.slice(0, idx);
- const port = Number.parseInt(trimmed.slice(idx + 1), 10);
- if (!host || !Number.isFinite(port)) {
- throw new Error(`Invalid relay endpoint: ${input}`);
- }
- return { host, port };
+ return relayTransport;
}
+
+function createEncryptedSocket(
+ channel: EncryptedChannel,
+ emitter: EventEmitter
+): RelaySocketLike {
+ let readyState = 1;
+
+ channel.setState("open");
+
+ const close = (code?: number, reason?: string) => {
+ if (readyState === 3) return;
+ readyState = 3;
+ channel.close(code, reason);
+ };
+
+ emitter.on("close", () => {
+ if (readyState === 3) return;
+ readyState = 3;
+ });
+
+ return {
+ get readyState() {
+ return readyState;
+ },
+ send: (data) => {
+ void channel.send(data).catch((error) => {
+ emitter.emit("error", error);
+ });
+ },
+ close,
+ on: (event, listener) => {
+ emitter.on(event, listener);
+ },
+ once: (event, listener) => {
+ emitter.once(event, listener);
+ },
+ };
+}
+
+function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer {
+ if (!isBinary) {
+ if (typeof data === "string") return data;
+ const buffer = bufferFromWsData(data);
+ if (buffer) return buffer.toString("utf8");
+ return String(data);
+ }
+
+ if (data instanceof ArrayBuffer) return data;
+
+ const buffer = bufferFromWsData(data);
+ if (buffer) {
+ const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
+ const out = new Uint8Array(view.byteLength);
+ out.set(view);
+ return out.buffer;
+ }
+
+ return String(data);
+}
+
+function bufferFromWsData(data: unknown): Buffer | null {
+ if (Buffer.isBuffer(data)) return data;
+ if (Array.isArray(data)) {
+ const buffers: Buffer[] = [];
+ for (const part of data) {
+ if (Buffer.isBuffer(part)) {
+ buffers.push(part);
+ } else if (part instanceof ArrayBuffer) {
+ buffers.push(Buffer.from(part));
+ } else if (ArrayBuffer.isView(part)) {
+ buffers.push(Buffer.from(part.buffer, part.byteOffset, part.byteLength));
+ } else if (typeof part === "string") {
+ buffers.push(Buffer.from(part, "utf8"));
+ } else {
+ return null;
+ }
+ }
+ return Buffer.concat(buffers);
+ }
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
+ if (ArrayBuffer.isView(data)) {
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
+ }
+ return null;
+}
+
+// buildRelayWebSocketUrl + parseHostPort live in ../shared/daemon-endpoints.ts
diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts
index faadf882a..2a75cb2eb 100644
--- a/packages/server/src/server/websocket-server.ts
+++ b/packages/server/src/server/websocket-server.ts
@@ -101,7 +101,9 @@ export class VoiceAssistantWebSocketServer {
this.bridge.broadcast(message);
}
- public async attachExternalSocket(ws: Parameters[0]): Promise {
+ public async attachExternalSocket(
+ ws: Parameters[0]
+ ): Promise {
const fakeRequest = {
url: "/ws",
headers: {},
diff --git a/packages/server/src/server/websocket-session-bridge.ts b/packages/server/src/server/websocket-session-bridge.ts
index 06cd515ca..bd8f3226a 100644
--- a/packages/server/src/server/websocket-session-bridge.ts
+++ b/packages/server/src/server/websocket-session-bridge.ts
@@ -1,5 +1,4 @@
import type { IncomingMessage } from "http";
-import type { WebSocket } from "ws";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { join } from "path";
import {
@@ -22,9 +21,30 @@ import type pino from "pino";
export type AgentMcpTransportFactory = () => Promise;
+function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffer {
+ if (typeof data === "string") return Buffer.from(data, "utf8");
+ if (Array.isArray(data)) {
+ return Buffer.concat(
+ data.map((item) =>
+ Buffer.isBuffer(item) ? item : Buffer.from(item as ArrayBuffer)
+ )
+ );
+ }
+ if (Buffer.isBuffer(data)) return data;
+ return Buffer.from(data as ArrayBuffer);
+}
+
+type WebSocketLike = {
+ readyState: number;
+ send: (data: string) => void;
+ close: (code?: number, reason?: string) => void;
+ on: (event: "message" | "close" | "error", listener: (...args: any[]) => void) => void;
+ once: (event: "close" | "error", listener: (...args: any[]) => void) => void;
+};
+
export class WebSocketSessionBridge {
private readonly logger: pino.Logger;
- private readonly sessions: Map = new Map();
+ private readonly sessions: Map = new Map();
private clientIdCounter = 0;
private readonly serverId: string;
private readonly agentManager: AgentManager;
@@ -98,7 +118,7 @@ export class WebSocketSessionBridge {
return this.sessions.size;
}
- public async attach(ws: WebSocket, _request: IncomingMessage): Promise {
+ public async attach(ws: WebSocketLike, _request: IncomingMessage): Promise {
const clientId = `client-${++this.clientIdCounter}`;
const connectionLogger = this.logger.child({ clientId });
@@ -156,7 +176,7 @@ export class WebSocketSessionBridge {
});
}
- private async detach(ws: WebSocket, connectionLogger: pino.Logger, clientId: string): Promise {
+ private async detach(ws: WebSocketLike, connectionLogger: pino.Logger, clientId: string): Promise {
const session = this.sessions.get(ws);
if (!session) return;
@@ -169,9 +189,13 @@ export class WebSocketSessionBridge {
this.sessions.delete(ws);
}
- private async handleRawMessage(ws: WebSocket, data: Buffer | ArrayBuffer | Buffer[]): Promise {
+ private async handleRawMessage(
+ ws: WebSocketLike,
+ data: Buffer | ArrayBuffer | Buffer[] | string
+ ): Promise {
try {
- const parsed = JSON.parse(data.toString());
+ const buffer = bufferFromWsData(data);
+ const parsed = JSON.parse(buffer.toString());
const parsedMessage = WSInboundMessageSchema.safeParse(parsed);
if (!parsedMessage.success) {
const requestInfo = extractRequestInfoFromUnknownWsInbound(parsed);
@@ -267,13 +291,7 @@ export class WebSocketSessionBridge {
let parsedPayload: unknown = null;
try {
- const buffer = Array.isArray(data)
- ? Buffer.concat(
- data.map((item) => (Buffer.isBuffer(item) ? item : Buffer.from(item as ArrayBuffer)))
- )
- : Buffer.isBuffer(data)
- ? data
- : Buffer.from(data as ArrayBuffer);
+ const buffer = bufferFromWsData(data);
rawPayload = buffer.toString();
parsedPayload = JSON.parse(rawPayload);
} catch (payloadError) {
@@ -328,7 +346,7 @@ export class WebSocketSessionBridge {
}
}
- private sendToClient(ws: WebSocket, message: WSOutboundMessage): void {
+ private sendToClient(ws: WebSocketLike, message: WSOutboundMessage): void {
// WebSocket.OPEN = 1
if (ws.readyState === 1) {
ws.send(JSON.stringify(message));
@@ -457,7 +475,7 @@ export class WebSocketSessionBridge {
reason: "finished" | "error" | "permission";
}): void {
const clientEntries: Array<{
- ws: WebSocket;
+ ws: WebSocketLike;
state: {
deviceType: "web" | "mobile" | null;
focusedAgentId: string | null;
diff --git a/packages/server/src/shared/connection-offer.ts b/packages/server/src/shared/connection-offer.ts
new file mode 100644
index 000000000..a21d3e4ef
--- /dev/null
+++ b/packages/server/src/shared/connection-offer.ts
@@ -0,0 +1,16 @@
+import { z } from "zod";
+
+export const ConnectionOfferV1Schema = z.object({
+ v: z.literal(1),
+ sessionId: z.string().min(1),
+ endpoints: z.array(z.string().min(1)).min(1),
+ daemonPublicKeyB64: z.string().min(1),
+ relay: z
+ .object({
+ endpoint: z.string().min(1),
+ })
+ .nullable()
+ .optional(),
+});
+
+export type ConnectionOfferV1 = z.infer;
diff --git a/packages/server/src/shared/daemon-endpoints.ts b/packages/server/src/shared/daemon-endpoints.ts
new file mode 100644
index 000000000..7eb50dbe0
--- /dev/null
+++ b/packages/server/src/shared/daemon-endpoints.ts
@@ -0,0 +1,117 @@
+export type HostPortParts = {
+ host: string;
+ port: number;
+ isIpv6: boolean;
+};
+
+export type RelayRole = "server" | "client";
+
+function parsePort(portStr: string, context: string): number {
+ const port = Number(portStr);
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
+ throw new Error(`${context}: port must be between 1 and 65535`);
+ }
+ return port;
+}
+
+export function parseHostPort(input: string): HostPortParts {
+ const trimmed = input.trim();
+ if (!trimmed) {
+ throw new Error("Host is required");
+ }
+
+ // IPv6: [::1]:6767
+ if (trimmed.startsWith("[")) {
+ const match = trimmed.match(/^\[([^\]]+)\]:(\d{1,5})$/);
+ if (!match) {
+ throw new Error("Invalid host:port (expected [::1]:6767)");
+ }
+ const host = match[1].trim();
+ if (!host) throw new Error("Host is required");
+ const port = parsePort(match[2], "Invalid host:port");
+ return { host, port, isIpv6: true };
+ }
+
+ const match = trimmed.match(/^(.+):(\d{1,5})$/);
+ if (!match) {
+ throw new Error("Invalid host:port (expected localhost:6767)");
+ }
+ const host = match[1].trim();
+ if (!host) throw new Error("Host is required");
+ const port = parsePort(match[2], "Invalid host:port");
+ return { host, port, isIpv6: false };
+}
+
+export function normalizeHostPort(input: string): string {
+ const { host, port, isIpv6 } = parseHostPort(input);
+ return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
+}
+
+export function deriveLabelFromEndpoint(endpoint: string): string {
+ try {
+ const { host } = parseHostPort(endpoint);
+ return host || "Unnamed Host";
+ } catch {
+ return "Unnamed Host";
+ }
+}
+
+function shouldUseSecureWebSocket(port: number): boolean {
+ return port === 443;
+}
+
+export function buildDaemonWebSocketUrl(endpoint: string): string {
+ const { host, port, isIpv6 } = parseHostPort(endpoint);
+ const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
+ const hostPart = isIpv6 ? `[${host}]` : host;
+ return `${protocol}://${hostPart}:${port}/ws`;
+}
+
+export function buildRelayWebSocketUrl(params: {
+ endpoint: string;
+ sessionId: string;
+ role: RelayRole;
+}): string {
+ const { host, port, isIpv6 } = parseHostPort(params.endpoint);
+ const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
+ const hostPart = isIpv6 ? `[${host}]` : host;
+ const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
+ url.searchParams.set("session", params.sessionId);
+ url.searchParams.set("role", params.role);
+ return url.toString();
+}
+
+export function extractHostPortFromWebSocketUrl(wsUrl: string): string {
+ const parsed = new URL(wsUrl);
+ if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
+ throw new Error("Invalid WebSocket URL protocol");
+ }
+ if (parsed.pathname.replace(/\/+$/, "") !== "/ws") {
+ throw new Error("Invalid WebSocket URL (expected /ws path)");
+ }
+
+ const host = parsed.hostname;
+ const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80;
+ if (!host) {
+ throw new Error("Invalid WebSocket URL (missing hostname)");
+ }
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
+ throw new Error("Invalid WebSocket URL (invalid port)");
+ }
+
+ const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
+ return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
+}
+
+export function isRelayClientWebSocketUrl(url: string): boolean {
+ try {
+ const parsed = new URL(url);
+ return (
+ parsed.searchParams.get("role") === "client" &&
+ parsed.searchParams.has("session")
+ );
+ } catch {
+ return false;
+ }
+}
+
diff --git a/packages/server/tsconfig.server.json b/packages/server/tsconfig.server.json
index 8aac9e25f..5d0db8ed6 100644
--- a/packages/server/tsconfig.server.json
+++ b/packages/server/tsconfig.server.json
@@ -3,7 +3,7 @@
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
- "lib": ["ES2020"],
+ "lib": ["ES2020", "DOM"],
"baseUrl": "./src",
"paths": {
"@server/*": ["./*"]
diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts
index 375c8779b..f50bf16e1 100644
--- a/packages/website/src/routeTree.gen.ts
+++ b/packages/website/src/routeTree.gen.ts
@@ -12,9 +12,9 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as DocsRouteImport } from './routes/docs'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DocsIndexRouteImport } from './routes/docs/index'
-import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
-import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration'
import { Route as DocsSecurityRouteImport } from './routes/docs/security'
+import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration'
+import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
const DocsRoute = DocsRouteImport.update({
id: '/docs',
@@ -31,9 +31,9 @@ const DocsIndexRoute = DocsIndexRouteImport.update({
path: '/',
getParentRoute: () => DocsRoute,
} as any)
-const DocsBestPracticesRoute = DocsBestPracticesRouteImport.update({
- id: '/best-practices',
- path: '/best-practices',
+const DocsSecurityRoute = DocsSecurityRouteImport.update({
+ id: '/security',
+ path: '/security',
getParentRoute: () => DocsRoute,
} as any)
const DocsConfigurationRoute = DocsConfigurationRouteImport.update({
@@ -41,9 +41,9 @@ const DocsConfigurationRoute = DocsConfigurationRouteImport.update({
path: '/configuration',
getParentRoute: () => DocsRoute,
} as any)
-const DocsSecurityRoute = DocsSecurityRouteImport.update({
- id: '/security',
- path: '/security',
+const DocsBestPracticesRoute = DocsBestPracticesRouteImport.update({
+ id: '/best-practices',
+ path: '/best-practices',
getParentRoute: () => DocsRoute,
} as any)
@@ -73,10 +73,28 @@ export interface FileRoutesById {
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/docs' | '/docs/best-practices' | '/docs/configuration' | '/docs/security' | '/docs/'
+ fullPaths:
+ | '/'
+ | '/docs'
+ | '/docs/best-practices'
+ | '/docs/configuration'
+ | '/docs/security'
+ | '/docs/'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/docs/best-practices' | '/docs/configuration' | '/docs/security' | '/docs'
- id: '__root__' | '/' | '/docs' | '/docs/best-practices' | '/docs/configuration' | '/docs/security' | '/docs/'
+ to:
+ | '/'
+ | '/docs/best-practices'
+ | '/docs/configuration'
+ | '/docs/security'
+ | '/docs'
+ id:
+ | '__root__'
+ | '/'
+ | '/docs'
+ | '/docs/best-practices'
+ | '/docs/configuration'
+ | '/docs/security'
+ | '/docs/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -107,11 +125,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsIndexRouteImport
parentRoute: typeof DocsRoute
}
- '/docs/best-practices': {
- id: '/docs/best-practices'
- path: '/best-practices'
- fullPath: '/docs/best-practices'
- preLoaderRoute: typeof DocsBestPracticesRouteImport
+ '/docs/security': {
+ id: '/docs/security'
+ path: '/security'
+ fullPath: '/docs/security'
+ preLoaderRoute: typeof DocsSecurityRouteImport
parentRoute: typeof DocsRoute
}
'/docs/configuration': {
@@ -121,11 +139,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsConfigurationRouteImport
parentRoute: typeof DocsRoute
}
- '/docs/security': {
- id: '/docs/security'
- path: '/security'
- fullPath: '/docs/security'
- preLoaderRoute: typeof DocsSecurityRouteImport
+ '/docs/best-practices': {
+ id: '/docs/best-practices'
+ path: '/best-practices'
+ fullPath: '/docs/best-practices'
+ preLoaderRoute: typeof DocsBestPracticesRouteImport
parentRoute: typeof DocsRoute
}
}
diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css
index 7bf137a67..42d8e0597 100644
--- a/packages/website/src/styles.css
+++ b/packages/website/src/styles.css
@@ -1,5 +1,13 @@
@import 'tailwindcss';
+/* Inline code styling - applies to code elements not inside pre blocks */
+:not(pre) > code {
+ background-color: var(--color-muted);
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.25rem;
+ font-size: 0.875em;
+}
+
@theme {
--font-serif: 'Playfair Display', serif;