feat: initial built-in sandboxing support powered by Docker sbx
This commit is contained in:
@@ -0,0 +1,322 @@
|
|||||||
|
# Docker sbx agent kit for Coyote
|
||||||
|
#
|
||||||
|
# Setup (paths use $HOME so commands work in bash/zsh/PowerShell/Git Bash):
|
||||||
|
# sbx create --kit ./sbx-kit/ coyote --name testing .
|
||||||
|
# sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/
|
||||||
|
# sbx cp $HOME/.coyote_password testing:/home/agent/
|
||||||
|
# sbx run testing --kit ./sbx-kit/
|
||||||
|
schemaVersion: "1"
|
||||||
|
kind: agent
|
||||||
|
name: coyote
|
||||||
|
displayName: Coyote
|
||||||
|
description: >
|
||||||
|
An all-in-one, batteries-included LLM CLI tool featuring Shell Assistant,
|
||||||
|
CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros.
|
||||||
|
|
||||||
|
agent:
|
||||||
|
image: "docker/sandbox-templates:shell-docker"
|
||||||
|
aiFilename: COYOTE.md
|
||||||
|
# persistence: persistent
|
||||||
|
entrypoint:
|
||||||
|
run: ["bash", "-lc", "exec /home/agent/.cargo/bin/coyote"]
|
||||||
|
|
||||||
|
network:
|
||||||
|
# Proxy-managed LLM providers: the proxy substitutes `proxy-managed` for
|
||||||
|
# the env var inside the sandbox and rewrites the auth header per
|
||||||
|
# serviceAuth at request time. Multiple domains may map to one service
|
||||||
|
# (e.g. jina) so they share a single credential.
|
||||||
|
serviceDomains:
|
||||||
|
api.openai.com: openai
|
||||||
|
api.anthropic.com: anthropic
|
||||||
|
generativelanguage.googleapis.com: gemini
|
||||||
|
api.cohere.ai: cohere
|
||||||
|
api.groq.com: groq
|
||||||
|
openrouter.ai: openrouter
|
||||||
|
api.ai21.com: ai21
|
||||||
|
api.cloudflare.com: cloudflare
|
||||||
|
api.deepinfra.com: deepinfra
|
||||||
|
api.deepseek.com: deepseek
|
||||||
|
api.mistral.ai: mistral
|
||||||
|
api.perplexity.ai: perplexity
|
||||||
|
api.voyageai.com: voyageai
|
||||||
|
api.x.ai: xai
|
||||||
|
api.jina.ai: jina
|
||||||
|
r.jina.ai: jina
|
||||||
|
qianfan.baidubce.com: ernie
|
||||||
|
api.hunyuan.cloud.tencent.com: hunyuan
|
||||||
|
api.minimax.chat: minimax
|
||||||
|
api.moonshot.cn: moonshot
|
||||||
|
dashscope.aliyuncs.com: qianwen
|
||||||
|
open.bigmodel.cn: zhipuai
|
||||||
|
serviceAuth:
|
||||||
|
openai:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
anthropic:
|
||||||
|
headerName: x-api-key
|
||||||
|
valueFormat: "%s"
|
||||||
|
gemini:
|
||||||
|
headerName: x-goog-api-key
|
||||||
|
valueFormat: "%s"
|
||||||
|
cohere:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
groq:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
openrouter:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
ai21:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
cloudflare:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
deepinfra:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
deepseek:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
mistral:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
perplexity:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
voyageai:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
xai:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
jina:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
ernie:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
hunyuan:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
minimax:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
moonshot:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
qianwen:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
zhipuai:
|
||||||
|
headerName: Authorization
|
||||||
|
valueFormat: "Bearer %s"
|
||||||
|
allowedDomains:
|
||||||
|
- "github.com:443"
|
||||||
|
- "api.github.com:443"
|
||||||
|
- "raw.githubusercontent.com:443"
|
||||||
|
- "objects.githubusercontent.com:443"
|
||||||
|
- "*.githubusercontent.com:443"
|
||||||
|
- "crates.io:443"
|
||||||
|
- "static.crates.io:443"
|
||||||
|
- "pypi.org:443"
|
||||||
|
- "files.pythonhosted.org:443"
|
||||||
|
- "registry.npmjs.org:443"
|
||||||
|
- "astral.sh:443"
|
||||||
|
- "sh.rustup.rs:443"
|
||||||
|
- "static.rust-lang.org:443"
|
||||||
|
|
||||||
|
- "claude.ai:443"
|
||||||
|
- "console.anthropic.com:443"
|
||||||
|
- "accounts.google.com:443"
|
||||||
|
# *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints
|
||||||
|
# (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI.
|
||||||
|
- "*.googleapis.com:443"
|
||||||
|
|
||||||
|
# Bedrock and GitHub Models use signed / GitHub-PAT auth that the proxy
|
||||||
|
# cannot rewrite. Domains are allow-listed; credentials must be injected
|
||||||
|
# separately (see README "Extending").
|
||||||
|
- "*.amazonaws.com:443"
|
||||||
|
- "models.inference.ai.azure.com:443"
|
||||||
|
|
||||||
|
- "api.githubcopilot.com:443"
|
||||||
|
- "mcp.atlassian.com:443"
|
||||||
|
- "duckduckgo.com:443"
|
||||||
|
- "html.duckduckgo.com:443"
|
||||||
|
- "lite.duckduckgo.com:443"
|
||||||
|
|
||||||
|
- "wttr.in:443"
|
||||||
|
# search_arxiv.sh uses http://, so :80 is required until the tool is fixed
|
||||||
|
- "export.arxiv.org:443"
|
||||||
|
- "export.arxiv.org:80"
|
||||||
|
- "en.wikipedia.org:443"
|
||||||
|
- "api.wolframalpha.com:443"
|
||||||
|
- "api.twilio.com:443"
|
||||||
|
- "api.tavily.com:443"
|
||||||
|
- "doi.org:443"
|
||||||
|
|
||||||
|
credentials:
|
||||||
|
sources:
|
||||||
|
openai:
|
||||||
|
env:
|
||||||
|
- OPENAI_API_KEY
|
||||||
|
anthropic:
|
||||||
|
env:
|
||||||
|
- ANTHROPIC_API_KEY
|
||||||
|
gemini:
|
||||||
|
env:
|
||||||
|
- GEMINI_API_KEY
|
||||||
|
- GOOGLE_API_KEY
|
||||||
|
cohere:
|
||||||
|
env:
|
||||||
|
- COHERE_API_KEY
|
||||||
|
groq:
|
||||||
|
env:
|
||||||
|
- GROQ_API_KEY
|
||||||
|
openrouter:
|
||||||
|
env:
|
||||||
|
- OPENROUTER_API_KEY
|
||||||
|
ai21:
|
||||||
|
env:
|
||||||
|
- AI21_API_KEY
|
||||||
|
cloudflare:
|
||||||
|
env:
|
||||||
|
- CLOUDFLARE_API_KEY
|
||||||
|
deepinfra:
|
||||||
|
env:
|
||||||
|
- DEEPINFRA_API_KEY
|
||||||
|
deepseek:
|
||||||
|
env:
|
||||||
|
- DEEPSEEK_API_KEY
|
||||||
|
mistral:
|
||||||
|
env:
|
||||||
|
- MISTRAL_API_KEY
|
||||||
|
perplexity:
|
||||||
|
env:
|
||||||
|
- PERPLEXITY_API_KEY
|
||||||
|
voyageai:
|
||||||
|
env:
|
||||||
|
- VOYAGE_API_KEY
|
||||||
|
xai:
|
||||||
|
env:
|
||||||
|
- XAI_API_KEY
|
||||||
|
jina:
|
||||||
|
env:
|
||||||
|
- JINA_API_KEY
|
||||||
|
ernie:
|
||||||
|
env:
|
||||||
|
- ERNIE_API_KEY
|
||||||
|
hunyuan:
|
||||||
|
env:
|
||||||
|
- HUNYUAN_API_KEY
|
||||||
|
minimax:
|
||||||
|
env:
|
||||||
|
- MINIMAX_API_KEY
|
||||||
|
moonshot:
|
||||||
|
env:
|
||||||
|
- MOONSHOT_API_KEY
|
||||||
|
qianwen:
|
||||||
|
env:
|
||||||
|
- DASHSCOPE_API_KEY
|
||||||
|
zhipuai:
|
||||||
|
env:
|
||||||
|
- ZHIPUAI_API_KEY
|
||||||
|
|
||||||
|
environment:
|
||||||
|
variables:
|
||||||
|
IS_SANDBOX: "1"
|
||||||
|
COYOTE_LOG_LEVEL: INFO
|
||||||
|
proxyManaged:
|
||||||
|
- OPENAI_API_KEY
|
||||||
|
- ANTHROPIC_API_KEY
|
||||||
|
- GEMINI_API_KEY
|
||||||
|
- GOOGLE_API_KEY
|
||||||
|
- COHERE_API_KEY
|
||||||
|
- GROQ_API_KEY
|
||||||
|
- OPENROUTER_API_KEY
|
||||||
|
- AI21_API_KEY
|
||||||
|
- CLOUDFLARE_API_KEY
|
||||||
|
- DEEPINFRA_API_KEY
|
||||||
|
- DEEPSEEK_API_KEY
|
||||||
|
- MISTRAL_API_KEY
|
||||||
|
- PERPLEXITY_API_KEY
|
||||||
|
- VOYAGE_API_KEY
|
||||||
|
- XAI_API_KEY
|
||||||
|
- JINA_API_KEY
|
||||||
|
- ERNIE_API_KEY
|
||||||
|
- HUNYUAN_API_KEY
|
||||||
|
- MINIMAX_API_KEY
|
||||||
|
- MOONSHOT_API_KEY
|
||||||
|
- DASHSCOPE_API_KEY
|
||||||
|
- ZHIPUAI_API_KEY
|
||||||
|
|
||||||
|
commands:
|
||||||
|
install:
|
||||||
|
- command: |
|
||||||
|
sudo apt-get update &&
|
||||||
|
sudo apt-get install -y \
|
||||||
|
jq curl git \
|
||||||
|
build-essential pkg-config \
|
||||||
|
cmake \
|
||||||
|
clang libclang-dev \
|
||||||
|
musl-tools \
|
||||||
|
libssl-dev
|
||||||
|
user: "1000"
|
||||||
|
description: Install system prerequisites
|
||||||
|
- command: "curl -LsSf https://astral.sh/uv/install.sh | sh"
|
||||||
|
user: "1000"
|
||||||
|
description: Install uv (required for Python-based custom tools)
|
||||||
|
- command: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||||
|
sh -s -- -y \
|
||||||
|
--default-toolchain stable \
|
||||||
|
--profile minimal \
|
||||||
|
--target x86_64-unknown-linux-musl
|
||||||
|
. "$HOME/.cargo/env"
|
||||||
|
cargo install --locked coyote-ai
|
||||||
|
user: "1000"
|
||||||
|
description: Install Coyote AI CLI via Rust's Cargo
|
||||||
|
|
||||||
|
startup:
|
||||||
|
- command: ["sh", "-c", "test -f \"$HOME/.config/coyote/config.yaml\" || coyote --info >/dev/null 2>&1 || true"]
|
||||||
|
user: "1000"
|
||||||
|
background: false
|
||||||
|
description: Bootstrap Coyote config directory on first sandbox start
|
||||||
|
|
||||||
|
memory: |
|
||||||
|
## Sandbox environment
|
||||||
|
|
||||||
|
You are running inside a Docker sandbox launched via `sbx run coyote`. The
|
||||||
|
user's project workspace is mounted at its absolute host path and is the
|
||||||
|
current working directory. `sudo` is passwordless; use it for system
|
||||||
|
package installs.
|
||||||
|
|
||||||
|
Coyote's configuration lives at `~/.config/coyote/` and logs at
|
||||||
|
`~/.cache/coyote/coyote.log`. Persistence is enabled, so config, sessions,
|
||||||
|
vault state, OAuth tokens, and installed tools survive sandbox restarts.
|
||||||
|
|
||||||
|
LLM provider credentials are forwarded by the sandbox HTTP proxy. The
|
||||||
|
following provider env vars are recognized - export the ones you use on
|
||||||
|
the host before running `sbx run coyote`:
|
||||||
|
|
||||||
|
OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY / GOOGLE_API_KEY,
|
||||||
|
COHERE_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, AI21_API_KEY,
|
||||||
|
CLOUDFLARE_API_KEY, DEEPINFRA_API_KEY, DEEPSEEK_API_KEY,
|
||||||
|
MISTRAL_API_KEY, PERPLEXITY_API_KEY, VOYAGE_API_KEY, XAI_API_KEY,
|
||||||
|
JINA_API_KEY, ERNIE_API_KEY, HUNYUAN_API_KEY, MINIMAX_API_KEY,
|
||||||
|
MOONSHOT_API_KEY, DASHSCOPE_API_KEY (Qwen), ZHIPUAI_API_KEY
|
||||||
|
|
||||||
|
Inside the sandbox these appear as the placeholder string `proxy-managed`;
|
||||||
|
the proxy substitutes the real value at request time. OAuth flows for
|
||||||
|
Claude Pro/Max and Gemini are also allow-listed.
|
||||||
|
|
||||||
|
Bedrock (AWS) and VertexAI (Google Cloud) use signed/OAuth-token requests
|
||||||
|
that the proxy cannot rewrite. Their domains are allow-listed but you must
|
||||||
|
inject credentials yourself via `sbx run --env AWS_ACCESS_KEY_ID=...` or
|
||||||
|
a mixin kit that mounts a service-account JSON.
|
||||||
|
|
||||||
|
Useful first-run commands:
|
||||||
|
- `coyote --info` # show config paths and resolved settings
|
||||||
|
- `coyote --list-secrets` # initialise the local vault
|
||||||
|
- `coyote --authenticate <client>` # OAuth flow (Claude Pro/Max, Gemini)
|
||||||
@@ -167,6 +167,9 @@ pub struct Cli {
|
|||||||
/// With --update, update even if Coyote was installed via a package manager
|
/// With --update, update even if Coyote was installed via a package manager
|
||||||
#[arg(long, requires = "update")]
|
#[arg(long, requires = "update")]
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
|
/// Launch Coyote inside a Docker sandbox (via `sbx`); name defaults to current directory basename
|
||||||
|
#[arg(long, exclusive = true, value_name = "NAME")]
|
||||||
|
pub sandbox: Option<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cli {
|
impl Cli {
|
||||||
@@ -495,4 +498,21 @@ mod tests {
|
|||||||
fn parse_force_without_update_fails() {
|
fn parse_force_without_update_fails() {
|
||||||
assert!(Cli::try_parse_from(["coyote", "--force"]).is_err());
|
assert!(Cli::try_parse_from(["coyote", "--force"]).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_sandbox_flag_no_value() {
|
||||||
|
let cli = parse(&["--sandbox"]);
|
||||||
|
assert_eq!(cli.sandbox, Some(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_sandbox_flag_with_name() {
|
||||||
|
let cli = parse(&["--sandbox", "my-box"]);
|
||||||
|
assert_eq!(cli.sandbox, Some(Some("my-box".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_sandbox_is_exclusive() {
|
||||||
|
assert!(Cli::try_parse_from(["coyote", "--sandbox", "--agent", "foo"]).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ const MEMORY_DIR_NAME: &str = "memory";
|
|||||||
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
|
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
|
||||||
const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md";
|
const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md";
|
||||||
const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote";
|
const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote";
|
||||||
|
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
|
||||||
|
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
|
||||||
const GIT_DIR_NAME: &str = ".git";
|
const GIT_DIR_NAME: &str = ".git";
|
||||||
const GITIGNORE_FILE_NAME: &str = ".gitignore";
|
const GITIGNORE_FILE_NAME: &str = ".gitignore";
|
||||||
const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [
|
const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [
|
||||||
|
|||||||
+38
-2
@@ -3,8 +3,8 @@ use super::{
|
|||||||
AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME,
|
AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME,
|
||||||
ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME,
|
ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME,
|
||||||
GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME,
|
GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME,
|
||||||
MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SKILLS_DIR_NAME,
|
MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME,
|
||||||
WORKSPACE_MEMORY_DIR_NAME,
|
SBX_KIT_HASH_FILE, SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME,
|
||||||
};
|
};
|
||||||
use crate::client::ProviderModels;
|
use crate::client::ProviderModels;
|
||||||
use crate::utils::{get_env_name, list_file_names, normalize_env_name};
|
use crate::utils::{get_env_name, list_file_names, normalize_env_name};
|
||||||
@@ -36,6 +36,10 @@ pub fn cache_path() -> PathBuf {
|
|||||||
base_dir.join(env!("CARGO_CRATE_NAME"))
|
base_dir.join(env!("CARGO_CRATE_NAME"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn sandbox_kit_override() -> Option<PathBuf> {
|
||||||
|
env::var_os(get_env_name("sandbox_kit")).map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn oauth_tokens_path() -> PathBuf {
|
pub fn oauth_tokens_path() -> PathBuf {
|
||||||
cache_path().join("oauth")
|
cache_path().join("oauth")
|
||||||
}
|
}
|
||||||
@@ -48,6 +52,14 @@ pub fn log_path() -> PathBuf {
|
|||||||
cache_path().join(format!("{}.log", env!("CARGO_CRATE_NAME")))
|
cache_path().join(format!("{}.log", env!("CARGO_CRATE_NAME")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn sbx_kit_dir() -> PathBuf {
|
||||||
|
cache_path().join(SBX_KIT_DIR_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sbx_kit_hash_file() -> PathBuf {
|
||||||
|
sbx_kit_dir().join(SBX_KIT_HASH_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn config_file() -> PathBuf {
|
pub fn config_file() -> PathBuf {
|
||||||
match env::var(get_env_name("config_file")) {
|
match env::var(get_env_name("config_file")) {
|
||||||
Ok(value) => PathBuf::from(value),
|
Ok(value) => PathBuf::from(value),
|
||||||
@@ -365,6 +377,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sandbox_kit_override_reflects_env_var_state() {
|
||||||
|
let env_name = get_env_name("sandbox_kit");
|
||||||
|
let prev = env::var_os(&env_name);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
env::remove_var(&env_name);
|
||||||
|
}
|
||||||
|
assert_eq!(sandbox_kit_override(), None);
|
||||||
|
|
||||||
|
let probe = PathBuf::from("/tmp/coyote-sandbox-kit-probe");
|
||||||
|
unsafe {
|
||||||
|
env::set_var(&env_name, &probe);
|
||||||
|
}
|
||||||
|
assert_eq!(sandbox_kit_override(), Some(probe));
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
match prev {
|
||||||
|
Some(v) => env::set_var(&env_name, v),
|
||||||
|
None => env::remove_var(&env_name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_skills_skips_invalid_directory_names() {
|
fn list_skills_skips_invalid_directory_names() {
|
||||||
let unique = time::SystemTime::now()
|
let unique = time::SystemTime::now()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ mod repl;
|
|||||||
mod utils;
|
mod utils;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod parsers;
|
mod parsers;
|
||||||
|
mod sandbox;
|
||||||
mod supervisor;
|
mod supervisor;
|
||||||
mod vault;
|
mod vault;
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ async fn main() -> Result<()> {
|
|||||||
shell.generate_completions(&mut cmd);
|
shell.generate_completions(&mut cmd);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if cli.tail_logs {
|
if cli.tail_logs {
|
||||||
tail_logs(cli.disable_log_colors).await;
|
tail_logs(cli.disable_log_colors).await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -92,6 +94,10 @@ async fn main() -> Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(name) = &cli.sandbox {
|
||||||
|
return sandbox::launch(name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
install_builtins()?;
|
install_builtins()?;
|
||||||
|
|
||||||
if let Some(category) = cli.install {
|
if let Some(category) = cli.install {
|
||||||
|
|||||||
+7
-1
@@ -18,6 +18,7 @@ use crate::utils::{
|
|||||||
AbortSignal, abortable_run_with_spinner, create_abort_signal, dimmed_text, set_text, temp_file,
|
AbortSignal, abortable_run_with_spinner, create_abort_signal, dimmed_text, set_text, temp_file,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::sandbox::SANDBOX_ENV_FLAG;
|
||||||
use crate::{config, graph, resolve_oauth_client};
|
use crate::{config, graph, resolve_oauth_client};
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use crossterm::cursor::SetCursorStyle;
|
use crossterm::cursor::SetCursorStyle;
|
||||||
@@ -278,7 +279,12 @@ Type ".help" for additional help.
|
|||||||
"#,
|
"#,
|
||||||
env!("CARGO_CRATE_NAME"),
|
env!("CARGO_CRATE_NAME"),
|
||||||
env!("CARGO_PKG_VERSION"),
|
env!("CARGO_PKG_VERSION"),
|
||||||
)
|
);
|
||||||
|
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
||||||
|
eprintln!(
|
||||||
|
"Sandbox mode is enabled. All changes made to the Coyote config will not persist to the host machine."
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
use rust_embed::RustEmbed;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use which::which;
|
||||||
|
|
||||||
|
use crate::config::paths;
|
||||||
|
use crate::utils::run_command_with_output;
|
||||||
|
use crate::vault::Vault;
|
||||||
|
|
||||||
|
const SBX_BINARY: &str = "sbx";
|
||||||
|
pub(crate) const SANDBOX_ENV_FLAG: &str = "IS_SANDBOX";
|
||||||
|
const SANDBOX_AGENT: &str = "coyote";
|
||||||
|
|
||||||
|
#[derive(RustEmbed)]
|
||||||
|
#[folder = "assets/sbx-kit/"]
|
||||||
|
struct EmbeddedKit;
|
||||||
|
|
||||||
|
pub fn launch(name: Option<String>) -> Result<()> {
|
||||||
|
ensure_sbx_installed()?;
|
||||||
|
bail_if_nested()?;
|
||||||
|
|
||||||
|
let name = resolve_name(name)?;
|
||||||
|
let kit_path = resolve_kit_path()?;
|
||||||
|
|
||||||
|
if sandbox_exists(&name)? {
|
||||||
|
info!("Re-attaching to existing sandbox '{name}'");
|
||||||
|
} else {
|
||||||
|
create_sandbox(&name, &kit_path)?;
|
||||||
|
copy_host_files(&name)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
exec_run(&name, &kit_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_sbx_installed() -> Result<()> {
|
||||||
|
which(SBX_BINARY).map_err(|_| {
|
||||||
|
anyhow!(
|
||||||
|
"`sbx` binary not found in PATH.\n\n\
|
||||||
|
Install Docker Sandboxes:\n https://docs.docker.com/ai/sandboxes/get-started/"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bail_if_nested() -> Result<()> {
|
||||||
|
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
||||||
|
bail!("Refusing to nest sandboxes: ${SANDBOX_ENV_FLAG} is set, already inside one");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_name(name: Option<String>) -> Result<String> {
|
||||||
|
if let Some(n) = name {
|
||||||
|
let trimmed = n.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
let sanitized = sanitize_name(trimmed);
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
bail!("Sandbox name '{trimmed}' sanitizes to an empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(sanitized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cwd = env::current_dir().context("Failed to determine current directory")?;
|
||||||
|
let basename = cwd
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or_else(|| anyhow!("Could not derive sandbox name from current directory"))?;
|
||||||
|
let sanitized = sanitize_name(basename);
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
bail!("Could not derive a valid sandbox name from '{basename}'; pass --sandbox <NAME>");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(sanitized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_name(input: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(input.len());
|
||||||
|
let mut last_was_dash = false;
|
||||||
|
for ch in input.chars() {
|
||||||
|
let lower = ch.to_ascii_lowercase();
|
||||||
|
if lower.is_ascii_alphanumeric() {
|
||||||
|
out.push(lower);
|
||||||
|
last_was_dash = false;
|
||||||
|
} else if !last_was_dash {
|
||||||
|
out.push('-');
|
||||||
|
last_was_dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.trim_matches('-').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_kit_path() -> Result<PathBuf> {
|
||||||
|
if let Some(path) = paths::sandbox_kit_override() {
|
||||||
|
if !path.exists() {
|
||||||
|
bail!(
|
||||||
|
"$COYOTE_SANDBOX_KIT is set but path does not exist: {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Using kit override from $COYOTE_SANDBOX_KIT: {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_embedded_kit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_embedded_kit() -> Result<PathBuf> {
|
||||||
|
let cache_root = paths::sbx_kit_dir();
|
||||||
|
let new_hash = compute_kit_hash()?;
|
||||||
|
let hash_file = paths::sbx_kit_hash_file();
|
||||||
|
if let Ok(existing) = fs::read_to_string(&hash_file)
|
||||||
|
&& existing == new_hash
|
||||||
|
{
|
||||||
|
return Ok(cache_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache_root.exists() {
|
||||||
|
fs::remove_dir_all(&cache_root)
|
||||||
|
.with_context(|| format!("Failed to clear stale kit at {}", cache_root.display()))?;
|
||||||
|
}
|
||||||
|
fs::create_dir_all(&cache_root)
|
||||||
|
.with_context(|| format!("Failed to create {}", cache_root.display()))?;
|
||||||
|
|
||||||
|
for entry in EmbeddedKit::iter() {
|
||||||
|
let file = EmbeddedKit::get(&entry)
|
||||||
|
.ok_or_else(|| anyhow!("Embedded kit file missing during extraction: {entry}"))?;
|
||||||
|
let dest = cache_root.join(entry.as_ref());
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(&dest, &file.data)
|
||||||
|
.with_context(|| format!("Failed to write {}", dest.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(&hash_file, &new_hash)
|
||||||
|
.with_context(|| format!("Failed to write {}", hash_file.display()))?;
|
||||||
|
debug!("Extracted embedded sbx-kit to {}", cache_root.display());
|
||||||
|
|
||||||
|
Ok(cache_root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_kit_hash() -> Result<String> {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut entries: Vec<_> = EmbeddedKit::iter().collect();
|
||||||
|
entries.sort();
|
||||||
|
|
||||||
|
for entry in &entries {
|
||||||
|
let file = EmbeddedKit::get(entry)
|
||||||
|
.ok_or_else(|| anyhow!("Embedded kit file missing during hash: {entry}"))?;
|
||||||
|
hasher.update(entry.as_bytes());
|
||||||
|
hasher.update(b"\0");
|
||||||
|
hasher.update(&file.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sandbox_exists(name: &str) -> Result<bool> {
|
||||||
|
let (success, stdout, stderr) =
|
||||||
|
run_command_with_output(SBX_BINARY, &["ls"], None).context("Failed to run `sbx ls`")?;
|
||||||
|
if !success {
|
||||||
|
bail!("`sbx ls` failed: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stdout
|
||||||
|
.lines()
|
||||||
|
.skip(1)
|
||||||
|
.any(|line| line.split_whitespace().next() == Some(name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_sandbox(name: &str, kit_path: &Path) -> Result<()> {
|
||||||
|
info!("Creating sandbox '{name}'");
|
||||||
|
let kit_str = kit_path
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| anyhow!("Kit path is not valid UTF-8: {}", kit_path.display()))?;
|
||||||
|
let status = Command::new(SBX_BINARY)
|
||||||
|
.args([
|
||||||
|
"create",
|
||||||
|
"--kit",
|
||||||
|
kit_str,
|
||||||
|
SANDBOX_AGENT,
|
||||||
|
"--name",
|
||||||
|
name,
|
||||||
|
".",
|
||||||
|
])
|
||||||
|
.stdin(Stdio::inherit())
|
||||||
|
.stdout(Stdio::inherit())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.status()
|
||||||
|
.context("Failed to spawn `sbx create`")?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
bail!("`sbx create` exited with {status}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_host_files(name: &str) -> Result<()> {
|
||||||
|
let config_dir = paths::config_dir();
|
||||||
|
let home_dir = dirs::home_dir().context("Could not determine home directory")?;
|
||||||
|
|
||||||
|
if config_dir.exists() {
|
||||||
|
ensure_sandbox_dir(name, "/home/agent/.config")?;
|
||||||
|
let src = format!("{}/", config_dir.display());
|
||||||
|
let dest = format!("{name}:/home/agent/.config/");
|
||||||
|
sbx_cp(&src, &dest)?;
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
"Skipping config copy: {} does not exist",
|
||||||
|
config_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
match resolve_vault_password_file() {
|
||||||
|
Some(password_file) if password_file.exists() => {
|
||||||
|
let dest_path = match password_file.strip_prefix(&home_dir) {
|
||||||
|
Ok(rel) => format!("/home/agent/{}", rel.display()),
|
||||||
|
Err(_) => password_file.display().to_string(),
|
||||||
|
};
|
||||||
|
if let Some(parent) = Path::new(&dest_path).parent()
|
||||||
|
&& let Some(parent_str) = parent.to_str()
|
||||||
|
&& !parent_str.is_empty()
|
||||||
|
{
|
||||||
|
ensure_sandbox_dir(name, parent_str)?;
|
||||||
|
}
|
||||||
|
let dest = format!("{name}:{dest_path}");
|
||||||
|
sbx_cp(&password_file.display().to_string(), &dest)?;
|
||||||
|
}
|
||||||
|
Some(password_file) => {
|
||||||
|
debug!(
|
||||||
|
"Skipping vault password copy: {} does not exist",
|
||||||
|
password_file.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
debug!("Skipping vault password copy: no local vault provider configured");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_sandbox_dir(sandbox: &str, dir: &str) -> Result<()> {
|
||||||
|
let dir_q = shell_words::quote(dir);
|
||||||
|
let cmd = format!("sudo mkdir -p {dir_q} && sudo chown agent:agent {dir_q}");
|
||||||
|
|
||||||
|
debug!("sbx exec {sandbox}: {cmd}");
|
||||||
|
|
||||||
|
let status = Command::new(SBX_BINARY)
|
||||||
|
.args(["exec", sandbox, "sh", "-c", &cmd])
|
||||||
|
.stdin(Stdio::inherit())
|
||||||
|
.stdout(Stdio::inherit())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.status()
|
||||||
|
.context("Failed to spawn `sbx exec` to prepare destination directory")?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
bail!("Preparing sandbox directory '{dir}' failed: sbx exec exited with {status}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_vault_password_file() -> Option<PathBuf> {
|
||||||
|
Vault::init_bare().ok()?.local_password_file().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sbx_cp(src: &str, dest: &str) -> Result<()> {
|
||||||
|
debug!("sbx cp {src} {dest}");
|
||||||
|
let status = Command::new(SBX_BINARY)
|
||||||
|
.args(["cp", src, dest])
|
||||||
|
.stdin(Stdio::inherit())
|
||||||
|
.stdout(Stdio::inherit())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.status()
|
||||||
|
.context("Failed to spawn `sbx cp`")?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
bail!("`sbx cp {src} {dest}` exited with {status}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec_run(name: &str, kit_path: &Path) -> Result<()> {
|
||||||
|
let kit_str = kit_path
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| anyhow!("Kit path is not valid UTF-8: {}", kit_path.display()))?;
|
||||||
|
let status = Command::new(SBX_BINARY)
|
||||||
|
.args(["run", name, "--kit", kit_str])
|
||||||
|
.stdin(Stdio::inherit())
|
||||||
|
.stdout(Stdio::inherit())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.status()
|
||||||
|
.context("Failed to spawn `sbx run`")?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
bail!("`sbx run` exited with {status}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_name_lowercases() {
|
||||||
|
assert_eq!(sanitize_name("Foo"), "foo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_name_replaces_non_alphanumeric() {
|
||||||
|
assert_eq!(sanitize_name("hello world!"), "hello-world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_name_collapses_dash_runs() {
|
||||||
|
assert_eq!(sanitize_name("a___b"), "a-b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_name_trims_dashes() {
|
||||||
|
assert_eq!(sanitize_name("---hi---"), "hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_name_handles_mixed_input() {
|
||||||
|
assert_eq!(sanitize_name("My Project (v2)"), "my-project-v2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_name_all_invalid_yields_empty() {
|
||||||
|
assert_eq!(sanitize_name("///"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_name_uses_explicit_arg() {
|
||||||
|
let n = resolve_name(Some("explicit-name".to_string())).unwrap();
|
||||||
|
assert_eq!(n, "explicit-name");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_name_sanitizes_explicit_arg() {
|
||||||
|
let n = resolve_name(Some("My Sandbox!".to_string())).unwrap();
|
||||||
|
assert_eq!(n, "my-sandbox");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_name_rejects_empty_after_sanitize() {
|
||||||
|
let err = resolve_name(Some("///".to_string()));
|
||||||
|
assert!(err.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_name_falls_back_to_cwd_when_none() {
|
||||||
|
let n = resolve_name(None).unwrap();
|
||||||
|
assert!(!n.is_empty());
|
||||||
|
assert!(n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_kit_hash_is_deterministic() {
|
||||||
|
let h1 = compute_kit_hash().unwrap();
|
||||||
|
let h2 = compute_kit_hash().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(h1, h2);
|
||||||
|
assert_eq!(h1.len(), 64);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user