Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6c114fe58
|
||
|
|
912e00a627
|
||
|
|
e006e29ff1
|
||
|
|
c0067d387c
|
||
|
|
118c346345
|
||
|
|
dc677a2529
|
||
|
|
5e2b9c98ad
|
||
|
|
c458ca93a9
|
||
|
|
860566bf50
|
||
|
|
7f90710427
|
||
|
|
93a934439b
|
||
|
|
ecda258d3a
|
||
|
|
3e598065f8
|
||
|
|
3abc30d633
|
||
|
|
f68937611e
|
||
|
|
a12cf84eb6 | ||
|
|
2b45e3a9b8
|
||
|
|
91dbaf5533
|
||
|
|
7a732436aa
|
||
|
|
98d3ba4a83
|
||
|
|
d734276927
|
||
|
|
5049143fcc
|
||
|
|
a968c3228d
|
||
|
|
f404acdbca | ||
|
|
efa570267d
|
||
|
|
9aeb9e6e2e | ||
|
|
3607a180d9
|
||
|
|
d429def0f6
|
||
|
|
e606eb7c49
|
||
|
|
a8fb32b6bd
|
||
|
|
1f7b8417fa
|
||
|
|
9540345ec7
|
||
|
|
70b6d51b55
|
||
|
|
9e5e8a60f2
|
||
|
|
d6447603bc
|
||
|
|
e6dc24beb5
|
||
|
|
e76c3efe4e
|
||
|
|
fac577589b | ||
|
|
737fc42ec1 | ||
|
|
888529f381
|
||
|
|
35e75e5b4f
|
||
|
|
dd7d75fd9f | ||
|
|
78f5fc8fb9 | ||
|
|
4f38214681
|
@@ -36,6 +36,17 @@ jobs:
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Cache DuckDB Extensions
|
||||
id: duckdb-extensions
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.duckdb/extensions
|
||||
key: duckdb-ext-${{ matrix.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
|
||||
- name: Install DuckDB Extensions
|
||||
if: steps.duckdb-extensions.outputs.cache-hit != 'true'
|
||||
run: cargo test --all duckdb
|
||||
|
||||
- name: Test
|
||||
run: cargo test --all
|
||||
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
## v0.8.3 (2026-08-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- infinite loop bug when attempting to interrupt a prompt exchange right before a session compression
|
||||
- ctrl-c inside of an auto-continue loop created an infinite loop
|
||||
|
||||
## v0.8.2 (2026-07-31)
|
||||
|
||||
### Fix
|
||||
|
||||
- sbx update doesn't allow undefined fields in sbx spec
|
||||
|
||||
## v0.8.1 (2026-07-30)
|
||||
|
||||
### Feat
|
||||
|
||||
Generated
+580
-155
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coyote-ai"
|
||||
version = "0.8.1"
|
||||
version = "0.8.3"
|
||||
edition = "2024"
|
||||
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
||||
description = "An all-in-one, batteries included LLM CLI Tool"
|
||||
@@ -17,8 +17,9 @@ exclude = [".github", "CONTRIBUTING.md"]
|
||||
anyhow = "1.0.69"
|
||||
bytes = "1.4.0"
|
||||
clap = { version = "4.5.40", features = ["cargo", "derive", "wrap_help"] }
|
||||
comfy-table = { version = "7.2.2", features = ["custom_styling"] }
|
||||
comfy-table = { version = "7.1.4", features = ["custom_styling"] }
|
||||
dirs = "6.0.0"
|
||||
duckdb = { version = "1.10505.0", features = ["bundled"] }
|
||||
dunce = "1.0.5"
|
||||
futures-util = "0.3.29"
|
||||
inquire = "0.9.4"
|
||||
|
||||
@@ -40,15 +40,57 @@ _write_project_cache() {
|
||||
_detect_heuristic() {
|
||||
local dir="$1"
|
||||
|
||||
local runner="" runner_type="" runner_targets=""
|
||||
if [[ -f "${dir}/Taskfile.yml" || -f "${dir}/Taskfile.yaml" || -f "${dir}/taskfile.yml" || -f "${dir}/taskfile.yaml" ]]; then
|
||||
runner="task" runner_type="taskfile"
|
||||
runner_targets=$( (cd "${dir}" && task --list-all 2>/dev/null | sed -n 's/^\* \([^:[:space:]]*\):.*/\1/p') || true)
|
||||
elif [[ -f "${dir}/justfile" || -f "${dir}/Justfile" ]]; then
|
||||
runner="just" runner_type="just"
|
||||
runner_targets=$( (cd "${dir}" && just --summary 2>/dev/null | tr ' ' '\n') || true)
|
||||
elif [[ -f "${dir}/Makefile" || -f "${dir}/makefile" || -f "${dir}/GNUmakefile" ]]; then
|
||||
runner="make" runner_type="make"
|
||||
local mk mkfiles=()
|
||||
for mk in Makefile makefile GNUmakefile; do
|
||||
[[ -f "${dir}/${mk}" ]] && mkfiles+=("${dir}/${mk}")
|
||||
done
|
||||
runner_targets=$(sed -n 's/^\([A-Za-z0-9_][A-Za-z0-9_.-]*\):\([^=].*\|\)$/\1/p' "${mkfiles[@]}" 2>/dev/null | sort -u || true)
|
||||
fi
|
||||
if [[ -n "${runner}" && -n "${runner_targets}" ]]; then
|
||||
_pick_target() {
|
||||
local c
|
||||
for c in "$@"; do
|
||||
if grep -qx "${c}" <<<"${runner_targets}"; then
|
||||
echo "${runner} ${c}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
local r_build r_test r_check r_lint r_fmt
|
||||
r_build=$(_pick_target build compile)
|
||||
r_test=$(_pick_target test tests unit)
|
||||
r_check=$(_pick_target check vet typecheck build)
|
||||
r_lint=$(_pick_target lint fmt-check)
|
||||
r_fmt=$(_pick_target fmt format)
|
||||
if [[ -n "${r_build}${r_test}${r_check}${r_lint}${r_fmt}" ]]; then
|
||||
echo "{\"type\":\"${runner_type}\",\"build\":\"${r_build}\",\"test\":\"${r_test}\",\"check\":\"${r_check}\",\"lint\":\"${r_lint}\",\"fmt\":\"${r_fmt}\"}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Rust
|
||||
if [[ -f "${dir}/Cargo.toml" ]]; then
|
||||
echo '{"type":"rust","build":"cargo build","test":"cargo test","check":"cargo check"}'
|
||||
echo '{"type":"rust","build":"cargo build","test":"cargo test","check":"cargo check","lint":"cargo clippy --no-deps -- -D warnings","fmt":"cargo fmt"}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Go
|
||||
if [[ -f "${dir}/go.mod" ]]; then
|
||||
echo '{"type":"go","build":"go build ./...","test":"go test ./...","check":"go vet ./..."}'
|
||||
local go_lint=""
|
||||
if compgen -G "${dir}/.golangci.*" &>/dev/null && command -v golangci-lint &>/dev/null; then
|
||||
go_lint="golangci-lint run"
|
||||
fi
|
||||
echo "{\"type\":\"go\",\"build\":\"go build ./...\",\"test\":\"go test ./...\",\"check\":\"go vet ./...\",\"lint\":\"${go_lint}\",\"fmt\":\"gofmt -w .\"}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -65,7 +107,25 @@ _detect_heuristic() {
|
||||
[[ -f "${dir}/pnpm-lock.yaml" ]] && pm="pnpm"
|
||||
[[ -f "${dir}/yarn.lock" ]] && pm="yarn"
|
||||
|
||||
echo "{\"type\":\"nodejs\",\"build\":\"${pm} run build\",\"test\":\"${pm} test\",\"check\":\"${pm} run lint\"}"
|
||||
# Emit only scripts the manifest actually declares (same introspection
|
||||
# contract as the runner tier: never guess a target into existence).
|
||||
_pkg_script() {
|
||||
local s
|
||||
for s in "$@"; do
|
||||
if jq -e --arg s "$s" '.scripts[$s] // empty' "${dir}/package.json" &>/dev/null; then
|
||||
echo "${pm} run ${s}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
local p_build p_test p_check p_lint p_fmt
|
||||
p_build=$(_pkg_script build compile)
|
||||
p_test=$(_pkg_script test)
|
||||
p_check=$(_pkg_script check typecheck tsc)
|
||||
p_lint=$(_pkg_script lint)
|
||||
p_fmt=$(_pkg_script fmt format prettier)
|
||||
echo "{\"type\":\"nodejs\",\"build\":\"${p_build}\",\"test\":\"${p_test}\",\"check\":\"${p_check}\",\"lint\":\"${p_lint}\",\"fmt\":\"${p_fmt}\"}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -82,7 +142,7 @@ _detect_heuristic() {
|
||||
check_cmd="uv run ruff check ."
|
||||
fi
|
||||
|
||||
echo "{\"type\":\"python\",\"build\":\"\",\"test\":\"${test_cmd}\",\"check\":\"${check_cmd}\"}"
|
||||
echo "{\"type\":\"python\",\"build\":\"\",\"test\":\"${test_cmd}\",\"check\":\"${check_cmd}\",\"lint\":\"${check_cmd}\",\"fmt\":\"ruff format .\"}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -144,17 +204,6 @@ _detect_heuristic() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Generic build systems (last resort before LLM)
|
||||
if [[ -f "${dir}/justfile" ]] || [[ -f "${dir}/Justfile" ]]; then
|
||||
echo '{"type":"just","build":"just build","test":"just test","check":"just lint"}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -f "${dir}/Makefile" ]] || [[ -f "${dir}/makefile" ]] || [[ -f "${dir}/GNUmakefile" ]]; then
|
||||
echo '{"type":"make","build":"make build","test":"make test","check":"make lint"}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -218,7 +267,9 @@ _detect_with_llm() {
|
||||
local prompt
|
||||
prompt=$(cat <<-EOF
|
||||
|
||||
Analyze this project directory and determine the project type, primary language, and the correct shell commands to build, test, and check (lint/typecheck) it.
|
||||
Analyze this project directory and determine the project type, primary language, and the correct shell commands to build, test, check (typecheck/vet), lint, and format it.
|
||||
|
||||
PRIORITY RULE: if the project declares its own task-runner interface (a Taskfile, justfile, Makefile, package.json scripts, or similar), those declared targets ARE the correct commands — prefer them over generic ecosystem defaults, and never invent a target the interface does not declare.
|
||||
|
||||
EOF
|
||||
)
|
||||
@@ -226,12 +277,12 @@ _detect_with_llm() {
|
||||
prompt+=$(cat <<-EOF
|
||||
|
||||
Respond with ONLY a valid JSON object. No markdown fences, no explanation, no extra text.
|
||||
The JSON must have exactly these 4 keys:
|
||||
{"type":"<language>","build":"<build command>","test":"<test command>","check":"<lint or typecheck command>"}
|
||||
The JSON must have exactly these 6 keys:
|
||||
{"type":"<language>","build":"<build command>","test":"<test command>","check":"<typecheck/vet command>","lint":"<lint command>","fmt":"<format command>"}
|
||||
|
||||
Rules:
|
||||
- "type" must be a single lowercase word (e.g. rust, go, python, nodejs, java, ruby, elixir, cpp, c, zig, haskell, scala, kotlin, dart, swift, php, dotnet, etc.)
|
||||
- If a command doesn't apply to this project, use an empty string, ""
|
||||
- If a command doesn't apply to this project, use an empty string, "" — NEVER guess a command that might not exist; a wrongly-guessed command is worse than an empty one
|
||||
- Use the most standard/common commands for the detected ecosystem
|
||||
- If you detect a package manager lockfile, use that package manager (e.g. pnpm over npm)
|
||||
EOF
|
||||
@@ -244,7 +295,7 @@ _detect_with_llm() {
|
||||
llm_response=$(echo "${llm_response}" | grep -o '{[^}]*}' | head -1)
|
||||
|
||||
if echo "${llm_response}" | jq -e '.type and .build != null and .test != null and .check != null' &>/dev/null; then
|
||||
echo "${llm_response}" | jq -c '{type: (.type // "unknown"), build: (.build // ""), test: (.test // ""), check: (.check // "")}'
|
||||
echo "${llm_response}" | jq -c '{type: (.type // "unknown"), build: (.build // ""), test: (.test // ""), check: (.check // ""), lint: (.lint // ""), fmt: (.fmt // "")}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -258,7 +309,7 @@ detect_project() {
|
||||
|
||||
local cached
|
||||
if cached=$(_read_project_cache "${dir}"); then
|
||||
echo "${cached}" | jq -c '{type, build, test, check}'
|
||||
echo "${cached}" | jq -c '{type, build, test, check, lint: (.lint // ""), fmt: (.fmt // "")}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -286,6 +337,31 @@ detect_project() {
|
||||
echo '{"type":"unknown","build":"","test":"","check":""}'
|
||||
}
|
||||
|
||||
# resolve_gate_dir maps a workspace root to the directory verification gates
|
||||
# must run in. A delivery-repo worker's workspace root holds only dotfiles
|
||||
# plus the clone, so gates aimed at the root detect nothing and silently
|
||||
# no-op. When the root has no project markers and exactly ONE first-level
|
||||
# git repo exists, gates run inside it; anything ambiguous stays at the root.
|
||||
resolve_gate_dir() {
|
||||
local dir="${1:-.}"
|
||||
local m
|
||||
for m in Taskfile.yml Taskfile.yaml taskfile.yml Cargo.toml go.mod package.json pyproject.toml setup.py pom.xml build.gradle mix.exs Gemfile composer.json Makefile justfile Justfile CMakeLists.txt; do
|
||||
if [[ -e "${dir}/${m}" ]]; then
|
||||
echo "${dir}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
local repos=() d
|
||||
for d in "${dir}"/*/; do
|
||||
[[ -d "${d}/.git" ]] && repos+=("${d}")
|
||||
done
|
||||
if [[ ${#repos[@]} -eq 1 ]]; then
|
||||
echo "${repos[0]%/}"
|
||||
return 0
|
||||
fi
|
||||
echo "${dir}"
|
||||
}
|
||||
|
||||
###########################
|
||||
## FILE SEARCH UTILITIES ##
|
||||
###########################
|
||||
|
||||
@@ -227,6 +227,11 @@ nodes:
|
||||
on unfamiliar lints, etc.).
|
||||
4. No dead code, no commented-out blocks, no premature abstractions.
|
||||
5. End your turn when editing is done. The graph runs verification next.
|
||||
6. VERIFICATION HONESTY: never state that a check, lint, build, or test
|
||||
passed unless you paste its literal command and exit code. A gate
|
||||
that did not run is UNVERIFIED — say so. An honest failure report
|
||||
always beats a success-shaped one; a false "passed" poisons every
|
||||
downstream consumer of your report.
|
||||
|
||||
Project directory: {{project_dir}}
|
||||
prompt: |
|
||||
@@ -248,7 +253,7 @@ nodes:
|
||||
- fs_write
|
||||
- fs_patch
|
||||
- execute_command
|
||||
max_iterations: 30
|
||||
max_iterations: 100
|
||||
state_updates:
|
||||
last_node_output: '{{output}}'
|
||||
fallback: end_failure
|
||||
|
||||
@@ -13,6 +13,7 @@ else
|
||||
fi
|
||||
|
||||
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
||||
project_dir=$(resolve_gate_dir "$project_dir")
|
||||
|
||||
if [[ -n "${BUILD_CMD:-}" ]]; then
|
||||
cmd="$BUILD_CMD"
|
||||
@@ -24,7 +25,7 @@ fi
|
||||
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
|
||||
jq -nc '{
|
||||
"build_ok": true,
|
||||
"build_output": "(no build/check command available for this project type)",
|
||||
"build_output": "(GATE NOT RUN: no build/check command configured or detected. This is NOT evidence that the build passed — set BUILD_CMD, and never report the build as verified.)",
|
||||
"_next": "verify_tests"
|
||||
}'
|
||||
exit 0
|
||||
|
||||
@@ -13,6 +13,7 @@ else
|
||||
fi
|
||||
|
||||
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
||||
project_dir=$(resolve_gate_dir "$project_dir")
|
||||
|
||||
if [[ -n "${TEST_CMD:-}" ]]; then
|
||||
cmd="$TEST_CMD"
|
||||
@@ -24,7 +25,7 @@ fi
|
||||
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
|
||||
jq -nc '{
|
||||
"tests_ok": true,
|
||||
"tests_output": "(no test command available for this project type)",
|
||||
"tests_output": "(GATE NOT RUN: no test command configured or detected. This is NOT evidence that tests passed — set TEST_CMD, and never report the suite as green.)",
|
||||
"_next": "self_review"
|
||||
}'
|
||||
exit 0
|
||||
|
||||
@@ -88,7 +88,6 @@ nodes:
|
||||
prompt: |
|
||||
Research prompt: {{initial_prompt}}
|
||||
tools: []
|
||||
temperature: 0.1
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
@@ -180,7 +179,6 @@ nodes:
|
||||
tools:
|
||||
- mcp:ddg-search
|
||||
max_iterations: 15
|
||||
temperature: 0.1
|
||||
state_updates:
|
||||
search_output: "{{output}}"
|
||||
fallback: synthesize
|
||||
@@ -251,7 +249,6 @@ nodes:
|
||||
tools:
|
||||
- mcp:personal-github
|
||||
max_iterations: 15
|
||||
temperature: 0.1
|
||||
state_updates:
|
||||
oss_output: "{{output}}"
|
||||
fallback: synthesize
|
||||
@@ -338,7 +335,6 @@ nodes:
|
||||
tools:
|
||||
- fetch_url_via_curl
|
||||
max_iterations: 20
|
||||
temperature: 0.1
|
||||
state_updates:
|
||||
findings: "{{output}}"
|
||||
fallback: final_format
|
||||
|
||||
@@ -266,6 +266,12 @@ instructions: |
|
||||
|
||||
**No evidence = not complete.** Mark a todo `completed` only after evidence is collected.
|
||||
|
||||
### Verification honesty (NON-NEGOTIABLE)
|
||||
|
||||
- Never state that a lint, build, or test passed unless you can paste its literal command and exit code. A gate that did not run is UNVERIFIED — report it as not run, never as "covered by" something else.
|
||||
- Never reuse a verification claim from an earlier report (yours or another agent's) without re-running the command yourself. Prior reports are unverified context, not evidence.
|
||||
- An honest failure — "gate X failed / could not run, here is the verbatim error" — is an acceptable, preferable deliverable. A success-shaped report with missing evidence poisons every downstream consumer.
|
||||
|
||||
### Independent code review (post-coder, non-trivial work)
|
||||
|
||||
After completing delegated `coder` work, spawn `code-reviewer` for an independent review pass if ANY of these are true:
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
schemaVersion: '1'
|
||||
schemaVersion: '2'
|
||||
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.
|
||||
Allows Sisyphus to reach DuckDuckGo plus a curated set of common
|
||||
content domains for its web-search MCP server. Schema v2 removed
|
||||
the bare '*' allow-all, so frequently fetched result domains are
|
||||
enumerated here.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
- '*'
|
||||
agentInstructions:
|
||||
content: |
|
||||
Web search runs against an enumerated network allow list. If fetching a
|
||||
search result is blocked by network policy, ask the user to run
|
||||
`sbx policy allow network <domain>` on the host to extend it.
|
||||
|
||||
permissions:
|
||||
network:
|
||||
allow:
|
||||
# DuckDuckGo search endpoints used by the ddg-search MCP server
|
||||
- 'duckduckgo.com'
|
||||
- 'html.duckduckgo.com'
|
||||
- 'lite.duckduckgo.com'
|
||||
# Common content/result domains fetched from search results
|
||||
# ('*.host' matches exactly one label and not the bare host itself)
|
||||
- '*.wikipedia.org'
|
||||
- 'github.com'
|
||||
- '*.githubusercontent.com'
|
||||
- 'stackoverflow.com'
|
||||
- '*.stackexchange.com'
|
||||
- 'developer.mozilla.org'
|
||||
- 'docs.python.org'
|
||||
- 'doc.rust-lang.org'
|
||||
- 'docs.rs'
|
||||
- 'crates.io'
|
||||
- 'pypi.org'
|
||||
- 'www.npmjs.com'
|
||||
# Jina reader fallback for fetching arbitrary pages as markdown
|
||||
- 'r.jina.ai'
|
||||
@@ -439,6 +439,12 @@ nodes:
|
||||
staleness report, gate decisions, and fix loop history. Downstream
|
||||
plan updates come from the sweep results.
|
||||
|
||||
VERIFICATION HONESTY: evidence marked "GATE NOT RUN" means that gate
|
||||
is UNVERIFIED — record it as not run; never paraphrase a skipped gate
|
||||
as covered, passing, or handled elsewhere. A handoff that admits an
|
||||
unverified gate is correct; one that dresses it up as verified poisons
|
||||
every downstream reader.
|
||||
|
||||
Then append durable, step-independent facts (if any) to {{notes_path}}
|
||||
- create the file if missing, never rewrite existing entries.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ else
|
||||
fi
|
||||
|
||||
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
||||
project_dir=$(resolve_gate_dir "$project_dir")
|
||||
|
||||
if [[ -n "${BUILD_CMD:-}" ]]; then
|
||||
cmd="$BUILD_CMD"
|
||||
@@ -24,7 +25,7 @@ fi
|
||||
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
|
||||
jq -nc '{
|
||||
"build_ok": true,
|
||||
"build_output": "(no build/check command available for this project type)",
|
||||
"build_output": "(GATE NOT RUN: no build/check command configured or detected. This is NOT evidence that the build passed — set BUILD_CMD, and never report the build as verified.)",
|
||||
"_next": "verify_tests"
|
||||
}'
|
||||
exit 0
|
||||
|
||||
@@ -13,19 +13,18 @@ else
|
||||
fi
|
||||
|
||||
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
||||
project_type=$(detect_project "$project_dir" | jq -r '.type // "unknown"')
|
||||
project_dir=$(resolve_gate_dir "$project_dir")
|
||||
project_info=$(detect_project "$project_dir")
|
||||
project_type=$(echo "$project_info" | jq -r '.type // "unknown"')
|
||||
|
||||
format_cmd="${FORMAT_CMD:-}"
|
||||
if [[ -z "$format_cmd" ]]; then
|
||||
case "$project_type" in
|
||||
rust) format_cmd="cargo fmt" ;;
|
||||
go) format_cmd="gofmt -w ." ;;
|
||||
python) command -v ruff &>/dev/null && format_cmd="ruff format ." ;;
|
||||
esac
|
||||
format_cmd=$(echo "$project_info" | jq -r '.fmt // ""')
|
||||
fi
|
||||
if [[ "$format_cmd" == "null" ]]; then format_cmd=""; fi
|
||||
|
||||
if [[ -z "$format_cmd" ]]; then
|
||||
format_output="(no format command configured for project type '$project_type'; skipped. Set FORMAT_CMD to enable.)"
|
||||
format_output="(GATE NOT RUN: no format command configured or detected for project type '$project_type'. This is NOT evidence that formatting is clean. Set FORMAT_CMD to enable.)"
|
||||
else
|
||||
fmt_rc=0
|
||||
fmt_out=$(cd "$project_dir" && eval "$format_cmd" 2>&1) || fmt_rc=$?
|
||||
@@ -37,12 +36,18 @@ fi
|
||||
|
||||
lint_cmd="${LINT_CMD:-}"
|
||||
if [[ -z "$lint_cmd" ]]; then
|
||||
lint_cmd=$(echo "$project_info" | jq -r '.lint // ""')
|
||||
fi
|
||||
# The skip message must read as a WARNING, never a reassurance: the previous
|
||||
# wording ("linting is covered by the build/check command") was quoted
|
||||
# verbatim by workers as false evidence that linting passed
|
||||
if [[ -z "$lint_cmd" || "$lint_cmd" == "null" ]]; then
|
||||
jq -nc \
|
||||
--arg fo "$format_output" \
|
||||
'{
|
||||
"format_output": $fo,
|
||||
"lint_ok": true,
|
||||
"lint_output": "(no LINT_CMD configured; linting is covered by the build/check command)",
|
||||
"lint_output": "(GATE NOT RUN: no lint command configured or detected. This is NOT evidence that linting passed — set LINT_CMD or add a Taskfile lint target, and never report linting as covered.)",
|
||||
"_next": "verify_build"
|
||||
}'
|
||||
exit 0
|
||||
|
||||
@@ -13,6 +13,7 @@ else
|
||||
fi
|
||||
|
||||
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
||||
project_dir=$(resolve_gate_dir "$project_dir")
|
||||
|
||||
if [[ -n "${TEST_CMD:-}" ]]; then
|
||||
cmd="$TEST_CMD"
|
||||
@@ -24,7 +25,7 @@ fi
|
||||
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
|
||||
jq -nc '{
|
||||
"tests_ok": true,
|
||||
"tests_output": "(no test command available for this project type)",
|
||||
"tests_output": "(GATE NOT RUN: no test command configured or detected. This is NOT evidence that tests passed — set TEST_CMD, and never report the suite as green.)",
|
||||
"_next": "edge_case_sweep"
|
||||
}'
|
||||
exit 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
schemaVersion: "1"
|
||||
schemaVersion: '2'
|
||||
kind: mixin
|
||||
name: built-in-tools
|
||||
description: >
|
||||
@@ -6,39 +6,39 @@ description: >
|
||||
global tools and the default MCP server set. Auto-applied by Coyote's sbx
|
||||
mixin discovery when running `coyote --sandbox`.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
# fetch_url_via_jina + jina reader fallback
|
||||
- "r.jina.ai:443"
|
||||
# get_current_weather (.sh, .py, .ts)
|
||||
- "wttr.in:443"
|
||||
# search_arxiv (the .sh tool still uses http://, so :80 is required until fixed)
|
||||
- "export.arxiv.org:443"
|
||||
- "export.arxiv.org:80"
|
||||
# search_arxiv + search_wikipedia may follow DOI redirects
|
||||
- "doi.org:443"
|
||||
# search_wikipedia
|
||||
- "en.wikipedia.org:443"
|
||||
# search_wolframalpha
|
||||
- "api.wolframalpha.com:443"
|
||||
# web_search_perplexity
|
||||
- "api.perplexity.ai:443"
|
||||
# web_search_tavily
|
||||
- "api.tavily.com:443"
|
||||
# send_twilio
|
||||
- "api.twilio.com:443"
|
||||
# MCP: github (built-in mcp.json: api.githubcopilot.com)
|
||||
- "api.githubcopilot.com:443"
|
||||
# MCP: atlassian (built-in mcp.json: mcp-remote -> mcp.atlassian.com)
|
||||
- "mcp.atlassian.com:443"
|
||||
# MCP: ddg-search (built-in mcp.json: uvx duckduckgo-mcp-server)
|
||||
- "duckduckgo.com:443"
|
||||
- "html.duckduckgo.com:443"
|
||||
- "lite.duckduckgo.com:443"
|
||||
# MCP: npx-based servers (mcp-remote) pull from npm
|
||||
- "registry.npmjs.org:443"
|
||||
# MCP: docker server may pull images from common registries
|
||||
- "ghcr.io:443"
|
||||
- "registry-1.docker.io:443"
|
||||
- "auth.docker.io:443"
|
||||
- "production.cloudflare.docker.com:443"
|
||||
permissions:
|
||||
network:
|
||||
allow:
|
||||
# fetch_url_via_jina + jina reader fallback
|
||||
- 'r.jina.ai'
|
||||
# get_current_weather (.sh, .py, .ts)
|
||||
- 'wttr.in'
|
||||
# search_arxiv (the .sh tool still uses http://, so :80 is required until fixed)
|
||||
- 'export.arxiv.org'
|
||||
- 'export.arxiv.org:80'
|
||||
# search_arxiv + search_wikipedia may follow DOI redirects
|
||||
- 'doi.org'
|
||||
# search_wikipedia
|
||||
- 'en.wikipedia.org'
|
||||
# search_wolframalpha
|
||||
- 'api.wolframalpha.com'
|
||||
# web_search_perplexity
|
||||
- 'api.perplexity.ai'
|
||||
# web_search_tavily
|
||||
- 'api.tavily.com'
|
||||
# send_twilio
|
||||
- 'api.twilio.com'
|
||||
# MCP: github (built-in mcp.json: api.githubcopilot.com)
|
||||
- 'api.githubcopilot.com'
|
||||
# MCP: atlassian (built-in mcp.json: mcp-remote -> mcp.atlassian.com)
|
||||
- 'mcp.atlassian.com'
|
||||
# MCP: ddg-search (built-in mcp.json: uvx duckduckgo-mcp-server)
|
||||
- 'duckduckgo.com'
|
||||
- 'html.duckduckgo.com'
|
||||
- 'lite.duckduckgo.com'
|
||||
# MCP: npx-based servers (mcp-remote) pull from npm
|
||||
- 'registry.npmjs.org'
|
||||
# MCP: docker server may pull images from common registries
|
||||
- 'ghcr.io'
|
||||
- 'registry-1.docker.io'
|
||||
- 'auth.docker.io'
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
if [ -z "$SSH_AUTH_SOCK" ]; then
|
||||
echo "WARNING: [git-ssh-sign] no SSH agent — cannot sign commits"
|
||||
fi
|
||||
|
||||
KEY=$(ssh-add -L 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$KEY" ]; then
|
||||
echo "WARNING: [git-ssh-sign] no keys in SSH agent — cannot sign commits"
|
||||
fi
|
||||
|
||||
KEY_FILE="/home/agent/.config/git/signing_key.pub"
|
||||
mkdir -p "$(dirname "$KEY_FILE")"
|
||||
printf '%s\n' "$KEY" > "$KEY_FILE"
|
||||
EMAIL=$(git config user.email 2>/dev/null || echo "agent@sandbox.local")
|
||||
printf '%s %s\n' "$EMAIL" "$KEY" > "/home/agent/.config/git/allowed_signers"
|
||||
|
||||
local_hook="$(git rev-parse --git-dir)/hooks/pre-commit"
|
||||
if [ -x "$local_hook" ]; then
|
||||
exec "$local_hook" "$@"
|
||||
fi
|
||||
+328
-253
@@ -4,7 +4,7 @@
|
||||
# sbx create --kit ./sbx-kit/ coyote --name testing .
|
||||
# sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/
|
||||
# sbx run testing --kit ./sbx-kit/
|
||||
schemaVersion: '1'
|
||||
schemaVersion: '2'
|
||||
kind: sandbox
|
||||
name: coyote
|
||||
displayName: Coyote
|
||||
@@ -13,199 +13,256 @@ description: >
|
||||
CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros.
|
||||
|
||||
sandbox:
|
||||
image: 'darkalex17/coyote:v0.8.1'
|
||||
aiFilename: COYOTE.md
|
||||
entrypoint:
|
||||
run: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote']
|
||||
image: 'darkalex17/coyote:v0.8.3'
|
||||
entrypoint: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote']
|
||||
|
||||
network:
|
||||
# Proxy-managed LLM providers: the proxy substitutes `proxy-managed` for
|
||||
# the env var inside the sandbox and rewrites the auth header per
|
||||
# serviceAuth at request time. Multiple domains may map to one service
|
||||
# (e.g. jina) so they share a single credential.
|
||||
serviceDomains:
|
||||
api.openai.com: openai
|
||||
api.anthropic.com: anthropic
|
||||
generativelanguage.googleapis.com: gemini
|
||||
api.cohere.ai: cohere
|
||||
api.groq.com: groq
|
||||
openrouter.ai: openrouter
|
||||
api.ai21.com: ai21
|
||||
api.cloudflare.com: cloudflare
|
||||
api.deepinfra.com: deepinfra
|
||||
api.deepseek.com: deepseek
|
||||
api.mistral.ai: mistral
|
||||
api.perplexity.ai: perplexity
|
||||
api.voyageai.com: voyageai
|
||||
api.x.ai: xai
|
||||
api.jina.ai: jina
|
||||
r.jina.ai: jina
|
||||
qianfan.baidubce.com: ernie
|
||||
api.hunyuan.cloud.tencent.com: hunyuan
|
||||
api.minimax.chat: minimax
|
||||
api.moonshot.cn: moonshot
|
||||
dashscope.aliyuncs.com: qianwen
|
||||
open.bigmodel.cn: zhipuai
|
||||
serviceAuth:
|
||||
openai:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
anthropic:
|
||||
headerName: x-api-key
|
||||
valueFormat: '%s'
|
||||
gemini:
|
||||
headerName: x-goog-api-key
|
||||
valueFormat: '%s'
|
||||
cohere:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
groq:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
openrouter:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
ai21:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
cloudflare:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
deepinfra:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
deepseek:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
mistral:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
perplexity:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
voyageai:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
xai:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
jina:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
ernie:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
hunyuan:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
minimax:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
moonshot:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
qianwen:
|
||||
headerName: Authorization
|
||||
valueFormat: 'Bearer %s'
|
||||
zhipuai:
|
||||
headerName: Authorization
|
||||
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'
|
||||
# Package managers and developer tools (cargo, uv, pip — useful at runtime for user installs)
|
||||
- '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'
|
||||
permissions:
|
||||
network:
|
||||
allow:
|
||||
# Coyote release + self-update + model-registry sync
|
||||
- 'github.com'
|
||||
- 'api.github.com'
|
||||
- 'raw.githubusercontent.com'
|
||||
- 'objects.githubusercontent.com'
|
||||
- '*.githubusercontent.com'
|
||||
# Package managers and developer tools (cargo, uv, pip — useful at runtime for user installs)
|
||||
- 'crates.io'
|
||||
- 'static.crates.io'
|
||||
- 'pypi.org'
|
||||
- 'files.pythonhosted.org'
|
||||
- 'astral.sh'
|
||||
- 'sh.rustup.rs'
|
||||
- 'static.rust-lang.org'
|
||||
|
||||
# LLM model OAuth + API endpoints
|
||||
- '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'
|
||||
# LLM model OAuth + API endpoints
|
||||
- 'claude.ai'
|
||||
- 'console.anthropic.com'
|
||||
- 'accounts.google.com'
|
||||
# *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints
|
||||
# (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI.
|
||||
- '*.googleapis.com'
|
||||
|
||||
# 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'
|
||||
# Bedrock and GitHub Models use signed / GitHub-PAT auth that the proxy
|
||||
# cannot rewrite; credentials must be injected separately (see README
|
||||
# "Extending"). NOTE: '*.amazonaws.com' matches exactly ONE label, so
|
||||
# two-label regional Bedrock hosts must be enumerated explicitly
|
||||
# ('**.' is declared but not yet enforced by sbx). Add your region
|
||||
# via a mixin if it's missing below.
|
||||
- '*.amazonaws.com'
|
||||
- 'bedrock-runtime.us-east-1.amazonaws.com'
|
||||
- 'bedrock-runtime.us-east-2.amazonaws.com'
|
||||
- 'bedrock-runtime.us-west-2.amazonaws.com'
|
||||
- 'bedrock-runtime.eu-west-1.amazonaws.com'
|
||||
- 'bedrock-runtime.eu-central-1.amazonaws.com'
|
||||
- 'bedrock-runtime.ap-southeast-2.amazonaws.com'
|
||||
- 'bedrock-runtime.ap-northeast-1.amazonaws.com'
|
||||
- 'models.inference.ai.azure.com'
|
||||
|
||||
# Proxy-managed LLM provider APIs. Every credentials[].apiKey.inject
|
||||
# domain below MUST also appear here. sbx does not derive allow entries
|
||||
# from inject rules.
|
||||
- 'api.openai.com'
|
||||
- 'api.anthropic.com'
|
||||
- 'generativelanguage.googleapis.com'
|
||||
- 'api.cohere.ai'
|
||||
- 'api.groq.com'
|
||||
- 'openrouter.ai'
|
||||
- 'api.ai21.com'
|
||||
- 'api.cloudflare.com'
|
||||
- 'api.deepinfra.com'
|
||||
- 'api.deepseek.com'
|
||||
- 'api.mistral.ai'
|
||||
- 'api.perplexity.ai'
|
||||
- 'api.voyageai.com'
|
||||
- 'api.x.ai'
|
||||
- 'api.jina.ai'
|
||||
- 'r.jina.ai'
|
||||
- 'qianfan.baidubce.com'
|
||||
- 'api.hunyuan.cloud.tencent.com'
|
||||
- 'api.minimax.chat'
|
||||
- 'api.moonshot.cn'
|
||||
- 'dashscope.aliyuncs.com'
|
||||
- 'open.bigmodel.cn'
|
||||
|
||||
# Proxy-managed LLM providers: inside the sandbox each apiKey env var holds
|
||||
# the `proxy-managed` sentinel; the proxy injects the real value into the
|
||||
# request header per the inject rules at request time. Values are bound by
|
||||
# the user via credential bindings (`sbx secret set <service>`); Coyote
|
||||
# pre-seeds them from its vault at launch. Multiple domains may map to one
|
||||
# service (e.g. jina) so they share a single credential.
|
||||
credentials:
|
||||
sources:
|
||||
openai:
|
||||
env:
|
||||
- OPENAI_API_KEY
|
||||
anthropic:
|
||||
env:
|
||||
- ANTHROPIC_API_KEY
|
||||
gemini:
|
||||
env:
|
||||
- GEMINI_API_KEY
|
||||
- GOOGLE_API_KEY
|
||||
cohere:
|
||||
env:
|
||||
- COHERE_API_KEY
|
||||
groq:
|
||||
env:
|
||||
- GROQ_API_KEY
|
||||
openrouter:
|
||||
env:
|
||||
- OPENROUTER_API_KEY
|
||||
ai21:
|
||||
env:
|
||||
- AI21_API_KEY
|
||||
cloudflare:
|
||||
env:
|
||||
- CLOUDFLARE_API_KEY
|
||||
deepinfra:
|
||||
env:
|
||||
- DEEPINFRA_API_KEY
|
||||
deepseek:
|
||||
env:
|
||||
- DEEPSEEK_API_KEY
|
||||
mistral:
|
||||
env:
|
||||
- MISTRAL_API_KEY
|
||||
perplexity:
|
||||
env:
|
||||
- PERPLEXITY_API_KEY
|
||||
voyageai:
|
||||
env:
|
||||
- VOYAGE_API_KEY
|
||||
xai:
|
||||
env:
|
||||
- XAI_API_KEY
|
||||
jina:
|
||||
env:
|
||||
- JINA_API_KEY
|
||||
ernie:
|
||||
env:
|
||||
- ERNIE_API_KEY
|
||||
hunyuan:
|
||||
env:
|
||||
- HUNYUAN_API_KEY
|
||||
minimax:
|
||||
env:
|
||||
- MINIMAX_API_KEY
|
||||
moonshot:
|
||||
env:
|
||||
- MOONSHOT_API_KEY
|
||||
qianwen:
|
||||
env:
|
||||
- DASHSCOPE_API_KEY
|
||||
zhipuai:
|
||||
env:
|
||||
- ZHIPUAI_API_KEY
|
||||
- service: openai
|
||||
description: OpenAI API key, injected on api.openai.com
|
||||
apiKey:
|
||||
name: OPENAI_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.openai.com
|
||||
scheme: bearer
|
||||
- service: anthropic
|
||||
description: Anthropic API key, injected as x-api-key on api.anthropic.com
|
||||
apiKey:
|
||||
name: ANTHROPIC_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.anthropic.com
|
||||
header: x-api-key
|
||||
format: '%s'
|
||||
- service: gemini
|
||||
description: Google Gemini API key, injected as x-goog-api-key on generativelanguage.googleapis.com
|
||||
apiKey:
|
||||
name: GEMINI_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: generativelanguage.googleapis.com
|
||||
header: x-goog-api-key
|
||||
format: '%s'
|
||||
- service: cohere
|
||||
description: Cohere API key, injected on api.cohere.ai
|
||||
apiKey:
|
||||
name: COHERE_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.cohere.ai
|
||||
scheme: bearer
|
||||
- service: groq
|
||||
description: Groq API key, injected on api.groq.com
|
||||
apiKey:
|
||||
name: GROQ_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.groq.com
|
||||
scheme: bearer
|
||||
- service: openrouter
|
||||
description: OpenRouter API key, injected on openrouter.ai
|
||||
apiKey:
|
||||
name: OPENROUTER_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: openrouter.ai
|
||||
scheme: bearer
|
||||
- service: ai21
|
||||
description: AI21 Labs API key, injected on api.ai21.com
|
||||
apiKey:
|
||||
name: AI21_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.ai21.com
|
||||
scheme: bearer
|
||||
- service: cloudflare
|
||||
description: Cloudflare Workers AI API key, injected on api.cloudflare.com
|
||||
apiKey:
|
||||
name: CLOUDFLARE_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.cloudflare.com
|
||||
scheme: bearer
|
||||
- service: deepinfra
|
||||
description: DeepInfra API key, injected on api.deepinfra.com
|
||||
apiKey:
|
||||
name: DEEPINFRA_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.deepinfra.com
|
||||
scheme: bearer
|
||||
- service: deepseek
|
||||
description: DeepSeek API key, injected on api.deepseek.com
|
||||
apiKey:
|
||||
name: DEEPSEEK_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.deepseek.com
|
||||
scheme: bearer
|
||||
- service: mistral
|
||||
description: Mistral API key, injected on api.mistral.ai
|
||||
apiKey:
|
||||
name: MISTRAL_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.mistral.ai
|
||||
scheme: bearer
|
||||
- service: perplexity
|
||||
description: Perplexity API key, injected on api.perplexity.ai
|
||||
apiKey:
|
||||
name: PERPLEXITY_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.perplexity.ai
|
||||
scheme: bearer
|
||||
- service: voyageai
|
||||
description: Voyage AI API key, injected on api.voyageai.com
|
||||
apiKey:
|
||||
name: VOYAGE_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.voyageai.com
|
||||
scheme: bearer
|
||||
- service: xai
|
||||
description: xAI (Grok) API key, injected on api.x.ai
|
||||
apiKey:
|
||||
name: XAI_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.x.ai
|
||||
scheme: bearer
|
||||
- service: jina
|
||||
description: Jina API key, injected on api.jina.ai and r.jina.ai
|
||||
apiKey:
|
||||
name: JINA_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.jina.ai
|
||||
scheme: bearer
|
||||
- domain: r.jina.ai
|
||||
scheme: bearer
|
||||
- service: ernie
|
||||
description: Baidu ERNIE API key, injected on qianfan.baidubce.com
|
||||
apiKey:
|
||||
name: ERNIE_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: qianfan.baidubce.com
|
||||
scheme: bearer
|
||||
- service: hunyuan
|
||||
description: Tencent Hunyuan API key, injected on api.hunyuan.cloud.tencent.com
|
||||
apiKey:
|
||||
name: HUNYUAN_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.hunyuan.cloud.tencent.com
|
||||
scheme: bearer
|
||||
- service: minimax
|
||||
description: MiniMax API key, injected on api.minimax.chat
|
||||
apiKey:
|
||||
name: MINIMAX_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.minimax.chat
|
||||
scheme: bearer
|
||||
- service: moonshot
|
||||
description: Moonshot AI API key, injected on api.moonshot.cn
|
||||
apiKey:
|
||||
name: MOONSHOT_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: api.moonshot.cn
|
||||
scheme: bearer
|
||||
- service: qianwen
|
||||
description: Alibaba Qianwen (DashScope) API key, injected on dashscope.aliyuncs.com
|
||||
apiKey:
|
||||
name: DASHSCOPE_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: dashscope.aliyuncs.com
|
||||
scheme: bearer
|
||||
- service: zhipuai
|
||||
description: Zhipu AI (GLM) API key, injected on open.bigmodel.cn
|
||||
apiKey:
|
||||
name: ZHIPUAI_API_KEY
|
||||
proxyManaged: true
|
||||
inject:
|
||||
- domain: open.bigmodel.cn
|
||||
scheme: bearer
|
||||
|
||||
environment:
|
||||
variables:
|
||||
@@ -213,31 +270,54 @@ environment:
|
||||
COYOTE_LOG_LEVEL: INFO
|
||||
COYOTE_CONFIG_DIR: /home/agent/.config/coyote
|
||||
EDITOR: nano
|
||||
proxyManaged:
|
||||
- OPENAI_API_KEY
|
||||
- ANTHROPIC_API_KEY
|
||||
- GEMINI_API_KEY
|
||||
- GOOGLE_API_KEY
|
||||
- COHERE_API_KEY
|
||||
- GROQ_API_KEY
|
||||
- OPENROUTER_API_KEY
|
||||
- AI21_API_KEY
|
||||
- CLOUDFLARE_API_KEY
|
||||
- DEEPINFRA_API_KEY
|
||||
- DEEPSEEK_API_KEY
|
||||
- MISTRAL_API_KEY
|
||||
- PERPLEXITY_API_KEY
|
||||
- VOYAGE_API_KEY
|
||||
- XAI_API_KEY
|
||||
- JINA_API_KEY
|
||||
- ERNIE_API_KEY
|
||||
- HUNYUAN_API_KEY
|
||||
- MINIMAX_API_KEY
|
||||
- MOONSHOT_API_KEY
|
||||
- DASHSCOPE_API_KEY
|
||||
- ZHIPUAI_API_KEY
|
||||
# Alias for the gemini credential: v2 apiKey supports a single env name
|
||||
# (GEMINI_API_KEY above). Coyote also recognizes GOOGLE_API_KEY, so keep
|
||||
# it set to the sentinel. Header injection happens per-domain regardless
|
||||
# of which env var the app reads.
|
||||
GOOGLE_API_KEY: proxy-managed
|
||||
|
||||
setup:
|
||||
files:
|
||||
- path: /home/agent/.config/git/ssh-signing-key-command
|
||||
mode: '0755'
|
||||
description: Resolve the forwarded SSH agent key for Git SSH signing
|
||||
content: |
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ -z "$SSH_AUTH_SOCK" ]; then
|
||||
echo "WARNING: [git-ssh-sign] no SSH agent - cannot sign commits" >&2
|
||||
fi
|
||||
|
||||
key=$(ssh-add -L 2>/dev/null | head -n 1)
|
||||
if [ -z "$key" ]; then
|
||||
echo "WARNING: [git-ssh-sign] no keys in SSH agent - cannot sign commits" >&2
|
||||
fi
|
||||
|
||||
config_dir="$GIT_SSH_SIGN_CONFIG_DIR"
|
||||
if [ -z "$config_dir" ]; then
|
||||
config_dir="/home/agent/.config/git"
|
||||
fi
|
||||
mkdir -p "$config_dir"
|
||||
|
||||
email=$(git config user.email 2>/dev/null || printf '%s' "agent@sandbox.local")
|
||||
printf '%s %s\n' "$email" "$key" > "$config_dir/allowed_signers"
|
||||
printf 'key::%s\n' "$key"
|
||||
|
||||
install:
|
||||
- command: |
|
||||
git config --system gpg.format ssh
|
||||
git config --system --unset-all user.signingKey || true
|
||||
git config --system commit.gpgSign true
|
||||
git config --system tag.gpgSign true
|
||||
git config --system gpg.ssh.defaultKeyCommand /home/agent/.config/git/ssh-signing-key-command
|
||||
git config --system gpg.ssh.allowedSignersFile /home/agent/.config/git/allowed_signers
|
||||
if [ "$(git config --system --get core.hooksPath || true)" = "/home/agent/.config/git/hooks" ]; then
|
||||
git config --system --unset-all core.hooksPath
|
||||
fi
|
||||
user: '0'
|
||||
description: Configure SSH commit signing with a dynamic key command
|
||||
|
||||
commands:
|
||||
startup:
|
||||
- command:
|
||||
[
|
||||
@@ -249,50 +329,45 @@ commands:
|
||||
background: false
|
||||
description: Bootstrap Coyote config directory on first sandbox start
|
||||
|
||||
profiles:
|
||||
headless:
|
||||
description: >
|
||||
Unattended mode for scripted use. Set COYOTE_PROMPT_FILE to the path of
|
||||
a prompt file, then launch: sbx run coyote --profile headless
|
||||
entrypoint:
|
||||
run: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote --headless -f "${COYOTE_PROMPT_FILE}"']
|
||||
environment:
|
||||
variables:
|
||||
COYOTE_LOG_LEVEL: WARN
|
||||
agentInstructions:
|
||||
filename: COYOTE.md
|
||||
content: |
|
||||
## Sandbox environment
|
||||
|
||||
agentContext: |
|
||||
## Sandbox environment
|
||||
You are running inside a Docker sandbox launched via `sbx run coyote`. The
|
||||
user's project workspace is mounted at its absolute host path and is the
|
||||
current working directory. `sudo` is passwordless; use it for system
|
||||
package installs.
|
||||
|
||||
You are running inside a Docker sandbox launched via `sbx run coyote`. The
|
||||
user's project workspace is mounted at its absolute host path and is the
|
||||
current working directory. `sudo` is passwordless; use it for system
|
||||
package installs.
|
||||
Coyote's configuration lives at `~/.config/coyote/` and logs at
|
||||
`~/.cache/coyote/coyote.log`. Persistence is enabled, so config, sessions,
|
||||
vault state, OAuth tokens, and installed tools survive sandbox restarts.
|
||||
|
||||
Coyote's configuration lives at `~/.config/coyote/` and logs at
|
||||
`~/.cache/coyote/coyote.log`. Persistence is enabled, so config, sessions,
|
||||
vault state, OAuth tokens, and installed tools survive sandbox restarts.
|
||||
LLM provider credentials are forwarded by the sandbox HTTP proxy via
|
||||
credential bindings. Coyote pre-seeds them from its vault at launch
|
||||
(`sbx secret set <service>`); users can also bind values manually on the
|
||||
host with `sbx secret set <service>` or `sbx secret import`. Recognized
|
||||
services:
|
||||
|
||||
LLM provider credentials are forwarded by the sandbox HTTP proxy. The
|
||||
following provider env vars are recognized - export the ones you use on
|
||||
the host before running `sbx run coyote`:
|
||||
openai, anthropic, gemini, cohere, groq, openrouter, ai21,
|
||||
cloudflare, deepinfra, deepseek, mistral, perplexity, voyageai,
|
||||
xai, jina, ernie, hunyuan, minimax, moonshot, qianwen, zhipuai
|
||||
|
||||
OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY / GOOGLE_API_KEY,
|
||||
COHERE_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, AI21_API_KEY,
|
||||
CLOUDFLARE_API_KEY, DEEPINFRA_API_KEY, DEEPSEEK_API_KEY,
|
||||
MISTRAL_API_KEY, PERPLEXITY_API_KEY, VOYAGE_API_KEY, XAI_API_KEY,
|
||||
JINA_API_KEY, ERNIE_API_KEY, HUNYUAN_API_KEY, MINIMAX_API_KEY,
|
||||
MOONSHOT_API_KEY, DASHSCOPE_API_KEY (Qwen), ZHIPUAI_API_KEY
|
||||
Inside the sandbox the corresponding env vars (OPENAI_API_KEY, etc.)
|
||||
hold the placeholder string `proxy-managed`; the proxy substitutes the
|
||||
real value at request time. OAuth flows for Claude Pro/Max and Gemini
|
||||
are also allow-listed.
|
||||
|
||||
Inside the sandbox these appear as the placeholder string `proxy-managed`;
|
||||
the proxy substitutes the real value at request time. OAuth flows for
|
||||
Claude Pro/Max and Gemini are also allow-listed.
|
||||
Bedrock (AWS) and VertexAI (Google Cloud) use signed/OAuth-token requests
|
||||
that the proxy cannot rewrite, so you must inject credentials yourself via
|
||||
`sbx run --env AWS_ACCESS_KEY_ID=...` or a mixin kit that mounts a
|
||||
service-account JSON. VertexAI regional endpoints are allow-listed via
|
||||
`*.googleapis.com`. Bedrock runtime endpoints are allow-listed for
|
||||
us-east-1/2, us-west-2, eu-west-1, eu-central-1, ap-southeast-2, and
|
||||
ap-northeast-1 only; other regions need a mixin allow entry
|
||||
(`bedrock-runtime.<region>.amazonaws.com`).
|
||||
|
||||
Bedrock (AWS) and VertexAI (Google Cloud) use signed/OAuth-token requests
|
||||
that the proxy cannot rewrite. Their domains are allow-listed but you must
|
||||
inject credentials yourself via `sbx run --env AWS_ACCESS_KEY_ID=...` or
|
||||
a mixin kit that mounts a service-account JSON.
|
||||
|
||||
Useful first-run commands:
|
||||
- `coyote --info` # show config paths and resolved settings
|
||||
- `coyote --list-secrets` # initialise the local vault
|
||||
- `coyote --authenticate <client>` # OAuth flow (Claude Pro/Max, Gemini)
|
||||
Useful first-run commands:
|
||||
- `coyote --info` # show config paths and resolved settings
|
||||
- `coyote --list-secrets` # initialise the local vault
|
||||
- `coyote --authenticate <client>` # OAuth flow (Claude Pro/Max, Gemini)
|
||||
+10
-3
@@ -1,3 +1,5 @@
|
||||
use std::mem;
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::utils::{base64_decode, encode_uri, hex_encode, hmac_sha256, sha256, strip_think_tag};
|
||||
@@ -275,10 +277,11 @@ async fn chat_completions_streaming(
|
||||
format!("Tool call '{function_name}' has non-JSON arguments '{function_arguments}'")
|
||||
})?;
|
||||
handler.tool_call(ToolCall::new(
|
||||
function_name.clone(),
|
||||
mem::take(&mut function_name),
|
||||
arguments,
|
||||
Some(function_id.clone()),
|
||||
Some(mem::take(&mut function_id)),
|
||||
))?;
|
||||
function_arguments.clear();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -529,7 +532,11 @@ fn extract_chat_completions(data: &Value) -> Result<ChatCompletionsOutput> {
|
||||
bail!("Invalid response data: {data}");
|
||||
}
|
||||
|
||||
let output = ChatCompletionsOutput { text, tool_calls, ..Default::default() };
|
||||
let output = ChatCompletionsOutput {
|
||||
text,
|
||||
tool_calls,
|
||||
..Default::default()
|
||||
};
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
|
||||
+101
-58
@@ -1,3 +1,5 @@
|
||||
use std::mem;
|
||||
|
||||
use super::access_token::get_access_token;
|
||||
use super::claude_oauth::ClaudeOAuthProvider;
|
||||
use super::oauth::{self, OAuthProvider};
|
||||
@@ -212,12 +214,8 @@ pub async fn claude_chat_completions_streaming(
|
||||
if let Some(text) = data["delta"]["text"].as_str() {
|
||||
handler.text(text)?;
|
||||
} else if let Some(text) = data["delta"]["thinking"].as_str() {
|
||||
if reasoning_state == 0 {
|
||||
handler.text("<think>\n")?;
|
||||
reasoning_state = 1;
|
||||
}
|
||||
reasoning_state = 1;
|
||||
thinking_text.push_str(text);
|
||||
handler.text(text)?;
|
||||
} else if let Some(signature) = data["delta"]["signature"].as_str() {
|
||||
thinking_signature.push_str(signature);
|
||||
} else if let (true, Some(partial_json)) = (
|
||||
@@ -229,11 +227,10 @@ pub async fn claude_chat_completions_streaming(
|
||||
}
|
||||
"content_block_stop" => {
|
||||
if reasoning_state == 1 {
|
||||
handler.text("\n</think>\n\n")?;
|
||||
reasoning_state = 0;
|
||||
handler.thinking_block(ThinkingBlock::Thinking {
|
||||
thinking: std::mem::take(&mut thinking_text),
|
||||
signature: std::mem::take(&mut thinking_signature),
|
||||
thinking: mem::take(&mut thinking_text),
|
||||
signature: mem::take(&mut thinking_signature),
|
||||
});
|
||||
}
|
||||
if !function_name.is_empty() {
|
||||
@@ -245,10 +242,11 @@ pub async fn claude_chat_completions_streaming(
|
||||
})?
|
||||
};
|
||||
handler.tool_call(ToolCall::new(
|
||||
function_name.clone(),
|
||||
mem::take(&mut function_name),
|
||||
arguments,
|
||||
Some(function_id.clone()),
|
||||
Some(mem::take(&mut function_id)),
|
||||
))?;
|
||||
function_arguments.clear();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -326,51 +324,99 @@ pub fn claude_build_chat_completions_body(
|
||||
})]
|
||||
}
|
||||
MessageContent::ToolCalls(MessageContentToolCalls {
|
||||
tool_results, text, ..
|
||||
tool_results,
|
||||
text,
|
||||
sequence,
|
||||
}) => {
|
||||
let mut assistant_parts = vec![];
|
||||
let mut user_parts = vec![];
|
||||
for (index, tool_result) in tool_results.iter().enumerate() {
|
||||
for block in &tool_result.thinking {
|
||||
assistant_parts.push(json!(block));
|
||||
}
|
||||
let round_text = if index == 0 && !text.is_empty() {
|
||||
Some(text.as_str())
|
||||
} else {
|
||||
tool_result.text.as_deref()
|
||||
};
|
||||
if let Some(round_text) = round_text {
|
||||
let round_text = strip_think_tag(round_text);
|
||||
let round_text = round_text.trim();
|
||||
if !round_text.is_empty() {
|
||||
assistant_parts.push(json!({
|
||||
"type": "text",
|
||||
"text": round_text,
|
||||
}))
|
||||
if !sequence {
|
||||
let mut assistant_parts = vec![];
|
||||
let mut user_parts = vec![];
|
||||
for (index, tool_result) in tool_results.iter().enumerate() {
|
||||
for block in &tool_result.thinking {
|
||||
assistant_parts.push(json!(block));
|
||||
}
|
||||
let round_text = if index == 0 && !text.is_empty() {
|
||||
Some(text.as_str())
|
||||
} else {
|
||||
tool_result.text.as_deref()
|
||||
};
|
||||
if let Some(round_text) = round_text {
|
||||
let round_text = strip_think_tag(round_text);
|
||||
let round_text = round_text.trim();
|
||||
if !round_text.is_empty() {
|
||||
assistant_parts.push(json!({
|
||||
"type": "text",
|
||||
"text": round_text,
|
||||
}))
|
||||
}
|
||||
}
|
||||
assistant_parts.push(json!({
|
||||
"type": "tool_use",
|
||||
"id": tool_result.call.id,
|
||||
"name": tool_result.call.name,
|
||||
"input": tool_result.call.arguments,
|
||||
}));
|
||||
user_parts.push(json!({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_result.call.id,
|
||||
"content": tool_result.output.to_string(),
|
||||
}));
|
||||
}
|
||||
assistant_parts.push(json!({
|
||||
"type": "tool_use",
|
||||
"id": tool_result.call.id,
|
||||
"name": tool_result.call.name,
|
||||
"input": tool_result.call.arguments,
|
||||
}));
|
||||
user_parts.push(json!({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_result.call.id,
|
||||
"content": tool_result.output.to_string(),
|
||||
}));
|
||||
vec![
|
||||
json!({ "role": "assistant", "content": assistant_parts }),
|
||||
json!({ "role": "user", "content": user_parts }),
|
||||
]
|
||||
} else {
|
||||
// One pair per round: Claude can reuse tool_use IDs across API calls.
|
||||
let mut messages = vec![];
|
||||
let mut assistant_parts: Vec<serde_json::Value> = vec![];
|
||||
let mut user_parts: Vec<serde_json::Value> = vec![];
|
||||
for (index, tool_result) in tool_results.iter().enumerate() {
|
||||
if index > 0 && tool_result.text.is_some() {
|
||||
messages.push(
|
||||
json!({ "role": "assistant", "content": assistant_parts }),
|
||||
);
|
||||
messages.push(json!({ "role": "user", "content": user_parts }));
|
||||
assistant_parts = vec![];
|
||||
user_parts = vec![];
|
||||
}
|
||||
for block in &tool_result.thinking {
|
||||
assistant_parts.push(json!(block));
|
||||
}
|
||||
let round_text = if index == 0 && !text.is_empty() {
|
||||
Some(text.as_str())
|
||||
} else {
|
||||
tool_result.text.as_deref()
|
||||
};
|
||||
if let Some(round_text) = round_text {
|
||||
let round_text = strip_think_tag(round_text);
|
||||
let round_text = round_text.trim();
|
||||
if !round_text.is_empty() {
|
||||
assistant_parts.push(json!({
|
||||
"type": "text",
|
||||
"text": round_text,
|
||||
}))
|
||||
}
|
||||
}
|
||||
assistant_parts.push(json!({
|
||||
"type": "tool_use",
|
||||
"id": tool_result.call.id,
|
||||
"name": tool_result.call.name,
|
||||
"input": tool_result.call.arguments,
|
||||
}));
|
||||
user_parts.push(json!({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_result.call.id,
|
||||
"content": tool_result.output.to_string(),
|
||||
}));
|
||||
}
|
||||
if !assistant_parts.is_empty() {
|
||||
messages
|
||||
.push(json!({ "role": "assistant", "content": assistant_parts }));
|
||||
messages.push(json!({ "role": "user", "content": user_parts }));
|
||||
}
|
||||
messages
|
||||
}
|
||||
vec![
|
||||
json!({
|
||||
"role": "assistant",
|
||||
"content": assistant_parts,
|
||||
}),
|
||||
json!({
|
||||
"role": "user",
|
||||
"content": user_parts,
|
||||
}),
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -430,7 +476,6 @@ pub fn claude_build_chat_completions_body(
|
||||
|
||||
pub fn claude_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOutput> {
|
||||
let mut text = String::new();
|
||||
let mut reasoning = None;
|
||||
let mut tool_calls = vec![];
|
||||
let mut thinking = vec![];
|
||||
if let Some(list) = data["content"].as_array() {
|
||||
@@ -438,10 +483,12 @@ pub fn claude_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
|
||||
match item["type"].as_str() {
|
||||
Some("thinking") => {
|
||||
if let Some(v) = item["thinking"].as_str() {
|
||||
reasoning = Some(v.to_string());
|
||||
thinking.push(ThinkingBlock::Thinking {
|
||||
thinking: v.to_string(),
|
||||
signature: item["signature"].as_str().unwrap_or_default().to_string(),
|
||||
signature: item["signature"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -477,10 +524,6 @@ pub fn claude_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(reasoning) = reasoning {
|
||||
text = format!("<think>\n{reasoning}\n</think>\n\n{text}")
|
||||
}
|
||||
|
||||
if text.is_empty() && tool_calls.is_empty() {
|
||||
bail!("Invalid response data: {data}");
|
||||
}
|
||||
|
||||
+89
-2
@@ -131,6 +131,17 @@ pub trait OAuthProvider: Send + Sync {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Extra form/body parameters appended to every token request routed
|
||||
/// through `build_token_request` (authorization-code exchange, refresh,
|
||||
/// client_credentials, and device-code polling). Used e.g. for the
|
||||
/// RFC 8707 `resource` indicator required by the MCP spec.
|
||||
/// NOTE: these are merged AFTER the caller's params and will overwrite
|
||||
/// a colliding key; do not return protocol parameter names
|
||||
/// (grant_type, client_id, code, refresh_token, ...).
|
||||
fn extra_token_params(&self) -> Vec<(&str, &str)> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn token_request_format(&self) -> TokenRequestFormat {
|
||||
TokenRequestFormat::Json
|
||||
}
|
||||
@@ -642,9 +653,14 @@ fn build_token_request(
|
||||
provider: &(impl OAuthProvider + ?Sized),
|
||||
params: &[(&str, &str)],
|
||||
) -> RequestBuilder {
|
||||
let all_params: Vec<(&str, &str)> = params
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(provider.extra_token_params())
|
||||
.collect();
|
||||
let mut request = match provider.token_request_format() {
|
||||
TokenRequestFormat::Json => {
|
||||
let body: serde_json::Map<String, Value> = params
|
||||
let body: serde_json::Map<String, Value> = all_params
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), Value::String(v.to_string())))
|
||||
.collect();
|
||||
@@ -660,7 +676,7 @@ fn build_token_request(
|
||||
}
|
||||
}
|
||||
TokenRequestFormat::FormUrlEncoded => {
|
||||
let mut form: HashMap<String, String> = params
|
||||
let mut form: HashMap<String, String> = all_params
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
@@ -870,6 +886,8 @@ pub(crate) fn client_config_info(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str;
|
||||
|
||||
use super::*;
|
||||
use crate::client::openai_compatible::OpenAICompatibleConfig;
|
||||
use crate::client::{ModelData, ProviderModels};
|
||||
@@ -1161,6 +1179,16 @@ echo_pkce_in_token_exchange: true
|
||||
assert!(provider.fixed_redirect_uri().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_extra_token_params_is_empty() {
|
||||
let provider = OpenAICompatibleOAuthProvider {
|
||||
config: base_config(),
|
||||
client_name: "test".into(),
|
||||
};
|
||||
|
||||
assert!(provider.extra_token_params().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_flow_device_code_parses() {
|
||||
let yaml = "client_id: x\ntoken_url: y\nflow: device_code";
|
||||
@@ -1381,4 +1409,63 @@ scopes:
|
||||
assert!(cfg.use_pkce_in_device_flow);
|
||||
assert_eq!(cfg.scopes, vec!["read", "write"]);
|
||||
}
|
||||
|
||||
struct ResourceStubProvider;
|
||||
|
||||
impl OAuthProvider for ResourceStubProvider {
|
||||
fn provider_name(&self) -> &str {
|
||||
"stub"
|
||||
}
|
||||
|
||||
fn client_id(&self) -> &str {
|
||||
"stub-client"
|
||||
}
|
||||
|
||||
fn authorize_url(&self) -> &str {
|
||||
"https://as.example/authorize"
|
||||
}
|
||||
|
||||
fn token_url(&self) -> &str {
|
||||
"https://as.example/token"
|
||||
}
|
||||
|
||||
fn redirect_uri(&self) -> &str {
|
||||
""
|
||||
}
|
||||
|
||||
fn scopes(&self) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn token_request_format(&self) -> TokenRequestFormat {
|
||||
TokenRequestFormat::FormUrlEncoded
|
||||
}
|
||||
|
||||
fn extra_token_params(&self) -> Vec<(&str, &str)> {
|
||||
vec![("resource", "https://rs.example/mcp")]
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_token_request_appends_extra_token_params_to_form_body() {
|
||||
let provider = ResourceStubProvider;
|
||||
|
||||
let request = build_token_request(
|
||||
&ReqwestClient::new(),
|
||||
&provider,
|
||||
&[("grant_type", "authorization_code")],
|
||||
)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let body = str::from_utf8(request.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||
assert!(
|
||||
body.contains("resource=https%3A%2F%2Frs.example%2Fmcp"),
|
||||
"body missing resource param: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("grant_type=authorization_code"),
|
||||
"body missing grant_type param: {body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+110
-14
@@ -12,6 +12,7 @@ use crate::config::prompts::{
|
||||
DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS, DEFAULT_TODO_INSTRUCTIONS,
|
||||
DEFAULT_USER_INTERACTION_INSTRUCTIONS,
|
||||
};
|
||||
use crate::graph::types::RagNode;
|
||||
use crate::graph::{Graph, GraphParser, NodeType};
|
||||
use crate::rag::RagInitConfig;
|
||||
use crate::vault::SECRET_RE;
|
||||
@@ -146,11 +147,18 @@ impl Agent {
|
||||
let rag = if rag_path.exists() {
|
||||
let key = RagKey::Agent(name.to_string());
|
||||
let app_clone = app.clone();
|
||||
let vault_clone = app_state.vault.clone();
|
||||
let rag_path_clone = rag_path.clone();
|
||||
let rag = app_state
|
||||
.rag_cache
|
||||
.load_with(key, || async move {
|
||||
Rag::load(&app_clone, DEFAULT_AGENT_NAME, &rag_path_clone)
|
||||
Rag::load_async(
|
||||
&app_clone,
|
||||
&vault_clone,
|
||||
DEFAULT_AGENT_NAME,
|
||||
&rag_path_clone,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await?;
|
||||
Some(rag)
|
||||
@@ -171,7 +179,15 @@ impl Agent {
|
||||
let rag = app_state
|
||||
.rag_cache
|
||||
.load_with(key, || async move {
|
||||
Rag::init(&app_clone, "rag", &rag_path_clone, &document_paths, abort).await
|
||||
Rag::init(
|
||||
&app_clone,
|
||||
"rag",
|
||||
&rag_path_clone,
|
||||
&document_paths,
|
||||
abort,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await?;
|
||||
Some(rag)
|
||||
@@ -573,6 +589,25 @@ impl Agent {
|
||||
_ => bail!("No return value from '_instructions' function"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn test_new(config: AgentConfig) -> Self {
|
||||
Self {
|
||||
name: config.name.clone(),
|
||||
config,
|
||||
shared_variables: Default::default(),
|
||||
session_variables: None,
|
||||
shared_dynamic_instructions: None,
|
||||
session_dynamic_instructions: None,
|
||||
functions: Functions::default(),
|
||||
rag: None,
|
||||
graph_rags: Default::default(),
|
||||
model: Model::default(),
|
||||
vault: std::sync::Arc::new(Vault::default()),
|
||||
is_graph: false,
|
||||
enabled_tools: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RoleLike for Agent {
|
||||
@@ -918,6 +953,30 @@ fn resolve_document_paths(
|
||||
Ok(document_paths)
|
||||
}
|
||||
|
||||
/// How a graph rag node describes the knowledge base it wants built.
|
||||
///
|
||||
/// `driver` is forwarded as-is: `None` means the node did not ask for one, which
|
||||
/// `RagInitConfig` resolves to yaml, so workflows written before drivers existed
|
||||
/// keep their current storage.
|
||||
///
|
||||
/// Every field is now named explicitly, so adding one to `RagInitConfig` breaks
|
||||
/// this literal. That is deliberate: the new field then gets a decision about
|
||||
/// whether a rag node can drive it, instead of silently taking its default.
|
||||
fn rag_init_config(rag_node: &RagNode) -> RagInitConfig {
|
||||
RagInitConfig {
|
||||
embedding_model: rag_node.embedding_model.clone(),
|
||||
chunk_size: rag_node.chunk_size,
|
||||
chunk_overlap: rag_node.chunk_overlap,
|
||||
reranker_model: rag_node.reranker_model.clone(),
|
||||
top_k: rag_node.top_k,
|
||||
batch_size: rag_node.batch_size,
|
||||
extractor_model: rag_node.extractor_model.clone(),
|
||||
extractor_prompt: rag_node.extractor_prompt.clone(),
|
||||
graph_hops: rag_node.graph_hops,
|
||||
driver: rag_node.driver.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn init_graph_rags(
|
||||
app: &AppConfig,
|
||||
@@ -945,26 +1004,28 @@ async fn init_graph_rags(
|
||||
};
|
||||
let rag = if rag_path.exists() {
|
||||
let app_clone = app.clone();
|
||||
let vault_clone = app_state.vault.clone();
|
||||
let path_clone = rag_path.clone();
|
||||
let name_clone = node_id.clone();
|
||||
app_state
|
||||
.rag_cache
|
||||
.load_with(key, || async move {
|
||||
Rag::load(&app_clone, &name_clone, &path_clone)
|
||||
Rag::load_async(&app_clone, &vault_clone, &name_clone, &path_clone).await
|
||||
})
|
||||
.await?
|
||||
} else {
|
||||
let config = RagInitConfig {
|
||||
embedding_model: rag_node.embedding_model.clone(),
|
||||
chunk_size: rag_node.chunk_size,
|
||||
chunk_overlap: rag_node.chunk_overlap,
|
||||
reranker_model: rag_node.reranker_model.clone(),
|
||||
top_k: rag_node.top_k,
|
||||
batch_size: rag_node.batch_size,
|
||||
extractor_model: rag_node.extractor_model.clone(),
|
||||
extractor_prompt: rag_node.extractor_prompt.clone(),
|
||||
graph_hops: rag_node.graph_hops,
|
||||
};
|
||||
// Checked before anything is built: an unknown driver would otherwise
|
||||
// fall through `Rag::create`'s catch-all to a yaml store, embed every
|
||||
// document, and persist the bogus driver string. The RAG would then be
|
||||
// rejected on every subsequent load, leaving the agent unstartable.
|
||||
// Graph validation catches this too, but it is skipped when
|
||||
// `validate_before_run` is off, so this guard is the load-bearing one.
|
||||
if let Some(driver) = &rag_node.driver
|
||||
&& let Some(message) = crate::graph::validator::rag_driver_error(driver)
|
||||
{
|
||||
bail!("rag node '{node_id}': {message}");
|
||||
}
|
||||
let config = rag_init_config(rag_node);
|
||||
let fully_specified = config.embedding_model.is_some()
|
||||
&& config.chunk_size.is_some()
|
||||
&& config.chunk_overlap.is_some();
|
||||
@@ -1298,4 +1359,39 @@ version: "1.0"
|
||||
|
||||
assert_eq!(meta.description, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rag_init_config_forwards_an_explicit_driver() {
|
||||
let node: RagNode =
|
||||
serde_yaml::from_str("documents: [\"./docs\"]\ndriver: duckdb\n").unwrap();
|
||||
|
||||
assert_eq!(rag_init_config(&node).driver.as_deref(), Some("duckdb"));
|
||||
}
|
||||
|
||||
/// A node that names no driver must forward `None`, which `RagInitConfig`
|
||||
/// documents as "yaml". Existing workflows therefore keep their yaml store.
|
||||
#[test]
|
||||
fn rag_init_config_leaves_the_driver_unset_by_default() {
|
||||
let node: RagNode = serde_yaml::from_str("documents: [\"./docs\"]\n").unwrap();
|
||||
|
||||
assert_eq!(rag_init_config(&node).driver, None);
|
||||
}
|
||||
|
||||
/// The driver must ride alongside the rest of the node's settings, not
|
||||
/// replace them.
|
||||
#[test]
|
||||
fn rag_init_config_forwards_the_other_settings_too() {
|
||||
let node: RagNode = serde_yaml::from_str(
|
||||
"documents: [\"./docs\"]\ndriver: duckdb\nchunk_size: 512\nchunk_overlap: 64\ntop_k: 7\nembedding_model: some:model\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = rag_init_config(&node);
|
||||
|
||||
assert_eq!(config.driver.as_deref(), Some("duckdb"));
|
||||
assert_eq!(config.chunk_size, Some(512));
|
||||
assert_eq!(config.chunk_overlap, Some(64));
|
||||
assert_eq!(config.top_k, Some(7));
|
||||
assert_eq!(config.embedding_model.as_deref(), Some("some:model"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +321,10 @@ impl Input {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_session(&self) -> bool {
|
||||
self.with_session
|
||||
}
|
||||
|
||||
pub fn with_agent(&self) -> bool {
|
||||
self.with_agent
|
||||
}
|
||||
|
||||
@@ -947,6 +947,7 @@ fn print_secret_summary(added: &[String], deferred: &[String]) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sandbox::SANDBOX_ENV_FLAG;
|
||||
use crate::utils::get_env_name;
|
||||
use serial_test::serial;
|
||||
use std::env;
|
||||
@@ -1431,6 +1432,12 @@ mod tests {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
async fn merge_detects_missing_secrets_in_output() {
|
||||
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
||||
eprintln!(
|
||||
"Skipping merge_detects_missing_secrets_in_output: secret interpolation is disabled inside a sandbox"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let _guard = TestVaultConfigGuard::new("merge-secret");
|
||||
let dir = fresh_temp_dir("merge-secret-");
|
||||
let remote = dir.join("remote.json");
|
||||
|
||||
+94
-1
@@ -16,7 +16,7 @@ use anyhow::{Context, Result, anyhow, bail};
|
||||
use log::LevelFilter;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs::{read_dir, read_to_string};
|
||||
use std::fs::{read_dir, read_to_string, remove_file};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
@@ -414,6 +414,10 @@ pub fn list_rags() -> Vec<String> {
|
||||
for entry in rd.flatten() {
|
||||
let name = entry.file_name();
|
||||
if let Some(name) = name.to_string_lossy().strip_suffix(".yaml") {
|
||||
if is_rag_sidecar_name(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
names.push(name.to_string());
|
||||
}
|
||||
}
|
||||
@@ -424,6 +428,30 @@ pub fn list_rags() -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_rag_sidecar_name(name: &str) -> bool {
|
||||
name.ends_with(".sbx-mixin")
|
||||
}
|
||||
|
||||
pub(crate) fn remove_rag_sidecars(dir: &Path, name: &str) -> Result<()> {
|
||||
let duckdb_path = dir.join(format!("{name}.duckdb"));
|
||||
if duckdb_path.exists() {
|
||||
let _ = remove_file(&duckdb_path);
|
||||
}
|
||||
let mixin_path = dir.join(format!("{name}.sbx-mixin.yaml"));
|
||||
if mixin_path.exists() {
|
||||
remove_file(&mixin_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to remove the sandbox mixin for RAG '{name}' at '{}'. \
|
||||
The RAG was NOT deleted so you can retry; this host remains \
|
||||
whitelisted in the sandbox until the file is removed.",
|
||||
mixin_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_macros() -> Vec<String> {
|
||||
list_file_names(macros_dir(), ".yaml")
|
||||
}
|
||||
@@ -846,4 +874,69 @@ mod tests {
|
||||
}
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
fn sidecar_temp_dir(label: &str) -> PathBuf {
|
||||
let unique = time::SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let root = env::temp_dir().join(format!("coyote-{label}-test-{unique}"));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_rag_sidecar_name_accepts_dotted_rag_names() {
|
||||
assert!(!is_rag_sidecar_name("v2.docs"));
|
||||
assert!(!is_rag_sidecar_name("myrag"));
|
||||
assert!(is_rag_sidecar_name("myrag.sbx-mixin"));
|
||||
assert!(is_rag_sidecar_name("v2.docs.sbx-mixin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_rag_sidecars_removes_both() {
|
||||
let root = sidecar_temp_dir("rag-sidecars-both");
|
||||
let duckdb = root.join("docs.duckdb");
|
||||
let mixin = root.join("docs.sbx-mixin.yaml");
|
||||
fs::write(&duckdb, "db").unwrap();
|
||||
fs::write(&mixin, "mixin").unwrap();
|
||||
|
||||
remove_rag_sidecars(&root, "docs").unwrap();
|
||||
|
||||
assert!(!duckdb.exists(), "the .duckdb sidecar must be removed");
|
||||
assert!(
|
||||
!mixin.exists(),
|
||||
"the .sbx-mixin.yaml sidecar must be removed"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_rag_sidecars_is_ok_when_absent() {
|
||||
let root = sidecar_temp_dir("rag-sidecars-absent");
|
||||
assert!(remove_rag_sidecars(&root, "docs").is_ok());
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_rag_sidecars_runs_before_yaml_unlink() {
|
||||
let root = sidecar_temp_dir("rag-sidecars-order");
|
||||
let yaml = root.join("docs.yaml");
|
||||
fs::write(&yaml, "rag").unwrap();
|
||||
let mixin = root.join("docs.sbx-mixin.yaml");
|
||||
fs::create_dir_all(&mixin).unwrap();
|
||||
fs::write(mixin.join("blocker"), "x").unwrap();
|
||||
|
||||
let err = remove_rag_sidecars(&root, "docs").unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("Failed to remove the sandbox mixin"),
|
||||
"got: {err}"
|
||||
);
|
||||
assert!(
|
||||
yaml.exists(),
|
||||
"the .yaml must survive a sidecar-removal failure so the delete is retryable"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
+358
-32
@@ -38,6 +38,7 @@ use super::memory::{
|
||||
};
|
||||
use crate::graph;
|
||||
use anyhow::{Context, Error, Result, bail};
|
||||
use colored::Colorize;
|
||||
use gman::providers::SupportedProvider;
|
||||
#[cfg(test)]
|
||||
use indexmap::IndexMap;
|
||||
@@ -141,6 +142,7 @@ pub struct RequestContext {
|
||||
pub role: Option<Role>,
|
||||
pub session: Option<Session>,
|
||||
pub rag: Option<Arc<Rag>>,
|
||||
pub rag_key: Option<RagKey>,
|
||||
pub agent: Option<Agent>,
|
||||
|
||||
pub last_message: Option<LastMessage>,
|
||||
@@ -175,6 +177,7 @@ impl RequestContext {
|
||||
role: None,
|
||||
session: None,
|
||||
rag: None,
|
||||
rag_key: None,
|
||||
agent: None,
|
||||
last_message: None,
|
||||
tool_scope: ToolScope::default(),
|
||||
@@ -228,6 +231,7 @@ impl RequestContext {
|
||||
role: None,
|
||||
session: None,
|
||||
rag: None,
|
||||
rag_key: None,
|
||||
agent: None,
|
||||
last_message: None,
|
||||
tool_scope: ToolScope {
|
||||
@@ -276,6 +280,7 @@ impl RequestContext {
|
||||
role: self.role.clone(),
|
||||
session: self.session.clone(),
|
||||
rag: self.rag.clone(),
|
||||
rag_key: self.rag_key.clone(),
|
||||
agent: self.agent.clone(),
|
||||
last_message: self.last_message.clone(),
|
||||
tool_scope: self.tool_scope.clone(),
|
||||
@@ -314,6 +319,7 @@ impl RequestContext {
|
||||
role: None,
|
||||
session: None,
|
||||
rag: None,
|
||||
rag_key: None,
|
||||
agent: None,
|
||||
last_message: None,
|
||||
tool_scope: ToolScope {
|
||||
@@ -728,6 +734,26 @@ impl RequestContext {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn on_chat_completion_error(&mut self, app: &AppConfig, input: &Input) {
|
||||
self.last_message = Some(LastMessage::new(input.clone(), String::new()));
|
||||
if input.session(&self.session).is_none() {
|
||||
if let Some(lm) = self.last_message.as_mut() {
|
||||
lm.continuous = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let mut i = input.clone();
|
||||
i.clear_patch();
|
||||
if let Some(session) = i.session_mut(&mut self.session) {
|
||||
let _ = session.add_message(&i, "[Response interrupted due to error]");
|
||||
if !app.dry_run && session.save_session() == Some(true) {
|
||||
let _ = session.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn discontinuous_last_message(&mut self) {
|
||||
if let Some(last_message) = self.last_message.as_mut() {
|
||||
last_message.continuous = false;
|
||||
@@ -807,7 +833,12 @@ impl RequestContext {
|
||||
let mut role = if let Some(session) = self.session.as_ref() {
|
||||
session.to_role()
|
||||
} else if let Some(agent) = self.agent.as_ref() {
|
||||
agent.to_role()
|
||||
let mut role = agent.to_role();
|
||||
if role.reasoning_effort().is_none() {
|
||||
role.set_reasoning_effort(app.reasoning_effort.clone());
|
||||
}
|
||||
|
||||
role
|
||||
} else if let Some(role) = self.role.as_ref() {
|
||||
role.clone()
|
||||
} else {
|
||||
@@ -1086,10 +1117,17 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
fn concrete_tool_names(&self) -> Vec<String> {
|
||||
let declarations = match &self.agent {
|
||||
Some(agent) => agent.functions().declarations(),
|
||||
None => self.tool_scope.functions.declarations(),
|
||||
};
|
||||
let declarations: Vec<&FunctionDeclaration> = self
|
||||
.tool_scope
|
||||
.functions
|
||||
.declarations()
|
||||
.iter()
|
||||
.chain(
|
||||
self.agent
|
||||
.iter()
|
||||
.flat_map(|agent| agent.functions().declarations()),
|
||||
)
|
||||
.collect();
|
||||
declarations
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
@@ -2400,8 +2438,12 @@ impl RequestContext {
|
||||
|
||||
println!("Skills:");
|
||||
for (name, description, loaded) in entries {
|
||||
let marker = if loaded { " (loaded)" } else { "" };
|
||||
println!(" • {name}{marker} — {description}");
|
||||
let marker = if loaded {
|
||||
"✓".green().bold().to_string()
|
||||
} else {
|
||||
"✗".red().bold().to_string()
|
||||
};
|
||||
println!(" {marker} {name} — {description}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -2425,7 +2467,31 @@ impl RequestContext {
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
|
||||
print_asset_names("tools", &names)
|
||||
let active: HashSet<String> = if self.app.config.function_calling_support {
|
||||
let role = self.extract_role(&self.app.config)?;
|
||||
match self.select_functions(&role) {
|
||||
None => HashSet::new(),
|
||||
Some(functions) => functions.iter().map(|f| f.name.clone()).collect(),
|
||||
}
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
|
||||
if names.is_empty() {
|
||||
println!("No tools found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Tools:");
|
||||
for name in &names {
|
||||
let marker = if active.contains(name.as_str()) {
|
||||
"✓".green().bold().to_string()
|
||||
} else {
|
||||
"✗".red().bold().to_string()
|
||||
};
|
||||
println!(" {marker} {name}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
"mcp-servers" => {
|
||||
let mut names: Vec<String> = vec![];
|
||||
@@ -2442,7 +2508,32 @@ impl RequestContext {
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
|
||||
print_asset_names("MCP servers", &names)
|
||||
if names.is_empty() {
|
||||
println!("No MCP servers found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let enabled: Option<Vec<String>> = if let Some(session) = &self.session {
|
||||
session.enabled_mcp_servers()
|
||||
} else if let Some(role) = &self.role {
|
||||
role.enabled_mcp_servers()
|
||||
} else {
|
||||
self.app.config.enabled_mcp_servers.clone()
|
||||
};
|
||||
let skill_mcps = self.skill_registry.loaded_mcp_servers();
|
||||
|
||||
println!("MCP servers:");
|
||||
for name in &names {
|
||||
let active = skill_mcps.contains(name.as_str())
|
||||
|| matches!(&enabled, Some(list) if list.iter().any(|s| s.trim() == "all") || self.mcp_list_covers(list, name));
|
||||
let marker = if active {
|
||||
"✓".green().bold().to_string()
|
||||
} else {
|
||||
"✗".red().bold().to_string()
|
||||
};
|
||||
println!(" {marker} {name}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => bail!(
|
||||
"Unknown kind '{kind}'. Valid kinds: roles, sessions, agents, rags, macros, skills, tools, mcp-servers"
|
||||
@@ -2468,6 +2559,14 @@ impl RequestContext {
|
||||
match file_ext {
|
||||
Some(file_ext) => {
|
||||
if let Some(name) = name.to_string_lossy().strip_suffix(file_ext) {
|
||||
// Sidecars are not independently deletable assets.
|
||||
// Guarded on `kind == "rag"` because this scan is shared
|
||||
// by all six kinds, and `session`/`macro` also use
|
||||
// `.yaml`. The helper lives in paths.rs beside
|
||||
// list_rags() so both filters cannot drift apart.
|
||||
if kind == "rag" && paths::is_rag_sidecar_name(name) {
|
||||
continue;
|
||||
}
|
||||
names.push(name.to_string());
|
||||
}
|
||||
}
|
||||
@@ -2504,6 +2603,13 @@ impl RequestContext {
|
||||
match file_ext {
|
||||
Some(ext) => {
|
||||
let path = dir.join(format!("{name}{ext}"));
|
||||
// Sidecars FIRST. If this fails, the .yaml is still on disk, the
|
||||
// RAG is still listed, and the user can retry. Unlinking the .yaml
|
||||
// first would make the deletion unretryable while leaving an
|
||||
// orphaned mixin whitelisting a host in every sandbox launch.
|
||||
if kind == "rag" {
|
||||
paths::remove_rag_sidecars(&dir, &name)?;
|
||||
}
|
||||
remove_file(&path).with_context(|| {
|
||||
format!("Failed to delete {kind} at '{}'", path.display())
|
||||
})?;
|
||||
@@ -2687,7 +2793,12 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
"rag_top_k" => {
|
||||
let value = value.parse().with_context(|| "Invalid value")?;
|
||||
let value: usize = value.parse().with_context(|| "Invalid value")?;
|
||||
if value == 0 {
|
||||
bail!(
|
||||
"rag_top_k must be >= 1; a top_k of 0 makes every query return no results."
|
||||
);
|
||||
}
|
||||
if !self.set_rag_top_k(value)? {
|
||||
self.update_app_config(|app| app.rag_top_k = value);
|
||||
}
|
||||
@@ -3638,6 +3749,14 @@ impl RequestContext {
|
||||
.then(|| Arc::new(RwLock::new(Supervisor::new(max_concurrent, max_depth))));
|
||||
|
||||
self.rag = agent.rag();
|
||||
// Keep `rag_key` in lockstep with `rag`. Agent RAGs are cached under
|
||||
// `RagKey::Agent(<agent name>)` (see `Agent::init`), so mirror that key exactly;
|
||||
// leaving the previous key in place would let `.rebuild rag` invalidate an
|
||||
// unrelated RAG's cache entry, and leaving it `None` would invalidate nothing.
|
||||
self.rag_key = self
|
||||
.rag
|
||||
.is_some()
|
||||
.then(|| RagKey::Agent(agent.name().to_string()));
|
||||
self.agent = Some(agent);
|
||||
self.supervisor = supervisor;
|
||||
self.inbox = None;
|
||||
@@ -3686,6 +3805,11 @@ impl RequestContext {
|
||||
self.pending_agents_guardrail_count = 0;
|
||||
self.todo_list = TodoList::default();
|
||||
self.rag.take();
|
||||
// Cleared alongside `rag` so the pair never disagrees: an agent RAG is
|
||||
// cached under `RagKey::Agent(<agent name>)`, and leaving that key behind
|
||||
// would outlive the RAG it names. Latent rather than live today only
|
||||
// because `rebuild_rag`/`edit_rag_docs` bail on `rag.is_none()` first.
|
||||
self.rag_key = None;
|
||||
self.discontinuous_last_message();
|
||||
}
|
||||
Ok(())
|
||||
@@ -3993,10 +4117,11 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
let app = self.app.config.clone();
|
||||
let vault = self.app.vault.clone();
|
||||
let rag_cache = self.rag_cache();
|
||||
let working_mode = self.working_mode;
|
||||
|
||||
let rag: Arc<Rag> = match rag {
|
||||
let (rag, rag_key): (Arc<Rag>, Option<RagKey>) = match rag {
|
||||
None => {
|
||||
let rag_path = self.rag_file(super::TEMP_RAG_NAME);
|
||||
if rag_path.exists() {
|
||||
@@ -4004,15 +4129,29 @@ impl RequestContext {
|
||||
format!("Failed to cleanup previous '{}' rag", super::TEMP_RAG_NAME)
|
||||
})?;
|
||||
}
|
||||
Arc::new(Rag::init(&app, super::TEMP_RAG_NAME, &rag_path, &[], abort_signal).await?)
|
||||
(
|
||||
Arc::new(
|
||||
Rag::init(
|
||||
&app,
|
||||
super::TEMP_RAG_NAME,
|
||||
&rag_path,
|
||||
&[],
|
||||
abort_signal,
|
||||
false,
|
||||
)
|
||||
.await?,
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
Some(name) => {
|
||||
let rag_path = self.rag_file(name);
|
||||
let key = RagKey::Named(name.to_string());
|
||||
|
||||
rag_cache
|
||||
.load_with(key, || {
|
||||
let loaded = rag_cache
|
||||
.load_with(key.clone(), || {
|
||||
let app = app.clone();
|
||||
let vault = vault.clone();
|
||||
let rag_path = rag_path.clone();
|
||||
let abort_signal = abort_signal.clone();
|
||||
async move {
|
||||
@@ -4020,16 +4159,39 @@ impl RequestContext {
|
||||
if working_mode.is_cmd() {
|
||||
bail!("Unknown RAG '{name}'");
|
||||
}
|
||||
Rag::init(&app, name, &rag_path, &[], abort_signal.clone()).await
|
||||
Rag::init(&app, name, &rag_path, &[], abort_signal.clone(), true)
|
||||
.await
|
||||
} else {
|
||||
Rag::load(&app, name, &rag_path)
|
||||
Rag::load_async(&app, &vault, name, &rag_path).await
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?
|
||||
.await?;
|
||||
(loaded, Some(key))
|
||||
}
|
||||
};
|
||||
self.rag = Some(rag);
|
||||
self.rag_key = rag_key;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn attach_rag(&mut self, name: &str) -> Result<()> {
|
||||
let rag_path = self.rag_file(name);
|
||||
if rag_path.exists() {
|
||||
bail!(
|
||||
"RAG '{name}' already exists at '{}'. \
|
||||
Use a different name, or delete the existing file first.",
|
||||
rag_path.display()
|
||||
);
|
||||
}
|
||||
let app = self.app.config.as_ref();
|
||||
let vault = self.app.vault.clone();
|
||||
let rag = Rag::attach(app, &vault, name, &rag_path).await?;
|
||||
let rag = Arc::new(rag);
|
||||
let key = RagKey::Named(name.to_string());
|
||||
self.rag_cache().insert(key.clone(), &rag);
|
||||
self.rag = Some(rag);
|
||||
self.rag_key = Some(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4039,6 +4201,12 @@ impl RequestContext {
|
||||
None => bail!("No RAG"),
|
||||
};
|
||||
|
||||
if rag.is_attached() {
|
||||
bail!(
|
||||
"Cannot edit documents on an attached RAG; Coyote does not own its source documents."
|
||||
);
|
||||
}
|
||||
|
||||
let document_paths = rag.document_paths();
|
||||
let temp_file = temp_file(&format!("-rag-{}", rag.name()), ".txt");
|
||||
tokio::fs::write(&temp_file, &document_paths.join("\n"))
|
||||
@@ -4064,15 +4232,18 @@ impl RequestContext {
|
||||
bail!("No changes")
|
||||
}
|
||||
|
||||
let key = if self.agent.is_some() {
|
||||
RagKey::Agent(rag.name().to_string())
|
||||
} else {
|
||||
RagKey::Named(rag.name().to_string())
|
||||
};
|
||||
self.rag_cache().invalidate(&key);
|
||||
if let Some(key) = self.rag_key.clone() {
|
||||
self.rag_cache().invalidate(&key);
|
||||
}
|
||||
|
||||
rag.refresh_document_paths(&new_document_paths, false, &self.app.config, abort_signal)
|
||||
.await?;
|
||||
rag.refresh_document_paths(
|
||||
&new_document_paths,
|
||||
false,
|
||||
false,
|
||||
&self.app.config,
|
||||
abort_signal,
|
||||
)
|
||||
.await?;
|
||||
self.rag = Some(Arc::new(rag));
|
||||
Ok(())
|
||||
}
|
||||
@@ -4083,15 +4254,25 @@ impl RequestContext {
|
||||
None => bail!("No RAG"),
|
||||
};
|
||||
|
||||
let key = if self.agent.is_some() {
|
||||
RagKey::Agent(rag.name().to_string())
|
||||
} else {
|
||||
RagKey::Named(rag.name().to_string())
|
||||
};
|
||||
self.rag_cache().invalidate(&key);
|
||||
if rag.is_attached() {
|
||||
bail!(
|
||||
"Cannot rebuild an attached RAG; Coyote does not own its source documents. \
|
||||
Re-index from the system that originally created '{}'.",
|
||||
rag.name()
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(key) = self.rag_key.clone() {
|
||||
self.rag_cache().invalidate(&key);
|
||||
}
|
||||
|
||||
let document_paths = rag.document_paths().to_vec();
|
||||
rag.refresh_document_paths(&document_paths, true, &self.app.config, abort_signal)
|
||||
println!(
|
||||
"Rebuilding re-embeds every document ({} files). \
|
||||
This will call the embedding API and may take a while.",
|
||||
rag.file_count()
|
||||
);
|
||||
rag.refresh_document_paths(&document_paths, true, true, &self.app.config, abort_signal)
|
||||
.await?;
|
||||
self.rag = Some(Arc::new(rag));
|
||||
Ok(())
|
||||
@@ -4114,6 +4295,7 @@ mod tests {
|
||||
use super::super::mcp_factory::McpFactory;
|
||||
use super::*;
|
||||
use crate::config::AppState;
|
||||
use crate::config::agent::AgentConfig;
|
||||
use crate::function::{ToolCall, skill};
|
||||
use crate::mcp::{McpServer, McpServersConfig, McpTransportType};
|
||||
use crate::utils;
|
||||
@@ -4297,6 +4479,42 @@ mod tests {
|
||||
assert_eq!(extracted.name(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_role_agent_without_reasoning_effort_inherits_app_config() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.agent = Some(Agent::test_new(AgentConfig {
|
||||
name: "test-agent".to_string(),
|
||||
reasoning_effort: None,
|
||||
..AgentConfig::default()
|
||||
}));
|
||||
let app = AppConfig {
|
||||
reasoning_effort: Some("max".to_string()),
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
let extracted = ctx.extract_role(&app).unwrap();
|
||||
|
||||
assert_eq!(extracted.reasoning_effort(), Some("max".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_role_agent_with_explicit_reasoning_effort_takes_priority_over_app_config() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.agent = Some(Agent::test_new(AgentConfig {
|
||||
name: "test-agent".to_string(),
|
||||
reasoning_effort: Some("low".to_string()),
|
||||
..AgentConfig::default()
|
||||
}));
|
||||
let app = AppConfig {
|
||||
reasoning_effort: Some("max".to_string()),
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
let extracted = ctx.extract_role(&app).unwrap();
|
||||
|
||||
assert_eq!(extracted.reasoning_effort(), Some("low".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_inject_skill_instructions_requires_function_calling() {
|
||||
let app = AppConfig {
|
||||
@@ -4460,6 +4678,44 @@ mod tests {
|
||||
|
||||
assert!(ctx.agent.is_none());
|
||||
assert!(ctx.rag.is_none());
|
||||
assert_eq!(ctx.rag_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn use_agent_does_not_carry_stale_rag_key() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let mut ctx = create_test_ctx();
|
||||
let app = ctx.app.config.clone();
|
||||
let agent_name = format!(
|
||||
"test_agent_{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let agent_dir = paths::agent_data_dir(&agent_name);
|
||||
create_dir_all(&agent_dir).unwrap();
|
||||
write(
|
||||
agent_dir.join("config.yaml"),
|
||||
format!("name: {agent_name}\ninstructions: hi\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
ctx.rag_key = Some(RagKey::Named("docs".to_string()));
|
||||
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
ctx.use_agent(&app, &agent_name, None, utils::create_abort_signal())
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
assert!(ctx.rag.is_none());
|
||||
assert_eq!(ctx.rag_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5424,6 +5680,54 @@ mod tests {
|
||||
assert!(lm.continuous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_chat_completion_error_without_session_sets_last_message_discontinuous() {
|
||||
let mut ctx = create_test_ctx();
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
let input = Input::from_str(&ctx, "hello", None).unwrap();
|
||||
|
||||
ctx.on_chat_completion_error(app.as_ref(), &input);
|
||||
|
||||
let lm = ctx.last_message.as_ref().unwrap();
|
||||
assert_eq!(lm.output, "");
|
||||
assert!(!lm.continuous, "no session means recovery is not possible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_chat_completion_error_with_session_sets_last_message_continuous() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.app = Arc::new(AppState {
|
||||
config: Arc::new(AppConfig {
|
||||
dry_run: true,
|
||||
..(*ctx.app.config).clone()
|
||||
}),
|
||||
..(*ctx.app).clone()
|
||||
});
|
||||
ctx.session = Some(Session::default());
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
let input = Input::from_str(&ctx, "hello", None).unwrap();
|
||||
|
||||
ctx.on_chat_completion_error(app.as_ref(), &input);
|
||||
|
||||
let lm = ctx.last_message.as_ref().unwrap();
|
||||
assert_eq!(lm.output, "");
|
||||
assert!(lm.continuous, "session present means .recover is available");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_chat_completion_error_with_session_checkpoints_session_messages() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.session = Some(Session::default());
|
||||
assert!(ctx.session.as_ref().unwrap().is_empty());
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
let input = Input::from_str(&ctx, "hello", None).unwrap();
|
||||
ctx.on_chat_completion_error(app.as_ref(), &input);
|
||||
assert!(
|
||||
!ctx.session.as_ref().unwrap().is_empty(),
|
||||
"session should have the interrupted turn checkpointed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_chat_completion_sweeps_auto_unload_skills_at_turn_end() {
|
||||
let mut ctx = create_test_ctx();
|
||||
@@ -5799,6 +6103,28 @@ mod tests {
|
||||
assert!(paths::list_rags().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn list_rags_skips_sbx_mixin_sidecars() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let rags_dir = paths::rags_dir();
|
||||
create_dir_all(&rags_dir).unwrap();
|
||||
write(rags_dir.join("docs.yaml"), "embedding_model: test").unwrap();
|
||||
write(rags_dir.join("docs.sbx-mixin.yaml"), "kind: mixin").unwrap();
|
||||
write(rags_dir.join("v2.docs.yaml"), "embedding_model: test").unwrap();
|
||||
|
||||
let names = paths::list_rags();
|
||||
assert!(names.contains(&"docs".to_string()));
|
||||
assert!(
|
||||
names.contains(&"v2.docs".to_string()),
|
||||
"a dotted RAG name must still be listed: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"docs.sbx-mixin".to_string()),
|
||||
"the sandbox mixin sidecar must not appear as a RAG: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn use_agent_errors_when_already_in_session() {
|
||||
|
||||
@@ -699,6 +699,19 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
if !self.dirty {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(path) = self.path.clone() {
|
||||
let name = self.name.clone();
|
||||
self.save(&name, Path::new(&path), false)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn guard_empty(&self) -> Result<()> {
|
||||
if !self.is_empty() {
|
||||
bail!(
|
||||
|
||||
+44
-2
@@ -88,11 +88,21 @@ async fn run_one_shot(prompt: &str, ctx: &mut RequestContext) -> Result<String>
|
||||
}
|
||||
|
||||
fn try_parse_json(raw: &str) -> Option<Value> {
|
||||
let cleaned = strip_code_fences(raw.trim());
|
||||
|
||||
let cleaned = strip_code_fences(strip_thinking_blocks(raw.trim()));
|
||||
serde_json::from_str(cleaned).ok()
|
||||
}
|
||||
|
||||
fn strip_thinking_blocks(s: &str) -> &str {
|
||||
let mut s = s.trim_start();
|
||||
while s.starts_with("<think>") {
|
||||
match s.find("</think>") {
|
||||
Some(end) => s = s[end + "</think>".len()..].trim_start(),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn strip_code_fences(s: &str) -> &str {
|
||||
let after_open = s
|
||||
.strip_prefix("```json")
|
||||
@@ -148,6 +158,38 @@ mod tests {
|
||||
assert_eq!(v, json!({"x": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_parse_json_strips_thinking_blocks() {
|
||||
let raw = "<think>\nsome reasoning\n</think>\n{\"a\": 1}";
|
||||
|
||||
let v = try_parse_json(raw).unwrap();
|
||||
|
||||
assert_eq!(v, json!({"a": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_parse_json_strips_empty_thinking_block() {
|
||||
let raw = "<think>\n\n</think>\n{\"a\": 1}";
|
||||
|
||||
let v = try_parse_json(raw).unwrap();
|
||||
|
||||
assert_eq!(v, json!({"a": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_parse_json_strips_multiple_thinking_blocks() {
|
||||
let raw = "<think>first</think>\n<think>second</think>\n{\"a\": 1}";
|
||||
|
||||
let v = try_parse_json(raw).unwrap();
|
||||
|
||||
assert_eq!(v, json!({"a": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_parse_json_unclosed_think_tag_returns_none() {
|
||||
assert!(try_parse_json("<think>unclosed {\"a\": 1}").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_parse_json_returns_none_on_prose() {
|
||||
assert!(try_parse_json("Here is the result: it's good").is_none());
|
||||
|
||||
@@ -367,6 +367,13 @@ pub struct RagNode {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub graph_hops: Option<usize>,
|
||||
|
||||
/// Storage driver for this node's knowledge base ("yaml", "duckdb"). `None`
|
||||
/// means "yaml". Only honored when the knowledge base is first built;
|
||||
/// changing it afterwards has no effect until the RAG is deleted and
|
||||
/// re-initialized.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub driver: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub state_updates: Option<HashMap<String, String>>,
|
||||
|
||||
@@ -1152,4 +1159,100 @@ nodes:
|
||||
assert!(triage.next.as_ref().unwrap().is_fan_out());
|
||||
assert_eq!(triage.next.as_ref().unwrap().as_slice().len(), 2);
|
||||
}
|
||||
|
||||
fn rag_node_of(graph: &Graph, id: &str) -> RagNode {
|
||||
match &graph.get_node(id).unwrap().node_type {
|
||||
NodeType::Rag(r) => r.clone(),
|
||||
other => panic!("expected a rag node, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rag_node_deserializes_an_explicit_driver() {
|
||||
let yaml = r#"
|
||||
name: kb
|
||||
start: research
|
||||
nodes:
|
||||
research:
|
||||
type: rag
|
||||
documents: ["./docs"]
|
||||
driver: duckdb
|
||||
next: done
|
||||
done:
|
||||
type: end
|
||||
output: ok
|
||||
"#;
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rag_node_of(&graph, "research").driver.as_deref(),
|
||||
Some("duckdb")
|
||||
);
|
||||
}
|
||||
|
||||
/// Workflows written before drivers existed must keep parsing, and must keep
|
||||
/// asking for nothing, so `RagInitConfig` resolves them to the yaml default.
|
||||
#[test]
|
||||
fn rag_node_without_a_driver_stays_unset() {
|
||||
let yaml = r#"
|
||||
name: kb
|
||||
start: research
|
||||
nodes:
|
||||
research:
|
||||
type: rag
|
||||
documents: ["./docs"]
|
||||
next: done
|
||||
done:
|
||||
type: end
|
||||
output: ok
|
||||
"#;
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
assert_eq!(rag_node_of(&graph, "research").driver, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rag_node_driver_survives_a_serialize_round_trip() {
|
||||
let yaml = r#"
|
||||
name: kb
|
||||
start: research
|
||||
nodes:
|
||||
research:
|
||||
type: rag
|
||||
documents: ["./docs"]
|
||||
driver: duckdb
|
||||
next: done
|
||||
done:
|
||||
type: end
|
||||
output: ok
|
||||
"#;
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
let reparsed: Graph =
|
||||
serde_yaml::from_str(&serde_yaml::to_string(&graph).unwrap()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rag_node_of(&reparsed, "research").driver.as_deref(),
|
||||
Some("duckdb")
|
||||
);
|
||||
}
|
||||
|
||||
/// `skip_serializing_if` must keep `driver:` out of graphs that never set it.
|
||||
#[test]
|
||||
fn rag_node_without_a_driver_omits_the_key_when_serialized() {
|
||||
let yaml = r#"
|
||||
name: kb
|
||||
start: research
|
||||
nodes:
|
||||
research:
|
||||
type: rag
|
||||
documents: ["./docs"]
|
||||
next: done
|
||||
done:
|
||||
type: end
|
||||
output: ok
|
||||
"#;
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
assert!(!serde_yaml::to_string(&graph).unwrap().contains("driver"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::state::template_root_keys;
|
||||
use super::types::{Graph, Node, NodeType};
|
||||
use crate::client::{Model, ModelType};
|
||||
use crate::config::{Agent, AppConfig, paths};
|
||||
use crate::rag::{GraphRagConfig, RagData};
|
||||
use anyhow::{Result, bail};
|
||||
use std::collections::{BTreeMap, HashSet, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
@@ -96,6 +97,51 @@ pub struct GraphValidator {
|
||||
skill_exists: fn(&str) -> bool,
|
||||
}
|
||||
|
||||
/// A minimal `RagData` whose only interesting field is `driver`. The numeric
|
||||
/// arguments are the smallest values that satisfy `validate()`'s unrelated
|
||||
/// floors (top_k >= 1, and chunk_size >= 1 with chunk_overlap < chunk_size for
|
||||
/// a non-attached RAG). `RagData::new` sets `attached: false`, which is the
|
||||
/// correct shape here: a graph rag node always builds its own local knowledge
|
||||
/// base from `documents` and can never be attached.
|
||||
fn rag_driver_probe(driver: &str) -> RagData {
|
||||
let mut data = RagData::new(
|
||||
String::new(),
|
||||
1,
|
||||
0,
|
||||
None,
|
||||
1,
|
||||
None,
|
||||
GraphRagConfig::default(),
|
||||
);
|
||||
data.driver = driver.to_string();
|
||||
data
|
||||
}
|
||||
|
||||
/// `Some(message)` when `driver` is one that `RagData::validate()` would reject.
|
||||
///
|
||||
/// The set of valid drivers is defined in exactly one place, `RagData::validate()`,
|
||||
/// so this asks that function rather than restating the list here.
|
||||
///
|
||||
/// Fails open on purpose: the first probe below uses the default driver, which is
|
||||
/// valid by definition. If even that one is rejected, `validate()` has grown a
|
||||
/// precondition the probe fixture no longer satisfies, and every verdict from here
|
||||
/// would be a false positive that rejects working graphs. In that case we decline
|
||||
/// to judge and leave enforcement to RAG construction. The
|
||||
/// `rag_driver_probe_fixture_is_accepted` test turns that silent degradation into a
|
||||
/// loud failure. Both `validate()` calls are load-bearing; neither is redundant.
|
||||
pub(crate) fn rag_driver_error(driver: &str) -> Option<String> {
|
||||
if rag_driver_probe(&RagData::default().driver)
|
||||
.validate()
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
rag_driver_probe(driver)
|
||||
.validate()
|
||||
.err()
|
||||
.map(|err| err.to_string())
|
||||
}
|
||||
|
||||
impl GraphValidator {
|
||||
pub fn new(base_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
@@ -154,6 +200,11 @@ impl GraphValidator {
|
||||
not be written to state",
|
||||
));
|
||||
}
|
||||
if let Some(driver) = &r.driver
|
||||
&& let Some(message) = rag_driver_error(driver)
|
||||
{
|
||||
result.error(ValidationError::with_node(node_id, message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1031,6 +1082,7 @@ mod tests {
|
||||
extractor_model: None,
|
||||
extractor_prompt: None,
|
||||
graph_hops: None,
|
||||
driver: None,
|
||||
state_updates,
|
||||
timeout: None,
|
||||
}),
|
||||
@@ -1385,6 +1437,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Guards the fail-open branch in `rag_driver_error`. If this fails,
|
||||
/// `RagData::validate()` grew a precondition the probe fixture no longer
|
||||
/// satisfies and rag-node driver validation has silently switched itself off.
|
||||
/// Repair the fixture in `rag_driver_probe`; do not delete this test.
|
||||
#[test]
|
||||
fn rag_driver_probe_fixture_is_accepted() {
|
||||
let default_driver = RagData::default().driver;
|
||||
assert!(
|
||||
rag_driver_probe(&default_driver).validate().is_ok(),
|
||||
"probe fixture rejected for the default driver '{default_driver}'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rag_driver_error_defers_to_ragdata_validate() {
|
||||
assert_eq!(rag_driver_error("yaml"), None);
|
||||
assert_eq!(rag_driver_error("duckdb"), None);
|
||||
|
||||
let message = rag_driver_error("duckdbb").expect("unknown driver must be rejected");
|
||||
assert!(message.contains("duckdbb"), "got: {message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rag_node_with_unknown_driver_errors_naming_the_node() {
|
||||
let mut node = rag_node("kb", &["./docs"], true);
|
||||
if let NodeType::Rag(ref mut r) = node.node_type {
|
||||
r.driver = Some("postgres".into());
|
||||
}
|
||||
let graph = graph_with(vec![("kb", node), ("end", end_node("end"))], "kb");
|
||||
|
||||
let result = validator().validate(&graph);
|
||||
|
||||
assert!(!result.is_valid());
|
||||
let err = result.into_result().unwrap_err().to_string();
|
||||
assert!(err.contains("[kb]"), "must name the node: {err}");
|
||||
assert!(err.contains("postgres"), "must name the driver: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rag_node_with_duckdb_driver_produces_no_findings() {
|
||||
let mut node = rag_node("kb", &["./docs"], true);
|
||||
if let NodeType::Rag(ref mut r) = node.node_type {
|
||||
r.driver = Some("duckdb".into());
|
||||
}
|
||||
let graph = graph_with(vec![("kb", node), ("end", end_node("end"))], "kb");
|
||||
|
||||
assert!(validator().validate(&graph).is_valid());
|
||||
}
|
||||
|
||||
fn agent_node(id: &str, agent: &str, next: Option<&str>) -> Node {
|
||||
Node {
|
||||
id: id.into(),
|
||||
|
||||
+322
-44
@@ -12,6 +12,8 @@ use url::Url;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProtectedResourceMetadata {
|
||||
#[serde(default)]
|
||||
resource: Option<String>,
|
||||
#[serde(default)]
|
||||
authorization_servers: Vec<String>,
|
||||
#[serde(default)]
|
||||
@@ -30,6 +32,13 @@ struct OAuthServerMetadata {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct McpRegistration {
|
||||
client_id: String,
|
||||
#[serde(default)]
|
||||
redirect_uri: Option<String>,
|
||||
}
|
||||
|
||||
struct DiscoveredOAuth {
|
||||
metadata: OAuthServerMetadata,
|
||||
resource: Option<String>,
|
||||
}
|
||||
|
||||
struct McpOAuthProvider {
|
||||
@@ -38,6 +47,7 @@ struct McpOAuthProvider {
|
||||
token_url: String,
|
||||
scopes: String,
|
||||
fixed_redirect: String,
|
||||
resource: String,
|
||||
}
|
||||
|
||||
impl OAuthProvider for McpOAuthProvider {
|
||||
@@ -76,6 +86,14 @@ impl OAuthProvider for McpOAuthProvider {
|
||||
fn fixed_redirect_uri(&self) -> Option<String> {
|
||||
Some(self.fixed_redirect.clone())
|
||||
}
|
||||
|
||||
fn extra_authorize_params(&self) -> Vec<(&str, &str)> {
|
||||
vec![("resource", self.resource.as_str())]
|
||||
}
|
||||
|
||||
fn extra_token_params(&self) -> Vec<(&str, &str)> {
|
||||
vec![("resource", self.resource.as_str())]
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_mcp_oauth_flow(
|
||||
@@ -85,36 +103,57 @@ pub async fn run_mcp_oauth_flow(
|
||||
callback_port: Option<u16>,
|
||||
redirect_host: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let metadata = discover_oauth_metadata(server_url).await?;
|
||||
let discovered = discover_oauth_metadata(server_url).await?;
|
||||
let metadata = discovered.metadata;
|
||||
let resource = resolve_resource(discovered.resource, server_url)?;
|
||||
|
||||
let host = redirect_host.unwrap_or("127.0.0.1");
|
||||
let bind_addr = format!("127.0.0.1:{}", callback_port.unwrap_or(0));
|
||||
let listener = TcpListener::bind(&bind_addr)?;
|
||||
let port = listener.local_addr()?.port();
|
||||
drop(listener);
|
||||
let redirect_uri = format!("http://{host}:{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")?
|
||||
}
|
||||
}
|
||||
// Reuse a cached dynamic registration together with the exact redirect
|
||||
// URI it was registered with (AWS et al. match redirect URIs exactly).
|
||||
// Only when no client_id is configured explicitly.
|
||||
let cached_reuse: Option<(String, String)> = if configured_client_id.is_none() {
|
||||
load_registration(server_name).and_then(|reg| {
|
||||
let redirect = reg.redirect_uri?;
|
||||
let port = cached_redirect_port(&redirect, host, callback_port)?;
|
||||
// The registered port must still be free for our callback listener.
|
||||
TcpListener::bind(format!("127.0.0.1:{port}")).ok()?;
|
||||
Some((reg.client_id, redirect))
|
||||
})
|
||||
} else {
|
||||
Text::new("Enter the OAuth client ID for this MCP server:")
|
||||
.prompt()
|
||||
.context("Failed to read client ID")?
|
||||
None
|
||||
};
|
||||
|
||||
let (client_id, redirect_uri) = if let Some(reused) = cached_reuse {
|
||||
reused
|
||||
} else {
|
||||
let bind_addr = format!("127.0.0.1:{}", callback_port.unwrap_or(0));
|
||||
let listener = TcpListener::bind(&bind_addr)?;
|
||||
let port = listener.local_addr()?.port();
|
||||
drop(listener);
|
||||
let redirect_uri = format!("http://{host}:{port}/callback");
|
||||
|
||||
let client_id = if let Some(id) = configured_client_id {
|
||||
id.to_string()
|
||||
} else if let Some(reg_endpoint) = &metadata.registration_endpoint {
|
||||
match register_client(reg_endpoint, &redirect_uri).await {
|
||||
Ok(id) => {
|
||||
let _ = save_registration(server_name, &id, &redirect_uri);
|
||||
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")?
|
||||
};
|
||||
(client_id, redirect_uri)
|
||||
};
|
||||
|
||||
let provider = McpOAuthProvider {
|
||||
@@ -123,6 +162,7 @@ pub async fn run_mcp_oauth_flow(
|
||||
token_url: metadata.token_endpoint,
|
||||
scopes: metadata.scopes_supported.join(" "),
|
||||
fixed_redirect: redirect_uri,
|
||||
resource,
|
||||
};
|
||||
|
||||
run_oauth_flow(&provider, &mcp_token_key(server_name)).await
|
||||
@@ -141,21 +181,20 @@ fn mcp_token_key(server_name: &str) -> String {
|
||||
format!("mcp_{server_name}")
|
||||
}
|
||||
|
||||
fn load_registered_client_id(server_name: &str) -> Option<String> {
|
||||
fn load_registration(server_name: &str) -> Option<McpRegistration> {
|
||||
let path = paths::oauth_tokens_dir().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)
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
fn save_registered_client_id(server_name: &str, client_id: &str) -> Result<()> {
|
||||
fn save_registration(server_name: &str, client_id: &str, redirect_uri: &str) -> Result<()> {
|
||||
let dir = paths::oauth_tokens_dir();
|
||||
fs::create_dir_all(&dir)?;
|
||||
|
||||
let path = dir.join(format!("mcp_{server_name}_registration.json"));
|
||||
let reg = McpRegistration {
|
||||
client_id: client_id.to_string(),
|
||||
redirect_uri: Some(redirect_uri.to_string()),
|
||||
};
|
||||
|
||||
fs::write(path, serde_json::to_string_pretty(®)?)?;
|
||||
@@ -163,6 +202,27 @@ fn save_registered_client_id(server_name: &str, client_id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the port of a cached registered redirect URI if it is still
|
||||
/// compatible with the current configuration: same redirect host, and, when
|
||||
/// a callback port is pinned in config, the same port. Servers like AWS
|
||||
/// match redirect URIs exactly, so a cached registration is only reusable
|
||||
/// with the identical redirect URI it was registered with.
|
||||
fn cached_redirect_port(
|
||||
cached_redirect: &str,
|
||||
host: &str,
|
||||
pinned_port: Option<u16>,
|
||||
) -> Option<u16> {
|
||||
let url = Url::parse(cached_redirect).ok()?;
|
||||
if url.host_str() != Some(host) {
|
||||
return None;
|
||||
}
|
||||
let port = url.port()?;
|
||||
if pinned_port.is_some_and(|p| p != port) {
|
||||
return None;
|
||||
}
|
||||
Some(port)
|
||||
}
|
||||
|
||||
async fn register_client(endpoint: &str, redirect_uri: &str) -> Result<String> {
|
||||
let body = serde_json::json!({
|
||||
"client_name": "Coyote",
|
||||
@@ -188,7 +248,44 @@ async fn register_client(endpoint: &str, redirect_uri: &str) -> Result<String> {
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> {
|
||||
/// Derives the canonical resource URI for an MCP server per RFC 8707 @ 2 and
|
||||
/// the MCP spec: the configured server URL with query and fragment stripped.
|
||||
fn canonical_resource(server_url: &str) -> Result<String> {
|
||||
let mut url =
|
||||
Url::parse(server_url).with_context(|| format!("Invalid MCP server URL: {server_url}"))?;
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
|
||||
let s = url.to_string();
|
||||
Ok(match url.path() {
|
||||
"/" => s.trim_end_matches('/').to_string(),
|
||||
_ => s,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves the RFC 8707 resource indicator: prefers the value advertised in
|
||||
/// the protected resource metadata, but only after validating it identifies
|
||||
/// the server we are connecting to (RFC 9728 @ 3.3); same scheme/host/port
|
||||
/// as the configured server URL. Falls back to the canonical server URL on
|
||||
/// mismatch, empty value, or absence.
|
||||
fn resolve_resource(advertised: Option<String>, server_url: &str) -> Result<String> {
|
||||
let canonical = canonical_resource(server_url)?;
|
||||
let Some(advertised) = advertised.filter(|r| !r.is_empty()) else {
|
||||
return Ok(canonical);
|
||||
};
|
||||
match (Url::parse(&advertised), Url::parse(server_url)) {
|
||||
(Ok(a), Ok(s)) if a.origin() == s.origin() => Ok(advertised),
|
||||
_ => {
|
||||
warn!(
|
||||
"Ignoring protected resource metadata resource '{advertised}': \
|
||||
it does not match the MCP server origin. Using '{canonical}' instead."
|
||||
);
|
||||
Ok(canonical)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn discover_oauth_metadata(server_url: &str) -> Result<DiscoveredOAuth> {
|
||||
let client = Client::new();
|
||||
let mut tried: Vec<String> = Vec::new();
|
||||
|
||||
@@ -231,7 +328,10 @@ async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata
|
||||
if meta.scopes_supported.is_empty() {
|
||||
meta.scopes_supported = pr.scopes_supported.clone();
|
||||
}
|
||||
return Ok(meta);
|
||||
return Ok(DiscoveredOAuth {
|
||||
metadata: meta,
|
||||
resource: pr.resource.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,7 +345,11 @@ async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata
|
||||
return resp
|
||||
.json::<OAuthServerMetadata>()
|
||||
.await
|
||||
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
|
||||
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"))
|
||||
.map(|metadata| DiscoveredOAuth {
|
||||
metadata,
|
||||
resource: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,23 +573,132 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_resource_strips_query() {
|
||||
let result = canonical_resource("https://aws-mcp.us-east-1.api.aws/mcp?oauth=initialize");
|
||||
|
||||
assert_eq!(result.unwrap(), "https://aws-mcp.us-east-1.api.aws/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_resource_strips_fragment() {
|
||||
let result = canonical_resource("https://example.com/mcp#section");
|
||||
|
||||
assert_eq!(result.unwrap(), "https://example.com/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_resource_preserves_path_and_port() {
|
||||
let result = canonical_resource("http://localhost:8080/mcp/v1?x=1");
|
||||
|
||||
assert_eq!(result.unwrap(), "http://localhost:8080/mcp/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_resource_rejects_invalid_url() {
|
||||
assert!(canonical_resource("not-a-url").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_resource_bare_host_has_no_trailing_slash() {
|
||||
let result = canonical_resource("https://mcp.example.com");
|
||||
|
||||
assert_eq!(result.unwrap(), "https://mcp.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_resource_prefers_matching_advertised() {
|
||||
let result = resolve_resource(
|
||||
Some("https://aws-mcp.us-east-1.api.aws/mcp".into()),
|
||||
"https://aws-mcp.us-east-1.api.aws/mcp?oauth=initialize",
|
||||
);
|
||||
|
||||
assert_eq!(result.unwrap(), "https://aws-mcp.us-east-1.api.aws/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_resource_rejects_cross_origin_advertised() {
|
||||
let result = resolve_resource(
|
||||
Some("https://evil.example.com/mcp".into()),
|
||||
"https://aws-mcp.us-east-1.api.aws/mcp",
|
||||
);
|
||||
|
||||
assert_eq!(result.unwrap(), "https://aws-mcp.us-east-1.api.aws/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_resource_empty_falls_back_to_canonical() {
|
||||
let result = resolve_resource(Some(String::new()), "https://example.com/mcp");
|
||||
|
||||
assert_eq!(result.unwrap(), "https://example.com/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_resource_none_falls_back_to_canonical() {
|
||||
let result = resolve_resource(None, "https://example.com/mcp");
|
||||
|
||||
assert_eq!(result.unwrap(), "https://example.com/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_resource_metadata_deserializes_resource_field() {
|
||||
let json = r#"{"resource":"https://aws-mcp.us-east-1.api.aws/mcp","authorization_servers":["https://us-east-1.oauth.signin.aws/"]}"#;
|
||||
|
||||
let pr: ProtectedResourceMetadata = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
pr.resource.as_deref(),
|
||||
Some("https://aws-mcp.us-east-1.api.aws/mcp")
|
||||
);
|
||||
assert_eq!(
|
||||
pr.authorization_servers,
|
||||
vec!["https://us-east-1.oauth.signin.aws/"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_provider_sends_resource_in_authorize_and_token_params() {
|
||||
let provider = McpOAuthProvider {
|
||||
client_id: "client-123".into(),
|
||||
authorize_url: "https://as.example/authorize".into(),
|
||||
token_url: "https://as.example/token".into(),
|
||||
scopes: String::new(),
|
||||
fixed_redirect: "http://127.0.0.1:9000/callback".into(),
|
||||
resource: "https://aws-mcp.us-east-1.api.aws/mcp".into(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
provider.extra_authorize_params(),
|
||||
vec![("resource", "https://aws-mcp.us-east-1.api.aws/mcp")]
|
||||
);
|
||||
assert_eq!(
|
||||
provider.extra_token_params(),
|
||||
vec![("resource", "https://aws-mcp.us-east-1.api.aws/mcp")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn registered_client_id_roundtrip() {
|
||||
with_temp_cache(|| {
|
||||
save_registered_client_id("notion", "client-xyz-123").unwrap();
|
||||
save_registration(
|
||||
"notion",
|
||||
"client-xyz-123",
|
||||
"http://127.0.0.1:49152/callback",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let loaded = load_registered_client_id("notion");
|
||||
let loaded = load_registration("notion");
|
||||
|
||||
assert_eq!(loaded, Some("client-xyz-123".to_string()));
|
||||
assert_eq!(loaded.unwrap().client_id, "client-xyz-123");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn load_registered_client_id_returns_none_for_missing() {
|
||||
fn load_registration_returns_none_for_missing() {
|
||||
with_temp_cache(|| {
|
||||
let loaded = load_registered_client_id("no-such-server");
|
||||
let loaded = load_registration("no-such-server");
|
||||
|
||||
assert!(loaded.is_none());
|
||||
});
|
||||
@@ -493,14 +706,79 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn registered_client_id_second_save_overwrites_first() {
|
||||
fn registration_second_save_overwrites_first() {
|
||||
with_temp_cache(|| {
|
||||
save_registered_client_id("github", "first-id").unwrap();
|
||||
save_registered_client_id("github", "second-id").unwrap();
|
||||
save_registration("github", "first-id", "http://127.0.0.1:49152/callback").unwrap();
|
||||
save_registration("github", "second-id", "http://127.0.0.1:49153/callback").unwrap();
|
||||
|
||||
let loaded = load_registered_client_id("github");
|
||||
let loaded = load_registration("github").unwrap();
|
||||
|
||||
assert_eq!(loaded, Some("second-id".to_string()));
|
||||
assert_eq!(loaded.client_id, "second-id");
|
||||
assert_eq!(
|
||||
loaded.redirect_uri.as_deref(),
|
||||
Some("http://127.0.0.1:49153/callback")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn old_format_registration_still_loads() {
|
||||
with_temp_cache(|| {
|
||||
let dir = paths::oauth_tokens_dir();
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(
|
||||
dir.join("mcp_legacy_registration.json"),
|
||||
r#"{"client_id":"legacy-id"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let loaded = load_registration("legacy").unwrap();
|
||||
|
||||
assert_eq!(loaded.client_id, "legacy-id");
|
||||
assert_eq!(loaded.redirect_uri, None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn save_registration_persists_redirect_uri() {
|
||||
with_temp_cache(|| {
|
||||
save_registration("aws", "client-abc", "http://127.0.0.1:49152/callback").unwrap();
|
||||
|
||||
let loaded = load_registration("aws").unwrap();
|
||||
|
||||
assert_eq!(loaded.client_id, "client-abc");
|
||||
assert_eq!(
|
||||
loaded.redirect_uri.as_deref(),
|
||||
Some("http://127.0.0.1:49152/callback")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_redirect_port_matches() {
|
||||
let port = cached_redirect_port("http://127.0.0.1:49152/callback", "127.0.0.1", None);
|
||||
|
||||
assert_eq!(port, Some(49152));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_redirect_port_rejects_host_mismatch() {
|
||||
let port = cached_redirect_port("http://127.0.0.1:49152/callback", "localhost", None);
|
||||
|
||||
assert_eq!(port, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_redirect_port_respects_pinned_port() {
|
||||
assert_eq!(
|
||||
cached_redirect_port("http://127.0.0.1:49152/callback", "127.0.0.1", Some(50000)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
cached_redirect_port("http://127.0.0.1:49152/callback", "127.0.0.1", Some(49152)),
|
||||
Some(49152)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1513
-90
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
use super::{DocumentId, RagData};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Abstracts where RAG vector data is stored and queried.
|
||||
///
|
||||
/// The Rag orchestrator owns: embeddings, chunking, BM25 keyword search, graph RAG,
|
||||
/// entity extraction, RRF merging. Providers own: vector storage and content retrieval.
|
||||
#[async_trait]
|
||||
pub trait RagProvider: Send + Sync {
|
||||
/// Vector similarity search. Returns (DocumentId, score) sorted by score desc.
|
||||
/// `embedding` is a single query vector from Coyote's embedding model.
|
||||
async fn vector_search(
|
||||
&self,
|
||||
embedding: &[f32],
|
||||
top_k: usize,
|
||||
min_score: f32,
|
||||
) -> Result<Vec<(DocumentId, f32)>>;
|
||||
|
||||
/// Resolve document IDs to their page content.
|
||||
///
|
||||
/// **Ordering contract:** implementations MUST return results in the same
|
||||
/// relative order as the input `ids` slice. `hybrid_search` passes an
|
||||
/// RRF-ranked list and feeds the result straight to the LLM. A provider
|
||||
/// that returns rows in storage order (e.g. Qdrant `get_points`, DuckDB
|
||||
/// `WHERE id IN (...)`) would silently discard the ranking. Implementations
|
||||
/// that query an unordered backend must re-sort by input position before
|
||||
/// returning.
|
||||
///
|
||||
/// Returns only IDs that were found; callers must handle partial returns
|
||||
/// (a missing ID is skipped, not an error).
|
||||
/// YamlProvider: reads from an in-memory content map built from data.files.
|
||||
/// DuckDbProvider: queries the documents table by id.
|
||||
/// QdrantProvider: fetches payload from the remote collection.
|
||||
async fn fetch_content(&self, ids: &[DocumentId]) -> Result<Vec<(DocumentId, String)>>;
|
||||
|
||||
/// Rebuild internal indexes from freshly updated RagData.
|
||||
/// Called once at the end of every sync_documents pass.
|
||||
///
|
||||
/// `full_rebuild` mirrors `sync_documents`' `refresh` parameter:
|
||||
/// - `true`: a full re-index (`.rebuild rag`, `--rebuild-rag`, initial build).
|
||||
/// Destructive strategies (wipe-then-reindex) are permitted.
|
||||
/// - `false`: an incremental change (`.edit rag-docs` adding/removing a file).
|
||||
/// Implementations MUST NOT wipe existing state; upsert only.
|
||||
///
|
||||
/// The parameter is part of the signature from the outset so it is fixed
|
||||
/// while there is exactly one implementor. Yaml/DuckDb ignore it,
|
||||
/// rebuilding their local state wholesale is fast and always correct.
|
||||
/// Only a remote provider is destructive enough to care.
|
||||
async fn rebuild_indexes(&mut self, data: &RagData, full_rebuild: bool) -> Result<()>;
|
||||
|
||||
/// Keyword / full-text search. Returns (DocumentId, BM25-style score) sorted desc.
|
||||
///
|
||||
/// Default impl returns `Ok(vec![])`. Callers fall back to `Rag.bm25` (local in-memory
|
||||
/// BM25 built from `data.files`).
|
||||
///
|
||||
/// Callers check `has_native_keyword_search()` before deciding which path to take:
|
||||
/// - true → call this method; skip `Rag.bm25`
|
||||
/// - false → call `Rag.keyword_search()` which uses `Rag.bm25` (sync, infallible)
|
||||
async fn keyword_search(&self, query: &str, top_k: usize) -> Result<Vec<(DocumentId, f32)>> {
|
||||
let _ = (query, top_k);
|
||||
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// Returns true if this provider implements a native keyword-search index.
|
||||
/// When false, `Rag.hybrid_search` uses the local `Rag.bm25` field instead.
|
||||
fn has_native_keyword_search(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Deep-clone the provider with fresh indexes derived from `data`.
|
||||
/// Required because Box<dyn RagProvider> is not Clone.
|
||||
/// Called by Rag's Clone impl (which clones before mutating in rebuild_rag/edit_rag_docs).
|
||||
fn duplicate(&self, data: &RagData) -> Box<dyn RagProvider>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
mod yaml;
|
||||
pub use self::yaml::YamlProvider;
|
||||
|
||||
mod duckdb;
|
||||
pub use self::duckdb::DuckDbProvider;
|
||||
pub(crate) use self::duckdb::duckdb_path_from_yaml;
|
||||
|
||||
mod qdrant;
|
||||
pub use self::qdrant::QdrantProvider;
|
||||
@@ -0,0 +1,743 @@
|
||||
use crate::rag::provider::RagProvider;
|
||||
use crate::rag::{DocumentId, RagData};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::RwLock;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use reqwest::{Client, Response, StatusCode};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Marks a `DocumentId` that stands in for a point id Coyote cannot carry
|
||||
/// directly. Qdrant accepts UUID strings as point ids, and that is what
|
||||
/// LangChain writes by default.
|
||||
///
|
||||
/// `DocumentId` packs `(file_index, document_index)` into one `usize` with the
|
||||
/// file index in the high half, so this bit is only reachable at a file index of
|
||||
/// 2^31. Nothing local gets near that, and an attached RAG builds no local index
|
||||
/// at all — `data.files` and `data.vectors` stay empty and every
|
||||
/// `DocumentId::split` caller early-returns on `data.attached`. Along the
|
||||
/// attached path the id is an opaque key carried through RRF, which is what
|
||||
/// makes a synthetic one safe here and nowhere else.
|
||||
const SYNTHETIC_ID_TAG: usize = 1 << (usize::BITS - 1);
|
||||
|
||||
/// Two-way map between a raw Qdrant point id and the `DocumentId` the retrieval
|
||||
/// pipeline sees.
|
||||
///
|
||||
/// Only ids that cannot survive the round trip are interned. A plain `u64` that
|
||||
/// fits below the tag keeps mapping to itself, so integer-keyed collections
|
||||
/// behave exactly as they did before this map existed.
|
||||
#[derive(Default)]
|
||||
struct PointIdInterner {
|
||||
handles: HashMap<String, DocumentId>,
|
||||
raw: HashMap<DocumentId, Value>,
|
||||
next: usize,
|
||||
}
|
||||
|
||||
impl PointIdInterner {
|
||||
/// The `DocumentId` for a raw point id, minting a handle if one is needed.
|
||||
///
|
||||
/// `None` only for a missing id, which is a malformed response.
|
||||
fn document_id(&mut self, raw: &Value) -> Option<DocumentId> {
|
||||
if raw.is_null() {
|
||||
return None;
|
||||
}
|
||||
// The pre-existing integer path, unchanged. `try_from` rather than `as`
|
||||
// so a value too wide for the target's `usize` is interned instead of
|
||||
// silently truncated into a different point.
|
||||
if let Some(n) = raw.as_u64()
|
||||
&& let Ok(n) = usize::try_from(n)
|
||||
&& n & SYNTHETIC_ID_TAG == 0
|
||||
{
|
||||
return Some(DocumentId(n));
|
||||
}
|
||||
Some(self.intern(raw))
|
||||
}
|
||||
|
||||
fn intern(&mut self, raw: &Value) -> DocumentId {
|
||||
// Keyed on the JSON rendering, so the string "1" and the integer 1 are
|
||||
// not conflated into one point.
|
||||
let key = raw.to_string();
|
||||
if let Some(handle) = self.handles.get(&key) {
|
||||
return *handle;
|
||||
}
|
||||
let handle = DocumentId(SYNTHETIC_ID_TAG | self.next);
|
||||
self.next += 1;
|
||||
self.handles.insert(key, handle);
|
||||
self.raw.insert(handle, raw.clone());
|
||||
handle
|
||||
}
|
||||
|
||||
/// The original id for a handle, or `None` when the id was never interned —
|
||||
/// i.e. it is a plain integer that is already its own id.
|
||||
fn raw_id(&self, handle: DocumentId) -> Option<&Value> {
|
||||
self.raw.get(&handle)
|
||||
}
|
||||
|
||||
/// Builds the `ids` array for an outbound `/points` fetch. Every entry is the
|
||||
/// id Qdrant issued, integer or string; a synthetic handle must never leave
|
||||
/// this process.
|
||||
fn outbound_ids(&self, ids: &[DocumentId]) -> Vec<Value> {
|
||||
ids.iter()
|
||||
.map(|id| match self.raw_id(*id) {
|
||||
Some(raw) => raw.clone(),
|
||||
None => Value::from(id.0 as u64),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_search_hits(
|
||||
interner: &mut PointIdInterner,
|
||||
body: &Value,
|
||||
min_score: f32,
|
||||
) -> Result<Vec<(DocumentId, f32)>> {
|
||||
let hits = body["result"]
|
||||
.as_array()
|
||||
.context("Unexpected /points/search response shape")?;
|
||||
|
||||
Ok(hits
|
||||
.iter()
|
||||
.filter_map(|pt| {
|
||||
let score = pt["score"].as_f64()? as f32;
|
||||
Some((interner.document_id(&pt["id"])?, score))
|
||||
})
|
||||
.filter(|(_, score)| *score > min_score)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn parse_points(interner: &mut PointIdInterner, body: &Value) -> Result<Vec<(DocumentId, String)>> {
|
||||
let points = body["result"]
|
||||
.as_array()
|
||||
.context("Unexpected /points response shape")?;
|
||||
|
||||
Ok(points
|
||||
.iter()
|
||||
.filter_map(|pt| {
|
||||
let text = pt["payload"]["page_content"].as_str()?.to_string();
|
||||
Some((interner.document_id(&pt["id"])?, text))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Render Qdrant's error envelope into a human-readable message.
|
||||
///
|
||||
/// `body` is the raw response text. Two shapes have to be tolerated:
|
||||
/// * application-level errors carry `{"status": {"error": "..."}, "time": 0.0}`,
|
||||
/// while successful responses carry a bare string `{"status": "ok", ...}` — so
|
||||
/// `status` is string-or-object and a struct with `status: String` fails to
|
||||
/// parse every error body;
|
||||
/// * routing-level 404s (a wrong HTTP verb) return an EMPTY body with no JSON at
|
||||
/// all, which without the length check surfaces as "EOF while parsing a value"
|
||||
/// instead of the actual 404.
|
||||
fn format_error_body(status: StatusCode, body: &str) -> String {
|
||||
if body.is_empty() {
|
||||
return format!("HTTP {status} (empty body — check the HTTP verb and path)");
|
||||
}
|
||||
serde_json::from_str::<Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v["status"]["error"].as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| format!("HTTP {status}: {body}"))
|
||||
}
|
||||
|
||||
/// Read the vector dimension out of a parsed `GET /collections/{name}` response.
|
||||
fn vector_dimension_from_collection(body: &Value) -> Result<u64> {
|
||||
let params = &body["result"]["config"]["params"];
|
||||
params["vectors"]["size"]
|
||||
.as_u64()
|
||||
.or_else(|| {
|
||||
params["vectors"]
|
||||
.as_object()
|
||||
.and_then(|m| m.values().next())
|
||||
.and_then(|v| v["size"].as_u64())
|
||||
})
|
||||
.context("Could not determine vector dimension from collection config")
|
||||
}
|
||||
|
||||
/// True if a parsed `GET /collections/{name}` response describes a NAMED
|
||||
/// (multi-vector) collection.
|
||||
///
|
||||
/// `vector_search` posts an unnamed vector, which a named-vector collection
|
||||
/// rejects with HTTP 400 on every query, so attaching one yields a RAG that is
|
||||
/// silently 100% broken. A named collection holding a SINGLE vector is
|
||||
/// structurally a map, identical in kind to the multi-named case, and rejects
|
||||
/// the same way; testing for a numeric `size` directly under `vectors` catches
|
||||
/// it, whereas counting keys (`len() > 1`) would wrongly accept it.
|
||||
fn is_multi_vector_config(body: &Value) -> bool {
|
||||
body["result"]["config"]["params"]["vectors"]["size"]
|
||||
.as_u64()
|
||||
.is_none()
|
||||
}
|
||||
|
||||
/// Query-only client for an external Qdrant collection.
|
||||
///
|
||||
/// Attach-only: this provider never writes to the remote collection. Coyote does
|
||||
/// not own the data, and `rebuild_indexes` refuses rather than pretending to.
|
||||
pub struct QdrantProvider {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
collection: String,
|
||||
point_ids: Arc<RwLock<PointIdInterner>>,
|
||||
}
|
||||
|
||||
impl QdrantProvider {
|
||||
fn make_client(api_key: Option<&str>) -> Result<Client> {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(key) = api_key {
|
||||
let mut value =
|
||||
HeaderValue::from_str(key).context("api-key header value is not valid ASCII")?;
|
||||
value.set_sensitive(true);
|
||||
headers.insert("api-key", value);
|
||||
}
|
||||
Client::builder()
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.context("Failed to build reqwest client")
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_base_url(host: &str) -> String {
|
||||
if host.starts_with("http://") || host.starts_with("https://") {
|
||||
host.to_string()
|
||||
} else {
|
||||
format!("http://{host}")
|
||||
}
|
||||
}
|
||||
|
||||
async fn error_message(resp: Response) -> String {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
format_error_body(status, &body)
|
||||
}
|
||||
|
||||
/// Shared `GET /collections/{name}` fetch. Both the dimension and the
|
||||
/// multi-vector probe discriminate on this same response.
|
||||
async fn fetch_collection(
|
||||
host: &str,
|
||||
collection: &str,
|
||||
api_key: Option<&str>,
|
||||
) -> Result<Value> {
|
||||
let base_url = Self::normalize_base_url(host);
|
||||
let client = Self::make_client(api_key)?;
|
||||
let resp = client
|
||||
.get(format!("{base_url}/collections/{collection}"))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to connect to {host}"))?;
|
||||
if !resp.status().is_success() {
|
||||
bail!(
|
||||
"Failed to read collection '{collection}': {}",
|
||||
Self::error_message(resp).await
|
||||
);
|
||||
}
|
||||
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
pub async fn new(host: &str, collection: &str, api_key: Option<&str>) -> Result<Self> {
|
||||
let base_url = Self::normalize_base_url(host);
|
||||
let client = Self::make_client(api_key)?;
|
||||
let resp = client
|
||||
.get(format!("{base_url}/collections/{collection}"))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to connect to {host}"))?;
|
||||
if !resp.status().is_success() {
|
||||
bail!(
|
||||
"Collection '{collection}' not accessible at {host}: {}",
|
||||
Self::error_message(resp).await
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
base_url,
|
||||
collection: collection.to_string(),
|
||||
point_ids: Arc::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_collections(host: &str, api_key: Option<&str>) -> Result<Vec<String>> {
|
||||
let base_url = Self::normalize_base_url(host);
|
||||
let client = Self::make_client(api_key)?;
|
||||
let resp = client
|
||||
.get(format!("{base_url}/collections"))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to connect to {host}"))?;
|
||||
if !resp.status().is_success() {
|
||||
bail!(
|
||||
"Failed to list collections: {}",
|
||||
Self::error_message(resp).await
|
||||
);
|
||||
}
|
||||
|
||||
let body: Value = resp.json().await?;
|
||||
let names = body["result"]["collections"]
|
||||
.as_array()
|
||||
.context("Unexpected /collections response shape")?
|
||||
.iter()
|
||||
.filter_map(|v| v["name"].as_str().map(str::to_string))
|
||||
.collect();
|
||||
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
pub async fn get_vector_dimension(
|
||||
host: &str,
|
||||
collection: &str,
|
||||
api_key: Option<&str>,
|
||||
) -> Result<u64> {
|
||||
let body = Self::fetch_collection(host, collection, api_key).await?;
|
||||
|
||||
vector_dimension_from_collection(&body)
|
||||
}
|
||||
|
||||
pub async fn is_multi_vector(
|
||||
host: &str,
|
||||
collection: &str,
|
||||
api_key: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let body = Self::fetch_collection(host, collection, api_key).await?;
|
||||
|
||||
Ok(is_multi_vector_config(&body))
|
||||
}
|
||||
|
||||
pub async fn sample_point_id(
|
||||
host: &str,
|
||||
collection: &str,
|
||||
api_key: Option<&str>,
|
||||
) -> Result<Option<String>> {
|
||||
let base_url = Self::normalize_base_url(host);
|
||||
let client = Self::make_client(api_key)?;
|
||||
let url = format!("{base_url}/collections/{collection}/points/scroll");
|
||||
let body = serde_json::json!({ "limit": 1, "with_payload": false });
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to connect to {host}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
bail!(
|
||||
"Failed to sample a point from '{collection}': {}",
|
||||
Self::error_message(resp).await
|
||||
);
|
||||
}
|
||||
|
||||
let data: Value = resp.json().await?;
|
||||
let id_val = data["result"]["points"]
|
||||
.as_array()
|
||||
.and_then(|pts| pts.first())
|
||||
.map(|pt| pt["id"].to_string());
|
||||
|
||||
Ok(id_val)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RagProvider for QdrantProvider {
|
||||
async fn vector_search(
|
||||
&self,
|
||||
embedding: &[f32],
|
||||
top_k: usize,
|
||||
min_score: f32,
|
||||
) -> Result<Vec<(DocumentId, f32)>> {
|
||||
let url = format!(
|
||||
"{}/collections/{}/points/search",
|
||||
self.base_url, self.collection
|
||||
);
|
||||
// `score_threshold` is deliberately NOT sent. It is metric-aware: on Cosine
|
||||
// collections 0.0 means "no floor" as expected, but Euclid collections score
|
||||
// by negative distance, where 0.0 filters everything out. The attach wizard
|
||||
// does not pin the distance metric, so filter locally instead.
|
||||
let body = serde_json::json!({
|
||||
"vector": embedding,
|
||||
"limit": top_k,
|
||||
"with_payload": false,
|
||||
});
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
bail!(
|
||||
"Qdrant search on '{}' failed: {}",
|
||||
self.collection,
|
||||
Self::error_message(resp).await
|
||||
);
|
||||
}
|
||||
let data: Value = resp.json().await?;
|
||||
// The interner is what lets a UUID-keyed collection work: a string id gets
|
||||
// a synthetic handle here and the original is replayed by `fetch_content`.
|
||||
let mut interner = self.point_ids.write();
|
||||
|
||||
parse_search_hits(&mut interner, &data, min_score)
|
||||
}
|
||||
|
||||
async fn fetch_content(&self, ids: &[DocumentId]) -> Result<Vec<(DocumentId, String)>> {
|
||||
if ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let url = format!("{}/collections/{}/points", self.base_url, self.collection);
|
||||
// Qdrant is asked for the ids it issued, never for a synthetic handle.
|
||||
let id_list = self.point_ids.read().outbound_ids(ids);
|
||||
let body = serde_json::json!({
|
||||
"ids": id_list,
|
||||
"with_payload": true,
|
||||
});
|
||||
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
bail!(
|
||||
"Qdrant point fetch on '{}' failed: {}",
|
||||
self.collection,
|
||||
Self::error_message(resp).await
|
||||
);
|
||||
}
|
||||
let data: Value = resp.json().await?;
|
||||
let mut rows = {
|
||||
let mut interner = self.point_ids.write();
|
||||
parse_points(&mut interner, &data)?
|
||||
};
|
||||
// `/points` does not guarantee response order matches request order, and the
|
||||
// caller's RRF ranking is carried by that order. Restore it.
|
||||
let position: HashMap<DocumentId, usize> =
|
||||
ids.iter().enumerate().map(|(i, id)| (*id, i)).collect();
|
||||
rows.sort_by_key(|(id, _)| position.get(id).copied().unwrap_or(usize::MAX));
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn rebuild_indexes(&mut self, data: &RagData, _full_rebuild: bool) -> Result<()> {
|
||||
// Both arms refuse. A silent `Ok(())` would make `.rebuild rag` and
|
||||
// `.edit rag-docs` look like they worked while writing nothing to the
|
||||
// remote, leaving the user believing the collection was updated.
|
||||
if data.attached {
|
||||
bail!(
|
||||
"This RAG is attached to an external Qdrant collection. Coyote does not own \
|
||||
its documents and cannot rebuild it. Manage the collection directly, or \
|
||||
create a Coyote-owned RAG with `.rag <name>`."
|
||||
);
|
||||
}
|
||||
bail!("Writing to Qdrant is not supported yet (attach-only).");
|
||||
}
|
||||
|
||||
fn duplicate(&self, _data: &RagData) -> Box<dyn RagProvider> {
|
||||
// Cloning the client shares the connection pool and the injected api-key
|
||||
// header. Sharing is correct: both handles address the same remote
|
||||
// collection, and neither of them writes to it.
|
||||
//
|
||||
// The point-id map is shared for the same reason, and because it MUST be:
|
||||
// `Rag::clone()` hands the clone `DocumentId`s that the original minted,
|
||||
// so a fresh map would resolve them to nothing and `fetch_content` would
|
||||
// ask Qdrant for a synthetic handle — zero results, no error. Resetting it
|
||||
// would also re-mint handles for ids the original still holds.
|
||||
Box::new(Self {
|
||||
client: self.client.clone(),
|
||||
base_url: self.base_url.clone(),
|
||||
collection: self.collection.clone(),
|
||||
point_ids: Arc::clone(&self.point_ids),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn error_message_reads_the_object_status_envelope() {
|
||||
let body =
|
||||
r#"{"status": {"error": "Wrong input: Not existing vector name error:"}, "time": 0.0}"#;
|
||||
let msg = format_error_body(StatusCode::BAD_REQUEST, body);
|
||||
assert!(msg.contains("Not existing vector name"), "got: {msg}");
|
||||
assert!(
|
||||
!msg.contains("EOF"),
|
||||
"must not fall through to a parse error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_message_survives_the_string_status_and_the_empty_body() {
|
||||
let ok = format_error_body(StatusCode::OK, r#"{"status": "ok", "time": 0.0}"#);
|
||||
assert!(
|
||||
ok.contains("200"),
|
||||
"no `status.error` present → fall back to status+body: {ok}"
|
||||
);
|
||||
|
||||
let empty = format_error_body(StatusCode::NOT_FOUND, "");
|
||||
|
||||
assert!(empty.contains("empty body"), "got: {empty}");
|
||||
assert!(
|
||||
empty.contains("verb"),
|
||||
"the message must point at the likely cause: {empty}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_dimension_handles_both_collection_shapes() {
|
||||
let unnamed = serde_json::json!({
|
||||
"result": {"config": {"params": {"vectors": {"size": 1536, "distance": "Cosine"}}}}
|
||||
});
|
||||
assert_eq!(vector_dimension_from_collection(&unnamed).unwrap(), 1536);
|
||||
|
||||
let named = serde_json::json!({
|
||||
"result": {"config": {"params": {"vectors": {"text": {"size": 768, "distance": "Cosine"}}}}}
|
||||
});
|
||||
assert_eq!(vector_dimension_from_collection(&named).unwrap(), 768);
|
||||
|
||||
let junk = serde_json::json!({"result": {"config": {"params": {}}}});
|
||||
assert!(vector_dimension_from_collection(&junk).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_multi_vector_rejects_the_named_single_collection() {
|
||||
let unnamed = serde_json::json!({
|
||||
"result": {"config": {"params": {"vectors": {"size": 1536, "distance": "Cosine"}}}}
|
||||
});
|
||||
assert!(!is_multi_vector_config(&unnamed));
|
||||
|
||||
let named_single = serde_json::json!({
|
||||
"result": {"config": {"params": {"vectors": {"text": {"size": 1536}}}}}
|
||||
});
|
||||
assert!(
|
||||
is_multi_vector_config(&named_single),
|
||||
"named-single must be rejected too"
|
||||
);
|
||||
|
||||
let named_multi = serde_json::json!({
|
||||
"result": {"config": {"params": {"vectors": {"text": {"size": 1536}, "image": {"size": 512}}}}}
|
||||
});
|
||||
assert!(is_multi_vector_config(&named_multi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_url_only_adds_a_scheme_when_missing() {
|
||||
assert_eq!(
|
||||
QdrantProvider::normalize_base_url("qdrant.example.com:6333"),
|
||||
"http://qdrant.example.com:6333"
|
||||
);
|
||||
assert_eq!(
|
||||
QdrantProvider::normalize_base_url("https://xyz.cloud.qdrant.io"),
|
||||
"https://xyz.cloud.qdrant.io"
|
||||
);
|
||||
assert_eq!(
|
||||
QdrantProvider::normalize_base_url("http://localhost:6333"),
|
||||
"http://localhost:6333"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebuild_indexes_refuses_for_attached_and_unattached_alike() {
|
||||
let mut provider = QdrantProvider {
|
||||
client: Client::new(),
|
||||
base_url: "http://localhost:6333".to_string(),
|
||||
collection: "c".to_string(),
|
||||
point_ids: Arc::default(),
|
||||
};
|
||||
|
||||
let attached = RagData {
|
||||
driver: "qdrant".to_string(),
|
||||
attached: true,
|
||||
..Default::default()
|
||||
};
|
||||
let err = provider
|
||||
.rebuild_indexes(&attached, true)
|
||||
.await
|
||||
.expect_err("an attached qdrant RAG must never report a successful rebuild");
|
||||
assert!(err.to_string().contains("cannot rebuild"), "got: {err}");
|
||||
|
||||
// `attached: false` is reserved for the (unimplemented) write path. It must
|
||||
// also refuse: silently succeeding would run a full paid embedding pass and
|
||||
// then discard every vector.
|
||||
let owned = RagData {
|
||||
driver: "qdrant".to_string(),
|
||||
attached: false,
|
||||
..Default::default()
|
||||
};
|
||||
let err = provider
|
||||
.rebuild_indexes(&owned, true)
|
||||
.await
|
||||
.expect_err("writing to qdrant is unimplemented and must fail loudly");
|
||||
assert!(err.to_string().contains("not supported yet"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_content_short_circuits_on_an_empty_id_list() {
|
||||
let provider = QdrantProvider {
|
||||
client: Client::new(),
|
||||
base_url: "http://127.0.0.1:1".to_string(),
|
||||
collection: "c".to_string(),
|
||||
point_ids: Arc::default(),
|
||||
};
|
||||
|
||||
assert!(provider.fetch_content(&[]).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// A UUID-keyed collection has to survive the whole `vector_search` →
|
||||
/// `fetch_content` round trip, and the fetch must ask Qdrant for the ORIGINAL
|
||||
/// string id. Parsing ids with `as_u64()` used to drop these hits inside a
|
||||
/// `filter_map`, i.e. zero results and no error.
|
||||
#[test]
|
||||
fn uuid_point_ids_round_trip_and_are_requested_verbatim() {
|
||||
let mut interner = PointIdInterner::default();
|
||||
let first_uuid = "3f1b0c2e-1111-4000-8000-000000000001";
|
||||
let second_uuid = "3f1b0c2e-2222-4000-8000-000000000002";
|
||||
|
||||
let search = serde_json::json!({
|
||||
"result": [
|
||||
{"id": first_uuid, "score": 0.91},
|
||||
{"id": second_uuid, "score": 0.42},
|
||||
]
|
||||
});
|
||||
let hits = parse_search_hits(&mut interner, &search, 0.0).unwrap();
|
||||
assert_eq!(hits.len(), 2, "string ids must not be silently dropped");
|
||||
|
||||
let ids: Vec<DocumentId> = hits.iter().map(|(id, _)| *id).collect();
|
||||
assert_eq!(
|
||||
interner.outbound_ids(&ids),
|
||||
vec![Value::from(first_uuid), Value::from(second_uuid)],
|
||||
"the fetch must send the ids Qdrant issued, not the handles"
|
||||
);
|
||||
|
||||
// Qdrant may answer /points in any order; the handles still map back and
|
||||
// the caller's RRF ranking is recoverable.
|
||||
let points = serde_json::json!({
|
||||
"result": [
|
||||
{"id": second_uuid, "payload": {"page_content": "second"}},
|
||||
{"id": first_uuid, "payload": {"page_content": "first"}},
|
||||
]
|
||||
});
|
||||
let mut rows = parse_points(&mut interner, &points).unwrap();
|
||||
let position: HashMap<DocumentId, usize> =
|
||||
ids.iter().enumerate().map(|(i, id)| (*id, i)).collect();
|
||||
rows.sort_by_key(|(id, _)| position.get(id).copied().unwrap_or(usize::MAX));
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![
|
||||
(ids[0], "first".to_string()),
|
||||
(ids[1], "second".to_string())
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Integer-keyed collections must be untouched by the interner: the id maps to
|
||||
/// itself on the way in and goes back out as the same integer.
|
||||
#[test]
|
||||
fn integer_point_ids_are_passed_through_untouched() {
|
||||
let mut interner = PointIdInterner::default();
|
||||
let search = serde_json::json!({
|
||||
"result": [{"id": 7, "score": 0.9}, {"id": 0, "score": 0.5}]
|
||||
});
|
||||
|
||||
let hits = parse_search_hits(&mut interner, &search, 0.0).unwrap();
|
||||
assert_eq!(
|
||||
hits,
|
||||
vec![(DocumentId(7), 0.9_f32), (DocumentId(0), 0.5_f32)]
|
||||
);
|
||||
|
||||
let ids: Vec<DocumentId> = hits.iter().map(|(id, _)| *id).collect();
|
||||
assert_eq!(
|
||||
interner.outbound_ids(&ids),
|
||||
vec![Value::from(7_u64), Value::from(0_u64)],
|
||||
"integer ids must not be regressed into synthetic handles"
|
||||
);
|
||||
assert!(
|
||||
interner.raw_id(DocumentId(7)).is_none(),
|
||||
"a plain integer id is its own id and needs no map entry"
|
||||
);
|
||||
}
|
||||
|
||||
/// Synthetic handles are stable per point id and live in a range no packed
|
||||
/// `DocumentId` can reach.
|
||||
#[test]
|
||||
fn synthetic_handles_are_stable_and_never_collide_with_packed_ids() {
|
||||
let mut interner = PointIdInterner::default();
|
||||
let uuid = Value::from("9d2f0a11-3333-4000-8000-00000000000a");
|
||||
|
||||
let handle = interner.document_id(&uuid).unwrap();
|
||||
assert_eq!(
|
||||
interner.document_id(&uuid).unwrap(),
|
||||
handle,
|
||||
"the same point id must keep the same handle across queries"
|
||||
);
|
||||
assert_ne!(
|
||||
interner.document_id(&Value::from("other")).unwrap(),
|
||||
handle,
|
||||
"distinct point ids must not share a handle"
|
||||
);
|
||||
assert_ne!(handle.0 & SYNTHETIC_ID_TAG, 0, "a handle carries the tag");
|
||||
|
||||
// A packed (file_index, document_index) never sets the tag bit: it is the
|
||||
// top bit of the file index, which would take 2^31 indexed files.
|
||||
for (file_index, document_index) in [(0, 0), (1, 0), (0, 4242), (1_000_000, 999)] {
|
||||
assert_eq!(
|
||||
DocumentId::new(file_index, document_index).0 & SYNTHETIC_ID_TAG,
|
||||
0,
|
||||
"packed ({file_index}, {document_index}) must stay out of the handle range"
|
||||
);
|
||||
}
|
||||
|
||||
// The one integer id that WOULD land on the tag is interned instead of
|
||||
// being handed back as itself, so it cannot alias a handle.
|
||||
let collides = Value::from(SYNTHETIC_ID_TAG as u64);
|
||||
let interned = interner.document_id(&collides).unwrap();
|
||||
assert_eq!(interner.raw_id(interned), Some(&collides));
|
||||
assert_eq!(
|
||||
interner.outbound_ids(&[interned]),
|
||||
vec![collides],
|
||||
"the original integer must still be what Qdrant is asked for"
|
||||
);
|
||||
}
|
||||
|
||||
/// `duplicate()` shares the map rather than resetting it: `Rag::clone()` hands
|
||||
/// the clone `DocumentId`s the original minted, and a fresh map would turn
|
||||
/// those into requests for a synthetic handle — zero results, no error.
|
||||
#[test]
|
||||
fn duplicate_shares_the_point_id_map() {
|
||||
let provider = QdrantProvider {
|
||||
client: Client::new(),
|
||||
base_url: "http://127.0.0.1:1".to_string(),
|
||||
collection: "c".to_string(),
|
||||
point_ids: Arc::default(),
|
||||
};
|
||||
let uuid = Value::from("c0ffee00-4444-4000-8000-000000000007");
|
||||
let handle = provider.point_ids.write().document_id(&uuid).unwrap();
|
||||
|
||||
let dup = provider.duplicate(&RagData {
|
||||
driver: "qdrant".to_string(),
|
||||
attached: true,
|
||||
..Default::default()
|
||||
});
|
||||
// Downcasting is not available through `dyn RagProvider`, so go via the
|
||||
// shared Arc: the clone must observe the original's interning.
|
||||
assert_eq!(Arc::strong_count(&provider.point_ids), 2);
|
||||
assert_eq!(provider.point_ids.read().raw_id(handle), Some(&uuid));
|
||||
drop(dup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn qdrant_list_collections_requires_running_instance() {
|
||||
let collections = QdrantProvider::list_collections("http://localhost:6333", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!collections.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn qdrant_vector_search_returns_results() {
|
||||
let provider = QdrantProvider::new("http://localhost:6333", "test-collection", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let embedding = vec![0.0f32; 1536];
|
||||
|
||||
let results = provider.vector_search(&embedding, 5, 0.0).await.unwrap();
|
||||
|
||||
assert!(results.len() <= 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use crate::rag::provider::RagProvider;
|
||||
use crate::rag::{DocumentId, RagData};
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use hnsw_rs::prelude::*;
|
||||
use indexmap::IndexMap;
|
||||
|
||||
pub struct YamlProvider {
|
||||
hnsw: Hnsw<'static, f32, DistCosine>,
|
||||
content_map: IndexMap<DocumentId, String>,
|
||||
}
|
||||
|
||||
impl YamlProvider {
|
||||
pub fn from_data(data: &RagData) -> Self {
|
||||
Self {
|
||||
hnsw: data.build_hnsw(),
|
||||
content_map: Self::build_content_map(data),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_content_map(data: &RagData) -> IndexMap<DocumentId, String> {
|
||||
data.iter_documents()
|
||||
.map(|(id, doc)| (id, doc.page_content.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RagProvider for YamlProvider {
|
||||
async fn vector_search(
|
||||
&self,
|
||||
embedding: &[f32],
|
||||
top_k: usize,
|
||||
min_score: f32,
|
||||
) -> Result<Vec<(DocumentId, f32)>> {
|
||||
let results = self
|
||||
.hnsw
|
||||
.parallel_search(&[embedding.to_vec()], top_k, 30)
|
||||
.into_iter()
|
||||
.flat_map(|list| {
|
||||
list.into_iter().filter_map(|v| {
|
||||
let score = 1.0 - v.distance;
|
||||
if score > min_score {
|
||||
Some((DocumentId(v.d_id), score))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn fetch_content(&self, ids: &[DocumentId]) -> Result<Vec<(DocumentId, String)>> {
|
||||
Ok(ids
|
||||
.iter()
|
||||
.filter_map(|id| self.content_map.get(id).map(|text| (*id, text.clone())))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn rebuild_indexes(&mut self, data: &RagData, _full_rebuild: bool) -> Result<()> {
|
||||
self.hnsw = data.build_hnsw();
|
||||
|
||||
self.content_map = Self::build_content_map(data);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn duplicate(&self, data: &RagData) -> Box<dyn RagProvider> {
|
||||
Box::new(YamlProvider::from_data(data))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod provider_tests {
|
||||
use super::*;
|
||||
use crate::rag::{RagDocument, RagFile};
|
||||
|
||||
fn minimal_rag_data() -> RagData {
|
||||
RagData {
|
||||
embedding_model: "text-embedding-3-small".to_string(),
|
||||
chunk_size: 1024,
|
||||
chunk_overlap: 50,
|
||||
top_k: 5,
|
||||
driver: "yaml".to_string(),
|
||||
attached: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Two files, one chunk each, with vectors, the minimum needed to exercise
|
||||
/// `build_content_map` and the `fetch_content` ordering contract.
|
||||
/// `DocumentId::new(f, d)` packs (file_index, document_index); `RagData::add`
|
||||
/// is the real insertion path but a direct literal is sufficient and avoids
|
||||
/// the embedding pipeline.
|
||||
fn populated_rag_data() -> RagData {
|
||||
let mut data = minimal_rag_data();
|
||||
// `files` must be populated: build_content_map iterates data.iter_documents(),
|
||||
// which enumerates `files`. Populating `vectors` alone would produce an EMPTY
|
||||
// content map, and every assertion below would vacuously pass on a broken impl.
|
||||
// The vectors inserted at the end are for the HNSW side only.
|
||||
data.files.insert(
|
||||
0,
|
||||
RagFile {
|
||||
hash: "h0".to_string(),
|
||||
path: "/tmp/a.md".to_string(),
|
||||
documents: vec![RagDocument {
|
||||
page_content: "alpha".to_string(),
|
||||
metadata: Default::default(),
|
||||
}],
|
||||
},
|
||||
);
|
||||
data.files.insert(
|
||||
1,
|
||||
RagFile {
|
||||
hash: "h1".to_string(),
|
||||
path: "/tmp/b.md".to_string(),
|
||||
documents: vec![RagDocument {
|
||||
page_content: "beta".to_string(),
|
||||
metadata: Default::default(),
|
||||
}],
|
||||
},
|
||||
);
|
||||
data.vectors
|
||||
.insert(DocumentId::new(0, 0), vec![1.0, 0.0, 0.0]);
|
||||
data.vectors
|
||||
.insert(DocumentId::new(1, 0), vec![0.0, 1.0, 0.0]);
|
||||
data
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn yaml_provider_empty_data_returns_nothing() {
|
||||
let data = minimal_rag_data();
|
||||
let provider = YamlProvider::from_data(&data);
|
||||
|
||||
let results = provider.fetch_content(&[]).await.unwrap();
|
||||
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn yaml_provider_fetch_content_preserves_input_order() {
|
||||
let data = populated_rag_data();
|
||||
let provider = YamlProvider::from_data(&data);
|
||||
|
||||
let a = DocumentId::new(0, 0);
|
||||
let b = DocumentId::new(1, 0);
|
||||
|
||||
let forward = provider.fetch_content(&[a, b]).await.unwrap();
|
||||
assert_eq!(forward.len(), 2, "both documents must resolve");
|
||||
assert_eq!(forward[0].1, "alpha");
|
||||
assert_eq!(forward[1].1, "beta");
|
||||
|
||||
let reversed = provider.fetch_content(&[b, a]).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
reversed[0].1, "beta",
|
||||
"fetch_content must honor input order"
|
||||
);
|
||||
assert_eq!(reversed[1].1, "alpha");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn yaml_provider_fetch_content_skips_missing_ids() {
|
||||
let data = populated_rag_data();
|
||||
let provider = YamlProvider::from_data(&data);
|
||||
|
||||
let a = DocumentId::new(0, 0);
|
||||
let missing = DocumentId::new(99, 0);
|
||||
let b = DocumentId::new(1, 0);
|
||||
|
||||
let out = provider.fetch_content(&[a, missing, b]).await.unwrap();
|
||||
assert_eq!(out.len(), 2, "missing id is skipped, not an error");
|
||||
assert_eq!(out[0].1, "alpha");
|
||||
assert_eq!(out[1].1, "beta");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn yaml_provider_duplicate_returns_equivalent_content() {
|
||||
let data = populated_rag_data();
|
||||
let provider = YamlProvider::from_data(&data);
|
||||
let dup = provider.duplicate(&data);
|
||||
let ids = [DocumentId::new(0, 0), DocumentId::new(1, 0)];
|
||||
|
||||
let r1 = provider.fetch_content(&ids).await.unwrap();
|
||||
let r2 = dup.fetch_content(&ids).await.unwrap();
|
||||
|
||||
assert_eq!(r1.len(), 2, "fixture must resolve both documents");
|
||||
assert_eq!(
|
||||
r1, r2,
|
||||
"duplicate must resolve the same content as the original"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn yaml_provider_content_is_keyed_on_files_not_vectors() {
|
||||
let mut data = populated_rag_data();
|
||||
let orphan = DocumentId::new(9, 0);
|
||||
data.vectors.insert(orphan, vec![0.0, 0.0, 1.0]);
|
||||
|
||||
let provider = YamlProvider::from_data(&data);
|
||||
|
||||
let out = provider.fetch_content(&[orphan]).await.unwrap();
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"an id present only in `vectors` must not resolve to content"
|
||||
);
|
||||
|
||||
let real = provider
|
||||
.fetch_content(&[DocumentId::new(0, 0), DocumentId::new(1, 0)])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(real.len(), 2, "file-backed documents must still resolve");
|
||||
assert_eq!(real[0].1, "alpha");
|
||||
assert_eq!(real[1].1, "beta");
|
||||
}
|
||||
}
|
||||
@@ -1749,6 +1749,86 @@ std::error::Error>> {
|
||||
);
|
||||
}
|
||||
|
||||
fn strip_ansi(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut chars = text.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\u{1b}' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
if chars.next() == Some('[') {
|
||||
for c in chars.by_ref() {
|
||||
if ('\u{40}'..='\u{7e}').contains(&c) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_ansi_removes_sgr_and_keeps_text() {
|
||||
assert_eq!(strip_ansi("\x1b[1mbold\x1b[0m"), "bold");
|
||||
assert_eq!(strip_ansi("\x1b[38;5;120mx\x1b[39m"), "x");
|
||||
assert_eq!(strip_ansi("plain"), "plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_table_pads_columns_by_display_width() {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
const WRAP_WIDTH: u16 = 80;
|
||||
|
||||
let options = RenderOptions::default();
|
||||
let mut render = MarkdownRender::init(options).unwrap();
|
||||
render.wrap_width = Some(WRAP_WIDTH);
|
||||
|
||||
let header = vec![
|
||||
"**Setting**".into(),
|
||||
"*Default*".into(),
|
||||
"`Description`".into(),
|
||||
];
|
||||
let alignments = vec![
|
||||
CellAlignment::Left,
|
||||
CellAlignment::Right,
|
||||
CellAlignment::Center,
|
||||
];
|
||||
let rows = vec![
|
||||
vec![
|
||||
"**temperature**".into(),
|
||||
"`0.7`".into(),
|
||||
"Controls how *random* the sampled reply is allowed to be".into(),
|
||||
],
|
||||
vec![
|
||||
"**top_p**".into(),
|
||||
"`1.0`".into(),
|
||||
"Nucleus sampling cutoff, applied **after** temperature".into(),
|
||||
],
|
||||
];
|
||||
|
||||
let output = render.render_table(header, alignments, rows);
|
||||
|
||||
assert!(
|
||||
output.contains('\u{1b}'),
|
||||
"fixture must actually contain ANSI escapes: {output:?}",
|
||||
);
|
||||
|
||||
let widths: Vec<usize> = output
|
||||
.lines()
|
||||
.map(|line| strip_ansi(line).width())
|
||||
.collect();
|
||||
assert!(!widths.is_empty(), "table rendered no lines");
|
||||
|
||||
for (index, width) in widths.iter().enumerate() {
|
||||
assert_eq!(
|
||||
*width, WRAP_WIDTH as usize,
|
||||
"line {index} display width; all widths were {widths:?} in output:\n{output}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_machine_renders_full_table_and_flushes_on_paragraph() {
|
||||
let options = RenderOptions::default();
|
||||
|
||||
+64
-20
@@ -53,7 +53,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
||||
4. Continue with the next pending item now. Call tools immediately."
|
||||
};
|
||||
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 58]> = LazyLock::new(|| {
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 60]> = LazyLock::new(|| {
|
||||
[
|
||||
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
||||
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
||||
@@ -217,6 +217,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 58]> = LazyLock::new(|| {
|
||||
"Initialize or access RAG",
|
||||
AssertState::False(StateFlags::AGENT),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".rag attach",
|
||||
"Attach to a pre-existing external RAG",
|
||||
AssertState::False(StateFlags::AGENT),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".edit rag-docs",
|
||||
"Add or remove documents from an existing RAG",
|
||||
@@ -278,6 +283,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 58]> = LazyLock::new(|| {
|
||||
"Continue previous response",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".recover",
|
||||
"Recover interrupted session after API error or Ctrl+C",
|
||||
AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".regenerate",
|
||||
"Regenerate last response",
|
||||
@@ -879,9 +889,17 @@ pub async fn run_repl_command(
|
||||
let version = args.map(|s| s.trim().to_string());
|
||||
task::spawn_blocking(move || config::run_self_update(version, false)).await??;
|
||||
}
|
||||
".rag" => {
|
||||
ctx.use_rag(args, abort_signal.clone()).await?;
|
||||
}
|
||||
".rag" => match split_first_arg(args) {
|
||||
Some(("attach", rest)) => match rest {
|
||||
Some(name) if !name.trim().is_empty() => {
|
||||
ctx.attach_rag(name.trim()).await?;
|
||||
}
|
||||
_ => println!("Usage: .rag attach <name>"),
|
||||
},
|
||||
_ => {
|
||||
ctx.use_rag(args, abort_signal.clone()).await?;
|
||||
}
|
||||
},
|
||||
".agent" => match split_first_arg(args) {
|
||||
Some((agent_name, args)) => {
|
||||
let (new_args, _) = split_args_text(args.unwrap_or_default(), cfg!(windows));
|
||||
@@ -1101,6 +1119,21 @@ pub async fn run_repl_command(
|
||||
input.set_continue_output(&output);
|
||||
ask(ctx, abort_signal.clone(), input, true).await?;
|
||||
}
|
||||
".recover" => {
|
||||
let has_recoverable = ctx
|
||||
.last_message
|
||||
.as_ref()
|
||||
.map(|v| v.continuous && v.input.with_session())
|
||||
.unwrap_or(false);
|
||||
if !has_recoverable {
|
||||
bail!("Unable to recover: no interrupted session response to recover from");
|
||||
}
|
||||
let recovery_text = args
|
||||
.unwrap_or("Please continue from where you left off.")
|
||||
.to_string();
|
||||
let recovery_input = Input::from_str(ctx, &recovery_text, None)?;
|
||||
ask(ctx, abort_signal.clone(), recovery_input, false).await?;
|
||||
}
|
||||
".regenerate" => {
|
||||
let LastMessage { mut input, .. } =
|
||||
match ctx.last_message.as_ref().filter(|v| v.continuous).cloned() {
|
||||
@@ -1307,18 +1340,28 @@ async fn ask(
|
||||
|
||||
let client = input.create_client()?;
|
||||
ctx.before_chat_completion(&input)?;
|
||||
let (output, tool_results) = if input.stream() {
|
||||
call_chat_completions_streaming(&input, client.as_ref(), ctx, abort_signal.clone()).await?
|
||||
} else {
|
||||
call_chat_completions(
|
||||
&input,
|
||||
true,
|
||||
false,
|
||||
client.as_ref(),
|
||||
ctx,
|
||||
abort_signal.clone(),
|
||||
)
|
||||
.await?
|
||||
let (output, tool_results) = {
|
||||
let result = if input.stream() {
|
||||
call_chat_completions_streaming(&input, client.as_ref(), ctx, abort_signal.clone())
|
||||
.await
|
||||
} else {
|
||||
call_chat_completions(
|
||||
&input,
|
||||
true,
|
||||
false,
|
||||
client.as_ref(),
|
||||
ctx,
|
||||
abort_signal.clone(),
|
||||
)
|
||||
.await
|
||||
};
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
ctx.on_chat_completion_error(app.as_ref(), &input);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
ctx.after_chat_completion(app.as_ref(), &input, &output, &tool_results)?;
|
||||
if !tool_results.is_empty() {
|
||||
@@ -1344,7 +1387,7 @@ async fn ask(
|
||||
}
|
||||
GuardrailAction::NoAction => {}
|
||||
}
|
||||
let do_continue = should_continue(ctx);
|
||||
let do_continue = should_continue(ctx) && !abort_signal.aborted_ctrlc();
|
||||
|
||||
if do_continue {
|
||||
let full_prompt = {
|
||||
@@ -1400,7 +1443,8 @@ async fn ask(
|
||||
.is_some_and(|s| s.needs_compression(app.compression_threshold));
|
||||
|
||||
if needs_compression {
|
||||
let agent_can_continue_after_compress = should_continue(ctx);
|
||||
let agent_can_continue_after_compress =
|
||||
should_continue(ctx) && !abort_signal.aborted_ctrlc();
|
||||
|
||||
if let Some(session) = ctx.session.as_mut() {
|
||||
session.set_compressing(true);
|
||||
@@ -1680,8 +1724,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_commands_has_58_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 58);
|
||||
fn repl_commands_has_60_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+265
-25
@@ -10,6 +10,7 @@ use sha2::{Digest, Sha256};
|
||||
use crate::config::paths;
|
||||
|
||||
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
||||
const SBX_MIXIN_FILE_SUFFIX: &str = ".sbx-mixin.yaml";
|
||||
const KIT_SPEC_FILE_NAME: &str = "spec.yaml";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -33,8 +34,12 @@ impl DiscoveredMixin {
|
||||
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()))?;
|
||||
wrap_mixin_bytes_as_kit(&bytes, &mixin_path.display().to_string())
|
||||
}
|
||||
|
||||
pub fn wrap_mixin_bytes_as_kit(bytes: &[u8], label: &str) -> Result<PathBuf> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&bytes);
|
||||
hasher.update(bytes);
|
||||
let hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
let kit_dir = paths::sbx_mixin_kits_dir().join(&hash);
|
||||
@@ -48,14 +53,10 @@ pub fn wrap_mixin_as_kit(mixin_path: &Path) -> Result<PathBuf> {
|
||||
|
||||
fs::create_dir_all(&kit_dir)
|
||||
.with_context(|| format!("Failed to create mixin kit dir {}", kit_dir.display()))?;
|
||||
fs::write(&spec_path, &bytes)
|
||||
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()
|
||||
);
|
||||
debug!("Wrapped mixin {label} as kit at {}", kit_dir.display());
|
||||
|
||||
Ok(kit_dir)
|
||||
}
|
||||
@@ -66,10 +67,16 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
|
||||
push_if_exists(&mut out, paths::sbx_mixin_file())?;
|
||||
push_if_exists(&mut out, paths::global_tools_sbx_mixin_file())?;
|
||||
|
||||
for path in collect_subdir_mixins(&paths::functions_dir()) {
|
||||
for path in collect_mixins(&paths::functions_dir(), &[ScanMode::SubdirNamed]) {
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
for path in collect_subdir_mixins(&paths::agents_data_dir()) {
|
||||
for path in collect_mixins(
|
||||
&paths::agents_data_dir(),
|
||||
&[ScanMode::SubdirNamed, ScanMode::SubdirFlat],
|
||||
) {
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
for path in collect_mixins(&paths::rags_dir(), &[ScanMode::Flat]) {
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
|
||||
@@ -89,15 +96,18 @@ pub fn summarize(path: &Path) -> Result<(usize, usize)> {
|
||||
.with_context(|| format!("Failed to parse sbx mixin {}", path.display()))?;
|
||||
|
||||
let installs = value
|
||||
.get("commands")
|
||||
.and_then(|c| c.get("install"))
|
||||
.get("setup")
|
||||
.and_then(|s| s.get("install"))
|
||||
.or_else(|| value.get("commands").and_then(|c| c.get("install")))
|
||||
.and_then(|i| i.as_sequence())
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let domains = value
|
||||
.get("network")
|
||||
.and_then(|n| n.get("allowedDomains"))
|
||||
.get("permissions")
|
||||
.and_then(|p| p.get("network"))
|
||||
.and_then(|n| n.get("allow"))
|
||||
.or_else(|| value.get("network").and_then(|n| n.get("allowedDomains")))
|
||||
.and_then(|d| d.as_sequence())
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0);
|
||||
@@ -153,7 +163,73 @@ fn read_mixin(path: PathBuf) -> Result<DiscoveredMixin> {
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_subdir_mixins(dir: &Path) -> Vec<PathBuf> {
|
||||
/// One on-disk layout a mixin scan can look for. A scan takes a set of these,
|
||||
/// and each mode contributes only the shape it names.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ScanMode {
|
||||
/// `<dir>/*.sbx-mixin.yaml`
|
||||
Flat,
|
||||
/// `<dir>/*/sbx-mixin.yaml`
|
||||
SubdirNamed,
|
||||
/// `<dir>/*/*.sbx-mixin.yaml`
|
||||
SubdirFlat,
|
||||
}
|
||||
|
||||
/// Collects mixin paths under `dir` for every requested layout. Missing or
|
||||
/// unreadable directories yield nothing rather than an error — these paths are
|
||||
/// all optional on disk.
|
||||
///
|
||||
/// Order is deterministic: flat matches first (sorted by file name), then each
|
||||
/// subdirectory in sorted order, contributing its named mixin before its
|
||||
/// suffixed ones.
|
||||
fn collect_mixins(dir: &Path, modes: &[ScanMode]) -> Vec<PathBuf> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
if modes.contains(&ScanMode::Flat) {
|
||||
result.extend(suffixed_mixins_in(dir));
|
||||
}
|
||||
|
||||
let named = modes.contains(&ScanMode::SubdirNamed);
|
||||
let subdir_flat = modes.contains(&ScanMode::SubdirFlat);
|
||||
if !named && !subdir_flat {
|
||||
return result;
|
||||
}
|
||||
|
||||
for subdir in subdirs_of(dir) {
|
||||
if named {
|
||||
let candidate = subdir.join(SBX_MIXIN_FILE_NAME);
|
||||
if candidate.exists() {
|
||||
result.push(candidate);
|
||||
}
|
||||
}
|
||||
if subdir_flat {
|
||||
result.extend(suffixed_mixins_in(&subdir));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn suffixed_mixins_in(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut result = Vec::new();
|
||||
let Ok(rd) = read_dir(dir) else { return result };
|
||||
|
||||
let mut entries: Vec<_> = rd
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|n| n.ends_with(SBX_MIXIN_FILE_SUFFIX))
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
result.extend(entries.into_iter().map(|e| e.path()));
|
||||
result
|
||||
}
|
||||
|
||||
fn subdirs_of(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut result = Vec::new();
|
||||
let Ok(rd) = read_dir(dir) else { return result };
|
||||
|
||||
@@ -163,13 +239,7 @@ fn collect_subdir_mixins(dir: &Path) -> Vec<PathBuf> {
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let candidate = entry.path().join(SBX_MIXIN_FILE_NAME);
|
||||
if candidate.exists() {
|
||||
result.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
result.extend(entries.into_iter().map(|e| e.path()));
|
||||
result
|
||||
}
|
||||
|
||||
@@ -189,6 +259,13 @@ mod tests {
|
||||
root
|
||||
}
|
||||
|
||||
fn file_names(paths: &[PathBuf]) -> Vec<&str> {
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| p.file_name().unwrap().to_str().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_counts_installs_and_domains() {
|
||||
let root = unique_root("sbx-mixin-counts");
|
||||
@@ -196,6 +273,34 @@ mod tests {
|
||||
fs::write(
|
||||
&path,
|
||||
r#"
|
||||
schemaVersion: "2"
|
||||
kind: mixin
|
||||
setup:
|
||||
install:
|
||||
- command: "echo hi"
|
||||
- command: "echo bye"
|
||||
permissions:
|
||||
network:
|
||||
allow:
|
||||
- "a.example.com:443"
|
||||
- "b.example.com:443"
|
||||
- "c.example.com:443"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(summarize(&path).unwrap(), (2, 3));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_falls_back_to_v1_field_paths() {
|
||||
let root = unique_root("sbx-mixin-counts-v1");
|
||||
let path = root.join("sbx-mixin.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
commands:
|
||||
@@ -244,7 +349,7 @@ network:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_subdir_mixins_sorts_and_skips_missing() {
|
||||
fn subdir_named_scan_sorts_and_skips_missing() {
|
||||
let root = unique_root("sbx-mixin-subdirs");
|
||||
for name in ["zebra", "apple", "no-mixin", "mango"] {
|
||||
let dir = root.join(name);
|
||||
@@ -254,7 +359,7 @@ network:
|
||||
}
|
||||
}
|
||||
|
||||
let found = collect_subdir_mixins(&root);
|
||||
let found = collect_mixins(&root, &[ScanMode::SubdirNamed]);
|
||||
let names: Vec<String> = found
|
||||
.iter()
|
||||
.map(|p| {
|
||||
@@ -272,9 +377,9 @@ network:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_subdir_mixins_returns_empty_for_missing_dir() {
|
||||
fn subdir_named_scan_returns_empty_for_missing_dir() {
|
||||
let absent = env::temp_dir().join("coyote-definitely-not-here-xyz");
|
||||
let found = collect_subdir_mixins(&absent);
|
||||
let found = collect_mixins(&absent, &[ScanMode::SubdirNamed]);
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
|
||||
@@ -343,6 +448,19 @@ network:
|
||||
assert_eq!(fs::read_to_string(&spec).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn wrap_mixin_bytes_as_kit_writes_spec_yaml() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let content = b"schemaVersion: '2'\nkind: mixin\nname: generated\n";
|
||||
|
||||
let kit_dir = wrap_mixin_bytes_as_kit(content, "generated").unwrap();
|
||||
let spec = kit_dir.join("spec.yaml");
|
||||
|
||||
assert!(spec.exists(), "spec.yaml must exist in wrapped kit dir");
|
||||
assert_eq!(fs::read(&spec).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn wrap_mixin_as_kit_is_deterministic_for_identical_content() {
|
||||
@@ -439,4 +557,126 @@ network:
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_scan_matches_rag_sidecars_by_suffix() {
|
||||
let root = unique_root("flat-mixins");
|
||||
fs::write(root.join("company-docs.sbx-mixin.yaml"), "kind: mixin\n").unwrap();
|
||||
fs::write(root.join("alpha.sbx-mixin.yaml"), "kind: mixin\n").unwrap();
|
||||
fs::write(root.join("company-docs.yaml"), "driver: qdrant\n").unwrap();
|
||||
fs::write(root.join("notes.yaml"), "driver: yaml\n").unwrap();
|
||||
fs::create_dir_all(root.join("decoy.sbx-mixin.yaml")).unwrap();
|
||||
|
||||
let found = collect_mixins(&root, &[ScanMode::Flat]);
|
||||
assert_eq!(
|
||||
file_names(&found),
|
||||
vec!["alpha.sbx-mixin.yaml", "company-docs.sbx-mixin.yaml"]
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Every scan site in `discover()` picks its modes assuming each mode owns
|
||||
/// exactly one layout and nothing else. `agents_data_dir()` requests two
|
||||
/// modes at once, so an overlap would collect the same file twice and
|
||||
/// `create_sandbox` would pass it as two `--kit` flags.
|
||||
#[test]
|
||||
fn each_scan_mode_owns_exactly_one_layout() {
|
||||
let root = unique_root("scan-mode-ownership");
|
||||
let agent = root.join("researcher");
|
||||
fs::create_dir_all(&agent).unwrap();
|
||||
let flat = root.join("company-docs.sbx-mixin.yaml");
|
||||
let subdir_named = agent.join("sbx-mixin.yaml");
|
||||
let subdir_flat = agent.join("handbook.sbx-mixin.yaml");
|
||||
for path in [&flat, &subdir_named, &subdir_flat] {
|
||||
fs::write(path, "kind: mixin\n").unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(collect_mixins(&root, &[ScanMode::Flat]), vec![flat.clone()]);
|
||||
assert_eq!(
|
||||
collect_mixins(&root, &[ScanMode::SubdirNamed]),
|
||||
vec![subdir_named.clone()]
|
||||
);
|
||||
assert_eq!(
|
||||
collect_mixins(&root, &[ScanMode::SubdirFlat]),
|
||||
vec![subdir_flat.clone()]
|
||||
);
|
||||
|
||||
let all = collect_mixins(
|
||||
&root,
|
||||
&[ScanMode::Flat, ScanMode::SubdirNamed, ScanMode::SubdirFlat],
|
||||
);
|
||||
assert_eq!(all, vec![flat, subdir_named, subdir_flat]);
|
||||
|
||||
let mut deduped = all.clone();
|
||||
deduped.sort();
|
||||
deduped.dedup();
|
||||
assert_eq!(
|
||||
deduped.len(),
|
||||
all.len(),
|
||||
"no mixin may be collected twice: {all:?}"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_scan_tolerates_a_missing_directory() {
|
||||
let root = unique_root("flat-missing");
|
||||
let absent = root.join("nope");
|
||||
assert!(collect_mixins(&absent, &[ScanMode::Flat]).is_empty());
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// `generate_rag_sbx_mixin` writes an agent-scoped RAG sidecar next to the
|
||||
/// rag yaml, at `<agents>/<agent>/<rag>.sbx-mixin.yaml`. Before `SubdirFlat`
|
||||
/// existed, nothing scanned that shape and attaching a Qdrant RAG from
|
||||
/// inside an agent produced no network allow rule and no credential.
|
||||
#[test]
|
||||
fn agent_scoped_rag_sidecar_is_discovered() {
|
||||
let root = unique_root("agent-scoped-rag");
|
||||
let agent = root.join("researcher");
|
||||
fs::create_dir_all(&agent).unwrap();
|
||||
fs::write(agent.join("company-docs.sbx-mixin.yaml"), "kind: mixin\n").unwrap();
|
||||
fs::write(agent.join("company-docs.yaml"), "driver: qdrant\n").unwrap();
|
||||
|
||||
let found = collect_mixins(&root, &[ScanMode::SubdirNamed, ScanMode::SubdirFlat]);
|
||||
assert_eq!(found, vec![agent.join("company-docs.sbx-mixin.yaml")]);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_level_mixin_and_rag_sidecars_are_both_discovered() {
|
||||
let root = unique_root("agent-both-shapes");
|
||||
let agent = root.join("researcher");
|
||||
fs::create_dir_all(&agent).unwrap();
|
||||
fs::write(agent.join("sbx-mixin.yaml"), "kind: mixin\n").unwrap();
|
||||
fs::write(agent.join("zebra.sbx-mixin.yaml"), "kind: mixin\n").unwrap();
|
||||
fs::write(agent.join("alpha.sbx-mixin.yaml"), "kind: mixin\n").unwrap();
|
||||
|
||||
let found = collect_mixins(&root, &[ScanMode::SubdirNamed, ScanMode::SubdirFlat]);
|
||||
assert_eq!(
|
||||
file_names(&found),
|
||||
vec![
|
||||
"sbx-mixin.yaml",
|
||||
"alpha.sbx-mixin.yaml",
|
||||
"zebra.sbx-mixin.yaml"
|
||||
]
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subdir_flat_scan_ignores_a_directory_named_like_a_mixin() {
|
||||
let root = unique_root("subdir-flat-decoy");
|
||||
let agent = root.join("researcher");
|
||||
fs::create_dir_all(agent.join("decoy.sbx-mixin.yaml")).unwrap();
|
||||
|
||||
assert!(collect_mixins(&root, &[ScanMode::SubdirFlat]).is_empty());
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
+211
-45
@@ -10,12 +10,17 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use which::which;
|
||||
|
||||
pub(crate) mod mcp_credentials;
|
||||
mod mixins;
|
||||
|
||||
pub(crate) use mcp_credentials::sandbox_secret_env_var;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::config::Config;
|
||||
use crate::config::VAULT_DATA_FILE_NAME;
|
||||
use crate::config::paths;
|
||||
use crate::rag::RagData;
|
||||
use crate::sandbox::mcp_credentials::MCP_MIXIN_NAME;
|
||||
use crate::sandbox::mixins::DiscoveredMixin;
|
||||
use crate::utils::run_command_with_output;
|
||||
use crate::vault::SECRET_RE;
|
||||
@@ -50,16 +55,22 @@ pub fn launch(name: Option<String>, fresh: bool) -> Result<()> {
|
||||
let registered = sbx_registered_services()?;
|
||||
inject_llm_secret(&config_content, &vault, ®istered)?;
|
||||
if !fresh {
|
||||
inject_mcp_secrets(&vault, ®istered)?;
|
||||
inject_rag_secrets(&vault, ®istered)?;
|
||||
}
|
||||
|
||||
let credentials_mixin = if fresh {
|
||||
None
|
||||
} else {
|
||||
inject_mcp_secrets(&vault, ®istered)?
|
||||
};
|
||||
|
||||
let discovered = mixins::discover()?;
|
||||
|
||||
if sandbox_exists(&name)? {
|
||||
info!("Re-attaching to existing sandbox '{name}'");
|
||||
} else {
|
||||
mixins::log_discovery(&discovered, false);
|
||||
create_sandbox(&name, &kit_path, &discovered)?;
|
||||
create_sandbox(&name, &kit_path, &discovered, credentials_mixin.as_deref())?;
|
||||
if !fresh {
|
||||
copy_host_files(&name)?;
|
||||
}
|
||||
@@ -232,7 +243,7 @@ fn inject_llm_secret(
|
||||
if registered.contains(&service) {
|
||||
eprintln!(
|
||||
"Secret for '{service}' already registered with sbx. \
|
||||
To update it, run: sbx secret set -g --force {service}"
|
||||
To update it, run: sbx secret set --force {service}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -247,23 +258,14 @@ fn inject_llm_secret(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_secret_placeholder(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(s) => SECRET_RE
|
||||
.captures(s)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|caps| caps[1].to_string()),
|
||||
Value::Object(map) => map.values().find_map(find_secret_placeholder),
|
||||
Value::Array(arr) => arr.iter().find_map(find_secret_placeholder),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn inject_mcp_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<()> {
|
||||
/// Registers one sbx secret per distinct `{{placeholder}}` in the MCP config
|
||||
/// and returns the generated schema-v2 `coyote-mcp` mixin (network egress for
|
||||
/// every remote MCP server + credential declarations), or `None` when the MCP
|
||||
/// config references no remote servers and no secrets.
|
||||
fn inject_mcp_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<Option<String>> {
|
||||
let mcp_path = paths::mcp_config_file();
|
||||
if !mcp_path.exists() {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&mcp_path)
|
||||
@@ -272,30 +274,94 @@ fn inject_mcp_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<()>
|
||||
.with_context(|| format!("Failed to parse {}", mcp_path.display()))?;
|
||||
|
||||
let Some(servers) = mcp.get("mcpServers").and_then(|v| v.as_object()) else {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
for (server_name, server_config) in servers {
|
||||
let Some(secret_name) = find_secret_placeholder(server_config) else {
|
||||
continue;
|
||||
};
|
||||
let credentials = mcp_credentials::collect_credentials(servers)?;
|
||||
let allow_entries = mcp_credentials::collect_server_allow_entries(servers);
|
||||
if credentials.is_empty() && allow_entries.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if registered.contains(server_name.as_str()) {
|
||||
for credential in &credentials {
|
||||
if registered.contains(credential.service_id.as_str()) {
|
||||
eprintln!(
|
||||
"Secret for '{server_name}' already registered with sbx. \
|
||||
To update it, run: sbx secret set -g --force {server_name}"
|
||||
"Secret for '{}' already registered with sbx. \
|
||||
To update it, run: sbx secret set --force {}",
|
||||
credential.service_id, credential.service_id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let secret_value = vault.get_secret(&secret_name, false).with_context(|| {
|
||||
format!(
|
||||
"Secret '{secret_name}' referenced by MCP server '{server_name}' not found \
|
||||
in vault. Add it with: coyote --add-secret {secret_name}"
|
||||
)
|
||||
})?;
|
||||
let secret_value = vault
|
||||
.get_secret(&credential.secret_name, false)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Secret '{}' referenced by MCP server(s) {} not found \
|
||||
in vault. Add it with: coyote --add-secret {}",
|
||||
credential.secret_name,
|
||||
mcp_credentials::quoted_list(&credential.servers),
|
||||
credential.secret_name
|
||||
)
|
||||
})?;
|
||||
|
||||
sbx_secret_set(server_name, &secret_value)?;
|
||||
sbx_secret_set(&credential.service_id, &secret_value)?;
|
||||
}
|
||||
|
||||
Ok(Some(mcp_credentials::render_mixin_yaml(
|
||||
&credentials,
|
||||
&allow_entries,
|
||||
)?))
|
||||
}
|
||||
|
||||
fn inject_rag_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<()> {
|
||||
let rags_dir = paths::rags_dir();
|
||||
if !rags_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(&rags_dir)?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
|
||||
continue;
|
||||
}
|
||||
let stem = match path.file_stem().and_then(|s| s.to_str()) {
|
||||
Some(s) if !paths::is_rag_sidecar_name(s) => s.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
let Ok(raw) = fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(data) = serde_yaml::from_str::<RagData>(&raw) else {
|
||||
continue;
|
||||
};
|
||||
if !data.attached {
|
||||
continue;
|
||||
}
|
||||
let Some(placeholder) = data.driver_config.get("api_key") else {
|
||||
continue;
|
||||
};
|
||||
let service_id = mcp_credentials::secret_service_id(&stem);
|
||||
if service_id.is_empty() || registered.contains(&service_id) {
|
||||
continue;
|
||||
}
|
||||
let secret_name = placeholder
|
||||
.trim_start_matches("{{")
|
||||
.trim_end_matches("}}")
|
||||
.trim();
|
||||
|
||||
match vault.get_secret(secret_name, false) {
|
||||
Ok(secret_value) => {
|
||||
sbx_secret_set(&service_id, &secret_value)
|
||||
.context("Failed to register RAG secret with sbx")?;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Warning: could not load secret '{secret_name}' for RAG '{stem}': {e}. \
|
||||
Queries to this RAG will fail inside the sandbox. \
|
||||
Run `coyote --add-secret {secret_name}` to fix."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -305,7 +371,7 @@ fn provider_to_sbx_service(provider_type: &str, client_name: Option<&str>) -> St
|
||||
match provider_type {
|
||||
"claude" => "anthropic".to_string(),
|
||||
"openai" => "openai".to_string(),
|
||||
"gemini" | "vertexai" => "google".to_string(),
|
||||
"gemini" | "vertexai" => "gemini".to_string(),
|
||||
"openai-compatible" => client_name.unwrap_or("openai-compatible").to_string(),
|
||||
other => client_name.unwrap_or(other).to_string(),
|
||||
}
|
||||
@@ -338,25 +404,29 @@ fn sbx_registered_services() -> Result<HashSet<String>> {
|
||||
|
||||
fn sbx_secret_set(service: &str, secret_value: &str) -> Result<()> {
|
||||
let mut child = Command::new(SBX_BINARY)
|
||||
.args(["secret", "set", "-g", service])
|
||||
.args(["secret", "set", service])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.context("Failed to spawn `sbx secret set -g`")?;
|
||||
.context("Failed to spawn `sbx secret set`")?;
|
||||
|
||||
if let Some(mut stdin_handle) = child.stdin.take() {
|
||||
stdin_handle
|
||||
.write_all(secret_value.as_bytes())
|
||||
.context("Failed to write secret to `sbx secret set -g` stdin")?;
|
||||
.context("Failed to write secret to `sbx secret set` stdin")?;
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.context("Failed to wait for `sbx secret set -g`")?;
|
||||
.context("Failed to wait for `sbx secret set`")?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("`sbx secret set -g {service}` exited with {status}");
|
||||
eprintln!(
|
||||
"Warning: failed to register sbx secret '{service}' \
|
||||
(`sbx secret set {service}` exited with {status}). \
|
||||
Set it manually with: echo '<value>' | sbx secret set {service}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -375,9 +445,17 @@ fn sandbox_exists(name: &str) -> Result<bool> {
|
||||
.any(|line| line.split_whitespace().next() == Some(name)))
|
||||
}
|
||||
|
||||
fn create_sandbox(name: &str, kit_path: &Path, mixins: &[DiscoveredMixin]) -> Result<()> {
|
||||
fn create_sandbox(
|
||||
name: &str,
|
||||
kit_path: &Path,
|
||||
mixins: &[DiscoveredMixin],
|
||||
credentials_mixin: Option<&str>,
|
||||
) -> Result<()> {
|
||||
info!("Creating sandbox '{name}'");
|
||||
let args = build_create_args(name, kit_path, mixins)?;
|
||||
let credentials_kit = credentials_mixin
|
||||
.map(|yaml| mixins::wrap_mixin_bytes_as_kit(yaml.as_bytes(), MCP_MIXIN_NAME))
|
||||
.transpose()?;
|
||||
let args = build_create_args(name, kit_path, mixins, credentials_kit.as_deref())?;
|
||||
debug!("sbx {}", args.join(" "));
|
||||
let status = Command::new(SBX_BINARY)
|
||||
.args(&args)
|
||||
@@ -398,6 +476,7 @@ fn build_create_args(
|
||||
name: &str,
|
||||
kit_path: &Path,
|
||||
mixins: &[DiscoveredMixin],
|
||||
credentials_kit: Option<&Path>,
|
||||
) -> Result<Vec<String>> {
|
||||
let kit_str = kit_path
|
||||
.to_str()
|
||||
@@ -421,6 +500,15 @@ fn build_create_args(
|
||||
args.push(mixin_str);
|
||||
}
|
||||
|
||||
if let Some(kit) = credentials_kit {
|
||||
let cred_str = kit
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Credentials kit path is not valid UTF-8: {}", kit.display()))?
|
||||
.to_string();
|
||||
args.push("--kit".to_string());
|
||||
args.push(cred_str);
|
||||
}
|
||||
|
||||
args.push(SANDBOX_AGENT.to_string());
|
||||
args.push(".".to_string());
|
||||
|
||||
@@ -558,6 +646,7 @@ fn chown_agent_recursive(sandbox: &str, path: &str) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn sanitize_name_lowercases() {
|
||||
@@ -626,8 +715,8 @@ 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)
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir_a = env::temp_dir().join(format!("coyote-mixin-a-{unique}"));
|
||||
@@ -650,7 +739,7 @@ mod tests {
|
||||
},
|
||||
];
|
||||
|
||||
let args = build_create_args("my-box", &kit, &mixins).unwrap();
|
||||
let args = build_create_args("my-box", &kit, &mixins, None).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
@@ -676,7 +765,9 @@ mod tests {
|
||||
#[test]
|
||||
fn build_create_args_with_no_mixins_omits_mixin_kits() {
|
||||
let kit = PathBuf::from("/cache/sbx-kit");
|
||||
let args = build_create_args("box", &kit, &[]).unwrap();
|
||||
|
||||
let args = build_create_args("box", &kit, &[], None).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
@@ -690,4 +781,79 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_create_args_appends_credentials_kit_after_mixins() {
|
||||
let kit = PathBuf::from("/cache/sbx-kit");
|
||||
let credentials_kit = PathBuf::from("/cache/sbx-mixin-kits/abc123");
|
||||
|
||||
let args = build_create_args("box", &kit, &[], Some(&credentials_kit)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"create".to_string(),
|
||||
"--name".to_string(),
|
||||
"box".to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cache/sbx-kit".to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cache/sbx-mixin-kits/abc123".to_string(),
|
||||
"coyote".to_string(),
|
||||
".".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_create_args_orders_base_kit_then_mixins_then_credentials_kit() {
|
||||
let kit = PathBuf::from("/cache/sbx-kit");
|
||||
let credentials_kit = PathBuf::from("/cache/sbx-mixin-kits/abc123");
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir = env::temp_dir().join(format!("coyote-mixin-cred-{unique}"));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let mixins = vec![DiscoveredMixin {
|
||||
path: dir.clone(),
|
||||
label: "user".into(),
|
||||
install_count: 0,
|
||||
domain_count: 0,
|
||||
}];
|
||||
|
||||
let args = build_create_args("box", &kit, &mixins, Some(&credentials_kit)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"create".to_string(),
|
||||
"--name".to_string(),
|
||||
"box".to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cache/sbx-kit".to_string(),
|
||||
"--kit".to_string(),
|
||||
dir.display().to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cache/sbx-mixin-kits/abc123".to_string(),
|
||||
"coyote".to_string(),
|
||||
".".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_to_sbx_service_maps_gemini_family_to_gemini() {
|
||||
assert_eq!(provider_to_sbx_service("gemini", None), "gemini");
|
||||
assert_eq!(provider_to_sbx_service("vertexai", None), "gemini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_to_sbx_service_maps_known_providers() {
|
||||
assert_eq!(provider_to_sbx_service("claude", None), "anthropic");
|
||||
assert_eq!(provider_to_sbx_service("openai", None), "openai");
|
||||
}
|
||||
}
|
||||
|
||||
+27
-2
@@ -1,5 +1,5 @@
|
||||
use crate::config::ensure_parent_exists;
|
||||
use crate::sandbox::SANDBOX_ENV_FLAG;
|
||||
use crate::sandbox::{SANDBOX_ENV_FLAG, sandbox_secret_env_var};
|
||||
use crate::vault::{SECRET_RE, Vault};
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
@@ -358,7 +358,32 @@ fn required_cli_preflight(label: &str, cli: &str, install_url: &str) {
|
||||
|
||||
pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec<String>)> {
|
||||
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
||||
return Ok((content.to_string(), vec![]));
|
||||
let (parsed, missing) = interpolate_secrets_with(content, None, |name| {
|
||||
env::var(sandbox_secret_env_var(name)).map_err(|_| {
|
||||
anyhow!(SecretError::NotFound {
|
||||
key: name.to_string(),
|
||||
provider: "sandbox environment",
|
||||
})
|
||||
})
|
||||
})?;
|
||||
|
||||
if !missing.is_empty() {
|
||||
let mut env_vars: Vec<String> = missing
|
||||
.iter()
|
||||
.map(|name| sandbox_secret_env_var(name))
|
||||
.collect();
|
||||
env_vars.sort();
|
||||
env_vars.dedup();
|
||||
eprintln!(
|
||||
"Config references secrets that are not available inside this sandbox \
|
||||
(expected env vars: {}). Sandbox secrets are provisioned at creation \
|
||||
from the host; add the missing secrets on the host, then re-create \
|
||||
the sandbox.",
|
||||
env_vars.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
return Ok((parsed, missing));
|
||||
}
|
||||
interpolate_secrets_with(content, vault.auth_hint(), |name| {
|
||||
vault.get_secret(name, false)
|
||||
|
||||
Reference in New Issue
Block a user