From 6ec536b0bbc9d2fe535cce046e44fe3a1e573d7b Mon Sep 17 00:00:00 2001 From: jackie-cqz <2557911191@qq.com> Date: Wed, 28 Oct 2026 16:05:45 +0800 Subject: [PATCH 01/10] fix(desktop): isolate contexts when Server identity changes --- desktop/README.md | 6 +- desktop/README.zh.md | 6 +- desktop/SECURITY.md | 2 +- desktop/VALIDATION.md | 32 ++ desktop/src-tauri/examples/server_probe.rs | 12 +- desktop/src-tauri/src/connections/session.rs | 138 ++++- .../src-tauri/tests/deployment_identity.rs | 542 ++++++++++++++++++ desktop/ui/src/app/MemoryWorkspace.tsx | 27 +- desktop/ui/src/generated/ipc.ts | 2 +- desktop/ui/tests/memory.test.tsx | 60 +- 10 files changed, 754 insertions(+), 73 deletions(-) create mode 100644 desktop/src-tauri/tests/deployment_identity.rs diff --git a/desktop/README.md b/desktop/README.md index 296547a9c1..85b60d2022 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -102,7 +102,11 @@ Connection checks call the public `get_server_info` operation through the existi Qualification still requires an explicitly selected native record matching the bundled contract. A discovery 404 permits that record's independently qualified legacy operations; 401, 403, 503, malformed responses and incompatible products/required majors do not select a legacy fallback. Missing feature groups, unsupported feature versions and absent operation IDs restrict the affected operations. Exact Artifact reads require their own qualification; the `memory.explicit` group does not establish that support. Discovery metadata never grants authorization or attests a remote binary. -Connection checks and anonymous identity revalidation still perform readiness probes. The observed `server_id` is recorded with the check; deployment-aware context fencing and explicit readiness separation are not established by this discovery result. +Connection checks bind the observed `server_id` to the Principal and recheck the deployment after collecting connection facts. Scope listing/default lookup/selection and Memory search/exact reads revalidate deployment and Principal before dispatch and before returning private results. A changed identity, failed discovery, or disappearance of discovery clears the active context, invalidating Scope selections, cursors and pending reads. Reconnect explicitly to establish a new context. Package version changes alone do not change deployment identity. + +Submitted writes retain their original endpoint, `serverId` (null for qualified legacy Servers), Principal, Scope and generation. If the post-write identity check fails, a successful HTTP reply is treated as an unknown outcome: private bodies/references are withheld and the write is never automatically replayed. The original deployment is shown in write recovery feedback. A late write cannot invalidate a newly activated context. Qualified legacy sessions require discovery to remain 404; discovery appearing requires explicit rechecking rather than silently acquiring a deployment identity. + +These checks observe identity around requests; the public mutation contract has no conditional Server-identity precondition. They cannot atomically prevent an endpoint from changing between identity verification and dispatch, or detect a replacement that changes back between observations. Legacy Servers have no durable deployment fence. Discovery does not attest binary identity. Connection checks and anonymous identity revalidation still perform readiness probes; explicit readiness separation remains pending. Connections displays discovery separately from the compatibility verdict, with guidance for denied or temporarily unavailable discovery and the reason for an incompatible product or major version. A failed discovery check keeps business operations unavailable; it does not remove the selected qualification record. After correcting the connection or service, explicitly check the connection again to reassess operation availability. Only a discovery 404 permits the qualified legacy policy described above. diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 026cc31f72..441d216e89 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -80,7 +80,11 @@ pnpm --dir desktop desktop:dev 操作资格仍要求明确选择与当前契约匹配的原生验证记录。仅发现接口的 404 允许沿用该记录已独立验证的旧版操作;401、403、503、无效响应、不兼容产品或必要 major 版本不会触发旧版回退。缺失功能组、不支持的功能版本和缺失 operation ID 分别限制受影响的操作。Artifact 精确读取需要独立资格,不能从 `memory.explicit` 推断。发现元数据不授予权限,也不证明远端二进制身份。 -当前连接检查和匿名身份复核仍会调用 readiness。检查结果记录观察到的 `server_id`;这不代表已经实现部署身份隔离或显式 readiness 分离。 +连接检查将观察到的 `server_id` 与 Principal 绑定,并在收集连接事实后再次核对部署。Scope 列举、默认范围查找、范围选择,以及记忆搜索和精确读取,均在发送请求前和返回私有结果前复核部署及 Principal。身份变化、发现失败或发现接口消失时,会清除活动上下文,使 Scope 选择、游标和进行中的读取失效;须明确重新连接以建立新上下文。仅软件包版本变化不视为部署变化。 + +已提交写入保留原始端点、`serverId`(已验证旧版 Server 为 null)、Principal、Scope 和代次。写入后的身份复核失败时,即使 HTTP 返回成功也按结果未知处理:不返回私有正文或引用,不自动重放。写入恢复反馈显示原部署;旧写入晚到不会使新激活的上下文失效。已验证旧版会话要求发现接口持续返回 404;发现接口出现后须明确重新检查,不能静默获得部署身份。 + +这些检查观察请求前后的身份;公开写入契约没有以 Server 身份为条件的前置约束,因此不能原子性阻止验证与发送之间的端点替换,也不能发现两次观察之间发生后又恢复的替换。旧版 Server 没有持久部署隔离保障;发现信息也不证明二进制身份。连接检查和匿名身份复核仍会调用 readiness,显式 readiness 分离尚未完成。 连接页分别展示发现状态和兼容性结论,说明访问被拒绝、暂时不可用时的恢复方法,以及产品或 major 版本不兼容的原因。发现检查失败时业务操作保持不可用,但不会删除已选择的资格记录。修正连接或服务后,须明确重新检查连接,重新判定操作可用性。仅发现接口的 404 适用上述已验证旧版策略。 diff --git a/desktop/SECURITY.md b/desktop/SECURITY.md index 9ae502eb6c..b9cfe1d1fc 100644 --- a/desktop/SECURITY.md +++ b/desktop/SECURITY.md @@ -16,7 +16,7 @@ Native code creates credential IDs and binds them to an exact endpoint/trust con Native reqwest uses Windows system trust through native-tls/Schannel, plus an optional connection-local PEM CA (maximum 64 KiB). Hostname/certificate validation remains enabled. Automatic proxy inheritance and redirects are disabled. HTTP requires loopback. Userinfo, query, fragment, control characters, backslashes and dot segments are rejected before URL normalization. UTF-8 and percent-encoded base paths are accepted only after rejecting encoded separators, dot segments, control characters and ambiguous double encoding. Public operation paths come from the generated OpenAPI manifest and preserve the base prefix. -Typed adapters separate liveness, readiness, identity and capabilities. Native context generations cancel old reads on connection, identity and Scope changes; a separate query generation cancels superseded Scope searches. Business operations require explicit qualified compatibility and actual Server authorization. The real-Server fixture exercises native memory adapters; product Memory commands remain unavailable until immutable write context and unknown-result handling are implemented. No automatic write retry or persistent queue exists. +Typed adapters separate liveness, readiness, identity and capabilities. Native context generations cancel old reads on connection, identity and Scope changes; a separate query generation cancels superseded Scope searches. Business operations require explicit qualified compatibility and actual Server authorization. Scope and Memory reads validate both deployment and Principal before dispatch and after the response. Observed identity loss clears the active context. Dispatched writes keep immutable endpoint/deployment/Principal/Scope metadata; failed post-write verification makes successful HTTP outcomes unknown without returning private content or automatically replaying. Late writes cannot clear a newer context. Qualified legacy Servers retain a null deployment identity and require discovery to remain 404. Identity observations do not supply an atomic Server-side conditional write fence or binary attestation. No automatic write retry or persistent queue exists. ## Budgets and error projection diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 5f98d5c2ba..4995732d52 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -20,6 +20,38 @@ cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml [Desktop CI](../.github/workflows/desktop.yml) also builds a Server wheel, runs real Server and CLI tests, checks Windows credentials and process containment, builds the installer, and exercises installation, the installed UI and uninstallation. Consult the PR checks for the exact commit being reviewed. Reports, logs and screenshots are generated under ignored `.artifacts/` and uploaded as Actions artifacts; do not copy them into the repository. +## Deployment context validation + +On 2026-10-28, Windows 10 x64 / Python 3.11.15 / Rust 1.95.0 ran the deployment-identity +working tree based on `aedf7beb1ecff2ea1fade6ccdd8e4bf6a298f799`. +All 70 native tests and 117 frontend tests passed, together with Clippy, Rust formatting, +IPC drift, TypeScript, frontend lint and the production build. Nine native HTTP regression tests +cover deployment replacement with an unchanged Principal, late Scope/Memory results, activation +races, Principal changes during reads, qualified legacy transitions and dispatched writes. +Replacement scenarios use synthetic TCP responses; they do not establish real deployment replacement +or installed-platform acceptance. The bilingual unknown-write regression checks the original Server ID, +preserved draft and explicit retry confirmation. + +The real SQLite Server harness passed anonymous loopback, static Bearer, HTTPS with an explicit CA +and base path, and an enforced Provider. It exercised native save/search/exact reads, original write +deployment metadata, uncertain writes without replay, Principal changes/revocation and independent +Server survival. Exact inputs: + +- Server sources: `aedf7beb1ecff2ea1fade6ccdd8e4bf6a298f799`. +- Wheel: `powercontext-1.4.2.dev36+gaedf7beb1.d20261028-py3-none-any.whl`; + SHA-256 `fde108a81a872a6228aad54832f2cd8f899a53bb4a7ff5c321871891fdbc5b68`. +- Normalized OpenAPI SHA-256: `c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a`. +- Debug `server_probe.exe` SHA-256: `a56d4db19650459c53094cd1a09fddfc40b6daa98f90dcf5f7d0c5514481390a`. + +This run used the explicit disposable `desktop-ci-fixture` qualification, not a new production +catalog record. The report records a dirty Desktop working tree; historical qualification records +are unchanged. Native Manager p95 round trips on these small local fixtures were 24.86–53.66 ms for +save, 19.77–37.51 ms for search and 17.64–36.04 ms for exact reads, including identity revalidation. +These are observations, not approved budgets, UI measurements or remote-network guarantees. +Readiness separation, signed Windows 11/macOS packages and installed acceptance remain open. +Repository file hooks passed; default Windows `ty check` reports existing POSIX-only attributes in +unchanged Python files. `ty check --python-platform linux` passed. + ## Native discovery acceptance The native `get_server_info` adapter and full real-Server harness were exercised on Windows x64 on 2026-10-23 with Rust 1.95.0 and Python 3.11.15. The Server sources, project configuration and lock matched baseline commit `c535d816`; the wheel was built at Desktop commit `f2b442fc41f724e206a50f2415a03476abdcdf76` with an unrelated dirty documentation tree. The wheel `powercontext-1.4.1.dev138+gf2b442fc4.d20261023-py3-none-any.whl` had SHA-256 `677812c860dd6f12866afa3b6b3e2e0529a6d37b427187c0ce4f3cd732349551`; the normalized contract digest was `5878999ce9e239d6642e4da858074b281ed465629432c578b3584eec47cbce6c`. All four isolated SQLite Server configurations passed: anonymous loopback, static Bearer, HTTPS with an explicit CA and reverse-proxy base path, and an enforced authentication Provider. Checks covered discovery, Scope selection, Memory save/search/exact revision reads, Principal changes, same-Principal revocation, uncertain writes without replay, pagination and independent Server survival. The Server environment included the declared `jieba==0.42.1` dependency. Results are recorded under ignored `.artifacts/real-server.json`. diff --git a/desktop/src-tauri/examples/server_probe.rs b/desktop/src-tauri/examples/server_probe.rs index 31fb37bb5d..42e3149917 100644 --- a/desktop/src-tauri/examples/server_probe.rs +++ b/desktop/src-tauri/examples/server_probe.rs @@ -74,10 +74,10 @@ async fn main() { fixture.token.take(), ) .unwrap(); - assert!(matches!( - api.server_info().await.unwrap(), - DiscoveryDocument::Server(_) - )); + let DiscoveryDocument::Server(server) = api.server_info().await.unwrap() else { + panic!("expected PowerContext discovery"); + }; + let server_id = server.server_id; if args .get(2) .is_some_and(|argument| argument == "discovery-only") @@ -166,6 +166,10 @@ async fn main() { let expected = format!("{keyword} 中文 café\nSecond line."); let saved = manager.remember(generation, &submitted).await.unwrap(); assert_eq!(saved.record.status, WriteStatus::Succeeded); + assert_eq!( + saved.record.context.server_id.as_deref(), + Some(server_id.as_str()) + ); let result = saved.result.unwrap(); assert_eq!( saved.record.references, diff --git a/desktop/src-tauri/src/connections/session.rs b/desktop/src-tauri/src/connections/session.rs index 67224edbba..9ec1e1450f 100644 --- a/desktop/src-tauri/src/connections/session.rs +++ b/desktop/src-tauri/src/connections/session.rs @@ -15,7 +15,7 @@ */ //! Native-owned connection and Scope context. Viewing a profile never activates it. -use super::discovery::DiscoveryResult; +use super::discovery::{DiscoveryResult, DiscoveryStatus}; use super::profiles::{ProfileInput, ProfileRepository, ProfileView}; use crate::{ error::SafeError, @@ -100,6 +100,7 @@ pub struct DesktopState { pub struct MemoryContext { pub connection_id: String, pub endpoint: String, + pub server_id: Option, pub principal: Option, pub generation: u32, pub scope_id: String, @@ -143,6 +144,7 @@ impl Drop for WriteGuard<'_> { } struct ReadSnapshot { api: Arc, + server_id: Option, identity: Option, cancelled: watch::Receiver, } @@ -346,6 +348,17 @@ impl ConnectionManager { }); let discovery = DiscoveryResult::assess(discovery, compatible); let supported_operations = discovery.supported_operations(); + if !supported_operations.is_empty() { + // Do not activate a Principal observed across a deployment replacement. + Self::deployment_unchanged( + &api, + discovery + .server + .as_ref() + .map(|server| server.server_id.as_str()), + ) + .await?; + } Ok(CheckReport { anonymous_access, connection_id: id.into(), @@ -406,14 +419,46 @@ impl ConnectionManager { } Ok(ReadSnapshot { api: active.api.clone(), + server_id: active + .view + .report + .discovery + .as_ref() + .and_then(|discovery| discovery.server.as_ref()) + .map(|server| server.server_id.clone()), identity: principal, cancelled: self.changed.subscribe(), }) } + async fn deployment_unchanged( + api: &ServerApi, + original: Option<&str>, + ) -> Result<(), ApiFailure> { + // Re-observation never creates qualification or silently upgrades a legacy session. + let current = DiscoveryResult::assess(api.server_info().await, None); + let unchanged = match current.status { + DiscoveryStatus::Supported => original.is_some_and(|id| { + current + .server + .as_ref() + .is_some_and(|server| server.server_id == id) + }), + DiscoveryStatus::Missing => original.is_none(), + _ => false, + }; + if unchanged { + Ok(()) + } else { + Err(current + .error + .unwrap_or_else(|| SafeError::StaleContext.into())) + } + } async fn identity_unchanged( &self, api: &ServerApi, original: &Option, + server_id: Option<&str>, generation: u32, ) -> Result<(), ApiFailure> { let current = api.principal().await; @@ -428,8 +473,16 @@ impl ConnectionManager { .await .is_ok_and(|r| r.checks.get("access_mode").is_some_and(|v| v == "disabled")) }; - if unchanged { - return Ok(()); + let result = if unchanged { + Self::deployment_unchanged(api, server_id).await + } else { + match current { + Err(error) => Err(error), + Ok(_) => Err(SafeError::StaleContext.into()), + } + }; + if result.is_ok() { + return result; } let mut inner = self.lock()?; if inner.generation == generation { @@ -438,10 +491,7 @@ impl ConnectionManager { } self.invalidate(&mut inner)?; } - match current { - Err(error) => Err(error), - Ok(_) => Err(SafeError::StaleContext.into()), - } + result } fn begin_scope_query(&self, generation: u32) -> Result, SafeError> { let inner = self.lock()?; @@ -470,6 +520,7 @@ impl ConnectionManager { ) -> Result { let ReadSnapshot { api, + server_id, identity, mut cancelled, } = self.snapshot(generation, "list_scopes")?; @@ -477,8 +528,12 @@ impl ConnectionManager { Self::cancellable( &mut cancelled, Self::cancellable(&mut query_cancelled, async { - self.identity_unchanged(&api, &identity, generation).await?; - api.scopes(query, cursor).await + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + let result = api.scopes(query, cursor).await; + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + result }), ) .await @@ -486,6 +541,7 @@ impl ConnectionManager { pub async fn default_scope(&self, generation: u32) -> Result { let ReadSnapshot { api, + server_id, identity, mut cancelled, } = self.snapshot(generation, "get_default_scope")?; @@ -493,8 +549,12 @@ impl ConnectionManager { Self::cancellable( &mut cancelled, Self::cancellable(&mut query_cancelled, async { - self.identity_unchanged(&api, &identity, generation).await?; - api.default_scope().await + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + let result = api.default_scope().await; + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + result }), ) .await @@ -506,12 +566,17 @@ impl ConnectionManager { ) -> Result { let ReadSnapshot { api, + server_id, identity, mut cancelled, } = self.snapshot(generation, "get_scope")?; let scope = Self::cancellable(&mut cancelled, async { - self.identity_unchanged(&api, &identity, generation).await?; - api.scope(id).await + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + let result = api.scope(id).await; + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + result }) .await?; let mut inner = self.lock()?; @@ -551,10 +616,12 @@ impl ConnectionManager { .identity .as_ref() .map(|value| value.principal.clone()); + let server_id = snapshot.server_id.clone(); Ok(( snapshot, MemoryContext { endpoint, + server_id, principal, connection_id: active.view.connection_id.clone(), generation, @@ -586,6 +653,7 @@ impl ConnectionManager { let ( ReadSnapshot { api, + server_id, identity, mut cancelled, }, @@ -595,8 +663,12 @@ impl ConnectionManager { Self::cancellable( &mut cancelled, Self::cancellable(&mut query_cancelled, async { - self.identity_unchanged(&api, &identity, generation).await?; - api.search(&context.scope_id, query).await + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + let result = api.search(&context.scope_id, query).await; + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + result }), ) .await @@ -609,6 +681,7 @@ impl ConnectionManager { let ( ReadSnapshot { api, + server_id, identity, mut cancelled, }, @@ -618,8 +691,12 @@ impl ConnectionManager { Self::cancellable( &mut cancelled, Self::cancellable(&mut query_cancelled, async { - self.identity_unchanged(&api, &identity, generation).await?; - api.atomic_entry(&context.scope_id, artifact).await + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + let result = api.atomic_entry(&context.scope_id, artifact).await; + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + result }), ) .await @@ -630,6 +707,7 @@ impl ConnectionManager { let ( ReadSnapshot { api, + server_id, identity, mut cancelled, }, @@ -637,7 +715,7 @@ impl ConnectionManager { ) = self.memory_snapshot(generation, "remember_memory")?; Self::cancellable( &mut cancelled, - self.identity_unchanged(&api, &identity, generation), + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation), ) .await?; let mut record = WriteRecord { @@ -660,15 +738,25 @@ impl ConnectionManager { armed: true, }; let response = api.remember(&record.context.scope_id, text).await; + let identity_result = self + .identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await; let result = match response { Ok(value) => { - record.status = WriteStatus::Succeeded; - record.references = value - .records - .iter() - .map(|record| record.artifact.clone()) - .collect(); - Some(value) + if let Err(error) = identity_result { + // A successful HTTP reply cannot establish which deployment accepted the write. + record.status = WriteStatus::Unknown; + record.error = Some(error); + None + } else { + record.status = WriteStatus::Succeeded; + record.references = value + .records + .iter() + .map(|record| record.artifact.clone()) + .collect(); + Some(value) + } } Err(error) => { record.status = if error.dispatched diff --git a/desktop/src-tauri/tests/deployment_identity.rs b/desktop/src-tauri/tests/deployment_identity.rs new file mode 100644 index 0000000000..2667b3f717 --- /dev/null +++ b/desktop/src-tauri/tests/deployment_identity.rs @@ -0,0 +1,542 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +mod common; + +use powercontext_desktop::{ + connections::{ + profiles::ProfileRepository, + session::{ConnectionManager, WriteStatus}, + }, + credentials::NativeVault, + error::SafeError, + transport::wire::ArtifactReference, +}; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::Notify, + time::{Duration, timeout}, +}; + +struct State { + discovery: (u16, Value), + principal: Option, + pause: Option, + requests: Vec, +} +struct Fixture { + manager: Arc, + id: String, + state: Arc>, + started: Arc, + release: Arc, + server: tokio::task::JoinHandle<()>, + _directory: tempfile::TempDir, +} +impl Drop for Fixture { + fn drop(&mut self) { + self.server.abort(); + self.release.notify_waiters(); + } +} +fn discovery(id: &str) -> Value { + json!({"schema_version":{"major":1,"minor":0},"product":"powercontext","server_id":id, + "package_version":"synthetic","api_contract_version":{"major":1,"minor":4},"feature_contracts":{ + "access.principal":{"version":{"major":1,"minor":0},"operations":["get_access_principal"]}, + "scope.selection":{"version":{"major":1,"minor":0},"operations":["list_scopes","get_scope","get_default_scope"]}, + "memory.explicit":{"version":{"major":1,"minor":0},"operations":["remember_memory","search_memory"]}}}) +} +fn scope() -> Value { + json!({"scope_id":"scope-a","title":"Private scope","summary":"","context_references":[], + "external_references":[],"version":1}) +} +fn reference() -> ArtifactReference { + serde_json::from_value(json!({"family":"atomic-memory","artifact_id":"atomic-a","revision":1})) + .unwrap() +} +fn record() -> Value { + json!({"artifact":reference(),"kind":"note","text":"private result","state":"active", + "state_version":1,"merged_into_id":null}) +} +impl Fixture { + async fn new(authenticated: bool) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let state = Arc::new(Mutex::new(State { + discovery: (200, discovery("server-a")), + principal: authenticated.then(|| "same-principal".into()), + pause: None, + requests: vec![], + })); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let (responses, entered, resume) = (state.clone(), started.clone(), release.clone()); + let server = tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let (responses, entered, resume) = + (responses.clone(), entered.clone(), resume.clone()); + tokio::spawn(async move { + let mut bytes = vec![]; + loop { + let mut chunk = [0; 4096]; + let count = socket.read(&mut chunk).await.unwrap(); + if count == 0 { + return; + } + bytes.extend_from_slice(&chunk[..count]); + if let Some(end) = bytes.windows(4).position(|v| v == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..end]); + let length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ") + .map(|value| value.parse::().unwrap()) + }) + .unwrap_or(0); + if bytes.len() >= end + 4 + length { + break; + } + } + } + let path = String::from_utf8_lossy(&bytes) + .split_whitespace() + .nth(1) + .unwrap() + .to_owned(); + let (status, value, pause) = { + let mut state = responses.lock().unwrap(); + state.requests.push(path.clone()); + let (status, value) = match path.as_str() { + "/v1/server-info" => state.discovery.clone(), + "/v1/access/me" => match &state.principal { + Some(id) => ( + 200, + json!({"principal":{"type":"service","id":id},"mode":"enforced", + "resource_kinds":[],"artifact_families":[],"provider_capabilities":{ + "safe_resource_filtering":true,"multi_requirement_check":true,"relationship_management":false, + "group_subjects":false,"multi_principal":false,"max_direct_resource_keys":100}}), + ), + None => (503, json!({"error":{"code":"runtime_not_ready"}})), + }, + "/health/ready" => ( + 200, + json!({"status":"ready","checks":{ + "access_mode":if state.principal.is_some() {"enforced"} else {"disabled"}}}), + ), + "/v1/capabilities" => (403, json!({})), + "/v1/scopes/default" | "/v1/scopes/scope-a" => (200, scope()), + path if path.starts_with("/v1/scopes?") => { + (200, json!({"items":[scope()],"next_cursor":"old-cursor"})) + } + "/v1/memory/search" => ( + 200, + json!({"mode":"text","hits":[{"memory":record(),"score":1,"matched_by":["text"]}]}), + ), + "/v1/memory/remember" => { + (200, json!({"changed":true,"records":[record()]})) + } + path if path.contains("/revisions/") => ( + 200, + json!({"scope_id":"scope-a","family":"atomic-memory", + "artifact_id":"atomic-a","revision":1,"content":{"schema":"powercontext.atomic-memory.v1", + "kind":"note","text":"private result"},"sources":[],"artifacts":[], + "content_digest":format!("sha256:{}", "0".repeat(64))}), + ), + _ => (200, json!({"status":"ok"})), + }; + ( + status, + value, + state + .pause + .as_ref() + .is_some_and(|prefix| path.starts_with(prefix)), + ) + }; + if pause { + entered.notify_one(); + resume.notified().await; + } + let body = value.to_string(); + let response = format!( + "HTTP/1.1 {status} Result\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + }); + } + }); + let directory = tempfile::tempdir().unwrap(); + let manager = Arc::new(common::connection_manager( + ProfileRepository::open( + directory.path().join("profiles.json"), + Arc::new(NativeVault), + ) + .unwrap(), + )); + let saved = manager.save_profile(serde_json::from_value(json!({"id":null,"revision":null,"name":"Synthetic", + "endpoint":endpoint,"authentication":if authenticated {"bearer"} else {"unauthenticated_loopback"}, + "caPem":null,"compatibility":common::PROFILE_ID,"keepCredential":false, + "credential":if authenticated {json!({"secret":"synthetic-token","storage":"session_only"})} else {Value::Null}})).unwrap()).unwrap(); + Self { + manager, + id: saved.profiles[0].id.clone(), + state, + started, + release, + server, + _directory: directory, + } + } + async fn activate(&self) -> u32 { + let state = self.manager.check(&self.id, true).await.unwrap(); + assert!(state.active.unwrap().report.compatibility_verified); + state.generation + } + async fn select(&self) -> u32 { + self.manager + .select_scope(self.activate().await, "scope-a") + .await + .unwrap() + .generation + } + fn replace(&self) { + self.state.lock().unwrap().discovery = (200, discovery("server-b")); + } + async fn entered(&self) { + timeout(Duration::from_secs(5), self.started.notified()) + .await + .unwrap(); + } + fn assert_disconnected(&self, previous: u32) { + let state = self.manager.state().unwrap(); + assert!(state.active.is_none()); + assert!(state.generation > previous); + assert!( + state + .reports + .iter() + .all(|report| report.connection_id != self.id) + ); + } +} + +#[tokio::test] +async fn replacement_with_the_same_principal_blocks_reads_and_writes_before_dispatch() { + for authenticated in [false, true] { + for write in [false, true] { + let fixture = Fixture::new(authenticated).await; + let generation = fixture.select().await; + fixture.state.lock().unwrap().requests.clear(); + fixture.replace(); + let error = if write { + fixture + .manager + .remember(generation, "must not reach server-b") + .await + .map(|_| ()) + } else { + fixture + .manager + .scopes(generation, "", Some("old-cursor")) + .await + .map(|_| ()) + } + .err() + .unwrap(); + assert_eq!(error.code, SafeError::StaleContext); + fixture.assert_disconnected(generation); + assert!(fixture.manager.state().unwrap().last_write.is_none()); + assert!( + !fixture + .state + .lock() + .unwrap() + .requests + .iter() + .any(|path| path.contains("/memory/") || path.starts_with("/v1/scopes")) + ); + assert!( + fixture + .manager + .scopes(generation, "", Some("old-cursor")) + .await + .is_err() + ); + // Only an explicit check can establish the replacement's context. + let generation = fixture.activate().await; + assert!( + fixture + .manager + .state() + .unwrap() + .active + .unwrap() + .scope + .is_none() + ); + assert!(fixture.manager.scopes(generation, "", None).await.is_ok()); + } + } +} + +#[tokio::test] +async fn late_private_reads_are_rejected_when_the_deployment_changes_during_the_request() { + for path in [ + "/v1/scopes?", + "/v1/scopes/default", + "/v1/scopes/scope-a", + "/v1/memory/search", + "/v1/scopes/scope-a/artifacts/", + ] { + let fixture = Fixture::new(true).await; + let generation = fixture.select().await; + fixture.state.lock().unwrap().pause = Some(path.into()); + let manager = fixture.manager.clone(); + let read = tokio::spawn(async move { + match path { + "/v1/scopes?" => manager.scopes(generation, "", None).await.map(|_| ()), + "/v1/scopes/default" => manager.default_scope(generation).await.map(|_| ()), + "/v1/scopes/scope-a" => manager + .select_scope(generation, "scope-a") + .await + .map(|_| ()), + "/v1/memory/search" => manager + .search_memory(generation, "private") + .await + .map(|_| ()), + _ => manager + .atomic_memory_entry(generation, &reference()) + .await + .map(|_| ()), + } + }); + fixture.entered().await; + fixture.replace(); + fixture.release.notify_one(); + let error = timeout(Duration::from_secs(5), read) + .await + .unwrap() + .unwrap() + .err() + .unwrap(); + assert_eq!(error.code, SafeError::StaleContext, "{path}"); + fixture.assert_disconnected(generation); + } +} + +#[tokio::test] +async fn failed_discovery_cannot_downgrade_an_active_deployment_to_legacy() { + for status in [404, 401, 403, 503] { + let fixture = Fixture::new(false).await; + let generation = fixture.activate().await; + fixture.state.lock().unwrap().discovery = (status, json!({})); + assert!(fixture.manager.scopes(generation, "", None).await.is_err()); + fixture.assert_disconnected(generation); + } +} + +#[tokio::test] +async fn a_legacy_session_requires_explicit_recheck_when_discovery_appears() { + let fixture = Fixture::new(false).await; + fixture.state.lock().unwrap().discovery = (404, json!({})); + let generation = fixture.activate().await; + assert!(fixture.manager.scopes(generation, "", None).await.is_ok()); + fixture.replace(); + assert_eq!( + fixture + .manager + .scopes(generation, "", None) + .await + .err() + .unwrap() + .code, + SafeError::StaleContext + ); + fixture.assert_disconnected(generation); +} + +#[tokio::test] +async fn unchanged_identity_allows_the_complete_flow_and_keeps_the_write_target() { + for authenticated in [false, true] { + let fixture = Fixture::new(authenticated).await; + let generation = fixture.select().await; + // A package upgrade is not a deployment replacement. + fixture.state.lock().unwrap().discovery.1["package_version"] = json!("new-package"); + assert!(fixture.manager.scopes(generation, "", None).await.is_ok()); + assert_eq!( + fixture + .manager + .default_scope(generation) + .await + .unwrap() + .scope_id, + "scope-a" + ); + assert_eq!( + fixture + .manager + .search_memory(generation, "private") + .await + .unwrap() + .hits + .len(), + 1 + ); + assert_eq!( + fixture + .manager + .atomic_memory_entry(generation, &reference()) + .await + .unwrap() + .text, + "private result" + ); + let outcome = fixture.manager.remember(generation, "note").await.unwrap(); + assert_eq!(outcome.record.status, WriteStatus::Succeeded); + assert_eq!( + serde_json::to_value(&outcome.record.context).unwrap()["serverId"], + "server-a" + ); + assert_eq!(outcome.record.references, vec![reference()]); + assert!(outcome.result.is_some()); + assert_eq!(fixture.manager.state().unwrap().generation, generation); + } +} + +#[tokio::test] +async fn replacement_during_a_write_preserves_an_unknown_original_target_without_replay() { + let fixture = Fixture::new(true).await; + let generation = fixture.select().await; + fixture.state.lock().unwrap().pause = Some("/v1/memory/remember".into()); + let manager = fixture.manager.clone(); + let write = + tokio::spawn(async move { manager.remember(generation, "private submitted note").await }); + fixture.entered().await; + fixture.replace(); + fixture.release.notify_one(); + let outcome = timeout(Duration::from_secs(5), write) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(outcome.record.status, WriteStatus::Unknown); + assert_eq!(outcome.record.error.unwrap().code, SafeError::StaleContext); + assert!(outcome.result.is_none()); + assert!(outcome.record.references.is_empty()); + let state = fixture.manager.state().unwrap(); + let original = serde_json::to_value(state.last_write.unwrap()).unwrap(); + assert_eq!(original["context"]["serverId"], "server-a"); + assert_eq!(original["context"]["scopeId"], "scope-a"); + assert!(!original.to_string().contains("private submitted note")); + fixture.assert_disconnected(generation); + assert_eq!( + fixture + .state + .lock() + .unwrap() + .requests + .iter() + .filter(|path| *path == "/v1/memory/remember") + .count(), + 1 + ); +} + +#[tokio::test] +async fn an_old_write_cannot_disconnect_a_newly_activated_deployment() { + let fixture = Fixture::new(true).await; + let generation = fixture.select().await; + fixture.state.lock().unwrap().pause = Some("/v1/memory/remember".into()); + let manager = fixture.manager.clone(); + let write = tokio::spawn(async move { manager.remember(generation, "note").await }); + fixture.entered().await; + fixture.replace(); + let replacement = fixture.activate().await; + fixture.release.notify_one(); + let outcome = timeout(Duration::from_secs(5), write) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(outcome.record.status, WriteStatus::Unknown); + assert!(outcome.result.is_none()); + let state = fixture.manager.state().unwrap(); + assert_eq!(state.generation, replacement); + assert_eq!( + state + .active + .unwrap() + .report + .discovery + .unwrap() + .server + .unwrap() + .server_id, + "server-b" + ); + assert_eq!( + serde_json::to_value(state.last_write.unwrap()).unwrap()["context"]["serverId"], + "server-a" + ); +} + +#[tokio::test] +async fn replacement_during_activation_does_not_mix_deployment_and_principal_facts() { + let fixture = Fixture::new(true).await; + fixture.state.lock().unwrap().pause = Some("/v1/access/me".into()); + let (manager, id) = (fixture.manager.clone(), fixture.id.clone()); + let check = tokio::spawn(async move { manager.check(&id, true).await }); + fixture.entered().await; + fixture.replace(); + fixture.release.notify_one(); + let error = timeout(Duration::from_secs(5), check) + .await + .unwrap() + .unwrap() + .err() + .unwrap(); + assert_eq!(error.code, SafeError::StaleContext); + assert!(fixture.manager.state().unwrap().active.is_none()); +} + +#[tokio::test] +async fn principal_revocation_while_reading_discards_private_results() { + let fixture = Fixture::new(true).await; + let generation = fixture.select().await; + fixture.state.lock().unwrap().pause = Some("/v1/memory/search".into()); + let manager = fixture.manager.clone(); + let read = tokio::spawn(async move { manager.search_memory(generation, "private").await }); + fixture.entered().await; + fixture.state.lock().unwrap().principal = Some("different-principal".into()); + fixture.release.notify_one(); + assert_eq!( + timeout(Duration::from_secs(5), read) + .await + .unwrap() + .unwrap() + .err() + .unwrap() + .code, + SafeError::StaleContext + ); + fixture.assert_disconnected(generation); +} diff --git a/desktop/ui/src/app/MemoryWorkspace.tsx b/desktop/ui/src/app/MemoryWorkspace.tsx index 1921a60e64..bfce32d15e 100644 --- a/desktop/ui/src/app/MemoryWorkspace.tsx +++ b/desktop/ui/src/app/MemoryWorkspace.tsx @@ -21,6 +21,7 @@ import type { ArtifactReference, AtomicMemoryRecord, MemorySearchHit, + MemoryContext, WriteOutcome, } from "../generated/ipc"; import { desktopApi } from "../shared/ipc"; @@ -43,6 +44,15 @@ type Props = { }; export const textBytes = (value: string) => new TextEncoder().encode(value).length; +function WriteTarget({ context }: { context: MemoryContext }) { + return ( + + {context.endpoint} · {context.serverId && <>{context.serverId} · } + {context.scopeId} + + ); +} + type NoteFormProps = Props & { onReadSaved: ( artifact: ArtifactReference, @@ -222,11 +232,7 @@ export function NoteForm({ title={t.failed} >

- {t.original}:{" "} - - {outcome.record.context.endpoint} ·{" "} - {outcome.record.context.scopeId} - + {t.original}:

) : ( @@ -255,11 +261,7 @@ export function NoteForm({ > {outcome.record.status === "unknown" && (

- {t.original}:{" "} - - {outcome.record.context.endpoint} ·{" "} - {outcome.record.context.scopeId} - + {t.original}:

)} @@ -474,10 +476,7 @@ export function MemoryWorkspace(props: Props) { record.context.generation !== active?.generation) && (

- {t.original}:{" "} - - {record.context.endpoint} · {record.context.scopeId} - + {t.original}:

{record.status === "unknown" diff --git a/desktop/ui/src/generated/ipc.ts b/desktop/ui/src/generated/ipc.ts index 6cb3161ac3..bbb9267b6e 100644 --- a/desktop/ui/src/generated/ipc.ts +++ b/desktop/ui/src/generated/ipc.ts @@ -37,7 +37,7 @@ export type DiscoveryResult = { status: DiscoveryStatus, server: ServerInfo | nu export type CheckReport = { connectionId: string, revision: number, checkedAt: number, liveness: Fact, readiness: Fact, identity: Fact, capabilities: Fact, compatibilityVerified: boolean, anonymousAccess: boolean, supportedOperations: Array, discovery?: DiscoveryResult, }; export type CompatibilityProfile = { id: string, serverCommit: string, contractSha256: string, artifactSha256: string, operations: Array, evidence: string, }; export type ActiveView = { connectionId: string, generation: number, report: CheckReport, scope: ScopeDescriptor | null, }; -export type MemoryContext = { connectionId: string, endpoint: string, principal: AccessPrincipal | null, generation: number, scopeId: string, }; +export type MemoryContext = { connectionId: string, endpoint: string, serverId: string | null, principal: AccessPrincipal | null, generation: number, scopeId: string, }; export type WriteStatus = "pending" | "succeeded" | "failed" | "unknown"; export type WriteRecord = { operationId: string, context: MemoryContext, status: WriteStatus, references: Array, error: ApiFailure | null, }; export type WriteOutcome = { record: WriteRecord, result: MemorySaveResponse | null, }; diff --git a/desktop/ui/tests/memory.test.tsx b/desktop/ui/tests/memory.test.tsx index 55a404a882..61ef8007ab 100644 --- a/desktop/ui/tests/memory.test.tsx +++ b/desktop/ui/tests/memory.test.tsx @@ -346,6 +346,7 @@ function outcome(status: "succeeded" | "unknown" | "failed"): WriteOutcome { context: { connectionId: "connection-a", endpoint: "http://127.0.0.1:8000", + serverId: "server-a", principal: null, generation: 1, scopeId: "scope-a", @@ -357,12 +358,12 @@ function outcome(status: "succeeded" | "unknown" | "failed"): WriteOutcome { result: status === "succeeded" ? { changed: false, records: [] } : null, }; } -function form() { +function form(language: "en" | "zh" = "en") { vi.mocked(desktopApi.state).mockResolvedValue(state); render( { - const user = userEvent.setup(); - form(); - vi.mocked(desktopApi.remember).mockResolvedValue(outcome("unknown")); - await user.type(screen.getByLabelText("Memory text"), "preserve me"); - await user.click(screen.getByRole("button", { name: "Save" })); - expect(screen.getByRole("alert").textContent).toContain("outcome is unknown"); - expect(screen.getByRole("alert").textContent).toContain( - "http://127.0.0.1:8000 · scope-a", - ); - expect( - (screen.getByLabelText("Memory text") as HTMLTextAreaElement).value, - ).toBe("preserve me"); - expect(desktopApi.remember).toHaveBeenCalledTimes(1); +test.each(["en", "zh"] as const)( + "%s unknown save retains its draft and original deployment without replay", + async (language) => { + const user = userEvent.setup(); + form(language); + const label = language === "en" ? "Memory text" : "记忆内容"; + const save = language === "en" ? "Save" : "保存"; + vi.mocked(desktopApi.remember).mockResolvedValue(outcome("unknown")); + await user.type(screen.getByLabelText(label), "preserve me"); + await user.click(screen.getByRole("button", { name: save })); + expect(screen.getByRole("alert").textContent).toContain( + language === "en" ? "outcome is unknown" : "未知", + ); + expect(screen.getByRole("alert").textContent).toContain( + "http://127.0.0.1:8000 · server-a · scope-a", + ); + expect((screen.getByLabelText(label) as HTMLTextAreaElement).value).toBe( + "preserve me", + ); + expect(desktopApi.remember).toHaveBeenCalledTimes(1); - await user.click(screen.getByRole("button", { name: "Save" })); - await answerConfirmation(user, false); - expect(desktopApi.remember).toHaveBeenCalledTimes(1); - expect( - (screen.getByLabelText("Memory text") as HTMLTextAreaElement).value, - ).toBe("preserve me"); - await user.click(screen.getByRole("button", { name: "Save" })); - await answerConfirmation(user, true); - expect(desktopApi.remember).toHaveBeenCalledTimes(2); -}); + await user.click(screen.getByRole("button", { name: save })); + await answerConfirmation(user, false); + expect(desktopApi.remember).toHaveBeenCalledTimes(1); + expect((screen.getByLabelText(label) as HTMLTextAreaElement).value).toBe( + "preserve me", + ); + await user.click(screen.getByRole("button", { name: save })); + await answerConfirmation(user, true); + expect(desktopApi.remember).toHaveBeenCalledTimes(2); + }, +); test.each(["forbidden", "network"])( "a save completing after a %s search failure keeps its receipt without restoring private bodies", async (code) => { From bdf9978944d1edb74ceb7d6546bc8ecd41714fdf Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Thu, 29 Oct 2026 11:51:52 +0800 Subject: [PATCH 02/10] feat(desktop): implement Linux credentials and packaged app boundary (#2297) * feat(desktop): implement Linux credentials boundary * ci(desktop): fix Linux Secret Service step indentation * ci(desktop): initialize Linux keyring in CI --- .github/workflows/desktop.yml | 7 + desktop/README.md | 4 +- desktop/README.zh.md | 4 +- desktop/src-tauri/Cargo.lock | 385 ++++++++++++++++++++++++++- desktop/src-tauri/Cargo.toml | 6 + desktop/src-tauri/src/credentials.rs | 35 ++- desktop/src-tauri/src/ipc.rs | 28 +- desktop/src-tauri/tests/profiles.rs | 5 + 8 files changed, 446 insertions(+), 28 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index ee39e3ac73..eb719dd4f5 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -256,6 +256,13 @@ jobs: - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings - run: cargo test --locked --manifest-path src-tauri/Cargo.toml - run: pnpm ipc:check + - name: Verify Linux Secret Service across processes + run: | + dbus-run-session -- bash -lc ' + set -euo pipefail + eval "$(printf "%s\n" powercontext-ci | gnome-keyring-daemon --daemonize --login --components=secrets)" + cargo run --locked --manifest-path src-tauri/Cargo.toml --example credential_probe + ' - name: Build unsigned Debian package run: pnpm tauri build --bundles deb --ci -- --locked - name: Verify Debian package diff --git a/desktop/README.md b/desktop/README.md index 7ba5cf6cda..f88df0b7e4 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -83,7 +83,7 @@ The unsigned installer is for internal verification. If WebView2 is absent, its ## Connect an existing Server 1. Open Connections and add a named profile. HTTP is restricted to literal loopback hosts; other addresses require HTTPS. A reverse-proxy path prefix is preserved. -2. Explicitly choose unauthenticated loopback access or Bearer authentication. Bearer storage is either Windows Credential Manager or this session only. Trust/address changes require credential reconfiguration. An optional CA augments system trust without disabling certificate checks. +2. Explicitly choose unauthenticated loopback access or Bearer authentication. Bearer storage uses Windows Credential Manager, macOS Keychain, or Linux Secret Service (GNOME Keyring/KWallet); session-only storage is also available only when explicitly selected. If the native credential store is unavailable or locked, saving fails without writing the secret to profile JSON; choose session-only storage or cancel. Trust/address changes require credential reconfiguration. An optional CA augments system trust without disabling certificate checks. 3. Select a qualified compatibility profile after comparing its tested build with your deployment. The selection does not prove the remote binary identity. See [validation guide](VALIDATION.md) for the exact fixture and supported combinations. 4. Save, then explicitly use the connection. Merely selecting a saved profile does not activate it. Review liveness, readiness, identity and capabilities separately; none implies resource authorization. 5. Open the Scope dropdown and find an authorized Scope by title (50 per page). Selecting a result updates the active Scope and closes the dialog. Selection never creates a Scope or changes Agent bindings. Editing a query cancels its old read; connection and identity changes invalidate old results. @@ -124,7 +124,7 @@ The canonical brand source is `website/assets/powercontext-color.png`, which is cargo run --locked --manifest-path desktop/src-tauri/Cargo.toml --example credential_probe ``` -This explicit probe creates a uniquely named synthetic credential in the preview namespace, loads it in another process and deletes it. It never reads another application's credential or prints a secret. Normal tests simulate unavailable storage and require an explicit session-only choice. Do not treat the probe as end-to-end S2 credential-form verification. +This explicit probe creates a uniquely named synthetic credential in the preview namespace, loads it in another process and deletes it. It never reads another application's credential or prints a secret. Normal tests simulate unavailable storage and require an explicit session-only choice; a native-store failure never downgrades a persistent request or writes the secret to profile JSON. Do not treat the probe as end-to-end S2 credential-form verification. The Windows CI workflow runs on PRs and the preview branch, verifies native vault persistence, builds an unsigned internal installer, and tests Chinese-path installation/uninstallation on its disposable runner. It uploads the installer and a JSON smoke report. CI and mocked IPC tests do not prove clean-machine installation, notification activation, independent service login or Agent capture/recall. diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 18a1aced8e..2dc1f2f924 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -69,7 +69,7 @@ pnpm --dir desktop desktop:dev ## 连接已有 Server 1. 在连接页添加名称和 Server 地址。HTTP 只允许字面 loopback 地址,其他地址必须 HTTPS;可保留反向代理路径前缀。 -2. 明确选择未认证本机服务或 Bearer。凭据可保存到 Windows 凭据管理器或仅本次会话;地址或信任变化后需要重新配置凭据。自定义 CA 不会关闭证书校验。 +2. 明确选择未认证本机服务或 Bearer。凭据可保存到 Windows 凭据管理器、macOS Keychain 或 Linux Secret Service(GNOME Keyring/KWallet);也可以明确选择仅本次会话。如果系统凭据库不可用或被锁定,保存会失败,秘密不会写入连接配置 JSON;此时只能选择仅本次会话或取消。地址或信任变化后需要重新配置凭据。自定义 CA 不会关闭证书校验。 3. 核对部署版本后选择已验证兼容配置,具体构建摘要和范围见 [验证说明](VALIDATION.md)。选择配置不能证明远端二进制身份。 4. 保存后点击“使用此连接”。查看其他配置不会自动切换当前连接。分别查看可达性、就绪、身份和能力,不把其中一项当作资源授权。 5. 通过顶部 Scope 下拉按钮按标题分页查找范围,每页最多 50 项。选择结果后更新当前范围并关闭弹窗;不创建范围或修改 Agent 绑定。修改查询取消旧请求,切换连接或身份后旧结果失效。 @@ -93,7 +93,7 @@ pnpm --dir desktop desktop:dev - OpenAPI 自动派生的 TypeScript schema 和十一项 operation 映射,包含契约摘要及漂移检查。 - `desktop/openapitools.json` 固定 OpenAPI Generator CLI 版本,Rust 模板位于 `desktop/scripts/openapi-generator/rust/`。修改 OpenAPI 合同或模板后,在仓库根目录执行 `pnpm --dir desktop generate`,不要手动编辑生成的 `src-tauri/src/transport/wire.rs`;CI 通过 `pnpm --dir desktop generate:check` 检查生成结果是否漂移。 - Rust 自动派生的 IPC 类型与安全错误;凭据只写请求必须显式选择持久化或仅会话,成功回执只含存储模式,不含秘密或凭据标识。 -- Windows Credential Manager 原生适配器;保存失败直接报错,只有显式选择才采用仅会话内存。秘密类型不支持序列化,不实现 Debug。 +- Windows Credential Manager、macOS Keychain 和 Linux Secret Service 原生适配器;保存失败直接报错,只有显式选择才采用仅会话内存,绝不把秘密降级写入连接配置。秘密类型不支持序列化,不实现 Debug。 - 使用系统证书信任的原生 HTTPS;可为单个客户端添加显式 CA,继续校验主机名。 - HTTP 仅允许 loopback,拒绝地址中的用户信息、查询、片段、反斜杠及路径逃逸;不继承代理、不跟随重定向。 - 连接超时 5 秒、完整请求超时 15 秒、响应上限 1 MiB、每个客户端最多 4 个并发读取;超限明确失败。 diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 612849b498..3c41f6e2ba 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -341,6 +352,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -487,6 +507,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.4.7" @@ -532,6 +561,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chrono" version = "0.4.45" @@ -544,6 +579,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "cmake" version = "0.1.58" @@ -765,6 +810,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "defmt" version = "1.1.1" @@ -848,6 +911,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1599,6 +1663,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1883,6 +1965,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -2077,7 +2169,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ "byteorder", + "dbus-secret-service", "log", + "secret-service", "security-framework 2.11.1", "security-framework 3.7.0", "windows-sys 0.60.2", @@ -2312,6 +2406,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -2322,6 +2429,20 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -2332,6 +2453,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2347,6 +2477,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2903,6 +3054,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -3001,6 +3161,36 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3337,6 +3527,25 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand", + "serde", + "sha2", + "zbus 4.4.0", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -3573,6 +3782,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3688,6 +3908,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -3993,7 +4219,7 @@ dependencies = [ "thiserror 2.0.20", "tracing", "windows-sys 0.60.2", - "zbus", + "zbus 5.19.0", ] [[package]] @@ -5462,6 +5688,16 @@ dependencies = [ "time", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yasna" version = "0.6.0" @@ -5495,6 +5731,38 @@ dependencies = [ "synstructure 0.14.0", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.19.0" @@ -5525,9 +5793,22 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.4", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", ] [[package]] @@ -5540,9 +5821,20 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.6", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -5553,7 +5845,7 @@ checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", "winnow 1.0.4", - "zvariant", + "zvariant 5.15.0", ] [[package]] @@ -5565,6 +5857,26 @@ dependencies = [ "serde", ] +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5591,6 +5903,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" @@ -5637,6 +5963,19 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.15.0" @@ -5648,8 +5987,21 @@ dependencies = [ "serde", "winnow 1.0.4", "zcheapstr", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", ] [[package]] @@ -5662,7 +6014,18 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.6", - "zvariant_utils", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 141df9b315..e48dc5d932 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -54,6 +54,12 @@ keyring = { version = "=3.6.3", default-features = false, features = ["windows-n keyring = { version = "=3.6.3", default-features = false, features = ["apple-native"] } libc = "=0.2.189" +[target.'cfg(target_os = "linux")'.dependencies] +# Use the desktop Secret Service (GNOME Keyring/KWallet) as the native vault. +# Keep this synchronous because the Vault trait is synchronous; callers must not +# invoke it from an async runtime thread without a blocking boundary. +keyring = { version = "=3.6.3", default-features = false, features = ["sync-secret-service", "crypto-rust"] } + [dev-dependencies] tauri = { version = "=2.11.0", features = ["test"] } rcgen = "=0.14.8" diff --git a/desktop/src-tauri/src/credentials.rs b/desktop/src-tauri/src/credentials.rs index 331614e15d..9ea82b5a3e 100644 --- a/desktop/src-tauri/src/credentials.rs +++ b/desktop/src-tauri/src/credentials.rs @@ -20,7 +20,7 @@ use serde::{Deserialize, Deserializer, Serialize}; use ts_rs::TS; use zeroize::Zeroizing; -#[cfg(any(windows, target_os = "macos"))] +#[cfg(any(windows, target_os = "macos", target_os = "linux"))] const SERVICE: &str = "com.powercontext.desktop.preview"; pub struct Secret(Zeroizing); impl Secret { @@ -67,13 +67,13 @@ pub trait Vault: Send + Sync { } pub struct NativeVault; -#[cfg(any(windows, target_os = "macos"))] +#[cfg(any(windows, target_os = "macos", target_os = "linux"))] impl NativeVault { fn entry(id: &CredentialId) -> Result { keyring::Entry::new(SERVICE, &id.0).map_err(|_| SafeError::CredentialUnavailable) } } -#[cfg(any(windows, target_os = "macos"))] +#[cfg(any(windows, target_os = "macos", target_os = "linux"))] impl Vault for NativeVault { fn put(&self, id: &CredentialId, secret: &Secret) -> Result<(), SafeError> { Self::entry(id)? @@ -94,7 +94,7 @@ impl Vault for NativeVault { } } } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] impl Vault for NativeVault { fn put(&self, _: &CredentialId, _: &Secret) -> Result<(), SafeError> { Err(SafeError::CredentialUnavailable) @@ -179,6 +179,20 @@ impl Credential { #[cfg(test)] mod tests { use super::*; + + struct Available; + impl Vault for Available { + fn put(&self, _: &CredentialId, _: &Secret) -> Result<(), SafeError> { + Ok(()) + } + fn read(&self, _: &CredentialId) -> Result { + Secret::new("synthetic".into()) + } + fn delete(&self, _: &CredentialId) -> Result<(), SafeError> { + Ok(()) + } + } + struct Unavailable; impl Vault for Unavailable { fn put(&self, _: &CredentialId, _: &Secret) -> Result<(), SafeError> { @@ -223,6 +237,19 @@ mod tests { ); } + #[test] + fn persistent_write_receipt_never_exposes_secret_or_native_id() { + let request: CredentialWriteRequest = + serde_json::from_str(r#"{"secret":"synthetic","storage":"persistent"}"#).unwrap(); + let (_, receipt) = request + .store(&Available, CredentialId::new("native-id".into()).unwrap()) + .unwrap(); + let encoded = serde_json::to_string(&receipt).unwrap(); + assert_eq!(encoded, r#"{"storage":"persistent"}"#); + assert!(!encoded.contains("synthetic")); + assert!(!encoded.contains("native-id")); + } + #[test] fn unavailable_vault_never_silently_downgrades() { let result = Credential::store( diff --git a/desktop/src-tauri/src/ipc.rs b/desktop/src-tauri/src/ipc.rs index 1e29c55750..2bdfc10551 100644 --- a/desktop/src-tauri/src/ipc.rs +++ b/desktop/src-tauri/src/ipc.rs @@ -43,18 +43,20 @@ pub fn foundation_info( Ok(FoundationInfo { version: env!("CARGO_PKG_VERSION").into(), phase: "S3".into(), - credential_backend: if cfg!(windows) { - "windows_credential_manager" - } else if cfg!(target_os = "macos") { - "macos_keychain" - } else { - "unavailable" - } - .into(), + credential_backend: credential_backend(current_target_os()).into(), server_connected: false, }) } +fn credential_backend(target_os: &str) -> &'static str { + match target_os { + "windows" => "windows_credential_manager", + "macos" => "macos_keychain", + "linux" => "linux_secret_service", + _ => "unavailable", + } +} + fn packaged_origin(target_os: &str) -> Option<(&'static str, &'static str)> { match target_os { "windows" | "android" => Some(("http", "tauri.localhost")), @@ -94,7 +96,15 @@ pub fn allowed_navigation(url: &url::Url) -> bool { #[cfg(test)] mod tests { - use super::packaged_navigation_allowed; + use super::{credential_backend, packaged_navigation_allowed}; + + #[test] + fn credential_backend_is_explicit_for_supported_targets() { + assert_eq!(credential_backend("windows"), "windows_credential_manager"); + assert_eq!(credential_backend("macos"), "macos_keychain"); + assert_eq!(credential_backend("linux"), "linux_secret_service"); + assert_eq!(credential_backend("other"), "unavailable"); + } #[test] fn packaged_origins_are_explicit_for_each_supported_target() { diff --git a/desktop/src-tauri/tests/profiles.rs b/desktop/src-tauri/tests/profiles.rs index b2aa4fcf4c..6c0a2e3e3f 100644 --- a/desktop/src-tauri/tests/profiles.rs +++ b/desktop/src-tauri/tests/profiles.rs @@ -98,6 +98,11 @@ fn explicit_session_works_without_vault_and_expires_on_restart() { Err(SafeError::CredentialUnavailable) )); assert!(repo.views().is_empty()); + let stored_after_failure = std::fs::read_to_string(&path).unwrap(); + assert!( + !stored_after_failure.contains("synthetic-private-marker"), + "a failed persistent write must never downgrade the secret into profiles.json" + ); let session = repo.save(input("Session", "session_only")).unwrap(); assert!(matches!( session.credential_state, From 4bc80aac1b50816248daf6d217676ac3ecfb839c Mon Sep 17 00:00:00 2001 From: jackie-cqz <2557911191@qq.com> Date: Thu, 29 Oct 2026 21:10:18 +0800 Subject: [PATCH 03/10] test(desktop): confirm uncertain draft discard in installed workflow --- desktop/tests/installed_workflow.py | 11 +++++++++ desktop/ui/tests/memory.test.tsx | 35 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/desktop/tests/installed_workflow.py b/desktop/tests/installed_workflow.py index 96ee1212bd..62a7b5a869 100644 --- a/desktop/tests/installed_workflow.py +++ b/desktop/tests/installed_workflow.py @@ -249,6 +249,7 @@ def exercise_memory(client: httpx.Client, prefix: str) -> dict[str, object]: "rawUtf8BudgetBoundary": True, "emptyAndCappedSearchPresentation": True, "committedLostResponseUnknownWithoutReplay": True, + "unknownDraftNavigationDiscardConfirmed": True, "lateSavePreservesNewScopeDraft": True, } @@ -351,6 +352,16 @@ def exercise_unknown_write(page: InstalledPage) -> None: if len(hits) != 1 or search_reference(hits[0]) != reference or counter.read_text(encoding="utf-8") != "1": raise HarnessFailure("installed_unknown_write_replayed_or_missing") page.clear_note() + # Clearing the editable form does not discard the retained submitted + # input. Finish recovery explicitly before the next Server scenario. + page.button("连接") + page.confirm("丢弃尚未保存的输入?", accept=False) # noqa: RUF001 - exact localized UI + page.element("//main//h1[normalize-space(.)='记忆']") + page.button("连接") + page.confirm("丢弃尚未保存的输入?", accept=True) # noqa: RUF001 - exact localized UI + page.field("连接名称") + if counter.read_text(encoding="utf-8") != "1": + raise HarnessFailure("installed_unknown_navigation_replayed_write") def exercise_late_save(page: InstalledPage) -> None: diff --git a/desktop/ui/tests/memory.test.tsx b/desktop/ui/tests/memory.test.tsx index 9ac29b0097..04266041a9 100644 --- a/desktop/ui/tests/memory.test.tsx +++ b/desktop/ui/tests/memory.test.tsx @@ -447,6 +447,41 @@ test.each(["en", "zh"] as const)( expect(desktopApi.remember).toHaveBeenCalledTimes(1); }, ); +test("leaving a cleared unknown-write form explicitly discards retained input without clearing the receipt", async () => { + const user = userEvent.setup(); + vi.mocked(desktopApi.state).mockResolvedValue(state); + render(); + await user.click(screen.getByRole("button", { name: "记忆" })); + const unknown = outcome("unknown"); + vi.mocked(desktopApi.remember).mockResolvedValueOnce(unknown); + vi.mocked(desktopApi.state).mockResolvedValue({ + ...state, + lastWrite: unknown.record, + }); + await user.type( + screen.getByLabelText("记忆内容"), + "uncertain submitted input", + ); + await user.click(screen.getByRole("button", { name: "保存" })); + await screen.findAllByText(/提交结果未知。/); + await user.clear(screen.getByLabelText("记忆内容")); + await user.click(screen.getByRole("button", { name: "连接" })); + await answerConfirmation(user, false); + expect(screen.getByRole("heading", { level: 1, name: "记忆" })).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "连接" })); + await answerConfirmation(user, true); + expect(await screen.findByLabelText("连接名称")).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "记忆" })); + expect(screen.queryByRole("alertdialog")).toBeNull(); + await user.type(screen.getByLabelText("记忆内容"), "next explicit write"); + await user.click(screen.getByRole("button", { name: "保存" })); + expect(screen.getByRole("alertdialog").textContent).toContain( + "再次保存可能产生重复记录", + ); + await answerConfirmation(user, false); + expect(desktopApi.remember).toHaveBeenCalledTimes(1); +}); + function form(language: "en" | "zh" = "en") { vi.mocked(desktopApi.state).mockResolvedValue(state); render( From 05465421123ff3cec775d952ad50538fe0092bfd Mon Sep 17 00:00:00 2001 From: knqiufan Date: Fri, 30 Oct 2026 00:17:36 +0800 Subject: [PATCH 04/10] feat(desktop): add local Server setup and management --- desktop/README.md | 16 +- desktop/README.zh.md | 18 +- desktop/SECURITY.md | 6 +- desktop/VALIDATION.md | 12 + desktop/src-tauri/build.rs | 1 + desktop/src-tauri/capabilities/main.json | 1 + desktop/src-tauri/src/commands.rs | 37 + desktop/src-tauri/src/connections/session.rs | 213 +++++- desktop/src-tauri/src/error.rs | 2 + desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/ops_bridge.rs | 80 +- desktop/src-tauri/src/ops_bridge/tests.rs | 145 ++++ desktop/src-tauri/src/ops_bridge/trust.rs | 89 ++- desktop/src-tauri/src/ops_bridge/types.rs | 10 + desktop/src-tauri/src/ops_bridge/wire.rs | 1 + desktop/src-tauri/tests/discovery.rs | 282 ++++++- .../tests/fixtures/local-setup-catalog.json | 44 ++ desktop/ui/src/app/App.tsx | 78 +- desktop/ui/src/app/Diagnostics.tsx | 2 +- desktop/ui/src/app/LocalServerSettings.tsx | 412 +++++++++- desktop/ui/src/app/connection-messages.ts | 8 + desktop/ui/src/app/local-server-messages.ts | 355 +++++++++ desktop/ui/src/app/messages.ts | 40 +- desktop/ui/src/app/style.css | 20 + desktop/ui/src/app/useLocalServer.ts | 329 ++++++++ desktop/ui/src/generated/ipc.ts | 8 +- desktop/ui/src/shared/ops.ts | 5 +- desktop/ui/tests/local-handoff.test.tsx | 240 ++++++ desktop/ui/tests/local-server.test.tsx | 41 +- desktop/ui/tests/local-setup.test.tsx | 707 ++++++++++++++++++ docs/en/development/desktop-local-server.md | 30 + docs/zh/development/desktop-local-server.md | 30 + tests/test_desktop_contract.py | 14 + 33 files changed, 3175 insertions(+), 102 deletions(-) create mode 100644 desktop/src-tauri/tests/fixtures/local-setup-catalog.json create mode 100644 desktop/ui/src/app/local-server-messages.ts create mode 100644 desktop/ui/src/app/useLocalServer.ts create mode 100644 desktop/ui/tests/local-handoff.test.tsx create mode 100644 desktop/ui/tests/local-setup.test.tsx create mode 100644 docs/en/development/desktop-local-server.md create mode 100644 docs/zh/development/desktop-local-server.md diff --git a/desktop/README.md b/desktop/README.md index 7ba5cf6cda..94169f8d64 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -29,13 +29,19 @@ Selecting a saved profile in the global connection menu opens that exact configu Scope selection also keeps an application-wide pending state. Escape, the close button and the backdrop can close the dialog, but do not cancel an accepted native selection. Memory editing and further Scope switching stay paused until it finishes. Closing a dialog whose trigger is disabled returns focus to the page heading. A late result still updates authoritative native state, while it cannot clear a new connection editor's draft guard. Failures after the dialog has closed appear in the shared error feedback. -## Local Server preparation +## Local Server setup and management -Overview remains the initial page. In disconnected Memories, **Set up locally** opens and focuses **Settings & diagnostics → Local Server**; **Connect an existing Server** opens the existing connection editor. Selecting either path does not install software, activate a connection, run diagnostics or probe readiness. +Overview and disconnected Memories offer **Set up locally** and the independent existing-Server connection path. Local setup opens **Settings & diagnostics → Local Server**. Opening the page only inspects local facts and retained operation references; it never installs, activates a private connection or probes model readiness. -The Local Server guidance is also available by opening Settings directly, including when native state is loading or unavailable. It explicitly marks Desktop installation, recovery and service management as unavailable; that status makes no claim about an actual local installation or running service. Settings stays at the bottom of the sidebar. +With distributor-provisioned trusted Installer/Ops resources, choose install, repair, start, stop, restart, login startup or service-registration removal. Review the actual program/configuration/data locations, endpoint, pinned Server version, space requirement and service effects, then explicitly approve the one-shot plan. Installation phases, configuration, registration, process state, liveness and readiness are shown separately. Missing resources leave local controls unavailable and preserve existing-Server access. -Prepare a Server independently using [Install and run](../docs/en/docs/get-started/install-and-run.md) and [Deploy the Server](../docs/en/docs/operate/deploy-server.md), observing their platform limits. Confirm the actual address, authentication and a qualified Desktop compatibility profile, then save and explicitly activate the connection and select an exact Scope. A running Server alone does not establish Desktop compatibility. Existing independently running Servers do not need to be reinstalled. The in-app Usage help provides a copyable address for this guide. +Operations retain native references across Desktop exit. Reopening queries the selected operation's authoritative Ops status without replay. An interrupted/failed attempt can offer a fresh approved recovery plan for the original action/options. An unknown live executor is query-only; cancellation is a request, not confirmation of rollback or completion. Records from older bridges remain queryable, but missing action metadata cannot authorize resume. + +After setup, explicitly choose **Connect and open default Scope**. Native code rechecks actual ownership, service/configuration/resource facts and the returned endpoint. A pinned managed catalog must name a bundled, current-contract Desktop qualification in `qualification_ref`; unqualified candidate references are rejected. The actual discovery package version must match the resource version. The ordinary connection manager then verifies identity/compatibility and resolves the public default Scope before opening Memory. This action discloses its readiness probe; application reopen never activates a session automatically. + +Registration removal preserves configuration and data and does not uninstall Desktop. The OS-managed Server survives Desktop exit. Unfinished installation is not promised to continue after Quit; query its durable result on reopen. Signed Windows/macOS packages, published qualified resources and clean-machine installed acceptance remain release responsibilities under #2108/#2109/#2112. + +See [the local setup contract](../docs/en/development/desktop-local-server.md) and [independent Server installation](../docs/en/docs/get-started/install-and-run.md). ## Run the installed preview @@ -203,4 +209,4 @@ launcher with an absolute interpreter path; launchers that locate dependencies r are unsuitable. The CLI must not depend on a shell profile or an interactive terminal. Diagnostics keep the existing output limits and deadlines and terminate their private process group on completion, timeout and cancellation. Before normal application exit, Desktop rejects new diagnostics, -cancels active work and waits for process-group cleanup and launcher reaping. The app does not install or manage a LaunchAgent. +cancels active work and waits for process-group cleanup and launcher reaping. Read-only diagnostics do not manage LaunchAgents; explicit Local Server actions use trusted independent Ops. diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 18a1aced8e..8bb9fee131 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -29,13 +29,19 @@ Scope 选择也具有应用层等待状态。Escape、关闭按钮及遮罩可以关闭弹窗,但不会取消已提交的原生选择;完成前暂停记忆编辑及再次切换 Scope。触发按钮被禁用时,关闭弹窗将焦点返回页面标题。晚到结果仍更新原生实际状态,但不会清除新连接编辑器的草稿导航保护;弹窗关闭后发生的失败由共享错误反馈显示。 -## Local Server 准备说明 +## Local Server 安装与管理 -默认启动页仍为总览。记忆页未连接时,“本地设置”进入并聚焦“设置与诊断 → Local Server”;“连接已有 Server”进入现有连接编辑器。选择任一路径都不会安装软件、激活连接、执行诊断或探测就绪状态。 +总览和未连接的记忆页提供“本地设置”及独立的已有 Server 连接路径。本地设置进入“设置与诊断 → Local Server”。打开页面只检查本机事实和保留的操作引用,不会安装、激活私有连接或探测模型就绪状态。 -也可直接进入设置查看 Local Server 说明;即使原生状态正在加载或不可用,说明仍可访问。区域明确标注 Desktop 安装、恢复及服务管理尚未接入,不据此判断本机是否已安装 Server 或服务是否运行。侧栏设置入口保持在底部。 +发行方提供可信 Installer/Ops 资源后,可选择安装、修复、启动、停止、重启、登录启动设置或移除服务注册。查看实际程序、配置和数据位置、地址、固定 Server 版本、空间需求及服务影响,再明确批准一次性计划。安装阶段、配置、注册、进程、存活和就绪状态分别显示。缺少资源时,本地控制不可用,已有 Server 连接仍可使用。 -请按[安装与运行](../docs/zh/docs/get-started/install-and-run.md)及[部署 Server](../docs/zh/docs/operate/deploy-server.md)独立准备 Server,并遵守文档中的平台适用范围。确认实际地址、认证方式和经过验证的 Desktop 兼容配置,再保存、显式激活连接并选择精确 Scope。Server 已运行不代表 Desktop 兼容性已验证;已有独立运行的 Server 无需重复安装。应用内“使用帮助”可复制本说明的文档地址。 +操作引用跨 Desktop 退出保留。重开后查询所选操作的权威 Ops 状态,不会重放。中断或失败的尝试可对原操作及选项重新预检并批准恢复计划;执行进程仍不确定时只能查询。取消只是请求,不代表回滚或操作已经结束。旧 bridge 的记录仍可查询,但没有操作描述时不能授权恢复。 + +安装后明确选择“连接并进入默认 Scope”。原生代码重新检查实际归属、服务、配置、资源及返回的地址。固定 managed catalog 的 `qualification_ref` 必须指向内置且匹配当前契约的 Desktop 资格记录;未验证的候选引用会被拒绝。实际发现的 Server 版本须匹配资源版本。随后复用连接管理器检查身份和兼容性,通过公共接口解析默认 Scope 后进入记忆。此动作会说明就绪探测的影响;重开应用不会自动激活会话。 + +移除注册保留配置和数据,也不卸载 Desktop。操作系统管理的 Server 独立于 Desktop 存活。不能保证尚未完成的安装在退出后继续,应在重开后查询持久结果。正式签名的 Windows/macOS 安装包、已发布的资格资源和干净机器安装验收仍由 #2108/#2109/#2112 负责。 + +详见[本地设置契约](../docs/zh/development/desktop-local-server.md)及[独立安装 Server](../docs/zh/docs/get-started/install-and-run.md)。 ## 直接运行安装版 @@ -99,7 +105,7 @@ pnpm --dir desktop desktop:dev - 连接超时 5 秒、完整请求超时 15 秒、响应上限 1 MiB、每个客户端最多 4 个并发读取;超限明确失败。 - 主窗口可调用明确列出的连接、Scope、记忆和诊断命令;没有通用 fetch、shell、文件、数据库或读取秘密的 IPC。 -应用不启动 Server、不管理服务、不持久化正文或查询;关闭窗口即退出。当前语言和主题也仅保留于会话内。 +普通连接和 Memory 操作不会启动或管理 Server;本地服务修改必须通过明确批准的 Ops 计划。应用不持久化正文或查询,关闭窗口即退出。当前语言和主题也仅保留于会话内。 [安全边界](SECURITY.md)记录具体约束;[验证说明](VALIDATION.md)列出测试范围和仍未满足的 P0 门槛。构建成功、Mock 测试和当前用户机器上的运行都不能代替标准用户、签名、通知冷启动激活、独立服务和 Agent 宿主验收。 @@ -167,4 +173,4 @@ pnpm desktop:build 构建产物是未签名的内部预览。Developer ID 签名、公证、干净机器上的 Gatekeeper、辅助功能权限、真实输入法输入以及安装版钥匙串弹窗仍需单独验收。macOS CI 任务会测试 Apple Silicon 和 Intel 构建,但任务本身的存在不代表运行已通过。 -macOS 本机诊断读取 `~/Library/Application Support/com.powercontext.desktop.preview/diagnostic-cli.json`。登记与 Windows 使用相同的 JSON 契约:绝对路径的 `powercontext` 可执行文件、经核对的 SHA-256 摘要和 `source: "explicit_local_installation"`。渲染进程无法提供可执行文件或参数。原生适配器只执行经过核验的 launcher 的私有只读副本。请使用自包含可执行文件,或解释器路径为绝对路径的 launcher;以自身位置查找依赖的 launcher 不适用。CLI 不能依赖 shell 配置或交互式终端。诊断沿用既有的输出上限与超时,并在完成、超时和取消时终止自己的私有进程组。应用正常退出前会拒绝新诊断、取消当前诊断,并等待进程组清理和 launcher 回收完成。应用不会安装或管理 LaunchAgent。 +macOS 本机诊断读取 `~/Library/Application Support/com.powercontext.desktop.preview/diagnostic-cli.json`。登记与 Windows 使用相同的 JSON 契约:绝对路径的 `powercontext` 可执行文件、经核对的 SHA-256 摘要和 `source: "explicit_local_installation"`。渲染进程无法提供可执行文件或参数。原生适配器只执行经过核验的 launcher 的私有只读副本。请使用自包含可执行文件,或解释器路径为绝对路径的 launcher;以自身位置查找依赖的 launcher 不适用。CLI 不能依赖 shell 配置或交互式终端。诊断沿用既有的输出上限与超时,并在完成、超时和取消时终止自己的私有进程组。应用正常退出前会拒绝新诊断、取消当前诊断,并等待进程组清理和 launcher 回收完成。只读诊断不管理 LaunchAgent;明确批准的 Local Server 操作通过可信的独立 Ops 执行。 diff --git a/desktop/SECURITY.md b/desktop/SECURITY.md index e659b31880..569922bdf7 100644 --- a/desktop/SECURITY.md +++ b/desktop/SECURITY.md @@ -56,7 +56,7 @@ Native code writes private files atomically (0600/0700 on POSIX; current-user pr links/reparse points, and validates ownership/protection on existing control files. This same-user bridge is not a privilege boundary against another process running as that user. -The native reference file stores only operation IDs and binding fingerprints before dispatch. Ops journals and locks +The native reference file stores operation IDs, binding fingerprints and the approved action/options before dispatch. It stores no outcome, raw plan, endpoint, path or credential. Legacy references without action metadata remain queryable but cannot authorize resume. Resume must match the retained action/options. Progress is correlated to the native approval ID as well as operation/attempt. Ops journals and locks remain the sole recovery authority. Four ordinary and two reserved status/cancel helpers bound native concurrency; timed-out children retain their slot until exit. Each invocation bounds JSONL messages to 64 KiB, aggregate output to 1 MiB and 128 messages, with 30-second passive-read, 180-second preflight and 30-minute mutation deadlines. Stderr is discarded. Invalid schema, @@ -72,3 +72,7 @@ See [Local Server Ops](../docs/en/docs/operate/local-server-ops.md#desktop-nativ resource configuration, producer pins, stable paths and recovery API. The bridge does not ship production resources or an installation wizard. Real service mutations, signing/notarization and clean-machine acceptance require separate platform evidence. + +## Explicit local connection handoff + +`connect_local_server` accepts only the current context generation. It obtains the endpoint through trusted native Ops, requires an owned/current registration, active process, valid configuration, present runtime, responding service and installation identity. It matches the observed resource ID/digest to the pinned catalog and selects only its named bundled Desktop qualification for the current HTTP contract. Known qualification IDs also require the catalog source commit and `wheel-sha256:` reference to match the record's original Server source and qualification wheel; a matching API contract cannot reinterpret historical evidence. Candidate/unrecognized qualifications fail closed. The discovery package version, Principal, basic Memory operation set and exact public default Scope must pass before a local activation is published. Pending or failed qualification/Scope reads expose no active local context and cannot replace a newer context. Existing Bearer/CA profiles are not overwritten. Page opening and application reopen never invoke this command. diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index ea6050baa6..00b692abf6 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -670,3 +670,15 @@ are documented in [Local Server Ops](../docs/en/docs/operate/local-server-ops.md 上述两项可选检查分别验证源码 Ops 子进程和完整独立分发包与原生桥接的只读兼容性。测试使用临时 home、绑定和状态目录, 不执行服务修改;真实服务管理器归属无法确认时保留预检拒绝。分发包测试要求另行提供已批准的清单摘要,并检查依赖被改动时 原生端拒绝启动。这些证据不代替签名/公证、真实服务修改、随包配置或干净机器验收。 + +## Local Server setup, recovery and management + +Local checks use macOS arm64, Python 3.12.15, Rust 1.95.0, Node 24.22.1, pnpm 11.13.1 and MacOSX26.5.sdk. The host Node patch differs from the package's 24.14.1 engine pin; pinned CI qualification remains separate. The frontend suite passes 157 tests, including both languages, explicit plan approval, plan dismissal during an awaited context guard, retained operation recovery, lost progress/reply reference discovery, cancellation, rejected resources and a generation-bound handoff into the exact returned default Scope. Deferred progress subscriptions are exercised through StrictMode remount and cleanup. A browser smoke check of the unavailable-native view at 800×600 found no horizontal overflow; it is not a native WebView or complete layout qualification. + +The Rust suite passes 111 top-level tests, with two opt-ins ignored by default. Synthetic HTTP scenarios verify no local activation before version/identity/basic-Memory/default-Scope validation completes, no activation after denied Scope resolution or an identity change during the exact read, monotonically newer Scope publication, exact-Scope reauthorization on ordinary refresh and loss of revoked selections. Real disposable helper processes verify approved recovery metadata, progress correlation, legacy query-only references and refusal of changed actions/options. A shared valid catalog is accepted by actual Python `ResourceCatalog` and by Rust, including omitted `executable=false` defaults and rejection of resource/source/wheel evidence mismatches. + +The source Ops opt-in was separately executed with `POWERCONTEXT_OPS_TEST_PYTHON` pointing to the prepared Python environment. It uses a disposable home, missing Server runtime and read-only inspection/preflight; it does not apply service operations or register a service. The independent distributed-bundle opt-in was not executed for this change. + +`make check`, Rust formatting/all-target Clippy with `-D warnings`, IPC export drift, frontend lint/typecheck/production build and `make docs-test` pass. The focused Python contract/fixture/Ops/installer/local-policy selection passes 89 tests with one platform skip. The commands at the top of this guide reproduce the checks; the setup behavior and supported recovery choices are documented in both locales under [Desktop local setup](../docs/en/development/desktop-local-server.md). + +UI tests mock native IPC; HTTP fixtures simulate Server responses. These results do not establish actual managed installation/apply, Windows execution, signed/notarized packages, clean-machine bootstrap, login/reboot, real IME, screen readers or installed credential/clipboard behavior. Product qualification records are unchanged. Required installed acceptance uses the exact published resources and final Windows/macOS packages under #2108/#2109/#2112; #2117 remains open until that evidence is linked. diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 3a87ac0b73..e5a6245778 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -35,6 +35,7 @@ fn main() { tauri_build::AppManifest::new().commands(&[ "foundation_info", "local_diagnostics", + "connect_local_server", "ops_references", "ops_inspect", "ops_config_validate", diff --git a/desktop/src-tauri/capabilities/main.json b/desktop/src-tauri/capabilities/main.json index a75e82e713..79dfdf880a 100644 --- a/desktop/src-tauri/capabilities/main.json +++ b/desktop/src-tauri/capabilities/main.json @@ -25,6 +25,7 @@ "allow-session-summary", "allow-reset-session-observations", "allow-preview-session-diagnostics", + "allow-connect-local-server", "allow-ops-references", "allow-ops-inspect", "allow-ops-config-validate", diff --git a/desktop/src-tauri/src/commands.rs b/desktop/src-tauri/src/commands.rs index 38648ed696..87a7418b65 100644 --- a/desktop/src-tauri/src/commands.rs +++ b/desktop/src-tauri/src/commands.rs @@ -346,3 +346,40 @@ pub async fn ops_cancel( .await .map_err(|_| crate::ops_bridge::OpsBridgeError::ProcessFailure)? } + +/// An explicit setup action may probe readiness and select the public default Scope. +#[tauri::command] +pub async fn connect_local_server( + window: tauri::WebviewWindow, + state: tauri::State<'_, HostState>, + ops: tauri::State<'_, crate::ops_bridge::OpsHost>, + generation: u32, +) -> Result { + let manager = manager(&window, &state)?; + if manager.state()?.generation != generation { + return Err(SafeError::StaleContext.into()); + } + let host = ops.inner().clone(); + let qualifications = manager.state()?.compatibility_profiles; + let (endpoint, qualification, version) = + tauri::async_runtime::spawn_blocking(move || host.connection_target(&qualifications)) + .await + .map_err(|_| SafeError::ProcessFailure)? + .map_err(|error| match error { + crate::ops_bridge::OpsBridgeError::IntegrityFailure => SafeError::IntegrityFailure, + crate::ops_bridge::OpsBridgeError::InvalidInput => SafeError::InvalidInput, + crate::ops_bridge::OpsBridgeError::Storage => SafeError::Storage, + crate::ops_bridge::OpsBridgeError::Busy => SafeError::Busy, + crate::ops_bridge::OpsBridgeError::Timeout => SafeError::Timeout, + crate::ops_bridge::OpsBridgeError::ForeignBinding => SafeError::Conflict, + crate::ops_bridge::OpsBridgeError::InvalidResponse => SafeError::InvalidResponse, + crate::ops_bridge::OpsBridgeError::Unavailable + | crate::ops_bridge::OpsBridgeError::UnsupportedPlatform => { + SafeError::RuntimeNotReady + } + _ => SafeError::ProcessFailure, + })?; + manager + .connect_local(generation, endpoint, qualification, version) + .await +} diff --git a/desktop/src-tauri/src/connections/session.rs b/desktop/src-tauri/src/connections/session.rs index 94733a616c..9423ea2d5e 100644 --- a/desktop/src-tauri/src/connections/session.rs +++ b/desktop/src-tauri/src/connections/session.rs @@ -230,7 +230,10 @@ impl ConnectionManager { } pub fn state(&self) -> Result { let inner = self.lock()?; - Ok(DesktopState { + Ok(self.state_view(&inner)) + } + fn state_view(&self, inner: &Inner) -> DesktopState { + DesktopState { generation: inner.generation, profiles: inner.profiles.views(), reports: inner.reports.values().cloned().collect(), @@ -238,7 +241,7 @@ impl ConnectionManager { compatibility_profiles: self.compatibility.clone(), pending_credential_cleanup: inner.profiles.pending_cleanup(), last_write: inner.last_write.clone(), - }) + } } pub fn save_profile(&self, input: ProfileInput) -> Result { let mut inner = self.lock()?; @@ -296,9 +299,82 @@ impl ConnectionManager { self.state() } pub async fn check(&self, id: &str, activate: bool) -> Result { + self.check_expected(id, activate, None, None).await + } + /// Explicit local setup handoff. The endpoint comes from native Ops, never the renderer. + pub async fn connect_local( + &self, + expected_generation: u32, + endpoint: String, + qualification: String, + server_version: String, + ) -> Result { + use super::profiles::Authentication; + let endpoint = crate::transport::Endpoint::parse(&endpoint)? + .as_str() + .to_owned(); + let contract: serde_json::Value = + serde_json::from_str(include_str!("../transport/operations.json")) + .map_err(|_| SafeError::InvalidResponse)?; + if !self.compatibility.iter().any(|profile| { + profile.id == qualification + && Some(profile.contract_sha256.as_str()) == contract["contractSha256"].as_str() + }) { + return Err(SafeError::CompatibilityUnverified.into()); + } + let (id, generation) = { + let mut inner = self.lock()?; + if inner.generation != expected_generation { + return Err(SafeError::StaleContext.into()); + } + let profiles = inner.profiles.views(); + let existing = profiles.iter().find(|p| { + p.endpoint == endpoint + && p.authentication == Authentication::UnauthenticatedLoopback + && p.ca_pem.is_none() + && p.compatibility.as_deref() == Some(qualification.as_str()) + }); + let id = if let Some(profile) = existing { + profile.id.clone() + } else { + let mut name = "Local Server".to_string(); + if profiles.iter().any(|p| p.name.eq_ignore_ascii_case(&name)) { + name = format!("Local Server {}", &uuid::Uuid::new_v4().to_string()[..8]); + } + inner + .profiles + .save(ProfileInput { + id: None, + revision: None, + name, + endpoint, + authentication: Authentication::UnauthenticatedLoopback, + ca_pem: None, + compatibility: Some(qualification), + keep_credential: false, + credential: None, + })? + .id + }; + self.invalidate(&mut inner)?; + (id, inner.generation) + }; + self.check_expected(&id, true, Some(generation), Some(&server_version)) + .await + } + async fn check_expected( + &self, + id: &str, + activate: bool, + expected: Option, + local_version: Option<&str>, + ) -> Result { let _permit = self.probes.try_acquire().map_err(|_| SafeError::Busy)?; - let (api, profile, generation, mut cancelled, use_as_active) = { + let (api, profile, generation, mut cancelled, use_as_active, retained_scope) = { let mut inner = self.lock()?; + if expected.is_some_and(|value| value != inner.generation) { + return Err(SafeError::StaleContext.into()); + } let profile = inner .profiles .views() @@ -310,7 +386,23 @@ impl ConnectionManager { .active .as_ref() .is_some_and(|a| a.view.connection_id == id); + let retained_scope = if !activate { + inner + .active + .as_ref() + .filter(|active| active.view.connection_id == id) + .and_then(|active| { + active + .view + .scope + .clone() + .map(|scope| (scope, active.view.report.clone())) + }) + } else { + None + }; if use_as_active { + inner.reports.remove(id); inner.active = None; self.invalidate(&mut inner)?; } @@ -325,6 +417,7 @@ impl ConnectionManager { inner.generation, self.changed.subscribe(), use_as_active, + retained_scope, ) }; let report = Self::cancellable(&mut cancelled, async { @@ -377,10 +470,119 @@ impl ConnectionManager { }) }) .await?; + // Local setup publishes no active context until version, identity and the exact default Scope pass. + let scope = if let Some(version) = local_version { + let server = report + .discovery + .as_ref() + .and_then(|discovery| discovery.server.as_ref()) + .ok_or(SafeError::CompatibilityUnverified)?; + if server.package_version != version + || [ + "get_default_scope", + "get_scope", + "remember_memory", + "search_memory", + "get_artifact_revision", + ] + .iter() + .any(|operation| { + !report + .supported_operations + .iter() + .any(|supported| supported == operation) + }) + { + return Err(SafeError::CompatibilityUnverified.into()); + } + let identity = report.identity.value.clone(); + if identity.is_none() && !report.anonymous_access { + return Err(SafeError::Unauthorized.into()); + } + let server_id = server.server_id.clone(); + Some( + Self::cancellable(&mut cancelled, async { + self.identity_unchanged(&api, &identity, generation).await?; + let default = api.default_scope().await?; + self.identity_unchanged(&api, &identity, generation).await?; + let exact = api.scope(&default.scope_id).await?; + self.identity_unchanged(&api, &identity, generation).await?; + match api.server_info().await? { + crate::transport::DiscoveryDocument::Server(current) + if current.server_id == server_id + && current.package_version == version => {} + _ => return Err(SafeError::StaleContext.into()), + } + Ok(exact) + }) + .await?, + ) + } else if let Some((scope, previous)) = retained_scope { + let previous_server = previous + .discovery + .as_ref() + .and_then(|value| value.server.as_ref()); + let current_server = report + .discovery + .as_ref() + .and_then(|value| value.server.as_ref()); + let same_identity = previous.identity.value == report.identity.value + && previous.anonymous_access == report.anonymous_access + && (report.identity.value.is_some() || report.anonymous_access); + match (previous_server, current_server) { + (Some(before), Some(current)) + if before.server_id == current.server_id + && same_identity + && report + .supported_operations + .iter() + .any(|operation| operation == "get_scope") => + { + let identity = report.identity.value.clone(); + let server_id = current.server_id.clone(); + Self::cancellable(&mut cancelled, async { + self.identity_unchanged(&api, &identity, generation).await?; + // Revoked or removed Scopes lose selection without disabling other authorized Scopes. + let exact = match api.scope(&scope.scope_id).await { + Ok(scope) => Some(scope), + Err(error) + if matches!( + error.code, + SafeError::Forbidden | SafeError::NotFound + ) => + { + None + } + Err(error) => return Err(error), + }; + self.identity_unchanged(&api, &identity, generation).await?; + match api.server_info().await? { + crate::transport::DiscoveryDocument::Server(current) + if current.server_id == server_id => + { + Ok(exact) + } + _ => Err(SafeError::StaleContext.into()), + } + }) + .await? + } + _ => None, + } + } else { + None + }; let mut inner = self.lock()?; if inner.generation != generation { return Err(SafeError::StaleContext.into()); } + // Scope publication must be newer than state snapshots taken while activation was pending. + let generation = if use_as_active && scope.is_some() { + self.invalidate(&mut inner)?; + inner.generation + } else { + generation + }; inner.reports.insert(id.into(), report.clone()); // Activation can expose diagnostic facts while business operations remain independently gated. if use_as_active { @@ -389,13 +591,12 @@ impl ConnectionManager { connection_id: id.into(), generation, report, - scope: None, + scope, }, api, }); } - drop(inner); - self.state().map_err(Into::into) + Ok(self.state_view(&inner)) } fn snapshot(&self, generation: u32, operation: &str) -> Result { let inner = self.lock()?; diff --git a/desktop/src-tauri/src/error.rs b/desktop/src-tauri/src/error.rs index d27ce6363d..e36199519a 100644 --- a/desktop/src-tauri/src/error.rs +++ b/desktop/src-tauri/src/error.rs @@ -43,6 +43,8 @@ pub enum SafeError { Conflict, AuthenticationUnavailable, RuntimeNotReady, + IntegrityFailure, + ProcessFailure, StaleContext, CompatibilityUnverified, Storage, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 47f8b231c1..388f0f5892 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -44,6 +44,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ ipc::foundation_info, commands::local_diagnostics, + commands::connect_local_server, commands::ops_references, commands::ops_inspect, commands::ops_config_validate, diff --git a/desktop/src-tauri/src/ops_bridge.rs b/desktop/src-tauri/src/ops_bridge.rs index 6156faf775..e0e480a82d 100644 --- a/desktop/src-tauri/src/ops_bridge.rs +++ b/desktop/src-tauri/src/ops_bridge.rs @@ -50,6 +50,8 @@ struct Host { } struct Approval { plan: Value, + operation: OpsOperation, + options: OpsOptions, binding: String, resume: Option, created: Instant, @@ -135,10 +137,13 @@ impl OpsHost { .read()?; Ok(OpsReferences { binding_available: binding.is_some(), + login_start_choice: cfg!(windows), operations: items .into_iter() .map(|item| OpsOperationReference { operation_id: item.operation_id, + operation: item.operation, + options: item.options, binding_status: match &binding { Some(b) if b.fingerprint == item.binding_id => { OpsReferenceBindingStatus::Current @@ -171,6 +176,42 @@ impl OpsHost { }, } } + pub fn connection_target( + &self, + qualifications: &[crate::connections::session::CompatibilityProfile], + ) -> Result<(String, String, String), OpsBridgeError> { + let reply = self.inspect(); + if reply.outcome != OpsOutcome::Succeeded { + return Err(reply.bridge_error.unwrap_or(OpsBridgeError::ProcessFailure)); + } + let components = reply + .components + .as_ref() + .ok_or(OpsBridgeError::InvalidResponse)?; + let endpoint = Self::owned_endpoint(components)?; + let (qualification, version) = self + .0 + .trust + .connection_qualification(components, qualifications)?; + Ok((endpoint, qualification, version)) + } + fn owned_endpoint(components: &OpsComponents) -> Result { + if components.manager_ownership != Some(OpsManagerOwnership::Owned) + || components.manager != Some(OpsManagerState::Active) + || components.registration != Some(OpsRegistrationState::Installed) + || components.definition != Some(OpsDefinitionState::Current) + || components.configuration != Some(OpsConfigurationState::Valid) + || components.liveness != Some(OpsLivenessState::Live) + || components.resources != Some(OpsRuntimeResources::Present) + || components.installation_id.is_none() + { + return Err(OpsBridgeError::Unavailable); + } + components + .endpoint + .clone() + .ok_or(OpsBridgeError::Unavailable) + } pub fn config_validate(&self) -> OpsValidationReply { match self .read(OpsAction::ConfigValidate) @@ -199,7 +240,27 @@ impl OpsHost { if !(1..64).contains(&resume.expected_attempt) { return Err(OpsBridgeError::InvalidInput); } - self.require_reference(&resume.operation_id, &binding.fingerprint)?; + let references = self + .0 + .references + .lock() + .map_err(|_| OpsBridgeError::Storage)? + .read()?; + let reference = references + .iter() + .find(|r| r.operation_id == resume.operation_id) + .ok_or(OpsBridgeError::UnknownOperation)?; + if reference.binding_id != binding.fingerprint { + return Err(OpsBridgeError::ForeignBinding); + } + let (Some(operation), Some(options)) = + (reference.operation, reference.options.as_ref()) + else { + return Err(OpsBridgeError::UnknownOperation); + }; + if operation != input.operation || options.wire_value() != input.options.wire_value() { + return Err(OpsBridgeError::InvalidInput); + } } let body = request( OpsAction::Preflight, @@ -265,6 +326,8 @@ impl OpsHost { id, Approval { plan: plan.clone(), + operation: input.operation, + options: input.options, binding: binding.fingerprint, resume: input.resume, created: Instant::now(), @@ -301,6 +364,7 @@ impl OpsHost { if Uuid::parse_str(&input.approval_id).is_err() || !trust::is_digest(&input.plan_id) { return Err(OpsBridgeError::InvalidInput); } + let approval_id = input.approval_id.clone(); let action = if resume { OpsAction::Resume } else { @@ -330,17 +394,25 @@ impl OpsHost { if resume { self.require_reference(&id, &binding.fingerprint)?; } - // The reference is durable before any possibility of dispatch. It is not an outcome. + // Metadata identifies the approved action for recovery; only Ops owns its outcome. self.0 .references .lock() .map_err(|_| OpsBridgeError::Storage)? - .add(&id, &binding.fingerprint)?; + .add( + &id, + &binding.fingerprint, + approval.operation, + approval.options, + )?; let mut fields = json!({"operation_id":id, "plan":approval.plan, "authorization":{"plan_id":input.plan_id,"approved":true}}); if let Some(reference) = approval.resume { fields["expected_attempt"] = reference.expected_attempt.into(); } - self.operation(&binding, action, id, fields, guard, emit) + self.operation(&binding, action, id, fields, guard, move |mut progress| { + progress.approval_id = Some(approval_id.clone()); + emit(progress); + }) } pub fn operation_status( &self, diff --git a/desktop/src-tauri/src/ops_bridge/tests.rs b/desktop/src-tauri/src/ops_bridge/tests.rs index bb680e1dbb..f27da5eb7d 100644 --- a/desktop/src-tauri/src/ops_bridge/tests.rs +++ b/desktop/src-tauri/src/ops_bridge/tests.rs @@ -931,3 +931,148 @@ fn actual_independent_bundle_runs_through_native_trust_and_read_only_bridge() { Some(OpsBridgeError::IntegrityFailure) ); } + +#[test] +fn reopening_preserves_approved_action_and_rejects_different_recovery_options() { + let f = Fixture::new(); + let host = f.host(); + let approval = f.approve(&host); + let approval_id = approval.approval_id.clone(); + let (send, receive) = mpsc::channel(); + let result = host + .apply(approval, move |event| { + let _ = send.send(event); + }) + .unwrap(); + let progress = receive.recv_timeout(Duration::from_secs(3)).unwrap(); + assert_eq!(progress.approval_id.as_deref(), Some(approval_id.as_str())); + let reopened = f.host(); + let references = reopened.references().unwrap(); + let reference = &references.operations[0]; + assert_eq!(reference.operation_id, result.operation_id); + assert_eq!(reference.operation, Some(OpsOperation::RemoveRegistration)); + assert_eq!(reference.options.as_ref().unwrap().wire_value(), json!({})); + assert!(matches!( + reopened.preflight(OpsPreflightInput { + operation: OpsOperation::Register, + options: OpsOptions { + start_on_login: Some(false), + ..Default::default() + }, + resume: Some(OpsResumeReference { + operation_id: result.operation_id, + expected_attempt: 1 + }) + }), + Err(OpsBridgeError::InvalidInput) + )); +} + +#[test] +fn legacy_operation_references_remain_queryable_without_inventing_an_action() { + let f = Fixture::new(); + let host = f.host(); + let result = host.apply(f.approve(&host), |_| {}).unwrap(); + let path = f.support.join("bridge-operations.json"); + let mut document: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + for field in ["operation", "options"] { + document["operations"][0] + .as_object_mut() + .unwrap() + .remove(field); + } + fs::write(&path, serde_json::to_vec(&document).unwrap()).unwrap(); + let reopened = f.host(); + assert!( + reopened.references().unwrap().operations[0] + .operation + .is_none() + ); + assert_eq!( + reopened + .operation_status(OpsStatusInput { + operation_id: result.operation_id + }) + .unwrap() + .outcome, + OpsOutcome::Succeeded + ); +} + +#[test] +fn local_handoff_requires_independent_positive_ownership_and_service_facts() { + let mut components: OpsComponents = serde_json::from_value(json!({ + "managerOwnership":"owned", "manager":"active", "registration":"installed", "definition":"current", + "configuration":"valid", "liveness":"live", "resources":"present", "installationId":"personal-a", + "endpoint":"http://127.0.0.1:8123" + })).unwrap(); + assert_eq!( + OpsHost::owned_endpoint(&components).unwrap(), + "http://127.0.0.1:8123" + ); + components.manager_ownership = Some(OpsManagerOwnership::Foreign); + assert!(OpsHost::owned_endpoint(&components).is_err()); + components.manager_ownership = Some(OpsManagerOwnership::Owned); + components.resources = Some(OpsRuntimeResources::MissingExecutable); + assert!(OpsHost::owned_endpoint(&components).is_err()); + components.resources = Some(OpsRuntimeResources::Present); + components.installation_id = None; + assert!(OpsHost::owned_endpoint(&components).is_err()); +} + +#[test] +fn local_qualification_is_bound_to_the_pinned_catalog_and_observed_resource_digest() { + let f = Fixture::new(); + // This valid catalog omits ResourceFile.executable=false. Python's ResourceCatalog accepts it. + let catalog = include_bytes!("../../tests/fixtures/local-setup-catalog.json").to_vec(); + let digest = "1a41e4f4b604815d6b6ed2a76679786a1f5795aed2549542aeeb643094f12c7e"; + fs::create_dir_all(f.resources.join("server")).unwrap(); + fs::write(f.resources.join("server/catalog.json"), &catalog).unwrap(); + let provision = f.resources.join("ops-provision.json"); + let mut value: Value = serde_json::from_slice(&fs::read(&provision).unwrap()).unwrap(); + value["binding_mode"] = "managed_catalog".into(); + value["catalog_sha256"] = trust::sha256(&catalog).into(); + fs::write(provision, serde_json::to_vec(&value).unwrap()).unwrap(); + let mut components: OpsComponents = + serde_json::from_value(json!({"resourceId":"resource-a", "resourceDigest":digest})) + .unwrap(); + let host = f.host(); + let mut qualifications = vec![crate::connections::session::CompatibilityProfile { + id: "qualified-profile-a".into(), + server_commit: "d".repeat(40), + artifact_sha256: "e".repeat(64), + contract_sha256: "synthetic-fixture".into(), + operations: vec![], + evidence: "Synthetic catalog identity only; not platform qualification".into(), + }]; + assert_eq!( + host.0 + .trust + .connection_qualification(&components, &qualifications) + .unwrap(), + ("qualified-profile-a".into(), "1.4.2".into()) + ); + qualifications[0].artifact_sha256 = "f".repeat(64); + assert!(matches!( + host.0 + .trust + .connection_qualification(&components, &qualifications), + Err(OpsBridgeError::IntegrityFailure) + )); + qualifications[0].artifact_sha256 = "e".repeat(64); + qualifications[0].server_commit = "f".repeat(40); + assert!(matches!( + host.0 + .trust + .connection_qualification(&components, &qualifications), + Err(OpsBridgeError::IntegrityFailure) + )); + qualifications[0].server_commit = "d".repeat(40); + components.resource_digest = Some("a".repeat(64)); + assert!(matches!( + host.0 + .trust + .connection_qualification(&components, &qualifications), + Err(OpsBridgeError::IntegrityFailure) + )); +} diff --git a/desktop/src-tauri/src/ops_bridge/trust.rs b/desktop/src-tauri/src/ops_bridge/trust.rs index 10f934514a..c3513b3aa4 100644 --- a/desktop/src-tauri/src/ops_bridge/trust.rs +++ b/desktop/src-tauri/src/ops_bridge/trust.rs @@ -159,6 +159,65 @@ impl Trust { digest, }) } + pub fn connection_qualification( + &self, + components: &super::OpsComponents, + qualifications: &[crate::connections::session::CompatibilityProfile], + ) -> Result<(String, String), Error> { + let binding = self.binding()?; + if binding.catalog_digest.is_none() { + return Err(Error::Unavailable); + } + let catalog: serde_json::Value = + serde_json::from_slice(&binding.bytes).map_err(|_| Error::IntegrityFailure)?; + let resource = catalog["resources"] + .as_array() + .ok_or(Error::IntegrityFailure)? + .iter() + .find(|resource| resource["resource_id"].as_str() == components.resource_id.as_deref()) + .ok_or(Error::IntegrityFailure)?; + let mut canonical = resource.clone(); + // Match ResourceSpec.model_dump: non-executable file entries may omit their false default. + if let Some(files) = canonical["files"].as_array_mut() { + for file in files { + file.as_object_mut() + .ok_or(Error::IntegrityFailure)? + .entry("executable") + .or_insert(serde_json::Value::Bool(false)); + } + } + canonical.sort_all_objects(); + if Some( + sha256(&serde_json::to_vec(&canonical).map_err(|_| Error::IntegrityFailure)?).as_str(), + ) != components.resource_digest.as_deref() + { + return Err(Error::IntegrityFailure); + } + let qualification = resource["qualification_ref"] + .as_str() + .ok_or(Error::IntegrityFailure)?; + let version = resource["server_version"] + .as_str() + .ok_or(Error::IntegrityFailure)?; + if let Some(record) = qualifications + .iter() + .find(|record| record.id == qualification) + { + let sources = resource["source_refs"] + .as_array() + .ok_or(Error::IntegrityFailure)?; + let commit_suffix = format!("@{}", record.server_commit); + let wheel = format!("wheel-sha256:{}", record.artifact_sha256); + let source_matches = sources + .iter() + .filter_map(serde_json::Value::as_str) + .any(|source| source.starts_with("git+") && source.ends_with(&commit_suffix)); + if !source_matches || !sources.iter().any(|source| source.as_str() == Some(&wheel)) { + return Err(Error::IntegrityFailure); + } + } + Ok((qualification.to_owned(), version.to_owned())) + } pub fn launch(&self, binding: &Binding) -> Result { if self.binding()?.fingerprint != binding.fingerprint { return Err(Error::StaleApproval); @@ -588,6 +647,10 @@ struct Document { pub(super) struct Reference { pub operation_id: String, pub binding_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, } pub(super) struct References { support: Option, @@ -613,7 +676,11 @@ impl References { } let mut ids = BTreeSet::new(); for r in &d.operations { - if wire::identifier(&r.operation_id).is_err() + if match (r.operation, &r.options) { + (Some(operation), Some(options)) => options.validate(operation).is_err(), + (None, None) => false, + _ => true, + } || wire::identifier(&r.operation_id).is_err() || !is_digest(&r.binding_id) || !ids.insert(&r.operation_id) { @@ -629,14 +696,26 @@ impl References { None => Err(Error::UnknownOperation), } } - pub fn add(&self, id: &str, binding: &str) -> Result<(), Error> { + pub fn add( + &self, + id: &str, + binding: &str, + operation: super::OpsOperation, + options: super::OpsOptions, + ) -> Result<(), Error> { let dir = self.support.as_ref().ok_or(Error::Storage)?; let mut operations = self.read()?; if let Some(r) = operations.iter().find(|r| r.operation_id == id) { - return if r.binding_id == binding { + if r.binding_id != binding { + return Err(Error::ForeignBinding); + } + return if r.operation == Some(operation) + && r.options.as_ref().map(super::OpsOptions::wire_value) + == Some(options.wire_value()) + { Ok(()) } else { - Err(Error::ForeignBinding) + Err(Error::InvalidInput) }; } if operations.len() >= 4096 { @@ -645,6 +724,8 @@ impl References { operations.push(Reference { operation_id: id.into(), binding_id: binding.into(), + operation: Some(operation), + options: Some(options), }); atomic( dir, diff --git a/desktop/src-tauri/src/ops_bridge/types.rs b/desktop/src-tauri/src/ops_bridge/types.rs index fe8225437c..adbf24c8e0 100644 --- a/desktop/src-tauri/src/ops_bridge/types.rs +++ b/desktop/src-tauri/src/ops_bridge/types.rs @@ -282,6 +282,12 @@ pub struct OpsCancelInput { pub struct OpsOperationReference { pub operation_id: String, pub binding_status: OpsReferenceBindingStatus, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub operation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub options: Option, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] @@ -296,6 +302,7 @@ pub enum OpsReferenceBindingStatus { #[serde(rename_all = "camelCase")] pub struct OpsReferences { pub binding_available: bool, + pub login_start_choice: bool, pub operations: Vec, } @@ -523,6 +530,9 @@ status_enum!(OpsRecoveryAction { Inspect => "inspect", Preflight => "preflight", #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase")] pub struct OpsProgress { + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub approval_id: Option, pub request_id: String, pub operation_id: String, pub attempt: u32, diff --git a/desktop/src-tauri/src/ops_bridge/wire.rs b/desktop/src-tauri/src/ops_bridge/wire.rs index 78bb833398..25a8dbd322 100644 --- a/desktop/src-tauri/src/ops_bridge/wire.rs +++ b/desktop/src-tauri/src/ops_bridge/wire.rs @@ -635,6 +635,7 @@ impl Stream { (Some(sequence), Some(phase)) }; Ok(Some(OpsProgress { + approval_id: None, request_id: id(&v["request_id"])?, operation_id: id(&v["operation_id"])?, attempt: number, diff --git a/desktop/src-tauri/tests/discovery.rs b/desktop/src-tauri/tests/discovery.rs index 611839cf10..51931de52f 100644 --- a/desktop/src-tauri/tests/discovery.rs +++ b/desktop/src-tauri/tests/discovery.rs @@ -27,11 +27,12 @@ use powercontext_desktop::{ use serde_json::{Value, json}; use std::sync::{ Arc, - atomic::{AtomicU16, Ordering}, + atomic::{AtomicBool, AtomicU16, Ordering}, }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::TcpListener, + sync::Notify, }; fn document() -> Value { @@ -46,6 +47,11 @@ struct Fixture { manager: ConnectionManager, id: String, status: Arc, + scope_status: Arc, + hold_scope: Arc, + change_access_on_exact: Arc, + scope_started: Arc, + scope_release: Arc, server: tokio::task::JoinHandle<()>, _directory: tempfile::TempDir, } @@ -59,11 +65,32 @@ async fn fixture(status: u16, document: Value, qualified: bool) -> Fixture { let endpoint = format!("http://{}/prefix", listener.local_addr().unwrap()); let status = Arc::new(AtomicU16::new(status)); let response_status = status.clone(); + let scope_status = Arc::new(AtomicU16::new(200)); + let hold_scope = Arc::new(AtomicBool::new(false)); + let change_access_on_exact = Arc::new(AtomicBool::new(false)); + let access_enabled = Arc::new(AtomicBool::new(false)); + let change_access = change_access_on_exact.clone(); + let scope_started = Arc::new(Notify::new()); + let scope_release = Arc::new(Notify::new()); + let (scope_reply, scope_hold, started, release) = ( + scope_status.clone(), + hold_scope.clone(), + scope_started.clone(), + scope_release.clone(), + ); let server = tokio::spawn(async move { loop { let (mut socket, _) = listener.accept().await.unwrap(); let document = document.clone(); let status = response_status.load(Ordering::Relaxed); + let (scope_status, hold_scope, scope_started, scope_release) = ( + scope_reply.clone(), + scope_hold.clone(), + started.clone(), + release.clone(), + ); + let change_access = change_access.clone(); + let access_enabled = access_enabled.clone(); tokio::spawn(async move { let mut bytes = Vec::new(); loop { @@ -82,14 +109,30 @@ async fn fixture(status: u16, document: Value, qualified: bool) -> Fixture { if status == 0 && path == "/prefix/v1/server-info" { return; } + if path.starts_with("/prefix/v1/scopes/") && hold_scope.load(Ordering::Relaxed) { + scope_started.notify_one(); + scope_release.notified().await; + } + if path == "/prefix/v1/scopes/default-exact" + && change_access.load(Ordering::Relaxed) + { + access_enabled.store(true, Ordering::Relaxed); + } let (status, body) = match path { "/prefix/v1/server-info" => (status, document), "/prefix/health/ready" => ( 200, - json!({"status":"ready","checks":{"access_mode":"disabled"}}), + json!({"status":"ready","checks":{"access_mode":if access_enabled.load(Ordering::Relaxed) {"enabled"} else {"disabled"}}}), ), "/prefix/v1/access/me" => (503, json!({"error":{"code":"runtime_not_ready"}})), "/prefix/v1/capabilities" => (403, json!({})), + "/prefix/v1/scopes/default" | "/prefix/v1/scopes/default-exact" => ( + scope_status.load(Ordering::Relaxed), + json!({ + "scope_id":"default-exact", "title":"Personal", "summary":"", "context_references":[], + "external_references":[], "version":1 + }), + ), path if path.starts_with("/prefix/v1/scopes?") => { (200, json!({"items":[],"next_cursor":null})) } @@ -122,6 +165,11 @@ async fn fixture(status: u16, document: Value, qualified: bool) -> Fixture { manager, id: state.profiles[0].id.clone(), status, + scope_status, + hold_scope, + change_access_on_exact, + scope_started, + scope_release, server, _directory: directory, } @@ -321,3 +369,233 @@ async fn optional_feature_and_operation_restrictions_do_not_disable_unrelated_sc ); } } + +#[tokio::test] +async fn explicit_local_handoff_resolves_the_public_default_scope_and_reuses_its_profile() { + let f = fixture(200, document(), true).await; + let endpoint = f.manager.state().unwrap().profiles[0].endpoint.clone(); + f.manager.remove_profile(&f.id, 1).unwrap(); + let before = f.manager.state().unwrap(); + let selected = f + .manager + .connect_local( + before.generation, + endpoint.clone(), + common::PROFILE_ID.into(), + "display-only-version".into(), + ) + .await + .unwrap(); + assert_eq!( + selected + .active + .as_ref() + .unwrap() + .scope + .as_ref() + .unwrap() + .scope_id, + "default-exact" + ); + assert_eq!(selected.profiles.len(), 1); + let id = selected.active.as_ref().unwrap().connection_id.clone(); + let again = f + .manager + .connect_local( + selected.generation, + endpoint.clone(), + common::PROFILE_ID.into(), + "display-only-version".into(), + ) + .await + .unwrap(); + assert_eq!(again.profiles.len(), 1); + assert_eq!(again.active.as_ref().unwrap().connection_id, id); + assert_eq!( + f.manager + .connect_local( + before.generation, + endpoint, + common::PROFILE_ID.into(), + "display-only-version".into() + ) + .await + .err() + .unwrap() + .code, + SafeError::StaleContext + ); + assert_eq!(f.manager.state().unwrap().generation, again.generation); +} + +#[tokio::test] +async fn local_handoff_cannot_select_a_scope_without_qualified_discovery() { + let f = fixture(503, document(), true).await; + let before = f.manager.state().unwrap(); + assert_eq!( + f.manager + .connect_local( + before.generation, + before.profiles[0].endpoint.clone(), + common::PROFILE_ID.into(), + "display-only-version".into() + ) + .await + .err() + .unwrap() + .code, + SafeError::CompatibilityUnverified + ); + assert!(f.manager.state().unwrap().active.is_none()); +} + +#[tokio::test] +async fn a_different_server_version_cannot_leave_an_activated_local_context() { + let f = fixture(200, document(), true).await; + let before = f.manager.state().unwrap(); + assert_eq!( + f.manager + .connect_local( + before.generation, + before.profiles[0].endpoint.clone(), + common::PROFILE_ID.into(), + "different-resource-version".into() + ) + .await + .err() + .unwrap() + .code, + SafeError::CompatibilityUnverified + ); + assert!(f.manager.state().unwrap().active.is_none()); +} + +#[tokio::test] +async fn local_handoff_requires_the_complete_basic_memory_operation_set() { + let mut info = document(); + info["feature_contracts"]["memory.explicit"]["operations"] = json!(["search_memory"]); + let f = fixture(200, info, true).await; + let before = f.manager.state().unwrap(); + assert_eq!( + f.manager + .connect_local( + before.generation, + before.profiles[0].endpoint.clone(), + common::PROFILE_ID.into(), + "display-only-version".into() + ) + .await + .err() + .unwrap() + .code, + SafeError::CompatibilityUnverified + ); + assert!(f.manager.state().unwrap().active.is_none()); +} + +#[tokio::test] +async fn a_pending_or_failed_default_scope_never_publishes_a_local_activation() { + let f = fixture(200, document(), true).await; + f.hold_scope.store(true, Ordering::Relaxed); + let before = f.manager.state().unwrap(); + let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(5), async { + tokio::join!( + f.manager.connect_local( + before.generation, + before.profiles[0].endpoint.clone(), + common::PROFILE_ID.into(), + "display-only-version".into() + ), + async { + f.scope_started.notified().await; + assert!(f.manager.state().unwrap().active.is_none()); + f.scope_status.store(403, Ordering::Relaxed); + f.hold_scope.store(false, Ordering::Relaxed); + f.scope_release.notify_one(); + } + ) + }) + .await + .unwrap(); + assert_eq!(result.err().unwrap().code, SafeError::Forbidden); + let state = f.manager.state().unwrap(); + assert!(state.active.is_none()); + assert!(state.reports.is_empty()); +} + +#[tokio::test] +async fn ordinary_refresh_reauthorizes_the_local_default_scope_and_drops_revoked_selection() { + let f = fixture(200, document(), true).await; + let before = f.manager.state().unwrap(); + let selected = f + .manager + .connect_local( + before.generation, + before.profiles[0].endpoint.clone(), + common::PROFILE_ID.into(), + "display-only-version".into(), + ) + .await + .unwrap(); + let id = selected.active.as_ref().unwrap().connection_id.clone(); + let refreshed = f.manager.check(&id, false).await.unwrap(); + assert_eq!( + refreshed.active.unwrap().scope.unwrap().scope_id, + "default-exact" + ); + f.scope_status.store(403, Ordering::Relaxed); + let revoked = f.manager.check(&id, false).await.unwrap(); + assert!(revoked.active.unwrap().scope.is_none()); +} + +#[tokio::test] +async fn published_local_scope_is_newer_than_pending_activation_snapshots() { + let f = fixture(200, document(), true).await; + f.hold_scope.store(true, Ordering::Relaxed); + let before = f.manager.state().unwrap(); + let (result, pending_generation) = + tokio::time::timeout(std::time::Duration::from_secs(5), async { + tokio::join!( + f.manager.connect_local( + before.generation, + before.profiles[0].endpoint.clone(), + common::PROFILE_ID.into(), + "display-only-version".into() + ), + async { + f.scope_started.notified().await; + let pending = f.manager.state().unwrap(); + assert!(pending.active.is_none()); + f.hold_scope.store(false, Ordering::Relaxed); + f.scope_release.notify_one(); + pending.generation + } + ) + }) + .await + .unwrap(); + let published = result.unwrap(); + assert!(published.generation > pending_generation); + assert_eq!( + published.active.unwrap().scope.unwrap().scope_id, + "default-exact" + ); +} + +#[tokio::test] +async fn local_handoff_rejects_identity_changes_during_exact_scope_resolution() { + let f = fixture(200, document(), true).await; + f.change_access_on_exact.store(true, Ordering::Relaxed); + let before = f.manager.state().unwrap(); + let result = f + .manager + .connect_local( + before.generation, + before.profiles[0].endpoint.clone(), + common::PROFILE_ID.into(), + "display-only-version".into(), + ) + .await; + assert_eq!(result.err().unwrap().code, SafeError::RuntimeNotReady); + assert!(f.manager.state().unwrap().active.is_none()); +} diff --git a/desktop/src-tauri/tests/fixtures/local-setup-catalog.json b/desktop/src-tauri/tests/fixtures/local-setup-catalog.json new file mode 100644 index 0000000000..c7ecca3c23 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/local-setup-catalog.json @@ -0,0 +1,44 @@ +{ + "format_version": 1, + "resources": [ + { + "resource_id": "resource-a", + "server_version": "1.4.2", + "python_version": "3.12.15", + "platform": "macos", + "architecture": "arm64", + "archive_format": "tar.gz", + "url": "https://example.invalid/server.tar.gz", + "allowed_origins": [ + "https://example.invalid" + ], + "archive_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "archive_size": 2, + "unpacked_size": 2, + "python_relative": "bin/python3", + "ops_protocol_min": 1, + "ops_protocol_max": 1, + "source_refs": [ + "git+https://example.invalid/repository@dddddddddddddddddddddddddddddddddddddddd", + "wheel-sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ], + "license_refs": [ + "synthetic-license" + ], + "qualification_ref": "qualified-profile-a", + "files": [ + { + "path": "bin/python3", + "size": 1, + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "executable": true + }, + { + "path": "README", + "size": 1, + "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + ] + } + ] +} diff --git a/desktop/ui/src/app/App.tsx b/desktop/ui/src/app/App.tsx index 241d5b95fa..1c1d4759a4 100644 --- a/desktop/ui/src/app/App.tsx +++ b/desktop/ui/src/app/App.tsx @@ -25,6 +25,8 @@ import memoryIcon from "../assets/memory.svg"; import settingsIcon from "../assets/settings.svg"; import observationIcon from "../assets/retrieval.svg"; +import { useLocalServer } from "./useLocalServer"; +import { opsApi } from "../shared/ops"; import { TopBar } from "./TopBar"; import { Overview } from "./Overview"; import { MemoryWorkspace } from "./MemoryWorkspace"; @@ -43,6 +45,8 @@ import { useConfirmation } from "./useConfirmation"; type Page = "home" | "connections" | "memories" | "observation" | "settings"; type Theme = "light" | "dark" | "system"; export function App() { + const localServer = useLocalServer(); + const [connectingLocal, setConnectingLocal] = useState(false); const [language, setLanguage] = useState("zh"); const [theme, setTheme] = useState("system"); const [page, setPage] = useState("home"); @@ -73,10 +77,12 @@ export function App() { // Pending operations may finish after their editor has unmounted. setPendingConnections((current) => current + (pending ? 1 : -1)); } + const acceptedGeneration = useRef(-1); function receiveState(next: DesktopState) { - setDesktop((current) => - !current || next.generation >= current.generation ? next : current, - ); + if (next.generation < acceptedGeneration.current) return false; + acceptedGeneration.current = next.generation; + setDesktop(next); + return true; } const { confirm, confirmation } = useConfirmation(language); function confirmSwitch() { @@ -167,6 +173,11 @@ export function App() { ); } catch (e) { setNativeError(e); + try { + receiveState(await desktopApi.state()); + } catch { + /* Preserve the original check failure. */ + } } finally { connectionPending(false); setRefreshing(false); @@ -308,6 +319,17 @@ export function App() { )} + {page === "home" && !desktop?.active && ( +

+ +
+ )} {page === "home" && (
{ + if ( + activeProfile && + plan.target.endpoint && + new URL(activeProfile.endpoint).origin === + new URL(plan.target.endpoint).origin + ) { + if (!(await confirmSwitch())) return false; + try { + receiveState( + await desktopApi.invalidate(activeProfile.id), + ); + setDirty(false); + } catch (error) { + setNativeError(error); + return false; + } + } + return true; + }} + onConnect={async () => { + if (!desktop || connectingLocal || !(await confirmSwitch())) + return; + setConnectingLocal(true); + connectionPending(true); + setNativeError(null); + try { + const next = await opsApi.connect(desktop.generation); + const accepted = receiveState(next); + if (accepted && next.active?.scope) { + setDirty(false); + setPage("memories"); + } + } catch (error) { + setNativeError(error); + try { + receiveState(await desktopApi.state()); + } catch { + /* Keep the connection error visible. */ + } + } finally { + setConnectingLocal(false); + connectionPending(false); + } + }} language={language} headingRef={localServerHeading} onManageConnections={() => navigate("connections")} diff --git a/desktop/ui/src/app/Diagnostics.tsx b/desktop/ui/src/app/Diagnostics.tsx index 884030abd9..a8c6834432 100644 --- a/desktop/ui/src/app/Diagnostics.tsx +++ b/desktop/ui/src/app/Diagnostics.tsx @@ -79,7 +79,7 @@ export function Diagnostics({ language }: { language: Language }) { } const code = typeof error === "string" ? error : null; return ( -
+

{zh ? "本地诊断" : "Local diagnostics"}

{zh diff --git a/desktop/ui/src/app/LocalServerSettings.tsx b/desktop/ui/src/app/LocalServerSettings.tsx index e741bb162c..20488cdd23 100644 --- a/desktop/ui/src/app/LocalServerSettings.tsx +++ b/desktop/ui/src/app/LocalServerSettings.tsx @@ -14,48 +14,416 @@ * limitations under the License. */ -import type { Ref } from "react"; -import { messages, type Language } from "./messages"; +import { useRef, useState, type Ref } from "react"; +import type { + OpsOperation, + OpsOptions, + OpsPlanApproval, +} from "../generated/ipc"; +import type { LocalServerController } from "./useLocalServer"; +import type { Language } from "./messages"; +import { localText, localError } from "./local-server-messages"; import { PageFeedback } from "./PageFeedback"; +import { Modal } from "./Modal"; +import { useConfirmation } from "./useConfirmation"; export function LocalServerSettings({ language, headingRef, + controller: c, onManageConnections, + onConnect, + onBeforeApply, + connecting = false, + connectionAvailable = true, }: { language: Language; headingRef?: Ref; + controller: LocalServerController; onManageConnections: () => void; + onConnect: () => Promise; + onBeforeApply: (plan: OpsPlanApproval) => Promise; + connecting?: boolean; + connectionAvailable?: boolean; }) { - const t = messages[language]; + const t = (key: string) => localText(language, key); + const [port, setPort] = useState("8000"); + const [login, setLogin] = useState(true); + const trigger = useRef(null); + const approving = useRef(false); + const { confirm, confirmation } = useConfirmation(language); + const facts = c.inspection?.components; + const available = + c.inspection?.outcome === "succeeded" && c.references?.bindingAvailable; + const missingResources = + c.inspection?.bridgeError === "unavailable" || + (c.references?.bindingAvailable === false && + (!c.inspection || c.inspection.outcome === "succeeded")); + const disabled = !!c.busy || connecting || !available || c.loading; + const status = c.record?.status; + const running = c.busy === "execute"; + const cancelAvailable = + (running && !!c.progress?.attempt) || + (status?.outcome === "pending" && status.executionState !== "interrupted"); + const unresolved = + c.record && ["pending", "unknown", "uncertain"].includes(c.record.outcome); + const portValid = + /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535; + function preview(operation: OpsOperation, button: HTMLElement) { + trigger.current = button; + const options: OpsOptions = {}; + if (operation === "install") { + options.httpPort = Number(port); + options.startOnLogin = login; + } else if (operation === "register") { + options.startOnLogin = login; + } + void c.preflight({ operation, options }); + } return (

- {t.localServer} + {t("title")}

-

{t.localServerIntro}

- -

{t.localServerUnavailableHint}

-
-

{t.prepareServer}

-
    -
  1. {t.prepareServerDocs}
  2. -
  3. {t.prepareServerAddress}
  4. -
  5. {t.prepareServerConnect}
  6. -
+

{t("intro")}

- + +
-

{t.localServerDiagnostics}

+ {c.loading && ( + + )} + {!c.loading && missingResources && ( + +

{t("unavailableHint")}

+
+ )} + {c.error && } + {!missingResources && + (c.inspection?.error || c.inspection?.bridgeError) && ( + + )} + {facts && ( +
+ {( + [ + "resources", + "configuration", + "registration", + "managerOwnership", + "manager", + "liveness", + "readiness", + "database", + "installationStage", + ] as const + ).map((key) => ( +
+
{t(key)}
+
{t(facts[key] ?? "unknown")}
+
+ ))} + {facts.endpoint && ( +
+
{t("endpoint")}
+
{facts.endpoint}
+
+ )} +
+ )} + {facts?.managerOwnership === "foreign" && ( +

{t("ownership_conflict")}

+ )} +

{t("policy")}

+
+ {t("preview")} + + setPort(event.target.value)} + /> + +
+ {( + [ + "install", + "repair", + "start", + "stop", + "restart", + "register", + "remove_registration", + ] as const + ).map((operation) => ( + + ))} +
+
+ {c.busy && ( + + {running && ( + <> +

{t(c.progress?.phase ?? "pending")}

+ {!c.listening &&

{t("progressMissing")}

} + + )} +
+ )} +
+ + {t("diagnostics")} +
+

{t("independent")}

+

{t("preservation")}

+

{t("history")}

+ {c.references?.operations.length ? ( + <> + + + {c.reference?.bindingStatus !== "current" && ( +

+ {t( + c.reference?.bindingStatus === "different" + ? "different" + : "unavailableHint", + )} +

+ )} + {c.reference && !c.reference.operation &&

{t("legacyHint")}

} + + ) : ( +

{t("none")}

+ )} + {c.selected && ( +

+ {c.selected} +

+ )} + {c.record && ( + + {c.record.attempt && ( +

+ {t("attempt")}: {c.record.attempt} +

+ )} + {status &&

{t(status.executionState)}

} + {!!unresolved &&

{t("uncertainHint")}

} + {status?.cancelRequested &&

{t("cancelHint")}

} + {(c.record.error || + c.record.bridgeError || + status?.result?.error) && ( +

+ {t( + localError( + c.record.error ?? + c.record.bridgeError ?? + status?.result?.error, + ), + )} +

+ )} + {status && ( +
    + {status.history.map((attempt) => ( +
  1. + {attempt.attempt}: {t(attempt.outcome ?? "pending")} +
  2. + ))} +
+ )} +
+ )} +
+ + + +
+

{t("recoveryHint")}

+ {c.approval && ( + trigger.current} + > +
+

{t("plan")}

+

{t(c.approval.plan.operation)}

+
+ {( + [ + ["version", c.approval.plan.target.packageVersion], + ["program", c.approval.plan.target.pythonExecutable], + ["configPath", c.approval.plan.target.envFile], + ["dataPath", c.approval.plan.target.dataDir], + ["endpoint", c.approval.plan.target.endpoint], + ] as const + ).map(([key, value]) => ( +
+
{t(key)}
+
{value ?? t("unknown")}
+
+ ))} +
+
{t("login")}
+
+ {c.approval.plan.target.startOnLogin ? t("yes") : t("no")} +
+
+ {c.approval.plan.installation && ( + <> +
+
{t("installationOwnership")}
+
+ {t( + `classification_${c.approval.plan.installation.classification}`, + )} +
+
+
+
{t("database")}
+
{t(c.approval.plan.installation.database)}
+
+
+
{t("resources")}
+
{t(c.approval.plan.installation.resources)}
+
+
+
{t("space")}
+
{c.approval.plan.installation.spaceRequired}
+
+ + )} +
+

{t(c.approval.plan.effects.mayStart ? "starts" : "noStart")}

+

{t("preservation")}

+

{t("expiry")}

+
+ + +
+
+
+ )} + {confirmation}
); } diff --git a/desktop/ui/src/app/connection-messages.ts b/desktop/ui/src/app/connection-messages.ts index ca7a662185..c923b2138f 100644 --- a/desktop/ui/src/app/connection-messages.ts +++ b/desktop/ui/src/app/connection-messages.ts @@ -132,6 +132,10 @@ export const connectionMessages = { unauthorized: "Server 要求有效凭据。", forbidden: "当前身份无权执行此操作。", authentication_unavailable: "认证服务暂时不可用;不会改用匿名身份重试。", + integrity_failure: + "可信资源校验失败。请核对发行包和固定资源,不能跳过校验执行。", + process_failure: + "本机辅助进程未完成请求。请检查本机状态和持久操作结果,不会自动重放。", runtime_not_ready: "服务未提供此项运行能力。", tls: "证书或主机名验证失败。", redirect: "服务返回重定向,桌面未跟随。", @@ -287,6 +291,10 @@ export const connectionMessages = { forbidden: "This identity cannot perform this operation.", authentication_unavailable: "Authentication is temporarily unavailable. No anonymous retry was attempted.", + integrity_failure: + "Trusted resource verification failed. Check the distribution package and pinned resources; verification cannot be bypassed.", + process_failure: + "The local helper did not complete the request. Inspect local state and durable operation results; work is not replayed automatically.", runtime_not_ready: "The Server does not provide this runtime capability.", tls: "Certificate or hostname verification failed.", redirect: "The Server returned a redirect; Desktop did not follow it.", diff --git a/desktop/ui/src/app/local-server-messages.ts b/desktop/ui/src/app/local-server-messages.ts new file mode 100644 index 0000000000..e46122fe79 --- /dev/null +++ b/desktop/ui/src/app/local-server-messages.ts @@ -0,0 +1,355 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Language } from "./messages"; +const text: Record = { + state: ["本机状态", "Local state"], + attempts: ["执行尝试历史", "Attempt history"], + yes: ["是", "Yes"], + no: ["否", "No"], + diagnostics: ["本机诊断", "Local diagnostics"], + request_failed: [ + "请求未完成。请检查本机状态和原操作结果,再选择受支持的下一步。", + "The request did not complete. Inspect local state and the original operation result before choosing a supported next step.", + ], + timeout: [ + "请求超时。若已提交执行,请先查询持久操作结果,不要重复执行。", + "The request timed out. If execution was submitted, query its durable result before retrying.", + ], + integrity_failure: [ + "可信资源校验失败。请核对发行包和固定资源,不能跳过校验执行。", + "Trusted resource verification failed. Check the distribution package and pinned resources; verification cannot be bypassed.", + ], + configuration_error: [ + "配置缺失、无效或冲突。请检查已有配置并使用支持的修复流程,保留原配置和数据。", + "Configuration is missing, invalid or conflicting. Inspect it and use supported repair while preserving configuration and data.", + ], + runtime_error: [ + "运行环境缺失或损坏。请检查实际状态并预检修复计划,保留用户数据。", + "The runtime is missing or damaged. Inspect actual state and review a repair plan while retaining user data.", + ], + registration_missing: [ + "服务尚未注册。请检查实际安装后预检注册或安装计划。", + "The service is not registered. Inspect the installation before reviewing registration or installation.", + ], + inspection_failed: [ + "本机检查失败。请检查权限和已有配置;不能据此判断服务已停止或未安装。", + "Local inspection failed. Check permissions and existing configuration; this does not establish that the service is stopped or absent.", + ], + storage: [ + "本地状态读写失败。请检查磁盘和权限,保留操作记录后再检查。", + "Local state could not be read or written. Check disk space and permissions and preserve operation records.", + ], + rejected_request: [ + "当前请求不受支持或未获授权。请使用匹配的发行资源和允许的操作,不会自动接管已有安装。", + "The request is unsupported or unauthorized. Use matching distribution resources and supported operations; existing installations will not be adopted automatically.", + ], + process_failure: [ + "辅助进程通信或进度记录失败。请查询持久结果并检查本机状态,不要自动重放。", + "Helper communication or progress recording failed. Query the durable result and inspect local state; do not replay automatically.", + ], + reference_limit: [ + "操作引用数量已达上限。请保留记录并联系维护者使用支持的维护流程。", + "The operation-reference limit was reached. Preserve the records and use a supported maintenance procedure with the maintainer.", + ], + shutting_down: [ + "应用正在退出。请重新打开后检查操作结果。", + "The application is shutting down. Reopen it and inspect the operation result.", + ], + attempt_conflict: [ + "操作尝试已变化。请查询最新状态并重新预检恢复计划。", + "The operation attempt changed. Query the latest status and preflight a new recovery plan.", + ], + installationOwnership: ["安装归属", "Installation ownership"], + classification_missing: ["新安装", "New installation"], + classification_owned_current: [ + "已有托管安装", + "Existing managed installation", + ], + classification_owned_repairable: [ + "可修复的托管安装", + "Repairable managed installation", + ], + classification_pending_operation: [ + "未完成的安装操作", + "Incomplete installation operation", + ], + title: ["Local Server", "Local Server"], + intro: [ + "在本机准备个人 Server,或独立连接已有 Server。", + "Set up a personal Server on this machine, or connect an existing Server independently.", + ], + refresh: ["重新检查本机状态", "Inspect local state"], + install: ["安装本地 Server", "Install local Server"], + repair: ["修复 runtime", "Repair runtime"], + start: ["启动服务", "Start service"], + stop: ["停止服务", "Stop service"], + restart: ["重启服务", "Restart service"], + register: ["更新登录启动设置", "Update login startup"], + remove_registration: ["移除服务注册", "Remove service registration"], + existing: ["连接已有 Server", "Connect an existing Server"], + connect: ["连接并进入默认 Scope", "Connect and open default Scope"], + connectConsent: [ + "将激活本机实际返回的 Server 地址,检查兼容性、身份和就绪状态(可能探测模型及依赖),再读取默认 Scope。是否继续?", + "Activate the actual local Server endpoint, check compatibility, identity and readiness (which may probe models and dependencies), then read its default Scope. Continue?", + ], + preview: ["预检并查看计划", "Preflight and review plan"], + plan: ["确认本机操作计划", "Review local operation plan"], + approve: ["批准并执行", "Approve and execute"], + dismiss: ["关闭计划", "Dismiss plan"], + expiry: [ + "计划有效期为 10 分钟,执行时会再次校验;过期或状态变化后需重新预检。", + "Plans expire after 10 minutes and are revalidated at execution. Preflight again after expiry or state changes.", + ], + starts: [ + "此操作可能启动或重启后台服务,并检查就绪状态。", + "This operation may start or restart the background service and check readiness.", + ], + noStart: [ + "此计划不会启动后台服务。", + "This plan will not start the background service.", + ], + preservation: [ + "保留配置和用户数据。移除服务注册不会卸载 Desktop,也不会删除用户数据。", + "Configuration and user data are retained. Removing service registration does not uninstall Desktop or delete user data.", + ], + independent: [ + "关闭 Desktop 后,已注册的 Server 由操作系统独立管理。尚未完成的操作须重新打开后检查,不保证退出后安装仍继续。", + "The OS manages the registered Server independently of Desktop. Reinspect unfinished operations after reopening; installation is not guaranteed to continue after Quit.", + ], + port: ["本地 HTTP 端口", "Local HTTP port"], + login: ["登录时启动", "Start on login"], + policy: [ + "新安装使用 loopback、SQLite 和个人数据目录;基础 Memory 功能无需配置模型。已有配置保留原有选择。", + "New installations use loopback, SQLite and personal data storage. Basic Memory needs no model configuration. Existing configuration choices are preserved.", + ], + version: ["Server 版本", "Server version"], + program: ["程序入口", "Program entry"], + configPath: ["配置文件", "Configuration file"], + dataPath: ["用户数据目录", "User data directory"], + endpoint: ["Server 地址", "Server endpoint"], + space: ["所需空间(字节)", "Required space (bytes)"], + resources: ["runtime 资源", "Runtime resources"], + configuration: ["配置", "Configuration"], + registration: ["服务注册", "Service registration"], + managerOwnership: ["服务归属", "Service ownership"], + manager: ["进程状态", "Process state"], + liveness: ["本机服务响应", "Local service response"], + readiness: ["就绪探测", "Readiness probe"], + installationStage: ["安装阶段", "Installation stage"], + database: ["数据库兼容性", "Database compatibility"], + operation: ["操作", "Operation"], + history: ["操作与恢复", "Operations and recovery"], + select: ["选择历史操作", "Select recorded operation"], + none: ["暂无操作记录", "No recorded operations"], + attempt: ["执行尝试", "Attempt"], + status: ["查询操作结果", "Query operation result"], + resume: ["预检恢复计划", "Review recovery plan"], + cancel: ["请求取消操作", "Request cancellation"], + cancelHint: [ + "取消请求不代表操作已经停止;以持久结果为准。", + "A cancellation request does not establish that work stopped; consult the durable result.", + ], + uncertainHint: [ + "结果尚未确认。先查询持久状态;不能自动重放或根据进度推断成功。", + "The outcome is unconfirmed. Query durable status first; progress does not establish success and work is never automatically replayed.", + ], + recoveryHint: [ + "恢复前重新检查实际状态并批准新计划;旧尝试的结果仍会保留。", + "Recovery reinspects actual state and requires approval of a fresh plan; earlier attempt results are retained.", + ], + legacyHint: [ + "此记录没有可验证的操作描述,只能查询。请检查本机状态后选择受支持的维护操作。", + "This record has no verifiable action description and is query-only. Inspect local state before choosing a supported maintenance operation.", + ], + unavailable: [ + "本地安装资源不可用", + "Local installation resources unavailable", + ], + unavailableHint: [ + "此 Desktop 需要发行方提供匹配且受信任的 Installer/Ops 资源。请使用包含这些资源的安装包;连接已有 Server 仍可使用。", + "This Desktop needs matching trusted Installer/Ops resources from its distributor. Use a package containing those resources; existing-Server connections remain available.", + ], + loading: ["正在检查本机状态", "Inspecting local state"], + executing: ["正在执行已批准操作", "Executing approved operation"], + preflighting: [ + "正在预检,不会执行安装或服务修改", + "Preflighting; no installation or service mutation", + ], + progressMissing: [ + "实时进度不可用;请重新检查本机状态以发现操作引用,再查询结果或请求取消。", + "Live progress is unavailable. Inspect local state to discover the operation reference, then query its result or request cancellation.", + ], + unknown: [ + "未知或无法确认;请重新检查", + "Unknown or unconfirmed; inspect again", + ], + succeeded: ["操作成功", "Operation succeeded"], + pending: ["尚无终态结果", "No terminal result yet"], + failed: ["失败", "Failed"], + partial_failure: ["部分失败", "Partial failure"], + uncertain: ["结果不确定", "Uncertain outcome"], + cancelled: ["已取消", "Cancelled"], + rejected: ["请求被拒绝", "Rejected"], + supported: ["支持", "Supported"], + unsupported: ["不支持", "Unsupported"], + missing: ["需要下载", "Download required"], + damaged: ["已损坏", "Damaged"], + verified: ["校验通过", "Verified"], + present: ["程序存在", "Program present"], + missing_executable: [ + "程序缺失,需要修复", + "Program missing; repair required", + ], + valid: ["有效", "Valid"], + missing_or_invalid: ["缺失或无效", "Missing or invalid"], + installed: ["已注册", "Registered"], + not_installed: ["未注册", "Not registered"], + invalid: ["无效", "Invalid"], + current: ["当前有效", "Current"], + owned: ["属于 PowerContext", "Owned by PowerContext"], + foreign: ["其他程序或未知归属", "Foreign or unowned"], + not_loaded: ["未加载", "Not loaded"], + active: ["运行中", "Running"], + inactive: ["未运行", "Stopped"], + live: ["有响应", "Responding"], + unreachable: ["无法访问", "Unreachable"], + not_probed: ["未探测", "Not probed"], + verified_during_operation: ["操作中已验证", "Verified during operation"], + new: ["新数据库", "New database"], + existing_ready: ["已确认兼容", "Compatibility confirmed"], + existing_unknown: [ + "需要目标 Server 检查", + "Target Server admission required", + ], + incompatible: ["不兼容,需要维护", "Incompatible; maintenance required"], + accepted: ["操作已接收", "Accepted"], + preparing: ["准备资源", "Preparing resources"], + prepared: ["资源已准备", "Resources prepared"], + publishing: ["发布本机资源", "Publishing local resources"], + resources_published: ["资源已就位", "Resources in place"], + configuration_prepared: ["配置已准备", "Configuration prepared"], + configured: ["配置已写入", "Configured"], + registering: ["注册后台服务", "Registering service"], + registration_dispatching: ["正在交付注册操作", "Dispatching registration"], + service: ["执行服务操作", "Applying service operation"], + complete: ["安装阶段完成", "Installation stages complete"], + finished: ["执行尝试已结束", "Attempt finished"], + interrupted: [ + "执行进程已退出,尚无终态结果", + "Executor exited without a terminal result", + ], + busy: [ + "其他操作正在执行;请检查操作状态后再试。", + "Another operation is running; inspect its status before retrying.", + ], + stale_approval: [ + "计划已失效,请重新预检。", + "The plan is stale; preflight again.", + ], + plan_stale: [ + "本机状态已变化,请重新预检。", + "Local state changed; preflight again.", + ], + port_conflict: [ + "端口被占用。请检查已有 Server,或选择其他端口重新预检;不会接管未知监听程序。", + "The port is occupied. Inspect the existing Server or preflight with another port; unknown listeners are never adopted.", + ], + existing_independent: [ + "发现独立安装。请通过连接页面使用,不会自动接管。", + "An independent installation was found. Use Connections; it will not be taken over.", + ], + ownership_conflict: [ + "安装或服务归属冲突。请检查已有安装,不会覆盖或接管。", + "Installation or service ownership conflicts. Inspect the existing installation; it will not be overwritten or adopted.", + ], + database_error: [ + "数据库检查未通过。请使用受支持的备份、迁移或维护流程;不要删除数据或反复重启。", + "Database admission did not pass. Use supported backup, migration or maintenance; do not delete data or repeatedly restart.", + ], + resource_error: [ + "资源下载、校验或准备失败。请检查网络和发行资源,重新检查后预检恢复计划。", + "Resource download, verification or preparation failed. Check connectivity and distribution resources, then inspect and review recovery.", + ], + different: [ + "此操作属于其他资源绑定,不能在当前资源下重放。", + "This operation belongs to a different resource binding and cannot be replayed here.", + ], + foreign_binding: [ + "资源绑定已改变。需要匹配的可信发行资源才能查询或恢复。", + "The resource binding changed. Matching trusted distribution resources are required to query or recover.", + ], + operation_missing: [ + "尚未找到持久操作。先检查本机实际状态,不能假定此前请求成功或自动重放。", + "No durable operation was found. Inspect actual local state; do not assume success or automatically replay.", + ], +}; +export function localText(language: Language, key: string): string { + return ( + text[key]?.[language === "zh" ? 0 : 1] ?? + text.unknown[language === "zh" ? 0 : 1] + ); +} +export function localError(error: unknown): string { + const key = + typeof error === "string" + ? error + : error && typeof error === "object" && "code" in error + ? String(error.code) + : "unknown"; + const aliases: Record = { + configuration_invalid: "configuration_error", + configuration_conflict: "configuration_error", + configuration_not_owned: "ownership_conflict", + configuration_not_local: "ownership_conflict", + configuration_path_unsupported: "rejected_request", + runtime_damaged: "runtime_error", + repair_required: "runtime_error", + installation_missing: "runtime_error", + installation_record_invalid: "storage", + insufficient_space_or_permissions: "storage", + option_unsupported: "rejected_request", + forbidden: "rejected_request", + invalid_input: "rejected_request", + invalid_request: "rejected_request", + invalid_response: "rejected_request", + unsupported_action: "rejected_request", + unsupported_protocol: "rejected_request", + unsupported_platform: "rejected_request", + invalid_input_or_binding: "rejected_request", + operation_busy: "busy", + plan_invalid: "stale_approval", + operation_conflict: "attempt_conflict", + operation_superseded: "attempt_conflict", + attempt_limit: "attempt_conflict", + unknown_operation: "operation_missing", + progress_unavailable: "process_failure", + checkpoint_failed: "process_failure", + repair_ownership_conflict: "ownership_conflict", + target_conflict: "ownership_conflict", + record_invalid: "storage", + record_unsupported: "storage", + authorization_required: "rejected_request", + invalid_target_binding: "rejected_request", + invalid_managed_binding: "rejected_request", + layout_invalid: "rejected_request", + }; + if (key.startsWith("database_")) return "database_error"; + if (key.startsWith("resource_")) return "resource_error"; + if (["unavailable", "managed_installation_unavailable"].includes(key)) + return "unavailableHint"; + return aliases[key] ?? (Object.hasOwn(text, key) ? key : "request_failed"); +} diff --git a/desktop/ui/src/app/messages.ts b/desktop/ui/src/app/messages.ts index 096a350258..bd40cb7390 100644 --- a/desktop/ui/src/app/messages.ts +++ b/desktop/ui/src/app/messages.ts @@ -25,27 +25,14 @@ export const messages = { setUpLocally: "本地设置", connectExisting: "连接已有 Server", startPathsHint: - "可以查看本地 Server 准备说明,或连接已经运行的本地/远程 Server。两条路径都不会自动安装或激活连接。", + "可以查看并批准本地 Server 安装计划,或独立连接已有本地/远程 Server。选择入口不会自动安装或激活连接。", localServer: "Local Server", - localServerIntro: "即使尚未连接 Server,也可在此查看本地准备说明。", - localServerUnavailable: "安装、恢复与服务管理尚未接入", - localServerUnavailableHint: - "此版本不能在 Desktop 内安装、修复、启动、停止、重启 Server 或设置登录启动。这不表示本机没有安装 Server,也不判断服务是否正在运行。", - prepareServer: "独立准备 Server", - prepareServerDocs: - "通过下方“使用帮助”复制并打开文档,按指引在 Desktop 外独立安装、配置和运行 PowerContext Server。", - prepareServerAddress: - "确认实际 Server 地址、认证方式及受支持的兼容配置;已有独立运行的 Server 可直接连接,无需重复安装。", - prepareServerConnect: - "准备完成后,连接已有 Server,保存配置并显式激活,再选择精确 Scope。Desktop 不会自动连接或恢复 Scope。", - localServerDiagnostics: - "下方本机诊断可手动执行,但不能代替安装、恢复或服务启停;关闭 Desktop 不会停止独立运行的 Server。", connectionUpdating: "正在更新连接状态…", connectionUpdatingHint: "可以浏览其他页面。连接操作完成前,记忆编辑、保存、搜索、精确读取及范围切换暂不可用;完成后请核对当前连接与精确 Scope。", help: "使用帮助", helpPrepare: - "请先按使用文档独立准备 PowerContext Server,或使用已运行的本地/远程 Server。Desktop 不安装或启停 Server。", + "发行包包含可信资源时,可在 Local Server 中查看并批准安装和服务操作计划;也可按文档独立准备 Server,或连接已有本地/远程 Server。", helpConnect: "在“连接”中保存地址及认证配置,核对受支持的兼容配置后,显式点击“使用此连接”。选择配置不会自动激活连接。", helpScope: @@ -140,7 +127,7 @@ export const messages = { "连接服务并选择范围后,使用全文搜索查找内容。这里不会自动加载全部记忆。", noConnections: "尚未添加连接", boundary: - "桌面不会安装或启动 Server。关闭窗口将退出桌面,不会停止独立运行的服务。", + "本地安装和服务修改需要明确批准计划。关闭窗口将退出桌面,不会停止独立运行的服务。", details: "连接状态详情", unverified: "未验证", identity: "身份与访问", @@ -232,29 +219,14 @@ export const messages = { setUpLocally: "Set up locally", connectExisting: "Connect an existing Server", startPathsHint: - "Read the local Server preparation guide, or connect an existing local or remote Server. Neither path installs anything or activates a connection automatically.", + "Review and approve a local Server installation plan, or independently connect an existing local or remote Server. Choosing an entry does not install or activate automatically.", localServer: "Local Server", - localServerIntro: - "Local preparation guidance remains available before connecting to a Server.", - localServerUnavailable: - "Installation, recovery and service management are not integrated", - localServerUnavailableHint: - "This version cannot install, repair, start, stop or restart a Server or configure login startup from Desktop. This does not mean a Server is absent or determine whether a service is running.", - prepareServer: "Prepare a Server independently", - prepareServerDocs: - "Copy and open the documentation from Usage help below, then follow the guide to install, configure and run PowerContext Server outside Desktop.", - prepareServerAddress: - "Confirm the actual Server address, authentication method and supported compatibility profile. An independently running Server can be connected without reinstalling it.", - prepareServerConnect: - "When ready, connect the existing Server, save and explicitly activate its profile, then select an exact Scope. Desktop does not automatically connect or restore a Scope.", - localServerDiagnostics: - "You can run the local diagnostics below explicitly. They do not install, recover, start or stop services. Closing Desktop leaves an independently running Server active.", connectionUpdating: "Updating connection state…", connectionUpdatingHint: "You can browse other pages. Memory editing, saving, searching, exact reads and Scope switching are unavailable until the connection operation finishes. Then verify the current connection and exact Scope.", help: "Usage help", helpPrepare: - "Prepare PowerContext Server independently using the usage guide, or use an existing local or remote Server. Desktop does not install, start or stop the Server.", + "With trusted distribution resources, review and approve installation or service plans in Local Server. Alternatively, prepare a Server using the guide or connect an existing local or remote Server.", helpConnect: "In Connections, save the address and authentication settings, verify a supported compatibility profile, then explicitly choose Use this connection. Selecting a profile does not activate it.", helpScope: @@ -356,7 +328,7 @@ export const messages = { "Connect a server and select a scope to search. This page does not automatically load all memories.", noConnections: "No connections added", boundary: - "Desktop does not install or start a Server. Closing the window exits Desktop and leaves independent services running.", + "Local installation and service changes require an explicitly approved plan. Closing the window exits Desktop and leaves independent services running.", details: "Connection status details", unverified: "Not verified", identity: "Identity and access", diff --git a/desktop/ui/src/app/style.css b/desktop/ui/src/app/style.css index 2b7cf617fa..7fee0812e3 100644 --- a/desktop/ui/src/app/style.css +++ b/desktop/ui/src/app/style.css @@ -1241,3 +1241,23 @@ footer { overflow-wrap: anywhere; user-select: text; } + +.local-server { + min-width: 0; +} +.local-server dd, +.local-server code, +.local-server select { + overflow-wrap: anywhere; + max-width: 100%; +} +.local-server fieldset { + min-width: 0; +} +.local-server .actions { + flex-wrap: wrap; +} + +.local-server-plan dd { + overflow-wrap: anywhere; +} diff --git a/desktop/ui/src/app/useLocalServer.ts b/desktop/ui/src/app/useLocalServer.ts new file mode 100644 index 0000000000..94f400693b --- /dev/null +++ b/desktop/ui/src/app/useLocalServer.ts @@ -0,0 +1,329 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useRef, useState } from "react"; +import { opsApi, subscribeOpsProgress } from "../shared/ops"; +import type { + OpsInspectionReply, + OpsReferences, + OpsOperationReply, + OpsProgress, + OpsPreflightInput, + OpsPlanApproval, +} from "../generated/ipc"; +import { localError } from "./local-server-messages"; + +type Approved = { plan: OpsPlanApproval; input: OpsPreflightInput }; +export function useLocalServer() { + const [inspection, setInspection] = useState(null); + const [references, setReferences] = useState(null); + const [selected, setSelected] = useState(""); + const selectedRef = useRef(selected); + selectedRef.current = selected; + const [record, setRecord] = useState(null); + const [progress, setProgress] = useState(null); + const [approval, setApprovalState] = useState(null); + const approvalRef = useRef(null); + function setApproval(next: Approved | null) { + approvalRef.current = next; + setApprovalState(next); + } + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [querying, setQuerying] = useState(false); + const [controlling, setControlling] = useState(false); + const [busy, setBusy] = useState<"preflight" | "execute" | null>(null); + const [listening, setListening] = useState(false); + const mounted = useRef(false); + const mutation = useRef(false); + const reads = useRef(0); + const queries = useRef(0); + const running = useRef<{ + approvalId: string; + knownIds: Set; + operationId?: string; + attempt?: number; + } | null>(null); + + function choose(id: string) { + queries.current++; + selectedRef.current = id; + setSelected(id); + setRecord(null); + setApproval(null); + } + async function refresh(clearError = true) { + const request = ++reads.current; + setLoading(true); + if (clearError) setError(null); + const [facts, refs] = await Promise.allSettled([ + Promise.resolve().then(() => opsApi.inspect()), + Promise.resolve().then(() => opsApi.references()), + ]); + if (!mounted.current || request !== reads.current) return; + setInspection(facts.status === "fulfilled" ? facts.value : null); + setReferences(refs.status === "fulfilled" ? refs.value : null); + if (facts.status === "rejected" || refs.status === "rejected") { + const failure = + facts.status === "rejected" + ? facts.reason + : refs.status === "rejected" + ? refs.reason + : null; + setError((current) => + !clearError && current ? current : localError(failure), + ); + } + const active = running.current; + if (refs.status === "fulfilled" && active && !active.operationId) { + const added = refs.value.operations.filter( + (item) => !active.knownIds.has(item.operationId), + ); + if (added.length === 1 && added[0].bindingStatus === "current") { + active.operationId = added[0].operationId; + choose(added[0].operationId); + } + } + if ( + refs.status === "fulfilled" && + !selectedRef.current && + refs.value.operations.length + ) { + choose( + refs.value.operations[refs.value.operations.length - 1].operationId, + ); + } + setLoading(false); + return refs.status === "fulfilled" ? refs.value : null; + } + function receive(reply: OpsOperationReply) { + if (!mounted.current || reply.operationId !== selectedRef.current) return; + const active = running.current; + if ( + active?.operationId === reply.operationId && + active.attempt && + (reply.attempt ?? 0) < active.attempt + ) + return; + setRecord((current) => { + if (current && (current.attempt ?? 0) > (reply.attempt ?? 0)) + return current; + if ( + current?.attempt === reply.attempt && + current?.status?.result && + !reply.status?.result + ) + return current; + return reply; + }); + } + async function query(id = selectedRef.current) { + if (!id || !mounted.current) return; + const request = ++queries.current; + setQuerying(true); + try { + const reply = await opsApi.operationStatus({ operationId: id }); + if (request === queries.current) receive(reply); + } catch (failure) { + if (mounted.current && request === queries.current) + setError(localError(failure)); + } finally { + if (mounted.current && request === queries.current) setQuerying(false); + } + } + useEffect(() => { + mounted.current = true; + void refresh(); + let unlisten: (() => void) | undefined; + let disposed = false; + void Promise.resolve() + .then(() => + subscribeOpsProgress((event) => { + const active = running.current; + if ( + disposed || + !mounted.current || + !active || + event.approvalId !== active.approvalId + ) + return; + if (active.operationId && active.operationId !== event.operationId) + return; + if (active.attempt && active.attempt !== event.attempt) return; + active.operationId = event.operationId; + active.attempt = event.attempt; + selectedRef.current = event.operationId; + setSelected(event.operationId); + setProgress((current) => + current && + current.operationId === event.operationId && + (current.sequence ?? -1) > (event.sequence ?? -1) + ? current + : event, + ); + }), + ) + .then((stop) => { + if (!disposed && mounted.current) { + unlisten = stop; + setListening(true); + } else stop(); + }) + .catch(() => { + if (!disposed && mounted.current) setListening(false); + }); + return () => { + disposed = true; + mounted.current = false; + reads.current++; + queries.current++; + unlisten?.(); + }; + }, []); + useEffect(() => { + if (selected) void query(selected); + }, [selected]); + + async function preflight(input: OpsPreflightInput) { + if (mutation.current) return; + mutation.current = true; + setBusy("preflight"); + setError(null); + setApproval(null); + try { + const reply = await opsApi.preflight(input); + if (!mounted.current) return; + if (reply.outcome === "succeeded" && reply.approval) + setApproval({ plan: reply.approval, input }); + else setError(localError(reply.error ?? reply.bridgeError)); + } catch (failure) { + if (mounted.current) setError(localError(failure)); + } finally { + mutation.current = false; + if (mounted.current) setBusy(null); + } + } + async function execute(expectedApprovalId: string) { + const accepted = approvalRef.current; + if ( + !mounted.current || + mutation.current || + !accepted || + accepted.plan.approvalId !== expectedApprovalId + ) + return; + mutation.current = true; + const knownIds = new Set( + references?.operations.map((item) => item.operationId), + ); + setApproval(null); + setBusy("execute"); + setError(null); + setProgress(null); + setRecord(null); + running.current = { + approvalId: accepted.plan.approvalId, + knownIds, + operationId: accepted.input.resume?.operationId, + attempt: accepted.input.resume + ? accepted.input.resume.expectedAttempt + 1 + : undefined, + }; + try { + const input = { + approvalId: accepted.plan.approvalId, + planId: accepted.plan.planId, + }; + const reply = await (accepted.input.resume + ? opsApi.resume(input) + : opsApi.apply(input)); + if (!mounted.current) return; + queries.current++; + selectedRef.current = reply.operationId; + setSelected(reply.operationId); + receive(reply); + } catch (failure) { + if (mounted.current) setError(localError(failure)); + } finally { + const observedId = running.current?.operationId; + running.current = null; + if (mounted.current) { + // Discover a newly persisted reference even if neither progress nor the reply arrived. + const latest = await refresh(false); + const added = + latest?.operations.filter( + (item) => !knownIds.has(item.operationId), + ) ?? []; + const id = + observedId ?? (added.length === 1 ? added[0].operationId : undefined); + if (id && id !== selectedRef.current) choose(id); + if (mounted.current) await query(); + if (mounted.current) setBusy(null); + } + mutation.current = false; + } + } + async function cancel() { + const operationId = running.current?.operationId ?? record?.operationId; + const attempt = running.current?.attempt ?? record?.attempt; + if (!operationId || !attempt || controlling) return; + setControlling(true); + try { + receive(await opsApi.cancel({ operationId, attempt })); + } catch (failure) { + if (mounted.current) setError(localError(failure)); + } finally { + if (mounted.current) setControlling(false); + } + } + const reference = references?.operations.find( + (item) => item.operationId === selected, + ); + const status = record?.status; + const canResume = + !!reference?.operation && + !!reference.options && + reference.bindingStatus === "current" && + !!status && + status.outcome !== "succeeded" && + status.recoveryActions.includes("preflight") && + (status.executionState === "interrupted" || + status.executionState === "finished"); + return { + inspection, + references, + selected, + reference, + record, + progress, + approval, + error, + loading, + querying, + controlling, + busy, + listening, + canResume, + refresh, + choose, + query, + preflight, + execute, + cancel, + dismiss: () => setApproval(null), + }; +} +export type LocalServerController = ReturnType; diff --git a/desktop/ui/src/generated/ipc.ts b/desktop/ui/src/generated/ipc.ts index be6f239d43..4e4bc4ce9e 100644 --- a/desktop/ui/src/generated/ipc.ts +++ b/desktop/ui/src/generated/ipc.ts @@ -15,7 +15,7 @@ */ // Generated from Rust IPC types. Do not edit. -export type SafeError = "unauthorized_window" | "invalid_endpoint" | "insecure_transport" | "invalid_certificate" | "credential_unavailable" | "credential_missing" | "invalid_credential" | "timeout" | "tls" | "network" | "redirect" | "unauthorized" | "forbidden" | "server" | "invalid_response" | "response_too_large" | "busy" | "invalid_input" | "not_found" | "cursor_expired" | "conflict" | "authentication_unavailable" | "runtime_not_ready" | "stale_context" | "compatibility_unverified" | "storage" | "profile_corrupt" | "duplicate_name" | "not_connected" | "scope_required"; +export type SafeError = "unauthorized_window" | "invalid_endpoint" | "insecure_transport" | "invalid_certificate" | "credential_unavailable" | "credential_missing" | "invalid_credential" | "timeout" | "tls" | "network" | "redirect" | "unauthorized" | "forbidden" | "server" | "invalid_response" | "response_too_large" | "busy" | "invalid_input" | "not_found" | "cursor_expired" | "conflict" | "authentication_unavailable" | "runtime_not_ready" | "integrity_failure" | "process_failure" | "stale_context" | "compatibility_unverified" | "storage" | "profile_corrupt" | "duplicate_name" | "not_connected" | "scope_required"; export type FoundationInfo = { version: string, phase: string, credentialBackend: string, serverConnected: boolean, }; export type StorageChoice = "persistent" | "session_only"; export type CredentialWriteRequest = { secret: string, storage: StorageChoice, }; @@ -31,9 +31,9 @@ export type OpsPreflightInput = { operation: OpsOperation, options: OpsOptions, export type OpsApprovalInput = { approvalId: string, planId: string, }; export type OpsStatusInput = { operationId: string, }; export type OpsCancelInput = { operationId: string, attempt: number, }; -export type OpsOperationReference = { operationId: string, bindingStatus: OpsReferenceBindingStatus, }; +export type OpsOperationReference = { operationId: string, bindingStatus: OpsReferenceBindingStatus, operation?: OpsOperation, options?: OpsOptions, }; export type OpsReferenceBindingStatus = "current" | "different" | "unavailable"; -export type OpsReferences = { bindingAvailable: boolean, operations: Array, }; +export type OpsReferences = { bindingAvailable: boolean, loginStartChoice: boolean, operations: Array, }; export type OpsInspectionReply = { outcome: OpsOutcome, components?: OpsComponents, error?: OpsErrorCode, bridgeError?: OpsBridgeError, exitCode?: number, }; export type OpsValidationReply = { outcome: OpsOutcome, configuration: OpsConfigurationState, error?: OpsErrorCode, bridgeError?: OpsBridgeError, exitCode?: number, }; export type OpsConfigurationState = "valid" | "missing_or_invalid" | "unknown"; @@ -46,7 +46,7 @@ export type OpsInstallationClassification = "missing" | "owned_current" | "owned export type OpsInstallationResources = "missing" | "damaged" | "verified"; export type OpsDatabaseClassification = "new" | "existing_unknown" | "existing_ready" | "incompatible" | "unknown"; export type OpsComponents = { support?: OpsSupportState, registration?: OpsRegistrationState, definition?: OpsDefinitionState, managerOwnership?: OpsManagerOwnership, manager?: OpsManagerState, liveness?: OpsLivenessState, readiness?: OpsReadinessState, endpoint?: string, maintenance?: OpsMaintenancePhase, resources?: OpsRuntimeResources, configuration?: OpsConfigurationState, installationId?: string, resourceId?: string, resourceDigest?: string, database?: OpsDatabaseClassification, connectionCompatibility?: OpsConnectionCompatibility, installationStage?: OpsServicePhase, installationObservation?: OpsObservationState, }; -export type OpsProgress = { requestId: string, operationId: string, attempt: number, sequence: number | null, phase: OpsServicePhase | null, accepted: boolean, }; +export type OpsProgress = { approvalId?: string, requestId: string, operationId: string, attempt: number, sequence: number | null, phase: OpsServicePhase | null, accepted: boolean, }; export type OpsOperationReply = { operationId: string, outcome: OpsOutcome, attempt?: number, exitCode?: number, error?: OpsErrorCode, bridgeError?: OpsBridgeError, status?: OpsOperationStatus, }; export type OpsOperationStatus = { operationId: string, attempt: number, phase: OpsAttemptPhase, executor: OpsExecutor, executionState: OpsExecutionState, outcome: OpsOutcome, cancelRequested?: boolean, result?: OpsStoredResult, observation: OpsObservation, history: Array, recoveryActions: Array, }; export type OpsExecutor = { pid?: number, observation: OpsExecutorObservation, }; diff --git a/desktop/ui/src/shared/ops.ts b/desktop/ui/src/shared/ops.ts index 915b408c38..a483de32d3 100644 --- a/desktop/ui/src/shared/ops.ts +++ b/desktop/ui/src/shared/ops.ts @@ -17,6 +17,7 @@ import { invoke } from "@tauri-apps/api/core"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import type { + DesktopState, OpsApprovalInput, OpsCancelInput, OpsInspectionReply, @@ -35,8 +36,10 @@ export const subscribeOpsProgress = (receive: (event: OpsProgress) => void) => receive(event.payload), ); -/** Fixed Ops v1 operations; executable, binding and complete plans remain native. */ +/** Fixed Ops v1 operations and explicit setup handoff; executable, binding and complete plans remain native. */ export const opsApi = { + connect: (generation: number) => + invoke("connect_local_server", { generation }), references: () => invoke("ops_references"), inspect: () => invoke("ops_inspect"), configValidate: () => invoke("ops_config_validate"), diff --git a/desktop/ui/tests/local-handoff.test.tsx b/desktop/ui/tests/local-handoff.test.tsx new file mode 100644 index 0000000000..c5f57f52da --- /dev/null +++ b/desktop/ui/tests/local-handoff.test.tsx @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { App } from "../src/app/App"; +import { opsApi } from "../src/shared/ops"; +import { desktopApi } from "../src/shared/ipc"; +import type { DesktopState } from "../src/generated/ipc"; +vi.mock("../src/shared/ipc", () => ({ + getFoundationInfo: vi.fn().mockResolvedValue(null), + desktopApi: { + state: vi.fn(), + cancelMemory: vi.fn().mockResolvedValue(undefined), + }, +})); +vi.mock("../src/shared/ops", () => ({ + subscribeOpsProgress: vi.fn().mockResolvedValue(() => {}), + opsApi: { + inspect: vi.fn(), + references: vi.fn(), + connect: vi.fn(), + apply: vi.fn(), + }, +})); +const disconnected: DesktopState = { + generation: 4, + profiles: [], + reports: [], + active: null, + compatibilityProfiles: [], + pendingCredentialCleanup: 0, + lastWrite: null, +}; +function connected(): DesktopState { + const fact = { value: null, error: null }; + return { + ...disconnected, + generation: 6, + profiles: [ + { + id: "local-profile", + revision: 1, + name: "Local Server", + endpoint: "http://127.0.0.1:8123/", + authentication: "unauthenticated_loopback", + caPem: null, + compatibility: "qualified", + credentialState: "not_required", + }, + ], + active: { + connectionId: "local-profile", + generation: 6, + scope: { + scope_id: "server-returned-default", + title: "Personal Scope", + summary: "", + context_references: [], + external_references: [], + version: 1, + }, + report: { + connectionId: "local-profile", + revision: 1, + checkedAt: 1, + liveness: fact, + readiness: fact, + identity: fact, + capabilities: fact, + compatibilityVerified: true, + anonymousAccess: true, + supportedOperations: [ + "remember_memory", + "search_memory", + "get_artifact_revision", + ], + }, + }, + }; +} +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(desktopApi.state).mockResolvedValue(disconnected); + vi.mocked(opsApi.references).mockResolvedValue({ + bindingAvailable: true, + loginStartChoice: false, + operations: [], + }); + vi.mocked(opsApi.inspect).mockResolvedValue({ + outcome: "succeeded", + components: { + installationId: "owned", + resources: "present", + managerOwnership: "owned", + manager: "active", + registration: "installed", + configuration: "valid", + liveness: "live", + }, + }); + vi.mocked(opsApi.connect).mockResolvedValue(connected()); +}); +afterEach(cleanup); +test.each(["en", "zh"] as const)( + "explicit local handoff opens the exact returned Scope and never reactivates on reopen (%s)", + async (language) => { + const user = userEvent.setup(); + const view = render(); + await user.click(screen.getByRole("button", { name: "本地设置" })); + await user.selectOptions(screen.getByLabelText("语言"), language); + const action = + language === "en" + ? "Connect and open default Scope" + : "连接并进入默认 Scope"; + const button = screen.getByRole("button", { name: action }); + await waitFor(() => + expect((button as HTMLButtonElement).disabled).toBe(false), + ); + expect(opsApi.connect).not.toHaveBeenCalled(); + await user.click(button); + const dialog = await screen.findByRole("alertdialog"); + await user.click( + within(dialog).getByRole("button", { + name: language === "en" ? "Confirm" : "确认", + }), + ); + expect( + (await screen.findAllByText("server-returned-default")).length, + ).toBeGreaterThan(0); + expect(opsApi.connect).toHaveBeenCalledWith(4); + expect(document.activeElement).toBe( + screen.getByRole("heading", { + level: 1, + name: language === "en" ? "Memories" : "记忆", + }), + ); + expect(opsApi.apply).not.toHaveBeenCalled(); + view.unmount(); + render(); + await screen.findByRole("button", { name: "本地设置" }); + expect(opsApi.connect).toHaveBeenCalledOnce(); + }, +); + +test("a late handoff cannot navigate after a newer native context was received", async () => { + const user = userEvent.setup(); + let finish!: (state: DesktopState) => void; + vi.mocked(opsApi.connect).mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + render(); + await user.click(screen.getByRole("button", { name: "本地设置" })); + await user.click( + screen.getByRole("button", { name: "连接并进入默认 Scope" }), + ); + await user.click( + within(await screen.findByRole("alertdialog")).getByRole("button", { + name: "确认", + }), + ); + await user.click(screen.getByRole("button", { name: "总览" })); + vi.mocked(desktopApi.state).mockResolvedValue({ + ...disconnected, + generation: 20, + }); + await user.click(screen.getByRole("button", { name: "刷新状态" })); + await waitFor(() => + expect( + (screen.getByRole("button", { name: "刷新状态" }) as HTMLButtonElement) + .disabled, + ).toBe(false), + ); + finish(connected()); + await waitFor(() => + expect(screen.queryByText("正在更新连接状态…")).toBeNull(), + ); + expect(screen.getByRole("heading", { level: 1, name: "总览" })).toBeTruthy(); + expect(screen.queryByText("server-returned-default")).toBeNull(); +}); + +test("local service management remains usable while connection storage is unavailable", async () => { + vi.mocked(desktopApi.state).mockRejectedValue({ code: "storage" }); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "本地设置" })); + const connect = screen.getByRole("button", { name: "连接并进入默认 Scope" }); + await waitFor(() => + expect( + (screen.getByRole("button", { name: "启动服务" }) as HTMLButtonElement) + .disabled, + ).toBe(false), + ); + expect((connect as HTMLButtonElement).disabled).toBe(true); + expect(opsApi.connect).not.toHaveBeenCalled(); +}); + +test("local handoff integrity failure is explicit and cannot activate or replay", async () => { + vi.mocked(opsApi.connect).mockRejectedValue({ code: "integrity_failure" }); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "本地设置" })); + await user.click( + screen.getByRole("button", { name: "连接并进入默认 Scope" }), + ); + await user.click( + within(await screen.findByRole("alertdialog")).getByRole("button", { + name: "确认", + }), + ); + await screen.findByText( + "可信资源校验失败。请核对发行包和固定资源,不能跳过校验执行。", + ); + expect(screen.queryByText("server-returned-default")).toBeNull(); + expect(opsApi.apply).not.toHaveBeenCalled(); + expect(opsApi.connect).toHaveBeenCalledOnce(); +}); diff --git a/desktop/ui/tests/local-server.test.tsx b/desktop/ui/tests/local-server.test.tsx index 9cceffbf0c..74649b97fc 100644 --- a/desktop/ui/tests/local-server.test.tsx +++ b/desktop/ui/tests/local-server.test.tsx @@ -19,7 +19,9 @@ import { cleanup, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { answerConfirmation } from "./confirmation"; import { App } from "../src/app/App"; +import { opsApi } from "../src/shared/ops"; import { desktopApi } from "../src/shared/ipc"; +import { localText } from "../src/app/local-server-messages"; import { messages } from "../src/app/messages"; import type { DesktopState } from "../src/generated/ipc"; @@ -33,6 +35,10 @@ vi.mock("../src/shared/ipc", () => ({ cancelMemory: vi.fn().mockResolvedValue(undefined), }, })); +vi.mock("../src/shared/ops", () => ({ + subscribeOpsProgress: vi.fn().mockResolvedValue(() => {}), + opsApi: { references: vi.fn(), inspect: vi.fn() }, +})); const disconnected: DesktopState = { generation: 0, profiles: [], @@ -43,6 +49,15 @@ const disconnected: DesktopState = { lastWrite: null, }; beforeEach(() => { + vi.mocked(opsApi.inspect).mockResolvedValue({ + outcome: "unknown", + bridgeError: "unavailable", + }); + vi.mocked(opsApi.references).mockResolvedValue({ + bindingAvailable: false, + loginStartChoice: false, + operations: [], + }); vi.mocked(desktopApi.state).mockResolvedValue(disconnected); }); afterEach(() => { @@ -52,7 +67,7 @@ afterEach(() => { }); test.each(["zh", "en"] as const)( - "disconnected Memory provides local and existing-Server paths without changing Overview (%s)", + "first use and disconnected Memory provide local and existing-Server paths (%s)", async (language) => { const user = userEvent.setup(); render(); @@ -60,10 +75,9 @@ test.each(["zh", "en"] as const)( expect( screen.getByRole("heading", { level: 1, name: "总览" }), ).toBeTruthy(); - // Overview retains its original content; the two-path entry belongs to Memory. expect( - screen.queryByRole("button", { name: messages.zh.setUpLocally }), - ).toBeNull(); + screen.getByRole("button", { name: messages.zh.setUpLocally }), + ).toBeTruthy(); await user.click(screen.getByRole("button", { name: "设置与诊断" })); await user.selectOptions(screen.getByLabelText("语言"), language); const t = messages[language]; @@ -90,11 +104,20 @@ test.each(["zh", "en"] as const)( within(region).getByRole("heading", { name: "Local Server", level: 2 }), ); expect( - within(region).getByRole("heading", { name: t.localServerUnavailable }), + within(region).getByRole("heading", { + name: localText(language, "unavailable"), + }), ).toBeTruthy(); - expect(within(region).getByText(t.localServerUnavailableHint)).toBeTruthy(); - expect(within(region).getAllByRole("listitem")).toHaveLength(3); - expect(within(region).getAllByRole("button")).toHaveLength(1); + expect( + within(region).getByText(localText(language, "unavailableHint")), + ).toBeTruthy(); + expect( + ( + within(region).getByRole("button", { + name: localText(language, "install"), + }) as HTMLButtonElement + ).disabled, + ).toBe(true); expect( screen .getByRole("button", { name: t.settings }) @@ -125,7 +148,7 @@ test.each(["loading", "error"])( await user.click(screen.getByRole("button", { name: "设置与诊断" })); const region = screen.getByRole("region", { name: "Local Server" }); expect( - within(region).getByText(messages.zh.localServerUnavailableHint), + within(region).getByText(localText("zh", "unavailableHint")), ).toBeTruthy(); expect( within(region) diff --git a/desktop/ui/tests/local-setup.test.tsx b/desktop/ui/tests/local-setup.test.tsx new file mode 100644 index 0000000000..669ec9c489 --- /dev/null +++ b/desktop/ui/tests/local-setup.test.tsx @@ -0,0 +1,707 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { StrictMode } from "react"; +import { + act, + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { useLocalServer } from "../src/app/useLocalServer"; +import { LocalServerSettings } from "../src/app/LocalServerSettings"; +import { opsApi, subscribeOpsProgress } from "../src/shared/ops"; +import { localText } from "../src/app/local-server-messages"; +import type { Language } from "../src/app/messages"; +import type { + OpsComponents, + OpsOperationReply, + OpsPlanApproval, + OpsReferences, + OpsProgress, +} from "../src/generated/ipc"; + +vi.mock("../src/shared/ops", () => ({ + subscribeOpsProgress: vi.fn(), + opsApi: { + inspect: vi.fn(), + references: vi.fn(), + preflight: vi.fn(), + apply: vi.fn(), + resume: vi.fn(), + operationStatus: vi.fn(), + cancel: vi.fn(), + connect: vi.fn(), + }, +})); +const missing: OpsComponents = { + support: "supported", + resources: "missing_executable", + configuration: "missing_or_invalid", + registration: "not_installed", + managerOwnership: "not_loaded", + manager: "inactive", + liveness: "unreachable", + readiness: "not_probed", +}; +const installed: OpsComponents = { + ...missing, + resources: "present", + configuration: "valid", + registration: "installed", + managerOwnership: "owned", + manager: "active", + liveness: "live", + endpoint: "http://127.0.0.1:8123", + installationId: "installation-a", +}; +let facts: OpsComponents; +let references: OpsReferences; +let progress: (event: OpsProgress) => void; +let status: OpsOperationReply; +function result( + outcome: OpsOperationReply["outcome"] = "succeeded", + attempt = 1, +): OpsOperationReply { + return { + operationId: "operation-a", + outcome, + attempt, + status: { + operationId: "operation-a", + attempt, + phase: outcome === "pending" ? "executing" : "finished", + executor: { observation: "exited" }, + executionState: outcome === "pending" ? "interrupted" : "finished", + outcome, + observation: { components: facts }, + history: [ + { + attempt: 1, + outcome: + attempt > 1 + ? "cancelled" + : outcome === "pending" + ? undefined + : outcome, + }, + ...(attempt > 1 ? [{ attempt, outcome }] : []), + ], + recoveryActions: outcome === "succeeded" ? [] : ["inspect", "preflight"], + ...(outcome === "pending" + ? {} + : { result: { outcome, components: facts, recoveryActions: [] } }), + }, + }; +} +function plan( + operation: OpsPlanApproval["operation"] = "install", +): OpsPlanApproval { + return { + approvalId: "approval-a", + planId: "plan-a", + operation, + options: {}, + target: { + targetId: "personal", + packageVersion: "1.4.2", + pythonExecutable: "/program/runtime/python", + envFile: "/config/server.env", + dataDir: "/data/personal", + endpoint: "http://127.0.0.1:8123", + startOnLogin: true, + }, + effects: { + mayStart: operation !== "stop" && operation !== "remove_registration", + preservesData: true, + }, + ...(operation === "install" + ? { + installation: { + classification: "missing", + resources: "missing", + database: "new", + spaceRequired: 123456, + verifyRunning: false, + preserveStopped: false, + registrationPresent: false, + }, + } + : {}), + }; +} +function Harness({ + language = "en", + before = async () => true, + connect = async () => {}, +}: { + language?: Language; + before?: (plan: OpsPlanApproval) => Promise; + connect?: () => Promise; +}) { + const controller = useLocalServer(); + return ( + {}} + onConnect={connect} + onBeforeApply={before} + /> + ); +} +beforeEach(() => { + vi.resetAllMocks(); + facts = missing; + references = { + bindingAvailable: true, + loginStartChoice: true, + operations: [], + }; + status = result(); + vi.mocked(opsApi.inspect).mockImplementation(async () => ({ + outcome: "succeeded", + components: facts, + })); + vi.mocked(opsApi.references).mockImplementation(async () => references); + vi.mocked(subscribeOpsProgress).mockImplementation(async (callback) => { + progress = callback; + return () => {}; + }); + vi.mocked(opsApi.preflight).mockImplementation(async (input) => ({ + outcome: "succeeded", + approval: { ...plan(input.operation), options: input.options }, + })); + vi.mocked(opsApi.operationStatus).mockImplementation(async () => status); + vi.mocked(opsApi.apply).mockImplementation(async () => { + facts = installed; + references = { + ...references, + operations: [ + { + operationId: "operation-a", + bindingStatus: "current", + operation: "install", + options: { httpPort: 8123, startOnLogin: true }, + }, + ], + }; + status = result(); + return status; + }); +}); +afterEach(cleanup); +async function open(language: Language = "en") { + const user = userEvent.setup(); + render(); + await waitFor(() => + expect(screen.queryByText(localText(language, "loading"))).toBeNull(), + ); + return user; +} +test.each(["en", "zh"] as const)( + "setup previews actual locations and requires explicit approval (%s)", + async (language) => { + const user = await open(language); + expect(opsApi.preflight).not.toHaveBeenCalled(); + expect(opsApi.apply).not.toHaveBeenCalled(); + await user.clear(screen.getByLabelText(localText(language, "port"))); + await user.type(screen.getByLabelText(localText(language, "port")), "8123"); + await user.click( + screen.getByRole("button", { name: localText(language, "install") }), + ); + const dialog = await screen.findByRole("dialog", { + name: localText(language, "plan"), + }); + expect(within(dialog).getByText("/data/personal")).toBeTruthy(); + expect(within(dialog).getByText("/config/server.env")).toBeTruthy(); + expect(within(dialog).getByText("123456")).toBeTruthy(); + expect( + within(dialog).getByText(localText(language, "classification_missing")), + ).toBeTruthy(); + expect(within(dialog).getByText(localText(language, "new"))).toBeTruthy(); + expect(opsApi.apply).not.toHaveBeenCalled(); + await user.click( + within(dialog).getByRole("button", { + name: localText(language, "approve"), + }), + ); + await screen.findByText(localText(language, "succeeded")); + expect(opsApi.preflight).toHaveBeenCalledWith({ + operation: "install", + options: { httpPort: 8123, startOnLogin: true }, + }); + expect(opsApi.apply).toHaveBeenCalledWith({ + approvalId: "approval-a", + planId: "plan-a", + }); + }, +); +test("dismissing a plan and switching to an existing Server never apply it", async () => { + const user = await open(); + await user.click( + screen.getByRole("button", { name: "Install local Server" }), + ); + await screen.findByRole("dialog"); + await user.keyboard("{Escape}"); + await user.click( + screen.getByRole("button", { name: "Connect an existing Server" }), + ); + expect(opsApi.apply).not.toHaveBeenCalled(); +}); +test.each([ + "repair", + "start", + "stop", + "restart", + "register", + "remove_registration", +] as const)( + "management %s uses the approved producer operation", + async (operation) => { + facts = installed; + const user = await open(); + await user.click( + screen.getByRole("button", { name: localText("en", operation) }), + ); + const dialog = await screen.findByRole("dialog"); + expect( + within(dialog).getByText(localText("en", "preservation")), + ).toBeTruthy(); + await user.click( + within(dialog).getByRole("button", { name: "Approve and execute" }), + ); + await waitFor(() => expect(opsApi.apply).toHaveBeenCalledOnce()); + expect(opsApi.preflight).toHaveBeenCalledWith({ + operation, + options: operation === "register" ? { startOnLogin: true } : {}, + }); + }, +); +test("reopening reconciles an interrupted operation and recovery keeps its identity and options", async () => { + references.operations = [ + { + operationId: "operation-a", + bindingStatus: "current", + operation: "install", + options: { httpPort: 8123, startOnLogin: false }, + }, + ]; + status = result("pending"); + vi.mocked(opsApi.resume).mockImplementation(async () => { + status = result("succeeded", 2); + return status; + }); + const user = await open(); + const resume = screen.getByRole("button", { name: "Review recovery plan" }); + await waitFor(() => + expect((resume as HTMLButtonElement).disabled).toBe(false), + ); + expect(opsApi.apply).not.toHaveBeenCalled(); + expect(opsApi.resume).not.toHaveBeenCalled(); + await user.click(resume); + expect(opsApi.preflight).toHaveBeenCalledWith({ + operation: "install", + options: { httpPort: 8123, startOnLogin: false }, + resume: { operationId: "operation-a", expectedAttempt: 1 }, + }); + await user.click( + (await screen.findByRole("dialog")).querySelectorAll("button")[1], + ); + await waitFor(() => + expect(opsApi.resume).toHaveBeenCalledWith({ + approvalId: "approval-a", + planId: "plan-a", + }), + ); + expect(opsApi.apply).not.toHaveBeenCalled(); +}); +test("uncertain live executor and legacy records are query-only, never blindly resumed", async () => { + references.operations = [ + { operationId: "operation-a", bindingStatus: "current" }, + ]; + status = result("pending"); + status.status!.executionState = "unknown"; + await open(); + await screen.findByText("No terminal result yet"); + expect( + ( + screen.getByRole("button", { + name: "Review recovery plan", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect(opsApi.resume).not.toHaveBeenCalled(); +}); +test("progress is bound to the approved request and cancellation does not imply completion", async () => { + status = result("pending"); + status.status!.executionState = "unknown"; + const user = await open(); + let finish!: (reply: OpsOperationReply) => void; + vi.mocked(opsApi.apply).mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + vi.mocked(opsApi.cancel).mockImplementation(async () => { + status = result("pending"); + status.status!.cancelRequested = true; + return status; + }); + await user.click( + screen.getByRole("button", { name: "Install local Server" }), + ); + await screen.findByRole("dialog"); + await user.click(screen.getByRole("button", { name: "Approve and execute" })); + await act(async () => + progress({ + approvalId: "old-approval", + requestId: "old", + operationId: "other", + attempt: 1, + accepted: true, + sequence: null, + phase: null, + }), + ); + expect( + ( + screen.getByRole("button", { + name: "Request cancellation", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + await act(async () => + progress({ + approvalId: "approval-a", + requestId: "request-a", + operationId: "operation-a", + attempt: 1, + accepted: true, + sequence: 1, + phase: "preparing", + }), + ); + await user.click( + screen.getByRole("button", { name: "Request cancellation" }), + ); + expect(opsApi.cancel).toHaveBeenCalledWith({ + operationId: "operation-a", + attempt: 1, + }); + await screen.findByText(localText("en", "cancelHint")); + expect(screen.queryByText("Operation succeeded")).toBeNull(); + await act(async () => finish(result("cancelled"))); +}); +test.each([ + "port_conflict", + "existing_independent", + "ownership_conflict", + "database_migration_required", + "resource_corrupt", + "stale_approval", +])("actionable failure %s is shown without applying", async (code) => { + vi.mocked(opsApi.preflight).mockRejectedValue(code); + const user = await open(); + await user.click( + screen.getByRole("button", { name: "Install local Server" }), + ); + await screen.findByRole("alert"); + expect(opsApi.apply).not.toHaveBeenCalled(); +}); +test("missing distribution resources keep the existing-Server path available", async () => { + references.bindingAvailable = false; + vi.mocked(opsApi.inspect).mockResolvedValue({ + outcome: "unknown", + bridgeError: "unavailable", + }); + render(); + await screen.findByText("Local installation resources unavailable"); + expect( + ( + screen.getByRole("button", { + name: "Install local Server", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect( + ( + screen.getByRole("button", { + name: "Connect an existing Server", + }) as HTMLButtonElement + ).disabled, + ).toBe(false); +}); +test("local connection handoff is explicit and its readiness effect is disclosed", async () => { + facts = installed; + const connect = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button", { + name: "Connect and open default Scope", + }); + await waitFor(() => + expect((button as HTMLButtonElement).disabled).toBe(false), + ); + expect(connect).not.toHaveBeenCalled(); + await user.click(button); + const confirmation = await screen.findByRole("alertdialog"); + expect( + within(confirmation).getByText(/may probe models and dependencies/), + ).toBeTruthy(); + await user.click( + within(confirmation).getByRole("button", { name: "Confirm" }), + ); + expect(connect).toHaveBeenCalledOnce(); +}); + +test("a lost reply without progress discovers the new durable reference instead of replaying", async () => { + const old = { ...result(), operationId: "old-operation" }; + references.operations = [ + { + operationId: "old-operation", + bindingStatus: "current", + operation: "stop", + options: {}, + }, + ]; + vi.mocked(opsApi.operationStatus).mockImplementation( + async ({ operationId }) => (operationId === "old-operation" ? old : status), + ); + vi.mocked(opsApi.apply).mockImplementation(async () => { + references = { + ...references, + operations: [ + ...references.operations, + { + operationId: "operation-a", + bindingStatus: "current", + operation: "install", + options: { httpPort: 8123 }, + }, + ], + }; + status = result("pending"); + throw "timeout"; + }); + const user = await open(); + await screen.findByText("Operation succeeded"); + await user.click( + screen.getByRole("button", { name: "Install local Server" }), + ); + await screen.findByRole("dialog"); + await user.click(screen.getByRole("button", { name: "Approve and execute" })); + await screen.findByText("No terminal result yet"); + expect( + (screen.getByLabelText("Select recorded operation") as HTMLSelectElement) + .value, + ).toBe("operation-a"); + expect(opsApi.apply).toHaveBeenCalledOnce(); + expect(opsApi.resume).not.toHaveBeenCalled(); + expect( + ( + screen.getByRole("button", { + name: "Install local Server", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); +}); +test("a cancelled application context guard cannot dispatch the approved service mutation", async () => { + const user = userEvent.setup(); + render( false} />); + await waitFor(() => + expect( + ( + screen.getByRole("button", { + name: "Stop service", + }) as HTMLButtonElement + ).disabled, + ).toBe(false), + ); + await user.click(screen.getByRole("button", { name: "Stop service" })); + await screen.findByRole("dialog"); + await user.click(screen.getByRole("button", { name: "Approve and execute" })); + expect(opsApi.apply).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeTruthy(); +}); + +test("integrity inspection failure is distinct from missing distribution resources", async () => { + references.bindingAvailable = false; + vi.mocked(opsApi.inspect).mockResolvedValue({ + outcome: "unknown", + bridgeError: "integrity_failure", + }); + render(); + await screen.findByText(localText("en", "integrity_failure")); + expect( + screen.queryByText("Local installation resources unavailable"), + ).toBeNull(); +}); +test("a definite cancelled attempt does not claim its outcome is unconfirmed", async () => { + references.operations = [ + { + operationId: "operation-a", + bindingStatus: "current", + operation: "stop", + options: {}, + }, + ]; + status = result("cancelled"); + await open(); + await screen.findByText("Cancelled"); + expect(screen.queryByText(localText("en", "uncertainHint"))).toBeNull(); +}); +test.each([ + "configuration_invalid", + "configuration_not_owned", + "runtime_damaged", + "registration_missing", + "option_unsupported", + "integrity_failure", + "timeout", +])("definite rejection %s has a specific recovery hint", async (code) => { + vi.mocked(opsApi.preflight).mockRejectedValue(code); + const user = await open(); + await user.click( + screen.getByRole("button", { name: "Install local Server" }), + ); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).not.toContain("Unknown or unconfirmed"); + expect(opsApi.apply).not.toHaveBeenCalled(); +}); + +test("lost progress still permits discovering and cancelling the active durable operation", async () => { + references.operations = [ + { + operationId: "old-operation", + bindingStatus: "current", + operation: "stop", + options: {}, + }, + ]; + const old = { ...result(), operationId: "old-operation" }; + vi.mocked(opsApi.operationStatus).mockImplementation( + async ({ operationId }) => (operationId === "old-operation" ? old : status), + ); + vi.mocked(subscribeOpsProgress).mockRejectedValue("unavailable"); + let finish!: (reply: OpsOperationReply) => void; + vi.mocked(opsApi.apply).mockImplementation(() => { + status = result("pending"); + status.status!.executionState = "unknown"; + references = { + ...references, + operations: [ + ...references.operations, + { + operationId: "operation-a", + bindingStatus: "current", + operation: "install", + options: {}, + }, + ], + }; + return new Promise((resolve) => { + finish = resolve; + }); + }); + vi.mocked(opsApi.cancel).mockImplementation(async () => status); + const user = await open(); + await screen.findByText("Operation succeeded"); + await user.click( + screen.getByRole("button", { name: "Install local Server" }), + ); + await screen.findByRole("dialog"); + await user.click(screen.getByRole("button", { name: "Approve and execute" })); + await user.click(screen.getByRole("button", { name: "Inspect local state" })); + const cancel = screen.getByRole("button", { name: "Request cancellation" }); + await waitFor(() => + expect((cancel as HTMLButtonElement).disabled).toBe(false), + ); + await user.click(cancel); + expect(opsApi.cancel).toHaveBeenCalledWith({ + operationId: "operation-a", + attempt: 1, + }); + await act(async () => { + status = result("cancelled"); + finish(status); + }); +}); + +test("deferred progress subscriptions are cleaned up across StrictMode remount and window close", async () => { + const active = new Set<(event: OpsProgress) => void>(); + const register: Array<() => void> = []; + vi.mocked(subscribeOpsProgress).mockImplementation( + (callback) => + new Promise((resolve) => { + register.push(() => { + active.add(callback); + resolve(() => { + active.delete(callback); + }); + }); + }), + ); + const view = render( + + + , + ); + await waitFor(() => expect(register.length).toBeGreaterThan(0)); + await act(async () => { + register.forEach((complete) => complete()); + }); + expect(active.size).toBe(1); + view.unmount(); + expect(active.size).toBe(0); +}); + +test("dismissing a plan during an awaited context guard revokes its execution consent", async () => { + let release!: (answer: boolean) => void; + const user = userEvent.setup(); + render( + + new Promise((resolve) => { + release = resolve; + }) + } + />, + ); + await waitFor(() => + expect( + ( + screen.getByRole("button", { + name: "Install local Server", + }) as HTMLButtonElement + ).disabled, + ).toBe(false), + ); + await user.click( + screen.getByRole("button", { name: "Install local Server" }), + ); + await screen.findByRole("dialog"); + await user.click(screen.getByRole("button", { name: "Approve and execute" })); + await user.keyboard("{Escape}"); + await act(async () => release(true)); + expect(opsApi.apply).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).toBeNull(); +}); diff --git a/docs/en/development/desktop-local-server.md b/docs/en/development/desktop-local-server.md new file mode 100644 index 0000000000..c9af812852 --- /dev/null +++ b/docs/en/development/desktop-local-server.md @@ -0,0 +1,30 @@ +--- +title: "Desktop local Server setup" +description: "Approved installation, durable recovery and independent service management in Desktop." +--- + +# Desktop local Server setup + +The complete local workflow lives under Settings & diagnostics → Local Server. The first-use entry also offers existing-Server connection, which never mutates local installation. Installation and service rules remain owned by independent Ops; Desktop translates the typed contract and never executes renderer-supplied commands or plans. + +## Plans and execution + +Every install/repair/service change requires preflight, review and explicit execution. Native-owned plans expire after ten minutes and revalidate resource/binding/state identity. The preview shows actual program/config/data locations, endpoint, Server version, required bytes, login-start setting and start/readiness effects. New personal installations follow RFC 1733: loopback, SQLite/user data and model-free basic Memory. Existing choices and data are preserved. The login-start choice is offered only where the OS adapter supports it; unsupported choices are not silently substituted. + +Progress phases are observations, not persistence evidence or an ETA. The UI separates installation, configuration, registration, process, liveness and readiness. It keeps long operations in the app-level controller across page changes. Passive refresh only reinspects; readiness is invoked by an approved start/install or explicit connection handoff. + +## Recovery + +The native reference store adds optional approved `operation`/`options` to its existing version-1 records; Ops journals remain authoritative for outcomes and attempts. Existing records load without fabricated metadata and remain query-only. Older bridge binaries that reject these additive fields cannot read newly written references; do not downgrade a controlling bridge during recovery. Preserve the store and use the matching/newer bridge. + +The selected operation is queried on reopen. Only that selection is queried, avoiding thousands of automatic helper processes for retained history. Status and cancellation remain available during an active mutation. Cancellation does not imply rollback. Terminal failure or confirmed executor interruption can offer a fresh preflight/resume using the original action/options and expected attempt. Unknown execution never automatically resumes. A lost reply refreshes durable references; it never reruns apply. Foreign bindings, ownership, occupied ports, damaged resources and incompatible databases remain distinct producer errors with supported next actions. + +## Connection and removal + +Explicit connection handoff reinspects native ownership and resources and uses the actual endpoint. The pinned resource's `qualification_ref` must equal a bundled Desktop compatibility profile ID for the current HTTP contract; its resource digest must match inspection. Distribution owners set this mapping only after qualification. The resource `source_refs` must contain `git+@` and `wheel-sha256:`, matching that profile's original evidence; the same API contract alone cannot qualify another build. An `unqualified-candidate:...` resource never establishes compatibility. Discovery must report the pinned Server package version. Default Scope is resolved with `get_default_scope` and selected by its returned exact ID. No raw Scope ID or copied local token is required. Existing remote/authenticated profiles remain independent; protected configurations can use the ordinary connection editor. Saved profiles are reused only for the same endpoint and qualification; the app does not silently rewrite independently saved targets or historical qualification choices. The existing 20-profile limit remains; remove unused Desktop profiles through Connections when necessary, without stopping the Server or deleting data. + +Stop, registration removal, Desktop removal and data deletion have different effects. This UI offers stop and registration removal, preserves configuration/data, and provides no data-deletion action. OS-managed service lifetime is independent of Desktop. Reopen after Quit reinspects authoritative operation state and does not activate a private session. + +## Validation boundary + +UI tests use mocked native IPC. Native connection tests use synthetic HTTP discovery and Scope responses. Bridge tests use disposable helper processes and private reference stores. These checks establish local contract behavior, not signed Windows 11/macOS clean-machine installation, login/reboot, IME or installed screen-reader/credential acceptance. Those require exact published resources and final packages under #2108/#2109/#2112; retain #2117 until its complete installed acceptance is linked. diff --git a/docs/zh/development/desktop-local-server.md b/docs/zh/development/desktop-local-server.md new file mode 100644 index 0000000000..a7c3dce7bc --- /dev/null +++ b/docs/zh/development/desktop-local-server.md @@ -0,0 +1,30 @@ +--- +title: "Desktop 本地 Server 设置" +description: "Desktop 中明确批准的安装、持久恢复与独立服务管理。" +--- + +# Desktop 本地 Server 设置 + +完整本地流程位于“设置与诊断 → Local Server”。首次使用也提供已有 Server 连接路径,选择该路径不会修改本地安装。安装和服务规则由独立 Ops 拥有;Desktop 只转换类型化契约,不执行渲染进程提供的任意命令或原始计划。 + +## 计划与执行 + +每次安装、修复和服务修改都需要预检、查看计划及明确执行。原生计划有效期为十分钟,并重新校验资源、绑定和状态身份。预览显示实际程序、配置、数据位置、地址、Server 版本、所需字节、登录启动设置及启动和就绪探测影响。新个人安装遵循 RFC 1733:loopback、SQLite/用户数据和无需模型的基础 Memory。保留已有选择和数据。仅在系统适配器支持时提供登录启动选项,不静默替换不受支持的选择。 + +进度阶段只是观察,不是持久化证据或完成时间估计。界面分别显示安装、配置、注册、进程、存活和就绪状态。应用级控制器在切换页面时保留长操作。普通刷新只重新检查;明确批准的启动、安装或连接交接才可触发就绪探测。 + +## 恢复 + +原生引用文件在既有版本 1 记录中增加可选的已批准 `operation`/`options`;结果和尝试仍以 Ops 日志为准。已有记录不会虚构元数据,仍可查询。拒绝新增字段的旧 bridge 无法读取新写入的引用,恢复期间不要降级控制 bridge;保留引用文件并使用匹配或更新的 bridge。 + +重开后查询所选操作。只查询该选择,避免对大量历史引用自动启动数千个辅助进程。执行期间仍可查询状态和请求取消。取消不代表回滚。终态失败或确认执行进程中断后,可按原操作、选项和预期尝试重新预检并批准恢复。执行状态未知时不自动恢复。响应丢失后重新读取持久引用,不重复 apply。其他绑定、归属冲突、端口占用、资源损坏和数据库不兼容保留生产者的独立错误及可行下一步。 + +## 连接与移除 + +明确连接时重新检查原生归属和资源,使用实际地址。固定资源的 `qualification_ref` 必须等于内置且匹配当前 HTTP 契约的 Desktop 兼容配置 ID,资源摘要须匹配检查结果。发行方只能在资格验证后设置该映射。资源的 `source_refs` 必须包含与该记录原始证据一致的 `git+<仓库>@<已验证 Server 提交>` 和 `wheel-sha256:<已验证 Server wheel 摘要>`;相同 API 契约不能把另一构建视为已验证;`unqualified-candidate:...` 资源不能建立兼容性。发现接口返回的 Server 包版本须匹配固定资源版本。通过 `get_default_scope` 解析默认 Scope,再按返回的精确 ID 选择,无需手填 Scope ID 或复制本地 token。已有远程和认证连接保持独立,受保护配置可使用普通连接编辑器。仅复用地址和兼容配置完全相同的已保存连接,不会静默改写独立配置的目标或历史资格选择。保留既有的 20 个连接上限;必要时在连接页面移除不用的桌面配置,不会停止 Server 或删除数据。 + +停止服务、移除注册、移除 Desktop 和删除数据具有不同影响。本界面提供停止和移除注册,保留配置和数据,不提供删除数据操作。系统管理的服务独立于 Desktop 存活。退出后重开检查权威操作状态,不激活私有会话。 + +## 验证边界 + +UI 测试使用模拟的原生 IPC;原生连接测试使用合成 HTTP 发现和 Scope 响应;bridge 测试使用临时辅助进程和私有引用文件。这些验证本地契约行为,不等于已签名 Windows 11/macOS 干净机器安装、登录/重启、输入法、安装版读屏和凭据验收。后者由 #2108/#2109/#2112 使用确切发布资源和最终安装包验证;完整安装验收记录关联前保留 #2117。 diff --git a/tests/test_desktop_contract.py b/tests/test_desktop_contract.py index bbd5564736..c73eca76f0 100644 --- a/tests/test_desktop_contract.py +++ b/tests/test_desktop_contract.py @@ -46,3 +46,17 @@ def test_desktop_contract_identity_matches_openapi(manifest: str) -> None: actual = match.group(1) assert actual == expected, f"Stale Desktop contract identity in {manifest}; run 'pnpm --dir desktop generate'." + + +def test_local_setup_catalog_digest_matches_the_ops_model(tmp_path: Path) -> None: + """The Rust handoff must accept catalog defaults normalized by actual Ops.""" + from powercontext.ops.resources import ResourceCatalog + + fixture = REPOSITORY_ROOT / "desktop/src-tauri/tests/fixtures/local-setup-catalog.json" + content = fixture.read_bytes() + catalog = tmp_path / "catalog.json" + catalog.write_bytes(content) + catalog.chmod(0o600) + resource = ResourceCatalog.read(catalog, hashlib.sha256(content).hexdigest()).resources[0] + assert resource.files[-1].executable is False + assert resource.digest == "1a41e4f4b604815d6b6ed2a76679786a1f5795aed2549542aeeb643094f12c7e" From fe390ee7f41295b5b2aa46b290cfbcb643189ad6 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Fri, 30 Oct 2026 11:01:40 +0800 Subject: [PATCH 05/10] test(zcode): separate Stop deadline from Server completion --- tests/e2e/test_zcode_service_chain.py | 51 ++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/tests/e2e/test_zcode_service_chain.py b/tests/e2e/test_zcode_service_chain.py index b01c89ccd7..21ec8b4a23 100644 --- a/tests/e2e/test_zcode_service_chain.py +++ b/tests/e2e/test_zcode_service_chain.py @@ -16,6 +16,7 @@ from __future__ import annotations +import asyncio import json import os import shutil @@ -45,7 +46,11 @@ class DeterministicSourcePipeline: + def __init__(self, delay: float = 0) -> None: + self.delay = delay + async def generate(self, request: AtomicMemoryExtractionInput, /) -> GenerationResult[AtomicMemoryExtractionOutput]: + await asyncio.sleep(self.delay) return GenerationResult( output=AtomicMemoryExtractionOutput( candidates=tuple( @@ -86,7 +91,10 @@ def _invoke_hook( return json.loads(result.stdout) -def test_zcode_source_is_processed_and_recalled_in_a_new_session(tmp_path: Path, monkeypatch) -> None: +@pytest.mark.parametrize("processing_delay", [0, 1.2], ids=["fast", "beyond-stop-budget"]) +def test_zcode_source_is_processed_and_recalled_in_a_new_session( + tmp_path: Path, monkeypatch, processing_delay: float +) -> None: monkeypatch.setenv("ZCODE_PLUGIN_DATA", str(tmp_path / "plugin-data")) node = shutil.which("node") if node is None: @@ -95,7 +103,7 @@ def test_zcode_source_is_processed_and_recalled_in_a_new_session(tmp_path: Path, settings=ServerSettings( database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'zcode.db'}"), mcp=McpConfig(enabled=False) ), - candidate_pipeline=atomic_memory_pipeline(DeterministicSourcePipeline()), + candidate_pipeline=atomic_memory_pipeline(DeterministicSourcePipeline(processing_delay)), ) with socket.socket() as socket_probe: socket_probe.bind(("127.0.0.1", 0)) @@ -234,13 +242,40 @@ def run_scope(action: str, *extra: str) -> dict[str, Any]: ) assert status.returncode == 0, status.stdout + status.stderr result = json.loads(status.stdout) - assert result["observation"]["stages"]["flush"]["state"] == "cursor_reached", result - assert result["pending"]["scopes"] == [] - entries = client.post("/v1/memory/entries/list", json={"scope_id": scope_id}) - entries.raise_for_status() - assert entries.json()["entries"] + flush = result["observation"]["stages"]["flush"] + assert result["observation"]["config"]["request_timeout_ms"] == 800 + assert result["observation"]["config"]["hook_budget_ms"] == 1000 + if processing_delay: + assert flush["state"] == "unknown", result + if flush["state"] == "unknown": + # The bounded Stop can time out while real Server processing continues. + # Preserve the durable pause; reading results must never replay the flush. + assert flush["code"] == "timeout", result + assert flush["source_position"] > 0 + assert result["pending"]["scopes"] == [ + { + "scope_id": scope_id, + "receipt_count": 1, + "target_position": flush["source_position"], + "tracking_incomplete": False, + "unknown_flush_paused": True, + } + ], result + else: + assert flush["state"] == "cursor_reached", result + assert flush["current_cursor"] >= flush["source_position"] + assert result["pending"]["scopes"] == [] + # Server completion has its own budget, independent of the host Hook deadline. + deadline = time.monotonic() + 15 + while True: + entries = client.post("/v1/memory/entries/list", json={"scope_id": scope_id}) + entries.raise_for_status() + generated = next((entry for entry in entries.json()["entries"] if "teal-731" in entry["text"]), None) + if generated is not None: + break + assert time.monotonic() < deadline, entries.json() + time.sleep(0.05) assert all(entry["artifact"]["family"] == "atomic-memory" for entry in entries.json()["entries"]) - generated = next(entry for entry in entries.json()["entries"] if "teal-731" in entry["text"]) captured = client.get(f"/v1/scopes/{scope_id}/sources").json()["items"] reference = generated["artifact"] revision = client.get( From 47790aaf98593cb790bfa8c855a0f2087fb38e2f Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 31 Oct 2026 13:34:25 +0800 Subject: [PATCH 06/10] fix(desktop): retain drafts across delayed local handoffs --- desktop/README.md | 4 +- desktop/README.zh.md | 4 +- desktop/SECURITY.md | 2 + desktop/VALIDATION.md | 7 + desktop/ui/src/app/App.tsx | 82 +++++++-- desktop/ui/src/app/MemoryWorkspace.tsx | 39 +++-- desktop/ui/src/app/memory-messages.ts | 8 +- desktop/ui/tests/local-handoff.test.tsx | 75 ++++++++ desktop/ui/tests/memory.test.tsx | 220 ++++++++++++++++++++++++ 9 files changed, 409 insertions(+), 32 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index 2f88f8a99b..8d67703451 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -112,7 +112,7 @@ Qualification still requires an explicitly selected native record matching the b Connection checks bind the observed `server_id` to the Principal and recheck the deployment after collecting connection facts. Scope listing/default lookup/selection and Memory search/exact reads revalidate deployment and Principal before dispatch and before returning private results. A changed identity, failed discovery, or disappearance of discovery clears the active context, invalidating Scope selections, cursors and pending reads. Reconnect explicitly to establish a new context. Package version changes alone do not change deployment identity. -Submitted writes retain their original endpoint, `serverId` (null for qualified legacy Servers), Principal, Scope and generation. If the post-write identity check fails, a successful HTTP reply is treated as an unknown outcome: private bodies/references are withheld and the write is never automatically replayed. The original deployment is shown in write recovery feedback. The submitted input survives that disconnection as a read-only recovery draft in the current window, with explicit copy/discard actions; it is never inserted into a new target's form or persisted. Closing the window loses it. Confirmed navigation discards it, and a late response cannot restore an explicitly discarded draft. A late write cannot invalidate a newly activated context. Qualified legacy sessions require discovery to remain 404; discovery appearing requires explicit rechecking rather than silently acquiring a deployment identity. A failed connection recheck removes the previous cached report instead of leaving old verification visible. +Submitted writes retain their original endpoint, `serverId` (null for qualified legacy Servers), Principal, Scope and generation. If the post-write identity check fails, a successful HTTP reply is treated as an unknown outcome: private bodies/references are withheld and the write is never automatically replayed. The original deployment is shown in write recovery feedback. Each submitted input is retained before native dispatch, independently of the form lifetime. Automatic read disconnection, rejected writes and pre-dispatch retry failure preserve the original and edited retry inputs as separate read-only recovery drafts in the current window, with explicit copy/discard actions; it is never inserted into a new target's form or persisted. Closing the window loses these drafts. A successful receipt clears only its own draft. Confirmed navigation discards all retained inputs, and a late response cannot restore an explicitly discarded draft. A late write cannot invalidate a newly activated context. Qualified legacy sessions require discovery to remain 404; discovery appearing requires explicit rechecking rather than silently acquiring a deployment identity. A failed connection recheck removes the previous cached report instead of leaving old verification visible. These checks observe identity around requests; the public mutation contract has no conditional Server-identity precondition. They cannot atomically prevent an endpoint from changing between identity verification and dispatch, or detect a replacement that changes back between observations. Legacy Servers have no durable deployment fence. Discovery does not attest binary identity. Connection checks and anonymous identity revalidation still perform readiness probes; explicit readiness separation remains pending. @@ -214,3 +214,5 @@ are unsuitable. The CLI must not depend on a shell profile or an interactive ter Diagnostics keep the existing output limits and deadlines and terminate their private process group on completion, timeout and cancellation. Before normal application exit, Desktop rejects new diagnostics, cancels active work and waits for process-group cleanup and launcher reaping. Read-only diagnostics do not manage LaunchAgents; explicit Local Server actions use trusted independent Ops. + +A pending local connection handoff may update the observed connection state after navigation. It opens Memories only while the initiating UI intent remains current; later navigation or editing keeps the current page, input and discard guard intact. diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 1c8fa88783..4d3a7f796b 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -90,7 +90,7 @@ pnpm --dir desktop desktop:dev 连接检查将观察到的 `server_id` 与 Principal 绑定,并在收集连接事实后再次核对部署。Scope 列举、默认范围查找、范围选择,以及记忆搜索和精确读取,均在发送请求前和返回私有结果前复核部署及 Principal。身份变化、发现失败或发现接口消失时,会清除活动上下文,使 Scope 选择、游标和进行中的读取失效;须明确重新连接以建立新上下文。仅软件包版本变化不视为部署变化。 -已提交写入保留原始端点、`serverId`(已验证旧版 Server 为 null)、Principal、Scope 和代次。写入后的身份复核失败时,即使 HTTP 返回成功也按结果未知处理:不返回私有正文或引用,不自动重放。写入恢复反馈显示原部署;断连后原始输入在当前窗口内作为只读恢复草稿保留,可明确复制或丢弃,不会填入新目标的表单,也不持久化;关闭窗口会丢失草稿。确认离开页面会丢弃草稿,晚到响应不能恢复已明确丢弃的输入。旧写入晚到不会使新激活的上下文失效。已验证旧版会话要求发现接口持续返回 404;发现接口出现后须明确重新检查,不能静默获得部署身份。连接复查失败会移除旧缓存报告,避免继续显示过期的验证结果。 +已提交写入保留原始端点、`serverId`(已验证旧版 Server 为 null)、Principal、Scope 和代次。写入后的身份复核失败时,即使 HTTP 返回成功也按结果未知处理:不返回私有正文或引用,不自动重放。写入恢复反馈显示原部署;每次输入在原生调用前独立保留,不依赖表单生命周期;自动读取断连、写入拒绝及发送前重试失败后,原始输入和编辑后的重试输入分别在当前窗口内作为只读恢复草稿保留,可明确复制或丢弃,不会填入新目标的表单,也不持久化;关闭窗口会丢失草稿。成功回执仅清除对应输入;确认离开页面会丢弃所有保留的输入,晚到响应不能恢复已明确丢弃的输入。旧写入晚到不会使新激活的上下文失效。已验证旧版会话要求发现接口持续返回 404;发现接口出现后须明确重新检查,不能静默获得部署身份。连接复查失败会移除旧缓存报告,避免继续显示过期的验证结果。 这些检查观察请求前后的身份;公开写入契约没有以 Server 身份为条件的前置约束,因此不能原子性阻止验证与发送之间的端点替换,也不能发现两次观察之间发生后又恢复的替换。旧版 Server 没有持久部署隔离保障;发现信息也不证明二进制身份。连接检查和匿名身份复核仍会调用 readiness,显式 readiness 分离尚未完成。 @@ -178,3 +178,5 @@ pnpm desktop:build 构建产物是未签名的内部预览。Developer ID 签名、公证、干净机器上的 Gatekeeper、辅助功能权限、真实输入法输入以及安装版钥匙串弹窗仍需单独验收。macOS CI 任务会测试 Apple Silicon 和 Intel 构建,但任务本身的存在不代表运行已通过。 macOS 本机诊断读取 `~/Library/Application Support/com.powercontext.desktop.preview/diagnostic-cli.json`。登记与 Windows 使用相同的 JSON 契约:绝对路径的 `powercontext` 可执行文件、经核对的 SHA-256 摘要和 `source: "explicit_local_installation"`。渲染进程无法提供可执行文件或参数。原生适配器只执行经过核验的 launcher 的私有只读副本。请使用自包含可执行文件,或解释器路径为绝对路径的 launcher;以自身位置查找依赖的 launcher 不适用。CLI 不能依赖 shell 配置或交互式终端。诊断沿用既有的输出上限与超时,并在完成、超时和取消时终止自己的私有进程组。应用正常退出前会拒绝新诊断、取消当前诊断,并等待进程组清理和 launcher 回收完成。只读诊断不管理 LaunchAgent;明确批准的 Local Server 操作通过可信的独立 Ops 执行。 + +本地连接交接等待期间发生页面切换后,晚到结果仍可更新连接状态。只有发起交接时的界面意图仍然有效,才自动进入记忆页;后续导航或编辑会保留当前页面、输入及丢弃确认保护。 diff --git a/desktop/SECURITY.md b/desktop/SECURITY.md index 324b932d7b..eed6a28a98 100644 --- a/desktop/SECURITY.md +++ b/desktop/SECURITY.md @@ -76,3 +76,5 @@ platform evidence. ## Explicit local connection handoff `connect_local_server` accepts only the current context generation. It obtains the endpoint through trusted native Ops, requires an owned/current registration, active process, valid configuration, present runtime, responding service and installation identity. It matches the observed resource ID/digest to the pinned catalog and selects only its named bundled Desktop qualification for the current HTTP contract. Known qualification IDs also require the catalog source commit and `wheel-sha256:` reference to match the record's original Server source and qualification wheel; a matching API contract cannot reinterpret historical evidence. Candidate/unrecognized qualifications fail closed. The discovery package version, Principal, basic Memory operation set and exact public default Scope must pass before a local activation is published. Pending or failed qualification/Scope reads expose no active local context and cannot replace a newer context. Existing Bearer/CA profiles are not overwritten. Page opening and application reopen never invoke this command. + +Submitted input is retained in window-local application state before native IPC, independently of the form lifetime. Automatic disconnection, rejected writes and pre-dispatch retry failure do not discard it; each attempt has its own identity. Successful receipts clear only their own input, confirmed navigation clears all retained copies, and late receipts cannot revive explicitly discarded copies. Recovery never persists or automatically replays content. Local handoff completion preserves newer navigation and editor intent. diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index b3713f150b..b73dc2c003 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -714,3 +714,10 @@ The source Ops opt-in was separately executed with `POWERCONTEXT_OPS_TEST_PYTHON `make check`, Rust formatting/all-target Clippy with `-D warnings`, IPC export drift, frontend lint/typecheck/production build and `make docs-test` pass. The focused Python contract/fixture/Ops/installer/local-policy selection passes 89 tests with one platform skip. The commands at the top of this guide reproduce the checks; the setup behavior and supported recovery choices are documented in both locales under [Desktop local setup](../docs/en/development/desktop-local-server.md). UI tests mock native IPC; HTTP fixtures simulate Server responses. These results do not establish actual managed installation/apply, Windows execution, signed/notarized packages, clean-machine bootstrap, login/reboot, real IME, screen readers or installed credential/clipboard behavior. Product qualification records are unchanged. Required installed acceptance uses the exact published resources and final Windows/macOS packages under #2108/#2109/#2112; #2117 remains open until that evidence is linked. + + +## Local handoff and submitted-input retention + +Local macOS arm64 UI checks use Node 24.22.1 and pnpm 11.13.1; Node differs from the 24.14.1 engine pin. The 176 frontend tests pass, including English/Chinese deferred local handoff, preserved newer connection input and discard confirmation, navigation without editing, concurrent read disconnection before a write receipt, unknown-write retry failure, independent recovery copies, explicit discard and late success/unknown/rejected receipts. The three local-handoff regressions fail against the prior App implementation. IPC is mocked; these checks do not qualify installed WebView, clipboard, actual managed apply or service lifecycle behavior. + +本地 macOS arm64 使用 Node 24.22.1、pnpm 11.13.1,Node 与固定的 24.14.1 不同。176 项前端测试通过,覆盖中英文延迟本地交接、新连接输入及丢弃确认保护、未编辑时的页面导航、写入回执前并发读取断连、未知写入重试失败、独立恢复副本、明确丢弃和晚到的成功/未知/拒绝回执。三项本地交接回归在原 App 实现上失败。IPC 使用模拟响应;这些检查不代表安装版 WebView、剪贴板、真实受管安装或服务生命周期验收。 diff --git a/desktop/ui/src/app/App.tsx b/desktop/ui/src/app/App.tsx index 5f2e0a4d7b..fa376d1ef7 100644 --- a/desktop/ui/src/app/App.tsx +++ b/desktop/ui/src/app/App.tsx @@ -17,7 +17,11 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { messages, type Language } from "./messages"; import { getFoundationInfo, desktopApi } from "../shared/ipc"; -import type { FoundationInfo, DesktopState } from "../generated/ipc"; +import type { + FoundationInfo, + DesktopState, + WriteRecord, +} from "../generated/ipc"; import logo from "../../../src-tauri/icons/brand.png"; import homeIcon from "../assets/overview.svg"; import connectionsIcon from "../assets/connections.svg"; @@ -32,7 +36,7 @@ import { Overview } from "./Overview"; import { MemoryWorkspace, SubmittedDraftRecovery, - type UncertainDraft, + type SubmittedDraft, } from "./MemoryWorkspace"; import { LocalServerSettings } from "./LocalServerSettings"; import { Help } from "./Help"; @@ -64,9 +68,37 @@ export function App() { const [stateLoading, setStateLoading] = useState(true); const stateRequest = useRef(0); const [dirty, setDirty] = useState(false); - const [uncertainDraft, setUncertainDraft] = useState( - null, - ); + const uiIntent = useRef(0); + function editorDirty(next: boolean) { + uiIntent.current++; + setDirty(next); + } + const [submittedDrafts, setSubmittedDrafts] = useState([]); + const submissionSequence = useRef(0); + function beginSubmission(text: string, target: SubmittedDraft["target"]) { + const id = ++submissionSequence.current; + setSubmittedDrafts((current) => [ + ...current, + { id, text, target, phase: "pending" }, + ]); + return (record: WriteRecord | null) => { + // Update only a retained intent. Explicit discard removes its identity, + // so late receipts cannot revive it or clear a different submission. + setSubmittedDrafts((current) => + current.flatMap((draft) => { + if (draft.id !== id) return [draft]; + if (record?.status === "succeeded") return []; + return [ + { + ...draft, + target: record?.context ?? draft.target, + phase: record?.status ?? "unconfirmed", + }, + ]; + }), + ); + }; + } const [scopeOpen, setScopeOpen] = useState(false); const [refreshing, setRefreshing] = useState(false); const [connectionSelection, setConnectionSelection] = useState<{ @@ -94,11 +126,11 @@ export function App() { const { confirm, confirmation } = useConfirmation(language); async function confirmSwitch() { if ( - (dirty || uncertainDraft) && + (dirty || submittedDrafts.length > 0) && !(await confirm(connectionMessages[language].discard)) ) return false; - setUncertainDraft(null); + setSubmittedDrafts([]); return true; } const activeProfile = desktop?.profiles.find( @@ -143,6 +175,7 @@ export function App() { if (localServer) target?.scrollIntoView?.({ block: "start" }); }, [page, connectionSelection]); function openScopes(trigger: HTMLElement) { + uiIntent.current++; scopeReturnFocus.current = trigger; setScopeOpen(true); } @@ -155,6 +188,7 @@ export function App() { !(await confirmSwitch()) ) return; + uiIntent.current++; settingsSection.current = target?.section; setConnectionSelection((current) => target?.profileId === undefined @@ -354,22 +388,30 @@ export function App() { )} {page === "memories" && ( <> - {uncertainDraft && - uncertainDraft.record.context.generation !== - desktop?.active?.generation && ( + {submittedDrafts + .filter( + (draft) => + draft.target.generation !== desktop?.active?.generation, + ) + .map((draft) => ( setUncertainDraft(null)} + onDiscard={() => + setSubmittedDrafts((current) => + current.filter((item) => item.id !== draft.id), + ) + } /> - )} + ))} navigate("connections")} onOpenScopes={openScopes} onOpenLocalServer={() => @@ -386,7 +428,7 @@ export function App() { state={desktop} language={language} onState={receiveState} - onDirty={setDirty} + onDirty={editorDirty} onPendingChange={connectionPending} /> )} @@ -451,15 +493,21 @@ export function App() { return true; }} onConnect={async () => { + const intent = uiIntent.current; if (!desktop || connectingLocal || !(await confirmSwitch())) return; + if (intent !== uiIntent.current) return; setConnectingLocal(true); connectionPending(true); setNativeError(null); try { const next = await opsApi.connect(desktop.generation); const accepted = receiveState(next); - if (accepted && next.active?.scope) { + if ( + accepted && + next.active?.scope && + intent === uiIntent.current + ) { setDirty(false); setPage("memories"); } diff --git a/desktop/ui/src/app/MemoryWorkspace.tsx b/desktop/ui/src/app/MemoryWorkspace.tsx index 5b2c123e37..9fb84d8a65 100644 --- a/desktop/ui/src/app/MemoryWorkspace.tsx +++ b/desktop/ui/src/app/MemoryWorkspace.tsx @@ -42,12 +42,22 @@ type Props = { onOpenScopes?: (trigger: HTMLElement) => void; onOpenLocalServer?: () => void; contextPending?: boolean; - onUncertainDraft?: (draft: UncertainDraft | null) => void; + onSubmission?: ( + text: string, + target: SubmittedTarget, + ) => (record: WriteRecord | null) => void; +}; +type SubmittedTarget = Pick & + Partial>; +export type SubmittedDraft = { + id: number; + text: string; + target: SubmittedTarget; + phase: "pending" | "unknown" | "unconfirmed" | "failed"; }; -export type UncertainDraft = { text: string; record: WriteRecord }; export const textBytes = (value: string) => new TextEncoder().encode(value).length; -function WriteTarget({ context }: { context: MemoryContext }) { +function WriteTarget({ context }: { context: SubmittedTarget }) { return ( {context.endpoint} · {context.serverId && <>{context.serverId} · } @@ -61,7 +71,7 @@ export function SubmittedDraftRecovery({ language, onDiscard, }: { - draft: UncertainDraft; + draft: SubmittedDraft; language: Language; onDiscard: () => void; }) { @@ -80,9 +90,10 @@ export function SubmittedDraftRecovery({
{confirmation}

{t.recovery}

+

{t[draft.phase]}

{t.recoveryHint}

- {t.original}: + {t.original}: