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:
copilot-swe-agent[bot]
2026-08-03 02:00:09 +00:00
committed by CodeWhale Bot
parent 5c228c5bdb
commit 5dcd264640
3 changed files with 916 additions and 0 deletions
+5
View File
@@ -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();
+523
View File
@@ -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: &registry_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: &registry_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: &registry_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: &registry_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 {
+388
View File
@@ -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))