diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 372949fef0..1e34a3644e 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -206,15 +206,28 @@ jobs: - name: Build unsigned macOS app and DMG run: | mkdir -p .artifacts - set -o pipefail - pnpm tauri build --ci --verbose -- --locked 2>&1 | tee .artifacts/macos-build.log + set -euo pipefail + : > .artifacts/macos-build.log + for attempt in 1 2 3; do + attempt_log=".artifacts/macos-build-attempt-${attempt}.log" + if pnpm tauri build --ci --verbose -- --locked 2>&1 | tee "$attempt_log" | tee -a .artifacts/macos-build.log; then + break + else + build_status=$? + fi + if [ "$attempt" -eq 3 ] || ! grep -Fq 'hdiutil: create failed - Resource busy' "$attempt_log"; then + exit "$build_status" + fi + echo "::warning::DMG creation found a busy resource; retrying after $((attempt * 5)) seconds (attempt $attempt of 3)." + sleep "$((attempt * 5))" + done - name: Upload failed macOS build diagnostics if: failure() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: desktop-macos-${{ matrix.runner }}-build-diagnostics path: | - desktop/.artifacts/macos-build.log + desktop/.artifacts/macos-build*.log desktop/src-tauri/target/release/bundle/dmg/bundle_dmg.sh if-no-files-found: warn - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 diff --git a/desktop/README.md b/desktop/README.md index 9c88623920..473a13a533 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -112,7 +112,13 @@ 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. 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. + +Desktop retains each submitted input in application memory from the start of the native call, independently of the form. An automatic disconnection during a concurrent read therefore leaves a read-only copy even while the write is pending. Recovery distinguishes pending, unknown, rejected and attempts without a receipt. A retry does not discard earlier uncertain input, including when that retry fails before its mutation reaches the Server. Each attempt has its own local identity: only a successful receipt clears its input, rejected saves keep their text for explicit recovery, and explicit discard removes only the selected copy (or all copies when confirming navigation). Late receipts cannot restore discarded inputs or clear another attempt. Inputs are limited to 8192 UTF-8 bytes per attempt; copies remain for pending, uncertain or rejected attempts until success, explicit discard or window close. They contain user input and observed original-target metadata, not returned private Server bodies, and are never replayed or persisted. + +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 e04dff40d5..b52b6cb88d 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -90,7 +90,13 @@ 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;发现接口出现后须明确重新检查,不能静默获得部署身份。连接复查失败会移除旧缓存报告,避免继续显示过期的验证结果。 + +应用从调用原生提交开始,在内存中独立保留每次输入,不依赖表单是否仍存在。因此,并发读取触发自动断连时,即使写入还在等待回执,仍有只读副本可恢复。恢复界面区分进行中、结果未知、保存被拒绝和未取得回执的尝试。重试不会丢弃先前的未知输入,包括重试的写请求尚未发出就失败的情况。每次尝试有独立的本地标识:成功回执只清理该次输入,保存被拒绝时仍保留正文供明确恢复,明确丢弃只移除所选副本,确认离开页面则丢弃所有副本;晚到回执不能恢复已丢弃输入或清除另一尝试。每次输入最多 8192 UTF-8 字节;尚未成功保存的尝试会保留副本,直到成功、明确丢弃或关闭窗口。副本仅含用户输入及观察到的原目标元数据,不含 Server 返回的私有正文,不重放,也不持久化。 + +这些检查观察请求前后的身份;公开写入契约没有以 Server 身份为条件的前置约束,因此不能原子性阻止验证与发送之间的端点替换,也不能发现两次观察之间发生后又恢复的替换。旧版 Server 没有持久部署隔离保障;发现信息也不证明二进制身份。连接检查和匿名身份复核仍会调用 readiness,显式 readiness 分离尚未完成。 连接页分别展示发现状态和兼容性结论,说明访问被拒绝、暂时不可用时的恢复方法,以及产品或 major 版本不兼容的原因。发现检查失败时业务操作保持不可用,但不会删除已选择的资格记录。修正连接或服务后,须明确重新检查连接,重新判定操作可用性。仅发现接口的 404 适用上述已验证旧版策略。 diff --git a/desktop/SECURITY.md b/desktop/SECURITY.md index e659b31880..23b2f1fe4e 100644 --- a/desktop/SECURITY.md +++ b/desktop/SECURITY.md @@ -16,7 +16,9 @@ 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. + +The renderer retains submitted input in window-local memory independently of form lifetime. Automatic disconnection does not discard it, and a retry cannot remove an earlier unresolved attempt. Recovery copies contain only user input and original-target metadata, never returned private Server bodies. Each copy is at most 8192 UTF-8 bytes plus metadata; retention grows with explicit user submissions awaiting success, recovery or discard. Successful receipts remove their own copy; rejected saves retain input for explicit recovery or discard; users can explicitly discard individual copies or all copies through navigation confirmation. Discarded identities cannot be revived by late receipts. Window close loses all copies. There is no disk persistence, background replay or export through session diagnostics. ## Budgets and error projection diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 4c479b5268..125befc238 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -20,6 +20,73 @@ 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. + +## Submission recovery checks + +On 2026-10-30, Windows 10 x64 / Rust 1.95.0 checks against baseline `90355b8e` cover +retry discovery failure with the original unknown receipt retained, and concurrent read disconnection +while a dispatched write is paused. Loopback TCP fixtures verify only the original mutation reaches +the Server, and that the pending receipt becomes unknown under its original operation identity. +All 89 native suite cases pass; proxy isolation additionally reruns its case in a spawned process. +All 146 frontend cases, TypeScript, lint, formatting, production build, Clippy and IPC drift checks pass. +Full App tests in English and Chinese verify retained original and edited retry inputs, recovery before +the write finishes, individual discard, late unknown receipts after discard, and conclusive late success +without restoring private results. TCP fixtures exercise the native Manager; jsdom tests mock IPC. +These checks do not establish installed WebView or live external Server acceptance of the new paths. + +## Rejected submission recovery and Python CI checks + +On 2026-10-31, Windows 10 x64 checks reproduce a paused save returning 403 after a concurrent +read disconnects the context. The native Manager retains the failed receipt and original target +without replay; the application keeps the rejected text available for explicit copy/discard. +English and Chinese App regressions verify copy, cancelled discard and explicit discard of rejected input. +All 90 native suite cases and 148 frontend tests pass, together with TypeScript, lint, production build, +Rust formatting, Clippy and IPC drift. Proxy isolation also reruns in a spawned process. +All 14 deployment-identity cases pass with real loopback sockets and synthetic HTTP responses; +App state-refresh tests use mocked IPC and do not qualify installed WebView behavior. + +Python 3.11.15 checks pass 38 cases across the affected MySQL admission/runner, worker logging +and ZCode service-chain modules; 57 runner cases are skipped by their platform/backend guards. +The admission fixture accepts the maintenance connection's missing-database keyword while +retaining its read-only assertion. The real worker test waits for a completion observation +before consuming captured descriptors. ZCode checks use Node 24.22.1 with a real HTTP Server +and SQLite, including a response deliberately delayed past Stop's unchanged 800 ms request +budget: the result remains unknown, another Stop does not replay the mutation, and independent +reads verify committed memory, exact provenance and recall in a new session. A normal response +also exercised the cursor-reached path. These local checks do not establish Linux/Python matrix +success; consult the new CI run for those results. + ## Native session observation checks Windows x64 checks for the native session collector use Rust 1.95.0, Node 24.14.1 and pnpm 11.13.1. diff --git a/desktop/src-tauri/examples/server_probe.rs b/desktop/src-tauri/examples/server_probe.rs index a6c9a44fb8..f53ae28a15 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") @@ -175,6 +175,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 94733a616c..0c7de8a4e0 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, @@ -103,6 +103,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, @@ -146,6 +147,7 @@ impl Drop for WriteGuard<'_> { } struct ReadSnapshot { api: Arc, + server_id: Option, identity: Option, cancelled: watch::Receiver, } @@ -310,6 +312,8 @@ impl ConnectionManager { .active .as_ref() .is_some_and(|a| a.view.connection_id == id); + // A failed recheck must not expose a previously verified report. + inner.reports.remove(id); if use_as_active { inner.active = None; self.invalidate(&mut inner)?; @@ -359,6 +363,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(), @@ -419,14 +434,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; @@ -441,8 +488,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 { @@ -451,10 +506,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()?; @@ -485,6 +537,7 @@ impl ConnectionManager { .user_operation(ObservedOperation::ListScopes, async { let ReadSnapshot { api, + server_id, identity, mut cancelled, } = self.snapshot(generation, "list_scopes")?; @@ -492,8 +545,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 @@ -505,6 +562,7 @@ impl ConnectionManager { .user_operation(ObservedOperation::GetDefaultScope, async { let ReadSnapshot { api, + server_id, identity, mut cancelled, } = self.snapshot(generation, "get_default_scope")?; @@ -512,8 +570,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 @@ -529,12 +591,17 @@ impl ConnectionManager { .user_operation(ObservedOperation::GetScope, async { 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()?; @@ -576,10 +643,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, @@ -613,6 +682,7 @@ impl ConnectionManager { let ( ReadSnapshot { api, + server_id, identity, mut cancelled, }, @@ -622,8 +692,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 @@ -640,6 +714,7 @@ impl ConnectionManager { let ( ReadSnapshot { api, + server_id, identity, mut cancelled, }, @@ -649,8 +724,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 @@ -668,6 +747,7 @@ impl ConnectionManager { let ( ReadSnapshot { api, + server_id, identity, mut cancelled, }, @@ -675,7 +755,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 { @@ -699,15 +779,25 @@ impl ConnectionManager { }; observation.write_dispatched(); 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..f9038ef919 --- /dev/null +++ b/desktop/src-tauri/tests/deployment_identity.rs @@ -0,0 +1,772 @@ +/* + * 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, + lose_write_response: bool, + reject_write: bool, +} +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![], + lose_write_response: false, + reject_write: false, + })); + 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, lose_response) = { + 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" if state.reject_write => { + (403, json!({"error":{"code":"forbidden"}})) + } + "/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)), + path == "/v1/memory/remember" && state.lose_write_response, + ) + }; + if pause { + entered.notify_one(); + resume.notified().await; + } + if lose_response { + return; + } + 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 failed_recheck_drops_the_previous_verified_report() { + for status in [200, 503] { + let fixture = Fixture::new(true).await; + fixture.activate().await; + assert!(!fixture.manager.state().unwrap().reports.is_empty()); + 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.state.lock().unwrap().discovery = (status, discovery("server-b")); + fixture.release.notify_one(); + assert!( + timeout(Duration::from_secs(5), check) + .await + .unwrap() + .unwrap() + .is_err() + ); + let state = fixture.manager.state().unwrap(); + assert!(state.active.is_none()); + assert!( + state + .reports + .iter() + .all(|report| report.connection_id != fixture.id) + ); + } +} + +#[tokio::test] +async fn post_write_discovery_503_disconnects_with_an_unknown_original_receipt() { + 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, "preserve me").await }); + fixture.entered().await; + fixture.state.lock().unwrap().discovery = (503, json!({"error":{"code":"runtime_not_ready"}})); + 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.as_ref().unwrap().code, + SafeError::RuntimeNotReady + ); + assert!(outcome.result.is_none()); + assert_eq!( + outcome.record.context.server_id.as_deref(), + Some("server-a") + ); + fixture.assert_disconnected(generation); + assert_eq!( + fixture + .manager + .state() + .unwrap() + .last_write + .unwrap() + .operation_id, + outcome.record.operation_id + ); +} + +#[tokio::test] +async fn retry_discovery_failure_preserves_the_original_unknown_receipt_without_dispatch() { + let fixture = Fixture::new(true).await; + let generation = fixture.select().await; + fixture.state.lock().unwrap().lose_write_response = true; + let original = fixture + .manager + .remember(generation, "original uncertain input") + .await + .unwrap(); + assert_eq!(original.record.status, WriteStatus::Unknown); + assert!(fixture.manager.state().unwrap().active.is_some()); + fixture.state.lock().unwrap().discovery = (503, json!({"error":{"code":"runtime_not_ready"}})); + let error = fixture + .manager + .remember(generation, "edited retry input") + .await + .err() + .unwrap(); + assert_eq!(error.code, SafeError::RuntimeNotReady); + fixture.assert_disconnected(generation); + let retained = fixture.manager.state().unwrap().last_write.unwrap(); + assert_eq!(retained.operation_id, original.record.operation_id); + assert_eq!(retained.status, WriteStatus::Unknown); + assert_eq!( + fixture + .state + .lock() + .unwrap() + .requests + .iter() + .filter(|path| *path == "/v1/memory/remember") + .count(), + 1 + ); +} + +#[tokio::test] +async fn concurrent_read_disconnect_keeps_the_pending_write_until_its_unknown_receipt() { + 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, "in-flight original input") + .await + }); + fixture.entered().await; + fixture.state.lock().unwrap().discovery = (503, json!({"error":{"code":"runtime_not_ready"}})); + let error = fixture + .manager + .search_memory(generation, "query") + .await + .err() + .unwrap(); + assert_eq!(error.code, SafeError::RuntimeNotReady); + fixture.assert_disconnected(generation); + let pending = fixture.manager.state().unwrap().last_write.unwrap(); + assert_eq!(pending.status, WriteStatus::Pending); + fixture.release.notify_one(); + let result = timeout(Duration::from_secs(5), write) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(result.record.status, WriteStatus::Unknown); + assert_eq!(result.record.operation_id, pending.operation_id); + assert!(result.result.is_none()); + assert_eq!( + fixture + .manager + .state() + .unwrap() + .last_write + .unwrap() + .operation_id, + pending.operation_id + ); + assert_eq!( + fixture + .state + .lock() + .unwrap() + .requests + .iter() + .filter(|path| *path == "/v1/memory/remember") + .count(), + 1 + ); +} + +#[tokio::test] +async fn rejected_write_after_read_disconnection_retains_its_failed_original_receipt() { + let fixture = Fixture::new(true).await; + let generation = fixture.select().await; + { + let mut state = fixture.state.lock().unwrap(); + state.pause = Some("/v1/memory/remember".into()); + state.reject_write = true; + } + let manager = fixture.manager.clone(); + let write = tokio::spawn(async move { + manager + .remember(generation, "rejected original input") + .await + }); + fixture.entered().await; + fixture.state.lock().unwrap().discovery = (503, json!({"error":{"code":"runtime_not_ready"}})); + assert!( + fixture + .manager + .search_memory(generation, "query") + .await + .is_err() + ); + fixture.assert_disconnected(generation); + let pending = fixture.manager.state().unwrap().last_write.unwrap(); + assert_eq!(pending.status, WriteStatus::Pending); + fixture.release.notify_one(); + let result = timeout(Duration::from_secs(5), write) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(result.record.status, WriteStatus::Failed); + assert_eq!( + result.record.error.as_ref().unwrap().code, + SafeError::Forbidden + ); + assert_eq!(result.record.operation_id, pending.operation_id); + assert_eq!(result.record.context.server_id.as_deref(), Some("server-a")); + assert_eq!(result.record.context.scope_id, "scope-a"); + assert!(result.result.is_none()); + assert!(result.record.references.is_empty()); + let retained = fixture.manager.state().unwrap().last_write.unwrap(); + assert_eq!(retained.operation_id, pending.operation_id); + assert_eq!(retained.status, WriteStatus::Failed); + assert_eq!( + fixture + .state + .lock() + .unwrap() + .requests + .iter() + .filter(|path| *path == "/v1/memory/remember") + .count(), + 1 + ); +} + +#[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/src-tauri/tests/memory.rs b/desktop/src-tauri/tests/memory.rs index c51a23a915..251caeac4a 100644 --- a/desktop/src-tauri/tests/memory.rs +++ b/desktop/src-tauri/tests/memory.rs @@ -29,7 +29,7 @@ use powercontext_desktop::{ }; use std::sync::{ Arc, Mutex, - atomic::{AtomicU16, Ordering}, + atomic::{AtomicU16, AtomicUsize, Ordering}, }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, @@ -44,6 +44,7 @@ struct Fixture { release: Arc, requests: Arc>>, fail: Arc, + http_requests: Arc, task: tokio::task::JoinHandle<()>, _dir: tempfile::TempDir, } @@ -63,6 +64,8 @@ async fn setup_response(response: serde_json::Value) -> Fixture { let release = Arc::new(Notify::new()); let fail = Arc::new(AtomicU16::new(200)); let requests = Arc::new(Mutex::new(vec![])); + let http_requests = Arc::new(AtomicUsize::new(0)); + let received = http_requests.clone(); let (start, finish, bodies, failure) = ( started.clone(), release.clone(), @@ -72,6 +75,7 @@ async fn setup_response(response: serde_json::Value) -> Fixture { let task = tokio::spawn(async move { loop { let (mut socket, _) = listener.accept().await.unwrap(); + let received = received.clone(); let (start, finish, bodies, failure) = ( start.clone(), finish.clone(), @@ -109,6 +113,7 @@ async fn setup_response(response: serde_json::Value) -> Fixture { .nth(1) .unwrap() .to_owned(); + received.fetch_add(1, Ordering::Relaxed); let (status, value) = match path.as_str() { "/v1/server-info" => (404, serde_json::json!({})), "/v1/access/me" => ( @@ -181,6 +186,7 @@ async fn setup_response(response: serde_json::Value) -> Fixture { release, requests, fail, + http_requests, task, _dir: dir, } @@ -485,8 +491,20 @@ async fn reset_excludes_in_flight_observations_without_cancelling_the_write() { let outcome = write.await.unwrap().unwrap(); assert_eq!(outcome.record.status, WriteStatus::Succeeded); let summary = fixture.manager.observations().summary().unwrap(); - assert!(summary.recent.is_empty()); - assert!(summary.operations.is_empty()); + // New post-write identity requests belong to the new window; the dispatched + // write and its original user operation must not repopulate it. + assert!( + summary + .recent + .iter() + .all(|record| record.operation != ObservedOperation::RememberMemory) + ); + assert!( + summary + .operations + .iter() + .all(|record| record.operation != ObservedOperation::RememberMemory) + ); assert_eq!(summary.evicted, 0); // Both the HTTP request and its user operation completed outside their reset window. assert_eq!(summary.dropped_since_start, 2); @@ -543,6 +561,7 @@ async fn successful_http_with_an_invalid_write_receipt_keeps_the_user_outcome_un async fn request_and_action_latency_use_milliseconds_with_distinct_sample_counts() { let fixture = setup().await; fixture.manager.observations().reset().unwrap(); + let before_requests = fixture.http_requests.load(Ordering::Relaxed); let generation = fixture.manager.state().unwrap().generation; let manager = fixture.manager.clone(); let started = std::time::Instant::now(); @@ -581,6 +600,6 @@ async fn request_and_action_latency_use_milliseconds_with_distinct_sample_counts .iter() .filter(|r| r.unit == ObservationUnit::HttpRequest) .count(), - 3 + fixture.http_requests.load(Ordering::Relaxed) - before_requests ); } diff --git a/desktop/tests/installed_workflow.py b/desktop/tests/installed_workflow.py index f49e930e03..31618581c2 100644 --- a/desktop/tests/installed_workflow.py +++ b/desktop/tests/installed_workflow.py @@ -250,6 +250,7 @@ def exercise_memory(client: httpx.Client, prefix: str) -> dict[str, object]: "rawUtf8BudgetBoundary": True, "emptyAndCappedSearchPresentation": True, "committedLostResponseUnknownWithoutReplay": True, + "unknownDraftNavigationDiscardConfirmed": True, "lateSavePreservesNewScopeDraft": True, } @@ -352,6 +353,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/src/app/App.tsx b/desktop/ui/src/app/App.tsx index fe791aed9c..31a9ac5c21 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"; @@ -27,7 +31,11 @@ import observationIcon from "../assets/retrieval.svg"; import { TopBar } from "./TopBar"; import { Overview } from "./Overview"; -import { MemoryWorkspace } from "./MemoryWorkspace"; +import { + MemoryWorkspace, + SubmittedDraftRecovery, + type SubmittedDraft, +} from "./MemoryWorkspace"; import { LocalServerSettings } from "./LocalServerSettings"; import { Help } from "./Help"; import { Diagnostics } from "./Diagnostics"; @@ -57,6 +65,32 @@ export function App() { const [stateLoading, setStateLoading] = useState(true); const stateRequest = useRef(0); const [dirty, setDirty] = useState(false); + 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 [connectionDraftReset, setConnectionDraftReset] = useState(0); const [scopeOpen, setScopeOpen] = useState(false); const [refreshing, setRefreshing] = useState(false); @@ -86,8 +120,14 @@ export function App() { return true; } const { confirm, confirmation } = useConfirmation(language); - function confirmSwitch() { - return !dirty || confirm(connectionMessages[language].discard); + async function confirmSwitch() { + if ( + (dirty || submittedDrafts.length > 0) && + !(await confirm(connectionMessages[language].discard)) + ) + return false; + setSubmittedDrafts([]); + return true; } const activeProfile = desktop?.profiles.find( (p) => p.id === desktop.active?.connectionId, @@ -327,19 +367,39 @@ export function App() { /> )} {page === "memories" && ( - navigate("connections")} - onOpenScopes={openScopes} - onOpenLocalServer={() => - navigate("settings", { section: "local-server" }) - } - contextPending={contextPending} - /> + <> + {submittedDrafts + .filter( + (draft) => + draft.target.generation !== desktop?.active?.generation, + ) + .map((draft) => ( + + setSubmittedDrafts((current) => + current.filter((item) => item.id !== draft.id), + ) + } + /> + ))} + navigate("connections")} + onOpenScopes={openScopes} + onOpenLocalServer={() => + navigate("settings", { section: "local-server" }) + } + contextPending={contextPending} + /> + )} {page === "connections" && ( void; onOpenLocalServer?: () => void; contextPending?: boolean; + 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 const textBytes = (value: string) => new TextEncoder().encode(value).length; +function WriteTarget({ context }: { context: SubmittedTarget }) { + return ( + + {context.endpoint} · {context.serverId && <>{context.serverId} · } + {context.scopeId} + + ); +} + +export function SubmittedDraftRecovery({ + draft, + language, + onDiscard, +}: { + draft: SubmittedDraft; + language: Language; + onDiscard: () => void; +}) { + const t = memoryMessages[language]; + const { confirm, confirmation } = useConfirmation(language); + const [feedback, setFeedback] = useState(""); + async function copy() { + try { + await navigator.clipboard.writeText(draft.text); + setFeedback(t.copied); + } catch { + setFeedback(t.copyFailed); + } + } + return ( +
+ {confirmation} +

{t.recovery}

+

{t[draft.phase]}

+

{t.recoveryHint}

+

+ {t.original}: +

+