Skip to main content

systemprompt_cli/commands/admin/config/
secret.rs

1//! `admin config secret set` — write a provider or custom credential into the
2//! profile's secrets file without hand-editing JSON.
3//!
4//! Infrastructure secrets (database URLs, at-rest pepper, signing seed) are
5//! rejected: they are provisioned out-of-band, so a partial edit here cannot
6//! corrupt the values the runtime depends on.
7//!
8//! A profile whose secrets come from Vault or an external source is refused:
9//! the file would be ignored at boot, so the edit would silently do nothing.
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14use std::path::Path;
15
16use anyhow::{Context, Result, bail};
17use clap::{Args, Subcommand};
18use systemprompt_config::{ProfileBootstrap, write_private_atomic};
19use systemprompt_manifest::profile::SecretsSource;
20
21use super::profile_io::{load_profile, profile_dir};
22use super::types::ConfigMutationOutput;
23use crate::CliConfig;
24use crate::shared::{CommandOutput, render_result};
25
26const RESERVED: &[&str] = &[
27    "oauth_at_rest_pepper",
28    "manifest_signing_secret_seed",
29    "database_url",
30    "database_write_url",
31    "database_session_url",
32    "external_database_url",
33    "internal_database_url",
34];
35
36#[derive(Debug, Subcommand)]
37pub enum SecretCommands {
38    #[command(about = "Set a provider or custom secret")]
39    Set(SetArgs),
40
41    #[command(about = "Report the active secrets source, key names, and gaps")]
42    Check,
43}
44
45#[derive(Debug, Clone, Args)]
46pub struct SetArgs {
47    #[arg(help = "Secret name (e.g. anthropic, minimax)")]
48    pub name: String,
49
50    #[arg(help = "Secret value")]
51    pub value: String,
52}
53
54pub async fn execute(command: &SecretCommands, config: &CliConfig) -> Result<()> {
55    let args = match command {
56        SecretCommands::Set(args) => args,
57        SecretCommands::Check => return super::secret_check::execute(config).await,
58    };
59
60    let profile_path = ProfileBootstrap::get_path()?;
61    let profile = load_profile(profile_path)?;
62    let secrets_config = profile
63        .secrets
64        .as_ref()
65        .ok_or_else(|| anyhow::anyhow!("profile has no secrets section"))?;
66    if matches!(
67        secrets_config.source,
68        SecretsSource::Vault | SecretsSource::External
69    ) {
70        bail!(
71            "secrets are managed by {}; rotate them there and run `admin config secret check`",
72            secrets_config.source_name()
73        );
74    }
75    let secrets_rel = secrets_config.secrets_path()?.to_owned();
76    let secrets_file = profile_dir(profile_path).join(&secrets_rel);
77
78    set_secret(&secrets_file, &args.name, &args.value)?;
79
80    render_result(
81        &CommandOutput::card_value(
82            "Secret Updated",
83            &ConfigMutationOutput {
84                field: "secrets".to_owned(),
85                message: format!("Secret '{}' set", args.name),
86            },
87        ),
88        config,
89    );
90    Ok(())
91}
92
93pub fn set_secret(secrets_file: &Path, name: &str, value: &str) -> Result<()> {
94    if RESERVED.contains(&name) {
95        bail!("'{name}' is a reserved infrastructure secret and cannot be set here");
96    }
97
98    let content = std::fs::read_to_string(secrets_file)
99        .with_context(|| format!("Failed to read secrets: {}", secrets_file.display()))?;
100    // JSON: operator tooling edits the on-disk secrets document by key, so a
101    // file still missing a required field can be completed one secret at a time.
102    let mut doc: serde_json::Value = serde_json::from_str(&content)
103        .with_context(|| format!("Failed to parse secrets: {}", secrets_file.display()))?;
104    let object = doc.as_object_mut().ok_or_else(|| {
105        anyhow::anyhow!(
106            "secrets file is not a JSON object: {}",
107            secrets_file.display()
108        )
109    })?;
110    object.insert(name.to_owned(), serde_json::Value::String(value.to_owned()));
111
112    let serialized = serde_json::to_string_pretty(&doc).context("Failed to serialize secrets")?;
113    write_private_atomic(secrets_file, serialized.as_bytes())
114        .with_context(|| format!("Failed to write {}", secrets_file.display()))
115}