Skip to main content

syncable_cli/agent/tools/platform/
set_secrets.rs

1//! Set deployment secrets tool for the agent
2//!
3//! Allows the agent to set environment variables and secrets on a deployment config.
4//! SECURITY: Secret values are NEVER returned in tool responses. Only key names are confirmed.
5//! For secrets (is_secret=true), values are collected via terminal prompt — the LLM never sees them.
6
7use rig::completion::ToolDefinition;
8use rig::tool::Tool;
9use serde::Deserialize;
10use serde_json::json;
11
12use crate::agent::tools::ExecutionContext;
13use crate::agent::tools::error::{ErrorCategory, format_error_for_llm};
14use crate::platform::api::types::DeploymentSecretInput;
15use crate::platform::api::{PlatformApiClient, PlatformApiError};
16
17/// Result of prompting the user for a secret value in the terminal.
18pub(super) enum SecretPromptResult {
19    /// User entered a non-empty value
20    Value(String),
21    /// User skipped this secret (Esc or empty input)
22    Skipped,
23    /// User cancelled all secret entry (Ctrl+C)
24    Cancelled,
25}
26
27/// Prompt the user for a secret value using masked terminal input.
28///
29/// The value is collected directly from the terminal and never enters the LLM context.
30pub(super) fn prompt_secret_value(key_name: &str) -> SecretPromptResult {
31    use colored::Colorize;
32    use inquire::{InquireError, Password, PasswordDisplayMode};
33
34    println!();
35    println!(
36        "  {} Enter value for {} {}",
37        "\u{1f512}".dimmed(),
38        key_name.cyan(),
39        "(hidden \u{2014} not visible to AI agent)".dimmed()
40    );
41
42    match Password::new(key_name)
43        .with_display_mode(PasswordDisplayMode::Masked)
44        .with_help_message("Esc to skip, Ctrl+C to cancel all")
45        .without_confirmation()
46        .prompt()
47    {
48        Ok(v) if v.trim().is_empty() => SecretPromptResult::Skipped,
49        Ok(v) => {
50            println!("  {} {} set", "\u{2713}".green(), key_name.cyan());
51            SecretPromptResult::Value(v)
52        }
53        Err(InquireError::OperationCanceled) => SecretPromptResult::Skipped,
54        Err(InquireError::OperationInterrupted) => SecretPromptResult::Cancelled,
55        Err(_) => SecretPromptResult::Cancelled,
56    }
57}
58
59/// A single secret argument from the agent
60#[derive(Debug, Deserialize)]
61pub struct SecretArg {
62    /// Environment variable name
63    pub key: String,
64    /// Environment variable value.
65    /// OMIT for secrets (is_secret=true) — user will be prompted in terminal.
66    /// Provide for non-secrets (NODE_ENV, PORT, etc.)
67    pub value: Option<String>,
68    /// Whether this is a secret (masked in responses). Default: true for safety
69    #[serde(default = "default_true")]
70    pub is_secret: bool,
71}
72
73pub(super) fn default_true() -> bool {
74    true
75}
76
77/// Arguments for the set deployment secrets tool
78#[derive(Debug, Deserialize)]
79pub struct SetDeploymentSecretsArgs {
80    /// Deployment config ID to set secrets on
81    pub config_id: String,
82    /// Environment variables to set
83    pub secrets: Vec<SecretArg>,
84}
85
86/// Error type for set deployment secrets operations
87#[derive(Debug, thiserror::Error)]
88#[error("Set deployment secrets error: {0}")]
89pub struct SetDeploymentSecretsError(String);
90
91/// Tool to set environment variables and secrets on a deployment configuration.
92///
93/// SECURITY: Secret values are sent securely to the backend and stored encrypted.
94/// Values are NEVER included in tool responses - only key names are confirmed.
95/// For secrets, values are collected via terminal prompt — the LLM never sees them.
96#[derive(Debug, Clone)]
97pub struct SetDeploymentSecretsTool {
98    execution_context: ExecutionContext,
99}
100
101impl SetDeploymentSecretsTool {
102    /// Create a new SetDeploymentSecretsTool (defaults to InteractiveCli)
103    pub fn new() -> Self {
104        Self {
105            execution_context: ExecutionContext::InteractiveCli,
106        }
107    }
108
109    /// Create with explicit execution context
110    pub fn with_context(ctx: ExecutionContext) -> Self {
111        Self {
112            execution_context: ctx,
113        }
114    }
115}
116
117impl Default for SetDeploymentSecretsTool {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123impl Tool for SetDeploymentSecretsTool {
124    const NAME: &'static str = "set_deployment_secrets";
125
126    type Error = SetDeploymentSecretsError;
127    type Args = SetDeploymentSecretsArgs;
128    type Output = String;
129
130    async fn definition(&self, _prompt: String) -> ToolDefinition {
131        ToolDefinition {
132            name: Self::NAME.to_string(),
133            description: r#"Set environment variables and secrets on a deployment configuration.
134
135Secret values are sent securely to the backend and stored encrypted.
136Values are NEVER returned in tool responses - only key names are confirmed.
137
138The is_secret flag (default: true) controls:
139- true: Value masked as "********" in UI and API responses, passed via secure terraform -var flags
140- false: Value visible in UI, stored in GitOps ConfigMap
141
142For secrets (is_secret=true): OMIT the "value" field. The user will be
143prompted securely in the terminal. The value goes directly to the backend.
144NEVER ask the user to type secret values in chat.
145
146For non-secrets (is_secret=false): Include the "value" field directly.
147
148Common secrets: DATABASE_URL, API_KEY, JWT_SECRET, REDIS_URL, etc.
149Common non-secrets: NODE_ENV, PORT, LOG_LEVEL, APP_NAME, etc.
150
151**Parameters:**
152- config_id: The deployment config ID (get from deploy_service or list_deployment_configs)
153- secrets: Array of {key, value?, is_secret} objects
154
155**Prerequisites:**
156- User must be authenticated via `sync-ctl auth login`
157- A deployment config must exist (create one with deploy_service first)
158
159**Example:**
160Set DATABASE_URL as a secret (value omitted — prompted in terminal) and NODE_ENV as a plain env var:
161```json
162{
163  "config_id": "config-123",
164  "secrets": [
165    {"key": "DATABASE_URL", "is_secret": true},
166    {"key": "NODE_ENV", "value": "production", "is_secret": false}
167  ]
168}
169```
170
171**IMPORTANT - After setting secrets:**
172- Trigger a new deployment for the secrets to take effect
173- Use trigger_deployment or deploy_service with preview_only=false"#
174                .to_string(),
175            parameters: json!({
176                "type": "object",
177                "properties": {
178                    "config_id": {
179                        "type": "string",
180                        "description": "The deployment config ID to set secrets on"
181                    },
182                    "secrets": {
183                        "type": "array",
184                        "description": "Environment variables to set. For secrets, omit value \u{2014} user is prompted in terminal.",
185                        "items": {
186                            "type": "object",
187                            "properties": {
188                                "key": {
189                                    "type": "string",
190                                    "description": "Environment variable name (e.g., DATABASE_URL)"
191                                },
192                                "value": {
193                                    "type": "string",
194                                    "description": "Environment variable value. Omit for secrets \u{2014} user will be prompted securely in terminal."
195                                },
196                                "is_secret": {
197                                    "type": "boolean",
198                                    "description": "Whether this is a secret (default: true). Secrets are masked in UI and API responses.",
199                                    "default": true
200                                }
201                            },
202                            "required": ["key"]
203                        }
204                    }
205                },
206                "required": ["config_id", "secrets"]
207            }),
208        }
209    }
210
211    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
212        // Validate config_id
213        if args.config_id.trim().is_empty() {
214            return Ok(format_error_for_llm(
215                "set_deployment_secrets",
216                ErrorCategory::ValidationFailed,
217                "config_id cannot be empty",
218                Some(vec![
219                    "Use list_deployment_configs to find valid config IDs",
220                    "Or deploy a service first with deploy_service",
221                ]),
222            ));
223        }
224
225        // Validate secrets list
226        if args.secrets.is_empty() {
227            return Ok(format_error_for_llm(
228                "set_deployment_secrets",
229                ErrorCategory::ValidationFailed,
230                "secrets array cannot be empty",
231                Some(vec!["Provide at least one secret with key and value"]),
232            ));
233        }
234
235        // Validate key format
236        for secret in &args.secrets {
237            if secret.key.trim().is_empty() {
238                return Ok(format_error_for_llm(
239                    "set_deployment_secrets",
240                    ErrorCategory::ValidationFailed,
241                    "Secret key cannot be empty",
242                    Some(vec!["Each secret must have a non-empty key name"]),
243                ));
244            }
245        }
246
247        // Resolve values — prompt for missing secret values in CLI mode
248        let mut resolved_secrets: Vec<DeploymentSecretInput> = Vec::new();
249        for secret in &args.secrets {
250            let value = match &secret.value {
251                Some(v) => v.clone(),
252                None if self.execution_context.has_terminal() => {
253                    match prompt_secret_value(&secret.key) {
254                        SecretPromptResult::Value(v) => v,
255                        SecretPromptResult::Skipped => continue,
256                        SecretPromptResult::Cancelled => {
257                            return Ok(format_error_for_llm(
258                                "set_deployment_secrets",
259                                ErrorCategory::ValidationFailed,
260                                "Secret entry cancelled by user",
261                                Some(vec![
262                                    "The user cancelled secret input. Try again when ready.",
263                                ]),
264                            ));
265                        }
266                    }
267                }
268                None => {
269                    return Ok(format_error_for_llm(
270                        "set_deployment_secrets",
271                        ErrorCategory::ValidationFailed,
272                        &format!(
273                            "Value required for secret '{}' in server mode (no terminal available)",
274                            secret.key
275                        ),
276                        Some(vec![
277                            "In server mode, all secrets must include a value",
278                            "The frontend should collect secret values via its own password UI",
279                        ]),
280                    ));
281                }
282            };
283            resolved_secrets.push(DeploymentSecretInput {
284                key: secret.key.clone(),
285                value,
286                is_secret: secret.is_secret,
287            });
288        }
289
290        if resolved_secrets.is_empty() {
291            return Ok(format_error_for_llm(
292                "set_deployment_secrets",
293                ErrorCategory::ValidationFailed,
294                "All secrets were skipped",
295                Some(vec!["Provide at least one secret value when prompted"]),
296            ));
297        }
298
299        // Create the API client
300        let client = match PlatformApiClient::new() {
301            Ok(c) => c,
302            Err(e) => {
303                return Ok(format_api_error("set_deployment_secrets", e));
304            }
305        };
306
307        // Call the API
308        match client
309            .update_deployment_config_secrets(&args.config_id, &resolved_secrets)
310            .await
311        {
312            Ok(()) => {
313                let secret_count = resolved_secrets.iter().filter(|s| s.is_secret).count();
314                let plain_count = resolved_secrets.len() - secret_count;
315
316                // SECURITY: Response contains ONLY keys, never values
317                let secrets_set: Vec<serde_json::Value> = resolved_secrets
318                    .iter()
319                    .map(|s| {
320                        json!({
321                            "key": s.key,
322                            "is_secret": s.is_secret,
323                        })
324                    })
325                    .collect();
326
327                let result = json!({
328                    "success": true,
329                    "config_id": args.config_id,
330                    "secrets_set": secrets_set,
331                    "message": format!(
332                        "Set {} environment variable(s) ({} secret, {} plain)",
333                        resolved_secrets.len(),
334                        secret_count,
335                        plain_count
336                    ),
337                    "next_steps": [
338                        "Trigger a new deployment for the secrets to take effect",
339                        format!("Use trigger_deployment with config_id '{}'", args.config_id),
340                    ],
341                });
342
343                serde_json::to_string_pretty(&result)
344                    .map_err(|e| SetDeploymentSecretsError(format!("Failed to serialize: {}", e)))
345            }
346            Err(e) => Ok(format_api_error("set_deployment_secrets", e)),
347        }
348    }
349}
350
351/// Format a PlatformApiError for LLM consumption
352fn format_api_error(tool_name: &str, error: PlatformApiError) -> String {
353    match error {
354        PlatformApiError::Unauthorized => format_error_for_llm(
355            tool_name,
356            ErrorCategory::PermissionDenied,
357            "Not authenticated - please run `sync-ctl auth login` first",
358            Some(vec![
359                "The user needs to authenticate with the Syncable platform",
360                "Run: sync-ctl auth login",
361            ]),
362        ),
363        PlatformApiError::NotFound(msg) => format_error_for_llm(
364            tool_name,
365            ErrorCategory::ResourceUnavailable,
366            &format!("Deployment config not found: {}", msg),
367            Some(vec![
368                "The config_id may be incorrect",
369                "Use list_deployment_configs to find valid config IDs",
370            ]),
371        ),
372        PlatformApiError::PermissionDenied(msg) => format_error_for_llm(
373            tool_name,
374            ErrorCategory::PermissionDenied,
375            &format!("Permission denied: {}", msg),
376            Some(vec!["Contact the project admin for access"]),
377        ),
378        PlatformApiError::RateLimited => format_error_for_llm(
379            tool_name,
380            ErrorCategory::ResourceUnavailable,
381            "Rate limit exceeded - please try again later",
382            Some(vec!["Wait a moment before retrying"]),
383        ),
384        PlatformApiError::HttpError(e) => format_error_for_llm(
385            tool_name,
386            ErrorCategory::NetworkError,
387            &format!("Network error: {}", e),
388            Some(vec!["Check network connectivity"]),
389        ),
390        PlatformApiError::ParseError(msg) => format_error_for_llm(
391            tool_name,
392            ErrorCategory::InternalError,
393            &format!("Failed to parse API response: {}", msg),
394            Some(vec!["This may be a temporary API issue"]),
395        ),
396        PlatformApiError::ApiError { status, message } => format_error_for_llm(
397            tool_name,
398            ErrorCategory::ExternalCommandFailed,
399            &format!("API error ({}): {}", status, message),
400            Some(vec!["Check the error message for details"]),
401        ),
402        PlatformApiError::ServerError { status, message } => format_error_for_llm(
403            tool_name,
404            ErrorCategory::ExternalCommandFailed,
405            &format!("Server error ({}): {}", status, message),
406            Some(vec!["Try again later"]),
407        ),
408        PlatformApiError::ConnectionFailed => format_error_for_llm(
409            tool_name,
410            ErrorCategory::NetworkError,
411            "Could not connect to Syncable API",
412            Some(vec!["Check your internet connection"]),
413        ),
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_tool_name() {
423        assert_eq!(SetDeploymentSecretsTool::NAME, "set_deployment_secrets");
424    }
425
426    #[test]
427    fn test_tool_creation() {
428        let tool = SetDeploymentSecretsTool::new();
429        assert!(format!("{:?}", tool).contains("SetDeploymentSecretsTool"));
430    }
431
432    #[test]
433    fn test_tool_with_context() {
434        let tool = SetDeploymentSecretsTool::with_context(ExecutionContext::HeadlessServer);
435        assert!(format!("{:?}", tool).contains("SetDeploymentSecretsTool"));
436    }
437
438    #[test]
439    fn test_default_is_secret_true() {
440        assert!(default_true());
441    }
442}