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..8d67703451 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 @@ -83,7 +89,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. @@ -104,7 +110,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. 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. 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. @@ -124,7 +134,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. @@ -203,4 +213,6 @@ 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. + +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 18a1aced8e..4d3a7f796b 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)。 ## 直接运行安装版 @@ -69,7 +75,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 绑定。修改查询取消旧请求,切换连接或身份后旧结果失效。 @@ -82,7 +88,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 适用上述已验证旧版策略。 @@ -93,13 +103,13 @@ 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 个并发读取;超限明确失败。 - 主窗口可调用明确列出的连接、Scope、记忆和诊断命令;没有通用 fetch、shell、文件、数据库或读取秘密的 IPC。 -应用不启动 Server、不管理服务、不持久化正文或查询;关闭窗口即退出。当前语言和主题也仅保留于会话内。 +普通连接和 Memory 操作不会启动或管理 Server;本地服务修改必须通过明确批准的 Ops 计划。应用不持久化正文或查询,关闭窗口即退出。当前语言和主题也仅保留于会话内。 [安全边界](SECURITY.md)记录具体约束;[验证说明](VALIDATION.md)列出测试范围和仍未满足的 P0 门槛。构建成功、Mock 测试和当前用户机器上的运行都不能代替标准用户、签名、通知冷启动激活、独立服务和 Agent 宿主验收。 @@ -167,4 +177,6 @@ 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..eed6a28a98 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 @@ -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,9 @@ 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. + +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 ea6050baa6..b73dc2c003 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 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. @@ -670,3 +702,22 @@ 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 163 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 126 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. The local handoff and retained-Scope rechecks carry the original discovered Server ID through every identity check: a replacement during default-Scope resolution prevents the exact read, a replacement during an exact read prevents publication, and a new deployment cannot inherit the former Scope selection. 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. + + +## 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/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 f54d8e47bb..5890967044 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -56,6 +56,12 @@ keyring = { version = "=3.6.3", default-features = false, features = ["apple-nat [target.'cfg(unix)'.dependencies] 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/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/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/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..41bafe5241 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, } @@ -230,7 +232,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 +243,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 +301,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,6 +388,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 + }; + // 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)?; @@ -325,6 +420,7 @@ impl ConnectionManager { inner.generation, self.changed.subscribe(), use_as_active, + retained_scope, ) }; let report = Self::cancellable(&mut cancelled, async { @@ -359,6 +455,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(), @@ -377,10 +484,134 @@ 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, Some(server_id.as_str()), generation) + .await?; + let default = api.default_scope().await?; + self.identity_unchanged(&api, &identity, Some(server_id.as_str()), generation) + .await?; + let exact = api.scope(&default.scope_id).await?; + self.identity_unchanged(&api, &identity, Some(server_id.as_str()), 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, + Some(server_id.as_str()), + 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, + Some(server_id.as_str()), + 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 +620,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()?; @@ -419,14 +649,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 +703,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 +721,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 +752,7 @@ impl ConnectionManager { .user_operation(ObservedOperation::ListScopes, async { let ReadSnapshot { api, + server_id, identity, mut cancelled, } = self.snapshot(generation, "list_scopes")?; @@ -492,8 +760,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 +777,7 @@ impl ConnectionManager { .user_operation(ObservedOperation::GetDefaultScope, async { let ReadSnapshot { api, + server_id, identity, mut cancelled, } = self.snapshot(generation, "get_default_scope")?; @@ -512,8 +785,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 +806,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 +858,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 +897,7 @@ impl ConnectionManager { let ( ReadSnapshot { api, + server_id, identity, mut cancelled, }, @@ -622,8 +907,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 +929,7 @@ impl ConnectionManager { let ( ReadSnapshot { api, + server_id, identity, mut cancelled, }, @@ -649,8 +939,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 +962,7 @@ impl ConnectionManager { let ( ReadSnapshot { api, + server_id, identity, mut cancelled, }, @@ -675,7 +970,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 +994,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/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/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/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/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/deployment_identity.rs b/desktop/src-tauri/tests/deployment_identity.rs new file mode 100644 index 0000000000..265de15e53 --- /dev/null +++ b/desktop/src-tauri/tests/deployment_identity.rs @@ -0,0 +1,695 @@ +/* + * 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 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 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); +} + +#[tokio::test] +async fn local_handoff_fences_default_and_exact_scope_reads_with_the_observed_deployment() { + for path in ["/v1/scopes/default", "/v1/scopes/scope-a"] { + let fixture = Fixture::new(false).await; + let before = fixture.manager.state().unwrap(); + let endpoint = before.profiles[0].endpoint.clone(); + fixture.state.lock().unwrap().pause = Some(path.into()); + let manager = fixture.manager.clone(); + let handoff = tokio::spawn(async move { + manager + .connect_local( + before.generation, + endpoint, + common::PROFILE_ID.into(), + "synthetic".into(), + ) + .await + }); + fixture.entered().await; + assert!(fixture.manager.state().unwrap().active.is_none()); + fixture.replace(); + fixture.release.notify_one(); + let error = timeout(Duration::from_secs(5), handoff) + .await + .unwrap() + .unwrap() + .err() + .unwrap(); + assert_eq!(error.code, SafeError::StaleContext, "{path}"); + fixture.assert_disconnected(before.generation); + if path == "/v1/scopes/default" { + assert!( + !fixture + .state + .lock() + .unwrap() + .requests + .iter() + .any(|request| request == "/v1/scopes/scope-a") + ); + } + } +} + +#[tokio::test] +async fn local_scope_retention_requires_the_same_deployment_and_rechecks_it_after_the_exact_read() { + for replace_during_read in [false, true] { + let fixture = Fixture::new(false).await; + let before = fixture.manager.state().unwrap(); + let selected = fixture + .manager + .connect_local( + before.generation, + before.profiles[0].endpoint.clone(), + common::PROFILE_ID.into(), + "synthetic".into(), + ) + .await + .unwrap(); + let id = selected.active.as_ref().unwrap().connection_id.clone(); + let refreshed = fixture.manager.check(&id, false).await.unwrap(); + assert_eq!(refreshed.active.unwrap().scope.unwrap().scope_id, "scope-a"); + if replace_during_read { + fixture.state.lock().unwrap().pause = Some("/v1/scopes/scope-a".into()); + let manager = fixture.manager.clone(); + let recheck = tokio::spawn(async move { manager.check(&id, false).await }); + fixture.entered().await; + fixture.replace(); + fixture.release.notify_one(); + let error = timeout(Duration::from_secs(5), recheck) + .await + .unwrap() + .unwrap() + .err() + .unwrap(); + assert_eq!(error.code, SafeError::StaleContext); + fixture.assert_disconnected(refreshed.generation); + } else { + fixture.replace(); + let replaced = fixture.manager.check(&id, false).await.unwrap(); + assert!(replaced.active.unwrap().scope.is_none()); + } + } +} 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/src-tauri/tests/memory.rs b/desktop/src-tauri/tests/memory.rs index c51a23a915..b4b27bf3c6 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, } @@ -480,13 +486,34 @@ async fn reset_excludes_in_flight_observations_without_cancelling_the_write() { ) .await .unwrap(); + let before_reset_requests = fixture.http_requests.load(Ordering::Relaxed); fixture.manager.observations().reset().unwrap(); fixture.release.notify_one(); 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 + .recent + .iter() + .filter(|record| record.unit == ObservationUnit::HttpRequest) + .count(), + fixture.http_requests.load(Ordering::Relaxed) - before_reset_requests + ); 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 +570,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 +609,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/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, diff --git a/desktop/tests/installed_workflow.py b/desktop/tests/installed_workflow.py index 96ee1212bd..1f34ee11b0 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("丢弃所有保留的提交文本和尚未保存的输入?这不会撤销提交或修改 Server 上的记录。", accept=False) # noqa: RUF001 - exact localized UI + page.element("//main//h1[normalize-space(.)='记忆']") + page.button("连接") + page.confirm("丢弃所有保留的提交文本和尚未保存的输入?这不会撤销提交或修改 Server 上的记录。", 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: @@ -381,7 +392,7 @@ def exercise_late_save(page: InstalledPage) -> None: page.click("//div[@class='topbar-group']/button") page.button("查找范围") page.click(f"//ul[@class='scope-list']/li[code[normalize-space(.)='{scope_b}']]/button") - page.confirm("丢弃尚未保存的输入?", accept=True) # noqa: RUF001 - exact localized UI + page.confirm("丢弃所有保留的提交文本和尚未保存的输入?这不会撤销提交或修改 Server 上的记录。", accept=True) # noqa: RUF001 - exact localized UI page.wait("return !document.querySelector('[role=dialog]');") draft = "new scope unsaved draft" page.type("记忆内容", draft, "textarea") diff --git a/desktop/ui/src/app/App.tsx b/desktop/ui/src/app/App.tsx index 241d5b95fa..347797810d 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"; @@ -25,9 +29,15 @@ 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"; +import { + MemoryWorkspace, + SubmittedDraftRecovery, + type SubmittedDraft, +} from "./MemoryWorkspace"; import { LocalServerSettings } from "./LocalServerSettings"; import { Help } from "./Help"; import { Diagnostics } from "./Diagnostics"; @@ -35,6 +45,7 @@ import { Connections } from "./Connections"; import { Scopes } from "./Scopes"; import { Observation, type ObservationTab } from "./Observation"; import { connectionMessages } from "./connection-messages"; +import { memoryMessages } from "./memory-messages"; import { PageFeedback, ErrorFeedback } from "./PageFeedback"; import { activeCheckReport } from "./context"; import { Modal } from "./Modal"; @@ -43,6 +54,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"); @@ -56,6 +69,37 @@ export function App() { const [stateLoading, setStateLoading] = useState(true); const stateRequest = useRef(0); const [dirty, setDirty] = useState(false); + 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<{ @@ -73,14 +117,26 @@ 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() { - return !dirty || confirm(connectionMessages[language].discard); + async function confirmSwitch() { + if ( + (dirty || submittedDrafts.length > 0) && + !(await confirm( + submittedDrafts.length > 0 + ? memoryMessages[language].discardAllSubmittedConfirm + : connectionMessages[language].discard, + )) + ) + return false; + setSubmittedDrafts([]); + return true; } const activeProfile = desktop?.profiles.find( (p) => p.id === desktop.active?.connectionId, @@ -124,6 +180,7 @@ export function App() { if (localServer) target?.scrollIntoView?.({ block: "start" }); }, [page, connectionSelection]); function openScopes(trigger: HTMLElement) { + uiIntent.current++; scopeReturnFocus.current = trigger; setScopeOpen(true); } @@ -136,6 +193,7 @@ export function App() { !(await confirmSwitch()) ) return; + uiIntent.current++; settingsSection.current = target?.section; setConnectionSelection((current) => target?.profileId === undefined @@ -167,6 +225,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 +371,17 @@ export function App() { )} + {page === "home" && !desktop?.active && ( +
+ +
+ )} {page === "home" && ( )} {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" && ( )} @@ -378,6 +472,62 @@ export function App() { { + 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 () => { + 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 && + intent === uiIntent.current + ) { + 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/MemoryWorkspace.tsx b/desktop/ui/src/app/MemoryWorkspace.tsx index 1921a60e64..9fb84d8a65 100644 --- a/desktop/ui/src/app/MemoryWorkspace.tsx +++ b/desktop/ui/src/app/MemoryWorkspace.tsx @@ -21,7 +21,9 @@ import type { ArtifactReference, AtomicMemoryRecord, MemorySearchHit, + MemoryContext, WriteOutcome, + WriteRecord, } from "../generated/ipc"; import { desktopApi } from "../shared/ipc"; import { connectionError, operationRestriction } from "./connection-messages"; @@ -40,9 +42,78 @@ type Props = { onOpenScopes?: (trigger: HTMLElement) => 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}: +

+