Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/channels/wasm/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@
//!
//! Encapsulates the logic for loading WASM channels, registering their
//! webhook routes, and injecting credentials from the secrets store.
//!
//! # Ownership model
//!
//! Boot-time secret lookups use `config.owner_id` because channels are
//! **instance-level resources** — they run as the instance operator, not as
//! individual users. This is intentional and distinct from tool-level
//! credential resolution, which is scoped to the calling user's `user_id`.
//!
//! See `docs/superpowers/specs/2026-04-01-ownership-model-design.md`.

use std::collections::HashSet;
use std::sync::Arc;
Expand Down Expand Up @@ -179,6 +188,7 @@ async fn register_channel(
let sig_key_secret_name = loaded.signature_key_secret_name();
let hmac_secret_name = loaded.hmac_secret_name();

// Channel-level secrets: owner_id is correct — channels are instance resources.
let webhook_secret = if let Some(secrets) = secrets_store {
secrets
.get_decrypted(&config.owner_id, &secret_name)
Expand Down
75 changes: 67 additions & 8 deletions src/channels/wasm/wrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,14 @@ impl WasmChannel {
}

/// Load broadcast metadata from settings store on startup.
///
/// # Legacy migration (remove after ownership model rollout — see #2069)
///
/// If no metadata is found under `self.owner_scope_id`, a second lookup
/// under `"default"` is attempted for backward compatibility with instances
/// that stored broadcast metadata before the ownership model migration.
/// Remove this fallback once all deployments have run the
/// `migrate_default_owner` bootstrap step and restarted at least once.
Comment thread
henrypark133 marked this conversation as resolved.
Resolved
async fn load_broadcast_metadata(&self) {
if let Some(ref store) = self.settings_store {
match store
Expand All @@ -1046,6 +1054,7 @@ impl WasmChannel {
);
}
Ok(_) => {
// LEGACY MIGRATION: remove after ownership model rollout — see #2069
if self.owner_scope_id != "default" {
match store
.get_setting("default", &self.broadcast_metadata_key())
Expand Down Expand Up @@ -1163,9 +1172,12 @@ impl WasmChannel {
);
let queue_path = websocket_queue_path(&channel_name);
let processing_queue_path = websocket_processing_queue_path(&channel_name);
let identify_payload =
resolve_websocket_identify_message(&config, websocket_secrets_store.as_deref())
.await;
let identify_payload = resolve_websocket_identify_message(
&config,
websocket_secrets_store.as_deref(),
&owner_scope_id,
)
.await;
let mut session_state = WebsocketSessionState::new(identify_payload.as_deref());

'reconnect: loop {
Expand Down Expand Up @@ -3120,14 +3132,19 @@ fn websocket_processing_queue_path(channel_name: &str) -> String {
format!("channels/{channel_name}/{WEBSOCKET_EVENT_PROCESSING_QUEUE_RELATIVE_PATH}")
}

async fn resolve_websocket_identify_message(
pub(crate) async fn resolve_websocket_identify_message(
config: &WebsocketRuntimeConfig,
store: Option<&(dyn SecretsStore + Send + Sync)>,
owner_scope_id: &str,
) -> Option<String> {
let identify = config.identify.clone()?;
Comment thread
henrypark133 marked this conversation as resolved.
Resolved
let secret_name = config.identify_secret_name.as_ref()?;
let store = store?;
let secret = store.get_decrypted("default", secret_name).await.ok()?;
// Channel runtime secrets are instance-owned, resolved under the channel's owner scope.
let secret = store
.get_decrypted(owner_scope_id, secret_name)
.await
.ok()?;
build_websocket_identify_message(&identify, secret.expose())
}

Expand Down Expand Up @@ -4180,9 +4197,9 @@ mod tests {
WebsocketRuntimeConfig, build_discord_gateway_presence_update,
build_websocket_identify_message, build_websocket_resume_message,
discord_gateway_presence_status, drain_guest_logs, parse_websocket_invalid_session,
parse_websocket_ready_session, should_warn_on_heartbeat_interval,
uses_owner_broadcast_target, websocket_heartbeat_sleep_duration,
websocket_reconnect_backoff,
parse_websocket_ready_session, resolve_websocket_identify_message,
should_warn_on_heartbeat_interval, uses_owner_broadcast_target,
websocket_heartbeat_sleep_duration, websocket_reconnect_backoff,
};
use crate::pairing::PairingStore;
use crate::testing::credentials::TEST_TELEGRAM_BOT_TOKEN;
Expand Down Expand Up @@ -4291,6 +4308,48 @@ mod tests {
assert_eq!(json["d"]["intents"], serde_json::json!(513));
}

/// Regression test for #2069: websocket identify must use owner_scope_id,
/// not hardcoded "default".
#[tokio::test]
async fn test_resolve_websocket_identify_message_uses_owner_scope() {
use crate::secrets::SecretsStore;
use crate::testing::credentials::test_secrets_store;

let store = test_secrets_store();

// Store secret under a specific owner, NOT under "default"
store
.create(
"owner_42",
crate::secrets::CreateSecretParams::new("discord_bot_token", "real_bot_token"),
)
.await
.expect("store token"); // safety: test code only

let config = WebsocketRuntimeConfig {
url: "wss://gateway.discord.gg/?v=10&encoding=json".to_string(),
connect_on_start: true,
identify: Some(serde_json::json!({
"intents": 513,
"properties": { "os": "linux", "browser": "ironclaw", "device": "ironclaw" }
})),
identify_secret_name: Some("discord_bot_token".to_string()),
};

// Should find the secret under "owner_42"
let payload = resolve_websocket_identify_message(&config, Some(&store), "owner_42").await;
assert!(payload.is_some(), "should resolve from owner scope"); // safety: test code only
let json: serde_json::Value = serde_json::from_str(payload.as_ref().unwrap()).unwrap(); // safety: test code only
assert_eq!(json["d"]["token"], serde_json::json!("real_bot_token"));

// Must NOT find the secret under "default"
let no_payload = resolve_websocket_identify_message(&config, Some(&store), "default").await;
assert!(
no_payload.is_none(),
"default scope must not find owner_42's secret"
);
}

#[test]
fn test_websocket_runtime_config_requires_allowlisted_host() {
let tool_capabilities = ToolCapabilities {
Expand Down
Loading
Loading