Compare commits
28 Commits
v0.7.3
..
c2cb4ac433
| Author | SHA1 | Date | |
|---|---|---|---|
|
c2cb4ac433
|
|||
|
605a9170b0
|
|||
|
385bd3eda2
|
|||
|
6c3d96ac83
|
|||
|
aa1fe7f7aa
|
|||
|
5e50828108
|
|||
|
693e2d9672
|
|||
|
16f324cefc
|
|||
|
cc50d39ab4
|
|||
|
fc23b532d9
|
|||
|
c2d4240138
|
|||
|
cd1b043b1e
|
|||
|
81b4f6a76e
|
|||
| d48b11dcfa | |||
| 86dd922d2c | |||
|
9ec20d74a4
|
|||
|
c78cdef5ae
|
|||
|
3df590f276
|
|||
|
91300c16fe
|
|||
|
52356ead6c
|
|||
|
ad9fc524d4
|
|||
|
af50909a89
|
|||
|
318d9ba1cd
|
|||
|
45d709f28e
|
|||
|
9cd074cb9b
|
|||
|
93eec45473
|
|||
|
e585e0b049
|
|||
|
13bfaf9aca
|
@@ -1,3 +1,20 @@
|
||||
## v0.7.4 (2026-07-02)
|
||||
|
||||
### Feat
|
||||
|
||||
- Pin specific usql version to sbx kit
|
||||
- recursively take ownership over the copied in coyote config for the sbx
|
||||
- explicitly specify the COYOTE_CONFIG_DIR in the sbx kit
|
||||
- --tail-logs can track log rollovers and incoporates a sleep timer to minimize idle CPU cycles
|
||||
- Added support for log rolling so log files don't just blow up over time
|
||||
|
||||
### Fix
|
||||
|
||||
- Added back in --kit specification for the running of the sbx
|
||||
- sbx isn't copying base files in their respective directories
|
||||
- Update deprecated sbx kit config
|
||||
- Properly chown the coyote config recursively and password file in the sbx
|
||||
|
||||
## v0.7.3 (2026-06-24)
|
||||
|
||||
### Fix
|
||||
|
||||
Generated
+54
-60
@@ -141,9 +141,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
@@ -174,9 +174,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.1"
|
||||
version = "1.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
|
||||
checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
@@ -321,11 +321,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.17.0"
|
||||
version = "1.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
|
||||
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
|
||||
dependencies = [
|
||||
"aws-lc-sys 0.41.0",
|
||||
"aws-lc-sys 0.42.0",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -344,14 +344,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.41.0"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
|
||||
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1193,14 +1194,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_complete"
|
||||
version = "4.6.5"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772"
|
||||
checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_lex",
|
||||
"is_executable",
|
||||
"shlex 1.3.0",
|
||||
"shlex 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1321,9 +1322,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.16.3"
|
||||
version = "0.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
|
||||
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
@@ -1408,7 +1409,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "coyote-ai"
|
||||
version = "0.7.3"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"ansi_colours",
|
||||
"anyhow",
|
||||
@@ -2045,9 +2046,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "1.0.1"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
|
||||
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
@@ -2055,9 +2056,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.10"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
|
||||
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -2775,9 +2776,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.12"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
|
||||
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
@@ -3069,9 +3070,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indicatif"
|
||||
version = "0.18.4"
|
||||
version = "0.18.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb"
|
||||
checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c"
|
||||
dependencies = [
|
||||
"console",
|
||||
"portable-atomic",
|
||||
@@ -3189,9 +3190,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.29"
|
||||
version = "0.2.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46"
|
||||
checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-static",
|
||||
@@ -3203,9 +3204,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.29"
|
||||
version = "0.2.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f"
|
||||
checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3273,9 +3274,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.102"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
|
||||
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -3345,9 +3346,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
|
||||
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -3940,13 +3941,12 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.5"
|
||||
version = "5.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
|
||||
checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4112,12 +4112,6 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
@@ -4986,9 +4980,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.1"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
@@ -6265,9 +6259,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter"
|
||||
version = "0.26.9"
|
||||
version = "0.26.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3"
|
||||
checksum = "3c343ed63e3f5c64d1acdecb5d2c13d4e169cb5fde0052106ebaa6c6f27f9e55"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"regex",
|
||||
@@ -6543,9 +6537,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.3"
|
||||
version = "1.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
|
||||
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
@@ -6645,9 +6639,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.125"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
|
||||
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -6658,9 +6652,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.75"
|
||||
version = "0.4.76"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
|
||||
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -6668,9 +6662,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.125"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
|
||||
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -6678,9 +6672,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.125"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
|
||||
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -6691,9 +6685,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.125"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
|
||||
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -6796,9 +6790,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.102"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
|
||||
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -7450,9 +7444,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3"
|
||||
checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coyote-ai"
|
||||
version = "0.7.3"
|
||||
version = "0.7.4"
|
||||
edition = "2024"
|
||||
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
||||
description = "An all-in-one, batteries included LLM CLI Tool"
|
||||
@@ -49,7 +49,7 @@ textwrap = "0.16.0"
|
||||
ansi_colours = "1.2.2"
|
||||
eventsource-stream = "0.2.3"
|
||||
log = "0.4.28"
|
||||
log4rs = { version = "1.4.0", features = ["file_appender"] }
|
||||
log4rs = { version = "1.4.0", features = ["file_appender", "rolling_file_appender", "compound_policy", "fixed_window_roller", "size_trigger"] }
|
||||
shell-words = "1.1.0"
|
||||
sha2 = "0.10.8"
|
||||
unicode-width = "0.2.0"
|
||||
|
||||
@@ -98,7 +98,7 @@ You can use the following command to run a bash script that downloads and instal
|
||||
OS (Linux/MacOS) and architecture (x86_64/arm64):
|
||||
|
||||
```shell
|
||||
curl -fsSL https://raw.githubusercontent.com/Dark-Alex-17/coyote/main/install_coyote.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/scripts/install_coyote.sh | bash
|
||||
```
|
||||
|
||||
#### Windows/Linux/MacOS (`PowerShell`)
|
||||
@@ -106,7 +106,7 @@ You can use the following command to run a PowerShell script that downloads and
|
||||
for your OS (Windows/Linux/MacOS) and architecture (x86_64/arm64):
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "iwr -useb https://raw.githubusercontent.com/Dark-Alex-17/coyote/main/scripts/install_coyote.ps1 | iex"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "iwr -useb https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/scripts/install_coyote.ps1 | iex"
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
schemaVersion: '1'
|
||||
kind: mixin
|
||||
name: sisyphus-ddg
|
||||
description: >
|
||||
Allows Sisyphus to hit all domains since it utilizes the DuckDuckGo
|
||||
MCP server. This allows the MCP server to actually perform web searches
|
||||
on arbitrary domains and retrieve info for the agent.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
- '*'
|
||||
+67
-57
@@ -5,20 +5,19 @@
|
||||
# sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/
|
||||
# sbx cp $HOME/.coyote_password testing:/home/agent/
|
||||
# sbx run testing --kit ./sbx-kit/
|
||||
schemaVersion: "1"
|
||||
kind: agent
|
||||
schemaVersion: '1'
|
||||
kind: sandbox
|
||||
name: coyote
|
||||
displayName: Coyote
|
||||
description: >
|
||||
An all-in-one, batteries-included LLM CLI tool featuring Shell Assistant,
|
||||
CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros.
|
||||
|
||||
agent:
|
||||
image: "docker/sandbox-templates:shell-docker"
|
||||
sandbox:
|
||||
image: 'docker/sandbox-templates:shell-docker'
|
||||
aiFilename: COYOTE.md
|
||||
# persistence: persistent
|
||||
entrypoint:
|
||||
run: ["bash", "-lc", "exec /home/agent/.cargo/bin/coyote"]
|
||||
run: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote']
|
||||
|
||||
network:
|
||||
# Proxy-managed LLM providers: the proxy substitutes `proxy-managed` for
|
||||
@@ -51,96 +50,96 @@ network:
|
||||
serviceAuth:
|
||||
openai:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
anthropic:
|
||||
headerName: x-api-key
|
||||
valueFormat: "%s"
|
||||
valueFormat: '%s'
|
||||
gemini:
|
||||
headerName: x-goog-api-key
|
||||
valueFormat: "%s"
|
||||
valueFormat: '%s'
|
||||
cohere:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
groq:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
openrouter:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
ai21:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
cloudflare:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
deepinfra:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
deepseek:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
mistral:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
perplexity:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
voyageai:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
xai:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
jina:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
ernie:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
hunyuan:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
minimax:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
moonshot:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
qianwen:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
zhipuai:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
valueFormat: 'Bearer %s'
|
||||
allowedDomains:
|
||||
# Coyote release + self-update + model-registry sync
|
||||
- "github.com:443"
|
||||
- "api.github.com:443"
|
||||
- "raw.githubusercontent.com:443"
|
||||
- "objects.githubusercontent.com:443"
|
||||
- "*.githubusercontent.com:443"
|
||||
- 'github.com:443'
|
||||
- 'api.github.com:443'
|
||||
- 'raw.githubusercontent.com:443'
|
||||
- 'objects.githubusercontent.com:443'
|
||||
- '*.githubusercontent.com:443'
|
||||
# Coyote install paths (cargo install + uv + rustup + Python tool deps at runtime)
|
||||
- "crates.io:443"
|
||||
- "static.crates.io:443"
|
||||
- "pypi.org:443"
|
||||
- "files.pythonhosted.org:443"
|
||||
- "astral.sh:443"
|
||||
- "sh.rustup.rs:443"
|
||||
- "static.rust-lang.org:443"
|
||||
- 'crates.io:443'
|
||||
- 'static.crates.io:443'
|
||||
- 'pypi.org:443'
|
||||
- 'files.pythonhosted.org:443'
|
||||
- 'astral.sh:443'
|
||||
- 'sh.rustup.rs:443'
|
||||
- 'static.rust-lang.org:443'
|
||||
|
||||
# LLM model OAuth + API endpoints
|
||||
- "claude.ai:443"
|
||||
- "console.anthropic.com:443"
|
||||
- "accounts.google.com:443"
|
||||
- 'claude.ai:443'
|
||||
- 'console.anthropic.com:443'
|
||||
- 'accounts.google.com:443'
|
||||
# *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints
|
||||
# (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI.
|
||||
- "*.googleapis.com:443"
|
||||
- '*.googleapis.com:443'
|
||||
|
||||
# Bedrock and GitHub Models use signed / GitHub-PAT auth that the proxy
|
||||
# cannot rewrite. Domains are allow-listed; credentials must be injected
|
||||
# separately (see README "Extending").
|
||||
- "*.amazonaws.com:443"
|
||||
- "models.inference.ai.azure.com:443"
|
||||
- '*.amazonaws.com:443'
|
||||
- 'models.inference.ai.azure.com:443'
|
||||
|
||||
credentials:
|
||||
sources:
|
||||
@@ -211,8 +210,9 @@ credentials:
|
||||
|
||||
environment:
|
||||
variables:
|
||||
IS_SANDBOX: "1"
|
||||
IS_SANDBOX: '1'
|
||||
COYOTE_LOG_LEVEL: INFO
|
||||
COYOTE_CONFIG_DIR: /home/agent/.config/coyote
|
||||
proxyManaged:
|
||||
- OPENAI_API_KEY
|
||||
- ANTHROPIC_API_KEY
|
||||
@@ -250,14 +250,19 @@ commands:
|
||||
libssl-dev \
|
||||
pandoc \
|
||||
bzip2
|
||||
user: "1000"
|
||||
user: '1000'
|
||||
description: Install system prerequisites (including pandoc for fetch_url_via_curl)
|
||||
- command: "curl -LsSf https://astral.sh/uv/install.sh | sh"
|
||||
user: "1000"
|
||||
description: Install uv (required for Python-based custom tools)
|
||||
- command: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
if [ -f "$HOME/.local/bin/uv" ]; then
|
||||
printf '#!/bin/sh\nexec uv tool run "$@"\n' > "$HOME/.local/bin/uvx"
|
||||
chmod +x "$HOME/.local/bin/uvx"
|
||||
fi
|
||||
user: '1000'
|
||||
description: Install uv and write a uvx shell wrapper (the installer may place a macOS binary at this path on Docker-for-Mac hosts, which the Linux container cannot execute)
|
||||
- command: |
|
||||
set -euo pipefail
|
||||
USQL_VERSION=$(curl -sSL https://api.github.com/repos/xo/usql/releases/latest | jq -r .tag_name | sed 's/^v//')
|
||||
USQL_VERSION=0.21.4
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) USQL_ARCH=amd64 ;;
|
||||
@@ -266,10 +271,10 @@ commands:
|
||||
esac
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
curl -sSL "https://github.com/xo/usql/releases/download/v${USQL_VERSION}/usql_static-${USQL_VERSION}-linux-${USQL_ARCH}.tar.bz2" -o "$TMPDIR/usql.tar.bz2"
|
||||
curl -fsSL --retry 3 "https://github.com/xo/usql/releases/download/v${USQL_VERSION}/usql_static-${USQL_VERSION}-linux-${USQL_ARCH}.tar.bz2" -o "$TMPDIR/usql.tar.bz2"
|
||||
tar -xjf "$TMPDIR/usql.tar.bz2" -C "$TMPDIR"
|
||||
sudo install -m 0755 "$TMPDIR/usql_static" /usr/local/bin/usql
|
||||
user: "1000"
|
||||
user: '1000'
|
||||
description: Install the usql universal SQL CLI (used by the built-in sql agent and execute_sql_code tool)
|
||||
- command: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
@@ -279,16 +284,21 @@ commands:
|
||||
--target x86_64-unknown-linux-musl
|
||||
. "$HOME/.cargo/env"
|
||||
cargo install --locked coyote-ai
|
||||
user: "1000"
|
||||
user: '1000'
|
||||
description: Install Coyote AI CLI via Rust's Cargo
|
||||
|
||||
startup:
|
||||
- command: ["sh", "-c", "test -f \"$HOME/.config/coyote/config.yaml\" || coyote --info >/dev/null 2>&1 || true"]
|
||||
user: "1000"
|
||||
- command:
|
||||
[
|
||||
'sh',
|
||||
'-c',
|
||||
'test -f "$HOME/.config/coyote/config.yaml" || coyote --info >/dev/null 2>&1 || true',
|
||||
]
|
||||
user: '1000'
|
||||
background: false
|
||||
description: Bootstrap Coyote config directory on first sandbox start
|
||||
|
||||
memory: |
|
||||
agentContext: |
|
||||
## Sandbox environment
|
||||
|
||||
You are running inside a Docker sandbox launched via `sbx run coyote`. The
|
||||
|
||||
+32
@@ -377,6 +377,14 @@
|
||||
thinking:
|
||||
type: enabled
|
||||
budget_tokens: 16000
|
||||
- name: claude-sonnet-5
|
||||
max_input_tokens: 1000000
|
||||
max_output_tokens: 128000
|
||||
require_max_tokens: true
|
||||
input_price: 3
|
||||
output_price: 15
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: claude-sonnet-4-6
|
||||
max_input_tokens: 200000
|
||||
max_output_tokens: 8192
|
||||
@@ -922,6 +930,14 @@
|
||||
thinking:
|
||||
type: enabled
|
||||
budget_tokens: 16000
|
||||
- name: claude-sonnet-5
|
||||
max_input_tokens: 1000000
|
||||
max_output_tokens: 128000
|
||||
require_max_tokens: true
|
||||
input_price: 3
|
||||
output_price: 15
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: claude-sonnet-4-6
|
||||
max_input_tokens: 200000
|
||||
max_output_tokens: 8192
|
||||
@@ -1103,6 +1119,14 @@
|
||||
thinking:
|
||||
type: enabled
|
||||
budget_tokens: 16000
|
||||
- name: us.anthropic.claude-sonnet-5
|
||||
max_input_tokens: 1000000
|
||||
max_output_tokens: 128000
|
||||
require_max_tokens: true
|
||||
input_price: 3
|
||||
output_price: 15
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: us.anthropic.claude-sonnet-4-6
|
||||
max_input_tokens: 200000
|
||||
max_output_tokens: 8192
|
||||
@@ -1785,6 +1809,14 @@
|
||||
output_price: 25
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: anthropic/claude-sonnet-5
|
||||
max_input_tokens: 1000000
|
||||
max_output_tokens: 128000
|
||||
require_max_tokens: true
|
||||
input_price: 3
|
||||
output_price: 15
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: anthropic/claude-sonnet-4.6
|
||||
max_input_tokens: 200000
|
||||
max_output_tokens: 8192
|
||||
|
||||
@@ -39,7 +39,7 @@ switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) {
|
||||
|
||||
if (-not $BinDir) {
|
||||
if ($isWin) { $BinDir = Join-Path $env:LOCALAPPDATA 'coyote\bin' }
|
||||
else { $home = $env:HOME; if (-not $home) { $home = (Get-Item -Path ~).FullName }; $BinDir = Join-Path $home '.local/bin' }
|
||||
else { $userHome = $env:HOME; if (-not $userHome) { $userHome = (Get-Item -Path ~).FullName }; $BinDir = Join-Path $userHome '.local/bin' }
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
|
||||
|
||||
@@ -95,13 +95,13 @@ if ($asset.name -match '\.zip$') {
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $extractDir)
|
||||
} elseif ($asset.name -match '\.tar\.gz$' -or $asset.name -match '\.tgz$') {
|
||||
$tar = Get-Command tar -ErrorAction SilentlyContinue
|
||||
if ($tar) { & $tar.FullName -xzf $archive -C $extractDir }
|
||||
if ($tar) { & $tar.Source -xzf $archive -C $extractDir }
|
||||
else { Fail "Asset is tar archive but 'tar' is not available." }
|
||||
} else {
|
||||
try { Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $extractDir) }
|
||||
catch {
|
||||
$tar = Get-Command tar -ErrorAction SilentlyContinue
|
||||
if ($tar) { & $tar.FullName -xf $archive -C $extractDir } else { Fail "Unknown archive format; neither zip nor tar workable." }
|
||||
if ($tar) { & $tar.Source -xf $archive -C $extractDir } else { Fail "Unknown archive format; neither zip nor tar workable." }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Regular → Executable
+13
-22
@@ -133,30 +133,21 @@ else
|
||||
echo "Error: unsupported OS for this installer: $OS" >&2; exit 1
|
||||
fi
|
||||
|
||||
DL_URLS=$(grep -oE '"browser_download_url":[[:space:]]*"[^"]+"' "$JSON" \
|
||||
| sed -E 's/.*"browser_download_url":[[:space:]]*"//; s/"$//' \
|
||||
|| true)
|
||||
|
||||
ASSET_NAME=""; ASSET_URL=""
|
||||
for candidate in "${ASSET_CANDIDATES[@]}"; do
|
||||
NAME=$(grep -oE '"name":\s*"[^"]+"' "$JSON" | sed 's/"name":\s*"//; s/"$//' | grep -Fx "$candidate" || true)
|
||||
if [[ -n "$NAME" ]]; then
|
||||
ASSET_NAME="$NAME"
|
||||
ASSET_URL=$(awk -v pat="$NAME" '
|
||||
BEGIN{ FS=":"; want=0 }
|
||||
/"name"/ {
|
||||
line=$0;
|
||||
gsub(/^\s+|\s+$/,"",line);
|
||||
gsub(/"name"\s*:\s*"|"/ ,"", line);
|
||||
want = (line==pat) ? 1 : 0;
|
||||
next
|
||||
}
|
||||
want==1 && /"browser_download_url"/ {
|
||||
u=$0;
|
||||
gsub(/^\s+|\s+$/,"",u);
|
||||
gsub(/.*"browser_download_url"\s*:\s*"|".*/ ,"", u);
|
||||
print u;
|
||||
exit
|
||||
}
|
||||
' "$JSON")
|
||||
if [[ -n "$ASSET_URL" ]]; then break; fi
|
||||
fi
|
||||
while IFS= read -r url; do
|
||||
[[ -z "$url" ]] && continue
|
||||
if [[ "$url" == */"$candidate" ]]; then
|
||||
ASSET_NAME="$candidate"
|
||||
ASSET_URL="$url"
|
||||
break
|
||||
fi
|
||||
done <<< "$DL_URLS"
|
||||
[[ -n "$ASSET_URL" ]] && break
|
||||
done
|
||||
|
||||
if [[ -z "$ASSET_URL" ]]; then
|
||||
|
||||
+29
-1
@@ -5,9 +5,9 @@ use crate::utils::list_file_names;
|
||||
use crate::vault::Vault;
|
||||
use clap_complete::{CompletionCandidate, Shell, generate};
|
||||
use clap_complete_nushell::Nushell;
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::{env, fs};
|
||||
|
||||
const COYOTE_CLI_NAME: &str = "coyote";
|
||||
|
||||
@@ -134,6 +134,34 @@ pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn mcp_server_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||
let cur = current.to_string_lossy();
|
||||
let content = match fs::read_to_string(paths::mcp_config_file()) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let json: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let servers = match json.get("mcpServers").and_then(|v| v.as_object()) {
|
||||
Some(s) => s,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
servers
|
||||
.iter()
|
||||
.filter(|(_, v)| {
|
||||
v.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|t| t == "http" || t == "sse")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter(|(k, _)| k.starts_with(&*cur))
|
||||
.map(|(k, _)| CompletionCandidate::new(k))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||
let cur = current.to_string_lossy();
|
||||
match load_app_config_for_completion() {
|
||||
|
||||
+5
-2
@@ -1,8 +1,8 @@
|
||||
mod completer;
|
||||
|
||||
use crate::cli::completer::{
|
||||
ShellCompletion, agent_completer, macro_completer, model_completer, rag_completer,
|
||||
role_completer, secrets_completer, session_completer,
|
||||
ShellCompletion, agent_completer, macro_completer, mcp_server_completer, model_completer,
|
||||
rag_completer, role_completer, secrets_completer, session_completer,
|
||||
};
|
||||
use crate::config::{AssetCategory, InstallFilter, MemoryScope};
|
||||
use anyhow::{Context, Result};
|
||||
@@ -171,6 +171,9 @@ pub struct Cli {
|
||||
/// Authenticate with an LLM provider using OAuth (e.g., --authenticate client_name)
|
||||
#[arg(long, exclusive = true, value_name = "CLIENT_NAME")]
|
||||
pub authenticate: Option<Option<String>>,
|
||||
/// Authenticate with an OAuth-protected remote MCP server (e.g., --auth-mcp server_name)
|
||||
#[arg(long, exclusive = true, value_name = "SERVER_NAME", add = ArgValueCompleter::new(mcp_server_completer))]
|
||||
pub auth_mcp: Option<String>,
|
||||
/// Generate static shell completion scripts
|
||||
#[arg(long, value_name = "SHELL", value_enum)]
|
||||
pub completions: Option<ShellCompletion>,
|
||||
|
||||
@@ -133,6 +133,13 @@ impl MessageContent {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
MessageContent::Text(text) => Some(text),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge_prompt(&mut self, replace_fn: impl Fn(&str) -> String) {
|
||||
match self {
|
||||
MessageContent::Text(text) => *text = replace_fn(text),
|
||||
|
||||
+10
-4
@@ -53,6 +53,10 @@ pub trait OAuthProvider: Send + Sync {
|
||||
fn extra_request_headers(&self) -> Vec<(&str, &str)> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn fixed_redirect_uri(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -72,14 +76,16 @@ pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) ->
|
||||
|
||||
let state = Uuid::new_v4().to_string();
|
||||
|
||||
let redirect_uri = if provider.uses_localhost_redirect() {
|
||||
let (redirect_uri, use_callback_listener) = if let Some(fixed) = provider.fixed_redirect_uri() {
|
||||
(fixed, true)
|
||||
} else if provider.uses_localhost_redirect() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let uri = format!("http://127.0.0.1:{port}/callback");
|
||||
drop(listener);
|
||||
uri
|
||||
(uri, true)
|
||||
} else {
|
||||
provider.redirect_uri().to_string()
|
||||
(provider.redirect_uri().to_string(), false)
|
||||
};
|
||||
|
||||
let encoded_scopes = urlencoding::encode(provider.scopes());
|
||||
@@ -112,7 +118,7 @@ pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) ->
|
||||
|
||||
let _ = open::that(&authorize_url);
|
||||
|
||||
let (code, returned_state) = if provider.uses_localhost_redirect() {
|
||||
let (code, returned_state) = if use_callback_listener {
|
||||
listen_for_oauth_callback(&redirect_uri)?
|
||||
} else {
|
||||
let input = Text::new("Paste the authorization code:").prompt()?;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::mcp::{ConnectedServer, JsonField, McpServer, McpTransportType, spawn_mcp_server};
|
||||
use crate::mcp::{
|
||||
ConnectedServer, JsonField, McpServer, McpTransportType, oauth, spawn_mcp_server,
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use parking_lot::Mutex;
|
||||
@@ -99,7 +101,12 @@ impl McpFactory {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let handle = spawn_mcp_server(spec, log_path).await?;
|
||||
let bearer_token = if spec.is_remote() {
|
||||
oauth::load_valid_mcp_token(name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let handle = spawn_mcp_server(spec, log_path, bearer_token).await?;
|
||||
self.insert_active(key, &handle);
|
||||
Ok(handle)
|
||||
}
|
||||
@@ -125,6 +132,7 @@ mod tests {
|
||||
cwd: None,
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth_client_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +149,7 @@ mod tests {
|
||||
cwd: None,
|
||||
url: Some(url.to_string()),
|
||||
headers,
|
||||
oauth_client_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ const RAGS_DIR_NAME: &str = "rags";
|
||||
const FUNCTIONS_DIR_NAME: &str = "functions";
|
||||
const FUNCTIONS_BIN_DIR_NAME: &str = "bin";
|
||||
const AGENTS_DIR_NAME: &str = "agents";
|
||||
const REPL_HISTORY_DIR_NAME: &str = "repl-history";
|
||||
const GLOBAL_TOOLS_DIR_NAME: &str = "tools";
|
||||
const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils";
|
||||
const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh";
|
||||
|
||||
@@ -8,6 +8,8 @@ use super::{
|
||||
SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME,
|
||||
};
|
||||
use crate::client::ProviderModels;
|
||||
use crate::config::REPL_HISTORY_DIR_NAME;
|
||||
use crate::config::session::Session;
|
||||
use crate::utils::{get_env_name, list_file_names, normalize_env_name};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
@@ -320,6 +322,20 @@ pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf {
|
||||
.join(MEMORY_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn repl_history_dir() -> PathBuf {
|
||||
cache_path().join(REPL_HISTORY_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn repl_history_file(session: &Option<Session>) -> PathBuf {
|
||||
let history_key = if let Some(session) = &session {
|
||||
format!("session_{}", session.name().replace('/', "_"))
|
||||
} else {
|
||||
"default".to_string()
|
||||
};
|
||||
|
||||
repl_history_dir().join(history_key)
|
||||
}
|
||||
|
||||
pub fn log_config() -> Result<(LevelFilter, Option<PathBuf>)> {
|
||||
let log_level = env::var(get_env_name("log_level"))
|
||||
.ok()
|
||||
|
||||
@@ -709,6 +709,10 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
pub fn extract_role(&self, app: &AppConfig) -> Result<Role> {
|
||||
self.extract_role_impl(app, true)
|
||||
}
|
||||
|
||||
fn extract_role_impl(&self, app: &AppConfig, inject_memory: bool) -> Result<Role> {
|
||||
let mut role = if let Some(session) = self.session.as_ref() {
|
||||
session.to_role()
|
||||
} else if let Some(agent) = self.agent.as_ref() {
|
||||
@@ -757,34 +761,36 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
let memory_config = self.memory_config();
|
||||
if memory_config.enabled {
|
||||
let store = MemoryStore {
|
||||
global_dir: paths::global_memory_dir(),
|
||||
workspace: memory_config.workspace,
|
||||
};
|
||||
let with_tools = app.function_calling_support;
|
||||
let cap = if with_tools {
|
||||
app.memory_cap_with_tools
|
||||
.unwrap_or(DEFAULT_MEMORY_CAP_WITH_TOOLS)
|
||||
} else {
|
||||
app.memory_cap_without_tools
|
||||
.unwrap_or(DEFAULT_MEMORY_CAP_WITHOUT_TOOLS)
|
||||
};
|
||||
match memory::build_memory_section(&store, with_tools, cap) {
|
||||
Ok(Some(section)) => {
|
||||
let separator = if role.is_empty_prompt() { "" } else { "\n\n" };
|
||||
role.append_to_prompt(separator);
|
||||
role.append_to_prompt(§ion);
|
||||
role.append_to_prompt("\n\n");
|
||||
role.append_to_prompt(if with_tools {
|
||||
prompts::DEFAULT_MEMORY_INSTRUCTIONS
|
||||
} else {
|
||||
prompts::DEFAULT_MEMORY_INSTRUCTIONS_READONLY
|
||||
});
|
||||
if inject_memory {
|
||||
let memory_config = self.memory_config();
|
||||
if memory_config.enabled {
|
||||
let store = MemoryStore {
|
||||
global_dir: paths::global_memory_dir(),
|
||||
workspace: memory_config.workspace,
|
||||
};
|
||||
let with_tools = app.function_calling_support;
|
||||
let cap = if with_tools {
|
||||
app.memory_cap_with_tools
|
||||
.unwrap_or(DEFAULT_MEMORY_CAP_WITH_TOOLS)
|
||||
} else {
|
||||
app.memory_cap_without_tools
|
||||
.unwrap_or(DEFAULT_MEMORY_CAP_WITHOUT_TOOLS)
|
||||
};
|
||||
match memory::build_memory_section(&store, with_tools, cap) {
|
||||
Ok(Some(section)) => {
|
||||
let separator = if role.is_empty_prompt() { "" } else { "\n\n" };
|
||||
role.append_to_prompt(separator);
|
||||
role.append_to_prompt(§ion);
|
||||
role.append_to_prompt("\n\n");
|
||||
role.append_to_prompt(if with_tools {
|
||||
prompts::DEFAULT_MEMORY_INSTRUCTIONS
|
||||
} else {
|
||||
prompts::DEFAULT_MEMORY_INSTRUCTIONS_READONLY
|
||||
});
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => warn!("memory injection failed: {}", e),
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => warn!("memory injection failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1276,7 +1282,7 @@ impl RequestContext {
|
||||
|
||||
pub fn generate_prompt_context(&self, app: &AppConfig) -> HashMap<&str, String> {
|
||||
let mut output = HashMap::new();
|
||||
let role = self.extract_role(app).unwrap_or_else(|err| {
|
||||
let role = self.extract_role_impl(app, false).unwrap_or_else(|err| {
|
||||
warn!("failed to compute effective role for prompt rendering: {err}");
|
||||
Role::default()
|
||||
});
|
||||
@@ -2334,6 +2340,17 @@ impl RequestContext {
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
} else if cmd == ".mcp" && args.first() == Some(&"auth") && args.len() == 2 {
|
||||
if let Some(mcp_config) = &self.app.mcp_config {
|
||||
values = super::map_completion_values(
|
||||
mcp_config
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.filter(|(_, spec)| spec.is_remote())
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
} else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2)
|
||||
|| (cmd == ".skill" && args.first() == Some(&"load") && args.len() == 2)
|
||||
{
|
||||
@@ -3681,6 +3698,7 @@ mod tests {
|
||||
cwd: None,
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth_client_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,6 +163,14 @@ impl Session {
|
||||
self.messages.is_empty() && self.compressed_messages.is_empty()
|
||||
}
|
||||
|
||||
pub fn messages(&self) -> &[Message] {
|
||||
&self.messages
|
||||
}
|
||||
|
||||
pub fn compressed_messages(&self) -> &[Message] {
|
||||
&self.compressed_messages
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
@@ -68,6 +68,14 @@ fn normalize_version(requested: Option<String>) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn preferred_update_target() -> Option<&'static str> {
|
||||
match (env::consts::OS, env::consts::ARCH) {
|
||||
("linux", "x86_64") => Some("x86_64-unknown-linux-musl"),
|
||||
("linux", "aarch64") => Some("aarch64-unknown-linux-musl"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_dir_writable(dir: &Path) -> bool {
|
||||
let probe = dir.join(format!(".coyote-update-write-test-{}", process::id()));
|
||||
match OpenOptions::new().write(true).create_new(true).open(&probe) {
|
||||
@@ -147,6 +155,9 @@ pub fn run_self_update(requested: Option<String>, force: bool) -> Result<()> {
|
||||
if let Some(tag) = &target_tag {
|
||||
builder.target_version_tag(tag.as_str());
|
||||
}
|
||||
if let Some(target) = preferred_update_target() {
|
||||
builder.target(target);
|
||||
}
|
||||
let status = builder
|
||||
.build()
|
||||
.context("Failed to configure the self-update")?
|
||||
|
||||
+65
-5
@@ -28,18 +28,22 @@ use crate::config::{
|
||||
install_builtins, list_agents, load_env_file, macro_execute, sync_models,
|
||||
};
|
||||
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
||||
use crate::mcp::McpServersConfig;
|
||||
use crate::render::{prompt_theme, render_error};
|
||||
use crate::repl::Repl;
|
||||
use crate::utils::*;
|
||||
use crate::vault::Vault;
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use crate::vault::{Vault, interpolate_secrets};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use clap::{CommandFactory, Parser};
|
||||
use clap_complete::CompleteEnv;
|
||||
use client::ClientConfig;
|
||||
use inquire::{Select, Text, set_global_render_config};
|
||||
use log::{LevelFilter, warn};
|
||||
use log4rs::append::console::ConsoleAppender;
|
||||
use log4rs::append::file::FileAppender;
|
||||
use log4rs::append::rolling_file::RollingFileAppender;
|
||||
use log4rs::append::rolling_file::policy::compound::CompoundPolicy;
|
||||
use log4rs::append::rolling_file::policy::compound::roll::fixed_window::FixedWindowRoller;
|
||||
use log4rs::append::rolling_file::policy::compound::trigger::size::SizeTrigger;
|
||||
use log4rs::config::{Appender, Logger, Root};
|
||||
use log4rs::encode::pattern::PatternEncoder;
|
||||
use oauth::OAuthProvider;
|
||||
@@ -117,6 +121,49 @@ async fn main() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(server_name) = &cli.auth_mcp {
|
||||
let cfg = Config::load_with_interpolation(true).await?;
|
||||
let app_config = AppConfig::from_config(cfg)?;
|
||||
let vault = Vault::init(&app_config)?;
|
||||
let mcp_path = paths::mcp_config_file();
|
||||
if !mcp_path.exists() {
|
||||
bail!(
|
||||
"No MCP configuration file found at '{}'",
|
||||
mcp_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let raw = tokio::fs::read_to_string(&mcp_path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read MCP config at '{}'", mcp_path.display()))?;
|
||||
|
||||
let (content, missing) = interpolate_secrets(&raw, &vault)?;
|
||||
if !missing.is_empty() {
|
||||
bail!(
|
||||
"MCP config references vault secrets that are missing: {:?}",
|
||||
missing
|
||||
);
|
||||
}
|
||||
|
||||
let mcp_config: McpServersConfig =
|
||||
serde_json::from_str(&content).context("Failed to parse MCP config file")?;
|
||||
let spec = mcp_config
|
||||
.mcp_servers
|
||||
.get(server_name.as_str())
|
||||
.ok_or_else(|| anyhow!("MCP server '{server_name}' not found in mcp.json"))?;
|
||||
if !spec.is_remote() {
|
||||
bail!(
|
||||
"MCP server '{server_name}' is a stdio server; OAuth is only supported for http/sse servers"
|
||||
);
|
||||
}
|
||||
|
||||
let url = spec.url.as_deref().expect("validated: remote spec has url");
|
||||
mcp::oauth::run_mcp_oauth_flow(server_name, url, spec.oauth_client_id.as_deref()).await?;
|
||||
println!("Authentication saved. '{server_name}' is now available for use.");
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if vault_flags {
|
||||
let cfg = Config::load_with_interpolation(true).await?;
|
||||
let app_config = AppConfig::from_config(cfg)?;
|
||||
@@ -585,7 +632,20 @@ fn setup_logger() -> Result<Option<PathBuf>> {
|
||||
}
|
||||
Some(path) => {
|
||||
ensure_parent_exists(&path)?;
|
||||
let file_appender = FileAppender::builder().encoder(encoder.clone()).build(path);
|
||||
|
||||
let archive_pattern = path
|
||||
.with_extension("archived.{}.log")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let trigger = SizeTrigger::new(10 * 1024 * 1024);
|
||||
let roller = FixedWindowRoller::builder()
|
||||
.build(&archive_pattern, 5)
|
||||
.unwrap();
|
||||
let policy = CompoundPolicy::new(Box::new(trigger), Box::new(roller));
|
||||
|
||||
let file_appender = RollingFileAppender::builder()
|
||||
.encoder(encoder.clone())
|
||||
.build(path, Box::new(policy));
|
||||
|
||||
match file_appender {
|
||||
Ok(appender) => {
|
||||
@@ -608,7 +668,7 @@ fn setup_logger() -> Result<Option<PathBuf>> {
|
||||
fn init_file_logger(
|
||||
log_level: LevelFilter,
|
||||
log_filter: Option<String>,
|
||||
file_appender: FileAppender,
|
||||
file_appender: RollingFileAppender,
|
||||
) -> log4rs::Config {
|
||||
let root_log_level = if log_filter.is_some() {
|
||||
LevelFilter::Off
|
||||
|
||||
+172
-11
@@ -1,3 +1,4 @@
|
||||
pub(crate) mod oauth;
|
||||
mod sse_transport;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
@@ -73,6 +74,8 @@ pub(crate) struct McpServer {
|
||||
pub url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<IndexMap<String, String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oauth_client_id: Option<String>,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
@@ -107,10 +110,10 @@ impl McpServer {
|
||||
"MCP server '{name}' is missing a \"command\" field (required for stdio transport)"
|
||||
));
|
||||
}
|
||||
if self.url.is_some() || self.headers.is_some() {
|
||||
if self.url.is_some() || self.headers.is_some() || self.oauth_client_id.is_some() {
|
||||
return Err(anyhow!(
|
||||
"MCP server '{name}' has type \"stdio\" but also specifies remote fields \
|
||||
(url/headers). Remove the remote fields or change the type to \"http\" or \"sse\"."
|
||||
(url/headers/oauth_client_id). Remove the remote fields or change the type to \"http\" or \"sse\"."
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -237,7 +240,7 @@ impl McpRegistry {
|
||||
|
||||
debug!("Starting selected MCP servers: {:?}", ids_to_start);
|
||||
|
||||
let results: Vec<(String, Arc<_>, ServerCatalog)> = stream::iter(
|
||||
let results: Vec<Option<(String, Arc<ConnectedServer>, ServerCatalog)>> = stream::iter(
|
||||
ids_to_start
|
||||
.into_iter()
|
||||
.map(|id| async { self.start_server(id).await }),
|
||||
@@ -246,7 +249,7 @@ impl McpRegistry {
|
||||
.try_collect()
|
||||
.await?;
|
||||
|
||||
for (id, server, catalog) in results {
|
||||
for (id, server, catalog) in results.into_iter().flatten() {
|
||||
self.servers.insert(id.clone(), server);
|
||||
self.catalogs.insert(id, catalog);
|
||||
}
|
||||
@@ -257,14 +260,30 @@ impl McpRegistry {
|
||||
async fn start_server(
|
||||
&self,
|
||||
id: String,
|
||||
) -> Result<(String, Arc<ConnectedServer>, ServerCatalog)> {
|
||||
) -> Result<Option<(String, Arc<ConnectedServer>, ServerCatalog)>> {
|
||||
let spec = self
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.mcp_servers.get(&id))
|
||||
.with_context(|| format!("MCP server not found in config: {id}"))?;
|
||||
|
||||
let service = spawn_mcp_server(spec, self.log_path.as_deref()).await?;
|
||||
let bearer_token = if spec.is_remote() {
|
||||
oauth::load_valid_mcp_token(&id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let service = match spawn_mcp_server(spec, self.log_path.as_deref(), bearer_token).await {
|
||||
Ok(s) => s,
|
||||
Err(e) if is_auth_required_error(&e) => {
|
||||
warn!(
|
||||
"MCP server '{id}' requires OAuth authentication. \
|
||||
Run `.mcp auth {id}` in the REPL to authenticate, then restart Coyote."
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let tools = service.list_tools(None).await?;
|
||||
debug!("Available tools for MCP server {id}: {tools:?}");
|
||||
@@ -289,7 +308,7 @@ impl McpRegistry {
|
||||
|
||||
info!("Started MCP server: {id}");
|
||||
|
||||
Ok((id.to_string(), service, catalog))
|
||||
Ok(Some((id.to_string(), service, catalog)))
|
||||
}
|
||||
|
||||
fn resolve_server_ids(&self, enabled_mcp_servers: Option<Vec<String>>) -> Vec<String> {
|
||||
@@ -337,15 +356,18 @@ impl McpRegistry {
|
||||
pub(crate) async fn spawn_mcp_server(
|
||||
spec: &McpServer,
|
||||
log_path: Option<&Path>,
|
||||
bearer_token: Option<String>,
|
||||
) -> Result<Arc<ConnectedServer>> {
|
||||
match spec.transport_type {
|
||||
McpTransportType::Http => {
|
||||
let url = spec.url.as_deref().expect("validated: http spec has url");
|
||||
spawn_http_mcp_server(url, spec.headers.as_ref()).await
|
||||
let headers = merge_bearer_token(spec.headers.as_ref(), bearer_token);
|
||||
spawn_http_mcp_server(url, headers.as_ref()).await
|
||||
}
|
||||
McpTransportType::Sse => {
|
||||
let url = spec.url.as_deref().expect("validated: sse spec has url");
|
||||
spawn_sse_mcp_server(url, spec.headers.as_ref()).await
|
||||
let headers = merge_bearer_token(spec.headers.as_ref(), bearer_token);
|
||||
spawn_sse_mcp_server(url, headers.as_ref()).await
|
||||
}
|
||||
McpTransportType::Stdio => {
|
||||
let command = spec
|
||||
@@ -357,6 +379,30 @@ pub(crate) async fn spawn_mcp_server(
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_bearer_token(
|
||||
headers: Option<&IndexMap<String, String>>,
|
||||
bearer_token: Option<String>,
|
||||
) -> Option<IndexMap<String, String>> {
|
||||
match (headers, bearer_token) {
|
||||
(None, None) => None,
|
||||
(Some(h), None) => Some(h.clone()),
|
||||
(None, Some(token)) => {
|
||||
let mut m = IndexMap::new();
|
||||
m.insert("Authorization".to_string(), format!("Bearer {token}"));
|
||||
Some(m)
|
||||
}
|
||||
(Some(h), Some(token)) => {
|
||||
let mut m = h.clone();
|
||||
m.insert("Authorization".to_string(), format!("Bearer {token}"));
|
||||
Some(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_auth_required_error(e: &anyhow::Error) -> bool {
|
||||
e.to_string().contains("Auth required")
|
||||
}
|
||||
|
||||
async fn spawn_http_mcp_server(
|
||||
url: &str,
|
||||
headers: Option<&IndexMap<String, String>>,
|
||||
@@ -433,8 +479,12 @@ async fn spawn_stdio_mcp_server(
|
||||
let log_file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(log_path)?;
|
||||
let (transport, _) = TokioChildProcess::builder(cmd).stderr(log_file).spawn()?;
|
||||
.open(log_path)
|
||||
.with_context(|| format!("Failed to open MCP log file at '{}'", log_path.display()))?;
|
||||
let (transport, _) = TokioChildProcess::builder(cmd)
|
||||
.stderr(log_file)
|
||||
.spawn()
|
||||
.with_context(|| format!("Failed to spawn MCP server: {command}"))?;
|
||||
transport
|
||||
} else {
|
||||
TokioChildProcess::new(cmd)?
|
||||
@@ -461,6 +511,7 @@ mod tests {
|
||||
cwd: None,
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth_client_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +524,7 @@ mod tests {
|
||||
cwd: None,
|
||||
url: Some(url.to_string()),
|
||||
headers: None,
|
||||
oauth_client_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,6 +537,7 @@ mod tests {
|
||||
cwd: None,
|
||||
url: Some(url.to_string()),
|
||||
headers: None,
|
||||
oauth_client_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,6 +555,7 @@ mod tests {
|
||||
#[test]
|
||||
fn validate_stdio_with_command_succeeds() {
|
||||
let spec = stdio_server("npx");
|
||||
|
||||
assert!(spec.validate("test").is_ok());
|
||||
}
|
||||
|
||||
@@ -515,8 +569,11 @@ mod tests {
|
||||
cwd: None,
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth_client_id: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("missing a \"command\" field"));
|
||||
}
|
||||
|
||||
@@ -530,8 +587,11 @@ mod tests {
|
||||
cwd: None,
|
||||
url: Some("http://localhost".into()),
|
||||
headers: None,
|
||||
oauth_client_id: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("remote fields"));
|
||||
}
|
||||
|
||||
@@ -547,14 +607,18 @@ mod tests {
|
||||
cwd: None,
|
||||
url: None,
|
||||
headers: Some(headers),
|
||||
oauth_client_id: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("remote fields"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_http_with_url_succeeds() {
|
||||
let spec = http_server("http://localhost:8080");
|
||||
|
||||
assert!(spec.validate("test").is_ok());
|
||||
}
|
||||
|
||||
@@ -568,8 +632,11 @@ mod tests {
|
||||
cwd: None,
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth_client_id: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("missing a \"url\" field"));
|
||||
}
|
||||
|
||||
@@ -583,8 +650,11 @@ mod tests {
|
||||
cwd: None,
|
||||
url: Some("http://localhost".into()),
|
||||
headers: None,
|
||||
oauth_client_id: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("stdio fields"));
|
||||
}
|
||||
|
||||
@@ -598,8 +668,11 @@ mod tests {
|
||||
cwd: None,
|
||||
url: Some("http://localhost".into()),
|
||||
headers: None,
|
||||
oauth_client_id: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("stdio fields"));
|
||||
}
|
||||
|
||||
@@ -613,14 +686,18 @@ mod tests {
|
||||
cwd: Some("/tmp".into()),
|
||||
url: Some("http://localhost".into()),
|
||||
headers: None,
|
||||
oauth_client_id: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("stdio fields"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_sse_with_url_succeeds() {
|
||||
let spec = sse_server("http://sse.example.com");
|
||||
|
||||
assert!(spec.validate("test").is_ok());
|
||||
}
|
||||
|
||||
@@ -634,8 +711,11 @@ mod tests {
|
||||
cwd: None,
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth_client_id: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("missing a \"url\" field"));
|
||||
}
|
||||
|
||||
@@ -661,9 +741,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let config: McpServersConfig = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert!(config.mcp_servers.contains_key("my-server"));
|
||||
|
||||
let spec = &config.mcp_servers["my-server"];
|
||||
|
||||
assert_eq!(spec.transport_type, McpTransportType::Stdio);
|
||||
assert_eq!(spec.command.as_deref(), Some("npx"));
|
||||
assert_eq!(
|
||||
@@ -684,7 +768,9 @@ mod tests {
|
||||
}
|
||||
}"#;
|
||||
let config: McpServersConfig = serde_json::from_str(json).unwrap();
|
||||
|
||||
let spec = &config.mcp_servers["remote"];
|
||||
|
||||
assert_eq!(spec.transport_type, McpTransportType::Http);
|
||||
assert_eq!(spec.url.as_deref(), Some("http://localhost:8080/mcp"));
|
||||
assert_eq!(
|
||||
@@ -709,7 +795,9 @@ mod tests {
|
||||
}
|
||||
}"#;
|
||||
let config: McpServersConfig = serde_json::from_str(json).unwrap();
|
||||
|
||||
let env = config.mcp_servers["s"].env.as_ref().unwrap();
|
||||
|
||||
assert!(matches!(env["STR_VAR"], JsonField::Str(ref s) if s == "hello"));
|
||||
assert!(matches!(env["BOOL_VAR"], JsonField::Bool(true)));
|
||||
assert!(matches!(env["INT_VAR"], JsonField::Int(42)));
|
||||
@@ -723,7 +811,9 @@ mod tests {
|
||||
"remote-api": { "type": "http", "url": "http://api.example.com" }
|
||||
}
|
||||
}"#;
|
||||
|
||||
let config: McpServersConfig = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(config.mcp_servers.len(), 2);
|
||||
assert!(config.mcp_servers.contains_key("github"));
|
||||
assert!(config.mcp_servers.contains_key("remote-api"));
|
||||
@@ -732,7 +822,9 @@ mod tests {
|
||||
#[test]
|
||||
fn deserialize_empty_servers_map() {
|
||||
let json = r#"{ "mcpServers": {} }"#;
|
||||
|
||||
let config: McpServersConfig = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert!(config.mcp_servers.is_empty());
|
||||
}
|
||||
|
||||
@@ -747,77 +839,96 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let config: McpServersConfig = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(config.mcp_servers["s"].cwd.as_deref(), Some("/tmp/work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_all_returns_all_configured_servers() {
|
||||
let registry = make_registry_with_config(&["github", "slack", "jira"]);
|
||||
|
||||
let mut ids = registry.resolve_server_ids(Some(vec!["all".to_string()]));
|
||||
ids.sort();
|
||||
|
||||
assert_eq!(ids, vec!["github", "jira", "slack"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_comma_separated_returns_matching_servers() {
|
||||
let registry = make_registry_with_config(&["github", "slack", "jira"]);
|
||||
|
||||
let mut ids =
|
||||
registry.resolve_server_ids(Some(vec!["github".to_string(), "jira".to_string()]));
|
||||
ids.sort();
|
||||
|
||||
assert_eq!(ids, vec!["github", "jira"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_single_server_name() {
|
||||
let registry = make_registry_with_config(&["github", "slack"]);
|
||||
|
||||
let ids = registry.resolve_server_ids(Some(vec!["slack".to_string()]));
|
||||
|
||||
assert_eq!(ids, vec!["slack"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_none_returns_empty() {
|
||||
let registry = make_registry_with_config(&["github"]);
|
||||
|
||||
let ids = registry.resolve_server_ids(None);
|
||||
|
||||
assert!(ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_no_config_returns_empty() {
|
||||
let registry = McpRegistry::default();
|
||||
|
||||
let ids = registry.resolve_server_ids(Some(vec!["all".to_string()]));
|
||||
|
||||
assert!(ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_nonexistent_server_filtered_out() {
|
||||
let registry = make_registry_with_config(&["github"]);
|
||||
|
||||
let ids = registry
|
||||
.resolve_server_ids(Some(vec!["github".to_string(), "nonexistent".to_string()]));
|
||||
|
||||
assert_eq!(ids, vec!["github"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_all_nonexistent_returns_empty() {
|
||||
let registry = make_registry_with_config(&["github"]);
|
||||
|
||||
let ids = registry.resolve_server_ids(Some(vec!["foo".to_string(), "bar".to_string()]));
|
||||
|
||||
assert!(ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_trims_whitespace() {
|
||||
let registry = make_registry_with_config(&["github", "slack"]);
|
||||
|
||||
let mut ids = registry.resolve_server_ids(Some(vec![
|
||||
" github ".to_string(),
|
||||
" slack ".to_string(),
|
||||
]));
|
||||
ids.sort();
|
||||
|
||||
assert_eq!(ids, vec!["github", "slack"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_default_is_empty() {
|
||||
let registry = McpRegistry::default();
|
||||
|
||||
assert!(registry.is_empty());
|
||||
assert!(registry.list_started_servers().is_empty());
|
||||
assert!(registry.mcp_config().is_none());
|
||||
@@ -827,6 +938,7 @@ mod tests {
|
||||
#[test]
|
||||
fn registry_with_config_reports_config() {
|
||||
let registry = make_registry_with_config(&["github"]);
|
||||
|
||||
assert!(registry.mcp_config().is_some());
|
||||
assert!(
|
||||
registry
|
||||
@@ -843,4 +955,53 @@ mod tests {
|
||||
assert_eq!(MCP_SEARCH_META_FUNCTION_NAME_PREFIX, "mcp_search");
|
||||
assert_eq!(MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, "mcp_describe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_bearer_token_both_none_returns_none() {
|
||||
assert!(merge_bearer_token(None, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_bearer_token_headers_only_passes_through() {
|
||||
let mut h = IndexMap::new();
|
||||
h.insert("X-Key".to_string(), "val".to_string());
|
||||
|
||||
let result = merge_bearer_token(Some(&h), None).unwrap();
|
||||
|
||||
assert_eq!(result["X-Key"], "val");
|
||||
assert!(!result.contains_key("Authorization"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_bearer_token_token_only_injects_bearer() {
|
||||
let result = merge_bearer_token(None, Some("tok123".to_string())).unwrap();
|
||||
|
||||
assert_eq!(result["Authorization"], "Bearer tok123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_bearer_token_both_merges_and_overrides_authorization() {
|
||||
let mut h = IndexMap::new();
|
||||
h.insert("Authorization".to_string(), "old".to_string());
|
||||
h.insert("X-Custom".to_string(), "keep".to_string());
|
||||
|
||||
let result = merge_bearer_token(Some(&h), Some("newtoken".to_string())).unwrap();
|
||||
|
||||
assert_eq!(result["Authorization"], "Bearer newtoken");
|
||||
assert_eq!(result["X-Custom"], "keep");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_auth_required_error_matches_rmcp_message() {
|
||||
let e = anyhow!("Auth required, when send initialize request");
|
||||
|
||||
assert!(is_auth_required_error(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_auth_required_error_does_not_match_unrelated() {
|
||||
let e = anyhow!("Connection refused");
|
||||
|
||||
assert!(!is_auth_required_error(&e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
use crate::client::oauth::{OAuthProvider, TokenRequestFormat, load_oauth_tokens, run_oauth_flow};
|
||||
use crate::config::paths;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::Utc;
|
||||
use inquire::Text;
|
||||
use log::warn;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::net::TcpListener;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProtectedResourceMetadata {
|
||||
#[serde(default)]
|
||||
authorization_servers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OAuthServerMetadata {
|
||||
authorization_endpoint: String,
|
||||
token_endpoint: String,
|
||||
#[serde(default)]
|
||||
scopes_supported: Vec<String>,
|
||||
registration_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct McpRegistration {
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
struct McpOAuthProvider {
|
||||
client_id: String,
|
||||
authorize_url: String,
|
||||
token_url: String,
|
||||
scopes: String,
|
||||
fixed_redirect: String,
|
||||
}
|
||||
|
||||
impl OAuthProvider for McpOAuthProvider {
|
||||
fn provider_name(&self) -> &str {
|
||||
"MCP"
|
||||
}
|
||||
|
||||
fn client_id(&self) -> &str {
|
||||
&self.client_id
|
||||
}
|
||||
|
||||
fn authorize_url(&self) -> &str {
|
||||
&self.authorize_url
|
||||
}
|
||||
|
||||
fn token_url(&self) -> &str {
|
||||
&self.token_url
|
||||
}
|
||||
|
||||
fn redirect_uri(&self) -> &str {
|
||||
""
|
||||
}
|
||||
|
||||
fn scopes(&self) -> &str {
|
||||
&self.scopes
|
||||
}
|
||||
|
||||
fn token_request_format(&self) -> TokenRequestFormat {
|
||||
TokenRequestFormat::FormUrlEncoded
|
||||
}
|
||||
|
||||
fn uses_localhost_redirect(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn fixed_redirect_uri(&self) -> Option<String> {
|
||||
Some(self.fixed_redirect.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_mcp_oauth_flow(
|
||||
server_name: &str,
|
||||
server_url: &str,
|
||||
configured_client_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let metadata = discover_oauth_metadata(server_url).await?;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
drop(listener);
|
||||
let redirect_uri = format!("http://127.0.0.1:{port}/callback");
|
||||
|
||||
let client_id = if let Some(id) = configured_client_id {
|
||||
id.to_string()
|
||||
} else if let Some(cached) = load_registered_client_id(server_name) {
|
||||
cached
|
||||
} else if let Some(reg_endpoint) = &metadata.registration_endpoint {
|
||||
match register_client(reg_endpoint, &redirect_uri).await {
|
||||
Ok(id) => {
|
||||
let _ = save_registered_client_id(server_name, &id);
|
||||
id
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Dynamic client registration failed: {e}. Falling back to manual entry.");
|
||||
Text::new("Enter the OAuth client ID for this MCP server:")
|
||||
.prompt()
|
||||
.context("Failed to read client ID")?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text::new("Enter the OAuth client ID for this MCP server:")
|
||||
.prompt()
|
||||
.context("Failed to read client ID")?
|
||||
};
|
||||
|
||||
let provider = McpOAuthProvider {
|
||||
client_id,
|
||||
authorize_url: metadata.authorization_endpoint,
|
||||
token_url: metadata.token_endpoint,
|
||||
scopes: metadata.scopes_supported.join(" "),
|
||||
fixed_redirect: redirect_uri,
|
||||
};
|
||||
|
||||
run_oauth_flow(&provider, &mcp_token_key(server_name)).await
|
||||
}
|
||||
|
||||
pub fn load_valid_mcp_token(server_name: &str) -> Option<String> {
|
||||
let tokens = load_oauth_tokens(&mcp_token_key(server_name))?;
|
||||
if Utc::now().timestamp() < tokens.expires_at {
|
||||
Some(tokens.access_token)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_token_key(server_name: &str) -> String {
|
||||
format!("mcp_{server_name}")
|
||||
}
|
||||
|
||||
fn load_registered_client_id(server_name: &str) -> Option<String> {
|
||||
let path = paths::oauth_tokens_path().join(format!("mcp_{server_name}_registration.json"));
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
let reg: McpRegistration = serde_json::from_str(&content).ok()?;
|
||||
|
||||
Some(reg.client_id)
|
||||
}
|
||||
|
||||
fn save_registered_client_id(server_name: &str, client_id: &str) -> Result<()> {
|
||||
let dir = paths::oauth_tokens_path();
|
||||
fs::create_dir_all(&dir)?;
|
||||
|
||||
let path = dir.join(format!("mcp_{server_name}_registration.json"));
|
||||
let reg = McpRegistration {
|
||||
client_id: client_id.to_string(),
|
||||
};
|
||||
|
||||
fs::write(path, serde_json::to_string_pretty(®)?)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn register_client(endpoint: &str, redirect_uri: &str) -> Result<String> {
|
||||
let body = serde_json::json!({
|
||||
"client_name": "Coyote",
|
||||
"redirect_uris": [redirect_uri],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none"
|
||||
});
|
||||
|
||||
let response: serde_json::Value = Client::new()
|
||||
.post(endpoint)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach registration endpoint")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse registration response")?;
|
||||
|
||||
response["client_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("Missing client_id in registration response: {response}"))
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> {
|
||||
let base = extract_base_url(server_url)?;
|
||||
let client = Client::new();
|
||||
|
||||
// RFC 9728: try protected resource metadata first; it points to the auth server
|
||||
let pr_url = format!("{base}/.well-known/oauth-protected-resource");
|
||||
if let Ok(resp) = client.get(&pr_url).send().await
|
||||
&& resp.status().is_success()
|
||||
&& let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await
|
||||
&& let Some(auth_server) = pr.authorization_servers.first()
|
||||
{
|
||||
let as_url = format!("{auth_server}/.well-known/oauth-authorization-server");
|
||||
if let Ok(resp) = client.get(&as_url).send().await
|
||||
&& resp.status().is_success()
|
||||
&& let Ok(meta) = resp.json::<OAuthServerMetadata>().await
|
||||
{
|
||||
return Ok(meta);
|
||||
}
|
||||
}
|
||||
|
||||
let as_url = format!("{base}/.well-known/oauth-authorization-server");
|
||||
let resp = client
|
||||
.get(&as_url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to reach {as_url}"))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
return resp
|
||||
.json::<OAuthServerMetadata>()
|
||||
.await
|
||||
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"Could not discover OAuth metadata for '{server_url}'.\n\
|
||||
Tried:\n {pr_url}\n {as_url}\n\
|
||||
Ensure the server supports MCP OAuth discovery, or consult its documentation."
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_base_url(url: &str) -> Result<String> {
|
||||
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
|
||||
let scheme = parsed.scheme();
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow!("No host in URL: {url}"))?;
|
||||
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
|
||||
|
||||
Ok(format!("{scheme}://{host}{port}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::get_env_name;
|
||||
use serial_test::serial;
|
||||
use std::{
|
||||
env, fs,
|
||||
time::{self, SystemTime},
|
||||
};
|
||||
|
||||
fn with_temp_cache<F: FnOnce()>(f: F) {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let root = env::temp_dir().join(format!("coyote-mcp-oauth-test-{unique}"));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let env_key = get_env_name("cache_dir");
|
||||
let prev = env::var_os(&env_key);
|
||||
unsafe {
|
||||
env::set_var(&env_key, &root);
|
||||
}
|
||||
f();
|
||||
unsafe {
|
||||
match prev {
|
||||
Some(v) => env::set_var(&env_key, v),
|
||||
None => env::remove_var(&env_key),
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_base_url_strips_path_and_query() {
|
||||
let result = extract_base_url("https://mcp.notion.com/mcp?foo=bar").unwrap();
|
||||
|
||||
assert_eq!(result, "https://mcp.notion.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_base_url_preserves_explicit_port() {
|
||||
let result = extract_base_url("http://localhost:8080/mcp").unwrap();
|
||||
|
||||
assert_eq!(result, "http://localhost:8080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_base_url_standard_port_omitted() {
|
||||
let result = extract_base_url("https://example.com/mcp/v1").unwrap();
|
||||
|
||||
assert_eq!(result, "https://example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_base_url_rejects_invalid_url() {
|
||||
assert!(extract_base_url("not-a-url").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn registered_client_id_roundtrip() {
|
||||
with_temp_cache(|| {
|
||||
save_registered_client_id("notion", "client-xyz-123").unwrap();
|
||||
|
||||
let loaded = load_registered_client_id("notion");
|
||||
|
||||
assert_eq!(loaded, Some("client-xyz-123".to_string()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn load_registered_client_id_returns_none_for_missing() {
|
||||
with_temp_cache(|| {
|
||||
let loaded = load_registered_client_id("no-such-server");
|
||||
|
||||
assert!(loaded.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn registered_client_id_second_save_overwrites_first() {
|
||||
with_temp_cache(|| {
|
||||
save_registered_client_id("github", "first-id").unwrap();
|
||||
save_registered_client_id("github", "second-id").unwrap();
|
||||
|
||||
let loaded = load_registered_client_id("github");
|
||||
|
||||
assert_eq!(loaded, Some("second-id".to_string()));
|
||||
});
|
||||
}
|
||||
}
|
||||
+163
-8
@@ -6,7 +6,10 @@ use self::completer::ReplCompleter;
|
||||
use self::highlighter::ReplHighlighter;
|
||||
use self::prompt::ReplPrompt;
|
||||
|
||||
use crate::client::{call_chat_completions, call_chat_completions_streaming, init_client, oauth};
|
||||
use crate::client::{
|
||||
Message, MessageRole, call_chat_completions, call_chat_completions_streaming, init_client,
|
||||
oauth,
|
||||
};
|
||||
use crate::config::{
|
||||
AgentVariables, AppConfig, AssertState, Input, LastMessage, RequestContext, StateFlags,
|
||||
macro_execute,
|
||||
@@ -20,7 +23,7 @@ use crate::utils::{
|
||||
};
|
||||
|
||||
use crate::sandbox::SANDBOX_ENV_FLAG;
|
||||
use crate::{config, graph, resolve_oauth_client};
|
||||
use crate::{config, graph, mcp, resolve_oauth_client};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use fancy_regex::Regex;
|
||||
@@ -29,9 +32,9 @@ use log::warn;
|
||||
use parking_lot::RwLock;
|
||||
use reedline::CursorConfig;
|
||||
use reedline::{
|
||||
ColumnarMenu, EditCommand, EditMode, Emacs, KeyCode, KeyModifiers, Keybindings, Reedline,
|
||||
ReedlineEvent, ReedlineMenu, ValidationResult, Validator, Vi, default_emacs_keybindings,
|
||||
default_vi_insert_keybindings, default_vi_normal_keybindings,
|
||||
ColumnarMenu, EditCommand, EditMode, Emacs, FileBackedHistory, KeyCode, KeyModifiers,
|
||||
Keybindings, Reedline, ReedlineEvent, ReedlineMenu, ValidationResult, Validator, Vi,
|
||||
default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings,
|
||||
};
|
||||
use reedline::{MenuBuilder, Signal};
|
||||
use std::sync::LazyLock;
|
||||
@@ -49,7 +52,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
||||
4. Continue with the next pending item now. Call tools immediately."
|
||||
};
|
||||
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 49]> = LazyLock::new(|| {
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
|
||||
[
|
||||
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
||||
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
||||
@@ -63,6 +66,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 49]> = LazyLock::new(|| {
|
||||
"Authenticate the current model client via OAuth (if configured)",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".mcp auth",
|
||||
"Authenticate with an MCP server via OAuth",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".edit config",
|
||||
"Modify configuration file",
|
||||
@@ -313,6 +321,58 @@ Type ".help" for additional help.
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let (messages_snapshot, compressed_count) = {
|
||||
let ctx = self.ctx.read();
|
||||
if let Some(session) = &ctx.session {
|
||||
let msgs: Vec<Message> = session
|
||||
.messages()
|
||||
.iter()
|
||||
.filter(|m| !m.role.is_system())
|
||||
.cloned()
|
||||
.collect();
|
||||
let compressed = session.compressed_messages().len();
|
||||
(msgs, compressed)
|
||||
} else {
|
||||
(vec![], 0)
|
||||
}
|
||||
};
|
||||
|
||||
if !messages_snapshot.is_empty() || compressed_count > 0 {
|
||||
let app = Arc::clone(&self.ctx.read().app.config);
|
||||
if compressed_count > 0 {
|
||||
println!(
|
||||
"{}",
|
||||
dimmed_text(&format!(
|
||||
"({compressed_count} earlier messages not shown; compressed for context)"
|
||||
))
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
for message in &messages_snapshot {
|
||||
match message.role {
|
||||
MessageRole::User => {
|
||||
if let Some(text) = message.content.as_text() {
|
||||
println!("{}", dimmed_text("You:"));
|
||||
println!("{text}");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
MessageRole::Assistant => {
|
||||
if let Some(text) = message.content.as_text() {
|
||||
app.print_markdown(text)?;
|
||||
println!();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
println!("{}", dimmed_text("─── ↑ previous conversation ───"));
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
if self.abort_signal.aborted_ctrld() {
|
||||
break;
|
||||
@@ -388,6 +448,14 @@ Type ".help" for additional help.
|
||||
editor = editor.with_buffer_editor(command, temp_file);
|
||||
}
|
||||
|
||||
if app.save_shell_history {
|
||||
let ctx = ctx.read();
|
||||
let history_path = paths::repl_history_file(&ctx.session);
|
||||
if let Ok(history) = FileBackedHistory::with_file(1000, history_path) {
|
||||
editor = editor.with_history(Box::new(history));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(editor)
|
||||
}
|
||||
|
||||
@@ -541,6 +609,53 @@ pub async fn run_repl_command(
|
||||
let (client_name, provider) = resolve_oauth_client(Some(client.name()), &clients)?;
|
||||
oauth::run_oauth_flow(&*provider, &client_name).await?;
|
||||
}
|
||||
".mcp" => match args {
|
||||
Some(args) => {
|
||||
let mut parts = args.splitn(2, char::is_whitespace);
|
||||
let sub = parts.next().unwrap_or("").trim();
|
||||
let rest = parts.next().map(str::trim).unwrap_or("");
|
||||
match sub {
|
||||
"auth" => {
|
||||
if rest.is_empty() {
|
||||
println!("Usage: .mcp auth <server_name>");
|
||||
} else {
|
||||
let server_name = rest;
|
||||
let server_spec = ctx
|
||||
.app
|
||||
.mcp_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.mcp_servers.get(server_name))
|
||||
.cloned();
|
||||
match server_spec {
|
||||
None => {
|
||||
bail!("MCP server '{}' not found in mcp.json.", server_name)
|
||||
}
|
||||
Some(spec) if !spec.is_remote() => bail!(
|
||||
"MCP server '{}' uses stdio transport; \
|
||||
OAuth is only supported for http/sse servers.",
|
||||
server_name
|
||||
),
|
||||
Some(spec) => {
|
||||
let url = spec
|
||||
.url
|
||||
.as_deref()
|
||||
.expect("validated: remote spec has url");
|
||||
let client_id = spec.oauth_client_id.as_deref();
|
||||
mcp::oauth::run_mcp_oauth_flow(server_name, url, client_id)
|
||||
.await?;
|
||||
println!(
|
||||
"Authentication saved. \
|
||||
Restart Coyote to connect to '{server_name}'."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => unknown_command()?,
|
||||
}
|
||||
}
|
||||
None => println!("Usage: .mcp auth <server_name>"),
|
||||
},
|
||||
".prompt" => match args {
|
||||
Some(text) => {
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
@@ -632,6 +747,46 @@ pub async fn run_repl_command(
|
||||
session.set_autonaming(false);
|
||||
}
|
||||
}
|
||||
if let Some(session) = &ctx.session {
|
||||
let messages_snapshot: Vec<Message> = session
|
||||
.messages()
|
||||
.iter()
|
||||
.filter(|m| !m.role.is_system())
|
||||
.cloned()
|
||||
.collect();
|
||||
let compressed_count = session.compressed_messages().len();
|
||||
if !messages_snapshot.is_empty() || compressed_count > 0 {
|
||||
if compressed_count > 0 {
|
||||
println!(
|
||||
"{}",
|
||||
dimmed_text(&format!(
|
||||
"({compressed_count} earlier messages not shown — compressed for context)"
|
||||
))
|
||||
);
|
||||
println!();
|
||||
}
|
||||
for message in &messages_snapshot {
|
||||
match message.role {
|
||||
MessageRole::User => {
|
||||
if let Some(text) = message.content.as_text() {
|
||||
println!("{}", dimmed_text("You:"));
|
||||
println!("{text}");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
MessageRole::Assistant => {
|
||||
if let Some(text) = message.content.as_text() {
|
||||
app.print_markdown(text)?;
|
||||
println!();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
println!("{}", dimmed_text("─── ↑ previous conversation ───"));
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
".install" => {
|
||||
let trimmed = args.map(str::trim).unwrap_or("");
|
||||
@@ -1415,8 +1570,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_commands_has_49_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 49);
|
||||
fn repl_commands_has_50_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+42
-11
@@ -316,6 +316,7 @@ fn sandbox_exists(name: &str) -> Result<bool> {
|
||||
fn create_sandbox(name: &str, kit_path: &Path, mixins: &[DiscoveredMixin]) -> Result<()> {
|
||||
info!("Creating sandbox '{name}'");
|
||||
let args = build_create_args(name, kit_path, mixins)?;
|
||||
debug!("sbx {}", args.join(" "));
|
||||
let status = Command::new(SBX_BINARY)
|
||||
.args(&args)
|
||||
.stdin(Stdio::inherit())
|
||||
@@ -342,6 +343,8 @@ fn build_create_args(
|
||||
|
||||
let mut args = vec![
|
||||
"create".to_string(),
|
||||
"--name".to_string(),
|
||||
name.to_string(),
|
||||
"--kit".to_string(),
|
||||
kit_str.to_string(),
|
||||
];
|
||||
@@ -357,8 +360,6 @@ fn build_create_args(
|
||||
}
|
||||
|
||||
args.push(SANDBOX_AGENT.to_string());
|
||||
args.push("--name".to_string());
|
||||
args.push(name.to_string());
|
||||
args.push(".".to_string());
|
||||
|
||||
Ok(args)
|
||||
@@ -369,10 +370,17 @@ fn copy_host_files(name: &str) -> Result<()> {
|
||||
let home_dir = dirs::home_dir().context("Could not determine home directory")?;
|
||||
|
||||
if config_dir.exists() {
|
||||
ensure_sandbox_dir(name, "/home/agent/.config")?;
|
||||
let src = format!("{}/", config_dir.display());
|
||||
let dest = format!("{name}:/home/agent/.config/");
|
||||
sbx_cp(&src, &dest)?;
|
||||
let sandbox_config_dir = "/home/agent/.config/coyote";
|
||||
ensure_sandbox_dir(name, sandbox_config_dir)?;
|
||||
let dest = format!("{name}:{sandbox_config_dir}/");
|
||||
for entry in fs::read_dir(&config_dir)
|
||||
.with_context(|| format!("Failed to read {}", config_dir.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
sbx_cp(&path.display().to_string(), &dest)?;
|
||||
}
|
||||
chown_agent_recursive(name, sandbox_config_dir)?;
|
||||
} else {
|
||||
debug!(
|
||||
"Skipping config copy: {} does not exist",
|
||||
@@ -390,6 +398,7 @@ fn copy_host_files(name: &str) -> Result<()> {
|
||||
}
|
||||
let dest = format!("{name}:{dest_path}");
|
||||
sbx_cp(&password_file.display().to_string(), &dest)?;
|
||||
chown_agent_recursive(name, &dest_path)?;
|
||||
}
|
||||
Some(password_file) => {
|
||||
debug!(
|
||||
@@ -505,8 +514,9 @@ fn exec_run(name: &str, kit_path: &Path) -> Result<()> {
|
||||
let kit_str = kit_path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Kit path is not valid UTF-8: {}", kit_path.display()))?;
|
||||
debug!("sbx run --name {name} --kit {kit_str}");
|
||||
let status = Command::new(SBX_BINARY)
|
||||
.args(["run", name, "--kit", kit_str])
|
||||
.args(["run", "--name", name, "--kit", kit_str])
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
@@ -520,6 +530,27 @@ fn exec_run(name: &str, kit_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn chown_agent_recursive(sandbox: &str, path: &str) -> Result<()> {
|
||||
let path_q = shell_words::quote(path);
|
||||
let cmd = format!("sudo chown -R agent:agent {path_q}");
|
||||
|
||||
debug!("sbx exec {sandbox}: {cmd}");
|
||||
|
||||
let status = Command::new(SBX_BINARY)
|
||||
.args(["exec", sandbox, "sh", "-c", &cmd])
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.status()
|
||||
.context("Failed to spawn `sbx exec` to chown copied files")?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("Chowning '{path}' in sandbox failed: sbx exec exited with {status}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -621,6 +652,8 @@ mod tests {
|
||||
args,
|
||||
vec![
|
||||
"create".to_string(),
|
||||
"--name".to_string(),
|
||||
"my-box".to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cache/sbx-kit".to_string(),
|
||||
"--kit".to_string(),
|
||||
@@ -628,8 +661,6 @@ mod tests {
|
||||
"--kit".to_string(),
|
||||
dir_b.display().to_string(),
|
||||
"coyote".to_string(),
|
||||
"--name".to_string(),
|
||||
"my-box".to_string(),
|
||||
".".to_string(),
|
||||
]
|
||||
);
|
||||
@@ -646,11 +677,11 @@ mod tests {
|
||||
args,
|
||||
vec![
|
||||
"create".to_string(),
|
||||
"--name".to_string(),
|
||||
"box".to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cache/sbx-kit".to_string(),
|
||||
"coyote".to_string(),
|
||||
"--name".to_string(),
|
||||
"box".to_string(),
|
||||
".".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
+33
-8
@@ -1,9 +1,11 @@
|
||||
use crate::config::paths;
|
||||
use colored::Colorize;
|
||||
use fancy_regex::Regex;
|
||||
use std::fs::File;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::process;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
pub async fn tail_logs(no_color: bool) {
|
||||
let re = Regex::new(r"^(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+<(?P<opid>[^\s>]+)>\s+\[(?P<level>[A-Z]+)\]\s+(?P<logger>[^:]+):(?P<line>\d+)\s+-\s+(?P<message>.*)$").unwrap();
|
||||
@@ -16,20 +18,43 @@ pub async fn tail_logs(no_color: bool) {
|
||||
process::exit(1);
|
||||
};
|
||||
|
||||
let mut lines = reader.lines();
|
||||
let mut line_buf = String::new();
|
||||
|
||||
loop {
|
||||
if let Some(Ok(line)) = lines.next() {
|
||||
if no_color {
|
||||
println!("{line}");
|
||||
} else {
|
||||
let colored_line = colorize_log_line(&line, &re);
|
||||
println!("{colored_line}");
|
||||
match reader.read_line(&mut line_buf) {
|
||||
Ok(0) => {
|
||||
if file_was_rotated(&file_path, &mut reader) {
|
||||
let file = File::open(&file_path).expect("Cannot open file");
|
||||
reader = BufReader::new(file);
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Ok(_) => {
|
||||
let line = line_buf.trim_end();
|
||||
if no_color {
|
||||
println!("{line}");
|
||||
} else {
|
||||
let colored_line = colorize_log_line(line, &re);
|
||||
println!("{colored_line}");
|
||||
}
|
||||
line_buf.clear();
|
||||
}
|
||||
Err(_) => {
|
||||
line_buf.clear();
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn file_was_rotated(path: &std::path::Path, reader: &mut BufReader<File>) -> bool {
|
||||
let current_pos = reader.stream_position().unwrap_or(0);
|
||||
match fs::metadata(path) {
|
||||
Ok(metadata) => metadata.len() < current_pos,
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn colorize_log_line(line: &str, re: &Regex) -> String {
|
||||
if let Some(caps) = re.captures(line).expect("Failed to capture log line") {
|
||||
let level = &caps["level"];
|
||||
|
||||
Reference in New Issue
Block a user