From b3664fbdaca85af98437d65017311de8fba69de1 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 30 Oct 2026 14:41:16 +0800 Subject: [PATCH 1/6] feat(pages): unify account page management in PageManage --- src/crates/assembly/core/AGENTS.md | 9 +- .../create-agent/references/tool-catalog.md | 3 +- .../agentic/agents/definitions/modes/claw.rs | 3 +- .../assembly/core/src/agentic/agents/mod.rs | 3 +- .../src/agentic/tools/agent-tool-exposure.md | 3 + .../src/agentic/tools/implementations/mod.rs | 4 + .../tools/implementations/page_manage_tool.rs | 457 ++++++++++++++++++ .../assembly/core/src/agentic/tools/mod.rs | 2 + .../src/agentic/tools/page_manage_host.rs | 110 +++++ .../agentic/tools/pipeline/tool_pipeline.rs | 14 +- .../core/src/agentic/tools/product_runtime.rs | 6 +- .../tools/product_runtime/materialization.rs | 3 + .../core/src/agentic/tools/registry.rs | 1 + .../core/src/product_runtime/account_pages.rs | 228 ++++++++- .../config/mode_config_canonicalizer.rs | 105 +++- .../remote_connect/account_pages_tests.rs | 296 +++++++++++- .../runtime_boundary.rs | 2 +- .../execution/tool-provider-groups/src/lib.rs | 7 +- .../src/remote_connect.rs | 4 +- .../src/remote_connect/page_upload.rs | 57 ++- 20 files changed, 1291 insertions(+), 26 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/page_manage_tool.rs create mode 100644 src/crates/assembly/core/src/agentic/tools/page_manage_host.rs diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index e49b90e9d4..7ba5636425 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -555,7 +555,7 @@ cargo test --locked -p openbitfun-core --no-default-features --features remote-c cargo test --locked -p openbitfun-core --no-default-features --features remote-connect --lib service::remote_connect::bot::weixin::tests ``` -Pages account publication and tool gates (including remote directory rejection): +Pages account management and tool gates (including remote directory rejection): ```bash cargo test -p openbitfun-core --no-default-features --features remote-connect,tools-pages,git,ssh-remote --lib page_ @@ -565,6 +565,13 @@ cargo test -p openbitfun-core --no-default-features --features remote-connect,to requires `remote-connect`; CLI and Desktop select both explicitly. Pages does not select MiniApp runtime or market dependencies. +`PageManage` is the default Pages Agent tool. `PagePublish` and `PageDeploy` +remain registered only for legacy explicit tool selections and existing calls; +do not add them back to mode defaults. List/version reads have no mutation +permission intent; writes retain fresh, account-scoped Ask-mode approval. +The focused fixtures cover loopback Relay requests, old Relay payloads, account +switches, and simulated remote workspace rejection, not live remote connections. + Scheduled-job workspace identity and the temporary 1.0.0 target upgrade boundary: ```bash diff --git a/src/crates/assembly/core/builtin_skills/create-agent/references/tool-catalog.md b/src/crates/assembly/core/builtin_skills/create-agent/references/tool-catalog.md index d5e608763f..ac54600aeb 100644 --- a/src/crates/assembly/core/builtin_skills/create-agent/references/tool-catalog.md +++ b/src/crates/assembly/core/builtin_skills/create-agent/references/tool-catalog.md @@ -115,6 +115,5 @@ Use these tables to select tools for the user's tasks, then put their exact name | `SessionMessage` | Send a message to another agent session and receive its result asynchronously. | | `SessionHistory` | Export an agent session transcript and index for targeted history reads. | | `PortForward` | Forward a port from an SSH host to the user's machine. | -| `PagePublish` | Upload, save a version of, and deploy an OpenBitFun Page. | -| `PageDeploy` | Deploy a saved OpenBitFun Page version to production. | +| `PageManage` | List, publish, update, delete, manage visibility, and deploy saved OpenBitFun Page versions. | | `GetTime` | Return the current time, weekday, and Unix timestamp. | diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs index 34afa612e5..dfa6b1b559 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs @@ -57,8 +57,7 @@ impl ClawMode { "ComputerUse".to_string(), "OpenBitFunControl".to_string(), "PublishAppearance".to_string(), - "PageDeploy".to_string(), - "PagePublish".to_string(), + "PageManage".to_string(), ], } } diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 5dc38751e3..d2b778d094 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -129,8 +129,7 @@ pub fn standard_harness_tools() -> Vec { // for the interval. "Cron".to_string(), "PublishAppearance".to_string(), - "PageDeploy".to_string(), - "PagePublish".to_string(), + "PageManage".to_string(), ] } diff --git a/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md index b6f2d4f5a3..ae54daf820 100644 --- a/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md +++ b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md @@ -17,6 +17,8 @@ Notes: inherit each tool's default behavior. - Dynamically registered MCP tools default to `Deferred`; they are discovered at runtime and are not enumerated in the built-in table below. +- Pages defaults expose `PageManage`. `PagePublish` and `PageDeploy` remain + registered for legacy explicit Agent tool selections and existing calls only. - When `ai.enable_deferred_tool_loading=false`, all allowed tools are exposed as `Direct`, and `GetToolSpec` plus `CallDeferredTool` are removed from the model-visible manifest. @@ -83,6 +85,7 @@ Notes: | `PublishMiniApp` | Direct | None | - | | `FrontendWorkbench` | Direct | None | - | | `PublishAppearance` | Direct | None | - | +| `PageManage` | Direct | None | - | | `PageDeploy` | Direct | None | - | | `PagePublish` | Direct | None | - | | `ControlHub` | Deferred | `ComputerUse` | Direct | diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index d4acf4a845..c7a5822945 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -62,6 +62,8 @@ pub mod openbitfun_control_tool; #[cfg(feature = "tools-pages")] pub mod page_deploy_tool; #[cfg(feature = "tools-pages")] +pub mod page_manage_tool; +#[cfg(feature = "tools-pages")] pub mod page_publish_tool; mod plan_artifact_diagnostics; #[cfg(feature = "tools-miniapp")] @@ -144,6 +146,8 @@ pub use openbitfun_control_tool::OpenBitFunControlTool; #[cfg(feature = "tools-pages")] pub use page_deploy_tool::PageDeployTool; #[cfg(feature = "tools-pages")] +pub use page_manage_tool::PageManageTool; +#[cfg(feature = "tools-pages")] pub use page_publish_tool::PagePublishTool; #[cfg(feature = "tools-miniapp")] pub use playbook_tool::PlaybookTool; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/page_manage_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/page_manage_tool.rs new file mode 100644 index 0000000000..80d05c000d --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/page_manage_tool.rs @@ -0,0 +1,457 @@ +//! Unified account-backed Pages management for Agents. + +use crate::agentic::tools::framework::{ + PermissionIntent, Tool, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::agentic::tools::page_manage_host::{invoke_page_manage, PageManageRequest}; +use crate::agentic::tools::page_publish_host::page_account_available; +use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +#[derive(Default)] +pub struct PageManageTool; + +impl PageManageTool { + pub fn new() -> Self { + Self + } +} + +fn parse_request( + input: &Value, + context: Option<&ToolUseContext>, +) -> Result { + let request: PageManageRequest = serde_json::from_value(input.clone()) + .map_err(|error| format!("Invalid PageManage input: {error}"))?; + if let Some(slug) = request.slug() { + if slug.is_empty() + || slug.len() > 64 + || slug.starts_with('-') + || !slug + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err("slug must be 1-64 lowercase letters, digits, or hyphens, starting with a letter or digit".into()); + } + } + match &request { + PageManageRequest::Publish { visibility, .. } + | PageManageRequest::Permissions { visibility, .. } + if !matches!(visibility.as_str(), "private" | "relay" | "public") => + { + return Err("visibility must be private, relay, or public".into()); + } + PageManageRequest::Deploy { version_id, .. } + | PageManageRequest::Delete { + version_id: Some(version_id), + .. + } if version_id.is_empty() + || !version_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) => + { + return Err("version_id must be a saved version identifier".into()); + } + _ => {} + } + if let PageManageRequest::Publish { + files, + directory, + title, + .. + } + | PageManageRequest::Update { + files, + directory, + title, + .. + } = &request + { + let has_content = files.is_some() || directory.is_some(); + if files.is_some() && directory.is_some() { + return Err("provide either directory or files, not both".into()); + } + if let Some(files) = files { + if !files.contains_key("index.html") && !files.contains_key("server/worker.js") { + return Err("files must include index.html and/or server/worker.js".into()); + } + } + if title.as_ref().is_some_and(|title| title.trim().is_empty()) { + return Err("title must not be empty".into()); + } + if let Some(directory) = directory { + if directory.trim().is_empty() { + return Err("directory must not be empty".into()); + } + if let Some(context) = context { + if context.is_remote() { + return Err("PageManage cannot read a remote workspace directory through the local host; use inline files".into()); + } + if context.workspace.is_none() { + return Err("PageManage directory requires a local workspace; use inline files when no workspace is open".into()); + } + } + } + match &request { + PageManageRequest::Publish { .. } if !has_content => { + return Err( + "publish requires files or directory; use deploy for a saved version".into(), + ); + } + PageManageRequest::Update { note, deploy, .. } if !has_content => { + if title.is_none() || note.is_some() || *deploy { + return Err( + "update requires title or content; note and deploy require content".into(), + ); + } + } + _ => {} + } + } + Ok(request) +} + +#[async_trait] +impl Tool for PageManageTool { + fn name(&self) -> &str { + "PageManage" + } + + async fn description(&self) -> OpenBitFunResult { + Ok(r#"Manage OpenBitFun Pages on the executing host's account relay. Requires GitHub account login. + +Actions: +- list: list published Pages by default, with name/title, slug, absolute url, visibility, updated_at (Unix seconds), generation and deployed_version_id. published_only=false also includes saved/unpublished Pages. +- publish: upload a complete files bundle or an existing local directory, save an immutable version, optionally deploy. visibility defaults to private; deploy defaults to false (preview only). Use public or deploy=true only when the user asks for it. +- update: update an existing Page's title and/or replace its content with a complete bundle. Preserves visibility and the existing title unless title is supplied. Content is saved as a new version; deploy=true makes it live. Use permissions to change access. +- permissions: set visibility to private (owner), relay (signed-in relay users), or public (anyone). +- delete: permanently delete a Page and all versions; with version_id, delete only that saved version. +- versions: list saved versions and their preview URLs for a slug. +- deploy: promote or roll back to an existing version_id without uploading content. +- unpublish: take a Page offline while retaining saved versions. + +Prefer inline files (relative path -> UTF-8 content); include index.html and/or server/worker.js. Do not create workspace files solely to publish. directory is only for sources already on disk or explicitly requested local copies; remote workspaces must use files. Content updates replace the entire bundle, not a patch. +Use the returned version_id to retry deployment after a saved-version/deploy failure. Do not upload again blindly. +Public URLs can be shared directly. Private/relay URLs require authenticated access; open them through the Pages scene/tool card. Never describe raw restricted URLs as public share links. +When the user requests an operation, call it directly. The permission system handles confirmation in Ask mode; Auto Approve and Full Access need no extra confirmation."#.into()) + } + + fn short_description(&self) -> String { + "List, publish, update, delete, and manage access to OpenBitFun Pages.".into() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", "additionalProperties": false, "required": ["action"], + "properties": { + "action": {"type": "string", "enum": ["list", "publish", "update", "permissions", "delete", "versions", "deploy", "unpublish"]}, + "slug": {"type": "string", "description": "Required except for list. Lowercase letters, digits, hyphens."}, + "published_only": {"type": "boolean", "description": "list only. Defaults to true; false also includes unpublished Pages."}, + "title": {"type": "string", "description": "publish/update only. Page name."}, + "visibility": {"type": "string", "enum": ["private", "relay", "public"], "description": "publish (default private) or permissions (required)."}, + "files": {"type": "object", "additionalProperties": {"type": "string"}, "description": "publish/update only. Complete inline file bundle; includes index.html and/or server/worker.js. Mutually exclusive with directory."}, + "directory": {"type": "string", "description": "publish/update only. Existing local workspace directory. Remote workspaces must use files."}, + "note": {"type": "string", "description": "Version note for publish/content update."}, + "deploy": {"type": "boolean", "description": "publish/content update only. Default false saves a preview; true deploys the new version."}, + "version_id": {"type": "string", "description": "Required for deploy; optional for delete (omit to delete the entire Page). Obtain from versions or a saved version result."} + } + }) + } + + fn output_schema(&self) -> Value { + let page = json!({"type": "object", "properties": { + "slug": {"type": "string"}, "name": {"type": "string"}, "title": {"type": "string"}, + "url": {"type": "string"}, "visibility": {"type": "string"}, + "updated_at": {"type": "integer", "description": "Unix timestamp in seconds"}, + "published": {"type": "boolean"}, "generation": {"type": "string"}, + "deployed_version_id": {"type": ["string", "null"]} + }, "required": ["slug", "name", "url", "visibility", "updated_at"], "additionalProperties": true}); + json!({"type": "object", "properties": { + "action": {"type": "string"}, "pages": {"type": "array", "items": page}, + "versions": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "slug": {"type": "string"}, "version_id": {"type": "string"}, + "visibility": {"type": "string"}, "url": {"type": "string"}, + "preview_url": {"type": "string"}, "deployed": {"type": "boolean"}, + "deleted": {"type": "boolean"}, "unpublished": {"type": "boolean"} + }, "required": ["action"], "additionalProperties": true}) + } + + fn is_concurrency_safe(&self, input: Option<&Value>) -> bool { + input + .and_then(|input| parse_request(input, None).ok()) + .is_some_and(|request| request.is_readonly()) + } + + async fn validate_input( + &self, + input: &Value, + context: Option<&ToolUseContext>, + ) -> ValidationResult { + match parse_request(input, context) { + Ok(_) => ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + }, + Err(message) => ValidationResult { + result: false, + message: Some(message), + error_code: Some(400), + meta: None, + }, + } + } + + fn permission_intents( + &self, + input: &Value, + context: &ToolUseContext, + ) -> OpenBitFunResult> { + let request = parse_request(input, Some(context)).map_err(OpenBitFunError::tool)?; + if request.is_readonly() { + return Ok(Vec::new()); + } + let mut resource = format!( + "page:{}; action={}", + request.slug().unwrap_or_default(), + request.action() + ); + // Include resolved defaults, so remote approval mailboxes also show + // private visibility and preview-only publication when fields are omitted. + let normalized = serde_json::to_value(&request) + .map_err(|error| OpenBitFunError::tool(error.to_string()))?; + for key in ["visibility", "title", "deploy", "version_id"] { + if let Some(value) = normalized.get(key).filter(|value| !value.is_null()) { + resource.push_str(&format!("; {key}={value}")); + } + } + // Content replacement has the same upload/deploy effects as legacy + // PagePublish, so existing publication denials must still apply. + let permission_action = match &request { + PageManageRequest::Update { + files, directory, .. + } if files.is_some() || directory.is_some() => "page_publish".to_string(), + _ => format!("page_{}", request.action()), + }; + let mut intent = PermissionIntent::new(permission_action, vec![resource]); + intent.save_resources.clear(); + intent + .display_metadata + .insert("permissionScope".into(), json!("account")); + intent.display_metadata.insert( + "requiresFreshApproval".into(), + json!(context.requires_fresh_approval()), + ); + intent + .display_metadata + .insert("pageOperation".into(), json!(request.action())); + intent + .display_metadata + .insert("pageSlug".into(), json!(request.slug())); + if let Some(visibility) = normalized.get("visibility") { + intent + .display_metadata + .insert("pageVisibility".into(), visibility.clone()); + } + if let Some(version) = normalized + .get("version_id") + .filter(|value| !value.is_null()) + { + intent + .display_metadata + .insert("pageVersionId".into(), version.clone()); + } + Ok(vec![intent]) + } + + async fn is_available_in_context(&self, _context: Option<&ToolUseContext>) -> bool { + page_account_available().await + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> OpenBitFunResult> { + let request = parse_request(input, Some(context)).map_err(OpenBitFunError::tool)?; + if !page_account_available().await { + return Err(OpenBitFunError::tool( + "PageManage requires a logged-in GitHub account", + )); + } + let result = invoke_page_manage(request) + .await + .map_err(OpenBitFunError::tool)?; + let assistant = format!("{}\nPrivate/relay URLs require authenticated access; use the Pages scene/tool card to open them.", serde_json::to_string(&result).map_err(|error| OpenBitFunError::tool(error.to_string()))?); + Ok(vec![ToolResult::Result { + data: result, + result_for_assistant: Some(assistant), + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::tools::account_login_capability::{ + lock_account_login_for_test, set_account_login_available, + }; + use std::collections::HashMap; + + fn context() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: Default::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: Default::default(), + runtime_handles: Default::default(), + } + } + + #[test] + fn page_manage_validates_actions_before_any_host_call() { + for input in [ + json!({"action": "list", "visibility": "public"}), + json!({"action": "permissions", "slug": "demo"}), + json!({"action": "permissions", "slug": "demo", "visibility": "everyone"}), + json!({"action": "delete", "slug": "../demo"}), + json!({"action": "delete", "slug": "demo", "version_id": "v1?other=x"}), + json!({"action": "publish", "slug": "demo"}), + json!({"action": "publish", "slug": "demo", "files": {"style.css": ""}}), + json!({"action": "publish", "slug": "demo", "files": {"index.html": ""}, "directory": "x"}), + json!({"action": "update", "slug": "demo"}), + json!({"action": "update", "slug": "demo", "title": "Demo", "deploy": true}), + json!({"action": "update", "slug": "demo", "title": "Demo", "visibility": "public"}), + ] { + assert!(parse_request(&input, Some(&context())).is_err(), "{input}"); + } + assert!(parse_request( + &json!({"action":"update", "slug":"demo", "title":"New name"}), + Some(&context()) + ) + .is_ok()); + } + + #[test] + fn page_manage_defaults_do_not_deploy_or_publish_publicly() { + let request = parse_request( + &json!({"action":"publish", "slug":"demo", "files":{"index.html":"Hello"}}), + None, + ) + .unwrap(); + assert!( + matches!(request, PageManageRequest::Publish { deploy: false, ref visibility, .. } if visibility == "private") + ); + assert!(matches!( + parse_request(&json!({"action":"list"}), None).unwrap(), + PageManageRequest::List { + published_only: true + } + )); + } + + #[test] + fn page_manage_reads_need_no_approval_but_mutations_are_account_scoped() { + let tool = PageManageTool::new(); + let preview = tool + .permission_intents( + &json!({"action":"publish", "slug":"demo", "files":{"index.html":"Hello"}}), + &context(), + ) + .unwrap(); + assert!(preview[0].resources[0].contains("visibility=\"private\"")); + assert!(preview[0].resources[0].contains("deploy=false")); + let replacement = tool.permission_intents(&json!({"action":"update", "slug":"demo", "files":{"index.html":"Hello"}, "deploy":true}), &context()).unwrap(); + assert_eq!(replacement[0].action, "page_publish"); + let rename = tool + .permission_intents( + &json!({"action":"update", "slug":"demo", "title":"Renamed"}), + &context(), + ) + .unwrap(); + assert_eq!(rename[0].action, "page_update"); + for input in [ + json!({"action":"list"}), + json!({"action":"versions", "slug":"demo"}), + ] { + assert!(tool + .permission_intents(&input, &context()) + .unwrap() + .is_empty()); + assert!(tool.is_concurrency_safe(Some(&input))); + } + for input in [ + json!({"action":"publish", "slug":"demo", "files":{"index.html":"Hello"}}), + json!({"action":"update", "slug":"demo", "title":"New name"}), + json!({"action":"permissions", "slug":"demo", "visibility":"public"}), + json!({"action":"delete", "slug":"demo"}), + json!({"action":"deploy", "slug":"demo", "version_id":"v1"}), + json!({"action":"unpublish", "slug":"demo"}), + ] { + let intents = tool.permission_intents(&input, &context()).unwrap(); + assert_eq!(intents.len(), 1); + assert_eq!(intents[0].display_metadata["permissionScope"], "account"); + assert!(intents[0].save_resources.is_empty()); + assert!(!tool.is_concurrency_safe(Some(&input))); + } + } + + #[tokio::test] + async fn page_manage_login_gate_and_no_workspace_inline_content() { + let _guard = lock_account_login_for_test(); + let tool = PageManageTool::new(); + set_account_login_available(false); + assert!(!tool.is_available_in_context(None).await); + assert!(tool + .call_impl(&json!({"action":"list"}), &context()) + .await + .unwrap_err() + .to_string() + .contains("logged-in")); + set_account_login_available(true); + assert!(tool.is_available_in_context(None).await); + let inline = json!({"action":"publish", "slug":"demo", "files":{"index.html":"Hello"}}); + assert!(tool.validate_input(&inline, Some(&context())).await.result); + let directory = json!({"action":"publish", "slug":"demo", "directory":"/tmp/demo"}); + assert!( + !tool + .validate_input(&directory, Some(&context())) + .await + .result + ); + let mut remote = context(); + remote.workspace = Some(crate::agentic::WorkspaceBinding::new_remote( + None, + std::path::PathBuf::from("/srv/page"), + "page-connection".into(), + "Remote".into(), + crate::service::remote_ssh::workspace_state::WorkspaceSessionIdentity { + workspace_kind: openbitfun_core_types::WorkspaceKind::Remote, + hostname: "remote.example".into(), + logical_workspace_path: "/srv/page".into(), + remote_connection_id: Some("page-connection".into()), + }, + )); + for action in ["publish", "update"] { + let input = json!({"action":action, "slug":"demo", "directory":"/srv/page"}); + assert!(tool + .validate_input(&input, Some(&remote)) + .await + .message + .unwrap() + .contains("remote workspace")); + let inline = json!({"action":action, "slug":"demo", "files":{"index.html":"Hello"}}); + assert!(tool.validate_input(&inline, Some(&remote)).await.result); + } + set_account_login_available(false); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/mod.rs b/src/crates/assembly/core/src/agentic/tools/mod.rs index 8f573e2bc9..e623e2b00c 100644 --- a/src/crates/assembly/core/src/agentic/tools/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/mod.rs @@ -26,6 +26,8 @@ pub mod openbitfun_control_host; #[cfg(feature = "tools-pages")] pub mod page_deploy_host; #[cfg(feature = "tools-pages")] +pub mod page_manage_host; +#[cfg(feature = "tools-pages")] pub mod page_publish_host; pub mod pipeline; #[cfg(feature = "plugin-runtime")] diff --git a/src/crates/assembly/core/src/agentic/tools/page_manage_host.rs b/src/crates/assembly/core/src/agentic/tools/page_manage_host.rs new file mode 100644 index 0000000000..b4748ceedf --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/page_manage_host.rs @@ -0,0 +1,110 @@ +//! Typed Pages operations, bound to the account on the executing host. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, OnceLock}; + +fn published_only_default() -> bool { + true +} + +fn private_visibility() -> String { + "private".into() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +pub enum PageManageRequest { + List { + #[serde(default = "published_only_default")] + published_only: bool, + }, + Publish { + slug: String, + #[serde(default = "private_visibility")] + visibility: String, + title: Option, + note: Option, + #[serde(default)] + deploy: bool, + files: Option>, + directory: Option, + }, + Update { + slug: String, + title: Option, + note: Option, + #[serde(default)] + deploy: bool, + files: Option>, + directory: Option, + }, + Permissions { + slug: String, + visibility: String, + }, + Delete { + slug: String, + version_id: Option, + }, + Versions { + slug: String, + }, + Deploy { + slug: String, + version_id: String, + }, + Unpublish { + slug: String, + }, +} + +impl PageManageRequest { + pub fn action(&self) -> &'static str { + match self { + Self::List { .. } => "list", + Self::Publish { .. } => "publish", + Self::Update { .. } => "update", + Self::Permissions { .. } => "permissions", + Self::Delete { .. } => "delete", + Self::Versions { .. } => "versions", + Self::Deploy { .. } => "deploy", + Self::Unpublish { .. } => "unpublish", + } + } + + pub fn slug(&self) -> Option<&str> { + match self { + Self::List { .. } => None, + Self::Publish { slug, .. } + | Self::Update { slug, .. } + | Self::Permissions { slug, .. } + | Self::Delete { slug, .. } + | Self::Versions { slug } + | Self::Deploy { slug, .. } + | Self::Unpublish { slug } => Some(slug), + } + } + + pub fn is_readonly(&self) -> bool { + matches!(self, Self::List { .. } | Self::Versions { .. }) + } +} + +pub type PageManageFuture = Pin> + Send>>; +pub type PageManageHandler = Arc PageManageFuture + Send + Sync>; +static PAGE_MANAGE_HANDLER: OnceLock = OnceLock::new(); + +pub fn set_page_manage_handler(handler: PageManageHandler) { + let _ = PAGE_MANAGE_HANDLER.set(handler); +} + +pub async fn invoke_page_manage(request: PageManageRequest) -> Result { + let handler = PAGE_MANAGE_HANDLER + .get() + .ok_or("PageManage host is not available on this surface")?; + handler(request).await +} 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 1aa4b21809..15d2b6c616 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 @@ -4517,12 +4517,24 @@ mod tests { #[tokio::test] async fn presentation_tools_follow_permission_mode_without_weakening_denials() { use crate::agentic::tools::implementations::{ - DesktopCardTool, PageDeployTool, PagePublishTool, + DesktopCardTool, PageDeployTool, PageManageTool, 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(PageManageTool::new()), + json!({"action": "permissions", "slug": "demo", "visibility": "public"}), + ), + ( + Box::new(PageManageTool::new()), + json!({"action": "update", "slug": "demo", "files": {"index.html": "Updated"}, "deploy": true}), + ), + ( + Box::new(PageManageTool::new()), + json!({"action": "delete", "slug": "demo"}), + ), ( Box::new(DesktopCardTool::new()), json!({"action": "create", "card": { diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs index cfbf4fedaa..4997ab3471 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs @@ -240,14 +240,16 @@ mod tests { .create_registry() .expect("CLI runtime plan must materialize in the product-full test build"); - for tool in ["PagePublish", "PageDeploy"] { + for tool in ["PageManage", "PagePublish", "PageDeploy"] { assert!(cli.get_tool(tool).is_some(), "CLI must materialize {tool}"); assert!(sdk.get_tool(tool).is_none(), "SDK must not inherit {tool}"); } let without_pages = |names: Vec| { names .into_iter() - .filter(|name| !matches!(name.as_str(), "PagePublish" | "PageDeploy")) + .filter(|name| { + !matches!(name.as_str(), "PageManage" | "PagePublish" | "PageDeploy") + }) .collect::>() }; assert_eq!(sdk.get_tool_names(), without_pages(cli.get_tool_names())); diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index def8570c79..5898fcb632 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -76,6 +76,7 @@ const PRODUCT_TOOL_REGISTRATION_ORDER: &[&str] = &[ "MiniAppBrowser", "FrontendWorkbench", "PublishAppearance", + "PageManage", "PageDeploy", "PagePublish", "ControlHub", @@ -193,6 +194,8 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { #[cfg(feature = "tools-miniapp")] "PublishAppearance" => Some(Arc::new(PublishAppearanceTool::new())), #[cfg(feature = "tools-pages")] + "PageManage" => Some(Arc::new(PageManageTool::new())), + #[cfg(feature = "tools-pages")] "PageDeploy" => Some(Arc::new(PageDeployTool::new())), #[cfg(feature = "tools-pages")] "PagePublish" => Some(Arc::new(PagePublishTool::new())), diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index 599c48a7a3..72d8048fe8 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -614,6 +614,7 @@ mod tests { "MiniAppBrowser", "FrontendWorkbench", "PublishAppearance", + "PageManage", "PageDeploy", "PagePublish", "ControlHub", diff --git a/src/crates/assembly/core/src/product_runtime/account_pages.rs b/src/crates/assembly/core/src/product_runtime/account_pages.rs index c5b0284b95..5f6b33d0c7 100644 --- a/src/crates/assembly/core/src/product_runtime/account_pages.rs +++ b/src/crates/assembly/core/src/product_runtime/account_pages.rs @@ -1,14 +1,17 @@ //! Wire account-backed Pages tools to the account owner on the executing host. use crate::agentic::tools::page_deploy_host::set_page_deploy_handler; +use crate::agentic::tools::page_manage_host::{set_page_manage_handler, PageManageRequest}; use crate::agentic::tools::page_publish_host::{ set_page_account_availability, set_page_publish_handler, PageAccountAvailability, PagePublishHostRequest, }; use crate::service::remote_connect::account_runtime::AccountRuntime; use openbitfun_services_integrations::remote_connect::{ - deploy_page_version_on_relay, join_relay_url, list_pages_from_relay, - publish_page_content_on_relay, + delete_page_from_relay, delete_page_version_on_relay, deploy_page_version_on_relay, + join_relay_url, list_page_versions_from_relay, list_pages_from_relay, + publish_page_content_on_relay, unpublish_page_from_relay, update_page_content_on_relay, + update_page_on_relay, PageInfo, }; use serde_json::Value; use std::sync::Arc; @@ -18,6 +21,14 @@ use std::sync::Arc; pub fn register_account_pages(runtime: &Arc) { set_page_account_availability(account_availability(runtime)); let account = Arc::downgrade(runtime); + set_page_manage_handler(Arc::new(move |request| { + let account = account.upgrade(); + Box::pin(async move { + let account = account.ok_or("Pages account runtime is unavailable")?; + manage(&account, request).await + }) + })); + let account = Arc::downgrade(runtime); set_page_publish_handler(Arc::new(move |request| { let account = account.upgrade(); Box::pin(async move { @@ -35,6 +46,219 @@ pub fn register_account_pages(runtime: &Arc) { })); } +fn page_value(relay_url: &str, page: PageInfo) -> Result { + let url = join_relay_url(relay_url, &page.url_path); + let preview_url = page + .preview_url_path + .as_deref() + .map(|path| join_relay_url(relay_url, path)); + let published = page + .deployed_version_id + .as_deref() + .is_some_and(|version| !version.is_empty()); + let mut value = serde_json::to_value(page).map_err(|error| error.to_string())?; + value["name"] = value["title"].clone(); + value["url"] = serde_json::json!(url); + value["published"] = serde_json::json!(published); + if let Some(url) = preview_url { + value["preview_url"] = serde_json::json!(url); + } + Ok(value) +} + +pub(crate) async fn manage( + account: &AccountRuntime, + request: PageManageRequest, +) -> Result { + let action = request.action(); + if let PageManageRequest::Publish { + slug, + visibility, + title, + note, + deploy, + directory, + files, + } = request + { + let mut result = publish( + account, + PagePublishHostRequest { + slug, + visibility, + title, + note, + deploy, + directory, + files, + }, + ) + .await?; + result["action"] = serde_json::json!(action); + return Ok(result); + } + if account.is_token_expired() { + return Err("Pages account session expired; sign in again".into()); + } + let generation = account.account_context_generation(); + let (session, relay_url) = account + .read_account_context_for_generation(generation) + .await + .map_err(|error| error.to_string())?; + let mut pages = list_pages_from_relay(&relay_url, &session.token) + .await + .map_err(|error| error.to_string())?; + if !account.account_context_is_current(generation) { + return Err("account context changed".into()); + } + if let PageManageRequest::List { published_only } = request { + pages.retain(|page| { + !published_only + || page + .deployed_version_id + .as_deref() + .is_some_and(|id| !id.is_empty()) + }); + pages.sort_by(|a, b| { + b.updated_at + .cmp(&a.updated_at) + .then_with(|| a.slug.cmp(&b.slug)) + }); + let pages = pages + .into_iter() + .map(|page| page_value(&relay_url, page)) + .collect::, _>>()?; + return Ok(serde_json::json!({"action": action, "pages": pages})); + } + let slug = request.slug().ok_or("Page slug is required")?; + let page = pages + .into_iter() + .find(|page| page.slug == slug) + .ok_or("Page not found; use list with published_only=false before retrying")?; + let readonly = request.is_readonly(); + let token = &session.token; + let mut result = match request { + PageManageRequest::Update { + title, + note, + deploy, + files, + directory, + .. + } => { + if files.is_some() || directory.is_some() { + let result = update_page_content_on_relay( + &relay_url, + token, + &page, + title.as_deref(), + note.as_deref(), + deploy, + directory.as_deref(), + files.as_ref(), + ) + .await + .map_err(|error| error.to_string())?; + serde_json::to_value(result).map_err(|error| error.to_string())? + } else { + let info = update_page_on_relay( + &relay_url, + token, + &page.slug, + &page.generation, + None, + title.as_deref(), + ) + .await + .map_err(|error| error.to_string())?; + page_value(&relay_url, info)? + } + } + PageManageRequest::Permissions { visibility, .. } => { + let info = update_page_on_relay( + &relay_url, + token, + &page.slug, + &page.generation, + Some(&visibility), + None, + ) + .await + .map_err(|error| error.to_string())?; + page_value(&relay_url, info)? + } + PageManageRequest::Delete { version_id, .. } => { + match version_id.as_deref() { + Some(version) => { + delete_page_version_on_relay( + &relay_url, + token, + &page.slug, + version, + &page.generation, + ) + .await + } + None => { + delete_page_from_relay(&relay_url, token, &page.slug, &page.generation).await + } + } + .map_err(|error| error.to_string())?; + let mut value = serde_json::json!({"slug": page.slug, "deleted": true}); + if let Some(version_id) = version_id { + value["version_id"] = serde_json::json!(version_id); + } + value + } + PageManageRequest::Unpublish { .. } => { + unpublish_page_from_relay(&relay_url, token, &page.slug, &page.generation) + .await + .map_err(|error| error.to_string())?; + serde_json::json!({"slug": page.slug, "unpublished": true}) + } + PageManageRequest::Versions { .. } => { + let versions = + list_page_versions_from_relay(&relay_url, token, &page.slug, &page.generation) + .await + .map_err(|error| error.to_string())?; + let versions = versions + .into_iter() + .map(|version| { + let preview_url = join_relay_url(&relay_url, &version.preview_url_path); + let mut value = + serde_json::to_value(version).map_err(|error| error.to_string())?; + value["preview_url"] = serde_json::json!(preview_url); + Ok(value) + }) + .collect::, String>>()?; + serde_json::json!({"slug": page.slug, "visibility": page.visibility, "versions": versions}) + } + PageManageRequest::Deploy { version_id, .. } => { + let info = deploy_page_version_on_relay( + &relay_url, + token, + &page.slug, + &version_id, + &page.generation, + ) + .await + .map_err(|error| error.to_string())?; + let mut value = page_value(&relay_url, info)?; + value["version_id"] = serde_json::json!(version_id); + value["deployed"] = serde_json::json!(true); + value + } + PageManageRequest::List { .. } | PageManageRequest::Publish { .. } => unreachable!(), + }; + if !account.account_context_is_current(generation) { + return Err(if readonly { "account context changed" } else { + "account context changed; Page operation outcome is unknown, inspect the original account before retrying" + }.into()); + } + result["action"] = serde_json::json!(action); + Ok(result) +} + pub(crate) async fn publish( account: &AccountRuntime, request: PagePublishHostRequest, diff --git a/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs b/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs index 7ce2870323..aa42db2221 100644 --- a/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs +++ b/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs @@ -111,10 +111,39 @@ pub fn resolve_effective_tools( let mut effective = Vec::new(); let mut seen = HashSet::new(); + // Keep legacy Pages overrides meaningful after the default tool is unified. + // A previous exclusion must not silently re-enable the capability under its + // new name; an explicit legacy opt-in keeps its narrower operation set. + let legacy_pages_override = mode_config.is_some_and(|config| { + config + .removed_tools + .iter() + .chain(&config.added_tools) + .any(|tool| matches!(tool.as_str(), "PagePublish" | "PageDeploy")) + }); + for tool in default_tools { if removed.contains(&tool) { continue; } + if tool == "PageManage" + && legacy_pages_override + && !added.iter().any(|tool| tool == "PageManage") + { + // A legacy exclusion selected a subset of the former two defaults. + // Preserve that subset until the user explicitly selects PageManage. + if removed.contains("PagePublish") || removed.contains("PageDeploy") { + for legacy in ["PageDeploy", "PagePublish"] { + if !removed.contains(legacy) + && valid_tools.contains(legacy) + && seen.insert(legacy.to_string()) + { + effective.push(legacy.to_string()); + } + } + } + continue; + } if seen.insert(tool.clone()) { effective.push(tool); } @@ -208,8 +237,18 @@ fn stored_agent_profile_from_overrides( normalize_skill_override_lists(disabled_user_skills, enabled_user_skills); let subagent_overrides = normalize_subagent_overrides(subagent_overrides); - added_tools.retain(|tool| !default_set.contains(tool)); - removed_tools.retain(|tool| default_set.contains(tool)); + let legacy_pages_override = added_tools + .iter() + .chain(&removed_tools) + .any(|tool| matches!(tool.as_str(), "PagePublish" | "PageDeploy")); + added_tools.retain(|tool| { + !default_set.contains(tool) || (tool == "PageManage" && legacy_pages_override) + }); + removed_tools.retain(|tool| { + default_set.contains(tool) + || (default_set.contains("PageManage") + && matches!(tool.as_str(), "PagePublish" | "PageDeploy")) + }); let removed_set: HashSet = removed_tools.iter().cloned().collect(); added_tools.retain(|tool| !removed_set.contains(tool)); @@ -664,6 +703,68 @@ mod tests { use serde_json::Value; use std::collections::HashSet; + #[test] + fn page_manage_default_preserves_legacy_tool_overrides_and_round_trip() { + let defaults = vec!["PageManage".into()]; + let valid = HashSet::from([ + "PageManage".into(), + "PagePublish".into(), + "PageDeploy".into(), + ]); + assert_eq!( + super::resolve_effective_tools(&defaults, None, &valid), + defaults + ); + for legacy in ["PagePublish", "PageDeploy"] { + let raw = serde_json::json!({"profile_id":"Standard", "removed_tools":[legacy]}); + let config = canonicalize_agent_profile("Standard", Some(&raw), &defaults, &valid) + .unwrap() + .unwrap(); + let retained = if legacy == "PagePublish" { + "PageDeploy" + } else { + "PagePublish" + }; + assert_eq!( + super::resolve_effective_tools(&defaults, Some(&config), &valid), + vec![retained] + ); + let saved = serde_json::to_value(config).unwrap(); + assert_eq!(saved["removed_tools"], raw["removed_tools"]); + let restored = serde_json::from_value(saved).unwrap(); + assert_eq!( + super::resolve_effective_tools(&defaults, Some(&restored), &valid), + vec![retained] + ); + let raw = serde_json::json!({"profile_id":"Standard", "added_tools":[legacy]}); + let config = canonicalize_agent_profile("Standard", Some(&raw), &defaults, &valid) + .unwrap() + .unwrap(); + assert_eq!( + super::resolve_effective_tools(&defaults, Some(&config), &valid), + vec![legacy] + ); + } + let raw = serde_json::json!({"profile_id":"Standard", "removed_tools":["PagePublish", "PageDeploy"]}); + let config = canonicalize_agent_profile("Standard", Some(&raw), &defaults, &valid) + .unwrap() + .unwrap(); + assert!(super::resolve_effective_tools(&defaults, Some(&config), &valid).is_empty()); + let raw = serde_json::json!({"profile_id":"Standard", "removed_tools":["PagePublish"], "added_tools":["PageManage"]}); + let config = canonicalize_agent_profile("Standard", Some(&raw), &defaults, &valid) + .unwrap() + .unwrap(); + assert_eq!( + super::resolve_effective_tools(&defaults, Some(&config), &valid), + defaults + ); + let raw = serde_json::json!({"profile_id":"Standard", "removed_tools":["PageManage", "PagePublish"]}); + let config = canonicalize_agent_profile("Standard", Some(&raw), &defaults, &valid) + .unwrap() + .unwrap(); + assert!(super::resolve_effective_tools(&defaults, Some(&config), &valid).is_empty()); + } + #[test] fn canonicalization_retains_future_fields_even_without_known_overrides() { for raw in [ diff --git a/src/crates/assembly/core/src/service/remote_connect/account_pages_tests.rs b/src/crates/assembly/core/src/service/remote_connect/account_pages_tests.rs index 8e335956d7..2a9be896ec 100644 --- a/src/crates/assembly/core/src/service/remote_connect/account_pages_tests.rs +++ b/src/crates/assembly/core/src/service/remote_connect/account_pages_tests.rs @@ -1,7 +1,8 @@ //! Account-backed Pages adapter contracts against a local Relay fixture. use super::*; +use crate::agentic::tools::page_manage_host::PageManageRequest; use crate::agentic::tools::page_publish_host::PagePublishHostRequest; -use crate::product_runtime::account_pages::{account_availability, deploy, publish}; +use crate::product_runtime::account_pages::{account_availability, deploy, manage, publish}; use serde_json::{json, Value}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -232,3 +233,296 @@ async fn page_deploy_does_not_report_success_after_account_changes() { .contains("outcome is unknown")); server.await.unwrap(); } + +fn manage_request(value: Value) -> PageManageRequest { + serde_json::from_value(value).unwrap() +} + +#[tokio::test] +async fn page_manage_publish_saves_a_private_preview_by_default() { + let (url, server) = relay(vec![ + ("GET /api/pages ", json!([])), + ( + "POST /api/pages/check-files ", + json!({"needed":["index.html"]}), + ), + ("POST /api/pages/upload-files ", json!({})), + ( + "POST /api/pages/demo/versions ", + json!({"version_id":"v1", "title":"demo", "file_count":1, + "total_bytes":5, "has_worker":false, "note":"", "created_at":1, "deployed":false, + "preview_url_path":"/preview/demo/v1"}), + ), + ]) + .await; + let runtime = logged_in(url.clone()).await; + let result = manage( + &runtime, + manage_request(json!({"action":"publish", "slug":"demo", "files":{"index.html":"Hello"}})), + ) + .await + .unwrap(); + assert_eq!(result["action"], "publish"); + assert_eq!(result["deployed"], false); + assert_eq!(result["visibility"], "private"); + assert_eq!(result["preview_url"], format!("{url}/preview/demo/v1")); + let bodies = server.await.unwrap(); + assert_eq!(bodies.len(), 4); + assert_eq!(bodies[2]["visibility"], "private"); +} + +#[tokio::test] +async fn page_manage_rejects_an_unreadable_catalog_instead_of_inventing_empty_results() { + let (url, server) = relay(vec![("GET /api/pages ", json!({"error":"unavailable"}))]).await; + let runtime = logged_in(url).await; + assert!(manage(&runtime, manage_request(json!({"action":"list"}))) + .await + .is_err()); + server.await.unwrap(); +} + +#[tokio::test] +async fn page_manage_list_returns_published_metadata_and_preserves_legacy_payloads() { + let mut private = page(); + private["slug"] = json!("private-demo"); + private["visibility"] = json!("private"); + private["updated_at"] = json!(10); + let mut unpublished = page(); + unpublished["slug"] = json!("draft"); + unpublished["deployed_version_id"] = Value::Null; + unpublished["updated_at"] = json!(20); + unpublished.as_object_mut().unwrap().remove("generation"); + let all = json!([page(), private, unpublished]); + let (url, server) = relay(vec![ + ("GET /api/pages ", all.clone()), + ("GET /api/pages ", all), + ]) + .await; + let runtime = logged_in(url.clone()).await; + let result = manage(&runtime, manage_request(json!({"action":"list"}))) + .await + .unwrap(); + assert_eq!(result["pages"].as_array().unwrap().len(), 2); + assert_eq!(result["pages"][0]["slug"], "private-demo"); + assert_eq!(result["pages"][0]["name"], "Demo"); + assert_eq!(result["pages"][0]["visibility"], "private"); + assert_eq!(result["pages"][0]["updated_at"], 10); + assert_eq!(result["pages"][0]["url"], format!("{url}/p/demo")); + assert!(!result.to_string().contains("test-pages-token")); + let result = manage( + &runtime, + manage_request(json!({"action":"list", "published_only":false})), + ) + .await + .unwrap(); + assert_eq!(result["pages"].as_array().unwrap().len(), 3); + assert_eq!(result["pages"][0]["slug"], "draft"); + assert_eq!(result["pages"][0]["generation"], ""); + assert_eq!(result["pages"][0]["published"], false); + let legacy: openbitfun_services_integrations::remote_connect::PageInfo = + serde_json::from_value(result["pages"][0].clone()).unwrap(); + let round_trip = serde_json::to_value(legacy).unwrap(); + assert_eq!(round_trip["title"], "Demo"); + assert_eq!(round_trip["visibility"], "public"); + server.await.unwrap(); +} + +#[tokio::test] +async fn page_manage_routes_metadata_permissions_and_delete_with_generation() { + for (input, method, expected_body) in [ + ( + json!({"action":"update", "slug":"demo", "title":"New name"}), + "PATCH /api/pages/demo ", + json!({"expected_generation":"generation-a", "title":"New name"}), + ), + ( + json!({"action":"permissions", "slug":"demo", "visibility":"private"}), + "PATCH /api/pages/demo ", + json!({"expected_generation":"generation-a", "visibility":"private"}), + ), + ( + json!({"action":"delete", "slug":"demo"}), + "DELETE /api/pages/demo?expected_generation=generation-a ", + Value::Null, + ), + ( + json!({"action":"delete", "slug":"demo", "version_id":"v1"}), + "DELETE /api/pages/demo/versions/v1?expected_generation=generation-a ", + Value::Null, + ), + ( + json!({"action":"unpublish", "slug":"demo"}), + "POST /api/pages/demo/unpublish?expected_generation=generation-a ", + Value::Null, + ), + ( + json!({"action":"deploy", "slug":"demo", "version_id":"v1"}), + "POST /api/pages/demo/deploy ", + json!({"expected_generation":"generation-a", "version_id":"v1"}), + ), + ] { + let (url, server) = + relay(vec![("GET /api/pages ", json!([page()])), (method, page())]).await; + let runtime = logged_in(url).await; + let result = manage(&runtime, manage_request(input.clone())) + .await + .unwrap(); + assert_eq!(result["action"], input["action"]); + assert_eq!(result["slug"], "demo"); + if input["action"] == "delete" { + assert_eq!(result["deleted"], true); + } + if input["action"] == "unpublish" { + assert_eq!(result["unpublished"], true); + } + if input["action"] == "deploy" { + assert_eq!(result["deployed"], true); + } + let bodies = server.await.unwrap(); + assert_eq!(bodies[1], expected_body); + } +} + +#[tokio::test] +async fn page_manage_content_update_keeps_name_visibility_and_inspected_generation() { + let mut existing = page(); + existing["title"] = json!("Existing title"); + existing["visibility"] = json!("relay"); + let (url, server) = relay(vec![ + ("GET /api/pages ", json!([existing])), + ( + "POST /api/pages/check-files ", + json!({"needed":["index.html"]}), + ), + ("POST /api/pages/upload-files ", json!({})), + ( + "POST /api/pages/demo/versions ", + json!({"version_id":"v2", "title":"Existing title", "file_count":1, + "total_bytes":5, "has_worker":false, "note":"", "created_at":2, "deployed":false, + "preview_url_path":"/preview/demo/v2"}), + ), + ]) + .await; + let runtime = logged_in(url.clone()).await; + let result = manage( + &runtime, + manage_request(json!({"action":"update", "slug":"demo", "files":{"index.html":"Hello"}})), + ) + .await + .unwrap(); + assert_eq!(result["action"], "update"); + assert_eq!(result["title"], "Existing title"); + assert_eq!(result["visibility"], "relay"); + assert_eq!(result["deployed"], false); + assert_eq!(result["preview_url"], format!("{url}/preview/demo/v2")); + let bodies = server.await.unwrap(); + assert_eq!(bodies[1]["expected_generation"], "generation-a"); + assert_eq!(bodies[1]["create"], false); + assert_eq!(bodies[2]["visibility"], "relay"); + assert_eq!(bodies[2]["title"], "Existing title"); +} + +#[tokio::test] +async fn page_manage_versions_has_absolute_previews_and_accepts_old_relay_payloads() { + let (url, server) = relay(vec![ + ("GET /api/pages ", json!([page()])), + ( + "GET /api/pages/demo/versions?expected_generation=generation-a ", + json!([{ + "version_id":"v1", "title":"Demo", "file_count":1, "total_bytes":5, + "has_worker":false, "note":"First", "created_at":1, "deployed":true, + "preview_url_path":"/preview/demo/v1" + }]), + ), + ]) + .await; + let runtime = logged_in(url.clone()).await; + let result = manage( + &runtime, + manage_request(json!({"action":"versions", "slug":"demo"})), + ) + .await + .unwrap(); + assert_eq!( + result["versions"][0]["preview_url"], + format!("{url}/preview/demo/v1") + ); + assert_eq!(result["versions"][0]["generation"], ""); + server.await.unwrap(); +} + +#[tokio::test] +async fn page_manage_missing_page_and_account_switch_never_continue_mutations() { + let (url, server) = relay(vec![("GET /api/pages ", json!([]))]).await; + let runtime = logged_in(url).await; + assert!(manage( + &runtime, + manage_request(json!({"action":"update", "slug":"demo", "files":{"index.html":"New"}})) + ) + .await + .unwrap_err() + .contains("Page not found")); + server.await.unwrap(); + let changed = runtime.clone(); + let (url, server) = relay_with_hook( + vec![("GET /api/pages ", json!([page()]))], + Arc::new(move |_| { + changed + .account_context_generation + .fetch_add(1, Ordering::AcqRel); + }), + ) + .await; + runtime + .account_context + .write() + .await + .as_mut() + .unwrap() + .relay_url = url; + assert_eq!( + manage( + &runtime, + manage_request(json!({"action":"delete", "slug":"demo"})) + ) + .await + .unwrap_err(), + "account context changed" + ); + server.await.unwrap(); +} + +#[tokio::test] +async fn page_manage_does_not_report_success_after_account_switch_during_mutation() { + let runtime = logged_in("http://127.0.0.1:1".into()).await; + let changed = runtime.clone(); + let (url, server) = relay_with_hook( + vec![ + ("GET /api/pages ", json!([page()])), + ("PATCH /api/pages/demo ", page()), + ], + Arc::new(move |index| { + if index == 1 { + changed + .account_context_generation + .fetch_add(1, Ordering::AcqRel); + } + }), + ) + .await; + runtime + .account_context + .write() + .await + .as_mut() + .unwrap() + .relay_url = url; + assert!(manage( + &runtime, + manage_request(json!({"action":"permissions", "slug":"demo", "visibility":"private"})) + ) + .await + .unwrap_err() + .contains("outcome is unknown")); + server.await.unwrap(); +} diff --git a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/runtime_boundary.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/runtime_boundary.rs index 3b9b5cb0b1..e0cf2a668f 100644 --- a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/runtime_boundary.rs +++ b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/runtime_boundary.rs @@ -134,7 +134,7 @@ fn cli_pages_are_independent_of_miniapp_and_acp() { .iter() .flat_map(|group| group.tool_names().iter().copied()) .collect(); - for tool in ["PagePublish", "PageDeploy"] { + for tool in ["PageManage", "PagePublish", "PageDeploy"] { assert!(names.contains(&tool)); } for tool in ["InitMiniApp", "PublishMiniApp", "CreateCanvas"] { diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 156c283759..bdb98ac785 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -114,7 +114,7 @@ pub fn tool_feature_group(tool_name: &str) -> Option { | "MiniAppBrowser" | "PublishAppearance" | "Playbook" => { Some(ToolPackFeatureGroup::MiniApp) } - "PageDeploy" | "PagePublish" => Some(ToolPackFeatureGroup::Pages), + "PageManage" | "PageDeploy" | "PagePublish" => Some(ToolPackFeatureGroup::Pages), "FrontendWorkbench" => Some(ToolPackFeatureGroup::Creation), "CreateCanvas" | "ReadCanvas" | "UpdateCanvas" | "PatchCanvas" => { Some(ToolPackFeatureGroup::Canvas) @@ -281,7 +281,9 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ ToolProviderGroupPlan { provider_id: "core.pages", feature_groups: &[ToolPackFeatureGroup::Pages], - tool_names: &["PageDeploy", "PagePublish"], + // Legacy names remain available for persisted Agent allowlists and old calls. + // New Agent defaults expose only PageManage. + tool_names: &["PageManage", "PageDeploy", "PagePublish"], }, ToolProviderGroupPlan { provider_id: "core.creation", @@ -611,6 +613,7 @@ mod tests { "MiniAppBrowser", "PublishAppearance", "Playbook", + "PageManage", "PageDeploy", "PagePublish", "FrontendWorkbench", diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 06662793bf..6c1db8aaf8 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -61,8 +61,8 @@ pub use page_upload::{ deploy_page_version_on_relay, join_relay_url, list_page_versions_from_relay, list_pages_from_relay, publish_page_content_on_relay, publish_page_to_relay, save_page_version_from_inline_files, save_page_version_to_relay, unpublish_page_from_relay, - update_page_on_relay, PageContentPublishResult, PageInfo, PageOpenLink, PagePublishResult, - PageSaveVersionResult, PageVersionInfo, + update_page_content_on_relay, update_page_on_relay, PageContentPublishResult, PageInfo, + PageOpenLink, PagePublishResult, PageSaveVersionResult, PageVersionInfo, }; pub use pairing::PairingState; pub use qr_generator::QrGenerator; diff --git a/src/crates/services/services-integrations/src/remote_connect/page_upload.rs b/src/crates/services/services-integrations/src/remote_connect/page_upload.rs index b273a77223..17d58c0b99 100644 --- a/src/crates/services/services-integrations/src/remote_connect/page_upload.rs +++ b/src/crates/services/services-integrations/src/remote_connect/page_upload.rs @@ -185,7 +185,7 @@ pub async fn save_page_version_to_relay( ) -> Result { let all_files = collect_page_files(Path::new(web_dir))?; save_page_version_from_collected_files( - relay_url, token, slug, visibility, title, note, all_files, + relay_url, token, slug, visibility, title, note, all_files, None, ) .await } @@ -202,7 +202,7 @@ pub async fn save_page_version_from_inline_files( ) -> Result { let all_files = collect_inline_page_files(files)?; save_page_version_from_collected_files( - relay_url, token, slug, visibility, title, note, all_files, + relay_url, token, slug, visibility, title, note, all_files, None, ) .await } @@ -239,6 +239,47 @@ pub async fn publish_page_content_on_relay( } }; + finish_page_publication(relay_url, token, saved, deploy).await +} + +/// Replace the content of the inspected Page without changing its access policy. +/// Keep the inspected generation throughout upload/freeze so a concurrent delete +/// and recreate cannot silently turn an update into creation or target a new Page. +pub async fn update_page_content_on_relay( + relay_url: &str, + token: &str, + page: &PageInfo, + title: Option<&str>, + note: Option<&str>, + deploy: bool, + directory: Option<&str>, + files: Option<&HashMap>, +) -> Result { + let all_files = match (directory, files) { + (Some(directory), None) => collect_page_files(Path::new(directory))?, + (None, Some(files)) => collect_inline_page_files(files)?, + _ => return Err(anyhow!("provide exactly one of directory or files")), + }; + let saved = save_page_version_from_collected_files( + relay_url, + token, + &page.slug, + &page.visibility, + Some(title.unwrap_or(&page.title)), + note, + all_files, + Some(page), + ) + .await?; + finish_page_publication(relay_url, token, saved, deploy).await +} + +async fn finish_page_publication( + relay_url: &str, + token: &str, + saved: PageSaveVersionResult, + deploy: bool, +) -> Result { if !deploy { let preview_url = join_relay_url(relay_url, &saved.preview_url_path); return Ok(PageContentPublishResult { @@ -323,6 +364,7 @@ async fn save_page_version_from_collected_files( title: Option<&str>, note: Option<&str>, all_files: Vec, + update_target: Option<&PageInfo>, ) -> Result { validate_slug(slug)?; validate_visibility(visibility)?; @@ -359,10 +401,13 @@ async fn save_page_version_from_collected_files( }) .collect(); let upload_id = uuid::Uuid::new_v4().simple().to_string(); - let existing_page = list_pages_from_relay(relay_url, token) - .await? - .into_iter() - .find(|page| page.slug == slug); + let existing_page = match update_target { + Some(page) => Some(page.clone()), + None => list_pages_from_relay(relay_url, token) + .await? + .into_iter() + .find(|page| page.slug == slug), + }; let create = existing_page.is_none(); let expected_generation = existing_page .as_ref() From 87d58ddb4e49e4bdbe9e88a17e71884678cc70ef Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 30 Oct 2026 14:41:23 +0800 Subject: [PATCH 2/6] feat(pages): expose management actions in cards and capability docs --- docs/interactive-capabilities/README.md | 4 +- .../capabilities.json | 242 ++++++++++-------- .../capabilities/feature.pages.md | 22 +- .../technical/product-control-open-audit.json | 88 +------ .../technical/tauri-command-map.json | 2 +- .../flow-chat-presentation/src/registry.ts | 1 + scripts/interactive-capabilities.test.mjs | 5 +- src/apps/cli/README.md | 23 +- .../generated/product-control-catalog.json | 242 ++++++++++-------- .../interactive-capabilities/catalog.json | 99 ++++--- .../generated/interactive-capabilities.json | 242 ++++++++++-------- .../tool-cards/PageManageToolDisplay.test.tsx | 111 ++++++++ .../tool-cards/PageManageToolDisplay.tsx | 105 ++++++++ .../src/flow_chat/tool-cards/index.test.ts | 1 + src/web-ui/src/flow_chat/tool-cards/index.ts | 2 + .../flow_chat/tool-cards/toolCardMetadata.ts | 11 + .../api/generated/productControl.ts | 2 +- src/web-ui/src/locales/en-US/flow-chat.json | 18 ++ src/web-ui/src/locales/zh-CN/flow-chat.json | 18 ++ src/web-ui/src/locales/zh-TW/flow-chat.json | 18 ++ 20 files changed, 806 insertions(+), 450 deletions(-) create mode 100644 src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.test.tsx create mode 100644 src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.tsx diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index a21d37a511..3e8822f738 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -33,12 +33,12 @@ Docs, website, search, and agents see only features, settings, and documented su ## 控制边界 / Control boundary -- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **56**、委托 **67**、需交互 **213**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **56**、委托 **72**、需交互 **208**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;智能体不接触原始 Tauri Command。 - `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 336 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读智能体只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **56 direct**, **67 delegated**, **213 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **56 direct**, **72 delegated**, **208 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. - `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 336 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 4c33f27459..2403df7421 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "9ec82305067852766c79ed3d802c3a16f1c4dd992f7d3195f91a2989da32f544", + "digest": "95e9b682d9e2fe6d4d9fe80b6564af3dec33f4b860e1b6ffc7da0eebea4d0e27", "ownerDigest": "f27c231da2e536e4f2123910f34f862b55d21feb0528bc915d4d13edbd528af9", "searchAcceptance": [ { @@ -178,8 +178,8 @@ "documentedItems": 336, "controlCoverage": { "direct": 56, - "delegated": 67, - "interactive": 213, + "delegated": 72, + "interactive": 208, "unsupported": 0 } }, @@ -9948,92 +9948,90 @@ } }, { - "id": "feature.pages:open:pages-list", + "id": "feature.pages:delegate:pages-list", "capabilityId": "feature.pages", "itemIds": [ "pages-list" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" } }, { - "id": "feature.pages:open:immutable-versions", + "id": "feature.pages:delegate:immutable-versions", "capabilityId": "feature.pages", "itemIds": [ "immutable-versions" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" @@ -10077,8 +10075,7 @@ "additionalProperties": true }, "delegateTools": [ - "PagePublish", - "PageDeploy" + "PageManage" ], "presentationTarget": { "kind": "scene", @@ -10086,46 +10083,45 @@ } }, { - "id": "feature.pages:open:visibility", + "id": "feature.pages:delegate:visibility", "capabilityId": "feature.pages", "itemIds": [ "visibility" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" @@ -10178,92 +10174,90 @@ } }, { - "id": "feature.pages:open:unpublish", + "id": "feature.pages:delegate:unpublish", "capabilityId": "feature.pages", "itemIds": [ "unpublish" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" } }, { - "id": "feature.pages:open:delete", + "id": "feature.pages:delegate:delete", "capabilityId": "feature.pages", "itemIds": [ "delete" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" @@ -25385,10 +25379,16 @@ "titleZh": "查看账户下的 Pages 与每个页面的当前发布状态", "titleEn": "List account Pages and inspect each page's current publishing state", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“查看账户下的 Pages 与每个页面的当前发布状态”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List account Pages and inspect each page's current publishing state” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=list 列出已发布页面的名称、URL、权限和更新时间;published_only=false 包含未发布页面" + ], + "workflowEn": [ + "Use PageManage action=list for published Page names, URLs, visibility and update times; published_only=false includes unpublished Pages" + ] } }, { @@ -25396,10 +25396,16 @@ "titleZh": "保存不可变页面版本并浏览版本历史", "titleEn": "Save immutable page versions and browse version history", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“保存不可变页面版本并浏览版本历史”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Save immutable page versions and browse version history” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=publish/update 保存完整内容为新版本,action=versions 查看版本历史" + ], + "workflowEn": [ + "Use PageManage action=publish/update to save complete content as a new version, and action=versions for history" + ] } }, { @@ -25409,16 +25415,13 @@ "control": { "kind": "delegate", "tools": [ - "PagePublish", - "PageDeploy" + "PageManage" ], "workflowZh": [ - "新建内容或保存新版本时用 PagePublish;只有用户明确要求上线时才设置 deploy,并确认可见性", - "切换到已保存版本或回滚时用 PageDeploy;私有/relay 链接通过 Pages 场景或工具卡安全打开" + "使用 PageManage action=publish/update 保存内容;仅用户要求上线时设置 deploy=true;action=deploy 切换到已有版本" ], "workflowEn": [ - "Use PagePublish for new content or a new saved version; set deploy only when the user explicitly asks to publish and confirm visibility", - "Use PageDeploy to promote or roll back to an existing saved version; open private/relay links safely through the Pages scene or tool card" + "Use PageManage action=publish/update to save content, set deploy=true only when requested, and action=deploy to promote an existing version" ] } }, @@ -25427,10 +25430,16 @@ "titleZh": "更新标题、公开/私有可见性和访问设置", "titleEn": "Update titles, public/private visibility, and access settings", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“更新标题、公开/私有可见性和访问设置”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Update titles, public/private visibility, and access settings” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=update 修改标题,action=permissions 将访问权限设置为 private、relay 或 public" + ], + "workflowEn": [ + "Use PageManage action=update for the title and action=permissions to set private, relay or public visibility" + ] } }, { @@ -25449,10 +25458,16 @@ "titleZh": "取消发布页面,同时保留可再次发布的版本", "titleEn": "Unpublish a page while retaining versions for later publication", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“取消发布页面,同时保留可再次发布的版本”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Unpublish a page while retaining versions for later publication” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=unpublish 下线页面并保留已保存版本" + ], + "workflowEn": [ + "Use PageManage action=unpublish to take a Page offline while retaining saved versions" + ] } }, { @@ -25460,10 +25475,16 @@ "titleZh": "删除单个版本或整个 Page", "titleEn": "Delete an individual version or an entire Page", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“删除单个版本或整个 Page”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Delete an individual version or an entire Page” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=delete 删除整个页面;提供 version_id 时仅删除该版本" + ], + "workflowEn": [ + "Use PageManage action=delete to delete a Page, or supply version_id to delete only that version" + ] } } ], @@ -25478,12 +25499,14 @@ "Choose a page and confirm publishing and visibility" ], "agentExamplesZh": [ - "打开 Pages", - "带我管理已发布的页面" + "列出我已发布的 Pages", + "把这个页面改成私有", + "更新这个页面的内容并上线" ], "agentExamplesEn": [ - "Open Pages", - "Take me to my published pages" + "List my published Pages", + "Make this Page private", + "Update this Page content and deploy it" ], "destination": { "kind": "scene", @@ -25529,16 +25552,25 @@ "Unpublish a page while retaining versions for later publication", "删除单个版本或整个 Page", "Delete an individual version or an entire Page", - "打开 Pages", - "带我管理已发布的页面", - "Open Pages", - "Take me to my published pages", - "PagePublish", - "PageDeploy", - "新建内容或保存新版本时用 PagePublish;只有用户明确要求上线时才设置 deploy,并确认可见性", - "切换到已保存版本或回滚时用 PageDeploy;私有/relay 链接通过 Pages 场景或工具卡安全打开", - "Use PagePublish for new content or a new saved version; set deploy only when the user explicitly asks to publish and confirm visibility", - "Use PageDeploy to promote or roll back to an existing saved version; open private/relay links safely through the Pages scene or tool card" + "列出我已发布的 Pages", + "把这个页面改成私有", + "更新这个页面的内容并上线", + "List my published Pages", + "Make this Page private", + "Update this Page content and deploy it", + "PageManage", + "使用 PageManage action=list 列出已发布页面的名称、URL、权限和更新时间;published_only=false 包含未发布页面", + "Use PageManage action=list for published Page names, URLs, visibility and update times; published_only=false includes unpublished Pages", + "使用 PageManage action=publish/update 保存完整内容为新版本,action=versions 查看版本历史", + "Use PageManage action=publish/update to save complete content as a new version, and action=versions for history", + "使用 PageManage action=publish/update 保存内容;仅用户要求上线时设置 deploy=true;action=deploy 切换到已有版本", + "Use PageManage action=publish/update to save content, set deploy=true only when requested, and action=deploy to promote an existing version", + "使用 PageManage action=update 修改标题,action=permissions 将访问权限设置为 private、relay 或 public", + "Use PageManage action=update for the title and action=permissions to set private, relay or public visibility", + "使用 PageManage action=unpublish 下线页面并保留已保存版本", + "Use PageManage action=unpublish to take a Page offline while retaining saved versions", + "使用 PageManage action=delete 删除整个页面;提供 version_id 时仅删除该版本", + "Use PageManage action=delete to delete a Page, or supply version_id to delete only that version" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/feature.pages/" }, diff --git a/docs/interactive-capabilities/capabilities/feature.pages.md b/docs/interactive-capabilities/capabilities/feature.pages.md index 3331882515..c915a6202c 100644 --- a/docs/interactive-capabilities/capabilities/feature.pages.md +++ b/docs/interactive-capabilities/capabilities/feature.pages.md @@ -17,19 +17,19 @@ Save page versions and publish them to public or private URLs while managing tit ## 完整功能清单 / Everything included -- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 查看账户下的 Pages 与每个页面的当前发布状态 +- **由专用智能体工具控制 / Delegated Agent tool** · `PageManage` · 查看账户下的 Pages 与每个页面的当前发布状态 - List account Pages and inspect each page's current publishing state -- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 保存不可变页面版本并浏览版本历史 +- **由专用智能体工具控制 / Delegated Agent tool** · `PageManage` · 保存不可变页面版本并浏览版本历史 - Save immutable page versions and browse version history -- **由专用智能体工具控制 / Delegated Agent tool** · `PagePublish` / `PageDeploy` · 发布或重新部署选中的页面版本 +- **由专用智能体工具控制 / Delegated Agent tool** · `PageManage` · 发布或重新部署选中的页面版本 - Publish or redeploy a selected page version -- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 更新标题、公开/私有可见性和访问设置 +- **由专用智能体工具控制 / Delegated Agent tool** · `PageManage` · 更新标题、公开/私有可见性和访问设置 - Update titles, public/private visibility, and access settings - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 创建可打开和分享的页面链接 - Create an openable, shareable link for a page -- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 取消发布页面,同时保留可再次发布的版本 +- **由专用智能体工具控制 / Delegated Agent tool** · `PageManage` · 取消发布页面,同时保留可再次发布的版本 - Unpublish a page while retaining versions for later publication -- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 删除单个版本或整个 Page +- **由专用智能体工具控制 / Delegated Agent tool** · `PageManage` · 删除单个版本或整个 Page - Delete an individual version or an entire Page ## 怎么用 / How to use it @@ -57,10 +57,12 @@ Save page versions and publish them to public or private URLs while managing tit ## 可以直接对智能体说 / Try saying -- “打开 Pages” - - “Open Pages” -- “带我管理已发布的页面” - - “Take me to my published pages” +- “列出我已发布的 Pages” + - “List my published Pages” +- “把这个页面改成私有” + - “Make this Page private” +- “更新这个页面的内容并上线” + - “Update this Page content and deploy it” 智能体会先查找相关功能或设置,确认目标后再替你打开、执行或修改。完整能力目录不会预先塞进对话上下文。 diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index 2247e29765..732acfbd19 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,12 +1,12 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "9ec82305067852766c79ed3d802c3a16f1c4dd992f7d3195f91a2989da32f544", - "count": 213, + "catalogDigest": "95e9b682d9e2fe6d4d9fe80b6564af3dec33f4b860e1b6ffc7da0eebea4d0e27", + "count": 208, "reasonCounts": { "externalAuth": 4, "secretEntry": 5, - "unstructuredInteraction": 179, + "unstructuredInteraction": 174, "visualSelection": 25 }, "entries": [ @@ -2248,55 +2248,6 @@ "command:dispatch_sync_result" ] }, - { - "capabilityId": "feature.pages", - "itemId": "pages-list", - "titleZh": "查看账户下的 Pages 与每个页面的当前发布状态", - "titleEn": "List account Pages and inspect each page's current publishing state", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“查看账户下的 Pages 与每个页面的当前发布状态”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List account Pages and inspect each page's current publishing state” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", - "presentationTarget": { - "kind": "scene", - "sceneId": "pages" - }, - "evidence": [ - "command:page_list" - ] - }, - { - "capabilityId": "feature.pages", - "itemId": "immutable-versions", - "titleZh": "保存不可变页面版本并浏览版本历史", - "titleEn": "Save immutable page versions and browse version history", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“保存不可变页面版本并浏览版本历史”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Save immutable page versions and browse version history” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", - "presentationTarget": { - "kind": "scene", - "sceneId": "pages" - }, - "evidence": [ - "command:page_save_version", - "command:page_list_versions" - ] - }, - { - "capabilityId": "feature.pages", - "itemId": "visibility", - "titleZh": "更新标题、公开/私有可见性和访问设置", - "titleEn": "Update titles, public/private visibility, and access settings", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“更新标题、公开/私有可见性和访问设置”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Update titles, public/private visibility, and access settings” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", - "presentationTarget": { - "kind": "scene", - "sceneId": "pages" - }, - "evidence": [ - "command:page_update" - ] - }, { "capabilityId": "feature.pages", "itemId": "share-open", @@ -2313,39 +2264,6 @@ "command:page_create_open_link" ] }, - { - "capabilityId": "feature.pages", - "itemId": "unpublish", - "titleZh": "取消发布页面,同时保留可再次发布的版本", - "titleEn": "Unpublish a page while retaining versions for later publication", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“取消发布页面,同时保留可再次发布的版本”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Unpublish a page while retaining versions for later publication” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", - "presentationTarget": { - "kind": "scene", - "sceneId": "pages" - }, - "evidence": [ - "command:page_unpublish" - ] - }, - { - "capabilityId": "feature.pages", - "itemId": "delete", - "titleZh": "删除单个版本或整个 Page", - "titleEn": "Delete an individual version or an entire Page", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“删除单个版本或整个 Page”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Delete an individual version or an entire Page” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", - "presentationTarget": { - "kind": "scene", - "sceneId": "pages" - }, - "evidence": [ - "command:page_delete_version", - "command:page_delete" - ] - }, { "capabilityId": "feature.voice-input", "itemId": "composer-recording", diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 232541ab0d..3be590176c 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "9ec82305067852766c79ed3d802c3a16f1c4dd992f7d3195f91a2989da32f544", + "catalogDigest": "95e9b682d9e2fe6d4d9fe80b6564af3dec33f4b860e1b6ffc7da0eebea4d0e27", "commandCount": 706, "coverage": { "commandCount": 706, diff --git a/packages/flow-chat-presentation/src/registry.ts b/packages/flow-chat-presentation/src/registry.ts index de0a3b97bf..0749f664f2 100644 --- a/packages/flow-chat-presentation/src/registry.ts +++ b/packages/flow-chat-presentation/src/registry.ts @@ -30,6 +30,7 @@ export const toolPresentationRegistry = { ExecCommand: { owner: 'standard', component: 'CommandToolCard' }, WriteStdin: { owner: 'standard', component: 'CommandToolCard' }, ExecControl: { owner: 'standard', component: 'CommandToolCard' }, + PageManage: { owner: 'standard', component: 'PagePublishToolCard' }, PageDeploy: { owner: 'standard', component: 'PageDeployToolCard' }, PagePublish: { owner: 'standard', component: 'PagePublishToolCard' }, view_image: { owner: 'standard', component: 'ViewImageToolCard' }, diff --git a/scripts/interactive-capabilities.test.mjs b/scripts/interactive-capabilities.test.mjs index 35eb784333..1921aadc85 100644 --- a/scripts/interactive-capabilities.test.mjs +++ b/scripts/interactive-capabilities.test.mjs @@ -223,12 +223,15 @@ test('every product Agent tool is mapped to a user capability or explicitly clas productTools, 'new Agent tools must be mapped in the shared manual or receive a reviewed framework-only exclusion', ); - assert.deepEqual(excludedTools, new Set(['Task', 'GetToolSpec', 'CallDeferredTool'])); + assert.deepEqual(excludedTools, new Set(['Task', 'GetToolSpec', 'CallDeferredTool', 'PagePublish', 'PageDeploy'])); assert.equal([...delegatedTools].filter((tool) => excludedTools.has(tool)).length, 0); const byId = new Map(source.capabilities.map((capability) => [capability.id, capability])); const item = (capabilityId, itemId) => byId.get(capabilityId)?.items .find(({ id }) => id === itemId); + for (const itemId of ['pages-list', 'immutable-versions', 'publish', 'visibility', 'unpublish', 'delete']) { + assert.deepEqual(item('feature.pages', itemId).control.tools, ['PageManage']); + } assert.deepEqual(item('feature.ai-assistant', 'image-generation').control.tools, ['generate_image']); assert.ok(item('feature.remote-workspaces', 'remote-files').control.tools.includes('Read')); assert.deepEqual(item('feature.remote-workspaces', 'port-forwarding').control.tools, ['PortForward']); diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 5f8003b59e..3e36a0ae01 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -259,14 +259,27 @@ the limit is enforced at the stdin reader. stdin EOF is a deterministic disconnect: the Host cancels in-flight turns and exits. `app/initialize` advertises only the methods this Host actually serves. -### Publishing Pages - -After `/login`, Standard and Claw sessions can use `PagePublish` to save page -content and optionally publish it, and `PageDeploy` to deploy or roll back to a -saved version. The tools appear only while the executing CLI Runtime has an +### Managing Pages + +After `/login`, Standard and Claw sessions use `PageManage` to list, publish, +update, delete, and manage access to Pages. `list` returns published Pages with +their name, absolute URL, visibility, and update time; set `published_only=false` +to include unpublished Pages. `publish` saves a complete content bundle; +`update` changes an existing Page's title or content while preserving visibility. +Both save a preview unless `deploy=true` is requested. `permissions` sets +`private` (owner), `relay` (signed-in Relay users), or `public` access. +`versions` lists saved versions; `deploy` promotes or rolls back to one; +`unpublish` takes a Page offline without deleting versions. `delete` removes the +whole Page, or only the supplied `version_id`. + +The tool appears only while the executing CLI Runtime has an account session. Existing tool permissions still apply; unattended `exec` and dispatch runs use their configured approval policy. +`PagePublish` and `PageDeploy` remain available for legacy explicit Agent tool +configurations and existing calls, but are no longer included in new defaults. +Legacy Pages exclusions remain effective after upgrading. + The CLI restores its saved account session at startup, including for `exec` and Shared Runtime hosts. Shared TUI account login remains unsupported: sign in through an embedded TUI before starting the Shared Runtime. Login changes in a diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index a632771ff9..c569353802 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "9ec82305067852766c79ed3d802c3a16f1c4dd992f7d3195f91a2989da32f544", + "digest": "95e9b682d9e2fe6d4d9fe80b6564af3dec33f4b860e1b6ffc7da0eebea4d0e27", "ownerDigest": "f27c231da2e536e4f2123910f34f862b55d21feb0528bc915d4d13edbd528af9", "searchAcceptance": [ { @@ -178,8 +178,8 @@ "documentedItems": 336, "controlCoverage": { "direct": 56, - "delegated": 67, - "interactive": 213, + "delegated": 72, + "interactive": 208, "unsupported": 0 } }, @@ -9948,92 +9948,90 @@ } }, { - "id": "feature.pages:open:pages-list", + "id": "feature.pages:delegate:pages-list", "capabilityId": "feature.pages", "itemIds": [ "pages-list" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" } }, { - "id": "feature.pages:open:immutable-versions", + "id": "feature.pages:delegate:immutable-versions", "capabilityId": "feature.pages", "itemIds": [ "immutable-versions" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" @@ -10077,8 +10075,7 @@ "additionalProperties": true }, "delegateTools": [ - "PagePublish", - "PageDeploy" + "PageManage" ], "presentationTarget": { "kind": "scene", @@ -10086,46 +10083,45 @@ } }, { - "id": "feature.pages:open:visibility", + "id": "feature.pages:delegate:visibility", "capabilityId": "feature.pages", "itemIds": [ "visibility" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" @@ -10178,92 +10174,90 @@ } }, { - "id": "feature.pages:open:unpublish", + "id": "feature.pages:delegate:unpublish", "capabilityId": "feature.pages", "itemIds": [ "unpublish" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" } }, { - "id": "feature.pages:open:delete", + "id": "feature.pages:delegate:delete", "capabilityId": "feature.pages", "itemIds": [ "delete" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" @@ -25426,10 +25420,16 @@ "titleZh": "查看账户下的 Pages 与每个页面的当前发布状态", "titleEn": "List account Pages and inspect each page's current publishing state", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“查看账户下的 Pages 与每个页面的当前发布状态”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List account Pages and inspect each page's current publishing state” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=list 列出已发布页面的名称、URL、权限和更新时间;published_only=false 包含未发布页面" + ], + "workflowEn": [ + "Use PageManage action=list for published Page names, URLs, visibility and update times; published_only=false includes unpublished Pages" + ] } }, { @@ -25437,10 +25437,16 @@ "titleZh": "保存不可变页面版本并浏览版本历史", "titleEn": "Save immutable page versions and browse version history", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“保存不可变页面版本并浏览版本历史”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Save immutable page versions and browse version history” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=publish/update 保存完整内容为新版本,action=versions 查看版本历史" + ], + "workflowEn": [ + "Use PageManage action=publish/update to save complete content as a new version, and action=versions for history" + ] } }, { @@ -25450,16 +25456,13 @@ "control": { "kind": "delegate", "tools": [ - "PagePublish", - "PageDeploy" + "PageManage" ], "workflowZh": [ - "新建内容或保存新版本时用 PagePublish;只有用户明确要求上线时才设置 deploy,并确认可见性", - "切换到已保存版本或回滚时用 PageDeploy;私有/relay 链接通过 Pages 场景或工具卡安全打开" + "使用 PageManage action=publish/update 保存内容;仅用户要求上线时设置 deploy=true;action=deploy 切换到已有版本" ], "workflowEn": [ - "Use PagePublish for new content or a new saved version; set deploy only when the user explicitly asks to publish and confirm visibility", - "Use PageDeploy to promote or roll back to an existing saved version; open private/relay links safely through the Pages scene or tool card" + "Use PageManage action=publish/update to save content, set deploy=true only when requested, and action=deploy to promote an existing version" ] } }, @@ -25468,10 +25471,16 @@ "titleZh": "更新标题、公开/私有可见性和访问设置", "titleEn": "Update titles, public/private visibility, and access settings", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“更新标题、公开/私有可见性和访问设置”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Update titles, public/private visibility, and access settings” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=update 修改标题,action=permissions 将访问权限设置为 private、relay 或 public" + ], + "workflowEn": [ + "Use PageManage action=update for the title and action=permissions to set private, relay or public visibility" + ] } }, { @@ -25490,10 +25499,16 @@ "titleZh": "取消发布页面,同时保留可再次发布的版本", "titleEn": "Unpublish a page while retaining versions for later publication", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“取消发布页面,同时保留可再次发布的版本”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Unpublish a page while retaining versions for later publication” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=unpublish 下线页面并保留已保存版本" + ], + "workflowEn": [ + "Use PageManage action=unpublish to take a Page offline while retaining saved versions" + ] } }, { @@ -25501,10 +25516,16 @@ "titleZh": "删除单个版本或整个 Page", "titleEn": "Delete an individual version or an entire Page", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“删除单个版本或整个 Page”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Delete an individual version or an entire Page” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=delete 删除整个页面;提供 version_id 时仅删除该版本" + ], + "workflowEn": [ + "Use PageManage action=delete to delete a Page, or supply version_id to delete only that version" + ] } } ], @@ -25519,12 +25540,14 @@ "Choose a page and confirm publishing and visibility" ], "agentExamplesZh": [ - "打开 Pages", - "带我管理已发布的页面" + "列出我已发布的 Pages", + "把这个页面改成私有", + "更新这个页面的内容并上线" ], "agentExamplesEn": [ - "Open Pages", - "Take me to my published pages" + "List my published Pages", + "Make this Page private", + "Update this Page content and deploy it" ], "destination": { "kind": "scene", @@ -25570,16 +25593,25 @@ "Unpublish a page while retaining versions for later publication", "删除单个版本或整个 Page", "Delete an individual version or an entire Page", - "打开 Pages", - "带我管理已发布的页面", - "Open Pages", - "Take me to my published pages", - "PagePublish", - "PageDeploy", - "新建内容或保存新版本时用 PagePublish;只有用户明确要求上线时才设置 deploy,并确认可见性", - "切换到已保存版本或回滚时用 PageDeploy;私有/relay 链接通过 Pages 场景或工具卡安全打开", - "Use PagePublish for new content or a new saved version; set deploy only when the user explicitly asks to publish and confirm visibility", - "Use PageDeploy to promote or roll back to an existing saved version; open private/relay links safely through the Pages scene or tool card" + "列出我已发布的 Pages", + "把这个页面改成私有", + "更新这个页面的内容并上线", + "List my published Pages", + "Make this Page private", + "Update this Page content and deploy it", + "PageManage", + "使用 PageManage action=list 列出已发布页面的名称、URL、权限和更新时间;published_only=false 包含未发布页面", + "Use PageManage action=list for published Page names, URLs, visibility and update times; published_only=false includes unpublished Pages", + "使用 PageManage action=publish/update 保存完整内容为新版本,action=versions 查看版本历史", + "Use PageManage action=publish/update to save complete content as a new version, and action=versions for history", + "使用 PageManage action=publish/update 保存内容;仅用户要求上线时设置 deploy=true;action=deploy 切换到已有版本", + "Use PageManage action=publish/update to save content, set deploy=true only when requested, and action=deploy to promote an existing version", + "使用 PageManage action=update 修改标题,action=permissions 将访问权限设置为 private、relay 或 public", + "Use PageManage action=update for the title and action=permissions to set private, relay or public visibility", + "使用 PageManage action=unpublish 下线页面并保留已保存版本", + "Use PageManage action=unpublish to take a Page offline while retaining saved versions", + "使用 PageManage action=delete 删除整个页面;提供 version_id 时仅删除该版本", + "Use PageManage action=delete to delete a Page, or supply version_id to delete only that version" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/feature.pages/" }, diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index af0b246aa4..4a44a82d41 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -207,6 +207,16 @@ } }, "agentToolExclusions": [ + { + "tool": "PagePublish", + "reasonZh": "旧版 Pages 工具兼容入口,仅为已有调用和显式工具配置保留;新功能统一通过 PageManage 提供。", + "reasonEn": "Legacy Pages compatibility entry retained for existing calls and explicit tool configurations; current functionality is exposed through PageManage." + }, + { + "tool": "PageDeploy", + "reasonZh": "旧版 Pages 部署兼容入口,仅为已有调用和显式工具配置保留;新功能统一通过 PageManage 提供。", + "reasonEn": "Legacy Pages deployment compatibility entry retained for existing calls and explicit tool configurations; current functionality is exposed through PageManage." + }, { "tool": "Task", "reasonZh": "兼容性委派原语,仅为显式配置的旧版智能体保留,不是独立的用户功能。", @@ -4891,10 +4901,16 @@ "titleZh": "查看账户下的 Pages 与每个页面的当前发布状态", "titleEn": "List account Pages and inspect each page's current publishing state", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“查看账户下的 Pages 与每个页面的当前发布状态”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List account Pages and inspect each page's current publishing state” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=list 列出已发布页面的名称、URL、权限和更新时间;published_only=false 包含未发布页面" + ], + "workflowEn": [ + "Use PageManage action=list for published Page names, URLs, visibility and update times; published_only=false includes unpublished Pages" + ] }, "evidence": [ "command:page_list" @@ -4905,10 +4921,16 @@ "titleZh": "保存不可变页面版本并浏览版本历史", "titleEn": "Save immutable page versions and browse version history", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“保存不可变页面版本并浏览版本历史”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Save immutable page versions and browse version history” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=publish/update 保存完整内容为新版本,action=versions 查看版本历史" + ], + "workflowEn": [ + "Use PageManage action=publish/update to save complete content as a new version, and action=versions for history" + ] }, "evidence": [ "command:page_save_version", @@ -4922,16 +4944,13 @@ "control": { "kind": "delegate", "tools": [ - "PagePublish", - "PageDeploy" + "PageManage" ], "workflowZh": [ - "新建内容或保存新版本时用 PagePublish;只有用户明确要求上线时才设置 deploy,并确认可见性", - "切换到已保存版本或回滚时用 PageDeploy;私有/relay 链接通过 Pages 场景或工具卡安全打开" + "使用 PageManage action=publish/update 保存内容;仅用户要求上线时设置 deploy=true;action=deploy 切换到已有版本" ], "workflowEn": [ - "Use PagePublish for new content or a new saved version; set deploy only when the user explicitly asks to publish and confirm visibility", - "Use PageDeploy to promote or roll back to an existing saved version; open private/relay links safely through the Pages scene or tool card" + "Use PageManage action=publish/update to save content, set deploy=true only when requested, and action=deploy to promote an existing version" ] }, "evidence": [ @@ -4944,10 +4963,16 @@ "titleZh": "更新标题、公开/私有可见性和访问设置", "titleEn": "Update titles, public/private visibility, and access settings", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“更新标题、公开/私有可见性和访问设置”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Update titles, public/private visibility, and access settings” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=update 修改标题,action=permissions 将访问权限设置为 private、relay 或 public" + ], + "workflowEn": [ + "Use PageManage action=update for the title and action=permissions to set private, relay or public visibility" + ] }, "evidence": [ "command:page_update" @@ -4972,10 +4997,16 @@ "titleZh": "取消发布页面,同时保留可再次发布的版本", "titleEn": "Unpublish a page while retaining versions for later publication", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“取消发布页面,同时保留可再次发布的版本”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Unpublish a page while retaining versions for later publication” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=unpublish 下线页面并保留已保存版本" + ], + "workflowEn": [ + "Use PageManage action=unpublish to take a Page offline while retaining saved versions" + ] }, "evidence": [ "command:page_unpublish" @@ -4986,10 +5017,16 @@ "titleZh": "删除单个版本或整个 Page", "titleEn": "Delete an individual version or an entire Page", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“删除单个版本或整个 Page”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Delete an individual version or an entire Page” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=delete 删除整个页面;提供 version_id 时仅删除该版本" + ], + "workflowEn": [ + "Use PageManage action=delete to delete a Page, or supply version_id to delete only that version" + ] }, "evidence": [ "command:page_delete_version", @@ -5008,12 +5045,14 @@ "Choose a page and confirm publishing and visibility" ], "agentExamplesZh": [ - "打开 Pages", - "带我管理已发布的页面" + "列出我已发布的 Pages", + "把这个页面改成私有", + "更新这个页面的内容并上线" ], "agentExamplesEn": [ - "Open Pages", - "Take me to my published pages" + "List my published Pages", + "Make this Page private", + "Update this Page content and deploy it" ], "destination": { "kind": "scene", diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 4c33f27459..2403df7421 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "9ec82305067852766c79ed3d802c3a16f1c4dd992f7d3195f91a2989da32f544", + "digest": "95e9b682d9e2fe6d4d9fe80b6564af3dec33f4b860e1b6ffc7da0eebea4d0e27", "ownerDigest": "f27c231da2e536e4f2123910f34f862b55d21feb0528bc915d4d13edbd528af9", "searchAcceptance": [ { @@ -178,8 +178,8 @@ "documentedItems": 336, "controlCoverage": { "direct": 56, - "delegated": 67, - "interactive": 213, + "delegated": 72, + "interactive": 208, "unsupported": 0 } }, @@ -9948,92 +9948,90 @@ } }, { - "id": "feature.pages:open:pages-list", + "id": "feature.pages:delegate:pages-list", "capabilityId": "feature.pages", "itemIds": [ "pages-list" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" } }, { - "id": "feature.pages:open:immutable-versions", + "id": "feature.pages:delegate:immutable-versions", "capabilityId": "feature.pages", "itemIds": [ "immutable-versions" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" @@ -10077,8 +10075,7 @@ "additionalProperties": true }, "delegateTools": [ - "PagePublish", - "PageDeploy" + "PageManage" ], "presentationTarget": { "kind": "scene", @@ -10086,46 +10083,45 @@ } }, { - "id": "feature.pages:open:visibility", + "id": "feature.pages:delegate:visibility", "capabilityId": "feature.pages", "itemIds": [ "visibility" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" @@ -10178,92 +10174,90 @@ } }, { - "id": "feature.pages:open:unpublish", + "id": "feature.pages:delegate:unpublish", "capabilityId": "feature.pages", "itemIds": [ "unpublish" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" } }, { - "id": "feature.pages:open:delete", + "id": "feature.pages:delegate:delete", "capabilityId": "feature.pages", "itemIds": [ "delete" ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", "availability": { "desktop": { "available": true }, "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true }, "peer": { "available": true, "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" + "product_control_v1" ] }, "remoteControl": { "available": true }, "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" + "available": true } }, "inputSchema": { "type": "object", - "additionalProperties": false + "additionalProperties": true }, "outputSchema": { "type": "object", "additionalProperties": true }, - "openReason": "unstructuredInteraction", + "delegateTools": [ + "PageManage" + ], "presentationTarget": { "kind": "scene", "sceneId": "pages" @@ -25385,10 +25379,16 @@ "titleZh": "查看账户下的 Pages 与每个页面的当前发布状态", "titleEn": "List account Pages and inspect each page's current publishing state", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“查看账户下的 Pages 与每个页面的当前发布状态”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“List account Pages and inspect each page's current publishing state” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=list 列出已发布页面的名称、URL、权限和更新时间;published_only=false 包含未发布页面" + ], + "workflowEn": [ + "Use PageManage action=list for published Page names, URLs, visibility and update times; published_only=false includes unpublished Pages" + ] } }, { @@ -25396,10 +25396,16 @@ "titleZh": "保存不可变页面版本并浏览版本历史", "titleEn": "Save immutable page versions and browse version history", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“保存不可变页面版本并浏览版本历史”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Save immutable page versions and browse version history” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=publish/update 保存完整内容为新版本,action=versions 查看版本历史" + ], + "workflowEn": [ + "Use PageManage action=publish/update to save complete content as a new version, and action=versions for history" + ] } }, { @@ -25409,16 +25415,13 @@ "control": { "kind": "delegate", "tools": [ - "PagePublish", - "PageDeploy" + "PageManage" ], "workflowZh": [ - "新建内容或保存新版本时用 PagePublish;只有用户明确要求上线时才设置 deploy,并确认可见性", - "切换到已保存版本或回滚时用 PageDeploy;私有/relay 链接通过 Pages 场景或工具卡安全打开" + "使用 PageManage action=publish/update 保存内容;仅用户要求上线时设置 deploy=true;action=deploy 切换到已有版本" ], "workflowEn": [ - "Use PagePublish for new content or a new saved version; set deploy only when the user explicitly asks to publish and confirm visibility", - "Use PageDeploy to promote or roll back to an existing saved version; open private/relay links safely through the Pages scene or tool card" + "Use PageManage action=publish/update to save content, set deploy=true only when requested, and action=deploy to promote an existing version" ] } }, @@ -25427,10 +25430,16 @@ "titleZh": "更新标题、公开/私有可见性和访问设置", "titleEn": "Update titles, public/private visibility, and access settings", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“更新标题、公开/私有可见性和访问设置”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Update titles, public/private visibility, and access settings” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=update 修改标题,action=permissions 将访问权限设置为 private、relay 或 public" + ], + "workflowEn": [ + "Use PageManage action=update for the title and action=permissions to set private, relay or public visibility" + ] } }, { @@ -25449,10 +25458,16 @@ "titleZh": "取消发布页面,同时保留可再次发布的版本", "titleEn": "Unpublish a page while retaining versions for later publication", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“取消发布页面,同时保留可再次发布的版本”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Unpublish a page while retaining versions for later publication” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=unpublish 下线页面并保留已保存版本" + ], + "workflowEn": [ + "Use PageManage action=unpublish to take a Page offline while retaining saved versions" + ] } }, { @@ -25460,10 +25475,16 @@ "titleZh": "删除单个版本或整个 Page", "titleEn": "Delete an individual version or an entire Page", "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“删除单个版本或整个 Page”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;智能体会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Delete an individual version or an entire Page” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "kind": "delegate", + "tools": [ + "PageManage" + ], + "workflowZh": [ + "使用 PageManage action=delete 删除整个页面;提供 version_id 时仅删除该版本" + ], + "workflowEn": [ + "Use PageManage action=delete to delete a Page, or supply version_id to delete only that version" + ] } } ], @@ -25478,12 +25499,14 @@ "Choose a page and confirm publishing and visibility" ], "agentExamplesZh": [ - "打开 Pages", - "带我管理已发布的页面" + "列出我已发布的 Pages", + "把这个页面改成私有", + "更新这个页面的内容并上线" ], "agentExamplesEn": [ - "Open Pages", - "Take me to my published pages" + "List my published Pages", + "Make this Page private", + "Update this Page content and deploy it" ], "destination": { "kind": "scene", @@ -25529,16 +25552,25 @@ "Unpublish a page while retaining versions for later publication", "删除单个版本或整个 Page", "Delete an individual version or an entire Page", - "打开 Pages", - "带我管理已发布的页面", - "Open Pages", - "Take me to my published pages", - "PagePublish", - "PageDeploy", - "新建内容或保存新版本时用 PagePublish;只有用户明确要求上线时才设置 deploy,并确认可见性", - "切换到已保存版本或回滚时用 PageDeploy;私有/relay 链接通过 Pages 场景或工具卡安全打开", - "Use PagePublish for new content or a new saved version; set deploy only when the user explicitly asks to publish and confirm visibility", - "Use PageDeploy to promote or roll back to an existing saved version; open private/relay links safely through the Pages scene or tool card" + "列出我已发布的 Pages", + "把这个页面改成私有", + "更新这个页面的内容并上线", + "List my published Pages", + "Make this Page private", + "Update this Page content and deploy it", + "PageManage", + "使用 PageManage action=list 列出已发布页面的名称、URL、权限和更新时间;published_only=false 包含未发布页面", + "Use PageManage action=list for published Page names, URLs, visibility and update times; published_only=false includes unpublished Pages", + "使用 PageManage action=publish/update 保存完整内容为新版本,action=versions 查看版本历史", + "Use PageManage action=publish/update to save complete content as a new version, and action=versions for history", + "使用 PageManage action=publish/update 保存内容;仅用户要求上线时设置 deploy=true;action=deploy 切换到已有版本", + "Use PageManage action=publish/update to save content, set deploy=true only when requested, and action=deploy to promote an existing version", + "使用 PageManage action=update 修改标题,action=permissions 将访问权限设置为 private、relay 或 public", + "Use PageManage action=update for the title and action=permissions to set private, relay or public visibility", + "使用 PageManage action=unpublish 下线页面并保留已保存版本", + "Use PageManage action=unpublish to take a Page offline while retaining saved versions", + "使用 PageManage action=delete 删除整个页面;提供 version_id 时仅删除该版本", + "Use PageManage action=delete to delete a Page, or supply version_id to delete only that version" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/feature.pages/" }, diff --git a/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.test.tsx new file mode 100644 index 0000000000..4942152d2c --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import en from '@/locales/en-US/flow-chat.json'; +import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; +import { PageManageDisplay } from './PageManageToolDisplay'; +import { getToolItemCardConfig } from './toolCardMetadata'; +import type { FlowToolItem } from '../types/flow-chat'; + +const mocks = vi.hoisted(() => ({ openLink: vi.fn(), openExternal: vi.fn(), list: vi.fn(), error: vi.fn() })); +vi.mock('@/infrastructure/api/service-api/PageAPI', () => ({ pageAPI: { createOpenLink: mocks.openLink, listPages: mocks.list } })); +vi.mock('@/infrastructure/api/service-api/SystemAPI', () => ({ systemAPI: { openExternal: mocks.openExternal } })); +vi.mock('@/shared/notification-system', () => ({ notificationService: { error: mocks.error } })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ + t: (key: string, params: Record = {}) => { + const value = key.split('.').reduce((node, part) => (node as Record)?.[part], en); + return String(value ?? key).replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(params[name] ?? '')); + }, + formatDate: (value: number) => `date:${value}`, + formatNumber: (value: number) => String(value), +}) })); + +let container: HTMLDivElement; +let root: Root; +function render(action: string, result: Record, success = true) { + const toolItem: FlowToolItem = { + id: 'page-manage', type: 'tool', toolName: 'PageManage', timestamp: 0, status: 'completed', + toolCall: { id: 'page-manage', input: { action, slug: 'demo' } }, + toolResult: { success, result, error: success ? undefined : 'Permission denied' }, + }; + act(() => root.render()); +} +function expand() { + act(() => container.querySelector('[data-openbitfun-part="surface"]')!.click()); +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.clearAllMocks(); + activateSurface('local'); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe('PageManage result card', () => { + it('shows names, URLs, access and localized update times in the page list', () => { + render('list', { pages: [{ name: 'Release notes', slug: 'release', url: 'https://relay.example/p/release', visibility: 'private', updated_at: 123 }] }); + expect(container.textContent).toContain('1 items'); + expand(); + expect(container.textContent).toContain('Release notes'); + expect(container.textContent).toContain('https://relay.example/p/release'); + expect(container.textContent).toContain('Owner only'); + expect(container.textContent).toContain('date:123000'); + }); + + it('distinguishes a known empty list from missing results and failed requests', () => { + render('list', { pages: [] }); + expect(container.textContent).toContain('0 items'); + expand(); + expect(container.textContent).toContain('No Pages or versions found'); + render('list', {}); + expect(container.textContent).not.toContain('0 items'); + render('list', { pages: [] }, false); + expect(container.textContent).not.toContain('0 items'); + expect(container.querySelector('[data-status="error"]')).not.toBeNull(); + }); + + it('keeps a saved update distinct from a deployed version', () => { + render('update', { slug: 'demo', version_id: 'v2', deployed: false, preview_url: '/preview/v2' }); + expect(container.textContent).toContain('Update Page'); + expect(container.textContent).toContain('Create page preview'); + expect(container.textContent).not.toContain('Page is live'); + render('deploy', { slug: 'demo', version_id: 'v2', deployed: true, url: '/p/demo' }); + expect(container.textContent).toContain('Page is live'); + }); + + it('opens restricted pages through the scoped access API using the recorded generation', async () => { + mocks.openLink.mockResolvedValue({ open_url: 'https://relay.example/access-handoff' }); + render('permissions', { slug: 'demo', generation: 'generation-a', visibility: 'private', published: true, url: 'https://relay.example/p/demo' }); + const open = container.querySelector('[aria-label="Open production URL"]'); + expect(open).not.toBeNull(); + await act(async () => { open!.click(); }); + expect(mocks.openLink).toHaveBeenCalledWith('demo', 'generation-a', undefined); + expect(mocks.openExternal).toHaveBeenCalledWith('https://relay.example/access-handoff'); + expect(mocks.list).not.toHaveBeenCalled(); + }); + + it('does not open a stale peer result after the active device changes', async () => { + let finish!: (value: { open_url: string }) => void; + mocks.openLink.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + render('deploy', { slug: 'demo', generation: 'generation-a', deployed: true, url: '/p/demo' }); + await act(async () => { container.querySelector('[aria-label="Open production URL"]')!.click(); }); + act(() => activateSurface('another-peer')); + await act(async () => { finish({ open_url: 'https://relay.example/stale' }); }); + expect(mocks.openExternal).not.toHaveBeenCalled(); + expect(container.querySelector('[aria-label="Open production URL"]')!.disabled).toBe(true); + }); + + it('shows operation failure and never provides open controls for deletion', () => { + render('delete', { deleted: false }, false); + expand(); + expect(container.textContent).toContain('Permission denied'); + expect(container.querySelector('[aria-label="Open production URL"]')).toBeNull(); + }); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.tsx new file mode 100644 index 0000000000..b70fed7e7e --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.tsx @@ -0,0 +1,105 @@ +import React, { useCallback, useState, useSyncExternalStore } from 'react'; +import { Icon, IconButton } from '@openbitfun/ui'; +import { PagePublishToolCard } from '@openbitfun/ui/flow-chat'; +import { useI18n } from '@/infrastructure/i18n'; +import { pageAPI } from '@/infrastructure/api/service-api/PageAPI'; +import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; +import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { notificationService } from '@/shared/notification-system'; +import type { ToolCardProps } from '../types/flow-chat'; +import { useToolCardDisclosure } from '../timeline/readerState'; +import { useToolCardHeightContract } from './useToolCardHeightContract'; +import { getToolCardStatus, getToolCardStatusDescription } from './toolCardStatus'; + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} +function text(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +export const PageManageDisplay: React.FC = ({ toolItem }) => { + const { t, formatDate, formatNumber } = useI18n('flow-chat'); + const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); + const [surface] = useState(getActiveSurfaceScope); + const surfaceCurrent = useSyncExternalStore(onSurfaceActivated, () => surface.isCurrent(), () => true); + const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId: toolItem.id, toolName: toolItem.toolName }); + const input = record(toolItem.isParamsStreaming ? toolItem.partialParams : toolItem.toolCall?.input); + const result = record(toolItem.toolResult?.result); + const status = getToolCardStatus(toolItem); + const success = status === 'completed' && toolItem.toolResult?.success === true; + const action = text(input.action) || text(result.action); + const slug = text(result.slug) || text(input.slug); + const version = text(result.version_id); + const pages = success && Array.isArray(result.pages) ? result.pages.map(record) : undefined; + const versions = success && Array.isArray(result.versions) ? result.versions.map(record) : undefined; + const labels: Record = { + list: t('toolCards.pageManage.list'), publish: t('toolCards.pageManage.publish'), + update: t('toolCards.pageManage.update'), permissions: t('toolCards.pageManage.permissions'), + delete: t('toolCards.pageManage.delete'), versions: t('toolCards.pageManage.versions'), + deploy: t('toolCards.pageManage.deploy'), unpublish: t('toolCards.pageManage.unpublish'), + }; + const visibility = (value: unknown) => { + switch (value) { + case 'private': return t('toolCards.pageManage.private'); + case 'relay': return t('toolCards.pageManage.relay'); + case 'public': return t('toolCards.pageManage.public'); + default: return text(value); + } + }; + const date = (value: unknown) => typeof value === 'number' && Number.isFinite(value) + ? formatDate(value * 1000, { dateStyle: 'medium', timeStyle: 'short' }) : ''; + const fields = pages ? pages.map(page => ({ + label: text(page.name) || text(page.title) || text(page.slug), + value: [text(page.url), visibility(page.visibility), date(page.updated_at)].filter(Boolean).join(' · '), + })) : versions ? versions.map(item => ({ + label: text(item.version_id), + value: [text(item.title), text(item.preview_url), date(item.created_at)].filter(Boolean).join(' · '), + })) : success ? [ + result.title ? { label: t('toolCards.pageManage.name'), value: text(result.title) } : null, + result.visibility ? { label: t('toolCards.pageManage.permissions'), value: visibility(result.visibility) } : null, + result.url ? { label: t('toolCards.pagePublish.labelPath'), value: text(result.url) } : null, + result.preview_url ? { label: t('toolCards.pagePublish.labelPreview'), value: text(result.preview_url) } : null, + typeof result.updated_at === 'number' ? { label: t('toolCards.pageManage.updated'), value: date(result.updated_at) } : null, + ].filter((field): field is NonNullable => field !== null) : []; + if (pages?.length === 0 || versions?.length === 0) { + fields.push({ label: labels[action] || '', value: t('toolCards.pageManage.empty') }); + } + const error = status === 'error' ? text(toolItem.toolResult?.error) || t('toolCards.default.failed') : undefined; + const toggle = useCallback(() => applyExpandedState(isExpanded, !isExpanded, setIsExpanded), [applyExpandedState, isExpanded, setIsExpanded]); + const open = async (preview: boolean) => { + if (!surface.isCurrent()) return; + try { + const generation = typeof result.generation === 'string' ? result.generation + : (await pageAPI.listPages()).find(page => page.slug === slug)?.generation; + if (!surface.isCurrent()) return; + if (generation == null) throw new Error('Page no longer exists'); + const link = await pageAPI.createOpenLink(slug, generation, preview ? version : undefined); + if (surface.isCurrent()) await systemAPI.openExternal(link.open_url); + } catch { + if (surface.isCurrent()) notificationService.error(t('toolCards.pagePublish.openFailed')); + } + }; + const live = result.deployed === true || result.published === true; + const preview = result.deployed === false && Boolean(result.preview_url); + const count = pages?.length ?? versions?.length; + const summary = typeof count === 'number' + ? t('toolCards.pageManage.count', { count: formatNumber(count) }) : slug; + return
+ + {live && Boolean(result.url) && } aria-label={t('toolCards.pagePublish.openProduction')} + onClick={() => void open(false)} />} + {Boolean(result.preview_url) && version && } aria-label={t('toolCards.pagePublish.openPreview')} + onClick={() => void open(true)} />} + : undefined} /> +
; +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/index.test.ts b/src/web-ui/src/flow_chat/tool-cards/index.test.ts index e6bc700c57..15f0afe557 100644 --- a/src/web-ui/src/flow_chat/tool-cards/index.test.ts +++ b/src/web-ui/src/flow_chat/tool-cards/index.test.ts @@ -93,6 +93,7 @@ describe('tool card registry', () => { 'ExecCommand', 'WriteStdin', 'ExecControl', + 'PageManage', 'PageDeploy', 'PagePublish', 'view_image', diff --git a/src/web-ui/src/flow_chat/tool-cards/index.ts b/src/web-ui/src/flow_chat/tool-cards/index.ts index 40a4e384ca..fc887fd737 100644 --- a/src/web-ui/src/flow_chat/tool-cards/index.ts +++ b/src/web-ui/src/flow_chat/tool-cards/index.ts @@ -68,6 +68,7 @@ import { ExecControlToolCard } from './ExecControlToolCard'; import { InitMiniAppDisplay } from './MiniAppToolDisplay'; import { PageDeployDisplay } from './PageDeployToolDisplay'; import { PagePublishDisplay } from './PagePublishToolDisplay'; +import { PageManageDisplay } from './PageManageToolDisplay'; import { GenerativeWidgetToolCard } from './GenerativeWidgetToolCard'; import { CanvasToolCard } from './CanvasToolCard'; import { ReviewSessionSummaryCard } from './ReviewSessionSummaryCard'; @@ -141,6 +142,7 @@ export const STANDARD_TOOL_CARD_ADAPTERS = { 'ExecControl': ExecControlToolCard, // OpenBitFun Page (session-only publish) + 'PageManage': PageManageDisplay, 'PageDeploy': PageDeployDisplay, 'PagePublish': PagePublishDisplay, diff --git a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts index 1962c59906..f079e6fdf7 100644 --- a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts +++ b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts @@ -58,6 +58,7 @@ const PROMINENT_TOOL_CARD_NAMES = new Set([ 'Git', 'GetFileDiff', 'InitMiniApp', + 'PageManage', 'PageDeploy', 'PagePublish', 'GenerativeUI', @@ -507,6 +508,16 @@ const TOOL_CARD_DEFINITIONS: Record = { displayMode: 'standard', primaryColor: 'var(--openbitfun-color-accent-hover)' }, + 'PageManage': { + toolName: 'PageManage', + displayName: 'Manage Pages', + icon: 'WEB', + requiresConfirmation: false, + resultDisplayType: 'detailed', + description: 'List, publish, update, delete, and manage access to OpenBitFun Pages', + displayMode: 'standard', + primaryColor: APPEARANCE_DOMAIN_TOKENS.toolIdentity.terminal + }, 'PageDeploy': { toolName: 'PageDeploy', displayName: 'Deploy Page', diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index 33d46c8957..44155e2798 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "9ec82305067852766c79ed3d802c3a16f1c4dd992f7d3195f91a2989da32f544" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "95e9b682d9e2fe6d4d9fe80b6564af3dec33f4b860e1b6ffc7da0eebea4d0e27" as const; export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "feature.desktop-cards" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 22bf4314cb..0f0b56459c 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -3414,6 +3414,24 @@ "hidden": "Hidden", "disabledNotice": "Desktop cards are turned off in settings, so these cards are not displayed." } + }, + "pageManage": { + "title": "Manage Pages", + "list": "List Pages", + "publish": "Publish Page", + "update": "Update Page", + "permissions": "Page access", + "delete": "Delete Page", + "versions": "Page versions", + "deploy": "Deploy Page", + "unpublish": "Unpublish Page", + "private": "Owner only", + "relay": "Relay users", + "public": "Public", + "name": "Name", + "updated": "Updated", + "empty": "No Pages or versions found", + "count": "{{count}} items" } }, "welcome": { diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index db050d59ae..fa349e7afd 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -3414,6 +3414,24 @@ "hidden": "已隐藏", "disabledNotice": "小卡片功能已在设置中关闭,这些卡片当前不会显示。" } + }, + "pageManage": { + "title": "管理 Pages", + "list": "列出 Pages", + "publish": "发布页面", + "update": "更新页面", + "permissions": "访问权限", + "delete": "删除页面", + "versions": "页面版本", + "deploy": "部署页面", + "unpublish": "取消发布", + "private": "仅自己", + "relay": "Relay 登录用户", + "public": "公开", + "name": "名称", + "updated": "更新时间", + "empty": "暂无页面或版本", + "count": "{{count}} 项" } }, "welcome": { diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index e805f3834e..0b0f59a022 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -3414,6 +3414,24 @@ "hidden": "已隱藏", "disabledNotice": "小卡片功能已在設定中關閉,這些卡片目前不會顯示。" } + }, + "pageManage": { + "title": "管理 Pages", + "list": "列出 Pages", + "publish": "發佈頁面", + "update": "更新頁面", + "permissions": "存取權限", + "delete": "刪除頁面", + "versions": "頁面版本", + "deploy": "部署頁面", + "unpublish": "取消發佈", + "private": "僅自己", + "relay": "Relay 登入使用者", + "public": "公開", + "name": "名稱", + "updated": "更新時間", + "empty": "暫無頁面或版本", + "count": "{{count}} 項" } }, "welcome": { From d897bb85c472e28342471d5d8da6a9e7b5fc8300 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 30 Oct 2026 14:51:30 +0800 Subject: [PATCH 3/6] fix(pages): fence account links to the executing device --- .../tool-cards/PageDeployToolDisplay.tsx | 23 ++--- .../tool-cards/PageManageToolDisplay.test.tsx | 92 ++++++++++++++++++- .../tool-cards/PageManageToolDisplay.tsx | 36 +++----- .../tool-cards/PagePublishToolDisplay.tsx | 30 ++---- .../flow_chat/tool-cards/usePageToolOpen.ts | 50 ++++++++++ src/web-ui/src/locales/en-US/flow-chat.json | 1 + src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + 8 files changed, 166 insertions(+), 68 deletions(-) create mode 100644 src/web-ui/src/flow_chat/tool-cards/usePageToolOpen.ts diff --git a/src/web-ui/src/flow_chat/tool-cards/PageDeployToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/PageDeployToolDisplay.tsx index 2f465fd7ee..84cfc4583d 100644 --- a/src/web-ui/src/flow_chat/tool-cards/PageDeployToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/PageDeployToolDisplay.tsx @@ -9,21 +9,11 @@ import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { PageDeployToolCard } from '@openbitfun/ui/flow-chat'; import { useToolCardHeightContract } from './useToolCardHeightContract'; -import { pageAPI } from '@/infrastructure/api/service-api/PageAPI'; -import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; -import { notificationService } from '@/shared/notification-system'; +import { usePageToolOpen } from './usePageToolOpen'; -async function openPage(slug: string, knownGeneration?: string) { - if (!slug) return; - const generation = knownGeneration || (await pageAPI.listPages()) - .find((page) => page.slug === slug)?.generation; - if (generation == null) throw new Error('Page no longer exists'); - const link = await pageAPI.createOpenLink(slug, generation); - await systemAPI.openExternal(link.open_url); -} - -export const PageDeployDisplay: React.FC = ({ toolItem }) => { +export const PageDeployDisplay: React.FC = ({ toolItem, sessionId }) => { const { t } = useTranslation('flow-chat'); + const { canOpen, openPage, unavailableReason } = usePageToolOpen(sessionId); const { status, toolResult, partialParams, isParamsStreaming, toolCall } = toolItem; const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); @@ -118,11 +108,10 @@ export const PageDeployDisplay: React.FC = ({ toolItem }) => { size="sm" icon={} aria-label={t('toolCards.pageDeploy.openProduction')} - title={t('toolCards.pageDeploy.openProduction')} + disabled={!canOpen} + title={!canOpen ? unavailableReason : t('toolCards.pageDeploy.openProduction')} data-testid="chat-page-deploy-open-btn" - onClick={() => void openPage(slug, generation).catch(() => { - notificationService.error(t('toolCards.pageDeploy.openFailed')); - })} + onClick={() => void openPage(slug, generation)} /> ) : undefined} /> diff --git a/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.test.tsx index 4942152d2c..be8a45c8d9 100644 --- a/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.test.tsx @@ -5,13 +5,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import en from '@/locales/en-US/flow-chat.json'; import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; import { PageManageDisplay } from './PageManageToolDisplay'; +import { PagePublishDisplay } from './PagePublishToolDisplay'; +import { PageDeployDisplay } from './PageDeployToolDisplay'; import { getToolItemCardConfig } from './toolCardMetadata'; import type { FlowToolItem } from '../types/flow-chat'; -const mocks = vi.hoisted(() => ({ openLink: vi.fn(), openExternal: vi.fn(), list: vi.fn(), error: vi.fn() })); +const mocks = vi.hoisted(() => ({ + openLink: vi.fn(), openExternal: vi.fn(), list: vi.fn(), error: vi.fn(), + sessions: new Map(), + listeners: new Set<() => void>(), +})); +vi.mock('../store/FlowChatStore', () => ({ flowChatStore: { + getState: () => ({ sessions: mocks.sessions }), + subscribe: (listener: () => void) => { mocks.listeners.add(listener); return () => mocks.listeners.delete(listener); }, +} })); vi.mock('@/infrastructure/api/service-api/PageAPI', () => ({ pageAPI: { createOpenLink: mocks.openLink, listPages: mocks.list } })); vi.mock('@/infrastructure/api/service-api/SystemAPI', () => ({ systemAPI: { openExternal: mocks.openExternal } })); vi.mock('@/shared/notification-system', () => ({ notificationService: { error: mocks.error } })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ + t: (key: string) => String(key.split('.').reduce((node, part) => (node as Record)?.[part], en) ?? key), +}) })); vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string, params: Record = {}) => { const value = key.split('.').reduce((node, part) => (node as Record)?.[part], en); @@ -23,13 +36,13 @@ vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ let container: HTMLDivElement; let root: Root; -function render(action: string, result: Record, success = true) { +function render(action: string, result: Record, success = true, Card = PageManageDisplay, sessionId?: string) { const toolItem: FlowToolItem = { id: 'page-manage', type: 'tool', toolName: 'PageManage', timestamp: 0, status: 'completed', - toolCall: { id: 'page-manage', input: { action, slug: 'demo' } }, + toolCall: { id: 'page-manage', input: { action, slug: 'demo', version_id: 'v1' } }, toolResult: { success, result, error: success ? undefined : 'Permission denied' }, }; - act(() => root.render()); + act(() => root.render()); } function expand() { act(() => container.querySelector('[data-openbitfun-part="surface"]')!.click()); @@ -37,12 +50,81 @@ function expand() { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; - vi.clearAllMocks(); + vi.resetAllMocks(); + mocks.sessions.clear(); activateSurface('local'); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); }); + +describe.each([ + ['PageManage', PageManageDisplay], ['PagePublish', PagePublishDisplay], ['PageDeploy', PageDeployDisplay], +] as const)('%s account link ownership', (_name, Card) => { + const result = { slug: 'demo', generation: 'generation-a', version_id: 'v1', deployed: true, url: 'https://relay.example/p/demo' }; + const button = () => container.querySelector('[aria-label="Open production URL"]')!; + + it('opens a normal session result with the recorded identity', async () => { + mocks.sessions.set('normal', { config: {} }); + mocks.openLink.mockResolvedValue({ open_url: 'https://relay.example/access' }); + render('deploy', result, true, Card, 'normal'); + await act(async () => { button().click(); }); + expect(mocks.openLink).toHaveBeenCalledWith('demo', 'generation-a', undefined); + expect(mocks.openExternal).toHaveBeenCalledWith('https://relay.example/access'); + }); + + it('never asks the controller for access to a dispatched task or its child', async () => { + mocks.sessions.set('dispatch', { config: { dispatchJobId: 'target-job' } }); + mocks.sessions.set('child', { parentSessionId: 'dispatch', config: {} }); + for (const sessionId of ['dispatch', 'child']) { + render('deploy', result, true, Card, sessionId); + expect(button().disabled).toBe(true); + expect(button().title).toContain('device that ran the task'); + await act(async () => { button().click(); }); + } + expect(mocks.list).not.toHaveBeenCalled(); + expect(mocks.openLink).not.toHaveBeenCalled(); + expect(mocks.openExternal).not.toHaveBeenCalled(); + }); + + it('waits for session ownership to hydrate and reacts to removal', () => { + render('deploy', result, true, Card, 'normal'); + expect(button().disabled).toBe(true); + act(() => { + mocks.sessions.set('normal', { config: {} }); + mocks.listeners.forEach(listener => listener()); + }); + expect(button().disabled).toBe(false); + act(() => { + mocks.sessions.delete('normal'); + mocks.listeners.forEach(listener => listener()); + }); + expect(button().disabled).toBe(true); + }); + + it('abandons an old-result lookup after switching devices', async () => { + let finish!: (value: { slug: string; generation: string }[]) => void; + mocks.list.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + render('deploy', { ...result, generation: undefined }, true, Card); + await act(async () => { button().click(); }); + act(() => activateSurface('another-peer')); + await act(async () => { finish([{ slug: 'demo', generation: 'other-device' }]); }); + expect(mocks.openLink).not.toHaveBeenCalled(); + expect(mocks.openExternal).not.toHaveBeenCalled(); + expect(button().disabled).toBe(true); + }); + + it('does not open a completed access request after switching devices', async () => { + let finish!: (value: { open_url: string }) => void; + mocks.openLink.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + render('deploy', result, true, Card); + await act(async () => { button().click(); }); + act(() => activateSurface('another-peer')); + await act(async () => { finish({ open_url: 'https://relay.example/stale' }); }); + expect(mocks.openExternal).not.toHaveBeenCalled(); + expect(mocks.error).not.toHaveBeenCalled(); + }); +}); afterEach(() => { act(() => root.unmount()); container.remove(); diff --git a/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.tsx index b70fed7e7e..fcb86a09a1 100644 --- a/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.tsx @@ -1,15 +1,12 @@ -import React, { useCallback, useState, useSyncExternalStore } from 'react'; +import React, { useCallback } from 'react'; import { Icon, IconButton } from '@openbitfun/ui'; import { PagePublishToolCard } from '@openbitfun/ui/flow-chat'; import { useI18n } from '@/infrastructure/i18n'; -import { pageAPI } from '@/infrastructure/api/service-api/PageAPI'; -import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; -import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; -import { notificationService } from '@/shared/notification-system'; import type { ToolCardProps } from '../types/flow-chat'; import { useToolCardDisclosure } from '../timeline/readerState'; import { useToolCardHeightContract } from './useToolCardHeightContract'; import { getToolCardStatus, getToolCardStatusDescription } from './toolCardStatus'; +import { usePageToolOpen } from './usePageToolOpen'; function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; @@ -18,11 +15,10 @@ function text(value: unknown): string { return typeof value === 'string' ? value : ''; } -export const PageManageDisplay: React.FC = ({ toolItem }) => { +export const PageManageDisplay: React.FC = ({ toolItem, sessionId }) => { const { t, formatDate, formatNumber } = useI18n('flow-chat'); const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); - const [surface] = useState(getActiveSurfaceScope); - const surfaceCurrent = useSyncExternalStore(onSurfaceActivated, () => surface.isCurrent(), () => true); + const { canOpen, openPage, unavailableReason } = usePageToolOpen(sessionId); const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId: toolItem.id, toolName: toolItem.toolName }); const input = record(toolItem.isParamsStreaming ? toolItem.partialParams : toolItem.toolCall?.input); const result = record(toolItem.toolResult?.result); @@ -67,19 +63,7 @@ export const PageManageDisplay: React.FC = ({ toolItem }) => { } const error = status === 'error' ? text(toolItem.toolResult?.error) || t('toolCards.default.failed') : undefined; const toggle = useCallback(() => applyExpandedState(isExpanded, !isExpanded, setIsExpanded), [applyExpandedState, isExpanded, setIsExpanded]); - const open = async (preview: boolean) => { - if (!surface.isCurrent()) return; - try { - const generation = typeof result.generation === 'string' ? result.generation - : (await pageAPI.listPages()).find(page => page.slug === slug)?.generation; - if (!surface.isCurrent()) return; - if (generation == null) throw new Error('Page no longer exists'); - const link = await pageAPI.createOpenLink(slug, generation, preview ? version : undefined); - if (surface.isCurrent()) await systemAPI.openExternal(link.open_url); - } catch { - if (surface.isCurrent()) notificationService.error(t('toolCards.pagePublish.openFailed')); - } - }; + const generation = typeof result.generation === 'string' ? result.generation : undefined; const live = result.deployed === true || result.published === true; const preview = result.deployed === false && Boolean(result.preview_url); const count = pages?.length ?? versions?.length; @@ -94,12 +78,14 @@ export const PageManageDisplay: React.FC = ({ toolItem }) => { || (success && live ? t('toolCards.pagePublish.publishedTitle') : success && preview ? t('toolCards.pagePublish.previewTitle') : undefined)} actions={success && slug && (live || preview) ? <> - {live && Boolean(result.url) && } aria-label={t('toolCards.pagePublish.openProduction')} - onClick={() => void open(false)} />} - {Boolean(result.preview_url) && version && void openPage(slug, generation)} />} + {Boolean(result.preview_url) && version && } aria-label={t('toolCards.pagePublish.openPreview')} - onClick={() => void open(true)} />} + onClick={() => void openPage(slug, generation, version)} />} : undefined} /> ; }; diff --git a/src/web-ui/src/flow_chat/tool-cards/PagePublishToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/PagePublishToolDisplay.tsx index 6930840695..82d862c523 100644 --- a/src/web-ui/src/flow_chat/tool-cards/PagePublishToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/PagePublishToolDisplay.tsx @@ -9,21 +9,11 @@ import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { PagePublishToolCard } from '@openbitfun/ui/flow-chat'; import { useToolCardHeightContract } from './useToolCardHeightContract'; -import { pageAPI } from '@/infrastructure/api/service-api/PageAPI'; -import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; -import { notificationService } from '@/shared/notification-system'; +import { usePageToolOpen } from './usePageToolOpen'; -async function openPage(slug: string, knownGeneration?: string, versionId?: string) { - if (!slug) return; - const generation = knownGeneration || (await pageAPI.listPages()) - .find((page) => page.slug === slug)?.generation; - if (generation == null) throw new Error('Page no longer exists'); - const link = await pageAPI.createOpenLink(slug, generation, versionId); - await systemAPI.openExternal(link.open_url); -} - -export const PagePublishDisplay: React.FC = ({ toolItem }) => { +export const PagePublishDisplay: React.FC = ({ toolItem, sessionId }) => { const { t } = useTranslation('flow-chat'); + const { canOpen, openPage, unavailableReason } = usePageToolOpen(sessionId); const { status, toolResult, partialParams, isParamsStreaming, toolCall } = toolItem; const [isExpanded, setIsExpanded] = useToolCardDisclosure('isExpanded'); @@ -123,11 +113,10 @@ export const PagePublishDisplay: React.FC = ({ toolItem }) => { size="sm" icon={} aria-label={t('toolCards.pagePublish.openProduction')} - title={t('toolCards.pagePublish.openProduction')} + disabled={!canOpen} + title={!canOpen ? unavailableReason : t('toolCards.pagePublish.openProduction')} data-testid="chat-page-publish-open-prod-btn" - onClick={() => void openPage(slug, generation).catch(() => { - notificationService.error(t('toolCards.pagePublish.openFailed')); - })} + onClick={() => void openPage(slug, generation)} /> )} {previewPath && versionId && ( @@ -137,11 +126,10 @@ export const PagePublishDisplay: React.FC = ({ toolItem }) => { size="sm" icon={} aria-label={t('toolCards.pagePublish.openPreview')} - title={t('toolCards.pagePublish.openPreview')} + disabled={!canOpen} + title={!canOpen ? unavailableReason : t('toolCards.pagePublish.openPreview')} data-testid="chat-page-publish-open-preview-btn" - onClick={() => void openPage(slug, generation, versionId).catch(() => { - notificationService.error(t('toolCards.pagePublish.openFailed')); - })} + onClick={() => void openPage(slug, generation, versionId)} /> )} diff --git a/src/web-ui/src/flow_chat/tool-cards/usePageToolOpen.ts b/src/web-ui/src/flow_chat/tool-cards/usePageToolOpen.ts new file mode 100644 index 0000000000..edcd18e0a5 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/usePageToolOpen.ts @@ -0,0 +1,50 @@ +import { useCallback, useState, useSyncExternalStore } from 'react'; +import { useI18n } from '@/infrastructure/i18n'; +import { pageAPI } from '@/infrastructure/api/service-api/PageAPI'; +import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; +import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { notificationService } from '@/shared/notification-system'; +import { flowChatStore } from '../store/FlowChatStore'; + +function subscribeToPageOwner(listener: () => void): () => void { + const unsubscribeSessions = flowChatStore.subscribe(listener); + const unsubscribeSurface = onSurfaceActivated(listener); + return () => { unsubscribeSessions(); unsubscribeSurface(); }; +} + +/** Access links belong to the executing account; a dispatch observer cannot mint them locally. */ +export function usePageToolOpen(sessionId?: string) { + const { t } = useI18n('flow-chat'); + const [surface] = useState(getActiveSurfaceScope); + const isCurrentOwner = useCallback(() => { + if (!surface.isCurrent()) return false; + if (!sessionId) return true; + const { sessions } = flowChatStore.getState(); + const visited = new Set(); + let currentId: string | undefined = sessionId; + while (currentId) { + if (visited.has(currentId)) return false; + visited.add(currentId); + const session = sessions.get(currentId); + if (!session || session.config?.dispatchTarget || session.config?.dispatchJobId) return false; + currentId = session.parentSessionId; + } + return true; + }, [sessionId, surface]); + const canOpen = useSyncExternalStore(subscribeToPageOwner, isCurrentOwner, isCurrentOwner); + + const openPage = async (slug: string, knownGeneration?: string, versionId?: string) => { + if (!slug || !isCurrentOwner()) return; + try { + const generation = knownGeneration ?? (await pageAPI.listPages()).find(page => page.slug === slug)?.generation; + if (!isCurrentOwner()) return; + if (generation == null) throw new Error('Page no longer exists'); + const link = await pageAPI.createOpenLink(slug, generation, versionId); + if (isCurrentOwner()) await systemAPI.openExternal(link.open_url); + } catch { + if (isCurrentOwner()) notificationService.error(t('toolCards.pagePublish.openFailed')); + } + }; + + return { canOpen, openPage, unavailableReason: t('toolCards.pagePublish.openOnExecutingDevice') }; +} diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 0f0b56459c..7251671b3c 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -2166,6 +2166,7 @@ "untitled": "(unnamed)", "publishFailed": "Could not publish OpenBitFun Page", "openFailed": "Could not create a secure link for this Page.", + "openOnExecutingDevice": "Open this Page from Pages on the device that ran the task.", "labelSlug": "slug", "labelVersion": "version", "labelPath": "url_path", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index fa349e7afd..fd89d619cb 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -2166,6 +2166,7 @@ "untitled": "(未命名)", "publishFailed": "无法发布 OpenBitFun Page", "openFailed": "无法为该页面创建安全访问链接。", + "openOnExecutingDevice": "请在执行此任务的设备上,通过 Pages 打开此页面。", "labelSlug": "slug", "labelVersion": "version", "labelPath": "url_path", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 0b0f59a022..81f1762282 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -2166,6 +2166,7 @@ "untitled": "(未命名)", "publishFailed": "無法發佈 OpenBitFun Page", "openFailed": "無法為該頁面建立安全存取連結。", + "openOnExecutingDevice": "請在執行此任務的裝置上,透過 Pages 開啟此頁面。", "labelSlug": "slug", "labelVersion": "version", "labelPath": "url_path", From 804bac274997a464c49bc41084afc3fd98022041 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 30 Oct 2026 14:57:05 +0800 Subject: [PATCH 4/6] fix(pages): reject content updates over concurrent metadata changes --- src/apps/cli/README.md | 6 + src/apps/relay-server/README.md | 6 + .../tools/implementations/page_manage_tool.rs | 5 +- .../remote_connect/account_pages_tests.rs | 80 +++++++--- src/crates/services/relay-service/AGENTS.md | 4 + .../relay-service/src/routes/pages.rs | 143 ++++++++++++++++++ .../src/remote_connect/page_upload.rs | 28 +++- 7 files changed, 248 insertions(+), 24 deletions(-) diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 3e36a0ae01..4331ed9a71 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -272,6 +272,12 @@ Both save a preview unless `deploy=true` is requested. `permissions` sets `unpublish` takes a Page offline without deleting versions. `delete` removes the whole Page, or only the supplied `version_id`. +Content updates require a Relay that supports metadata preconditions. If another +device changes the title or permissions during upload, the save is rejected; +list the Page again before retrying. Older Relays return an unsupported state +for content updates while retaining their existing publish and management paths. +Do not replace a failed `update` with `publish`, which can change access settings. + The tool appears only while the executing CLI Runtime has an account session. Existing tool permissions still apply; unattended `exec` and dispatch runs use their configured approval policy. diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index 231e9146ab..4eaad53727 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -164,6 +164,12 @@ account APIs are enabled. Published Pages must use an origin separate from the account sign-in surface. Without both isolated origins, the standalone host returns 503 for `/api/pages`, `/api/page-auth`, and `/p` routes. +`POST /api/pages/check-files` advertises `metadata_preconditions_supported`. +Content updates use optional `expected_visibility` and `expected_title` on +version freeze to reject concurrent metadata changes under the Page write lease. +Older clients omit these fields and retain their existing publishing behavior; +new clients require this capability before attempting a content-only update. + Application limits do not replace network-layer protection. Public deployment also requires bounded proxy connections and request bodies, TLS, upstream DDoS protection, and alerts for saturation, rejected requests, and disk growth. diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/page_manage_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/page_manage_tool.rs index 80d05c000d..a8903903ae 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/page_manage_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/page_manage_tool.rs @@ -119,7 +119,7 @@ impl Tool for PageManageTool { } async fn description(&self) -> OpenBitFunResult { - Ok(r#"Manage OpenBitFun Pages on the executing host's account relay. Requires GitHub account login. + Ok(r#"Manage OpenBitFun Pages on the executing host's account relay. Requires OpenBitFun account login. Actions: - list: list published Pages by default, with name/title, slug, absolute url, visibility, updated_at (Unix seconds), generation and deployed_version_id. published_only=false also includes saved/unpublished Pages. @@ -133,6 +133,7 @@ Actions: Prefer inline files (relative path -> UTF-8 content); include index.html and/or server/worker.js. Do not create workspace files solely to publish. directory is only for sources already on disk or explicitly requested local copies; remote workspaces must use files. Content updates replace the entire bundle, not a patch. Use the returned version_id to retry deployment after a saved-version/deploy failure. Do not upload again blindly. +Content updates require Relay metadata precondition support. If unsupported, report it; do not substitute publish, which can change access settings. If metadata changes during an update, list the Page again before retrying. Public URLs can be shared directly. Private/relay URLs require authenticated access; open them through the Pages scene/tool card. Never describe raw restricted URLs as public share links. When the user requests an operation, call it directly. The permission system handles confirmation in Ask mode; Auto Approve and Full Access need no extra confirmation."#.into()) } @@ -278,7 +279,7 @@ When the user requests an operation, call it directly. The permission system han let request = parse_request(input, Some(context)).map_err(OpenBitFunError::tool)?; if !page_account_available().await { return Err(OpenBitFunError::tool( - "PageManage requires a logged-in GitHub account", + "PageManage requires a logged-in OpenBitFun account", )); } let result = invoke_page_manage(request) diff --git a/src/crates/assembly/core/src/service/remote_connect/account_pages_tests.rs b/src/crates/assembly/core/src/service/remote_connect/account_pages_tests.rs index 2a9be896ec..ee0bd14c8e 100644 --- a/src/crates/assembly/core/src/service/remote_connect/account_pages_tests.rs +++ b/src/crates/assembly/core/src/service/remote_connect/account_pages_tests.rs @@ -50,7 +50,7 @@ async fn relay_with_hook( let url = format!("http://{}", listener.local_addr().unwrap()); let task = tokio::spawn(async move { let mut bodies = Vec::new(); - for (index, (expected, response)) in responses.into_iter().enumerate() { + for (index, (expected, mut response)) in responses.into_iter().enumerate() { let (mut socket, _) = tokio::time::timeout(Duration::from_secs(10), listener.accept()) .await .unwrap() @@ -94,6 +94,9 @@ async fn relay_with_hook( serde_json::from_slice(&data[header_end..header_end + length]).unwrap() }); hook(index); + if response["upload_id"] == "$request" { + response["upload_id"] = bodies.last().unwrap()["upload_id"].clone(); + } let body = response.to_string(); let wire = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body); socket.write_all(wire.as_bytes()).await.unwrap(); @@ -385,14 +388,15 @@ async fn page_manage_routes_metadata_permissions_and_delete_with_generation() { #[tokio::test] async fn page_manage_content_update_keeps_name_visibility_and_inspected_generation() { - let mut existing = page(); - existing["title"] = json!("Existing title"); - existing["visibility"] = json!("relay"); - let (url, server) = relay(vec![ + for title in [None, Some("New title")] { + let mut existing = page(); + existing["title"] = json!("Existing title"); + existing["visibility"] = json!("relay"); + let (url, server) = relay(vec![ ("GET /api/pages ", json!([existing])), ( "POST /api/pages/check-files ", - json!({"needed":["index.html"]}), + json!({"needed":["index.html"], "upload_id":"$request", "expected_generation":"generation-a", "create":false, "metadata_preconditions_supported":true}), ), ("POST /api/pages/upload-files ", json!({})), ( @@ -403,23 +407,61 @@ async fn page_manage_content_update_keeps_name_visibility_and_inspected_generati ), ]) .await; - let runtime = logged_in(url.clone()).await; - let result = manage( + let runtime = logged_in(url.clone()).await; + let result = manage( &runtime, - manage_request(json!({"action":"update", "slug":"demo", "files":{"index.html":"Hello"}})), + manage_request(json!({"action":"update", "slug":"demo", "title":title, "files":{"index.html":"Hello"}})), ) .await .unwrap(); - assert_eq!(result["action"], "update"); - assert_eq!(result["title"], "Existing title"); - assert_eq!(result["visibility"], "relay"); - assert_eq!(result["deployed"], false); - assert_eq!(result["preview_url"], format!("{url}/preview/demo/v2")); - let bodies = server.await.unwrap(); - assert_eq!(bodies[1]["expected_generation"], "generation-a"); - assert_eq!(bodies[1]["create"], false); - assert_eq!(bodies[2]["visibility"], "relay"); - assert_eq!(bodies[2]["title"], "Existing title"); + assert_eq!(result["action"], "update"); + assert_eq!(result["title"], title.unwrap_or("Existing title")); + assert_eq!(result["visibility"], "relay"); + assert_eq!(result["deployed"], false); + assert_eq!(result["preview_url"], format!("{url}/preview/demo/v2")); + let bodies = server.await.unwrap(); + assert_eq!(bodies[1]["expected_generation"], "generation-a"); + assert_eq!(bodies[1]["create"], false); + assert_eq!(bodies[2]["visibility"], "relay"); + assert_eq!(bodies[2]["title"], title.unwrap_or("Existing title")); + assert_eq!(bodies[3]["expected_visibility"], "relay"); + assert_eq!( + bodies[3]["expected_title"], + if title.is_none() { + json!("Existing title") + } else { + Value::Null + } + ); + } +} + +#[tokio::test] +async fn page_manage_content_update_refuses_unnegotiated_metadata_protection() { + for check in [ + json!({"needed":["index.html"]}), + json!({"needed":["index.html"], "upload_id":"$request", + "expected_generation":"generation-a", "create":false}), + json!({"needed":["index.html"], "metadata_preconditions_supported":true}), + ] { + let (url, server) = relay(vec![ + ("GET /api/pages ", json!([page()])), + ("POST /api/pages/check-files ", check), + ]) + .await; + let runtime = logged_in(url).await; + let error = manage( + &runtime, + manage_request(json!({ + "action":"update", "slug":"demo", "files":{"index.html":"Hello"}, + })), + ) + .await + .unwrap_err(); + assert!(error.contains("does not support safe Page content updates")); + // Neither an upload nor a freeze was issued to the older Relay. + assert_eq!(server.await.unwrap().len(), 2); + } } #[tokio::test] diff --git a/src/crates/services/relay-service/AGENTS.md b/src/crates/services/relay-service/AGENTS.md index 031d17bebc..d2495d9c33 100644 --- a/src/crates/services/relay-service/AGENTS.md +++ b/src/crates/services/relay-service/AGENTS.md @@ -42,6 +42,10 @@ and embedded hosts. Run `cargo test -p openbitfun-relay-service` and `node scripts/check-core-boundaries.mjs` after changes. +For Pages upload, generation fencing, metadata preconditions, and legacy-client +compatibility, the focused entry point is +`cargo test --locked -p openbitfun-relay-service --lib routes::pages::tests`. + Account identity supports independent GitHub and email-code users. Preserve legacy numeric GitHub Relay IDs; email `accountId` values occupy the `email-` namespace. Never derive identity from the caller's claimed user ID or display name. New diff --git a/src/crates/services/relay-service/src/routes/pages.rs b/src/crates/services/relay-service/src/routes/pages.rs index f3847517fe..060e180434 100644 --- a/src/crates/services/relay-service/src/routes/pages.rs +++ b/src/crates/services/relay-service/src/routes/pages.rs @@ -618,6 +618,7 @@ pub struct CheckPageFilesResponse { pub existing_count: usize, pub total_count: usize, pub freeze_idempotency_supported: bool, + pub metadata_preconditions_supported: bool, } #[derive(Deserialize)] @@ -650,6 +651,10 @@ pub struct FreezeVersionRequest { #[serde(default)] pub expected_generation: Option, #[serde(default)] + pub expected_visibility: Option, + #[serde(default)] + pub expected_title: Option, + #[serde(default)] pub create: bool, #[serde(default)] pub title: String, @@ -1897,6 +1902,18 @@ async fn freeze_version( ) { return Err(StatusCode::CONFLICT); } + // Content-only updates must not restore stale metadata if another device + // renames the Page or restricts access during the upload. All metadata + // writers take this same lifecycle lease, so the check holds through save. + if body.expected_visibility.as_ref().is_some_and(|expected| { + current_page.as_ref().map(|page| &page.visibility) != Some(expected) + }) || body + .expected_title + .as_ref() + .is_some_and(|expected| current_page.as_ref().map(|page| &page.title) != Some(expected)) + { + return Err(StatusCode::CONFLICT); + } let count = PageVersionRow::count_for_page(db, &auth.user_id, &slug) .await @@ -2928,6 +2945,7 @@ fn process_check_page_files( existing_count, total_count, freeze_idempotency_supported: true, + metadata_preconditions_supported: true, }) } @@ -3258,6 +3276,131 @@ mod tests { version["version_id"].as_str().unwrap().to_string() } + #[tokio::test] + async fn freeze_metadata_preconditions_preserve_concurrent_access_and_title_changes() { + for (new_visibility, new_title) in [("private", "guarded"), ("public", "Renamed elsewhere")] + { + let (app, alice, _, pool) = setup_app_with_pool().await; + let slug = "guarded"; + let files = [("index.html", b"

original

".as_slice())]; + let (upload_id, upload_files) = begin_test_upload(&app, &alice, slug, &files).await; + finish_test_upload(&app, &alice, slug, "public", &upload_id, upload_files).await; + let generation = test_page_generation(&app, &alice, slug).await.unwrap(); + let files = [("index.html", b"

updated

".as_slice())]; + let (upload_id, upload_files) = begin_test_upload(&app, &alice, slug, &files).await; + let upload = serde_json::json!({ + "slug": slug, "upload_id": upload_id, "expected_generation": generation, + "visibility": "public", "title": slug, "files": upload_files, "finalize": true, + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/upload-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(upload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + // Another client changes metadata after the content upload has started. + let changes = serde_json::json!({ + "expected_generation": generation, "visibility": new_visibility, "title": new_title, + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/pages/guarded") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(changes.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let mut freeze = serde_json::json!({ + "upload_id": upload_id, "expected_generation": generation, "title": slug, + "expected_visibility": "public", "expected_title": slug, + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/guarded/versions") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(freeze.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + let current = PageRow::get(&pool, "101", slug).await.unwrap().unwrap(); + assert_eq!(current.visibility, new_visibility); + assert_eq!(current.title, new_title); + assert_eq!( + PageVersionRow::count_for_page(&pool, "101", slug) + .await + .unwrap(), + 1 + ); + + // A fresh observation permits a retry; an explicit new title may replace the current one. + freeze["expected_visibility"] = serde_json::json!(new_visibility); + freeze["expected_title"] = serde_json::Value::Null; + freeze["title"] = serde_json::json!("Agent title"); + // The upload's access setting must also match the fresh observation. + let mut upload = upload; + upload["visibility"] = serde_json::json!(new_visibility); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/upload-files") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(upload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/pages/guarded/versions") + .header("Authorization", format!("Bearer {alice}")) + .header("content-type", "application/json") + .body(axum::body::Body::from(freeze.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let current = PageRow::get(&pool, "101", slug).await.unwrap().unwrap(); + assert_eq!(current.visibility, new_visibility); + assert_eq!(current.title, "Agent title"); + assert_eq!( + PageVersionRow::count_for_page(&pool, "101", slug) + .await + .unwrap(), + 2 + ); + } + } + #[tokio::test] async fn completed_freeze_replays_the_original_version() { let (app, alice, _) = setup_app().await; diff --git a/src/crates/services/services-integrations/src/remote_connect/page_upload.rs b/src/crates/services/services-integrations/src/remote_connect/page_upload.rs index 17d58c0b99..a839cdd605 100644 --- a/src/crates/services/services-integrations/src/remote_connect/page_upload.rs +++ b/src/crates/services/services-integrations/src/remote_connect/page_upload.rs @@ -265,7 +265,7 @@ pub async fn update_page_content_on_relay( token, &page.slug, &page.visibility, - Some(title.unwrap_or(&page.title)), + title, note, all_files, Some(page), @@ -376,7 +376,11 @@ async fn save_page_version_from_collected_files( )); } + let expected_title = update_target + .filter(|_| title.is_none()) + .map(|page| page.title.as_str()); let title = title + .or(expected_title) .map(str::trim) .filter(|t| !t.is_empty()) .unwrap_or(slug) @@ -446,12 +450,23 @@ async fn save_page_version_from_collected_files( .as_bool() .unwrap_or(false); let upload_mode = validate_upload_session_echo(&check_body, &upload_id)?; + let generation_bound = upload_mode == UploadSessionMode::ManifestBound + && validate_generation_intent_echo(&check_body, expected_generation.as_deref(), create)?; + if update_target.is_some() + && (upload_mode != UploadSessionMode::ManifestBound + || !generation_bound + || expected_generation.is_none() + || check_body["metadata_preconditions_supported"].as_bool() != Some(true)) + { + return Err(anyhow!( + "This Relay does not support safe Page content updates; upgrade the Relay before retrying. Do not substitute publish because it can change access settings." + )); + } if upload_mode == UploadSessionMode::LegacyRelay { warn!( "Page relay does not support manifest-bound upload sessions; using the legacy upload flow" ); - } else if !validate_generation_intent_echo(&check_body, expected_generation.as_deref(), create)? - { + } else if !generation_bound { warn!( "Page relay does not support generation-bound uploads; using its legacy generation flow" ); @@ -510,6 +525,8 @@ async fn save_page_version_from_collected_files( "create": create, "title": title, "note": note.unwrap_or(""), + "expected_visibility": update_target.map(|page| &page.visibility), + "expected_title": expected_title, })) .timeout(std::time::Duration::from_secs(30)); let freeze_policy = if freeze_idempotency_supported { @@ -522,6 +539,11 @@ async fn save_page_version_from_collected_files( .map_err(|e| anyhow!("freeze version failed: {e}"))?; if !freeze_resp.status().is_success() { let status = freeze_resp.status(); + if update_target.is_some() && status == reqwest::StatusCode::CONFLICT { + return Err(anyhow!( + "Page changed while saving content; list the Page again and retry with its current title and permissions" + )); + } let body = freeze_resp.text().await.unwrap_or_default(); return Err(anyhow!("freeze version failed: HTTP {status} — {body}")); } From 817a5bc054239dddca99671629425ee4a84bf374 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 30 Oct 2026 15:00:49 +0800 Subject: [PATCH 5/6] fix(pages): gate link actions on unsupported peer hosts --- .../tool-cards/PageManageToolDisplay.test.tsx | 25 +++++++++++++++++++ .../flow_chat/tool-cards/usePageToolOpen.ts | 15 ++++++++--- src/web-ui/src/locales/en-US/flow-chat.json | 1 + src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.test.tsx index be8a45c8d9..e86933a9bd 100644 --- a/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/PageManageToolDisplay.test.tsx @@ -14,6 +14,11 @@ const mocks = vi.hoisted(() => ({ openLink: vi.fn(), openExternal: vi.fn(), list: vi.fn(), error: vi.fn(), sessions: new Map(), listeners: new Set<() => void>(), + peerActive: false, + hostKind: null as 'cli' | 'desktop' | null, +})); +vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ + usePeerDeviceModeOptional: () => ({ peerMode: { active: mocks.peerActive }, currentPeerCapabilities: { hostKind: mocks.hostKind } }), })); vi.mock('../store/FlowChatStore', () => ({ flowChatStore: { getState: () => ({ sessions: mocks.sessions }), @@ -52,6 +57,8 @@ beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; vi.resetAllMocks(); mocks.sessions.clear(); + mocks.peerActive = false; + mocks.hostKind = null; activateSurface('local'); container = document.createElement('div'); document.body.appendChild(container); @@ -102,6 +109,24 @@ describe.each([ expect(button().disabled).toBe(true); }); + it('gates CLI and unknown peers while retaining the Desktop peer path', async () => { + mocks.peerActive = true; + for (const hostKind of ['cli', null] as const) { + mocks.hostKind = hostKind; + render('deploy', result, true, Card); + expect(button().disabled).toBe(true); + expect(button().title).toContain('does not support opening Pages'); + await act(async () => { button().click(); }); + } + expect(mocks.openLink).not.toHaveBeenCalled(); + mocks.hostKind = 'desktop'; + mocks.openLink.mockResolvedValue({ open_url: 'https://relay.example/peer-access' }); + render('deploy', result, true, Card); + expect(button().disabled).toBe(false); + await act(async () => { button().click(); }); + expect(mocks.openExternal).toHaveBeenCalledWith('https://relay.example/peer-access'); + }); + it('abandons an old-result lookup after switching devices', async () => { let finish!: (value: { slug: string; generation: string }[]) => void; mocks.list.mockImplementation(() => new Promise(resolve => { finish = resolve; })); diff --git a/src/web-ui/src/flow_chat/tool-cards/usePageToolOpen.ts b/src/web-ui/src/flow_chat/tool-cards/usePageToolOpen.ts index edcd18e0a5..b126a14344 100644 --- a/src/web-ui/src/flow_chat/tool-cards/usePageToolOpen.ts +++ b/src/web-ui/src/flow_chat/tool-cards/usePageToolOpen.ts @@ -3,6 +3,7 @@ import { useI18n } from '@/infrastructure/i18n'; import { pageAPI } from '@/infrastructure/api/service-api/PageAPI'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; import { notificationService } from '@/shared/notification-system'; import { flowChatStore } from '../store/FlowChatStore'; @@ -15,9 +16,13 @@ function subscribeToPageOwner(listener: () => void): () => void { /** Access links belong to the executing account; a dispatch observer cannot mint them locally. */ export function usePageToolOpen(sessionId?: string) { const { t } = useI18n('flow-chat'); + const peer = usePeerDeviceModeOptional(); + // Desktop peers implement Pages commands; CLI and unclassified peers cannot + // mint account access links through HostInvoke. + const hostSupportsOpen = !peer?.peerMode.active || peer.currentPeerCapabilities?.hostKind === 'desktop'; const [surface] = useState(getActiveSurfaceScope); const isCurrentOwner = useCallback(() => { - if (!surface.isCurrent()) return false; + if (!surface.isCurrent() || !hostSupportsOpen) return false; if (!sessionId) return true; const { sessions } = flowChatStore.getState(); const visited = new Set(); @@ -30,7 +35,7 @@ export function usePageToolOpen(sessionId?: string) { currentId = session.parentSessionId; } return true; - }, [sessionId, surface]); + }, [hostSupportsOpen, sessionId, surface]); const canOpen = useSyncExternalStore(subscribeToPageOwner, isCurrentOwner, isCurrentOwner); const openPage = async (slug: string, knownGeneration?: string, versionId?: string) => { @@ -46,5 +51,9 @@ export function usePageToolOpen(sessionId?: string) { } }; - return { canOpen, openPage, unavailableReason: t('toolCards.pagePublish.openOnExecutingDevice') }; + return { + canOpen, openPage, + unavailableReason: hostSupportsOpen + ? t('toolCards.pagePublish.openOnExecutingDevice') : t('toolCards.pagePublish.hostUnsupported'), + }; } diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 7251671b3c..868e14fefc 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -2167,6 +2167,7 @@ "publishFailed": "Could not publish OpenBitFun Page", "openFailed": "Could not create a secure link for this Page.", "openOnExecutingDevice": "Open this Page from Pages on the device that ran the task.", + "hostUnsupported": "This host does not support opening Pages from tool cards.", "labelSlug": "slug", "labelVersion": "version", "labelPath": "url_path", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index fd89d619cb..923d745af9 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -2167,6 +2167,7 @@ "publishFailed": "无法发布 OpenBitFun Page", "openFailed": "无法为该页面创建安全访问链接。", "openOnExecutingDevice": "请在执行此任务的设备上,通过 Pages 打开此页面。", + "hostUnsupported": "此主机暂不支持从工具卡片打开 Pages。", "labelSlug": "slug", "labelVersion": "version", "labelPath": "url_path", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 81f1762282..c43646473e 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -2167,6 +2167,7 @@ "publishFailed": "無法發佈 OpenBitFun Page", "openFailed": "無法為該頁面建立安全存取連結。", "openOnExecutingDevice": "請在執行此任務的裝置上,透過 Pages 開啟此頁面。", + "hostUnsupported": "此主機暫不支援從工具卡片開啟 Pages。", "labelSlug": "slug", "labelVersion": "version", "labelPath": "url_path", From 6744e29aaa53ac6af44e62dcd25583d2640be5f8 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 30 Oct 2026 15:32:14 +0800 Subject: [PATCH 6/6] fix(pages): register PageManage with the Desktop account host --- src/apps/desktop/AGENTS.md | 6 + .../desktop/src/api/remote_connect_api.rs | 29 +++- .../api/remote_connect_api/page_tool_tests.rs | 153 ++++++++++++++++++ .../src/agentic/tools/page_manage_host.rs | 2 +- .../core/src/product_runtime/account_pages.rs | 95 ++++++----- 5 files changed, 246 insertions(+), 39 deletions(-) create mode 100644 src/apps/desktop/src/api/remote_connect_api/page_tool_tests.rs diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 1fb6e5f933..5fea987bc5 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -192,6 +192,12 @@ For shared GitHub sign-in and token redaction, use For the matching cross-entry UI state, run `pnpm --dir src/web-ui run test:run src/infrastructure/account-identity/AccountIdentityService.test.ts src/features/market-account/AccountIdentityControls.test.tsx src/app/components/RemoteConnectDialog/ensureAccountSession.test.ts`. +For Pages Agent startup registration and Desktop-owned account execution, run +`cargo test --locked -p openbitfun-desktop --lib api::remote_connect_api::page_tool_tests`. +This child-process fixture invokes the actual registered tool against a loopback +Relay and covers account-switch fencing; it does not launch the native UI or +exercise a live remote device. + For side-question request compatibility, initial model selection, and optional message metadata, use `cargo test -p openbitfun-desktop --no-default-features --lib api::btw_api::tests`. diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index ab96ce7e7e..8bf601aef6 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -4,6 +4,7 @@ use crate::embedded_relay_host::DesktopEmbeddedRelayHost; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use openbitfun_core::agentic::tools::account_login_capability::set_account_login_available; use openbitfun_core::agentic::tools::page_deploy_host::set_page_deploy_handler; +use openbitfun_core::agentic::tools::page_manage_host::set_page_manage_handler; use openbitfun_core::agentic::tools::page_publish_host::set_page_publish_handler; use openbitfun_core::service::dispatch::{ DispatchAccountDaemonIdentity, DispatchAccountDaemonProvisionRequest, @@ -34,6 +35,8 @@ use std::sync::{Arc, OnceLock}; use tauri::{AppHandle, Emitter}; use tokio::sync::RwLock; +#[cfg(test)] +mod page_tool_tests; mod relay_routes; #[cfg(test)] mod relay_routes_tests; @@ -754,6 +757,25 @@ fn sync_account_login_capability(logged_in: bool) { set_account_login_available(logged_in); } +fn register_page_manage_host() { + set_page_manage_handler(Arc::new(|request| { + Box::pin(async move { + if TOKEN_EXPIRED.load(Ordering::Relaxed) { + return Err("Pages account session expired; sign in again".into()); + } + let generation = account_context_generation(); + let (session, relay_url) = read_account_context_for_generation(generation).await?; + openbitfun_core::product_runtime::account_pages::manage_with_account_context( + &relay_url, + &session.token, + request, + || account_context_is_current(generation), + ) + .await + }) + })); +} + fn register_page_deploy_host() { set_page_deploy_handler(Arc::new(|slug, version_id| { Box::pin(async move { @@ -1375,9 +1397,14 @@ async fn register_delegated_identity_providers() { }); } -pub fn init_on_startup() { +fn register_page_tool_hosts() { + register_page_manage_host(); register_page_deploy_host(); register_page_publish_host(); +} + +pub fn init_on_startup() { + register_page_tool_hosts(); tokio::spawn(async { let startup_generation = account_context_generation(); // Restore persisted account session (if any) before anything else diff --git a/src/apps/desktop/src/api/remote_connect_api/page_tool_tests.rs b/src/apps/desktop/src/api/remote_connect_api/page_tool_tests.rs new file mode 100644 index 0000000000..53d6d84220 --- /dev/null +++ b/src/apps/desktop/src/api/remote_connect_api/page_tool_tests.rs @@ -0,0 +1,153 @@ +//! Exercise the Desktop registration used by startup, not only the shared handler. +use super::*; +use axum::{extract::Json, http::HeaderMap, routing::get, Router}; +use openbitfun_core::agentic::tools::{ + framework::{Tool, ToolResult, ToolUseContext}, + implementations::PageManageTool, +}; +use serde_json::{json, Value}; +use std::sync::atomic::AtomicBool; + +#[tokio::test] +async fn desktop_page_manage_registration_reaches_the_executing_account() { + // Registrations are process-wide OnceLocks. Isolate the production registry + // and account state from other Desktop tests and from the developer's login. + if std::env::var_os("OPENBITFUN_PAGE_TOOL_TEST_CHILD").is_none() { + let root = tempfile::tempdir().unwrap(); + let mut command = openbitfun_services_core::process_manager::create_tokio_command( + std::env::current_exe().unwrap(), + ); + command.args([ + "api::remote_connect_api::page_tool_tests::desktop_page_manage_registration_reaches_the_executing_account", + "--exact", "--nocapture", + ]) + .env("OPENBITFUN_PAGE_TOOL_TEST_CHILD", "1") + .env("OPENBITFUN_HOME", root.path()) + .env("NO_PROXY", "127.0.0.1,localhost") + .env("no_proxy", "127.0.0.1,localhost") + .kill_on_drop(true); + let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output()) + .await + .unwrap() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed")); + return; + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay_url = format!("http://{}", listener.local_addr().unwrap()); + let page = json!({ + "slug":"demo", "generation":"generation-a", "title":"Desktop Page", "visibility":"private", + "file_count":1, "total_bytes":5, "created_at":1, "updated_at":2, + "url_path":"/p/demo", "deployed_version_id":"v1", + }); + let change_account = Arc::new(AtomicBool::new(false)); + let mutations = Arc::new(AtomicUsize::new(0)); + let app = Router::new() + .route( + "/api/pages", + get({ + let page = page.clone(); + let change_account = change_account.clone(); + move |headers: HeaderMap| { + let page = page.clone(); + let change_account = change_account.clone(); + async move { + assert_eq!(headers["authorization"], "Bearer desktop-pages-token"); + if change_account.swap(false, Ordering::SeqCst) { + ACCOUNT_CONTEXT_GENERATION.fetch_add(1, Ordering::AcqRel); + } + Json(json!([page])) + } + } + }), + ) + .route( + "/api/pages/demo", + axum::routing::patch({ + let mutations = mutations.clone(); + move |headers: HeaderMap, Json(request): Json| { + let page = page.clone(); + let mutations = mutations.clone(); + async move { + assert_eq!(headers["authorization"], "Bearer desktop-pages-token"); + assert_eq!( + request, + json!({"expected_generation":"generation-a", "visibility":"public"}) + ); + mutations.fetch_add(1, Ordering::SeqCst); + let mut page = page; + page["visibility"] = json!("public"); + Json(page) + } + } + }), + ); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + *get_account_context().write().await = Some(AccountContextState { + session: AccountSession::new("desktop-pages-token".into(), "desktop-user".into(), [0; 32]), + relay_url: relay_url.clone(), + }); + sync_account_login_capability(true); + register_page_tool_hosts(); + let tool = PageManageTool::new(); + let context = ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: Default::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: Default::default(), + runtime_handles: Default::default(), + }; + assert!(tool.is_available_in_context(Some(&context)).await); + let results = tool + .call_impl(&json!({"action":"list"}), &context) + .await + .unwrap(); + let ToolResult::Result { data, .. } = &results[0] else { + panic!("expected a Page list") + }; + assert_eq!(data["pages"][0]["name"], "Desktop Page"); + assert_eq!(data["pages"][0]["url"], format!("{relay_url}/p/demo")); + assert_eq!(data["pages"][0]["visibility"], "private"); + assert_eq!(data["pages"][0]["updated_at"], 2); + + let permissions = json!({"action":"permissions", "slug":"demo", "visibility":"public"}); + tool.call_impl(&permissions, &context).await.unwrap(); + assert_eq!(mutations.load(Ordering::SeqCst), 1); + change_account.store(true, Ordering::SeqCst); + assert!(tool + .call_impl(&permissions, &context) + .await + .unwrap_err() + .to_string() + .contains("account context changed")); + assert_eq!( + mutations.load(Ordering::SeqCst), + 1, + "a stale account snapshot cannot continue to mutation" + ); + + sync_account_login_capability(false); + assert!(!tool.is_available_in_context(Some(&context)).await); + assert!(tool + .call_impl(&json!({"action":"list"}), &context) + .await + .unwrap_err() + .to_string() + .contains("logged-in")); + server.abort(); +} diff --git a/src/crates/assembly/core/src/agentic/tools/page_manage_host.rs b/src/crates/assembly/core/src/agentic/tools/page_manage_host.rs index b4748ceedf..720ddb4163 100644 --- a/src/crates/assembly/core/src/agentic/tools/page_manage_host.rs +++ b/src/crates/assembly/core/src/agentic/tools/page_manage_host.rs @@ -105,6 +105,6 @@ pub fn set_page_manage_handler(handler: PageManageHandler) { pub async fn invoke_page_manage(request: PageManageRequest) -> Result { let handler = PAGE_MANAGE_HANDLER .get() - .ok_or("PageManage host is not available on this surface")?; + .ok_or("PageManage host handler is not registered; this is a host initialization error")?; handler(request).await } diff --git a/src/crates/assembly/core/src/product_runtime/account_pages.rs b/src/crates/assembly/core/src/product_runtime/account_pages.rs index 5f6b33d0c7..4c37869b3a 100644 --- a/src/crates/assembly/core/src/product_runtime/account_pages.rs +++ b/src/crates/assembly/core/src/product_runtime/account_pages.rs @@ -70,6 +70,32 @@ pub(crate) async fn manage( account: &AccountRuntime, request: PageManageRequest, ) -> Result { + if account.is_token_expired() { + return Err("Pages account session expired; sign in again".into()); + } + let generation = account.account_context_generation(); + let (session, relay_url) = account + .read_account_context_for_generation(generation) + .await + .map_err(|error| error.to_string())?; + manage_with_account_context(&relay_url, &session.token, request, || { + account.account_context_is_current(generation) + }) + .await +} + +/// Execute against one host-owned account snapshot. Desktop retains its own +/// account lifecycle; CLI uses AccountRuntime. Both supply the same token/Relay +/// pair and a fence that invalidates it when that host changes accounts. +pub async fn manage_with_account_context( + relay_url: &str, + token: &str, + request: PageManageRequest, + is_current: impl Fn() -> bool + Send + Sync, +) -> Result { + if !is_current() { + return Err("account context changed".into()); + } let action = request.action(); if let PageManageRequest::Publish { slug, @@ -81,34 +107,30 @@ pub(crate) async fn manage( files, } = request { - let mut result = publish( - account, - PagePublishHostRequest { - slug, - visibility, - title, - note, - deploy, - directory, - files, - }, + let result = publish_page_content_on_relay( + relay_url, + token, + &slug, + &visibility, + title.as_deref(), + note.as_deref(), + deploy, + directory.as_deref(), + files.as_ref(), ) - .await?; + .await + .map_err(|error| error.to_string())?; + if !is_current() { + return Err("account context changed; publication outcome is unknown, inspect the original account before retrying".into()); + } + let mut result = serde_json::to_value(result).map_err(|error| error.to_string())?; result["action"] = serde_json::json!(action); return Ok(result); } - if account.is_token_expired() { - return Err("Pages account session expired; sign in again".into()); - } - let generation = account.account_context_generation(); - let (session, relay_url) = account - .read_account_context_for_generation(generation) + let mut pages = list_pages_from_relay(relay_url, token) .await .map_err(|error| error.to_string())?; - let mut pages = list_pages_from_relay(&relay_url, &session.token) - .await - .map_err(|error| error.to_string())?; - if !account.account_context_is_current(generation) { + if !is_current() { return Err("account context changed".into()); } if let PageManageRequest::List { published_only } = request { @@ -126,7 +148,7 @@ pub(crate) async fn manage( }); let pages = pages .into_iter() - .map(|page| page_value(&relay_url, page)) + .map(|page| page_value(relay_url, page)) .collect::, _>>()?; return Ok(serde_json::json!({"action": action, "pages": pages})); } @@ -136,7 +158,6 @@ pub(crate) async fn manage( .find(|page| page.slug == slug) .ok_or("Page not found; use list with published_only=false before retrying")?; let readonly = request.is_readonly(); - let token = &session.token; let mut result = match request { PageManageRequest::Update { title, @@ -148,7 +169,7 @@ pub(crate) async fn manage( } => { if files.is_some() || directory.is_some() { let result = update_page_content_on_relay( - &relay_url, + relay_url, token, &page, title.as_deref(), @@ -162,7 +183,7 @@ pub(crate) async fn manage( serde_json::to_value(result).map_err(|error| error.to_string())? } else { let info = update_page_on_relay( - &relay_url, + relay_url, token, &page.slug, &page.generation, @@ -171,12 +192,12 @@ pub(crate) async fn manage( ) .await .map_err(|error| error.to_string())?; - page_value(&relay_url, info)? + page_value(relay_url, info)? } } PageManageRequest::Permissions { visibility, .. } => { let info = update_page_on_relay( - &relay_url, + relay_url, token, &page.slug, &page.generation, @@ -185,13 +206,13 @@ pub(crate) async fn manage( ) .await .map_err(|error| error.to_string())?; - page_value(&relay_url, info)? + page_value(relay_url, info)? } PageManageRequest::Delete { version_id, .. } => { match version_id.as_deref() { Some(version) => { delete_page_version_on_relay( - &relay_url, + relay_url, token, &page.slug, version, @@ -200,7 +221,7 @@ pub(crate) async fn manage( .await } None => { - delete_page_from_relay(&relay_url, token, &page.slug, &page.generation).await + delete_page_from_relay(relay_url, token, &page.slug, &page.generation).await } } .map_err(|error| error.to_string())?; @@ -211,20 +232,20 @@ pub(crate) async fn manage( value } PageManageRequest::Unpublish { .. } => { - unpublish_page_from_relay(&relay_url, token, &page.slug, &page.generation) + unpublish_page_from_relay(relay_url, token, &page.slug, &page.generation) .await .map_err(|error| error.to_string())?; serde_json::json!({"slug": page.slug, "unpublished": true}) } PageManageRequest::Versions { .. } => { let versions = - list_page_versions_from_relay(&relay_url, token, &page.slug, &page.generation) + list_page_versions_from_relay(relay_url, token, &page.slug, &page.generation) .await .map_err(|error| error.to_string())?; let versions = versions .into_iter() .map(|version| { - let preview_url = join_relay_url(&relay_url, &version.preview_url_path); + let preview_url = join_relay_url(relay_url, &version.preview_url_path); let mut value = serde_json::to_value(version).map_err(|error| error.to_string())?; value["preview_url"] = serde_json::json!(preview_url); @@ -235,7 +256,7 @@ pub(crate) async fn manage( } PageManageRequest::Deploy { version_id, .. } => { let info = deploy_page_version_on_relay( - &relay_url, + relay_url, token, &page.slug, &version_id, @@ -243,14 +264,14 @@ pub(crate) async fn manage( ) .await .map_err(|error| error.to_string())?; - let mut value = page_value(&relay_url, info)?; + let mut value = page_value(relay_url, info)?; value["version_id"] = serde_json::json!(version_id); value["deployed"] = serde_json::json!(true); value } PageManageRequest::List { .. } | PageManageRequest::Publish { .. } => unreachable!(), }; - if !account.account_context_is_current(generation) { + if !is_current() { return Err(if readonly { "account context changed" } else { "account context changed; Page operation outcome is unknown, inspect the original account before retrying" }.into());