feat: upgraded to sbx kit v2 spec for improved integration
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-08-10 15:26:16 -06:00
parent efa570267d
commit 91dbaf5533
7 changed files with 1732 additions and 347 deletions
+34 -7
View File
@@ -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'
+37 -37
View File
@@ -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
View File
@@ -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
View File
@@ -33,8 +33,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);
@@ -48,14 +52,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)
} }
@@ -89,15 +89,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);
@@ -196,6 +199,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:
@@ -343,6 +374,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() {
+156 -48
View File
@@ -10,12 +10,16 @@ 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::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;
@@ -49,9 +53,11 @@ pub fn launch(name: Option<String>, fresh: bool) -> Result<()> {
let vault = Vault::init(&bootstrap)?; let vault = Vault::init(&bootstrap)?;
let registered = sbx_registered_services()?; let registered = sbx_registered_services()?;
inject_llm_secret(&config_content, &vault, &registered)?; inject_llm_secret(&config_content, &vault, &registered)?;
if !fresh { let credentials_mixin = if fresh {
inject_mcp_secrets(&vault, &registered)?; None
} } else {
inject_mcp_secrets(&vault, &registered)?
};
let discovered = mixins::discover()?; let discovered = mixins::discover()?;
@@ -59,7 +65,7 @@ pub fn launch(name: Option<String>, fresh: bool) -> Result<()> {
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)?;
} }
@@ -232,7 +238,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;
} }
@@ -247,23 +253,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)
@@ -272,40 +269,51 @@ 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,
)?))
} }
fn provider_to_sbx_service(provider_type: &str, client_name: Option<&str>) -> String { fn provider_to_sbx_service(provider_type: &str, client_name: Option<&str>) -> String {
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(),
} }
@@ -338,25 +346,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(())
@@ -375,9 +387,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)
@@ -398,6 +418,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()
@@ -421,6 +442,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());
@@ -558,6 +588,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() {
@@ -626,8 +657,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}"));
@@ -650,7 +681,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,
@@ -676,7 +707,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![
@@ -690,4 +723,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
View File
@@ -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)