Compare commits
5 Commits
master
...
feat/googl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
603aa88f3c | ||
|
|
b4f3e4f37b | ||
|
|
6b1fe960e3 | ||
|
|
7a4ea4cbe9 | ||
|
|
c1e05069ea |
@ -11,12 +11,13 @@ pub use schema::{
|
|||||||
ComposioConfig, Config, ConversationalAiConfig, CostConfig, CronConfig, DataRetentionConfig,
|
ComposioConfig, Config, ConversationalAiConfig, CostConfig, CronConfig, DataRetentionConfig,
|
||||||
DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, EdgeTtsConfig, ElevenLabsTtsConfig,
|
DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, EdgeTtsConfig, ElevenLabsTtsConfig,
|
||||||
EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig, GoogleTtsConfig,
|
EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig, GoogleTtsConfig,
|
||||||
HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig, HttpRequestConfig,
|
GoogleWorkspaceConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig,
|
||||||
IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, McpConfig, McpServerConfig,
|
HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, McpConfig,
|
||||||
McpTransport, MemoryConfig, Microsoft365Config, ModelRouteConfig, MultimodalConfig,
|
McpServerConfig, McpTransport, MemoryConfig, Microsoft365Config, ModelRouteConfig,
|
||||||
NextcloudTalkConfig, NodeTransportConfig, NodesConfig, NotionConfig, ObservabilityConfig,
|
MultimodalConfig, NextcloudTalkConfig, NodeTransportConfig, NodesConfig, NotionConfig,
|
||||||
OpenAiTtsConfig, OpenVpnTunnelConfig, OtpConfig, OtpMethod, PeripheralBoardConfig,
|
ObservabilityConfig, OpenAiTtsConfig, OpenVpnTunnelConfig, OtpConfig, OtpMethod,
|
||||||
PeripheralsConfig, ProjectIntelConfig, ProxyConfig, ProxyScope, QdrantConfig,
|
PeripheralBoardConfig, PeripheralsConfig, ProjectIntelConfig, ProxyConfig, ProxyScope,
|
||||||
|
QdrantConfig,
|
||||||
QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig,
|
QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig,
|
||||||
SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig,
|
SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig,
|
||||||
SecurityOpsConfig, SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig,
|
SecurityOpsConfig, SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig,
|
||||||
|
|||||||
@ -260,6 +260,10 @@ pub struct Config {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub web_search: WebSearchConfig,
|
pub web_search: WebSearchConfig,
|
||||||
|
|
||||||
|
/// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub google_workspace: GoogleWorkspaceConfig,
|
||||||
|
|
||||||
/// Project delivery intelligence configuration (`[project_intel]`).
|
/// Project delivery intelligence configuration (`[project_intel]`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub project_intel: ProjectIntelConfig,
|
pub project_intel: ProjectIntelConfig,
|
||||||
@ -1839,6 +1843,94 @@ impl Default for WebSearchConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Google Workspace ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]` section).
|
||||||
|
///
|
||||||
|
/// ## Defaults
|
||||||
|
/// - `enabled`: `false` (tool is not registered unless explicitly opted-in).
|
||||||
|
/// - `allowed_services`: empty vector, which grants access to the full default
|
||||||
|
/// service set: `drive`, `sheets`, `gmail`, `calendar`, `docs`, `slides`,
|
||||||
|
/// `tasks`, `people`, `chat`, `classroom`, `forms`, `keep`, `meet`, `events`.
|
||||||
|
/// - `credentials_path`: `None` (uses default `gws` credential discovery).
|
||||||
|
/// - `default_account`: `None` (uses the `gws` active account).
|
||||||
|
/// - `rate_limit_per_minute`: `60`.
|
||||||
|
/// - `timeout_secs`: `30`.
|
||||||
|
/// - `audit_log`: `false`.
|
||||||
|
/// - `credentials_path`: `None` (uses default `gws` credential discovery).
|
||||||
|
/// - `default_account`: `None` (uses the `gws` active account).
|
||||||
|
/// - `rate_limit_per_minute`: `60`.
|
||||||
|
/// - `timeout_secs`: `30`.
|
||||||
|
/// - `audit_log`: `false`.
|
||||||
|
///
|
||||||
|
/// ## Compatibility
|
||||||
|
/// Configs that omit the `[google_workspace]` section entirely are treated as
|
||||||
|
/// `GoogleWorkspaceConfig::default()` (disabled, all defaults allowed). Adding
|
||||||
|
/// the section is purely opt-in and does not affect other config sections.
|
||||||
|
///
|
||||||
|
/// ## Rollback / Migration
|
||||||
|
/// To revert, remove the `[google_workspace]` section from the config file (or
|
||||||
|
/// set `enabled = false`). No data migration is required; the tool simply stops
|
||||||
|
/// being registered.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct GoogleWorkspaceConfig {
|
||||||
|
/// Enable the `google_workspace` tool. Default: `false`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Restrict which Google Workspace services the agent can access.
|
||||||
|
///
|
||||||
|
/// When empty (the default), the full default service set is allowed (see
|
||||||
|
/// struct-level docs). When non-empty, only the listed service IDs are
|
||||||
|
/// permitted. Each entry must be non-empty, lowercase alphanumeric with
|
||||||
|
/// optional underscores/hyphens, and unique.
|
||||||
|
#[serde(default)]
|
||||||
|
pub allowed_services: Vec<String>,
|
||||||
|
/// Path to service account JSON or OAuth client credentials file.
|
||||||
|
///
|
||||||
|
/// When `None`, the tool relies on the default `gws` credential discovery
|
||||||
|
/// (`gws auth login`). Set this to point at a service-account key or an
|
||||||
|
/// OAuth client-secrets JSON for headless / CI environments.
|
||||||
|
#[serde(default)]
|
||||||
|
pub credentials_path: Option<String>,
|
||||||
|
/// Default Google account email to pass to `gws --account`.
|
||||||
|
///
|
||||||
|
/// When `None`, the currently active `gws` account is used.
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_account: Option<String>,
|
||||||
|
/// Maximum number of `gws` API calls allowed per minute. Default: `60`.
|
||||||
|
#[serde(default = "default_gws_rate_limit")]
|
||||||
|
pub rate_limit_per_minute: u32,
|
||||||
|
/// Command execution timeout in seconds. Default: `30`.
|
||||||
|
#[serde(default = "default_gws_timeout_secs")]
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
/// Enable audit logging of every `gws` invocation (service, resource,
|
||||||
|
/// method, timestamp). Default: `false`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub audit_log: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_gws_rate_limit() -> u32 {
|
||||||
|
60
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_gws_timeout_secs() -> u64 {
|
||||||
|
30
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GoogleWorkspaceConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
allowed_services: Vec::new(),
|
||||||
|
credentials_path: None,
|
||||||
|
default_account: None,
|
||||||
|
rate_limit_per_minute: default_gws_rate_limit(),
|
||||||
|
timeout_secs: default_gws_timeout_secs(),
|
||||||
|
audit_log: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Project Intelligence ────────────────────────────────────────
|
// ── Project Intelligence ────────────────────────────────────────
|
||||||
|
|
||||||
/// Project delivery intelligence configuration (`[project_intel]` section).
|
/// Project delivery intelligence configuration (`[project_intel]` section).
|
||||||
@ -5263,6 +5355,7 @@ impl Default for Config {
|
|||||||
multimodal: MultimodalConfig::default(),
|
multimodal: MultimodalConfig::default(),
|
||||||
web_fetch: WebFetchConfig::default(),
|
web_fetch: WebFetchConfig::default(),
|
||||||
web_search: WebSearchConfig::default(),
|
web_search: WebSearchConfig::default(),
|
||||||
|
google_workspace: GoogleWorkspaceConfig::default(),
|
||||||
project_intel: ProjectIntelConfig::default(),
|
project_intel: ProjectIntelConfig::default(),
|
||||||
proxy: ProxyConfig::default(),
|
proxy: ProxyConfig::default(),
|
||||||
identity: IdentityConfig::default(),
|
identity: IdentityConfig::default(),
|
||||||
@ -6421,6 +6514,28 @@ impl Config {
|
|||||||
validate_mcp_config(&self.mcp)?;
|
validate_mcp_config(&self.mcp)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Google Workspace allowed_services validation
|
||||||
|
let mut seen_gws_services = std::collections::HashSet::new();
|
||||||
|
for (i, service) in self.google_workspace.allowed_services.iter().enumerate() {
|
||||||
|
let normalized = service.trim();
|
||||||
|
if normalized.is_empty() {
|
||||||
|
anyhow::bail!("google_workspace.allowed_services[{i}] must not be empty");
|
||||||
|
}
|
||||||
|
if !normalized
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
|
||||||
|
{
|
||||||
|
anyhow::bail!(
|
||||||
|
"google_workspace.allowed_services[{i}] contains invalid characters: {normalized}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !seen_gws_services.insert(normalized.to_string()) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"google_workspace.allowed_services contains duplicate entry: {normalized}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Project intelligence
|
// Project intelligence
|
||||||
if self.project_intel.enabled {
|
if self.project_intel.enabled {
|
||||||
let lang = &self.project_intel.default_language;
|
let lang = &self.project_intel.default_language;
|
||||||
@ -6473,7 +6588,6 @@ impl Config {
|
|||||||
if let Err(msg) = self.security.nevis.validate() {
|
if let Err(msg) = self.security.nevis.validate() {
|
||||||
anyhow::bail!("security.nevis: {msg}");
|
anyhow::bail!("security.nevis: {msg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -7567,6 +7681,7 @@ default_temperature = 0.7
|
|||||||
multimodal: MultimodalConfig::default(),
|
multimodal: MultimodalConfig::default(),
|
||||||
web_fetch: WebFetchConfig::default(),
|
web_fetch: WebFetchConfig::default(),
|
||||||
web_search: WebSearchConfig::default(),
|
web_search: WebSearchConfig::default(),
|
||||||
|
google_workspace: GoogleWorkspaceConfig::default(),
|
||||||
project_intel: ProjectIntelConfig::default(),
|
project_intel: ProjectIntelConfig::default(),
|
||||||
proxy: ProxyConfig::default(),
|
proxy: ProxyConfig::default(),
|
||||||
agent: AgentConfig::default(),
|
agent: AgentConfig::default(),
|
||||||
@ -7870,6 +7985,7 @@ tool_dispatcher = "xml"
|
|||||||
multimodal: MultimodalConfig::default(),
|
multimodal: MultimodalConfig::default(),
|
||||||
web_fetch: WebFetchConfig::default(),
|
web_fetch: WebFetchConfig::default(),
|
||||||
web_search: WebSearchConfig::default(),
|
web_search: WebSearchConfig::default(),
|
||||||
|
google_workspace: GoogleWorkspaceConfig::default(),
|
||||||
project_intel: ProjectIntelConfig::default(),
|
project_intel: ProjectIntelConfig::default(),
|
||||||
proxy: ProxyConfig::default(),
|
proxy: ProxyConfig::default(),
|
||||||
agent: AgentConfig::default(),
|
agent: AgentConfig::default(),
|
||||||
|
|||||||
@ -509,6 +509,18 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
// ── Productivity ────────────────────────────────────────
|
// ── Productivity ────────────────────────────────────────
|
||||||
|
IntegrationEntry {
|
||||||
|
name: "Google Workspace",
|
||||||
|
description: "Drive, Gmail, Calendar, Sheets, Docs via gws CLI",
|
||||||
|
category: IntegrationCategory::Productivity,
|
||||||
|
status_fn: |c| {
|
||||||
|
if c.google_workspace.enabled {
|
||||||
|
IntegrationStatus::Active
|
||||||
|
} else {
|
||||||
|
IntegrationStatus::Available
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
IntegrationEntry {
|
IntegrationEntry {
|
||||||
name: "GitHub",
|
name: "GitHub",
|
||||||
description: "Code, issues, PRs",
|
description: "Code, issues, PRs",
|
||||||
|
|||||||
@ -172,6 +172,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
|
|||||||
multimodal: crate::config::MultimodalConfig::default(),
|
multimodal: crate::config::MultimodalConfig::default(),
|
||||||
web_fetch: crate::config::WebFetchConfig::default(),
|
web_fetch: crate::config::WebFetchConfig::default(),
|
||||||
web_search: crate::config::WebSearchConfig::default(),
|
web_search: crate::config::WebSearchConfig::default(),
|
||||||
|
google_workspace: crate::config::GoogleWorkspaceConfig::default(),
|
||||||
project_intel: crate::config::ProjectIntelConfig::default(),
|
project_intel: crate::config::ProjectIntelConfig::default(),
|
||||||
proxy: crate::config::ProxyConfig::default(),
|
proxy: crate::config::ProxyConfig::default(),
|
||||||
identity: crate::config::IdentityConfig::default(),
|
identity: crate::config::IdentityConfig::default(),
|
||||||
@ -542,6 +543,7 @@ async fn run_quick_setup_with_home(
|
|||||||
multimodal: crate::config::MultimodalConfig::default(),
|
multimodal: crate::config::MultimodalConfig::default(),
|
||||||
web_fetch: crate::config::WebFetchConfig::default(),
|
web_fetch: crate::config::WebFetchConfig::default(),
|
||||||
web_search: crate::config::WebSearchConfig::default(),
|
web_search: crate::config::WebSearchConfig::default(),
|
||||||
|
google_workspace: crate::config::GoogleWorkspaceConfig::default(),
|
||||||
project_intel: crate::config::ProjectIntelConfig::default(),
|
project_intel: crate::config::ProjectIntelConfig::default(),
|
||||||
proxy: crate::config::ProxyConfig::default(),
|
proxy: crate::config::ProxyConfig::default(),
|
||||||
identity: crate::config::IdentityConfig::default(),
|
identity: crate::config::IdentityConfig::default(),
|
||||||
|
|||||||
@ -12,6 +12,7 @@ pub enum CliCategory {
|
|||||||
Container,
|
Container,
|
||||||
Build,
|
Build,
|
||||||
Cloud,
|
Cloud,
|
||||||
|
Productivity,
|
||||||
AiAgent,
|
AiAgent,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ impl std::fmt::Display for CliCategory {
|
|||||||
Self::Container => write!(f, "Container"),
|
Self::Container => write!(f, "Container"),
|
||||||
Self::Build => write!(f, "Build"),
|
Self::Build => write!(f, "Build"),
|
||||||
Self::Cloud => write!(f, "Cloud"),
|
Self::Cloud => write!(f, "Cloud"),
|
||||||
|
Self::Productivity => write!(f, "Productivity"),
|
||||||
Self::AiAgent => write!(f, "AI Agent"),
|
Self::AiAgent => write!(f, "AI Agent"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -106,6 +108,11 @@ const KNOWN_CLIS: &[KnownCli] = &[
|
|||||||
version_args: &["--version"],
|
version_args: &["--version"],
|
||||||
category: CliCategory::Language,
|
category: CliCategory::Language,
|
||||||
},
|
},
|
||||||
|
KnownCli {
|
||||||
|
name: "gws",
|
||||||
|
version_args: &["--version"],
|
||||||
|
category: CliCategory::Productivity,
|
||||||
|
},
|
||||||
KnownCli {
|
KnownCli {
|
||||||
name: "claude",
|
name: "claude",
|
||||||
version_args: &["--version"],
|
version_args: &["--version"],
|
||||||
@ -252,6 +259,7 @@ mod tests {
|
|||||||
assert_eq!(CliCategory::Container.to_string(), "Container");
|
assert_eq!(CliCategory::Container.to_string(), "Container");
|
||||||
assert_eq!(CliCategory::Build.to_string(), "Build");
|
assert_eq!(CliCategory::Build.to_string(), "Build");
|
||||||
assert_eq!(CliCategory::Cloud.to_string(), "Cloud");
|
assert_eq!(CliCategory::Cloud.to_string(), "Cloud");
|
||||||
|
assert_eq!(CliCategory::Productivity.to_string(), "Productivity");
|
||||||
assert_eq!(CliCategory::AiAgent.to_string(), "AI Agent");
|
assert_eq!(CliCategory::AiAgent.to_string(), "AI Agent");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
716
src/tools/google_workspace.rs
Normal file
716
src/tools/google_workspace.rs
Normal file
@ -0,0 +1,716 @@
|
|||||||
|
use super::traits::{Tool, ToolResult};
|
||||||
|
use crate::security::SecurityPolicy;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Default `gws` command execution time before kill (overridden by config).
|
||||||
|
const DEFAULT_GWS_TIMEOUT_SECS: u64 = 30;
|
||||||
|
/// Maximum output size in bytes (1MB).
|
||||||
|
const MAX_OUTPUT_BYTES: usize = 1_048_576;
|
||||||
|
|
||||||
|
/// Allowed Google Workspace services that gws can target.
|
||||||
|
const DEFAULT_ALLOWED_SERVICES: &[&str] = &[
|
||||||
|
"drive",
|
||||||
|
"sheets",
|
||||||
|
"gmail",
|
||||||
|
"calendar",
|
||||||
|
"docs",
|
||||||
|
"slides",
|
||||||
|
"tasks",
|
||||||
|
"people",
|
||||||
|
"chat",
|
||||||
|
"classroom",
|
||||||
|
"forms",
|
||||||
|
"keep",
|
||||||
|
"meet",
|
||||||
|
"events",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Google Workspace CLI (`gws`) integration tool.
|
||||||
|
///
|
||||||
|
/// Wraps the `gws` CLI binary to give the agent structured access to
|
||||||
|
/// Google Workspace services (Drive, Gmail, Calendar, Sheets, etc.).
|
||||||
|
/// Requires `gws` to be installed and authenticated (`gws auth login`).
|
||||||
|
pub struct GoogleWorkspaceTool {
|
||||||
|
security: Arc<SecurityPolicy>,
|
||||||
|
allowed_services: Vec<String>,
|
||||||
|
credentials_path: Option<String>,
|
||||||
|
default_account: Option<String>,
|
||||||
|
rate_limit_per_minute: u32,
|
||||||
|
timeout_secs: u64,
|
||||||
|
audit_log: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GoogleWorkspaceTool {
|
||||||
|
/// Create a new `GoogleWorkspaceTool`.
|
||||||
|
///
|
||||||
|
/// If `allowed_services` is empty, the default service set is used.
|
||||||
|
pub fn new(
|
||||||
|
security: Arc<SecurityPolicy>,
|
||||||
|
allowed_services: Vec<String>,
|
||||||
|
credentials_path: Option<String>,
|
||||||
|
default_account: Option<String>,
|
||||||
|
rate_limit_per_minute: u32,
|
||||||
|
timeout_secs: u64,
|
||||||
|
audit_log: bool,
|
||||||
|
) -> Self {
|
||||||
|
let services = if allowed_services.is_empty() {
|
||||||
|
DEFAULT_ALLOWED_SERVICES
|
||||||
|
.iter()
|
||||||
|
.map(|s| (*s).to_string())
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
allowed_services
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
security,
|
||||||
|
allowed_services: services,
|
||||||
|
credentials_path,
|
||||||
|
default_account,
|
||||||
|
rate_limit_per_minute,
|
||||||
|
timeout_secs,
|
||||||
|
audit_log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for GoogleWorkspaceTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"google_workspace"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Interact with Google Workspace services (Drive, Gmail, Calendar, Sheets, Docs, etc.) \
|
||||||
|
via the gws CLI. Requires gws to be installed and authenticated."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"service": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Google Workspace service (e.g. drive, gmail, calendar, sheets, docs, slides, tasks, people, chat, forms, keep, meet)"
|
||||||
|
},
|
||||||
|
"resource": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service resource (e.g. files, messages, events, spreadsheets)"
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Method to call on the resource (e.g. list, get, create, update, delete)"
|
||||||
|
},
|
||||||
|
"sub_resource": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional sub-resource for nested operations"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "URL/query parameters as key-value pairs (passed as --params JSON)"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Request body for POST/PATCH/PUT operations (passed as --json JSON)"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["json", "table", "yaml", "csv"],
|
||||||
|
"description": "Output format (default: json)"
|
||||||
|
},
|
||||||
|
"page_all": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Auto-paginate through all results"
|
||||||
|
},
|
||||||
|
"page_limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max pages to fetch when using page_all (default: 10)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["service", "resource", "method"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a Google Workspace CLI command with input validation and security enforcement.
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||||
|
let service = args
|
||||||
|
.get("service")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'service' parameter"))?;
|
||||||
|
let resource = args
|
||||||
|
.get("resource")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'resource' parameter"))?;
|
||||||
|
let method = args
|
||||||
|
.get("method")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'method' parameter"))?;
|
||||||
|
|
||||||
|
// Security checks
|
||||||
|
if self.security.is_rate_limited() {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some("Rate limit exceeded: too many actions in the last hour".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate service is in the allowlist
|
||||||
|
if !self.allowed_services.iter().any(|s| s == service) {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some(format!(
|
||||||
|
"Service '{service}' is not in the allowed services list. \
|
||||||
|
Allowed: {}",
|
||||||
|
self.allowed_services.join(", ")
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate inputs contain no shell metacharacters
|
||||||
|
for (label, value) in [
|
||||||
|
("service", service),
|
||||||
|
("resource", resource),
|
||||||
|
("method", method),
|
||||||
|
] {
|
||||||
|
if !value
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
{
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some(format!(
|
||||||
|
"Invalid characters in '{label}': only alphanumeric, underscore, and hyphen are allowed"
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the gws command — validate all optional fields before consuming budget
|
||||||
|
let mut cmd_args: Vec<String> = vec![service.to_string(), resource.to_string()];
|
||||||
|
|
||||||
|
if let Some(sub_resource_value) = args.get("sub_resource") {
|
||||||
|
let sub_resource = match sub_resource_value.as_str() {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some("'sub_resource' must be a string".into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !sub_resource
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
{
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some(
|
||||||
|
"Invalid characters in 'sub_resource': only alphanumeric, underscore, and hyphen are allowed"
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cmd_args.push(sub_resource.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_args.push(method.to_string());
|
||||||
|
|
||||||
|
if let Some(params) = args.get("params") {
|
||||||
|
if !params.is_object() {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some("'params' must be an object".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cmd_args.push("--params".into());
|
||||||
|
cmd_args.push(params.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(body) = args.get("body") {
|
||||||
|
if !body.is_object() {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some("'body' must be an object".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cmd_args.push("--json".into());
|
||||||
|
cmd_args.push(body.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(format_value) = args.get("format") {
|
||||||
|
let format = match format_value.as_str() {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some("'format' must be a string".into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match format {
|
||||||
|
"json" | "table" | "yaml" | "csv" => {
|
||||||
|
cmd_args.push("--format".into());
|
||||||
|
cmd_args.push(format.to_string());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some(format!(
|
||||||
|
"Invalid format '{format}': must be json, table, yaml, or csv"
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let page_all = match args.get("page_all") {
|
||||||
|
Some(v) => match v.as_bool() {
|
||||||
|
Some(b) => b,
|
||||||
|
None => {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some("'page_all' must be a boolean".into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
if page_all {
|
||||||
|
cmd_args.push("--page-all".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let page_limit = match args.get("page_limit") {
|
||||||
|
Some(v) => match v.as_u64() {
|
||||||
|
Some(n) => Some(n),
|
||||||
|
None => {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some("'page_limit' must be a non-negative integer".into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
if page_all || page_limit.is_some() {
|
||||||
|
cmd_args.push("--page-limit".into());
|
||||||
|
cmd_args.push(page_limit.unwrap_or(10).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charge action budget only after all validation passes
|
||||||
|
if !self.security.record_action() {
|
||||||
|
return Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some("Rate limit exceeded: action budget exhausted".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cmd = tokio::process::Command::new("gws");
|
||||||
|
cmd.args(&cmd_args);
|
||||||
|
cmd.env_clear();
|
||||||
|
// gws needs PATH to find itself and HOME/APPDATA for credential storage
|
||||||
|
for key in &["PATH", "HOME", "APPDATA", "USERPROFILE", "LANG", "TERM"] {
|
||||||
|
if let Ok(val) = std::env::var(key) {
|
||||||
|
cmd.env(key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply credential path if configured
|
||||||
|
if let Some(ref creds) = self.credentials_path {
|
||||||
|
cmd.env("GOOGLE_APPLICATION_CREDENTIALS", creds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply default account if configured
|
||||||
|
if let Some(ref account) = self.default_account {
|
||||||
|
cmd.args(["--account", account]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.audit_log {
|
||||||
|
tracing::info!(
|
||||||
|
tool = "google_workspace",
|
||||||
|
service = service,
|
||||||
|
resource = resource,
|
||||||
|
method = method,
|
||||||
|
"gws audit: executing API call"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply credential path if configured
|
||||||
|
if let Some(ref creds) = self.credentials_path {
|
||||||
|
cmd.env("GOOGLE_APPLICATION_CREDENTIALS", creds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply default account if configured
|
||||||
|
if let Some(ref account) = self.default_account {
|
||||||
|
cmd.args(["--account", account]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.audit_log {
|
||||||
|
tracing::info!(
|
||||||
|
tool = "google_workspace",
|
||||||
|
service = service,
|
||||||
|
resource = resource,
|
||||||
|
method = method,
|
||||||
|
"gws audit: executing API call"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result =
|
||||||
|
tokio::time::timeout(Duration::from_secs(self.timeout_secs), cmd.output()).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Ok(output)) => {
|
||||||
|
let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
|
let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||||
|
|
||||||
|
if stdout.len() > MAX_OUTPUT_BYTES {
|
||||||
|
// Find a valid char boundary at or before MAX_OUTPUT_BYTES
|
||||||
|
let mut boundary = MAX_OUTPUT_BYTES;
|
||||||
|
while boundary > 0 && !stdout.is_char_boundary(boundary) {
|
||||||
|
boundary -= 1;
|
||||||
|
}
|
||||||
|
stdout.truncate(boundary);
|
||||||
|
stdout.push_str("\n... [output truncated at 1MB]");
|
||||||
|
}
|
||||||
|
if stderr.len() > MAX_OUTPUT_BYTES {
|
||||||
|
let mut boundary = MAX_OUTPUT_BYTES;
|
||||||
|
while boundary > 0 && !stderr.is_char_boundary(boundary) {
|
||||||
|
boundary -= 1;
|
||||||
|
}
|
||||||
|
stderr.truncate(boundary);
|
||||||
|
stderr.push_str("\n... [stderr truncated at 1MB]");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ToolResult {
|
||||||
|
success: output.status.success(),
|
||||||
|
output: stdout,
|
||||||
|
error: if stderr.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(stderr)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some(format!(
|
||||||
|
"Failed to execute gws: {e}. Is gws installed? Run: npm install -g @googleworkspace/cli"
|
||||||
|
)),
|
||||||
|
}),
|
||||||
|
Err(_) => Ok(ToolResult {
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some(format!(
|
||||||
|
"gws command timed out after {}s and was killed", self.timeout_secs
|
||||||
|
)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::security::{AutonomyLevel, SecurityPolicy};
|
||||||
|
|
||||||
|
fn test_security() -> Arc<SecurityPolicy> {
|
||||||
|
Arc::new(SecurityPolicy {
|
||||||
|
autonomy: AutonomyLevel::Full,
|
||||||
|
workspace_dir: std::env::temp_dir(),
|
||||||
|
..SecurityPolicy::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_name() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
|
||||||
|
assert_eq!(tool.name(), "google_workspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_description_non_empty() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
|
||||||
|
assert!(!tool.description().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_schema_has_required_fields() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
|
||||||
|
let schema = tool.parameters_schema();
|
||||||
|
assert!(schema["properties"]["service"].is_object());
|
||||||
|
assert!(schema["properties"]["resource"].is_object());
|
||||||
|
assert!(schema["properties"]["method"].is_object());
|
||||||
|
let required = schema["required"]
|
||||||
|
.as_array()
|
||||||
|
.expect("required should be an array");
|
||||||
|
assert!(required.contains(&json!("service")));
|
||||||
|
assert!(required.contains(&json!("resource")));
|
||||||
|
assert!(required.contains(&json!("method")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_allowed_services_populated() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
|
||||||
|
assert!(!tool.allowed_services.is_empty());
|
||||||
|
assert!(tool.allowed_services.contains(&"drive".to_string()));
|
||||||
|
assert!(tool.allowed_services.contains(&"gmail".to_string()));
|
||||||
|
assert!(tool.allowed_services.contains(&"calendar".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_allowed_services_override_defaults() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(
|
||||||
|
test_security(),
|
||||||
|
vec!["drive".into(), "sheets".into()],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
60,
|
||||||
|
30,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(tool.allowed_services.len(), 2);
|
||||||
|
assert!(tool.allowed_services.contains(&"drive".to_string()));
|
||||||
|
assert!(tool.allowed_services.contains(&"sheets".to_string()));
|
||||||
|
assert!(!tool.allowed_services.contains(&"gmail".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_disallowed_service() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(
|
||||||
|
test_security(),
|
||||||
|
vec!["drive".into()],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
60,
|
||||||
|
30,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({
|
||||||
|
"service": "gmail",
|
||||||
|
"resource": "users",
|
||||||
|
"method": "list"
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.expect("disallowed service should return a result");
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.contains("not in the allowed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_shell_injection_in_service() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(
|
||||||
|
test_security(),
|
||||||
|
vec!["drive; rm -rf /".into()],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
60,
|
||||||
|
30,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({
|
||||||
|
"service": "drive; rm -rf /",
|
||||||
|
"resource": "files",
|
||||||
|
"method": "list"
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.expect("shell injection should return a result");
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.contains("Invalid characters"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_shell_injection_in_resource() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({
|
||||||
|
"service": "drive",
|
||||||
|
"resource": "files$(whoami)",
|
||||||
|
"method": "list"
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.expect("shell injection should return a result");
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.contains("Invalid characters"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_invalid_format() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({
|
||||||
|
"service": "drive",
|
||||||
|
"resource": "files",
|
||||||
|
"method": "list",
|
||||||
|
"format": "xml"
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.expect("invalid format should return a result");
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.contains("Invalid format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_wrong_type_params() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({
|
||||||
|
"service": "drive",
|
||||||
|
"resource": "files",
|
||||||
|
"method": "list",
|
||||||
|
"params": "not_an_object"
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.expect("wrong type params should return a result");
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.contains("'params' must be an object"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_wrong_type_body() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({
|
||||||
|
"service": "drive",
|
||||||
|
"resource": "files",
|
||||||
|
"method": "create",
|
||||||
|
"body": "not_an_object"
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.expect("wrong type body should return a result");
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.contains("'body' must be an object"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_wrong_type_page_all() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({
|
||||||
|
"service": "drive",
|
||||||
|
"resource": "files",
|
||||||
|
"method": "list",
|
||||||
|
"page_all": "yes"
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.expect("wrong type page_all should return a result");
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.contains("'page_all' must be a boolean"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_wrong_type_page_limit() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({
|
||||||
|
"service": "drive",
|
||||||
|
"resource": "files",
|
||||||
|
"method": "list",
|
||||||
|
"page_limit": "ten"
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.expect("wrong type page_limit should return a result");
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.contains("'page_limit' must be a non-negative integer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_wrong_type_sub_resource() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({
|
||||||
|
"service": "drive",
|
||||||
|
"resource": "files",
|
||||||
|
"method": "list",
|
||||||
|
"sub_resource": 123
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.expect("wrong type sub_resource should return a result");
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.contains("'sub_resource' must be a string"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_required_param_returns_error() {
|
||||||
|
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
|
||||||
|
let result = tool.execute(json!({"service": "drive"})).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rate_limited_returns_error() {
|
||||||
|
let security = Arc::new(SecurityPolicy {
|
||||||
|
autonomy: AutonomyLevel::Full,
|
||||||
|
max_actions_per_hour: 0,
|
||||||
|
workspace_dir: std::env::temp_dir(),
|
||||||
|
..SecurityPolicy::default()
|
||||||
|
});
|
||||||
|
let tool = GoogleWorkspaceTool::new(security, vec![], None, None, 60, 30, false);
|
||||||
|
let result = tool
|
||||||
|
.execute(json!({
|
||||||
|
"service": "drive",
|
||||||
|
"resource": "files",
|
||||||
|
"method": "list"
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.expect("rate-limited should return a result");
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gws_timeout_is_reasonable() {
|
||||||
|
assert_eq!(DEFAULT_GWS_TIMEOUT_SECS, 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,6 +37,7 @@ pub mod file_read;
|
|||||||
pub mod file_write;
|
pub mod file_write;
|
||||||
pub mod git_operations;
|
pub mod git_operations;
|
||||||
pub mod glob_search;
|
pub mod glob_search;
|
||||||
|
pub mod google_workspace;
|
||||||
#[cfg(feature = "hardware")]
|
#[cfg(feature = "hardware")]
|
||||||
pub mod hardware_board_info;
|
pub mod hardware_board_info;
|
||||||
#[cfg(feature = "hardware")]
|
#[cfg(feature = "hardware")]
|
||||||
@ -96,6 +97,7 @@ pub use file_read::FileReadTool;
|
|||||||
pub use file_write::FileWriteTool;
|
pub use file_write::FileWriteTool;
|
||||||
pub use git_operations::GitOperationsTool;
|
pub use git_operations::GitOperationsTool;
|
||||||
pub use glob_search::GlobSearchTool;
|
pub use glob_search::GlobSearchTool;
|
||||||
|
pub use google_workspace::GoogleWorkspaceTool;
|
||||||
#[cfg(feature = "hardware")]
|
#[cfg(feature = "hardware")]
|
||||||
pub use hardware_board_info::HardwareBoardInfoTool;
|
pub use hardware_board_info::HardwareBoardInfoTool;
|
||||||
#[cfg(feature = "hardware")]
|
#[cfg(feature = "hardware")]
|
||||||
@ -379,6 +381,23 @@ pub fn all_tools_with_runtime(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Google Workspace CLI (gws) integration — requires shell access
|
||||||
|
if root_config.google_workspace.enabled && has_shell_access {
|
||||||
|
tool_arcs.push(Arc::new(GoogleWorkspaceTool::new(
|
||||||
|
security.clone(),
|
||||||
|
root_config.google_workspace.allowed_services.clone(),
|
||||||
|
root_config.google_workspace.credentials_path.clone(),
|
||||||
|
root_config.google_workspace.default_account.clone(),
|
||||||
|
root_config.google_workspace.rate_limit_per_minute,
|
||||||
|
root_config.google_workspace.timeout_secs,
|
||||||
|
root_config.google_workspace.audit_log,
|
||||||
|
)));
|
||||||
|
} else if root_config.google_workspace.enabled {
|
||||||
|
tracing::warn!(
|
||||||
|
"google_workspace: skipped registration because shell access is unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Notion API tool (conditionally registered)
|
// Notion API tool (conditionally registered)
|
||||||
if root_config.notion.enabled {
|
if root_config.notion.enabled {
|
||||||
let notion_api_key = if root_config.notion.api_key.trim().is_empty() {
|
let notion_api_key = if root_config.notion.api_key.trim().is_empty() {
|
||||||
@ -431,6 +450,7 @@ pub fn all_tools_with_runtime(
|
|||||||
if root_config.cloud_ops.enabled {
|
if root_config.cloud_ops.enabled {
|
||||||
tool_arcs.push(Arc::new(CloudOpsTool::new(root_config.cloud_ops.clone())));
|
tool_arcs.push(Arc::new(CloudOpsTool::new(root_config.cloud_ops.clone())));
|
||||||
tool_arcs.push(Arc::new(CloudPatternsTool::new()));
|
tool_arcs.push(Arc::new(CloudPatternsTool::new()));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PDF extraction (feature-gated at compile time via rag-pdf)
|
// PDF extraction (feature-gated at compile time via rag-pdf)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user