diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 18f9a2612df5..01c3d39e9134 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,24 +1,24 @@ -d3add5597be9e422974496ed388dc0abf5d99941ff60b874d008beab1a1b8a0b module/account-core +c520390ad8eaae8ba5c2f926100a833988cbe9cc4f04f8511a44edf36bb35db2 module/account-core 1b9d38050a1de968515048d4ada89d5b9d686bd51be0f0c0cb8987a124bb59a7 module/account-helpers 71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id 1fe4f05f95e7b29cd81daf65c76fce5db229158c4ffcb8341b390c8ea2e63696 module/account-resolution 87714a883fa2d8c39db921b25992546ab07e23bb5c6bfe739c340d65a989db47 module/agent-config-primitives -69fb37366dfba4812b97cfa0e460f035c734549af44550bfc9c8282287fe95d5 module/agent-harness -da346499dab0431c8bf81a6af11bce8fbef7e214c5a45654205813692ced7cd3 module/agent-harness-runtime +699cbf02c6a575564693e6f608a44ed536d354c6ae501657c36fbf6c1a1ec580 module/agent-harness +b4ed6b0824e6b693b5f9d6b64a73feba66d253f1404065377a03437d5e8c8bd7 module/agent-harness-runtime b39e78226063156e97da6189b34f1bf92dc07f4bc49a7f67e8dcc521dd878dac module/agent-media-payload -8ccf959fc20ebcc2a57c785a2c6b66d6a462c6be771c7319475c55e282abbe39 module/agent-runtime +a8adf1cb3f44fca407adf41933096f63322cd3c77d9fa88bb68ea16f2aadc7a9 module/agent-runtime 68040b4e24969a5d209097b982e1382c7d8bed48d1d1fd5ef5f20754064421e0 module/agent-scope-runtime 8fecb210e22bce4532b6ab649b09465f0bd2c857a44abf40db7d683d6491e6da module/allow-from 74f099da550f5c949541a529d716a7c39a0af7f183d51e433a8b433c98be59f1 module/allowlist-config-edit -f3d76dc2535cb227efa451521456439957eb7c5ae71ad35465eb78f0043f845b module/approval-auth-runtime -73fa7901c4f7777982750f80603169ade151489a898cf2be15d6330e32d8281a module/approval-client-runtime -41e0c014e7246083cb1df7f02a27da58c16cbd74dd55f57108ad75eacf753bbf module/approval-delivery-runtime +eaf10594d0b67aaa51dd633278cca395bc30e26c334a3c2ef1a0b3404c46e839 module/approval-auth-runtime +41d34eb3b55e8c14eaf791e3e95ab22fae0423b5c24a5b02cc36c14d377cf802 module/approval-client-runtime +b74733ee07372d72752985a798e247752675fb35f44ed553092ad2223cc8be2e module/approval-delivery-runtime cc74dadd03fa6cff9514efa44a56a6febcfb4a13e41f14d14d051c5bafa53601 module/approval-gateway-runtime c5a5a0736d0a999e1b7c8fa967f2d220b03c0c5d8ac30ec43cd0e5bb9b1e2962 module/approval-handler-adapter-runtime b0d4604617be567422fcdceb8766b2db938690561991465085440c3b6e42d811 module/approval-handler-runtime -61e5d2728413b16d1437c39f97e55373a303cdbc9898404518f6c28230ac807f module/approval-native-runtime +ba3f0a2de13b5884770c84e337e3ccf0e582dd10fe7c7291f06be547d732c537 module/approval-native-runtime fe40c01e9b168c9b00426eb4febdc557d69d38082e092ca994589acfbb1ae99e module/approval-reply-runtime -f7be03440aaff8016828cd51ba23170c97289018f26545a305d346cbec1dd4b0 module/approval-runtime +3141aebd499c8575b1c4f6337b435e5a3de4a7059186873ded8bb43eb30e3c37 module/approval-runtime 01ca912836b8dec672f705e294f72d346e778557e4c591317d67558ea7669c0b module/archive d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean-param b234c18152ce7c204483f12b56432eaf628487a58985b45fad5a748efbe7102b module/channel-actions @@ -26,57 +26,57 @@ b234c18152ce7c204483f12b56432eaf628487a58985b45fad5a748efbe7102b module/channel c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives 45af2c37b55888cd81115aa6837c3cc2ae5a73dd0173e982fff608bed8c3d607 module/channel-config-schema a38cb34f477cdd6a1a0a664ddcfa8f0f2e1d5f1c30f7fb8bcff0aa3b5fb81a1c module/channel-contract -b3bb3ec3fda300b365e69b49df9b1c7cd3d30ceb559051986cc4ac1acbf7aa1b module/channel-core +258d1d3f54f24722a1f839bd922e690a197e438fd550a25dfcf43f1b80d74441 module/channel-core b0441683ed12ad0d1e3bd81124f9bfc492bfc8e3307bf1dc85068674e49419a6 module/channel-dm-policy -96f17b6f38de9f3d886a6b3d5e98118a737f4f23e3d81c281466afa53514c1b9 module/channel-entry-contract +8e57800c267af0a7d7deda601012662e8f926251d3bb075f1461783c13c1746a module/channel-entry-contract e348cf7e5d26ee4dc7f8de827834f7db5379a08aedc498b5ca899b3fdff497dc module/channel-feedback -031d760be39711519b5e523b7ee5558db4104673734098701c755c1dbcbaa742 module/channel-inbound +49cf568cb291de3d5cfb5785692bbe040a0562d87fd4c162569918882ebf656c module/channel-inbound 3cd9fdf44a03bd23098a5106ada7b41dd0b4e69039051cbee5fc05965060f8e6 module/channel-inbound-debounce -cc0c189a754c91267948a74096f9617f7f53945f09751243070917961c71ab46 module/channel-ingress-runtime +a1269e30c077d543af196e28a63524f03146268b7f4630f440233b2f08762f11 module/channel-ingress-runtime 4ef3dd0ac32f99659e17df55ce4dc1aeda9711ddca0df5508d1907fcb4d43f16 module/channel-lifecycle 0e47457e38d1df0bd572e1408cde2ca6a788b65205f43c585316b5ad3a8f2f16 module/channel-logging -e1d85974b7784b98bc4b65435082472879f19a89bdcab4c89636129f06318c25 module/channel-message -ee175564f77830ca8b8dbc35df2dd071834dccb1b2799c3219927293625cf1d3 module/channel-outbound -78aab538d382e8032e1422ce8fb6dc584fb385eb38939fadc1fe6f889aad6f72 module/channel-pairing -9442957e87a267a0626f2ddccfa4176365eaf3a479bc16d05effa1503e9ef836 module/channel-plugin-common +63efff2edc8356db5c8ca1f9a9be43ee8441b4522f8e69d3178c404571790059 module/channel-message +909c88b892041807d65da73ad18062353331cb5c2de3da5a442ce37dd8348a9f module/channel-outbound +35b5dec6d52ce5ec372f930ca4546815236e2eef0e96ac15072e91692e620565 module/channel-pairing +762262620488e88a6e006f0c8b13a70387e7e0198cb6d74c3eedea03343df4db module/channel-plugin-common 43a4a56defd68e433dc5a30e25eefccf598513d1bd721b6861574c6fe9783bd5 module/channel-policy d92a99cfcbc320be570bd9ab1aa2aa402fd6d136dad6fbd5f8acf28326052639 module/channel-reply-pipeline 482370e60135db9bfaf07f24bab549e5fde09ab265a6061a1f587c5d93929e91 module/channel-runtime-context e5bab77cf56d4a549b4ba1ba16c15f86394266ecd07b31f96abd5adbfa9bd94b module/channel-secret-basic-runtime 53677d02f5cc8744bd9d335bcb4172a7eda083872f90ffc65c3e474ee149ec0e module/channel-secret-runtime 337b8b5ee6e4bd478aaac99edb20a029daff9e3b31f567a5dcb2d4e678587d64 module/channel-send-result -844e8476d0c2967820ff612ca4c11841c149fbfcf541378b9c980013baf30f05 module/channel-setup +e2cec53758720feeb0cf0dc25f9b5ea25c71b483a7f8e455a6a0fd7a9d62490c module/channel-setup 528ab5be35a3ad564e74699c6c4aec289a2c424e19f14b7e74fcaf41b0acbe7f module/channel-status 91b43898e843e9497ff1c3aad3d5de5357f0f44e3987d28e5ea70d980b7d418a module/channel-streaming 67df67da5ae72e9eaeb19d41b6bd2432ec4fd8b7b63b2b616fb98f3b4e0ec41d module/channel-streaming-config 1303df5cc58539c6941e2cd159c93259804c925795219f1630f4d740896a77c1 module/cli-argv ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime -d83f2f6c25b98f4d9b01c4889a98727c7dac9dfd8bd3343b92a5757673cdbaa8 module/command-auth -120bd935151416cd400c76f32884355a18a82adff4e3625bf19a98f294cbeb48 module/command-auth-native +fdf8da3db72f5d747adc1d91b269d66c2668fa6449e8225b97073e23323ab1c5 module/command-auth +c1f9d2773799af049ab6906636a6684206ec27af8a0b7b4acfa29a1972bdf9b3 module/command-auth-native 1c725b558e450d7961fc6fe7c0843838860946cb78c1ec82f68305489d534524 module/command-detection ebaccf95249d1037448f58c543b448ccccff493c4b2086f10093faff87668a61 module/command-primitives-runtime e85a6cc9ac9972d142cde8ff6e5b8a84b643868b7b1c402b0eede321262f2cd4 module/command-status 3bd594bf7f101450275b8e3593e6420ce25e90093489f2ebe1375ea0f3bb7c23 module/config-contracts -1ac59199900b7ee5ea1d73adcb513d282b62dfe011ce807ddf0df125a30f8008 module/config-mutation -e5924d304f8a4c6a5dd84b57d6f12a8a95eed2626840016c1e7b4112a0859434 module/config-runtime +73221bab9ad02d0197b0cbef593aff5707bb19e0e2fdbaeb2d93ae387a206c8f module/config-mutation +5089d197ae955d1617ba4dc9d4ba3850fe7007f7c8bfeb790d75ae5718d5a724 module/config-runtime 834069efe69c5cda2747b2415baad705767276d5d3ddbe99c2ce83d2b11527eb module/conversation-runtime -7bb90d276132ba253c6de92b0d92947754a385bb4db20644ec099ff8229241da module/core -1bb1488249efbb4cbeb5bd271adb35e4e791a3f827512b71a33dd42a631f816d module/dedupe-runtime +9012eac78f5db5f34ab61a7861baec4bcbb64586af761c6eea268beda6427010 module/core +e5c61cf4d54cc9f82ab757712acaa1f2d7d0c39eef2bb5c80631dd1441c2c737 module/dedupe-runtime ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap c944f438e9108f3e023149c3d22d152e1c59867151668b9e61bba4da0c09c2cb module/diagnostic-runtime 3519f2584015d6817eefd7803cca7089d66d8569c297367a10aa2114cf6d24d2 module/directory-runtime -2dfe49cef20c48574f36b0cb04cb0a8528a70dd793547a134862f7ac03b55538 module/discord +6297773a7852270d371686e1eed84ca054273a546b20c1a7b6121d6490ff46e4 module/discord 12f4ff032680218e63370698e2ec8b0c46a5a4dc86ce05462dd1284a06ed2eef module/error-runtime -cdf0108ad6ac4fcd8f1b2f50b0a6f10f28f1dd1a36a60b8345a4f5f0862c8eac module/extension-shared +8c80daf459dc80caf9b343e52cbef678f514e34d175b93c2986e71d54b43f782 module/extension-shared dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime -eee525309e5347b2343090b3582a7cc3c4872ecdd9b9103cbf376af8b4a53f17 module/gateway-runtime +0311c99dafaeda7e7239b7126fd864fd2e9a12e0b8dcba41d17c9bd120cd398d module/gateway-runtime 575656e5e0195c8d1813a4e2e3a271e800bb97d44f2dd78c242e3b6714ffa097 module/group-access -e86e937127cf17772834e1c06dc5407d7504b97cac9fe32ef3bf730ad9ea3e58 module/health +b77c88bbac32be4aa847c20a93566f9ea7d36b801564aa7c16abb52a44e45002 module/health 54301207a429f30f78aeadb99d6b61591c81b6ea4f467daf3d49efe9106bde81 module/hook-runtime 73c55fa5541efc308d1c99e012f651e45ca84d9049bb8125b6dea687f435862b module/inbound-envelope 4928af5d2509f696b896f53ac790303a0742202dbcdae3e44fe6d1b434a9c1ba module/inbound-event-delivery -5eb1a3fe93907110732ba427e0bf5d9218286d9c4d45f7614c599c0965c706b0 module/inbound-reply-dispatch -2edf37f740ba16cb9b42a1577d26f5f4cf34fc25a705b5a8db58ab5e8e579ba3 module/infra-runtime +ca97301f667633874ac20d0d68a1e84090aa2668cac4562ad64db688515d243f module/inbound-reply-dispatch +d4103ee20cc1d864fdcfda7da32c7350e96ba1596eb678846bcd7a9fa63a6e47 module/infra-runtime ce73721421f1b903dd04ead4df173582e59ea3e9990248102c448b419cc6d272 module/ingress-effect-once 6d0ef8e970d63f711298747344f3c6757fbad6d920b270be06e7611130d846cf module/interactive-runtime 408d257ab5cc4b88a22b7e7595039cb8fc524b261c44141b294fbd0100ba62ee module/json-store @@ -89,49 +89,49 @@ f74d7295fe716aa140aa0bc9300d6259d71dab826de0808fca6bb02592bf5d6e module/media-m 6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store bb9f787cd2362268894393f5cf8d16ea582609cb1bb27fbdfc8ea97a76cea214 module/media-understanding 7fc60001aee2f0f95caa7eebe4e8dc74d3a4dcac71f16ce63447d73226f9c683 module/media-understanding-runtime -daa4f31dbdf3bcd7866e1f625928c66d898af4aca5287c05b53bf79387160405 module/meeting-runtime -327414d8448f9bbac5d6770f67be92bc7e343415779476611b61acf6e842b0ad module/memory-core-host-engine-foundation -c73e056b3c97d04cc2c975a054ed1f8ee17a52031325027422f0f09dbeb36a22 module/memory-host-core +83aed3a868c3765a6acd079d2f1ca81746dc26d182ad431781648e6ecd6f56d7 module/meeting-runtime +99b5eb9d24fc2f1cf0cbd086c4693718380dc2dd9ca5ddd66546c03016d55ebe module/memory-core-host-engine-foundation +9989c67ccac0600c8727d8137f2a64df3d04af855cd515e22930c96b025be108 module/memory-host-core 1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets -954347d67297cad3af41cff661261d3ba13fca4ad6c0ea26b76f0762d99267a4 module/model-session-runtime -3615ee13fa68a3f0544b6cbe84e26de26a44101c65da42ff84ba4e4adc367aff module/models-provider-runtime +3b84166ce93a8f5107183aeb46efb8a1be13e729cbc759a584884409072cc83e module/model-session-runtime +12ef243996027d41039354e682b45e7fa05fd89597035312eca86b0d15f399c6 module/models-provider-runtime bba9f68844a60e9bd9b03c694684f62bd7cc11542d6f600bf65b561cadbca8ea module/native-command-config-runtime 62b6329334d16090db4af2a45f4ab5d43842b62db92f458df73af3d9323e681a module/native-command-registry e1230968f3a3587679a4fa57b67ab36af9bcc81b2bce730c9e724eb66c4418e9 module/param-readers ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe e3984a76820581c5281412b2449b2bb431110547c2cd833e63d29991e18f7176 module/plugin-config-runtime -da2b1d199b7d7bfc751f808cfdfdfc169f503e06ef464c999870a7d99d3aff52 module/plugin-entry -74ef9d3f9b05c7885a374972f222c127c7b2425dd7a6ccabbae4e1f7f7cbfd80 module/plugin-runtime -1c49345c1c580267910d10ad2324d84023f1131501b101b74f60a3befcc417ed module/provider-auth -697e8210c6d46abac5f0a9bd10b80d177e94d445b79f5c7b2de47881b5fe755e module/provider-catalog-runtime +03a39fb83f32c8aefb9d06ff092d21ff40f195f87e6cd08340f6573e0a6d1c5a module/plugin-entry +cd1fab1de064baa56180938a314c9518bfb90bf34138b26f0ba02d956ce12e52 module/plugin-runtime +2aa4b53f69087c4103c39f5c5e9daea63eda47bb12454b7c0f0a907fb99e6e1e module/provider-auth +56a116276bc40e8718055c5ad8df86a380f7c371cbf14d12c2c0fee04f97302f module/provider-catalog-runtime 8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture d4542e5452cebc9982524720d46fe4704ab8d2f2b8ad263998441a4ad29adde6 module/question-gateway-runtime deebc97abbbae14b005ca599d9708fc4ca71964406f6cbb38af7339cf2cdd0df module/reply-chunking -256074548569c9121d241f2ed799619aeb974967678525acfbaa1edefa757907 module/reply-dispatch-runtime +1d01de2598fb9a5f5ecbd2ac599e05676ef701c7d0c6646329a9cc0769ecb8ab module/reply-dispatch-runtime 73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history 0e32d365a83854a04b27522abf9a331a5170ebd345157e11bace539af6a70d88 module/reply-payload -de032724ff99c53d223b790e8c19962667ff18a84a8031dfe302693b2b79a4ca module/reply-runtime +46530f1296bf52358b93a932bef8bce84e7319548ef183eea58039a29130e487 module/reply-runtime aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk 3941a8508e73944bf0d920a7334e525c617fc24105ca984fcdc69930ae5cbb7b module/routing 7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command 9a119658075d586a41dcbd2b4e431c5da1ec2c3d6d21317a18aeeef06359c8ac module/runtime -6df4f6a901ddfafd4f441ef6a2d9a41fca48dd953dc377def2b8ed39db95934f module/runtime-config-snapshot +a9b9ac28b62304d457b0cd2e330225be6b7c763200701a03bf548048a9862e5e module/runtime-config-snapshot 45bede83ee89886eeda9155f9d4356ce7fa7611c4be744443c1c439173d666ce module/runtime-env 7e871b7319745678bb83fcfc1b54c8751b0ab1af92ff06c01d0659ac92863c11 module/runtime-group-policy -f1f830504aae30386b1327c2b9ec4323f8ecdaa8911c25928bea52be28f53d18 module/runtime-store +c10f69e70606506ab616b42bf99317eb8d92c6ec531d7c21b82bef3f33d8373c module/runtime-store d17862c40825af1ddf0257b44f1e1cbb9c375e8e5ed668fae75d530d1a465cf9 module/secret-file 8e2ac4d3973d8d8ce4478e3440d66ee5c0d9213b0fe9e927c421d14fd31e5e86 module/secret-input ccd790bc28ce44ca8a2df9a643103555dc650e9778a969c2f6fdeb1f0e1fba1b module/secret-input-runtime f473090754cb12a5c4a2d39d693315700b25cb053c825c30b3a889a555f10f16 module/secret-ref-runtime 3ac69236c14ac1861aff09f267c523b8f34a63b9bbca085d326c21056604ede0 module/security-runtime -15dd73d9240c1bd5d863bde45f241f7f71fbb00c743566fd9803bef709ec29aa module/session-catalog -074cf714c2958f4e5dc7d1502817f9c56ac1a64f916e5e5bd99678be2f662e10 module/session-discussion +8f825a9fb90beeb81e1e9b6081441ad736b00406ad646139979bea580deefd42 module/session-catalog +f264d6b9cb193b3e7244adbb94c62fdfce3d42969db0e827283f1d35c4f3e1f6 module/session-discussion b43997ba4064ef3577a9d8efa178fff4dadb6645a0b6a52a79616360d66840c2 module/session-store-runtime 5af106014a62ef2802236dbc5484d55bfffc2cf7105fd184a4d3b80ac0ab7727 module/setup b64a0abce77e276b739f8b3979ba60b9b6407955aaedd7803c7d7b9936250574 module/setup-runtime 44d37e0d9131ad2859f41068f2604090c784e65f1bd6ebda8e051b6f2e5e1660 module/setup-tools f25f9cea5d406f959e11f59a236f48da59d805b8d8cac24df7973d625e77f132 module/skill-commands-runtime -5937ae7be61668d317deb03b23ba904c6c3bf7816e5a6c63b240956766e0b3d0 module/speech-settings +c3309590231fc1576f4cb746a1f8e7d3209d1c4d2205730d03a8e0fcb0361095 module/speech-settings 3650198b8d9b0ec76560ebc30bd09fb9f99b22366749cd870918160dd87a2f33 module/ssrf-policy e6ec1baaa72323bfd0e6e0f2bb1550896d8c95ae4a5d588bd1faeeacd651ed16 module/ssrf-runtime c1f4358865191005afa6885abf8ed891e0d6fbdc82a04bb7473a5108a0e21516 module/state-paths @@ -141,11 +141,11 @@ f097d0096b21c8a052f0f649b7512ecf2aba4744ae6956f001950e053828b309 module/string- aef35bee2502cd6ed8765409b758e452aff8ac9469fd773e6a2a44c9a1bc3f66 module/temp-path 87fa81b9e58d8fc04a4b4202d2d37fca339615f5225687d9db905151439e0f4d module/text-chunking 87f5a0d7948934e403f4e15cb4d2fb54dd97a16a35dffec3cf5ee66ddbd46169 module/text-runtime -c78a01396098e46ad7357ba6f13e64114c5fcd1da67322076497347808a48ef8 module/tool-plugin +13b06b24683c94edffe5247c3ace0572e7b37ba96ae1d0eeaa79c523c106c322 module/tool-plugin dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results 9a2f1691d747799e833f19ce8b31b844421a660354221d42299ade9cfefcbeda module/tool-send cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media -756bd596dbd2b1d73ff7c459c622c6f99a91607d24503858f4b69b515d6de68d module/webhook-ingress +211f58b9cba3ae900fe15df0b53de685708e8b20036f0130f63f20b2ae16e67c module/webhook-ingress 3d7a4d6c0e769a78a47d6a67393c72b3a5df9c272058f1f072a28ac9d6b2cfb8 module/webhook-request-guards de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html 9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod diff --git a/extensions/chutes/implicit-provider.test.ts b/extensions/chutes/implicit-provider.test.ts index 802f2057c9fe..b3f9851b6009 100644 --- a/extensions/chutes/implicit-provider.test.ts +++ b/extensions/chutes/implicit-provider.test.ts @@ -1,9 +1,10 @@ // Chutes tests cover implicit provider plugin behavior. import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime"; import { resolveOAuthApiKeyMarker } from "openclaw/plugin-sdk/provider-auth"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import plugin from "./index.js"; import { CHUTES_BASE_URL } from "./models.js"; +import { refreshChutesOAuthCredential } from "./oauth.js"; const CHUTES_OAUTH_MARKER = resolveOAuthApiKeyMarker("chutes"); @@ -67,12 +68,6 @@ async function withRealChutesDiscovery( } describe("chutes implicit provider auth mode", () => { - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); - }); - it("publishes the env vars used by core api-key auto-detection", async () => { const provider = await registerSingleProviderPlugin(plugin); @@ -80,36 +75,9 @@ describe("chutes implicit provider auth mode", () => { }); it("registers plugin-owned OAuth refresh behavior", async () => { - vi.stubEnv("CHUTES_CLIENT_SECRET", ""); - const fetchMock = vi.fn(async () => - jsonResponse({ access_token: "at_new", refresh_token: "rt_new", expires_in: 1800 }), - ); - vi.stubGlobal("fetch", fetchMock); const provider = await registerSingleProviderPlugin(plugin); - const credential = { - type: "oauth" as const, - provider: "chutes", - access: "at_old", - refresh: "rt_old", - expires: 1, - clientId: "cid_test", - email: "fred@example.com", - }; - expect(provider.refreshOAuth).toBeTypeOf("function"); - await expect(provider.refreshOAuth!(credential)).resolves.toMatchObject({ - type: "oauth", - provider: "chutes", - access: "at_new", - refresh: "rt_new", - clientId: "cid_test", - email: "fred@example.com", - }); - expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchMock).toHaveBeenCalledWith( - "https://api.chutes.ai/idp/token", - expect.objectContaining({ method: "POST" }), - ); + expect(provider.refreshOAuth).toBe(refreshChutesOAuthCredential); }); it("does not publish a provider when no API key is resolved", async () => { diff --git a/extensions/chutes/oauth.test.ts b/extensions/chutes/oauth.test.ts index dcb7be3affb9..fa67ae1f2ca5 100644 --- a/extensions/chutes/oauth.test.ts +++ b/extensions/chutes/oauth.test.ts @@ -1,5 +1,6 @@ // Chutes tests cover oauth plugin behavior. import type { OAuthCredential } from "openclaw/plugin-sdk/provider-auth"; +import { jsonResponse } from "openclaw/plugin-sdk/test-env"; import { afterEach, describe, expect, it, vi } from "vitest"; import { loginChutes, refreshChutesOAuthCredential } from "./oauth.js"; @@ -222,10 +223,7 @@ describe("chutes plugin OAuth", () => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; if (url === "https://api.chutes.ai/idp/userinfo") { - return new Response(JSON.stringify({ login: "test", name: "Test" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + return jsonResponse({ login: "test", name: "Test" }); } if (url === "https://api.chutes.ai/idp/token") { return oversizedTokenJson; @@ -269,10 +267,11 @@ describe("chutes plugin OAuth", () => { const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = fetchInputUrl(input); if (url === CHUTES_TOKEN_ENDPOINT) { - return new Response( - '{"access_token":"at_timeout","refresh_token":"rt_timeout","expires_in":3600}', - { status: 200, headers: { "Content-Type": "application/json" } }, - ); + return jsonResponse({ + access_token: "at_timeout", + refresh_token: "rt_timeout", + expires_in: 3600, + }); } if (url === CHUTES_USERINFO_ENDPOINT) { return await rejectWhenAborted(init); @@ -310,10 +309,11 @@ describe("chutes plugin OAuth", () => { const fetchFn = vi.fn(async (input: RequestInfo | URL) => { const url = fetchInputUrl(input); if (url === CHUTES_TOKEN_ENDPOINT) { - return new Response( - '{"access_token":"at_123","refresh_token":"rt_123","expires_in":3600}', - { status: 200, headers: { "Content-Type": "application/json" } }, - ); + return jsonResponse({ + access_token: "at_123", + refresh_token: "rt_123", + expires_in: 3600, + }); } if (url === CHUTES_USERINFO_ENDPOINT) { return userInfoResponse; @@ -335,10 +335,11 @@ describe("chutes plugin OAuth", () => { const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = fetchInputUrl(input); if (url === CHUTES_TOKEN_ENDPOINT) { - return new Response( - '{"access_token":"at_cancel","refresh_token":"rt_cancel","expires_in":3600}', - { status: 200, headers: { "Content-Type": "application/json" } }, - ); + return jsonResponse({ + access_token: "at_cancel", + refresh_token: "rt_cancel", + expires_in: 3600, + }); } if (url === CHUTES_USERINFO_ENDPOINT) { controller.abort(reason); @@ -377,14 +378,11 @@ describe("chutes plugin OAuth", () => { refresh_token: "rt_old", client_secret: "secret_env", }); - return new Response( - JSON.stringify({ - access_token: "at_new", - refresh_token: "rt_new", - expires_in: 1800, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); + return jsonResponse({ + access_token: "at_new", + refresh_token: "rt_new", + expires_in: 1800, + }); }); const credential = createStoredCredential(); const now = 2_000_000; @@ -420,10 +418,7 @@ describe("chutes plugin OAuth", () => { throw new Error("expected URL-encoded Chutes refresh request"); } expect(body.get("client_id")).toBe("cid_env"); - return new Response('{"access_token":"at_new","expires_in":1800}', { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + return jsonResponse({ access_token: "at_new", expires_in: 1800 }); }); const refreshed = await refreshChutesOAuthCredential( @@ -441,13 +436,7 @@ describe("chutes plugin OAuth", () => { response: { access_token: "at_new", refresh_token: "", expires_in: 1800 }, }, ])("preserves the old refresh token when the replacement is $label", async ({ response }) => { - const fetchFn = vi.fn( - async () => - new Response(JSON.stringify(response), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); + const fetchFn = vi.fn(async () => jsonResponse(response)); const refreshed = await refreshChutesOAuthCredential(createStoredCredential(), { fetchFn, @@ -485,13 +474,7 @@ describe("chutes plugin OAuth", () => { message: "Chutes token refresh returned invalid expires_in", }, ])("rejects $label", async ({ response, message }) => { - const fetchFn = vi.fn( - async () => - new Response(JSON.stringify(response), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); + const fetchFn = vi.fn(async () => jsonResponse(response)); await expect( refreshChutesOAuthCredential(createStoredCredential(), { fetchFn, now: 5_000_000 }), diff --git a/extensions/chutes/oauth.ts b/extensions/chutes/oauth.ts index 54b7b0e870a4..571b21b619c5 100644 --- a/extensions/chutes/oauth.ts +++ b/extensions/chutes/oauth.ts @@ -5,7 +5,6 @@ import { randomBytes } from "node:crypto"; import { resolveExpiresAtMsFromDurationSeconds } from "openclaw/plugin-sdk/number-runtime"; import { generatePkceVerifierChallenge, - toFormUrlEncoded, type OAuthCredential, } from "openclaw/plugin-sdk/provider-auth"; import { @@ -119,6 +118,40 @@ function resolveChutesExpiresAt(value: unknown, now: number): number | undefined }); } +async function requestChutesTokenGrant(params: { + body: URLSearchParams; + responseLabel: "Chutes token exchange" | "Chutes token refresh"; + fetchFn?: typeof fetch; + now?: number; + signal?: AbortSignal; +}): Promise<{ access: string; refresh: string | undefined; expires: number }> { + const response = await (params.fetchFn ?? fetch)(CHUTES_TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.body, + signal: buildOAuthRequestSignal({ + timeoutMs: CHUTES_OAUTH_REQUEST_TIMEOUT_MS, + ...(params.signal ? { signal: params.signal } : {}), + }), + }); + await assertOkOrThrowProviderError(response, `${params.responseLabel} failed`); + + const data = await readProviderJsonResponse<{ + access_token?: string; + refresh_token?: string; + expires_in?: number; + }>(response, params.responseLabel); + const access = normalizeOptionalString(data.access_token); + const expires = resolveChutesExpiresAt(data.expires_in, params.now ?? Date.now()); + if (!access) { + throw new Error(`${params.responseLabel} returned no access_token`); + } + if (expires === undefined) { + throw new Error(`${params.responseLabel} returned invalid expires_in`); + } + return { access, refresh: normalizeOptionalString(data.refresh_token), expires }; +} + async function fetchChutesUserInfo(params: { accessToken: string; fetchFn?: typeof fetch; @@ -151,52 +184,32 @@ async function exchangeChutesCodeForTokens(params: { }): Promise { const fetchFn = params.fetchFn ?? fetch; const now = params.now ?? Date.now(); - const body = new URLSearchParams( - toFormUrlEncoded({ - grant_type: "authorization_code", - client_id: params.app.clientId, - code: params.code, - redirect_uri: params.app.redirectUri, - code_verifier: params.codeVerifier, - }), - ); + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: params.app.clientId, + code: params.code, + redirect_uri: params.app.redirectUri, + code_verifier: params.codeVerifier, + }); if (params.app.clientSecret) { body.set("client_secret", params.app.clientSecret); } - const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + const token = await requestChutesTokenGrant({ body, - signal: buildOAuthRequestSignal({ - timeoutMs: CHUTES_OAUTH_REQUEST_TIMEOUT_MS, - ...(params.signal ? { signal: params.signal } : {}), - }), + responseLabel: "Chutes token exchange", + fetchFn, + now, + ...(params.signal ? { signal: params.signal } : {}), }); - await assertOkOrThrowProviderError(response, "Chutes token exchange failed"); - - const data = await readProviderJsonResponse<{ - access_token?: string; - refresh_token?: string; - expires_in?: number; - }>(response, "Chutes token exchange"); - const access = normalizeOptionalString(data.access_token); - const refresh = normalizeOptionalString(data.refresh_token); - const expires = resolveChutesExpiresAt(data.expires_in, now); - if (!access) { - throw new Error("Chutes token exchange returned no access_token"); - } - if (!refresh) { + if (!token.refresh) { throw new Error("Chutes token exchange returned no refresh_token"); } - if (expires === undefined) { - throw new Error("Chutes token exchange returned invalid expires_in"); - } let info: ChutesUserInfo | null = null; try { info = await fetchChutesUserInfo({ - accessToken: access, + accessToken: token.access, fetchFn, ...(params.signal ? { signal: params.signal } : {}), }); @@ -208,9 +221,9 @@ async function exchangeChutesCodeForTokens(params: { // not discard issued credentials when userinfo is unavailable or times out. } return { - access, - refresh, - expires, + access: token.access, + refresh: token.refresh, + expires: token.expires, email: info?.username, accountId: info?.sub, clientId: params.app.clientId, @@ -232,46 +245,28 @@ export async function refreshChutesOAuthCredential( throw new Error("Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth)."); } const clientSecret = normalizeOptionalString(process.env.CHUTES_CLIENT_SECRET); - const body = new URLSearchParams( - toFormUrlEncoded({ - grant_type: "refresh_token", - client_id: clientId, - refresh_token: refreshToken, - }), - ); + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: refreshToken, + }); if (clientSecret) { body.set("client_secret", clientSecret); } - const response = await (options.fetchFn ?? fetch)(CHUTES_TOKEN_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + const token = await requestChutesTokenGrant({ body, - signal: buildOAuthRequestSignal({ timeoutMs: CHUTES_OAUTH_REQUEST_TIMEOUT_MS }), + responseLabel: "Chutes token refresh", + fetchFn: options.fetchFn, + now: options.now, }); - await assertOkOrThrowProviderError(response, "Chutes token refresh failed"); - - const data = await readProviderJsonResponse<{ - access_token?: string; - refresh_token?: string; - expires_in?: number; - }>(response, "Chutes token refresh"); - const access = normalizeOptionalString(data.access_token); - const replacementRefresh = normalizeOptionalString(data.refresh_token); - const expires = resolveChutesExpiresAt(data.expires_in, options.now ?? Date.now()); - if (!access) { - throw new Error("Chutes token refresh returned no access_token"); - } - if (expires === undefined) { - throw new Error("Chutes token refresh returned invalid expires_in"); - } return { ...credential, - access, + access: token.access, // RFC 6749 section 6 makes replacement refresh tokens optional. - refresh: replacementRefresh ?? refreshToken, - expires, + refresh: token.refresh ?? refreshToken, + expires: token.expires, clientId, }; } diff --git a/src/agents/auth-profiles.chutes.lifecycle.test.ts b/src/agents/auth-profiles.chutes.lifecycle.test.ts deleted file mode 100644 index 5f23691c4f07..000000000000 --- a/src/agents/auth-profiles.chutes.lifecycle.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resetFileLockStateForTest } from "../infra/file-lock.js"; -import { isPluginRegistryLoadInFlight } from "../plugins/loader-cache.js"; -import { - cleanupPluginLoaderFixturesForTest, - loadOpenClawPlugins, - resetPluginLoaderTestStateForTest, - useNoBundledPlugins, - writePlugin, -} from "../plugins/loader.test-fixtures.js"; -import { resolveProviderOAuthCredentialWithPlugin } from "../plugins/provider-runtime.runtime.js"; -import { resolveProviderRefOwnership } from "../plugins/providers.js"; -import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; -import { - clearRuntimeAuthProfileStoreSnapshots, - ensureAuthProfileStore, - resolveApiKeyForProfile, - type AuthProfileStore, -} from "./auth-profiles.js"; -import { loadPersistedAuthProfileStore } from "./auth-profiles/persisted.js"; - -const START_AUTH_CALLBACK = "__openclawChutesLifecycleStart"; -const DISABLED_REGISTER_CALLBACK = "__openclawDisabledChutesRegister"; - -function writeChutesPlugin(params: { id: string; registerBody: string }) { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: params.id, - body: `module.exports = { - id: ${JSON.stringify(params.id)}, - register(api) { - ${params.registerBody} - }, - };`, - }); - fs.writeFileSync( - path.join(plugin.dir, "openclaw.plugin.json"), - JSON.stringify( - { - id: params.id, - providers: ["chutes"], - configSchema: { type: "object", additionalProperties: false, properties: {} }, - }, - null, - 2, - ), - "utf8", - ); - return plugin; -} - -function createPluginConfig(params: { id: string; file: string; enabled: boolean }) { - return { - plugins: { - allow: [params.id], - load: { paths: [params.file] }, - entries: { [params.id]: { enabled: params.enabled } }, - }, - } satisfies OpenClawConfig; -} - -beforeEach(() => { - clearRuntimeAuthProfileStoreSnapshots(); - resetFileLockStateForTest(); - resetPluginLoaderTestStateForTest(); -}); - -afterEach(() => { - vi.unstubAllGlobals(); - clearRuntimeAuthProfileStoreSnapshots(); - resetFileLockStateForTest(); - resetPluginLoaderTestStateForTest(); -}); - -afterAll(cleanupPluginLoaderFixturesForTest); - -describe("Chutes auth-profile plugin lifecycle", () => { - it("resumes an expired OAuth refresh after synchronous provider registration completes", async () => { - await withOpenClawTestState( - { layout: "state-only", prefix: "openclaw-chutes-lifecycle-", agentEnv: "main" }, - async (state) => { - const expiredCredential = { - type: "oauth" as const, - provider: "chutes", - access: "at_old", - refresh: "rt_old", - expires: 1, - clientId: "cid_test", - }; - const refreshedCredential = { - ...expiredCredential, - access: "at_new", - refresh: "rt_new", - expires: 4_102_444_800_000, - }; - const initialStore: AuthProfileStore = { - version: 1, - profiles: { "chutes:default": expiredCredential }, - }; - await state.writeAuthProfiles(initialStore); - const store = ensureAuthProfileStore(); - const plugin = writeChutesPlugin({ - id: "chutes-lifecycle", - registerBody: ` - globalThis[${JSON.stringify(START_AUTH_CALLBACK)}](); - api.registerProvider({ - id: "chutes", - label: "Chutes", - auth: [], - async refreshOAuth(credential) { - return { - ...credential, - access: "at_new", - refresh: "rt_new", - expires: 4102444800000, - }; - }, - }); - `, - }); - const config = createPluginConfig({ id: plugin.id, file: plugin.file, enabled: true }); - const loadOptions: NonNullable[0]> = { - cache: false, - workspaceDir: plugin.dir, - config, - onlyPluginIds: [plugin.id], - }; - let authResolution: ReturnType | undefined; - const startAuthDuringRegister = vi.fn(() => { - if (authResolution) { - throw new Error("Chutes lifecycle fixture registered more than once"); - } - expect(isPluginRegistryLoadInFlight(loadOptions)).toBe(true); - authResolution = resolveApiKeyForProfile({ - cfg: config, - store, - profileId: "chutes:default", - }); - }); - vi.stubGlobal(START_AUTH_CALLBACK, startAuthDuringRegister); - - loadOpenClawPlugins(loadOptions); - - expect(isPluginRegistryLoadInFlight(loadOptions)).toBe(false); - if (!authResolution) { - throw new Error("Chutes lifecycle fixture did not start auth resolution"); - } - await expect(authResolution).resolves.toMatchObject({ apiKey: "at_new" }); - expect(loadPersistedAuthProfileStore(state.agentDir())?.profiles["chutes:default"]).toEqual( - refreshedCredential, - ); - expect(startAuthDuringRegister).toHaveBeenCalledOnce(); - }, - ); - }); - - it("distinguishes an explicitly disabled owner from an unowned provider", async () => { - const disabledRegister = vi.fn(); - vi.stubGlobal(DISABLED_REGISTER_CALLBACK, disabledRegister); - const plugin = writeChutesPlugin({ - id: "disabled-chutes", - registerBody: `globalThis[${JSON.stringify(DISABLED_REGISTER_CALLBACK)}]();`, - }); - const config = createPluginConfig({ id: plugin.id, file: plugin.file, enabled: false }); - const credential = { - type: "oauth" as const, - provider: "chutes", - access: "at_old", - refresh: "rt_old", - expires: 1, - }; - - expect( - resolveProviderRefOwnership({ provider: "chutes", config, workspaceDir: plugin.dir }), - ).toEqual({ status: "owned", pluginIds: [plugin.id] }); - await expect( - resolveProviderOAuthCredentialWithPlugin({ - provider: "chutes", - config, - workspaceDir: plugin.dir, - credential, - refresh: true, - }), - ).resolves.toEqual({ status: "configured-unavailable" }); - await expect( - resolveProviderOAuthCredentialWithPlugin({ - provider: "not-owned", - config, - workspaceDir: plugin.dir, - credential: { ...credential, provider: "not-owned" }, - refresh: true, - }), - ).resolves.toEqual({ status: "unowned" }); - expect(disabledRegister).not.toHaveBeenCalled(); - }); -}); diff --git a/src/agents/auth-profiles.chutes.test.ts b/src/agents/auth-profiles.chutes.test.ts deleted file mode 100644 index a6b3a22063d2..000000000000 --- a/src/agents/auth-profiles.chutes.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Chutes auth profile integration tests. - * Verifies expired OAuth profiles refresh through the generic provider seam - * while preserving the shared auth-profile store contracts. - */ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; -import type { AuthProfileStore } from "./auth-profiles.js"; - -type ResolveProviderOAuthCredentialWithPlugin = - typeof import("../plugins/provider-runtime.runtime.js").resolveProviderOAuthCredentialWithPlugin; - -const { resolveProviderOAuthCredentialWithPluginMock } = vi.hoisted(() => ({ - resolveProviderOAuthCredentialWithPluginMock: vi.fn(), -})); - -vi.mock("../plugins/provider-runtime.runtime.js", () => ({ - buildProviderAuthDoctorHintWithPlugin: async () => undefined, - formatProviderAuthProfileApiKeyWithPlugin: async () => undefined, - resolveProviderOAuthCredentialWithPlugin: resolveProviderOAuthCredentialWithPluginMock, -})); - -vi.mock("../plugins/provider-runtime.js", () => ({ - resolveExternalAuthProfilesWithPlugins: () => [], -})); - -afterAll(() => { - vi.doUnmock("../plugins/provider-runtime.runtime.js"); - vi.doUnmock("../plugins/provider-runtime.js"); -}); - -let clearRuntimeAuthProfileStoreSnapshots: typeof import("./auth-profiles.js").clearRuntimeAuthProfileStoreSnapshots; -let ensureAuthProfileStore: typeof import("./auth-profiles.js").ensureAuthProfileStore; -let loadPersistedAuthProfileStore: typeof import("./auth-profiles/persisted.js").loadPersistedAuthProfileStore; -let resolveApiKeyForProfile: typeof import("./auth-profiles.js").resolveApiKeyForProfile; -let resetFileLockStateForTest: typeof import("../infra/file-lock.js").resetFileLockStateForTest; - -describe("auth-profiles (chutes)", () => { - beforeAll(async () => { - ({ clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore, resolveApiKeyForProfile } = - await import("./auth-profiles.js")); - ({ loadPersistedAuthProfileStore } = await import("./auth-profiles/persisted.js")); - ({ resetFileLockStateForTest } = await import("../infra/file-lock.js")); - }); - - beforeEach(() => { - resolveProviderOAuthCredentialWithPluginMock.mockReset(); - clearRuntimeAuthProfileStoreSnapshots(); - resetFileLockStateForTest(); - }); - - afterEach(async () => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - clearRuntimeAuthProfileStoreSnapshots(); - resetFileLockStateForTest(); - }); - - it("refreshes expired Chutes OAuth credentials", async () => { - await withOpenClawTestState( - { - layout: "state-only", - prefix: "openclaw-chutes-", - agentEnv: "main", - }, - async (state) => { - const storedCredential = { - type: "oauth" as const, - provider: "chutes", - access: "at_old", - refresh: "rt_old", - expires: Date.now() - 60_000, - clientId: "cid_test", - }; - const store: AuthProfileStore = { - version: 1, - profiles: { - "chutes:default": storedCredential, - }, - }; - await state.writeAuthProfiles(store); - const refreshedCredential = { - ...storedCredential, - access: "at_new", - refresh: "rt_new", - expires: Date.now() + 3_600_000, - }; - resolveProviderOAuthCredentialWithPluginMock.mockResolvedValue({ - status: "available", - credential: refreshedCredential, - apiKey: refreshedCredential.access, - }); - const fetchSpy = vi.spyOn(globalThis, "fetch"); - - const loaded = ensureAuthProfileStore(); - const resolved = await resolveApiKeyForProfile({ - store: loaded, - profileId: "chutes:default", - }); - - expect(resolved?.apiKey).toBe("at_new"); - expect(resolveProviderOAuthCredentialWithPluginMock).toHaveBeenCalledOnce(); - expect(resolveProviderOAuthCredentialWithPluginMock).toHaveBeenCalledWith({ - provider: "chutes", - config: undefined, - credential: storedCredential, - refresh: true, - }); - expect(fetchSpy).not.toHaveBeenCalled(); - - const persisted = loadPersistedAuthProfileStore(state.agentDir()); - expect(persisted?.profiles["chutes:default"]).toEqual(refreshedCredential); - }, - ); - }); -}); diff --git a/src/agents/auth-profiles.provider-refresh-lifecycle.test.ts b/src/agents/auth-profiles.provider-refresh-lifecycle.test.ts new file mode 100644 index 000000000000..cad18631e54d --- /dev/null +++ b/src/agents/auth-profiles.provider-refresh-lifecycle.test.ts @@ -0,0 +1,149 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resetFileLockStateForTest } from "../infra/file-lock.js"; +import { isPluginRegistryLoadInFlight } from "../plugins/loader-cache.js"; +import { + cleanupPluginLoaderFixturesForTest, + EMPTY_PLUGIN_SCHEMA, + loadOpenClawPlugins, + resetPluginLoaderTestStateForTest, + useNoBundledPlugins, + writePlugin, +} from "../plugins/loader.test-fixtures.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; +import { + oauthCred, + readAuthProfileStoreForTest, + storeWith, +} from "./auth-profiles/oauth-test-utils.js"; +import { resolveApiKeyForProfile } from "./auth-profiles/oauth.js"; +import { clearRuntimeAuthProfileStoreSnapshots } from "./auth-profiles/runtime-snapshots.js"; + +const START_AUTH_CALLBACK = "__openclawProviderRefreshLifecycleStart"; +const PLUGIN_ID = "provider-refresh-lifecycle"; +const PROVIDER_ID = "lifecycle-provider"; +const PROFILE_ID = `${PROVIDER_ID}:default`; + +function writeLifecycleProviderPlugin(registerBody: string) { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: PLUGIN_ID, + body: `module.exports = { + id: ${JSON.stringify(PLUGIN_ID)}, + register(api) { + ${registerBody} + }, + };`, + }); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: plugin.id, + providers: [PROVIDER_ID], + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf8", + ); + return plugin; +} + +beforeEach(() => { + clearRuntimeAuthProfileStoreSnapshots(); + resetFileLockStateForTest(); + resetPluginLoaderTestStateForTest(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + clearRuntimeAuthProfileStoreSnapshots(); + resetFileLockStateForTest(); + resetPluginLoaderTestStateForTest(); +}); + +afterAll(cleanupPluginLoaderFixturesForTest); + +describe("provider OAuth refresh lifecycle", () => { + it("resumes refresh after synchronous provider registration completes", async () => { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-provider-refresh-", agentEnv: "main" }, + async (state) => { + const expiredCredential = oauthCred({ + provider: PROVIDER_ID, + access: "access-old", + refresh: "refresh-old", + expires: 1, + }); + const refreshedCredential = { + ...expiredCredential, + access: "access-new", + refresh: "refresh-new", + expires: 4_102_444_800_000, + }; + const initialStore = storeWith(PROFILE_ID, expiredCredential); + await state.writeAuthProfiles(initialStore); + const plugin = writeLifecycleProviderPlugin(` + globalThis[${JSON.stringify(START_AUTH_CALLBACK)}](); + api.registerProvider({ + id: ${JSON.stringify(PROVIDER_ID)}, + label: "Lifecycle Provider", + auth: [], + async refreshOAuth(credential) { + return { + ...credential, + access: "access-new", + refresh: "refresh-new", + expires: 4102444800000, + }; + }, + }); + `); + const config = { + plugins: { + allow: [plugin.id], + load: { paths: [plugin.file] }, + entries: { [plugin.id]: { enabled: true } }, + }, + } satisfies OpenClawConfig; + const loadOptions: NonNullable[0]> = { + cache: false, + workspaceDir: plugin.dir, + config, + onlyPluginIds: [plugin.id], + }; + let authResolution: ReturnType | undefined; + let registrationStarted = false; + const startAuthDuringRegister = vi.fn(() => { + if (registrationStarted) { + throw new Error("provider lifecycle fixture registered more than once"); + } + registrationStarted = true; + expect(isPluginRegistryLoadInFlight(loadOptions)).toBe(true); + authResolution = resolveApiKeyForProfile({ + cfg: config, + store: initialStore, + profileId: PROFILE_ID, + }); + }); + vi.stubGlobal(START_AUTH_CALLBACK, startAuthDuringRegister); + + loadOpenClawPlugins(loadOptions); + + expect(isPluginRegistryLoadInFlight(loadOptions)).toBe(false); + if (!authResolution) { + throw new Error("provider lifecycle fixture did not start auth resolution"); + } + await expect(authResolution).resolves.toMatchObject({ apiKey: "access-new" }); + expect(readAuthProfileStoreForTest(state.agentDir()).profiles[PROFILE_ID]).toEqual( + refreshedCredential, + ); + expect(startAuthDuringRegister).toHaveBeenCalledOnce(); + }, + ); + }); +}); diff --git a/src/agents/chutes-oauth.flow.test.ts b/src/agents/chutes-oauth.flow.test.ts deleted file mode 100644 index 3c9215a0d837..000000000000 --- a/src/agents/chutes-oauth.flow.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** Tests the retained core Chutes OAuth token exchange compatibility flow. */ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; -import { exchangeChutesCodeForTokens } from "./chutes-oauth.js"; - -const CHUTES_TOKEN_ENDPOINT = "https://api.chutes.ai/idp/token"; -const CHUTES_USERINFO_ENDPOINT = "https://api.chutes.ai/idp/userinfo"; - -const urlToString = (url: Request | URL | string): string => { - if (typeof url === "string") { - return url; - } - return "url" in url ? url.url : String(url); -}; - -function rejectWhenAborted(init?: RequestInit): Promise { - const signal = init?.signal; - if (!signal) { - return Promise.reject(new Error("missing OAuth request signal")); - } - return new Promise((_, reject) => { - const rejectWithReason = () => - reject(signal.reason instanceof Error ? signal.reason : new Error("OAuth request aborted")); - if (signal.aborted) { - rejectWithReason(); - return; - } - signal.addEventListener("abort", rejectWithReason, { once: true }); - }); -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("chutes-oauth", () => { - it("exchanges code for tokens and stores username as email", async () => { - const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); - const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = urlToString(input); - if (url === CHUTES_TOKEN_ENDPOINT) { - expect(init?.method).toBe("POST"); - expect( - String(init?.headers && (init.headers as Record)["Content-Type"]), - ).toContain("application/x-www-form-urlencoded"); - return new Response( - JSON.stringify({ - access_token: "at_123", - refresh_token: "rt_123", - expires_in: 3600, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - if (url === CHUTES_USERINFO_ENDPOINT) { - expect( - String(init?.headers && (init.headers as Record).Authorization), - ).toBe("Bearer at_123"); - return new Response(JSON.stringify({ username: "fred", sub: "sub_1" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - return new Response("not found", { status: 404 }); - }); - - const now = 1_000_000; - const creds = await exchangeChutesCodeForTokens({ - app: { - clientId: "cid_test", - redirectUri: "http://127.0.0.1:1456/oauth-callback", - scopes: ["openid"], - }, - code: "code_123", - codeVerifier: "verifier_123", - fetchFn, - now, - }); - - expect(creds.access).toBe("at_123"); - expect(creds.refresh).toBe("rt_123"); - expect(creds.email).toBe("fred"); - expect((creds as unknown as { accountId?: string }).accountId).toBe("sub_1"); - expect((creds as unknown as { clientId?: string }).clientId).toBe("cid_test"); - expect(creds.expires).toBe(now + 3600 * 1000 - 5 * 60 * 1000); - expect(timeoutSpy).toHaveBeenCalledTimes(2); - expect(timeoutSpy).toHaveBeenNthCalledWith(1, 30_000); - expect(timeoutSpy).toHaveBeenNthCalledWith(2, 30_000); - }); - - it("rejects unsafe exchange token lifetimes", async () => { - const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => { - const url = urlToString(input); - if (url !== CHUTES_TOKEN_ENDPOINT) { - return new Response("not found", { status: 404 }); - } - return new Response( - '{"access_token":"at_unsafe","refresh_token":"rt_unsafe","expires_in":1e309}', - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - - await expect( - exchangeChutesCodeForTokens({ - app: { - clientId: "cid_test", - redirectUri: "http://127.0.0.1:1456/oauth-callback", - scopes: ["openid"], - }, - code: "code_unsafe", - codeVerifier: "verifier_unsafe", - fetchFn, - now: 1_000_000, - }), - ).rejects.toThrow("Chutes token exchange returned invalid expires_in"); - }); - - it("cancels failed userinfo response bodies during token exchange", async () => { - const userInfoResponse = new Response("temporarily unavailable", { status: 503 }); - const cancel = vi.spyOn(userInfoResponse.body!, "cancel").mockResolvedValue(undefined); - const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => { - const url = urlToString(input); - if (url === CHUTES_TOKEN_ENDPOINT) { - return new Response( - JSON.stringify({ - access_token: "at_123", - refresh_token: "rt_123", - expires_in: 3600, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - if (url === CHUTES_USERINFO_ENDPOINT) { - return userInfoResponse; - } - return new Response("not found", { status: 404 }); - }); - - const creds = await exchangeChutesCodeForTokens({ - app: { - clientId: "cid_test", - redirectUri: "http://127.0.0.1:1456/oauth-callback", - scopes: ["openid"], - }, - code: "code_123", - codeVerifier: "verifier_123", - fetchFn, - now: 1_000_000, - }); - - expect(cancel).toHaveBeenCalledOnce(); - expect(creds.access).toBe("at_123"); - expect(creds.email).toBeUndefined(); - expect((creds as unknown as { accountId?: string }).accountId).toBeUndefined(); - }); - - it("keeps issued tokens when userinfo exceeds the fixed deadline", async () => { - const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockImplementation((delay) => { - expect(delay).toBe(30_000); - return AbortSignal.abort(new DOMException("OAuth request timed out", "TimeoutError")); - }); - const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = urlToString(input); - if (url === CHUTES_TOKEN_ENDPOINT) { - return new Response( - '{"access_token":"at_timeout","refresh_token":"rt_timeout","expires_in":3600}', - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - if (url === CHUTES_USERINFO_ENDPOINT) { - return await rejectWhenAborted(init); - } - return new Response("not found", { status: 404 }); - }); - - const credentials = await exchangeChutesCodeForTokens({ - app: { - clientId: "cid_test", - redirectUri: "http://127.0.0.1:1456/oauth-callback", - scopes: ["openid"], - }, - code: "code_test", - codeVerifier: "verifier_test", - fetchFn, - now: 1_000_000, - }); - - expect(credentials).toMatchObject({ access: "at_timeout", refresh: "rt_timeout" }); - expect(credentials.email).toBeUndefined(); - expect(timeoutSpy).toHaveBeenCalledTimes(2); - }); - - it("normalizes and redacts structured token exchange errors", async () => { - const leakedClientSecret = "oauth-client-secret-1234567890"; - const response = new Response( - JSON.stringify({ - error: "invalid_grant", - error_description: `Authorization failed for client_secret=${leakedClientSecret}`, - }), - { - status: 400, - headers: { - "content-type": "application/json", - "x-request-id": "chutes_req_123", - }, - }, - ); - const textSpy = vi.spyOn(response, "text").mockRejectedValue(new Error("unbounded")); - const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => { - const url = urlToString(input); - if (url === CHUTES_TOKEN_ENDPOINT) { - return response; - } - return new Response("not found", { status: 404 }); - }); - - let error: unknown; - try { - await exchangeChutesCodeForTokens({ - app: { - clientId: "cid_test", - redirectUri: "http://127.0.0.1:1456/oauth-callback", - scopes: ["openid"], - }, - code: "code_401", - codeVerifier: "verifier_401", - fetchFn, - now: 1_000_000, - }); - } catch (caught) { - error = caught; - } - - expect(error).toMatchObject({ - name: "ProviderHttpError", - status: 400, - errorCode: "invalid_grant", - requestId: "chutes_req_123", - }); - const message = (error as Error).message; - expect(message).toContain("Chutes token exchange failed (400): Authorization failed"); - expect(message).toContain("[code=invalid_grant]"); - expect(message).not.toContain(leakedClientSecret); - expect(message).not.toContain("error_description"); - expect((error as { errorBody?: string }).errorBody).not.toContain(leakedClientSecret); - expect(textSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/src/agents/chutes-oauth.test.ts b/src/agents/chutes-oauth.test.ts deleted file mode 100644 index 697eb535f6df..000000000000 --- a/src/agents/chutes-oauth.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** Tests Chutes OAuth callback parsing and PKCE generation. */ -import { describe, expect, it } from "vitest"; -import { generateChutesPkce, parseOAuthCallbackInput } from "./chutes-oauth.js"; - -describe("parseOAuthCallbackInput", () => { - it("rejects code-only input (state required)", () => { - const parsed = parseOAuthCallbackInput("abc123", "expected-state"); - expect(parsed).toEqual({ - error: "Paste the full redirect URL (must include code + state).", - }); - }); - - it("accepts full redirect URL when state matches", () => { - const parsed = parseOAuthCallbackInput( - "http://127.0.0.1:1456/oauth-callback?code=abc123&state=expected-state", - "expected-state", - ); - expect(parsed).toEqual({ code: "abc123", state: "expected-state" }); - }); - - it("accepts querystring-only input when state matches", () => { - const parsed = parseOAuthCallbackInput("code=abc123&state=expected-state", "expected-state"); - expect(parsed).toEqual({ code: "abc123", state: "expected-state" }); - }); - - it("rejects missing state", () => { - const parsed = parseOAuthCallbackInput( - "http://127.0.0.1:1456/oauth-callback?code=abc123", - "expected-state", - ); - expect(parsed).toEqual({ - error: "Missing 'state' parameter. Paste the full redirect URL.", - }); - }); - - it("rejects state mismatch", () => { - const parsed = parseOAuthCallbackInput( - "http://127.0.0.1:1456/oauth-callback?code=abc123&state=evil", - "expected-state", - ); - expect(parsed).toEqual({ - error: "OAuth state mismatch - possible CSRF attack. Please retry login.", - }); - }); -}); - -describe("generateChutesPkce", () => { - it("returns verifier and challenge", () => { - const pkce = generateChutesPkce(); - expect(pkce.verifier).toMatch(/^[0-9a-f]{64}$/); - expect(pkce.challenge).toMatch(/^[A-Za-z0-9_-]+$/); - }); -}); diff --git a/src/agents/chutes-oauth.ts b/src/agents/chutes-oauth.ts deleted file mode 100644 index b78ed76aef50..000000000000 --- a/src/agents/chutes-oauth.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Implements Chutes OAuth PKCE, callback parsing, and token exchange for the - * deprecated core login compatibility surface. - */ -import { randomBytes } from "node:crypto"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { sha256Base64Url } from "../infra/crypto-digest.js"; -import { cancelUnreadResponseBody } from "../infra/http-body.js"; -import { resolveExpiresAtMsFromDurationSeconds } from "../infra/parse-finite-number.js"; -import type { OAuthCredentials } from "../llm/oauth.js"; -import { buildOAuthRequestSignal } from "../llm/utils/oauth/abort.js"; -import { assertOkOrThrowProviderError, readProviderJsonResponse } from "./provider-http-errors.js"; - -const CHUTES_OAUTH_REQUEST_TIMEOUT_MS = 30_000; - -const CHUTES_OAUTH_ISSUER = "https://api.chutes.ai"; -export const CHUTES_AUTHORIZE_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/authorize`; -const CHUTES_TOKEN_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/token`; -const CHUTES_USERINFO_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/userinfo`; - -const DEFAULT_EXPIRES_BUFFER_MS = 5 * 60 * 1000; - -type ChutesPkce = { verifier: string; challenge: string }; - -type ChutesUserInfo = { - sub?: string; - username?: string; - created_at?: string; -}; - -/** OAuth client settings for the Chutes authorization-code flow. */ -export type ChutesOAuthAppConfig = { - clientId: string; - clientSecret?: string; - redirectUri: string; - scopes: string[]; -}; - -type ChutesStoredOAuth = OAuthCredentials & { - clientId?: string; -}; - -/** Generates a PKCE verifier/challenge pair for Chutes login. */ -export function generateChutesPkce(): ChutesPkce { - const verifier = randomBytes(32).toString("hex"); - const challenge = sha256Base64Url(verifier); - return { verifier, challenge }; -} - -/** Parses pasted Chutes redirect input and enforces the expected OAuth state. */ -export function parseOAuthCallbackInput( - input: string, - expectedState: string, -): { code: string; state: string } | { error: string } { - const trimmed = input.trim(); - if (!trimmed) { - return { error: "No input provided" }; - } - - // Manual flow must validate CSRF state; require URL (or querystring) that includes `state`. - let url: URL; - try { - url = new URL(trimmed); - } catch { - // Code-only paste (common) is no longer accepted because it defeats state validation. - if ( - !/\s/.test(trimmed) && - !trimmed.includes("://") && - !trimmed.includes("?") && - !trimmed.includes("=") - ) { - return { error: "Paste the full redirect URL (must include code + state)." }; - } - - // Users sometimes paste only the query string: `?code=...&state=...` or `code=...&state=...` - const qs = trimmed.startsWith("?") ? trimmed : `?${trimmed}`; - try { - url = new URL(`http://localhost/${qs}`); - } catch { - return { error: "Paste the full redirect URL (must include code + state)." }; - } - } - - const code = normalizeOptionalString(url.searchParams.get("code")); - const state = normalizeOptionalString(url.searchParams.get("state")); - if (!code) { - return { error: "Missing 'code' parameter in URL" }; - } - if (!state) { - return { error: "Missing 'state' parameter. Paste the full redirect URL." }; - } - if (state !== expectedState) { - return { error: "OAuth state mismatch - possible CSRF attack. Please retry login." }; - } - return { code, state }; -} - -function resolveChutesExpiresAt(value: unknown, now: number): number | undefined { - return resolveExpiresAtMsFromDurationSeconds(value, { - nowMs: now, - bufferMs: DEFAULT_EXPIRES_BUFFER_MS, - minRemainingMs: 30_000, - }); -} - -async function fetchChutesUserInfo(params: { - accessToken: string; - fetchFn?: typeof fetch; -}): Promise { - const fetchFn = params.fetchFn ?? fetch; - const response = await fetchFn(CHUTES_USERINFO_ENDPOINT, { - headers: { Authorization: `Bearer ${params.accessToken}` }, - signal: buildOAuthRequestSignal({ timeoutMs: CHUTES_OAUTH_REQUEST_TIMEOUT_MS }), - }); - if (!response.ok) { - await cancelUnreadResponseBody(response); - return null; - } - const data = await readProviderJsonResponse(response, "Chutes userinfo"); - if (!data || typeof data !== "object") { - return null; - } - const typed = data as ChutesUserInfo; - return typed; -} - -/** Exchanges an authorization code for stored Chutes OAuth credentials. */ -export async function exchangeChutesCodeForTokens(params: { - app: ChutesOAuthAppConfig; - code: string; - codeVerifier: string; - fetchFn?: typeof fetch; - now?: number; -}): Promise { - const fetchFn = params.fetchFn ?? fetch; - const now = params.now ?? Date.now(); - - const body = new URLSearchParams({ - grant_type: "authorization_code", - client_id: params.app.clientId, - code: params.code, - redirect_uri: params.app.redirectUri, - code_verifier: params.codeVerifier, - }); - if (params.app.clientSecret) { - body.set("client_secret", params.app.clientSecret); - } - - const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body, - signal: buildOAuthRequestSignal({ timeoutMs: CHUTES_OAUTH_REQUEST_TIMEOUT_MS }), - }); - await assertOkOrThrowProviderError(response, "Chutes token exchange failed"); - - const data = await readProviderJsonResponse<{ - access_token?: string; - refresh_token?: string; - expires_in?: number; - }>(response, "Chutes token exchange"); - - const access = data.access_token?.trim(); - const refresh = data.refresh_token?.trim(); - const expires = resolveChutesExpiresAt(data.expires_in, now); - - if (!access) { - throw new Error("Chutes token exchange returned no access_token"); - } - if (!refresh) { - throw new Error("Chutes token exchange returned no refresh_token"); - } - if (expires === undefined) { - throw new Error("Chutes token exchange returned invalid expires_in"); - } - - let info: ChutesUserInfo | null = null; - try { - info = await fetchChutesUserInfo({ accessToken: access, fetchFn }); - } catch { - // Token exchange completes authentication; optional profile enrichment must - // not discard issued credentials when userinfo is unavailable or times out. - } - - return { - access, - refresh, - expires, - email: info?.username, - accountId: info?.sub, - clientId: params.app.clientId, - } as unknown as ChutesStoredOAuth; -} diff --git a/src/commands/chutes-oauth.test.ts b/src/commands/chutes-oauth.test.ts deleted file mode 100644 index 5dbb790c35f0..000000000000 --- a/src/commands/chutes-oauth.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -// Chutes OAuth tests cover OAuth endpoints, local callback handling, and fetch preconnect behavior. -import { describe, expect, it, vi } from "vitest"; -import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; -import { getFreePort } from "../test-utils/ports.js"; -import { loginChutes } from "./chutes-oauth.js"; - -const CHUTES_TOKEN_ENDPOINT = "https://api.chutes.ai/idp/token"; -const CHUTES_USERINFO_ENDPOINT = "https://api.chutes.ai/idp/userinfo"; - -const urlToString = (url: Request | URL | string): string => { - if (typeof url === "string") { - return url; - } - return "url" in url ? url.url : String(url); -}; - -function createOAuthFetchFn(params: { - accessToken: string; - refreshToken: string; - username: string; - passthrough?: boolean; -}) { - return withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = urlToString(input); - if (url === CHUTES_TOKEN_ENDPOINT) { - return new Response( - JSON.stringify({ - access_token: params.accessToken, - refresh_token: params.refreshToken, - expires_in: 3600, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - if (url === CHUTES_USERINFO_ENDPOINT) { - return new Response(JSON.stringify({ username: params.username }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (params.passthrough) { - return fetch(input, init); - } - return new Response("not found", { status: 404 }); - }); -} - -describe("loginChutes", () => { - it("captures local redirect and exchanges code for tokens", async () => { - const port = await getFreePort(); - const redirectUri = `http://127.0.0.1:${port}/oauth-callback`; - - const fetchFn = createOAuthFetchFn({ - accessToken: "at_local", - refreshToken: "rt_local", - username: "local-user", - passthrough: true, - }); - - const onPrompt = vi.fn(async () => { - throw new Error("onPrompt should not be called for local callback"); - }); - - const creds = await loginChutes({ - app: { clientId: "cid_test", redirectUri, scopes: ["openid"] }, - onAuth: async ({ url }) => { - const state = new URL(url).searchParams.get("state"); - if (state === null) { - throw new Error("expected OAuth state"); - } - expect(state).toMatch(/\S/u); - await fetch(`${redirectUri}?code=code_local&state=${state}`); - }, - onPrompt, - fetchFn, - }); - - expect(onPrompt).not.toHaveBeenCalled(); - expect(creds.access).toBe("at_local"); - expect(creds.refresh).toBe("rt_local"); - expect(creds.email).toBe("local-user"); - }); - - it("supports manual flow with pasted redirect URL", async () => { - const fetchFn = createOAuthFetchFn({ - accessToken: "at_manual", - refreshToken: "rt_manual", - username: "manual-user", - }); - - let capturedState: string | null = null; - const creds = await loginChutes({ - app: { - clientId: "cid_test", - redirectUri: "http://127.0.0.1:1456/oauth-callback", - scopes: ["openid"], - }, - manual: true, - onAuth: async ({ url }) => { - capturedState = new URL(url).searchParams.get("state"); - }, - onPrompt: async () => { - if (!capturedState) { - throw new Error("missing state"); - } - return `?code=code_manual&state=${capturedState}`; - }, - fetchFn, - }); - - expect(creds.access).toBe("at_manual"); - expect(creds.refresh).toBe("rt_manual"); - expect(creds.email).toBe("manual-user"); - }); - - it("does not reuse code_verifier as state", async () => { - const fetchFn = createOAuthFetchFn({ - accessToken: "at_manual", - refreshToken: "rt_manual", - username: "manual-user", - }); - - const createPkce = () => ({ - verifier: "verifier_123", - challenge: "chal_123", - }); - const createState = () => "state_456"; - - const creds = await loginChutes({ - app: { - clientId: "cid_test", - redirectUri: "http://127.0.0.1:1456/oauth-callback", - scopes: ["openid"], - }, - manual: true, - createPkce, - createState, - onAuth: async ({ url }) => { - const parsed = new URL(url); - expect(parsed.searchParams.get("state")).toBe("state_456"); - expect(parsed.searchParams.get("state")).not.toBe("verifier_123"); - }, - onPrompt: async () => "?code=code_manual&state=state_456", - fetchFn, - }); - - expect(creds.access).toBe("at_manual"); - }); - - it("rejects pasted redirect URLs missing state", async () => { - const fetchFn = withFetchPreconnect(async () => new Response("not found", { status: 404 })); - - await expect( - loginChutes({ - app: { - clientId: "cid_test", - redirectUri: "http://127.0.0.1:1456/oauth-callback", - scopes: ["openid"], - }, - manual: true, - createPkce: () => ({ verifier: "verifier_123", challenge: "chal_123" }), - createState: () => "state_456", - onAuth: async () => {}, - onPrompt: async () => "http://127.0.0.1:1456/oauth-callback?code=code_only", - fetchFn, - }), - ).rejects.toThrow("Missing 'state' parameter"); - }); -}); diff --git a/src/commands/chutes-oauth.ts b/src/commands/chutes-oauth.ts deleted file mode 100644 index 4b55ac0e40a7..000000000000 --- a/src/commands/chutes-oauth.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Chutes OAuth login flow with loopback callback handling and manual paste fallback. -import { randomBytes } from "node:crypto"; -import { createServer } from "node:http"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import type { ChutesOAuthAppConfig } from "../agents/chutes-oauth.js"; -import { - CHUTES_AUTHORIZE_ENDPOINT, - exchangeChutesCodeForTokens, - generateChutesPkce, - parseOAuthCallbackInput, -} from "../agents/chutes-oauth.js"; -import { isLoopbackHost } from "../gateway/net.js"; -import { toErrorObject } from "../infra/errors.js"; -import type { OAuthCredentials } from "../llm/oauth.js"; - -type OAuthPrompt = { - message: string; - placeholder?: string; -}; - -function parseManualOAuthInput( - input: string, - expectedState: string, -): { code: string; state: string } { - const trimmed = normalizeOptionalString(input ?? "") ?? ""; - if (!trimmed) { - throw new Error("Missing OAuth redirect URL or authorization code."); - } - - // Support pasting either: - // - Full redirect URL (preferred; validates state) - // - Raw authorization code (legacy/manual copy flows) - const looksLikeRedirect = - /^https?:\/\//i.test(trimmed) || trimmed.includes("://") || trimmed.includes("?"); - if (!looksLikeRedirect) { - return { code: trimmed, state: expectedState }; - } - - const parsed = parseOAuthCallbackInput(trimmed, expectedState); - if ("error" in parsed) { - throw new Error(parsed.error); - } - if (parsed.state !== expectedState) { - throw new Error("Invalid OAuth state"); - } - return parsed; -} - -function buildAuthorizeUrl(params: { - clientId: string; - redirectUri: string; - scopes: string[]; - state: string; - challenge: string; -}): string { - const qs = new URLSearchParams({ - client_id: params.clientId, - redirect_uri: params.redirectUri, - response_type: "code", - scope: params.scopes.join(" "), - state: params.state, - code_challenge: params.challenge, - code_challenge_method: "S256", - }); - return `${CHUTES_AUTHORIZE_ENDPOINT}?${qs.toString()}`; -} - -async function waitForLocalCallback(params: { - redirectUri: string; - expectedState: string; - timeoutMs: number; - onProgress?: (message: string) => void; -}): Promise<{ code: string; state: string }> { - const redirectUrl = new URL(params.redirectUri); - if (redirectUrl.protocol !== "http:") { - throw new Error(`Chutes OAuth redirect URI must be http:// (got ${params.redirectUri})`); - } - const hostname = redirectUrl.hostname || "127.0.0.1"; - if (!isLoopbackHost(hostname)) { - throw new Error( - `Chutes OAuth redirect hostname must be loopback (got ${hostname}). Use http://127.0.0.1:/...`, - ); - } - const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80; - const expectedPath = redirectUrl.pathname || "/"; - - return await new Promise<{ code: string; state: string }>((resolve, reject) => { - let timeout: NodeJS.Timeout | null = null; - const server = createServer((req, res) => { - try { - const requestUrl = new URL(req.url ?? "/", redirectUrl.origin); - if (requestUrl.pathname !== expectedPath) { - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Not found"); - return; - } - - const code = requestUrl.searchParams.get("code")?.trim(); - const state = requestUrl.searchParams.get("state")?.trim(); - - if (!code) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Missing code"); - return; - } - if (!state || state !== params.expectedState) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Invalid state"); - return; - } - - res.statusCode = 200; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end( - [ - "", - "", - "

