Refactor owner scope across channels and fix default routing fallback (#1151)

* refactor: add explicit owner scope across channels

* fix: tighten routine owner target routing

* fix: address owner scope review feedback

* Fix owner-scope onboarding and event trigger isolation

* Tighten routing fallback and wizard owner validation

* fix: address owner-scope follow-up review

* fix: tighten owner-scope follow-up details

* fix: import Channel trait in telegram test

* fix: normalize http webhook sender ids

* fix: address remaining owner-scope review issues

* fix: reconcile config rebase fallout

* fix: reconcile extension manager rebase drift

* fix: address current copilot review regressions

* fix: restore clippy matrix after rebase
This commit is contained in:
Henry Park
2026-03-16 13:31:03 -07:00
committed by GitHub
parent 971b4c2ef4
commit 878a67cdb6
50 changed files with 2767 additions and 1071 deletions
+37 -5
View File
@@ -129,21 +129,28 @@ impl Tool for MessageTool {
.map(|c| c.to_string())
};
// Get target: use param → conversation default → job metadata
// Get target: use param → conversation default → job metadata → owner scope
// fallback when a specific channel is known.
let target = if let Some(t) = params.get("target").and_then(|v| v.as_str()) {
t.to_string()
Some(t.to_string())
} else if let Some(t) = self
.default_target
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
{
t
Some(t)
} else if let Some(t) = ctx.metadata.get("notify_user").and_then(|v| v.as_str()) {
t.to_string()
Some(t.to_string())
} else if channel.is_some() {
Some(ctx.user_id.clone())
} else {
None
};
let Some(target) = target else {
return Err(ToolError::ExecutionFailed(
"No target specified and no active conversation. Provide target parameter."
"No target specified and no channel-scoped routing target could be resolved. Provide target parameter."
.to_string(),
));
};
@@ -659,6 +666,31 @@ mod tests {
);
}
#[tokio::test]
async fn message_tool_falls_back_to_ctx_user_when_channel_known() {
// Regression for owner-scoped notifications: a channel can be known
// even when the concrete delivery target is omitted, so the message
// tool should pass ctx.user_id through to the channel layer.
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
let mut ctx =
crate::context::JobContext::with_user("owner-scope", "routine-job", "price alert");
ctx.metadata = serde_json::json!({
"notify_channel": "telegram",
});
let result = tool
.execute(serde_json::json!({"content": "NEAR price is $5"}), &ctx)
.await;
assert!(result.is_err()); // safety: test-only assertion
let err = result.unwrap_err().to_string();
let mentions_missing_target = err.contains("No target specified");
assert!(!mentions_missing_target); // safety: test-only assertion
let mentions_missing_channel = err.contains("No channel specified");
assert!(!mentions_missing_channel); // safety: test-only assertion
}
#[tokio::test]
async fn message_tool_no_metadata_still_errors() {
// When neither conversation context nor metadata is set, should still
+2 -3
View File
@@ -106,7 +106,7 @@ pub(crate) fn routine_create_parameters_schema() -> serde_json::Value {
},
"notify_user": {
"type": "string",
"description": "User or destination to notify, for example a username or chat ID."
"description": "Optional explicit user or destination to notify, for example a username or chat ID. Omit it to use the configured owner's last-seen target for that channel."
},
"timezone": {
"type": "string",
@@ -387,8 +387,7 @@ impl Tool for RoutineCreateTool {
user: params
.get("notify_user")
.and_then(|v| v.as_str())
.unwrap_or("default")
.to_string(),
.map(String::from),
..NotifyConfig::default()
},
last_run_at: None,
+188 -8
View File
@@ -841,13 +841,7 @@ impl Tool for WasmToolWrapper {
// Pre-resolve host credentials from secrets store (async, before blocking task).
// This decrypts the secrets once so the sync http_request() host function
// can inject them without needing async access.
//
// BUG FIX: ExtensionManager stores OAuth tokens under user_id "default"
// (hardcoded at construction in app.rs), but this was previously looking
// them up under ctx.user_id — which could be a Telegram user ID, web
// gateway user, etc. — causing credential resolution to silently fail.
// Must match the storage key until per-user credential isolation is added.
let credential_user_id = "default";
let credential_user_id = &ctx.user_id;
let host_credentials = resolve_host_credentials(
&self.capabilities,
self.secrets_store.as_deref(),
@@ -1165,6 +1159,13 @@ async fn resolve_host_credentials(
let secret = match store.get_decrypted(user_id, &mapping.secret_name).await {
Ok(s) => Some(s),
Err(e) => {
tracing::trace!(
user_id = %user_id,
secret_name = %mapping.secret_name,
error = %e,
"No matching host credential resolved for WASM tool in the requested scope"
);
// If lookup fails and we're not already looking up "default", try "default" as fallback
if user_id != "default" {
tracing::debug!(
@@ -1385,7 +1386,16 @@ fn build_tool_usage_hint(tool_name: &str, schema: &serde_json::Value) -> String
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use uuid::Uuid;
use crate::context::JobContext;
use crate::secrets::{
CreateSecretParams, DecryptedSecret, InMemorySecretsStore, Secret, SecretError, SecretRef,
SecretsStore,
};
use crate::testing::credentials::{
TEST_BEARER_TOKEN_123, TEST_GOOGLE_OAUTH_FRESH, TEST_GOOGLE_OAUTH_LEGACY,
@@ -1396,6 +1406,78 @@ mod tests {
use crate::tools::wasm::capabilities::Capabilities;
use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime};
struct RecordingSecretsStore {
inner: InMemorySecretsStore,
get_decrypted_lookups: Mutex<Vec<(String, String)>>,
}
impl RecordingSecretsStore {
fn new() -> Self {
Self {
inner: test_secrets_store(),
get_decrypted_lookups: Mutex::new(Vec::new()),
}
}
fn decrypted_lookups(&self) -> Vec<(String, String)> {
self.get_decrypted_lookups.lock().unwrap().clone()
}
}
#[async_trait]
impl SecretsStore for RecordingSecretsStore {
async fn create(
&self,
user_id: &str,
params: CreateSecretParams,
) -> Result<Secret, SecretError> {
self.inner.create(user_id, params).await
}
async fn get(&self, user_id: &str, name: &str) -> Result<Secret, SecretError> {
self.inner.get(user_id, name).await
}
async fn get_decrypted(
&self,
user_id: &str,
name: &str,
) -> Result<DecryptedSecret, SecretError> {
self.get_decrypted_lookups
.lock()
.unwrap()
.push((user_id.to_string(), name.to_string()));
self.inner.get_decrypted(user_id, name).await
}
async fn exists(&self, user_id: &str, name: &str) -> Result<bool, SecretError> {
self.inner.exists(user_id, name).await
}
async fn list(&self, user_id: &str) -> Result<Vec<SecretRef>, SecretError> {
self.inner.list(user_id).await
}
async fn delete(&self, user_id: &str, name: &str) -> Result<bool, SecretError> {
self.inner.delete(user_id, name).await
}
async fn record_usage(&self, secret_id: Uuid) -> Result<(), SecretError> {
self.inner.record_usage(secret_id).await
}
async fn is_accessible(
&self,
user_id: &str,
secret_name: &str,
allowed_secrets: &[String],
) -> Result<bool, SecretError> {
self.inner
.is_accessible(user_id, secret_name, allowed_secrets)
.await
}
}
#[test]
fn test_wrapper_creation() {
// This test verifies the runtime can be created
@@ -1691,6 +1773,104 @@ mod tests {
);
}
#[tokio::test]
async fn test_resolve_host_credentials_owner_scope_bearer() {
use std::collections::HashMap;
use crate::secrets::{
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
};
use crate::tools::wasm::capabilities::HttpCapability;
use crate::tools::wasm::wrapper::resolve_host_credentials;
let store = test_secrets_store();
let ctx = JobContext::with_user("owner-scope", "owner-scope test", "owner-scope test");
store
.create(
&ctx.user_id,
CreateSecretParams::new("google_oauth_token", TEST_GOOGLE_OAUTH_TOKEN),
)
.await
.unwrap();
let mut credentials = HashMap::new();
credentials.insert(
"google_oauth_token".to_string(),
CredentialMapping {
secret_name: "google_oauth_token".to_string(),
location: CredentialLocation::AuthorizationBearer,
host_patterns: vec!["www.googleapis.com".to_string()],
},
);
let caps = Capabilities {
http: Some(HttpCapability {
credentials,
..Default::default()
}),
..Default::default()
};
let result = resolve_host_credentials(&caps, Some(&store), &ctx.user_id, None).await;
assert_eq!(result.len(), 1);
assert_eq!(
result[0].headers.get("Authorization"),
Some(&format!("Bearer {TEST_GOOGLE_OAUTH_TOKEN}"))
);
}
#[tokio::test]
async fn test_execute_resolves_host_credentials_from_owner_scope_context() {
use std::collections::HashMap;
use crate::secrets::{CredentialLocation, CredentialMapping};
use crate::tools::wasm::capabilities::HttpCapability;
let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::for_testing()).unwrap());
let prepared = runtime
.prepare("search", b"\0asm\x0d\0\x01\0", None)
.await
.unwrap();
let store = Arc::new(RecordingSecretsStore::new());
let ctx = JobContext::with_user("owner-scope", "owner-scope test", "owner-scope test");
store
.create(
&ctx.user_id,
CreateSecretParams::new("google_oauth_token", TEST_GOOGLE_OAUTH_TOKEN),
)
.await
.unwrap();
let mut credentials = HashMap::new();
credentials.insert(
"google_oauth_token".to_string(),
CredentialMapping {
secret_name: "google_oauth_token".to_string(),
location: CredentialLocation::AuthorizationBearer,
host_patterns: vec!["www.googleapis.com".to_string()],
},
);
let caps = Capabilities {
http: Some(HttpCapability {
credentials,
..Default::default()
}),
..Default::default()
};
let wrapper = super::WasmToolWrapper::new(Arc::clone(&runtime), prepared, caps)
.with_secrets_store(store.clone());
let result = wrapper.execute(serde_json::json!({}), &ctx).await;
assert!(result.is_err());
let lookups = store.decrypted_lookups();
assert!(lookups.contains(&("owner-scope".to_string(), "google_oauth_token".to_string())));
assert!(!lookups.contains(&("default".to_string(), "google_oauth_token".to_string())));
}
#[tokio::test]
async fn test_resolve_host_credentials_missing_secret() {
use std::collections::HashMap;