mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 00:59:33 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ca8b1bf68 | ||
|
|
d887309208 | ||
|
|
0c119b5c1e |
Generated
+1
-1
@@ -269,7 +269,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whatsapp-channel"
|
name = "whatsapp-channel"
|
||||||
version = "0.2.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -2640,7 +2640,7 @@ function renderExtensionCard(ext) {
|
|||||||
pairingSection.className = 'ext-pairing';
|
pairingSection.className = 'ext-pairing';
|
||||||
pairingSection.setAttribute('data-channel', ext.name);
|
pairingSection.setAttribute('data-channel', ext.name);
|
||||||
card.appendChild(pairingSection);
|
card.appendChild(pairingSection);
|
||||||
loadPairingRequests(ext.name, pairingSection);
|
loadPairingRequests(ext.name, pairingSection, ext.activation_status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
@@ -3034,11 +3034,19 @@ function openOAuthUrl(url) {
|
|||||||
|
|
||||||
// --- Pairing ---
|
// --- Pairing ---
|
||||||
|
|
||||||
function loadPairingRequests(channel, container) {
|
function loadPairingRequests(channel, container, status) {
|
||||||
apiFetch('/api/pairing/' + encodeURIComponent(channel))
|
apiFetch('/api/pairing/' + encodeURIComponent(channel))
|
||||||
.then(data => {
|
.then(data => {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
if (!data.requests || data.requests.length === 0) return;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const heading = document.createElement('div');
|
const heading = document.createElement('div');
|
||||||
heading.className = 'pairing-heading';
|
heading.className = 'pairing-heading';
|
||||||
|
|||||||
@@ -2865,6 +2865,13 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pairing-hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* Configure modal */
|
/* Configure modal */
|
||||||
.configure-overlay {
|
.configure-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -3739,6 +3739,26 @@ 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
|
// Load capabilities file once to extract all secret names
|
||||||
let cap_path = self
|
let cap_path = self
|
||||||
.wasm_channels_dir
|
.wasm_channels_dir
|
||||||
|
|||||||
@@ -440,6 +440,39 @@ impl PairingStore {
|
|||||||
Ok(file.allow_from)
|
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).
|
/// Check if a sender is allowed (by id or username).
|
||||||
pub fn is_sender_allowed(
|
pub fn is_sender_allowed(
|
||||||
&self,
|
&self,
|
||||||
@@ -517,6 +550,11 @@ impl PairingStore {
|
|||||||
requests: &[PairingRequest],
|
requests: &[PairingRequest],
|
||||||
) -> Result<(), PairingStoreError> {
|
) -> Result<(), PairingStoreError> {
|
||||||
let path = pairing_path(&self.base_dir, channel)?;
|
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()
|
let mut file = fs::OpenOptions::new()
|
||||||
.write(true)
|
.write(true)
|
||||||
.create(true)
|
.create(true)
|
||||||
@@ -717,4 +755,117 @@ mod tests {
|
|||||||
store.list_pending("").unwrap_err();
|
store.list_pending("").unwrap_err();
|
||||||
store.upsert_request("", "u1", None).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,
|
tool = name,
|
||||||
path = %cap_path.display(),
|
path = %cap_path.display(),
|
||||||
"Capabilities file missing \"description\" field; \
|
"Capabilities file missing \"description\" field; \
|
||||||
using WASM-exported description when available"
|
tool will use generic fallback description"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if params.is_none() && cap_file.parameters.is_none() {
|
if params.is_none() && cap_file.parameters.is_none() {
|
||||||
@@ -171,7 +171,7 @@ impl WasmToolLoader {
|
|||||||
tool = name,
|
tool = name,
|
||||||
path = %cap_path.display(),
|
path = %cap_path.display(),
|
||||||
"Capabilities file missing \"parameters\" field; \
|
"Capabilities file missing \"parameters\" field; \
|
||||||
using exported WASM schema when available"
|
tool will accept any JSON object (permissive fallback)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
(caps, oauth, desc, params)
|
(caps, oauth, desc, params)
|
||||||
@@ -186,7 +186,7 @@ impl WasmToolLoader {
|
|||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
tool = name,
|
tool = name,
|
||||||
"No capabilities file for WASM tool; \
|
"No capabilities file for WASM tool; \
|
||||||
using default permissions and WASM-exported metadata when available"
|
tool will use generic fallback description and accept any JSON object"
|
||||||
);
|
);
|
||||||
(Capabilities::default(), None, None, None)
|
(Capabilities::default(), None, None, None)
|
||||||
};
|
};
|
||||||
|
|||||||
+19
-68
@@ -493,10 +493,6 @@ struct WasmToolSchemas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
fn permissive_schema() -> serde_json::Value {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -525,20 +521,9 @@ impl WasmToolSchemas {
|
|||||||
.unwrap_or(0)
|
.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 {
|
fn new(discovery: serde_json::Value) -> Self {
|
||||||
let advertised = if Self::should_advertise_discovery(&discovery) {
|
|
||||||
discovery.clone()
|
|
||||||
} else {
|
|
||||||
Self::permissive_schema()
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
advertised,
|
advertised: Self::permissive_schema(),
|
||||||
discovery,
|
discovery,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1505,7 +1490,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_small_exported_schema_is_advertised_directly() {
|
async fn test_advertised_schema_stays_permissive_until_sidecar_override() {
|
||||||
let discovery_schema = serde_json::json!({
|
let discovery_schema = serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1525,19 +1510,30 @@ mod tests {
|
|||||||
wrapper.schemas = super::WasmToolSchemas::new(discovery_schema.clone());
|
wrapper.schemas = super::WasmToolSchemas::new(discovery_schema.clone());
|
||||||
wrapper.description = "Search documents".to_string();
|
wrapper.description = "Search documents".to_string();
|
||||||
|
|
||||||
// Small typed exported schemas should be advertised directly so the
|
// Advertised schema stays permissive; discovery holds the typed schema
|
||||||
// model sees the actual required parameters.
|
assert_eq!(
|
||||||
assert_eq!(wrapper.parameters_schema(), discovery_schema);
|
wrapper.parameters_schema(),
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"additionalProperties": true
|
||||||
|
})
|
||||||
|
);
|
||||||
assert_eq!(wrapper.discovery_schema(), discovery_schema);
|
assert_eq!(wrapper.discovery_schema(), discovery_schema);
|
||||||
|
|
||||||
// Raw description is clean — no tool_info hint baked in
|
// Raw description is clean — no tool_info hint baked in
|
||||||
assert!(!wrapper.description().contains("tool_info"));
|
assert!(!wrapper.description().contains("tool_info"));
|
||||||
|
|
||||||
// When advertised is typed, schema() should not add a tool_info hint.
|
// But schema() composes the hint at display time when advertised is permissive
|
||||||
let schema = wrapper.schema();
|
let schema = wrapper.schema();
|
||||||
assert!(
|
assert!(
|
||||||
!schema.description.contains("tool_info"),
|
schema.description.contains("tool_info"),
|
||||||
"schema().description should not contain tool_info hint when typed: {}",
|
"schema().description should contain tool_info hint: {}",
|
||||||
|
schema.description
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
schema.description.contains("include_schema: true"),
|
||||||
|
"hint should mention include_schema: true: {}",
|
||||||
schema.description
|
schema.description
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1571,51 +1567,6 @@ 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]
|
#[test]
|
||||||
fn test_capabilities_default() {
|
fn test_capabilities_default() {
|
||||||
let caps = Capabilities::default();
|
let caps = Capabilities::default();
|
||||||
|
|||||||
Reference in New Issue
Block a user