mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Merge branch 'nearai:staging' into staging
This commit is contained in:
@@ -539,6 +539,65 @@ impl ChannelWorkspaceStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a text frame to a JSON queue stored at `path`.
|
||||
///
|
||||
/// The queue is stored as a JSON array of strings and bounded to the most
|
||||
/// recent `max_items` entries so websocket runtimes cannot grow it without
|
||||
/// limit.
|
||||
pub fn append_json_text_queue(
|
||||
&self,
|
||||
path: &str,
|
||||
text: &str,
|
||||
max_items: usize,
|
||||
) -> Result<(), String> {
|
||||
let mut data = self
|
||||
.data
|
||||
.write()
|
||||
.map_err(|_| "workspace store lock poisoned".to_string())?;
|
||||
|
||||
let mut queue: Vec<String> = data
|
||||
.get(path)
|
||||
.and_then(|raw| serde_json::from_str(raw).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
queue.push(text.to_string());
|
||||
|
||||
if queue.len() > max_items {
|
||||
let overflow = queue.len() - max_items;
|
||||
queue.drain(0..overflow);
|
||||
}
|
||||
|
||||
let serialized = serde_json::to_string(&queue)
|
||||
.map_err(|error| format!("failed to serialize websocket queue: {error}"))?;
|
||||
|
||||
data.insert(path.to_string(), serialized);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically move a queued JSON array of text frames from `source_path` to `dest_path`.
|
||||
pub fn move_json_text_queue(&self, source_path: &str, dest_path: &str) -> Result<bool, String> {
|
||||
let mut data = self
|
||||
.data
|
||||
.write()
|
||||
.map_err(|_| "workspace store lock poisoned".to_string())?;
|
||||
|
||||
let Some(raw_queue) = data.remove(source_path) else {
|
||||
data.remove(dest_path);
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let queue: Vec<String> = serde_json::from_str(&raw_queue)
|
||||
.map_err(|error| format!("failed to deserialize websocket queue: {error}"))?;
|
||||
|
||||
if queue.is_empty() {
|
||||
data.remove(dest_path);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
data.insert(dest_path.to_string(), raw_queue);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::tools::wasm::WorkspaceReader for ChannelWorkspaceStore {
|
||||
@@ -818,6 +877,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_workspace_store_append_json_text_queue_is_bounded() {
|
||||
use crate::channels::wasm::host::ChannelWorkspaceStore;
|
||||
use crate::tools::wasm::WorkspaceReader;
|
||||
|
||||
let store = ChannelWorkspaceStore::new();
|
||||
let path = "channels/discord/state/gateway_event_queue";
|
||||
|
||||
store.append_json_text_queue(path, "frame-1", 2).unwrap();
|
||||
store.append_json_text_queue(path, "frame-2", 2).unwrap();
|
||||
store.append_json_text_queue(path, "frame-3", 2).unwrap();
|
||||
|
||||
let queue: Vec<String> = serde_json::from_str(&store.read(path).unwrap()).unwrap();
|
||||
assert_eq!(queue, vec!["frame-2".to_string(), "frame-3".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_workspace_store_move_json_text_queue_is_atomic() {
|
||||
use crate::channels::wasm::host::ChannelWorkspaceStore;
|
||||
use crate::tools::wasm::WorkspaceReader;
|
||||
|
||||
let store = ChannelWorkspaceStore::new();
|
||||
let live_path = "channels/discord/state/gateway_event_queue";
|
||||
let drain_path = "channels/discord/state/gateway_event_queue_processing";
|
||||
|
||||
store
|
||||
.append_json_text_queue(live_path, "frame-1", 4)
|
||||
.unwrap();
|
||||
store
|
||||
.append_json_text_queue(live_path, "frame-2", 4)
|
||||
.unwrap();
|
||||
|
||||
assert!(store.move_json_text_queue(live_path, drain_path).unwrap());
|
||||
assert_eq!(store.read(live_path), None);
|
||||
|
||||
let drained: Vec<String> = serde_json::from_str(&store.read(drain_path).unwrap()).unwrap();
|
||||
assert_eq!(drained, vec!["frame-1".to_string(), "frame-2".to_string()]);
|
||||
|
||||
store
|
||||
.append_json_text_queue(live_path, "frame-3", 4)
|
||||
.unwrap();
|
||||
let live: Vec<String> = serde_json::from_str(&store.read(live_path).unwrap()).unwrap();
|
||||
assert_eq!(live, vec!["frame-3".to_string()]);
|
||||
}
|
||||
|
||||
// === QA Plan P2 - 2.3: WASM channel lifecycle tests ===
|
||||
|
||||
#[test]
|
||||
|
||||
+1106
-3
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,37 @@ use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::*;
|
||||
|
||||
pub(crate) fn derive_activation_status(
|
||||
ext: &crate::extensions::InstalledExtension,
|
||||
pairing_store: &crate::pairing::PairingStore,
|
||||
has_owner_binding: bool,
|
||||
) -> Option<ExtensionActivationStatus> {
|
||||
if ext.kind == crate::extensions::ExtensionKind::WasmChannel {
|
||||
let allowlist_exists = pairing_store
|
||||
.has_allow_from_file(&ext.name)
|
||||
.unwrap_or(false);
|
||||
let has_paired = pairing_store
|
||||
.read_allow_from(&ext.name)
|
||||
.map(|list| !list.is_empty())
|
||||
.unwrap_or(false);
|
||||
classify_wasm_channel_activation(
|
||||
ext,
|
||||
has_paired,
|
||||
has_owner_binding || (ext.active && !allowlist_exists),
|
||||
)
|
||||
} else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
|
||||
Some(if ext.active {
|
||||
ExtensionActivationStatus::Active
|
||||
} else if ext.authenticated {
|
||||
ExtensionActivationStatus::Configured
|
||||
} else {
|
||||
ExtensionActivationStatus::Installed
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn extensions_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
@@ -38,27 +69,11 @@ pub async fn extensions_list_handler(
|
||||
let extensions = installed
|
||||
.into_iter()
|
||||
.map(|ext| {
|
||||
let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel {
|
||||
let has_paired = pairing_store
|
||||
.read_allow_from(&ext.name)
|
||||
.map(|list| !list.is_empty())
|
||||
.unwrap_or(false);
|
||||
crate::channels::web::types::classify_wasm_channel_activation(
|
||||
&ext,
|
||||
has_paired,
|
||||
owner_bound_channels.contains(&ext.name),
|
||||
)
|
||||
} else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
|
||||
Some(if ext.active {
|
||||
crate::channels::web::types::ExtensionActivationStatus::Active
|
||||
} else if ext.authenticated {
|
||||
crate::channels::web::types::ExtensionActivationStatus::Configured
|
||||
} else {
|
||||
crate::channels::web::types::ExtensionActivationStatus::Installed
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let activation_status = derive_activation_status(
|
||||
&ext,
|
||||
&pairing_store,
|
||||
owner_bound_channels.contains(&ext.name),
|
||||
);
|
||||
ExtensionInfo {
|
||||
name: ext.name,
|
||||
display_name: ext.display_name,
|
||||
@@ -143,3 +158,63 @@ pub async fn extensions_remove_handler(
|
||||
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::derive_activation_status;
|
||||
use crate::channels::web::types::ExtensionActivationStatus;
|
||||
use crate::extensions::{ExtensionKind, InstalledExtension};
|
||||
use crate::pairing::PairingStore;
|
||||
|
||||
fn active_authenticated_wasm_channel(name: &str) -> InstalledExtension {
|
||||
InstalledExtension {
|
||||
name: name.to_string(),
|
||||
kind: ExtensionKind::WasmChannel,
|
||||
display_name: None,
|
||||
description: None,
|
||||
url: None,
|
||||
authenticated: true,
|
||||
active: true,
|
||||
tools: Vec::new(),
|
||||
needs_setup: false,
|
||||
has_auth: false,
|
||||
installed: true,
|
||||
activation_error: None,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_authenticated_wasm_channel_without_allowlist_file_is_active() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let pairing_store = PairingStore::with_base_dir(temp_dir.path().to_path_buf());
|
||||
let ext = active_authenticated_wasm_channel("discord");
|
||||
|
||||
assert_eq!(
|
||||
derive_activation_status(&ext, &pairing_store, false),
|
||||
Some(ExtensionActivationStatus::Active)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_authenticated_wasm_channel_with_empty_allowlist_file_is_pairing() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let pairing_store = PairingStore::with_base_dir(temp_dir.path().to_path_buf());
|
||||
let ext = active_authenticated_wasm_channel("discord");
|
||||
|
||||
fs::write(
|
||||
temp_dir.path().join("discord-allowFrom.json"),
|
||||
r#"{"version":1,"allowFrom":[]}"#,
|
||||
)
|
||||
.expect("write empty allowlist");
|
||||
|
||||
assert_eq!(
|
||||
derive_activation_status(&ext, &pairing_store, false),
|
||||
Some(ExtensionActivationStatus::Pairing)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2159,27 +2159,12 @@ async fn extensions_list_handler(
|
||||
let extensions = installed
|
||||
.into_iter()
|
||||
.map(|ext| {
|
||||
let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel {
|
||||
let has_paired = pairing_store
|
||||
.read_allow_from(&ext.name)
|
||||
.map(|list| !list.is_empty())
|
||||
.unwrap_or(false);
|
||||
crate::channels::web::types::classify_wasm_channel_activation(
|
||||
let activation_status =
|
||||
crate::channels::web::handlers::extensions::derive_activation_status(
|
||||
&ext,
|
||||
has_paired,
|
||||
&pairing_store,
|
||||
owner_bound_channels.contains(&ext.name),
|
||||
)
|
||||
} else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
|
||||
Some(if ext.active {
|
||||
ExtensionActivationStatus::Active
|
||||
} else if ext.authenticated {
|
||||
ExtensionActivationStatus::Configured
|
||||
} else {
|
||||
ExtensionActivationStatus::Installed
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
);
|
||||
ExtensionInfo {
|
||||
name: ext.name,
|
||||
display_name: ext.display_name,
|
||||
|
||||
@@ -420,6 +420,12 @@ impl PairingStore {
|
||||
Ok(Some(entry))
|
||||
}
|
||||
|
||||
/// Read the allowFrom list for a channel.
|
||||
pub fn has_allow_from_file(&self, channel: &str) -> Result<bool, PairingStoreError> {
|
||||
let path = allow_from_path(&self.base_dir, channel)?;
|
||||
Ok(path.exists())
|
||||
}
|
||||
|
||||
/// Read the allowFrom list for a channel.
|
||||
pub fn read_allow_from(&self, channel: &str) -> Result<Vec<String>, PairingStoreError> {
|
||||
let path = allow_from_path(&self.base_dir, channel)?;
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct Capabilities {
|
||||
pub secrets: Option<SecretsCapability>,
|
||||
/// Webhook authentication and signature verification.
|
||||
pub webhook: Option<WebhookCapability>,
|
||||
/// Arbitrary websocket configuration preserved from capabilities JSON.
|
||||
pub websocket: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Capabilities {
|
||||
@@ -341,6 +343,7 @@ mod tests {
|
||||
assert!(caps.tool_invoke.is_none());
|
||||
assert!(caps.secrets.is_none());
|
||||
assert!(caps.webhook.is_none());
|
||||
assert!(caps.websocket.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -75,6 +75,10 @@ pub struct CapabilitiesFile {
|
||||
#[serde(default)]
|
||||
pub webhook: Option<WebhookCapabilitySchema>,
|
||||
|
||||
/// Arbitrary websocket configuration preserved for runtime consumers.
|
||||
#[serde(default)]
|
||||
pub websocket: Option<serde_json::Value>,
|
||||
|
||||
/// Authentication setup instructions.
|
||||
/// Used by `optimclaw config` to guide users through auth setup.
|
||||
#[serde(default)]
|
||||
@@ -155,6 +159,7 @@ impl CapabilitiesFile {
|
||||
self.tool_invoke = self.tool_invoke.or(inner.tool_invoke);
|
||||
self.workspace = self.workspace.or(inner.workspace);
|
||||
self.webhook = self.webhook.or(inner.webhook);
|
||||
self.websocket = self.websocket.or(inner.websocket);
|
||||
self.auth = self.auth.or(inner.auth);
|
||||
self.setup = self.setup.or(inner.setup);
|
||||
}
|
||||
@@ -250,6 +255,8 @@ impl CapabilitiesFile {
|
||||
caps.webhook = Some(webhook.to_webhook_capability());
|
||||
}
|
||||
|
||||
caps.websocket = self.websocket.clone();
|
||||
|
||||
caps
|
||||
}
|
||||
}
|
||||
@@ -745,6 +752,8 @@ fn default_tool_setup_field_input_type() -> ToolSetupFieldInputType {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::tools::wasm::capabilities_schema::{CapabilitiesFile, CredentialLocationSchema};
|
||||
|
||||
#[test]
|
||||
@@ -1402,6 +1411,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discord_websocket_config_preserved_in_runtime_capabilities() {
|
||||
let json = r#"{
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [{ "host": "discord.com", "path_prefix": "/api/v10" }]
|
||||
},
|
||||
"websocket": {
|
||||
"url": "wss://gateway.discord.gg/?v=10&encoding=json",
|
||||
"connect_on_start": true,
|
||||
"identify": {
|
||||
"intents": 513,
|
||||
"properties": {
|
||||
"os": "linux",
|
||||
"browser": "ironclaw",
|
||||
"device": "ironclaw"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let file = CapabilitiesFile::from_json(json).unwrap();
|
||||
let caps = file.to_capabilities();
|
||||
|
||||
assert_eq!(
|
||||
caps.websocket,
|
||||
Some(json!({
|
||||
"url": "wss://gateway.discord.gg/?v=10&encoding=json",
|
||||
"connect_on_start": true,
|
||||
"identify": {
|
||||
"intents": 513,
|
||||
"properties": {
|
||||
"os": "linux",
|
||||
"browser": "ironclaw",
|
||||
"device": "ironclaw"
|
||||
}
|
||||
}
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tool description ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user