mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 23:16:26 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab806efec4 | ||
|
|
83675c91da |
Generated
+1
-1
@@ -269,7 +269,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "whatsapp-channel"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -2640,7 +2640,7 @@ function renderExtensionCard(ext) {
|
||||
pairingSection.className = 'ext-pairing';
|
||||
pairingSection.setAttribute('data-channel', ext.name);
|
||||
card.appendChild(pairingSection);
|
||||
loadPairingRequests(ext.name, pairingSection, ext.activation_status);
|
||||
loadPairingRequests(ext.name, pairingSection);
|
||||
}
|
||||
|
||||
return card;
|
||||
@@ -3034,19 +3034,11 @@ function openOAuthUrl(url) {
|
||||
|
||||
// --- Pairing ---
|
||||
|
||||
function loadPairingRequests(channel, container, status) {
|
||||
function loadPairingRequests(channel, container) {
|
||||
apiFetch('/api/pairing/' + encodeURIComponent(channel))
|
||||
.then(data => {
|
||||
container.innerHTML = '';
|
||||
if (!data.requests || data.requests.length === 0) {
|
||||
if (status === 'pairing') {
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'pairing-hint';
|
||||
hint.textContent = 'Send any message to your bot to receive a pairing request here.';
|
||||
container.appendChild(hint);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!data.requests || data.requests.length === 0) return;
|
||||
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'pairing-heading';
|
||||
|
||||
@@ -2865,13 +2865,6 @@ body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pairing-hint {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
margin: 4px 0 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Configure modal */
|
||||
.configure-overlay {
|
||||
position: fixed;
|
||||
|
||||
@@ -3739,26 +3739,6 @@ impl ExtensionManager {
|
||||
}
|
||||
};
|
||||
|
||||
// Credentials changed (new bot token) — clear pairing state so existing users
|
||||
// must re-approve with the new bot identity.
|
||||
if cred_count > 0 {
|
||||
let pairing_store = crate::pairing::PairingStore::new();
|
||||
if let Err(e) = pairing_store.clear_allow_from(name) {
|
||||
tracing::warn!(
|
||||
channel = %name,
|
||||
error = %e,
|
||||
"Failed to clear allow-from on credential refresh"
|
||||
);
|
||||
}
|
||||
if let Err(e) = pairing_store.clear_pending(name) {
|
||||
tracing::warn!(
|
||||
channel = %name,
|
||||
error = %e,
|
||||
"Failed to clear pending pairings on credential refresh"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Load capabilities file once to extract all secret names
|
||||
let cap_path = self
|
||||
.wasm_channels_dir
|
||||
|
||||
@@ -440,39 +440,6 @@ impl PairingStore {
|
||||
Ok(file.allow_from)
|
||||
}
|
||||
|
||||
/// Clear the allow-from list for a channel.
|
||||
///
|
||||
/// Called on credential refresh so that existing users must re-approve
|
||||
/// after a bot token change.
|
||||
pub fn clear_allow_from(&self, channel: &str) -> Result<(), PairingStoreError> {
|
||||
let path = allow_from_path(&self.base_dir, channel)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&path)?;
|
||||
file.lock_exclusive()?;
|
||||
let store = AllowFromStoreFile {
|
||||
version: 1,
|
||||
allow_from: Vec::new(),
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&store)?;
|
||||
fs::write(&path, json)?;
|
||||
fs4::FileExt::unlock(&file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear all pending pairing requests for a channel.
|
||||
///
|
||||
/// Called on credential refresh so stale requests don't confuse users.
|
||||
pub fn clear_pending(&self, channel: &str) -> Result<(), PairingStoreError> {
|
||||
self.write_pairing_file(channel, &[])
|
||||
}
|
||||
|
||||
/// Check if a sender is allowed (by id or username).
|
||||
pub fn is_sender_allowed(
|
||||
&self,
|
||||
@@ -550,11 +517,6 @@ impl PairingStore {
|
||||
requests: &[PairingRequest],
|
||||
) -> Result<(), PairingStoreError> {
|
||||
let path = pairing_path(&self.base_dir, channel)?;
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
PairingStoreError::InvalidPath(format!("path has no parent: {}", path.display()))
|
||||
})?;
|
||||
fs::create_dir_all(parent)?;
|
||||
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
@@ -755,117 +717,4 @@ mod tests {
|
||||
store.list_pending("").unwrap_err();
|
||||
store.upsert_request("", "u1", None).unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_allow_from_removes_all_entries() {
|
||||
let (store, _) = test_store();
|
||||
let r1 = store.upsert_request("telegram", "user1", None).unwrap();
|
||||
store.approve("telegram", &r1.code).unwrap();
|
||||
|
||||
let list = store.read_allow_from("telegram").unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
|
||||
store.clear_allow_from("telegram").unwrap();
|
||||
let list = store.read_allow_from("telegram").unwrap();
|
||||
assert!(list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_pending_removes_all_requests() {
|
||||
let (store, _) = test_store();
|
||||
store
|
||||
.upsert_request("telegram", "user1", Some(serde_json::json!({"chat_id": 1})))
|
||||
.unwrap();
|
||||
store
|
||||
.upsert_request("telegram", "user2", Some(serde_json::json!({"chat_id": 2})))
|
||||
.unwrap();
|
||||
|
||||
let requests = store.list_pending("telegram").unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
|
||||
store.clear_pending("telegram").unwrap();
|
||||
let requests = store.list_pending("telegram").unwrap();
|
||||
assert!(requests.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_allow_from_allows_new_approval() {
|
||||
let (store, _) = test_store();
|
||||
let r1 = store.upsert_request("telegram", "user1", None).unwrap();
|
||||
store.approve("telegram", &r1.code).unwrap();
|
||||
|
||||
assert!(store.is_sender_allowed("telegram", "user1", None).unwrap());
|
||||
|
||||
store.clear_allow_from("telegram").unwrap();
|
||||
|
||||
assert!(!store.is_sender_allowed("telegram", "user1", None).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_allow_from_on_nonexistent_file() {
|
||||
let (store, _) = test_store();
|
||||
// No requests created, so allow_from file doesn't exist
|
||||
let result = store.clear_allow_from("telegram");
|
||||
assert!(result.is_ok());
|
||||
|
||||
// After clearing, should return empty list
|
||||
let list = store.read_allow_from("telegram").unwrap();
|
||||
assert!(list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_pending_on_nonexistent_file() {
|
||||
let (store, _) = test_store();
|
||||
// No requests created, so pairing file doesn't exist
|
||||
let result = store.clear_pending("telegram");
|
||||
assert!(result.is_ok());
|
||||
|
||||
// After clearing, should return empty list
|
||||
let requests = store.list_pending("telegram").unwrap();
|
||||
assert!(requests.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_and_reapprove_workflow() {
|
||||
let (store, _) = test_store();
|
||||
|
||||
// Step 1: Create and approve user1
|
||||
let r1 = store.upsert_request("telegram", "user1", None).unwrap();
|
||||
store.approve("telegram", &r1.code).unwrap();
|
||||
assert!(store.is_sender_allowed("telegram", "user1", None).unwrap());
|
||||
|
||||
// Step 2: Simulate credential refresh by clearing pairing state
|
||||
store.clear_allow_from("telegram").unwrap();
|
||||
store.clear_pending("telegram").unwrap();
|
||||
|
||||
// Step 3: Verify user1 is no longer approved and no pending requests exist
|
||||
assert!(!store.is_sender_allowed("telegram", "user1", None).unwrap());
|
||||
let requests = store.list_pending("telegram").unwrap();
|
||||
assert!(requests.is_empty());
|
||||
|
||||
// Step 4: Create new pairing request and approve user1 again
|
||||
let r2 = store.upsert_request("telegram", "user1", None).unwrap();
|
||||
assert!(r2.created); // Should be a new request
|
||||
store.approve("telegram", &r2.code).unwrap();
|
||||
assert!(store.is_sender_allowed("telegram", "user1", None).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_one_channel_doesnt_affect_other() {
|
||||
let (store, _) = test_store();
|
||||
|
||||
// Approve users on two channels
|
||||
let r1 = store.upsert_request("telegram", "user1", None).unwrap();
|
||||
store.approve("telegram", &r1.code).unwrap();
|
||||
|
||||
let r2 = store.upsert_request("discord", "user2", None).unwrap();
|
||||
store.approve("discord", &r2.code).unwrap();
|
||||
|
||||
// Clear only telegram
|
||||
store.clear_allow_from("telegram").unwrap();
|
||||
|
||||
// Verify telegram is cleared but discord is not
|
||||
assert!(!store.is_sender_allowed("telegram", "user1", None).unwrap());
|
||||
assert!(store.is_sender_allowed("discord", "user2", None).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ impl WasmToolLoader {
|
||||
tool = name,
|
||||
path = %cap_path.display(),
|
||||
"Capabilities file missing \"description\" field; \
|
||||
tool will use generic fallback description"
|
||||
using WASM-exported description when available"
|
||||
);
|
||||
}
|
||||
if params.is_none() && cap_file.parameters.is_none() {
|
||||
@@ -171,7 +171,7 @@ impl WasmToolLoader {
|
||||
tool = name,
|
||||
path = %cap_path.display(),
|
||||
"Capabilities file missing \"parameters\" field; \
|
||||
tool will accept any JSON object (permissive fallback)"
|
||||
using exported WASM schema when available"
|
||||
);
|
||||
}
|
||||
(caps, oauth, desc, params)
|
||||
@@ -186,7 +186,7 @@ impl WasmToolLoader {
|
||||
tracing::warn!(
|
||||
tool = name,
|
||||
"No capabilities file for WASM tool; \
|
||||
tool will use generic fallback description and accept any JSON object"
|
||||
using default permissions and WASM-exported metadata when available"
|
||||
);
|
||||
(Capabilities::default(), None, None, None)
|
||||
};
|
||||
|
||||
+68
-19
@@ -493,6 +493,10 @@ struct WasmToolSchemas {
|
||||
}
|
||||
|
||||
impl WasmToolSchemas {
|
||||
/// Keep advertised schemas reasonably small because they are serialized
|
||||
/// into the main tool list shown to the model.
|
||||
const MAX_ADVERTISED_SCHEMA_BYTES: usize = 8 * 1024;
|
||||
|
||||
fn permissive_schema() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
@@ -521,9 +525,20 @@ impl WasmToolSchemas {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn should_advertise_discovery(schema: &serde_json::Value) -> bool {
|
||||
Self::typed_property_count(schema) > 0
|
||||
&& schema.to_string().len() <= Self::MAX_ADVERTISED_SCHEMA_BYTES
|
||||
}
|
||||
|
||||
fn new(discovery: serde_json::Value) -> Self {
|
||||
let advertised = if Self::should_advertise_discovery(&discovery) {
|
||||
discovery.clone()
|
||||
} else {
|
||||
Self::permissive_schema()
|
||||
};
|
||||
|
||||
Self {
|
||||
advertised: Self::permissive_schema(),
|
||||
advertised,
|
||||
discovery,
|
||||
}
|
||||
}
|
||||
@@ -1490,7 +1505,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_advertised_schema_stays_permissive_until_sidecar_override() {
|
||||
async fn test_small_exported_schema_is_advertised_directly() {
|
||||
let discovery_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1510,30 +1525,19 @@ mod tests {
|
||||
wrapper.schemas = super::WasmToolSchemas::new(discovery_schema.clone());
|
||||
wrapper.description = "Search documents".to_string();
|
||||
|
||||
// Advertised schema stays permissive; discovery holds the typed schema
|
||||
assert_eq!(
|
||||
wrapper.parameters_schema(),
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": true
|
||||
})
|
||||
);
|
||||
// Small typed exported schemas should be advertised directly so the
|
||||
// model sees the actual required parameters.
|
||||
assert_eq!(wrapper.parameters_schema(), discovery_schema);
|
||||
assert_eq!(wrapper.discovery_schema(), discovery_schema);
|
||||
|
||||
// Raw description is clean — no tool_info hint baked in
|
||||
assert!(!wrapper.description().contains("tool_info"));
|
||||
|
||||
// But schema() composes the hint at display time when advertised is permissive
|
||||
// When advertised is typed, schema() should not add a tool_info hint.
|
||||
let schema = wrapper.schema();
|
||||
assert!(
|
||||
schema.description.contains("tool_info"),
|
||||
"schema().description should contain tool_info hint: {}",
|
||||
schema.description
|
||||
);
|
||||
assert!(
|
||||
schema.description.contains("include_schema: true"),
|
||||
"hint should mention include_schema: true: {}",
|
||||
!schema.description.contains("tool_info"),
|
||||
"schema().description should not contain tool_info hint when typed: {}",
|
||||
schema.description
|
||||
);
|
||||
|
||||
@@ -1567,6 +1571,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_large_exported_schema_stays_permissive_for_advertising() {
|
||||
let mut properties = serde_json::Map::new();
|
||||
for i in 0..200 {
|
||||
properties.insert(
|
||||
format!("field_{i:03}"),
|
||||
serde_json::json!({
|
||||
"type": "string",
|
||||
"description": "x".repeat(64)
|
||||
}),
|
||||
);
|
||||
}
|
||||
let discovery_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
});
|
||||
|
||||
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 mut wrapper =
|
||||
super::WasmToolWrapper::new(Arc::clone(&runtime), prepared, Capabilities::default());
|
||||
wrapper.schemas = super::WasmToolSchemas::new(discovery_schema.clone());
|
||||
wrapper.description = "Search documents".to_string();
|
||||
|
||||
assert_eq!(
|
||||
wrapper.parameters_schema(),
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": true
|
||||
})
|
||||
);
|
||||
assert_eq!(wrapper.discovery_schema(), discovery_schema);
|
||||
|
||||
let schema = wrapper.schema();
|
||||
assert!(
|
||||
schema.description.contains("tool_info"),
|
||||
"large schemas should still fall back to the tool_info hint: {}",
|
||||
schema.description
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capabilities_default() {
|
||||
let caps = Capabilities::default();
|
||||
|
||||
Reference in New Issue
Block a user