feat: add skill lifecycle routes to runtime API (install, update, uninstall, trust, audit)
- Add POST /v1/skills/install for installing from remote sources
- Add POST /v1/skills/{name}/update for updating by name
- Add DELETE /v1/skills/{name} for uninstalling
- Add POST /v1/skills/{name}/trust for marking skill as trusted
- Add GET /v1/skills/{name}/audit for read-only inspection receipts
- Add `skill_lifecycle: bool` to RuntimeCapabilities in protocol crate
- Advertise skill_lifecycle=true in GET /v1/runtime/info
- Add ApiError::forbidden for network-policy-denied responses
- Add 15 API tests covering success, not-found, invalid scope, digest drift, and auth
The trust note preserves exact advisory wording from the TUI: "advisory and
digest-bound; records your review intent but does not sandbox or auto-authorize
scripts."
Closes #5070
This commit is contained in:
committed by
CodeWhale Bot
parent
5c228c5bdb
commit
5dcd264640
@@ -74,6 +74,10 @@ pub struct RuntimeCapabilities {
|
||||
/// /v1/apps/mcp/servers` family of endpoints.
|
||||
#[serde(default)]
|
||||
pub mcp_server_management: bool,
|
||||
/// Skill lifecycle operations (install, update, uninstall, trust, audit)
|
||||
/// are available via the HTTP API.
|
||||
#[serde(default)]
|
||||
pub skill_lifecycle: bool,
|
||||
}
|
||||
|
||||
/// Experimental opt-in flags advertised by `GET /v1/runtime/info`.
|
||||
@@ -372,6 +376,7 @@ mod tests {
|
||||
fleet_local_target: true,
|
||||
thread_goals: true,
|
||||
memory: true,
|
||||
skill_lifecycle: false,
|
||||
};
|
||||
let value = serde_json::to_value(&caps).unwrap();
|
||||
let obj = value.as_object().unwrap();
|
||||
|
||||
@@ -331,6 +331,101 @@ struct SetSkillEnabledResponse {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
// ─── Skill lifecycle request/response types ────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InstallSkillRequest {
|
||||
/// Remote source spec: `github:owner/repo`, `https://…`, or a registry name.
|
||||
source: String,
|
||||
/// `"project"` or `"global"` (default: `"global"`).
|
||||
#[serde(default)]
|
||||
scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UpdateSkillRequest {
|
||||
/// `"project"`, `"global"`, or `null` (auto-detect).
|
||||
#[serde(default)]
|
||||
scope: Option<String>,
|
||||
/// Digest the caller observed before requesting the update. The mutation
|
||||
/// will fail if the on-disk digest has changed since.
|
||||
#[serde(default)]
|
||||
expected_digest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UninstallSkillQuery {
|
||||
/// `"project"`, `"global"`, or `null` (auto-detect).
|
||||
#[serde(default)]
|
||||
scope: Option<String>,
|
||||
/// Digest the caller observed. The mutation will fail if it has drifted.
|
||||
#[serde(default)]
|
||||
expected_digest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TrustSkillRequest {
|
||||
/// `"project"`, `"global"`, or `null` (auto-detect).
|
||||
#[serde(default)]
|
||||
scope: Option<String>,
|
||||
/// Digest the caller reviewed. The mutation will fail if it has drifted.
|
||||
#[serde(default)]
|
||||
expected_digest: Option<String>,
|
||||
}
|
||||
|
||||
/// Scope query parameter used by the audit endpoint.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct SkillScopeQuery {
|
||||
/// `"project"` or `"global"` to restrict to one root.
|
||||
scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SkillMutationReceiptResponse {
|
||||
/// Skill name as recorded by the mutation.
|
||||
name: String,
|
||||
/// Human-readable action performed: `"installed"`, `"updated"`, `"removed"`,
|
||||
/// `"trusted"`, `"no_change"`, etc.
|
||||
outcome: &'static str,
|
||||
/// Resolved install scope: `"project"` or `"global"`.
|
||||
scope: String,
|
||||
/// Display path of the skill package (may be redacted for plugin snapshots).
|
||||
safe_target_path: String,
|
||||
/// Trust advisory note, present only for `"trusted"` outcomes.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
trust_note: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Read-only audit receipt for a single installed skill.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SkillAuditEntry {
|
||||
name: String,
|
||||
safe_display_path: String,
|
||||
source_kind: String,
|
||||
scope: String,
|
||||
digest: SkillAuditDigest,
|
||||
trust: String,
|
||||
integrity: String,
|
||||
available_actions: Vec<String>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SkillAuditDigest {
|
||||
state: String,
|
||||
/// Hex digest value; absent when the digest is unknown.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SkillAuditResponse {
|
||||
/// `true` when multiple owned copies with the same name exist. The
|
||||
/// caller should re-request with an explicit `scope` parameter.
|
||||
ambiguous: bool,
|
||||
skills: Vec<SkillAuditEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DecideApprovalBody {
|
||||
decision: String,
|
||||
@@ -404,6 +499,7 @@ fn default_runtime_capabilities() -> RuntimeCapabilities {
|
||||
thread_goals: true,
|
||||
memory: true,
|
||||
mcp_server_management: true,
|
||||
skill_lifecycle: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -914,6 +1010,15 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
"/v1/apps/mcp/servers/{name}/reconnect",
|
||||
post(reconnect_mcp_server),
|
||||
)
|
||||
.route("/v1/skills/install", post(install_skill_api))
|
||||
.route(
|
||||
"/v1/skills/{name}",
|
||||
post(set_skill_enabled).delete(uninstall_skill_api),
|
||||
)
|
||||
.route("/v1/skills/{name}/update", post(update_skill_api))
|
||||
.route("/v1/skills/{name}/trust", post(trust_skill_api))
|
||||
.route("/v1/skills/{name}/audit", get(audit_skill_api))
|
||||
.route("/v1/apps/mcp/servers", get(list_mcp_servers))
|
||||
.route("/v1/apps/mcp/tools", get(list_mcp_tools))
|
||||
.route(
|
||||
"/v1/automations",
|
||||
@@ -2396,6 +2501,417 @@ async fn set_skill_enabled(
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── Skill lifecycle helpers ────────────────────────────────────────────────
|
||||
|
||||
/// Build a [`crate::skills::mutation::MutationContext`] from the current
|
||||
/// server state. Reads the network policy and installer settings directly
|
||||
/// from the config already held in `state`.
|
||||
fn mutation_context_settings(
|
||||
state: &RuntimeApiState,
|
||||
) -> (
|
||||
crate::network_policy::NetworkPolicy,
|
||||
u64,
|
||||
String,
|
||||
Option<PathBuf>,
|
||||
) {
|
||||
use crate::skills::install::{DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL};
|
||||
let config = state.config.read();
|
||||
let network = config
|
||||
.network
|
||||
.clone()
|
||||
.map(|p| p.into_runtime())
|
||||
.unwrap_or_default();
|
||||
let skills_cfg = config.skills.as_ref();
|
||||
let max_size = skills_cfg
|
||||
.and_then(|s| s.max_install_size_bytes)
|
||||
.unwrap_or(DEFAULT_MAX_SIZE_BYTES);
|
||||
let registry_url = skills_cfg
|
||||
.and_then(|s| s.registry_url.clone())
|
||||
.unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string());
|
||||
let configured_skills_dir = config.skills_dir.as_ref().map(PathBuf::from);
|
||||
(network, max_size, registry_url, configured_skills_dir)
|
||||
}
|
||||
|
||||
fn parse_api_scope(
|
||||
scope: Option<&str>,
|
||||
) -> Result<Option<crate::skills::mutation::SkillTargetScope>, ApiError> {
|
||||
match scope {
|
||||
None => Ok(None),
|
||||
Some("project") => Ok(Some(crate::skills::mutation::SkillTargetScope::Project)),
|
||||
Some("global") => Ok(Some(crate::skills::mutation::SkillTargetScope::Global)),
|
||||
Some(other) => Err(ApiError::bad_request(format!(
|
||||
"invalid scope '{other}'; expected \"project\" or \"global\""
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn receipt_to_response(
|
||||
receipt: &crate::skills::mutation::SkillMutationReceipt,
|
||||
) -> SkillMutationReceiptResponse {
|
||||
use crate::skills::mutation::SkillMutationOutcome;
|
||||
use crate::skills::roots::SkillScope;
|
||||
|
||||
const TRUST_NOTE: &str = "The .trusted marker is advisory and digest-bound; \
|
||||
it records your review intent but does not sandbox or auto-authorize scripts.";
|
||||
|
||||
let outcome: &'static str = match &receipt.outcome {
|
||||
SkillMutationOutcome::Installed => "installed",
|
||||
SkillMutationOutcome::Updated => "updated",
|
||||
SkillMutationOutcome::NoChange => "no_change",
|
||||
SkillMutationOutcome::Removed => "removed",
|
||||
SkillMutationOutcome::Trusted => "trusted",
|
||||
SkillMutationOutcome::Imported => "imported",
|
||||
SkillMutationOutcome::AlreadyPresent => "already_present",
|
||||
// NeedsApproval / NetworkDenied are returned as ApiError::forbidden
|
||||
// before reaching this conversion; they should not appear here.
|
||||
SkillMutationOutcome::NeedsApproval(_) => "needs_approval",
|
||||
SkillMutationOutcome::NetworkDenied(_) => "network_denied",
|
||||
};
|
||||
let scope = match receipt.scope {
|
||||
SkillScope::Project => "project".to_string(),
|
||||
SkillScope::Global => "global".to_string(),
|
||||
SkillScope::Logical => "logical".to_string(),
|
||||
};
|
||||
let trust_note = if receipt.outcome == SkillMutationOutcome::Trusted {
|
||||
Some(TRUST_NOTE)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
SkillMutationReceiptResponse {
|
||||
name: receipt.name.clone(),
|
||||
outcome,
|
||||
scope,
|
||||
safe_target_path: receipt.safe_target_path.clone(),
|
||||
trust_note,
|
||||
}
|
||||
}
|
||||
|
||||
fn outcome_is_policy_error(outcome: &crate::skills::mutation::SkillMutationOutcome) -> bool {
|
||||
matches!(
|
||||
outcome,
|
||||
crate::skills::mutation::SkillMutationOutcome::NeedsApproval(_)
|
||||
| crate::skills::mutation::SkillMutationOutcome::NetworkDenied(_)
|
||||
)
|
||||
}
|
||||
|
||||
fn policy_error_message(outcome: &crate::skills::mutation::SkillMutationOutcome) -> String {
|
||||
match outcome {
|
||||
crate::skills::mutation::SkillMutationOutcome::NeedsApproval(host) => format!(
|
||||
"network access to '{host}' requires explicit approval; \
|
||||
approve the host in your network policy before installing this skill"
|
||||
),
|
||||
crate::skills::mutation::SkillMutationOutcome::NetworkDenied(host) => {
|
||||
format!("network access to '{host}' was denied by the active network policy")
|
||||
}
|
||||
_ => "operation denied by policy".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── POST /v1/skills/install ────────────────────────────────────────────────
|
||||
|
||||
async fn install_skill_api(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Json(req): Json<InstallSkillRequest>,
|
||||
) -> Result<(StatusCode, Json<SkillMutationReceiptResponse>), ApiError> {
|
||||
use crate::skills::install::InstallSource;
|
||||
use crate::skills::mutation::{MutationContext, SkillMutationRequest, SkillTargetScope};
|
||||
|
||||
let source = InstallSource::parse(&req.source)
|
||||
.map_err(|err| ApiError::bad_request(format!("invalid install source: {err}")))?;
|
||||
let target = parse_api_scope(req.scope.as_deref())?.unwrap_or(SkillTargetScope::Global);
|
||||
|
||||
let (network, max_size, registry_url, configured_skills_dir) =
|
||||
mutation_context_settings(&state);
|
||||
let home = crate::config::effective_home_dir();
|
||||
let workspace = state.workspace.clone();
|
||||
|
||||
let receipt = crate::skills::mutation::execute(
|
||||
SkillMutationRequest::InstallRemote { source, target },
|
||||
&MutationContext {
|
||||
workspace: &workspace,
|
||||
home: home.as_deref(),
|
||||
configured_skills_dir: configured_skills_dir.as_deref(),
|
||||
network: &network,
|
||||
max_size,
|
||||
registry_url: ®istry_url,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|err| ApiError::bad_request(format!("install failed: {err:#}")))?;
|
||||
|
||||
if outcome_is_policy_error(&receipt.outcome) {
|
||||
return Err(ApiError::forbidden(policy_error_message(&receipt.outcome)));
|
||||
}
|
||||
|
||||
let status = if receipt.outcome == crate::skills::mutation::SkillMutationOutcome::Installed {
|
||||
StatusCode::CREATED
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
Ok((status, Json(receipt_to_response(&receipt))))
|
||||
}
|
||||
|
||||
// ─── POST /v1/skills/{name}/update ─────────────────────────────────────────
|
||||
|
||||
async fn update_skill_api(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<UpdateSkillRequest>,
|
||||
) -> Result<Json<SkillMutationReceiptResponse>, ApiError> {
|
||||
use crate::skills::mutation::{MutationContext, SkillMutationRequest};
|
||||
|
||||
let scope = parse_api_scope(req.scope.as_deref())?;
|
||||
let (network, max_size, registry_url, configured_skills_dir) =
|
||||
mutation_context_settings(&state);
|
||||
let home = crate::config::effective_home_dir();
|
||||
let workspace = state.workspace.clone();
|
||||
|
||||
let receipt = crate::skills::mutation::execute(
|
||||
SkillMutationRequest::UpdateByName {
|
||||
name: name.clone(),
|
||||
scope,
|
||||
expected_digest: req.expected_digest,
|
||||
},
|
||||
&MutationContext {
|
||||
workspace: &workspace,
|
||||
home: home.as_deref(),
|
||||
configured_skills_dir: configured_skills_dir.as_deref(),
|
||||
network: &network,
|
||||
max_size,
|
||||
registry_url: ®istry_url,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
let msg = err.to_string();
|
||||
if msg.contains("not found") {
|
||||
ApiError::not_found(format!("update failed: {err:#}"))
|
||||
} else if msg.contains("digest") {
|
||||
ApiError::bad_request(format!("update failed: {err:#}"))
|
||||
} else {
|
||||
ApiError::bad_request(format!("update failed: {err:#}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
if outcome_is_policy_error(&receipt.outcome) {
|
||||
return Err(ApiError::forbidden(policy_error_message(&receipt.outcome)));
|
||||
}
|
||||
|
||||
Ok(Json(receipt_to_response(&receipt)))
|
||||
}
|
||||
|
||||
// ─── DELETE /v1/skills/{name} (uninstall) ──────────────────────────────────
|
||||
|
||||
async fn uninstall_skill_api(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(name): Path<String>,
|
||||
Query(query): Query<UninstallSkillQuery>,
|
||||
) -> Result<Json<SkillMutationReceiptResponse>, ApiError> {
|
||||
use crate::skills::mutation::{MutationContext, SkillMutationRequest};
|
||||
|
||||
let scope = parse_api_scope(query.scope.as_deref())?;
|
||||
let (network, max_size, registry_url, configured_skills_dir) =
|
||||
mutation_context_settings(&state);
|
||||
let home = crate::config::effective_home_dir();
|
||||
|
||||
let receipt = crate::skills::mutation::execute_sync(
|
||||
SkillMutationRequest::RemoveByName {
|
||||
name: name.clone(),
|
||||
scope,
|
||||
expected_digest: query.expected_digest,
|
||||
},
|
||||
&MutationContext {
|
||||
workspace: &state.workspace,
|
||||
home: home.as_deref(),
|
||||
configured_skills_dir: configured_skills_dir.as_deref(),
|
||||
network: &network,
|
||||
max_size,
|
||||
registry_url: ®istry_url,
|
||||
},
|
||||
)
|
||||
.map_err(|err| {
|
||||
let msg = err.to_string();
|
||||
if msg.contains("not found") {
|
||||
ApiError::not_found(format!("uninstall failed: {err:#}"))
|
||||
} else {
|
||||
ApiError::bad_request(format!("uninstall failed: {err:#}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Json(receipt_to_response(&receipt)))
|
||||
}
|
||||
|
||||
// ─── POST /v1/skills/{name}/trust ──────────────────────────────────────────
|
||||
|
||||
async fn trust_skill_api(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<TrustSkillRequest>,
|
||||
) -> Result<Json<SkillMutationReceiptResponse>, ApiError> {
|
||||
use crate::skills::mutation::{MutationContext, SkillMutationRequest};
|
||||
|
||||
let scope = parse_api_scope(req.scope.as_deref())?;
|
||||
let (network, max_size, registry_url, configured_skills_dir) =
|
||||
mutation_context_settings(&state);
|
||||
let home = crate::config::effective_home_dir();
|
||||
|
||||
let receipt = crate::skills::mutation::execute_sync(
|
||||
SkillMutationRequest::TrustByName {
|
||||
name: name.clone(),
|
||||
scope,
|
||||
expected_digest: req.expected_digest,
|
||||
},
|
||||
&MutationContext {
|
||||
workspace: &state.workspace,
|
||||
home: home.as_deref(),
|
||||
configured_skills_dir: configured_skills_dir.as_deref(),
|
||||
network: &network,
|
||||
max_size,
|
||||
registry_url: ®istry_url,
|
||||
},
|
||||
)
|
||||
.map_err(|err| {
|
||||
let msg = err.to_string();
|
||||
if msg.contains("not found") {
|
||||
ApiError::not_found(format!("trust failed: {err:#}"))
|
||||
} else {
|
||||
ApiError::bad_request(format!("trust failed: {err:#}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Json(receipt_to_response(&receipt)))
|
||||
}
|
||||
|
||||
// ─── GET /v1/skills/{name}/audit ───────────────────────────────────────────
|
||||
|
||||
async fn audit_skill_api(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(name): Path<String>,
|
||||
Query(query): Query<SkillScopeQuery>,
|
||||
) -> Result<Json<SkillAuditResponse>, ApiError> {
|
||||
use crate::skills::audit::{
|
||||
AuditedSkill, DigestState, IntegrityState, SkillActionKind, SkillAuditMode,
|
||||
SkillAuditWarning, SkillSourceKind, TrustState, scan_with_configured,
|
||||
};
|
||||
use crate::skills::roots::SkillRootKind;
|
||||
|
||||
let scope_filter = parse_api_scope(query.scope.as_deref())?;
|
||||
let home = crate::config::effective_home_dir();
|
||||
let configured_skills_dir = {
|
||||
let config = state.config.read();
|
||||
config.skills_dir.as_ref().map(PathBuf::from)
|
||||
};
|
||||
let canonical = crate::skills::normalize_skill_name_for_lookup(&name);
|
||||
|
||||
let snap = scan_with_configured(
|
||||
&state.workspace,
|
||||
home.as_deref(),
|
||||
configured_skills_dir.as_deref(),
|
||||
SkillAuditMode::Compatible,
|
||||
None,
|
||||
);
|
||||
|
||||
let mut matches: Vec<&AuditedSkill> = snap
|
||||
.skills
|
||||
.iter()
|
||||
.filter(|s| s.id.canonical_name == canonical)
|
||||
.collect();
|
||||
|
||||
if let Some(scope) = scope_filter {
|
||||
let want = match scope {
|
||||
crate::skills::mutation::SkillTargetScope::Project => SkillRootKind::CodeWhaleProject,
|
||||
crate::skills::mutation::SkillTargetScope::Global => SkillRootKind::CodeWhaleGlobal,
|
||||
};
|
||||
matches.retain(|s| s.root.kind == want);
|
||||
}
|
||||
|
||||
if matches.is_empty() {
|
||||
return Err(ApiError::not_found(format!(
|
||||
"skill '{name}' not found in any audited root"
|
||||
)));
|
||||
}
|
||||
|
||||
let ambiguous = matches.len() > 1;
|
||||
let entries = matches
|
||||
.into_iter()
|
||||
.map(|skill| {
|
||||
let source_kind = match skill.source_kind {
|
||||
SkillSourceKind::CodeWhaleManaged => "codewhale_managed",
|
||||
SkillSourceKind::CodeWhaleManual => "codewhale_manual",
|
||||
SkillSourceKind::CompatibleExternal => "compatible_external",
|
||||
SkillSourceKind::BuiltIn => "built_in",
|
||||
SkillSourceKind::ReviewedPluginSnapshot => "reviewed_plugin_snapshot",
|
||||
SkillSourceKind::RegistryCache => "registry_cache",
|
||||
};
|
||||
let scope_str = match skill.root.kind {
|
||||
SkillRootKind::CodeWhaleProject => "project",
|
||||
SkillRootKind::CodeWhaleGlobal => "global",
|
||||
_ => "other",
|
||||
};
|
||||
let digest = match &skill.digest {
|
||||
DigestState::Known(v) => SkillAuditDigest {
|
||||
state: "known".to_string(),
|
||||
value: Some(v.clone()),
|
||||
},
|
||||
DigestState::Unknown(reason) => SkillAuditDigest {
|
||||
state: format!("unknown:{reason:?}").to_ascii_lowercase(),
|
||||
value: None,
|
||||
},
|
||||
};
|
||||
let trust = match &skill.trust {
|
||||
TrustState::TrustedForDigest(_) => "trusted_for_digest",
|
||||
TrustState::TrustStale => "trust_stale",
|
||||
TrustState::LegacyAdvisory => "legacy_advisory",
|
||||
TrustState::Untrusted => "untrusted",
|
||||
TrustState::NotApplicable => "not_applicable",
|
||||
TrustState::Unknown => "unknown",
|
||||
};
|
||||
let integrity = match &skill.integrity {
|
||||
IntegrityState::Healthy => "healthy",
|
||||
IntegrityState::LocalContentDrift => "local_content_drift",
|
||||
IntegrityState::BrokenManagedInstall => "broken_managed_install",
|
||||
IntegrityState::LegacyMetadataUnknown => "legacy_metadata_unknown",
|
||||
IntegrityState::Unknown => "unknown",
|
||||
};
|
||||
let available_actions = skill
|
||||
.available_actions
|
||||
.iter()
|
||||
.map(|a| match a {
|
||||
SkillActionKind::Install => "install",
|
||||
SkillActionKind::Import => "import",
|
||||
SkillActionKind::Update => "update",
|
||||
SkillActionKind::Remove => "remove",
|
||||
SkillActionKind::Trust => "trust",
|
||||
})
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let warnings = skill
|
||||
.warnings
|
||||
.iter()
|
||||
.map(|w| match w {
|
||||
SkillAuditWarning::Message(m) => m.clone(),
|
||||
})
|
||||
.collect();
|
||||
SkillAuditEntry {
|
||||
name: skill.name.clone(),
|
||||
safe_display_path: skill.safe_display_path.clone(),
|
||||
source_kind: source_kind.to_string(),
|
||||
scope: scope_str.to_string(),
|
||||
digest,
|
||||
trust: trust.to_string(),
|
||||
integrity: integrity.to_string(),
|
||||
available_actions,
|
||||
warnings,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(SkillAuditResponse {
|
||||
ambiguous,
|
||||
skills: entries,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn decide_approval(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(approval_id): Path<String>,
|
||||
@@ -5746,6 +6262,13 @@ impl ApiError {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
|
||||
@@ -8031,6 +8031,44 @@ async fn fleet_receipt_api_list_and_get_round_trip() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) =
|
||||
spawn_test_server_with_root_token_mobile_workspace(
|
||||
root.clone(),
|
||||
// ─── Skill lifecycle API tests ──────────────────────────────────────────────
|
||||
|
||||
/// Create a minimal skill package under `root_dir/.codewhale/skills/<name>`.
|
||||
/// Returns the skill dir path plus a digest that can be used in requests.
|
||||
fn create_managed_skill(root_dir: &std::path::Path, name: &str) -> Result<(PathBuf, String)> {
|
||||
let skill_dir = root_dir.join(".codewhale").join("skills").join(name);
|
||||
fs::create_dir_all(&skill_dir)?;
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
format!("---\nname: {name}\ndescription: test skill\n---\nbody\n"),
|
||||
)?;
|
||||
// Write an `.installed-from` marker so the mutation module considers it
|
||||
// managed (and therefore eligible for update/remove/trust).
|
||||
let digest = crate::skills::audit::compute_package_digest(&skill_dir).expect("package digest");
|
||||
crate::skills::install::write_installed_from_v2(
|
||||
&skill_dir,
|
||||
&format!("github:test/{name}"),
|
||||
None,
|
||||
"sha256:test",
|
||||
&digest,
|
||||
name,
|
||||
)?;
|
||||
Ok((skill_dir, digest))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lifecycle_uninstall_removes_installed_skill() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path().join("runtime");
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
let sessions_dir = root.join("sessions");
|
||||
fs::create_dir_all(&root)?;
|
||||
|
||||
create_managed_skill(&workspace, "hello")?;
|
||||
|
||||
let Some((addr, _runtime_threads, handle)) =
|
||||
spawn_test_server_with_root_token_mobile_workspace(
|
||||
root,
|
||||
sessions_dir,
|
||||
None,
|
||||
false,
|
||||
@@ -8104,12 +8142,37 @@ async fn fleet_receipt_api_list_and_get_round_trip() -> Result<()> {
|
||||
.get(format!(
|
||||
"http://{addr}/v1/fleet/runs/{}/receipts/task-receipt/evidence",
|
||||
run_id.0 ))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
// Confirm skill is visible.
|
||||
let list: serde_json::Value = client
|
||||
.get(format!("http://{addr}/v1/skills"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(completed["status"].as_str(), Some("complete"));
|
||||
assert!(
|
||||
list["skills"]
|
||||
.as_array()
|
||||
.is_some_and(|s| s.iter().any(|sk| sk["name"] == "hello")),
|
||||
"hello skill must appear in GET /v1/skills before uninstall"
|
||||
);
|
||||
|
||||
// Uninstall it.
|
||||
let resp = client
|
||||
.delete(format!("http://{addr}/v1/skills/hello"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?;
|
||||
assert_eq!(resp["outcome"], "removed");
|
||||
assert_eq!(resp["name"], "hello");
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
@@ -8117,6 +8180,7 @@ async fn fleet_receipt_api_list_and_get_round_trip() -> Result<()> {
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_goal_on_unknown_thread_returns_404() -> Result<()> {
|
||||
async fn skill_lifecycle_uninstall_404s_for_unknown_skill() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -8134,6 +8198,10 @@ async fn thread_goal_on_unknown_thread_returns_404() -> Result<()> {
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(put_resp.status(), 404);
|
||||
.delete(format!("http://{addr}/v1/skills/no-such-skill"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
@@ -8141,12 +8209,325 @@ async fn thread_goal_on_unknown_thread_returns_404() -> Result<()> {
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_info_advertises_thread_goals_capability() -> Result<()> {
|
||||
async fn skill_lifecycle_uninstall_rejects_invalid_scope() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let resp = client
|
||||
.delete(format!("http://{addr}/v1/skills/hello?scope=badscope"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lifecycle_trust_marks_installed_skill() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path().join("runtime");
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
let sessions_dir = root.join("sessions");
|
||||
fs::create_dir_all(&root)?;
|
||||
|
||||
create_managed_skill(&workspace, "trustme")?;
|
||||
|
||||
let Some((addr, _runtime_threads, handle)) =
|
||||
spawn_test_server_with_root_token_mobile_workspace(
|
||||
root,
|
||||
sessions_dir,
|
||||
None,
|
||||
false,
|
||||
workspace,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let resp = client
|
||||
.post(format!("http://{addr}/v1/skills/trustme/trust"))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<serde_json::Value>()
|
||||
.await?;
|
||||
assert_eq!(resp["outcome"], "trusted");
|
||||
assert_eq!(resp["name"], "trustme");
|
||||
// The trust note must be present and must carry the advisory wording.
|
||||
let trust_note = resp["trust_note"].as_str().expect("trust_note");
|
||||
assert!(
|
||||
trust_note.contains("advisory") && trust_note.contains("digest-bound"),
|
||||
"trust_note must preserve advisory and digest-bound wording"
|
||||
);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lifecycle_trust_404s_for_unknown_skill() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let resp = client
|
||||
.post(format!("http://{addr}/v1/skills/no-such/trust"))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lifecycle_trust_rejects_invalid_scope() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let resp = client
|
||||
.post(format!("http://{addr}/v1/skills/hello/trust"))
|
||||
.json(&json!({ "scope": "invalid" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lifecycle_trust_rejects_digest_drift() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path().join("runtime");
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
let sessions_dir = root.join("sessions");
|
||||
fs::create_dir_all(&root)?;
|
||||
|
||||
create_managed_skill(&workspace, "drifted")?;
|
||||
|
||||
let Some((addr, _runtime_threads, handle)) =
|
||||
spawn_test_server_with_root_token_mobile_workspace(
|
||||
root,
|
||||
sessions_dir,
|
||||
None,
|
||||
false,
|
||||
workspace,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
// Pass a deliberately wrong digest to confirm drift detection.
|
||||
let resp = client
|
||||
.post(format!("http://{addr}/v1/skills/drifted/trust"))
|
||||
.json(&json!({ "expected_digest": "sha256:definitely_wrong_digest" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lifecycle_audit_returns_receipt_for_installed_skill() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path().join("runtime");
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
let sessions_dir = root.join("sessions");
|
||||
fs::create_dir_all(&root)?;
|
||||
|
||||
create_managed_skill(&workspace, "auditable")?;
|
||||
|
||||
let Some((addr, _runtime_threads, handle)) =
|
||||
spawn_test_server_with_root_token_mobile_workspace(
|
||||
root,
|
||||
sessions_dir,
|
||||
None,
|
||||
false,
|
||||
workspace,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let resp: serde_json::Value = client
|
||||
.get(format!("http://{addr}/v1/skills/auditable/audit"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(resp["ambiguous"], false);
|
||||
let skills = resp["skills"].as_array().expect("skills array");
|
||||
assert_eq!(skills.len(), 1);
|
||||
let entry = &skills[0];
|
||||
assert_eq!(entry["name"], "auditable");
|
||||
assert_eq!(entry["source_kind"], "codewhale_managed");
|
||||
// Digest must be known for a properly written managed skill.
|
||||
assert_eq!(entry["digest"]["state"], "known");
|
||||
assert!(
|
||||
entry["digest"]["value"].as_str().is_some(),
|
||||
"digest.value must be present when state=known"
|
||||
);
|
||||
// Trust state: untrusted because we haven't run trust.
|
||||
assert_eq!(entry["trust"], "untrusted");
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lifecycle_audit_404s_for_unknown_skill() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let resp = client
|
||||
.get(format!("http://{addr}/v1/skills/no-such-skill/audit"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lifecycle_audit_rejects_invalid_scope() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let resp = client
|
||||
.get(format!("http://{addr}/v1/skills/hello/audit?scope=nope"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lifecycle_install_rejects_empty_source() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let resp = client
|
||||
.post(format!("http://{addr}/v1/skills/install"))
|
||||
.json(&json!({ "source": " " }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lifecycle_install_rejects_invalid_scope() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let resp = client
|
||||
.post(format!("http://{addr}/v1/skills/install"))
|
||||
.json(&json!({ "source": "github:owner/repo", "scope": "badscope" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lifecycle_update_rejects_invalid_scope() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let resp = client
|
||||
.post(format!("http://{addr}/v1/skills/hello/update"))
|
||||
.json(&json!({ "scope": "wrong" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lifecycle_endpoints_require_auth_when_token_is_set() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("codewhale-skill-auth-{}", Uuid::new_v4()));
|
||||
let sessions_dir = root.join("sessions");
|
||||
let token = "skill-lifecycle-test-token".to_string();
|
||||
let Some((addr, _runtime_threads, handle)) =
|
||||
spawn_test_server_with_root_and_token(root, sessions_dir, Some(token)).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
// All skill lifecycle endpoints must require auth.
|
||||
for (method, path) in &[
|
||||
("GET", "/v1/skills/any/audit"),
|
||||
("POST", "/v1/skills/install"),
|
||||
("POST", "/v1/skills/any/update"),
|
||||
("DELETE", "/v1/skills/any"),
|
||||
("POST", "/v1/skills/any/trust"),
|
||||
] {
|
||||
let resp = client
|
||||
.request(
|
||||
reqwest::Method::from_bytes(method.as_bytes()).unwrap(),
|
||||
format!("http://{addr}{path}"),
|
||||
)
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"{method} {path} must require auth"
|
||||
);
|
||||
}
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_lifecycle_runtime_info_advertises_skill_lifecycle_capability() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
>>>>>>> 3130b49a6 (feat: expose bounded memory inspection and lifecycle controls via Runtime API)
|
||||
>>>>>>> 9864e2d71 (feat: add skill lifecycle routes to runtime API (install, update, uninstall, trust, audit))
|
||||
let info: serde_json::Value = client
|
||||
.get(format!("http://{addr}/v1/runtime/info"))
|
||||
.send()
|
||||
@@ -8155,6 +8536,7 @@ async fn runtime_info_advertises_thread_goals_capability() -> Result<()> {
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
<<<<<<< HEAD
|
||||
<<<<<<< HEAD
|
||||
info["capabilities"]["thread_goals"].as_bool(),
|
||||
Some(true),
|
||||
@@ -8348,11 +8730,15 @@ async fn memory_summary_is_redacted_to_max_chars() -> Result<()> {
|
||||
assert!(
|
||||
summary.ends_with("…") || summary.chars().count() <= 300,
|
||||
"overlong text must be truncated with an ellipsis"
|
||||
=======
|
||||
info["capabilities"]["skill_lifecycle"], true,
|
||||
"runtime/info must advertise skill_lifecycle capability"
|
||||
);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
<<<<<<< HEAD
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_clear_removes_global_scope() -> Result<()> {
|
||||
@@ -8496,3 +8882,5 @@ async fn memory_search_query_filters_results() -> Result<()> {
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
=======
|
||||
>>>>>>> 9864e2d71 (feat: add skill lifecycle routes to runtime API (install, update, uninstall, trust, audit))
|
||||
|
||||
Reference in New Issue
Block a user