Compare commits
38 Commits
v0.7.0
...
385bd3eda2
| Author | SHA1 | Date | |
|---|---|---|---|
|
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
|
|||
| 040dad05d2 | |||
| 1ba38860f2 | |||
|
84ec5fe7b8
|
|||
| 1684788fe6 | |||
|
4b7e242998
|
|||
| f69aba2dd8 | |||
| c3487ecd0e | |||
|
db75391fb6
|
|||
|
e3815af69b
|
|||
| 66a485f924 | |||
| 49d7204f89 | |||
|
bbcae1fc2b
|
@@ -1,3 +1,82 @@
|
||||
## 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
|
||||
|
||||
- apply bootstrapping of functions at startup to fix edge case
|
||||
|
||||
## v0.7.2 (2026-06-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- usql version upgrade
|
||||
|
||||
## v0.7.1 (2026-06-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- sbx mixins must be passed in directories, not as files and the files must be named spec.yaml per new sbx version
|
||||
|
||||
## v0.7.0 (2026-06-18)
|
||||
|
||||
### Feat
|
||||
|
||||
- added configurable cache path via the COYOTE_CACHE_PATH environment variable
|
||||
- added a memory option to .set tab completions
|
||||
- Added a diagnostic .info tools subcommand to make it easier to see what tools are enabled in all contexts
|
||||
- Added additional info outputs for enabled skills and sbx directories
|
||||
- directly execute shell commands from within the REPL
|
||||
- created mixin kit for built-in functions and MCP servers
|
||||
- Added sbx mixins for the secrets providers so users can also bootstrap those as well.
|
||||
- added support for loading sbx mixins that are dynamically discovered in the users workspace and config directory
|
||||
- Added a --fresh flag to let users create a truly bare bones sandbox without bootstrapping their config
|
||||
- initial built-in sandboxing support powered by Docker sbx
|
||||
- Added the ability to auto-bootstrap workspace memory when in git repos
|
||||
- Added explicit guardrail handling for pending agents
|
||||
- auto-append memory to memory index and don't necessarily require the LLM to remember to do it after a write
|
||||
- Added an --init-memory [global|workspace] flag to easily and quickly enable memory
|
||||
- added memory global configuration settings to the output of --info and .info
|
||||
- added .set memory REPL commands to control memory injection and applied formatting
|
||||
- Create the built-in memory management tools
|
||||
- Append the memory system prompts (readonly or r/w) to the system prompt when applicable
|
||||
- Created the --no-memory CLI flag to disable memory for this invocation
|
||||
- Added the memory configuration properties and storage to the main app config, roles, sessions, and agents.
|
||||
- initial scaffolding of a memory system
|
||||
|
||||
### Fix
|
||||
|
||||
- rebuild the tool scope after dynamically updating the skills_enabled value in the REPL
|
||||
- properly resolve Windows-based local vault password file locations and bootstrap them into the sandbox when possible
|
||||
- auto-translation of user-prefixed Mac and Linux paths for the vault password file when running inside a sandbox
|
||||
- don't attempt to auto complete .vault list in the REPL; that's the end of the command
|
||||
- buffer tool stdout as well as stderr so that any tools that error to stdout are captured and included in the response to the model, enabling the model to see what went wrong and to reason about how to fix it.
|
||||
- auto-bootstrapped memory was accidentally putting the MEMORY.md directly in the repo root rather than .coyote/memory/MEMORY.md
|
||||
- improved the fs_patch script description and added improved error handling to it.
|
||||
- added in forgotten require_max_tokens to the fable model
|
||||
- append memory functions to non-graph based agents on init
|
||||
- when auto_continue is disabled via the .set auto_continue false command, it should strip the todo functions from the list of functions
|
||||
- use rawPredict for non-streaming Claude requests
|
||||
|
||||
### Refactor
|
||||
|
||||
- Migrated the .skills command completion to use StateFlags and updated the help messages
|
||||
|
||||
## v0.6.0 (2026-06-05)
|
||||
|
||||
### Feat
|
||||
|
||||
Generated
+188
-300
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coyote-ai"
|
||||
version = "0.6.0"
|
||||
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:
|
||||
- '*'
|
||||
+71
-60
@@ -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,25 +250,31 @@ 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="0.19.20"
|
||||
USQL_VERSION=0.21.4
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) USQL_ARCH=amd64 ;;
|
||||
aarch64) USQL_ARCH=arm64 ;;
|
||||
*) echo "Unsupported arch for usql install: $ARCH" >&2; exit 1 ;;
|
||||
esac
|
||||
curl -sSL "https://github.com/xo/usql/releases/download/v${USQL_VERSION}/usql_static-${USQL_VERSION}-linux-${USQL_ARCH}.tar.bz2" -o /tmp/usql.tar.bz2
|
||||
sudo tar -xjf /tmp/usql.tar.bz2 -C /usr/local/bin
|
||||
sudo chmod +x /usr/local/bin/usql
|
||||
rm -f /tmp/usql.tar.bz2
|
||||
user: "1000"
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
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'
|
||||
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 | \
|
||||
@@ -278,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>,
|
||||
|
||||
+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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,7 @@ const SBX_KIT_DIR_NAME: &str = "sbx-kit";
|
||||
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
|
||||
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
||||
const SBX_VAULT_MIXINS_DIR_NAME: &str = "sbx-vault-mixins";
|
||||
const SBX_MIXIN_KITS_DIR_NAME: &str = "sbx-mixin-kits";
|
||||
const GIT_DIR_NAME: &str = ".git";
|
||||
const GITIGNORE_FILE_NAME: &str = ".gitignore";
|
||||
const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [
|
||||
|
||||
+14
-4
@@ -4,8 +4,8 @@ use super::{
|
||||
ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME,
|
||||
GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME,
|
||||
MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME,
|
||||
SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_VAULT_MIXINS_DIR_NAME, SKILLS_DIR_NAME,
|
||||
WORKSPACE_MEMORY_DIR_NAME,
|
||||
SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, SBX_VAULT_MIXINS_DIR_NAME,
|
||||
SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME,
|
||||
};
|
||||
use crate::client::ProviderModels;
|
||||
use crate::utils::{get_env_name, list_file_names, normalize_env_name};
|
||||
@@ -33,8 +33,14 @@ pub fn local_path(name: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
pub fn cache_path() -> PathBuf {
|
||||
let base_dir = dirs::cache_dir().unwrap_or_else(env::temp_dir);
|
||||
base_dir.join(env!("CARGO_CRATE_NAME"))
|
||||
if let Ok(v) = env::var(get_env_name("cache_dir")) {
|
||||
PathBuf::from(v)
|
||||
} else if let Ok(v) = env::var("XDG_CACHE_HOME") {
|
||||
PathBuf::from(v).join(env!("CARGO_CRATE_NAME"))
|
||||
} else {
|
||||
let base_dir = dirs::cache_dir().unwrap_or_else(env::temp_dir);
|
||||
base_dir.join(env!("CARGO_CRATE_NAME"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sandbox_kit_override() -> Option<PathBuf> {
|
||||
@@ -148,6 +154,10 @@ pub fn sbx_vault_mixins_hash_file() -> PathBuf {
|
||||
sbx_vault_mixins_dir().join(SBX_KIT_HASH_FILE)
|
||||
}
|
||||
|
||||
pub fn sbx_mixin_kits_dir() -> PathBuf {
|
||||
cache_path().join(SBX_MIXIN_KITS_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn config_file() -> PathBuf {
|
||||
match env::var(get_env_name("config_file")) {
|
||||
Ok(value) => PathBuf::from(value),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")?
|
||||
|
||||
+69
-6
@@ -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)?;
|
||||
@@ -137,7 +184,10 @@ async fn main() -> Result<()> {
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
let ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?;
|
||||
let mut ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?;
|
||||
let app_config = Arc::clone(&ctx.app.config);
|
||||
ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone())
|
||||
.await?;
|
||||
|
||||
{
|
||||
let app = &*ctx.app.config;
|
||||
@@ -582,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) => {
|
||||
@@ -605,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()));
|
||||
});
|
||||
}
|
||||
}
|
||||
+56
-4
@@ -20,7 +20,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;
|
||||
@@ -49,7 +49,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 +63,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",
|
||||
@@ -541,6 +546,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);
|
||||
@@ -1415,8 +1467,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]
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::fs::{read_dir, read_to_string};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_yaml::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::config::paths;
|
||||
|
||||
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
||||
const KIT_SPEC_FILE_NAME: &str = "spec.yaml";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredMixin {
|
||||
@@ -17,6 +20,46 @@ pub struct DiscoveredMixin {
|
||||
pub domain_count: usize,
|
||||
}
|
||||
|
||||
impl DiscoveredMixin {
|
||||
pub fn kit_path(&self) -> Result<PathBuf> {
|
||||
if self.path.is_dir() {
|
||||
return Ok(self.path.clone());
|
||||
}
|
||||
|
||||
wrap_mixin_as_kit(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wrap_mixin_as_kit(mixin_path: &Path) -> Result<PathBuf> {
|
||||
let bytes = fs::read(mixin_path)
|
||||
.with_context(|| format!("Failed to read sbx mixin {}", mixin_path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&bytes);
|
||||
let hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
let kit_dir = paths::sbx_mixin_kits_dir().join(&hash);
|
||||
let spec_path = kit_dir.join(KIT_SPEC_FILE_NAME);
|
||||
|
||||
if let Ok(existing) = fs::read(&spec_path)
|
||||
&& existing == bytes
|
||||
{
|
||||
return Ok(kit_dir);
|
||||
}
|
||||
|
||||
fs::create_dir_all(&kit_dir)
|
||||
.with_context(|| format!("Failed to create mixin kit dir {}", kit_dir.display()))?;
|
||||
fs::write(&spec_path, &bytes)
|
||||
.with_context(|| format!("Failed to write {}", spec_path.display()))?;
|
||||
|
||||
debug!(
|
||||
"Wrapped mixin {} as kit at {}",
|
||||
mixin_path.display(),
|
||||
kit_dir.display()
|
||||
);
|
||||
|
||||
Ok(kit_dir)
|
||||
}
|
||||
|
||||
pub fn discover() -> Result<Vec<DiscoveredMixin>> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -234,4 +277,166 @@ network:
|
||||
let found = collect_subdir_mixins(&absent);
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
|
||||
mod wrap_as_kit {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
use std::ffi::OsString;
|
||||
|
||||
struct TestCacheDirGuard {
|
||||
key: String,
|
||||
previous: Option<OsString>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestCacheDirGuard {
|
||||
fn new() -> Self {
|
||||
let key = crate::utils::get_env_name("cache_dir");
|
||||
let previous = env::var_os(&key);
|
||||
let nanos = time::SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = env::temp_dir().join(format!("coyote-mixin-wrap-cache-{nanos}"));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
unsafe {
|
||||
env::set_var(&key, &path);
|
||||
}
|
||||
Self {
|
||||
key,
|
||||
previous,
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestCacheDirGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
match &self.previous {
|
||||
Some(v) => env::set_var(&self.key, v),
|
||||
None => env::remove_var(&self.key),
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_mixin(name: &str, content: &str) -> PathBuf {
|
||||
let root = unique_root(&format!("wrap-src-{name}"));
|
||||
let path = root.join("sbx-mixin.yaml");
|
||||
fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn wrap_mixin_as_kit_creates_spec_yaml_with_original_content() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let content = "schemaVersion: \"1\"\nkind: mixin\nname: probe\n";
|
||||
let mixin = write_mixin("content", content);
|
||||
|
||||
let kit_dir = wrap_mixin_as_kit(&mixin).unwrap();
|
||||
let spec = kit_dir.join("spec.yaml");
|
||||
|
||||
assert!(spec.exists(), "spec.yaml must exist in wrapped kit dir");
|
||||
assert_eq!(fs::read_to_string(&spec).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn wrap_mixin_as_kit_is_deterministic_for_identical_content() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let content = "schemaVersion: \"1\"\nkind: mixin\nname: probe\n";
|
||||
let mixin_one = write_mixin("dedup-1", content);
|
||||
let mixin_two = write_mixin("dedup-2", content);
|
||||
|
||||
let kit_a = wrap_mixin_as_kit(&mixin_one).unwrap();
|
||||
let kit_b = wrap_mixin_as_kit(&mixin_two).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
kit_a, kit_b,
|
||||
"same content should share the same content-addressed kit dir"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn wrap_mixin_as_kit_different_content_yields_different_dirs() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let mixin_a = write_mixin("diff-a", "kind: mixin\nname: a\n");
|
||||
let mixin_b = write_mixin("diff-b", "kind: mixin\nname: b\n");
|
||||
|
||||
let kit_a = wrap_mixin_as_kit(&mixin_a).unwrap();
|
||||
let kit_b = wrap_mixin_as_kit(&mixin_b).unwrap();
|
||||
|
||||
assert_ne!(
|
||||
kit_a, kit_b,
|
||||
"different content must hash to different kit dirs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn wrap_mixin_as_kit_is_idempotent_on_cache_hit() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let mixin = write_mixin("idempotent", "kind: mixin\nname: probe\n");
|
||||
|
||||
let kit_first = wrap_mixin_as_kit(&mixin).unwrap();
|
||||
let spec = kit_first.join("spec.yaml");
|
||||
let mtime_first = fs::metadata(&spec).unwrap().modified().unwrap();
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
|
||||
let kit_second = wrap_mixin_as_kit(&mixin).unwrap();
|
||||
let mtime_second = fs::metadata(kit_second.join("spec.yaml"))
|
||||
.unwrap()
|
||||
.modified()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(kit_first, kit_second);
|
||||
assert_eq!(
|
||||
mtime_first, mtime_second,
|
||||
"cache hit must not rewrite spec.yaml"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn kit_path_passes_through_existing_directory() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let dir = unique_root("kit-path-dir-passthrough");
|
||||
|
||||
let m = DiscoveredMixin {
|
||||
path: dir.clone(),
|
||||
label: "vault".into(),
|
||||
install_count: 1,
|
||||
domain_count: 1,
|
||||
};
|
||||
|
||||
assert_eq!(m.kit_path().unwrap(), dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn kit_path_wraps_file_into_kit_dir() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let mixin = write_mixin("kit-path-wrap", "kind: mixin\nname: probe\n");
|
||||
|
||||
let m = DiscoveredMixin {
|
||||
path: mixin.clone(),
|
||||
label: mixin.display().to_string(),
|
||||
install_count: 0,
|
||||
domain_count: 0,
|
||||
};
|
||||
|
||||
let wrapped = m.kit_path().unwrap();
|
||||
assert!(wrapped.is_dir(), "kit_path of a file should be a directory");
|
||||
assert!(wrapped.join("spec.yaml").exists());
|
||||
assert_ne!(
|
||||
wrapped, mixin,
|
||||
"kit_path should not return the original file path"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+109
-19
@@ -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,22 +343,23 @@ fn build_create_args(
|
||||
|
||||
let mut args = vec![
|
||||
"create".to_string(),
|
||||
"--name".to_string(),
|
||||
name.to_string(),
|
||||
"--kit".to_string(),
|
||||
kit_str.to_string(),
|
||||
];
|
||||
|
||||
for mixin in mixins {
|
||||
let mixin_str = mixin
|
||||
.path
|
||||
let mixin_kit = mixin.kit_path()?;
|
||||
let mixin_str = mixin_kit
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Mixin path is not valid UTF-8: {}", mixin.path.display()))?;
|
||||
.ok_or_else(|| anyhow!("Mixin kit path is not valid UTF-8: {}", mixin_kit.display()))?
|
||||
.to_string();
|
||||
args.push("--kit".to_string());
|
||||
args.push(mixin_str.to_string());
|
||||
args.push(mixin_str);
|
||||
}
|
||||
|
||||
args.push(SANDBOX_AGENT.to_string());
|
||||
args.push("--name".to_string());
|
||||
args.push(name.to_string());
|
||||
args.push(".".to_string());
|
||||
|
||||
Ok(args)
|
||||
@@ -368,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",
|
||||
@@ -389,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!(
|
||||
@@ -504,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())
|
||||
@@ -519,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::*;
|
||||
@@ -590,15 +622,24 @@ mod tests {
|
||||
#[test]
|
||||
fn build_create_args_emits_base_kit_before_mixins() {
|
||||
let kit = PathBuf::from("/cache/sbx-kit");
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir_a = env::temp_dir().join(format!("coyote-mixin-a-{unique}"));
|
||||
let dir_b = env::temp_dir().join(format!("coyote-mixin-b-{unique}"));
|
||||
fs::create_dir_all(&dir_a).unwrap();
|
||||
fs::create_dir_all(&dir_b).unwrap();
|
||||
|
||||
let mixins = vec![
|
||||
DiscoveredMixin {
|
||||
path: PathBuf::from("/cfg/sbx-mixin.yaml"),
|
||||
path: dir_a.clone(),
|
||||
label: "user".into(),
|
||||
install_count: 0,
|
||||
domain_count: 0,
|
||||
},
|
||||
DiscoveredMixin {
|
||||
path: PathBuf::from("/cfg/agents/sql/sbx-mixin.yaml"),
|
||||
path: dir_b.clone(),
|
||||
label: "sql".into(),
|
||||
install_count: 0,
|
||||
domain_count: 0,
|
||||
@@ -611,18 +652,21 @@ 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(),
|
||||
"/cfg/sbx-mixin.yaml".to_string(),
|
||||
dir_a.display().to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cfg/agents/sql/sbx-mixin.yaml".to_string(),
|
||||
dir_b.display().to_string(),
|
||||
"coyote".to_string(),
|
||||
"--name".to_string(),
|
||||
"my-box".to_string(),
|
||||
".".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir_a);
|
||||
let _ = fs::remove_dir_all(&dir_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -633,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(),
|
||||
]
|
||||
);
|
||||
@@ -645,6 +689,7 @@ mod tests {
|
||||
|
||||
mod vault_mixins {
|
||||
use super::*;
|
||||
use crate::utils::get_env_name;
|
||||
use gman::providers::aws_secrets_manager::AwsSecretsManagerProvider;
|
||||
use gman::providers::azure_key_vault::AzureKeyVaultProvider;
|
||||
use gman::providers::gcp_secret_manager::GcpSecretManagerProvider;
|
||||
@@ -652,6 +697,46 @@ mod tests {
|
||||
use gman::providers::local::LocalProvider;
|
||||
use gman::providers::one_password::OnePasswordProvider;
|
||||
use serial_test::serial;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct TestCacheDirGuard {
|
||||
key: String,
|
||||
previous: Option<std::ffi::OsString>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestCacheDirGuard {
|
||||
fn new() -> Self {
|
||||
let key = get_env_name("cache_dir");
|
||||
let previous = env::var_os(&key);
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = env::temp_dir().join(format!("coyote-sandbox-vault-tests-{unique}"));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
unsafe {
|
||||
env::set_var(&key, &path);
|
||||
}
|
||||
Self {
|
||||
key,
|
||||
previous,
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestCacheDirGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
match &self.previous {
|
||||
Some(v) => env::set_var(&self.key, v),
|
||||
None => env::remove_var(&self.key),
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_local() {
|
||||
@@ -664,6 +749,7 @@ mod tests {
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_aws() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let p = SupportedProvider::AwsSecretsManager {
|
||||
provider_def: AwsSecretsManagerProvider {
|
||||
aws_profile: None,
|
||||
@@ -680,6 +766,7 @@ mod tests {
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_gcp() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let p = SupportedProvider::GcpSecretManager {
|
||||
provider_def: GcpSecretManagerProvider {
|
||||
gcp_project_id: None,
|
||||
@@ -695,6 +782,7 @@ mod tests {
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_one_password() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let p = SupportedProvider::OnePassword {
|
||||
provider_def: OnePasswordProvider {
|
||||
vault: None,
|
||||
@@ -711,6 +799,7 @@ mod tests {
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_azure() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let p = SupportedProvider::AzureKeyVault {
|
||||
provider_def: AzureKeyVaultProvider { vault_name: None },
|
||||
};
|
||||
@@ -724,6 +813,7 @@ mod tests {
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_gopass() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let p = SupportedProvider::Gopass {
|
||||
provider_def: GopassProvider { store: None },
|
||||
};
|
||||
|
||||
+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