Merge remote-tracking branch 'refs/remotes/origin/main'
This commit is contained in:
@@ -40,15 +40,57 @@ _write_project_cache() {
|
|||||||
_detect_heuristic() {
|
_detect_heuristic() {
|
||||||
local dir="$1"
|
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
|
# Rust
|
||||||
if [[ -f "${dir}/Cargo.toml" ]]; then
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
if [[ -f "${dir}/go.mod" ]]; then
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -65,7 +107,25 @@ _detect_heuristic() {
|
|||||||
[[ -f "${dir}/pnpm-lock.yaml" ]] && pm="pnpm"
|
[[ -f "${dir}/pnpm-lock.yaml" ]] && pm="pnpm"
|
||||||
[[ -f "${dir}/yarn.lock" ]] && pm="yarn"
|
[[ -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
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -82,7 +142,7 @@ _detect_heuristic() {
|
|||||||
check_cmd="uv run ruff check ."
|
check_cmd="uv run ruff check ."
|
||||||
fi
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -144,17 +204,6 @@ _detect_heuristic() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
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
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +267,9 @@ _detect_with_llm() {
|
|||||||
local prompt
|
local prompt
|
||||||
prompt=$(cat <<-EOF
|
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
|
EOF
|
||||||
)
|
)
|
||||||
@@ -226,12 +277,12 @@ _detect_with_llm() {
|
|||||||
prompt+=$(cat <<-EOF
|
prompt+=$(cat <<-EOF
|
||||||
|
|
||||||
Respond with ONLY a valid JSON object. No markdown fences, no explanation, no extra text.
|
Respond with ONLY a valid JSON object. No markdown fences, no explanation, no extra text.
|
||||||
The JSON must have exactly these 4 keys:
|
The JSON must have exactly these 6 keys:
|
||||||
{"type":"<language>","build":"<build command>","test":"<test command>","check":"<lint or typecheck command>"}
|
{"type":"<language>","build":"<build command>","test":"<test command>","check":"<typecheck/vet command>","lint":"<lint command>","fmt":"<format command>"}
|
||||||
|
|
||||||
Rules:
|
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.)
|
- "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
|
- 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)
|
- If you detect a package manager lockfile, use that package manager (e.g. pnpm over npm)
|
||||||
EOF
|
EOF
|
||||||
@@ -244,7 +295,7 @@ _detect_with_llm() {
|
|||||||
llm_response=$(echo "${llm_response}" | grep -o '{[^}]*}' | head -1)
|
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
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -258,7 +309,7 @@ detect_project() {
|
|||||||
|
|
||||||
local cached
|
local cached
|
||||||
if cached=$(_read_project_cache "${dir}"); then
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -286,6 +337,31 @@ detect_project() {
|
|||||||
echo '{"type":"unknown","build":"","test":"","check":""}'
|
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 ##
|
## FILE SEARCH UTILITIES ##
|
||||||
###########################
|
###########################
|
||||||
|
|||||||
@@ -227,6 +227,11 @@ nodes:
|
|||||||
on unfamiliar lints, etc.).
|
on unfamiliar lints, etc.).
|
||||||
4. No dead code, no commented-out blocks, no premature abstractions.
|
4. No dead code, no commented-out blocks, no premature abstractions.
|
||||||
5. End your turn when editing is done. The graph runs verification next.
|
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}}
|
Project directory: {{project_dir}}
|
||||||
prompt: |
|
prompt: |
|
||||||
@@ -248,7 +253,7 @@ nodes:
|
|||||||
- fs_write
|
- fs_write
|
||||||
- fs_patch
|
- fs_patch
|
||||||
- execute_command
|
- execute_command
|
||||||
max_iterations: 30
|
max_iterations: 100
|
||||||
state_updates:
|
state_updates:
|
||||||
last_node_output: '{{output}}'
|
last_node_output: '{{output}}'
|
||||||
fallback: end_failure
|
fallback: end_failure
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
||||||
|
project_dir=$(resolve_gate_dir "$project_dir")
|
||||||
|
|
||||||
if [[ -n "${BUILD_CMD:-}" ]]; then
|
if [[ -n "${BUILD_CMD:-}" ]]; then
|
||||||
cmd="$BUILD_CMD"
|
cmd="$BUILD_CMD"
|
||||||
@@ -24,7 +25,7 @@ fi
|
|||||||
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
|
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
|
||||||
jq -nc '{
|
jq -nc '{
|
||||||
"build_ok": true,
|
"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"
|
"_next": "verify_tests"
|
||||||
}'
|
}'
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
||||||
|
project_dir=$(resolve_gate_dir "$project_dir")
|
||||||
|
|
||||||
if [[ -n "${TEST_CMD:-}" ]]; then
|
if [[ -n "${TEST_CMD:-}" ]]; then
|
||||||
cmd="$TEST_CMD"
|
cmd="$TEST_CMD"
|
||||||
@@ -24,7 +25,7 @@ fi
|
|||||||
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
|
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
|
||||||
jq -nc '{
|
jq -nc '{
|
||||||
"tests_ok": true,
|
"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"
|
"_next": "self_review"
|
||||||
}'
|
}'
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -266,6 +266,12 @@ instructions: |
|
|||||||
|
|
||||||
**No evidence = not complete.** Mark a todo `completed` only after evidence is collected.
|
**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)
|
### 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:
|
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
|
kind: mixin
|
||||||
name: sisyphus-ddg
|
name: sisyphus-ddg
|
||||||
description: >
|
description: >
|
||||||
Allows Sisyphus to hit all domains since it utilizes the DuckDuckGo
|
Allows Sisyphus to reach DuckDuckGo plus a curated set of common
|
||||||
MCP server. This allows the MCP server to actually perform web searches
|
content domains for its web-search MCP server. Schema v2 removed
|
||||||
on arbitrary domains and retrieve info for the agent.
|
the bare '*' allow-all, so frequently fetched result domains are
|
||||||
|
enumerated here.
|
||||||
|
|
||||||
network:
|
agentInstructions:
|
||||||
allowedDomains:
|
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
|
staleness report, gate decisions, and fix loop history. Downstream
|
||||||
plan updates come from the sweep results.
|
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}}
|
Then append durable, step-independent facts (if any) to {{notes_path}}
|
||||||
- create the file if missing, never rewrite existing entries.
|
- create the file if missing, never rewrite existing entries.
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
||||||
|
project_dir=$(resolve_gate_dir "$project_dir")
|
||||||
|
|
||||||
if [[ -n "${BUILD_CMD:-}" ]]; then
|
if [[ -n "${BUILD_CMD:-}" ]]; then
|
||||||
cmd="$BUILD_CMD"
|
cmd="$BUILD_CMD"
|
||||||
@@ -24,7 +25,7 @@ fi
|
|||||||
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
|
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
|
||||||
jq -nc '{
|
jq -nc '{
|
||||||
"build_ok": true,
|
"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"
|
"_next": "verify_tests"
|
||||||
}'
|
}'
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -13,19 +13,18 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
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:-}"
|
format_cmd="${FORMAT_CMD:-}"
|
||||||
if [[ -z "$format_cmd" ]]; then
|
if [[ -z "$format_cmd" ]]; then
|
||||||
case "$project_type" in
|
format_cmd=$(echo "$project_info" | jq -r '.fmt // ""')
|
||||||
rust) format_cmd="cargo fmt" ;;
|
|
||||||
go) format_cmd="gofmt -w ." ;;
|
|
||||||
python) command -v ruff &>/dev/null && format_cmd="ruff format ." ;;
|
|
||||||
esac
|
|
||||||
fi
|
fi
|
||||||
|
if [[ "$format_cmd" == "null" ]]; then format_cmd=""; fi
|
||||||
|
|
||||||
if [[ -z "$format_cmd" ]]; then
|
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
|
else
|
||||||
fmt_rc=0
|
fmt_rc=0
|
||||||
fmt_out=$(cd "$project_dir" && eval "$format_cmd" 2>&1) || fmt_rc=$?
|
fmt_out=$(cd "$project_dir" && eval "$format_cmd" 2>&1) || fmt_rc=$?
|
||||||
@@ -37,12 +36,18 @@ fi
|
|||||||
|
|
||||||
lint_cmd="${LINT_CMD:-}"
|
lint_cmd="${LINT_CMD:-}"
|
||||||
if [[ -z "$lint_cmd" ]]; then
|
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 \
|
jq -nc \
|
||||||
--arg fo "$format_output" \
|
--arg fo "$format_output" \
|
||||||
'{
|
'{
|
||||||
"format_output": $fo,
|
"format_output": $fo,
|
||||||
"lint_ok": true,
|
"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"
|
"_next": "verify_build"
|
||||||
}'
|
}'
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
project_dir=$(echo "$state" | jq -r '.project_dir // "."')
|
||||||
|
project_dir=$(resolve_gate_dir "$project_dir")
|
||||||
|
|
||||||
if [[ -n "${TEST_CMD:-}" ]]; then
|
if [[ -n "${TEST_CMD:-}" ]]; then
|
||||||
cmd="$TEST_CMD"
|
cmd="$TEST_CMD"
|
||||||
@@ -24,7 +25,7 @@ fi
|
|||||||
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
|
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
|
||||||
jq -nc '{
|
jq -nc '{
|
||||||
"tests_ok": true,
|
"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"
|
"_next": "edge_case_sweep"
|
||||||
}'
|
}'
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
schemaVersion: "1"
|
schemaVersion: '2'
|
||||||
kind: mixin
|
kind: mixin
|
||||||
name: built-in-tools
|
name: built-in-tools
|
||||||
description: >
|
description: >
|
||||||
@@ -6,39 +6,39 @@ description: >
|
|||||||
global tools and the default MCP server set. Auto-applied by Coyote's sbx
|
global tools and the default MCP server set. Auto-applied by Coyote's sbx
|
||||||
mixin discovery when running `coyote --sandbox`.
|
mixin discovery when running `coyote --sandbox`.
|
||||||
|
|
||||||
network:
|
permissions:
|
||||||
allowedDomains:
|
network:
|
||||||
# fetch_url_via_jina + jina reader fallback
|
allow:
|
||||||
- "r.jina.ai:443"
|
# fetch_url_via_jina + jina reader fallback
|
||||||
# get_current_weather (.sh, .py, .ts)
|
- 'r.jina.ai'
|
||||||
- "wttr.in:443"
|
# get_current_weather (.sh, .py, .ts)
|
||||||
# search_arxiv (the .sh tool still uses http://, so :80 is required until fixed)
|
- 'wttr.in'
|
||||||
- "export.arxiv.org:443"
|
# search_arxiv (the .sh tool still uses http://, so :80 is required until fixed)
|
||||||
- "export.arxiv.org:80"
|
- 'export.arxiv.org'
|
||||||
# search_arxiv + search_wikipedia may follow DOI redirects
|
- 'export.arxiv.org:80'
|
||||||
- "doi.org:443"
|
# search_arxiv + search_wikipedia may follow DOI redirects
|
||||||
# search_wikipedia
|
- 'doi.org'
|
||||||
- "en.wikipedia.org:443"
|
# search_wikipedia
|
||||||
# search_wolframalpha
|
- 'en.wikipedia.org'
|
||||||
- "api.wolframalpha.com:443"
|
# search_wolframalpha
|
||||||
# web_search_perplexity
|
- 'api.wolframalpha.com'
|
||||||
- "api.perplexity.ai:443"
|
# web_search_perplexity
|
||||||
# web_search_tavily
|
- 'api.perplexity.ai'
|
||||||
- "api.tavily.com:443"
|
# web_search_tavily
|
||||||
# send_twilio
|
- 'api.tavily.com'
|
||||||
- "api.twilio.com:443"
|
# send_twilio
|
||||||
# MCP: github (built-in mcp.json: api.githubcopilot.com)
|
- 'api.twilio.com'
|
||||||
- "api.githubcopilot.com:443"
|
# MCP: github (built-in mcp.json: api.githubcopilot.com)
|
||||||
# MCP: atlassian (built-in mcp.json: mcp-remote -> mcp.atlassian.com)
|
- 'api.githubcopilot.com'
|
||||||
- "mcp.atlassian.com:443"
|
# MCP: atlassian (built-in mcp.json: mcp-remote -> mcp.atlassian.com)
|
||||||
# MCP: ddg-search (built-in mcp.json: uvx duckduckgo-mcp-server)
|
- 'mcp.atlassian.com'
|
||||||
- "duckduckgo.com:443"
|
# MCP: ddg-search (built-in mcp.json: uvx duckduckgo-mcp-server)
|
||||||
- "html.duckduckgo.com:443"
|
- 'duckduckgo.com'
|
||||||
- "lite.duckduckgo.com:443"
|
- 'html.duckduckgo.com'
|
||||||
# MCP: npx-based servers (mcp-remote) pull from npm
|
- 'lite.duckduckgo.com'
|
||||||
- "registry.npmjs.org:443"
|
# MCP: npx-based servers (mcp-remote) pull from npm
|
||||||
# MCP: docker server may pull images from common registries
|
- 'registry.npmjs.org'
|
||||||
- "ghcr.io:443"
|
# MCP: docker server may pull images from common registries
|
||||||
- "registry-1.docker.io:443"
|
- 'ghcr.io'
|
||||||
- "auth.docker.io:443"
|
- 'registry-1.docker.io'
|
||||||
- "production.cloudflare.docker.com:443"
|
- 'auth.docker.io'
|
||||||
+287
-242
@@ -4,7 +4,7 @@
|
|||||||
# sbx create --kit ./sbx-kit/ coyote --name testing .
|
# sbx create --kit ./sbx-kit/ coyote --name testing .
|
||||||
# sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/
|
# sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/
|
||||||
# sbx run testing --kit ./sbx-kit/
|
# sbx run testing --kit ./sbx-kit/
|
||||||
schemaVersion: '1'
|
schemaVersion: '2'
|
||||||
kind: sandbox
|
kind: sandbox
|
||||||
name: coyote
|
name: coyote
|
||||||
displayName: Coyote
|
displayName: Coyote
|
||||||
@@ -14,198 +14,255 @@ description: >
|
|||||||
|
|
||||||
sandbox:
|
sandbox:
|
||||||
image: 'darkalex17/coyote:v0.8.3'
|
image: 'darkalex17/coyote:v0.8.3'
|
||||||
aiFilename: COYOTE.md
|
entrypoint: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote']
|
||||||
entrypoint:
|
|
||||||
run: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote']
|
|
||||||
|
|
||||||
network:
|
permissions:
|
||||||
# Proxy-managed LLM providers: the proxy substitutes `proxy-managed` for
|
network:
|
||||||
# the env var inside the sandbox and rewrites the auth header per
|
allow:
|
||||||
# serviceAuth at request time. Multiple domains may map to one service
|
# Coyote release + self-update + model-registry sync
|
||||||
# (e.g. jina) so they share a single credential.
|
- 'github.com'
|
||||||
serviceDomains:
|
- 'api.github.com'
|
||||||
api.openai.com: openai
|
- 'raw.githubusercontent.com'
|
||||||
api.anthropic.com: anthropic
|
- 'objects.githubusercontent.com'
|
||||||
generativelanguage.googleapis.com: gemini
|
- '*.githubusercontent.com'
|
||||||
api.cohere.ai: cohere
|
# Package managers and developer tools (cargo, uv, pip — useful at runtime for user installs)
|
||||||
api.groq.com: groq
|
- 'crates.io'
|
||||||
openrouter.ai: openrouter
|
- 'static.crates.io'
|
||||||
api.ai21.com: ai21
|
- 'pypi.org'
|
||||||
api.cloudflare.com: cloudflare
|
- 'files.pythonhosted.org'
|
||||||
api.deepinfra.com: deepinfra
|
- 'astral.sh'
|
||||||
api.deepseek.com: deepseek
|
- 'sh.rustup.rs'
|
||||||
api.mistral.ai: mistral
|
- 'static.rust-lang.org'
|
||||||
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'
|
|
||||||
|
|
||||||
# LLM model OAuth + API endpoints
|
# LLM model OAuth + API endpoints
|
||||||
- 'claude.ai:443'
|
- 'claude.ai'
|
||||||
- 'console.anthropic.com:443'
|
- 'console.anthropic.com'
|
||||||
- 'accounts.google.com:443'
|
- 'accounts.google.com'
|
||||||
# *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints
|
# *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints
|
||||||
# (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI.
|
# (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI.
|
||||||
- '*.googleapis.com:443'
|
- '*.googleapis.com'
|
||||||
|
|
||||||
# Bedrock and GitHub Models use signed / GitHub-PAT auth that the proxy
|
# Bedrock and GitHub Models use signed / GitHub-PAT auth that the proxy
|
||||||
# cannot rewrite. Domains are allow-listed; credentials must be injected
|
# cannot rewrite; credentials must be injected separately (see README
|
||||||
# separately (see README "Extending").
|
# "Extending"). NOTE: '*.amazonaws.com' matches exactly ONE label, so
|
||||||
- '*.amazonaws.com:443'
|
# two-label regional Bedrock hosts must be enumerated explicitly
|
||||||
- 'models.inference.ai.azure.com:443'
|
# ('**.' 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:
|
credentials:
|
||||||
sources:
|
- service: openai
|
||||||
openai:
|
description: OpenAI API key, injected on api.openai.com
|
||||||
env:
|
apiKey:
|
||||||
- OPENAI_API_KEY
|
name: OPENAI_API_KEY
|
||||||
anthropic:
|
proxyManaged: true
|
||||||
env:
|
inject:
|
||||||
- ANTHROPIC_API_KEY
|
- domain: api.openai.com
|
||||||
gemini:
|
scheme: bearer
|
||||||
env:
|
- service: anthropic
|
||||||
- GEMINI_API_KEY
|
description: Anthropic API key, injected as x-api-key on api.anthropic.com
|
||||||
- GOOGLE_API_KEY
|
apiKey:
|
||||||
cohere:
|
name: ANTHROPIC_API_KEY
|
||||||
env:
|
proxyManaged: true
|
||||||
- COHERE_API_KEY
|
inject:
|
||||||
groq:
|
- domain: api.anthropic.com
|
||||||
env:
|
header: x-api-key
|
||||||
- GROQ_API_KEY
|
format: '%s'
|
||||||
openrouter:
|
- service: gemini
|
||||||
env:
|
description: Google Gemini API key, injected as x-goog-api-key on generativelanguage.googleapis.com
|
||||||
- OPENROUTER_API_KEY
|
apiKey:
|
||||||
ai21:
|
name: GEMINI_API_KEY
|
||||||
env:
|
proxyManaged: true
|
||||||
- AI21_API_KEY
|
inject:
|
||||||
cloudflare:
|
- domain: generativelanguage.googleapis.com
|
||||||
env:
|
header: x-goog-api-key
|
||||||
- CLOUDFLARE_API_KEY
|
format: '%s'
|
||||||
deepinfra:
|
- service: cohere
|
||||||
env:
|
description: Cohere API key, injected on api.cohere.ai
|
||||||
- DEEPINFRA_API_KEY
|
apiKey:
|
||||||
deepseek:
|
name: COHERE_API_KEY
|
||||||
env:
|
proxyManaged: true
|
||||||
- DEEPSEEK_API_KEY
|
inject:
|
||||||
mistral:
|
- domain: api.cohere.ai
|
||||||
env:
|
scheme: bearer
|
||||||
- MISTRAL_API_KEY
|
- service: groq
|
||||||
perplexity:
|
description: Groq API key, injected on api.groq.com
|
||||||
env:
|
apiKey:
|
||||||
- PERPLEXITY_API_KEY
|
name: GROQ_API_KEY
|
||||||
voyageai:
|
proxyManaged: true
|
||||||
env:
|
inject:
|
||||||
- VOYAGE_API_KEY
|
- domain: api.groq.com
|
||||||
xai:
|
scheme: bearer
|
||||||
env:
|
- service: openrouter
|
||||||
- XAI_API_KEY
|
description: OpenRouter API key, injected on openrouter.ai
|
||||||
jina:
|
apiKey:
|
||||||
env:
|
name: OPENROUTER_API_KEY
|
||||||
- JINA_API_KEY
|
proxyManaged: true
|
||||||
ernie:
|
inject:
|
||||||
env:
|
- domain: openrouter.ai
|
||||||
- ERNIE_API_KEY
|
scheme: bearer
|
||||||
hunyuan:
|
- service: ai21
|
||||||
env:
|
description: AI21 Labs API key, injected on api.ai21.com
|
||||||
- HUNYUAN_API_KEY
|
apiKey:
|
||||||
minimax:
|
name: AI21_API_KEY
|
||||||
env:
|
proxyManaged: true
|
||||||
- MINIMAX_API_KEY
|
inject:
|
||||||
moonshot:
|
- domain: api.ai21.com
|
||||||
env:
|
scheme: bearer
|
||||||
- MOONSHOT_API_KEY
|
- service: cloudflare
|
||||||
qianwen:
|
description: Cloudflare Workers AI API key, injected on api.cloudflare.com
|
||||||
env:
|
apiKey:
|
||||||
- DASHSCOPE_API_KEY
|
name: CLOUDFLARE_API_KEY
|
||||||
zhipuai:
|
proxyManaged: true
|
||||||
env:
|
inject:
|
||||||
- ZHIPUAI_API_KEY
|
- 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:
|
environment:
|
||||||
variables:
|
variables:
|
||||||
@@ -213,32 +270,14 @@ environment:
|
|||||||
COYOTE_LOG_LEVEL: INFO
|
COYOTE_LOG_LEVEL: INFO
|
||||||
COYOTE_CONFIG_DIR: /home/agent/.config/coyote
|
COYOTE_CONFIG_DIR: /home/agent/.config/coyote
|
||||||
EDITOR: nano
|
EDITOR: nano
|
||||||
proxyManaged:
|
# Alias for the gemini credential: v2 apiKey supports a single env name
|
||||||
- OPENAI_API_KEY
|
# (GEMINI_API_KEY above). Coyote also recognizes GOOGLE_API_KEY, so keep
|
||||||
- ANTHROPIC_API_KEY
|
# it set to the sentinel. Header injection happens per-domain regardless
|
||||||
- GEMINI_API_KEY
|
# of which env var the app reads.
|
||||||
- GOOGLE_API_KEY
|
GOOGLE_API_KEY: proxy-managed
|
||||||
- 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
|
|
||||||
|
|
||||||
commands:
|
setup:
|
||||||
initFiles:
|
files:
|
||||||
- path: /home/agent/.config/git/ssh-signing-key-command
|
- path: /home/agent/.config/git/ssh-signing-key-command
|
||||||
mode: '0755'
|
mode: '0755'
|
||||||
description: Resolve the forwarded SSH agent key for Git SSH signing
|
description: Resolve the forwarded SSH agent key for Git SSH signing
|
||||||
@@ -290,39 +329,45 @@ commands:
|
|||||||
background: false
|
background: false
|
||||||
description: Bootstrap Coyote config directory on first sandbox start
|
description: Bootstrap Coyote config directory on first sandbox start
|
||||||
|
|
||||||
agentContext: |
|
agentInstructions:
|
||||||
## Sandbox environment
|
filename: COYOTE.md
|
||||||
|
content: |
|
||||||
|
## Sandbox environment
|
||||||
|
|
||||||
You are running inside a Docker sandbox launched via `sbx run coyote`. The
|
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
|
user's project workspace is mounted at its absolute host path and is the
|
||||||
current working directory. `sudo` is passwordless; use it for system
|
current working directory. `sudo` is passwordless; use it for system
|
||||||
package installs.
|
package installs.
|
||||||
|
|
||||||
Coyote's configuration lives at `~/.config/coyote/` and logs at
|
Coyote's configuration lives at `~/.config/coyote/` and logs at
|
||||||
`~/.cache/coyote/coyote.log`. Persistence is enabled, so config, sessions,
|
`~/.cache/coyote/coyote.log`. Persistence is enabled, so config, sessions,
|
||||||
vault state, OAuth tokens, and installed tools survive sandbox restarts.
|
vault state, OAuth tokens, and installed tools survive sandbox restarts.
|
||||||
|
|
||||||
LLM provider credentials are forwarded by the sandbox HTTP proxy. The
|
LLM provider credentials are forwarded by the sandbox HTTP proxy via
|
||||||
following provider env vars are recognized - export the ones you use on
|
credential bindings. Coyote pre-seeds them from its vault at launch
|
||||||
the host before running `sbx run coyote`:
|
(`sbx secret set <service>`); users can also bind values manually on the
|
||||||
|
host with `sbx secret set <service>` or `sbx secret import`. Recognized
|
||||||
|
services:
|
||||||
|
|
||||||
OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY / GOOGLE_API_KEY,
|
openai, anthropic, gemini, cohere, groq, openrouter, ai21,
|
||||||
COHERE_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, AI21_API_KEY,
|
cloudflare, deepinfra, deepseek, mistral, perplexity, voyageai,
|
||||||
CLOUDFLARE_API_KEY, DEEPINFRA_API_KEY, DEEPSEEK_API_KEY,
|
xai, jina, ernie, hunyuan, minimax, moonshot, qianwen, zhipuai
|
||||||
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 these appear as the placeholder string `proxy-managed`;
|
Inside the sandbox the corresponding env vars (OPENAI_API_KEY, etc.)
|
||||||
the proxy substitutes the real value at request time. OAuth flows for
|
hold the placeholder string `proxy-managed`; the proxy substitutes the
|
||||||
Claude Pro/Max and Gemini are also allow-listed.
|
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
|
Bedrock (AWS) and VertexAI (Google Cloud) use signed/OAuth-token requests
|
||||||
that the proxy cannot rewrite. Their domains are allow-listed but you must
|
that the proxy cannot rewrite, so you must inject credentials yourself via
|
||||||
inject credentials yourself via `sbx run --env AWS_ACCESS_KEY_ID=...` or
|
`sbx run --env AWS_ACCESS_KEY_ID=...` or a mixin kit that mounts a
|
||||||
a mixin kit that mounts a service-account JSON.
|
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`).
|
||||||
|
|
||||||
Useful first-run commands:
|
Useful first-run commands:
|
||||||
- `coyote --info` # show config paths and resolved settings
|
- `coyote --info` # show config paths and resolved settings
|
||||||
- `coyote --list-secrets` # initialise the local vault
|
- `coyote --list-secrets` # initialise the local vault
|
||||||
- `coyote --authenticate <client>` # OAuth flow (Claude Pro/Max, Gemini)
|
- `coyote --authenticate <client>` # OAuth flow (Claude Pro/Max, Gemini)
|
||||||
File diff suppressed because it is too large
Load Diff
+55
-11
@@ -34,8 +34,12 @@ impl DiscoveredMixin {
|
|||||||
pub fn wrap_mixin_as_kit(mixin_path: &Path) -> Result<PathBuf> {
|
pub fn wrap_mixin_as_kit(mixin_path: &Path) -> Result<PathBuf> {
|
||||||
let bytes = fs::read(mixin_path)
|
let bytes = fs::read(mixin_path)
|
||||||
.with_context(|| format!("Failed to read sbx mixin {}", mixin_path.display()))?;
|
.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();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(&bytes);
|
hasher.update(bytes);
|
||||||
let hash = format!("{:x}", hasher.finalize());
|
let hash = format!("{:x}", hasher.finalize());
|
||||||
|
|
||||||
let kit_dir = paths::sbx_mixin_kits_dir().join(&hash);
|
let kit_dir = paths::sbx_mixin_kits_dir().join(&hash);
|
||||||
@@ -49,14 +53,10 @@ pub fn wrap_mixin_as_kit(mixin_path: &Path) -> Result<PathBuf> {
|
|||||||
|
|
||||||
fs::create_dir_all(&kit_dir)
|
fs::create_dir_all(&kit_dir)
|
||||||
.with_context(|| format!("Failed to create mixin kit dir {}", kit_dir.display()))?;
|
.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()))?;
|
.with_context(|| format!("Failed to write {}", spec_path.display()))?;
|
||||||
|
|
||||||
debug!(
|
debug!("Wrapped mixin {label} as kit at {}", kit_dir.display());
|
||||||
"Wrapped mixin {} as kit at {}",
|
|
||||||
mixin_path.display(),
|
|
||||||
kit_dir.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(kit_dir)
|
Ok(kit_dir)
|
||||||
}
|
}
|
||||||
@@ -97,15 +97,18 @@ pub fn summarize(path: &Path) -> Result<(usize, usize)> {
|
|||||||
.with_context(|| format!("Failed to parse sbx mixin {}", path.display()))?;
|
.with_context(|| format!("Failed to parse sbx mixin {}", path.display()))?;
|
||||||
|
|
||||||
let installs = value
|
let installs = value
|
||||||
.get("commands")
|
.get("setup")
|
||||||
.and_then(|c| c.get("install"))
|
.and_then(|s| s.get("install"))
|
||||||
|
.or_else(|| value.get("commands").and_then(|c| c.get("install")))
|
||||||
.and_then(|i| i.as_sequence())
|
.and_then(|i| i.as_sequence())
|
||||||
.map(|s| s.len())
|
.map(|s| s.len())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let domains = value
|
let domains = value
|
||||||
.get("network")
|
.get("permissions")
|
||||||
.and_then(|n| n.get("allowedDomains"))
|
.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())
|
.and_then(|d| d.as_sequence())
|
||||||
.map(|s| s.len())
|
.map(|s| s.len())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
@@ -228,6 +231,34 @@ mod tests {
|
|||||||
fs::write(
|
fs::write(
|
||||||
&path,
|
&path,
|
||||||
r#"
|
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"
|
schemaVersion: "1"
|
||||||
kind: mixin
|
kind: mixin
|
||||||
commands:
|
commands:
|
||||||
@@ -375,6 +406,19 @@ network:
|
|||||||
assert_eq!(fs::read_to_string(&spec).unwrap(), content);
|
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]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn wrap_mixin_as_kit_is_deterministic_for_identical_content() {
|
fn wrap_mixin_as_kit_is_deterministic_for_identical_content() {
|
||||||
|
|||||||
+157
-46
@@ -10,13 +10,17 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use which::which;
|
use which::which;
|
||||||
|
|
||||||
|
mod mcp_credentials;
|
||||||
mod mixins;
|
mod mixins;
|
||||||
|
|
||||||
|
pub(crate) use mcp_credentials::sandbox_secret_env_var;
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::config::VAULT_DATA_FILE_NAME;
|
use crate::config::VAULT_DATA_FILE_NAME;
|
||||||
use crate::config::paths;
|
use crate::config::paths;
|
||||||
use crate::rag::RagData;
|
use crate::rag::RagData;
|
||||||
|
use crate::sandbox::mcp_credentials::MCP_MIXIN_NAME;
|
||||||
use crate::sandbox::mixins::DiscoveredMixin;
|
use crate::sandbox::mixins::DiscoveredMixin;
|
||||||
use crate::utils::run_command_with_output;
|
use crate::utils::run_command_with_output;
|
||||||
use crate::vault::SECRET_RE;
|
use crate::vault::SECRET_RE;
|
||||||
@@ -51,17 +55,22 @@ pub fn launch(name: Option<String>, fresh: bool) -> Result<()> {
|
|||||||
let registered = sbx_registered_services()?;
|
let registered = sbx_registered_services()?;
|
||||||
inject_llm_secret(&config_content, &vault, ®istered)?;
|
inject_llm_secret(&config_content, &vault, ®istered)?;
|
||||||
if !fresh {
|
if !fresh {
|
||||||
inject_mcp_secrets(&vault, ®istered)?;
|
|
||||||
inject_rag_secrets(&vault, ®istered)?;
|
inject_rag_secrets(&vault, ®istered)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let credentials_mixin = if fresh {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
inject_mcp_secrets(&vault, ®istered)?
|
||||||
|
};
|
||||||
|
|
||||||
let discovered = mixins::discover()?;
|
let discovered = mixins::discover()?;
|
||||||
|
|
||||||
if sandbox_exists(&name)? {
|
if sandbox_exists(&name)? {
|
||||||
info!("Re-attaching to existing sandbox '{name}'");
|
info!("Re-attaching to existing sandbox '{name}'");
|
||||||
} else {
|
} else {
|
||||||
mixins::log_discovery(&discovered, false);
|
mixins::log_discovery(&discovered, false);
|
||||||
create_sandbox(&name, &kit_path, &discovered)?;
|
create_sandbox(&name, &kit_path, &discovered, credentials_mixin.as_deref())?;
|
||||||
if !fresh {
|
if !fresh {
|
||||||
copy_host_files(&name)?;
|
copy_host_files(&name)?;
|
||||||
}
|
}
|
||||||
@@ -234,7 +243,7 @@ fn inject_llm_secret(
|
|||||||
if registered.contains(&service) {
|
if registered.contains(&service) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Secret for '{service}' already registered with sbx. \
|
"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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -249,23 +258,14 @@ fn inject_llm_secret(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_secret_placeholder(value: &Value) -> Option<String> {
|
/// Registers one sbx secret per distinct `{{placeholder}}` in the MCP config
|
||||||
match value {
|
/// and returns the generated schema-v2 `coyote-mcp` mixin (network egress for
|
||||||
Value::String(s) => SECRET_RE
|
/// every remote MCP server + credential declarations), or `None` when the MCP
|
||||||
.captures(s)
|
/// config references no remote servers and no secrets.
|
||||||
.ok()
|
fn inject_mcp_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<Option<String>> {
|
||||||
.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<()> {
|
|
||||||
let mcp_path = paths::mcp_config_file();
|
let mcp_path = paths::mcp_config_file();
|
||||||
if !mcp_path.exists() {
|
if !mcp_path.exists() {
|
||||||
return Ok(());
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = fs::read_to_string(&mcp_path)
|
let content = fs::read_to_string(&mcp_path)
|
||||||
@@ -274,33 +274,44 @@ fn inject_mcp_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<()>
|
|||||||
.with_context(|| format!("Failed to parse {}", mcp_path.display()))?;
|
.with_context(|| format!("Failed to parse {}", mcp_path.display()))?;
|
||||||
|
|
||||||
let Some(servers) = mcp.get("mcpServers").and_then(|v| v.as_object()) else {
|
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 credentials = mcp_credentials::collect_credentials(servers)?;
|
||||||
let Some(secret_name) = find_secret_placeholder(server_config) else {
|
let allow_entries = mcp_credentials::collect_server_allow_entries(servers);
|
||||||
continue;
|
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!(
|
eprintln!(
|
||||||
"Secret for '{server_name}' already registered with sbx. \
|
"Secret for '{}' already registered with sbx. \
|
||||||
To update it, run: sbx secret set -g --force {server_name}"
|
To update it, run: sbx secret set --force {}",
|
||||||
|
credential.service_id, credential.service_id
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let secret_value = vault.get_secret(&secret_name, false).with_context(|| {
|
let secret_value = vault
|
||||||
format!(
|
.get_secret(&credential.secret_name, false)
|
||||||
"Secret '{secret_name}' referenced by MCP server '{server_name}' not found \
|
.with_context(|| {
|
||||||
in vault. Add it with: coyote --add-secret {secret_name}"
|
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(())
|
Ok(Some(mcp_credentials::render_mixin_yaml(
|
||||||
|
&credentials,
|
||||||
|
&allow_entries,
|
||||||
|
)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers the API key of every attached RAG with the sbx proxy.
|
/// Registers the API key of every attached RAG with the sbx proxy.
|
||||||
@@ -366,7 +377,7 @@ fn provider_to_sbx_service(provider_type: &str, client_name: Option<&str>) -> St
|
|||||||
match provider_type {
|
match provider_type {
|
||||||
"claude" => "anthropic".to_string(),
|
"claude" => "anthropic".to_string(),
|
||||||
"openai" => "openai".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(),
|
"openai-compatible" => client_name.unwrap_or("openai-compatible").to_string(),
|
||||||
other => client_name.unwrap_or(other).to_string(),
|
other => client_name.unwrap_or(other).to_string(),
|
||||||
}
|
}
|
||||||
@@ -399,25 +410,29 @@ fn sbx_registered_services() -> Result<HashSet<String>> {
|
|||||||
|
|
||||||
fn sbx_secret_set(service: &str, secret_value: &str) -> Result<()> {
|
fn sbx_secret_set(service: &str, secret_value: &str) -> Result<()> {
|
||||||
let mut child = Command::new(SBX_BINARY)
|
let mut child = Command::new(SBX_BINARY)
|
||||||
.args(["secret", "set", "-g", service])
|
.args(["secret", "set", service])
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::inherit())
|
.stdout(Stdio::inherit())
|
||||||
.stderr(Stdio::inherit())
|
.stderr(Stdio::inherit())
|
||||||
.spawn()
|
.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() {
|
if let Some(mut stdin_handle) = child.stdin.take() {
|
||||||
stdin_handle
|
stdin_handle
|
||||||
.write_all(secret_value.as_bytes())
|
.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
|
let status = child
|
||||||
.wait()
|
.wait()
|
||||||
.context("Failed to wait for `sbx secret set -g`")?;
|
.context("Failed to wait for `sbx secret set`")?;
|
||||||
|
|
||||||
if !status.success() {
|
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(())
|
Ok(())
|
||||||
@@ -436,9 +451,17 @@ fn sandbox_exists(name: &str) -> Result<bool> {
|
|||||||
.any(|line| line.split_whitespace().next() == Some(name)))
|
.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}'");
|
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(" "));
|
debug!("sbx {}", args.join(" "));
|
||||||
let status = Command::new(SBX_BINARY)
|
let status = Command::new(SBX_BINARY)
|
||||||
.args(&args)
|
.args(&args)
|
||||||
@@ -459,6 +482,7 @@ fn build_create_args(
|
|||||||
name: &str,
|
name: &str,
|
||||||
kit_path: &Path,
|
kit_path: &Path,
|
||||||
mixins: &[DiscoveredMixin],
|
mixins: &[DiscoveredMixin],
|
||||||
|
credentials_kit: Option<&Path>,
|
||||||
) -> Result<Vec<String>> {
|
) -> Result<Vec<String>> {
|
||||||
let kit_str = kit_path
|
let kit_str = kit_path
|
||||||
.to_str()
|
.to_str()
|
||||||
@@ -482,6 +506,15 @@ fn build_create_args(
|
|||||||
args.push(mixin_str);
|
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(SANDBOX_AGENT.to_string());
|
||||||
args.push(".".to_string());
|
args.push(".".to_string());
|
||||||
|
|
||||||
@@ -619,6 +652,7 @@ fn chown_agent_recursive(sandbox: &str, path: &str) -> Result<()> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitize_name_lowercases() {
|
fn sanitize_name_lowercases() {
|
||||||
@@ -687,8 +721,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn build_create_args_emits_base_kit_before_mixins() {
|
fn build_create_args_emits_base_kit_before_mixins() {
|
||||||
let kit = PathBuf::from("/cache/sbx-kit");
|
let kit = PathBuf::from("/cache/sbx-kit");
|
||||||
let unique = std::time::SystemTime::now()
|
let unique = SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let dir_a = env::temp_dir().join(format!("coyote-mixin-a-{unique}"));
|
let dir_a = env::temp_dir().join(format!("coyote-mixin-a-{unique}"));
|
||||||
@@ -711,7 +745,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!(
|
assert_eq!(
|
||||||
args,
|
args,
|
||||||
@@ -737,7 +771,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn build_create_args_with_no_mixins_omits_mixin_kits() {
|
fn build_create_args_with_no_mixins_omits_mixin_kits() {
|
||||||
let kit = PathBuf::from("/cache/sbx-kit");
|
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!(
|
assert_eq!(
|
||||||
args,
|
args,
|
||||||
vec![
|
vec![
|
||||||
@@ -751,4 +787,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::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 crate::vault::{SECRET_RE, Vault};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use anyhow::anyhow;
|
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>)> {
|
pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec<String>)> {
|
||||||
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
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| {
|
interpolate_secrets_with(content, vault.auth_hint(), |name| {
|
||||||
vault.get_secret(name, false)
|
vault.get_secret(name, false)
|
||||||
|
|||||||
Reference in New Issue
Block a user