Skip to content
Merged
5 changes: 4 additions & 1 deletion crates/ironclaw_capabilities/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ fn dispatch_error_kind(error: &DispatchError) -> DispatchFailureKind {

fn dispatch_error_safe_summary(error: &DispatchError) -> Option<String> {
match error {
DispatchError::FirstParty { safe_summary, .. } => safe_summary.clone(),
DispatchError::FirstParty { safe_summary, .. }
| DispatchError::Wasm { safe_summary, .. } => safe_summary.clone(),
Comment on lines 152 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover populated WASM summaries at the conversion boundary.

Line 155 is the new forwarding path, but the tests only exercise safe_summary: None. Assert that a populated sanitized WASM summary reaches CapabilityInvocationError::Dispatch; otherwise this model-visible contract can regress unnoticed.

Proposed test
+    #[test]
+    fn from_dispatch_error_preserves_wasm_safe_summary() {
+        let err = CapabilityInvocationError::from(DispatchError::Wasm {
+            kind: RuntimeDispatchErrorKind::InputEncode,
+            safe_summary: Some("provider error code: channel_not_found".to_string()),
+        });
+
+        match err {
+            CapabilityInvocationError::Dispatch { safe_summary, .. } => {
+                assert_eq!(
+                    safe_summary.as_deref(),
+                    Some("provider error code: channel_not_found")
+                );
+            }
+            other => panic!("expected Dispatch variant, got {other:?}"),
+        }
+    }

As per path instructions, “Test through the caller,” so cover the populated value at this conversion boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_capabilities/src/error.rs` around lines 152 - 155, Extend the
conversion-boundary test that exercises dispatch errors through the caller of
dispatch_error_safe_summary to use a WASM error with a populated sanitized
safe_summary, then assert CapabilityInvocationError::Dispatch preserves and
exposes that exact summary. Keep the existing None case as appropriate and
verify the model-visible error contract rather than testing the helper in
isolation.

Source: Path instructions

_ => None,
}
}
Expand Down Expand Up @@ -242,6 +243,7 @@ mod tests {
fn dispatch_error_kind_forwards_wasm_runtime_kind_as_str() {
let kind = dispatch_error_kind(&DispatchError::Wasm {
kind: RuntimeDispatchErrorKind::Memory,
safe_summary: None,
});
assert_eq!(kind.as_str(), "Memory");
}
Expand Down Expand Up @@ -272,6 +274,7 @@ mod tests {
fn from_dispatch_error_preserves_redacted_runtime_kind() {
let err = CapabilityInvocationError::from(DispatchError::Wasm {
kind: RuntimeDispatchErrorKind::Guest,
safe_summary: None,
});
match err {
CapabilityInvocationError::Dispatch { kind, .. } => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ impl RuntimeAdapter<LocalFilesystem, InMemoryResourceGovernor> for RecordingRunt
.reserve(request.scope, request.estimate)
.map_err(|_| DispatchError::Wasm {
kind: RuntimeDispatchErrorKind::Resource,
safe_summary: None,
})?,
};
let output_bytes = usage.output_bytes;
Expand All @@ -409,6 +410,7 @@ impl RuntimeAdapter<LocalFilesystem, InMemoryResourceGovernor> for RecordingRunt
.reconcile(reservation.id, usage.clone())
.map_err(|_| DispatchError::Wasm {
kind: RuntimeDispatchErrorKind::Resource,
safe_summary: None,
})?;
Ok(RuntimeAdapterResult {
output,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1401,6 +1401,7 @@ impl CapabilityDispatcher for FailingDispatcher {
) -> Result<CapabilityDispatchResult, DispatchError> {
Err(DispatchError::Wasm {
kind: RuntimeDispatchErrorKind::Backend,
safe_summary: None,
})
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/ironclaw_conversations/src/inbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,7 @@ mod tests {
agent_id: Some(agent()),
project_id: Some(project()),
prompt: "test trigger prompt".to_string(),
delivery_target: None,
};
let content_ref =
TriggerInboundContentRef::new("content:test-trigger-creator").expect("content ref");
Expand Down
5 changes: 4 additions & 1 deletion crates/ironclaw_dispatcher/tests/dispatch_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,10 @@ fn dispatch_error_for_runtime(
kind: RuntimeDispatchErrorKind,
) -> DispatchError {
match runtime {
RuntimeKind::Wasm => DispatchError::Wasm { kind },
RuntimeKind::Wasm => DispatchError::Wasm {
kind,
safe_summary: None,
},
RuntimeKind::Script => DispatchError::Script { kind },
RuntimeKind::Mcp => DispatchError::Mcp { kind },
RuntimeKind::FirstParty | RuntimeKind::System => DispatchError::UnsupportedRuntime {
Expand Down
5 changes: 4 additions & 1 deletion crates/ironclaw_dispatcher/tests/event_dispatch_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,10 @@ fn dispatch_error_for_runtime(
kind: RuntimeDispatchErrorKind,
) -> DispatchError {
match runtime {
RuntimeKind::Wasm => DispatchError::Wasm { kind },
RuntimeKind::Wasm => DispatchError::Wasm {
kind,
safe_summary: None,
},
RuntimeKind::Script => DispatchError::Script { kind },
RuntimeKind::Mcp => DispatchError::Mcp { kind },
RuntimeKind::FirstParty | RuntimeKind::System => DispatchError::UnsupportedRuntime {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,10 @@ fn dispatch_error_for_runtime(
kind: RuntimeDispatchErrorKind,
) -> DispatchError {
match runtime {
RuntimeKind::Wasm => DispatchError::Wasm { kind },
RuntimeKind::Wasm => DispatchError::Wasm {
kind,
safe_summary: None,
},
RuntimeKind::Script => DispatchError::Script { kind },
RuntimeKind::Mcp => DispatchError::Mcp { kind },
RuntimeKind::FirstParty | RuntimeKind::System => DispatchError::UnsupportedRuntime {
Expand Down
5 changes: 4 additions & 1 deletion crates/ironclaw_dispatcher/tests/vertical_slice_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,10 @@ fn dispatch_error_for_runtime(
kind: RuntimeDispatchErrorKind,
) -> DispatchError {
match runtime {
RuntimeKind::Wasm => DispatchError::Wasm { kind },
RuntimeKind::Wasm => DispatchError::Wasm {
kind,
safe_summary: None,
},
RuntimeKind::Script => DispatchError::Script { kind },
RuntimeKind::Mcp => DispatchError::Mcp { kind },
RuntimeKind::FirstParty | RuntimeKind::System => DispatchError::UnsupportedRuntime {
Expand Down
44 changes: 36 additions & 8 deletions crates/ironclaw_first_party_extensions/assets/slack/manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ module = "wasm/slack_user_tool.wasm"

[[capabilities]]
id = "slack.search_messages"
description = "Search across all messages you can see (your DMs, group DMs, and channels you belong to). Requires the search:read user scope."
description = "Search across all messages you can see (your DMs, group DMs, and channels you belong to). Matches carry the author's resolved user_display_name, and mentions inside match text are already resolved to @display-names (use display names in user-facing output); threaded hits carry thread_ts (follow up with slack.get_thread_replies). When total exceeds count, fetch more with page. Requires the search:read user scope. Raw Slack ids (U…/W…/C…/D…) are for tool calls only — never include one in a reply, not even in parentheses; refer to people and channels by name."
effects = ["dispatch_capability", "network", "use_secret"]
runtime_credentials = [
{ handle = "slack_user_token", source = { type = "product_auth_account", provider = "slack_personal", setup = { kind = "oauth", scopes = ["search:read", "channels:history", "groups:history", "im:history", "mpim:history", "channels:read", "groups:read", "im:read", "mpim:read", "users:read"] } }, provider_scopes = ["search:read", "channels:history", "groups:history", "im:history", "mpim:history", "channels:read", "groups:read", "im:read", "mpim:read", "users:read"], audience = { scheme = "https", host_pattern = "slack.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } },
Expand All @@ -32,49 +32,77 @@ required_host_ports = ["host.runtime.http_egress"]

[[capabilities]]
id = "slack.list_conversations"
description = "List channels, private channels, DMs (im), and group DMs (mpim) you belong to. Use this to discover DM conversation IDs."
description = "List channels, private channels, DMs (im), and group DMs (mpim) visible to you — not only ones you belong to; each channel's is_member marks membership. Use this to discover DM conversation IDs; DM entries include the counterpart's resolved user_display_name. Results page: pass the previous call's next_cursor as cursor until next_cursor is absent. Raw Slack ids (U…/W…/C…/D…) are for tool calls only — never include one in a reply, not even in parentheses; refer to people and channels by name."
effects = ["dispatch_capability", "network", "use_secret"]
runtime_credentials = [
{ handle = "slack_user_token", source = { type = "product_auth_account", provider = "slack_personal", setup = { kind = "oauth", scopes = ["search:read", "channels:history", "groups:history", "im:history", "mpim:history", "channels:read", "groups:read", "im:read", "mpim:read", "users:read"] } }, provider_scopes = ["search:read", "channels:history", "groups:history", "im:history", "mpim:history", "channels:read", "groups:read", "im:read", "mpim:read", "users:read"], audience = { scheme = "https", host_pattern = "slack.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } },
]
default_permission = "ask"
visibility = "model"
input_schema_ref = "schemas/slack/list_conversations.input.v1.json"
output_schema_ref = "schemas/slack/raw_output.v1.json"
output_schema_ref = "schemas/slack/list_conversations.output.v1.json"
prompt_doc_ref = "prompts/slack/list_conversations.md"
required_host_ports = ["host.runtime.http_egress"]

[[capabilities]]
id = "slack.get_conversation_history"
description = "Read message history from any channel or DM you can see, identified by its conversation ID."
description = "Read message history from any channel or DM you can see, identified by its conversation ID. Results are newest-first, at most 999 per call (limit above 999 is clamped; Slack rejects 1000); when has_more is true, fetch older messages by calling again with latest set to the oldest returned ts. Messages include the author's raw user id plus a resolved user_display_name, and mentions inside message text are already resolved to @display-names \u2014 use display names in user-facing output, never raw U\u2026/W\u2026 ids. Messages with is_current_user=true were written by the connected account (result-level current_user_id): they are the requesting user's own words \u2014 attribute them to the requester, not a third party. Thread replies are NOT included \u2014 a parent's reply_count > 0 means there is a thread; fetch it with slack.get_thread_replies. Raw Slack ids (U…/W…/C…/D…) are for tool calls only — never include one in a reply, not even in parentheses; refer to people and channels by name."
effects = ["dispatch_capability", "network", "use_secret"]
runtime_credentials = [
{ handle = "slack_user_token", source = { type = "product_auth_account", provider = "slack_personal", setup = { kind = "oauth", scopes = ["search:read", "channels:history", "groups:history", "im:history", "mpim:history", "channels:read", "groups:read", "im:read", "mpim:read", "users:read"] } }, provider_scopes = ["search:read", "channels:history", "groups:history", "im:history", "mpim:history", "channels:read", "groups:read", "im:read", "mpim:read", "users:read"], audience = { scheme = "https", host_pattern = "slack.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } },
]
default_permission = "ask"
visibility = "model"
input_schema_ref = "schemas/slack/get_conversation_history.input.v1.json"
output_schema_ref = "schemas/slack/raw_output.v1.json"
output_schema_ref = "schemas/slack/get_conversation_history.output.v1.json"
prompt_doc_ref = "prompts/slack/get_conversation_history.md"
required_host_ports = ["host.runtime.http_egress"]

[[capabilities]]
id = "slack.get_thread_replies"
description = "Read the replies of one thread (channel + the parent message's thread_ts). Conversation history only shows thread parents (reply_count), never the replies — use this whenever a thread's content matters. Same output shape as history: resolved user_display_name per message, is_current_user marking, and current_user_id. Raw Slack ids (U…/W…/C…/D…) are for tool calls only — never include one in a reply, not even in parentheses; refer to people and channels by name."
effects = ["dispatch_capability", "network", "use_secret"]
runtime_credentials = [
{ handle = "slack_user_token", source = { type = "product_auth_account", provider = "slack_personal", setup = { kind = "oauth", scopes = ["search:read", "channels:history", "groups:history", "im:history", "mpim:history", "channels:read", "groups:read", "im:read", "mpim:read", "users:read"] } }, provider_scopes = ["search:read", "channels:history", "groups:history", "im:history", "mpim:history", "channels:read", "groups:read", "im:read", "mpim:read", "users:read"], audience = { scheme = "https", host_pattern = "slack.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } },
]
default_permission = "ask"
visibility = "model"
input_schema_ref = "schemas/slack/get_thread_replies.input.v1.json"
output_schema_ref = "schemas/slack/get_thread_replies.output.v1.json"
prompt_doc_ref = "prompts/slack/get_thread_replies.md"
required_host_ports = ["host.runtime.http_egress"]

[[capabilities]]
id = "slack.get_user_info"
description = "Get information about a Slack user (name, real name)."
description = "Get information about a Slack user (name, real name); includes presence-relevant profile fields: status text/emoji, timezone, and title. History and DM-list outputs already carry resolved display names; use this for extra fields on a specific user, e.g. before answering whether someone is away or what time it is for them. Raw Slack ids (U…/W…/C…/D…) are for tool calls only — never include one in a reply, not even in parentheses; refer to people and channels by name."
effects = ["dispatch_capability", "network", "use_secret"]
runtime_credentials = [
{ handle = "slack_user_token", source = { type = "product_auth_account", provider = "slack_personal", setup = { kind = "oauth", scopes = ["search:read", "channels:history", "groups:history", "im:history", "mpim:history", "channels:read", "groups:read", "im:read", "mpim:read", "users:read"] } }, provider_scopes = ["search:read", "channels:history", "groups:history", "im:history", "mpim:history", "channels:read", "groups:read", "im:read", "mpim:read", "users:read"], audience = { scheme = "https", host_pattern = "slack.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } },
]
default_permission = "ask"
visibility = "model"
input_schema_ref = "schemas/slack/get_user_info.input.v1.json"
output_schema_ref = "schemas/slack/raw_output.v1.json"
output_schema_ref = "schemas/slack/get_user_info.output.v1.json"
prompt_doc_ref = "prompts/slack/get_user_info.md"
required_host_ports = ["host.runtime.http_egress"]

[[capabilities]]
id = "slack.whoami"
description = "Resolve who the connected Slack account is: the user id (and display name when resolvable) that search/history/DM results belong to. Call this before answering anything that depends on which messages are the requester's own. Takes no parameters. Raw Slack ids (U…/W…/C…/D…) are for tool calls only — never include one in a reply, not even in parentheses; refer to people and channels by name."
effects = ["dispatch_capability", "network", "use_secret"]
runtime_credentials = [
{ handle = "slack_user_token", source = { type = "product_auth_account", provider = "slack_personal", setup = { kind = "oauth", scopes = ["search:read", "channels:history", "groups:history", "im:history", "mpim:history", "channels:read", "groups:read", "im:read", "mpim:read", "users:read"] } }, provider_scopes = ["search:read", "channels:history", "groups:history", "im:history", "mpim:history", "channels:read", "groups:read", "im:read", "mpim:read", "users:read"], audience = { scheme = "https", host_pattern = "slack.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } },
]
default_permission = "ask"
visibility = "model"
input_schema_ref = "schemas/slack/whoami.input.v1.json"
output_schema_ref = "schemas/slack/whoami.output.v1.json"
prompt_doc_ref = "prompts/slack/whoami.md"
required_host_ports = ["host.runtime.http_egress"]

[[capabilities]]
id = "slack.send_message"
description = "Send a message as you to a channel or DM. Requires the chat:write user scope; the message appears to come from your account."
description = "Send a message as you to a channel or DM; it appears to come from your account and requires the chat:write user scope. Use it when messaging someone or posting somewhere is the task the user asked for. The run's final reply (including routine/trigger results) is delivered automatically to the configured outbound delivery target. Do not use this to deliver your reply or a routine/trigger result — it would arrive twice. To notify someone in the text, use the mention encoding <@U…> with their real user id (e.g. <@U0123ABCD>); a plain @name does not notify anyone."
effects = ["dispatch_capability", "network", "use_secret", "external_write"]
runtime_credentials = [
{ handle = "slack_user_token", source = { type = "product_auth_account", provider = "slack_personal", setup = { kind = "oauth", scopes = ["search:read", "channels:history", "groups:history", "im:history", "mpim:history", "channels:read", "groups:read", "im:read", "mpim:read", "users:read", "chat:write"] } }, provider_scopes = ["search:read", "channels:history", "groups:history", "im:history", "mpim:history", "channels:read", "groups:read", "im:read", "mpim:read", "users:read", "chat:write"], audience = { scheme = "https", host_pattern = "slack.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Read the replies of one Slack thread via conversations.replies (channel + the parent message's thread_ts). Conversation history only carries thread parents; use this to read the thread content itself.

The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field.
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Send a message as you to a channel or DM. The message appears to come from your account.

Use it when messaging someone or posting somewhere is the task the user asked for. The run's final reply (including routine/trigger results) is delivered to the requesting user automatically; do not use this capability to deliver your reply or a routine/trigger result back to them — it would arrive twice.

The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Resolve who the connected Slack account is (auth.test plus a best-effort users.info display-name lookup).

The host selects this operation from the capability id. Provide only the parameters described by the input schema; do not include an action field.
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Slack get_conversation_history",
"description": "Read message history from a channel or DM.",
"description": "Read message history from a channel or DM. Results are newest-first; when the result's has_more is true, fetch older messages by calling again with latest set to the oldest returned ts.",
"type": "object",
"required": ["channel"],
"properties": {
"channel": { "type": "string", "description": "Conversation ID (C... for a channel, D... for a DM)." },
"limit": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Maximum messages to return (default 50)." },
"latest": { "type": "string", "description": "Only return messages before this timestamp (pagination cursor)." },
"limit": { "type": "integer", "minimum": 1, "maximum": 999, "description": "Maximum messages to return (default 50, max 999 — Slack rejects 1000)." },
"latest": { "type": "string", "description": "Only return messages before this timestamp. For older pages, pass the oldest returned ts." },
"oldest": { "type": "string", "description": "Only return messages after this timestamp." }
},
"additionalProperties": false
Expand Down
Loading
Loading