Skip to content
Open
27 changes: 27 additions & 0 deletions crates/ironclaw_product_workflow/src/reborn_services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1627,6 +1627,17 @@ pub trait RebornServicesApi: Send + Sync {
request: RebornTimelineRequest,
) -> Result<RebornTimelineResponse, RebornServicesError>;

/// Return the effective global auto-approve toggle for the authenticated
/// caller. This is a narrow session-bootstrap read, not the operator
/// config key/value surface; implementations must derive scope from the
/// trusted caller.
async fn global_auto_approve_enabled(
&self,
_caller: WebUiAuthenticatedCaller,
) -> Result<bool, RebornServicesError> {
Ok(false)
}

/// Read the raw bytes of one landed attachment so the browser can render an
/// image thumbnail (or download a file) for a persisted message. The default
/// reports the bytes are unavailable; compositions that wire a reader over
Expand Down Expand Up @@ -2626,6 +2637,22 @@ impl RebornServices {

#[async_trait]
impl RebornServicesApi for RebornServices {
async fn global_auto_approve_enabled(
&self,
caller: WebUiAuthenticatedCaller,
) -> Result<bool, RebornServicesError> {
let Some(config) = &self.operator_approval_config else {
return Ok(false);
};
let scope = caller_resource_scope(&caller);
let operator_scope = operator_tool_permission_scope(&scope);
config
.auto_approve
.is_enabled(&operator_scope)
.await
.map_err(operator_config_store_error)
}

async fn get_operator_setup(
&self,
caller: WebUiAuthenticatedCaller,
Expand Down
38 changes: 36 additions & 2 deletions crates/ironclaw_webui_v2/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ use crate::router::{WebUiV2Capabilities, WebUiV2State};
use crate::schema::WebChatV2EventFrame;
use crate::sse_capacity::{SSE_MAX_LIFETIME, SseSlot};

const GLOBAL_AUTO_APPROVE_FEATURE_TIMEOUT: Duration = Duration::from_millis(100);
const SETTINGS_TOOLS_AUTO_APPROVE_KEY: &str = "agent.auto_approve_tools";
const SETTINGS_TOOL_CONFIG_PREFIX: &str = "tool.";
const SETTINGS_TOOL_CAPABILITY_ID_MAX_BYTES: usize =
Expand Down Expand Up @@ -96,6 +97,10 @@ pub struct WebUiV2Features {
/// `IRONCLAW_REBORN_PROJECTS`, while the surface is still being
/// finished.
pub reborn_projects: bool,
/// Effective global auto-approve setting for the authenticated caller.
/// The browser treats it as a bootstrap UI flag and does not inspect the
/// operator settings payload shape.
pub global_auto_approve: bool,
}

/// `GET /api/webchat/v2/session`
Expand All @@ -104,17 +109,46 @@ pub async fn get_session(
Extension(caller): Extension<WebUiAuthenticatedCaller>,
Extension(capabilities): Extension<WebUiV2Capabilities>,
) -> Json<WebUiV2SessionResponse> {
let tenant_id = caller.tenant_id.to_string();
let user_id = caller.user_id.to_string();
let global_auto_approve = global_auto_approve_enabled(&state, caller).await;
Json(WebUiV2SessionResponse {
tenant_id: caller.tenant_id.to_string(),
user_id: caller.user_id.to_string(),
tenant_id,
user_id,
capabilities,
features: WebUiV2Features {
reborn_projects: state.reborn_projects_enabled(),
global_auto_approve,
},
attachments: webui_attachment_capabilities(),
})
}

async fn global_auto_approve_enabled(
state: &WebUiV2State,
caller: WebUiAuthenticatedCaller,
) -> bool {
match tokio::time::timeout(
GLOBAL_AUTO_APPROVE_FEATURE_TIMEOUT,
state.services().global_auto_approve_enabled(caller),
)
.await
{
Ok(Ok(enabled)) => enabled,
Ok(Err(error)) => {
tracing::debug!(?error, "failed to read global auto-approve session feature");
false
}
Err(_) => {
tracing::debug!(
timeout_ms = GLOBAL_AUTO_APPROVE_FEATURE_TIMEOUT.as_millis(),
"timed out reading global auto-approve session feature"
);
false
}
}
}
Comment thread
italic-jinxin marked this conversation as resolved.

/// `POST /api/webchat/v2/threads`
///
/// Body shape: [`WebUiCreateThreadRequest`].
Expand Down
140 changes: 140 additions & 0 deletions crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ struct StubServices {
delete_thread_calls: Mutex<Vec<RebornDeleteThreadRequest>>,
submit_turn_calls: Mutex<Vec<WebUiSendMessageRequest>>,
get_timeline_calls: Mutex<Vec<RebornTimelineRequest>>,
global_auto_approve_enabled: Mutex<bool>,
global_auto_approve_calls: Mutex<usize>,
stall_global_auto_approve: Mutex<bool>,
next_global_auto_approve_error: Mutex<Option<RebornServicesError>>,
read_attachment_calls: Mutex<Vec<RebornAttachmentRequest>>,
read_attachment_response: Mutex<Option<RebornAttachmentBytes>>,
stream_events_calls: Mutex<Vec<RebornStreamEventsRequest>>,
Expand Down Expand Up @@ -371,6 +375,25 @@ impl StubServices {

#[async_trait]
impl RebornServicesApi for StubServices {
async fn global_auto_approve_enabled(
&self,
_caller: WebUiAuthenticatedCaller,
) -> Result<bool, RebornServicesError> {
*self.global_auto_approve_calls.lock().expect("lock") += 1;
if *self.stall_global_auto_approve.lock().expect("lock") {
std::future::pending::<()>().await;
}
if let Some(error) = self
.next_global_auto_approve_error
.lock()
.expect("lock")
.take()
{
return Err(error);
}
Ok(*self.global_auto_approve_enabled.lock().expect("lock"))
}

async fn create_thread(
&self,
_caller: WebUiAuthenticatedCaller,
Expand Down Expand Up @@ -976,6 +999,16 @@ impl RebornServicesApi for StubServices {
.lock()
.expect("lock")
.push(key.clone());
if let Some(entry) = self
.operator_config_entries
.lock()
.expect("lock")
.iter()
.find(|entry| entry.key == key)
.cloned()
{
return Ok(RebornOperatorConfigGetResponse { entry });
}
Ok(RebornOperatorConfigGetResponse {
entry: operator_config_entry(key, serde_json::json!("configured")),
})
Expand Down Expand Up @@ -2758,6 +2791,113 @@ async fn get_session_reports_reborn_projects_feature_from_state_flag() {
}
}

// The approval card hint needs the effective global auto-approve setting. Keep
// that as a narrow facade read surfaced through the session bootstrap feature,
// not an operator config key lookup from the browser or route handler.
#[tokio::test]
async fn get_session_reports_global_auto_approve_feature_from_facade() {
for enabled in [false, true] {
let services = Arc::new(StubServices::default());
*services.global_auto_approve_enabled.lock().expect("lock") = enabled;
let router = webui_v2_router(WebUiV2State::new(
services.clone(),
DEFAULT_SSE_MAX_CONCURRENT_PER_CALLER,
))
.layer(axum::Extension(caller()))
.layer(axum::Extension(WebUiV2Capabilities::default()));

let response = router
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/api/webchat/v2/session")
.body(Body::empty())
.expect("request"),
)
.await
.expect("oneshot");

assert_eq!(response.status(), StatusCode::OK);
let body = read_json(response).await;
assert_eq!(
body["features"]["global_auto_approve"], enabled,
"features.global_auto_approve must mirror the facade flag (enabled={enabled})"
);
assert_eq!(
*services.global_auto_approve_calls.lock().expect("lock"),
1,
"session handler should read the feature through the narrow facade"
);
assert!(
services
.get_operator_config_key_calls
.lock()
.expect("lock")
.is_empty(),
"session handler must not read arbitrary operator config keys"
);
}
}

#[tokio::test]
async fn get_session_defaults_global_auto_approve_false_when_facade_read_fails() {
let services = Arc::new(StubServices::default());
*services
.next_global_auto_approve_error
.lock()
.expect("lock") = Some(service_unavailable_error(false));
let router = webui_v2_router(WebUiV2State::new(
services.clone(),
DEFAULT_SSE_MAX_CONCURRENT_PER_CALLER,
))
.layer(axum::Extension(caller()))
.layer(axum::Extension(WebUiV2Capabilities::default()));

let response = router
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/api/webchat/v2/session")
.body(Body::empty())
.expect("request"),
)
.await
.expect("oneshot");

assert_eq!(response.status(), StatusCode::OK);
let body = read_json(response).await;
assert_eq!(body["features"]["global_auto_approve"], false);
assert_eq!(*services.global_auto_approve_calls.lock().expect("lock"), 1);
}

#[tokio::test]
async fn get_session_defaults_global_auto_approve_false_when_facade_stalls() {
let services = Arc::new(StubServices::default());
*services.stall_global_auto_approve.lock().expect("lock") = true;
let router = webui_v2_router(WebUiV2State::new(
services.clone(),
DEFAULT_SSE_MAX_CONCURRENT_PER_CALLER,
))
.layer(axum::Extension(caller()))
.layer(axum::Extension(WebUiV2Capabilities::default()));

let response = router
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/api/webchat/v2/session")
.body(Body::empty())
.expect("request"),
)
.await
.expect("oneshot");

assert_eq!(response.status(), StatusCode::OK);
let body = read_json(response).await;
assert_eq!(body["features"]["global_auto_approve"], false);
assert_eq!(*services.global_auto_approve_calls.lock().expect("lock"), 1);
}

#[tokio::test]
async fn operator_routes_dispatch_to_facade_with_body_and_query_inputs() {
let services = Arc::new(StubServices::default());
Expand Down
5 changes: 5 additions & 0 deletions crates/ironclaw_webui_v2_static/src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,11 @@ mod tests {
assert!(auth.contains("setIsSessionChecking(Boolean(nextToken))"));
assert!(auth.contains("setIsSessionChecking(true);"));
assert!(auth.contains("isAdmin: Boolean(session?.capabilities?.operator_webui_config)"));
assert!(
auth.contains(
"globalAutoApproveEnabled: Boolean(session?.features?.global_auto_approve)"
)
);
assert!(!auth.contains("isAdmin: false"));

let sidebar_nav = asset_text("js/components/sidebar-nav.js");
Expand Down
1,004 changes: 508 additions & 496 deletions crates/ironclaw_webui_v2_static/static/dist/app.js

Large diffs are not rendered by default.

Large diffs are not rendered by default.

This file was deleted.

Loading
Loading