mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user