Compare commits

..
Author SHA1 Message Date
Coffee ab806efec4 Rename WASM schema test for direct advertising behavior 2026-03-18 14:10:19 +08:00
Coffee 83675c91da fix: brave web search 2026-03-18 13:55:09 +08:00
7 changed files with 75 additions and 212 deletions
+1 -1
View File
@@ -269,7 +269,7 @@ dependencies = [
[[package]]
name = "whatsapp-channel"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"serde",
"serde_json",
+3 -11
View File
@@ -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';
-7
View File
@@ -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;
-20
View File
@@ -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
-151
View File
@@ -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());
}
}
+3 -3
View File
@@ -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
View File
@@ -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();