feat: Improved credentials management for docker sandboxes
This commit is contained in:
@@ -1,33 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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
|
|
||||||
+1
-45
@@ -30,7 +30,7 @@ use std::io::{Read, stdin};
|
|||||||
",
|
",
|
||||||
group(
|
group(
|
||||||
ArgGroup::new("sbx-mode")
|
ArgGroup::new("sbx-mode")
|
||||||
.args(["sandbox", "fresh", "no_mixins"])
|
.args(["sandbox"])
|
||||||
.multiple(true)
|
.multiple(true)
|
||||||
.conflicts_with_all([
|
.conflicts_with_all([
|
||||||
"model", "prompt", "role", "session", "agent", "rag", "rebuild_rag",
|
"model", "prompt", "role", "session", "agent", "rag", "rebuild_rag",
|
||||||
@@ -227,13 +227,6 @@ pub struct Cli {
|
|||||||
/// Launch Coyote inside a Docker sandbox (via `sbx`); name defaults to current directory basename
|
/// Launch Coyote inside a Docker sandbox (via `sbx`); name defaults to current directory basename
|
||||||
#[arg(long, value_name = "NAME", help_heading = "Sandbox")]
|
#[arg(long, value_name = "NAME", help_heading = "Sandbox")]
|
||||||
pub sandbox: Option<Option<String>>,
|
pub sandbox: Option<Option<String>>,
|
||||||
/// Create the sandbox without bootstrapping the host config or vault password file
|
|
||||||
#[arg(long, requires = "sandbox", help_heading = "Sandbox")]
|
|
||||||
pub fresh: bool,
|
|
||||||
/// Skip discovery and application of all sbx mixins (user and built-in)
|
|
||||||
#[arg(long, requires = "sandbox", help_heading = "Sandbox")]
|
|
||||||
pub no_mixins: bool,
|
|
||||||
|
|
||||||
/// Display information
|
/// Display information
|
||||||
#[arg(long, help_heading = "Diagnostics & Tools")]
|
#[arg(long, help_heading = "Diagnostics & Tools")]
|
||||||
pub info: bool,
|
pub info: bool,
|
||||||
@@ -608,41 +601,4 @@ mod tests {
|
|||||||
assert!(Cli::try_parse_from(["coyote", "--sandbox", "--agent", "foo"]).is_err());
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-4
@@ -49,6 +49,7 @@ use crate::client::{
|
|||||||
};
|
};
|
||||||
use crate::function::{FunctionDeclaration, Functions};
|
use crate::function::{FunctionDeclaration, Functions};
|
||||||
use crate::rag::Rag;
|
use crate::rag::Rag;
|
||||||
|
use crate::sandbox::SANDBOX_ENV_FLAG;
|
||||||
use crate::utils::*;
|
use crate::utils::*;
|
||||||
pub use macros::macro_execute;
|
pub use macros::macro_execute;
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
|
|||||||
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
|
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
|
||||||
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
|
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
|
||||||
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
||||||
const SBX_VAULT_MIXINS_DIR_NAME: &str = "sbx-vault-mixins";
|
pub(crate) const VAULT_DATA_FILE_NAME: &str = "vault.yml";
|
||||||
const SBX_MIXIN_KITS_DIR_NAME: &str = "sbx-mixin-kits";
|
const SBX_MIXIN_KITS_DIR_NAME: &str = "sbx-mixin-kits";
|
||||||
const GIT_DIR_NAME: &str = ".git";
|
const GIT_DIR_NAME: &str = ".git";
|
||||||
const GITIGNORE_FILE_NAME: &str = ".gitignore";
|
const GITIGNORE_FILE_NAME: &str = ".gitignore";
|
||||||
@@ -521,6 +522,12 @@ pub async fn sync_models(url: &str, abort_signal: AbortSignal) -> Result<()> {
|
|||||||
impl Config {
|
impl Config {
|
||||||
pub async fn load_with_interpolation(info_flag: bool) -> Result<Self> {
|
pub async fn load_with_interpolation(info_flag: bool) -> Result<Self> {
|
||||||
let config_path = paths::config_file();
|
let config_path = paths::config_file();
|
||||||
|
|
||||||
|
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
||||||
|
let (config, _) = Self::load_from_file(&config_path)?;
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
|
||||||
let (mut config, content) = if !config_path.exists() {
|
let (mut config, content) = if !config_path.exists() {
|
||||||
match env::var(get_env_name("provider"))
|
match env::var(get_env_name("provider"))
|
||||||
.ok()
|
.ok()
|
||||||
@@ -754,9 +761,7 @@ pub async fn create_config_file(config_path: &Path) -> Result<()> {
|
|||||||
let provider_choice = prompt_provider_choice()?;
|
let provider_choice = prompt_provider_choice()?;
|
||||||
let mut vault = match &provider_choice {
|
let mut vault = match &provider_choice {
|
||||||
None => Vault::default_local(),
|
None => Vault::default_local(),
|
||||||
Some(provider) => Vault {
|
Some(provider) => Vault::from_provider(provider.clone()),
|
||||||
provider: provider.clone(),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
create_vault_password_file(&mut vault)?;
|
create_vault_password_file(&mut vault)?;
|
||||||
if provider_choice.is_some() {
|
if provider_choice.is_some() {
|
||||||
@@ -1108,4 +1113,57 @@ clients:
|
|||||||
assert!(!state.assert(StateFlags::SESSION));
|
assert!(!state.assert(StateFlags::SESSION));
|
||||||
assert!(!state.assert(StateFlags::empty()));
|
assert!(!state.assert(StateFlags::empty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn sandbox_config_load_no_interpolation() {
|
||||||
|
use std::fs;
|
||||||
|
use std::time;
|
||||||
|
|
||||||
|
let unique = time::SystemTime::now()
|
||||||
|
.duration_since(time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let tmp_dir = std::env::temp_dir().join(format!("coyote-sandbox-cfg-{unique}"));
|
||||||
|
fs::create_dir_all(&tmp_dir).unwrap();
|
||||||
|
let config_path = tmp_dir.join("config.yaml");
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
"model: claude:claude-3-5-haiku\nclients:\n - type: claude\n api_key: '{{ANTHROPIC_API_KEY}}'\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let config_env = get_env_name("config_file");
|
||||||
|
let prev_config = std::env::var_os(&config_env);
|
||||||
|
let prev_sandbox = std::env::var_os(crate::sandbox::SANDBOX_ENV_FLAG);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var(&config_env, &config_path);
|
||||||
|
std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = Config::load_with_interpolation(false).await;
|
||||||
|
let (_, raw) = Config::load_from_file(&config_path).unwrap();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
match prev_config {
|
||||||
|
Some(v) => std::env::set_var(&config_env, v),
|
||||||
|
None => std::env::remove_var(&config_env),
|
||||||
|
}
|
||||||
|
match prev_sandbox {
|
||||||
|
Some(v) => std::env::set_var(crate::sandbox::SANDBOX_ENV_FLAG, v),
|
||||||
|
None => std::env::remove_var(crate::sandbox::SANDBOX_ENV_FLAG),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = fs::remove_dir_all(&tmp_dir);
|
||||||
|
|
||||||
|
result.expect(
|
||||||
|
"load_with_interpolation should succeed in sandbox mode with placeholder values",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
raw.contains("{{ANTHROPIC_API_KEY}}"),
|
||||||
|
"placeholder should be preserved as a literal string in sandbox mode"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-9
@@ -5,7 +5,7 @@ use super::{
|
|||||||
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, MACROS_DIR_NAME, MCP_FILE_NAME,
|
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, MACROS_DIR_NAME, MCP_FILE_NAME,
|
||||||
MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME,
|
MEMORY_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_MIXIN_KITS_DIR_NAME,
|
SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME,
|
||||||
SBX_VAULT_MIXINS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
|
SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
|
||||||
};
|
};
|
||||||
use crate::client::ProviderModels;
|
use crate::client::ProviderModels;
|
||||||
use crate::config::REPL_HISTORY_DIR_NAME;
|
use crate::config::REPL_HISTORY_DIR_NAME;
|
||||||
@@ -148,14 +148,6 @@ pub fn sbx_kit_hash_file() -> PathBuf {
|
|||||||
sbx_kit_dir().join(SBX_KIT_HASH_FILE)
|
sbx_kit_dir().join(SBX_KIT_HASH_FILE)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sbx_vault_mixins_dir() -> PathBuf {
|
|
||||||
cache_dir().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 sbx_mixin_kits_dir() -> PathBuf {
|
pub fn sbx_mixin_kits_dir() -> PathBuf {
|
||||||
cache_dir().join(SBX_MIXIN_KITS_DIR_NAME)
|
cache_dir().join(SBX_MIXIN_KITS_DIR_NAME)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -106,7 +106,7 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(name) = &cli.sandbox {
|
if let Some(name) = &cli.sandbox {
|
||||||
return sandbox::launch(name.clone(), cli.fresh, cli.no_mixins);
|
return sandbox::launch(name.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
install_builtins()?;
|
install_builtins()?;
|
||||||
|
|||||||
+119
-462
@@ -1,19 +1,23 @@
|
|||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use rust_embed::RustEmbed;
|
use rust_embed::RustEmbed;
|
||||||
|
use serde_json::Value;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use which::which;
|
use which::which;
|
||||||
|
|
||||||
mod mixins;
|
mod mixins;
|
||||||
|
|
||||||
use gman::providers::SupportedProvider;
|
use crate::config::AppConfig;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::config::VAULT_DATA_FILE_NAME;
|
||||||
use crate::config::paths;
|
use crate::config::paths;
|
||||||
use crate::sandbox::mixins::DiscoveredMixin;
|
use crate::sandbox::mixins::DiscoveredMixin;
|
||||||
use crate::utils::run_command_with_output;
|
use crate::utils::run_command_with_output;
|
||||||
|
use crate::vault::SECRET_RE;
|
||||||
use crate::vault::Vault;
|
use crate::vault::Vault;
|
||||||
|
|
||||||
const SBX_BINARY: &str = "sbx";
|
const SBX_BINARY: &str = "sbx";
|
||||||
@@ -24,49 +28,35 @@ const SANDBOX_AGENT: &str = "coyote";
|
|||||||
#[folder = "assets/sbx-kit/"]
|
#[folder = "assets/sbx-kit/"]
|
||||||
struct EmbeddedKit;
|
struct EmbeddedKit;
|
||||||
|
|
||||||
#[derive(RustEmbed)]
|
pub fn launch(name: Option<String>) -> Result<()> {
|
||||||
#[folder = "assets/sbx-vault-mixins/"]
|
|
||||||
struct EmbeddedVaultMixins;
|
|
||||||
|
|
||||||
pub fn launch(name: Option<String>, fresh: bool, no_mixins: bool) -> Result<()> {
|
|
||||||
ensure_sbx_installed()?;
|
ensure_sbx_installed()?;
|
||||||
bail_if_nested()?;
|
bail_if_nested()?;
|
||||||
|
|
||||||
let name = resolve_name(name)?;
|
let name = resolve_name(name)?;
|
||||||
let kit_path = resolve_kit_path()?;
|
let kit_path = resolve_kit_path()?;
|
||||||
|
|
||||||
let discovered = if no_mixins {
|
let config_path = paths::config_file();
|
||||||
Vec::new()
|
if !config_path.exists() {
|
||||||
} else {
|
bail!("No Coyote config found. Run `coyote` on your host to complete setup first.");
|
||||||
let mut all = mixins::discover()?;
|
}
|
||||||
if let Ok(vault) = Vault::init_bare()
|
let (config, config_content) = Config::load_from_file(&config_path)?;
|
||||||
&& let Some(vault_mixin) = extract_vault_mixin(&vault.provider)?
|
let bootstrap = AppConfig {
|
||||||
{
|
vault_password_file: config.vault_password_file.clone(),
|
||||||
all.insert(0, vault_mixin);
|
secrets_provider: config.secrets_provider.clone(),
|
||||||
}
|
..AppConfig::default()
|
||||||
all
|
|
||||||
};
|
};
|
||||||
|
let vault = Vault::init(&bootstrap)?;
|
||||||
|
inject_llm_secret(&config_content, &vault)?;
|
||||||
|
inject_mcp_secrets(&vault)?;
|
||||||
|
|
||||||
|
let discovered = mixins::discover()?;
|
||||||
|
|
||||||
if sandbox_exists(&name)? {
|
if sandbox_exists(&name)? {
|
||||||
info!("Re-attaching to existing sandbox '{name}'");
|
info!("Re-attaching to existing sandbox '{name}'");
|
||||||
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 {
|
} else {
|
||||||
mixins::log_discovery(&discovered, no_mixins);
|
mixins::log_discovery(&discovered, false);
|
||||||
|
create_sandbox(&name, &kit_path, &discovered)?;
|
||||||
if fresh {
|
copy_host_files(&name)?;
|
||||||
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)
|
exec_run(&name, &kit_path)
|
||||||
@@ -207,97 +197,118 @@ fn compute_kit_hash() -> Result<String> {
|
|||||||
Ok(format!("{:x}", hasher.finalize()))
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_vault_mixin(provider: &SupportedProvider) -> Result<Option<DiscoveredMixin>> {
|
fn inject_llm_secret(config_content: &str, vault: &Vault) -> Result<()> {
|
||||||
let provider_dir = match provider {
|
let value: serde_yaml::Value = serde_yaml::from_str(config_content)
|
||||||
SupportedProvider::Local { .. } => return Ok(None),
|
.context("Failed to parse config for LLM secret injection")?;
|
||||||
SupportedProvider::AwsSecretsManager { .. } => "aws_secrets_manager",
|
|
||||||
SupportedProvider::GcpSecretManager { .. } => "gcp_secret_manager",
|
let Some(clients) = value.get("clients").and_then(|v| v.as_sequence()) else {
|
||||||
SupportedProvider::AzureKeyVault { .. } => "azure_key_vault",
|
return Ok(());
|
||||||
SupportedProvider::Gopass { .. } => "gopass",
|
|
||||||
SupportedProvider::OnePassword { .. } => "one_password",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let cache_root = extract_vault_mixins_cache()?;
|
for client in clients {
|
||||||
let provider_root = cache_root.join(provider_dir);
|
let Some(api_key) = client.get("api_key").and_then(|v| v.as_str()) else {
|
||||||
let spec_path = provider_root.join("spec.yaml");
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
if !spec_path.exists() {
|
let Some(caps) = SECRET_RE.captures(api_key)? else {
|
||||||
bail!(
|
continue;
|
||||||
"Embedded vault mixin for '{provider_dir}' is missing spec.yaml at {}",
|
};
|
||||||
spec_path.display()
|
let secret_name = caps[1].to_string();
|
||||||
);
|
|
||||||
|
let client_type = client.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let client_name = client.get("name").and_then(|v| v.as_str());
|
||||||
|
let service = provider_to_sbx_service(client_type, client_name);
|
||||||
|
|
||||||
|
let secret_value = vault
|
||||||
|
.get_secret(&secret_name, false)
|
||||||
|
.with_context(|| format!("Failed to decrypt LLM api_key secret '{secret_name}'"))?;
|
||||||
|
|
||||||
|
sbx_secret_set(&service, &secret_value)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let label = format!("<built-in: vault-{provider_dir}>");
|
Ok(())
|
||||||
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> {
|
fn find_secret_placeholder(value: &Value) -> Option<String> {
|
||||||
let cache_root = paths::sbx_vault_mixins_dir();
|
match value {
|
||||||
let new_hash = compute_vault_mixins_hash()?;
|
Value::String(s) => SECRET_RE
|
||||||
let hash_file = paths::sbx_vault_mixins_hash_file();
|
.captures(s)
|
||||||
if let Ok(existing) = fs::read_to_string(&hash_file)
|
.ok()
|
||||||
&& existing == new_hash
|
.flatten()
|
||||||
{
|
.map(|caps| caps[1].to_string()),
|
||||||
return Ok(cache_root);
|
Value::Object(map) => map.values().find_map(find_secret_placeholder),
|
||||||
|
Value::Array(arr) => arr.iter().find_map(find_secret_placeholder),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_mcp_secrets(vault: &Vault) -> Result<()> {
|
||||||
|
let mcp_path = paths::mcp_config_file();
|
||||||
|
if !mcp_path.exists() {
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache_root.exists() {
|
let content = fs::read_to_string(&mcp_path)
|
||||||
fs::remove_dir_all(&cache_root).with_context(|| {
|
.with_context(|| format!("Failed to read {}", mcp_path.display()))?;
|
||||||
|
let mcp: Value = serde_json::from_str(&content)
|
||||||
|
.with_context(|| format!("Failed to parse {}", mcp_path.display()))?;
|
||||||
|
|
||||||
|
let Some(servers) = mcp.get("mcpServers").and_then(|v| v.as_object()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
for (server_name, server_config) in servers {
|
||||||
|
let Some(secret_name) = find_secret_placeholder(server_config) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let secret_value = vault.get_secret(&secret_name, false).with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"Failed to clear stale vault mixins at {}",
|
"Secret '{secret_name}' referenced by MCP server '{server_name}' not found \
|
||||||
cache_root.display()
|
in vault. Add it with: coyote --add-secret {secret_name}"
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
}
|
|
||||||
fs::create_dir_all(&cache_root)
|
|
||||||
.with_context(|| format!("Failed to create {}", cache_root.display()))?;
|
|
||||||
|
|
||||||
for entry in EmbeddedVaultMixins::iter() {
|
sbx_secret_set(server_name, &secret_value)?;
|
||||||
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)
|
Ok(())
|
||||||
.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> {
|
fn provider_to_sbx_service(provider_type: &str, client_name: Option<&str>) -> String {
|
||||||
let mut hasher = Sha256::new();
|
match provider_type {
|
||||||
let mut entries: Vec<_> = EmbeddedVaultMixins::iter().collect();
|
"claude" => "anthropic".to_string(),
|
||||||
entries.sort();
|
"openai" => "openai".to_string(),
|
||||||
|
"gemini" | "vertexai" => "google".to_string(),
|
||||||
|
"openai-compatible" => client_name.unwrap_or("openai-compatible").to_string(),
|
||||||
|
other => client_name.unwrap_or(other).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for entry in &entries {
|
fn sbx_secret_set(service: &str, secret_value: &str) -> Result<()> {
|
||||||
let file = EmbeddedVaultMixins::get(entry)
|
let mut child = Command::new(SBX_BINARY)
|
||||||
.ok_or_else(|| anyhow!("Embedded vault mixin file missing during hash: {entry}"))?;
|
.args(["secret", "set", "-g", service])
|
||||||
hasher.update(entry.as_bytes());
|
.stdin(Stdio::piped())
|
||||||
hasher.update(b"\0");
|
.stdout(Stdio::inherit())
|
||||||
hasher.update(&file.data);
|
.stderr(Stdio::inherit())
|
||||||
|
.spawn()
|
||||||
|
.context("Failed to spawn `sbx secret set -g`")?;
|
||||||
|
|
||||||
|
if let Some(ref mut stdin) = child.stdin {
|
||||||
|
stdin
|
||||||
|
.write_all(secret_value.as_bytes())
|
||||||
|
.context("Failed to write secret to `sbx secret set -g` stdin")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(format!("{:x}", hasher.finalize()))
|
let status = child
|
||||||
|
.wait()
|
||||||
|
.context("Failed to wait for `sbx secret set -g`")?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
bail!("`sbx secret set -g {service}` exited with {status}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sandbox_exists(name: &str) -> Result<bool> {
|
fn sandbox_exists(name: &str) -> Result<bool> {
|
||||||
@@ -367,7 +378,6 @@ fn build_create_args(
|
|||||||
|
|
||||||
fn copy_host_files(name: &str) -> Result<()> {
|
fn copy_host_files(name: &str) -> Result<()> {
|
||||||
let config_dir = paths::config_dir();
|
let config_dir = paths::config_dir();
|
||||||
let home_dir = dirs::home_dir().context("Could not determine home directory")?;
|
|
||||||
|
|
||||||
if config_dir.exists() {
|
if config_dir.exists() {
|
||||||
let sandbox_config_dir = "/home/agent/.config/coyote";
|
let sandbox_config_dir = "/home/agent/.config/coyote";
|
||||||
@@ -378,6 +388,9 @@ fn copy_host_files(name: &str) -> Result<()> {
|
|||||||
{
|
{
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
if path.file_name().is_some_and(|n| n == VAULT_DATA_FILE_NAME) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
sbx_cp(&path.display().to_string(), &dest)?;
|
sbx_cp(&path.display().to_string(), &dest)?;
|
||||||
}
|
}
|
||||||
chown_agent_recursive(name, sandbox_config_dir)?;
|
chown_agent_recursive(name, sandbox_config_dir)?;
|
||||||
@@ -409,86 +422,9 @@ fn copy_host_files(name: &str) -> Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)?;
|
|
||||||
chown_agent_recursive(name, &dest_path)?;
|
|
||||||
}
|
|
||||||
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(())
|
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<()> {
|
fn ensure_sandbox_dir(sandbox: &str, dir: &str) -> Result<()> {
|
||||||
let dir_q = shell_words::quote(dir);
|
let dir_q = shell_words::quote(dir);
|
||||||
let cmd = format!("sudo mkdir -p {dir_q} && sudo chown agent:agent {dir_q}");
|
let cmd = format!("sudo mkdir -p {dir_q} && sudo chown agent:agent {dir_q}");
|
||||||
@@ -510,10 +446,6 @@ fn ensure_sandbox_dir(sandbox: &str, dir: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_vault_password_file() -> Option<PathBuf> {
|
|
||||||
Vault::init_bare().ok()?.local_password_file().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sbx_cp(src: &str, dest: &str) -> Result<()> {
|
fn sbx_cp(src: &str, dest: &str) -> Result<()> {
|
||||||
debug!("sbx cp {src} {dest}");
|
debug!("sbx cp {src} {dest}");
|
||||||
let status = Command::new(SBX_BINARY)
|
let status = Command::new(SBX_BINARY)
|
||||||
@@ -707,279 +639,4 @@ mod tests {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
mod vault_mixins {
|
|
||||||
use super::*;
|
|
||||||
use crate::utils::get_env_name;
|
|
||||||
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;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
struct TestCacheDirGuard {
|
|
||||||
key: String,
|
|
||||||
previous: Option<std::ffi::OsString>,
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TestCacheDirGuard {
|
|
||||||
fn new() -> Self {
|
|
||||||
let key = get_env_name("cache_dir");
|
|
||||||
let previous = env::var_os(&key);
|
|
||||||
let unique = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
let path = env::temp_dir().join(format!("coyote-sandbox-vault-tests-{unique}"));
|
|
||||||
fs::create_dir_all(&path).unwrap();
|
|
||||||
unsafe {
|
|
||||||
env::set_var(&key, &path);
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
key,
|
|
||||||
previous,
|
|
||||||
path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TestCacheDirGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
unsafe {
|
|
||||||
match &self.previous {
|
|
||||||
Some(v) => env::set_var(&self.key, v),
|
|
||||||
None => env::remove_var(&self.key),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = fs::remove_dir_all(&self.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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 _guard = TestCacheDirGuard::new();
|
|
||||||
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 _guard = TestCacheDirGuard::new();
|
|
||||||
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 _guard = TestCacheDirGuard::new();
|
|
||||||
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 _guard = TestCacheDirGuard::new();
|
|
||||||
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 _guard = TestCacheDirGuard::new();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-29
@@ -1,9 +1,11 @@
|
|||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
|
use std::env;
|
||||||
use std::fs::read_to_string;
|
use std::fs::read_to_string;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::config::paths;
|
use crate::config::paths;
|
||||||
|
use crate::sandbox::SANDBOX_ENV_FLAG;
|
||||||
pub use utils::create_vault_password_file;
|
pub use utils::create_vault_password_file;
|
||||||
pub use utils::interpolate_secrets;
|
pub use utils::interpolate_secrets;
|
||||||
pub use utils::prompt_provider_choice;
|
pub use utils::prompt_provider_choice;
|
||||||
@@ -17,7 +19,7 @@ use gman::providers::SecretProvider;
|
|||||||
use gman::providers::SupportedProvider;
|
use gman::providers::SupportedProvider;
|
||||||
use gman::providers::local::LocalProvider;
|
use gman::providers::local::LocalProvider;
|
||||||
use inquire::{Password, PasswordDisplayMode, required};
|
use inquire::{Password, PasswordDisplayMode, required};
|
||||||
use log::{info, warn};
|
use log::warn;
|
||||||
use serde_yaml::Value;
|
use serde_yaml::Value;
|
||||||
use std::sync::{Arc, LazyLock};
|
use std::sync::{Arc, LazyLock};
|
||||||
use tokio::runtime::Handle;
|
use tokio::runtime::Handle;
|
||||||
@@ -25,34 +27,10 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
pub static SECRET_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{([^{}]+)}}").unwrap());
|
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_dir(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)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct Vault {
|
pub struct Vault {
|
||||||
pub(crate) provider: SupportedProvider,
|
pub(crate) provider: SupportedProvider,
|
||||||
|
sandbox_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type GlobalVault = Arc<Vault>;
|
pub type GlobalVault = Arc<Vault>;
|
||||||
@@ -90,7 +68,10 @@ impl Vault {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self { provider })
|
Ok(Self {
|
||||||
|
provider,
|
||||||
|
sandbox_mode: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_local() -> Self {
|
pub fn default_local() -> Self {
|
||||||
@@ -102,10 +83,25 @@ impl Vault {
|
|||||||
..LocalProvider::default()
|
..LocalProvider::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sandbox_mode: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_provider(provider: SupportedProvider) -> Self {
|
||||||
|
Self {
|
||||||
|
provider,
|
||||||
|
sandbox_mode: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(config: &AppConfig) -> Result<Self> {
|
pub fn init(config: &AppConfig) -> Result<Self> {
|
||||||
|
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
||||||
|
return Ok(Self {
|
||||||
|
sandbox_mode: true,
|
||||||
|
..Self::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let mut provider = match &config.secrets_provider {
|
let mut provider = match &config.secrets_provider {
|
||||||
Some(p) => p.clone(),
|
Some(p) => p.clone(),
|
||||||
None => SupportedProvider::Local {
|
None => SupportedProvider::Local {
|
||||||
@@ -117,11 +113,13 @@ impl Vault {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let SupportedProvider::Local { provider_def } = &mut provider {
|
if let SupportedProvider::Local { provider_def } = &mut provider {
|
||||||
apply_sandboxed_home_translation(provider_def);
|
|
||||||
ensure_password_file_initialized(provider_def)?;
|
ensure_password_file_initialized(provider_def)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Self { provider })
|
Ok(Self {
|
||||||
|
provider,
|
||||||
|
sandbox_mode: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn local_password_file(&self) -> Result<PathBuf> {
|
pub fn local_password_file(&self) -> Result<PathBuf> {
|
||||||
@@ -148,6 +146,11 @@ impl Vault {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_secret(&self, secret_name: &str) -> Result<()> {
|
pub fn add_secret(&self, secret_name: &str) -> Result<()> {
|
||||||
|
if self.sandbox_mode {
|
||||||
|
bail!(
|
||||||
|
"Vault management is disabled in sandbox mode. Use `coyote --add-secret` on your host."
|
||||||
|
);
|
||||||
|
}
|
||||||
let secret_value = Password::new("Enter the secret value:")
|
let secret_value = Password::new("Enter the secret value:")
|
||||||
.with_validator(required!())
|
.with_validator(required!())
|
||||||
.with_display_mode(PasswordDisplayMode::Masked)
|
.with_display_mode(PasswordDisplayMode::Masked)
|
||||||
@@ -164,6 +167,11 @@ impl Vault {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_secret(&self, secret_name: &str, display_output: bool) -> Result<String> {
|
pub fn get_secret(&self, secret_name: &str, display_output: bool) -> Result<String> {
|
||||||
|
if self.sandbox_mode {
|
||||||
|
bail!(
|
||||||
|
"Vault management is disabled in sandbox mode. Use `coyote --add-secret` on your host."
|
||||||
|
);
|
||||||
|
}
|
||||||
let h = Handle::current();
|
let h = Handle::current();
|
||||||
let secret = tokio::task::block_in_place(|| {
|
let secret = tokio::task::block_in_place(|| {
|
||||||
h.block_on(self.provider_ref().get_secret(secret_name))
|
h.block_on(self.provider_ref().get_secret(secret_name))
|
||||||
@@ -177,6 +185,11 @@ impl Vault {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_secret(&self, secret_name: &str) -> Result<()> {
|
pub fn update_secret(&self, secret_name: &str) -> Result<()> {
|
||||||
|
if self.sandbox_mode {
|
||||||
|
bail!(
|
||||||
|
"Vault management is disabled in sandbox mode. Use `coyote --add-secret` on your host."
|
||||||
|
);
|
||||||
|
}
|
||||||
let secret_value = Password::new("Enter the secret value:")
|
let secret_value = Password::new("Enter the secret value:")
|
||||||
.with_validator(required!())
|
.with_validator(required!())
|
||||||
.with_display_mode(PasswordDisplayMode::Masked)
|
.with_display_mode(PasswordDisplayMode::Masked)
|
||||||
@@ -195,6 +208,11 @@ impl Vault {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_secret(&self, secret_name: &str) -> Result<()> {
|
pub fn delete_secret(&self, secret_name: &str) -> Result<()> {
|
||||||
|
if self.sandbox_mode {
|
||||||
|
bail!(
|
||||||
|
"Vault management is disabled in sandbox mode. Use `coyote --add-secret` on your host."
|
||||||
|
);
|
||||||
|
}
|
||||||
let h = Handle::current();
|
let h = Handle::current();
|
||||||
tokio::task::block_in_place(|| h.block_on(self.provider_ref().delete_secret(secret_name)))?;
|
tokio::task::block_in_place(|| h.block_on(self.provider_ref().delete_secret(secret_name)))?;
|
||||||
println!("✓ Secret '{secret_name}' deleted from the vault.");
|
println!("✓ Secret '{secret_name}' deleted from the vault.");
|
||||||
@@ -203,6 +221,11 @@ impl Vault {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_secrets(&self, display_output: bool) -> Result<Vec<String>> {
|
pub fn list_secrets(&self, display_output: bool) -> Result<Vec<String>> {
|
||||||
|
if self.sandbox_mode {
|
||||||
|
bail!(
|
||||||
|
"Vault management is disabled in sandbox mode. Use `coyote --add-secret` on your host."
|
||||||
|
);
|
||||||
|
}
|
||||||
let h = Handle::current();
|
let h = Handle::current();
|
||||||
let secrets =
|
let secrets =
|
||||||
tokio::task::block_in_place(|| h.block_on(self.provider_ref().list_secrets()))?;
|
tokio::task::block_in_place(|| h.block_on(self.provider_ref().list_secrets()))?;
|
||||||
@@ -346,4 +369,35 @@ mod tests {
|
|||||||
let vault = Vault::default();
|
let vault = Vault::default();
|
||||||
assert!(vault.local_password_file().is_err());
|
assert!(vault.local_password_file().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vault_disabled_in_sandbox() {
|
||||||
|
let prev = std::env::var_os("IS_SANDBOX");
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("IS_SANDBOX", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
let vault = Vault::init(&AppConfig::default()).unwrap();
|
||||||
|
|
||||||
|
let check = |err: anyhow::Error| {
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("Vault management is disabled in sandbox mode"),
|
||||||
|
"expected vault-disabled error, got: {msg}"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
check(vault.add_secret("test").unwrap_err());
|
||||||
|
check(vault.get_secret("test", false).unwrap_err());
|
||||||
|
check(vault.update_secret("test").unwrap_err());
|
||||||
|
check(vault.delete_secret("test").unwrap_err());
|
||||||
|
check(vault.list_secrets(false).unwrap_err());
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
match prev {
|
||||||
|
Some(v) => std::env::set_var("IS_SANDBOX", v),
|
||||||
|
None => std::env::remove_var("IS_SANDBOX"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user