mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 16:49:34 +00:00
feat(skills): credential specs in skill frontmatter, HTTP tool hardening, mission leases
Skills can now declare API credentials in YAML frontmatter (SkillCredentialSpec, SkillCredentialLocation, SkillOAuthConfig, ProviderRefreshStrategy). Valid specs are registered into SharedCredentialRegistry at startup; the HttpTool auto-injects credentials for matching hosts — same zero-exposure model as WASM tools. HTTP tool security hardening: - Block LLM-provided auth headers for hosts with registered credentials - Return structured authentication_required error for missing credentials - Strip sensitive response headers (Set-Cookie, WWW-Authenticate, Authorization) - Scan response body through LeakDetector before returning to LLM Mission capability leases: registered mission_create/list/fire/pause/resume/delete as a "missions" capability so threads receive leases. Removed routine_* aliases from effect adapter — descriptions mention "routine" for LLM intent mapping. Includes 10 integration tests (tests/skill_credential_injection.rs) covering the full pipeline: YAML parsing → validation → registry → HttpTool wiring → per-user isolation. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,658 @@
|
||||
//! Integration test: skill-based credential injection pipeline.
|
||||
//!
|
||||
//! Tests the complete flow from skill YAML frontmatter → credential parsing →
|
||||
//! validation → SharedCredentialRegistry registration → HttpTool wiring.
|
||||
//!
|
||||
//! Scenario: Multiple skills declare credentials in their frontmatter. The test
|
||||
//! verifies that:
|
||||
//!
|
||||
//! 1. Credential specs are correctly parsed from YAML (all location types, refresh strategies)
|
||||
//! 2. Validation rejects insecure or malformed specs
|
||||
//! 3. Valid specs are registered into SharedCredentialRegistry
|
||||
//! 4. Invalid specs are skipped (not registered)
|
||||
//! 5. HttpTool's requires_approval detects registered credential hosts
|
||||
//! 6. LLM-provided auth headers are rejected for registered hosts
|
||||
//! 7. Non-auth headers pass through for registered hosts
|
||||
//! 8. Unregistered hosts allow auth headers (LLM-constructed)
|
||||
//! 9. Per-user credential isolation works at the SecretsStore level
|
||||
//! 10. Multi-skill credential registration doesn't interfere
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use ironclaw::secrets::{
|
||||
CreateSecretParams, CredentialMapping, InMemorySecretsStore, SecretsCrypto, SecretsStore,
|
||||
};
|
||||
use ironclaw::tools::builtin::HttpTool;
|
||||
use ironclaw::tools::wasm::SharedCredentialRegistry;
|
||||
use ironclaw::tools::{ApprovalRequirement, Tool};
|
||||
use ironclaw_skills::types::*;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Create an in-memory secrets store for testing.
|
||||
fn test_secrets_store() -> InMemorySecretsStore {
|
||||
let crypto = Arc::new(
|
||||
SecretsCrypto::new(SecretString::from(
|
||||
"0123456789abcdef0123456789abcdef".to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
InMemorySecretsStore::new(crypto)
|
||||
}
|
||||
|
||||
/// Build a LoadedSkill from frontmatter YAML and prompt content.
|
||||
fn make_skill(
|
||||
name: &str,
|
||||
credentials: Vec<SkillCredentialSpec>,
|
||||
prompt: &str,
|
||||
) -> ironclaw_skills::LoadedSkill {
|
||||
ironclaw_skills::LoadedSkill {
|
||||
manifest: SkillManifest {
|
||||
name: name.to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
description: format!("{} skill", name),
|
||||
activation: ActivationCriteria::default(),
|
||||
credentials,
|
||||
metadata: None,
|
||||
},
|
||||
prompt_content: prompt.to_string(),
|
||||
trust: SkillTrust::Trusted,
|
||||
source: SkillSource::User(PathBuf::from("/tmp/test-skills")),
|
||||
content_hash: format!("sha256:{}", name),
|
||||
compiled_patterns: vec![],
|
||||
lowercased_keywords: vec![],
|
||||
lowercased_exclude_keywords: vec![],
|
||||
lowercased_tags: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an HttpTool with credential injection.
|
||||
fn http_tool_with_credentials(
|
||||
registry: Arc<SharedCredentialRegistry>,
|
||||
store: Arc<dyn SecretsStore + Send + Sync>,
|
||||
) -> HttpTool {
|
||||
HttpTool::new().with_credentials(registry, store)
|
||||
}
|
||||
|
||||
// ── Frontmatter Parsing Tests ────────────────────────────────────────────
|
||||
|
||||
/// Full Gmail-like skill with OAuth, scopes, PKCE, and extra params.
|
||||
#[test]
|
||||
fn test_parse_complex_google_credential_spec() {
|
||||
let yaml = r#"
|
||||
name: gmail
|
||||
version: "1.0.0"
|
||||
description: Gmail API integration
|
||||
activation:
|
||||
keywords: ["email", "gmail", "inbox"]
|
||||
credentials:
|
||||
- name: google_oauth_token
|
||||
provider: google
|
||||
location:
|
||||
type: bearer
|
||||
hosts:
|
||||
- "gmail.googleapis.com"
|
||||
- "www.googleapis.com"
|
||||
oauth:
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
token_url: "https://oauth2.googleapis.com/token"
|
||||
scopes:
|
||||
- "https://www.googleapis.com/auth/gmail.modify"
|
||||
- "https://www.googleapis.com/auth/gmail.readonly"
|
||||
use_pkce: true
|
||||
extra_params:
|
||||
access_type: offline
|
||||
prompt: consent
|
||||
test_url: "https://www.googleapis.com/oauth2/v1/userinfo"
|
||||
setup_instructions: "Enable Gmail API in Google Cloud Console"
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
|
||||
assert_eq!(manifest.name, "gmail");
|
||||
assert_eq!(manifest.credentials.len(), 1);
|
||||
|
||||
let cred = &manifest.credentials[0];
|
||||
assert_eq!(cred.name, "google_oauth_token");
|
||||
assert_eq!(cred.provider, "google");
|
||||
assert!(matches!(cred.location, SkillCredentialLocation::Bearer));
|
||||
assert_eq!(cred.hosts.len(), 2);
|
||||
assert_eq!(cred.hosts[0], "gmail.googleapis.com");
|
||||
assert_eq!(cred.hosts[1], "www.googleapis.com");
|
||||
|
||||
let oauth = cred.oauth.as_ref().unwrap();
|
||||
assert!(oauth.use_pkce);
|
||||
assert_eq!(oauth.scopes.len(), 2);
|
||||
assert_eq!(oauth.extra_params.get("access_type").unwrap(), "offline");
|
||||
assert_eq!(
|
||||
oauth.test_url.as_deref(),
|
||||
Some("https://www.googleapis.com/oauth2/v1/userinfo")
|
||||
);
|
||||
assert!(matches!(oauth.refresh, ProviderRefreshStrategy::Standard));
|
||||
|
||||
assert_eq!(
|
||||
cred.setup_instructions.as_deref(),
|
||||
Some("Enable Gmail API in Google Cloud Console")
|
||||
);
|
||||
}
|
||||
|
||||
/// Multi-credential skill (e.g., a tool that needs both GitHub and Slack).
|
||||
#[test]
|
||||
fn test_parse_multi_credential_skill() {
|
||||
let yaml = r#"
|
||||
name: devops-notify
|
||||
version: "1.0.0"
|
||||
description: Deploy notification skill
|
||||
credentials:
|
||||
- name: github_token
|
||||
provider: github
|
||||
location:
|
||||
type: bearer
|
||||
hosts: ["api.github.com"]
|
||||
oauth:
|
||||
authorization_url: "https://github.com/login/oauth/authorize"
|
||||
token_url: "https://github.com/login/oauth/access_token"
|
||||
scopes: ["repo", "read:org"]
|
||||
refresh:
|
||||
strategy: reauthorize_only
|
||||
- name: slack_bot_token
|
||||
provider: slack
|
||||
location:
|
||||
type: bearer
|
||||
hosts: ["slack.com", "api.slack.com"]
|
||||
oauth:
|
||||
authorization_url: "https://slack.com/oauth/v2/authorize"
|
||||
token_url: "https://slack.com/api/oauth.v2.access"
|
||||
scopes: ["chat:write", "channels:read"]
|
||||
refresh:
|
||||
strategy: custom
|
||||
refresh_url: "https://slack.com/api/oauth.v2.access"
|
||||
extra_params:
|
||||
grant_type: refresh_token
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
assert_eq!(manifest.credentials.len(), 2);
|
||||
|
||||
// GitHub: reauthorize_only
|
||||
let gh = &manifest.credentials[0];
|
||||
assert_eq!(gh.name, "github_token");
|
||||
assert!(matches!(
|
||||
gh.oauth.as_ref().unwrap().refresh,
|
||||
ProviderRefreshStrategy::ReauthorizeOnly
|
||||
));
|
||||
|
||||
// Slack: custom refresh
|
||||
let sl = &manifest.credentials[1];
|
||||
assert_eq!(sl.name, "slack_bot_token");
|
||||
assert_eq!(sl.hosts, vec!["slack.com", "api.slack.com"]);
|
||||
match &sl.oauth.as_ref().unwrap().refresh {
|
||||
ProviderRefreshStrategy::Custom {
|
||||
refresh_url,
|
||||
extra_params,
|
||||
} => {
|
||||
assert_eq!(refresh_url, "https://slack.com/api/oauth.v2.access");
|
||||
assert_eq!(extra_params.get("grant_type").unwrap(), "refresh_token");
|
||||
}
|
||||
other => panic!("expected Custom refresh, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// All credential location types parse correctly.
|
||||
#[test]
|
||||
fn test_parse_all_credential_location_types() {
|
||||
let yaml = r#"
|
||||
name: multi-auth
|
||||
credentials:
|
||||
- name: bearer_cred
|
||||
provider: example
|
||||
location:
|
||||
type: bearer
|
||||
hosts: ["api.example.com"]
|
||||
- name: basic_cred
|
||||
provider: example
|
||||
location:
|
||||
type: basic_auth
|
||||
username: admin
|
||||
hosts: ["api.example.com"]
|
||||
- name: header_cred
|
||||
provider: example
|
||||
location:
|
||||
type: header
|
||||
name: X-API-Key
|
||||
prefix: "Token"
|
||||
hosts: ["api.example.com"]
|
||||
- name: query_cred
|
||||
provider: example
|
||||
location:
|
||||
type: query_param
|
||||
name: access_token
|
||||
hosts: ["api.example.com"]
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
assert_eq!(manifest.credentials.len(), 4);
|
||||
|
||||
assert!(matches!(
|
||||
manifest.credentials[0].location,
|
||||
SkillCredentialLocation::Bearer
|
||||
));
|
||||
match &manifest.credentials[1].location {
|
||||
SkillCredentialLocation::BasicAuth { username } => assert_eq!(username, "admin"),
|
||||
_ => panic!("expected BasicAuth"),
|
||||
}
|
||||
match &manifest.credentials[2].location {
|
||||
SkillCredentialLocation::Header { name, prefix } => {
|
||||
assert_eq!(name, "X-API-Key");
|
||||
assert_eq!(prefix.as_deref(), Some("Token"));
|
||||
}
|
||||
_ => panic!("expected Header"),
|
||||
}
|
||||
match &manifest.credentials[3].location {
|
||||
SkillCredentialLocation::QueryParam { name } => assert_eq!(name, "access_token"),
|
||||
_ => panic!("expected QueryParam"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation Tests ─────────────────────────────────────────────────────
|
||||
|
||||
/// Invalid credential specs are caught by validation.
|
||||
#[test]
|
||||
fn test_validation_rejects_insecure_and_malformed_specs() {
|
||||
// HTTP OAuth URL
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "token".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.example.com".to_string()],
|
||||
oauth: Some(SkillOAuthConfig {
|
||||
authorization_url: "http://insecure.example.com/auth".to_string(),
|
||||
token_url: "https://secure.example.com/token".to_string(),
|
||||
scopes: vec![],
|
||||
use_pkce: false,
|
||||
extra_params: Default::default(),
|
||||
refresh: ProviderRefreshStrategy::Standard,
|
||||
test_url: None,
|
||||
}),
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = ironclaw_skills::validate_credential_spec(&spec);
|
||||
assert!(!errors.is_empty());
|
||||
assert!(errors.iter().any(|e| e.contains("HTTPS")));
|
||||
|
||||
// Empty hosts
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "token".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec![],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = ironclaw_skills::validate_credential_spec(&spec);
|
||||
assert!(errors.iter().any(|e| e.contains("at least one host")));
|
||||
|
||||
// Uppercase name
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "INVALID_NAME".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.example.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = ironclaw_skills::validate_credential_spec(&spec);
|
||||
assert!(errors.iter().any(|e| e.contains("lowercase")));
|
||||
|
||||
// Empty provider
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "token".to_string(),
|
||||
provider: "".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.example.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = ironclaw_skills::validate_credential_spec(&spec);
|
||||
assert!(errors.iter().any(|e| e.contains("provider")));
|
||||
|
||||
// Multiple errors accumulate
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "BAD".to_string(),
|
||||
provider: "".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec![],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = ironclaw_skills::validate_credential_spec(&spec);
|
||||
assert_eq!(errors.len(), 3, "should accumulate: bad name + empty provider + empty hosts");
|
||||
}
|
||||
|
||||
// ── Registry Pipeline Tests ──────────────────────────────────────────────
|
||||
|
||||
/// Valid skill credentials are registered; invalid ones are skipped.
|
||||
#[test]
|
||||
fn test_register_skill_credentials_mixed_valid_invalid() {
|
||||
let valid_skill = make_skill(
|
||||
"weather",
|
||||
vec![SkillCredentialSpec {
|
||||
name: "weather_token".to_string(),
|
||||
provider: "weatherco".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.weather.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
}],
|
||||
"Call the weather API via http tool.",
|
||||
);
|
||||
|
||||
let invalid_skill = make_skill(
|
||||
"broken",
|
||||
vec![SkillCredentialSpec {
|
||||
name: "UPPERCASE_BAD".to_string(), // invalid name
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.broken.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
}],
|
||||
"This skill has a bad credential spec.",
|
||||
);
|
||||
|
||||
let registry = SharedCredentialRegistry::new();
|
||||
ironclaw::skills::register_skill_credentials(&[valid_skill, invalid_skill], ®istry);
|
||||
|
||||
// Valid should be registered
|
||||
assert!(registry.has_credentials_for_host("api.weather.com"));
|
||||
// Invalid should be skipped
|
||||
assert!(!registry.has_credentials_for_host("api.broken.com"));
|
||||
}
|
||||
|
||||
/// Multiple skills register independent credentials without interference.
|
||||
#[test]
|
||||
fn test_multi_skill_credential_registration() {
|
||||
let github_skill = make_skill(
|
||||
"github",
|
||||
vec![SkillCredentialSpec {
|
||||
name: "github_token".to_string(),
|
||||
provider: "github".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.github.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
}],
|
||||
"GitHub API skill.",
|
||||
);
|
||||
|
||||
let slack_skill = make_skill(
|
||||
"slack",
|
||||
vec![SkillCredentialSpec {
|
||||
name: "slack_token".to_string(),
|
||||
provider: "slack".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["slack.com".to_string(), "api.slack.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
}],
|
||||
"Slack API skill.",
|
||||
);
|
||||
|
||||
let no_creds_skill = make_skill("writing", vec![], "Just a writing skill, no API access.");
|
||||
|
||||
let registry = SharedCredentialRegistry::new();
|
||||
ironclaw::skills::register_skill_credentials(
|
||||
&[github_skill, slack_skill, no_creds_skill],
|
||||
®istry,
|
||||
);
|
||||
|
||||
assert!(registry.has_credentials_for_host("api.github.com"));
|
||||
assert!(registry.has_credentials_for_host("slack.com"));
|
||||
assert!(registry.has_credentials_for_host("api.slack.com"));
|
||||
assert!(!registry.has_credentials_for_host("unregistered.example.com"));
|
||||
}
|
||||
|
||||
/// Skill credential spec → CredentialMapping conversion preserves all fields.
|
||||
#[test]
|
||||
fn test_credential_spec_to_mapping_all_location_types() {
|
||||
// Bearer
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "token".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.test.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let mapping = ironclaw::skills::credential_spec_to_mapping(&spec);
|
||||
assert_eq!(mapping.secret_name, "token");
|
||||
assert!(matches!(
|
||||
mapping.location,
|
||||
ironclaw::secrets::CredentialLocation::AuthorizationBearer
|
||||
));
|
||||
assert_eq!(mapping.host_patterns, vec!["api.test.com"]);
|
||||
|
||||
// Header with prefix
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "api_key".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Header {
|
||||
name: "X-API-Key".to_string(),
|
||||
prefix: Some("Token".to_string()),
|
||||
},
|
||||
hosts: vec!["*.example.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let mapping = ironclaw::skills::credential_spec_to_mapping(&spec);
|
||||
match &mapping.location {
|
||||
ironclaw::secrets::CredentialLocation::Header { name, prefix } => {
|
||||
assert_eq!(name, "X-API-Key");
|
||||
assert_eq!(prefix.as_deref(), Some("Token"));
|
||||
}
|
||||
_ => panic!("expected Header location"),
|
||||
}
|
||||
assert_eq!(mapping.host_patterns, vec!["*.example.com"]);
|
||||
|
||||
// BasicAuth
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "basic_pass".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::BasicAuth {
|
||||
username: "admin".to_string(),
|
||||
},
|
||||
hosts: vec!["api.example.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let mapping = ironclaw::skills::credential_spec_to_mapping(&spec);
|
||||
match &mapping.location {
|
||||
ironclaw::secrets::CredentialLocation::AuthorizationBasic { username } => {
|
||||
assert_eq!(username, "admin");
|
||||
}
|
||||
_ => panic!("expected AuthorizationBasic location"),
|
||||
}
|
||||
|
||||
// QueryParam
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "key".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::QueryParam {
|
||||
name: "api_key".to_string(),
|
||||
},
|
||||
hosts: vec!["api.legacy.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let mapping = ironclaw::skills::credential_spec_to_mapping(&spec);
|
||||
match &mapping.location {
|
||||
ironclaw::secrets::CredentialLocation::QueryParam { name } => {
|
||||
assert_eq!(name, "api_key");
|
||||
}
|
||||
_ => panic!("expected QueryParam location"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── HttpTool Approval & Header Blocking Tests ────────────────────────────
|
||||
|
||||
/// HttpTool requires approval for hosts with registered credentials.
|
||||
#[test]
|
||||
fn test_http_tool_requires_approval_for_credentialed_host() {
|
||||
let registry = Arc::new(SharedCredentialRegistry::new());
|
||||
registry.add_mappings(vec![CredentialMapping::bearer(
|
||||
"github_token",
|
||||
"api.github.com",
|
||||
)]);
|
||||
|
||||
let tool = http_tool_with_credentials(registry, Arc::new(test_secrets_store()));
|
||||
|
||||
// Credentialed host → requires approval
|
||||
let params = serde_json::json!({
|
||||
"url": "https://api.github.com/repos/nearai/ironclaw/issues",
|
||||
"method": "GET"
|
||||
});
|
||||
assert_eq!(
|
||||
tool.requires_approval(¶ms),
|
||||
ApprovalRequirement::UnlessAutoApproved,
|
||||
);
|
||||
|
||||
// Unregistered host → no approval needed for GET
|
||||
let params = serde_json::json!({
|
||||
"url": "https://example.com/public-api",
|
||||
"method": "GET"
|
||||
});
|
||||
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
||||
}
|
||||
|
||||
/// Per-user credential isolation at the SecretsStore level.
|
||||
#[tokio::test]
|
||||
async fn test_per_user_credential_isolation() {
|
||||
let store = test_secrets_store();
|
||||
|
||||
// Store secret for user-a
|
||||
store
|
||||
.create(
|
||||
"user-a",
|
||||
CreateSecretParams::new("github_token", "user-a-secret"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// user-a can retrieve it
|
||||
let secret = store.get_decrypted("user-a", "github_token").await.unwrap();
|
||||
assert_eq!(secret.expose(), "user-a-secret");
|
||||
|
||||
// user-b cannot
|
||||
let err = store.get_decrypted("user-b", "github_token").await;
|
||||
assert!(err.is_err(), "user-b should not access user-a's secret");
|
||||
|
||||
// user-b stores their own
|
||||
store
|
||||
.create(
|
||||
"user-b",
|
||||
CreateSecretParams::new("github_token", "user-b-secret"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Each user sees their own value
|
||||
let a = store.get_decrypted("user-a", "github_token").await.unwrap();
|
||||
let b = store.get_decrypted("user-b", "github_token").await.unwrap();
|
||||
assert_eq!(a.expose(), "user-a-secret");
|
||||
assert_eq!(b.expose(), "user-b-secret");
|
||||
}
|
||||
|
||||
// ── End-to-End Scenario ──────────────────────────────────────────────────
|
||||
|
||||
/// Complete scenario: parse skill YAML → validate → register → verify HttpTool behavior.
|
||||
///
|
||||
/// Simulates what happens when IronClaw discovers skills at startup:
|
||||
/// 1. Parse frontmatter with credential specs
|
||||
/// 2. Validate specs (reject bad ones)
|
||||
/// 3. Register valid specs into SharedCredentialRegistry
|
||||
/// 4. HttpTool picks up registered credentials for approval checks
|
||||
/// 5. Secrets store provides per-user isolation
|
||||
#[tokio::test]
|
||||
async fn test_full_skill_credential_pipeline() {
|
||||
// Step 1: Parse skill YAML (like skill discovery)
|
||||
let yaml = r#"
|
||||
name: github
|
||||
version: "1.0.0"
|
||||
description: GitHub API integration
|
||||
activation:
|
||||
keywords: ["github", "issues", "pull request"]
|
||||
credentials:
|
||||
- name: github_token
|
||||
provider: github
|
||||
location:
|
||||
type: bearer
|
||||
hosts: ["api.github.com"]
|
||||
oauth:
|
||||
authorization_url: "https://github.com/login/oauth/authorize"
|
||||
token_url: "https://github.com/login/oauth/access_token"
|
||||
scopes: ["repo"]
|
||||
refresh:
|
||||
strategy: reauthorize_only
|
||||
setup_instructions: "Create a PAT at https://github.com/settings/tokens"
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
|
||||
// Step 2: Validate
|
||||
for spec in &manifest.credentials {
|
||||
let errors = ironclaw_skills::validate_credential_spec(spec);
|
||||
assert!(errors.is_empty(), "valid spec should pass validation: {:?}", errors);
|
||||
}
|
||||
|
||||
// Step 3: Build LoadedSkill and register (same code path as app.rs)
|
||||
let skill = make_skill(
|
||||
&manifest.name,
|
||||
manifest.credentials.clone(),
|
||||
"GitHub API skill content.",
|
||||
);
|
||||
|
||||
let registry = Arc::new(SharedCredentialRegistry::new());
|
||||
ironclaw::skills::register_skill_credentials(&[skill], ®istry);
|
||||
|
||||
// Step 4: Verify registry state
|
||||
assert!(registry.has_credentials_for_host("api.github.com"));
|
||||
assert!(!registry.has_credentials_for_host("gitlab.com"));
|
||||
|
||||
let mappings = registry.find_for_host("api.github.com");
|
||||
assert_eq!(mappings.len(), 1);
|
||||
assert_eq!(mappings[0].secret_name, "github_token");
|
||||
|
||||
// Step 5: HttpTool integration
|
||||
let store = Arc::new(test_secrets_store());
|
||||
let tool = http_tool_with_credentials(Arc::clone(®istry), store.clone());
|
||||
|
||||
// Before storing secret: credentialed host requires approval
|
||||
let params = serde_json::json!({
|
||||
"url": "https://api.github.com/repos/nearai/ironclaw",
|
||||
"method": "GET"
|
||||
});
|
||||
assert_eq!(
|
||||
tool.requires_approval(¶ms),
|
||||
ApprovalRequirement::UnlessAutoApproved,
|
||||
);
|
||||
|
||||
// Store credential for user
|
||||
store
|
||||
.create(
|
||||
"developer",
|
||||
CreateSecretParams::new("github_token", "ghp_test_secret_42")
|
||||
.with_provider("github"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify secret exists and is user-scoped
|
||||
assert!(store.exists("developer", "github_token").await.unwrap());
|
||||
assert!(!store.exists("other-user", "github_token").await.unwrap());
|
||||
|
||||
// Verify the credential can be decrypted (for injection)
|
||||
let decrypted = store
|
||||
.get_decrypted("developer", "github_token")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(decrypted.expose(), "ghp_test_secret_42");
|
||||
}
|
||||
Reference in New Issue
Block a user