Chutes OAuth complete

", - "

You can close this window and return to OpenClaw.

", - ].join(""), - ); - if (timeout) { - clearTimeout(timeout); - } - server.close(); - resolve({ code, state }); - } catch (err) { - if (timeout) { - clearTimeout(timeout); - } - server.close(); - reject(toErrorObject(err, "Non-Error rejection")); - } - }); - - server.once("error", (err) => { - if (timeout) { - clearTimeout(timeout); - } - server.close(); - reject(err); - }); - server.listen(port, hostname, () => { - params.onProgress?.(`Waiting for OAuth callback on ${redirectUrl.origin}${expectedPath}…`); - }); - - timeout = setTimeout(() => { - try { - server.close(); - } catch {} - reject(new Error("OAuth callback timeout")); - }, params.timeoutMs); - }); -} - -/** Run a PKCE OAuth login for Chutes and exchange the resulting code for credentials. */ -export async function loginChutes(params: { - app: ChutesOAuthAppConfig; - manual?: boolean; - timeoutMs?: number; - createPkce?: typeof generateChutesPkce; - createState?: () => string; - onAuth: (event: { url: string }) => Promise; - onPrompt: (prompt: OAuthPrompt) => Promise; - onProgress?: (message: string) => void; - fetchFn?: typeof fetch; -}): Promise { - const createPkce = params.createPkce ?? generateChutesPkce; - const createState = params.createState ?? (() => randomBytes(16).toString("hex")); - - const { verifier, challenge } = createPkce(); - const state = createState(); - const timeoutMs = params.timeoutMs ?? 3 * 60 * 1000; - - const url = buildAuthorizeUrl({ - clientId: params.app.clientId, - redirectUri: params.app.redirectUri, - scopes: params.app.scopes, - state, - challenge, - }); - - let codeAndState: { code: string; state: string }; - if (params.manual) { - await params.onAuth({ url }); - params.onProgress?.("Waiting for redirect URL…"); - const input = await params.onPrompt({ - message: "Paste the redirect URL (or authorization code)", - placeholder: `${params.app.redirectUri}?code=...&state=...`, - }); - codeAndState = parseManualOAuthInput(input, state); - } else { - const callback = waitForLocalCallback({ - redirectUri: params.app.redirectUri, - expectedState: state, - timeoutMs, - onProgress: params.onProgress, - }).catch(async () => { - params.onProgress?.("OAuth callback not detected; paste redirect URL…"); - const input = await params.onPrompt({ - message: "Paste the redirect URL (or authorization code)", - placeholder: `${params.app.redirectUri}?code=...&state=...`, - }); - return parseManualOAuthInput(input, state); - }); - - await params.onAuth({ url }); - codeAndState = await callback; - } - - params.onProgress?.("Exchanging code for tokens…"); - return await exchangeChutesCodeForTokens({ - app: params.app, - code: codeAndState.code, - codeVerifier: verifier, - fetchFn: params.fetchFn, - }); -} diff --git a/src/plugin-sdk/provider-auth-login.runtime.ts b/src/plugin-sdk/provider-auth-login.runtime.ts index 57902dd01fcc..d0c7123d40aa 100644 --- a/src/plugin-sdk/provider-auth-login.runtime.ts +++ b/src/plugin-sdk/provider-auth-login.runtime.ts @@ -1,6 +1,4 @@ /** @deprecated Provider-owned login helpers; use provider auth hooks instead. */ -export { loginChutes } from "../commands/chutes-oauth.js"; -/** @deprecated Provider-owned login helpers; use provider auth hooks instead. */ export { loginOpenAICodexOAuth } from "../plugins/provider-openai-chatgpt-oauth.js"; /** @deprecated Provider-owned login helpers; use provider auth hooks instead. */ export { githubCopilotLoginCommand } from "./github-copilot-login.js"; diff --git a/src/plugins/compat/deprecation-marking.ts b/src/plugins/compat/deprecation-marking.ts index d1dc8e7e451e..58a3621f65d3 100644 --- a/src/plugins/compat/deprecation-marking.ts +++ b/src/plugins/compat/deprecation-marking.ts @@ -119,7 +119,6 @@ export const DEPRECATION_MARKING_COMPAT_RECORDS = [ "openclaw/plugin-sdk/provider-auth DEFAULT_COPILOT_API_BASE_URL", "openclaw/plugin-sdk/provider-auth deriveCopilotApiBaseUrlFromToken", "openclaw/plugin-sdk/provider-auth resolveCopilotApiToken", - "openclaw/plugin-sdk/provider-auth-login.runtime loginChutes", "openclaw/plugin-sdk/provider-auth-login.runtime loginOpenAICodexOAuth", "openclaw/plugin-sdk/provider-auth-login.runtime githubCopilotLoginCommand", "openclaw/plugin-sdk/provider-auth-copilot-cache CachedCopilotToken", diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts index 86f21bf8766a..2d3bad784e8a 100644 --- a/src/plugins/compat/registry.test.ts +++ b/src/plugins/compat/registry.test.ts @@ -40,7 +40,7 @@ const deprecationMarkingCodes = [ const deprecationMarkingSurfaceCounts: Record<(typeof deprecationMarkingCodes)[number], number> = { "plugin-sdk-channel-setup-input-fields": 22, "plugin-sdk-broad-runtime-barrels": 12, - "plugin-sdk-provider-owned-helper-shims": 35, + "plugin-sdk-provider-owned-helper-shims": 34, "message-presentation-legacy-bridges": 21, "plugin-sdk-focused-compat-aliases": 23, "agent-harness-terminal-result-aliases": 10,