diff --git a/.github/workflows/e2e-harness.yml b/.github/workflows/e2e-harness.yml index 60bc7b88c8..32ba4b0854 100644 --- a/.github/workflows/e2e-harness.yml +++ b/.github/workflows/e2e-harness.yml @@ -315,6 +315,12 @@ jobs: compose=(docker compose -f e2e/bub/compose.yaml -f e2e/bub/compose.oceanbase.yaml) "${compose[@]}" up --detach --wait --wait-timeout 660 oceanbase container_id=$("${compose[@]}" ps -q oceanbase) + # SLIM restores a demo datafile capped at 1G and ignores resource environment overrides. + # Repeated isolated schemas and FULLTEXT indexes need bounded growth through the whole job. + docker exec "$container_id" obclient -h127.0.0.1 -P2881 -uroot -e \ + "SHOW PARAMETERS WHERE name IN ('datafile_size', 'datafile_next', 'datafile_maxsize'); + ALTER SYSTEM SET datafile_maxsize='8G'; + SHOW PARAMETERS WHERE name IN ('datafile_size', 'datafile_next', 'datafile_maxsize');" oceanbase_ip=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container_id") test -n "$oceanbase_ip" export POWERCONTEXT_TEST_OCEANBASE_URL="mysql+aoceanbase://root%40test:powercontext-e2e@${oceanbase_ip}:2881/powercontext?charset=utf8mb4" diff --git a/desktop/README.md b/desktop/README.md index ee721e6e97..818231e3f5 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -96,7 +96,7 @@ Register an explicitly trusted local installation in `%APPDATA%/com.powercontext } ``` -The native adapter checks the absolute executable path, pinned digest and fixed `--version` result before running either `service status --json` or `doctor integrations --json`. This is a local installation pin, not publisher-signature verification; the Python environment and dependencies must also be trusted. The adapter admits 1.0.1 and 1.1.1 and their development builds; the registration must pin the exact installed version. The example identifies the tested baseline. Other version series require adapter qualification. No PATH-first CLI selection or renderer-provided commands are accepted. +The native adapter checks the absolute executable path, pinned digest and fixed `--version` result before running either `service status --json` or `doctor integrations --json`. This is a local installation pin, not publisher-signature verification; the Python environment and dependencies must also be trusted. The adapter admits 1.0.1, 1.1.1 and 1.2.1 and their development builds; the registration must pin the exact installed version. The example identifies the tested baseline. Other version series require adapter qualification. No PATH-first CLI selection or renderer-provided commands are accepted. Version verification has a 15-second deadline, service status 20 seconds, and integration diagnostics 60 seconds. Each invocation limits combined stdout/stderr to 256 KiB. Helpers run hidden in an owned Windows Job; completion, timeout and cancellation clean up their descendants. Only allowlisted status fields reach the UI. Valid unhealthy JSON remains useful even with exit code 1. Isolated real-CLI checks pass; installed-application qualification remains open in [validation guide](VALIDATION.md). @@ -116,4 +116,6 @@ The CI-only lifecycle scenario forcibly ends its own installed Desktop process a Installed boundary checks exercise an 8192-byte Unicode note, reject over-budget input, display zero and capped-ten search results, and verify cancel/confirm behavior when disconnecting with an unsaved draft. Each result requires its matching remote report. -After upgrading from an earlier preview, select `sqlite-atomic-f702c041-v1` and recheck your connection. A previous `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` / `sqlite-6e237568-v1` / `sqlite-legacy-58f7f4f6-v1` selection is not silently upgraded to a different contract. See [current qualification](VALIDATION.md). +After upgrading from an earlier preview, open Connections and select your saved connection. Profiles matching the bundled API contract appear under “Available for this Desktop build”; compare their tested Server builds with your deployment before selecting one, saving, checking and explicitly using the connection again. New connections start with no compatibility selection. + +Historical profiles appear as disabled records. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced; it is never silently upgraded. This build currently has no product qualification record matching its bundled contract, so business operations remain unavailable in ordinary builds. The independently qualified `ci-fixtures` build is for disposable acceptance tests. Historical qualification evidence and remaining requirements are documented in the [validation guide](VALIDATION.md). diff --git a/desktop/README.zh.md b/desktop/README.zh.md index fb1516ed41..49cfd80c0f 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -98,4 +98,6 @@ Windows GitHub Actions 会构建未签名安装包,安装到临时中文路径 安装版边界测试覆盖 8192 字节 Unicode 正文、超限输入禁止提交、搜索无结果和最多 10 条的提示,以及未保存草稿时取消或确认断开连接的行为。是否通过以对应远程报告为准。 -从早期预览版升级后,请选择 `sqlite-atomic-f702c041-v1` 并重新检查连接。旧的 `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` / `sqlite-6e237568-v1` / `sqlite-legacy-58f7f4f6-v1` 选择不会静默升级为另一份契约。当前验证范围见[验证说明](VALIDATION.md)。 +从早期预览版升级后,请打开「连接」并选中已保存的连接。与当前 API 契约匹配的配置显示在「此 Desktop 构建可用」中;先核对已测试的 Server 构建与实际部署,再明确选择、保存、检查并使用该连接。新连接默认不选择兼容配置。 + +历史配置显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。当前构建尚无匹配打包契约的产品资格记录,因此普通构建暂不开放业务操作。独立验证的 `ci-fixtures` 构建仅用于临时验收测试。历史资格证据和剩余要求见[验证说明](VALIDATION.md)。 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 47f3ccdbd6..d8ce14bcef 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -22,11 +22,11 @@ cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) identifies the qualified Server source, normalized OpenAPI contract digest and qualification-wheel digest for `sqlite-atomic-f702c041-v1`, described in [Atomic Memory protocol qualification](#atomic-memory-protocol-qualification). CI builds have their own artifact identities. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves the recorded Server sources, normalized OpenAPI contract digests and qualification-wheel digests for the historical profiles below. No product record currently matches this build’s bundled contract; ordinary builds keep business operations unavailable until an independently verified matching profile is added. Historical records are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. CI builds have their own artifact identities and do not change product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. -Selecting a compatibility profile does not attest the remote binary identity. Existing `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` / `sqlite-6e237568-v1` / `sqlite-legacy-58f7f4f6-v1` selections require explicitly choosing the current profile and rechecking the connection. Desktop reads memories only through Atomic Memory references, so Servers that answer only with Memory entry citations are not qualified. CLI diagnostics separately verify the registered executable path, digest and exact version. +Selecting a compatibility profile does not attest the remote binary identity. Existing `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` / `sqlite-6e237568-v1` / `sqlite-legacy-58f7f4f6-v1` selections require explicitly choosing an available matching profile and rechecking the connection. Desktop reads memories only through Atomic Memory references, so Servers that answer only with Memory entry citations are not qualified. CLI diagnostics separately verify the registered executable path, digest and exact version. ### Atomic Memory protocol qualification @@ -36,6 +36,18 @@ The native `ConnectionManager` exercised the actual isolated built-wheel SQLite These results establish the stated Server protocol compatibility. Windows credentials, process containment, registered-executable checks and installed-package acceptance for this Atomic profile require their actual Windows CI checks. This protocol record does not establish platform or release acceptance. +### Historical protocol qualifications + +`sqlite-6e237568-v1` records the original Server source, contract digest and qualification-wheel digest. Its qualification wheel was built from checkout `ea30fd5c`; its backend sources, OpenAPI contract, project configuration and lock matched the recorded Server commit. The immutable catalog preserves its identities and original ten operations. + +`sqlite-legacy-58f7f4f6-v1` identifies Server sources at `58f7f4f6cbadd698bac35b3fca019d9f48f55b81`, contract digest `9f4ba7b35ecd3b3639af4e648aff84b713a7f30b51d5a4c30c8809aeb8dc8024`, and qualification-wheel digest `7203cf1a98cb9dfd8631e142f7a0f5e74c39845da4fb7d862117a49fa1ef38af`. Its original ten Desktop operations and Legacy Memory protocol were exercised by the native `ConnectionManager` against the isolated built-wheel SQLite Server in all four harness modes: anonymous loopback, Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. These checks cover save/search/exact historical citation reads, identity changes, revocation, ambiguous writes without replay, and Scope pagination. The original `sqlite-6e237568-v1` record retains its identities and operations. The Base Legacy qualification requires explicitly selecting a profile whose recorded contract matches the bundled Desktop contract and rechecking the connection. + +This qualification used macOS 26.2 ARM64, Rust 1.95.0 and Python 3.12.7. An external system Python launcher was used for native process creation because this environment can terminate native subprocesses before their first request. The wheel, native client, fixtures and business assertions were preserved. The real CLI's fixed version, service-status and integration commands were also exercised with an isolated home and PATH; their actual outputs passed the native projections. These checks establish the stated SQLite compatibility and CLI response handling. Windows registered-executable checks, credentials, process containment and installed-package acceptance require their actual Windows CI checks. No remote legacy deployment was tested in this qualification. + +`sqlite-atomic-7bd5b85c-v1` identifies Server sources at `7bd5b85c154c284d0ad550de31ecf8aa629d8ccf`, contract digest `0807df984ba706b505d4fff0cb849d4566222f10572d91a5e03399b833d1fe4f`, and qualification-wheel digest `057eca5de29961684bfea8ea8aece7c133b3d8b46a024db69a944519bc5b2746`. Its native `ConnectionManager` was exercised against the isolated built-wheel SQLite Server in all four harness modes: anonymous loopback, Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. These checks cover Atomic writes, all returned references, search, immutable revision reads, identity changes, revocation, ambiguous writes without replay, and Scope pagination. These are historical results for that qualified build; this Desktop build reads only Atomic Memory references and does not retain the Legacy citation protocol. + +The Atomic qualification run used macOS 26.2 ARM64, Rust 1.95.0 and Python 3.12.7. An external launcher routed native process creation through system Python 3.9.6 because the same signed native executable exited with SIGKILL when launched by the Conda Python parent before its first request. The wheel, native client, harness fixtures and business assertions were unchanged by that launcher. This establishes the stated SQLite compatibility; Windows IPC, vault, process containment and installed-package acceptance require their actual Windows CI checks. No legacy remote deployment was tested in the Atomic qualification run. + ## CI fixture 的独立资格 CI fixture 使用独立资格记录,不会修改历史生产资格。原生 HTTP 测试显式注入 synthetic profile;真实 Server 和安装验收使用 `fixture_qualification.py` 从当前合同、Server 提交和构建 wheel 生成的 `.artifacts/ci-compatibility.json`,读取时重新核对合同、操作集合和 wheel 哈希。 diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 31a5914fa2..9f8fb4a346 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -4475,6 +4475,7 @@ version = "12.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" dependencies = [ + "serde_json", "thiserror 2.0.20", "ts-rs-macros", ] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 4da000f307..d8098f9106 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -35,7 +35,7 @@ tauri = { version = "=2.11.0", features = [] } serde = { version = "=1.0.228", features = ["derive"] } serde_json = "=1.0.149" sha2 = "=0.10.9" -ts-rs = "=12.0.1" +ts-rs = { version = "=12.0.1", features = ["serde-json-impl"] } reqwest = { version = "=0.12.28", default-features = false, features = ["native-tls", "json", "stream"] } url = "=2.5.8" tokio = { version = "=1.52.1", features = ["macros", "rt-multi-thread", "net", "io-util", "sync", "time"] } diff --git a/desktop/src-tauri/examples/server_probe.rs b/desktop/src-tauri/examples/server_probe.rs index 7f5ccfe64f..7ebf86975a 100644 --- a/desktop/src-tauri/examples/server_probe.rs +++ b/desktop/src-tauri/examples/server_probe.rs @@ -153,6 +153,14 @@ async fn main() { let saved = manager.remember(generation, &submitted).await.unwrap(); assert_eq!(saved.record.status, WriteStatus::Succeeded); let result = saved.result.unwrap(); + assert_eq!( + saved.record.references, + result + .records + .iter() + .map(|record| record.artifact.clone()) + .collect::>() + ); assert_eq!(result.records.len(), 1); let entry = &result.records[0]; assert_eq!(entry.text, expected); @@ -187,6 +195,18 @@ async fn main() { .unwrap(); save_ms.push(start.elapsed().as_secs_f64() * 1000.0); assert_eq!(result.record.status, WriteStatus::Succeeded); + let records = result.result.unwrap().records; + assert!(!records.is_empty()); + assert_eq!(result.record.references.len(), records.len()); + for (record, reference) in records.iter().zip(&result.record.references) { + assert_eq!(reference, &record.artifact); + let exact = manager + .atomic_memory_entry(generation, reference) + .await + .unwrap(); + assert_eq!(exact.artifact, *reference); + assert_eq!(exact.text, record.text); + } } let mut search_ms = vec![]; let mut exact_ms = vec![]; diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 8d9be29d6e..098c1a4e0e 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -1,4 +1,62 @@ [ + { + "id": "sqlite-6e237568-v1", + "serverCommit": "6e2375688bc38a667840c6b2dc8269d8848027bc", + "contractSha256": "9af88b2b779c372a21ae9f398d0ca75b333f2e5da2afa3626df879911a442edd", + "artifactSha256": "92c5d14c64969e75e48d30d755c0b1e25d1f8ed9d22e781a8241de0a9098be25", + "operations": [ + "get_liveness", + "get_readiness", + "get_access_principal", + "get_capabilities", + "list_scopes", + "get_scope", + "get_default_scope", + "remember_memory", + "search_memory", + "get_memory_entry" + ], + "evidence": "desktop/VALIDATION.md" + }, + { + "id": "sqlite-atomic-7bd5b85c-v1", + "serverCommit": "7bd5b85c154c284d0ad550de31ecf8aa629d8ccf", + "contractSha256": "0807df984ba706b505d4fff0cb849d4566222f10572d91a5e03399b833d1fe4f", + "artifactSha256": "057eca5de29961684bfea8ea8aece7c133b3d8b46a024db69a944519bc5b2746", + "operations": [ + "get_liveness", + "get_readiness", + "get_access_principal", + "get_capabilities", + "list_scopes", + "get_scope", + "get_default_scope", + "remember_memory", + "search_memory", + "get_memory_entry", + "get_artifact_revision" + ], + "evidence": "desktop/VALIDATION.md" + }, + { + "id": "sqlite-legacy-58f7f4f6-v1", + "serverCommit": "58f7f4f6cbadd698bac35b3fca019d9f48f55b81", + "contractSha256": "9f4ba7b35ecd3b3639af4e648aff84b713a7f30b51d5a4c30c8809aeb8dc8024", + "artifactSha256": "7203cf1a98cb9dfd8631e142f7a0f5e74c39845da4fb7d862117a49fa1ef38af", + "operations": [ + "get_liveness", + "get_readiness", + "get_access_principal", + "get_capabilities", + "list_scopes", + "get_scope", + "get_default_scope", + "remember_memory", + "search_memory", + "get_memory_entry" + ], + "evidence": "desktop/VALIDATION.md" + }, { "id": "sqlite-atomic-f702c041-v1", "serverCommit": "f702c0418d69e3072e1ec2fa657e0df81e8e51b4", diff --git a/desktop/src-tauri/src/connections/session.rs b/desktop/src-tauri/src/connections/session.rs index 5b15e69765..ca97ba304b 100644 --- a/desktop/src-tauri/src/connections/session.rs +++ b/desktop/src-tauri/src/connections/session.rs @@ -114,7 +114,7 @@ pub struct WriteRecord { pub operation_id: String, pub context: MemoryContext, pub status: WriteStatus, - pub artifact: Option, + pub references: Vec, pub error: Option, } #[derive(Clone, Serialize, TS)] @@ -635,7 +635,7 @@ impl ConnectionManager { operation_id: uuid::Uuid::new_v4().to_string(), context, status: WriteStatus::Pending, - artifact: None, + references: vec![], error: None, }; { @@ -654,8 +654,11 @@ impl ConnectionManager { let result = match response { Ok(value) => { record.status = WriteStatus::Succeeded; - record.artifact = - (value.records.len() == 1).then(|| value.records[0].artifact.clone()); + record.references = value + .records + .iter() + .map(|record| record.artifact.clone()) + .collect(); Some(value) } Err(error) => { diff --git a/desktop/src-tauri/src/credentials.rs b/desktop/src-tauri/src/credentials.rs index e8225511b2..5046eb1a99 100644 --- a/desktop/src-tauri/src/credentials.rs +++ b/desktop/src-tauri/src/credentials.rs @@ -20,6 +20,7 @@ use serde::{Deserialize, Deserializer, Serialize}; use ts_rs::TS; use zeroize::Zeroizing; +#[cfg(windows)] const SERVICE: &str = "com.powercontext.desktop.preview"; pub struct Secret(Zeroizing); impl Secret { diff --git a/desktop/src-tauri/src/transport/api.rs b/desktop/src-tauri/src/transport/api.rs index baad787d9e..2764be36c8 100644 --- a/desktop/src-tauri/src/transport/api.rs +++ b/desktop/src-tauri/src/transport/api.rs @@ -455,6 +455,7 @@ fn valid_atomic_record(record: &AtomicMemoryRecord) -> bool { && !record.kind.trim().is_empty() && record.kind.chars().count() <= 128 && !record.text.is_empty() + && (record.state == AtomicMemoryState::Merged) == record.merged_into_id.is_some() } fn invalid_received() -> ApiFailure { ApiFailure { diff --git a/desktop/src-tauri/tests/api.rs b/desktop/src-tauri/tests/api.rs index 37746d821c..e2071ccbd8 100644 --- a/desktop/src-tauri/tests/api.rs +++ b/desktop/src-tauri/tests/api.rs @@ -16,7 +16,10 @@ use powercontext_desktop::{ error::SafeError, - transport::{Endpoint, ServerApi, wire::ReadinessStatus}, + transport::{ + Endpoint, ServerApi, + wire::{ArtifactReference, ReadinessStatus}, + }, }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, @@ -227,3 +230,79 @@ async fn atomic_records_survive_save_search_and_exact_revision_read() { ); request.await.unwrap(); } + +fn atomic_reference() -> ArtifactReference { + ArtifactReference { + family: "atomic-memory".into(), + artifact_id: "atomic/?x".into(), + revision: 7, + } +} + +fn atomic_revision() -> serde_json::Value { + serde_json::json!({ + "scope_id":"scope/中文?x", "family":"atomic-memory", "artifact_id":"atomic/?x", "revision":7, + "content":{"schema":"powercontext.atomic-memory.v1","kind":"note","text":"exact immutable text","creation":{"type":"merge","input_artifact_ids":["input-a","input-b"]}}, + "sources":[{"source_type":"document","source_id":"source 中文"}], + "artifacts":[{"family":"atomic-memory","artifact_id":"input-a","revision":1}], + "content_digest":format!("sha256:{}", "a".repeat(64)) + }) +} + +#[tokio::test] +async fn atomic_exact_read_encodes_every_path_segment_and_preserves_its_reference() { + let body = atomic_revision(); + let (api, request) = fixture(200, body.to_string()).await; + let reference = atomic_reference(); + let result = api.atomic_entry("scope/中文?x", &reference).await.unwrap(); + assert_eq!(result.artifact, reference); + assert_eq!(result.text, "exact immutable text"); + assert_eq!(result.source_refs[0].source_type, "document"); + assert_eq!(result.source_refs[0].source_id, "source 中文"); + assert!(request.await.unwrap().starts_with( + "GET /proxy/%E4%B8%AD%E6%96%87/v1/scopes/scope%2F%E4%B8%AD%E6%96%87%3Fx/artifacts/atomic-memory/atomic%2F%3Fx/revisions/7 " + )); +} + +#[tokio::test] +async fn atomic_exact_read_rejects_mismatched_identity_without_using_current() { + for (field, value) in [ + ("scope_id", serde_json::json!("another-scope")), + ("family", serde_json::json!("skill")), + ("artifact_id", serde_json::json!("another-artifact")), + ("revision", serde_json::json!(8)), + ] { + let mut body = atomic_revision(); + body[field] = value; + let (api, request) = fixture(200, body.to_string()).await; + let error = api + .atomic_entry("scope/中文?x", &atomic_reference()) + .await + .err() + .unwrap(); + assert_eq!(error.code, SafeError::InvalidResponse, "{field}"); + assert!(request.await.unwrap().contains("/revisions/7 ")); + } +} + +#[tokio::test] +async fn atomic_write_rejects_inconsistent_merge_state_without_losing_dispatch_uncertainty() { + for (state, merged_into) in [ + ("merged", serde_json::Value::Null), + ("active", serde_json::json!("another-memory")), + ] { + let body = serde_json::json!({"changed":true,"records":[{ + "artifact":{"family":"atomic-memory","artifact_id":"atomic-a","revision":1}, + "kind":"note","text":"synthetic note","state":state,"state_version":1,"merged_into_id":merged_into + }]}); + let (api, request) = fixture(200, body.to_string()).await; + let error = api + .remember("scope-a", "synthetic note") + .await + .err() + .unwrap(); + assert_eq!(error.code, SafeError::InvalidResponse); + assert!(error.dispatched); + request.await.unwrap(); + } +} diff --git a/desktop/src-tauri/tests/ipc.rs b/desktop/src-tauri/tests/ipc.rs index a3c243db24..76f2800296 100644 --- a/desktop/src-tauri/tests/ipc.rs +++ b/desktop/src-tauri/tests/ipc.rs @@ -21,6 +21,13 @@ use tauri::{ webview::InvokeRequest, }; +// Tauri mock IPC uses the host platform's local protocol; product navigation remains Windows-only. +const LOCAL_ORIGIN: &str = if cfg!(any(windows, target_os = "android")) { + "http://tauri.localhost" +} else { + "tauri://localhost" +}; + fn test_context() -> tauri::Context { // The macOS plist must be embedded once per test executable. tauri::generate_context!() @@ -48,23 +55,18 @@ fn permissions_reject_untrusted_window_origin_and_arbitrary_commands() { .build(test_context()) .unwrap(); for (label, origin, command, allowed) in [ - ("main", "http://tauri.localhost", "foundation_info", true), - ("other", "http://tauri.localhost", "foundation_info", false), + ("main", LOCAL_ORIGIN, "foundation_info", true), + ("other", LOCAL_ORIGIN, "foundation_info", false), ("main", "https://evil.example", "foundation_info", false), - ("main", "http://tauri.localhost", "desktop_state", true), - ("other", "http://tauri.localhost", "desktop_state", false), + ("main", LOCAL_ORIGIN, "desktop_state", true), + ("other", LOCAL_ORIGIN, "desktop_state", false), ("main", "https://evil.example", "desktop_state", false), - ("main", "http://tauri.localhost", "disconnect", true), - ("other", "http://tauri.localhost", "disconnect", false), + ("main", LOCAL_ORIGIN, "disconnect", true), + ("other", LOCAL_ORIGIN, "disconnect", false), ("main", "https://evil.example", "disconnect", false), - ( - "main", - "http://tauri.localhost", - "plugin:shell|execute", - false, - ), - ("main", "http://tauri.localhost", "fetch", false), - ("main", "http://tauri.localhost", "credential_read", false), + ("main", LOCAL_ORIGIN, "plugin:shell|execute", false), + ("main", LOCAL_ORIGIN, "fetch", false), + ("main", LOCAL_ORIGIN, "credential_read", false), ] { use tauri::Manager; let window = app.get_webview_window(label).unwrap_or_else(|| { @@ -117,11 +119,11 @@ fn diagnostic_command_is_narrow_and_requires_the_trusted_window() { .build(test_context()) .unwrap(); for (label, origin, kind, expected_missing) in [ - ("main", "http://tauri.localhost", "service", true), - ("main", "http://tauri.localhost", "integrations", true), - ("other", "http://tauri.localhost", "service", false), + ("main", LOCAL_ORIGIN, "service", true), + ("main", LOCAL_ORIGIN, "integrations", true), + ("other", LOCAL_ORIGIN, "service", false), ("main", "https://evil.example", "service", false), - ("main", "http://tauri.localhost", "shell", false), + ("main", LOCAL_ORIGIN, "shell", false), ] { use tauri::Manager; let window = app.get_webview_window(label).unwrap_or_else(|| { @@ -203,7 +205,7 @@ fn memory_commands_reject_untrusted_callers_before_accessing_the_connection() { .expect_err("no connection or forbidden caller"); assert_eq!( error.get("code").and_then(serde_json::Value::as_str) == Some("not_connected"), - label == "main" && origin == "http://tauri.localhost", + label == "main" && origin == LOCAL_ORIGIN, "{command} {label} {origin}: {error}" ); } diff --git a/desktop/src-tauri/tests/memory.rs b/desktop/src-tauri/tests/memory.rs index c9afde5b7b..a00f365d94 100644 --- a/desktop/src-tauri/tests/memory.rs +++ b/desktop/src-tauri/tests/memory.rs @@ -51,6 +51,9 @@ impl Drop for Fixture { } } async fn setup() -> Fixture { + setup_response(serde_json::json!({"changed":false,"records":[]})).await +} +async fn setup_response(response: serde_json::Value) -> Fixture { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let endpoint = format!("http://{}", listener.local_addr().unwrap()); let started = Arc::new(Notify::new()); @@ -72,6 +75,7 @@ async fn setup() -> Fixture { bodies.clone(), failure.clone(), ); + let response = response.clone(); tokio::spawn(async move { let mut bytes = vec![]; let end = loop { @@ -127,7 +131,7 @@ async fn setup() -> Fixture { if status != 200 { (status, serde_json::json!({"detail":"must not leak"})) } else { - (200, serde_json::json!({"changed":false,"records":[]})) + (200, response) } } path if path.starts_with("/v1/scopes/") => ( @@ -207,7 +211,7 @@ async fn dispatched_write_keeps_its_original_target_and_rejects_duplicate_clicks assert_eq!(outcome.record.context.connection_id, fixture.a); assert_eq!(outcome.record.context.scope_id, "scope-a"); assert!(outcome.result.is_none()); - assert!(outcome.record.artifact.is_none()); + assert!(outcome.record.references.is_empty()); let requests = fixture.requests.lock().unwrap(); assert_eq!( requests.as_slice(), @@ -245,10 +249,67 @@ async fn success_without_records_does_not_invent_a_reference() { .await .unwrap(); assert_eq!(result.record.status, WriteStatus::Succeeded); - assert!(result.record.artifact.is_none()); + assert!(result.record.references.is_empty()); assert!(result.result.unwrap().records.is_empty()); } +#[tokio::test] +async fn atomic_write_retains_every_exact_reference_after_context_changes() { + let response = serde_json::json!({"changed":true,"records":[ + {"artifact":{"family":"atomic-memory","artifact_id":"atomic-a","revision":3},"kind":"note","text":"first","state":"active","state_version":1,"merged_into_id":null}, + {"artifact":{"family":"atomic-memory","artifact_id":"atomic-b","revision":5},"kind":"note","text":"second","state":"forgotten","state_version":2,"merged_into_id":null} + ]}); + for change_context in [false, true] { + let fixture = setup_response(response.clone()).await; + let generation = fixture.manager.state().unwrap().generation; + let manager = fixture.manager.clone(); + let write = + tokio::spawn(async move { manager.remember(generation, "synthetic note").await }); + tokio::time::timeout( + std::time::Duration::from_secs(3), + fixture.started.notified(), + ) + .await + .unwrap(); + if change_context { + fixture.manager.check(&fixture.b, true).await.unwrap(); + } + fixture.release.notify_one(); + let outcome = write.await.unwrap().unwrap(); + assert_eq!(outcome.record.status, WriteStatus::Succeeded); + assert_eq!(outcome.record.context.connection_id, fixture.a); + if change_context { + assert!(outcome.result.is_none()); + } else { + assert_eq!( + serde_json::to_value(outcome.result.unwrap()).unwrap(), + response + ); + } + assert_eq!(outcome.record.references.len(), 2); + for (reference, expected) in outcome + .record + .references + .iter() + .zip([("atomic-a", 3), ("atomic-b", 5)]) + { + assert_eq!(reference.family, "atomic-memory"); + assert_eq!(reference.artifact_id, expected.0); + assert_eq!(reference.revision, expected.1); + } + assert_eq!( + fixture + .manager + .state() + .unwrap() + .last_write + .unwrap() + .references, + outcome.record.references + ); + } +} + #[tokio::test] async fn dropping_a_dispatched_write_preserves_unknown_metadata_without_body() { let fixture = setup().await; diff --git a/desktop/tests/installed_fixture.py b/desktop/tests/installed_fixture.py index 4c8f0be65d..015b2326a7 100644 --- a/desktop/tests/installed_fixture.py +++ b/desktop/tests/installed_fixture.py @@ -61,7 +61,10 @@ def exact_memory_text(server: httpx.Client, scope: str, reference: dict[str, Any value = response.json() if value["scope_id"] != scope or any(value[key] != reference[key] for key in ("family", "artifact_id", "revision")): raise HarnessFailure("installed_independent_exact_read_mismatch") - return value["content"]["text"] + content = value["content"] + if content["schema"] != "powercontext.atomic-memory.v1" or not isinstance(content["text"], str): + raise HarnessFailure("installed_exact_atomic_content_mismatch") + return content["text"] def wait_ready(client: httpx.Client, process: subprocess.Popen[bytes]) -> None: diff --git a/desktop/tests/real_server.py b/desktop/tests/real_server.py index 617665aa86..fb2808e591 100644 --- a/desktop/tests/real_server.py +++ b/desktop/tests/real_server.py @@ -351,7 +351,7 @@ def main() -> None: "serverAliveAfterClientExit": True, "committedWriteWithLostResponseUnknownWithoutReplay": True, "providerIdentityChangeInvalidatesContext": bool(config["provider"]), - "sameIdentityRevocationDeniesHistoricalCitation": bool(config["provider"]), + "sameIdentityRevocationDeniesExactMemoryRead": bool(config["provider"]), "sameTitleScopePagination51": bool(config["provider"]), "performance": probe_measurement(probe.stdout), }) diff --git a/desktop/ui/src/app/Connections.tsx b/desktop/ui/src/app/Connections.tsx index cba0bf0caf..c691e2dad2 100644 --- a/desktop/ui/src/app/Connections.tsx +++ b/desktop/ui/src/app/Connections.tsx @@ -22,6 +22,7 @@ import type { StorageChoice, Fact, } from "../generated/ipc"; +import { contractSha256 } from "../generated/operations"; import { desktopApi } from "../shared/ipc"; import { connectionMessages, connectionError } from "./connection-messages"; import { messages, type Language } from "./messages"; @@ -155,6 +156,18 @@ export function Connections({ state, language, onState, onDirty }: Props) { (profile.credentialState === "stored" || profile.credentialState === "session_only") && !invalidated.current; + const availableProfiles = state?.compatibilityProfiles.filter( + (p) => p.contractSha256 === contractSha256, + ); + const historicalProfiles = state?.compatibilityProfiles.filter( + (p) => p.contractSha256 !== contractSha256, + ); + const selectedCompatibility = state?.compatibilityProfiles.find( + (p) => p.id === compatibility, + ); + const unavailableCompatibility = + compatibility !== "" && + selectedCompatibility?.contractSha256 !== contractSha256; return (
@@ -315,14 +328,39 @@ export function Connections({ state, language, onState, onDirty }: Props) { }} > - {state?.compatibilityProfiles.map((p) => ( - + {availableProfiles.map((p) => ( + + ))} + + )} + {!!historicalProfiles?.length && ( + + {historicalProfiles.map((p) => ( + + ))} + + )} + {compatibility && !selectedCompatibility && ( + - ))} + )}

{t.compatibilityHint}

+ {unavailableCompatibility && ( +

+ {selectedCompatibility + ? t.profileContractMismatch + : t.profileMissing} +

+ )}
{t.ca}