Compare commits
1393 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c4c9c5edd | |||
| 0f34255359 | |||
| de5fb65cb8 | |||
| e773f84e39 | |||
| b969c216fc | |||
| f51a4d2aca | |||
| 30b3a9de30 | |||
| 0391f6553b | |||
| d0c986c4f0 | |||
| 384028e12e | |||
| 4c14d6c8db | |||
| 6fa437613b | |||
| 949fa1051f | |||
| 4965727f39 | |||
| 7c34883267 | |||
| 072c3dc55c | |||
| 83b3875131 | |||
| a35083808c | |||
| 3d3ec9d972 | |||
| 9838a2850f | |||
| 6c6bc6ff1c | |||
| 1791c1a765 | |||
| 6e53c061ff | |||
| 1773f8aea2 | |||
| 52bdf57743 | |||
| b3ab24eb8e | |||
| 6ac1c1d6ea | |||
| 7655a501d0 | |||
| 3b1b14b0b1 | |||
| bf15c87d2b | |||
| 2dae4d382f | |||
| e9a47a02d1 | |||
| 929666a8c8 | |||
| cd409e5667 | |||
| 8e80823b03 | |||
| 319afd192d | |||
| 6e0daf0936 | |||
| d0cb4e092f | |||
| f5a881c99d | |||
| 66377fc030 | |||
| d8d295b0b3 | |||
| ef36e24522 | |||
| d43d4fcced | |||
| d42b69df74 | |||
| 1ec1f6dcbf | |||
| e96b939732 | |||
| e479c870fd | |||
| f2db894685 | |||
| 4f03283126 | |||
| ed7dec0975 | |||
| fbb3da506f | |||
| dfa6c5c2b3 | |||
| 028eed5fe8 | |||
| 2b16a87f04 | |||
| 731049936d | |||
| 5a5b058ba0 | |||
| 72f28be648 | |||
| ff645524d8 | |||
| c534390bc0 | |||
| c5003e5441 | |||
| 0b3ebb0c63 | |||
| 23981496f9 | |||
| f2e425dc2b | |||
| e48d68bbc7 | |||
| 842fc8d08b | |||
| 54ec14262b | |||
| d0c70178e0 | |||
| 3bc9c330eb | |||
| b7fcc8584f | |||
| 2b8ce3f06b | |||
| 41d44021e7 | |||
| 1ab1e312b2 | |||
| fa9aafce83 | |||
| 0d5dec4c66 | |||
| b2d5889f6e | |||
| 2ee71e4154 | |||
| 1656f491fd | |||
| a057b3c9e8 | |||
| 236b27cb3a | |||
| 1634abf293 | |||
| ca9688b5cc | |||
| e6364d031d | |||
| 01c8d099ad | |||
| f6e619f078 | |||
| b2b331230b | |||
| c0c9742e44 | |||
| abcca86e4e | |||
| a5d8f89b53 | |||
| a0d2a7232e | |||
| f449115ec5 | |||
| 16bc4cdef3 | |||
| 23e4ba845c | |||
| d9f9e93dee | |||
| dae34f3a61 | |||
| af61b353a4 | |||
| 29476b222d | |||
| e7c16cc0e6 | |||
| 3dddbe1053 | |||
| 2dea6bfa7e | |||
| 04e3bfed35 | |||
| 3e32050601 | |||
| 7fb45ed9b8 | |||
| b7ba94f0c1 | |||
| 5b827528f8 | |||
| 409e33d9c2 | |||
| 747277d914 | |||
| 068dca3366 | |||
| 336a1ad9cf | |||
| d42f767d0c | |||
| 9b8ae62399 | |||
| 7c38b535f6 | |||
| af370ab23e | |||
| 1210657fda | |||
| 12afec953f | |||
| 8e2707e232 | |||
| 8befe7f8a7 | |||
| a70fcc8ae0 | |||
| fa521154ff | |||
| dffc1a4dcd | |||
| 61e385b331 | |||
| f8f319713f | |||
| 509215e935 | |||
| f726656d1e | |||
| 30d3e1da21 | |||
| bb665bf22c | |||
| f17dcb6213 | |||
| d54c101100 | |||
| 96daa51d45 | |||
| 6084421ec6 | |||
| bca5c0d569 | |||
| 8c3cdba21c | |||
| e274b5a040 | |||
| a139d35fa2 | |||
| 8312a19f02 | |||
| fe8b28cdd9 | |||
| 10eb1beccf | |||
| a4b347b454 | |||
| 688a0ce439 | |||
| 6146acbb69 | |||
| 35492f8513 | |||
| db9be87d94 | |||
| 4a99b9b651 | |||
| 7dea403302 | |||
| 10731dfee3 | |||
| d00f2d9c0c | |||
| 8b89980a89 | |||
| 634a429c50 | |||
| bf90815b9e | |||
| 2b113c4d6c | |||
| 7f6a288bd3 | |||
| daf471c450 | |||
| cb78fa46a1 | |||
| 316e8b2eb2 | |||
| 4de81ed6c4 | |||
| b6fb24f6d2 | |||
| 2b1c26f900 | |||
| 47ea9356d1 | |||
| f12c1b391f | |||
| 05658b6609 | |||
| dfb6630de1 | |||
| eb7656d68c | |||
| 0e1dcf9cb4 | |||
| d05c3d0659 | |||
| 36292d3fbb | |||
| 1ae344d8a6 | |||
| 01c43b0b0c | |||
| fc4aa9a683 | |||
| c043e9767f | |||
| 8b48299d8f | |||
| 0f27cff247 | |||
| 2d1ae0916f | |||
| d0455f2683 | |||
| f25fe032c4 | |||
| 0a5b1fbcd1 | |||
| 8daab932a2 | |||
| 3b7b45c29e | |||
| 3a446dd400 | |||
| 876efb11e7 | |||
| c7615aa559 | |||
| 6042485367 | |||
| 725c340257 | |||
| 9abd7ceda2 | |||
| cf81cb942b | |||
| 9097ef90b7 | |||
| 4f1a4ab072 | |||
| 0facc63019 | |||
| 95735c3978 | |||
| d5d33d4848 | |||
| 84e9401d53 | |||
| 84bee3d7d0 | |||
| 6ff3c39989 | |||
| 5c8239a2a9 | |||
| f9170c5d02 | |||
| 6ccb19e274 | |||
| 52f876bfbc | |||
| 415ff7f483 | |||
| 74bc5bfd7c | |||
| 7e1e7ba2d8 | |||
| aac5b4673f | |||
| c86b257d38 | |||
| 4291d56e0b | |||
| 5599603bdb | |||
| e0f69a2294 | |||
| 510915a801 | |||
| 609d029e20 | |||
| 4275ed68a2 | |||
| 75d2785d20 | |||
| b77b47bb98 | |||
| 5f87f7bbf5 | |||
| 1afdb850f3 | |||
| 725a6b71dc | |||
| f4bb5b381d | |||
| 11d4fc101e | |||
| 4e6fb47a3f | |||
| a39bb4310c | |||
| f1ac18933c | |||
| 2bae4d2dba | |||
| 1797233989 | |||
| 3171781d58 | |||
| 35ddd8db5e | |||
| c269d5f258 | |||
| 627cc1f04b | |||
| 53d0bf653a | |||
| a5a9788b20 | |||
| 9c04a79c0a | |||
| f3519d895c | |||
| 0ac5480034 | |||
| 5cd48bbe7d | |||
| 8dacafce7f | |||
| 8c4b8f2c38 | |||
| fee8bcc50b | |||
| cb9f4a0485 | |||
| 79f340a410 | |||
| 081e5ef572 | |||
| f5577ad78e | |||
| b2b1c1de9c | |||
| 3e6917c8ae | |||
| 564b6aa2aa | |||
| eb3eb3c39e | |||
| 2579609922 | |||
| 946b0229e8 | |||
| 0a1eeedc10 | |||
| 5a58feefdc | |||
| 48b15bd099 | |||
| 1c96477686 | |||
| 04ebf8d67a | |||
| 82d83a9d54 | |||
| 66de8aedeb | |||
| 154b8e3e0e | |||
| d9f2ee40f7 | |||
| 6bcb89cf38 | |||
| 3475ecde6f | |||
| 728cd5e974 | |||
| 0fab14ad9d | |||
| d23febcd6a | |||
| d763926364 | |||
| 7a683a4b62 | |||
| 44a237b637 | |||
| 375304bf13 | |||
| d59aab7fd3 | |||
| 60748b1370 | |||
| 624cb33534 | |||
| 9603eff79a | |||
| e9d6dec2f4 | |||
| 0cbfea79fa | |||
| aaae327563 | |||
| 5b23f847d6 | |||
| 5a55789bc1 | |||
| 9c396f6331 | |||
| 51e871f9e5 | |||
| 47634c294d | |||
| 9c1122def0 | |||
| 2bd9e84851 | |||
| 5c2eedc340 | |||
| 3b7d103758 | |||
| 8146c43aa3 | |||
| df386927ce | |||
| 0c18b2c442 | |||
| 1a7b7eba59 | |||
| 54fb59b8f3 | |||
| a2f0d335f4 | |||
| 2e70c3ceab | |||
| ca1902fb4e | |||
| f11a89031b | |||
| 3c22fab679 | |||
| ad46e95df9 | |||
| 7d4f2d9aed | |||
| 738b3592cd | |||
| cd2af64860 | |||
| 04f1e767b2 | |||
| 1c7ac2a6ab | |||
| 77cf40da87 | |||
| 1fe8df85cb | |||
| 139f80a291 | |||
| 2d066b8715 | |||
| 757243993c | |||
| 4fb114dcb3 | |||
| 612cdac4c3 | |||
| 57c66fe813 | |||
| 9c02ea9098 | |||
| 042b65dfcc | |||
| f6a72ef3c2 | |||
| 568cc368ae | |||
| 5abe3c2145 | |||
| 0e76d21f11 | |||
| 5e8693bc42 | |||
| ef78b198cb | |||
| 5fdaef3646 | |||
| fa4670c5fe | |||
| c4402a1ce5 | |||
| edd8c613d6 | |||
| 0a7f5bf6a5 | |||
| eb3e865f15 | |||
| 1baa55c145 | |||
| 6ef3837e73 | |||
| e7167e35ed | |||
| 2e0325e3bf | |||
| 56b3b44342 | |||
| 113eea5047 | |||
| dcadaad228 | |||
| 7b04e6ac42 | |||
| 1533868680 | |||
| f275cc180b | |||
| 31d3aef8d6 | |||
| bd467ff765 | |||
| d0a4cce41e | |||
| 429f973280 | |||
| 6320f739d4 | |||
| 1732932c57 | |||
| 574b6ab5b1 | |||
| 1c737f88fe | |||
| fa8d9b9189 | |||
| 512dbedee3 | |||
| db21c2d397 | |||
| 1078d178d7 | |||
| a6e780b2f6 | |||
| 4e48d0a431 | |||
| c289a88f50 | |||
| 4ce495cb2c | |||
| dfea2991c9 | |||
| 6f5fc2276a | |||
| d51a9ebb0e | |||
| 536d3d76a3 | |||
| 5c52dbf661 | |||
| 8f797f213e | |||
| ed68f378d7 | |||
| 7e8de907f8 | |||
| f3c9252840 | |||
| da9e27f466 | |||
| aa74e28112 | |||
| e569f15631 | |||
| eaace34233 | |||
| 7a839e7eb6 | |||
| 1b24b6a02b | |||
| 2b4a68e276 | |||
| b1e3d79eaa | |||
| 2fb2035dbf | |||
| 3c51290e0d | |||
| 765196d5c3 | |||
| 12c2d37e62 | |||
| 151a551be9 | |||
| 09ce6ff99e | |||
| 6ffd7111a6 | |||
| 7904a14af1 | |||
| 1b79730db8 | |||
| ad8799522c | |||
| f65668cb5f | |||
| 393d21d86c | |||
| 232c512502 | |||
| 8c1e6a82b2 | |||
| 2d54efe851 | |||
| c2a4f256c8 | |||
| c91c85532a | |||
| 326d4049da | |||
| 9b7c4b3884 | |||
| bcde09ae91 | |||
| 632651aee2 | |||
| d2b76acb72 | |||
| daf69e8154 | |||
| e5c8abab9e | |||
| 2c312e20f1 | |||
| 9f1a8be2bf | |||
| bd7d362d3b | |||
| 0d0b77ded6 | |||
| 83a25d26fc | |||
| 9b7df414e6 | |||
| f87016a5fe | |||
| 5894ffe82e | |||
| 50fa106d87 | |||
| 983e1b2303 | |||
| de7f567b9a | |||
| 0eabc89840 | |||
| 400e901c9c | |||
| 57b4865ab3 | |||
| fd41000bc3 | |||
| a70937c926 | |||
| 4ec2222fa9 | |||
| fe974f420d | |||
| e65e5f40c9 | |||
| 0235eb6c72 | |||
| e943e63174 | |||
| b4ba6e4eaf | |||
| 0adcb68092 | |||
| dadef27d7a | |||
| 53465a4d2d | |||
| 4e837cfa2d | |||
| 964e6169cb | |||
| c379191f80 | |||
| 912ebffc63 | |||
| b7a11b7bd4 | |||
| 95bdb28a05 | |||
| 9930ba91c5 | |||
| 802c02eb74 | |||
| 2d4e3253ca | |||
| da8d45d6c6 | |||
| 18b4575e4d | |||
| 40fb59e5f7 | |||
| e3ff8c4d28 | |||
| ce59e2dd76 | |||
| 32cfc49002 | |||
| d19bc1562b | |||
| ea018a68cc | |||
| 1089444807 | |||
| 4af8228c34 | |||
| ebea98b8ec | |||
| 51683071e8 | |||
| bfa46b2471 | |||
| de62797128 | |||
| 05673fb6cf | |||
| 350f4709b7 | |||
| b5f7ba502d | |||
| 8ba80d2dac | |||
| b11eea07b0 | |||
| 35cea9be25 | |||
| 3e0e608110 | |||
| e2f8909982 | |||
| ac613b6632 | |||
| 5323652cfd | |||
| a58ff1ac63 | |||
| 2b60ee96f2 | |||
| da6f07b7c1 | |||
| f0b624d6c9 | |||
| e4c3c02a36 | |||
| ae796b1194 | |||
| c892f38d3c | |||
| d98b6beb4d | |||
| b80abf8dd1 | |||
| acfa762617 | |||
| a44f1912b3 | |||
| bcbfb357be | |||
| b2179de839 | |||
| b1102cedd7 | |||
| 571f8c78bd | |||
| 93fbd103ba | |||
| a740d563d7 | |||
| 8f6e67553f | |||
| 0a8be132b9 | |||
| 4c932edabc | |||
| 5283872e00 | |||
| 45c314fbe6 | |||
| 119e53967b | |||
| d36a004468 | |||
| 8778c39ed0 | |||
| b071f73fef | |||
| afde0a17b7 | |||
| 714de9d996 | |||
| 6b587fa411 | |||
| 7e2f5126bc | |||
| 9eab82b717 | |||
| e49ccf49fd | |||
| aac3615d7a | |||
| 7de6e925aa | |||
| 6fdfe8ea73 | |||
| 84bfaad6e6 | |||
| fcac2464e6 | |||
| 3eb48cbea7 | |||
| 72a48c4992 | |||
| 993c1de361 | |||
| 90342a4f3a | |||
| 0cd632ba84 | |||
| e8779ac329 | |||
| 32d844d3b6 | |||
| 36725ce153 | |||
| 0d537ece10 | |||
| f825dd2897 | |||
| 9faa95d558 | |||
| 365cbe8d50 | |||
| 9a322d52e2 | |||
| 89013efbca | |||
| 3a90335b5a | |||
| 676b64e8a3 | |||
| 9007920695 | |||
| 2887376646 | |||
| e48d452c63 | |||
| 165841ae79 | |||
| 76acdb7ae7 | |||
| dfbe4041f5 | |||
| 4fd1a6dec3 | |||
| aa394d0e14 | |||
| d5b17d344b | |||
| 8111e18dbd | |||
| ba7d12f205 | |||
| ef66ad3b52 | |||
| 69e4339af9 | |||
| 779904657f | |||
| cb0f6cefa4 | |||
| 25ef01b74a | |||
| 6db0201fcd | |||
| 40e508823f | |||
| 3368284b2a | |||
| ece01d89fe | |||
| 9c0c4f50ec | |||
| 6729637f61 | |||
| 18d22aa426 | |||
| a3641526ab | |||
| f50e06a1b6 | |||
| 2ae3b45ac1 | |||
| 780a43711f | |||
| d682b604de | |||
| 721a6bf0f9 | |||
| 25a5f1cb96 | |||
| fa75d84b75 | |||
| bb2df13be0 | |||
| 5918def440 | |||
| 1fdd3592d3 | |||
| a96d299971 | |||
| 42ff634a9d | |||
| b45d7c3256 | |||
| 77ca508274 | |||
| 61b7398cb7 | |||
| 0321d5ed74 | |||
| 78627ce7c2 | |||
| c851bdd47a | |||
| 9ec0016258 | |||
| 01776e0569 | |||
| d8f14078f0 | |||
| 38244b8e94 | |||
| 9308762d0b | |||
| 8eb1c76337 | |||
| f94ad21f1e | |||
| 8d640ccc68 | |||
| f566e6451f | |||
| 75a7855223 | |||
| 8f105288d2 | |||
| 696a6ec4d4 | |||
| 3271ff1d6e | |||
| 0efcfc0864 | |||
| 0aba911912 | |||
| 4efb5cc18e | |||
| 57db3f22a1 | |||
| 9b44c80b30 | |||
| 7c7f4d0eb7 | |||
| ccc24e2c26 | |||
| 62bdbe1821 | |||
| 58d1d11762 | |||
| 8a9096cd52 | |||
| b70298fbca | |||
| 7616b02bb1 | |||
| d4c205f8e1 | |||
| 0ba60ff69c | |||
| 755a7e1b20 | |||
| 3061d8e057 | |||
| ea5597b483 | |||
| b41e75a15d | |||
| bfdbaa5ab6 | |||
| 93ae3b8405 | |||
| f249a82383 | |||
| ea9486ae2d | |||
| da95b58a2a | |||
| e15d5d0533 | |||
| 1cf45f8439 | |||
| 2a9ef806a0 | |||
| 32115a8b98 | |||
| 7ce902b096 | |||
| c4e8b60d2c | |||
| c9fdd68232 | |||
| 50260fd385 | |||
| 0822f5610a | |||
| 2c2ca7f03b | |||
| c4014c0092 | |||
| c08441c42c | |||
| 980f274fc9 | |||
| 9f1f65f0e3 | |||
| cddd836909 | |||
| bab7eeaf91 | |||
| d45915d39f | |||
| fcc814accd | |||
| 78a3d965e0 | |||
| 66ad8a9289 | |||
| df6634727e | |||
| 8b5cd97ceb | |||
| 25297ce3f5 | |||
| 3800fea962 | |||
| ecb91bbb1a | |||
| ab993904d7 | |||
| 2467a103b2 | |||
| 68569afb4b | |||
| 67d0ab3030 | |||
| 4024bca55d | |||
| b928b96af5 | |||
| cb871a3fc1 | |||
| da0a062fa7 | |||
| ba316a10cc | |||
| 4086408b10 | |||
| c1f82d9ec1 | |||
| eb6cace60f | |||
| 00f4331343 | |||
| cb469ecf45 | |||
| 46a694bbc7 | |||
| 56c406b19e | |||
| 79a6506593 | |||
| 99a6fcf3f7 | |||
| abe9440096 | |||
| 542c8020ec | |||
| 8edf2146ae | |||
| 3c8d0083cb | |||
| 28248f9602 | |||
| d225c4a7d1 | |||
| e05a8477b9 | |||
| 357a6e340a | |||
| a87d37f26d | |||
| 958a4fd414 | |||
| a27efd57bd | |||
| d57db17300 | |||
| 4f1c6e76fd | |||
| 2111d0c653 | |||
| 642e6acf49 | |||
| 88716d8d2a | |||
| c2e37c78ff | |||
| 5a2688c7b5 | |||
| ba1d80bd00 | |||
| 4dfcd56893 | |||
| e0ddc488d0 | |||
| c012019a8a | |||
| 7896b30489 | |||
| ffc465394e | |||
| 0edbdb1948 | |||
| 5dc187f00c | |||
| 231d2d5fdf | |||
| 20ba8d4891 | |||
| b32f6a0e00 | |||
| e5c77315ce | |||
| dd8f7552ad | |||
| 95ed49ce9a | |||
| 92760de472 | |||
| 24190d09da | |||
| 07bdb8af7e | |||
| 6a48688c09 | |||
| c03a745f61 | |||
| 48fdf3775d | |||
| e5708d443a | |||
| e2ea20f862 | |||
| 1eb924739b | |||
| 350f956f2c | |||
| 1f3ae2346e | |||
| 4f3bedfdb7 | |||
| 6f75feaeb8 | |||
| e949cc383f | |||
| a4bd960880 | |||
| 3636a2bf51 | |||
| 103003d9ff | |||
| ce23c70855 | |||
| c5fa757ef6 | |||
| bb9a9633a8 | |||
| ca98f87b2f | |||
| df64771ecf | |||
| cbe11e3de0 | |||
| daa753112c | |||
| 2e654e8d63 | |||
| cf92099d40 | |||
| a55c880191 | |||
| a8680f9a09 | |||
| 2e08a868a7 | |||
| 2785009c6f | |||
| 73d9469bf8 | |||
| 72100ba3ab | |||
| ec5099db89 | |||
| 209380edf8 | |||
| cf8251bb81 | |||
| 6ed2d69ff9 | |||
| e7e544174f | |||
| 9b0d9db3a3 | |||
| fd768334a9 | |||
| 9f90d0721a | |||
| 1920138122 | |||
| 27d940f5b6 | |||
| 7ba72aeb6c | |||
| f19d37c7bb | |||
| 9d5bf38416 | |||
| e0c1f2fdc0 | |||
| 08fdac0561 | |||
| d3eeddfc2f | |||
| 0a24bc0427 | |||
| 9da97d1a41 | |||
| 29b7b2068a | |||
| f4ab057807 | |||
| cb35db0c7e | |||
| f13db1c836 | |||
| 59063a7c15 | |||
| 5bc4971432 | |||
| 59c8d2d17f | |||
| 21405b0dfc | |||
| 256304037e | |||
| d8ae905d54 | |||
| bbc34215a2 | |||
| 583fc4fb11 | |||
| 0b2b8c7c52 | |||
| 5d83be76c9 | |||
| 877bc61b53 | |||
| fcaeee7073 | |||
| 6857f16609 | |||
| 2faf7cea93 | |||
| 26d5cca97c | |||
| 99fea64823 | |||
| 42c17adb5e | |||
| 3467b0ba07 | |||
| 490cb834e5 | |||
| cd12ad8aab | |||
| eceb41f6f7 | |||
| 6f496b7739 | |||
| e961e02f71 | |||
| 36a02b3e67 | |||
| b73042500e | |||
| 6406ed869a | |||
| f839d949b2 | |||
| d4f7dc067e | |||
| 3dbfe65eea | |||
| ddd4b55cf6 | |||
| 298c6eea1f | |||
| 74806aa5e3 | |||
| 55aeb8a0d3 | |||
| 86ea00dc21 | |||
| a0a7e74a62 | |||
| bb7397c636 | |||
| 7dc44b04c1 | |||
| 45232137a2 | |||
| b1c3e38df0 | |||
| 0be62c3542 | |||
| 523f91758d | |||
| 5baba5f84e | |||
| ffbcd83d1e | |||
| 1baf9f6a83 | |||
| 77b20377cc | |||
| 3ffb9a3b5e | |||
| 29807119d5 | |||
| b88ea39b83 | |||
| 0a2dcd844b | |||
| 2ed95634fe | |||
| 9526f9861a | |||
| 7b93356fb7 | |||
| 17ff25bd20 | |||
| d24de1ec3b | |||
| 44e1f271c8 | |||
| 8ff09f8337 | |||
| e91aa0657e | |||
| 14801b46fc | |||
| 99a7548c07 | |||
| 35bbc2ba87 | |||
| cf78d28d74 | |||
| eeca541dde | |||
| 4bba49770d | |||
| f5d5661adf | |||
| f83fb70360 | |||
| 355c13564c | |||
| f1dd59bf82 | |||
| fd1e959c2d | |||
| 1b2c1545a0 | |||
| 05ac67c520 | |||
| f5ee2b3a4f | |||
| 8afdf75e2b | |||
| 6aed3c0fd3 | |||
| f0fbd8b012 | |||
| 5a3eb5ad62 | |||
| 79beb20ba2 | |||
| 3da1afed68 | |||
| 717a259056 | |||
| adaa30c73a | |||
| ff292e67ce | |||
| 31752aa944 | |||
| bf11a42c37 | |||
| 8049f33435 | |||
| 115591c5b6 | |||
| a3938d62f6 | |||
| 3c7a8579ad | |||
| f5a9421b10 | |||
| 562d0e3b5f | |||
| bf7e813573 | |||
| c69abe08eb | |||
| 5a29ec78ca | |||
| 42b43f8c58 | |||
| c1f8f1d9d0 | |||
| 35f8be33d2 | |||
| 2a875b486e | |||
| c13de0b41d | |||
| b9bd380ed8 | |||
| 6bd689a847 | |||
| 8fb655198f | |||
| a4308a2428 | |||
| ca8e2bccab | |||
| 83c206d68a | |||
| f102d1bb9d | |||
| fb6a809f91 | |||
| d8feadb57a | |||
| 1050126132 | |||
| 9554292083 | |||
| a8f67f0be6 | |||
| d0a78da54f | |||
| fadad6e061 | |||
| 6a7b812513 | |||
| 6711eaf8a5 | |||
| 71fdc829e6 | |||
| 19c96e8c0b | |||
| 98e75fce17 | |||
| 252841ab13 | |||
| a7cb270999 | |||
| efdf874407 | |||
| 7db1cbe178 | |||
| 1f63ee565f | |||
| 006e1352d8 | |||
| 4d075a703e | |||
| 3ab9d99eed | |||
| 842e91d019 | |||
| ba3158e01a | |||
| 8b60003601 | |||
| 86a2808bff | |||
| b77070cccf | |||
| 10a50645ef | |||
| d0ba56c5ac | |||
| b8f8e7f4dd | |||
| 3fba8ceb97 | |||
| 60823fd9bd | |||
| e79cf5a8b1 | |||
| 018f7aa4df | |||
| 414ad72d17 | |||
| e1150f1b93 | |||
| d17fc7e448 | |||
| 4c5f78ca01 | |||
| 43c258c0f2 | |||
| 484a33f348 | |||
| d5d8c01dc7 | |||
| 097e66391f | |||
| 7466575120 | |||
| e19a5dc2b1 | |||
| 4f9a08a5cd | |||
| f00667ea25 | |||
| 3ba2eb6298 | |||
| 1850013cae | |||
| 79cbb20988 | |||
| 496bad8b98 | |||
| 960ed66501 | |||
| 1a89a5dd14 | |||
| 5b44825cb3 | |||
| 1ffb0fe787 | |||
| 40cc7f5426 | |||
| 46a6d79784 | |||
| 20d606c4c4 | |||
| e388334127 | |||
| c9f2358769 | |||
| 4044957819 | |||
| b185d130ba | |||
| 2bed0d78af | |||
| 0baf08fda1 | |||
| 048ee4b838 | |||
| c4d85dc045 | |||
| d0861670bd | |||
| 744fadbded | |||
| 0f257f792a | |||
| 53e04968fe | |||
| 285906e546 | |||
| 5bc5b695a9 | |||
| 2da2057a37 | |||
| 121c9bd6f3 | |||
| 7dbb21be8e | |||
| cc8a2457c0 | |||
| f5c851e11e | |||
| b4a2cf8382 | |||
| 873cee6947 | |||
| abdf4c30b2 | |||
| 408f52a081 | |||
| 51d5f16770 | |||
| 8e1cdf3a1f | |||
| d26518687a | |||
| 986ff8c59f | |||
| dfe5c03ba3 | |||
| 87f270df23 | |||
| ee4dc12d51 | |||
| 8b4bdaa8a4 | |||
| 221c0b4cf8 | |||
| 1a25104a3d | |||
| 478a543e2e | |||
| 6a012fd625 | |||
| d445cb420c | |||
| 1fa7a587d6 | |||
| c64bcd047b | |||
| d4d15c8a71 | |||
| 0efa6428d0 | |||
| 17e6354383 | |||
| 99877e8e63 | |||
| 98337a14b3 | |||
| 2164d1062e | |||
| 76c8fc8697 | |||
| 828d9955f2 | |||
| b33bd6aaeb | |||
| e3be5f8369 | |||
| d97c211e82 | |||
| 4ced7b886e | |||
| 66a8a4503c | |||
| fd5b168acd | |||
| 2941a7002d | |||
| b518fb29c6 | |||
| f504bfdde8 | |||
| c1236e86fa | |||
| 12a045a0ad | |||
| f57f6e0ca6 | |||
| e3960cde3f | |||
| 3b943485f8 | |||
| ecc6243edc | |||
| 328d47f1df | |||
| 9a88c94b30 | |||
| 1578229b8b | |||
| 28f97e6152 | |||
| d5a2f0324e | |||
| 23a0bf2abe | |||
| f9fe95d182 | |||
| 07b93e1d26 | |||
| cd65183f24 | |||
| 0fd365a975 | |||
| 7c2cb57434 | |||
| 2a728ee68c | |||
| 07f1280cb0 | |||
| 5bdb9c0e99 | |||
| b9b0d46773 | |||
| 6947ab18dc | |||
| 177ad3f06d | |||
| 58a12a757e | |||
| b9ff4ca1fe | |||
| 720c5b53fd | |||
| f13ae50ff8 | |||
| 2f4a248314 | |||
| a6ea74f8e6 | |||
| fa6409bca8 | |||
| cf50e91bc8 | |||
| 9877733748 | |||
| 4cff7901bd | |||
| 0d819c21a4 | |||
| 056c11687b | |||
| d4e9f23ee9 | |||
| 7d6f17d77f | |||
| 0ed7ea698a | |||
| 74526645eb | |||
| cb095c8606 | |||
| 376d007371 | |||
| 01492b6515 | |||
| 3c81ac0315 | |||
| 9c8967ef5d | |||
| 720b9dd116 | |||
| 9f9f6b75e7 | |||
| 26cbbafc86 | |||
| 67743325ee | |||
| 32df2ef7bd | |||
| ccd8950d40 | |||
| 86a7ab6e28 | |||
| e3e3498a4b | |||
| 56f018ddd6 | |||
| 53d3134fe8 | |||
| e6400b0b0f | |||
| 15d286b617 | |||
| 6b2634512c | |||
| 9211183f2d | |||
| cd8c7f391b | |||
| 4b51c96e4e | |||
| d9960d83c1 | |||
| 32affaee02 | |||
| 60430fcd2e | |||
| 55e55c8825 | |||
| 146f7ab433 | |||
| 105d0481d3 | |||
| 1f95d7fc8b | |||
| 3a8bfc0a5d | |||
| 26cc2bd384 | |||
| 248c731e78 | |||
| b38155fe9a | |||
| ec763a7546 | |||
| 4181e72977 | |||
| 5462cfdc3a | |||
| 367baaca20 | |||
| e576a82c43 | |||
| e5bb5b5be5 | |||
| f082f1e06e | |||
| 0d9a1009ff | |||
| 33aaccd1c3 | |||
| a4385dc920 | |||
| ed14e1f0d0 | |||
| bab4f8e628 | |||
| 4c3a853673 | |||
| d63eae528c | |||
| 70ba369d65 | |||
| 68f6f3f0bd | |||
| 23717c5036 | |||
| 54abf4b0d7 | |||
| ab9ea827a4 | |||
| a005a97fef | |||
| 933c157092 | |||
| cf0c72a557 | |||
| f2b8f7bd5b | |||
| 7acd26a2fc | |||
| 23eec7d841 | |||
| fe555f197c | |||
| e533f99fa9 | |||
| fb60637b7f | |||
| 5206c9f2fb | |||
| a3747b1ee3 | |||
| 96e4fdb443 | |||
| 684e18bab2 | |||
| f328cd5246 | |||
| 61b786b2b7 | |||
| 6b46217d19 | |||
| dc3c733612 | |||
| 580791088c | |||
| 225b44ad3a | |||
| 99fcc82705 | |||
| fb1fc5feee | |||
| 3da3e201de | |||
| 55da6ca449 | |||
| 28b25e8abb | |||
| 2ebad5af1c | |||
| 8dbf72099a | |||
| 0590365683 | |||
| 8e3f7c45d2 | |||
| a8a4993ffd | |||
| 8a9831d37c | |||
| 314e075df2 | |||
| 0ef07bc142 | |||
| 4a166cf227 | |||
| eec082e541 | |||
| 7006a4aad3 | |||
| 66fb44fbfb | |||
| eb1de642db | |||
| 4570e1db7d | |||
| 17a1d302b9 | |||
| 11a3b5aac9 | |||
| d8a13481eb | |||
| a83f86a4a1 | |||
| 6d2928888c | |||
| 7551415db9 | |||
| f1285be76b | |||
| 93cdc89daf | |||
| f3f88190bb | |||
| 11c8db14a1 | |||
| e84eb3e671 | |||
| 029db06477 | |||
| f34d7e0fe0 | |||
| 587a556d6b | |||
| 52929c0600 | |||
| 4e341d1354 | |||
| 323200b551 | |||
| dbe156e881 | |||
| f00038b383 | |||
| 3b6739d3e9 | |||
| d7055f8fd2 | |||
| 1fc213468b | |||
| af1749f3b3 | |||
| 2b15e952c2 | |||
| 343b6ac31b | |||
| 9046296ed3 | |||
| b4e9a0c975 | |||
| 71791d5a6a | |||
| 7acdaad04e | |||
| b7ac9095e6 | |||
| f42fca667c | |||
| be3648c511 | |||
| 5fa682d8f0 | |||
| 30348e41c6 | |||
| 7343597075 | |||
| 0b2ff4cfd9 | |||
| 50e62122bb | |||
| eeae5ce7fd | |||
| edb3651c32 | |||
| 3155e4fd5a | |||
| 5a443dfa53 | |||
| cfdca57551 | |||
| 473f7df658 | |||
| 57e6a9a762 | |||
| 7660a78330 | |||
| 29884f8d6f | |||
| 76c5bff7d6 | |||
| f74ead8d43 | |||
| bfd0dcde35 | |||
| 38604acd94 | |||
| c928df7237 | |||
| 30b4c14296 | |||
| 2daead27cf | |||
| d38b232724 | |||
| c3587d6cae | |||
| b3b507c6ea | |||
| 7879a58f4b | |||
| 579b00503f | |||
| 36a21ae9b0 | |||
| 11f897b7df | |||
| 054a6d301c | |||
| 1f9b4e3af6 | |||
| 4ce2e73521 | |||
| c7caa9a87d | |||
| 7a518166bb | |||
| 89291c384b | |||
| 9d802abd9a | |||
| 480bf916e2 | |||
| 9a4021a277 | |||
| 2b07a2a8ab | |||
| 77bc11f91c | |||
| 7890bd7369 | |||
| b6982236a6 | |||
| 6c54977c15 | |||
| 494f41d575 | |||
| cffec07329 | |||
| 6833e3de5d | |||
| 20b4e2b859 | |||
| ff14e743ea | |||
| 6668805aca | |||
| 3a113b7752 | |||
| e229a36e9f | |||
| f5670cae06 | |||
| cc79c507f6 | |||
| b0b3896941 | |||
| 9b6bc0e66b | |||
| f8d168bde0 | |||
| 325ed80252 | |||
| e43abd3f14 | |||
| d9645b4802 | |||
| 5ec3663748 | |||
| 84d9c5f5e5 | |||
| f3bd6e4957 | |||
| 6cb55eaaa7 | |||
| 3f27b23d5a | |||
| 4102e2f1b8 | |||
| 35d42be828 | |||
| 6a2b8328df | |||
| cc8e6e00a0 | |||
| 6e0c1cb051 | |||
| 8f9aa3e8c5 | |||
| 88c404bcfc | |||
| 920436da65 | |||
| 4759633df1 | |||
| e824b3514b | |||
| 2e2f05a0e1 | |||
| 02270abc87 | |||
| 2cc0d8c058 | |||
| 340d1c64b4 | |||
| 2d74119a08 | |||
| e0bf86f06c | |||
| df55d45b6f | |||
| 305ef06090 | |||
| a665382060 | |||
| 49f99e200a | |||
| fa0f2b971f | |||
| fe46a2663b | |||
| a32021dc3e | |||
| 4cf3e84b39 | |||
| 24c3ab6fe0 | |||
| d8f1124d59 | |||
| 07be761779 | |||
| b0b4b33b6b | |||
| d33285a9cd | |||
| 49e7004664 | |||
| 0637e4b2a5 | |||
| 3e6d27ac4e | |||
| 506cc9e7a1 | |||
| 21ba04755b | |||
| cbac9fe4ac | |||
| b339097179 | |||
| 07eed3de56 | |||
| 326fb04d12 | |||
| d2098e4492 | |||
| 362fc3e235 | |||
| 6444258ad3 | |||
| 3dbd6766ab | |||
| 318f59ec3e | |||
| 57dafec0ec | |||
| 518dfd4e42 | |||
| 9984248f51 | |||
| 9cb1bfa1c1 | |||
| 5fa3ac1e01 | |||
| f3882671c9 | |||
| 7c76561569 | |||
| bd2002010c | |||
| 317e15c746 | |||
| 40f818ff5e | |||
| 1d9199b529 | |||
| cb213b55f6 | |||
| 7a52a93d08 | |||
| d4a93bc25c | |||
| 3853f632e5 | |||
| 7fb0b4e1eb | |||
| 04951b0629 | |||
| eff092268a | |||
| b977e8a284 | |||
| 621f710d60 | |||
| c731a87d07 | |||
| 786eac1d6f | |||
| 1eb50ffac4 | |||
| 67b7877bbf | |||
| 3166cc911b | |||
| 5adbeb1bad | |||
| 4d0e74ab6c | |||
| a580639abf | |||
| 494743a4e5 | |||
| 4eb6aec016 | |||
| 5a47d6ffc3 | |||
| 0afa370869 | |||
| 08cc8f2281 | |||
| 708f04b02f | |||
| 1c257f170a | |||
| 06052640e8 | |||
| fa61699f9a | |||
| 98377c7c6b | |||
| 805a29252e | |||
| f699dc3777 | |||
| ad17966e2f | |||
| 2a86e40730 | |||
| b5cd758c21 | |||
| 56b11ad5a8 | |||
| 1110d96769 | |||
| 050c1c5391 | |||
| 357891a063 | |||
| 66bc003126 | |||
| 55d2608808 | |||
| a6a9930a34 | |||
| 6d70524aa8 | |||
| ee5acd6d4b | |||
| aa30995aa1 | |||
| d45c27e51f | |||
| 67fdee6d6b | |||
| 0d00d6dfd4 | |||
| 6546a1a23a | |||
| 72d4317d7f | |||
| 6d01d70c24 | |||
| dafa8a2881 | |||
| ab314a22e0 | |||
| c65114be1a | |||
| 19d9e7ac05 | |||
| d19972b317 | |||
| 9790b39d80 | |||
| b9b1bc2726 | |||
| 8a194b4abc | |||
| 46e00ad5e7 | |||
| 3389231ecb | |||
| d772ff06c8 | |||
| d9290137bc | |||
| 686b3f884c | |||
| 914216eca4 | |||
| 0ef429f532 | |||
| 9bd5d4355c | |||
| 1bd5500832 | |||
| cf192f8551 | |||
| afede929b3 | |||
| d44bb41d27 | |||
| fa346d7b78 | |||
| ec1047583a | |||
| 7e6fa94720 | |||
| 4933113252 | |||
| 2f050b197e | |||
| 4c4c167416 | |||
| 777fb6b7bb | |||
| 9f9098406c | |||
| 4533dd6e5d | |||
| 212b13b099 | |||
| c409edd3fa | |||
| 193ebba657 | |||
| fac4951f27 | |||
| c4e76eb635 | |||
| 0279f09459 | |||
| 831de9ba8f | |||
| 7947059884 | |||
| 1fe9f648b1 | |||
| 0335bccd91 | |||
| f9347235e4 | |||
| b1664ec9c7 | |||
| 801e7dd811 | |||
| 82f71d25e5 | |||
| b977ae19af | |||
| c0a010335b | |||
| d41372b9d9 | |||
| 8c1d39064d | |||
| 7c925aa5a0 | |||
| 651a9e9be4 | |||
| c264e98c62 | |||
| 1ac7338d9a | |||
| 8dbb22cc93 | |||
| 59e6064006 | |||
| f648267dd9 | |||
| 26ce65995f | |||
| 0de3bb36d5 | |||
| 755c031f6a | |||
| 7ac628a697 | |||
| 7dd0899856 | |||
| 8dd8818e08 | |||
| f1a1032cd6 | |||
| b383fbeed3 | |||
| 91c870a0c4 | |||
| 5a57cbe571 | |||
| 6480ef369f | |||
| 2455a2b26a | |||
| 2d105d16f8 | |||
| 7a836c9ff0 | |||
| 738269eb74 | |||
| 9b5ce2530a | |||
| 38d6930fbe | |||
| 0d98e93253 | |||
| 64babcac7a | |||
| 464f0645a8 | |||
| ef08c3f038 | |||
| 48ad3bbbe6 | |||
| 843ff5f2d4 | |||
| 60bf349201 | |||
| a54706a063 | |||
| 6cc8570369 | |||
| dd958fddfc | |||
| 12722acb55 | |||
| 687a10b8cc | |||
| 9f80d8ec7c | |||
| dcc41e932d | |||
| e3cd431551 | |||
| e2ea02160d | |||
| 89b20baafe | |||
| e2733d21bf | |||
| 701e146c06 | |||
| 8bc9209094 | |||
| a1533a17f7 | |||
| 84d64f9395 | |||
| fb03149df4 | |||
| ab994d2c63 | |||
| a1ded60bca | |||
| f62f4b6703 | |||
| de5b75eff6 | |||
| bf0184d0cf | |||
| 64525f825c | |||
| 5805bb051b | |||
| ef3bab5a74 | |||
| f428ed9038 | |||
| e4fea2b80b | |||
| d1943a9337 | |||
| d781508952 | |||
| 22144cd51b | |||
| b7fdc266ad | |||
| b99eb4c9f3 | |||
| 239e9bafc8 | |||
| 8978ac425e | |||
| 81f9093c3c | |||
| 41c8bdfada | |||
| 8b47368167 | |||
| e60c3fc1b3 | |||
| db5e4b986b | |||
| 78532d76bd | |||
| 04f2972b4a | |||
| e87ce9c680 | |||
| 53a0c966a5 | |||
| d6d5c5ccd1 | |||
| 001a19eb2c | |||
| 43b530ca1c | |||
| 44564df028 | |||
| 70c1732dd1 | |||
| 99e9e506be | |||
| 5a93447294 | |||
| a39951d463 | |||
| 1281c1d155 | |||
| 236f8560b3 | |||
| ae3711bfbd | |||
| 449bee9645 | |||
| 4d146ea2f5 | |||
| 3b5149ca39 | |||
| 4c86da044e | |||
| d01e06f09a | |||
| c782404bee | |||
| 6019c1e718 | |||
| 65c2532cd5 | |||
| 3e2e3eb023 | |||
| 0258c746bc | |||
| 920b3880c1 | |||
| 66db6c749d | |||
| e4abd06094 | |||
| 98abf3983a | |||
| 10102e1cf2 | |||
| a057f6a306 | |||
| 2045395ccb | |||
| d3674f4d6c | |||
| cdb915d527 | |||
| a7c8341452 | |||
| 7b478909b2 | |||
| 7b392ca74b | |||
| eee04fa2ce | |||
| 025f794f0f | |||
| 4fac94f259 | |||
| 0f409cb99d |
@@ -0,0 +1,26 @@
|
||||
# detect-secrets exclusion patterns (regex)
|
||||
#
|
||||
# Note: detect-secrets does not read this file by default. If you want these
|
||||
# applied, wire them into your scan command (e.g. translate to --exclude-files
|
||||
# / --exclude-lines) or into a baseline's filters_used.
|
||||
|
||||
[exclude-files]
|
||||
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
||||
pattern = (^|/)pnpm-lock\.yaml$
|
||||
|
||||
[exclude-lines]
|
||||
# Fastlane checks for private key marker; not a real key.
|
||||
pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\)
|
||||
# UI label string for Anthropic auth mode.
|
||||
pattern = case \.apiKeyEnv: "API key \(env var\)"
|
||||
# CodingKeys mapping uses apiKey literal.
|
||||
pattern = case apikey = "apiKey"
|
||||
# Schema labels referencing password fields (not actual secrets).
|
||||
pattern = "gateway\.remote\.password"
|
||||
pattern = "gateway\.auth\.password"
|
||||
# Schema label for talk API key (label text only).
|
||||
pattern = "talk\.apiKey"
|
||||
# checking for typeof is not something we care about.
|
||||
pattern = === "string"
|
||||
# specific optional-chaining password check that didn't match the line above.
|
||||
pattern = typeof remote\?\.password === "string"
|
||||
@@ -74,9 +74,9 @@ jobs:
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
- runtime: bun
|
||||
task: lint
|
||||
command: bunx biome check src
|
||||
- runtime: node
|
||||
task: format
|
||||
command: pnpm format
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: bunx vitest run
|
||||
@@ -141,6 +141,31 @@ jobs:
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
secrets:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install detect-secrets
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install detect-secrets==1.5.0
|
||||
|
||||
- name: Detect secrets
|
||||
run: |
|
||||
if ! detect-secrets scan --baseline .secrets.baseline; then
|
||||
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checks-windows:
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
defaults:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Install Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
install-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout CLI
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
- name: Run installer docker tests
|
||||
env:
|
||||
CLAWDBOT_INSTALL_URL: https://clawd.bot/install.sh
|
||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
||||
CLAWDBOT_NO_ONBOARD: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
||||
run: pnpm test:install:smoke
|
||||
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
.env
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
@@ -54,3 +55,5 @@ apps/ios/*.mobileprovision
|
||||
# Local untracked files
|
||||
.local/
|
||||
.vscode/
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"indentWidth": 2,
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/oxlintrc",
|
||||
"extends": ["recommended"]
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"plugins_used": [
|
||||
{
|
||||
"name": "ArtifactoryDetector"
|
||||
},
|
||||
{
|
||||
"name": "AWSKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "AzureStorageKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "Base64HighEntropyString",
|
||||
"limit": 4.5
|
||||
},
|
||||
{
|
||||
"name": "BasicAuthDetector"
|
||||
},
|
||||
{
|
||||
"name": "CloudantDetector"
|
||||
},
|
||||
{
|
||||
"name": "DiscordBotTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "GitHubTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "GitLabTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "HexHighEntropyString",
|
||||
"limit": 3.0
|
||||
},
|
||||
{
|
||||
"name": "IbmCloudIamDetector"
|
||||
},
|
||||
{
|
||||
"name": "IbmCosHmacDetector"
|
||||
},
|
||||
{
|
||||
"name": "IPPublicDetector"
|
||||
},
|
||||
{
|
||||
"name": "JwtTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "KeywordDetector",
|
||||
"keyword_exclude": ""
|
||||
},
|
||||
{
|
||||
"name": "MailchimpDetector"
|
||||
},
|
||||
{
|
||||
"name": "NpmDetector"
|
||||
},
|
||||
{
|
||||
"name": "OpenAIDetector"
|
||||
},
|
||||
{
|
||||
"name": "PrivateKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "PypiTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "SendGridDetector"
|
||||
},
|
||||
{
|
||||
"name": "SlackDetector"
|
||||
},
|
||||
{
|
||||
"name": "SoftlayerDetector"
|
||||
},
|
||||
{
|
||||
"name": "SquareOAuthDetector"
|
||||
},
|
||||
{
|
||||
"name": "StripeDetector"
|
||||
},
|
||||
{
|
||||
"name": "TelegramBotTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "TwilioKeyDetector"
|
||||
}
|
||||
],
|
||||
"filters_used": [
|
||||
{
|
||||
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.common.is_baseline_file",
|
||||
"filename": ".secrets.baseline"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
||||
"min_level": 2
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_lock_file"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_sequential_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_swagger_file"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_templated_secret"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.regex.should_exclude_file",
|
||||
"pattern": [
|
||||
"(^|/)pnpm-lock\\.yaml$"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.regex.should_exclude_line",
|
||||
"pattern": [
|
||||
"key_content\\.include\\?\\(\"BEGIN PRIVATE KEY\"\\)",
|
||||
"case \\.apiKeyEnv: \"API key \\(env var\\)\"",
|
||||
"case apikey = \"apiKey\"",
|
||||
"\"gateway\\.remote\\.password\"",
|
||||
"\"gateway\\.auth\\.password\"",
|
||||
"\"talk\\.apiKey\"",
|
||||
"=== \"string\"",
|
||||
"typeof remote\\?\\.password === \"string\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"results": {
|
||||
".env.example": [
|
||||
{
|
||||
"type": "Twilio API Key",
|
||||
"filename": ".env.example",
|
||||
"hashed_secret": "3c7206eff845bc69cf12d904d0f95f9aec15535e",
|
||||
"is_verified": false,
|
||||
"line_number": 2
|
||||
}
|
||||
],
|
||||
"appcast.xml": [
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "appcast.xml",
|
||||
"hashed_secret": "1b1c2b73eca84e441a823c37a06c71c9fadcfe24",
|
||||
"is_verified": false,
|
||||
"line_number": 19
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "appcast.xml",
|
||||
"hashed_secret": "5c47736fee5151b26b3bb61bb38955da0e8937c6",
|
||||
"is_verified": false,
|
||||
"line_number": 35
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "appcast.xml",
|
||||
"hashed_secret": "bbbca47179268f154c63affa0ca441c6e49e650f",
|
||||
"is_verified": false,
|
||||
"line_number": 52
|
||||
}
|
||||
],
|
||||
"apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
|
||||
"hashed_secret": "e761624445731fcb8b15da94343c6b92e507d190",
|
||||
"is_verified": false,
|
||||
"line_number": 26
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
|
||||
"hashed_secret": "a23c8630c8a5fbaa21f095e0269c135c20d21689",
|
||||
"is_verified": false,
|
||||
"line_number": 42
|
||||
}
|
||||
],
|
||||
"apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift",
|
||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
||||
"is_verified": false,
|
||||
"line_number": 83
|
||||
}
|
||||
],
|
||||
"apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift",
|
||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
||||
"is_verified": false,
|
||||
"line_number": 27
|
||||
}
|
||||
],
|
||||
"docs/configuration.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/configuration.md",
|
||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
||||
"is_verified": false,
|
||||
"line_number": 268
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/configuration.md",
|
||||
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
|
||||
"is_verified": false,
|
||||
"line_number": 465
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/configuration.md",
|
||||
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
|
||||
"is_verified": false,
|
||||
"line_number": 718
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/configuration.md",
|
||||
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
|
||||
"is_verified": false,
|
||||
"line_number": 760
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/configuration.md",
|
||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
||||
"is_verified": false,
|
||||
"line_number": 859
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/configuration.md",
|
||||
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
|
||||
"is_verified": false,
|
||||
"line_number": 982
|
||||
}
|
||||
],
|
||||
"docs/faq.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/faq.md",
|
||||
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
|
||||
"is_verified": false,
|
||||
"line_number": 593
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/faq.md",
|
||||
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
|
||||
"is_verified": false,
|
||||
"line_number": 650
|
||||
}
|
||||
],
|
||||
"docs/skills-config.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/skills-config.md",
|
||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
||||
"is_verified": false,
|
||||
"line_number": 28
|
||||
}
|
||||
],
|
||||
"docs/skills.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/skills.md",
|
||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
||||
"is_verified": false,
|
||||
"line_number": 97
|
||||
}
|
||||
],
|
||||
"docs/tailscale.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/tailscale.md",
|
||||
"hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9",
|
||||
"is_verified": false,
|
||||
"line_number": 52
|
||||
}
|
||||
],
|
||||
"docs/talk.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/talk.md",
|
||||
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
|
||||
"is_verified": false,
|
||||
"line_number": 50
|
||||
}
|
||||
],
|
||||
"docs/telegram.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/telegram.md",
|
||||
"hashed_secret": "e9fe51f94eadabf54dbf2fbbd57188b9abee436e",
|
||||
"is_verified": false,
|
||||
"line_number": 57
|
||||
}
|
||||
],
|
||||
"skills/local-places/SERVER_README.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "skills/local-places/SERVER_README.md",
|
||||
"hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3",
|
||||
"is_verified": false,
|
||||
"line_number": 28
|
||||
}
|
||||
],
|
||||
"skills/openai-whisper-api/SKILL.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "skills/openai-whisper-api/SKILL.md",
|
||||
"hashed_secret": "1077361f94d70e1ddcc7c6dc581a489532a81d03",
|
||||
"is_verified": false,
|
||||
"line_number": 39
|
||||
}
|
||||
],
|
||||
"skills/trello/SKILL.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "skills/trello/SKILL.md",
|
||||
"hashed_secret": "11fa7c37d697f30e6aee828b4426a10f83ab2380",
|
||||
"is_verified": false,
|
||||
"line_number": 18
|
||||
}
|
||||
],
|
||||
"src/agents/models-config.test.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/models-config.test.ts",
|
||||
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
|
||||
"is_verified": false,
|
||||
"line_number": 25
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/models-config.test.ts",
|
||||
"hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7",
|
||||
"is_verified": false,
|
||||
"line_number": 90
|
||||
}
|
||||
],
|
||||
"src/agents/skills.test.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/skills.test.ts",
|
||||
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
|
||||
"is_verified": false,
|
||||
"line_number": 158
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/skills.test.ts",
|
||||
"hashed_secret": "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb",
|
||||
"is_verified": false,
|
||||
"line_number": 265
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/skills.test.ts",
|
||||
"hashed_secret": "5df3a673d724e8a1eb673a8baf623e183940804d",
|
||||
"is_verified": false,
|
||||
"line_number": 462
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/skills.test.ts",
|
||||
"hashed_secret": "8921daaa546693e52bc1f9c40bdcf15e816e0448",
|
||||
"is_verified": false,
|
||||
"line_number": 490
|
||||
}
|
||||
],
|
||||
"src/browser/target-id.test.ts": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "src/browser/target-id.test.ts",
|
||||
"hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46",
|
||||
"is_verified": false,
|
||||
"line_number": 16
|
||||
}
|
||||
],
|
||||
"src/commands/antigravity-oauth.ts": [
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "src/commands/antigravity-oauth.ts",
|
||||
"hashed_secret": "709d0f232b6ac4f8d24dec3e4fabfdb14257174f",
|
||||
"is_verified": false,
|
||||
"line_number": 17
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "src/commands/antigravity-oauth.ts",
|
||||
"hashed_secret": "3848603b8e866f62d07c206ff622279b9dcb0238",
|
||||
"is_verified": false,
|
||||
"line_number": 20
|
||||
}
|
||||
],
|
||||
"src/commands/onboard-auth.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/commands/onboard-auth.ts",
|
||||
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
|
||||
"is_verified": false,
|
||||
"line_number": 50
|
||||
}
|
||||
],
|
||||
"src/config/config.test.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/config/config.test.ts",
|
||||
"hashed_secret": "bea2f7b64fab8d1d414d0449530b1e088d36d5b1",
|
||||
"is_verified": false,
|
||||
"line_number": 520
|
||||
}
|
||||
],
|
||||
"src/gateway/server.auth.test.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/gateway/server.auth.test.ts",
|
||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
||||
"is_verified": false,
|
||||
"line_number": 89
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/gateway/server.auth.test.ts",
|
||||
"hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c",
|
||||
"is_verified": false,
|
||||
"line_number": 109
|
||||
}
|
||||
],
|
||||
"src/infra/env.test.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/infra/env.test.ts",
|
||||
"hashed_secret": "df98a117ddabf85991b9fe0e268214dc0e1254dc",
|
||||
"is_verified": false,
|
||||
"line_number": 10
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/infra/env.test.ts",
|
||||
"hashed_secret": "6d811dc1f59a55ca1a3d38b5042a062b9f79e8ec",
|
||||
"is_verified": false,
|
||||
"line_number": 25
|
||||
}
|
||||
],
|
||||
"src/infra/shell-env.test.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/infra/shell-env.test.ts",
|
||||
"hashed_secret": "65c10dc3549fe07424148a8a4790a3341ecbc253",
|
||||
"is_verified": false,
|
||||
"line_number": 35
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "src/infra/shell-env.test.ts",
|
||||
"hashed_secret": "64db6bf7f0e5a0491df4419f0eb1bbcc402989e8",
|
||||
"is_verified": false,
|
||||
"line_number": 56
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/infra/shell-env.test.ts",
|
||||
"hashed_secret": "e013ffda590d2178607c16d11b1ea42f75ceb0e7",
|
||||
"is_verified": false,
|
||||
"line_number": 73
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "src/infra/shell-env.test.ts",
|
||||
"hashed_secret": "be6ee9a6bf9f2dad84a5a67d6c0576a5bacc391e",
|
||||
"is_verified": false,
|
||||
"line_number": 75
|
||||
}
|
||||
],
|
||||
"src/web/qr-image.test.ts": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "src/web/qr-image.test.ts",
|
||||
"hashed_secret": "564666dc1ca6e7318b2d5feeb1ce7b5bf717411e",
|
||||
"is_verified": false,
|
||||
"line_number": 12
|
||||
}
|
||||
],
|
||||
"vendor/a2ui/README.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "vendor/a2ui/README.md",
|
||||
"hashed_secret": "2619a5397a5d054dab3fe24e6a8da1fbd76ec3a6",
|
||||
"is_verified": false,
|
||||
"line_number": 123
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-01-05T13:01:00Z"
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
# Repository Guidelines
|
||||
- Repo: https://github.com/clawdbot/clawdbot
|
||||
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
- Tests: colocated `*.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
||||
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
||||
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||
@@ -19,12 +26,12 @@
|
||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
- Type-check/build: `pnpm build` (tsc)
|
||||
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm lint` before commits.
|
||||
- Add brief code comments for tricky or non-obvious logic.
|
||||
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||
@@ -53,6 +60,9 @@
|
||||
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
|
||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
||||
|
||||
## Shorthand Commands
|
||||
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||
|
||||
### PR Workflow (Review vs Land)
|
||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
|
||||
@@ -60,15 +70,20 @@
|
||||
## Security & Configuration Tips
|
||||
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
|
||||
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
||||
- Environment variables: see `~/.profile`.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
|
||||
## Troubleshooting
|
||||
- Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
||||
|
||||
## Agent-Specific Notes
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||
@@ -91,6 +106,7 @@
|
||||
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
@@ -100,6 +116,14 @@
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
|
||||
## NPM + 1Password (publish/verify)
|
||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
|
||||
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
|
||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||
- Kill the tmux session after publish.
|
||||
|
||||
## Exclamation Mark Escaping Workaround
|
||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
||||
|
||||
|
||||
+414
-1
@@ -1,5 +1,417 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Highlights
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors).
|
||||
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
|
||||
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
||||
|
||||
### Changes
|
||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
||||
- TUI: show provider/model labels for the active session and default model.
|
||||
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
||||
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
|
||||
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
|
||||
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
|
||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
|
||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
||||
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
||||
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
||||
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
|
||||
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`.
|
||||
- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.
|
||||
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
||||
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
|
||||
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
||||
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
||||
|
||||
### Fixes
|
||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
|
||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
||||
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
||||
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
|
||||
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
||||
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
|
||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||
- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake).
|
||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
||||
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
||||
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
||||
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
||||
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
|
||||
|
||||
## 2026.1.14-1
|
||||
|
||||
### Highlights
|
||||
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure.
|
||||
- Browser control: Chrome extension relay takeover mode + remote browser control via `clawdbot browser serve`.
|
||||
- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.
|
||||
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
|
||||
|
||||
### Changes
|
||||
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
|
||||
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
|
||||
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
||||
- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
|
||||
- Security: add `SECURITY.md` reporting policy.
|
||||
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
|
||||
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
||||
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
||||
- Docs: expand gateway security hardening guidance and incident response checklist.
|
||||
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
|
||||
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
|
||||
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
|
||||
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
|
||||
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
|
||||
|
||||
### Fixes
|
||||
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
|
||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
|
||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||
- Agents: harden Antigravity Claude history/tool-call sanitization. (#968) — thanks @rdev.
|
||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
||||
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
|
||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
||||
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
|
||||
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
|
||||
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
|
||||
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
|
||||
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
|
||||
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
|
||||
|
||||
## 2026.1.14
|
||||
|
||||
### Changes
|
||||
- Usage: add MiniMax coding plan usage tracking.
|
||||
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
|
||||
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
|
||||
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
|
||||
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
|
||||
|
||||
### Fixes
|
||||
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
||||
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
|
||||
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
|
||||
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
|
||||
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
|
||||
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
||||
|
||||
#### Agents / Auth / Tools / Sandbox
|
||||
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
|
||||
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
|
||||
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
|
||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
||||
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
|
||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||
|
||||
#### macOS / Apps
|
||||
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
|
||||
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
|
||||
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
||||
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
|
||||
- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi.
|
||||
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
|
||||
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
|
||||
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
|
||||
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
|
||||
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
|
||||
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
|
||||
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
|
||||
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
|
||||
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
|
||||
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
|
||||
|
||||
## 2026.1.13
|
||||
|
||||
### Fixes
|
||||
- Postinstall: treat already-applied pnpm patches as no-ops to avoid npm/bun install failures.
|
||||
- Packaging: pin `@mariozechner/pi-ai` to 0.45.7 and refresh patched dependency to match npm resolution.
|
||||
|
||||
## 2026.1.12-2
|
||||
|
||||
### Fixes
|
||||
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
|
||||
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
|
||||
- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien.
|
||||
|
||||
## 2026.1.12-1
|
||||
|
||||
### Fixes
|
||||
- Packaging: include `dist/channels/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/channels/registry.js`).
|
||||
|
||||
## 2026.1.12
|
||||
|
||||
### Highlights
|
||||
- **BREAKING:** rename chat “providers” (Slack/Telegram/WhatsApp/…) to **channels** across CLI/RPC/config; legacy config keys auto-migrate on load (and are written back as `channels.*`).
|
||||
- Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
|
||||
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
|
||||
- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs). (#811) — thanks @siraht; (#818) — thanks @mickahouan.
|
||||
- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI).
|
||||
- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks. (#700) — thanks @thewilloftheshadow; (#583) — thanks @mitschabaude-bot.
|
||||
|
||||
### New & Improved
|
||||
- Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI. (#819) — thanks @mukhtharcm.
|
||||
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
|
||||
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
|
||||
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer.
|
||||
- Tools: add provider/model-specific tool policy overrides (`tools.byProvider`) to trim tool exposure per provider.
|
||||
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06.
|
||||
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior.
|
||||
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4.
|
||||
- Providers: add `discord.allowBots`; trim legacy MiniMax M2 from default catalogs; route MiniMax vision to the Coding Plan VLM endpoint (also accepts `@/path/to/file.png` inputs). (#802) — thanks @zknicker.
|
||||
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer. (#823) — thanks @roshanasingh4; (#786) — thanks @meaningfool.
|
||||
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
|
||||
|
||||
### Installer
|
||||
- Install: run `clawdbot doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
|
||||
|
||||
### Fixes
|
||||
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
|
||||
- Tools: apply global tool allow/deny even when agent-specific tool policy is set.
|
||||
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ.
|
||||
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax.
|
||||
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg.
|
||||
- Agents: auto-recover from compaction context overflow by resetting the session and retrying; propagate overflow details from embedded runs so callers can recover.
|
||||
- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing. (#809) — thanks @latitudeki5223.
|
||||
- Onboarding/Auth: honor `CLAWDBOT_AGENT_DIR` / `PI_CODING_AGENT_DIR` when writing auth profiles (MiniMax). (#829) — thanks @roshanasingh4.
|
||||
- Anthropic: handle `overloaded_error` with a friendly message and failover classification. (#832) — thanks @danielz1z.
|
||||
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors. (#804) — thanks @ThomsenDrake.
|
||||
- Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution. (#793) — thanks @hsrvc; (#450, #447) — thanks @thewilloftheshadow.
|
||||
- Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles. (#810) — thanks @mcinteerj.
|
||||
- System events: include local timestamps when events are injected into prompts. (#245) — thanks @thewilloftheshadow.
|
||||
- Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping.
|
||||
- Discord/Slack: centralize reply-thread planning; fix autoThread routing + add per-channel autoThread; avoid duplicate listeners; keep reasoning italics intact; allow clearing channel parents via message tool. (#800, #807) — thanks @davidguttman; (#744) — thanks @thewilloftheshadow.
|
||||
- Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics. (#727, #739) — thanks @thewilloftheshadow; (#821) — thanks @gumadeiras; (#779) — thanks @azade-c.
|
||||
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798) — thanks @thewilloftheshadow.
|
||||
- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params. (#205, #252) — thanks @thewilloftheshadow.
|
||||
- Gateway/CLI: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides; `agents.list` respects explicit config; reduce noisy loopback WS logs during tests; run `clawdbot doctor --non-interactive` during updates. (#781) — thanks @ronyrus.
|
||||
- Onboarding/Control UI: refuse invalid configs (run doctor first); quote Windows browser URLs for OAuth; keep chat scroll position unless the user is near the bottom. (#764) — thanks @mukhtharcm; (#794) — thanks @roshanasingh4; (#217) — thanks @thewilloftheshadow.
|
||||
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)". (#782) — thanks @AbhisekBasu1; (#796) — thanks @gabriel-trigo; (#747) — thanks @thewilloftheshadow.
|
||||
- Connections UI: polish multi-account account cards. (#816) — thanks @steipete.
|
||||
|
||||
### Maintenance
|
||||
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
|
||||
- Testing: update Vitest + browser-playwright to 4.0.17.
|
||||
- Docs: add Amazon Bedrock provider notes and link from models/FAQ.
|
||||
|
||||
## 2026.1.11
|
||||
|
||||
### Highlights
|
||||
- Plugins are now first-class: loader + CLI management, plus the new Voice Call plugin.
|
||||
- Config: modular `$include` support for split config files. (#731) — thanks @pasogott.
|
||||
- Agents/Pi: reserve compaction headroom so pre-compaction memory writes can run before auto-compaction.
|
||||
- Agents: automatic pre-compaction memory flush turn to store durable memories before compaction.
|
||||
|
||||
### Changes
|
||||
- CLI/Onboarding: simplify MiniMax auth choice to a single M2.1 option.
|
||||
- CLI: configure section selection now loops until Continue.
|
||||
- Docs: explain MiniMax vs MiniMax Lightning (speed vs cost) and restore LM Studio example.
|
||||
- Docs: add Cerebras GLM 4.6/4.7 config example (OpenAI-compatible endpoint).
|
||||
- Onboarding/CLI: group model/auth choice by provider and label Z.AI as GLM 4.7.
|
||||
- Onboarding/Docs: add Moonshot AI (Kimi K2) auth choice + config example.
|
||||
- CLI/Onboarding: prompt to reuse detected API keys for Moonshot/MiniMax/Z.AI/Gemini/Anthropic/OpenCode.
|
||||
- Auto-reply: add compact `/model` picker (models + available providers) and show provider endpoints in `/model status`.
|
||||
- Control UI: add Config tab model presets (MiniMax M2.1, GLM 4.7, Kimi) for one-click setup.
|
||||
- Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, and config schema + Control UI labels (uiHints).
|
||||
- Plugins: add `clawdbot plugins install` (path/tgz/npm), plus `list|info|enable|disable|doctor` UX.
|
||||
- Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests.
|
||||
- Docs: add plugins doc + cross-links from tools/skills/gateway config.
|
||||
- Docs: add beginner-friendly plugin quick start + expand Voice Call plugin docs.
|
||||
- Tests: add Docker plugin loader + tgz-install smoke test.
|
||||
- Tests: extend Docker plugin E2E to cover installing from local folders (`plugins.load.paths`) and `file:` npm specs.
|
||||
- Tests: add coverage for pre-compaction memory flush settings.
|
||||
- Tests: modernize live model smoke selection for current releases and enforce tools/images/thinking-high coverage. (#769) — thanks @steipete.
|
||||
- Agents/Tools: add `apply_patch` tool for multi-file edits (experimental; gated by tools.exec.applyPatch; OpenAI-only).
|
||||
- Agents/Tools: rename the bash tool to exec (config alias maintained). (#748) — thanks @myfunc.
|
||||
- Agents: add pre-compaction memory flush config (`agents.defaults.compaction.*`) with a soft threshold + system prompt.
|
||||
- Config: add `$include` directive for modular config files. (#731) — thanks @pasogott.
|
||||
- Build: set pnpm minimum release age to 2880 minutes (2 days). (#718) — thanks @dan-dr.
|
||||
- macOS: prompt to install the global `clawdbot` CLI when missing in local mode; install via `clawd.bot/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime.
|
||||
- Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell.
|
||||
- Cron/CLI: add `--model` flag to cron add/edit commands. (#711) — thanks @mjrussell.
|
||||
- Cron/CLI: trim model overrides on cron edits and document main-session guidance. (#711) — thanks @mjrussell.
|
||||
- Skills: bundle `skill-creator` to guide creating and packaging skills.
|
||||
- Providers: add per-DM history limit overrides (`dmHistoryLimit`) with provider-level config. (#728) — thanks @pkrmf.
|
||||
- Discord: expose channel/category management actions in the message tool. (#730) — thanks @NicholasSpisak.
|
||||
- Docs: rename README “macOS app” section to “Apps”. (#733) — thanks @AbhisekBasu1.
|
||||
- Gateway: require `client.id` in WebSocket connect params; use `client.instanceId` for presence de-dupe; update docs/tests.
|
||||
- macOS: remove the attach-only gateway setting; local mode now always manages launchd while still attaching to an existing gateway if present.
|
||||
|
||||
### Installer
|
||||
- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests.
|
||||
- Postinstall: skip pnpm patch fallback when the new patcher is active.
|
||||
- Installer tests: add root+non-root docker smokes, CI workflow to fetch clawd.bot scripts and run install sh/cli with onboarding skipped.
|
||||
- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git.
|
||||
- Installer UX: add `install.sh --help` with flags/env and git install hint.
|
||||
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).
|
||||
|
||||
### Fixes
|
||||
- Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias).
|
||||
- Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete.
|
||||
- CLI: fix guardCancel typing for configure prompts. (#769) — thanks @steipete.
|
||||
- Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging; preserve close codes.
|
||||
- Gateway/Auth: send invalid connect responses before closing the handshake; stabilize invalid-connect auth test.
|
||||
- Gateway: tighten gateway listener detection.
|
||||
- Control UI: hide onboarding chat when configured and guard the mobile chat sidebar overlay.
|
||||
- Auth: read Codex keychain credentials and make the lookup platform-aware.
|
||||
- macOS/Release: avoid bundling dist artifacts in relay builds and generate appcasts from zip-only sources.
|
||||
- Doctor: surface plugin diagnostics in the report.
|
||||
- Plugins: treat `plugins.load.paths` directory entries as package roots when they contain `package.json` + `clawdbot.extensions`; load plugin packages from config dirs; extract archives without system tar.
|
||||
- Config: expand `~` in `CLAWDBOT_CONFIG_PATH` and common path-like config fields (including `plugins.load.paths`); guard invalid `$include` paths. (#731) — thanks @pasogott.
|
||||
- Agents: stop pre-creating session transcripts so first user messages persist in JSONL history.
|
||||
- Agents: skip pre-compaction memory flush when the session workspace is read-only.
|
||||
- Auto-reply: ignore inline `/status` directives unless the message is directive-only.
|
||||
- Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo.
|
||||
- Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight.
|
||||
- Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc.
|
||||
- Auto-reply: treat whitespace-only sender ids as missing for command authorization (WhatsApp self-chat). (#766) — thanks @steipete.
|
||||
- Heartbeat: refresh prompt text for updated defaults.
|
||||
- Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc.
|
||||
- Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka.
|
||||
- CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z.
|
||||
- Agents: treat message tool errors as failures so fallback replies still send; require `to` + `message` for `action=send`. (#717) — thanks @theglove44.
|
||||
- Agents: preserve reasoning items on tool-only turns.
|
||||
- Agents/Subagents: wait for completion before announcing, align wait timeout with run timeout, and make announce prompts more emphatic.
|
||||
- Agents: route subagent transcripts to the target agent sessions directory and add regression coverage. (#708) — thanks @xMikeMickelson.
|
||||
- Agents/Tools: preserve action enums when flattening tool schemas. (#708) — thanks @xMikeMickelson.
|
||||
- Gateway/Agents: canonicalize main session aliases for store writes and add regression coverage. (#709) — thanks @xMikeMickelson.
|
||||
- Agents: reset sessions and retry when auto-compaction overflows instead of crashing the gateway.
|
||||
- Providers/Telegram: normalize command mentions for consistent parsing. (#729) — thanks @obviyus.
|
||||
- Providers: skip DM history limit handling for non-DM sessions. (#728) — thanks @pkrmf.
|
||||
- Sandbox: fix non-main mode incorrectly sandboxing the main DM session and align `/status` runtime reporting with effective sandbox state.
|
||||
- Sandbox/Gateway: treat `agent:<id>:main` as a main-session alias when `session.mainKey` is customized (backwards compatible).
|
||||
- Auto-reply: fast-path allowlisted slash commands (inline `/help`/`/commands`/`/status`/`/whoami` stripped before model).
|
||||
|
||||
## 2026.1.10
|
||||
|
||||
### Highlights
|
||||
- CLI: `clawdbot status` now table-based + shows OS/update/gateway/daemon/agents/sessions; `status --all` adds a full read-only debug report (tables, log tails, Tailscale summary, and scan progress via OSC-9 + spinner).
|
||||
- CLI Backends: add Codex CLI fallback with resume support (text output) and JSONL parsing for new runs, plus a live CLI resume probe.
|
||||
- CLI: add `clawdbot update` (safe-ish git checkout update) + `--update` shorthand. (#673) — thanks @fm1randa.
|
||||
- Gateway: add OpenAI-compatible `/v1/chat/completions` HTTP endpoint (auth, SSE streaming, per-agent routing). (#680).
|
||||
|
||||
### Changes
|
||||
- Onboarding/Models: add first-class Z.AI (GLM) auth choice (`zai-api-key`) + `--zai-api-key` flag.
|
||||
- CLI/Onboarding: add OpenRouter API key auth option in configure/onboard. (#703) — thanks @mteam88.
|
||||
- Agents: add human-delay pacing between block replies (modes: off/natural/custom, per-agent configurable). (#446) — thanks @tony-freedomology.
|
||||
- Agents/Browser: add `browser.target` (sandbox/host/custom) with sandbox host-control gating via `agents.defaults.sandbox.browser.allowHostControl`, allowlists for custom control URLs/hosts/ports, and expand browser tool docs (remote control, profiles, internals).
|
||||
- Onboarding/Models: add catalog-backed default model picker to onboarding + configure. (#611) — thanks @jonasjancarik.
|
||||
- Agents/OpenCode Zen: update fallback models + defaults, keep legacy alias mappings. (#669) — thanks @magimetal.
|
||||
- CLI: add `clawdbot reset` and `clawdbot uninstall` flows (interactive + non-interactive) plus docker cleanup smoke test.
|
||||
- Providers: move provider wiring to a plugin architecture. (#661).
|
||||
- Providers: unify group history context wrappers across providers with per-provider/per-account `historyLimit` overrides (fallback to `messages.groupChat.historyLimit`). Set `0` to disable. (#672).
|
||||
- Gateway/Heartbeat: optionally deliver heartbeat `Reasoning:` output (`agents.defaults.heartbeat.includeReasoning`). (#690)
|
||||
- Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo.
|
||||
|
||||
### Fixes
|
||||
- Auto-reply: suppress draft/typing streaming for `NO_REPLY` (silent system ops) so it doesn’t leak partial output.
|
||||
- CLI/Status: expand tables to full terminal width; clarify provider setup vs runtime warnings; richer per-provider detail; token previews in `status` while keeping `status --all` redacted; add troubleshooting link footer; keep log tails pasteable; show gateway auth used when reachable; surface provider runtime errors (Signal/iMessage/Slack); harden `tailscale status --json` parsing; make `status --all` scan progress determinate; and replace the footer with a 3-line “Next steps” recommendation (share/debug/probe).
|
||||
- CLI/Gateway: clarify that `clawdbot gateway status` reports RPC health (connect + RPC) and shows RPC failures separately from connect failures.
|
||||
- CLI/Update: gate progress spinner on stdout TTY and align clean-check step label. (#701) — thanks @bjesuiter.
|
||||
- Telegram: add `/whoami` + `/id` commands to reveal sender id for allowlists; allow `@username` and prefixed ids in `allowFrom` prompts (with stability warning).
|
||||
- Heartbeat: strip markup-wrapped `HEARTBEAT_OK` so acks don’t leak to external providers (e.g., Telegram).
|
||||
- Control UI: stop auto-writing `telegram.groups["*"]` and warn/confirm before enabling wildcard groups.
|
||||
- WhatsApp: send ack reactions only for handled messages and ignore legacy `messages.ackReaction` (doctor copies to `whatsapp.ackReaction`). (#629) — thanks @pasogott.
|
||||
- Sandbox/Skills: mirror skills into sandbox workspaces for read-only mounts so SKILL.md stays accessible.
|
||||
- Terminal/Table: ANSI-safe wrapping to prevent table clipping/color loss; add regression coverage.
|
||||
- Docker: allow optional apt packages during image build and document the build arg. (#697) — thanks @gabriel-trigo.
|
||||
- Gateway/Heartbeat: deliver reasoning even when the main heartbeat reply is `HEARTBEAT_OK`. (#694) — thanks @antons.
|
||||
- Agents/Pi: inject config `temperature`/`maxTokens` into streaming without replacing the session streamFn; cover with live maxTokens probe. (#732) — thanks @peschee.
|
||||
- macOS: clear unsigned launchd overrides on signed restarts and warn via doctor when attach-only/disable markers are set. (#695) — thanks @jeffersonwarrior.
|
||||
- Agents: enforce single-writer session locks and drop orphan tool results to prevent tool-call ID failures (MiniMax/Anthropic-compatible APIs).
|
||||
- Docs: make `clawdbot status` the first diagnostic step, clarify `status --deep` behavior, and document `/whoami` + `/id`.
|
||||
- Docs/Testing: clarify live tool+image probes and how to list your testable `provider/model` ids.
|
||||
- Tests/Live: make gateway bash+read probes resilient to provider formatting while still validating real tool calls.
|
||||
- WhatsApp: detect @lid mentions in groups using authDir reverse mapping + resolve self JID E.164 for mention gating. (#692) — thanks @peschee.
|
||||
- Gateway/Auth: default to token auth on loopback during onboarding, add doctor token generation flow, and tighten audio transcription config to Whisper-only.
|
||||
- Providers: dedupe inbound messages across providers to avoid duplicate LLM runs on redeliveries/reconnects. (#689) — thanks @adam91holt.
|
||||
- Agents: strip `<thought>`/`<antthinking>` tags from hidden reasoning output and cover tag variants in tests. (#688) — thanks @theglove44.
|
||||
- macOS: save model picker selections as normalized provider/model IDs and keep manual entries aligned. (#683) — thanks @benithors.
|
||||
- Agents: recognize "usage limit" errors as rate limits for failover. (#687) — thanks @evalexpr.
|
||||
- CLI: avoid success message when daemon restart is skipped. (#685) — thanks @carlulsoe.
|
||||
- Commands: disable `/config` + `/debug` by default; gate via `commands.config`/`commands.debug` and hide from native registration/help output.
|
||||
- Agents/System: clarify that sub-agents remain sandboxed and cannot use elevated host access.
|
||||
- Gateway: disable the OpenAI-compatible `/v1/chat/completions` endpoint by default; enable via `gateway.http.endpoints.chatCompletions.enabled=true`.
|
||||
- macOS: stabilize bridge tunnels, guard invoke senders on disconnect, and drain stdout/stderr to avoid deadlocks. (#676) — thanks @ngutman.
|
||||
- Agents/System: clarify sandboxed runtime in system prompt and surface elevated availability when sandboxed.
|
||||
- Auto-reply: prefer `RawBody` for command/directive parsing (WhatsApp + Discord) and prevent fallback runs from clobbering concurrent session updates. (#643) — thanks @mcinteerj.
|
||||
- WhatsApp: fix group reactions by preserving message IDs and sender JIDs in history; normalize participant phone numbers to JIDs in outbound reactions. (#640) — thanks @mcinteerj.
|
||||
- WhatsApp: expose group participant IDs to the model so reactions can target the right sender.
|
||||
- Cron: `wakeMode: "now"` waits for heartbeat completion (and retries when the main lane is busy). (#666) — thanks @roshanasingh4.
|
||||
- Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone `reasoning` items that trigger 400 “required following item”) and replay reasoning items in Responses/Codex Responses history for tool-call-only turns.
|
||||
- Sandbox: add `clawdbot sandbox explain` (effective policy inspector + fix-it keys); improve “sandbox jail” tool-policy/elevated errors with actionable config key paths; link to docs.
|
||||
- Hooks/Gmail: keep Tailscale serve path at `/` while preserving the public path. (#668) — thanks @antons.
|
||||
- Hooks/Gmail: allow Tailscale target URLs to preserve internal serve paths.
|
||||
- Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage.
|
||||
- Auth: throttle external CLI credential syncs (Claude/Codex), reduce Keychain reads, and skip sync when cached credentials are still fresh.
|
||||
- CLI: respect `CLAWDBOT_STATE_DIR` for node pairing + voice wake settings storage. (#664) — thanks @azade-c.
|
||||
- Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage.
|
||||
- Gateway/Control UI: make `chat.send` non-blocking, wire Stop to `chat.abort`, and treat `/stop` as an out-of-band abort. (#653)
|
||||
- Gateway/Control UI: allow `chat.abort` without `runId` (abort active runs), suppress post-abort chat streaming, and prune stuck chat runs. (#653)
|
||||
- Gateway/Control UI: sniff image attachments for chat.send, drop non-images, and log mismatches. (#670) — thanks @cristip73.
|
||||
- macOS: force `restart-mac.sh --sign` to require identities and keep bundled Node signed for relay verification. (#580) — thanks @jeffersonwarrior.
|
||||
- Gateway/Agent: accept image attachments on `agent` (multimodal message) and add live gateway image probe (`CLAWDBOT_LIVE_GATEWAY_IMAGE_PROBE=1`).
|
||||
- CLI: `clawdbot sessions` now includes `elev:*` + `usage:*` flags in the table output.
|
||||
- CLI/Pairing: accept positional provider for `pairing list|approve` (npm-run compatible); update docs/bot hints.
|
||||
- Branding: normalize user-facing “ClawdBot”/“CLAWDBOT” → “Clawdbot” (CLI, status, docs).
|
||||
- Auto-reply: fix native `/model` not updating the actual chat session (Telegram/Slack/Discord). (#646)
|
||||
- Doctor: offer to run `clawdbot update` first on git installs (keeps doctor output aligned with latest).
|
||||
- Doctor: avoid false legacy workspace warning when install dir is `~/clawdbot`. (#660)
|
||||
- iMessage: fix reasoning persistence across DMs; avoid partial/duplicate replies when reasoning is enabled. (#655) — thanks @antons.
|
||||
- Models/Auth: allow MiniMax API configs without `models.providers.minimax.apiKey` (auth profiles / `MINIMAX_API_KEY`). (#656) — thanks @mneves75.
|
||||
- Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan.
|
||||
- Agents: harden Cloud Code Assist tool ID sanitization (toolUse/toolCall/toolResult) and scrub extra JSON Schema constraints. (#665) — thanks @sebslight.
|
||||
- Agents: sanitize tool results + Cloud Code Assist tool IDs at context-build time (prevents mid-run strict-provider request rejects).
|
||||
- Agents/Tools: resolve workspace-relative Read/Write/Edit paths; align bash default cwd. (#642) — thanks @mukhtharcm.
|
||||
- Discord: include forwarded message snapshots in agent session context. (#667) — thanks @rubyrunsstuff.
|
||||
- Telegram: add `telegram.draftChunk` to tune draft streaming chunking for `streamMode: "block"`. (#667) — thanks @rubyrunsstuff.
|
||||
- Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults.
|
||||
- iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski.
|
||||
- Auth: read Codex CLI keychain tokens on macOS before falling back to `~/.codex/auth.json`, preventing stale refresh tokens from breaking gateway live tests.
|
||||
- iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks).
|
||||
- Telegram: serialize media-group processing to avoid missed albums under load.
|
||||
- Signal: handle `dataMessage.reaction` events (signal-cli SSE) to avoid broken attachment errors. (#637) — thanks @neist.
|
||||
- Docs: showcase entries for ParentPay, R2 Upload, iOS TestFlight, and Oura Health. (#650) — thanks @henrino3.
|
||||
- Agents: repair session transcripts by dropping duplicate tool results across the whole history (unblocks Anthropic-compatible APIs after retries).
|
||||
- Tests/Live: reset the gateway session between model runs to avoid cross-provider transcript incompatibilities (notably OpenAI Responses reasoning replay rules).
|
||||
|
||||
|
||||
## 2026.1.9
|
||||
|
||||
### Highlights
|
||||
@@ -57,6 +469,7 @@
|
||||
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
|
||||
- Build: Docker build cache layer (#605) — thanks @zknicker.
|
||||
|
||||
- Auth: enable OAuth token refresh for Claude Code CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
|
||||
|
||||
## 2026.1.8
|
||||
|
||||
@@ -73,7 +486,7 @@
|
||||
- Previously, if you didn’t configure an allowlist, your bot could be **open to anyone** (especially discoverable Telegram bots).
|
||||
- New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`).
|
||||
- To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`).
|
||||
- Approve requests via `clawdbot pairing list --provider <provider>` + `clawdbot pairing approve --provider <provider> <code>`.
|
||||
- Approve requests via `clawdbot pairing list <provider>` + `clawdbot pairing approve <provider> <code>`.
|
||||
- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation.
|
||||
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
|
||||
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ Welcome to the lobster tank! 🦞
|
||||
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
||||
|
||||
- **Shadow** - Discord + Slack subsystem
|
||||
- GitHub: [@4shadowed](https://github.com/4shadowed) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||
|
||||
- **Jos** - Telegram, API, Nix mode
|
||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||
|
||||
+10
@@ -8,6 +8,14 @@ RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG CLAWDBOT_DOCKER_APT_PACKAGES=""
|
||||
RUN if [ -n "$CLAWDBOT_DOCKER_APT_PACKAGES" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $CLAWDBOT_DOCKER_APT_PACKAGES && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||
fi
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
@@ -17,6 +25,8 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV CLAWDBOT_PREFER_PNPM=1
|
||||
RUN pnpm ui:install
|
||||
RUN pnpm ui:build
|
||||
|
||||
|
||||
+1
-1
Submodule Peekaboo updated: c1243a7978...5c195f5e46
@@ -1,7 +1,7 @@
|
||||
# 🦞 CLAWDBOT — Personal AI Assistant
|
||||
# 🦞 Clawdbot — Personal AI Assistant
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="CLAWDBOT" width="400">
|
||||
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="Clawdbot" width="400">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -16,26 +16,26 @@
|
||||
</p>
|
||||
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/getting-started) · [Updating](https://docs.clawd.bot/updating) · [Showcase](https://docs.clawd.bot/showcase) · [FAQ](https://docs.clawd.bot/faq) · [Wizard](https://docs.clawd.bot/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/docker) · [Discord](https://discord.gg/clawd)
|
||||
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/start/getting-started) · [Updating](https://docs.clawd.bot/install/updating) · [Showcase](https://docs.clawd.bot/start/showcase) · [FAQ](https://docs.clawd.bot/start/faq) · [Wizard](https://docs.clawd.bot/start/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
|
||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Works with npm, pnpm, or bun.
|
||||
New install? Start here: [Getting started](https://docs.clawd.bot/getting-started)
|
||||
New install? Start here: [Getting started](https://docs.clawd.bot/start/getting-started)
|
||||
|
||||
**Subscriptions (OAuth):**
|
||||
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
|
||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
||||
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawd.bot/onboarding).
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawd.bot/start/onboarding).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
- Models config + CLI: [Models](https://docs.clawd.bot/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/model-failover)
|
||||
- Models config + CLI: [Models](https://docs.clawd.bot/concepts/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/concepts/model-failover)
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
@@ -54,7 +54,7 @@ The wizard installs the Gateway daemon (launchd/systemd user service) so it stay
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
Full beginner guide (auth, pairing, providers): [Getting started](https://docs.clawd.bot/getting-started)
|
||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.clawd.bot/start/getting-started)
|
||||
|
||||
```bash
|
||||
clawdbot onboard --install-daemon
|
||||
@@ -64,11 +64,11 @@ clawdbot gateway --port 18789 --verbose
|
||||
# Send a message
|
||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
|
||||
clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
Upgrading? [Updating guide](https://docs.clawd.bot/updating) (and run `clawdbot doctor`).
|
||||
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
|
||||
|
||||
## From source (development)
|
||||
|
||||
@@ -94,89 +94,94 @@ Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` pro
|
||||
|
||||
Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
|
||||
|
||||
Full security guide: [Security](https://docs.clawd.bot/security)
|
||||
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `clawdbot pairing approve --provider <provider> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `clawdbot pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
||||
|
||||
Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
|
||||
- **[Multi-provider inbox](https://docs.clawd.bot/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawd.bot/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
|
||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://docs.clawd.bot/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
|
||||
- **[Onboarding](https://docs.clawd.bot/wizard) + [skills](https://docs.clawd.bot/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
- **[Companion apps](https://docs.clawd.bot/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
|
||||
- **[Onboarding](https://docs.clawd.bot/start/wizard) + [skills](https://docs.clawd.bot/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#clawdbot/clawdbot&type=date&legend=top-left)
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### Core platform
|
||||
- [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
|
||||
- [CLI surface](https://docs.clawd.bot/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/wizard), and [doctor](https://docs.clawd.bot/doctor).
|
||||
- [Pi agent runtime](https://docs.clawd.bot/agent) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://docs.clawd.bot/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/groups).
|
||||
- [Media pipeline](https://docs.clawd.bot/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/audio).
|
||||
- [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||
- [CLI surface](https://docs.clawd.bot/tools/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/start/wizard), and [doctor](https://docs.clawd.bot/gateway/doctor).
|
||||
- [Pi agent runtime](https://docs.clawd.bot/concepts/agent) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://docs.clawd.bot/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/concepts/groups).
|
||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
||||
|
||||
### Providers
|
||||
- [Providers](https://docs.clawd.bot/surface): [WhatsApp](https://docs.clawd.bot/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/telegram) (grammY), [Slack](https://docs.clawd.bot/slack) (Bolt), [Discord](https://docs.clawd.bot/discord) (discord.js), [Signal](https://docs.clawd.bot/signal) (signal-cli), [iMessage](https://docs.clawd.bot/imessage) (imsg), [WebChat](https://docs.clawd.bot/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/surface).
|
||||
### Channels
|
||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
||||
|
||||
### Apps + nodes
|
||||
- [macOS app](https://docs.clawd.bot/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/talk) overlay, [WebChat](https://docs.clawd.bot/webchat), debug tools, [remote gateway](https://docs.clawd.bot/remote) control.
|
||||
- [iOS node](https://docs.clawd.bot/ios): [Canvas](https://docs.clawd.bot/mac/canvas), [Voice Wake](https://docs.clawd.bot/voicewake), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://docs.clawd.bot/android): [Canvas](https://docs.clawd.bot/mac/canvas), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, optional SMS.
|
||||
- [macOS app](https://docs.clawd.bot/platforms/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/nodes/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/nodes/talk) overlay, [WebChat](https://docs.clawd.bot/web/webchat), debug tools, [remote gateway](https://docs.clawd.bot/gateway/remote) control.
|
||||
- [iOS node](https://docs.clawd.bot/platforms/ios): [Canvas](https://docs.clawd.bot/platforms/mac/canvas), [Voice Wake](https://docs.clawd.bot/nodes/voicewake), [Talk Mode](https://docs.clawd.bot/nodes/talk), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://docs.clawd.bot/platforms/android): [Canvas](https://docs.clawd.bot/platforms/mac/canvas), [Talk Mode](https://docs.clawd.bot/nodes/talk), camera, screen recording, optional SMS.
|
||||
- [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
- [Browser control](https://docs.clawd.bot/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.clawd.bot/mac/canvas): [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.clawd.bot/cron); [webhooks](https://docs.clawd.bot/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/gmail-pubsub).
|
||||
- [Skills platform](https://docs.clawd.bot/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
- [Browser control](https://docs.clawd.bot/tools/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.clawd.bot/platforms/mac/canvas): [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/nodes/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.clawd.bot/automation/cron-jobs); [webhooks](https://docs.clawd.bot/automation/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/automation/gmail-pubsub).
|
||||
- [Skills platform](https://docs.clawd.bot/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Runtime + safety
|
||||
- [Provider routing](https://docs.clawd.bot/provider-routing), [retry policy](https://docs.clawd.bot/retry), and [streaming/chunking](https://docs.clawd.bot/streaming).
|
||||
- [Presence](https://docs.clawd.bot/presence), [typing indicators](https://docs.clawd.bot/typing-indicators), and [usage tracking](https://docs.clawd.bot/usage-tracking).
|
||||
- [Models](https://docs.clawd.bot/models), [model failover](https://docs.clawd.bot/model-failover), and [session pruning](https://docs.clawd.bot/session-pruning).
|
||||
- [Security](https://docs.clawd.bot/security) and [troubleshooting](https://docs.clawd.bot/troubleshooting).
|
||||
- [Channel routing](https://docs.clawd.bot/concepts/channel-routing), [retry policy](https://docs.clawd.bot/concepts/retry), and [streaming/chunking](https://docs.clawd.bot/concepts/streaming).
|
||||
- [Presence](https://docs.clawd.bot/concepts/presence), [typing indicators](https://docs.clawd.bot/concepts/typing-indicators), and [usage tracking](https://docs.clawd.bot/concepts/usage-tracking).
|
||||
- [Models](https://docs.clawd.bot/concepts/models), [model failover](https://docs.clawd.bot/concepts/model-failover), and [session pruning](https://docs.clawd.bot/concepts/session-pruning).
|
||||
- [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/channels/troubleshooting).
|
||||
|
||||
### Ops + packaging
|
||||
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.clawd.bot/tailscale) or [SSH tunnels](https://docs.clawd.bot/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.clawd.bot/nix) for declarative config; [Docker](https://docs.clawd.bot/docker)-based installs.
|
||||
- [Doctor](https://docs.clawd.bot/doctor) migrations, [logging](https://docs.clawd.bot/logging).
|
||||
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/web/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.clawd.bot/gateway/tailscale) or [SSH tunnels](https://docs.clawd.bot/gateway/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.clawd.bot/install/nix) for declarative config; [Docker](https://docs.clawd.bot/install/docker)-based installs.
|
||||
- [Doctor](https://docs.clawd.bot/gateway/doctor) migrations, [logging](https://docs.clawd.bot/logging).
|
||||
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │ ws://127.0.0.1:18789
|
||||
│ (control plane) │ bridge: tcp://0.0.0.0:18790
|
||||
│ Gateway │
|
||||
│ (control plane) │
|
||||
│ ws://127.0.0.1:18789 │
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (clawdbot …)
|
||||
├─ WebChat UI
|
||||
├─ macOS app
|
||||
└─ iOS/Android nodes
|
||||
└─ iOS / Android nodes
|
||||
```
|
||||
|
||||
## Key subsystems
|
||||
|
||||
- **[Gateway WebSocket network](https://docs.clawd.bot/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)).
|
||||
- **[Tailscale exposure](https://docs.clawd.bot/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/remote)).
|
||||
- **[Browser control](https://docs.clawd.bot/browser)** — clawd‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.clawd.bot/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always‑on speech and continuous conversation.
|
||||
- **[Gateway WebSocket network](https://docs.clawd.bot/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)).
|
||||
- **[Tailscale exposure](https://docs.clawd.bot/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/gateway/remote)).
|
||||
- **[Browser control](https://docs.clawd.bot/tools/browser)** — clawd‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.clawd.bot/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always‑on speech and continuous conversation.
|
||||
- **[Nodes](https://docs.clawd.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
|
||||
## Tailscale access (Gateway dashboard)
|
||||
@@ -193,17 +198,17 @@ Notes:
|
||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
||||
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
||||
|
||||
Details: [Tailscale guide](https://docs.clawd.bot/tailscale) · [Web surfaces](https://docs.clawd.bot/web)
|
||||
Details: [Tailscale guide](https://docs.clawd.bot/gateway/tailscale) · [Web surfaces](https://docs.clawd.bot/web)
|
||||
|
||||
## Remote Gateway (Linux is great)
|
||||
|
||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
||||
|
||||
- **Gateway host** runs the bash tool and provider connections by default.
|
||||
- **Gateway host** runs the exec tool and channel connections by default.
|
||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||
In short: bash runs where the Gateway lives; device actions run where the device lives.
|
||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
||||
|
||||
Details: [Remote access](https://docs.clawd.bot/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/security)
|
||||
Details: [Remote access](https://docs.clawd.bot/gateway/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
## macOS permissions via the Gateway protocol
|
||||
|
||||
@@ -218,7 +223,7 @@ Elevated bash (host permissions) is separate from macOS TCC:
|
||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
||||
|
||||
Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/macos) · [Gateway protocol](https://docs.clawd.bot/architecture)
|
||||
Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/platforms/macos) · [Gateway protocol](https://docs.clawd.bot/concepts/architecture)
|
||||
|
||||
## Agent to Agent (sessions_* tools)
|
||||
|
||||
@@ -227,7 +232,7 @@ Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd
|
||||
- `sessions_history` — fetch transcript logs for a session.
|
||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
||||
|
||||
Details: [Session tools](https://docs.clawd.bot/session-tool)
|
||||
Details: [Session tools](https://docs.clawd.bot/concepts/session-tool)
|
||||
|
||||
## Skills registry (ClawdHub)
|
||||
|
||||
@@ -237,18 +242,18 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
|
||||
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high
|
||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||
- `/verbose on|off`
|
||||
- `/cost on|off` — append per-response token/cost usage lines
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
## macOS app (optional)
|
||||
## Apps (optional)
|
||||
|
||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
||||
|
||||
@@ -274,13 +279,13 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `clawdbot nodes …`.
|
||||
|
||||
Runbook: [iOS connect](https://docs.clawd.bot/ios).
|
||||
Runbook: [iOS connect](https://docs.clawd.bot/platforms/ios).
|
||||
|
||||
### Android node (optional)
|
||||
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: [Android connect](https://docs.clawd.bot/android).
|
||||
- Runbook: [Android connect](https://docs.clawd.bot/platforms/android).
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
@@ -300,7 +305,7 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
||||
}
|
||||
```
|
||||
|
||||
[Full configuration reference (all keys + examples).](https://docs.clawd.bot/configuration)
|
||||
[Full configuration reference (all keys + examples).](https://docs.clawd.bot/gateway/configuration)
|
||||
|
||||
## Security model (important)
|
||||
|
||||
@@ -308,54 +313,63 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
||||
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
|
||||
Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxing](https://docs.clawd.bot/docker) · [Sandbox config](https://docs.clawd.bot/configuration)
|
||||
Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker + sandboxing](https://docs.clawd.bot/install/docker) · [Sandbox config](https://docs.clawd.bot/gateway/configuration)
|
||||
|
||||
### [WhatsApp](https://docs.clawd.bot/whatsapp)
|
||||
### [WhatsApp](https://docs.clawd.bot/channels/whatsapp)
|
||||
|
||||
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
|
||||
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
- Link the device: `pnpm clawdbot channels login` (stores creds in `~/.clawdbot/credentials`).
|
||||
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
|
||||
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Telegram](https://docs.clawd.bot/telegram)
|
||||
### [Telegram](https://docs.clawd.bot/channels/telegram)
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
|
||||
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed.
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
|
||||
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF"
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### [Slack](https://docs.clawd.bot/slack)
|
||||
### [Slack](https://docs.clawd.bot/channels/slack)
|
||||
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
|
||||
|
||||
### [Discord](https://docs.clawd.bot/discord)
|
||||
### [Discord](https://docs.clawd.bot/channels/discord)
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
discord: {
|
||||
token: "1234abcd"
|
||||
channels: {
|
||||
discord: {
|
||||
token: "1234abcd"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### [Signal](https://docs.clawd.bot/signal)
|
||||
### [Signal](https://docs.clawd.bot/channels/signal)
|
||||
|
||||
- Requires `signal-cli` and a `signal` config section.
|
||||
- Requires `signal-cli` and a `channels.signal` config section.
|
||||
|
||||
### [iMessage](https://docs.clawd.bot/imessage)
|
||||
### [iMessage](https://docs.clawd.bot/channels/imessage)
|
||||
|
||||
- macOS only; Messages must be signed in.
|
||||
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [WebChat](https://docs.clawd.bot/webchat)
|
||||
### [Microsoft Teams](https://docs.clawd.bot/channels/msteams)
|
||||
|
||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
||||
|
||||
### [WebChat](https://docs.clawd.bot/web/webchat)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
|
||||
@@ -375,68 +389,68 @@ Browser control (optional):
|
||||
|
||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/configuration)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/concepts/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/gateway/configuration)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/remote)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/windows), [Linux](https://docs.clawd.bot/linux), [macOS](https://docs.clawd.bot/macos), [iOS](https://docs.clawd.bot/ios), [Android](https://docs.clawd.bot/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.clawd.bot/security)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/gateway/remote)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/start/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/automation/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/automation/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/platforms/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/platforms/windows), [Linux](https://docs.clawd.bot/platforms/linux), [macOS](https://docs.clawd.bot/platforms/macos), [iOS](https://docs.clawd.bot/platforms/ios), [Android](https://docs.clawd.bot/platforms/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/channels/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
## Advanced docs (discovery + control)
|
||||
|
||||
- [Discovery + transports](https://docs.clawd.bot/discovery)
|
||||
- [Bonjour/mDNS](https://docs.clawd.bot/bonjour)
|
||||
- [Discovery + transports](https://docs.clawd.bot/gateway/discovery)
|
||||
- [Bonjour/mDNS](https://docs.clawd.bot/gateway/bonjour)
|
||||
- [Gateway pairing](https://docs.clawd.bot/gateway/pairing)
|
||||
- [Remote gateway README](https://docs.clawd.bot/remote-gateway-readme)
|
||||
- [Control UI](https://docs.clawd.bot/control-ui)
|
||||
- [Dashboard](https://docs.clawd.bot/dashboard)
|
||||
- [Remote gateway README](https://docs.clawd.bot/gateway/remote-gateway-readme)
|
||||
- [Control UI](https://docs.clawd.bot/web/control-ui)
|
||||
- [Dashboard](https://docs.clawd.bot/web/dashboard)
|
||||
|
||||
## Operations & troubleshooting
|
||||
|
||||
- [Health checks](https://docs.clawd.bot/health)
|
||||
- [Gateway lock](https://docs.clawd.bot/gateway-lock)
|
||||
- [Background process](https://docs.clawd.bot/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.clawd.bot/browser-linux-troubleshooting)
|
||||
- [Health checks](https://docs.clawd.bot/gateway/health)
|
||||
- [Gateway lock](https://docs.clawd.bot/gateway/gateway-lock)
|
||||
- [Background process](https://docs.clawd.bot/gateway/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.clawd.bot/tools/browser-linux-troubleshooting)
|
||||
- [Logging](https://docs.clawd.bot/logging)
|
||||
|
||||
## Deep dives
|
||||
|
||||
- [Agent loop](https://docs.clawd.bot/agent-loop)
|
||||
- [Presence](https://docs.clawd.bot/presence)
|
||||
- [TypeBox schemas](https://docs.clawd.bot/typebox)
|
||||
- [RPC adapters](https://docs.clawd.bot/rpc)
|
||||
- [Queue](https://docs.clawd.bot/queue)
|
||||
- [Agent loop](https://docs.clawd.bot/concepts/agent-loop)
|
||||
- [Presence](https://docs.clawd.bot/concepts/presence)
|
||||
- [TypeBox schemas](https://docs.clawd.bot/concepts/typebox)
|
||||
- [RPC adapters](https://docs.clawd.bot/reference/rpc)
|
||||
- [Queue](https://docs.clawd.bot/concepts/queue)
|
||||
|
||||
## Workspace & skills
|
||||
|
||||
- [Skills config](https://docs.clawd.bot/skills-config)
|
||||
- [Default AGENTS](https://docs.clawd.bot/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.clawd.bot/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.clawd.bot/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.clawd.bot/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.clawd.bot/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.clawd.bot/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.clawd.bot/templates/USER)
|
||||
- [Skills config](https://docs.clawd.bot/tools/skills-config)
|
||||
- [Default AGENTS](https://docs.clawd.bot/reference/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.clawd.bot/reference/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.clawd.bot/reference/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.clawd.bot/reference/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.clawd.bot/reference/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.clawd.bot/reference/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.clawd.bot/reference/templates/USER)
|
||||
|
||||
## Platform internals
|
||||
|
||||
- [macOS dev setup](https://docs.clawd.bot/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.clawd.bot/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.clawd.bot/mac/voicewake)
|
||||
- [iOS node](https://docs.clawd.bot/ios)
|
||||
- [Android node](https://docs.clawd.bot/android)
|
||||
- [Windows (WSL2)](https://docs.clawd.bot/windows)
|
||||
- [Linux app](https://docs.clawd.bot/linux)
|
||||
- [macOS dev setup](https://docs.clawd.bot/platforms/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.clawd.bot/platforms/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.clawd.bot/platforms/mac/voicewake)
|
||||
- [iOS node](https://docs.clawd.bot/platforms/ios)
|
||||
- [Android node](https://docs.clawd.bot/platforms/android)
|
||||
- [Windows (WSL2)](https://docs.clawd.bot/platforms/windows)
|
||||
- [Linux app](https://docs.clawd.bot/platforms/linux)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
- [docs.clawd.bot/gmail-pubsub](https://docs.clawd.bot/gmail-pubsub)
|
||||
- [docs.clawd.bot/gmail-pubsub](https://docs.clawd.bot/automation/gmail-pubsub)
|
||||
|
||||
## Clawd
|
||||
|
||||
@@ -454,20 +468,29 @@ AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
||||
|
||||
Core contributors:
|
||||
- @cpojer — Telegram onboarding UX + docs
|
||||
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
|
||||
<a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a>
|
||||
<a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a>
|
||||
<a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||
<a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a>
|
||||
<a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a>
|
||||
<a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a>
|
||||
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a>
|
||||
<a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
|
||||
<a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
||||
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a>
|
||||
<a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a>
|
||||
<a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
|
||||
<a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a>
|
||||
<a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
|
||||
<a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
|
||||
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
|
||||
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
||||
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a>
|
||||
<a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
|
||||
<a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a>
|
||||
<a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a>
|
||||
<a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
||||
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
|
||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
||||
<a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
|
||||
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
|
||||
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Security Policy
|
||||
|
||||
If you believe you’ve found a security issue in Clawdbot, please report it privately.
|
||||
|
||||
## Reporting
|
||||
|
||||
- Email: `steipete@gmail.com`
|
||||
- What to include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
|
||||
|
||||
## Operational Guidance
|
||||
|
||||
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
|
||||
|
||||
- `https://docs.clawd.bot/gateway/security`
|
||||
|
||||
@@ -34,8 +34,7 @@ extension AttributedString {
|
||||
var ranges: [Range<AttributedString.Index>] = []
|
||||
for wordRange in wordRanges {
|
||||
if let lastRange = ranges.last,
|
||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
|
||||
{
|
||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength {
|
||||
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
||||
} else {
|
||||
ranges.append(wordRange)
|
||||
|
||||
@@ -13,8 +13,7 @@ public actor TranscriptsStore {
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
fileURL = dir.appendingPathComponent("transcripts.log")
|
||||
if let data = try? Data(contentsOf: fileURL),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
{
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ public struct WakeWordGateConfig: Sendable, Equatable {
|
||||
public init(
|
||||
triggers: [String],
|
||||
minPostTriggerGap: TimeInterval = 0.45,
|
||||
minCommandLength: Int = 1)
|
||||
{
|
||||
minCommandLength: Int = 1) {
|
||||
self.triggers = triggers
|
||||
self.minPostTriggerGap = minPostTriggerGap
|
||||
self.minCommandLength = minCommandLength
|
||||
@@ -57,6 +56,12 @@ public enum WakeWordGate {
|
||||
let tokens: [String]
|
||||
}
|
||||
|
||||
private struct MatchCandidate {
|
||||
let index: Int
|
||||
let triggerEnd: TimeInterval
|
||||
let gap: TimeInterval
|
||||
}
|
||||
|
||||
public static func match(
|
||||
transcript: String,
|
||||
segments: [WakeWordSegment],
|
||||
@@ -68,7 +73,7 @@ public enum WakeWordGate {
|
||||
let tokens = normalizeSegments(segments)
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
|
||||
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
|
||||
var best: MatchCandidate?
|
||||
|
||||
for trigger in triggerTokens {
|
||||
let count = trigger.tokens.count
|
||||
@@ -84,7 +89,7 @@ public enum WakeWordGate {
|
||||
|
||||
if let best, i <= best.index { continue }
|
||||
|
||||
best = (i, triggerEnd, gap)
|
||||
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ enum CLIRegistry {
|
||||
subcommands: [
|
||||
descriptor(for: ServiceInstall.self),
|
||||
descriptor(for: ServiceUninstall.self),
|
||||
descriptor(for: ServiceStatus.self),
|
||||
descriptor(for: ServiceStatus.self)
|
||||
])
|
||||
let doctorDesc = descriptor(for: DoctorCommand.self)
|
||||
let setupDesc = descriptor(for: SetupCommand.self)
|
||||
@@ -54,7 +54,7 @@ enum CLIRegistry {
|
||||
startDesc,
|
||||
stopDesc,
|
||||
restartDesc,
|
||||
statusDesc,
|
||||
statusDesc
|
||||
])
|
||||
return [root]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ private enum LaunchdHelper {
|
||||
"Label": label,
|
||||
"ProgramArguments": [executable, "serve"],
|
||||
"RunAtLoad": true,
|
||||
"KeepAlive": true,
|
||||
"KeepAlive": true
|
||||
]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: plistURL)
|
||||
|
||||
@@ -25,78 +25,123 @@ private func dispatch(invocation: CommandInvocation) async throws {
|
||||
|
||||
switch first {
|
||||
case "swabble":
|
||||
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
|
||||
let sub = path[1]
|
||||
switch sub {
|
||||
case "serve":
|
||||
var cmd = ServeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "transcribe":
|
||||
var cmd = TranscribeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "test-hook":
|
||||
var cmd = TestHookCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "mic":
|
||||
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
|
||||
let micSub = path[2]
|
||||
if micSub == "list" {
|
||||
var cmd = MicList(parsed: parsed)
|
||||
try await cmd.run()
|
||||
} else if micSub == "set" {
|
||||
var cmd = MicSet(parsed: parsed)
|
||||
try await cmd.run()
|
||||
} else {
|
||||
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
||||
}
|
||||
case "service":
|
||||
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
|
||||
let svcSub = path[2]
|
||||
switch svcSub {
|
||||
case "install":
|
||||
var cmd = ServiceInstall()
|
||||
try await cmd.run()
|
||||
case "uninstall":
|
||||
var cmd = ServiceUninstall()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = ServiceStatus()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
||||
}
|
||||
case "doctor":
|
||||
var cmd = DoctorCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "setup":
|
||||
var cmd = SetupCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "health":
|
||||
var cmd = HealthCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "tail-log":
|
||||
var cmd = TailLogCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "start":
|
||||
var cmd = StartCommand()
|
||||
try await cmd.run()
|
||||
case "stop":
|
||||
var cmd = StopCommand()
|
||||
try await cmd.run()
|
||||
case "restart":
|
||||
var cmd = RestartCommand()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = StatusCommand()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
||||
}
|
||||
try await dispatchSwabble(parsed: parsed, path: path)
|
||||
default:
|
||||
throw CommanderProgramError.unknownCommand(first)
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws {
|
||||
let sub = try subcommand(path, index: 1, command: "swabble")
|
||||
switch sub {
|
||||
case "mic":
|
||||
try await dispatchMic(parsed: parsed, path: path)
|
||||
case "service":
|
||||
try await dispatchService(path: path)
|
||||
default:
|
||||
let handlers = swabbleHandlers(parsed: parsed)
|
||||
guard let handler = handlers[sub] else {
|
||||
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
||||
}
|
||||
try await handler()
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] {
|
||||
[
|
||||
"serve": {
|
||||
var cmd = ServeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"transcribe": {
|
||||
var cmd = TranscribeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"test-hook": {
|
||||
var cmd = TestHookCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"doctor": {
|
||||
var cmd = DoctorCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"setup": {
|
||||
var cmd = SetupCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"health": {
|
||||
var cmd = HealthCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"tail-log": {
|
||||
var cmd = TailLogCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"start": {
|
||||
var cmd = StartCommand()
|
||||
try await cmd.run()
|
||||
},
|
||||
"stop": {
|
||||
var cmd = StopCommand()
|
||||
try await cmd.run()
|
||||
},
|
||||
"restart": {
|
||||
var cmd = RestartCommand()
|
||||
try await cmd.run()
|
||||
},
|
||||
"status": {
|
||||
var cmd = StatusCommand()
|
||||
try await cmd.run()
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatchMic(parsed: ParsedValues, path: [String]) async throws {
|
||||
let micSub = try subcommand(path, index: 2, command: "mic")
|
||||
switch micSub {
|
||||
case "list":
|
||||
var cmd = MicList(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "set":
|
||||
var cmd = MicSet(parsed: parsed)
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatchService(path: [String]) async throws {
|
||||
let svcSub = try subcommand(path, index: 2, command: "service")
|
||||
switch svcSub {
|
||||
case "install":
|
||||
var cmd = ServiceInstall()
|
||||
try await cmd.run()
|
||||
case "uninstall":
|
||||
var cmd = ServiceUninstall()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = ServiceStatus()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
||||
}
|
||||
}
|
||||
|
||||
private func subcommand(_ path: [String], index: Int, command: String) throws -> String {
|
||||
guard path.count > index else {
|
||||
throw CommanderProgramError.missingSubcommand(command: command)
|
||||
}
|
||||
return path[index]
|
||||
}
|
||||
|
||||
if #available(macOS 26.0, *) {
|
||||
let exitCode = await runCLI()
|
||||
exit(exitCode)
|
||||
|
||||
+244
-24
@@ -1,55 +1,275 @@
|
||||
<?xml version="1.0" standalone="yes"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdis</title>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.5-3</title>
|
||||
<pubDate>Mon, 05 Jan 2026 04:30:46 +0100</pubDate>
|
||||
<title>2026.1.15</title>
|
||||
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>3095</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
|
||||
<sparkle:version>5998</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
|
||||
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
|
||||
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
|
||||
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
|
||||
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
|
||||
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
|
||||
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
|
||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
|
||||
<li>TUI: show provider/model labels for the active session and default model.</li>
|
||||
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
|
||||
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
|
||||
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
|
||||
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
|
||||
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
|
||||
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
|
||||
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
|
||||
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
|
||||
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
|
||||
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
|
||||
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
|
||||
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
|
||||
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
|
||||
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</li>
|
||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
||||
<li>Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
|
||||
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
|
||||
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
|
||||
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
|
||||
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
|
||||
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
|
||||
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
|
||||
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
|
||||
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
|
||||
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
|
||||
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
|
||||
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
|
||||
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
|
||||
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
|
||||
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
|
||||
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
|
||||
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
|
||||
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
|
||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
||||
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
|
||||
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
|
||||
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
|
||||
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
|
||||
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
|
||||
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
|
||||
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160800596" type="application/octet-stream" sparkle:edSignature="P8U3nvIFpbGmRItT/NGPmJ/i370OMVvDHYQL/znYsLI0MrbGfXgMGEvR5A0uwW+cJevlX/hrJLiY51zo4rAMBg=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.5-3</title>
|
||||
<pubDate>Mon, 05 Jan 2026 03:57:59 +0100</pubDate>
|
||||
<title>2026.1.14-1</title>
|
||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>3091</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
|
||||
<sparkle:version>5825</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
|
||||
<h3>Fixes</h3>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
|
||||
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
|
||||
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
|
||||
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
|
||||
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<h4>Web Tools</h4>
|
||||
<ul>
|
||||
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
|
||||
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
|
||||
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
|
||||
</ul>
|
||||
<h4>Browser / Control UI</h4>
|
||||
<ul>
|
||||
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
|
||||
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
|
||||
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
|
||||
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
|
||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
||||
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
|
||||
</ul>
|
||||
<h4>Plugins</h4>
|
||||
<ul>
|
||||
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
|
||||
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
|
||||
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
|
||||
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
|
||||
</ul>
|
||||
<h4>Security</h4>
|
||||
<ul>
|
||||
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
|
||||
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
|
||||
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
|
||||
</ul>
|
||||
<h4>Onboarding / Daemon</h4>
|
||||
<ul>
|
||||
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
|
||||
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
|
||||
</ul>
|
||||
<h4>Auth / Usage / Config</h4>
|
||||
<ul>
|
||||
<li>Usage: add MiniMax coding plan usage tracking.</li>
|
||||
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
|
||||
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
|
||||
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
|
||||
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
|
||||
</ul>
|
||||
<h4>Channels</h4>
|
||||
<ul>
|
||||
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
|
||||
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
|
||||
</ul>
|
||||
<h4>Docs</h4>
|
||||
<ul>
|
||||
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
|
||||
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
|
||||
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
|
||||
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
|
||||
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
|
||||
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
|
||||
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<h4>Gateway / Daemon / Sessions</h4>
|
||||
<ul>
|
||||
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
|
||||
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
|
||||
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
|
||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
||||
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
|
||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
||||
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
|
||||
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
|
||||
</ul>
|
||||
<h4>CLI / Onboarding</h4>
|
||||
<ul>
|
||||
<li>Onboarding: show web search setup at the end (not the beginning).</li>
|
||||
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
|
||||
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
|
||||
</ul>
|
||||
<h4>Control UI / TUI</h4>
|
||||
<ul>
|
||||
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
|
||||
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
|
||||
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
|
||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
||||
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
|
||||
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
|
||||
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
|
||||
</ul>
|
||||
<h4>Agents / Auth / Tools / Sandbox</h4>
|
||||
<ul>
|
||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
||||
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
|
||||
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
|
||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
||||
</ul>
|
||||
<h4>macOS / Apps</h4>
|
||||
<ul>
|
||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
||||
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
|
||||
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
|
||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
||||
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
|
||||
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
|
||||
</ul>
|
||||
<h4>Channels / Messaging</h4>
|
||||
<ul>
|
||||
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
|
||||
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
|
||||
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
|
||||
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
|
||||
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
|
||||
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
|
||||
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
|
||||
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
|
||||
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
|
||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
||||
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
|
||||
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
|
||||
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
|
||||
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
|
||||
<li>WhatsApp: harden owner command auth.</li>
|
||||
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
|
||||
</ul>
|
||||
<h4>Config / Doctor / Packaging</h4>
|
||||
<ul>
|
||||
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
|
||||
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
|
||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160797048" type="application/octet-stream" sparkle:edSignature="5KYFg0SW7liwLxLJbfzd2KsAxbX06gMH0rH/W3a4V0p4N48hjz4AsSrfFLdGZSnW+6XaJjC3MN6Ynh+l7kffDQ=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.5-2</title>
|
||||
<pubDate>Mon, 05 Jan 2026 03:51:30 +0100</pubDate>
|
||||
<title>2026.1.12-2</title>
|
||||
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>3089</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-2</sparkle:shortVersionString>
|
||||
<sparkle:version>5534</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-2</h2>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
|
||||
<li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/*.js</code> modules.</li>
|
||||
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-2/Clawdbot-2026.1.5-2.zip" length="150250417" type="application/octet-stream" sparkle:edSignature="ntHNmwyHrv6cPk6NAKOT3AUkwdt5ZadrGU6mJK4GmVxi44uIMT3ZXluvnqK9SxXQwA0H0dXjiGMS/cg8NbgqDA=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 20260109
|
||||
versionName = "2026.1.9"
|
||||
versionCode = 202601114
|
||||
versionName = "2026.1.11-4"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -49,6 +49,7 @@ android {
|
||||
|
||||
lint {
|
||||
disable += setOf("IconLauncherShape")
|
||||
warningsAsErrors = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
@@ -72,6 +73,7 @@ androidComponents {
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +102,7 @@ dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
||||
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation("androidx.camera:camera-core:1.5.2")
|
||||
@@ -116,7 +119,7 @@ dependencies {
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
||||
testImplementation("org.robolectric:robolectric:4.16")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
|
||||
@@ -27,6 +27,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
||||
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
|
||||
|
||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||
@@ -138,7 +139,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String = "main") {
|
||||
fun loadChat(sessionKey: String) {
|
||||
runtime.loadChat(sessionKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
|
||||
class NodeApp : Application() {
|
||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.clawdbot.android.bridge.BridgeDiscovery
|
||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
||||
import com.clawdbot.android.bridge.BridgePairingClient
|
||||
import com.clawdbot.android.bridge.BridgeSession
|
||||
import com.clawdbot.android.bridge.BridgeTlsParams
|
||||
import com.clawdbot.android.node.CameraCaptureManager
|
||||
import com.clawdbot.android.node.LocationCaptureManager
|
||||
import com.clawdbot.android.BuildConfig
|
||||
@@ -78,7 +79,7 @@ class NodeRuntime(context: Context) {
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(command))
|
||||
put("sessionKey", JsonPrimitive(mainSessionKey.value))
|
||||
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
|
||||
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
}.toString(),
|
||||
@@ -142,12 +143,13 @@ class NodeRuntime(context: Context) {
|
||||
private val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { name, remote ->
|
||||
onConnected = { name, remote, mainSessionKey ->
|
||||
_statusText.value = "Connected"
|
||||
_serverName.value = name
|
||||
_remoteAddress.value = remote
|
||||
_isConnected.value = true
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
applyMainSessionKey(mainSessionKey)
|
||||
scope.launch { refreshBrandingFromGateway() }
|
||||
scope.launch { refreshWakeWordsFromGateway() }
|
||||
maybeNavigateToA2uiOnConnect()
|
||||
@@ -159,6 +161,9 @@ class NodeRuntime(context: Context) {
|
||||
onInvoke = { req ->
|
||||
handleInvoke(req.command, req.paramsJson)
|
||||
},
|
||||
onTlsFingerprint = { stableId, fingerprint ->
|
||||
prefs.saveBridgeTlsFingerprint(stableId, fingerprint)
|
||||
},
|
||||
)
|
||||
|
||||
private val chat = ChatController(scope = scope, session = session, json = json)
|
||||
@@ -172,11 +177,31 @@ class NodeRuntime(context: Context) {
|
||||
_remoteAddress.value = null
|
||||
_isConnected.value = false
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
_mainSessionKey.value = "main"
|
||||
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
|
||||
_mainSessionKey.value = "main"
|
||||
}
|
||||
val mainKey = resolveMainSessionKey()
|
||||
talkMode.setMainSessionKey(mainKey)
|
||||
chat.applyMainSessionKey(mainKey)
|
||||
chat.onDisconnected(message)
|
||||
showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
private fun applyMainSessionKey(candidate: String?) {
|
||||
val trimmed = candidate?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return
|
||||
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
|
||||
if (_mainSessionKey.value == trimmed) return
|
||||
_mainSessionKey.value = trimmed
|
||||
talkMode.setMainSessionKey(trimmed)
|
||||
chat.applyMainSessionKey(trimmed)
|
||||
}
|
||||
|
||||
private fun resolveMainSessionKey(): String {
|
||||
val trimmed = _mainSessionKey.value.trim()
|
||||
return if (trimmed.isEmpty()) "main" else trimmed
|
||||
}
|
||||
|
||||
private fun maybeNavigateToA2uiOnConnect() {
|
||||
val a2uiUrl = resolveA2uiHostUrl() ?: return
|
||||
val current = canvas.currentUrl()?.trim().orEmpty()
|
||||
@@ -467,12 +492,17 @@ class NodeRuntime(context: Context) {
|
||||
scope.launch {
|
||||
_statusText.value = "Connecting…"
|
||||
val storedToken = prefs.loadBridgeToken()
|
||||
val tls = resolveTlsParams(endpoint)
|
||||
val resolved =
|
||||
if (storedToken.isNullOrBlank()) {
|
||||
_statusText.value = "Pairing…"
|
||||
BridgePairingClient().pairAndHello(
|
||||
endpoint = endpoint,
|
||||
hello = buildPairingHello(token = null),
|
||||
tls = tls,
|
||||
onTlsFingerprint = { fingerprint ->
|
||||
prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||
@@ -489,6 +519,7 @@ class NodeRuntime(context: Context) {
|
||||
session.connect(
|
||||
endpoint = endpoint,
|
||||
hello = buildSessionHello(token = authToken),
|
||||
tls = tls,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -535,6 +566,41 @@ class NodeRuntime(context: Context) {
|
||||
session.disconnect()
|
||||
}
|
||||
|
||||
private fun resolveTlsParams(endpoint: BridgeEndpoint): BridgeTlsParams? {
|
||||
val stored = prefs.loadBridgeTlsFingerprint(endpoint.stableId)
|
||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
||||
val manual = endpoint.stableId.startsWith("manual|")
|
||||
|
||||
if (hinted) {
|
||||
return BridgeTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||
allowTOFU = stored == null,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return BridgeTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = stored,
|
||||
allowTOFU = false,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (manual) {
|
||||
return BridgeTlsParams(
|
||||
required = false,
|
||||
expectedFingerprint = null,
|
||||
allowTOFU = true,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
scope.launch {
|
||||
val trimmed = payloadJson.trim()
|
||||
@@ -559,7 +625,7 @@ class NodeRuntime(context: Context) {
|
||||
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
||||
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
||||
|
||||
val sessionKey = "main"
|
||||
val sessionKey = resolveMainSessionKey()
|
||||
val message =
|
||||
ClawdbotCanvasA2UIAction.formatAgentMessage(
|
||||
actionName = name,
|
||||
@@ -607,8 +673,9 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String = "main") {
|
||||
chat.load(sessionKey)
|
||||
fun loadChat(sessionKey: String) {
|
||||
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
|
||||
chat.load(key)
|
||||
}
|
||||
|
||||
fun refreshChat() {
|
||||
@@ -701,7 +768,7 @@ class NodeRuntime(context: Context) {
|
||||
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
||||
_mainSessionKey.value = mainKey
|
||||
applyMainSessionKey(mainKey)
|
||||
|
||||
val parsed = parseHexColorArgb(raw)
|
||||
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
||||
|
||||
@@ -147,6 +147,16 @@ class SecurePrefs(context: Context) {
|
||||
prefs.edit { putString(key, token.trim()) }
|
||||
}
|
||||
|
||||
fun loadBridgeTlsFingerprint(stableId: String): String? {
|
||||
val key = "bridge.tls.$stableId"
|
||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveBridgeTlsFingerprint(stableId: String, fingerprint: String) {
|
||||
val key = "bridge.tls.$stableId"
|
||||
prefs.edit { putString(key, fingerprint.trim()) }
|
||||
}
|
||||
|
||||
private fun loadOrCreateInstanceId(): String {
|
||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||
if (!existing.isNullOrBlank()) return existing
|
||||
|
||||
@@ -4,3 +4,10 @@ internal fun normalizeMainKey(raw: String?): String {
|
||||
val trimmed = raw?.trim()
|
||||
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
|
||||
}
|
||||
|
||||
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return false
|
||||
if (trimmed == "global") return true
|
||||
return trimmed.startsWith("agent:")
|
||||
}
|
||||
|
||||
@@ -143,6 +143,8 @@ class BridgeDiscovery(
|
||||
val gatewayPort = txtInt(resolved, "gatewayPort")
|
||||
val bridgePort = txtInt(resolved, "bridgePort")
|
||||
val canvasPort = txtInt(resolved, "canvasPort")
|
||||
val tlsEnabled = txtBool(resolved, "bridgeTls")
|
||||
val tlsFingerprint = txt(resolved, "bridgeTlsSha256")
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById[id] =
|
||||
BridgeEndpoint(
|
||||
@@ -155,6 +157,8 @@ class BridgeDiscovery(
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
publish()
|
||||
}
|
||||
@@ -209,6 +213,11 @@ class BridgeDiscovery(
|
||||
return txt(info, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
|
||||
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
|
||||
private suspend fun refreshUnicast(domain: String) {
|
||||
val ptrName = "${serviceType}${domain}"
|
||||
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||
@@ -252,6 +261,8 @@ class BridgeDiscovery(
|
||||
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
||||
val bridgePort = txtIntValue(txt, "bridgePort")
|
||||
val canvasPort = txtIntValue(txt, "canvasPort")
|
||||
val tlsEnabled = txtBoolValue(txt, "bridgeTls")
|
||||
val tlsFingerprint = txtValue(txt, "bridgeTlsSha256")
|
||||
val id = stableId(instanceName, domain)
|
||||
next[id] =
|
||||
BridgeEndpoint(
|
||||
@@ -264,6 +275,8 @@ class BridgeDiscovery(
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -474,6 +487,11 @@ class BridgeDiscovery(
|
||||
return txtValue(records, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
|
||||
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
|
||||
private fun decodeDnsTxtString(raw: String): String {
|
||||
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
||||
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
||||
|
||||
@@ -10,6 +10,8 @@ data class BridgeEndpoint(
|
||||
val gatewayPort: Int? = null,
|
||||
val bridgePort: Int? = null,
|
||||
val canvasPort: Int? = null,
|
||||
val tlsEnabled: Boolean = false,
|
||||
val tlsFingerprintSha256: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||
@@ -18,6 +20,8 @@ data class BridgeEndpoint(
|
||||
name = "$host:$port",
|
||||
host = host,
|
||||
port = port,
|
||||
tlsEnabled = false,
|
||||
tlsFingerprintSha256 = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+109
-85
@@ -14,7 +14,6 @@ import java.io.BufferedWriter
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
|
||||
class BridgePairingClient {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@@ -33,95 +32,120 @@ class BridgePairingClient {
|
||||
|
||||
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
||||
|
||||
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
|
||||
suspend fun pairAndHello(
|
||||
endpoint: BridgeEndpoint,
|
||||
hello: Hello,
|
||||
tls: BridgeTlsParams? = null,
|
||||
onTlsFingerprint: ((String) -> Unit)? = null,
|
||||
): PairResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val socket = Socket()
|
||||
socket.tcpNoDelay = true
|
||||
try {
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 60_000
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
fun send(line: String) {
|
||||
writer.write(line)
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("hello"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
when (firstObj["type"].asStringOrNull()) {
|
||||
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||
"error" -> {
|
||||
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
|
||||
}
|
||||
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("pair-request"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
while (true) {
|
||||
val nextLine = reader.readLine() ?: break
|
||||
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
||||
when (next["type"].asStringOrNull()) {
|
||||
"pair-ok" -> {
|
||||
val token = next["token"].asStringOrNull()
|
||||
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||
}
|
||||
"error" -> {
|
||||
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
|
||||
}
|
||||
}
|
||||
}
|
||||
PairResult(ok = false, token = null, error = "pairing failed")
|
||||
}
|
||||
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
||||
PairResult(ok = false, token = null, error = message)
|
||||
} finally {
|
||||
if (tls != null) {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
return@withContext pairAndHelloWithTls(endpoint, hello, tls, onTlsFingerprint)
|
||||
} catch (e: Exception) {
|
||||
if (tls.required) throw e
|
||||
}
|
||||
}
|
||||
pairAndHelloWithTls(endpoint, hello, null, null)
|
||||
}
|
||||
|
||||
private fun pairAndHelloWithTls(
|
||||
endpoint: BridgeEndpoint,
|
||||
hello: Hello,
|
||||
tls: BridgeTlsParams?,
|
||||
onTlsFingerprint: ((String) -> Unit)?,
|
||||
): PairResult {
|
||||
val socket =
|
||||
createBridgeSocket(tls) { fingerprint ->
|
||||
onTlsFingerprint?.invoke(fingerprint)
|
||||
}
|
||||
socket.tcpNoDelay = true
|
||||
try {
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 60_000
|
||||
startTlsHandshakeIfNeeded(socket)
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
fun send(line: String) {
|
||||
writer.write(line)
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("hello"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||
?: return PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
return when (firstObj["type"].asStringOrNull()) {
|
||||
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||
"error" -> {
|
||||
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||
return PairResult(ok = false, token = null, error = "$code: $message")
|
||||
}
|
||||
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("pair-request"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
while (true) {
|
||||
val nextLine = reader.readLine() ?: break
|
||||
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
||||
when (next["type"].asStringOrNull()) {
|
||||
"pair-ok" -> {
|
||||
val token = next["token"].asStringOrNull()
|
||||
return PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||
}
|
||||
"error" -> {
|
||||
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||
return PairResult(ok = false, token = null, error = "$c: $m")
|
||||
}
|
||||
}
|
||||
}
|
||||
PairResult(ok = false, token = null, error = "pairing failed")
|
||||
}
|
||||
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
||||
return PairResult(ok = false, token = null, error = message)
|
||||
} finally {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
@@ -31,10 +31,11 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class BridgeSession(
|
||||
private val scope: CoroutineScope,
|
||||
private val onConnected: (serverName: String, remoteAddress: String?) -> Unit,
|
||||
private val onConnected: (serverName: String, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
||||
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
|
||||
) {
|
||||
data class Hello(
|
||||
val nodeId: String,
|
||||
@@ -64,12 +65,19 @@ class BridgeSession(
|
||||
private val writeLock = Mutex()
|
||||
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||
@Volatile private var canvasHostUrl: String? = null
|
||||
@Volatile private var mainSessionKey: String? = null
|
||||
|
||||
private var desired: Pair<BridgeEndpoint, Hello>? = null
|
||||
private data class DesiredConnection(
|
||||
val endpoint: BridgeEndpoint,
|
||||
val hello: Hello,
|
||||
val tls: BridgeTlsParams?,
|
||||
)
|
||||
|
||||
private var desired: DesiredConnection? = null
|
||||
private var job: Job? = null
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
|
||||
desired = endpoint to hello
|
||||
fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) {
|
||||
desired = DesiredConnection(endpoint, hello, tls)
|
||||
if (job == null) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
}
|
||||
@@ -77,7 +85,7 @@ class BridgeSession(
|
||||
|
||||
suspend fun updateHello(hello: Hello) {
|
||||
val target = desired ?: return
|
||||
desired = target.first to hello
|
||||
desired = target.copy(hello = hello)
|
||||
val conn = currentConnection ?: return
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
}
|
||||
@@ -90,11 +98,13 @@ class BridgeSession(
|
||||
job?.cancelAndJoin()
|
||||
job = null
|
||||
canvasHostUrl = null
|
||||
mainSessionKey = null
|
||||
onDisconnected("Offline")
|
||||
}
|
||||
}
|
||||
|
||||
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
||||
fun currentMainSessionKey(): String? = mainSessionKey
|
||||
|
||||
suspend fun sendEvent(event: String, payloadJson: String?) {
|
||||
val conn = currentConnection ?: return
|
||||
@@ -162,10 +172,10 @@ class BridgeSession(
|
||||
continue
|
||||
}
|
||||
|
||||
val (endpoint, hello) = target
|
||||
val (endpoint, hello, tls) = target
|
||||
try {
|
||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||
connectOnce(endpoint, hello)
|
||||
connectOnce(endpoint, hello, tls)
|
||||
attempt = 0
|
||||
} catch (err: Throwable) {
|
||||
attempt += 1
|
||||
@@ -189,58 +199,76 @@ class BridgeSession(
|
||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||
}
|
||||
|
||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
|
||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val socket = Socket()
|
||||
socket.tcpNoDelay = true
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 0
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
val conn = Connection(socket, reader, writer, writeLock)
|
||||
currentConnection = conn
|
||||
|
||||
try {
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
|
||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||
?: throw IllegalStateException("unexpected bridge response")
|
||||
when (first["type"].asStringOrNull()) {
|
||||
"hello-ok" -> {
|
||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
||||
runCatching {
|
||||
android.util.Log.d(
|
||||
"ClawdbotBridge",
|
||||
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
||||
)
|
||||
}
|
||||
}
|
||||
onConnected(name, conn.remoteAddress)
|
||||
}
|
||||
"error" -> {
|
||||
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
||||
throw IllegalStateException("$code: $msg")
|
||||
}
|
||||
else -> throw IllegalStateException("unexpected bridge response")
|
||||
if (tls != null) {
|
||||
try {
|
||||
connectWithSocket(endpoint, hello, tls)
|
||||
return@withContext
|
||||
} catch (err: Throwable) {
|
||||
if (tls.required) throw err
|
||||
}
|
||||
}
|
||||
connectWithSocket(endpoint, hello, null)
|
||||
}
|
||||
|
||||
while (scope.isActive) {
|
||||
val line = reader.readLine() ?: break
|
||||
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
||||
when (frame["type"].asStringOrNull()) {
|
||||
"event" -> {
|
||||
val event = frame["event"].asStringOrNull() ?: return@withContext
|
||||
val payload = frame["payloadJSON"].asStringOrNull()
|
||||
onEvent(event, payload)
|
||||
private suspend fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
|
||||
val socket =
|
||||
createBridgeSocket(tls) { fingerprint ->
|
||||
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
||||
}
|
||||
socket.tcpNoDelay = true
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 0
|
||||
startTlsHandshakeIfNeeded(socket)
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
val conn = Connection(socket, reader, writer, writeLock)
|
||||
currentConnection = conn
|
||||
|
||||
try {
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
|
||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||
?: throw IllegalStateException("unexpected bridge response")
|
||||
when (first["type"].asStringOrNull()) {
|
||||
"hello-ok" -> {
|
||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
||||
mainSessionKey = rawMainSessionKey
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
||||
runCatching {
|
||||
android.util.Log.d(
|
||||
"ClawdbotBridge",
|
||||
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
||||
)
|
||||
}
|
||||
}
|
||||
onConnected(name, conn.remoteAddress, rawMainSessionKey)
|
||||
}
|
||||
"error" -> {
|
||||
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
||||
throw IllegalStateException("$code: $msg")
|
||||
}
|
||||
else -> throw IllegalStateException("unexpected bridge response")
|
||||
}
|
||||
|
||||
while (scope.isActive) {
|
||||
val line = reader.readLine() ?: break
|
||||
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
||||
when (frame["type"].asStringOrNull()) {
|
||||
"event" -> {
|
||||
val event = frame["event"].asStringOrNull() ?: continue
|
||||
val payload = frame["payloadJSON"].asStringOrNull()
|
||||
onEvent(event, payload)
|
||||
}
|
||||
"ping" -> {
|
||||
val id = frame["id"].asStringOrNull() ?: ""
|
||||
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
||||
@@ -286,20 +314,20 @@ class BridgeSession(
|
||||
},
|
||||
)
|
||||
}
|
||||
"invoke-res" -> {
|
||||
// gateway->node only (ignore)
|
||||
}
|
||||
"invoke-res" -> {
|
||||
// gateway->node only (ignore)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
currentConnection = null
|
||||
for ((_, waiter) in pending) {
|
||||
waiter.cancel()
|
||||
}
|
||||
pending.clear()
|
||||
conn.closeQuietly()
|
||||
}
|
||||
} finally {
|
||||
currentConnection = null
|
||||
for ((_, waiter) in pending) {
|
||||
waiter.cancel()
|
||||
}
|
||||
pending.clear()
|
||||
conn.closeQuietly()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildHelloJson(hello: Hello): JsonObject =
|
||||
buildJsonObject {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.clawdbot.android.bridge
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import java.net.Socket
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
data class BridgeTlsParams(
|
||||
val required: Boolean,
|
||||
val expectedFingerprint: String?,
|
||||
val allowTOFU: Boolean,
|
||||
val stableId: String,
|
||||
)
|
||||
|
||||
fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? = null): Socket {
|
||||
if (params == null) return Socket()
|
||||
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
||||
val defaultTrust = defaultTrustManager()
|
||||
@SuppressLint("CustomX509TrustManager")
|
||||
val trustManager =
|
||||
object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
defaultTrust.checkClientTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
|
||||
val fingerprint = sha256Hex(chain[0].encoded)
|
||||
if (expected != null) {
|
||||
if (fingerprint != expected) {
|
||||
throw CertificateException("bridge TLS fingerprint mismatch")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (params.allowTOFU) {
|
||||
onStore?.invoke(fingerprint)
|
||||
return
|
||||
}
|
||||
defaultTrust.checkServerTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
|
||||
}
|
||||
|
||||
val context = SSLContext.getInstance("TLS")
|
||||
context.init(null, arrayOf(trustManager), SecureRandom())
|
||||
return context.socketFactory.createSocket()
|
||||
}
|
||||
|
||||
fun startTlsHandshakeIfNeeded(socket: Socket) {
|
||||
if (socket is SSLSocket) {
|
||||
socket.startHandshake()
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultTrustManager(): X509TrustManager {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as java.security.KeyStore?)
|
||||
val trust =
|
||||
factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
|
||||
return trust ?: throw IllegalStateException("No default X509TrustManager found")
|
||||
}
|
||||
|
||||
private fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = StringBuilder(digest.size * 2)
|
||||
for (byte in digest) {
|
||||
out.append(String.format("%02x", byte))
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun normalizeFingerprint(raw: String): String {
|
||||
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
}
|
||||
@@ -71,12 +71,21 @@ class ChatController(
|
||||
_sessionId.value = null
|
||||
}
|
||||
|
||||
fun load(sessionKey: String = "main") {
|
||||
fun load(sessionKey: String) {
|
||||
val key = sessionKey.trim().ifEmpty { "main" }
|
||||
_sessionKey.value = key
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun applyMainSessionKey(mainSessionKey: String) {
|
||||
val trimmed = mainSessionKey.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
if (_sessionKey.value == trimmed) return
|
||||
if (_sessionKey.value != "main") return
|
||||
_sessionKey.value = trimmed
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.util.Base64
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.ExifInterface
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
|
||||
@@ -122,13 +122,7 @@ class ScreenRecordManager(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMediaRecorder(): MediaRecorder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaRecorder()
|
||||
}
|
||||
private fun createMediaRecorder(): MediaRecorder = MediaRecorder(context)
|
||||
|
||||
private suspend fun ensureMicPermission() {
|
||||
val granted =
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.clawdbot.android.chat.ChatSessionEntry
|
||||
fun ChatComposer(
|
||||
sessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
mainSessionKey: String,
|
||||
healthOk: Boolean,
|
||||
thinkingLevel: String,
|
||||
pendingRunCount: Int,
|
||||
@@ -61,7 +62,7 @@ fun ChatComposer(
|
||||
var showThinkingMenu by remember { mutableStateOf(false) }
|
||||
var showSessionMenu by remember { mutableStateOf(false) }
|
||||
|
||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
|
||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
|
||||
val currentSessionLabel =
|
||||
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
||||
|
||||
|
||||
@@ -33,13 +33,14 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val healthOk by viewModel.chatHealthOk.collectAsState()
|
||||
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
||||
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
|
||||
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
||||
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadChat("main")
|
||||
LaunchedEffect(mainSessionKey) {
|
||||
viewModel.loadChat(mainSessionKey)
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
}
|
||||
|
||||
@@ -85,6 +86,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
ChatComposer(
|
||||
sessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
mainSessionKey = mainSessionKey,
|
||||
healthOk = healthOk,
|
||||
thinkingLevel = thinkingLevel,
|
||||
pendingRunCount = pendingRunCount,
|
||||
|
||||
@@ -2,20 +2,23 @@ package com.clawdbot.android.ui.chat
|
||||
|
||||
import com.clawdbot.android.chat.ChatSessionEntry
|
||||
|
||||
private const val MAIN_SESSION_KEY = "main"
|
||||
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
||||
|
||||
fun resolveSessionChoices(
|
||||
currentSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
mainSessionKey: String,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
): List<ChatSessionEntry> {
|
||||
val current = currentSessionKey.trim()
|
||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
||||
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
|
||||
val aliasKey = if (mainKey == "main") null else "main"
|
||||
val cutoff = nowMs - RECENT_WINDOW_MS
|
||||
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
|
||||
val recent = mutableListOf<ChatSessionEntry>()
|
||||
val seen = mutableSetOf<String>()
|
||||
for (entry in sorted) {
|
||||
if (aliasKey != null && entry.key == aliasKey) continue
|
||||
if (!seen.add(entry.key)) continue
|
||||
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
|
||||
recent.add(entry)
|
||||
@@ -23,13 +26,13 @@ fun resolveSessionChoices(
|
||||
|
||||
val result = mutableListOf<ChatSessionEntry>()
|
||||
val included = mutableSetOf<String>()
|
||||
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
|
||||
val mainEntry = sorted.firstOrNull { it.key == mainKey }
|
||||
if (mainEntry != null) {
|
||||
result.add(mainEntry)
|
||||
included.add(MAIN_SESSION_KEY)
|
||||
} else if (current == MAIN_SESSION_KEY) {
|
||||
result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null))
|
||||
included.add(MAIN_SESSION_KEY)
|
||||
included.add(mainKey)
|
||||
} else if (current == mainKey) {
|
||||
result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null))
|
||||
included.add(mainKey)
|
||||
}
|
||||
|
||||
for (entry in recent) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import android.speech.tts.UtteranceProgressListener
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.clawdbot.android.bridge.BridgeSession
|
||||
import com.clawdbot.android.isCanonicalMainSessionKey
|
||||
import com.clawdbot.android.normalizeMainKey
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
@@ -116,6 +117,13 @@ class TalkModeManager(
|
||||
chatSubscribedSessionKey = null
|
||||
}
|
||||
|
||||
fun setMainSessionKey(sessionKey: String?) {
|
||||
val trimmed = sessionKey?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return
|
||||
if (isCanonicalMainSessionKey(mainSessionKey)) return
|
||||
mainSessionKey = trimmed
|
||||
}
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
if (_isEnabled.value == enabled) return
|
||||
_isEnabled.value = enabled
|
||||
@@ -827,7 +835,9 @@ class TalkModeManager(
|
||||
val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
|
||||
|
||||
mainSessionKey = mainKey
|
||||
if (!isCanonicalMainSessionKey(mainSessionKey)) {
|
||||
mainSessionKey = mainKey
|
||||
}
|
||||
defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
||||
voiceAliases = aliases
|
||||
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
|
||||
|
||||
@@ -30,7 +30,7 @@ class BridgeSessionTest {
|
||||
val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { _, _ -> connected.complete(Unit) },
|
||||
onConnected = { _, _, _ -> connected.complete(Unit) },
|
||||
onDisconnected = { /* ignore */ },
|
||||
onEvent = { _, _ -> /* ignore */ },
|
||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||
@@ -97,7 +97,7 @@ class BridgeSessionTest {
|
||||
val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { _, _ -> connected.complete(Unit) },
|
||||
onConnected = { _, _, _ -> connected.complete(Unit) },
|
||||
onDisconnected = { /* ignore */ },
|
||||
onEvent = { _, _ -> /* ignore */ },
|
||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||
@@ -167,7 +167,7 @@ class BridgeSessionTest {
|
||||
val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { _, _ -> connected.complete(Unit) },
|
||||
onConnected = { _, _, _ -> connected.complete(Unit) },
|
||||
onDisconnected = { /* ignore */ },
|
||||
onEvent = { _, _ -> /* ignore */ },
|
||||
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
|
||||
@@ -239,7 +239,7 @@ class BridgeSessionTest {
|
||||
val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { _, _ -> connected.countDown() },
|
||||
onConnected = { _, _, _ -> connected.countDown() },
|
||||
onDisconnected = { /* ignore */ },
|
||||
onEvent = { _, _ -> /* ignore */ },
|
||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||
|
||||
@@ -19,7 +19,7 @@ class SessionFiltersTest {
|
||||
ChatSessionEntry(key = "recent-2", updatedAtMs = recent2),
|
||||
)
|
||||
|
||||
val result = resolveSessionChoices("main", sessions, nowMs = now).map { it.key }
|
||||
val result = resolveSessionChoices("main", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
|
||||
assertEquals(listOf("main", "recent-1", "recent-2"), result)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class SessionFiltersTest {
|
||||
val recent = now - 10 * 60 * 1000L
|
||||
val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent))
|
||||
|
||||
val result = resolveSessionChoices("custom", sessions, nowMs = now).map { it.key }
|
||||
val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
|
||||
assertEquals(listOf("main", "custom"), result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,36 @@ actor BridgeClient {
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams? = nil,
|
||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||
{
|
||||
do {
|
||||
return try await self.pairAndHelloOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onStatus: onStatus)
|
||||
} catch {
|
||||
if let tls, !tls.required {
|
||||
return try await self.pairAndHelloOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: nil,
|
||||
onStatus: onStatus)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func pairAndHelloOnce(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||
{
|
||||
self.lineBuffer = Data()
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let params = self.makeParameters(tls: tls)
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
|
||||
defer { connection.cancel() }
|
||||
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
||||
@@ -142,6 +168,18 @@ actor BridgeClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
|
||||
if let tlsOptions = makeBridgeTLSOptions(tls) {
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
|
||||
private struct TimeoutError: LocalizedError, Sendable {
|
||||
var purpose: String
|
||||
var seconds: Int
|
||||
@@ -161,18 +199,10 @@ actor BridgeClient {
|
||||
purpose: String,
|
||||
_ op: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask {
|
||||
try await op()
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
|
||||
throw TimeoutError(purpose: purpose, seconds: seconds)
|
||||
}
|
||||
let result = try await group.next()!
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: Double(seconds),
|
||||
onTimeout: { TimeoutError(purpose: purpose, seconds: seconds) },
|
||||
operation: op)
|
||||
}
|
||||
|
||||
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
|
||||
|
||||
@@ -10,6 +10,7 @@ protocol BridgePairingClient: Sendable {
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||
}
|
||||
|
||||
@@ -115,7 +116,14 @@ final class BridgeConnectionController {
|
||||
|
||||
self.didAutoConnect = true
|
||||
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
||||
self.startAutoConnect(endpoint: endpoint, token: token, instanceId: instanceId)
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
|
||||
self.startAutoConnect(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -131,8 +139,14 @@ final class BridgeConnectionController {
|
||||
|
||||
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(bridge: target)
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(endpoint: target.endpoint, token: token, instanceId: instanceId)
|
||||
self.startAutoConnect(
|
||||
endpoint: target.endpoint,
|
||||
bridgeStableID: target.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
}
|
||||
|
||||
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||
@@ -171,7 +185,13 @@ final class BridgeConnectionController {
|
||||
"bridge-token.\(instanceId)"
|
||||
}
|
||||
|
||||
private func startAutoConnect(endpoint: NWEndpoint, token: String, instanceId: String) {
|
||||
private func startAutoConnect(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
tls: BridgeTLSParams?,
|
||||
token: String,
|
||||
instanceId: String)
|
||||
{
|
||||
guard let appModel else { return }
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -180,6 +200,7 @@ final class BridgeConnectionController {
|
||||
let refreshed = try await self.bridgeClientFactory().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
appModel.bridgeStatusText = status
|
||||
@@ -192,7 +213,11 @@ final class BridgeConnectionController {
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount(instanceId: instanceId))
|
||||
}
|
||||
appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))
|
||||
appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: bridgeStableID,
|
||||
tls: tls,
|
||||
hello: self.makeHello(token: resolvedToken))
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||
@@ -201,6 +226,47 @@ final class BridgeConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(
|
||||
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
|
||||
{
|
||||
let stableID = bridge.stableID
|
||||
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
if let stored {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
|
||||
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return BridgeTLSParams(
|
||||
required: false,
|
||||
expectedFingerprint: nil,
|
||||
allowTOFU: true,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||
let key = "node.displayName"
|
||||
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
@@ -23,6 +23,8 @@ final class BridgeDiscoveryModel {
|
||||
var gatewayPort: Int?
|
||||
var bridgePort: Int?
|
||||
var canvasPort: Int?
|
||||
var tlsEnabled: Bool
|
||||
var tlsFingerprintSha256: String?
|
||||
var cliPath: String?
|
||||
}
|
||||
|
||||
@@ -90,6 +92,8 @@ final class BridgeDiscoveryModel {
|
||||
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
||||
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
|
||||
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
||||
tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"),
|
||||
tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"),
|
||||
cliPath: Self.txtValue(txt, key: "cliPath"))
|
||||
default:
|
||||
return nil
|
||||
@@ -214,4 +218,9 @@ final class BridgeDiscoveryModel {
|
||||
guard let raw = self.txtValue(dict, key: key) else { return nil }
|
||||
return Int(raw)
|
||||
}
|
||||
|
||||
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
||||
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ actor BridgeSession {
|
||||
|
||||
private(set) var state: State = .idle
|
||||
private var canvasHostUrl: String?
|
||||
private var mainSessionKey: String?
|
||||
|
||||
func currentCanvasHostUrl() -> String? {
|
||||
self.canvasHostUrl
|
||||
@@ -68,15 +69,42 @@ actor BridgeSession {
|
||||
func connect(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
onConnected: (@Sendable (String) async -> Void)? = nil,
|
||||
tls: BridgeTLSParams? = nil,
|
||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||
async throws
|
||||
{
|
||||
await self.disconnect()
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await self.connectOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onConnected: onConnected,
|
||||
onInvoke: onInvoke)
|
||||
} catch {
|
||||
if let tls, !tls.required {
|
||||
try await self.connectOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: nil,
|
||||
onConnected: onConnected,
|
||||
onInvoke: onInvoke)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
private func connectOnce(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onConnected: (@Sendable (String, String?) async -> Void)?,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
||||
{
|
||||
let params = self.makeParameters(tls: tls)
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
|
||||
self.connection = connection
|
||||
@@ -107,7 +135,9 @@ actor BridgeSession {
|
||||
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
||||
self.state = .connected(serverName: ok.serverName)
|
||||
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
await onConnected?(ok.serverName)
|
||||
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.mainSessionKey = (mainKey?.isEmpty == false) ? mainKey : nil
|
||||
await onConnected?(ok.serverName, self.mainSessionKey)
|
||||
} else if base.type == "error" {
|
||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
||||
self.state = .failed(message: "\(err.code): \(err.message)")
|
||||
@@ -217,6 +247,7 @@ actor BridgeSession {
|
||||
self.queue = nil
|
||||
self.buffer = Data()
|
||||
self.canvasHostUrl = nil
|
||||
self.mainSessionKey = nil
|
||||
|
||||
let pending = self.pendingRPC.values
|
||||
self.pendingRPC.removeAll()
|
||||
@@ -234,6 +265,10 @@ actor BridgeSession {
|
||||
self.state = .idle
|
||||
}
|
||||
|
||||
func currentMainSessionKey() -> String? {
|
||||
self.mainSessionKey
|
||||
}
|
||||
|
||||
private func beginRPC(
|
||||
id: String,
|
||||
request: BridgeRPCRequest,
|
||||
@@ -247,6 +282,18 @@ actor BridgeSession {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
|
||||
if let tlsOptions = makeBridgeTLSOptions(tls) {
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
|
||||
private func timeoutRPC(id: String) async {
|
||||
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
|
||||
@@ -321,20 +368,10 @@ actor BridgeSession {
|
||||
seconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
throw TimeoutError(message: "UNAVAILABLE: connection timeout")
|
||||
}
|
||||
|
||||
guard let first = try await group.next() else {
|
||||
throw TimeoutError(message: "UNAVAILABLE: connection timeout")
|
||||
}
|
||||
|
||||
group.cancelAll()
|
||||
return first
|
||||
}
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: seconds,
|
||||
onTimeout: { TimeoutError(message: "UNAVAILABLE: connection timeout") },
|
||||
operation: operation)
|
||||
}
|
||||
|
||||
private static func makeStateStream(for connection: NWConnection) -> AsyncStream<NWConnection.State> {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Security
|
||||
|
||||
struct BridgeTLSParams: Sendable {
|
||||
let required: Bool
|
||||
let expectedFingerprint: String?
|
||||
let allowTOFU: Bool
|
||||
let storeKey: String?
|
||||
}
|
||||
|
||||
enum BridgeTLSStore {
|
||||
private static let service = "com.clawdbot.bridge.tls"
|
||||
|
||||
static func loadFingerprint(stableID: String) -> String? {
|
||||
KeychainStore.loadString(service: service, account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func saveFingerprint(_ value: String, stableID: String) {
|
||||
_ = KeychainStore.saveString(value, service: service, account: stableID)
|
||||
}
|
||||
}
|
||||
|
||||
func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options? {
|
||||
guard let params else { return nil }
|
||||
let options = NWProtocolTLS.Options()
|
||||
let expected = params.expectedFingerprint.map(normalizeBridgeFingerprint)
|
||||
let allowTOFU = params.allowTOFU
|
||||
let storeKey = params.storeKey
|
||||
|
||||
sec_protocol_options_set_verify_block(
|
||||
options.securityProtocolOptions,
|
||||
{ _, trust, complete in
|
||||
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
|
||||
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
|
||||
let cert = chain.first
|
||||
{
|
||||
let data = SecCertificateCopyData(cert) as Data
|
||||
let fingerprint = sha256Hex(data)
|
||||
if let expected {
|
||||
complete(fingerprint == expected)
|
||||
return
|
||||
}
|
||||
if allowTOFU {
|
||||
if let storeKey { BridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
|
||||
complete(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
let ok = SecTrustEvaluateWithError(trustRef, nil)
|
||||
complete(ok)
|
||||
},
|
||||
DispatchQueue(label: "com.clawdbot.bridge.tls.verify"))
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
private func sha256Hex(_ data: Data) -> String {
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private func normalizeBridgeFingerprint(_ raw: String) -> String {
|
||||
raw.lowercased().filter { $0.isHexDigit }
|
||||
}
|
||||
@@ -190,14 +190,7 @@ actor CameraController {
|
||||
}
|
||||
|
||||
func listDevices() -> [CameraDeviceInfo] {
|
||||
let types: [AVCaptureDevice.DeviceType] = [
|
||||
.builtInWideAngleCamera,
|
||||
]
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: types,
|
||||
mediaType: .video,
|
||||
position: .unspecified)
|
||||
return session.devices.map { device in
|
||||
return Self.discoverVideoDevices().map { device in
|
||||
CameraDeviceInfo(
|
||||
id: device.uniqueID,
|
||||
name: device.localizedName,
|
||||
@@ -232,7 +225,7 @@ actor CameraController {
|
||||
deviceId: String?) -> AVCaptureDevice?
|
||||
{
|
||||
if let deviceId, !deviceId.isEmpty {
|
||||
if let match = AVCaptureDevice.devices(for: .video).first(where: { $0.uniqueID == deviceId }) {
|
||||
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
@@ -252,6 +245,24 @@ actor CameraController {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
|
||||
let types: [AVCaptureDevice.DeviceType] = [
|
||||
.builtInWideAngleCamera,
|
||||
.builtInUltraWideCamera,
|
||||
.builtInTelephotoCamera,
|
||||
.builtInDualCamera,
|
||||
.builtInDualWideCamera,
|
||||
.builtInTripleCamera,
|
||||
.builtInTrueDepthCamera,
|
||||
.builtInLiDARDepthCamera,
|
||||
]
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: types,
|
||||
mediaType: .video,
|
||||
position: .unspecified)
|
||||
return session.devices
|
||||
}
|
||||
|
||||
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
||||
let q = quality ?? 0.9
|
||||
return min(1.0, max(0.05, q))
|
||||
|
||||
@@ -6,7 +6,7 @@ struct ChatSheet: View {
|
||||
@State private var viewModel: ClawdbotChatViewModel
|
||||
private let userAccent: Color?
|
||||
|
||||
init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) {
|
||||
init(bridge: BridgeSession, sessionKey: String, userAccent: Color? = nil) {
|
||||
let transport = IOSBridgeChatTransport(bridge: bridge)
|
||||
self._viewModel = State(
|
||||
initialValue: ClawdbotChatViewModel(
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.9</string>
|
||||
<string>2026.1.11-4</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260109</string>
|
||||
<string>202601113</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
@@ -35,10 +35,10 @@
|
||||
<string>Clawdbot can capture photos or short video clips when requested via the bridge.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Clawdbot discovers and connects to your Clawdbot bridge on the local network.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Clawdbot uses your location when you allow location sharing.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Clawdbot can share your location in the background when you enable Always.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Clawdbot uses your location when you allow location sharing.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Clawdbot needs microphone access for voice wake.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
|
||||
@@ -86,24 +86,11 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func withTimeout<T>(
|
||||
private func withTimeout<T: Sendable>(
|
||||
timeoutMs: Int,
|
||||
operation: @escaping () async throws -> T) async throws -> T
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
if timeoutMs == 0 {
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
return try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
|
||||
throw Error.timeout
|
||||
}
|
||||
let result = try await group.next()!
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
|
||||
}
|
||||
|
||||
private static func accuracyValue(_ accuracy: ClawdbotLocationAccuracy) -> CLLocationAccuracy {
|
||||
@@ -117,26 +104,35 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
if let cont = self.authContinuation {
|
||||
self.authContinuation = nil
|
||||
cont.resume(returning: manager.authorizationStatus)
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
if let cont = self.authContinuation {
|
||||
self.authContinuation = nil
|
||||
cont.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
if let latest = locations.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
let locs = locations
|
||||
Task { @MainActor in
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
if let latest = locs.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
cont.resume(throwing: error)
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
||||
let err = error
|
||||
Task { @MainActor in
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
cont.resume(throwing: err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ final class NodeAppModel {
|
||||
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
|
||||
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
|
||||
let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
|
||||
let sessionKey = "main"
|
||||
let sessionKey = self.mainSessionKey
|
||||
|
||||
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
|
||||
actionName: name,
|
||||
@@ -204,12 +204,15 @@ final class NodeAppModel {
|
||||
|
||||
func connectToBridge(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
tls: BridgeTLSParams?,
|
||||
hello: BridgeHello)
|
||||
{
|
||||
self.bridgeTask?.cancel()
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
||||
let id = bridgeStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.connectedBridgeID = id.isEmpty ? BridgeEndpointID.stableID(endpoint) : id
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
|
||||
@@ -230,12 +233,16 @@ final class NodeAppModel {
|
||||
try await self.bridge.connect(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
onConnected: { [weak self] serverName in
|
||||
tls: tls,
|
||||
onConnected: { [weak self] serverName, mainSessionKey in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
self.bridgeStatusText = "Connected"
|
||||
self.bridgeServerName = serverName
|
||||
}
|
||||
await MainActor.run {
|
||||
self.applyMainSessionKey(mainSessionKey)
|
||||
}
|
||||
if let addr = await self.bridge.currentRemoteAddress() {
|
||||
await MainActor.run {
|
||||
self.bridgeRemoteAddress = addr
|
||||
@@ -284,7 +291,10 @@ final class NodeAppModel {
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionKey = "main"
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = "main"
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
}
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
}
|
||||
@@ -301,10 +311,23 @@ final class NodeAppModel {
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionKey = "main"
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = "main"
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
}
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
private func applyMainSessionKey(_ key: String?) {
|
||||
let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let current = self.mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if SessionKey.isCanonicalMainSessionKey(current) { return }
|
||||
if trimmed == current { return }
|
||||
self.mainSessionKey = trimmed
|
||||
self.talkMode.updateMainSessionKey(trimmed)
|
||||
}
|
||||
|
||||
var seamColor: Color {
|
||||
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
||||
}
|
||||
@@ -333,7 +356,10 @@ final class NodeAppModel {
|
||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
await MainActor.run {
|
||||
self.seamColorHex = raw.isEmpty ? nil : raw
|
||||
self.mainSessionKey = mainKey
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = mainKey
|
||||
self.talkMode.updateMainSessionKey(mainKey)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
@@ -137,9 +137,11 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
||||
{
|
||||
{ sample, type, error in
|
||||
let sampleBox = UncheckedSendableBox(value: sample)
|
||||
// ReplayKit can call the capture handler on a background queue.
|
||||
// Serialize writes to avoid queue asserts.
|
||||
recordQueue.async {
|
||||
let sample = sampleBox.value
|
||||
if let error {
|
||||
state.withLock { state in
|
||||
if state.handlerError == nil { state.handlerError = error }
|
||||
|
||||
@@ -5,4 +5,11 @@ enum SessionKey {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "main" : trimmed
|
||||
}
|
||||
|
||||
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
|
||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return false }
|
||||
if trimmed == "global" { return true }
|
||||
return trimmed.hasPrefix("agent:")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,9 +407,11 @@ struct SettingsTab: View {
|
||||
modelIdentifier: self.modelIdentifier(),
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands())
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge)
|
||||
let token = try await BridgeClient().pairAndHello(
|
||||
endpoint: bridge.endpoint,
|
||||
hello: hello,
|
||||
tls: tlsParams,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
statusStore.text = status
|
||||
@@ -425,6 +427,8 @@ struct SettingsTab: View {
|
||||
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: bridge.endpoint,
|
||||
bridgeStableID: bridge.stableID,
|
||||
tls: tlsParams,
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
@@ -461,6 +465,8 @@ struct SettingsTab: View {
|
||||
defer { self.connectingBridgeID = nil }
|
||||
|
||||
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
|
||||
|
||||
do {
|
||||
let statusStore = self.connectStatus
|
||||
@@ -484,6 +490,7 @@ struct SettingsTab: View {
|
||||
let token = try await BridgeClient().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tlsParams,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
statusStore.text = status
|
||||
@@ -499,6 +506,8 @@ struct SettingsTab: View {
|
||||
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: stableID,
|
||||
tls: tlsParams,
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
@@ -515,6 +524,47 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(
|
||||
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
|
||||
{
|
||||
let stableID = bridge.stableID
|
||||
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
if let stored {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
|
||||
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return BridgeTLSParams(
|
||||
required: false,
|
||||
expectedFingerprint: nil,
|
||||
allowTOFU: true,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
|
||||
@@ -53,6 +53,13 @@ final class TalkModeManager: NSObject {
|
||||
self.bridge = bridge
|
||||
}
|
||||
|
||||
func updateMainSessionKey(_ sessionKey: String?) {
|
||||
let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
if SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { return }
|
||||
self.mainSessionKey = trimmed
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
self.isEnabled = enabled
|
||||
if enabled {
|
||||
@@ -288,9 +295,8 @@ final class TalkModeManager: NSObject {
|
||||
self.chatSubscribedSessionKeys.insert(key)
|
||||
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.warning(
|
||||
"chat.subscribe failed sessionKey=\(key, privacy: .public) " +
|
||||
"err=\(error.localizedDescription, privacy: .public)")
|
||||
let err = error.localizedDescription
|
||||
self.logger.warning("chat.subscribe failed key=\(key, privacy: .public) err=\(err, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,9 +534,8 @@ final class TalkModeManager: NSObject {
|
||||
self.lastPlaybackWasPCM = false
|
||||
result = await self.mp3Player.play(stream: stream)
|
||||
}
|
||||
self.logger.info(
|
||||
"elevenlabs stream finished=\(result.finished, privacy: .public) " +
|
||||
"dur=\(Date().timeIntervalSince(started), privacy: .public)s")
|
||||
let duration = Date().timeIntervalSince(started)
|
||||
self.logger.info("elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s")
|
||||
if !result.finished, let interruptedAt = result.interruptedAt {
|
||||
self.lastInterruptedAtSeconds = interruptedAt
|
||||
}
|
||||
@@ -651,7 +656,10 @@ final class TalkModeManager: NSObject {
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let talk = config["talk"] as? [String: Any]
|
||||
let session = config["session"] as? [String: Any]
|
||||
self.mainSessionKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = mainKey
|
||||
}
|
||||
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let aliases = talk?["voiceAliases"] as? [String: Any] {
|
||||
var resolved: [String: String] = [:]
|
||||
|
||||
@@ -26,7 +26,8 @@ Sources/Voice/VoiceTab.swift
|
||||
Sources/Voice/VoiceWakeManager.swift
|
||||
Sources/Voice/VoiceWakePreferences.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
||||
|
||||
@@ -27,6 +27,7 @@ private actor MockBridgePairingClient: BridgePairingClient {
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||
{
|
||||
self.lastToken = hello.token
|
||||
@@ -175,7 +176,6 @@ private func withKeychainValues<T>(
|
||||
}
|
||||
|
||||
@Test @MainActor func makeHelloBuildsCapsAndCommands() {
|
||||
let defaults = UserDefaults.standard
|
||||
let voiceWakeKey = VoiceWakePreferences.enabledKey
|
||||
|
||||
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||
@@ -245,6 +245,8 @@ private func withKeychainValues<T>(
|
||||
gatewayPort: 18789,
|
||||
bridgePort: 18790,
|
||||
canvasPort: 18793,
|
||||
tlsEnabled: false,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
let mock = MockBridgePairingClient(resultToken: "new-token")
|
||||
let account = "bridge-token.ios-test"
|
||||
@@ -293,6 +295,8 @@ private func withKeychainValues<T>(
|
||||
gatewayPort: 18789,
|
||||
bridgePort: 18790,
|
||||
canvasPort: 18793,
|
||||
tlsEnabled: false,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
|
||||
name: "Gateway B",
|
||||
@@ -304,6 +308,8 @@ private func withKeychainValues<T>(
|
||||
gatewayPort: 28789,
|
||||
bridgePort: 28790,
|
||||
canvasPort: 28793,
|
||||
tlsEnabled: false,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
|
||||
let mock = MockBridgePairingClient(resultToken: "token-ok")
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.9</string>
|
||||
<string>2026.1.11-4</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260109</string>
|
||||
<string>202601113</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
+25
-1
@@ -2,9 +2,13 @@ name: Clawdbot
|
||||
options:
|
||||
bundleIdPrefix: com.clawdbot
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
iOS: "18.0"
|
||||
xcodeVersion: "16.0"
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "6.0"
|
||||
|
||||
packages:
|
||||
ClawdbotKit:
|
||||
path: ../shared/ClawdbotKit
|
||||
@@ -68,11 +72,15 @@ targets:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios
|
||||
PROVISIONING_PROFILE_SPECIFIER: "com.clawdbot.ios Development"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
info:
|
||||
path: Sources/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Clawdbot
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.1.9"
|
||||
CFBundleVersion: "20260109"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -84,8 +92,20 @@ targets:
|
||||
NSBonjourServices:
|
||||
- _clawdbot-bridge._tcp
|
||||
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the bridge.
|
||||
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
|
||||
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
|
||||
NSMicrophoneUsageDescription: Clawdbot needs microphone access for voice wake.
|
||||
NSSpeechRecognitionUsageDescription: Clawdbot uses on-device speech recognition for voice wake.
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
UISupportedInterfaceOrientations~ipad:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
|
||||
ClawdbotTests:
|
||||
type: bundle.unit-test
|
||||
@@ -96,13 +116,17 @@ targets:
|
||||
- target: Clawdbot
|
||||
- package: Swabble
|
||||
product: SwabbleKit
|
||||
- sdk: AppIntents.framework
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios.tests
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Clawdbot.app/Clawdbot"
|
||||
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||
info:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: ClawdbotTests
|
||||
CFBundleShortVersionString: "2026.1.9"
|
||||
CFBundleVersion: "20260109"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "9de32b5fc115432dadd84c3ab4d67d2fed22ffaf5675a77033d69ea194ac3862",
|
||||
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
@@ -73,6 +73,15 @@
|
||||
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||
"state" : {
|
||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-configuration",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -144,6 +153,24 @@
|
||||
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
|
||||
"version" : "1.6.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-math",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swiftui-math",
|
||||
"state" : {
|
||||
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "textual",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/textual",
|
||||
"state" : {
|
||||
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -10,7 +10,10 @@ let package = Package(
|
||||
],
|
||||
products: [
|
||||
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
|
||||
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
|
||||
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
|
||||
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
|
||||
.executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
||||
@@ -36,10 +39,20 @@ let package = Package(
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.target(
|
||||
name: "ClawdbotDiscovery",
|
||||
dependencies: [
|
||||
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||
],
|
||||
path: "Sources/ClawdbotDiscovery",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "Clawdbot",
|
||||
dependencies: [
|
||||
"ClawdbotIPC",
|
||||
"ClawdbotDiscovery",
|
||||
"ClawdbotProtocol",
|
||||
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||
.product(name: "ClawdbotChatUI", package: "ClawdbotKit"),
|
||||
@@ -61,11 +74,30 @@ let package = Package(
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "ClawdbotDiscoveryCLI",
|
||||
dependencies: [
|
||||
"ClawdbotDiscovery",
|
||||
],
|
||||
path: "Sources/ClawdbotDiscoveryCLI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "ClawdbotWizardCLI",
|
||||
dependencies: [
|
||||
"ClawdbotProtocol",
|
||||
],
|
||||
path: "Sources/ClawdbotWizardCLI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "ClawdbotIPCTests",
|
||||
dependencies: [
|
||||
"ClawdbotIPC",
|
||||
"Clawdbot",
|
||||
"ClawdbotDiscovery",
|
||||
"ClawdbotProtocol",
|
||||
.product(name: "SwabbleKit", package: "swabble"),
|
||||
],
|
||||
|
||||
@@ -170,6 +170,10 @@ final class AppState {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||
}
|
||||
|
||||
var systemRunPolicy: SystemRunPolicy {
|
||||
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
|
||||
}
|
||||
|
||||
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
||||
var canvasPanelVisible: Bool = false
|
||||
|
||||
@@ -182,14 +186,6 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
var attachExistingGatewayOnly: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.attachExistingGatewayOnly, forKey: attachExistingGatewayOnlyKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var remoteTarget: String {
|
||||
didSet {
|
||||
self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
|
||||
@@ -212,7 +208,7 @@ final class AppState {
|
||||
private var earBoostTask: Task<Void, Never>?
|
||||
|
||||
init(preview: Bool = false) {
|
||||
self.isPreview = preview
|
||||
self.isPreview = preview || ProcessInfo.processInfo.isRunningTests
|
||||
let onboardingSeen = UserDefaults.standard.bool(forKey: "clawdbot.onboardingSeen")
|
||||
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
self.launchAtLogin = false
|
||||
@@ -300,10 +296,9 @@ final class AppState {
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
self.systemRunPolicy = SystemRunPolicy.load()
|
||||
self.peekabooBridgeEnabled = UserDefaults.standard
|
||||
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
||||
self.attachExistingGatewayOnly = UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
||||
|
||||
if !self.isPreview {
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
let current = await LaunchAgentManager.status()
|
||||
@@ -346,6 +341,15 @@ final class AppState {
|
||||
return host
|
||||
}
|
||||
|
||||
private static func sanitizeSSHTarget(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.hasPrefix("ssh ") {
|
||||
return trimmed.replacingOccurrences(of: "ssh ", with: "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private func startConfigWatcher() {
|
||||
let configUrl = ClawdbotConfigFile.url()
|
||||
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
|
||||
@@ -411,6 +415,7 @@ final class AppState {
|
||||
|
||||
let connectionMode = self.connectionMode
|
||||
let remoteTarget = self.remoteTarget
|
||||
let remoteIdentity = self.remoteIdentity
|
||||
let desiredMode: String? = switch connectionMode {
|
||||
case .local:
|
||||
"local"
|
||||
@@ -440,15 +445,46 @@ final class AppState {
|
||||
changed = true
|
||||
}
|
||||
|
||||
if connectionMode == .remote, let host = remoteHost {
|
||||
if connectionMode == .remote {
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
if existingUrl != desiredUrl {
|
||||
remote["url"] = desiredUrl
|
||||
var remoteChanged = false
|
||||
|
||||
if let host = remoteHost {
|
||||
let existingUrl = (remote["url"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
if existingUrl != desiredUrl {
|
||||
remote["url"] = desiredUrl
|
||||
remoteChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
||||
if !sanitizedTarget.isEmpty {
|
||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||
remote["sshTarget"] = sanitizedTarget
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshTarget"] != nil {
|
||||
remote.removeValue(forKey: "sshTarget")
|
||||
remoteChanged = true
|
||||
}
|
||||
|
||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedIdentity.isEmpty {
|
||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||
remote["sshIdentity"] = trimmedIdentity
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshIdentity"] != nil {
|
||||
remote.removeValue(forKey: "sshIdentity")
|
||||
remoteChanged = true
|
||||
}
|
||||
|
||||
if remoteChanged {
|
||||
gateway["remote"] = remote
|
||||
changed = true
|
||||
}
|
||||
@@ -604,7 +640,6 @@ extension AppState {
|
||||
state.remoteIdentity = "~/.ssh/id_ed25519"
|
||||
state.remoteProjectRoot = "~/Projects/clawdbot"
|
||||
state.remoteCliPath = ""
|
||||
state.attachExistingGatewayOnly = false
|
||||
return state
|
||||
}
|
||||
}
|
||||
@@ -623,10 +658,6 @@ enum AppStateStore {
|
||||
static var canvasEnabled: Bool {
|
||||
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
static var attachExistingGatewayOnly: Bool {
|
||||
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -282,7 +282,12 @@ actor BridgeConnectionHandler {
|
||||
do {
|
||||
try await self.send(BridgePairOk(type: "pair-ok", token: token))
|
||||
self.isAuthenticated = true
|
||||
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
|
||||
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
|
||||
try await self.send(
|
||||
BridgeHelloOk(
|
||||
type: "hello-ok",
|
||||
serverName: serverName,
|
||||
mainSessionKey: mainSessionKey))
|
||||
} catch {
|
||||
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
@@ -298,7 +303,12 @@ actor BridgeConnectionHandler {
|
||||
case .ok:
|
||||
self.isAuthenticated = true
|
||||
do {
|
||||
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
|
||||
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
|
||||
try await self.send(
|
||||
BridgeHelloOk(
|
||||
type: "hello-ok",
|
||||
serverName: serverName,
|
||||
mainSessionKey: mainSessionKey))
|
||||
} catch {
|
||||
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ actor BridgeServer {
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
provider: .last))
|
||||
channel: .last))
|
||||
|
||||
case "agent.request":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||
@@ -205,7 +205,7 @@ actor BridgeServer {
|
||||
?? "node-\(nodeId)"
|
||||
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let provider = GatewayAgentProvider(raw: link.channel)
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
|
||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: message,
|
||||
@@ -213,7 +213,7 @@ actor BridgeServer {
|
||||
thinking: thinking,
|
||||
deliver: link.deliver,
|
||||
to: to,
|
||||
provider: provider))
|
||||
channel: channel))
|
||||
|
||||
default:
|
||||
break
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class CLIInstallPrompter {
|
||||
static let shared = CLIInstallPrompter()
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "cli.prompt")
|
||||
private var isPrompting = false
|
||||
|
||||
func checkAndPromptIfNeeded(reason: String) {
|
||||
guard self.shouldPrompt() else { return }
|
||||
guard let version = Self.appVersion() else { return }
|
||||
self.isPrompting = true
|
||||
UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Install Clawdbot CLI?"
|
||||
alert.informativeText = "Local mode needs the CLI so launchd can run the gateway."
|
||||
alert.addButton(withTitle: "Install CLI")
|
||||
alert.addButton(withTitle: "Not now")
|
||||
alert.addButton(withTitle: "Open Settings")
|
||||
let response = alert.runModal()
|
||||
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
Task { await self.installCLI() }
|
||||
case .alertThirdButtonReturn:
|
||||
self.openSettings(tab: .general)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)")
|
||||
self.isPrompting = false
|
||||
}
|
||||
|
||||
private func shouldPrompt() -> Bool {
|
||||
guard !self.isPrompting else { return false }
|
||||
guard AppStateStore.shared.onboardingSeen else { return false }
|
||||
guard AppStateStore.shared.connectionMode == .local else { return false }
|
||||
guard CLIInstaller.installedLocation() == nil else { return false }
|
||||
guard let version = Self.appVersion() else { return false }
|
||||
let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey)
|
||||
return lastPrompt != version
|
||||
}
|
||||
|
||||
private func installCLI() async {
|
||||
let status = StatusBox()
|
||||
await CLIInstaller.install { message in
|
||||
await status.set(message)
|
||||
}
|
||||
if let message = await status.get() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "CLI install finished"
|
||||
alert.informativeText = message
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
|
||||
private func openSettings(tab: SettingsTab) {
|
||||
SettingsTabRouter.request(tab)
|
||||
SettingsWindowOpener.shared.open()
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||
}
|
||||
}
|
||||
|
||||
private static func appVersion() -> String? {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
}
|
||||
}
|
||||
|
||||
private actor StatusBox {
|
||||
private var value: String?
|
||||
|
||||
func set(_ value: String) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
func get() -> String? {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,16 @@ import Foundation
|
||||
|
||||
@MainActor
|
||||
enum CLIInstaller {
|
||||
private static func embeddedHelperURL() -> URL {
|
||||
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdbot")
|
||||
}
|
||||
|
||||
static func installedLocation() -> String? {
|
||||
self.installedLocation(
|
||||
searchPaths: cliHelperSearchPaths,
|
||||
embeddedHelper: self.embeddedHelperURL(),
|
||||
searchPaths: CommandResolver.preferredPaths(),
|
||||
fileManager: .default)
|
||||
}
|
||||
|
||||
static func installedLocation(
|
||||
searchPaths: [String],
|
||||
embeddedHelper: URL,
|
||||
fileManager: FileManager) -> String?
|
||||
{
|
||||
let embedded = embeddedHelper.resolvingSymlinksInPath()
|
||||
|
||||
for basePath in searchPaths {
|
||||
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdbot").path
|
||||
var isDirectory: ObjCBool = false
|
||||
@@ -32,10 +24,7 @@ enum CLIInstaller {
|
||||
|
||||
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
|
||||
|
||||
let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
|
||||
if resolved == embedded {
|
||||
return candidate
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -45,58 +34,70 @@ enum CLIInstaller {
|
||||
self.installedLocation() != nil
|
||||
}
|
||||
|
||||
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
||||
let helper = self.embeddedHelperURL()
|
||||
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
|
||||
await statusHandler(
|
||||
"Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " +
|
||||
"(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).")
|
||||
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
|
||||
let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||
let prefix = Self.installPrefix()
|
||||
await statusHandler("Installing clawdbot CLI…")
|
||||
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
|
||||
let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: nil, timeout: 900)
|
||||
|
||||
if response.success {
|
||||
let parsed = self.parseInstallEvents(response.stdout)
|
||||
let installedVersion = parsed.last { $0.event == "done" }?.version
|
||||
let summary = installedVersion.map { "Installed clawdbot \($0)." } ?? "Installed clawdbot."
|
||||
await statusHandler(summary)
|
||||
return
|
||||
}
|
||||
|
||||
let targets = cliHelperSearchPaths.map { "\($0)/clawdbot" }
|
||||
let result = await self.privilegedSymlink(source: helper.path, targets: targets)
|
||||
await statusHandler(result)
|
||||
}
|
||||
|
||||
private static func privilegedSymlink(source: String, targets: [String]) async -> String {
|
||||
let escapedSource = self.shellEscape(source)
|
||||
let targetList = targets.map(self.shellEscape).joined(separator: " ")
|
||||
let cmds = [
|
||||
"mkdir -p /usr/local/bin /opt/homebrew/bin",
|
||||
targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
|
||||
].joined(separator: "; ")
|
||||
|
||||
let script = """
|
||||
do shell script "\(cmds)" with administrator privileges
|
||||
"""
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||
proc.arguments = ["-e", script]
|
||||
|
||||
let pipe = Pipe()
|
||||
proc.standardOutput = pipe
|
||||
proc.standardError = pipe
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if proc.terminationStatus == 0 {
|
||||
return output.isEmpty ? "CLI helper linked into \(targetList)" : output
|
||||
}
|
||||
if output.lowercased().contains("user canceled") {
|
||||
return "Install canceled"
|
||||
}
|
||||
return "Failed to install CLI helper: \(output)"
|
||||
} catch {
|
||||
return "Failed to run installer: \(error.localizedDescription)"
|
||||
let parsed = self.parseInstallEvents(response.stdout)
|
||||
if let error = parsed.last(where: { $0.event == "error" })?.message {
|
||||
await statusHandler("Install failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
let detail = response.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fallback = response.errorMessage ?? "install failed"
|
||||
await statusHandler("Install failed: \(detail.isEmpty ? fallback : detail)")
|
||||
}
|
||||
|
||||
private static func shellEscape(_ path: String) -> String {
|
||||
"'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
||||
private static func installPrefix() -> String {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot")
|
||||
.path
|
||||
}
|
||||
|
||||
private static func installScriptCommand(version: String, prefix: String) -> [String] {
|
||||
let escapedVersion = self.shellEscape(version)
|
||||
let escapedPrefix = self.shellEscape(prefix)
|
||||
let script = """
|
||||
curl -fsSL https://clawd.bot/install-cli.sh | \
|
||||
bash -s -- --json --no-onboard --prefix \(escapedPrefix) --version \(escapedVersion)
|
||||
"""
|
||||
return ["/bin/bash", "-lc", script]
|
||||
}
|
||||
|
||||
private static func parseInstallEvents(_ output: String) -> [InstallEvent] {
|
||||
let decoder = JSONDecoder()
|
||||
let lines = output
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { String($0) }
|
||||
var events: [InstallEvent] = []
|
||||
for line in lines {
|
||||
guard let data = line.data(using: .utf8) else { continue }
|
||||
if let event = try? decoder.decode(InstallEvent.self, from: data) {
|
||||
events.append(event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
private static func shellEscape(_ raw: String) -> String {
|
||||
"'" + raw.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
||||
}
|
||||
}
|
||||
|
||||
private struct InstallEvent: Decodable {
|
||||
let event: String
|
||||
let version: String?
|
||||
let message: String?
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
provider: .last,
|
||||
channel: .last,
|
||||
idempotencyKey: actionId))
|
||||
|
||||
await MainActor.run {
|
||||
|
||||
@@ -84,12 +84,30 @@ enum CommandResolver {
|
||||
"/bin",
|
||||
]
|
||||
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||
extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1)
|
||||
let clawdbotPaths = self.clawdbotManagedPaths(home: home)
|
||||
if !clawdbotPaths.isEmpty {
|
||||
extras.insert(contentsOf: clawdbotPaths, at: 1)
|
||||
}
|
||||
extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1 + clawdbotPaths.count)
|
||||
var seen = Set<String>()
|
||||
// Preserve order while stripping duplicates so PATH lookups remain deterministic.
|
||||
return (extras + current).filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
private static func clawdbotManagedPaths(home: URL) -> [String] {
|
||||
let base = home.appendingPathComponent(".clawdbot")
|
||||
let bin = base.appendingPathComponent("bin")
|
||||
let nodeBin = base.appendingPathComponent("tools/node/bin")
|
||||
var paths: [String] = []
|
||||
if FileManager.default.fileExists(atPath: bin.path) {
|
||||
paths.append(bin.path)
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: nodeBin.path) {
|
||||
paths.append(nodeBin.path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
private static func nodeManagerBinPaths(home: URL) -> [String] {
|
||||
var bins: [String] = []
|
||||
|
||||
@@ -387,10 +405,6 @@ enum CommandResolver {
|
||||
cliPath: cliPath)
|
||||
}
|
||||
|
||||
static var attachExistingGatewayOnly: Bool {
|
||||
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
||||
}
|
||||
|
||||
static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool {
|
||||
self.connectionSettings(defaults: defaults).mode == .remote
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ struct ConfigSettings: View {
|
||||
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
|
||||
+ "so it won’t interfere with your daily browser."
|
||||
@State private var configModel: String = ""
|
||||
@State private var customModel: String = ""
|
||||
@State private var configSaving = false
|
||||
@State private var hasLoaded = false
|
||||
@State private var models: [ModelChoice] = []
|
||||
@State private var modelsLoading = false
|
||||
@State private var modelSearchQuery: String = ""
|
||||
@State private var isModelPickerOpen = false
|
||||
@State private var modelError: String?
|
||||
@State private var modelsSourceLabel: String?
|
||||
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
||||
@@ -36,10 +37,10 @@ struct ConfigSettings: View {
|
||||
@State private var talkInterruptOnSpeech: Bool = true
|
||||
@State private var talkApiKey: String = ""
|
||||
@State private var gatewayApiKeyFound = false
|
||||
@FocusState private var modelSearchFocused: Bool
|
||||
|
||||
private struct ConfigDraft {
|
||||
let configModel: String
|
||||
let customModel: String
|
||||
let heartbeatMinutes: Int?
|
||||
let heartbeatBody: String
|
||||
let browserEnabled: Bool
|
||||
@@ -69,7 +70,9 @@ struct ConfigSettings: View {
|
||||
self.allowAutosave = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
@@ -106,8 +109,7 @@ struct ConfigSettings: View {
|
||||
GridRow {
|
||||
self.gridLabel("Model")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
self.modelPicker
|
||||
self.customModelField
|
||||
self.modelPickerField
|
||||
self.modelMetaLabels
|
||||
}
|
||||
}
|
||||
@@ -116,37 +118,114 @@ struct ConfigSettings: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var modelPicker: some View {
|
||||
Picker("Model", selection: self.$configModel) {
|
||||
ForEach(self.models) { choice in
|
||||
Text("\(choice.name) — \(choice.provider.uppercased())")
|
||||
.tag(choice.id)
|
||||
private var modelPickerField: some View {
|
||||
Button {
|
||||
guard !self.modelsLoading else { return }
|
||||
self.isModelPickerOpen = true
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Text(self.modelPickerLabel)
|
||||
.foregroundStyle(self.modelPickerLabelIsPlaceholder ? .secondary : .primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Manual entry…").tag("__custom__")
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(
|
||||
Color(nsColor: .textBackgroundColor)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(
|
||||
Color.secondary.opacity(0.25),
|
||||
lineWidth: 1))
|
||||
.popover(isPresented: self.$isModelPickerOpen, arrowEdge: .bottom) {
|
||||
self.modelPickerPopover
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
||||
.onChange(of: self.configModel) { _, _ in
|
||||
self.autosaveConfig()
|
||||
.onChange(of: self.isModelPickerOpen) { _, isOpen in
|
||||
if isOpen {
|
||||
self.modelSearchQuery = ""
|
||||
self.modelSearchFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var customModelField: some View {
|
||||
if self.configModel == "__custom__" {
|
||||
TextField("Enter model ID", text: self.$customModel)
|
||||
private var modelPickerPopover: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
TextField("Search models", text: self.$modelSearchQuery)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.customModel) { _, newValue in
|
||||
self.configModel = newValue
|
||||
self.autosaveConfig()
|
||||
.focused(self.$modelSearchFocused)
|
||||
.controlSize(.small)
|
||||
.onSubmit {
|
||||
if let exact = self.exactMatchForQuery() {
|
||||
self.selectModel(exact)
|
||||
return
|
||||
}
|
||||
if let manual = self.manualEntryCandidate {
|
||||
self.selectManualModel(manual)
|
||||
return
|
||||
}
|
||||
if self.modelSearchMatches.count == 1 {
|
||||
self.selectModel(self.modelSearchMatches[0])
|
||||
}
|
||||
}
|
||||
List {
|
||||
if self.modelSearchMatches.isEmpty {
|
||||
Text("No models match \"\(self.modelSearchQuery)\"")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.modelSearchMatches) { choice in
|
||||
Button {
|
||||
self.selectModel(choice)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Text(choice.name)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(choice.provider.uppercased())
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 6)
|
||||
.background(Color.secondary.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
}
|
||||
}
|
||||
|
||||
if let manual = self.manualEntryCandidate {
|
||||
Button("Use \"\(manual)\"") {
|
||||
self.selectManualModel(manual)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
.frame(width: 340, height: 260)
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var modelMetaLabels: some View {
|
||||
if self.shouldShowProviderHintForSelection {
|
||||
self.statusLine(label: "Tip: prefer provider/model (e.g. openai-codex/gpt-5.2)", color: .orange)
|
||||
}
|
||||
|
||||
if let contextLabel = self.selectedContextLabel {
|
||||
Text(contextLabel)
|
||||
.font(.footnote)
|
||||
@@ -384,7 +463,9 @@ struct ConfigSettings: View {
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private func loadConfig() async {
|
||||
let parsed = await ConfigStore.load()
|
||||
let agents = parsed["agents"] as? [String: Any]
|
||||
@@ -403,10 +484,8 @@ struct ConfigSettings: View {
|
||||
}()
|
||||
if !loadedModel.isEmpty {
|
||||
self.configModel = loadedModel
|
||||
self.customModel = loadedModel
|
||||
} else {
|
||||
self.configModel = SessionLoader.fallbackModel
|
||||
self.customModel = SessionLoader.fallbackModel
|
||||
}
|
||||
|
||||
if let heartbeatEvery {
|
||||
@@ -459,7 +538,6 @@ struct ConfigSettings: View {
|
||||
defer { self.configSaving = false }
|
||||
|
||||
let configModel = self.configModel
|
||||
let customModel = self.customModel
|
||||
let heartbeatMinutes = self.heartbeatMinutes
|
||||
let heartbeatBody = self.heartbeatBody
|
||||
let browserEnabled = self.browserEnabled
|
||||
@@ -472,7 +550,6 @@ struct ConfigSettings: View {
|
||||
|
||||
let draft = ConfigDraft(
|
||||
configModel: configModel,
|
||||
customModel: customModel,
|
||||
heartbeatMinutes: heartbeatMinutes,
|
||||
heartbeatBody: heartbeatBody,
|
||||
browserEnabled: browserEnabled,
|
||||
@@ -498,8 +575,7 @@ struct ConfigSettings: View {
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
var talk = root["talk"] as? [String: Any] ?? [:]
|
||||
|
||||
let chosenModel = (draft.configModel == "__custom__" ? draft.customModel : draft.configModel)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let chosenModel = draft.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedModel = chosenModel
|
||||
if !trimmedModel.isEmpty {
|
||||
var model = defaults["model"] as? [String: Any] ?? [:]
|
||||
@@ -567,7 +643,9 @@ struct ConfigSettings: View {
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private var browserColor: Color {
|
||||
let raw = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hex = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw
|
||||
@@ -664,7 +742,9 @@ struct ConfigSettings: View {
|
||||
if host == "::1" { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private func loadModels() async {
|
||||
guard !self.modelsLoading else { return }
|
||||
self.modelsLoading = true
|
||||
@@ -678,23 +758,11 @@ struct ConfigSettings: View {
|
||||
timeoutMs: 15000)
|
||||
self.models = res.models
|
||||
self.modelsSourceLabel = "gateway"
|
||||
if !self.configModel.isEmpty,
|
||||
!res.models.contains(where: { $0.id == self.configModel })
|
||||
{
|
||||
self.customModel = self.configModel
|
||||
self.configModel = "__custom__"
|
||||
}
|
||||
} catch {
|
||||
do {
|
||||
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
|
||||
self.models = loaded
|
||||
self.modelsSourceLabel = "local fallback"
|
||||
if !self.configModel.isEmpty,
|
||||
!loaded.contains(where: { $0.id == self.configModel })
|
||||
{
|
||||
self.customModel = self.configModel
|
||||
self.configModel = "__custom__"
|
||||
}
|
||||
} catch {
|
||||
self.modelError = error.localizedDescription
|
||||
self.models = []
|
||||
@@ -707,11 +775,129 @@ struct ConfigSettings: View {
|
||||
let models: [ModelChoice]
|
||||
}
|
||||
|
||||
private var modelSearchMatches: [ModelChoice] {
|
||||
let raw = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !raw.isEmpty else { return self.models }
|
||||
let tokens = raw
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { token in
|
||||
token.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||
}
|
||||
.filter { !$0.isEmpty }
|
||||
guard !tokens.isEmpty else { return self.models }
|
||||
return self.models.filter { choice in
|
||||
let haystack = [
|
||||
choice.id,
|
||||
choice.name,
|
||||
choice.provider,
|
||||
self.modelRef(for: choice),
|
||||
]
|
||||
.joined(separator: " ")
|
||||
.lowercased()
|
||||
return tokens.allSatisfy { haystack.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedModelChoice: ModelChoice? {
|
||||
guard !self.configModel.isEmpty else { return nil }
|
||||
return self.models.first(where: { self.matchesConfigModel($0) })
|
||||
}
|
||||
|
||||
private var modelPickerLabel: String {
|
||||
if let choice = self.selectedModelChoice {
|
||||
return "\(choice.name) — \(choice.provider.uppercased())"
|
||||
}
|
||||
if !self.configModel.isEmpty { return self.configModel }
|
||||
return "Select model"
|
||||
}
|
||||
|
||||
private var modelPickerLabelIsPlaceholder: Bool {
|
||||
self.configModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private var manualEntryCandidate: String? {
|
||||
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
guard !self.isKnownModelRef(cleaned) else { return nil }
|
||||
return cleaned
|
||||
}
|
||||
|
||||
private func isKnownModelRef(_ value: String) -> Bool {
|
||||
let needle = value.lowercased()
|
||||
return self.models.contains { choice in
|
||||
choice.id.lowercased() == needle
|
||||
|| self.modelRef(for: choice).lowercased() == needle
|
||||
}
|
||||
}
|
||||
|
||||
private func modelRef(for choice: ModelChoice) -> String {
|
||||
let id = choice.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let provider = choice.provider.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !provider.isEmpty else { return id }
|
||||
let normalizedProvider = provider.lowercased()
|
||||
if id.lowercased().hasPrefix("\(normalizedProvider)/") {
|
||||
return id
|
||||
}
|
||||
return "\(normalizedProvider)/\(id)"
|
||||
}
|
||||
|
||||
private func matchesConfigModel(_ choice: ModelChoice) -> Bool {
|
||||
let configured = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !configured.isEmpty else { return false }
|
||||
if configured.caseInsensitiveCompare(choice.id) == .orderedSame { return true }
|
||||
let ref = self.modelRef(for: choice)
|
||||
return configured.caseInsensitiveCompare(ref) == .orderedSame
|
||||
}
|
||||
|
||||
private func exactMatchForQuery() -> ModelChoice? {
|
||||
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%")).lowercased()
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
return self.models.first(where: { choice in
|
||||
let id = choice.id.lowercased()
|
||||
if id == cleaned { return true }
|
||||
return self.modelRef(for: choice).lowercased() == cleaned
|
||||
})
|
||||
}
|
||||
|
||||
private var shouldShowProviderHint: Bool {
|
||||
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||
return !cleaned.contains("/")
|
||||
}
|
||||
|
||||
private var shouldShowProviderHintForSelection: Bool {
|
||||
let trimmed = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
return !trimmed.contains("/")
|
||||
}
|
||||
|
||||
private func selectModel(_ choice: ModelChoice) {
|
||||
self.configModel = self.modelRef(for: choice)
|
||||
self.autosaveConfig()
|
||||
self.isModelPickerOpen = false
|
||||
}
|
||||
|
||||
private func selectManualModel(_ value: String) {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let slash = trimmed.firstIndex(of: "/") {
|
||||
let provider = trimmed[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let model = trimmed[trimmed.index(after: slash)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.configModel = provider.isEmpty ? String(model) : "\(provider)/\(model)"
|
||||
} else {
|
||||
self.configModel = trimmed
|
||||
}
|
||||
self.autosaveConfig()
|
||||
self.isModelPickerOpen = false
|
||||
}
|
||||
|
||||
private var selectedContextLabel: String? {
|
||||
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
|
||||
guard
|
||||
!chosenId.isEmpty,
|
||||
let choice = self.models.first(where: { $0.id == chosenId }),
|
||||
let choice = self.selectedModelChoice,
|
||||
let context = choice.contextWindow
|
||||
else {
|
||||
return nil
|
||||
@@ -722,8 +908,7 @@ struct ConfigSettings: View {
|
||||
}
|
||||
|
||||
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
|
||||
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
|
||||
guard !chosenId.isEmpty, let choice = self.models.first(where: { $0.id == chosenId }) else { return nil }
|
||||
guard let choice = self.selectedModelChoice else { return nil }
|
||||
guard choice.provider.lowercased() == "anthropic" else { return nil }
|
||||
return AnthropicAuthResolver.resolve()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ enum ConfigStore {
|
||||
}
|
||||
|
||||
private static let overrideStore = OverrideStore()
|
||||
@MainActor private static var lastHash: String?
|
||||
|
||||
private static func isRemoteMode() async -> Bool {
|
||||
let overrides = await self.overrideStore.overrides
|
||||
@@ -75,6 +76,7 @@ enum ConfigStore {
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 8000)
|
||||
self.lastHash = snap.hash
|
||||
return snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
} catch {
|
||||
return nil
|
||||
@@ -83,17 +85,24 @@ enum ConfigStore {
|
||||
|
||||
@MainActor
|
||||
private static func saveToGateway(_ root: [String: Any]) async throws {
|
||||
if self.lastHash == nil {
|
||||
_ = await self.loadFromGateway()
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode config.",
|
||||
])
|
||||
}
|
||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
var params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
if let baseHash = self.lastHash {
|
||||
params["baseHash"] = AnyCodable(baseHash)
|
||||
}
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configSet,
|
||||
params: params,
|
||||
timeoutMs: 10000)
|
||||
_ = await self.loadFromGateway()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -27,8 +27,7 @@ final class ConnectionModeCoordinator {
|
||||
GatewayProcessManager.shared.setActive(true)
|
||||
if GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||
mode: .local,
|
||||
paused: paused,
|
||||
attachExistingOnly: AppStateStore.attachExistingGatewayOnly)
|
||||
paused: paused)
|
||||
{
|
||||
Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() }
|
||||
}
|
||||
|
||||
+3
-3
@@ -11,9 +11,9 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func providerHeaderActions(_ provider: ConnectionProvider) -> some View {
|
||||
func channelHeaderActions(_ channel: ConnectionChannel) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
if provider == .whatsapp {
|
||||
if channel == .whatsapp {
|
||||
Button("Logout") {
|
||||
Task { await self.store.logoutWhatsApp() }
|
||||
}
|
||||
@@ -21,7 +21,7 @@ extension ConnectionsSettings {
|
||||
.disabled(self.store.whatsappBusy)
|
||||
}
|
||||
|
||||
if provider == .telegram {
|
||||
if channel == .telegram {
|
||||
Button("Logout") {
|
||||
Task { await self.store.logoutTelegram() }
|
||||
}
|
||||
+100
-60
@@ -1,8 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
extension ConnectionsSettings {
|
||||
private func channelStatus<T: Decodable>(
|
||||
_ id: String,
|
||||
as type: T.Type) -> T?
|
||||
{
|
||||
self.store.snapshot?.decodeChannel(id, as: type)
|
||||
}
|
||||
|
||||
var whatsAppTint: Color {
|
||||
guard let status = self.store.snapshot?.whatsapp else { return .secondary }
|
||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if !status.linked { return .red }
|
||||
if status.lastError != nil { return .orange }
|
||||
@@ -12,7 +20,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var telegramTint: Color {
|
||||
guard let status = self.store.snapshot?.telegram else { return .secondary }
|
||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
@@ -21,7 +30,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var discordTint: Color {
|
||||
guard let status = self.store.snapshot?.discord else { return .secondary }
|
||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
@@ -30,7 +40,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var signalTint: Color {
|
||||
guard let status = self.store.snapshot?.signal else { return .secondary }
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
@@ -39,7 +50,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var imessageTint: Color {
|
||||
guard let status = self.store.snapshot?.imessage else { return .secondary }
|
||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
@@ -48,7 +60,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var whatsAppSummary: String {
|
||||
guard let status = self.store.snapshot?.whatsapp else { return "Checking…" }
|
||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.linked { return "Not linked" }
|
||||
if status.connected { return "Connected" }
|
||||
if status.running { return "Running" }
|
||||
@@ -56,35 +69,40 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var telegramSummary: String {
|
||||
guard let status = self.store.snapshot?.telegram else { return "Checking…" }
|
||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var discordSummary: String {
|
||||
guard let status = self.store.snapshot?.discord else { return "Checking…" }
|
||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var signalSummary: String {
|
||||
guard let status = self.store.snapshot?.signal else { return "Checking…" }
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var imessageSummary: String {
|
||||
guard let status = self.store.snapshot?.imessage else { return "Checking…" }
|
||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var whatsAppDetails: String? {
|
||||
guard let status = self.store.snapshot?.whatsapp else { return nil }
|
||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
if let e164 = status.`self`?.e164 ?? status.`self`?.jid {
|
||||
lines.append("Linked as \(e164)")
|
||||
@@ -114,7 +132,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var telegramDetails: String? {
|
||||
guard let status = self.store.snapshot?.telegram else { return nil }
|
||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
if let source = status.tokenSource {
|
||||
lines.append("Token source: \(source)")
|
||||
@@ -145,7 +164,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var discordDetails: String? {
|
||||
guard let status = self.store.snapshot?.discord else { return nil }
|
||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
if let source = status.tokenSource {
|
||||
lines.append("Token source: \(source)")
|
||||
@@ -173,7 +193,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var signalDetails: String? {
|
||||
guard let status = self.store.snapshot?.signal else { return nil }
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
lines.append("Base URL: \(status.baseUrl)")
|
||||
if let probe = status.probe {
|
||||
@@ -199,7 +220,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var imessageDetails: String? {
|
||||
guard let status = self.store.snapshot?.imessage else { return nil }
|
||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
if let cliPath = status.cliPath, !cliPath.isEmpty {
|
||||
lines.append("CLI: \(cliPath)")
|
||||
@@ -221,63 +243,68 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var isTelegramTokenLocked: Bool {
|
||||
self.store.snapshot?.telegram.tokenSource == "env"
|
||||
self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
|
||||
}
|
||||
|
||||
var isDiscordTokenLocked: Bool {
|
||||
self.store.snapshot?.discord?.tokenSource == "env"
|
||||
self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
|
||||
}
|
||||
|
||||
var orderedProviders: [ConnectionProvider] {
|
||||
ConnectionProvider.allCases.sorted { lhs, rhs in
|
||||
let lhsEnabled = self.providerEnabled(lhs)
|
||||
let rhsEnabled = self.providerEnabled(rhs)
|
||||
var orderedChannels: [ConnectionChannel] {
|
||||
ConnectionChannel.allCases.sorted { lhs, rhs in
|
||||
let lhsEnabled = self.channelEnabled(lhs)
|
||||
let rhsEnabled = self.channelEnabled(rhs)
|
||||
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
|
||||
return lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
}
|
||||
|
||||
var enabledProviders: [ConnectionProvider] {
|
||||
self.orderedProviders.filter { self.providerEnabled($0) }
|
||||
var enabledChannels: [ConnectionChannel] {
|
||||
self.orderedChannels.filter { self.channelEnabled($0) }
|
||||
}
|
||||
|
||||
var availableProviders: [ConnectionProvider] {
|
||||
self.orderedProviders.filter { !self.providerEnabled($0) }
|
||||
var availableChannels: [ConnectionChannel] {
|
||||
self.orderedChannels.filter { !self.channelEnabled($0) }
|
||||
}
|
||||
|
||||
func ensureSelection() {
|
||||
guard let selected = self.selectedProvider else {
|
||||
self.selectedProvider = self.orderedProviders.first
|
||||
guard let selected = self.selectedChannel else {
|
||||
self.selectedChannel = self.orderedChannels.first
|
||||
return
|
||||
}
|
||||
if !self.orderedProviders.contains(selected) {
|
||||
self.selectedProvider = self.orderedProviders.first
|
||||
if !self.orderedChannels.contains(selected) {
|
||||
self.selectedChannel = self.orderedChannels.first
|
||||
}
|
||||
}
|
||||
|
||||
func providerEnabled(_ provider: ConnectionProvider) -> Bool {
|
||||
switch provider {
|
||||
func channelEnabled(_ channel: ConnectionChannel) -> Bool {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
guard let status = self.store.snapshot?.whatsapp else { return false }
|
||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.linked || status.running
|
||||
case .telegram:
|
||||
guard let status = self.store.snapshot?.telegram else { return false }
|
||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .discord:
|
||||
guard let status = self.store.snapshot?.discord else { return false }
|
||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .signal:
|
||||
guard let status = self.store.snapshot?.signal else { return false }
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .imessage:
|
||||
guard let status = self.store.snapshot?.imessage else { return false }
|
||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func providerSection(_ provider: ConnectionProvider) -> some View {
|
||||
switch provider {
|
||||
func channelSection(_ channel: ConnectionChannel) -> some View {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
self.whatsAppSection
|
||||
case .telegram:
|
||||
@@ -291,8 +318,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
func providerTint(_ provider: ConnectionProvider) -> Color {
|
||||
switch provider {
|
||||
func channelTint(_ channel: ConnectionChannel) -> Color {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
self.whatsAppTint
|
||||
case .telegram:
|
||||
@@ -306,8 +333,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
func providerSummary(_ provider: ConnectionProvider) -> String {
|
||||
switch provider {
|
||||
func channelSummary(_ channel: ConnectionChannel) -> String {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
self.whatsAppSummary
|
||||
case .telegram:
|
||||
@@ -321,8 +348,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
func providerDetails(_ provider: ConnectionProvider) -> String? {
|
||||
switch provider {
|
||||
func channelDetails(_ channel: ConnectionChannel) -> String? {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
self.whatsAppDetails
|
||||
case .telegram:
|
||||
@@ -336,43 +363,56 @@ extension ConnectionsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
func providerLastCheckText(_ provider: ConnectionProvider) -> String {
|
||||
guard let date = self.providerLastCheck(provider) else { return "never" }
|
||||
func channelLastCheckText(_ channel: ConnectionChannel) -> String {
|
||||
guard let date = self.channelLastCheck(channel) else { return "never" }
|
||||
return relativeAge(from: date)
|
||||
}
|
||||
|
||||
func providerLastCheck(_ provider: ConnectionProvider) -> Date? {
|
||||
switch provider {
|
||||
func channelLastCheck(_ channel: ConnectionChannel) -> Date? {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
guard let status = self.store.snapshot?.whatsapp else { return nil }
|
||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return nil }
|
||||
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
|
||||
case .telegram:
|
||||
return self.date(fromMs: self.store.snapshot?.telegram.lastProbeAt)
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
||||
.lastProbeAt)
|
||||
case .discord:
|
||||
return self.date(fromMs: self.store.snapshot?.discord?.lastProbeAt)
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||
.lastProbeAt)
|
||||
case .signal:
|
||||
return self.date(fromMs: self.store.snapshot?.signal?.lastProbeAt)
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
||||
case .imessage:
|
||||
return self.date(fromMs: self.store.snapshot?.imessage?.lastProbeAt)
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
|
||||
.lastProbeAt)
|
||||
}
|
||||
}
|
||||
|
||||
func providerHasError(_ provider: ConnectionProvider) -> Bool {
|
||||
switch provider {
|
||||
func channelHasError(_ channel: ConnectionChannel) -> Bool {
|
||||
switch channel {
|
||||
case .whatsapp:
|
||||
guard let status = self.store.snapshot?.whatsapp else { return false }
|
||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
|
||||
case .telegram:
|
||||
guard let status = self.store.snapshot?.telegram else { return false }
|
||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .discord:
|
||||
guard let status = self.store.snapshot?.discord else { return false }
|
||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .signal:
|
||||
guard let status = self.store.snapshot?.signal else { return false }
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .imessage:
|
||||
guard let status = self.store.snapshot?.imessage else { return false }
|
||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ extension ConnectionsSettings {
|
||||
self.store.start()
|
||||
self.ensureSelection()
|
||||
}
|
||||
.onChange(of: self.orderedProviders) { _, _ in
|
||||
.onChange(of: self.orderedChannels) { _, _ in
|
||||
self.ensureSelection()
|
||||
}
|
||||
.onDisappear { self.store.stop() }
|
||||
@@ -20,17 +20,17 @@ extension ConnectionsSettings {
|
||||
private var sidebar: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
if !self.enabledProviders.isEmpty {
|
||||
if !self.enabledChannels.isEmpty {
|
||||
self.sidebarSectionHeader("Configured")
|
||||
ForEach(self.enabledProviders) { provider in
|
||||
self.sidebarRow(provider)
|
||||
ForEach(self.enabledChannels) { channel in
|
||||
self.sidebarRow(channel)
|
||||
}
|
||||
}
|
||||
|
||||
if !self.availableProviders.isEmpty {
|
||||
if !self.availableChannels.isEmpty {
|
||||
self.sidebarSectionHeader("Available")
|
||||
ForEach(self.availableProviders) { provider in
|
||||
self.sidebarRow(provider)
|
||||
ForEach(self.availableChannels) { channel in
|
||||
self.sidebarRow(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,8 @@ extension ConnectionsSettings {
|
||||
|
||||
private var detail: some View {
|
||||
Group {
|
||||
if let provider = self.selectedProvider {
|
||||
self.providerDetail(provider)
|
||||
if let channel = self.selectedChannel {
|
||||
self.channelDetail(channel)
|
||||
} else {
|
||||
self.emptyDetail
|
||||
}
|
||||
@@ -59,7 +59,7 @@ extension ConnectionsSettings {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Connections")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Select a provider to view status and settings.")
|
||||
Text("Select a channel to view status and settings.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -67,12 +67,12 @@ extension ConnectionsSettings {
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
|
||||
private func providerDetail(_ provider: ConnectionProvider) -> some View {
|
||||
private func channelDetail(_ channel: ConnectionChannel) -> some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.detailHeader(for: provider)
|
||||
self.detailHeader(for: channel)
|
||||
Divider()
|
||||
self.providerSection(provider)
|
||||
self.channelSection(channel)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -81,18 +81,18 @@ extension ConnectionsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
private func sidebarRow(_ provider: ConnectionProvider) -> some View {
|
||||
let isSelected = self.selectedProvider == provider
|
||||
private func sidebarRow(_ channel: ConnectionChannel) -> some View {
|
||||
let isSelected = self.selectedChannel == channel
|
||||
return Button {
|
||||
self.selectedProvider = provider
|
||||
self.selectedChannel = channel
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(self.providerTint(provider))
|
||||
.fill(self.channelTint(channel))
|
||||
.frame(width: 8, height: 8)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(provider.title)
|
||||
Text(self.providerSummary(provider))
|
||||
Text(channel.title)
|
||||
Text(self.channelSummary(channel))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -119,23 +119,23 @@ extension ConnectionsSettings {
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
private func detailHeader(for provider: ConnectionProvider) -> some View {
|
||||
private func detailHeader(for channel: ConnectionChannel) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Label(provider.detailTitle, systemImage: provider.systemImage)
|
||||
Label(channel.detailTitle, systemImage: channel.systemImage)
|
||||
.font(.title3.weight(.semibold))
|
||||
self.statusBadge(
|
||||
self.providerSummary(provider),
|
||||
color: self.providerTint(provider))
|
||||
self.channelSummary(channel),
|
||||
color: self.channelTint(channel))
|
||||
Spacer()
|
||||
self.providerHeaderActions(provider)
|
||||
self.channelHeaderActions(channel)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Text("Last check \(self.providerLastCheckText(provider))")
|
||||
Text("Last check \(self.channelLastCheckText(channel))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if self.providerHasError(provider) {
|
||||
if self.channelHasError(channel) {
|
||||
Text("Error")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 6)
|
||||
@@ -146,7 +146,7 @@ extension ConnectionsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
if let details = self.providerDetails(provider) {
|
||||
if let details = self.channelDetails(channel) {
|
||||
Text(details)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -2,7 +2,7 @@ import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectionsSettings: View {
|
||||
enum ConnectionProvider: String, CaseIterable, Identifiable, Hashable {
|
||||
enum ConnectionChannel: String, CaseIterable, Identifiable, Hashable {
|
||||
case whatsapp
|
||||
case telegram
|
||||
case discord
|
||||
@@ -53,7 +53,7 @@ struct ConnectionsSettings: View {
|
||||
}
|
||||
|
||||
@Bindable var store: ConnectionsStore
|
||||
@State var selectedProvider: ConnectionProvider?
|
||||
@State var selectedChannel: ConnectionChannel?
|
||||
@State var showTelegramToken = false
|
||||
@State var showDiscordToken = false
|
||||
|
||||
|
||||
@@ -2,6 +2,16 @@ import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
extension ConnectionsStore {
|
||||
var isTelegramTokenLocked: Bool {
|
||||
self.snapshot?.decodeChannel("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
||||
.tokenSource == "env"
|
||||
}
|
||||
|
||||
var isDiscordTokenLocked: Bool {
|
||||
self.snapshot?.decodeChannel("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||
.tokenSource == "env"
|
||||
}
|
||||
|
||||
func loadConfig() async {
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
@@ -12,6 +22,7 @@ extension ConnectionsStore {
|
||||
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
|
||||
: nil
|
||||
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
self.configHash = snap.hash
|
||||
self.configLoaded = true
|
||||
|
||||
self.applyUIConfig(snap)
|
||||
@@ -34,10 +45,23 @@ extension ConnectionsStore {
|
||||
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||
}
|
||||
|
||||
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
|
||||
if let channels = snap.config?["channels"]?.dictionaryValue,
|
||||
let entry = channels[key]?.dictionaryValue
|
||||
{
|
||||
return entry
|
||||
}
|
||||
return snap.config?[key]?.dictionaryValue
|
||||
}
|
||||
|
||||
private func applyTelegramConfig(_ snap: ConfigSnapshot) {
|
||||
let telegram = snap.config?["telegram"]?.dictionaryValue
|
||||
let telegram = self.resolveChannelConfig(snap, key: "telegram")
|
||||
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
|
||||
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
|
||||
let groups = telegram?["groups"]?.dictionaryValue
|
||||
let defaultGroup = groups?["*"]?.dictionaryValue
|
||||
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
|
||||
?? telegram?["requireMention"]?.boolValue
|
||||
?? true
|
||||
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
|
||||
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
|
||||
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
||||
@@ -46,7 +70,7 @@ extension ConnectionsStore {
|
||||
}
|
||||
|
||||
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
|
||||
let discord = snap.config?["discord"]?.dictionaryValue
|
||||
let discord = self.resolveChannelConfig(snap, key: "discord")
|
||||
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
|
||||
self.discordToken = discord?["token"]?.stringValue ?? ""
|
||||
|
||||
@@ -122,7 +146,7 @@ extension ConnectionsStore {
|
||||
}
|
||||
|
||||
private func applySignalConfig(_ snap: ConfigSnapshot) {
|
||||
let signal = snap.config?["signal"]?.dictionaryValue
|
||||
let signal = self.resolveChannelConfig(snap, key: "signal")
|
||||
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
|
||||
self.signalAccount = signal?["account"]?.stringValue ?? ""
|
||||
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
|
||||
@@ -139,7 +163,7 @@ extension ConnectionsStore {
|
||||
}
|
||||
|
||||
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
|
||||
let imessage = snap.config?["imessage"]?.dictionaryValue
|
||||
let imessage = self.resolveChannelConfig(snap, key: "imessage")
|
||||
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
|
||||
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
|
||||
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
|
||||
@@ -150,6 +174,15 @@ extension ConnectionsStore {
|
||||
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
|
||||
}
|
||||
|
||||
private func channelConfigRoot(for key: String) -> [String: Any] {
|
||||
if let channels = self.configRoot["channels"] as? [String: Any],
|
||||
let entry = channels[key] as? [String: Any]
|
||||
{
|
||||
return entry
|
||||
}
|
||||
return self.configRoot[key] as? [String: Any] ?? [:]
|
||||
}
|
||||
|
||||
func saveTelegramConfig() async {
|
||||
guard !self.isSavingConfig else { return }
|
||||
self.isSavingConfig = true
|
||||
@@ -158,30 +191,24 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:]
|
||||
let token = self.trimmed(self.telegramToken)
|
||||
if token.isEmpty {
|
||||
telegram.removeValue(forKey: "botToken")
|
||||
} else {
|
||||
telegram["botToken"] = token
|
||||
var telegram: [String: Any] = [:]
|
||||
if !self.isTelegramTokenLocked {
|
||||
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
|
||||
}
|
||||
|
||||
telegram["requireMention"] = self.telegramRequireMention
|
||||
|
||||
telegram["requireMention"] = NSNull()
|
||||
telegram["groups"] = [
|
||||
"*": [
|
||||
"requireMention": self.telegramRequireMention,
|
||||
],
|
||||
]
|
||||
let allow = self.splitCsv(self.telegramAllowFrom)
|
||||
if allow.isEmpty {
|
||||
telegram.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
telegram["allowFrom"] = allow
|
||||
}
|
||||
self.setPatchList(&telegram, key: "allowFrom", values: allow)
|
||||
self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
|
||||
self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
|
||||
self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
|
||||
self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
|
||||
|
||||
self.setOptionalString(&telegram, key: "proxy", value: self.telegramProxy)
|
||||
self.setOptionalString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
|
||||
self.setOptionalString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
|
||||
self.setOptionalString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
|
||||
|
||||
self.setSection("telegram", payload: telegram)
|
||||
await self.persistConfig()
|
||||
await self.persistChannelPatch("telegram", payload: telegram)
|
||||
}
|
||||
|
||||
func saveDiscordConfig() async {
|
||||
@@ -192,9 +219,9 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
let discord = self.buildDiscordConfig()
|
||||
self.setSection("discord", payload: discord)
|
||||
await self.persistConfig()
|
||||
let base = self.channelConfigRoot(for: "discord")
|
||||
let discord = self.buildDiscordPatch(base: base)
|
||||
await self.persistChannelPatch("discord", payload: discord)
|
||||
}
|
||||
|
||||
func saveSignalConfig() async {
|
||||
@@ -205,42 +232,23 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var signal: [String: Any] = (self.configRoot["signal"] as? [String: Any]) ?? [:]
|
||||
if self.signalEnabled {
|
||||
signal.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
signal["enabled"] = false
|
||||
}
|
||||
|
||||
self.setOptionalString(&signal, key: "account", value: self.signalAccount)
|
||||
self.setOptionalString(&signal, key: "httpUrl", value: self.signalHttpUrl)
|
||||
self.setOptionalString(&signal, key: "httpHost", value: self.signalHttpHost)
|
||||
self.setOptionalNumber(&signal, key: "httpPort", value: self.signalHttpPort)
|
||||
self.setOptionalString(&signal, key: "cliPath", value: self.signalCliPath)
|
||||
|
||||
if self.signalAutoStart {
|
||||
signal.removeValue(forKey: "autoStart")
|
||||
} else {
|
||||
signal["autoStart"] = false
|
||||
}
|
||||
|
||||
self.setOptionalString(&signal, key: "receiveMode", value: self.signalReceiveMode)
|
||||
|
||||
self.setOptionalBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments)
|
||||
self.setOptionalBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories)
|
||||
self.setOptionalBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts)
|
||||
|
||||
var signal: [String: Any] = [:]
|
||||
self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
|
||||
self.setPatchString(&signal, key: "account", value: self.signalAccount)
|
||||
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
|
||||
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
|
||||
self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort)
|
||||
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
|
||||
self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
|
||||
self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
|
||||
self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
|
||||
self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
|
||||
self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
|
||||
let allow = self.splitCsv(self.signalAllowFrom)
|
||||
if allow.isEmpty {
|
||||
signal.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
signal["allowFrom"] = allow
|
||||
}
|
||||
self.setPatchList(&signal, key: "allowFrom", values: allow)
|
||||
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
|
||||
|
||||
self.setOptionalNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
|
||||
|
||||
self.setSection("signal", payload: signal)
|
||||
await self.persistConfig()
|
||||
await self.persistChannelPatch("signal", payload: signal)
|
||||
}
|
||||
|
||||
func saveIMessageConfig() async {
|
||||
@@ -251,147 +259,168 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var imessage: [String: Any] = (self.configRoot["imessage"] as? [String: Any]) ?? [:]
|
||||
if self.imessageEnabled {
|
||||
imessage.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
imessage["enabled"] = false
|
||||
}
|
||||
|
||||
self.setOptionalString(&imessage, key: "cliPath", value: self.imessageCliPath)
|
||||
self.setOptionalString(&imessage, key: "dbPath", value: self.imessageDbPath)
|
||||
var imessage: [String: Any] = [:]
|
||||
self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
|
||||
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
|
||||
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
|
||||
|
||||
let service = self.trimmed(self.imessageService)
|
||||
if service.isEmpty || service == "auto" {
|
||||
imessage.removeValue(forKey: "service")
|
||||
imessage["service"] = NSNull()
|
||||
} else {
|
||||
imessage["service"] = service
|
||||
}
|
||||
|
||||
self.setOptionalString(&imessage, key: "region", value: self.imessageRegion)
|
||||
self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
|
||||
|
||||
let allow = self.splitCsv(self.imessageAllowFrom)
|
||||
if allow.isEmpty {
|
||||
imessage.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
imessage["allowFrom"] = allow
|
||||
}
|
||||
self.setPatchList(&imessage, key: "allowFrom", values: allow)
|
||||
|
||||
self.setOptionalBool(&imessage, key: "includeAttachments", value: self.imessageIncludeAttachments)
|
||||
self.setOptionalNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
|
||||
self.setPatchBool(
|
||||
&imessage,
|
||||
key: "includeAttachments",
|
||||
value: self.imessageIncludeAttachments,
|
||||
defaultValue: false)
|
||||
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
|
||||
|
||||
self.setSection("imessage", payload: imessage)
|
||||
await self.persistConfig()
|
||||
await self.persistChannelPatch("imessage", payload: imessage)
|
||||
}
|
||||
|
||||
private func buildDiscordConfig() -> [String: Any] {
|
||||
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:]
|
||||
if self.discordEnabled {
|
||||
discord.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
discord["enabled"] = false
|
||||
private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
|
||||
var discord: [String: Any] = [:]
|
||||
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
|
||||
if !self.isDiscordTokenLocked {
|
||||
self.setPatchString(&discord, key: "token", value: self.discordToken)
|
||||
}
|
||||
self.setOptionalString(&discord, key: "token", value: self.discordToken)
|
||||
|
||||
if let dm = self.buildDiscordDmConfig(base: discord["dm"] as? [String: Any] ?? [:]) {
|
||||
if let dm = self.buildDiscordDmPatch() {
|
||||
discord["dm"] = dm
|
||||
} else {
|
||||
discord.removeValue(forKey: "dm")
|
||||
discord["dm"] = NSNull()
|
||||
}
|
||||
|
||||
self.setOptionalNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
|
||||
self.setOptionalInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
|
||||
self.setOptionalInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
|
||||
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
|
||||
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
|
||||
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
|
||||
|
||||
let replyToMode = self.trimmed(self.discordReplyToMode)
|
||||
if replyToMode.isEmpty || replyToMode == "off" {
|
||||
discord.removeValue(forKey: "replyToMode")
|
||||
} else if ["first", "all"].contains(replyToMode) {
|
||||
discord["replyToMode"] = replyToMode
|
||||
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
|
||||
discord["replyToMode"] = NSNull()
|
||||
} else {
|
||||
discord.removeValue(forKey: "replyToMode")
|
||||
discord["replyToMode"] = replyToMode
|
||||
}
|
||||
|
||||
if let guilds = self.buildDiscordGuildsConfig() {
|
||||
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
|
||||
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
|
||||
discord["guilds"] = guilds
|
||||
} else {
|
||||
discord.removeValue(forKey: "guilds")
|
||||
discord["guilds"] = NSNull()
|
||||
}
|
||||
|
||||
if let actions = self.buildDiscordActionsConfig(base: discord["actions"] as? [String: Any] ?? [:]) {
|
||||
if let actions = self.buildDiscordActionsPatch() {
|
||||
discord["actions"] = actions
|
||||
} else {
|
||||
discord.removeValue(forKey: "actions")
|
||||
discord["actions"] = NSNull()
|
||||
}
|
||||
|
||||
if let slash = self.buildDiscordSlashConfig(base: discord["slashCommand"] as? [String: Any] ?? [:]) {
|
||||
if let slash = self.buildDiscordSlashPatch() {
|
||||
discord["slashCommand"] = slash
|
||||
} else {
|
||||
discord.removeValue(forKey: "slashCommand")
|
||||
discord["slashCommand"] = NSNull()
|
||||
}
|
||||
|
||||
return discord
|
||||
}
|
||||
|
||||
private func buildDiscordDmConfig(base: [String: Any]) -> [String: Any]? {
|
||||
var dm = base
|
||||
if self.discordDmEnabled {
|
||||
dm.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
dm["enabled"] = false
|
||||
}
|
||||
private func buildDiscordDmPatch() -> [String: Any]? {
|
||||
var dm: [String: Any] = [:]
|
||||
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
|
||||
let allow = self.splitCsv(self.discordAllowFrom)
|
||||
if allow.isEmpty {
|
||||
dm.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
dm["allowFrom"] = allow
|
||||
}
|
||||
|
||||
if self.discordGroupEnabled {
|
||||
dm["groupEnabled"] = true
|
||||
} else {
|
||||
dm.removeValue(forKey: "groupEnabled")
|
||||
}
|
||||
|
||||
self.setPatchList(&dm, key: "allowFrom", values: allow)
|
||||
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
|
||||
let groupChannels = self.splitCsv(self.discordGroupChannels)
|
||||
if groupChannels.isEmpty {
|
||||
dm.removeValue(forKey: "groupChannels")
|
||||
} else {
|
||||
dm["groupChannels"] = groupChannels
|
||||
}
|
||||
|
||||
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
|
||||
return dm.isEmpty ? nil : dm
|
||||
}
|
||||
|
||||
private func buildDiscordGuildsConfig() -> [String: Any]? {
|
||||
let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in
|
||||
let key = self.trimmed(entry.key)
|
||||
guard !key.isEmpty else { return }
|
||||
var payload: [String: Any] = [:]
|
||||
let slug = self.trimmed(entry.slug)
|
||||
if !slug.isEmpty { payload["slug"] = slug }
|
||||
if entry.requireMention { payload["requireMention"] = true }
|
||||
if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) {
|
||||
payload["reactionNotifications"] = entry.reactionNotifications
|
||||
}
|
||||
let users = self.splitCsv(entry.users)
|
||||
if !users.isEmpty { payload["users"] = users }
|
||||
let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in
|
||||
let channelKey = self.trimmed(channel.key)
|
||||
guard !channelKey.isEmpty else { return }
|
||||
var channelPayload: [String: Any] = [:]
|
||||
if !channel.allow { channelPayload["allow"] = false }
|
||||
if channel.requireMention { channelPayload["requireMention"] = true }
|
||||
channelsResult[channelKey] = channelPayload
|
||||
}
|
||||
if !channels.isEmpty { payload["channels"] = channels }
|
||||
result[key] = payload
|
||||
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
|
||||
if self.discordGuilds.isEmpty {
|
||||
return NSNull()
|
||||
}
|
||||
return guilds.isEmpty ? nil : guilds
|
||||
var patch: [String: Any] = [:]
|
||||
let baseKeys = Set(base.keys)
|
||||
var formKeys = Set<String>()
|
||||
for entry in self.discordGuilds {
|
||||
let key = self.trimmed(entry.key)
|
||||
guard !key.isEmpty else { continue }
|
||||
formKeys.insert(key)
|
||||
let baseGuild = base[key] as? [String: Any] ?? [:]
|
||||
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
|
||||
}
|
||||
for key in baseKeys.subtracting(formKeys) {
|
||||
patch[key] = NSNull()
|
||||
}
|
||||
return patch.isEmpty ? NSNull() : patch
|
||||
}
|
||||
|
||||
private func buildDiscordActionsConfig(base: [String: Any]) -> [String: Any]? {
|
||||
var actions = base
|
||||
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
|
||||
var payload: [String: Any] = [:]
|
||||
let slug = self.trimmed(entry.slug)
|
||||
if slug.isEmpty {
|
||||
payload["slug"] = NSNull()
|
||||
} else {
|
||||
payload["slug"] = slug
|
||||
}
|
||||
if entry.requireMention {
|
||||
payload["requireMention"] = true
|
||||
} else {
|
||||
payload["requireMention"] = NSNull()
|
||||
}
|
||||
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
|
||||
payload["reactionNotifications"] = entry.reactionNotifications
|
||||
} else {
|
||||
payload["reactionNotifications"] = NSNull()
|
||||
}
|
||||
let users = self.splitCsv(entry.users)
|
||||
self.setPatchList(&payload, key: "users", values: users)
|
||||
|
||||
let baseChannels = base["channels"] as? [String: Any] ?? [:]
|
||||
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
|
||||
payload["channels"] = channels
|
||||
} else {
|
||||
payload["channels"] = NSNull()
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
|
||||
if forms.isEmpty {
|
||||
return NSNull()
|
||||
}
|
||||
var patch: [String: Any] = [:]
|
||||
let baseKeys = Set(base.keys)
|
||||
var formKeys = Set<String>()
|
||||
for channel in forms {
|
||||
let channelKey = self.trimmed(channel.key)
|
||||
guard !channelKey.isEmpty else { continue }
|
||||
formKeys.insert(channelKey)
|
||||
var channelPayload: [String: Any] = [:]
|
||||
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
|
||||
self.setPatchBool(
|
||||
&channelPayload,
|
||||
key: "requireMention",
|
||||
value: channel.requireMention,
|
||||
defaultValue: false)
|
||||
patch[channelKey] = channelPayload
|
||||
}
|
||||
for key in baseKeys.subtracting(formKeys) {
|
||||
patch[key] = NSNull()
|
||||
}
|
||||
return patch.isEmpty ? NSNull() : patch
|
||||
}
|
||||
|
||||
private func buildDiscordActionsPatch() -> [String: Any]? {
|
||||
var actions: [String: Any] = [:]
|
||||
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
|
||||
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
|
||||
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
|
||||
@@ -410,52 +439,44 @@ extension ConnectionsStore {
|
||||
return actions.isEmpty ? nil : actions
|
||||
}
|
||||
|
||||
private func buildDiscordSlashConfig(base: [String: Any]) -> [String: Any]? {
|
||||
var slash = base
|
||||
if self.discordSlashEnabled {
|
||||
slash["enabled"] = true
|
||||
} else {
|
||||
slash.removeValue(forKey: "enabled")
|
||||
}
|
||||
self.setOptionalString(&slash, key: "name", value: self.discordSlashName)
|
||||
self.setOptionalString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
|
||||
if self.discordSlashEphemeral {
|
||||
slash.removeValue(forKey: "ephemeral")
|
||||
} else {
|
||||
slash["ephemeral"] = false
|
||||
}
|
||||
private func buildDiscordSlashPatch() -> [String: Any]? {
|
||||
var slash: [String: Any] = [:]
|
||||
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
|
||||
self.setPatchString(&slash, key: "name", value: self.discordSlashName)
|
||||
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
|
||||
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
|
||||
return slash.isEmpty ? nil : slash
|
||||
}
|
||||
|
||||
private func persistConfig() async {
|
||||
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
|
||||
do {
|
||||
guard let baseHash = self.configHash else {
|
||||
self.configStatus = "Config hash missing; reload and retry."
|
||||
return
|
||||
}
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: self.configRoot,
|
||||
withJSONObject: ["channels": [channelId: payload]],
|
||||
options: [.prettyPrinted, .sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
self.configStatus = "Failed to encode config."
|
||||
return
|
||||
}
|
||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
let params: [String: AnyCodable] = [
|
||||
"raw": AnyCodable(raw),
|
||||
"baseHash": AnyCodable(baseHash),
|
||||
]
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configSet,
|
||||
method: .configPatch,
|
||||
params: params,
|
||||
timeoutMs: 10000)
|
||||
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
|
||||
await self.loadConfig()
|
||||
await self.refresh(probe: true)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func setSection(_ key: String, payload: [String: Any]) {
|
||||
if payload.isEmpty {
|
||||
self.configRoot.removeValue(forKey: key)
|
||||
} else {
|
||||
self.configRoot[key] = payload
|
||||
}
|
||||
}
|
||||
|
||||
private func stringList(from values: [AnyCodable]?) -> String {
|
||||
guard let values else { return "" }
|
||||
let strings = values.compactMap { entry -> String? in
|
||||
@@ -492,25 +513,29 @@ extension ConnectionsStore {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func setOptionalString(_ target: inout [String: Any], key: String, value: String) {
|
||||
private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target[key] = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private func setOptionalNumber(_ target: inout [String: Any], key: String, value: String) {
|
||||
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target.removeValue(forKey: key)
|
||||
} else if let number = Double(trimmed) {
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
if let number = Double(trimmed) {
|
||||
target[key] = number
|
||||
} else {
|
||||
target[key] = NSNull()
|
||||
}
|
||||
}
|
||||
|
||||
private func setOptionalInt(
|
||||
private func setPatchInt(
|
||||
_ target: inout [String: Any],
|
||||
key: String,
|
||||
value: String,
|
||||
@@ -518,26 +543,39 @@ extension ConnectionsStore {
|
||||
{
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
guard let number = Int(trimmed) else {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
let isValid = allowZero ? number >= 0 : number > 0
|
||||
guard isValid else {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
target[key] = number
|
||||
}
|
||||
|
||||
private func setOptionalBool(_ target: inout [String: Any], key: String, value: Bool) {
|
||||
if value {
|
||||
target[key] = true
|
||||
private func setPatchBool(
|
||||
_ target: inout [String: Any],
|
||||
key: String,
|
||||
value: Bool,
|
||||
defaultValue: Bool)
|
||||
{
|
||||
if value == defaultValue {
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
|
||||
if values.isEmpty {
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target[key] = values
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +586,7 @@ extension ConnectionsStore {
|
||||
defaultValue: Bool)
|
||||
{
|
||||
if value == defaultValue {
|
||||
actions.removeValue(forKey: key)
|
||||
actions[key] = NSNull()
|
||||
} else {
|
||||
actions[key] = value
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ extension ConnectionsStore {
|
||||
"probe": AnyCodable(probe),
|
||||
"timeoutMs": AnyCodable(8000),
|
||||
]
|
||||
let snap: ProvidersStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .providersStatus,
|
||||
let snap: ChannelsStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .channelsStatus,
|
||||
params: params,
|
||||
timeoutMs: 12000)
|
||||
self.snapshot = snap
|
||||
@@ -100,9 +100,12 @@ extension ConnectionsStore {
|
||||
self.whatsappBusy = true
|
||||
defer { self.whatsappBusy = false }
|
||||
do {
|
||||
let result: WhatsAppLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .webLogout,
|
||||
params: nil,
|
||||
let params: [String: AnyCodable] = [
|
||||
"channel": AnyCodable("whatsapp"),
|
||||
]
|
||||
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .channelsLogout,
|
||||
params: params,
|
||||
timeoutMs: 15000)
|
||||
self.whatsappLoginMessage = result.cleared
|
||||
? "Logged out and cleared credentials."
|
||||
@@ -119,9 +122,12 @@ extension ConnectionsStore {
|
||||
self.telegramBusy = true
|
||||
defer { self.telegramBusy = false }
|
||||
do {
|
||||
let result: TelegramLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .telegramLogout,
|
||||
params: nil,
|
||||
let params: [String: AnyCodable] = [
|
||||
"channel": AnyCodable("telegram"),
|
||||
]
|
||||
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .channelsLogout,
|
||||
params: params,
|
||||
timeoutMs: 15000)
|
||||
if result.envToken == true {
|
||||
self.configStatus = "Telegram token still set via env; config cleared."
|
||||
@@ -148,11 +154,9 @@ private struct WhatsAppLoginWaitResult: Codable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
private struct WhatsAppLogoutResult: Codable {
|
||||
let cleared: Bool
|
||||
}
|
||||
|
||||
private struct TelegramLogoutResult: Codable {
|
||||
private struct ChannelLogoutResult: Codable {
|
||||
let channel: String?
|
||||
let accountId: String?
|
||||
let cleared: Bool
|
||||
let envToken: Bool?
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct ProvidersStatusSnapshot: Codable {
|
||||
struct ChannelsStatusSnapshot: Codable {
|
||||
struct WhatsAppSelf: Codable {
|
||||
let e164: String?
|
||||
let jid: String?
|
||||
@@ -121,12 +121,54 @@ struct ProvidersStatusSnapshot: Codable {
|
||||
let lastProbeAt: Double?
|
||||
}
|
||||
|
||||
struct ChannelAccountSnapshot: Codable {
|
||||
let accountId: String
|
||||
let name: String?
|
||||
let enabled: Bool?
|
||||
let configured: Bool?
|
||||
let linked: Bool?
|
||||
let running: Bool?
|
||||
let connected: Bool?
|
||||
let reconnectAttempts: Int?
|
||||
let lastConnectedAt: Double?
|
||||
let lastError: String?
|
||||
let lastStartAt: Double?
|
||||
let lastStopAt: Double?
|
||||
let lastInboundAt: Double?
|
||||
let lastOutboundAt: Double?
|
||||
let lastProbeAt: Double?
|
||||
let mode: String?
|
||||
let dmPolicy: String?
|
||||
let allowFrom: [String]?
|
||||
let tokenSource: String?
|
||||
let botTokenSource: String?
|
||||
let appTokenSource: String?
|
||||
let baseUrl: String?
|
||||
let allowUnmentionedGroups: Bool?
|
||||
let cliPath: String?
|
||||
let dbPath: String?
|
||||
let port: Int?
|
||||
let probe: AnyCodable?
|
||||
let audit: AnyCodable?
|
||||
let application: AnyCodable?
|
||||
}
|
||||
|
||||
let ts: Double
|
||||
let whatsapp: WhatsAppStatus
|
||||
let telegram: TelegramStatus
|
||||
let discord: DiscordStatus?
|
||||
let signal: SignalStatus?
|
||||
let imessage: IMessageStatus?
|
||||
let channelOrder: [String]
|
||||
let channelLabels: [String: String]
|
||||
let channels: [String: AnyCodable]
|
||||
let channelAccounts: [String: [ChannelAccountSnapshot]]
|
||||
let channelDefaultAccountId: [String: String]
|
||||
|
||||
func decodeChannel<T: Decodable>(_ id: String, as type: T.Type) -> T? {
|
||||
guard let value = self.channels[id] else { return nil }
|
||||
do {
|
||||
let data = try JSONEncoder().encode(value)
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigSnapshot: Codable {
|
||||
@@ -138,6 +180,7 @@ struct ConfigSnapshot: Codable {
|
||||
let path: String?
|
||||
let exists: Bool?
|
||||
let raw: String?
|
||||
let hash: String?
|
||||
let parsed: AnyCodable?
|
||||
let valid: Bool?
|
||||
let config: [String: AnyCodable]?
|
||||
@@ -188,7 +231,7 @@ struct DiscordGuildForm: Identifiable {
|
||||
final class ConnectionsStore {
|
||||
static let shared = ConnectionsStore()
|
||||
|
||||
var snapshot: ProvidersStatusSnapshot?
|
||||
var snapshot: ChannelsStatusSnapshot?
|
||||
var lastError: String?
|
||||
var lastSuccess: Date?
|
||||
var isRefreshing = false
|
||||
@@ -265,6 +308,7 @@ final class ConnectionsStore {
|
||||
var pollTask: Task<Void, Never>?
|
||||
var configRoot: [String: Any] = [:]
|
||||
var configLoaded = false
|
||||
var configHash: String?
|
||||
|
||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||
self.isPreview = isPreview
|
||||
|
||||
@@ -26,15 +26,17 @@ let remoteProjectRootKey = "clawdbot.remoteProjectRoot"
|
||||
let remoteCliPathKey = "clawdbot.remoteCliPath"
|
||||
let canvasEnabledKey = "clawdbot.canvasEnabled"
|
||||
let cameraEnabledKey = "clawdbot.cameraEnabled"
|
||||
let systemRunPolicyKey = "clawdbot.systemRunPolicy"
|
||||
let systemRunAllowlistKey = "clawdbot.systemRunAllowlist"
|
||||
let systemRunEnabledKey = "clawdbot.systemRunEnabled"
|
||||
let locationModeKey = "clawdbot.locationMode"
|
||||
let locationPreciseKey = "clawdbot.locationPreciseEnabled"
|
||||
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
|
||||
let deepLinkKeyKey = "clawdbot.deepLinkKey"
|
||||
let modelCatalogPathKey = "clawdbot.modelCatalogPath"
|
||||
let modelCatalogReloadKey = "clawdbot.modelCatalogReload"
|
||||
let attachExistingGatewayOnlyKey = "clawdbot.gateway.attachExistingOnly"
|
||||
let cliInstallPromptedVersionKey = "clawdbot.cliInstallPromptedVersion"
|
||||
let heartbeatsEnabledKey = "clawdbot.heartbeatsEnabled"
|
||||
let debugFileLogEnabledKey = "clawdbot.debug.fileLogEnabled"
|
||||
let appLogLevelKey = "clawdbot.debug.appLogLevel"
|
||||
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
||||
let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"]
|
||||
|
||||
@@ -108,6 +108,7 @@ final class ControlChannel {
|
||||
self.logger.info(
|
||||
"control channel configure mode=remote " +
|
||||
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||
self.state = .connecting
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
await self.configure()
|
||||
} catch {
|
||||
@@ -182,7 +183,7 @@ final class ControlChannel {
|
||||
{
|
||||
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||
return
|
||||
"Gateway rejected token; set CLAWDBOT_GATEWAY_TOKEN in the mac app environment " +
|
||||
"Gateway rejected token; set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) " +
|
||||
"or clear it on the gateway. " +
|
||||
"Reason: \(reason)"
|
||||
}
|
||||
@@ -211,12 +212,6 @@ final class ControlChannel {
|
||||
return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry."
|
||||
case .cannotFindHost, .cannotConnectToHost:
|
||||
let isRemote = CommandResolver.connectionModeIsRemote()
|
||||
if AppStateStore.attachExistingGatewayOnly, !isRemote {
|
||||
return """
|
||||
Cannot reach gateway at localhost:\(port) and “Attach existing gateway only” is enabled.
|
||||
Disable it in Debug Settings or start a gateway on that port.
|
||||
"""
|
||||
}
|
||||
if isRemote {
|
||||
return """
|
||||
Cannot reach gateway at localhost:\(port).
|
||||
|
||||
@@ -13,7 +13,9 @@ extension CronJobEditor {
|
||||
guard let job else { return }
|
||||
self.name = job.name
|
||||
self.description = job.description ?? ""
|
||||
self.agentId = job.agentId ?? ""
|
||||
self.enabled = job.enabled
|
||||
self.deleteAfterRun = job.deleteAfterRun ?? false
|
||||
self.sessionTarget = job.sessionTarget
|
||||
self.wakeMode = job.wakeMode
|
||||
|
||||
@@ -34,13 +36,13 @@ extension CronJobEditor {
|
||||
case let .systemEvent(text):
|
||||
self.payloadKind = .systemEvent
|
||||
self.systemEventText = text
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = message
|
||||
self.thinking = thinking ?? ""
|
||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||
self.deliver = deliver ?? false
|
||||
self.provider = GatewayAgentProvider(raw: provider)
|
||||
self.channel = GatewayAgentChannel(raw: channel)
|
||||
self.to = to ?? ""
|
||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||
}
|
||||
@@ -59,18 +61,60 @@ extension CronJobEditor {
|
||||
}
|
||||
|
||||
func buildPayload() throws -> [String: AnyCodable] {
|
||||
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let name = try self.requireName()
|
||||
let description = self.trimmed(self.description)
|
||||
let agentId = self.trimmed(self.agentId)
|
||||
let schedule = try self.buildSchedule()
|
||||
let payload = try self.buildSelectedPayload()
|
||||
|
||||
try self.validateSessionTarget(payload)
|
||||
try self.validatePayloadRequiredFields(payload)
|
||||
|
||||
var root: [String: Any] = [
|
||||
"name": name,
|
||||
"enabled": self.enabled,
|
||||
"schedule": schedule,
|
||||
"sessionTarget": self.sessionTarget.rawValue,
|
||||
"wakeMode": self.wakeMode.rawValue,
|
||||
"payload": payload,
|
||||
]
|
||||
self.applyDeleteAfterRun(to: &root)
|
||||
if !description.isEmpty { root["description"] = description }
|
||||
if !agentId.isEmpty {
|
||||
root["agentId"] = agentId
|
||||
} else if self.job?.agentId != nil {
|
||||
root["agentId"] = NSNull()
|
||||
}
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
root["isolation"] = [
|
||||
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||||
]
|
||||
}
|
||||
|
||||
return root.mapValues { AnyCodable($0) }
|
||||
}
|
||||
|
||||
func trimmed(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func requireName() throws -> String {
|
||||
let name = self.trimmed(self.name)
|
||||
if name.isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
|
||||
}
|
||||
let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let schedule: [String: Any]
|
||||
return name
|
||||
}
|
||||
|
||||
func buildSchedule() throws -> [String: Any] {
|
||||
switch self.scheduleKind {
|
||||
case .at:
|
||||
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
|
||||
return ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
|
||||
case .every:
|
||||
guard let ms = Self.parseDurationMs(self.everyText) else {
|
||||
throw NSError(
|
||||
@@ -78,34 +122,35 @@ extension CronJobEditor {
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
|
||||
}
|
||||
schedule = ["kind": "every", "everyMs": ms]
|
||||
return ["kind": "every", "everyMs": ms]
|
||||
case .cron:
|
||||
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let expr = self.trimmed(self.cronExpr)
|
||||
if expr.isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
|
||||
}
|
||||
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let tz = self.trimmed(self.cronTz)
|
||||
if tz.isEmpty {
|
||||
schedule = ["kind": "cron", "expr": expr]
|
||||
} else {
|
||||
schedule = ["kind": "cron", "expr": expr, "tz": tz]
|
||||
return ["kind": "cron", "expr": expr]
|
||||
}
|
||||
return ["kind": "cron", "expr": expr, "tz": tz]
|
||||
}
|
||||
}
|
||||
|
||||
let payload: [String: Any] = {
|
||||
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return ["kind": "systemEvent", "text": text]
|
||||
case .agentTurn:
|
||||
return self.buildAgentTurnPayload()
|
||||
}
|
||||
}()
|
||||
func buildSelectedPayload() throws -> [String: Any] {
|
||||
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
let text = self.trimmed(self.systemEventText)
|
||||
return ["kind": "systemEvent", "text": text]
|
||||
case .agentTurn:
|
||||
return self.buildAgentTurnPayload()
|
||||
}
|
||||
}
|
||||
|
||||
func validateSessionTarget(_ payload: [String: Any]) throws {
|
||||
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
@@ -122,7 +167,9 @@ extension CronJobEditor {
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
|
||||
}
|
||||
}
|
||||
|
||||
func validatePayloadRequiredFields(_ payload: [String: Any]) throws {
|
||||
if payload["kind"] as? String == "systemEvent" {
|
||||
if (payload["text"] as? String ?? "").isEmpty {
|
||||
throw NSError(
|
||||
@@ -130,7 +177,8 @@ extension CronJobEditor {
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
|
||||
}
|
||||
} else if payload["kind"] as? String == "agentTurn" {
|
||||
}
|
||||
if payload["kind"] as? String == "agentTurn" {
|
||||
if (payload["message"] as? String ?? "").isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
@@ -138,25 +186,20 @@ extension CronJobEditor {
|
||||
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var root: [String: Any] = [
|
||||
"name": name,
|
||||
"enabled": self.enabled,
|
||||
"schedule": schedule,
|
||||
"sessionTarget": self.sessionTarget.rawValue,
|
||||
"wakeMode": self.wakeMode.rawValue,
|
||||
"payload": payload,
|
||||
]
|
||||
if !description.isEmpty { root["description"] = description }
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
root["isolation"] = [
|
||||
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||||
]
|
||||
func applyDeleteAfterRun(
|
||||
to root: inout [String: Any],
|
||||
scheduleKind: ScheduleKind? = nil,
|
||||
deleteAfterRun: Bool? = nil)
|
||||
{
|
||||
let resolvedSchedule = scheduleKind ?? self.scheduleKind
|
||||
let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun
|
||||
if resolvedSchedule == .at {
|
||||
root["deleteAfterRun"] = resolvedDelete
|
||||
} else if self.job?.deleteAfterRun != nil {
|
||||
root["deleteAfterRun"] = false
|
||||
}
|
||||
|
||||
return root.mapValues { AnyCodable($0) }
|
||||
}
|
||||
|
||||
func buildAgentTurnPayload() -> [String: Any] {
|
||||
@@ -167,7 +210,7 @@ extension CronJobEditor {
|
||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||
payload["deliver"] = self.deliver
|
||||
if self.deliver {
|
||||
payload["provider"] = self.provider.rawValue
|
||||
payload["channel"] = self.channel.rawValue
|
||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !to.isEmpty { payload["to"] = to }
|
||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||
|
||||
@@ -3,6 +3,7 @@ extension CronJobEditor {
|
||||
mutating func exerciseForTesting() {
|
||||
self.name = "Test job"
|
||||
self.description = "Test description"
|
||||
self.agentId = "ops"
|
||||
self.enabled = true
|
||||
self.sessionTarget = .isolated
|
||||
self.wakeMode = .now
|
||||
@@ -13,7 +14,7 @@ extension CronJobEditor {
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = "Run diagnostic"
|
||||
self.deliver = true
|
||||
self.provider = .last
|
||||
self.channel = .last
|
||||
self.to = "+15551230000"
|
||||
self.thinking = "low"
|
||||
self.timeoutSeconds = "90"
|
||||
|
||||
@@ -18,7 +18,7 @@ struct CronJobEditor: View {
|
||||
static let scheduleKindNote =
|
||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||
static let isolatedPayloadNote =
|
||||
"Isolated jobs always run an agent turn. The result can be delivered to a provider, "
|
||||
"Isolated jobs always run an agent turn. The result can be delivered to a channel, "
|
||||
+ "and a short summary is posted back to your main chat."
|
||||
static let mainPayloadNote =
|
||||
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||
@@ -27,9 +27,11 @@ struct CronJobEditor: View {
|
||||
|
||||
@State var name: String = ""
|
||||
@State var description: String = ""
|
||||
@State var agentId: String = ""
|
||||
@State var enabled: Bool = true
|
||||
@State var sessionTarget: CronSessionTarget = .main
|
||||
@State var wakeMode: CronWakeMode = .nextHeartbeat
|
||||
@State var deleteAfterRun: Bool = false
|
||||
|
||||
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
|
||||
@State var scheduleKind: ScheduleKind = .every
|
||||
@@ -43,7 +45,7 @@ struct CronJobEditor: View {
|
||||
@State var systemEventText: String = ""
|
||||
@State var agentMessage: String = ""
|
||||
@State var deliver: Bool = false
|
||||
@State var provider: GatewayAgentProvider = .last
|
||||
@State var channel: GatewayAgentChannel = .last
|
||||
@State var to: String = ""
|
||||
@State var thinking: String = ""
|
||||
@State var timeoutSeconds: String = ""
|
||||
@@ -77,6 +79,12 @@ struct CronJobEditor: View {
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Agent ID")
|
||||
TextField("Optional (default agent)", text: self.$agentId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$enabled)
|
||||
@@ -149,6 +157,11 @@ struct CronJobEditor: View {
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Auto-delete")
|
||||
Toggle("Delete after successful run", isOn: self.$deleteAfterRun)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
case .every:
|
||||
GridRow {
|
||||
self.gridLabel("Every")
|
||||
@@ -310,7 +323,7 @@ struct CronJobEditor: View {
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Deliver")
|
||||
Toggle("Deliver result to a provider", isOn: self.$deliver)
|
||||
Toggle("Deliver result to a channel", isOn: self.$deliver)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
@@ -318,15 +331,15 @@ struct CronJobEditor: View {
|
||||
if self.deliver {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Provider")
|
||||
Picker("", selection: self.$provider) {
|
||||
Text("last").tag(GatewayAgentProvider.last)
|
||||
Text("whatsapp").tag(GatewayAgentProvider.whatsapp)
|
||||
Text("telegram").tag(GatewayAgentProvider.telegram)
|
||||
Text("discord").tag(GatewayAgentProvider.discord)
|
||||
Text("slack").tag(GatewayAgentProvider.slack)
|
||||
Text("signal").tag(GatewayAgentProvider.signal)
|
||||
Text("imessage").tag(GatewayAgentProvider.imessage)
|
||||
self.gridLabel("Channel")
|
||||
Picker("", selection: self.$channel) {
|
||||
Text("last").tag(GatewayAgentChannel.last)
|
||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
||||
Text("telegram").tag(GatewayAgentChannel.telegram)
|
||||
Text("discord").tag(GatewayAgentChannel.discord)
|
||||
Text("slack").tag(GatewayAgentChannel.slack)
|
||||
Text("signal").tag(GatewayAgentChannel.signal)
|
||||
Text("imessage").tag(GatewayAgentChannel.imessage)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
@@ -74,12 +74,12 @@ enum CronPayload: Codable, Equatable {
|
||||
thinking: String?,
|
||||
timeoutSeconds: Int?,
|
||||
deliver: Bool?,
|
||||
provider: String?,
|
||||
channel: String?,
|
||||
to: String?,
|
||||
bestEffortDeliver: Bool?)
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case kind, text, message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver
|
||||
case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver
|
||||
}
|
||||
|
||||
var kind: String {
|
||||
@@ -101,7 +101,8 @@ enum CronPayload: Codable, Equatable {
|
||||
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
|
||||
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
|
||||
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
|
||||
provider: container.decodeIfPresent(String.self, forKey: .provider),
|
||||
channel: container.decodeIfPresent(String.self, forKey: .channel)
|
||||
?? container.decodeIfPresent(String.self, forKey: .provider),
|
||||
to: container.decodeIfPresent(String.self, forKey: .to),
|
||||
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
||||
default:
|
||||
@@ -118,12 +119,12 @@ enum CronPayload: Codable, Equatable {
|
||||
switch self {
|
||||
case let .systemEvent(text):
|
||||
try container.encode(text, forKey: .text)
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver):
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
||||
try container.encode(message, forKey: .message)
|
||||
try container.encodeIfPresent(thinking, forKey: .thinking)
|
||||
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
||||
try container.encodeIfPresent(deliver, forKey: .deliver)
|
||||
try container.encodeIfPresent(provider, forKey: .provider)
|
||||
try container.encodeIfPresent(channel, forKey: .channel)
|
||||
try container.encodeIfPresent(to, forKey: .to)
|
||||
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
|
||||
}
|
||||
@@ -145,9 +146,11 @@ struct CronJobState: Codable, Equatable {
|
||||
|
||||
struct CronJob: Identifiable, Codable, Equatable {
|
||||
let id: String
|
||||
let agentId: String?
|
||||
var name: String
|
||||
var description: String?
|
||||
var enabled: Bool
|
||||
var deleteAfterRun: Bool?
|
||||
let createdAtMs: Int
|
||||
let updatedAtMs: Int
|
||||
let schedule: CronSchedule
|
||||
|
||||
@@ -20,6 +20,9 @@ extension CronSettings {
|
||||
HStack(spacing: 6) {
|
||||
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
|
||||
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
|
||||
if let agentId = job.agentId, !agentId.isEmpty {
|
||||
StatusPill(text: "agent \(agentId)", tint: .secondary)
|
||||
}
|
||||
if let status = job.state.lastStatus {
|
||||
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
|
||||
}
|
||||
@@ -91,9 +94,15 @@ extension CronSettings {
|
||||
func detailCard(_ job: CronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
|
||||
if case .at = job.schedule, job.deleteAfterRun == true {
|
||||
LabeledContent("Auto-delete") { Text("after success") }
|
||||
}
|
||||
if let desc = job.description, !desc.isEmpty {
|
||||
LabeledContent("Description") { Text(desc).font(.callout) }
|
||||
}
|
||||
if let agentId = job.agentId, !agentId.isEmpty {
|
||||
LabeledContent("Agent") { Text(agentId) }
|
||||
}
|
||||
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
|
||||
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
|
||||
LabeledContent("Next run") {
|
||||
|
||||
@@ -7,9 +7,11 @@ struct CronSettings_Previews: PreviewProvider {
|
||||
store.jobs = [
|
||||
CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: "Daily summary",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
|
||||
@@ -20,7 +22,7 @@ struct CronSettings_Previews: PreviewProvider {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 600,
|
||||
deliver: true,
|
||||
provider: "last",
|
||||
channel: "last",
|
||||
to: nil,
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||
@@ -59,9 +61,11 @@ extension CronSettings {
|
||||
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: "Daily summary",
|
||||
description: "Summary job",
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_100_000,
|
||||
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
|
||||
@@ -72,7 +76,7 @@ extension CronSettings {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 120,
|
||||
deliver: true,
|
||||
provider: "whatsapp",
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||
|
||||
@@ -5,6 +5,7 @@ import SwiftUI
|
||||
enum DebugActions {
|
||||
private static let verboseDefaultsKey = "clawdbot.debug.verboseMain"
|
||||
private static let sessionMenuLimit = 12
|
||||
private static let onboardingSeenKey = "clawdbot.onboardingSeen"
|
||||
|
||||
@MainActor
|
||||
static func openAgentEventsWindow() {
|
||||
@@ -183,6 +184,14 @@ enum DebugActions {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func restartOnboarding() {
|
||||
UserDefaults.standard.set(false, forKey: self.onboardingSeenKey)
|
||||
UserDefaults.standard.set(0, forKey: onboardingVersionKey)
|
||||
AppStateStore.shared.onboardingSeen = false
|
||||
OnboardingController.shared.restart()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func resolveSessionStorePath() -> String {
|
||||
let defaultPath = SessionLoader.defaultStorePath
|
||||
|
||||
@@ -28,7 +28,6 @@ struct DebugSettings: View {
|
||||
@State private var tunnelResetInFlight = false
|
||||
@State private var tunnelResetStatus: String?
|
||||
@State private var pendingKill: DebugActions.PortListener?
|
||||
@AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false
|
||||
@AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false
|
||||
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
|
||||
|
||||
@@ -108,7 +107,7 @@ struct DebugSettings: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("CLI helper")
|
||||
self.gridLabel("CLI")
|
||||
let loc = CLIInstaller.installedLocation()
|
||||
Text(loc ?? "missing")
|
||||
.font(.caption.monospaced())
|
||||
@@ -145,16 +144,6 @@ struct DebugSettings: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Attach only")
|
||||
Toggle("", isOn: self.$attachExistingGatewayOnly)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.help(
|
||||
"When enabled in local mode, the mac app will only connect " +
|
||||
"to an already-running gateway " +
|
||||
"and will not start one itself.")
|
||||
}
|
||||
}
|
||||
|
||||
let key = DeepLinkHandler.currentKey()
|
||||
@@ -497,6 +486,7 @@ struct DebugSettings: View {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Restart app") { DebugActions.restartApp() }
|
||||
Button("Restart onboarding") { DebugActions.restartOnboarding() }
|
||||
Button("Reveal app in Finder") { self.revealApp() }
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -782,7 +772,7 @@ struct DebugSettings: View {
|
||||
}
|
||||
|
||||
private var canRestartGateway: Bool {
|
||||
self.state.connectionMode == .local && !self.attachExistingGatewayOnly
|
||||
self.state.connectionMode == .local
|
||||
}
|
||||
|
||||
private func configURL() -> URL {
|
||||
|
||||
@@ -59,7 +59,7 @@ final class DeepLinkHandler {
|
||||
}
|
||||
|
||||
do {
|
||||
let provider = GatewayAgentProvider(raw: link.channel)
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
let explicitSessionKey = link.sessionKey?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.nonEmpty
|
||||
@@ -72,9 +72,9 @@ final class DeepLinkHandler {
|
||||
message: messagePreview,
|
||||
sessionKey: resolvedSessionKey,
|
||||
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||
deliver: provider.shouldDeliver(link.deliver),
|
||||
deliver: channel.shouldDeliver(link.deliver),
|
||||
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||
provider: provider,
|
||||
channel: channel,
|
||||
timeoutSeconds: link.timeoutSeconds,
|
||||
idempotencyKey: UUID().uuidString)
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewayAgentChannel: String, CaseIterable, Sendable {
|
||||
case last
|
||||
case webchat
|
||||
case whatsapp
|
||||
case telegram
|
||||
|
||||
init(raw: String?) {
|
||||
let trimmed = raw?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased() ?? ""
|
||||
self = GatewayAgentChannel(rawValue: trimmed) ?? .last
|
||||
}
|
||||
|
||||
func shouldDeliver(_ isLast: Bool) -> Bool {
|
||||
switch self {
|
||||
case .webchat:
|
||||
false
|
||||
case .last:
|
||||
isLast
|
||||
case .whatsapp, .telegram:
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,8 @@ enum GatewayAutostartPolicy {
|
||||
|
||||
static func shouldEnsureLaunchAgent(
|
||||
mode: AppState.ConnectionMode,
|
||||
paused: Bool,
|
||||
attachExistingOnly: Bool) -> Bool
|
||||
paused: Bool) -> Bool
|
||||
{
|
||||
self.shouldStartGateway(mode: mode, paused: paused) && !attachExistingOnly
|
||||
self.shouldStartGateway(mode: mode, paused: paused)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
@@ -75,6 +76,7 @@ actor GatewayChannelActor {
|
||||
private var tickIntervalMs: Double = 30000
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private let connectTimeoutSeconds: Double = 6
|
||||
private var watchdogTask: Task<Void, Never>?
|
||||
private var tickTask: Task<Void, Never>?
|
||||
private let defaultRequestTimeoutMs: Double = 15000
|
||||
@@ -163,7 +165,15 @@ actor GatewayChannelActor {
|
||||
self.task = self.session.makeWebSocketTask(url: self.url)
|
||||
self.task?.resume()
|
||||
do {
|
||||
try await self.sendConnect()
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: self.connectTimeoutSeconds,
|
||||
onTimeout: {
|
||||
NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect timed out"])
|
||||
},
|
||||
operation: { try await self.sendConnect() })
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
|
||||
self.connected = false
|
||||
@@ -192,15 +202,17 @@ actor GatewayChannelActor {
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||
let clientName = InstanceIdentity.displayName
|
||||
let clientDisplayName = InstanceIdentity.displayName
|
||||
let clientId = "clawdbot-macos"
|
||||
|
||||
let reqId = UUID().uuidString
|
||||
var client: [String: ProtoAnyCodable] = [
|
||||
"name": ProtoAnyCodable(clientName),
|
||||
"id": ProtoAnyCodable(clientId),
|
||||
"displayName": ProtoAnyCodable(clientDisplayName),
|
||||
"version": ProtoAnyCodable(
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
||||
"platform": ProtoAnyCodable(platform),
|
||||
"mode": ProtoAnyCodable("app"),
|
||||
"mode": ProtoAnyCodable("ui"),
|
||||
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
|
||||
]
|
||||
client["deviceFamily"] = ProtoAnyCodable("Mac")
|
||||
@@ -379,7 +391,11 @@ actor GatewayChannelActor {
|
||||
}
|
||||
}
|
||||
|
||||
func request(method: String, params: [String: AnyCodable]?, timeoutMs: Double? = nil) async throws -> Data {
|
||||
func request(
|
||||
method: String,
|
||||
params: [String: ClawdbotProtocol.AnyCodable]?,
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
@@ -430,8 +446,8 @@ actor GatewayChannelActor {
|
||||
if res.ok == false {
|
||||
let code = res.error?["code"]?.value as? String
|
||||
let msg = res.error?["message"]?.value as? String
|
||||
let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in
|
||||
acc[pair.key] = AnyCodable(pair.value.value)
|
||||
let details: [String: ClawdbotProtocol.AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in
|
||||
acc[pair.key] = ClawdbotProtocol.AnyCodable(pair.value.value)
|
||||
}
|
||||
throw GatewayResponseError(method: method, code: code, message: msg, details: details)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import OSLog
|
||||
|
||||
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection")
|
||||
|
||||
enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
|
||||
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||
case last
|
||||
case whatsapp
|
||||
case telegram
|
||||
@@ -13,11 +13,12 @@ enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
|
||||
case slack
|
||||
case signal
|
||||
case imessage
|
||||
case msteams
|
||||
case webchat
|
||||
|
||||
init(raw: String?) {
|
||||
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
self = GatewayAgentProvider(rawValue: normalized) ?? .last
|
||||
self = GatewayAgentChannel(rawValue: normalized) ?? .last
|
||||
}
|
||||
|
||||
var isDeliverable: Bool { self != .webchat }
|
||||
@@ -31,7 +32,7 @@ struct GatewayAgentInvocation: Sendable {
|
||||
var thinking: String?
|
||||
var deliver: Bool = false
|
||||
var to: String?
|
||||
var provider: GatewayAgentProvider = .last
|
||||
var channel: GatewayAgentChannel = .last
|
||||
var timeoutSeconds: Int?
|
||||
var idempotencyKey: String = UUID().uuidString
|
||||
}
|
||||
@@ -51,9 +52,10 @@ actor GatewayConnection {
|
||||
case setHeartbeats = "set-heartbeats"
|
||||
case systemEvent = "system-event"
|
||||
case health
|
||||
case providersStatus = "providers.status"
|
||||
case channelsStatus = "channels.status"
|
||||
case configGet = "config.get"
|
||||
case configSet = "config.set"
|
||||
case configPatch = "config.patch"
|
||||
case wizardStart = "wizard.start"
|
||||
case wizardNext = "wizard.next"
|
||||
case wizardCancel = "wizard.cancel"
|
||||
@@ -61,8 +63,7 @@ actor GatewayConnection {
|
||||
case talkMode = "talk.mode"
|
||||
case webLoginStart = "web.login.start"
|
||||
case webLoginWait = "web.login.wait"
|
||||
case webLogout = "web.logout"
|
||||
case telegramLogout = "telegram.logout"
|
||||
case channelsLogout = "channels.logout"
|
||||
case modelsList = "models.list"
|
||||
case chatHistory = "chat.history"
|
||||
case chatSend = "chat.send"
|
||||
@@ -232,7 +233,18 @@ actor GatewayConnection {
|
||||
|
||||
func canvasHostUrl() async -> String? {
|
||||
guard let snapshot = self.lastSnapshot else { return nil }
|
||||
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
|
||||
(defaults?[key]?.stringValue ?? "")
|
||||
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func cachedMainSessionKey() -> String? {
|
||||
guard let snapshot = self.lastSnapshot else { return nil }
|
||||
let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey")
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
@@ -267,12 +279,35 @@ actor GatewayConnection {
|
||||
private func broadcast(_ push: GatewayPush) {
|
||||
if case let .snapshot(snapshot) = push {
|
||||
self.lastSnapshot = snapshot
|
||||
if let mainSessionKey = self.cachedMainSessionKey() {
|
||||
Task { @MainActor in
|
||||
WorkActivityStore.shared.setMainSessionKey(mainSessionKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, continuation) in self.subscribers {
|
||||
continuation.yield(push)
|
||||
}
|
||||
}
|
||||
|
||||
private func canonicalizeSessionKey(_ raw: String) -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return trimmed }
|
||||
guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed }
|
||||
let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey")
|
||||
guard !mainSessionKey.isEmpty else { return trimmed }
|
||||
let mainKey = self.sessionDefaultString(defaults, key: "mainKey")
|
||||
let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId")
|
||||
let isMainAlias =
|
||||
trimmed == "main" ||
|
||||
(!mainKey.isEmpty && trimmed == mainKey) ||
|
||||
trimmed == mainSessionKey ||
|
||||
(!defaultAgentId.isEmpty &&
|
||||
(trimmed == "agent:\(defaultAgentId):main" ||
|
||||
(mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)")))
|
||||
return isMainAlias ? mainSessionKey : trimmed
|
||||
}
|
||||
|
||||
private func configure(url: URL, token: String?, password: String?) async {
|
||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token,
|
||||
self.configuredPassword == password
|
||||
@@ -331,6 +366,9 @@ extension GatewayConnection {
|
||||
}
|
||||
|
||||
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
|
||||
if let cached = self.cachedMainSessionKey() {
|
||||
return cached
|
||||
}
|
||||
do {
|
||||
let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
|
||||
return try Self.mainSessionKey(fromConfigGetData: data)
|
||||
@@ -361,14 +399,15 @@ extension GatewayConnection {
|
||||
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
|
||||
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return (false, "message empty") }
|
||||
let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey)
|
||||
|
||||
var params: [String: AnyCodable] = [
|
||||
"message": AnyCodable(trimmed),
|
||||
"sessionKey": AnyCodable(invocation.sessionKey),
|
||||
"sessionKey": AnyCodable(sessionKey),
|
||||
"thinking": AnyCodable(invocation.thinking ?? "default"),
|
||||
"deliver": AnyCodable(invocation.deliver),
|
||||
"to": AnyCodable(invocation.to ?? ""),
|
||||
"provider": AnyCodable(invocation.provider.rawValue),
|
||||
"channel": AnyCodable(invocation.channel.rawValue),
|
||||
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
|
||||
]
|
||||
if let timeout = invocation.timeoutSeconds {
|
||||
@@ -389,7 +428,7 @@ extension GatewayConnection {
|
||||
sessionKey: String,
|
||||
deliver: Bool,
|
||||
to: String?,
|
||||
provider: GatewayAgentProvider = .last,
|
||||
channel: GatewayAgentChannel = .last,
|
||||
timeoutSeconds: Int? = nil,
|
||||
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
|
||||
{
|
||||
@@ -399,7 +438,7 @@ extension GatewayConnection {
|
||||
thinking: thinking,
|
||||
deliver: deliver,
|
||||
to: to,
|
||||
provider: provider,
|
||||
channel: channel,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
idempotencyKey: idempotencyKey))
|
||||
}
|
||||
@@ -468,7 +507,8 @@ extension GatewayConnection {
|
||||
limit: Int? = nil,
|
||||
timeoutMs: Int? = nil) async throws -> ClawdbotChatHistoryPayload
|
||||
{
|
||||
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(sessionKey)]
|
||||
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
||||
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)]
|
||||
if let limit { params["limit"] = AnyCodable(limit) }
|
||||
let timeout = timeoutMs.map { Double($0) }
|
||||
return try await self.requestDecoded(
|
||||
@@ -485,8 +525,9 @@ extension GatewayConnection {
|
||||
attachments: [ClawdbotChatAttachmentPayload],
|
||||
timeoutMs: Int = 30000) async throws -> ClawdbotChatSendResponse
|
||||
{
|
||||
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
||||
var params: [String: AnyCodable] = [
|
||||
"sessionKey": AnyCodable(sessionKey),
|
||||
"sessionKey": AnyCodable(resolvedKey),
|
||||
"message": AnyCodable(message),
|
||||
"thinking": AnyCodable(thinking),
|
||||
"idempotencyKey": AnyCodable(idempotencyKey),
|
||||
@@ -512,10 +553,11 @@ extension GatewayConnection {
|
||||
}
|
||||
|
||||
func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
|
||||
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
||||
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
|
||||
let res: AbortResponse = try await self.requestDecoded(
|
||||
method: .chatAbort,
|
||||
params: ["sessionKey": AnyCodable(sessionKey), "runId": AnyCodable(runId)])
|
||||
params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)])
|
||||
return res.aborted ?? false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotDiscovery
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayDiscoveryInlineList: View {
|
||||
|
||||
@@ -3,6 +3,7 @@ import OSLog
|
||||
|
||||
enum GatewayEndpointState: Sendable, Equatable {
|
||||
case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?)
|
||||
case connecting(mode: AppState.ConnectionMode, detail: String)
|
||||
case unavailable(mode: AppState.ConnectionMode, reason: String)
|
||||
}
|
||||
|
||||
@@ -14,6 +15,7 @@ enum GatewayEndpointState: Sendable, Equatable {
|
||||
actor GatewayEndpointStore {
|
||||
static let shared = GatewayEndpointStore()
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
private static let remoteConnectingDetail = "Connecting to remote gateway…"
|
||||
|
||||
struct Deps: Sendable {
|
||||
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||
@@ -26,13 +28,21 @@ actor GatewayEndpointStore {
|
||||
|
||||
static let live = Deps(
|
||||
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
||||
token: { ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] },
|
||||
token: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
return GatewayEndpointStore.resolveGatewayToken(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||
},
|
||||
password: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
return GatewayEndpointStore.resolveGatewayPassword(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||
},
|
||||
localPort: { GatewayEnvironment.gatewayPort() },
|
||||
localHost: {
|
||||
@@ -52,7 +62,8 @@ actor GatewayEndpointStore {
|
||||
private static func resolveGatewayPassword(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]) -> String?
|
||||
env: [String: String],
|
||||
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
|
||||
{
|
||||
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -80,6 +91,51 @@ actor GatewayEndpointStore {
|
||||
return pw
|
||||
}
|
||||
}
|
||||
if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!password.isEmpty
|
||||
{
|
||||
return password
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveGatewayToken(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String],
|
||||
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
|
||||
{
|
||||
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let token = remote["token"] as? String
|
||||
{
|
||||
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let token = auth["token"] as? String
|
||||
{
|
||||
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
{
|
||||
return token
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -88,6 +144,7 @@ actor GatewayEndpointStore {
|
||||
|
||||
private var state: GatewayEndpointState
|
||||
private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:]
|
||||
private var remoteEnsure: (token: UUID, task: Task<UInt16, Error>)?
|
||||
|
||||
init(deps: Deps = .live) {
|
||||
self.deps = deps
|
||||
@@ -115,7 +172,8 @@ actor GatewayEndpointStore {
|
||||
token: token,
|
||||
password: password)
|
||||
case .remote:
|
||||
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
|
||||
self.state = .connecting(mode: .remote, detail: Self.remoteConnectingDetail)
|
||||
Task { await self.setMode(.remote) }
|
||||
case .unconfigured:
|
||||
self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured")
|
||||
}
|
||||
@@ -144,6 +202,7 @@ actor GatewayEndpointStore {
|
||||
let password = self.deps.password()
|
||||
switch mode {
|
||||
case .local:
|
||||
self.cancelRemoteEnsure()
|
||||
let port = self.deps.localPort()
|
||||
let host = await self.deps.localHost()
|
||||
self.setState(.ready(
|
||||
@@ -154,15 +213,18 @@ actor GatewayEndpointStore {
|
||||
case .remote:
|
||||
let port = await self.deps.remotePortIfRunning()
|
||||
guard let port else {
|
||||
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
|
||||
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
||||
self.kickRemoteEnsureIfNeeded(detail: Self.remoteConnectingDetail)
|
||||
return
|
||||
}
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.ready(
|
||||
mode: .remote,
|
||||
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
|
||||
token: token,
|
||||
password: password))
|
||||
case .unconfigured:
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
||||
}
|
||||
}
|
||||
@@ -176,8 +238,13 @@ actor GatewayEndpointStore {
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
let port = try await self.deps.ensureRemoteTunnel()
|
||||
await self.setMode(.remote)
|
||||
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"])
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
@@ -186,6 +253,11 @@ actor GatewayEndpointStore {
|
||||
switch self.state {
|
||||
case let .ready(_, url, token, password):
|
||||
return (url, token, password)
|
||||
case let .connecting(mode, _):
|
||||
guard mode == .remote else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
||||
}
|
||||
return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||
case let .unavailable(mode, reason):
|
||||
guard mode == .remote else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
|
||||
@@ -193,21 +265,76 @@ actor GatewayEndpointStore {
|
||||
|
||||
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet),
|
||||
// recreate it on demand so callers can recover without a manual reconnect.
|
||||
do {
|
||||
self.logger.info(
|
||||
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)")
|
||||
let forwarded = try await self.deps.ensureRemoteTunnel()
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
|
||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||
return (url, token, password)
|
||||
} catch {
|
||||
let msg = "\(reason) (\(error.localizedDescription))"
|
||||
self.setState(.unavailable(mode: .remote, reason: msg))
|
||||
self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)")
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
self.logger.info(
|
||||
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)")
|
||||
return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelRemoteEnsure() {
|
||||
self.remoteEnsure?.task.cancel()
|
||||
self.remoteEnsure = nil
|
||||
}
|
||||
|
||||
private func kickRemoteEnsureIfNeeded(detail: String) {
|
||||
if self.remoteEnsure != nil {
|
||||
self.setState(.connecting(mode: .remote, detail: detail))
|
||||
return
|
||||
}
|
||||
|
||||
let deps = self.deps
|
||||
let token = UUID()
|
||||
let task = Task.detached(priority: .utility) { try await deps.ensureRemoteTunnel() }
|
||||
self.remoteEnsure = (token: token, task: task)
|
||||
self.setState(.connecting(mode: .remote, detail: detail))
|
||||
}
|
||||
|
||||
private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config {
|
||||
let mode = await self.deps.mode()
|
||||
guard mode == .remote else {
|
||||
throw NSError(
|
||||
domain: "RemoteTunnel",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
|
||||
self.kickRemoteEnsureIfNeeded(detail: detail)
|
||||
guard let ensure = self.remoteEnsure else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
||||
}
|
||||
|
||||
do {
|
||||
let forwarded = try await ensure.task.value
|
||||
let stillRemote = await self.deps.mode() == .remote
|
||||
guard stillRemote else {
|
||||
throw NSError(
|
||||
domain: "RemoteTunnel",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
|
||||
if self.remoteEnsure?.token == ensure.token {
|
||||
self.remoteEnsure = nil
|
||||
}
|
||||
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
|
||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||
return (url, token, password)
|
||||
} catch let err as CancellationError {
|
||||
if self.remoteEnsure?.token == ensure.token {
|
||||
self.remoteEnsure = nil
|
||||
}
|
||||
throw err
|
||||
} catch {
|
||||
if self.remoteEnsure?.token == ensure.token {
|
||||
self.remoteEnsure = nil
|
||||
}
|
||||
let msg = "Remote control tunnel failed (\(error.localizedDescription))"
|
||||
self.setState(.unavailable(mode: .remote, reason: msg))
|
||||
self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)")
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +355,11 @@ actor GatewayEndpointStore {
|
||||
self.logger
|
||||
.debug(
|
||||
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)")
|
||||
case let .connecting(mode, detail):
|
||||
let modeDesc = String(describing: mode)
|
||||
self.logger
|
||||
.debug(
|
||||
"endpoint connecting mode=\(modeDesc, privacy: .public) detail=\(detail, privacy: .public)")
|
||||
case let .unavailable(mode, reason):
|
||||
let modeDesc = String(describing: mode)
|
||||
self.logger
|
||||
@@ -270,14 +402,61 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
}
|
||||
|
||||
extension GatewayEndpointStore {
|
||||
static func dashboardURL(for config: GatewayConnection.Config) throws -> URL {
|
||||
guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else {
|
||||
throw NSError(domain: "Dashboard", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Invalid gateway URL",
|
||||
])
|
||||
}
|
||||
switch components.scheme?.lowercased() {
|
||||
case "ws":
|
||||
components.scheme = "http"
|
||||
case "wss":
|
||||
components.scheme = "https"
|
||||
default:
|
||||
components.scheme = "http"
|
||||
}
|
||||
components.path = "/"
|
||||
var queryItems: [URLQueryItem] = []
|
||||
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
{
|
||||
queryItems.append(URLQueryItem(name: "token", value: token))
|
||||
}
|
||||
if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!password.isEmpty
|
||||
{
|
||||
queryItems.append(URLQueryItem(name: "password", value: password))
|
||||
}
|
||||
components.queryItems = queryItems.isEmpty ? nil : queryItems
|
||||
guard let url = components.url else {
|
||||
throw NSError(domain: "Dashboard", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
|
||||
])
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GatewayEndpointStore {
|
||||
static func _testResolveGatewayPassword(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]) -> String?
|
||||
env: [String: String],
|
||||
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
|
||||
{
|
||||
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
|
||||
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
|
||||
}
|
||||
|
||||
static func _testResolveGatewayToken(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String],
|
||||
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
|
||||
{
|
||||
self.resolveGatewayToken(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
|
||||
}
|
||||
|
||||
static func _testResolveGatewayBindMode(
|
||||
|
||||
@@ -65,12 +65,6 @@ enum GatewayEnvironment {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.env")
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
|
||||
static func bundledGatewayExecutable() -> String? {
|
||||
guard let res = Bundle.main.resourceURL else { return nil }
|
||||
let path = res.appendingPathComponent("Relay/clawdbot").path
|
||||
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
|
||||
}
|
||||
|
||||
static func gatewayPort() -> Int {
|
||||
if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PORT"] {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -105,28 +99,6 @@ enum GatewayEnvironment {
|
||||
}
|
||||
let expected = self.expectedGatewayVersion()
|
||||
|
||||
if let bundled = self.bundledGatewayExecutable() {
|
||||
let installed = self.readGatewayVersion(binary: bundled)
|
||||
if let expected, let installed, !installed.compatible(with: expected) {
|
||||
let message =
|
||||
"Bundled gateway \(installed.description) is incompatible with app " +
|
||||
"\(expected.description); rebuild the app bundle."
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .incompatible(found: installed.description, required: expected.description),
|
||||
nodeVersion: nil,
|
||||
gatewayVersion: installed.description,
|
||||
requiredGateway: expected.description,
|
||||
message: message)
|
||||
}
|
||||
let gatewayVersionText = installed?.description ?? "unknown"
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .ok,
|
||||
nodeVersion: nil,
|
||||
gatewayVersion: gatewayVersionText,
|
||||
requiredGateway: expected?.description,
|
||||
message: "Bundled gateway \(gatewayVersionText) (bun)")
|
||||
}
|
||||
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||
|
||||
@@ -147,7 +119,7 @@ enum GatewayEnvironment {
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: expected?.description,
|
||||
message: "clawdbot CLI not found in PATH; install the global package.")
|
||||
message: "clawdbot CLI not found in PATH; install the CLI.")
|
||||
}
|
||||
|
||||
let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) }
|
||||
@@ -197,7 +169,6 @@ enum GatewayEnvironment {
|
||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||
let status = self.check()
|
||||
let gatewayBin = CommandResolver.clawdbotExecutable()
|
||||
let bundled = self.bundledGatewayExecutable()
|
||||
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
|
||||
|
||||
guard case .ok = status.kind else {
|
||||
@@ -205,20 +176,17 @@ enum GatewayEnvironment {
|
||||
}
|
||||
|
||||
let port = self.gatewayPort()
|
||||
if let bundled {
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
let cmd = [bundled, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
return GatewayCommandResolution(status: status, command: cmd)
|
||||
}
|
||||
if let gatewayBin {
|
||||
let cmd = [gatewayBin, "gateway", "--port", "\(port)"]
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
return GatewayCommandResolution(status: status, command: cmd)
|
||||
}
|
||||
|
||||
if let entry = projectEntrypoint,
|
||||
case let .success(resolvedRuntime) = runtime
|
||||
{
|
||||
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)"]
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
return GatewayCommandResolution(status: status, command: cmd)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,312 +2,167 @@ import Foundation
|
||||
|
||||
enum GatewayLaunchAgentManager {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
|
||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||
|
||||
private static var plistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
private static var legacyPlistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
private static func gatewayExecutablePath(bundlePath: String) -> String {
|
||||
"\(bundlePath)/Contents/Resources/Relay/clawdbot"
|
||||
}
|
||||
|
||||
private static func relayDir(bundlePath: String) -> String {
|
||||
"\(bundlePath)/Contents/Resources/Relay"
|
||||
}
|
||||
|
||||
private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] {
|
||||
#if DEBUG
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
|
||||
return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
|
||||
}
|
||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
||||
case let .success(runtime) = CommandResolver.runtimeResolution()
|
||||
{
|
||||
return CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "gateway",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
}
|
||||
#endif
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
}
|
||||
|
||||
static func isLoaded() async -> Bool {
|
||||
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
||||
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return result.status == 0
|
||||
guard let loaded = await self.readDaemonLoaded() else { return false }
|
||||
return loaded
|
||||
}
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||
if enabled {
|
||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
||||
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
|
||||
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
|
||||
}
|
||||
|
||||
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
||||
let desiredToken = self.preferredGatewayToken()
|
||||
let desiredPassword = self.preferredGatewayPassword()
|
||||
let desiredConfig = DesiredConfig(
|
||||
port: port,
|
||||
bind: desiredBind,
|
||||
token: desiredToken,
|
||||
password: desiredPassword)
|
||||
|
||||
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
|
||||
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
|
||||
let loaded = await self.isLoaded()
|
||||
if loaded,
|
||||
let existing = self.readPlistConfig(),
|
||||
existing.matches(desiredConfig)
|
||||
{
|
||||
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
||||
await self.ensureEnabled()
|
||||
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return nil
|
||||
}
|
||||
|
||||
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
||||
self.writePlist(bundlePath: bundlePath, port: port)
|
||||
|
||||
await self.ensureEnabled()
|
||||
if loaded {
|
||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
}
|
||||
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||
if bootstrap.status != 0 {
|
||||
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.logger.error("launchd bootstrap failed: \(msg)")
|
||||
return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "Failed to bootstrap gateway launchd job"
|
||||
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
await self.ensureEnabled()
|
||||
_ = bundlePath
|
||||
if enabled, self.isLaunchAgentWriteDisabled() {
|
||||
self.logger.info("launchd enable skipped (disable marker set)")
|
||||
return nil
|
||||
}
|
||||
|
||||
self.logger.info("launchd disable requested")
|
||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
await self.ensureDisabled()
|
||||
try? FileManager.default.removeItem(at: self.plistURL)
|
||||
return nil
|
||||
if enabled {
|
||||
self.logger.info("launchd enable requested via CLI port=\(port)")
|
||||
return await self.runDaemonCommand([
|
||||
"install",
|
||||
"--force",
|
||||
"--port",
|
||||
"\(port)",
|
||||
"--runtime",
|
||||
"node",
|
||||
])
|
||||
}
|
||||
|
||||
self.logger.info("launchd disable requested via CLI")
|
||||
return await self.runDaemonCommand(["uninstall"])
|
||||
}
|
||||
|
||||
static func kickstart() async {
|
||||
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
_ = await self.runDaemonCommand(["restart"], timeout: 20)
|
||||
}
|
||||
|
||||
private static func writePlist(bundlePath: String, port: Int) {
|
||||
let relayDir = self.relayDir(bundlePath: bundlePath)
|
||||
let preferredPath = ([relayDir] + CommandResolver.preferredPaths())
|
||||
.joined(separator: ":")
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
let programArguments = self.gatewayProgramArguments(bundlePath: bundlePath, port: port, bind: bind)
|
||||
let token = self.preferredGatewayToken()
|
||||
let password = self.preferredGatewayPassword()
|
||||
var envEntries = """
|
||||
<key>PATH</key>
|
||||
<string>\(preferredPath)</string>
|
||||
<key>CLAWDBOT_IMAGE_BACKEND</key>
|
||||
<string>sips</string>
|
||||
"""
|
||||
if let token {
|
||||
let escapedToken = self.escapePlistValue(token)
|
||||
envEntries += """
|
||||
<key>CLAWDBOT_GATEWAY_TOKEN</key>
|
||||
<string>\(escapedToken)</string>
|
||||
"""
|
||||
}
|
||||
if let password {
|
||||
let escapedPassword = self.escapePlistValue(password)
|
||||
envEntries += """
|
||||
<key>CLAWDBOT_GATEWAY_PASSWORD</key>
|
||||
<string>\(escapedPassword)</string>
|
||||
"""
|
||||
}
|
||||
let argsXml = programArguments
|
||||
.map { "<string>\(self.escapePlistValue($0))</string>" }
|
||||
.joined(separator: "\n ")
|
||||
let plist = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>\(gatewayLaunchdLabel)</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
\(argsXml)
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
\(envEntries)
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>\(LogLocator.launchdGatewayLogPath)</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>\(LogLocator.launchdGatewayLogPath)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
do {
|
||||
try plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
self.logger.error("launchd plist write failed: \(error.localizedDescription)")
|
||||
}
|
||||
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
|
||||
LaunchAgentPlist.snapshot(url: self.plistURL)
|
||||
}
|
||||
|
||||
private static func preferredGatewayBind() -> String? {
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
static func launchdGatewayLogPath() -> String {
|
||||
let snapshot = self.launchdConfigSnapshot()
|
||||
if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!stdout.isEmpty
|
||||
{
|
||||
return stdout
|
||||
}
|
||||
if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!stderr.isEmpty
|
||||
{
|
||||
return stderr
|
||||
}
|
||||
return LogLocator.launchdGatewayLogPath
|
||||
}
|
||||
}
|
||||
|
||||
extension GatewayLaunchAgentManager {
|
||||
private static func isLaunchAgentWriteDisabled() -> Bool {
|
||||
let marker = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||
return FileManager.default.fileExists(atPath: marker.path)
|
||||
}
|
||||
|
||||
private static func readDaemonLoaded() async -> Bool? {
|
||||
let result = await self.runDaemonCommandResult(
|
||||
["status", "--json", "--no-probe"],
|
||||
timeout: 15,
|
||||
quiet: true)
|
||||
guard result.success, let payload = result.payload else { return nil }
|
||||
guard
|
||||
let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
||||
let service = json["service"] as? [String: Any],
|
||||
let loaded = service["loaded"] as? Bool
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] {
|
||||
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.supportedBindModes.contains(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let bind = gateway["bind"] as? String
|
||||
{
|
||||
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.supportedBindModes.contains(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return loaded
|
||||
}
|
||||
|
||||
private static func preferredGatewayToken() -> String? {
|
||||
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||
private struct CommandResult {
|
||||
let success: Bool
|
||||
let payload: Data?
|
||||
let message: String?
|
||||
}
|
||||
|
||||
private struct ParsedDaemonJson {
|
||||
let text: String
|
||||
let object: [String: Any]
|
||||
}
|
||||
|
||||
private static func runDaemonCommand(
|
||||
_ args: [String],
|
||||
timeout: Double = 15,
|
||||
quiet: Bool = false) async -> String?
|
||||
{
|
||||
let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet)
|
||||
if result.success { return nil }
|
||||
return result.message ?? "Gateway daemon command failed"
|
||||
}
|
||||
|
||||
private static func runDaemonCommandResult(
|
||||
_ args: [String],
|
||||
timeout: Double,
|
||||
quiet: Bool) async -> CommandResult
|
||||
{
|
||||
let command = CommandResolver.clawdbotCommand(
|
||||
subcommand: "daemon",
|
||||
extraArgs: self.withJsonFlag(args))
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||
let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr)
|
||||
let ok = parsed?.object["ok"] as? Bool
|
||||
let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String)
|
||||
let payload = parsed?.text.data(using: .utf8)
|
||||
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
|
||||
let success = ok ?? response.success
|
||||
if success {
|
||||
return CommandResult(success: true, payload: payload, message: nil)
|
||||
}
|
||||
|
||||
if quiet {
|
||||
return CommandResult(success: false, payload: payload, message: message)
|
||||
}
|
||||
|
||||
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
|
||||
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
||||
let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" }
|
||||
?? "Gateway daemon command failed (\(exit))"
|
||||
self.logger.error("\(fullMessage, privacy: .public)")
|
||||
return CommandResult(success: false, payload: payload, message: detail)
|
||||
}
|
||||
|
||||
private static func withJsonFlag(_ args: [String]) -> [String] {
|
||||
if args.contains("--json") { return args }
|
||||
return args + ["--json"]
|
||||
}
|
||||
|
||||
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func preferredGatewayPassword() -> String? {
|
||||
// First check environment variable
|
||||
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
guard let start = trimmed.firstIndex(of: "{"),
|
||||
let end = trimmed.lastIndex(of: "}")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
// Then check config file (gateway.auth.password)
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let password = auth["password"] as? String
|
||||
{
|
||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
let jsonText = String(trimmed[start...end])
|
||||
guard let data = jsonText.data(using: .utf8) else { return nil }
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
return ParsedDaemonJson(text: jsonText, object: object)
|
||||
}
|
||||
|
||||
private static func escapePlistValue(_ raw: String) -> String {
|
||||
raw
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
|
||||
private struct DesiredConfig: Equatable {
|
||||
let port: Int
|
||||
let bind: String
|
||||
let token: String?
|
||||
let password: String?
|
||||
}
|
||||
|
||||
private struct InstalledConfig: Equatable {
|
||||
let port: Int?
|
||||
let bind: String?
|
||||
let token: String?
|
||||
let password: String?
|
||||
|
||||
func matches(_ desired: DesiredConfig) -> Bool {
|
||||
guard self.port == desired.port else { return false }
|
||||
guard (self.bind ?? "loopback") == desired.bind else { return false }
|
||||
guard self.token == desired.token else { return false }
|
||||
guard self.password == desired.password else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private static func readPlistConfig() -> InstalledConfig? {
|
||||
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
|
||||
return InstalledConfig(
|
||||
port: snapshot.port,
|
||||
bind: snapshot.bind,
|
||||
token: snapshot.token,
|
||||
password: snapshot.password)
|
||||
}
|
||||
|
||||
private static func ensureEnabled() async {
|
||||
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
guard result.status != 0 else { return }
|
||||
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if msg.isEmpty {
|
||||
self.logger.warning("launchd enable failed")
|
||||
} else {
|
||||
self.logger.warning("launchd enable failed: \(msg)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureDisabled() async {
|
||||
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
guard result.status != 0 else { return }
|
||||
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if msg.isEmpty {
|
||||
self.logger.warning("launchd disable failed")
|
||||
} else {
|
||||
self.logger.warning("launchd disable failed: \(msg)")
|
||||
}
|
||||
private static func summarize(_ text: String) -> String? {
|
||||
let lines = text
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard let last = lines.last else { return nil }
|
||||
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GatewayLaunchAgentManager {
|
||||
static func _testGatewayExecutablePath(bundlePath: String) -> String {
|
||||
self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
}
|
||||
|
||||
static func _testRelayDir(bundlePath: String) -> String {
|
||||
self.relayDir(bundlePath: bundlePath)
|
||||
}
|
||||
|
||||
static func _testPreferredGatewayBind() -> String? {
|
||||
self.preferredGatewayBind()
|
||||
}
|
||||
|
||||
static func _testPreferredGatewayToken() -> String? {
|
||||
self.preferredGatewayToken()
|
||||
}
|
||||
|
||||
static func _testEscapePlistValue(_ raw: String) -> String {
|
||||
self.escapePlistValue(raw)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -69,7 +69,6 @@ final class GatewayProcessManager {
|
||||
|
||||
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||
guard !CommandResolver.connectionModeIsRemote() else { return }
|
||||
guard !AppStateStore.attachExistingGatewayOnly else { return }
|
||||
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
||||
guard !enabled else { return }
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
@@ -88,6 +87,14 @@ final class GatewayProcessManager {
|
||||
self.status = .stopped
|
||||
return
|
||||
}
|
||||
// Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks).
|
||||
// Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port.
|
||||
switch self.status {
|
||||
case .starting, .running, .attachedExisting:
|
||||
return
|
||||
case .stopped, .failed:
|
||||
break
|
||||
}
|
||||
self.status = .starting
|
||||
self.logger.debug("gateway start requested")
|
||||
|
||||
@@ -97,15 +104,6 @@ final class GatewayProcessManager {
|
||||
if await self.attachExistingGatewayIfAvailable() {
|
||||
return
|
||||
}
|
||||
// Respect debug toggle: only attach, never spawn, when enabled.
|
||||
if AppStateStore.attachExistingGatewayOnly {
|
||||
await MainActor.run {
|
||||
self.status = .failed("Attach-only enabled; no gateway to attach")
|
||||
self.appendLog("[gateway] attach-only enabled; not spawning local gateway\n")
|
||||
self.logger.warning("gateway attach-only enabled; not spawning")
|
||||
}
|
||||
return
|
||||
}
|
||||
await self.enableLaunchdGateway()
|
||||
}
|
||||
}
|
||||
@@ -150,7 +148,7 @@ final class GatewayProcessManager {
|
||||
|
||||
func refreshLog() {
|
||||
guard self.logRefreshTask == nil else { return }
|
||||
let path = LogLocator.launchdGatewayLogPath
|
||||
let path = GatewayLaunchAgentManager.launchdGatewayLogPath()
|
||||
let limit = self.logLimit
|
||||
self.logRefreshTask = Task { [weak self] in
|
||||
let log = await Task.detached(priority: .utility) {
|
||||
@@ -221,9 +219,21 @@ final class GatewayProcessManager {
|
||||
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
||||
let instanceText = instance ?? "pid unknown"
|
||||
if let snap {
|
||||
let linked = snap.web.linked ? "linked" : "not linked"
|
||||
let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||
return "port \(port), \(linked), auth \(authAge), \(instanceText)"
|
||||
let linkId = snap.channelOrder?.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
}) ?? snap.channels.keys.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
})
|
||||
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
|
||||
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
||||
let label =
|
||||
linkId.flatMap { snap.channelLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"channel"
|
||||
let linkText = linked ? "linked" : "not linked"
|
||||
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
||||
}
|
||||
return "port \(port), health probe succeeded, \(instanceText)"
|
||||
}
|
||||
@@ -239,7 +249,7 @@ final class GatewayProcessManager {
|
||||
let lower = message.lowercased()
|
||||
if self.isGatewayAuthFailure(error) {
|
||||
return """
|
||||
Gateway on port \(port) rejected auth. Set CLAWDBOT_GATEWAY_TOKEN in the app \
|
||||
Gateway on port \(port) rejected auth. Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) \
|
||||
to match the running gateway (or clear it on the gateway) and retry.
|
||||
"""
|
||||
}
|
||||
@@ -352,7 +362,7 @@ final class GatewayProcessManager {
|
||||
|
||||
func clearLog() {
|
||||
self.log = ""
|
||||
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
|
||||
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
|
||||
self.logger.debug("gateway log cleared")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
@@ -12,7 +13,8 @@ struct GeneralSettings: View {
|
||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||
private let healthStore = HealthStore.shared
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
@State private var gatewayDiscovery = GatewayDiscoveryModel()
|
||||
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
||||
localDisplayName: InstanceIdentity.displayName)
|
||||
@State private var isInstallingCLI = false
|
||||
@State private var cliStatus: String?
|
||||
@State private var cliInstalled = false
|
||||
@@ -28,10 +30,22 @@ struct GeneralSettings: View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !self.state.onboardingSeen {
|
||||
Text("Complete onboarding to finish setup")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundColor(.accentColor)
|
||||
.padding(.bottom, 2)
|
||||
Button {
|
||||
DebugActions.restartOnboarding()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Label("Complete onboarding to finish setup", systemImage: "arrow.counterclockwise")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -69,6 +83,28 @@ struct GeneralSettings: View {
|
||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||
binding: self.$cameraEnabled)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Node Run Commands")
|
||||
.font(.body)
|
||||
|
||||
Picker("", selection: self.$state.systemRunPolicy) {
|
||||
ForEach(SystemRunPolicy.allCases) { policy in
|
||||
Text(policy.title).tag(policy)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Text("""
|
||||
Controls remote command execution on this Mac when it is paired as a node. \
|
||||
"Always Ask" prompts on each command; "Always Allow" runs without prompts; \
|
||||
"Never" disables `system.run`.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Location Access")
|
||||
.font(.body)
|
||||
@@ -78,7 +114,8 @@ struct GeneralSettings: View {
|
||||
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||
.disabled(self.locationMode == .off)
|
||||
@@ -150,13 +187,18 @@ struct GeneralSettings: View {
|
||||
|
||||
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
||||
guard mode != .off else { return true }
|
||||
guard CLLocationManager.locationServicesEnabled() else {
|
||||
await MainActor.run { LocationPermissionHelper.openSettings() }
|
||||
return false
|
||||
}
|
||||
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
|
||||
if status == .authorizedAlways {
|
||||
let requireAlways = mode == .always
|
||||
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
|
||||
return true
|
||||
}
|
||||
let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
|
||||
return updated == .authorizedAlways
|
||||
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
|
||||
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
|
||||
}
|
||||
|
||||
private var connectionSection: some View {
|
||||
@@ -330,7 +372,7 @@ struct GeneralSettings: View {
|
||||
Button {
|
||||
Task { await self.installCLI() }
|
||||
} label: {
|
||||
let title = self.cliInstalled ? "Reinstall CLI helper" : "Install CLI helper"
|
||||
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
|
||||
ZStack {
|
||||
Text(title)
|
||||
.opacity(self.isInstallingCLI ? 0 : 1)
|
||||
@@ -369,7 +411,7 @@ struct GeneralSettings: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text("Symlink \"clawdbot\" into /usr/local/bin and /opt/homebrew/bin for scripts.")
|
||||
Text("Installs a user-space Node 22+ runtime and the CLI (no Homebrew).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
@@ -437,10 +479,8 @@ struct GeneralSettings: View {
|
||||
self.isInstallingCLI = true
|
||||
defer { isInstallingCLI = false }
|
||||
await CLIInstaller.install { status in
|
||||
await MainActor.run {
|
||||
self.cliStatus = status
|
||||
self.refreshCLIStatus()
|
||||
}
|
||||
self.cliStatus = status
|
||||
self.refreshCLIStatus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +519,19 @@ struct GeneralSettings: View {
|
||||
}
|
||||
|
||||
if let snap = snapshot {
|
||||
Text("Linked auth age: \(healthAgeString(snap.web.authAgeMs))")
|
||||
let linkId = snap.channelOrder?.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
}) ?? snap.channels.keys.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
})
|
||||
let linkLabel =
|
||||
linkId.flatMap { snap.channelLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"Link channel"
|
||||
let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }
|
||||
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)")
|
||||
|
||||
@@ -4,35 +4,29 @@ import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct HealthSnapshot: Codable, Sendable {
|
||||
struct Telegram: Codable, Sendable {
|
||||
struct ChannelSummary: Codable, Sendable {
|
||||
struct Probe: Codable, Sendable {
|
||||
struct Bot: Codable, Sendable {
|
||||
let id: Int?
|
||||
let username: String?
|
||||
}
|
||||
|
||||
let ok: Bool
|
||||
struct Webhook: Codable, Sendable {
|
||||
let url: String?
|
||||
}
|
||||
|
||||
let ok: Bool?
|
||||
let status: Int?
|
||||
let error: String?
|
||||
let elapsedMs: Double?
|
||||
let bot: Bot?
|
||||
let webhook: Webhook?
|
||||
}
|
||||
|
||||
let configured: Bool
|
||||
let probe: Probe?
|
||||
}
|
||||
|
||||
struct Web: Codable, Sendable {
|
||||
struct Connect: Codable, Sendable {
|
||||
let ok: Bool
|
||||
let status: Int?
|
||||
let error: String?
|
||||
let elapsedMs: Double?
|
||||
}
|
||||
|
||||
let linked: Bool
|
||||
let configured: Bool?
|
||||
let linked: Bool?
|
||||
let authAgeMs: Double?
|
||||
let connect: Connect?
|
||||
let probe: Probe?
|
||||
let lastProbeAt: Double?
|
||||
}
|
||||
|
||||
struct SessionInfo: Codable, Sendable {
|
||||
@@ -50,8 +44,9 @@ struct HealthSnapshot: Codable, Sendable {
|
||||
let ok: Bool?
|
||||
let ts: Double
|
||||
let durationMs: Double
|
||||
let web: Web
|
||||
let telegram: Telegram?
|
||||
let channels: [String: ChannelSummary]
|
||||
let channelOrder: [String]?
|
||||
let channelLabels: [String: String]?
|
||||
let heartbeatSeconds: Int?
|
||||
let sessions: Sessions
|
||||
}
|
||||
@@ -94,6 +89,13 @@ final class HealthStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only escape hatch: the HealthStore is a process-wide singleton but
|
||||
// state derivation is pure from `snapshot` + `lastError`.
|
||||
func __setSnapshotForTest(_ snapshot: HealthSnapshot?, lastError: String? = nil) {
|
||||
self.snapshot = snapshot
|
||||
self.lastError = lastError
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.loopTask == nil else { return }
|
||||
self.loopTask = Task { [weak self] in
|
||||
@@ -142,10 +144,49 @@ final class HealthStore {
|
||||
}
|
||||
}
|
||||
|
||||
private static func isTelegramHealthy(_ snap: HealthSnapshot) -> Bool {
|
||||
guard let tg = snap.telegram, tg.configured else { return false }
|
||||
private static func isChannelHealthy(_ summary: HealthSnapshot.ChannelSummary) -> Bool {
|
||||
guard summary.configured == true else { return false }
|
||||
// If probe is missing, treat it as "configured but unknown health" (not a hard fail).
|
||||
return tg.probe?.ok ?? true
|
||||
return summary.probe?.ok ?? true
|
||||
}
|
||||
|
||||
private static func describeProbeFailure(_ probe: HealthSnapshot.ChannelSummary.Probe) -> String {
|
||||
let elapsed = probe.elapsedMs.map { "\(Int($0))ms" }
|
||||
if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil {
|
||||
if let elapsed { return "Health check timed out (\(elapsed))" }
|
||||
return "Health check timed out"
|
||||
}
|
||||
let code = probe.status.map { "status \($0)" } ?? "status unknown"
|
||||
let reason = probe.error?.isEmpty == false ? probe.error! : "health probe failed"
|
||||
if let elapsed { return "\(reason) (\(code), \(elapsed))" }
|
||||
return "\(reason) (\(code))"
|
||||
}
|
||||
|
||||
private func resolveLinkChannel(
|
||||
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
|
||||
{
|
||||
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
||||
for id in order {
|
||||
if let summary = snap.channels[id], summary.linked != nil {
|
||||
return (id: id, summary: summary)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveFallbackChannel(
|
||||
_ snap: HealthSnapshot,
|
||||
excluding id: String?) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
|
||||
{
|
||||
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
||||
for channelId in order {
|
||||
if channelId == id { continue }
|
||||
guard let summary = snap.channels[channelId] else { continue }
|
||||
if Self.isChannelHealthy(summary) {
|
||||
return (id: channelId, summary: summary)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var state: HealthState {
|
||||
@@ -153,13 +194,15 @@ final class HealthStore {
|
||||
return .degraded(error)
|
||||
}
|
||||
guard let snap = self.snapshot else { return .unknown }
|
||||
if !snap.web.linked {
|
||||
// WhatsApp Web linking is optional if Telegram is healthy; don't paint the whole app red.
|
||||
return Self.isTelegramHealthy(snap) ? .degraded("Not linked") : .linkingNeeded
|
||||
guard let link = self.resolveLinkChannel(snap) else { return .unknown }
|
||||
if link.summary.linked != true {
|
||||
// Linking is optional if any other channel is healthy; don't paint the whole app red.
|
||||
let fallback = self.resolveFallbackChannel(snap, excluding: link.id)
|
||||
return fallback != nil ? .degraded("Not linked") : .linkingNeeded
|
||||
}
|
||||
if let connect = snap.web.connect, !connect.ok {
|
||||
let reason = connect.error ?? "connect failed"
|
||||
return .degraded(reason)
|
||||
// A channel can be "linked" but still unhealthy (failed probe / cannot connect).
|
||||
if let probe = link.summary.probe, probe.ok == false {
|
||||
return .degraded(Self.describeProbeFailure(probe))
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
@@ -168,19 +211,22 @@ final class HealthStore {
|
||||
if self.isRefreshing { return "Health check running…" }
|
||||
if let error = self.lastError { return "Health check failed: \(error)" }
|
||||
guard let snap = self.snapshot else { return "Health check pending" }
|
||||
if !snap.web.linked {
|
||||
if let tg = snap.telegram, tg.configured {
|
||||
let tgLabel = (tg.probe?.ok ?? true) ? "Telegram ok" : "Telegram degraded"
|
||||
return "\(tgLabel) · Not linked — run clawdbot login"
|
||||
guard let link = self.resolveLinkChannel(snap) else { return "Health check pending" }
|
||||
if link.summary.linked != true {
|
||||
if let fallback = self.resolveFallbackChannel(snap, excluding: link.id) {
|
||||
let fallbackLabel = snap.channelLabels?[fallback.id] ?? fallback.id.capitalized
|
||||
let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded"
|
||||
return "\(fallbackLabel) \(fallbackState) · Not linked — run clawdbot login"
|
||||
}
|
||||
return "Not linked — run clawdbot login"
|
||||
}
|
||||
let auth = snap.web.authAgeMs.map { msToAge($0) } ?? "unknown"
|
||||
if let connect = snap.web.connect, !connect.ok {
|
||||
let code = connect.status.map(String.init) ?? "?"
|
||||
return "Link stale? status \(code)"
|
||||
let auth = link.summary.authAgeMs.map { msToAge($0) } ?? "unknown"
|
||||
if let probe = link.summary.probe, probe.ok == false {
|
||||
let status = probe.status.map(String.init) ?? "?"
|
||||
let suffix = probe.status == nil ? "probe degraded" : "probe degraded · status \(status)"
|
||||
return "linked · auth \(auth) · \(suffix)"
|
||||
}
|
||||
return "linked · auth \(auth) · socket ok"
|
||||
return "linked · auth \(auth)"
|
||||
}
|
||||
|
||||
/// Short, human-friendly detail for the last failure, used in the UI.
|
||||
@@ -201,17 +247,11 @@ final class HealthStore {
|
||||
}
|
||||
|
||||
func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String {
|
||||
if !snap.web.linked {
|
||||
if let link = self.resolveLinkChannel(snap), link.summary.linked != true {
|
||||
return "Not linked — run clawdbot login"
|
||||
}
|
||||
if let connect = snap.web.connect, !connect.ok {
|
||||
let elapsed = connect.elapsedMs.map { "\(Int($0))ms" } ?? "unknown duration"
|
||||
if let err = connect.error, err.lowercased().contains("timeout") || connect.status == nil {
|
||||
return "Health check timed out (\(elapsed))"
|
||||
}
|
||||
let code = connect.status.map { "status \($0)" } ?? "status unknown"
|
||||
let reason = connect.error ?? "connect failed"
|
||||
return "\(reason) (\(code), \(elapsed))"
|
||||
if let link = self.resolveLinkChannel(snap), let probe = link.summary.probe, probe.ok == false {
|
||||
return Self.describeProbeFailure(probe)
|
||||
}
|
||||
if let fallback, !fallback.isEmpty {
|
||||
return fallback
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user