diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs index 012bc0213e..94d010b8a2 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs @@ -617,7 +617,7 @@ fn presets() -> Value { } } }, - "notes": "Command sources run locally with existing CLI auth (gh) and require user approval. This command is already written for the shell on this computer; keep its quoting style when you change the PR number or repository." + "notes": "Command sources run locally with existing CLI auth (gh) and require user approval only in Ask mode. This command is already written for the shell on this computer; keep its quoting style when you change the PR number or repository." }, { "id": "repository-stats", @@ -880,7 +880,7 @@ Paths are JSON Pointers (e.g. /current/temperature_2m) into the card data. With Data sources (dataSource.kind): - http: url, optional headers, intervalSeconds (30-86400), parse json|text. Fetched from this computer. -- command: shell command run locally on this computer with {shell}, intervalSeconds (30-86400), optional cwd (defaults to the local workspace) and timeoutSeconds (default 20, max 120), parse json|text. Requires user approval; not allowed for remote workspaces. Prefer CLIs that already hold credentials (gh, kubectl) over putting secrets in http headers. {command_guidance} +- command: shell command run locally on this computer with {shell}, intervalSeconds (30-86400), optional cwd (defaults to the local workspace) and timeoutSeconds (default 20, max 120), parse json|text. The permission system requests approval only in Ask mode; Auto Approve and Full Access need no additional confirmation. Not allowed for remote workspaces. Prefer CLIs that already hold credentials (gh, kubectl) over putting secrets in http headers. {command_guidance} Workflow for command sources: 1. Run the exact command once with ExecCommand and confirm it prints the JSON you plan to bind (card commands additionally skip shell profiles). {command_test_guidance} @@ -1083,7 +1083,7 @@ Cards are shown only on this desktop. They are unavailable for remote mobile or fn permission_intents( &self, input: &Value, - _context: &ToolUseContext, + context: &ToolUseContext, ) -> OpenBitFunResult> { let Some(command) = command_source(input) else { return Ok(Vec::new()); @@ -1093,9 +1093,10 @@ Cards are shown only on this desktop. They are unavailable for remote mobile or vec![format!("desktop-card:command:{command}")], ); intent.save_resources.clear(); - intent - .display_metadata - .insert("requiresFreshApproval".to_string(), Value::Bool(true)); + intent.display_metadata.insert( + "requiresFreshApproval".to_string(), + Value::Bool(context.requires_fresh_approval()), + ); intent.display_metadata.insert( "description".to_string(), Value::String( @@ -1918,7 +1919,7 @@ mod tests { } #[test] - fn command_sources_always_require_fresh_approval() { + fn command_sources_require_fresh_approval_in_ask_mode() { let input = json!({ "action": "create", "card": { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs index 4171497d0a..e231630fdf 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs @@ -33,6 +33,8 @@ impl Tool for PageDeployTool { Requires a logged-in GitHub account. This tool is only available after account login. To create or update page content and publish, use PagePublish instead. Existing versions can also be reviewed from the Pages scene. +When the user has requested deployment, call the tool directly; the permission system requests confirmation only in Ask mode. Auto Approve and Full Access do not need an additional confirmation. + Input: slug (page path id), version_id (immutable saved version from a prior PagePublish). Returns absolute `url` plus url_path / deployed_version_id. Public links can be shared directly. Private and relay links must be opened or copied through the Pages scene/tool card so the browser receives a scoped one-time access handoff. When telling the user the link: paste the full absolute URL and put a trailing space after it (before any punctuation or newline). @@ -86,7 +88,7 @@ Preview a version at /p/{username}/{slug}/@v/{version_id}."# fn permission_intents( &self, input: &Value, - _context: &ToolUseContext, + context: &ToolUseContext, ) -> OpenBitFunResult> { let slug = input .get("slug") @@ -107,9 +109,10 @@ Preview a version at /p/{username}/{slug}/@v/{version_id}."# "permissionScope".to_string(), Value::String("account".to_string()), ); - intent - .display_metadata - .insert("requiresFreshApproval".to_string(), Value::Bool(true)); + intent.display_metadata.insert( + "requiresFreshApproval".to_string(), + Value::Bool(context.requires_fresh_approval()), + ); intent.display_metadata.insert( "pageOperation".to_string(), Value::String("deploy".to_string()), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs index 0a4c577446..3ba21c09ec 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs @@ -36,7 +36,7 @@ impl Tool for PagePublishTool { Requires a logged-in GitHub account. This tool is only available after account login. Published Pages can be reviewed and managed later from the Pages scene. -When you produce self-contained publishable web content (landing page, docs site, or a Page with server/worker.js) and the user is logged in, proactively ask whether they want it published to OpenBitFun Page (suggest a slug and visibility). If they already said publish/deploy/上线, proceed with permission confirmation. +When you produce self-contained publishable web content (landing page, docs site, or a Page with server/worker.js) and the user is logged in, proactively ask whether they want it published to OpenBitFun Page (suggest a slug and visibility). If they already said publish/deploy/上线, call the tool directly; the permission system requests confirmation only in Ask mode. Auto Approve and Full Access do not need an additional confirmation. IMPORTANT — content source: - Prefer `files` (inline path→UTF-8 content). For agent-authored pages, pass HTML/JS directly in `files` and call PagePublish. Do NOT Write/Edit page files into the user workspace just to publish, and do NOT create folders like openbitfun-page/ unless the user explicitly asked to keep a local copy. @@ -127,7 +127,7 @@ Use PageDeploy only to switch an already-saved version_id (rollback / promote a fn permission_intents( &self, input: &Value, - _context: &ToolUseContext, + context: &ToolUseContext, ) -> OpenBitFunResult> { let slug = input .get("slug") @@ -154,16 +154,17 @@ Use PageDeploy only to switch an already-saved version_id (rollback / promote a } ); let mut intent = PermissionIntent::new("page_publish", vec![resource]); - // Publishing decisions should stay per-call: a remembered wildcard grant could - // otherwise hide a later change from private preview to public production. + // In Ask mode, keep publishing decisions per-call so a remembered grant + // cannot hide a later change from private preview to public production. intent.save_resources.clear(); intent.display_metadata.insert( "permissionScope".to_string(), Value::String("account".to_string()), ); - intent - .display_metadata - .insert("requiresFreshApproval".to_string(), Value::Bool(true)); + intent.display_metadata.insert( + "requiresFreshApproval".to_string(), + Value::Bool(context.requires_fresh_approval()), + ); intent.display_metadata.insert( "pageOperation".to_string(), Value::String(if deploy { "publish" } else { "save" }.to_string()), diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index 0c308e5525..c5fe70cd39 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -4512,6 +4512,131 @@ mod tests { ) } + #[cfg(feature = "tools-pages")] + #[tokio::test] + async fn presentation_tools_follow_permission_mode_without_weakening_denials() { + use crate::agentic::tools::implementations::{ + DesktopCardTool, PageDeployTool, PagePublishTool, + }; + use openbitfun_agent_runtime::permission::PERMISSION_MODE_CONTEXT_KEY; + use openbitfun_runtime_ports::PermissionMode; + + let tools: Vec<(Box, serde_json::Value)> = vec![ + ( + Box::new(DesktopCardTool::new()), + json!({"action": "create", "card": { + "title": "Status", "template": {"kind": "note", "text": "Ready"}, + "dataSource": {"kind": "command", "command": "git status", "intervalSeconds": 60} + }}), + ), + ( + Box::new(PagePublishTool::new()), + json!({"slug": "demo", "visibility": "public", "deploy": true, + "files": {"index.html": "

Demo

"}}), + ), + ( + Box::new(PageDeployTool::new()), + json!({"slug": "demo", "version_id": "v1"}), + ), + ]; + for (tool, input) in tools { + for mode in [ + PermissionMode::Ask, + PermissionMode::AutoApprove, + PermissionMode::FullAccess, + ] { + let store = Arc::new(MemoryPermissionStore::default()); + let manager = permission_test_manager(store); + let mut events = manager.subscribe(); + let pipeline = + test_tool_pipeline().with_permission_request_manager(manager.clone()); + let mut context = permission_test_context(); + // Pages also work without an open workspace. + if tool.name().starts_with("Page") { + context.workspace = None; + } + context + .context_vars + .insert(PERMISSION_MODE_CONTEXT_KEY.into(), mode.as_str().into()); + let mut options = ToolExecutionOptions::default(); + options.auto_approve_ask = mode.auto_approve_ask(); + options.permission_policy = ResolvedPermissionPolicy::new( + vec![PermissionRule::new( + "*", + "*", + if mode == PermissionMode::AutoApprove { + PermissionEffect::Ask + } else { + // Ask still requires a fresh reply even with an allow rule. + PermissionEffect::Allow + }, + )], + Vec::new(), + ); + let task = ToolTask::new( + test_tool_call("presentation", tool.name()), + context, + options, + ); + let tool_context = pipeline.build_tool_use_context(&task, CancellationToken::new()); + let intents = tool.permission_intents(&input, &tool_context).unwrap(); + assert_eq!(intents.len(), 1); + assert_eq!( + intents[0].display_metadata["requiresFreshApproval"], + mode == PermissionMode::Ask + ); + assert!(intents[0].save_resources.is_empty()); + if mode == PermissionMode::Ask { + assert!(matches!( + pipeline + .draft_permission_plan( + task.clone(), + tool.name().into(), + intents.clone(), + tool_context.clone() + ) + .await + .unwrap(), + PermissionPlanDraft::Requests(_) + )); + } else { + let authorization = timeout( + Duration::from_secs(1), + pipeline.authorize_permission_intents( + &task, + tool.name(), + intents.clone(), + &tool_context, + &CancellationToken::new(), + ), + ) + .await + .expect("non-Ask modes must not wait for a user reply") + .unwrap(); + assert!(matches!(authorization, PermissionAuthorization::Allowed)); + assert!(manager.pending_requests().is_empty()); + assert!(matches!( + events.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + } + + let mut denied = task; + denied.options.permission_policy = ResolvedPermissionPolicy::new( + vec![PermissionRule::new("*", "*", PermissionEffect::Deny)], + Vec::new(), + ); + assert!(matches!( + pipeline + .draft_permission_plan(denied, tool.name().into(), intents, tool_context) + .await + .unwrap(), + PermissionPlanDraft::Rejected { .. } + )); + } + } + } + async fn wait_for_permission_request( manager: &PermissionRequestManager, ) -> openbitfun_runtime_ports::PermissionRequest { diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index 66f19c92fa..eb3320c166 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -91,6 +91,24 @@ pub struct ToolUseContext { } impl ToolUseContext { + /// Whether a tool's per-call approval should be raised in the resolved turn mode. + /// Missing mode context keeps the interactive default for older callers. + pub(crate) fn requires_fresh_approval(&self) -> bool { + if let Some(mode) = self + .custom_data + .get(PERMISSION_MODE_CONTEXT_KEY) + .and_then(Value::as_str) + .and_then(PermissionMode::parse) + { + return mode == PermissionMode::Ask; + } + !self + .custom_data + .get(AUTO_APPROVE_ASK_CONTEXT_KEY) + .and_then(Value::as_bool) + .unwrap_or(false) + } + pub(crate) fn delegation_policy(&self) -> DelegationPolicy { delegation_policy_from_custom_data(&self.custom_data) } @@ -1641,7 +1659,7 @@ mod context_builder_tests { #[cfg(test)] mod task_context_tests { - use super::build_tool_use_context_for_task; + use super::{build_tool_use_context_for_task, ToolUseContext}; use crate::agentic::core::ToolCall; use crate::agentic::tools::pipeline::{ SubagentParentInfo, ToolExecutionContext, ToolExecutionOptions, ToolTask, @@ -1660,6 +1678,37 @@ mod task_context_tests { use tokio_util::sync::CancellationToken; use tool_runtime::context::PrimaryModelFacts; + #[test] + fn fresh_approval_uses_the_turn_mode_and_supports_legacy_context() { + let mut context = ToolUseContext::for_tool_listing(None, None); + assert!(context.requires_fresh_approval()); + context + .custom_data + .insert(AUTO_APPROVE_ASK_CONTEXT_KEY.into(), json!(true)); + assert!(!context.requires_fresh_approval()); + for (mode, requires_approval) in [ + ("ask", true), + ("auto_approve", false), + ("auto", false), + ("full_access", false), + ("full", false), + ] { + context + .custom_data + .insert(PERMISSION_MODE_CONTEXT_KEY.into(), json!(mode)); + assert_eq!( + context.requires_fresh_approval(), + requires_approval, + "{mode}" + ); + } + context.custom_data.remove(AUTO_APPROVE_ASK_CONTEXT_KEY); + context + .custom_data + .insert(PERMISSION_MODE_CONTEXT_KEY.into(), json!("future-mode")); + assert!(context.requires_fresh_approval()); + } + fn loaded_spec(tool_name: &str) -> LoadedDeferredToolSpec { LoadedDeferredToolSpec { tool_name: tool_name.to_string(),