diff --git a/.gitignore b/.gitignore index 5bfa57b47..364367e31 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ bench/**/*.json # tmp tmp/models ~/ +/${HOME/.skulk/vector}/buffer/ diff --git a/dashboard-react/package-lock.json b/dashboard-react/package-lock.json index 89148ddd8..b08b61e66 100644 --- a/dashboard-react/package-lock.json +++ b/dashboard-react/package-lock.json @@ -12,12 +12,11 @@ "@reduxjs/toolkit": "^2.11.2", "@tolgee/react": "^6.6.0", "@tolgee/web": "^6.6.0", - "@types/qrcode": "^1.5.6", "d3": "^7.9.0", "highlight.js": "^11.11.1", "katex": "^0.16.41", "marked": "^17.0.5", - "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-icons": "^5.6.0", @@ -2488,20 +2487,12 @@ "version": "24.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, - "node_modules/@types/qrcode": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", - "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3201,6 +3192,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3210,6 +3202,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3398,15 +3391,6 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/camelize": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", @@ -3554,6 +3538,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3566,6 +3551,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/colorette": { @@ -4091,15 +4077,6 @@ } } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/decko": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decko/-/decko-1.2.0.tgz", @@ -4195,12 +4172,6 @@ "node": ">=8" } }, - "node_modules/dijkstrajs": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", - "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -4243,6 +4214,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/empathic": { @@ -4773,6 +4745,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5080,6 +5053,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6184,15 +6158,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6217,6 +6182,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6530,160 +6496,13 @@ "node": ">=6" } }, - "node_modules/qrcode": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", - "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", - "license": "MIT", - "dependencies": { - "dijkstrajs": "^1.0.1", - "pngjs": "^5.0.0", - "yargs": "^15.3.1" - }, - "bin": { - "qrcode": "bin/qrcode" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/qrcode/node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/qrcode/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/qrcode/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/pngjs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", - "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/qrcode/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" - }, - "node_modules/qrcode/node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/react": { @@ -6957,6 +6776,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6972,12 +6792,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "license": "ISC" - }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -7103,12 +6917,6 @@ "semver": "bin/semver.js" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" - }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -7341,6 +7149,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -7355,6 +7164,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7825,6 +7635,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -8159,12 +7970,6 @@ "node": ">= 8" } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "license": "ISC" - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/dashboard-react/package.json b/dashboard-react/package.json index 478e4d010..da4857216 100644 --- a/dashboard-react/package.json +++ b/dashboard-react/package.json @@ -17,12 +17,11 @@ "@reduxjs/toolkit": "^2.11.2", "@tolgee/react": "^6.6.0", "@tolgee/web": "^6.6.0", - "@types/qrcode": "^1.5.6", "d3": "^7.9.0", "highlight.js": "^11.11.1", "katex": "^0.16.41", "marked": "^17.0.5", - "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-icons": "^5.6.0", diff --git a/dashboard-react/src/components/common/QrCode.tsx b/dashboard-react/src/components/common/QrCode.tsx new file mode 100644 index 000000000..aaffeea16 --- /dev/null +++ b/dashboard-react/src/components/common/QrCode.tsx @@ -0,0 +1,60 @@ +import { QRCodeSVG } from 'qrcode.react'; +import styled from 'styled-components'; + +type QrCodeLevel = 'L' | 'M' | 'Q' | 'H'; + +interface QrCodeProps { + value: string; + alt: string; + size?: number; + level?: QrCodeLevel; + logoSrc?: string; + logoSize?: number; + marginSize?: number; + className?: string; +} + +const Frame = styled.div` + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: ${({ theme }) => theme.radii.md}; + border: 1px solid ${({ theme }) => theme.colors.border}; + background: #ffffff; + padding: 10px; + line-height: 0; +`; + +export function QrCode({ + value, + alt, + size = 220, + level = 'H', + logoSrc, + logoSize, + marginSize = 1, + className, +}: QrCodeProps) { + const resolvedLogoSize = logoSize ?? Math.round(size * 0.2); + + return ( + + + + ); +} diff --git a/dashboard-react/src/components/observability/ObservabilityPanel.tsx b/dashboard-react/src/components/observability/ObservabilityPanel.tsx index 1fa02466a..fa1f352b1 100644 --- a/dashboard-react/src/components/observability/ObservabilityPanel.tsx +++ b/dashboard-react/src/components/observability/ObservabilityPanel.tsx @@ -11,6 +11,7 @@ import { } from '../../store/slices/uiSlice'; import { LiveTab } from './LiveTab'; import { NodeTab } from './NodeTab'; +import { PairingTab } from './PairingTab'; import { TracesTab } from './TracesTab'; /** @@ -154,6 +155,7 @@ const TAB_ORDER: { key: ObservabilityTab; label: string }[] = [ { key: 'live', label: 'Live' }, { key: 'node', label: 'Node' }, { key: 'traces', label: 'Traces' }, + { key: 'pairing', label: 'Pairing' }, ]; export function ObservabilityPanel() { @@ -285,6 +287,7 @@ export function ObservabilityPanel() { {activeTab === 'live' && } {activeTab === 'node' && } {activeTab === 'traces' && } + {activeTab === 'pairing' && } diff --git a/dashboard-react/src/components/observability/PairingTab.tsx b/dashboard-react/src/components/observability/PairingTab.tsx new file mode 100644 index 000000000..3123533ae --- /dev/null +++ b/dashboard-react/src/components/observability/PairingTab.tsx @@ -0,0 +1,407 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { FiRefreshCw } from 'react-icons/fi'; +import { Button } from '../common/Button'; +import { QrCode } from '../common/QrCode'; +import { useRemoteAccess, type RemoteAccessInfo } from '../../hooks/useRemoteAccess'; + +const TAILSCALE_DOCS_URL = 'https://foxlight-foundation.github.io/Skulk/tailscale/'; +const QR_RENEW_EARLY_MS = 30_000; +const PAIRING_OPERATOR_TOKEN_STORAGE_KEY = 'skulkCompanionPairingToken'; + +interface CompanionPairingQrPayload { + version: 1; + clusterId: string; + clusterName: string; + pairingNonce: string; + expiresAt: string; + exchangeUrl: string; + clusterPublicKey: string; + lanUrl: string | null; + tailscaleUrl: string | null; + preferredUrl: string | null; +} + +interface CompanionPairingSessionResponse { + qrPayload: CompanionPairingQrPayload; +} + +type SessionState = + | { status: 'idle' | 'loading' } + | { status: 'error'; message: string } + | { status: 'ready'; payload: CompanionPairingQrPayload }; + +const Scroll = styled.div` + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 14px; + padding-right: 2px; +`; + +const Section = styled.section` + border: 1px solid ${({ theme }) => theme.colors.border}; + background: ${({ theme }) => theme.colors.surface}; + border-radius: ${({ theme }) => theme.radii.md}; + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; +`; + +const Header = styled.div` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +`; + +const TitleGroup = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +`; + +const Title = styled.h3` + margin: 0; + color: ${({ theme }) => theme.colors.text}; + font-family: ${({ theme }) => theme.fonts.body}; + font-size: ${({ theme }) => theme.fontSizes.lg}; +`; + +const BodyText = styled.p` + margin: 0; + color: ${({ theme }) => theme.colors.textSecondary}; + font-family: ${({ theme }) => theme.fonts.body}; + font-size: ${({ theme }) => theme.fontSizes.sm}; + line-height: 1.5; +`; + +const MutedText = styled(BodyText)` + color: ${({ theme }) => theme.colors.textMuted}; +`; + +const StatusGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +`; + +const StatusCell = styled.div` + border: 1px solid ${({ theme }) => theme.colors.borderLight}; + background: ${({ theme }) => theme.colors.surfaceSunken}; + border-radius: ${({ theme }) => theme.radii.sm}; + padding: 10px; + min-width: 0; +`; + +const Label = styled.div` + color: ${({ theme }) => theme.colors.textMuted}; + font-family: ${({ theme }) => theme.fonts.body}; + font-size: ${({ theme }) => theme.fontSizes.xs}; + margin-bottom: 4px; +`; + +const Value = styled.div<{ $warn?: boolean; $ok?: boolean }>` + color: ${({ $ok, $warn, theme }) => + $ok ? theme.colors.healthy : $warn ? theme.colors.warningOnSurface : theme.colors.text}; + font-family: ${({ theme }) => theme.fonts.body}; + font-size: ${({ theme }) => theme.fontSizes.sm}; + overflow-wrap: anywhere; +`; + +const Advisory = styled.div<{ $level: 'info' | 'warning' | 'error' }>` + border: 1px solid + ${({ $level, theme }) => + $level === 'error' + ? theme.colors.errorBg + : $level === 'warning' + ? theme.colors.warningBg + : theme.colors.borderLight}; + background: ${({ $level, theme }) => + $level === 'error' + ? theme.colors.errorBg + : $level === 'warning' + ? theme.colors.warningBg + : theme.colors.infoBg}; + border-radius: ${({ theme }) => theme.radii.sm}; + padding: 10px 12px; + color: ${({ $level, theme }) => + $level === 'error' + ? theme.colors.errorOnSurface + : $level === 'warning' + ? theme.colors.warningOnSurface + : theme.colors.textSecondary}; + font-family: ${({ theme }) => theme.fonts.body}; + font-size: ${({ theme }) => theme.fontSizes.sm}; + line-height: 1.45; +`; + +const Link = styled.a` + color: ${({ theme }) => theme.colors.gold}; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +`; + +const QrWrap = styled.div` + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +`; + +const Instructions = styled.ol` + margin: 0; + padding-left: 18px; + color: ${({ theme }) => theme.colors.textSecondary}; + font-family: ${({ theme }) => theme.fonts.body}; + font-size: ${({ theme }) => theme.fontSizes.sm}; + line-height: 1.55; +`; + +function pairingReadiness(access: RemoteAccessInfo): { + canPair: boolean; + level: 'info' | 'warning' | 'error'; + message: string; +} { + if (!access.preferredUrl) { + return { + canPair: false, + level: 'error', + message: 'Skulk does not currently have a reachable LAN or Tailscale URL for companion pairing.', + }; + } + if (!access.tailscale.running || !access.tailscale.url) { + return { + canPair: true, + level: 'warning', + message: 'Pairing is available on this local network. Remote access away from home needs Tailscale on this cluster node and on the phone.', + }; + } + return { + canPair: true, + level: 'info', + message: 'Tailscale is ready. SkulkOps can use the private Tailscale URL after pairing.', + }; +} + +async function createPairingSession(signal: AbortSignal): Promise { + const operatorToken = window.localStorage.getItem(PAIRING_OPERATOR_TOKEN_STORAGE_KEY); + const response = await fetch('/v1/companion/pairing-sessions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(operatorToken ? { 'X-Skulk-Operator-Token': operatorToken } : {}), + }, + body: JSON.stringify({}), + signal, + }); + if (!response.ok) { + if (response.status === 403) { + throw new Error( + 'Pairing requires a local dashboard session. If you are opening this dashboard over LAN or Tailscale, configure SKULK_COMPANION_PAIRING_TOKEN and store it in this browser as skulkCompanionPairingToken.', + ); + } + throw new Error('Could not create a SkulkOps pairing code.'); + } + const session = (await response.json()) as CompanionPairingSessionResponse; + return session.qrPayload; +} + +function formatTimeRemaining(expiresAt: string, nowMs: number): string { + const remainingMs = new Date(expiresAt).getTime() - nowMs; + if (!Number.isFinite(remainingMs) || remainingMs <= 0) return 'rotating now'; + const totalSeconds = Math.ceil(remainingMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +export function PairingTab() { + const access = useRemoteAccess(); + const [session, setSession] = useState({ status: 'idle' }); + const [nowMs, setNowMs] = useState(() => Date.now()); + const mountedRef = useRef(false); + const requestIdRef = useRef(0); + + const readiness = useMemo( + () => (access.status === 'ok' ? pairingReadiness(access.data) : null), + [access], + ); + + const loadSession = useCallback( + async (signal: AbortSignal, options: { showLoading?: boolean } = {}) => { + if (!readiness?.canPair) { + setSession({ status: 'idle' }); + return; + } + const requestId = ++requestIdRef.current; + if (options.showLoading ?? true) setSession({ status: 'loading' }); + try { + const payload = await createPairingSession(signal); + if (!signal.aborted && mountedRef.current && requestId === requestIdRef.current) { + setSession({ status: 'ready', payload }); + } + } catch (error) { + if (signal.aborted || !mountedRef.current || requestId !== requestIdRef.current) return; + setSession({ + status: 'error', + message: error instanceof Error ? error.message : 'Could not create a SkulkOps pairing code.', + }); + } + }, + [readiness], + ); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + const id = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(id); + }, []); + + useEffect(() => { + if (!readiness?.canPair) { + setSession({ status: 'idle' }); + return; + } + const controller = new AbortController(); + const timer = window.setTimeout(() => { + void loadSession(controller.signal, { showLoading: true }); + }, 0); + return () => { + controller.abort(); + window.clearTimeout(timer); + }; + }, [loadSession, readiness]); + + useEffect(() => { + if (session.status !== 'ready') return; + const expiresMs = new Date(session.payload.expiresAt).getTime(); + if (!Number.isFinite(expiresMs)) return; + const delay = Math.max(1000, expiresMs - Date.now() - QR_RENEW_EARLY_MS); + let controller: AbortController | null = null; + const timer = window.setTimeout(() => { + controller = new AbortController(); + void loadSession(controller.signal, { showLoading: false }); + }, delay); + return () => { + window.clearTimeout(timer); + controller?.abort(); + }; + }, [loadSession, session]); + + if (access.status === 'loading') { + return ( + +
+ SkulkOps Pairing + Checking local reachability... +
+
+ ); + } + + if (access.status === 'error') { + return ( + +
+ SkulkOps Pairing + Could not read this node's pairing readiness. +
+
+ ); + } + + const remaining = session.status === 'ready' + ? formatTimeRemaining(session.payload.expiresAt, nowMs) + : null; + + return ( + +
+
+ + SkulkOps Pairing + Scan this code from SkulkOps to pair a phone with this cluster. + + {session.status === 'loading' && ( + + )} +
+ + + + + + {access.data.tailscale.running + ? access.data.tailscale.url ?? 'running' + : 'not running'} + + + + + {access.data.local.url ?? 'unavailable'} + + + + + {readiness.message}{' '} + {(!access.data.tailscale.running || !access.data.tailscale.url) && ( + + Set up Tailscale. + + )} + + + {!readiness.canPair && ( + Pairing will become available once Skulk can advertise a reachable URL. + )} + + {session.status === 'error' && ( + {session.message} + )} + + {session.status === 'ready' && readiness.canPair && ( + + +
+ +
  • Open SkulkOps on your phone.
  • +
  • Tap Scan QR.
  • +
  • Point the camera at this code.
  • +
    + + This code renews automatically while this tab is open. Current code rotates in {remaining}. + +
    +
    + )} +
    +
    + ); +} diff --git a/dashboard-react/src/components/pages/OperatorPage.tsx b/dashboard-react/src/components/pages/OperatorPage.tsx index 681bf05ed..1c1f95268 100644 --- a/dashboard-react/src/components/pages/OperatorPage.tsx +++ b/dashboard-react/src/components/pages/OperatorPage.tsx @@ -1,10 +1,10 @@ -import { useState, useCallback, useEffect } from 'react'; -import QRCode from 'qrcode'; +import { useState, useCallback } from 'react'; import styled from 'styled-components'; import { useGetRawStateQuery, useGetLocalNodeIdQuery, useRestartNodeMutation } from '../../store/endpoints/cluster'; import { addToast } from '../../hooks/useToast'; import { useRemoteAccess } from '../../hooks/useRemoteAccess'; import { copyToClipboard } from '../../utils/clipboard'; +import { QrCode } from '../common/QrCode'; /* ── Types ─────────────────────────────────────────────────── */ @@ -260,7 +260,6 @@ function fmtBytes(bytes: number): string { function RemoteAccessCard() { const access = useRemoteAccess(); - const [qrDataUrl, setQrDataUrl] = useState(null); const [copied, setCopied] = useState(false); const operatorUrl = @@ -272,13 +271,6 @@ function RemoteAccessCard() { const tailscaleRunning = access.status === 'ok' ? access.data.tailscale.running : false; - useEffect(() => { - if (!operatorUrl) { setQrDataUrl(null); return; } - QRCode.toDataURL(operatorUrl, { width: 180, margin: 1 }) - .then(setQrDataUrl) - .catch(() => setQrDataUrl(null)); - }, [operatorUrl]); - const handleCopy = useCallback(() => { if (!operatorUrl) return; copyToClipboard(operatorUrl).then(() => { @@ -317,9 +309,9 @@ function RemoteAccessCard() { not running )} - {operatorUrl && qrDataUrl && ( + {operatorUrl && ( - Operator panel QR code + Scan to open operator panel {copied ? 'Copied!' : 'Copy URL'} diff --git a/dashboard-react/src/store/slices/uiSlice.ts b/dashboard-react/src/store/slices/uiSlice.ts index 65db18ccf..0521f9ca2 100644 --- a/dashboard-react/src/store/slices/uiSlice.ts +++ b/dashboard-react/src/store/slices/uiSlice.ts @@ -3,7 +3,7 @@ import type { NavRoute } from '../../components/layout/HeaderNav'; import type { ThemeName } from '../../theme'; /** Tabs available inside the observability panel. */ -export type ObservabilityTab = 'live' | 'node' | 'traces'; +export type ObservabilityTab = 'live' | 'node' | 'traces' | 'pairing'; /** Default width of the right-side observability panel, in pixels. */ const OBSERVABILITY_WIDTH_DEFAULT = 560; diff --git a/pyproject.toml b/pyproject.toml index aa9d1cfb6..7845c0bfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "types-PyYAML>=6.0.12", "mlx-vlm==0.4.4", "transformers>=5.0.0,<5.4.0", + "cryptography>=46.0.0", ] [project.scripts] diff --git a/src/exo/api/companion.py b/src/exo/api/companion.py new file mode 100644 index 000000000..275eede67 --- /dev/null +++ b/src/exo/api/companion.py @@ -0,0 +1,563 @@ +"""Companion app pairing and read-only overview contracts.""" + +from __future__ import annotations + +import base64 +import contextlib +import hashlib +import os +import secrets +import socket +import threading +from collections.abc import Sequence +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Literal, final + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + PublicFormat, +) +from pydantic import Field, ValidationError + +from exo.connectivity.remote_access import RemoteAccessInfo +from exo.shared.constants import ( + SKULK_COMPANION_CLUSTER_KEY, + SKULK_COMPANION_CREDENTIALS, +) +from exo.shared.types.common import NodeId +from exo.shared.types.events import Event +from exo.shared.types.state import State +from exo.shared.types.worker.instances import InstanceId +from exo.utils.pydantic_ext import CamelCaseModel, FrozenModel + +PAIRING_VERSION: Literal[1] = 1 +PAIRING_SESSION_TTL = timedelta(minutes=5) +MAX_PAIRING_SESSIONS = 64 +MAX_COMPANION_CREDENTIALS = 512 +COMPANION_READ_SCOPES: tuple[str, ...] = ( + "cluster:read", + "nodes:read", + "models:read", + "events:read", +) + + +@final +class CompanionPairingSessionRequest(CamelCaseModel): + cluster_name: str | None = Field( + default=None, + min_length=1, + max_length=80, + description="Optional consumer-facing cluster name to display in the app.", + ) + + +@final +class CompanionPairingQrPayload(FrozenModel): + version: Literal[1] + cluster_id: str + cluster_name: str + pairing_nonce: str + expires_at: datetime + exchange_url: str + cluster_public_key: str + lan_url: str | None = None + tailscale_url: str | None = None + preferred_url: str | None = None + + +@final +class CompanionPairingSessionResponse(FrozenModel): + qr_payload: CompanionPairingQrPayload + + +@final +class CompanionPairingExchangeRequest(CamelCaseModel): + client_name: str | None = Field(default=None, min_length=1, max_length=80) + + +@final +class CompanionClusterMetadata(FrozenModel): + cluster_id: str + cluster_name: str + cluster_public_key: str + node_id: NodeId + base_url: str | None + + +@final +class CompanionPairingExchangeResponse(FrozenModel): + credential_id: str + token: str + scopes: Sequence[str] + issued_at: datetime + expires_at: datetime | None + cluster: CompanionClusterMetadata + + +@final +class CompanionNodeOverview(FrozenModel): + node_id: NodeId + display_name: str + online: bool + model_id: str | None = None + exo_version: str | None = None + lan_ips: Sequence[str] = () + + +@final +class CompanionRunningModel(FrozenModel): + instance_id: InstanceId + model_id: str + node_ids: Sequence[NodeId] + + +@final +class CompanionConnectionOverview(FrozenModel): + authenticated: bool + scopes: Sequence[str] + transport_url: str | None + tailscale_running: bool + + +@final +class CompanionEventOverview(FrozenModel): + event_type: str + + +@final +class CompanionCredentialRecord(FrozenModel): + credential_id: str + token_hash: str + scopes: Sequence[str] + issued_at: datetime + cluster_name: str + client_name: str | None = None + revoked: bool = False + + +@final +class CompanionCredentialStore(FrozenModel): + credentials: Sequence[CompanionCredentialRecord] = () + + +@final +class CompanionOverviewResponse(FrozenModel): + cluster: CompanionClusterMetadata + connection: CompanionConnectionOverview + nodes: Sequence[CompanionNodeOverview] + running_models: Sequence[CompanionRunningModel] + recent_events: Sequence[CompanionEventOverview] + + +@final +class _PairingSession: + def __init__( + self, + *, + nonce: str, + expires_at: datetime, + cluster_name: str, + base_url: str | None, + ) -> None: + self.nonce = nonce + self.expires_at = expires_at + self.cluster_name = cluster_name + self.base_url = base_url + self.exchanged = False + + +@final +class CompanionCredential: + def __init__( + self, + *, + credential_id: str, + token_hash: str, + scopes: Sequence[str], + issued_at: datetime, + cluster_name: str, + client_name: str | None = None, + revoked: bool = False, + ) -> None: + self.credential_id = credential_id + self.token_hash = token_hash + self.scopes = tuple(scopes) + self.issued_at = issued_at + self.cluster_name = cluster_name + self.client_name = client_name + self.revoked = revoked + + +class CompanionAuthError(Exception): + """Raised when a companion credential is missing or invalid.""" + + +class CompanionPairingError(Exception): + """Raised when a pairing session cannot be created or exchanged.""" + + +@final +class CompanionPairingManager: + """In-process companion pairing and credential registry. + + Credentials are intentionally stored as SHA-256 hashes. The cleartext bearer + token is returned once during nonce exchange and must be stored by the + mobile app in platform secure storage. + """ + + def __init__( + self, + *, + node_id: NodeId, + key_path: Path = SKULK_COMPANION_CLUSTER_KEY, + credentials_path: Path = SKULK_COMPANION_CREDENTIALS, + ) -> None: + self._node_id = node_id + self._key_path = key_path + self._credentials_path = credentials_path + self._lock = threading.RLock() + self._sessions: dict[str, _PairingSession] = {} + self._credentials: dict[str, CompanionCredential] = self._load_credentials() + self._credential_hash_index: dict[str, CompanionCredential] = { + credential.token_hash: credential + for credential in self._credentials.values() + if not credential.revoked + } + self._cluster_private_key = self._load_or_create_cluster_key() + public_key = self._cluster_private_key.public_key().public_bytes( + encoding=Encoding.Raw, + format=PublicFormat.Raw, + ) + self._cluster_public_key = f"ed25519:{base64.urlsafe_b64encode(public_key).decode().rstrip('=')}" + self._cluster_id = hashlib.sha256(self._cluster_public_key.encode()).hexdigest()[:24] + + @property + def cluster_id(self) -> str: + return self._cluster_id + + @property + def cluster_public_key(self) -> str: + return self._cluster_public_key + + def create_session( + self, + *, + request: CompanionPairingSessionRequest, + remote_access: RemoteAccessInfo, + ) -> CompanionPairingSessionResponse: + with self._lock: + self._prune_expired_sessions() + nonce = secrets.token_urlsafe(32) + now = _utc_now() + expires_at = now + PAIRING_SESSION_TTL + cluster_name = request.cluster_name or default_cluster_name() + base_url = remote_access.preferred_url + if base_url is None: + raise CompanionPairingError("pairing_url_unavailable") + session = _PairingSession( + nonce=nonce, + expires_at=expires_at, + cluster_name=cluster_name, + base_url=base_url, + ) + self._sessions[nonce] = session + self._trim_pairing_sessions() + exchange_url = f"{base_url}/v1/companion/pairing-sessions/{nonce}/exchange" + return CompanionPairingSessionResponse( + qr_payload=CompanionPairingQrPayload( + version=PAIRING_VERSION, + cluster_id=self.cluster_id, + cluster_name=cluster_name, + pairing_nonce=nonce, + expires_at=expires_at, + exchange_url=exchange_url, + cluster_public_key=self.cluster_public_key, + lan_url=remote_access.local.url, + tailscale_url=remote_access.tailscale.url, + preferred_url=remote_access.preferred_url, + ) + ) + + def exchange_session( + self, + *, + nonce: str, + request: CompanionPairingExchangeRequest, + ) -> CompanionPairingExchangeResponse: + with self._lock: + session = self._sessions.get(nonce) + if session is None: + raise CompanionAuthError("invalid_code") + if session.exchanged: + raise CompanionAuthError("already_used") + if session.expires_at <= _utc_now(): + del self._sessions[nonce] + raise CompanionAuthError("expired_code") + active_credentials = sum( + 1 for credential in self._credentials.values() if not credential.revoked + ) + if active_credentials >= MAX_COMPANION_CREDENTIALS: + raise CompanionAuthError("credential_limit_reached") + + session.exchanged = True + credential_id = secrets.token_urlsafe(16) + token = secrets.token_urlsafe(48) + issued_at = _utc_now() + credential = CompanionCredential( + credential_id=credential_id, + token_hash=_hash_token(token), + scopes=COMPANION_READ_SCOPES, + issued_at=issued_at, + cluster_name=session.cluster_name, + client_name=request.client_name, + ) + self._credentials[credential_id] = credential + try: + self._save_credentials() + except Exception: + session.exchanged = False + del self._credentials[credential_id] + raise + self._credential_hash_index[credential.token_hash] = credential + return CompanionPairingExchangeResponse( + credential_id=credential_id, + token=token, + scopes=credential.scopes, + issued_at=issued_at, + expires_at=None, + cluster=CompanionClusterMetadata( + cluster_id=self.cluster_id, + cluster_name=session.cluster_name, + cluster_public_key=self.cluster_public_key, + node_id=self._node_id, + base_url=session.base_url, + ), + ) + + def authenticate_bearer(self, bearer_token: str | None) -> CompanionCredential: + if not bearer_token: + raise CompanionAuthError("missing_token") + token_hash = _hash_token(bearer_token) + credential = self._credential_hash_index.get(token_hash) + if credential is not None and not credential.revoked: + return credential + raise CompanionAuthError("auth_failed") + + def revoke_credential(self, credential_id: str) -> bool: + with self._lock: + credential = self._credentials.get(credential_id) + if credential is None: + return False + was_revoked = credential.revoked + had_hash_index = credential.token_hash in self._credential_hash_index + credential.revoked = True + self._credential_hash_index.pop(credential.token_hash, None) + try: + self._save_credentials() + except Exception: + credential.revoked = was_revoked + if had_hash_index: + self._credential_hash_index[credential.token_hash] = credential + raise + return True + + def _prune_expired_sessions(self) -> None: + now = _utc_now() + expired = [ + nonce + for nonce, session in self._sessions.items() + if session.expires_at <= now + ] + for nonce in expired: + del self._sessions[nonce] + + def _trim_pairing_sessions(self) -> None: + if len(self._sessions) <= MAX_PAIRING_SESSIONS: + return + excess = len(self._sessions) - MAX_PAIRING_SESSIONS + ordered = sorted(self._sessions.values(), key=lambda session: session.expires_at) + for session in ordered[:excess]: + self._sessions.pop(session.nonce, None) + + def _load_or_create_cluster_key(self) -> Ed25519PrivateKey: + if self._key_path.exists(): + try: + private_key = Ed25519PrivateKey.from_private_bytes( + self._key_path.read_bytes() + ) + except (OSError, ValueError): + with contextlib.suppress(OSError): + self._key_path.unlink() + else: + with contextlib.suppress(OSError): + self._key_path.chmod(0o600) + return private_key + private_key = Ed25519PrivateKey.generate() + private_bytes = private_key.private_bytes( + encoding=Encoding.Raw, + format=PrivateFormat.Raw, + encryption_algorithm=NoEncryption(), + ) + _atomic_write_private_file(self._key_path, private_bytes) + return private_key + + def _load_credentials(self) -> dict[str, CompanionCredential]: + if not self._credentials_path.exists(): + return {} + try: + store = CompanionCredentialStore.model_validate_json( + self._credentials_path.read_text() + ) + except (OSError, UnicodeDecodeError, ValidationError): + return {} + return { + record.credential_id: CompanionCredential( + credential_id=record.credential_id, + token_hash=record.token_hash, + scopes=record.scopes, + issued_at=record.issued_at, + cluster_name=record.cluster_name, + client_name=record.client_name, + revoked=record.revoked, + ) + for record in store.credentials + } + + def _save_credentials(self) -> None: + store = CompanionCredentialStore( + credentials=tuple( + CompanionCredentialRecord( + credential_id=credential.credential_id, + token_hash=credential.token_hash, + scopes=credential.scopes, + issued_at=credential.issued_at, + cluster_name=credential.cluster_name, + client_name=credential.client_name, + revoked=credential.revoked, + ) + for credential in self._credentials.values() + ) + ) + _atomic_write_private_file( + self._credentials_path, + store.model_dump_json(indent=2).encode(), + ) + + +def build_companion_overview( + *, + node_id: NodeId, + state: State, + remote_access: RemoteAccessInfo, + credential: CompanionCredential, + manager: CompanionPairingManager, + recent_events: Sequence[Event], +) -> CompanionOverviewResponse: + node_ids = set(state.topology.list_nodes()) + node_ids.update(state.node_identities.keys()) + node_ids.update(state.node_network.keys()) + node_ids.add(node_id) + + nodes = [ + _build_node_overview( + node_id=known_node_id, + local_node_id=node_id, + state=state, + ) + for known_node_id in sorted(node_ids) + ] + running_models = [ + CompanionRunningModel( + instance_id=instance_id, + model_id=str(instance.shard_assignments.model_id), + node_ids=tuple(instance.shard_assignments.node_to_runner.keys()), + ) + for instance_id, instance in state.instances.items() + ] + return CompanionOverviewResponse( + cluster=CompanionClusterMetadata( + cluster_id=manager.cluster_id, + cluster_name=credential.cluster_name, + cluster_public_key=manager.cluster_public_key, + node_id=node_id, + base_url=remote_access.preferred_url, + ), + connection=CompanionConnectionOverview( + authenticated=True, + scopes=credential.scopes, + transport_url=remote_access.preferred_url, + tailscale_running=remote_access.tailscale.running, + ), + nodes=nodes, + running_models=running_models, + recent_events=tuple( + CompanionEventOverview(event_type=event.__class__.__name__) + for event in recent_events + ), + ) + + +def default_cluster_name() -> str: + hostname = socket.gethostname().split(".")[0] + return f"{hostname} Skulk" if hostname else "Skulk Cluster" + + +def bearer_token_from_authorization(authorization: str | None) -> str | None: + if authorization is None: + return None + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token: + return None + return token.strip() + + +def _build_node_overview( + *, + node_id: NodeId, + local_node_id: NodeId, + state: State, +) -> CompanionNodeOverview: + identity = state.node_identities.get(node_id) + network = state.node_network.get(node_id) + lan_ips = tuple(iface.ip_address for iface in network.interfaces) if network else () + return CompanionNodeOverview( + node_id=node_id, + display_name=identity.friendly_name if identity else str(node_id), + online=node_id in state.last_seen or node_id == local_node_id, + model_id=identity.model_id if identity else None, + exo_version=identity.exo_version if identity else None, + lan_ips=lan_ips, + ) + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _atomic_write_private_file(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp") + fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, path) + with contextlib.suppress(OSError): + path.chmod(0o600) + except Exception: + with contextlib.suppress(OSError): + os.close(fd) + with contextlib.suppress(OSError): + tmp_path.unlink() + raise diff --git a/src/exo/api/main.py b/src/exo/api/main.py index cfc3d38cd..bf29c8f3e 100644 --- a/src/exo/api/main.py +++ b/src/exo/api/main.py @@ -1,6 +1,8 @@ import base64 import contextlib import hashlib +import hmac +import ipaddress import json import os import platform @@ -13,6 +15,7 @@ from http import HTTPStatus from pathlib import Path from typing import TYPE_CHECKING, Annotated, Literal, Protocol, cast +from urllib.parse import urlparse from uuid import uuid4 import anyio @@ -54,6 +57,20 @@ generate_responses_stream, responses_request_to_text_generation, ) +from exo.api.companion import ( + COMPANION_READ_SCOPES, + CompanionAuthError, + CompanionCredential, + CompanionOverviewResponse, + CompanionPairingError, + CompanionPairingExchangeRequest, + CompanionPairingExchangeResponse, + CompanionPairingManager, + CompanionPairingSessionRequest, + CompanionPairingSessionResponse, + bearer_token_from_authorization, + build_companion_overview, +) from exo.api.keepalive import with_sse_keepalive from exo.api.types import ( AddCustomModelParams, @@ -364,6 +381,10 @@ def _text_image_hash_cache_enabled() -> bool: "name": "Diagnostics", "description": "Read-only local and cluster diagnostics for stuck runner, placement, resource, and process inspection.", }, + { + "name": "Companion", + "description": "Native companion app pairing, scoped credentials, and read-only cluster overview.", + }, { "name": "Images", "description": "Image generation, editing, retrieval, and benchmarking endpoints.", @@ -475,6 +496,65 @@ def _json_request_body(schema: dict[str, object]) -> dict[str, object]: } +def _is_loopback_host(host: str) -> bool: + host = _normalize_forwarded_host(host).lower() + if host in {"localhost", "testclient"}: + return True + with contextlib.suppress(ValueError): + return ipaddress.ip_address(host).is_loopback + return False + + +def _normalize_forwarded_host(value: str) -> str: + host = value.strip().strip('"') + if host.startswith("["): + end = host.find("]") + if end != -1: + return host[1:end] + if host.count(":") == 1: + maybe_host, maybe_port = host.rsplit(":", 1) + if maybe_port.isdigit(): + return maybe_host + return host + + +def _has_forwarded_client_headers(request: Request) -> bool: + return any( + header in request.headers + for header in ("x-forwarded-for", "x-real-ip", "forwarded") + ) + + +def _is_loopback_client(request: Request) -> bool: + host = request.client.host if request.client else "" + if not _is_loopback_host(host): + return False + return not _has_forwarded_client_headers(request) + + +def _has_loopback_request_host(request: Request) -> bool: + host = request.headers.get("host") + return bool(host and _is_loopback_host(host)) + + +def _has_trusted_loopback_pairing_origin(request: Request) -> bool: + if not _has_loopback_request_host(request): + return False + + sec_fetch_site = request.headers.get("sec-fetch-site") + if sec_fetch_site and sec_fetch_site.lower() not in {"none", "same-origin"}: + return False + + origin = request.headers.get("origin") + if not origin: + return True + + parsed_origin = urlparse(origin) + if parsed_origin.scheme not in {"http", "https"}: + return False + return _is_loopback_host(parsed_origin.hostname or "") + + class API: def __init__( self, @@ -513,6 +593,7 @@ def __init__( [RunnerId, task_types.TaskId], Awaitable[RunnerTaskCancelResponse] ] | None = None self._sent_image_hashes: set[str] = set() + self._companion_pairing: CompanionPairingManager | None = None # Initialize optimizer if store path is available if exo_config and exo_config.model_store and exo_config.model_store.enabled: from exo.store.model_optimizer import ModelOptimizer @@ -1191,6 +1272,33 @@ def _setup_routes(self) -> None: "code generation so mobile users land directly on the operator panel." ), )(self.get_remote_access) + self.app.post( + "/v1/companion/pairing-sessions", + tags=["Companion"], + summary="Create companion pairing session", + description=( + "Create a short-lived, single-use companion pairing QR payload. " + "The QR contains a nonce and cluster metadata, not a bearer credential." + ), + )(self.create_companion_pairing_session) + self.app.post( + "/v1/companion/pairing-sessions/{nonce}/exchange", + tags=["Companion"], + summary="Exchange companion pairing nonce", + description=( + "Exchange a valid pairing nonce for a revocable read-only companion " + "credential. The cleartext token is returned once." + ), + )(self.exchange_companion_pairing_session) + self.app.get( + "/v1/companion/overview", + tags=["Companion"], + summary="Get companion cluster overview", + description=( + "Return a companion-safe read-only cluster overview. Requires a " + "read-only companion bearer token." + ), + )(self.get_companion_overview) async def place_instance(self, payload: PlaceInstanceParams): command = PlaceInstance( @@ -4528,6 +4636,86 @@ async def get_remote_access(self) -> RemoteAccessInfo: self.port, ) + async def create_companion_pairing_session( + self, request: Request, payload: CompanionPairingSessionRequest + ) -> CompanionPairingSessionResponse: + self._require_companion_pairing_operator(request) + pairing = self._get_companion_pairing() + remote_access = await self.get_remote_access() + try: + return pairing.create_session( + request=payload, + remote_access=remote_access, + ) + except CompanionPairingError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + async def exchange_companion_pairing_session( + self, + nonce: str, + payload: CompanionPairingExchangeRequest, + ) -> CompanionPairingExchangeResponse: + pairing = self._get_companion_pairing() + try: + return pairing.exchange_session( + nonce=nonce, + request=payload, + ) + except CompanionAuthError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + async def get_companion_overview( + self, + request: Request, + ) -> CompanionOverviewResponse: + credential = self._require_companion_credential(request) + remote_access = await self.get_remote_access() + return build_companion_overview( + node_id=self.node_id, + state=self.state, + remote_access=remote_access, + credential=credential, + manager=self._get_companion_pairing(), + recent_events=self._recent_companion_events(limit=20), + ) + + def _require_companion_credential(self, request: Request) -> CompanionCredential: + token = bearer_token_from_authorization(request.headers.get("authorization")) + try: + credential = self._get_companion_pairing().authenticate_bearer(token) + except CompanionAuthError as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + required_scopes = set(COMPANION_READ_SCOPES) + if not required_scopes.issubset(set(credential.scopes)): + raise HTTPException(status_code=403, detail="insufficient_scope") + return credential + + def _get_companion_pairing(self) -> CompanionPairingManager: + if self._companion_pairing is None: + self._companion_pairing = CompanionPairingManager(node_id=self.node_id) + return self._companion_pairing + + def _require_companion_pairing_operator(self, request: Request) -> None: + if _is_loopback_client(request) and _has_trusted_loopback_pairing_origin(request): + return + + expected = preferred_env_value( + "SKULK_COMPANION_PAIRING_TOKEN", + "EXO_COMPANION_PAIRING_TOKEN", + ) + supplied = request.headers.get("x-skulk-operator-token") + if expected and supplied and hmac.compare_digest(expected, supplied): + return + + raise HTTPException(status_code=403, detail="operator_auth_required") + + def _recent_companion_events(self, *, limit: int) -> Sequence[Event]: + if self._event_log is None or limit <= 0: + return () + end = len(self._event_log) + start = max(0, end - limit) + return tuple(self._event_log.read_range(start, end)) + async def get_store_downloads(self) -> JSONResponse: if self._store_client is None: raise HTTPException(status_code=503, detail="Store not configured") diff --git a/src/exo/api/tests/test_companion_api.py b/src/exo/api/tests/test_companion_api.py new file mode 100644 index 000000000..2cb4c12d2 --- /dev/null +++ b/src/exo/api/tests/test_companion_api.py @@ -0,0 +1,961 @@ +"""Tests for native companion pairing and read-only overview endpoints.""" + +from __future__ import annotations + +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import cast +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastapi import Request +from fastapi.testclient import TestClient + +import exo.api.companion as companion_module +import exo.api.main as api_main +import exo.connectivity.remote_access as remote_access_module +from exo.api.companion import CompanionPairingManager +from exo.api.main import API +from exo.connectivity.remote_access import ( + LocalAccess, + RemoteAccessInfo, + TailscaleAccess, +) +from exo.connectivity.tailscale import TailscaleStatus +from exo.shared.election import ElectionMessage +from exo.shared.types.commands import ForwarderCommand, ForwarderDownloadCommand +from exo.shared.types.common import NodeId +from exo.shared.types.events import IndexedEvent +from exo.shared.types.profiling import NetworkInterfaceInfo, NodeNetworkInfo +from exo.shared.types.state import State +from exo.utils.channels import channel + + +def _json_object(response: httpx.Response) -> dict[str, object]: + return cast(dict[str, object], cast(object, response.json())) + + +def _build_api(tmp_path: Path, node_id: str = "local-node") -> API: + command_sender, _ = channel[ForwarderCommand]() + download_sender, _ = channel[ForwarderDownloadCommand]() + _, event_receiver = channel[IndexedEvent]() + _, election_receiver = channel[ElectionMessage]() + api = API( + NodeId(node_id), + port=52415, + event_receiver=event_receiver, + command_sender=command_sender, + download_command_sender=download_sender, + election_receiver=election_receiver, + enable_event_log=False, + mount_dashboard=False, + ) + api._companion_pairing = CompanionPairingManager( # pyright: ignore[reportPrivateUsage] + node_id=api.node_id, + key_path=tmp_path / "companion_cluster.key", + credentials_path=tmp_path / "companion_credentials.json", + ) + return api + + +def _build_client(api: API, *, raise_server_exceptions: bool = True) -> TestClient: + return TestClient( + api.app, + base_url="http://localhost", + raise_server_exceptions=raise_server_exceptions, + ) + + +def _tailscale_running() -> TailscaleStatus: + return TailscaleStatus( + running=True, + self_ip="100.1.2.3", + hostname="my-node", + dns_name="my-node.tailnet-abc.ts.net", + tailnet="tailnet-abc.ts.net", + version="1.66.1", + ) + + +def _tailscale_not_running() -> TailscaleStatus: + return TailscaleStatus(running=False) + + +def _remote_access_info() -> RemoteAccessInfo: + return RemoteAccessInfo( + local=LocalAccess( + ip="192.168.1.5", + port=52415, + url="http://192.168.1.5:52415", + ), + tailscale=TailscaleAccess( + running=False, + port=52415, + ), + preferred_url="http://192.168.1.5:52415", + operator_url="http://192.168.1.5:52415/operator", + ) + + +def _state_with_lan_ip(node_id: str, ip: str) -> State: + iface = NetworkInterfaceInfo(name="en0", ip_address=ip) + return State().model_copy( + update={"node_network": {NodeId(node_id): NodeNetworkInfo(interfaces=[iface])}} + ) + + +def _non_loopback_request(_: Request) -> bool: + return False + + +def test_create_pairing_session_returns_companion_qr_payload( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + api.state = _state_with_lan_ip("local-node", "192.168.1.5") + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + + response = client.post( + "/v1/companion/pairing-sessions", + json={"clusterName": "Kitchen Cluster"}, + ) + + assert response.status_code == 200 + body = _json_object(response) + payload = cast(dict[str, object], body["qrPayload"]) + assert payload["version"] == 1 + assert payload["clusterName"] == "Kitchen Cluster" + assert payload["pairingNonce"] + assert payload["clusterPublicKey"] + assert payload["lanUrl"] == "http://192.168.1.5:52415" + assert payload["tailscaleUrl"] == "http://my-node.tailnet-abc.ts.net:52415" + assert str(payload["exchangeUrl"]).endswith("/exchange") + + +def test_pairing_session_creation_requires_operator_token_for_nonlocal_request( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr(api_main, "_is_loopback_client", _non_loopback_request) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + + response = client.post("/v1/companion/pairing-sessions", json={}) + + assert response.status_code == 403 + assert ( + cast(dict[str, object], _json_object(response)["error"])["message"] + == "operator_auth_required" + ) + + +def test_pairing_session_creation_rejects_forwarded_nonlocal_loopback_request( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api, raise_server_exceptions=False) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + + response = client.post( + "/v1/companion/pairing-sessions", + headers={"X-Forwarded-For": "203.0.113.10"}, + json={}, + ) + + assert response.status_code == 403 + assert ( + cast(dict[str, object], _json_object(response)["error"])["message"] + == "operator_auth_required" + ) + + +def test_pairing_session_creation_rejects_cross_site_loopback_browser_request( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + + response = client.post( + "/v1/companion/pairing-sessions", + headers={ + "Origin": "https://evil.example", + "Sec-Fetch-Site": "cross-site", + }, + json={}, + ) + + assert response.status_code == 403 + assert ( + cast(dict[str, object], _json_object(response)["error"])["message"] + == "operator_auth_required" + ) + + +def test_pairing_session_creation_accepts_same_origin_loopback_browser_request( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + + response = client.post( + "/v1/companion/pairing-sessions", + headers={ + "Origin": "http://localhost:52415", + "Sec-Fetch-Site": "same-origin", + }, + json={}, + ) + + assert response.status_code == 200 + + +def test_pairing_session_creation_accepts_mixed_case_loopback_host( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + + response = client.post( + "/v1/companion/pairing-sessions", + headers={ + "Host": "LocalHost:52415", + "Origin": "http://LocalHost:52415", + "Sec-Fetch-Site": "same-origin", + }, + json={}, + ) + + assert response.status_code == 200 + + +def test_pairing_session_creation_rejects_loopback_peer_with_nonlocal_host( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = TestClient(api.app, base_url="http://skulk.local") + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + + response = client.post("/v1/companion/pairing-sessions", json={}) + + assert response.status_code == 403 + assert ( + cast(dict[str, object], _json_object(response)["error"])["message"] + == "operator_auth_required" + ) + + +def test_forwarded_loopback_with_ipv6_port_requires_operator_token( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + + response = client.post( + "/v1/companion/pairing-sessions", + headers={"Forwarded": 'for="[::1]:4711"'}, + json={}, + ) + + assert response.status_code == 403 + assert ( + cast(dict[str, object], _json_object(response)["error"])["message"] + == "operator_auth_required" + ) + + +def test_forwarded_loopback_with_ipv4_port_requires_operator_token( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + + response = client.post( + "/v1/companion/pairing-sessions", + headers={"X-Forwarded-For": "127.0.0.1:4711"}, + json={}, + ) + + assert response.status_code == 403 + assert ( + cast(dict[str, object], _json_object(response)["error"])["message"] + == "operator_auth_required" + ) + + +def test_pairing_session_creation_accepts_forwarded_nonlocal_request_with_token( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setenv("SKULK_COMPANION_PAIRING_TOKEN", "operator-token") + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + + response = client.post( + "/v1/companion/pairing-sessions", + headers={ + "X-Forwarded-For": "203.0.113.10", + "X-Skulk-Operator-Token": "operator-token", + }, + json={}, + ) + + assert response.status_code == 200 + + +def test_pairing_session_creation_accepts_configured_operator_token( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr(api_main, "_is_loopback_client", _non_loopback_request) + monkeypatch.setenv("SKULK_COMPANION_PAIRING_TOKEN", "operator-token") + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + + response = client.post( + "/v1/companion/pairing-sessions", + headers={"X-Skulk-Operator-Token": "operator-token"}, + json={}, + ) + + assert response.status_code == 200 + assert cast(dict[str, object], _json_object(response)["qrPayload"])[ + "pairingNonce" + ] + + +def test_pairing_session_creation_rejects_missing_reachable_url( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_not_running()), + ) + + response = client.post("/v1/companion/pairing-sessions", json={}) + + assert response.status_code == 409 + assert ( + cast(dict[str, object], _json_object(response)["error"])["message"] + == "pairing_url_unavailable" + ) + + +def test_companion_cluster_id_is_derived_from_persisted_cluster_key( + tmp_path: Path, +) -> None: + key_path = tmp_path / "companion_cluster.key" + first = CompanionPairingManager( + node_id=NodeId("first-node"), + key_path=key_path, + credentials_path=tmp_path / "first_credentials.json", + ) + second = CompanionPairingManager( + node_id=NodeId("second-node"), + key_path=key_path, + credentials_path=tmp_path / "second_credentials.json", + ) + + assert first.cluster_id == second.cluster_id + + +def test_companion_cluster_key_permissions_are_private(tmp_path: Path) -> None: + key_path = tmp_path / "companion_cluster.key" + CompanionPairingManager( + node_id=NodeId("local-node"), + key_path=key_path, + credentials_path=tmp_path / "companion_credentials.json", + ) + + assert key_path.stat().st_mode & 0o777 == 0o600 + + +def test_pairing_exchange_returns_read_only_credential( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + session_response = client.post("/v1/companion/pairing-sessions", json={}) + nonce = cast( + str, + cast(dict[str, object], _json_object(session_response)["qrPayload"])[ + "pairingNonce" + ], + ) + + response = client.post( + f"/v1/companion/pairing-sessions/{nonce}/exchange", + json={"clientName": "Thomas iPhone"}, + ) + + assert response.status_code == 200 + body = _json_object(response) + assert body["token"] + assert body["credentialId"] + assert body["scopes"] == [ + "cluster:read", + "nodes:read", + "models:read", + "events:read", + ] + + +def test_pairing_exchange_persists_client_name( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + session_response = client.post("/v1/companion/pairing-sessions", json={}) + nonce = cast( + str, + cast(dict[str, object], _json_object(session_response)["qrPayload"])[ + "pairingNonce" + ], + ) + + response = client.post( + f"/v1/companion/pairing-sessions/{nonce}/exchange", + json={"clientName": "Thomas iPhone"}, + ) + + credential_id = cast(str, _json_object(response)["credentialId"]) + credentials = (tmp_path / "companion_credentials.json").read_text() + assert credential_id in credentials + assert "Thomas iPhone" in credentials + + +def test_pairing_nonce_is_single_use( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + session_response = client.post("/v1/companion/pairing-sessions", json={}) + nonce = cast( + str, + cast(dict[str, object], _json_object(session_response)["qrPayload"])[ + "pairingNonce" + ], + ) + + first = client.post(f"/v1/companion/pairing-sessions/{nonce}/exchange", json={}) + second = client.post(f"/v1/companion/pairing-sessions/{nonce}/exchange", json={}) + + assert first.status_code == 200 + assert second.status_code == 400 + assert ( + cast(dict[str, object], _json_object(second)["error"])["message"] + == "already_used" + ) + + +def test_pairing_exchange_rejects_credential_limit( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + monkeypatch.setattr(companion_module, "MAX_COMPANION_CREDENTIALS", 1) + + first_session = client.post("/v1/companion/pairing-sessions", json={}) + first_nonce = cast( + str, + cast(dict[str, object], _json_object(first_session)["qrPayload"])[ + "pairingNonce" + ], + ) + second_session = client.post("/v1/companion/pairing-sessions", json={}) + second_nonce = cast( + str, + cast(dict[str, object], _json_object(second_session)["qrPayload"])[ + "pairingNonce" + ], + ) + + first = client.post(f"/v1/companion/pairing-sessions/{first_nonce}/exchange", json={}) + second = client.post( + f"/v1/companion/pairing-sessions/{second_nonce}/exchange", + json={}, + ) + + assert first.status_code == 200 + assert second.status_code == 400 + assert ( + cast(dict[str, object], _json_object(second)["error"])["message"] + == "credential_limit_reached" + ) + + +def test_pairing_exchange_ignores_revoked_credentials_for_limit( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + monkeypatch.setattr(companion_module, "MAX_COMPANION_CREDENTIALS", 1) + + first_session = client.post("/v1/companion/pairing-sessions", json={}) + first_nonce = cast( + str, + cast(dict[str, object], _json_object(first_session)["qrPayload"])[ + "pairingNonce" + ], + ) + first = client.post(f"/v1/companion/pairing-sessions/{first_nonce}/exchange", json={}) + first_body = _json_object(first) + manager = api._companion_pairing # pyright: ignore[reportPrivateUsage] + assert manager is not None + assert manager.revoke_credential(cast(str, first_body["credentialId"])) is True + + second_session = client.post("/v1/companion/pairing-sessions", json={}) + second_nonce = cast( + str, + cast(dict[str, object], _json_object(second_session)["qrPayload"])[ + "pairingNonce" + ], + ) + second = client.post( + f"/v1/companion/pairing-sessions/{second_nonce}/exchange", + json={}, + ) + + assert first.status_code == 200 + assert second.status_code == 200 + + +def test_pairing_nonce_survives_failed_credential_persistence( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api, raise_server_exceptions=False) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + session_response = client.post("/v1/companion/pairing-sessions", json={}) + nonce = cast( + str, + cast(dict[str, object], _json_object(session_response)["qrPayload"])[ + "pairingNonce" + ], + ) + manager = api._companion_pairing # pyright: ignore[reportPrivateUsage] + assert manager is not None + original_save = manager._save_credentials # pyright: ignore[reportPrivateUsage] + + def fail_save() -> None: + raise OSError("disk full") + + monkeypatch.setattr(manager, "_save_credentials", fail_save) + first = client.post(f"/v1/companion/pairing-sessions/{nonce}/exchange", json={}) + monkeypatch.setattr(manager, "_save_credentials", original_save) + + second = client.post(f"/v1/companion/pairing-sessions/{nonce}/exchange", json={}) + + assert first.status_code == 500 + assert second.status_code == 200 + + +def test_pairing_nonce_exchange_is_concurrency_safe( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + manager = CompanionPairingManager( + node_id=NodeId("local-node"), + key_path=tmp_path / "companion_cluster.key", + credentials_path=tmp_path / "companion_credentials.json", + ) + session = manager.create_session( + request=companion_module.CompanionPairingSessionRequest(), + remote_access=_remote_access_info(), + ) + nonce = session.qr_payload.pairing_nonce + original_save = manager._save_credentials # pyright: ignore[reportPrivateUsage] + + def slow_save() -> None: + time.sleep(0.05) + original_save() + + def exchange() -> str: + try: + response = manager.exchange_session( + nonce=nonce, + request=companion_module.CompanionPairingExchangeRequest(), + ) + except companion_module.CompanionAuthError as exc: + return str(exc) + return response.credential_id + + monkeypatch.setattr(manager, "_save_credentials", slow_save) + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(exchange) for _ in range(2)] + results = tuple(future.result() for future in futures) + + assert sum(result == "already_used" for result in results) == 1 + assert sum(result != "already_used" for result in results) == 1 + + +def test_pairing_nonce_expiry_is_rejected( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + now = datetime(2026, 5, 5, tzinfo=timezone.utc) + monkeypatch.setattr(companion_module, "_utc_now", lambda: now) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + session_response = client.post("/v1/companion/pairing-sessions", json={}) + nonce = cast( + str, + cast(dict[str, object], _json_object(session_response)["qrPayload"])[ + "pairingNonce" + ], + ) + monkeypatch.setattr( + companion_module, + "_utc_now", + lambda: now + timedelta(minutes=6), + ) + + response = client.post(f"/v1/companion/pairing-sessions/{nonce}/exchange", json={}) + + assert response.status_code == 400 + assert ( + cast(dict[str, object], _json_object(response)["error"])["message"] + == "expired_code" + ) + + +def test_companion_overview_requires_bearer_token(tmp_path: Path) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + + response = client.get("/v1/companion/overview") + + assert response.status_code == 401 + assert ( + cast(dict[str, object], _json_object(response)["error"])["message"] + == "missing_token" + ) + + +def test_companion_overview_returns_safe_read_only_shape( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + api.state = _state_with_lan_ip("local-node", "192.168.1.5") + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + session_response = client.post("/v1/companion/pairing-sessions", json={}) + nonce = cast( + str, + cast(dict[str, object], _json_object(session_response)["qrPayload"])[ + "pairingNonce" + ], + ) + exchange_response = client.post( + f"/v1/companion/pairing-sessions/{nonce}/exchange", + json={}, + ) + token = cast(str, _json_object(exchange_response)["token"]) + + response = client.get( + "/v1/companion/overview", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + body = _json_object(response) + assert set(body) == { + "cluster", + "connection", + "nodes", + "runningModels", + "recentEvents", + } + assert cast(dict[str, object], body["connection"])["authenticated"] is True + nodes = cast(list[dict[str, object]], body["nodes"]) + assert nodes[0]["nodeId"] == "local-node" + assert "admin" not in body + + +def test_revoked_companion_credential_cannot_authenticate( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + session_response = client.post("/v1/companion/pairing-sessions", json={}) + nonce = cast( + str, + cast(dict[str, object], _json_object(session_response)["qrPayload"])[ + "pairingNonce" + ], + ) + exchange_response = client.post( + f"/v1/companion/pairing-sessions/{nonce}/exchange", + json={}, + ) + exchange_body = _json_object(exchange_response) + token = cast(str, exchange_body["token"]) + credential_id = cast(str, exchange_body["credentialId"]) + manager = api._companion_pairing # pyright: ignore[reportPrivateUsage] + assert manager is not None + assert manager.revoke_credential(credential_id) is True + + response = client.get( + "/v1/companion/overview", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 401 + assert ( + cast(dict[str, object], _json_object(response)["error"])["message"] + == "auth_failed" + ) + + +def test_revoke_credential_rolls_back_memory_state_when_persistence_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + manager = CompanionPairingManager( + node_id=NodeId("local-node"), + key_path=tmp_path / "companion_cluster.key", + credentials_path=tmp_path / "companion_credentials.json", + ) + session = manager.create_session( + request=companion_module.CompanionPairingSessionRequest(), + remote_access=_remote_access_info(), + ) + exchange = manager.exchange_session( + nonce=session.qr_payload.pairing_nonce, + request=companion_module.CompanionPairingExchangeRequest(), + ) + + def fail_save() -> None: + raise OSError("disk full") + + monkeypatch.setattr(manager, "_save_credentials", fail_save) + with pytest.raises(OSError, match="disk full"): + manager.revoke_credential(exchange.credential_id) + + assert manager.authenticate_bearer(exchange.token).credential_id == exchange.credential_id + + +def test_companion_credential_hash_survives_manager_restart( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _build_api(tmp_path) + client = _build_client(api) + monkeypatch.setattr( + remote_access_module, + "query_tailscale_status", + AsyncMock(return_value=_tailscale_running()), + ) + session_response = client.post("/v1/companion/pairing-sessions", json={}) + nonce = cast( + str, + cast(dict[str, object], _json_object(session_response)["qrPayload"])[ + "pairingNonce" + ], + ) + exchange_response = client.post( + f"/v1/companion/pairing-sessions/{nonce}/exchange", + json={}, + ) + token = cast(str, _json_object(exchange_response)["token"]) + + restarted = CompanionPairingManager( + node_id=api.node_id, + key_path=tmp_path / "companion_cluster.key", + credentials_path=tmp_path / "companion_credentials.json", + ) + + assert restarted.authenticate_bearer(token).scopes == ( + "cluster:read", + "nodes:read", + "models:read", + "events:read", + ) + + +def test_corrupted_companion_credential_store_does_not_block_startup( + tmp_path: Path, +) -> None: + credentials_path = tmp_path / "companion_credentials.json" + credentials_path.write_text("{") + + manager = CompanionPairingManager( + node_id=NodeId("local-node"), + key_path=tmp_path / "companion_cluster.key", + credentials_path=credentials_path, + ) + + assert manager.revoke_credential("missing") is False + + +def test_non_utf8_companion_credential_store_does_not_block_startup( + tmp_path: Path, +) -> None: + credentials_path = tmp_path / "companion_credentials.json" + credentials_path.write_bytes(b"\xff\xfe") + + manager = CompanionPairingManager( + node_id=NodeId("local-node"), + key_path=tmp_path / "companion_cluster.key", + credentials_path=credentials_path, + ) + + assert manager.revoke_credential("missing") is False + + +def test_corrupted_companion_cluster_key_is_regenerated(tmp_path: Path) -> None: + key_path = tmp_path / "companion_cluster.key" + key_path.write_bytes(b"not-an-ed25519-key") + + manager = CompanionPairingManager( + node_id=NodeId("local-node"), + key_path=key_path, + credentials_path=tmp_path / "companion_credentials.json", + ) + + assert manager.cluster_public_key.startswith("ed25519:") + assert key_path.stat().st_size == 32 + + +def test_api_does_not_initialize_companion_pairing_at_startup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail_init(*_: object, **__: object) -> CompanionPairingManager: + raise AssertionError("companion pairing should initialize lazily") + + monkeypatch.setattr(api_main, "CompanionPairingManager", fail_init) + command_sender, _ = channel[ForwarderCommand]() + download_sender, _ = channel[ForwarderDownloadCommand]() + _, event_receiver = channel[IndexedEvent]() + _, election_receiver = channel[ElectionMessage]() + + api = API( + NodeId("local-node"), + port=52415, + event_receiver=event_receiver, + command_sender=command_sender, + download_command_sender=download_sender, + election_receiver=election_receiver, + enable_event_log=False, + mount_dashboard=False, + ) + + assert api._companion_pairing is None # pyright: ignore[reportPrivateUsage] diff --git a/src/exo/shared/constants.py b/src/exo/shared/constants.py index 10d44ca28..409ed5442 100644 --- a/src/exo/shared/constants.py +++ b/src/exo/shared/constants.py @@ -134,6 +134,8 @@ def add_model_search_path(path: Path) -> None: # Identity (config) SKULK_NODE_ID_KEYPAIR = SKULK_CONFIG_HOME / "node_id.keypair" +SKULK_COMPANION_CLUSTER_KEY = SKULK_CONFIG_HOME / "companion_cluster.key" +SKULK_COMPANION_CREDENTIALS = SKULK_CONFIG_HOME / "companion_credentials.json" SKULK_CONFIG_FILE = SKULK_CONFIG_HOME / "config.toml" # libp2p topics for event forwarding @@ -186,6 +188,8 @@ def add_model_search_path(path: Path) -> None: EXO_LOG = SKULK_LOG EXO_TEST_LOG = SKULK_TEST_LOG EXO_NODE_ID_KEYPAIR = SKULK_NODE_ID_KEYPAIR +EXO_COMPANION_CLUSTER_KEY = SKULK_COMPANION_CLUSTER_KEY +EXO_COMPANION_CREDENTIALS = SKULK_COMPANION_CREDENTIALS EXO_CONFIG_FILE = SKULK_CONFIG_FILE EXO_MAX_CHUNK_SIZE = SKULK_MAX_CHUNK_SIZE EXO_CUSTOM_MODEL_CARDS_DIR = SKULK_CUSTOM_MODEL_CARDS_DIR diff --git a/uv.lock b/uv.lock index 2590c5abe..375fc5744 100644 --- a/uv.lock +++ b/uv.lock @@ -348,34 +348,43 @@ name = "cryptography" version = "46.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'linux'" }, + { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, @@ -557,6 +566,7 @@ dependencies = [ { name = "aiofiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "ddgs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "exo-pyo3-bindings", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -601,6 +611,7 @@ requires-dist = [ { name = "aiofiles", specifier = ">=24.1.0" }, { name = "aiohttp", specifier = ">=3.12.14" }, { name = "anyio", specifier = "==4.11.0" }, + { name = "cryptography", specifier = ">=46.0.0" }, { name = "ddgs", specifier = ">=9.6.1" }, { name = "exo-pyo3-bindings", editable = "rust/exo_pyo3_bindings" }, { name = "fastapi", specifier = ">=0.116.1" },