Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
373d80121a
|
|||
|
3299a4699e
|
|||
|
d4dbda1e89
|
|||
|
e77fa6ef42
|
|||
|
241dda24f0
|
|||
|
e5668e4495
|
|||
|
4a01e9a66c
|
|||
|
530000bc2f
|
|||
|
f2e8f3ab59
|
|||
|
2f33b6631e
|
|||
|
8c288195a0
|
|||
|
e6a5e67a8e
|
|||
|
6ae474c79e
|
|||
|
8e0b07c9fb
|
|||
|
69589bd5e5
|
|||
|
587df087ed
|
|||
|
ee100eef96
|
@@ -25,6 +25,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
|
||||
* [REPL](https://github.com/Dark-Alex-17/coyote/wiki/REPL): Interactive Read-Eval-Print Loop for conversational interactions with LLMs and Coyote.
|
||||
* [Custom REPL Prompt](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt): Customize the REPL prompt to provide useful contextual information.
|
||||
* [Vault](https://github.com/Dark-Alex-17/coyote/wiki/Vault): Securely store and manage sensitive information such as API keys and credentials.
|
||||
* [Sandboxes](https://github.com/Dark-Alex-17/coyote/wiki/Sandboxes): Launch Coyote inside an isolated [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) with one command. Host config and vault credentials are projected in automatically; everything else is delegated to the `sbx` CLI.
|
||||
* [Shell Integrations](https://github.com/Dark-Alex-17/coyote/wiki/Shell-Integrations): Seamlessly integrate Coyote with your shell environment for enhanced command-line assistance.
|
||||
* [Function Calling](https://github.com/Dark-Alex-17/coyote/wiki/Tools): Leverage function calling capabilities to extend Coyote's functionality with custom tools
|
||||
* [Creating Custom Tools](https://github.com/Dark-Alex-17/coyote/wiki/Custom-Tools): You can create your own custom tools to enhance Coyote's capabilities.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
name: built-in-tools
|
||||
description: >
|
||||
Installs binaries and allows network domains required by Coyote's built-in
|
||||
global tools and the default MCP server set. Auto-applied by Coyote's sbx
|
||||
mixin discovery when running `coyote --sandbox`.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
# fetch_url_via_jina + jina reader fallback
|
||||
- "r.jina.ai:443"
|
||||
# get_current_weather (.sh, .py, .ts)
|
||||
- "wttr.in:443"
|
||||
# search_arxiv (the .sh tool still uses http://, so :80 is required until fixed)
|
||||
- "export.arxiv.org:443"
|
||||
- "export.arxiv.org:80"
|
||||
# search_arxiv + search_wikipedia may follow DOI redirects
|
||||
- "doi.org:443"
|
||||
# search_wikipedia
|
||||
- "en.wikipedia.org:443"
|
||||
# search_wolframalpha
|
||||
- "api.wolframalpha.com:443"
|
||||
# web_search_perplexity
|
||||
- "api.perplexity.ai:443"
|
||||
# web_search_tavily
|
||||
- "api.tavily.com:443"
|
||||
# send_twilio
|
||||
- "api.twilio.com:443"
|
||||
# MCP: github (built-in mcp.json: api.githubcopilot.com)
|
||||
- "api.githubcopilot.com:443"
|
||||
# MCP: atlassian (built-in mcp.json: mcp-remote -> mcp.atlassian.com)
|
||||
- "mcp.atlassian.com:443"
|
||||
# MCP: ddg-search (built-in mcp.json: uvx duckduckgo-mcp-server)
|
||||
- "duckduckgo.com:443"
|
||||
- "html.duckduckgo.com:443"
|
||||
- "lite.duckduckgo.com:443"
|
||||
# MCP: npx-based servers (mcp-remote) pull from npm
|
||||
- "registry.npmjs.org:443"
|
||||
# MCP: docker server may pull images from common registries
|
||||
- "ghcr.io:443"
|
||||
- "registry-1.docker.io:443"
|
||||
- "auth.docker.io:443"
|
||||
- "production.cloudflare.docker.com:443"
|
||||
@@ -0,0 +1,325 @@
|
||||
# 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:
|
||||
# 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"
|
||||
# Coyote install paths (cargo install + uv + rustup + Python tool deps at runtime)
|
||||
- "crates.io:443"
|
||||
- "static.crates.io:443"
|
||||
- "pypi.org:443"
|
||||
- "files.pythonhosted.org:443"
|
||||
- "astral.sh:443"
|
||||
- "sh.rustup.rs:443"
|
||||
- "static.rust-lang.org:443"
|
||||
|
||||
# LLM model OAuth + API endpoints
|
||||
- "claude.ai:443"
|
||||
- "console.anthropic.com:443"
|
||||
- "accounts.google.com:443"
|
||||
# *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints
|
||||
# (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI.
|
||||
- "*.googleapis.com:443"
|
||||
|
||||
# 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"
|
||||
|
||||
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 \
|
||||
pandoc \
|
||||
bzip2
|
||||
user: "1000"
|
||||
description: Install system prerequisites (including pandoc for fetch_url_via_curl)
|
||||
- command: "curl -LsSf https://astral.sh/uv/install.sh | sh"
|
||||
user: "1000"
|
||||
description: Install uv (required for Python-based custom tools)
|
||||
- command: |
|
||||
set -euo pipefail
|
||||
USQL_VERSION="0.19.20"
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) USQL_ARCH=amd64 ;;
|
||||
aarch64) USQL_ARCH=arm64 ;;
|
||||
*) echo "Unsupported arch for usql install: $ARCH" >&2; exit 1 ;;
|
||||
esac
|
||||
curl -sSL "https://github.com/xo/usql/releases/download/v${USQL_VERSION}/usql_static-${USQL_VERSION}-linux-${USQL_ARCH}.tar.bz2" -o /tmp/usql.tar.bz2
|
||||
sudo tar -xjf /tmp/usql.tar.bz2 -C /usr/local/bin
|
||||
sudo chmod +x /usr/local/bin/usql
|
||||
rm -f /tmp/usql.tar.bz2
|
||||
user: "1000"
|
||||
description: Install the usql universal SQL CLI (used by the built-in sql agent and execute_sql_code tool)
|
||||
- command: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
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)
|
||||
@@ -0,0 +1,33 @@
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
name: vault-aws-secrets-manager
|
||||
description: >
|
||||
Installs the AWS CLI v2 so the Coyote vault can read secrets from AWS
|
||||
Secrets Manager inside the sandbox. The AWS Rust SDK does not strictly
|
||||
require the CLI, but most users authenticate via `aws sso login` or
|
||||
`aws configure`, which need the CLI to be installed. After install, run
|
||||
the appropriate auth command in the sandbox; cached credentials persist
|
||||
for the lifetime of the sandbox.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
- "awscli.amazonaws.com:443"
|
||||
- "sts.amazonaws.com:443"
|
||||
- "*.sts.amazonaws.com:443"
|
||||
- "*.secretsmanager.amazonaws.com:443"
|
||||
- "*.amazonaws.com:443"
|
||||
- "*.awsapps.com:443"
|
||||
|
||||
commands:
|
||||
install:
|
||||
- command: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y unzip
|
||||
ARCH=$(uname -m)
|
||||
curl -sSL "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscliv2.zip
|
||||
unzip -q /tmp/awscliv2.zip -d /tmp
|
||||
sudo /tmp/aws/install
|
||||
rm -rf /tmp/awscliv2.zip /tmp/aws
|
||||
user: "1000"
|
||||
description: Install AWS CLI v2 from the official installer
|
||||
@@ -0,0 +1,24 @@
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
name: vault-azure-key-vault
|
||||
description: >
|
||||
Installs the Azure CLI (`az`) so the Coyote vault can read secrets from
|
||||
Azure Key Vault inside the sandbox. After install, run `az login` in the
|
||||
sandbox to authenticate; the session token persists for the lifetime of
|
||||
the sandbox.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
- "aka.ms:443"
|
||||
- "packages.microsoft.com:443"
|
||||
- "azurecliprod.blob.core.windows.net:443"
|
||||
- "login.microsoftonline.com:443"
|
||||
- "graph.microsoft.com:443"
|
||||
- "management.azure.com:443"
|
||||
- "*.vault.azure.net:443"
|
||||
|
||||
commands:
|
||||
install:
|
||||
- command: "curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash"
|
||||
user: "1000"
|
||||
description: Install Azure CLI via Microsoft's official install script
|
||||
@@ -0,0 +1,34 @@
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
name: vault-gcp-secret-manager
|
||||
description: >
|
||||
Installs the Google Cloud CLI (`gcloud`) so the Coyote vault can read
|
||||
secrets from GCP Secret Manager inside the sandbox. The GCP Rust SDK does
|
||||
not strictly require the CLI, but most users authenticate via
|
||||
`gcloud auth application-default login`, which needs the CLI to be
|
||||
installed. After install, run that command in the sandbox; the ADC file
|
||||
persists for the lifetime of the sandbox.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
- "packages.cloud.google.com:443"
|
||||
- "accounts.google.com:443"
|
||||
- "oauth2.googleapis.com:443"
|
||||
- "secretmanager.googleapis.com:443"
|
||||
- "cloudresourcemanager.googleapis.com:443"
|
||||
- "*.googleapis.com:443"
|
||||
|
||||
commands:
|
||||
install:
|
||||
- command: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y apt-transport-https ca-certificates gnupg
|
||||
echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" \
|
||||
| sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list >/dev/null
|
||||
curl -sSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \
|
||||
| sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y google-cloud-cli
|
||||
user: "1000"
|
||||
description: Install gcloud CLI from Google's official apt repository
|
||||
@@ -0,0 +1,30 @@
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
name: vault-gopass
|
||||
description: >
|
||||
Installs `gopass` and `gpg` so the Coyote vault can read secrets from a
|
||||
gopass store inside the sandbox. The store must be cloned manually
|
||||
(gopass walks a user-specific git remote, so v1 only allowlists github.com
|
||||
and gitlab.com; add other hosts via a user mixin if needed). After install,
|
||||
run `gopass setup` or `gopass clone <remote>` in the sandbox.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
- "github.com:443"
|
||||
- "api.github.com:443"
|
||||
- "objects.githubusercontent.com:443"
|
||||
- "gitlab.com:443"
|
||||
|
||||
commands:
|
||||
install:
|
||||
- command: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gnupg2 git
|
||||
GOPASS_VERSION="1.15.13"
|
||||
ARCH=$(dpkg --print-architecture)
|
||||
curl -sSL "https://github.com/gopasspw/gopass/releases/download/v${GOPASS_VERSION}/gopass_${GOPASS_VERSION}_linux_${ARCH}.deb" -o /tmp/gopass.deb
|
||||
sudo dpkg -i /tmp/gopass.deb
|
||||
rm -f /tmp/gopass.deb
|
||||
user: "1000"
|
||||
description: Install gnupg2, git, and gopass from the official .deb release
|
||||
@@ -0,0 +1,31 @@
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
name: vault-one-password
|
||||
description: >
|
||||
Installs the 1Password CLI (`op`) so the Coyote vault can decrypt secrets
|
||||
inside the sandbox. After install, run `op signin` in the sandbox to
|
||||
authenticate; credentials persist for the lifetime of the sandbox.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
- "downloads.1password.com:443"
|
||||
- "cache.agilebits.com:443"
|
||||
- "my.1password.com:443"
|
||||
- "my.1password.eu:443"
|
||||
- "my.1password.ca:443"
|
||||
- "events.1password.com:443"
|
||||
|
||||
commands:
|
||||
install:
|
||||
- command: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y unzip
|
||||
OP_VERSION="v2.30.3"
|
||||
ARCH=$(dpkg --print-architecture)
|
||||
curl -sSL "https://cache.agilebits.com/dist/1P/op2/pkg/${OP_VERSION}/op_linux_${ARCH}_${OP_VERSION}.zip" -o /tmp/op.zip
|
||||
sudo unzip -od /usr/local/bin /tmp/op.zip op
|
||||
sudo chmod +x /usr/local/bin/op
|
||||
rm -f /tmp/op.zip
|
||||
user: "1000"
|
||||
description: Install 1Password CLI from the official archive
|
||||
+79
-2
@@ -6,7 +6,7 @@ use crate::cli::completer::{
|
||||
};
|
||||
use crate::config::{AssetCategory, InstallFilter, MemoryScope};
|
||||
use anyhow::{Context, Result};
|
||||
use clap::ValueHint;
|
||||
use clap::{ArgGroup, ValueHint};
|
||||
use clap::{Parser, crate_authors, crate_description, crate_version};
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use is_terminal::IsTerminal;
|
||||
@@ -27,7 +27,20 @@ use std::io::{Read, stdin};
|
||||
{usage-heading} {usage}
|
||||
|
||||
{all-args}{after-help}
|
||||
"
|
||||
",
|
||||
group(
|
||||
ArgGroup::new("sbx-mode")
|
||||
.args(["sandbox", "fresh", "no_mixins"])
|
||||
.multiple(true)
|
||||
.conflicts_with_all([
|
||||
"model", "prompt", "role", "session", "agent", "rag", "rebuild_rag",
|
||||
"macro_name", "execute", "code", "file", "no_stream", "no_memory",
|
||||
"init_memory", "dry_run", "info", "build_tools", "install",
|
||||
"install_from", "sync_models", "list_models", "list_roles",
|
||||
"list_sessions", "list_agents", "list_rags", "list_macros",
|
||||
"list_skills", "skill", "tail_logs", "completions", "update",
|
||||
])
|
||||
),
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// Select a LLM model
|
||||
@@ -167,6 +180,15 @@ pub struct Cli {
|
||||
/// With --update, update even if Coyote was installed via a package manager
|
||||
#[arg(long, requires = "update")]
|
||||
pub force: bool,
|
||||
/// Launch Coyote inside a Docker sandbox (via `sbx`); name defaults to current directory basename
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub sandbox: Option<Option<String>>,
|
||||
/// Create the sandbox without bootstrapping the host config or vault password file
|
||||
#[arg(long, requires = "sandbox")]
|
||||
pub fresh: bool,
|
||||
/// Skip discovery and application of all sbx mixins (user and built-in)
|
||||
#[arg(long, requires = "sandbox")]
|
||||
pub no_mixins: bool,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
@@ -495,4 +517,59 @@ mod tests {
|
||||
fn parse_force_without_update_fails() {
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fresh_flag_requires_sandbox() {
|
||||
assert!(Cli::try_parse_from(["coyote", "--fresh"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fresh_flag_with_sandbox() {
|
||||
let cli = parse(&["--sandbox", "--fresh"]);
|
||||
assert_eq!(cli.sandbox, Some(None));
|
||||
assert!(cli.fresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fresh_flag_with_named_sandbox() {
|
||||
let cli = parse(&["--sandbox", "foo", "--fresh"]);
|
||||
assert_eq!(cli.sandbox, Some(Some("foo".to_string())));
|
||||
assert!(cli.fresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_no_mixins_requires_sandbox() {
|
||||
assert!(Cli::try_parse_from(["coyote", "--no-mixins"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_no_mixins_with_sandbox() {
|
||||
let cli = parse(&["--sandbox", "--no-mixins"]);
|
||||
assert!(cli.no_mixins);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sandbox_with_fresh_and_no_mixins() {
|
||||
let cli = parse(&["--sandbox", "foo", "--fresh", "--no-mixins"]);
|
||||
assert_eq!(cli.sandbox, Some(Some("foo".to_string())));
|
||||
assert!(cli.fresh);
|
||||
assert!(cli.no_mixins);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,10 +274,25 @@ impl AppConfig {
|
||||
|
||||
pub fn vault_password_file(&self) -> PathBuf {
|
||||
match &self.vault_password_file {
|
||||
Some(path) => match path.exists() {
|
||||
true => path.clone(),
|
||||
false => gman::config::Config::local_provider_password_file(),
|
||||
},
|
||||
Some(path) => {
|
||||
if path.exists() {
|
||||
return path.clone();
|
||||
}
|
||||
|
||||
if let Some(translated) = paths::translate_sandboxed_home_path(path)
|
||||
&& translated.exists()
|
||||
{
|
||||
info!(
|
||||
"vault_password_file '{}' not found; resolved to sandboxed path '{}'",
|
||||
path.display(),
|
||||
translated.display()
|
||||
);
|
||||
|
||||
return translated;
|
||||
}
|
||||
|
||||
gman::config::Config::local_provider_password_file()
|
||||
}
|
||||
None => gman::config::Config::local_provider_password_file(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,10 @@ const MEMORY_DIR_NAME: &str = "memory";
|
||||
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
|
||||
const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md";
|
||||
const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote";
|
||||
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
|
||||
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
|
||||
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
||||
const SBX_VAULT_MIXINS_DIR_NAME: &str = "sbx-vault-mixins";
|
||||
const GIT_DIR_NAME: &str = ".git";
|
||||
const GITIGNORE_FILE_NAME: &str = ".gitignore";
|
||||
const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [
|
||||
@@ -667,6 +671,9 @@ bitflags::bitflags! {
|
||||
const SESSION = 1 << 2;
|
||||
const RAG = 1 << 3;
|
||||
const AGENT = 1 << 4;
|
||||
const FUNCTION_CALLING = 1 << 5;
|
||||
const AUTO_CONTINUE = 1 << 6;
|
||||
const SKILLS_ENABLED = 1 << 7;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+292
-1
@@ -3,7 +3,8 @@ use super::{
|
||||
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,
|
||||
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,
|
||||
SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_VAULT_MIXINS_DIR_NAME, SKILLS_DIR_NAME,
|
||||
WORKSPACE_MEMORY_DIR_NAME,
|
||||
};
|
||||
use crate::client::ProviderModels;
|
||||
@@ -36,6 +37,89 @@ pub fn cache_path() -> PathBuf {
|
||||
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 translate_sandboxed_home_path(path: &Path) -> Option<PathBuf> {
|
||||
env::var_os("IS_SANDBOX")?;
|
||||
|
||||
let s = path.to_str()?;
|
||||
|
||||
if let Some(translated) = translate_unix_home_style(s, "/home/") {
|
||||
return Some(translated);
|
||||
}
|
||||
|
||||
if let Some(translated) = translate_unix_home_style(s, "/Users/") {
|
||||
return Some(translated);
|
||||
}
|
||||
|
||||
translate_windows_users_path(s)
|
||||
}
|
||||
|
||||
fn translate_unix_home_style(s: &str, prefix: &str) -> Option<PathBuf> {
|
||||
let rest = s.strip_prefix(prefix)?;
|
||||
let (user, tail) = match rest.split_once('/') {
|
||||
Some((u, t)) => (u, t),
|
||||
None => (rest, ""),
|
||||
};
|
||||
|
||||
if user.is_empty() || user == "agent" {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(if tail.is_empty() {
|
||||
PathBuf::from("/home/agent")
|
||||
} else {
|
||||
PathBuf::from(format!("/home/agent/{tail}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn translate_windows_users_path(s: &str) -> Option<PathBuf> {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() < 4 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' || bytes[2] != b'\\' {
|
||||
return None;
|
||||
}
|
||||
|
||||
let after_drive = &s[3..];
|
||||
let rest = after_drive.strip_prefix("Users\\")?;
|
||||
let (user, tail) = match rest.split_once('\\') {
|
||||
Some((u, t)) => (u, t.replace('\\', "/")),
|
||||
None => (rest, String::new()),
|
||||
};
|
||||
|
||||
if user.is_empty() || user == "agent" {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(if tail.is_empty() {
|
||||
PathBuf::from("/home/agent")
|
||||
} else {
|
||||
PathBuf::from(format!("/home/agent/{tail}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sbx_mixin_file() -> PathBuf {
|
||||
config_dir().join(SBX_MIXIN_FILE_NAME)
|
||||
}
|
||||
|
||||
pub fn global_tools_sbx_mixin_file() -> PathBuf {
|
||||
functions_dir().join(SBX_MIXIN_FILE_NAME)
|
||||
}
|
||||
|
||||
pub fn find_workspace_sbx_mixin(start: &Path) -> Option<PathBuf> {
|
||||
for dir in start.ancestors() {
|
||||
let candidate = dir
|
||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
||||
.join(SBX_MIXIN_FILE_NAME);
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn oauth_tokens_path() -> PathBuf {
|
||||
cache_path().join("oauth")
|
||||
}
|
||||
@@ -48,6 +132,22 @@ pub fn log_path() -> PathBuf {
|
||||
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 sbx_vault_mixins_dir() -> PathBuf {
|
||||
cache_path().join(SBX_VAULT_MIXINS_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn sbx_vault_mixins_hash_file() -> PathBuf {
|
||||
sbx_vault_mixins_dir().join(SBX_KIT_HASH_FILE)
|
||||
}
|
||||
|
||||
pub fn config_file() -> PathBuf {
|
||||
match env::var(get_env_name("config_file")) {
|
||||
Ok(value) => PathBuf::from(value),
|
||||
@@ -365,6 +465,197 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod sandbox_home_translation {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
fn with_sandbox<F: FnOnce()>(f: F) {
|
||||
let prev = env::var_os("IS_SANDBOX");
|
||||
unsafe {
|
||||
env::set_var("IS_SANDBOX", "1");
|
||||
}
|
||||
f();
|
||||
unsafe {
|
||||
match prev {
|
||||
Some(v) => env::set_var("IS_SANDBOX", v),
|
||||
None => env::remove_var("IS_SANDBOX"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn without_sandbox<F: FnOnce()>(f: F) {
|
||||
let prev = env::var_os("IS_SANDBOX");
|
||||
unsafe {
|
||||
env::remove_var("IS_SANDBOX");
|
||||
}
|
||||
f();
|
||||
unsafe {
|
||||
if let Some(v) = prev {
|
||||
env::set_var("IS_SANDBOX", v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_when_not_in_sandbox() {
|
||||
without_sandbox(|| {
|
||||
let p = Path::new("/home/atusa/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_host_home_to_agent_home() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/home/atusa/.coyote_password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
Some(PathBuf::from("/home/agent/.coyote_password"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_nested_host_home_path() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/home/atusa/.config/coyote/.password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_when_path_already_targets_agent_home() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/home/agent/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_when_path_is_outside_home() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/etc/coyote/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_for_relative_path() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new(".coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_for_first_segment_not_home() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/opt/atusa/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_macos_users_path() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/Users/atusa/.coyote_password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
Some(PathBuf::from("/home/agent/.coyote_password"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_macos_nested_path() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/Users/atusa/.config/coyote/.password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_when_macos_path_already_targets_agent() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/Users/agent/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_windows_drive_letter_path() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("C:\\Users\\atusa\\.coyote_password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
Some(PathBuf::from("/home/agent/.coyote_password"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_windows_nested_path() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("D:\\Users\\atusa\\.config\\coyote\\.password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_when_windows_path_already_targets_agent() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("C:\\Users\\agent\\.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn list_skills_skips_invalid_directory_names() {
|
||||
let unique = time::SystemTime::now()
|
||||
|
||||
@@ -371,9 +371,32 @@ impl RequestContext {
|
||||
if self.rag.is_some() {
|
||||
flags |= StateFlags::RAG;
|
||||
}
|
||||
if self.app.config.function_calling_support {
|
||||
flags |= StateFlags::FUNCTION_CALLING;
|
||||
}
|
||||
if self.auto_continue_config().enabled {
|
||||
flags |= StateFlags::AUTO_CONTINUE;
|
||||
}
|
||||
if self.resolved_skills_enabled() {
|
||||
flags |= StateFlags::SKILLS_ENABLED;
|
||||
}
|
||||
flags
|
||||
}
|
||||
|
||||
pub fn resolved_skills_enabled(&self) -> bool {
|
||||
if let Some(agent) = &self.agent
|
||||
&& let Some(value) = agent.skills_enabled()
|
||||
{
|
||||
return value;
|
||||
}
|
||||
let app = &self.app.config;
|
||||
self.session
|
||||
.as_ref()
|
||||
.and_then(|s| s.skills_enabled())
|
||||
.or_else(|| self.role.as_ref().and_then(|r| r.skills_enabled()))
|
||||
.unwrap_or(app.skills_enabled)
|
||||
}
|
||||
|
||||
pub fn messages_file(&self) -> PathBuf {
|
||||
match &self.agent {
|
||||
None => match env::var(get_env_name("messages_file")) {
|
||||
@@ -450,6 +473,50 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn todo_info(&self) -> Result<String> {
|
||||
if !self.auto_continue_config().enabled {
|
||||
bail!(
|
||||
"Auto-continuation is disabled. Enable it by setting `auto_continue: true` in your config or running `.set auto_continue true`."
|
||||
);
|
||||
}
|
||||
|
||||
if self.todo_list.is_empty() {
|
||||
return Ok("No todos in the running list.\n".to_string());
|
||||
}
|
||||
|
||||
let mut out = self.todo_list.render_for_model();
|
||||
out.push('\n');
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn tools_info(&self) -> Result<String> {
|
||||
if !self.app.config.function_calling_support {
|
||||
bail!(
|
||||
"Function calling is disabled. Enable it by setting `function_calling_support: true` in your config or running `.set function_calling_support true`."
|
||||
);
|
||||
}
|
||||
let role = self.extract_role(&self.app.config)?;
|
||||
match self.select_functions(&role) {
|
||||
None => Ok("No tools enabled for the next request.\n".to_string()),
|
||||
Some(functions) => {
|
||||
let mut names: Vec<&str> = functions.iter().map(|f| f.name.as_str()).collect();
|
||||
names.sort_unstable();
|
||||
let mut out = format!(
|
||||
"Tools enabled for the next request: {}\n\n",
|
||||
functions.len()
|
||||
);
|
||||
|
||||
for name in names {
|
||||
out.push_str(" ");
|
||||
out.push_str(name);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_sessions(&self) -> Vec<String> {
|
||||
list_file_names(self.sessions_dir(), ".yaml")
|
||||
}
|
||||
@@ -1036,6 +1103,10 @@ impl RequestContext {
|
||||
"enabled_mcp_servers",
|
||||
super::format_option_value(&role.enabled_mcp_servers().map(|v| v.join(","))),
|
||||
),
|
||||
(
|
||||
"enabled_skills",
|
||||
super::format_option_value(&role.enabled_skills().map(|v| v.join(","))),
|
||||
),
|
||||
(
|
||||
"max_output_tokens",
|
||||
role.model()
|
||||
@@ -1071,6 +1142,7 @@ impl RequestContext {
|
||||
app.function_calling_support.to_string(),
|
||||
),
|
||||
("mcp_server_support", app.mcp_server_support.to_string()),
|
||||
("skills_enabled", app.skills_enabled.to_string()),
|
||||
("auto_continue", app.auto_continue.to_string()),
|
||||
("max_auto_continues", app.max_auto_continues.to_string()),
|
||||
("stream", app.stream.to_string()),
|
||||
@@ -1090,6 +1162,7 @@ impl RequestContext {
|
||||
("rags_dir", display_path(&paths::rags_dir())),
|
||||
("macros_dir", display_path(&paths::macros_dir())),
|
||||
("functions_dir", display_path(&paths::functions_dir())),
|
||||
("sbx_kit_dir", display_path(&paths::sbx_kit_dir())),
|
||||
("messages_file", display_path(&self.messages_file())),
|
||||
];
|
||||
|
||||
@@ -1947,6 +2020,7 @@ impl RequestContext {
|
||||
} else {
|
||||
self.update_app_config(|app| app.skills_enabled = value.unwrap_or(true));
|
||||
}
|
||||
self.refresh_tool_scope(abort_signal.clone()).await?;
|
||||
}
|
||||
"enabled_mcp_servers" => {
|
||||
let raw: Option<String> = super::parse_value(value)?;
|
||||
@@ -2201,11 +2275,6 @@ impl RequestContext {
|
||||
super::map_completion_values(values)
|
||||
}
|
||||
".macro" => super::map_completion_values(paths::list_macros()),
|
||||
".skill" => super::map_completion_values(vec![
|
||||
"loaded".to_string(),
|
||||
"load".to_string(),
|
||||
"unload".to_string(),
|
||||
]),
|
||||
".starter" => match &self.agent {
|
||||
Some(agent) => agent
|
||||
.conversation_starters()
|
||||
@@ -2401,7 +2470,7 @@ impl RequestContext {
|
||||
_ => vec![],
|
||||
};
|
||||
values = candidates.into_iter().map(|v| (v, None)).collect();
|
||||
} else if cmd == ".vault" && args.len() == 2 {
|
||||
} else if cmd == ".vault" && args.len() == 2 && args[0] != "list" {
|
||||
values = self
|
||||
.app
|
||||
.vault
|
||||
@@ -3757,6 +3826,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn update_skills_enabled_false_removes_skill_meta_tools_from_scope() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let app_state = app_state_with_mcp_config(false, &[]);
|
||||
let mut ctx = RequestContext::new(app_state, WorkingMode::Repl);
|
||||
let app = ctx.app.config.clone();
|
||||
let abort = utils::create_abort_signal();
|
||||
|
||||
run_async(ctx.rebuild_tool_scope(&app, None, abort.clone())).unwrap();
|
||||
|
||||
let names_before: Vec<String> = ctx
|
||||
.tool_scope
|
||||
.functions
|
||||
.declarations()
|
||||
.iter()
|
||||
.map(|f| f.name.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
names_before.iter().any(|n| n.starts_with("skill__")),
|
||||
"expected skill__* functions before toggle, got: {names_before:?}"
|
||||
);
|
||||
|
||||
run_async(ctx.update("skills_enabled false", abort)).unwrap();
|
||||
|
||||
let names_after: Vec<String> = ctx
|
||||
.tool_scope
|
||||
.functions
|
||||
.declarations()
|
||||
.iter()
|
||||
.map(|f| f.name.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
!names_after.iter().any(|n| n.starts_with("skill__")),
|
||||
"expected skill__* functions to be removed after `.set skills_enabled false`, got: {names_after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_functions_returns_none_when_no_tools_enabled() {
|
||||
let ctx = create_test_ctx();
|
||||
@@ -4056,9 +4163,84 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_empty_context() {
|
||||
fn state_empty_context_has_no_context_flags() {
|
||||
let ctx = create_test_ctx();
|
||||
assert_eq!(ctx.state(), StateFlags::empty());
|
||||
|
||||
let state = ctx.state();
|
||||
|
||||
assert!(!state.contains(StateFlags::ROLE));
|
||||
assert!(!state.contains(StateFlags::SESSION));
|
||||
assert!(!state.contains(StateFlags::SESSION_EMPTY));
|
||||
assert!(!state.contains(StateFlags::AGENT));
|
||||
assert!(!state.contains(StateFlags::RAG));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_includes_function_calling_when_app_enables_it() {
|
||||
let ctx = create_test_ctx();
|
||||
|
||||
assert!(ctx.state().contains(StateFlags::FUNCTION_CALLING));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_includes_skills_enabled_when_app_enables_it() {
|
||||
let ctx = create_test_ctx();
|
||||
|
||||
assert!(ctx.state().contains(StateFlags::SKILLS_ENABLED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_omits_skills_enabled_when_app_disables_it() {
|
||||
let mut ctx = create_test_ctx();
|
||||
|
||||
ctx.update_app_config(|app| app.skills_enabled = false);
|
||||
|
||||
assert!(!ctx.state().contains(StateFlags::SKILLS_ENABLED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_skills_enabled_respects_session_override() {
|
||||
let mut ctx = create_test_ctx();
|
||||
let mut session = Session::default();
|
||||
session.set_skills_enabled(Some(false));
|
||||
|
||||
ctx.session = Some(session);
|
||||
|
||||
assert!(!ctx.state().contains(StateFlags::SKILLS_ENABLED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_skills_enabled_respects_role_override() {
|
||||
let mut ctx = create_test_ctx();
|
||||
let role = Role::new("r", "---\nskills_enabled: false\n---\nbody");
|
||||
|
||||
ctx.role = Some(role);
|
||||
|
||||
assert!(!ctx.state().contains(StateFlags::SKILLS_ENABLED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_omits_function_calling_when_app_disables_it() {
|
||||
let app_state = {
|
||||
let config = AppConfig {
|
||||
function_calling_support: false,
|
||||
..AppConfig::default()
|
||||
};
|
||||
Arc::new(AppState {
|
||||
config: Arc::new(config),
|
||||
vault: Arc::new(Vault::default()),
|
||||
mcp_factory: Arc::new(McpFactory::default()),
|
||||
rag_cache: Arc::new(RagCache::default()),
|
||||
mcp_config: None,
|
||||
mcp_log_path: None,
|
||||
mcp_registry: None,
|
||||
functions: Functions::default(),
|
||||
})
|
||||
};
|
||||
|
||||
let ctx = RequestContext::new(app_state, WorkingMode::Cmd);
|
||||
|
||||
assert!(!ctx.state().contains(StateFlags::FUNCTION_CALLING));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4086,6 +4268,144 @@ mod tests {
|
||||
assert!(state.contains(StateFlags::SESSION_EMPTY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_info_errors_when_auto_continue_disabled() {
|
||||
let ctx = create_test_ctx();
|
||||
let err = ctx.todo_info().unwrap_err();
|
||||
|
||||
let msg = err.to_string();
|
||||
|
||||
assert!(
|
||||
msg.contains("Auto-continuation is disabled"),
|
||||
"expected error to mention auto-continuation, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_info_returns_empty_message_when_list_is_empty() {
|
||||
let mut ctx = create_test_ctx();
|
||||
|
||||
ctx.update_app_config(|app| app.auto_continue = true);
|
||||
|
||||
let info = ctx.todo_info().unwrap();
|
||||
assert!(
|
||||
info.contains("No todos in the running list"),
|
||||
"expected 'No todos' message, got: {info}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_info_renders_running_list() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.update_app_config(|app| app.auto_continue = true);
|
||||
ctx.init_todo_list("Map Labs");
|
||||
ctx.add_todo("Discover columns");
|
||||
ctx.add_todo("Write report");
|
||||
|
||||
ctx.mark_todo_done(1);
|
||||
|
||||
let info = ctx.todo_info().unwrap();
|
||||
assert!(
|
||||
info.contains("Goal: Map Labs"),
|
||||
"expected goal in output, got: {info}"
|
||||
);
|
||||
assert!(
|
||||
info.contains("Progress: 1/2 completed"),
|
||||
"expected progress line, got: {info}"
|
||||
);
|
||||
assert!(
|
||||
info.contains("Discover columns"),
|
||||
"expected first task, got: {info}"
|
||||
);
|
||||
assert!(
|
||||
info.contains("Write report"),
|
||||
"expected second task, got: {info}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_info_returns_message_when_no_tools_enabled() {
|
||||
let ctx = create_test_ctx();
|
||||
|
||||
let info = ctx.tools_info().unwrap();
|
||||
|
||||
assert!(
|
||||
info.contains("No tools enabled"),
|
||||
"expected 'No tools enabled' message, got: {info}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_info_lists_enabled_tool_names_alphabetically() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.tool_scope.functions.append_todo_functions();
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec!["all".to_string()]));
|
||||
ctx.role = Some(role);
|
||||
|
||||
let info = ctx.tools_info().unwrap();
|
||||
|
||||
assert!(
|
||||
info.contains("Tools enabled for the next request:"),
|
||||
"expected count line, got: {info}"
|
||||
);
|
||||
assert!(
|
||||
info.contains("todo__init"),
|
||||
"expected todo__init in output, got: {info}"
|
||||
);
|
||||
|
||||
let positions: Vec<usize> = info
|
||||
.lines()
|
||||
.filter(|line| line.trim().starts_with("todo__"))
|
||||
.enumerate()
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
assert!(
|
||||
!positions.is_empty(),
|
||||
"expected at least one todo__ entry, got: {info}"
|
||||
);
|
||||
|
||||
let todo_lines: Vec<&str> = info
|
||||
.lines()
|
||||
.filter(|line| line.trim().starts_with("todo__"))
|
||||
.collect();
|
||||
let mut sorted = todo_lines.clone();
|
||||
sorted.sort_unstable();
|
||||
assert_eq!(
|
||||
todo_lines, sorted,
|
||||
"expected todo__ entries to be alphabetically sorted, got: {todo_lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_info_errors_when_function_calling_disabled() {
|
||||
let app_state = {
|
||||
let config = AppConfig {
|
||||
function_calling_support: false,
|
||||
..AppConfig::default()
|
||||
};
|
||||
Arc::new(AppState {
|
||||
config: Arc::new(config),
|
||||
vault: Arc::new(Vault::default()),
|
||||
mcp_factory: Arc::new(McpFactory::default()),
|
||||
rag_cache: Arc::new(RagCache::default()),
|
||||
mcp_config: None,
|
||||
mcp_log_path: None,
|
||||
mcp_registry: None,
|
||||
functions: Functions::default(),
|
||||
})
|
||||
};
|
||||
let ctx = RequestContext::new(app_state, WorkingMode::Cmd);
|
||||
|
||||
let err = ctx.tools_info().unwrap_err();
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("Function calling is disabled"),
|
||||
"expected error to mention function calling, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_info_errors_when_no_role() {
|
||||
let ctx = create_test_ctx();
|
||||
|
||||
@@ -10,6 +10,7 @@ mod repl;
|
||||
mod utils;
|
||||
mod mcp;
|
||||
mod parsers;
|
||||
mod sandbox;
|
||||
mod supervisor;
|
||||
mod vault;
|
||||
|
||||
@@ -56,6 +57,7 @@ async fn main() -> Result<()> {
|
||||
shell.generate_completions(&mut cmd);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if cli.tail_logs {
|
||||
tail_logs(cli.disable_log_colors).await;
|
||||
return Ok(());
|
||||
@@ -92,6 +94,10 @@ async fn main() -> Result<()> {
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(name) = &cli.sandbox {
|
||||
return sandbox::launch(name.clone(), cli.fresh, cli.no_mixins);
|
||||
}
|
||||
|
||||
install_builtins()?;
|
||||
|
||||
if let Some(category) = cli.install {
|
||||
|
||||
+127
-11
@@ -15,9 +15,11 @@ use crate::config::{AssetCategory, paths};
|
||||
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
||||
use crate::render::render_error;
|
||||
use crate::utils::{
|
||||
AbortSignal, abortable_run_with_spinner, create_abort_signal, dimmed_text, set_text, temp_file,
|
||||
AbortSignal, SHELL, abortable_run_with_spinner, create_abort_signal, dimmed_text, run_command,
|
||||
set_text, temp_file,
|
||||
};
|
||||
|
||||
use crate::sandbox::SANDBOX_ENV_FLAG;
|
||||
use crate::{config, graph, resolve_oauth_client};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
@@ -47,10 +49,15 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
||||
4. Continue with the next pending item now. Call tools immediately."
|
||||
};
|
||||
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 44]> = LazyLock::new(|| {
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 49]> = LazyLock::new(|| {
|
||||
[
|
||||
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
||||
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
||||
ReplCommand::new(
|
||||
".info tools",
|
||||
"Show the list of enabled tools to be passed to the LLM",
|
||||
AssertState::True(StateFlags::FUNCTION_CALLING),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".authenticate",
|
||||
"Authenticate the current model client via OAuth (if configured)",
|
||||
@@ -161,6 +168,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 44]> = LazyLock::new(|| {
|
||||
"Clear the todo list and stop auto-continuation",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".info todo",
|
||||
"Show the current todo list driving auto-continuation",
|
||||
AssertState::True(StateFlags::AUTO_CONTINUE),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".rag",
|
||||
"Initialize or access RAG",
|
||||
@@ -194,13 +206,28 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 44]> = LazyLock::new(|| {
|
||||
ReplCommand::new(".macro", "Execute a macro", AssertState::pass()),
|
||||
ReplCommand::new(
|
||||
".skill",
|
||||
"List, load, unload, or create skills",
|
||||
AssertState::pass(),
|
||||
"Create a new skill",
|
||||
AssertState::True(StateFlags::SKILLS_ENABLED),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".skill load",
|
||||
"Load a skill into the current context",
|
||||
AssertState::True(StateFlags::SKILLS_ENABLED),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".skill loaded",
|
||||
"List currently-loaded skills",
|
||||
AssertState::True(StateFlags::SKILLS_ENABLED),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".skill unload",
|
||||
"Unload a skill from the current context",
|
||||
AssertState::True(StateFlags::SKILLS_ENABLED),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".edit skill",
|
||||
"Modify an existing skill by name",
|
||||
AssertState::pass(),
|
||||
AssertState::True(StateFlags::SKILLS_ENABLED),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".file",
|
||||
@@ -278,7 +305,12 @@ Type ".help" for additional help.
|
||||
"#,
|
||||
env!("CARGO_CRATE_NAME"),
|
||||
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 {
|
||||
@@ -473,6 +505,14 @@ pub async fn run_repl_command(
|
||||
let info = ctx.agent_info()?;
|
||||
print!("{info}");
|
||||
}
|
||||
Some("tools") => {
|
||||
let info = ctx.tools_info()?;
|
||||
print!("{info}");
|
||||
}
|
||||
Some("todo") => {
|
||||
let info = ctx.todo_info()?;
|
||||
print!("{info}");
|
||||
}
|
||||
Some(_) => unknown_command()?,
|
||||
None => {
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
@@ -955,9 +995,13 @@ pub async fn run_repl_command(
|
||||
_ => unknown_command()?,
|
||||
},
|
||||
None => {
|
||||
reset_continuation(ctx);
|
||||
let input = Input::from_str(ctx, line, None)?;
|
||||
ask(ctx, abort_signal.clone(), input, true).await?;
|
||||
if let Some(cmd) = try_extract_shell_command(line) {
|
||||
handle_shell_passthrough(cmd)?;
|
||||
} else {
|
||||
reset_continuation(ctx);
|
||||
let input = Input::from_str(ctx, line, None)?;
|
||||
ask(ctx, abort_signal.clone(), input, true).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1173,10 +1217,12 @@ fn dump_repl_help() {
|
||||
.join("\n");
|
||||
println!(
|
||||
r###"{head}
|
||||
{:<24} Run an arbitrary shell command (stdout/stderr stream to your terminal; Ctrl+C interrupts)
|
||||
|
||||
Type ::: to start multi-line editing, type ::: to finish it.
|
||||
Press Ctrl+O to open an editor for editing the input buffer.
|
||||
Press Ctrl+C to cancel the response, Ctrl+D to exit the REPL."###,
|
||||
"!<command>",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1192,6 +1238,25 @@ fn parse_command(line: &str) -> Option<(&str, Option<&str>)> {
|
||||
}
|
||||
}
|
||||
|
||||
fn try_extract_shell_command(line: &str) -> Option<&str> {
|
||||
let rest = line.strip_prefix('!')?;
|
||||
Some(rest.trim_start())
|
||||
}
|
||||
|
||||
fn handle_shell_passthrough(cmd: &str) -> Result<()> {
|
||||
if cmd.is_empty() {
|
||||
eprintln!("Usage: !<command>");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let status = run_command(&SHELL.cmd, &[&SHELL.arg, cmd], None)?;
|
||||
if status != 0 {
|
||||
eprintln!("[exit {status}]");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn split_first_arg(args: Option<&str>) -> Option<(&str, Option<&str>)> {
|
||||
args.map(|v| match v.split_once(' ') {
|
||||
Some((subcmd, args)) => (subcmd, Some(args.trim())),
|
||||
@@ -1350,8 +1415,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_commands_has_44_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 44);
|
||||
fn repl_commands_has_49_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 49);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1526,6 +1591,57 @@ mod tests {
|
||||
assert_eq!(parse_command("."), Some((".", None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_extract_shell_command_strips_bang() {
|
||||
assert_eq!(try_extract_shell_command("!ls"), Some("ls"));
|
||||
assert_eq!(try_extract_shell_command("!ls -la"), Some("ls -la"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_extract_shell_command_trims_inner_whitespace() {
|
||||
assert_eq!(try_extract_shell_command("! echo hi"), Some("echo hi"));
|
||||
assert_eq!(try_extract_shell_command("! ls"), Some("ls"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_extract_shell_command_only_bang_yields_empty() {
|
||||
assert_eq!(try_extract_shell_command("!"), Some(""));
|
||||
assert_eq!(try_extract_shell_command("! "), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_extract_shell_command_rejects_leading_whitespace() {
|
||||
assert!(try_extract_shell_command(" !ls").is_none());
|
||||
assert!(try_extract_shell_command("\t!ls").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_extract_shell_command_rejects_inline_bang() {
|
||||
assert!(try_extract_shell_command("echo !foo").is_none());
|
||||
assert!(try_extract_shell_command("hello world").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_extract_shell_command_strips_one_leading_bang() {
|
||||
assert_eq!(try_extract_shell_command("!!ls"), Some("!ls"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_extract_shell_command_preserves_pipes_and_redirects() {
|
||||
assert_eq!(
|
||||
try_extract_shell_command("!ls -la | grep yaml"),
|
||||
Some("ls -la | grep yaml")
|
||||
);
|
||||
assert_eq!(
|
||||
try_extract_shell_command("!cat foo.txt > /tmp/out"),
|
||||
Some("cat foo.txt > /tmp/out")
|
||||
);
|
||||
assert_eq!(
|
||||
try_extract_shell_command(r#"!echo "$HOME""#),
|
||||
Some(r#"echo "$HOME""#)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_first_arg_none_input() {
|
||||
assert!(split_first_arg(None).is_none());
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
use std::env;
|
||||
use std::fs::{read_dir, read_to_string};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_yaml::Value;
|
||||
|
||||
use crate::config::paths;
|
||||
|
||||
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredMixin {
|
||||
pub path: PathBuf,
|
||||
pub label: String,
|
||||
pub install_count: usize,
|
||||
pub domain_count: usize,
|
||||
}
|
||||
|
||||
pub fn discover() -> Result<Vec<DiscoveredMixin>> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
push_if_exists(&mut out, paths::sbx_mixin_file())?;
|
||||
push_if_exists(&mut out, paths::global_tools_sbx_mixin_file())?;
|
||||
|
||||
for path in collect_subdir_mixins(&paths::functions_dir()) {
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
for path in collect_subdir_mixins(&paths::agents_data_dir()) {
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
|
||||
if let Ok(cwd) = env::current_dir()
|
||||
&& let Some(path) = paths::find_workspace_sbx_mixin(&cwd)
|
||||
{
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn summarize(path: &Path) -> Result<(usize, usize)> {
|
||||
let content = read_to_string(path)
|
||||
.with_context(|| format!("Failed to read sbx mixin {}", path.display()))?;
|
||||
let value: Value = serde_yaml::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse sbx mixin {}", path.display()))?;
|
||||
|
||||
let installs = value
|
||||
.get("commands")
|
||||
.and_then(|c| c.get("install"))
|
||||
.and_then(|i| i.as_sequence())
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let domains = value
|
||||
.get("network")
|
||||
.and_then(|n| n.get("allowedDomains"))
|
||||
.and_then(|d| d.as_sequence())
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok((installs, domains))
|
||||
}
|
||||
|
||||
pub fn log_discovery(mixins: &[DiscoveredMixin], disabled: bool) {
|
||||
if disabled {
|
||||
info!("Mixin discovery disabled via --no-mixins.");
|
||||
return;
|
||||
}
|
||||
|
||||
if mixins.is_empty() {
|
||||
info!("No sbx mixins discovered.");
|
||||
return;
|
||||
}
|
||||
|
||||
let header = format!("Applying {} sbx mixin(s):", mixins.len());
|
||||
info!("{header}");
|
||||
println!("{header}");
|
||||
|
||||
for m in mixins {
|
||||
let line = format!(
|
||||
" {} (adds: {} install{}, {} domain{})",
|
||||
m.label,
|
||||
m.install_count,
|
||||
if m.install_count == 1 { "" } else { "s" },
|
||||
m.domain_count,
|
||||
if m.domain_count == 1 { "" } else { "s" },
|
||||
);
|
||||
info!("{line}");
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
fn push_if_exists(out: &mut Vec<DiscoveredMixin>, path: PathBuf) -> Result<()> {
|
||||
if path.exists() {
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_mixin(path: PathBuf) -> Result<DiscoveredMixin> {
|
||||
let label = path.display().to_string();
|
||||
let (install_count, domain_count) = summarize(&path)?;
|
||||
|
||||
Ok(DiscoveredMixin {
|
||||
path,
|
||||
label,
|
||||
install_count,
|
||||
domain_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_subdir_mixins(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut result = Vec::new();
|
||||
let Ok(rd) = read_dir(dir) else { return result };
|
||||
|
||||
let mut entries: Vec<_> = rd
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let candidate = entry.path().join(SBX_MIXIN_FILE_NAME);
|
||||
if candidate.exists() {
|
||||
result.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::time;
|
||||
|
||||
fn unique_root(prefix: &str) -> PathBuf {
|
||||
let nanos = time::SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let root = env::temp_dir().join(format!("coyote-{prefix}-{nanos}"));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_counts_installs_and_domains() {
|
||||
let root = unique_root("sbx-mixin-counts");
|
||||
let path = root.join("sbx-mixin.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
commands:
|
||||
install:
|
||||
- command: "echo hi"
|
||||
- command: "echo bye"
|
||||
network:
|
||||
allowedDomains:
|
||||
- "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_treats_missing_blocks_as_zero() {
|
||||
let root = unique_root("sbx-mixin-empty");
|
||||
let path = root.join("sbx-mixin.yaml");
|
||||
fs::write(&path, "schemaVersion: \"1\"\nkind: mixin\n").unwrap();
|
||||
|
||||
assert_eq!(summarize(&path).unwrap(), (0, 0));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_returns_err_on_malformed_yaml() {
|
||||
let root = unique_root("sbx-mixin-bad");
|
||||
let path = root.join("sbx-mixin.yaml");
|
||||
fs::write(&path, "this: is: not: yaml: ::").unwrap();
|
||||
|
||||
let err = summarize(&path).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains(&path.display().to_string()),
|
||||
"expected error to mention path; got: {msg}"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_subdir_mixins_sorts_and_skips_missing() {
|
||||
let root = unique_root("sbx-mixin-subdirs");
|
||||
for name in ["zebra", "apple", "no-mixin", "mango"] {
|
||||
let dir = root.join(name);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
if name != "no-mixin" {
|
||||
fs::write(dir.join("sbx-mixin.yaml"), "kind: mixin\n").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let found = collect_subdir_mixins(&root);
|
||||
let names: Vec<String> = found
|
||||
.iter()
|
||||
.map(|p| {
|
||||
p.parent()
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(names, vec!["apple", "mango", "zebra"]);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_subdir_mixins_returns_empty_for_missing_dir() {
|
||||
let absent = env::temp_dir().join("coyote-definitely-not-here-xyz");
|
||||
let found = collect_subdir_mixins(&absent);
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
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;
|
||||
|
||||
mod mixins;
|
||||
|
||||
use gman::providers::SupportedProvider;
|
||||
|
||||
use crate::config::paths;
|
||||
use crate::sandbox::mixins::DiscoveredMixin;
|
||||
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;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "assets/sbx-vault-mixins/"]
|
||||
struct EmbeddedVaultMixins;
|
||||
|
||||
pub fn launch(name: Option<String>, fresh: bool, no_mixins: bool) -> Result<()> {
|
||||
ensure_sbx_installed()?;
|
||||
bail_if_nested()?;
|
||||
|
||||
let name = resolve_name(name)?;
|
||||
let kit_path = resolve_kit_path()?;
|
||||
|
||||
let discovered = if no_mixins {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut all = mixins::discover()?;
|
||||
if let Ok(vault) = Vault::init_bare()
|
||||
&& let Some(vault_mixin) = extract_vault_mixin(&vault.provider)?
|
||||
{
|
||||
all.insert(0, vault_mixin);
|
||||
}
|
||||
all
|
||||
};
|
||||
|
||||
if sandbox_exists(&name)? {
|
||||
info!("Re-attaching to existing sandbox '{name}'");
|
||||
if fresh {
|
||||
debug!("--fresh ignored: re-attaching to existing sandbox '{name}'");
|
||||
}
|
||||
if no_mixins {
|
||||
debug!("--no-mixins ignored: re-attaching to existing sandbox '{name}'");
|
||||
}
|
||||
} else {
|
||||
mixins::log_discovery(&discovered, no_mixins);
|
||||
|
||||
if fresh {
|
||||
let msg = format!("Creating fresh sandbox '{name}' (no host config will be copied)");
|
||||
info!("{msg}");
|
||||
println!("{msg}");
|
||||
create_sandbox(&name, &kit_path, &discovered)?;
|
||||
} else {
|
||||
create_sandbox(&name, &kit_path, &discovered)?;
|
||||
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 extract_vault_mixin(provider: &SupportedProvider) -> Result<Option<DiscoveredMixin>> {
|
||||
let provider_dir = match provider {
|
||||
SupportedProvider::Local { .. } => return Ok(None),
|
||||
SupportedProvider::AwsSecretsManager { .. } => "aws_secrets_manager",
|
||||
SupportedProvider::GcpSecretManager { .. } => "gcp_secret_manager",
|
||||
SupportedProvider::AzureKeyVault { .. } => "azure_key_vault",
|
||||
SupportedProvider::Gopass { .. } => "gopass",
|
||||
SupportedProvider::OnePassword { .. } => "one_password",
|
||||
};
|
||||
|
||||
let cache_root = extract_vault_mixins_cache()?;
|
||||
let provider_root = cache_root.join(provider_dir);
|
||||
let spec_path = provider_root.join("spec.yaml");
|
||||
|
||||
if !spec_path.exists() {
|
||||
bail!(
|
||||
"Embedded vault mixin for '{provider_dir}' is missing spec.yaml at {}",
|
||||
spec_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let label = format!("<built-in: vault-{provider_dir}>");
|
||||
let (install_count, domain_count) = mixins::summarize(&spec_path)?;
|
||||
|
||||
Ok(Some(DiscoveredMixin {
|
||||
path: provider_root,
|
||||
label,
|
||||
install_count,
|
||||
domain_count,
|
||||
}))
|
||||
}
|
||||
|
||||
fn extract_vault_mixins_cache() -> Result<PathBuf> {
|
||||
let cache_root = paths::sbx_vault_mixins_dir();
|
||||
let new_hash = compute_vault_mixins_hash()?;
|
||||
let hash_file = paths::sbx_vault_mixins_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 vault mixins at {}",
|
||||
cache_root.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::create_dir_all(&cache_root)
|
||||
.with_context(|| format!("Failed to create {}", cache_root.display()))?;
|
||||
|
||||
for entry in EmbeddedVaultMixins::iter() {
|
||||
let file = EmbeddedVaultMixins::get(&entry).ok_or_else(|| {
|
||||
anyhow!("Embedded vault mixin 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-vault-mixins to {}",
|
||||
cache_root.display()
|
||||
);
|
||||
|
||||
Ok(cache_root)
|
||||
}
|
||||
|
||||
fn compute_vault_mixins_hash() -> Result<String> {
|
||||
let mut hasher = Sha256::new();
|
||||
let mut entries: Vec<_> = EmbeddedVaultMixins::iter().collect();
|
||||
entries.sort();
|
||||
|
||||
for entry in &entries {
|
||||
let file = EmbeddedVaultMixins::get(entry)
|
||||
.ok_or_else(|| anyhow!("Embedded vault mixin 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, mixins: &[DiscoveredMixin]) -> Result<()> {
|
||||
info!("Creating sandbox '{name}'");
|
||||
let args = build_create_args(name, kit_path, mixins)?;
|
||||
let status = Command::new(SBX_BINARY)
|
||||
.args(&args)
|
||||
.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 build_create_args(
|
||||
name: &str,
|
||||
kit_path: &Path,
|
||||
mixins: &[DiscoveredMixin],
|
||||
) -> Result<Vec<String>> {
|
||||
let kit_str = kit_path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Kit path is not valid UTF-8: {}", kit_path.display()))?;
|
||||
|
||||
let mut args = vec![
|
||||
"create".to_string(),
|
||||
"--kit".to_string(),
|
||||
kit_str.to_string(),
|
||||
];
|
||||
|
||||
for mixin in mixins {
|
||||
let mixin_str = mixin
|
||||
.path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Mixin path is not valid UTF-8: {}", mixin.path.display()))?;
|
||||
args.push("--kit".to_string());
|
||||
args.push(mixin_str.to_string());
|
||||
}
|
||||
|
||||
args.push(SANDBOX_AGENT.to_string());
|
||||
args.push("--name".to_string());
|
||||
args.push(name.to_string());
|
||||
args.push(".".to_string());
|
||||
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
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 = host_to_sandbox_path(&password_file, &home_dir, cfg!(windows))?;
|
||||
if let Some(parent) = sandbox_path_parent(&dest_path)
|
||||
&& !parent.is_empty()
|
||||
{
|
||||
ensure_sandbox_dir(name, parent)?;
|
||||
}
|
||||
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 host_to_sandbox_path(
|
||||
host_path: &Path,
|
||||
home_dir: &Path,
|
||||
is_windows_host: bool,
|
||||
) -> Result<String> {
|
||||
let host_str = host_path.to_str().context("Host path is not valid UTF-8")?;
|
||||
let home_str = home_dir
|
||||
.to_str()
|
||||
.context("Home directory is not valid UTF-8")?;
|
||||
|
||||
if let Some(rel) = strip_host_home(host_str, home_str) {
|
||||
let unixified = rel.replace('\\', "/");
|
||||
return Ok(format!("/home/agent/{unixified}"));
|
||||
}
|
||||
|
||||
if is_windows_host {
|
||||
bail!(
|
||||
"Path '{host_str}' is outside your Windows user profile ({home_str}). \
|
||||
Sandbox mode cannot copy files from outside %USERPROFILE% into a Linux \
|
||||
sandbox. Move the file under your user profile and update your config \
|
||||
accordingly."
|
||||
);
|
||||
}
|
||||
|
||||
Ok(host_str.to_string())
|
||||
}
|
||||
|
||||
fn strip_host_home(path: &str, home: &str) -> Option<String> {
|
||||
let path_norm: String = path
|
||||
.chars()
|
||||
.map(|c| if c == '\\' { '/' } else { c })
|
||||
.collect();
|
||||
let home_norm: String = home
|
||||
.chars()
|
||||
.map(|c| if c == '\\' { '/' } else { c })
|
||||
.collect();
|
||||
let home_norm = home_norm.trim_end_matches('/');
|
||||
|
||||
if home_norm.is_empty() || path_norm.len() <= home_norm.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (head, tail) = path_norm.split_at(home_norm.len());
|
||||
if head != home_norm || !tail.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(tail[1..].to_string())
|
||||
}
|
||||
|
||||
fn sandbox_path_parent(linux_path: &str) -> Option<&str> {
|
||||
linux_path.rsplit_once('/').map(|(parent, _)| parent)
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_create_args_emits_base_kit_before_mixins() {
|
||||
let kit = PathBuf::from("/cache/sbx-kit");
|
||||
let mixins = vec![
|
||||
DiscoveredMixin {
|
||||
path: PathBuf::from("/cfg/sbx-mixin.yaml"),
|
||||
label: "user".into(),
|
||||
install_count: 0,
|
||||
domain_count: 0,
|
||||
},
|
||||
DiscoveredMixin {
|
||||
path: PathBuf::from("/cfg/agents/sql/sbx-mixin.yaml"),
|
||||
label: "sql".into(),
|
||||
install_count: 0,
|
||||
domain_count: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let args = build_create_args("my-box", &kit, &mixins).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"create".to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cache/sbx-kit".to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cfg/sbx-mixin.yaml".to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cfg/agents/sql/sbx-mixin.yaml".to_string(),
|
||||
"coyote".to_string(),
|
||||
"--name".to_string(),
|
||||
"my-box".to_string(),
|
||||
".".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_create_args_with_no_mixins_omits_mixin_kits() {
|
||||
let kit = PathBuf::from("/cache/sbx-kit");
|
||||
let args = build_create_args("box", &kit, &[]).unwrap();
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"create".to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cache/sbx-kit".to_string(),
|
||||
"coyote".to_string(),
|
||||
"--name".to_string(),
|
||||
"box".to_string(),
|
||||
".".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
mod vault_mixins {
|
||||
use super::*;
|
||||
use gman::providers::aws_secrets_manager::AwsSecretsManagerProvider;
|
||||
use gman::providers::azure_key_vault::AzureKeyVaultProvider;
|
||||
use gman::providers::gcp_secret_manager::GcpSecretManagerProvider;
|
||||
use gman::providers::gopass::GopassProvider;
|
||||
use gman::providers::local::LocalProvider;
|
||||
use gman::providers::one_password::OnePasswordProvider;
|
||||
use serial_test::serial;
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_local() {
|
||||
let p = SupportedProvider::Local {
|
||||
provider_def: LocalProvider::default(),
|
||||
};
|
||||
assert!(extract_vault_mixin(&p).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_aws() {
|
||||
let p = SupportedProvider::AwsSecretsManager {
|
||||
provider_def: AwsSecretsManagerProvider {
|
||||
aws_profile: None,
|
||||
aws_region: None,
|
||||
},
|
||||
};
|
||||
let m = extract_vault_mixin(&p)
|
||||
.unwrap()
|
||||
.expect("expected vault mixin");
|
||||
assert!(m.path.join("spec.yaml").exists());
|
||||
assert!(m.label.contains("aws_secrets_manager"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_gcp() {
|
||||
let p = SupportedProvider::GcpSecretManager {
|
||||
provider_def: GcpSecretManagerProvider {
|
||||
gcp_project_id: None,
|
||||
},
|
||||
};
|
||||
let m = extract_vault_mixin(&p)
|
||||
.unwrap()
|
||||
.expect("expected vault mixin");
|
||||
assert!(m.path.join("spec.yaml").exists());
|
||||
assert!(m.label.contains("gcp_secret_manager"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_one_password() {
|
||||
let p = SupportedProvider::OnePassword {
|
||||
provider_def: OnePasswordProvider {
|
||||
vault: None,
|
||||
account: None,
|
||||
},
|
||||
};
|
||||
let m = extract_vault_mixin(&p)
|
||||
.unwrap()
|
||||
.expect("expected vault mixin");
|
||||
assert!(m.path.join("spec.yaml").exists());
|
||||
assert!(m.label.contains("one_password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_azure() {
|
||||
let p = SupportedProvider::AzureKeyVault {
|
||||
provider_def: AzureKeyVaultProvider { vault_name: None },
|
||||
};
|
||||
let m = extract_vault_mixin(&p)
|
||||
.unwrap()
|
||||
.expect("expected vault mixin");
|
||||
assert!(m.path.join("spec.yaml").exists());
|
||||
assert!(m.label.contains("azure_key_vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_gopass() {
|
||||
let p = SupportedProvider::Gopass {
|
||||
provider_def: GopassProvider { store: None },
|
||||
};
|
||||
let m = extract_vault_mixin(&p)
|
||||
.unwrap()
|
||||
.expect("expected vault mixin");
|
||||
assert!(m.path.join("spec.yaml").exists());
|
||||
assert!(m.label.contains("gopass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_is_deterministic() {
|
||||
let h1 = compute_vault_mixins_hash().unwrap();
|
||||
let h2 = compute_vault_mixins_hash().unwrap();
|
||||
assert_eq!(h1, h2);
|
||||
assert_eq!(h1.len(), 64);
|
||||
}
|
||||
}
|
||||
|
||||
mod host_to_sandbox_path_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn linux_under_home() {
|
||||
let dest = host_to_sandbox_path(
|
||||
Path::new("/home/atusa/.coyote_password"),
|
||||
Path::new("/home/atusa"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dest, "/home/agent/.coyote_password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_nested_under_home() {
|
||||
let dest = host_to_sandbox_path(
|
||||
Path::new("/home/atusa/.config/coyote/.password"),
|
||||
Path::new("/home/atusa"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dest, "/home/agent/.config/coyote/.password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_outside_home_returns_verbatim() {
|
||||
let dest = host_to_sandbox_path(
|
||||
Path::new("/etc/coyote/.password"),
|
||||
Path::new("/home/atusa"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dest, "/etc/coyote/.password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_under_home_with_spaces() {
|
||||
let dest = host_to_sandbox_path(
|
||||
Path::new("/Users/atusa/Library/Application Support/coyote/.password"),
|
||||
Path::new("/Users/atusa"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
dest,
|
||||
"/home/agent/Library/Application Support/coyote/.password"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_under_home_converts_backslashes() {
|
||||
let dest = host_to_sandbox_path(
|
||||
Path::new(r"C:\Users\atusa\.coyote_password"),
|
||||
Path::new(r"C:\Users\atusa"),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dest, "/home/agent/.coyote_password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_nested_under_home() {
|
||||
let dest = host_to_sandbox_path(
|
||||
Path::new(r"C:\Users\atusa\Documents\my\vault.txt"),
|
||||
Path::new(r"C:\Users\atusa"),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dest, "/home/agent/Documents/my/vault.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_outside_home_bails_with_clear_error() {
|
||||
let err = host_to_sandbox_path(
|
||||
Path::new(r"C:\Program Files\Coyote\vault.txt"),
|
||||
Path::new(r"C:\Users\atusa"),
|
||||
true,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("Program Files"),
|
||||
"error should name the offending path: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("user profile"),
|
||||
"error should explain the limitation: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_tolerates_trailing_slash_in_home() {
|
||||
let dest = host_to_sandbox_path(
|
||||
Path::new(r"C:\Users\atusa\foo"),
|
||||
Path::new(r"C:\Users\atusa\"),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dest, "/home/agent/foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_path_parent_extracts_parent_for_nested() {
|
||||
assert_eq!(
|
||||
sandbox_path_parent("/home/agent/.coyote_password"),
|
||||
Some("/home/agent")
|
||||
);
|
||||
assert_eq!(
|
||||
sandbox_path_parent("/etc/coyote/.password"),
|
||||
Some("/etc/coyote")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_path_parent_handles_edge_cases() {
|
||||
assert_eq!(sandbox_path_parent("/file"), Some(""));
|
||||
assert_eq!(sandbox_path_parent("noparent"), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -17,7 +17,7 @@ use gman::providers::SecretProvider;
|
||||
use gman::providers::SupportedProvider;
|
||||
use gman::providers::local::LocalProvider;
|
||||
use inquire::{Password, PasswordDisplayMode, required};
|
||||
use log::warn;
|
||||
use log::{info, warn};
|
||||
use serde_yaml::Value;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tokio::runtime::Handle;
|
||||
@@ -25,6 +25,31 @@ use uuid::Uuid;
|
||||
|
||||
pub static SECRET_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{([^{}]+)}}").unwrap());
|
||||
|
||||
fn apply_sandboxed_home_translation(provider_def: &mut LocalProvider) {
|
||||
let Some(ref pf) = provider_def.password_file else {
|
||||
return;
|
||||
};
|
||||
|
||||
if pf.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(translated) = paths::translate_sandboxed_home_path(pf) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !translated.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
"vault password file '{}' not found; resolved to sandboxed path '{}'",
|
||||
pf.display(),
|
||||
translated.display()
|
||||
);
|
||||
provider_def.password_file = Some(translated);
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Vault {
|
||||
pub(crate) provider: SupportedProvider,
|
||||
@@ -92,6 +117,7 @@ impl Vault {
|
||||
};
|
||||
|
||||
if let SupportedProvider::Local { provider_def } = &mut provider {
|
||||
apply_sandboxed_home_translation(provider_def);
|
||||
ensure_password_file_initialized(provider_def)?;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user