refactor(chutes): remove accidental core OAuth shim (#120993)

* refactor(chutes): remove accidental core OAuth shim

* chore(plugin-sdk): refresh API baseline

* chore(plugin-sdk): refresh API baseline

* chore(plugin-sdk): refresh API baseline

* chore(plugin-sdk): refresh API baseline

* chore(plugin-sdk): refresh API baseline
This commit is contained in:
Peter Steinberger
2026-08-09 02:27:27 -07:00
committed by GitHub
parent 7d4066639e
commit d0e812e18f
15 changed files with 289 additions and 1396 deletions
+48 -48
View File
@@ -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
+3 -35
View File
@@ -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<T>(
}
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 () => {
+25 -42
View File
@@ -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 }),
+63 -68
View File
@@ -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<ChutesStoredOAuth> {
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,
};
}
@@ -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<Parameters<typeof loadOpenClawPlugins>[0]> = {
cache: false,
workspaceDir: plugin.dir,
config,
onlyPluginIds: [plugin.id],
};
let authResolution: ReturnType<typeof resolveApiKeyForProfile> | 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();
});
});
-116
View File
@@ -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<ResolveProviderOAuthCredentialWithPlugin>(),
}));
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);
},
);
});
});
@@ -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<Parameters<typeof loadOpenClawPlugins>[0]> = {
cache: false,
workspaceDir: plugin.dir,
config,
onlyPluginIds: [plugin.id],
};
let authResolution: ReturnType<typeof resolveApiKeyForProfile> | 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();
},
);
});
});
-248
View File
@@ -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<Response> {
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<string, string>)["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<string, string>).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();
});
});
-53
View File
@@ -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_-]+$/);
});
});
-193
View File
@@ -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<ChutesUserInfo | null> {
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<unknown>(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<ChutesStoredOAuth> {
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;
}
-169
View File
@@ -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");
});
});
-221
View File
@@ -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:<port>/...`,
);
}
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(
[
"<!doctype html>",
"<html><head><meta charset='utf-8' /></head>",
"<body><h2>Chutes OAuth complete</h2>",
"<p>You can close this window and return to OpenClaw.</p></body></html>",
].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<void>;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
fetchFn?: typeof fetch;
}): Promise<OAuthCredentials> {
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,
});
}
@@ -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";
@@ -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",
+1 -1
View File
@@ -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,