From 8e32966936891ba1b447dc0679b271dfa77869cf Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Tue, 3 Nov 2026 10:41:47 +0800 Subject: [PATCH 01/19] fix(desktop): bound diagnostic registration reads --- desktop/src-tauri/src/diagnostics.rs | 22 ++++++++++++++++- desktop/src-tauri/tests/diagnostics.rs | 33 ++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/diagnostics.rs b/desktop/src-tauri/src/diagnostics.rs index 4923ab7705..1c41a16c79 100644 --- a/desktop/src-tauri/src/diagnostics.rs +++ b/desktop/src-tauri/src/diagnostics.rs @@ -437,8 +437,28 @@ fn execute( kind: DiagnosticKind, cancelled: std::sync::Arc, ) -> Result { + #[cfg(unix)] + use std::os::unix::fs::OpenOptionsExt; + #[cfg(windows)] + use std::os::windows::fs::OpenOptionsExt; use std::{io::Read, time::Duration}; - let file = std::fs::File::open(registration).map_err(|_| SafeError::NotFound)?; + + let metadata = std::fs::metadata(registration).map_err(|_| SafeError::NotFound)?; + if !metadata.is_file() { + return Err(SafeError::CompatibilityUnverified); + } + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + options.custom_flags(libc::O_NONBLOCK); + #[cfg(windows)] + options.share_mode(1); + let file = options + .open(registration) + .map_err(|_| SafeError::NotFound)?; + if !file.metadata().map_err(|_| SafeError::Storage)?.is_file() { + return Err(SafeError::CompatibilityUnverified); + } let mut bytes = Vec::new(); file.take(16385) .read_to_end(&mut bytes) diff --git a/desktop/src-tauri/tests/diagnostics.rs b/desktop/src-tauri/tests/diagnostics.rs index b22833ba36..4e894937a5 100644 --- a/desktop/src-tauri/tests/diagnostics.rs +++ b/desktop/src-tauri/tests/diagnostics.rs @@ -124,3 +124,36 @@ async fn non_regular_registered_executable_is_rejected_without_blocking_shutdown assert_eq!(result.unwrap_err(), SafeError::CompatibilityUnverified); diagnostics.shutdown(); } + +#[cfg(unix)] +#[tokio::test] +async fn non_regular_registration_is_rejected_without_blocking_shutdown() { + use powercontext_desktop::diagnostics::LocalDiagnostics; + use std::{ffi::CString, os::unix::ffi::OsStrExt, time::Duration}; + + let directory = tempfile::tempdir().unwrap(); + let registration = directory.path().join("diagnostic-cli.json"); + let registration_name = CString::new(registration.as_os_str().as_bytes()).unwrap(); + assert_eq!( + unsafe { libc::mkfifo(registration_name.as_ptr(), 0o600) }, + 0 + ); + + let diagnostics = std::sync::Arc::new(LocalDiagnostics::new(registration)); + let caller = diagnostics.clone(); + let run = tokio::spawn(async move { caller.run(DiagnosticKind::Service).await }); + let result = tokio::time::timeout(Duration::from_secs(1), run) + .await + .expect("FIFO registration must not block the diagnostic call") + .unwrap(); + assert_eq!(result.unwrap_err(), SafeError::CompatibilityUnverified); + + let shutdown = diagnostics.clone(); + tokio::time::timeout( + Duration::from_secs(1), + tokio::task::spawn_blocking(move || shutdown.shutdown()), + ) + .await + .expect("shutdown must not wait on a FIFO registration") + .unwrap(); +} From cdf6f9140d5d5b140063e030243cd6e37be98b03 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Wed, 4 Nov 2026 15:08:34 +0800 Subject: [PATCH 02/19] feat(desktop): integrate Linux personal service lifecycle --- .github/workflows/desktop.yml | 3 +- .../workflows/managed-resource-candidates.yml | 33 ++++ .github/workflows/native-service.yml | 2 +- desktop/README.md | 10 ++ desktop/README.zh.md | 10 ++ desktop/VALIDATION.md | 17 ++ desktop/src-tauri/src/ops_bridge.rs | 2 +- desktop/src-tauri/src/ops_bridge/tests.rs | 27 ++-- desktop/src-tauri/src/ops_bridge/trust.rs | 4 +- desktop/src-tauri/tests/ipc.rs | 2 +- desktop/ui/src/app/LocalServerSettings.tsx | 71 ++++++++- desktop/ui/src/app/messages.ts | 29 ++++ desktop/ui/tests/local-server.test.tsx | 30 +++- scripts/managed_resource_interpreters.json | 10 ++ scripts/prepare_managed_resources.py | 2 +- .../native/test_personal_service_lifecycle.py | 148 ++++++++++++++++++ tests/test_ci_pipeline.py | 7 +- tests/test_managed_resource_producer.py | 4 +- 18 files changed, 390 insertions(+), 21 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 2a7799ccd1..a6f59af211 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -82,7 +82,8 @@ jobs: - run: pnpm build - run: cargo fmt --manifest-path src-tauri/Cargo.toml --check - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings - - run: cargo test --locked --manifest-path src-tauri/Cargo.toml + - name: Test Windows IPC and Desktop behavior + run: cargo test --locked --manifest-path src-tauri/Cargo.toml - run: pnpm ipc:check - name: Verify diagnostic process containment run: cargo run --locked --manifest-path src-tauri/Cargo.toml --example diagnostic_process_probe diff --git a/.github/workflows/managed-resource-candidates.yml b/.github/workflows/managed-resource-candidates.yml index 59bbb90619..748f87bd54 100644 --- a/.github/workflows/managed-resource-candidates.yml +++ b/.github/workflows/managed-resource-candidates.yml @@ -33,6 +33,8 @@ jobs: fail-fast: false matrix: include: + - target: linux-x86_64 + runner: ubuntu-24.04 - target: windows-x86_64 runner: windows-2025 - target: macos-arm64 @@ -91,6 +93,37 @@ jobs: PY - name: Produce twice, compare bytes, consume catalog, and run bundled launcher/Ops run: uv run --no-sync pytest -q tests/test_managed_resource_producer.py tests/test_ops_resources.py --junitxml build/resource-tests.xml + - name: Install Linux Desktop dependencies for native Ops acceptance + if: matrix.target == 'linux-x86_64' + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y \ + build-essential \ + dbus-x11 \ + gnome-keyring \ + libayatana-appindicator3-dev \ + libgtk-3-dev \ + libsecret-1-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + librsvg2-dev \ + libxdo-dev \ + openjdk-17-jre-headless \ + pkg-config + - name: Install pinned Rust toolchain for native Ops acceptance + if: matrix.target == 'linux-x86_64' + run: rustup toolchain install 1.95.0 --profile minimal + - name: Verify the real Linux Ops bundle through the native trust boundary + if: matrix.target == 'linux-x86_64' + run: | + mkdir -p build/resource-evidence + export POWERCONTEXT_OPS_TEST_BUNDLE="$GITHUB_WORKSPACE/build/resource-candidate/ops" + export POWERCONTEXT_OPS_TEST_BUNDLE_SHA256="$(sha256sum "$POWERCONTEXT_OPS_TEST_BUNDLE/bundle.json" | cut -d ' ' -f1)" + { + echo "bundle=$POWERCONTEXT_OPS_TEST_BUNDLE" + echo "bundle_manifest_sha256=$POWERCONTEXT_OPS_TEST_BUNDLE_SHA256" + CARGO_BUILD_JOBS=2 cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib actual_independent_bundle_runs_through_native_trust_and_read_only_bridge -- --ignored + } 2>&1 | tee build/resource-evidence/native-ops-bridge.txt - name: Retain unqualified candidate evidence if: success() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 diff --git a/.github/workflows/native-service.yml b/.github/workflows/native-service.yml index 50a1907fdc..c8b3f8404a 100644 --- a/.github/workflows/native-service.yml +++ b/.github/workflows/native-service.yml @@ -27,7 +27,7 @@ jobs: linux-systemd-user: needs: changes if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').native) }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 env: POWERCONTEXT_RUN_NATIVE_SERVICE_TESTS: "1" diff --git a/desktop/README.md b/desktop/README.md index a6b47a6df4..7938f38202 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -142,6 +142,16 @@ This explicit probe creates a uniquely named synthetic credential in the preview 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. +## Local service status + +On Linux, the Local Server settings page can inspect the PowerContext user service through the trusted Ops Bridge. This check is read-only: it reports supported, registration, ownership, manager, liveness, readiness and maintenance status, while keeping paths, commands, environment values and raw process output out of the UI. + +The bridge only addresses PowerContext's registered service target and rejects untrusted windows, remote origins and arbitrary renderer input. A missing or unavailable local service does not disable remote Server connections. This workstream does not add install, start, stop or restart controls; those mutations remain in the service-management boundary and must use the same ownership and approval rules. + +The unit is written to the current user's `$XDG_CONFIG_HOME/systemd/user/` directory, or `~/.config/systemd/user/` when `XDG_CONFIG_HOME` is unset, and all manager calls use `systemctl --user`. Normal installation and recovery do not require `sudo`, never write a system unit under `/etc/systemd/system/`, and never call `loginctl enable-linger`. At login, the user's existing systemd manager may activate the enabled unit through `default.target`; at logout, the service follows that user manager's normal lifetime. If the account already has linger enabled by the user or administrator, PowerContext does not change that setting or silently create system-wide persistence. + +Linux validation combines the Desktop Rust IPC/Ops Bridge tests with the real systemd `--user` lifecycle suite in the [native personal service workflow](../.github/workflows/native-service.yml). The lifecycle suite covers installation, restart, uninstall, foreign registrations, unrelated user services, recovery after an external stop, an unavailable user manager, and the user-session boundary. It verifies that login activation is the user's `default.target`, explicit stop leaves that activation enabled, the pre-existing linger state is unchanged, and no system unit is created. The suite does not force a logout of the CI account; actual GNOME session logout/login qualification belongs to the Ubuntu Desktop package acceptance. The managed-resource candidate workflow also generates a real Linux Ops bundle from a pinned standalone Python runtime and runs it through the native manifest/digest trust boundary; that candidate check does not claim signed release qualification. + ## Local CLI diagnostics Settings provides explicit local service and Agent integration checks. They remain separate from the active remote connection; a missing local CLI does not disable remote operations. Integration checks may start temporary Agent helpers and do not prove capture/recall. diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 269543f437..9c1c3d9c34 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -117,6 +117,16 @@ pnpm --dir desktop desktop:dev [安全边界](SECURITY.md)记录具体约束;[验证说明](VALIDATION.md)列出测试范围和仍未满足的 P0 门槛。构建成功、Mock 测试和当前用户机器上的运行都不能代替标准用户、签名、通知冷启动激活、独立服务和 Agent 宿主验收。 +## 本地服务状态 + +在 Linux 上,「本地 Server」设置页可以通过可信的 Ops Bridge 检查 PowerContext 的用户服务。这个检查是只读的:它只显示是否支持、是否注册、管理归属、管理器状态、存活状态、就绪状态和维护状态,不会把路径、命令、环境值或原始进程输出显示给界面。 + +Ops Bridge 只处理已登记的 PowerContext 服务目标,并拒绝不可信窗口、远程来源和渲染器传入的任意输入。本地服务缺失或暂时不可用时,远程 Server 连接仍然可以使用。本工作流不增加安装、启动、停止或重启按钮;这些修改操作仍由服务管理边界负责,并且必须继续遵守归属和审批规则。 + +服务单元写入当前用户的 `$XDG_CONFIG_HOME/systemd/user/`;未设置 `XDG_CONFIG_HOME` 时使用 `~/.config/systemd/user/`,所有管理器调用都使用 `systemctl --user`。正常安装和恢复不需要 `sudo`,不会写入 `/etc/systemd/system/` 之类的系统级 unit,也不会调用 `loginctl enable-linger`。用户登录时,已有的用户级 systemd manager 可以通过 `default.target` 激活已启用的服务;用户注销时,服务遵循该用户 manager 的正常生命周期。如果账户本来已经由用户或管理员启用了 linger,PowerContext 不会修改它,也不会默默创建系统级持久化。 + +Linux 验收由 Desktop Rust IPC/Ops Bridge 测试和真实 systemd `--user` 生命周期测试共同覆盖,后者位于 [native personal service workflow](../.github/workflows/native-service.yml)。生命周期测试覆盖安装、重启、卸载、外部登记、无关用户服务、外部停止后的恢复、用户服务管理器不可用,以及用户会话边界。测试验证登录激活使用当前用户的 `default.target`,显式停止不会取消该激活,已有 linger 状态保持不变,也不会创建系统级 unit。测试不会强制注销 CI 账户;真实 GNOME 会话的注销/重新登录资格由 Ubuntu Desktop 打包验收覆盖。managed-resource candidate workflow 还会使用固定 SHA-256 的独立 Python runtime 生成真实 Linux Ops bundle,并通过原生 manifest/摘要信任边界验收;该 candidate 检查不等同于已签名发布包资格。 + ## 本机 CLI 诊断 设置中的诊断只检查当前电脑,与远程连接状态分开。程序不从 PATH 自动选择 CLI,也不安装或启动 Server。集成检查可能运行临时 Agent 辅助进程;结果不代表实际 capture/recall 已验证。 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index be554956d0..1fcc3eec27 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -20,6 +20,23 @@ 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. +### Linux local-service validation + +The Linux Desktop job runs the full Rust suite, including the Linux Ops Bridge and trusted-window IPC boundary tests. The separate [native personal service workflow](../.github/workflows/native-service.yml) runs the lifecycle suite against the runner's real systemd `--user` manager. It covers installation, start/restart, uninstall, foreign registrations, unrelated user services, recovery after an external stop, an unavailable user manager, and the user-session boundary. The native checks verify `default.target` login activation, explicit stop without disabling that activation, unchanged pre-existing linger state, and absence of a system-level unit. They do not force logout of the active CI account; GNOME logout/login behavior is reserved for installed Ubuntu Desktop qualification. The Linux managed-resource candidate job builds a real Ops bundle from a pinned standalone Python runtime and runs the generated bundle through the native manifest/digest trust test; the candidate remains unsigned and is not a release qualification. + +To reproduce the systemd lifecycle checks on a disposable Linux user manager: + +```sh +sudo systemctl start "user@$(id -u).service" +export XDG_RUNTIME_DIR="/run/user/$(id -u)" +export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus" +export POWERCONTEXT_RUN_NATIVE_SERVICE_TESTS=1 +export POWERCONTEXT_NATIVE_SERVICE_IDENTIFIER="powercontext-local-validation-$(id -u).service" +uv run --no-sync pytest -q tests/native/test_personal_service_lifecycle.py +``` + +The Desktop page currently performs a read-only status inspection through `ops.inspect`; it does not install, start, stop or restart a service. The product uses the current user's `systemctl --user` manager and does not enable linger, write system units or require root. A passing Rust, lifecycle or candidate-bundle test does not by itself qualify a signed package or a normal user's login-session configuration. + ## Deployment context validation On 2026-10-28, Windows 10 x64 / Python 3.11.15 / Rust 1.95.0 ran the deployment-identity diff --git a/desktop/src-tauri/src/ops_bridge.rs b/desktop/src-tauri/src/ops_bridge.rs index 6156faf775..9140c5a3ee 100644 --- a/desktop/src-tauri/src/ops_bridge.rs +++ b/desktop/src-tauri/src/ops_bridge.rs @@ -426,5 +426,5 @@ fn request(action: OpsAction, mut fields: Value) -> Value { fields["action"] = action.wire_name().into(); fields } -#[cfg(all(test, target_os = "macos"))] +#[cfg(all(test, any(target_os = "macos", target_os = "linux")))] mod tests; diff --git a/desktop/src-tauri/src/ops_bridge/tests.rs b/desktop/src-tauri/src/ops_bridge/tests.rs index bb680e1dbb..301466ef06 100644 --- a/desktop/src-tauri/src/ops_bridge/tests.rs +++ b/desktop/src-tauri/src/ops_bridge/tests.rs @@ -24,7 +24,7 @@ use std::{ fn fixture_dir() -> tempfile::TempDir { tempfile::Builder::new() .prefix("ops-bridge-") - .tempdir_in("/private/tmp") + .tempdir() .unwrap() } #[cfg(unix)] @@ -78,7 +78,14 @@ impl Fixture { .keys() .map(|p| fs::metadata(support.join("bundle").join(p)).unwrap().len()) .sum::(); - let manifest = json!({"format_version":1,"python_version":"3.12.15","platform":if cfg!(windows){"win32"}else{"darwin"},"architecture":std::env::consts::ARCH,"python_relative":"runtime/helper","arguments":["-I","-S","-B","entry.py"],"requirements_sha256":"a".repeat(64),"installed_bytes":size,"files_sha256":files}); + let platform = if cfg!(windows) { + "win32" + } else if cfg!(target_os = "macos") { + "darwin" + } else { + "linux" + }; + let manifest = json!({"format_version":1,"python_version":"3.12.15","platform":platform,"architecture":std::env::consts::ARCH,"python_relative":"runtime/helper","arguments":["-I","-S","-B","entry.py"],"requirements_sha256":"a".repeat(64),"installed_bytes":size,"files_sha256":files}); let bytes = serde_json::to_vec(&manifest).unwrap(); fs::write(support.join("bundle/bundle.json"), &bytes).unwrap(); fs::write(resources.join("ops-provision.json"),serde_json::to_vec(&json!({"format_version":1,"bundle_manifest_sha256":trust::sha256(&bytes),"binding_mode":"target_file"})).unwrap()).unwrap(); @@ -167,7 +174,7 @@ else: "#; #[test] -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] fn independent_missing_target_inspection_and_unhealthy_result_are_preserved() { let f = Fixture::new(); let host = f.host(); @@ -215,7 +222,7 @@ fn non_utf8_native_path_returns_unavailable_without_panicking() { ); } #[test] -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] fn approvals_are_one_shot_expiring_and_bound_to_exact_plan_and_target() { let f = Fixture::new(); let host = f.host(); @@ -258,7 +265,7 @@ fn approvals_are_one_shot_expiring_and_bound_to_exact_plan_and_target() { assert!(host.references().unwrap().operations.is_empty()); } #[test] -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] fn progress_arrives_during_apply_and_status_cancel_use_independent_children() { let f = Fixture::new(); f.behavior("await_cancel"); @@ -304,7 +311,7 @@ fn progress_arrives_during_apply_and_status_cancel_use_independent_children() { ); } #[test] -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] fn lost_response_keeps_reference_and_helper_finishes_without_replay() { let f = Fixture::new(); let host = f.host(); @@ -375,7 +382,7 @@ fn await_terminal_status(host: &OpsHost, operation_id: &str) -> OpsOperationRepl } } #[test] -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] fn malformed_reference_store_fails_before_dispatch_and_foreign_binding_cannot_resume() { let f = Fixture::new(); let host = f.host(); @@ -408,7 +415,7 @@ fn malformed_reference_store_fails_before_dispatch_and_foreign_binding_cannot_re ); } #[test] -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] fn bundle_and_packaged_pins_reject_tampering_and_extra_files() { let f = Fixture::new(); fs::write(f.support.join("bundle/entry.py"), b"tampered").unwrap(); @@ -464,7 +471,7 @@ fn child( ) } #[test] -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] fn real_children_bound_streams_deadlines_and_inherited_pipe_descendants() { for stream in ["stdout", "stderr"] { let script = format!( @@ -602,7 +609,7 @@ fn control_admission_remains_available_when_ordinary_helpers_are_reserved() { #[test] #[ignore = "requires a prepared local Python environment with Ops dependencies; read-only real subprocess"] -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] fn real_current_ops_inspect_and_preflight_with_missing_server_runtime() { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() diff --git a/desktop/src-tauri/src/ops_bridge/trust.rs b/desktop/src-tauri/src/ops_bridge/trust.rs index 10f934514a..8c7da3fe9a 100644 --- a/desktop/src-tauri/src/ops_bridge/trust.rs +++ b/desktop/src-tauri/src/ops_bridge/trust.rs @@ -101,7 +101,7 @@ impl Trust { } } pub fn binding(&self) -> Result { - if !cfg!(any(windows, target_os = "macos")) { + if !cfg!(any(windows, target_os = "macos", target_os = "linux")) { return Err(Error::UnsupportedPlatform); } let p = self.provision.as_ref().map_err(|e| *e)?; @@ -305,6 +305,8 @@ fn verify_bundle(root: &Path, pin: &str) -> Result { "win32" } else if cfg!(target_os = "macos") { "darwin" + } else if cfg!(target_os = "linux") { + "linux" } else { "unsupported" }; diff --git a/desktop/src-tauri/tests/ipc.rs b/desktop/src-tauri/tests/ipc.rs index 7ad171c337..cdd5760f3d 100644 --- a/desktop/src-tauri/tests/ipc.rs +++ b/desktop/src-tauri/tests/ipc.rs @@ -388,7 +388,7 @@ fn ops_commands_require_the_packaged_main_window_and_reject_arbitrary_input() { }, ) }; - let missing_host = if cfg!(any(windows, target_os = "macos")) { + let missing_host = if cfg!(any(windows, target_os = "macos", target_os = "linux")) { "unavailable" } else { "unsupported_platform" diff --git a/desktop/ui/src/app/LocalServerSettings.tsx b/desktop/ui/src/app/LocalServerSettings.tsx index e741bb162c..5685e5a175 100644 --- a/desktop/ui/src/app/LocalServerSettings.tsx +++ b/desktop/ui/src/app/LocalServerSettings.tsx @@ -14,9 +14,15 @@ * limitations under the License. */ -import type { Ref } from "react"; +import { useState, type Ref } from "react"; import { messages, type Language } from "./messages"; import { PageFeedback } from "./PageFeedback"; +import { opsApi } from "../shared/ops"; +import type { OpsComponents, OpsInspectionReply } from "../generated/ipc"; + +function statusValue(value: string | undefined, unknownLabel: string) { + return value?.replaceAll("_", " ") ?? unknownLabel; +} export function LocalServerSettings({ language, @@ -28,6 +34,31 @@ export function LocalServerSettings({ onManageConnections: () => void; }) { const t = messages[language]; + const [inspection, setInspection] = useState(null); + const [inspectionPending, setInspectionPending] = useState(false); + + async function inspectLocalServer() { + setInspectionPending(true); + try { + setInspection(await opsApi.inspect()); + } catch { + setInspection({ outcome: "unknown", bridgeError: "process_failure" }); + } finally { + setInspectionPending(false); + } + } + + const components: OpsComponents | undefined = inspection?.components; + const facts = [ + [t.serviceSupport, components?.support], + [t.serviceRegistration, components?.registration], + [t.serviceDefinition, components?.definition], + [t.serviceOwnership, components?.managerOwnership], + [t.serviceManager, components?.manager], + [t.serviceLiveness, components?.liveness], + [t.serviceReadiness, components?.readiness], + [t.serviceMaintenance, components?.maintenance], + ] as const; return (
+
+ {inspection && ( + +

+ {inspection.bridgeError === "unavailable" || inspection.bridgeError + ? t.localServerInspectionUnavailable + : t.localServerInspectionComplete} +

+ {components && ( +
+ {facts.map(([label, value]) => ( +
+
{label}
+
{statusValue(value, t.unknown)}
+
+ ))} +
+ )} +
+ )}

{t.localServerDiagnostics}

); diff --git a/desktop/ui/src/app/messages.ts b/desktop/ui/src/app/messages.ts index 096a350258..89e8140e9a 100644 --- a/desktop/ui/src/app/messages.ts +++ b/desktop/ui/src/app/messages.ts @@ -40,6 +40,20 @@ export const messages = { "准备完成后,连接已有 Server,保存配置并显式激活,再选择精确 Scope。Desktop 不会自动连接或恢复 Scope。", localServerDiagnostics: "下方本机诊断可手动执行,但不能代替安装、恢复或服务启停;关闭 Desktop 不会停止独立运行的 Server。", + localServerInspect: "检查本地服务状态", + localServerInspecting: "正在检查本地服务…", + localServerInspectionTitle: "本地服务状态", + localServerInspectionComplete: "已读取受信任的本地服务状态。", + localServerInspectionUnavailable: "本地服务管理资源暂不可用。", + localServerInspectionFailed: "无法读取本地服务状态。", + serviceSupport: "平台支持", + serviceRegistration: "服务注册", + serviceDefinition: "服务定义", + serviceOwnership: "服务归属", + serviceManager: "服务管理器", + serviceLiveness: "存活状态", + serviceReadiness: "就绪状态", + serviceMaintenance: "维护状态", connectionUpdating: "正在更新连接状态…", connectionUpdatingHint: "可以浏览其他页面。连接操作完成前,记忆编辑、保存、搜索、精确读取及范围切换暂不可用;完成后请核对当前连接与精确 Scope。", @@ -249,6 +263,21 @@ export const messages = { "When ready, connect the existing Server, save and explicitly activate its profile, then select an exact Scope. Desktop does not automatically connect or restore a Scope.", localServerDiagnostics: "You can run the local diagnostics below explicitly. They do not install, recover, start or stop services. Closing Desktop leaves an independently running Server active.", + localServerInspect: "Inspect local service", + localServerInspecting: "Inspecting local service…", + localServerInspectionTitle: "Local service status", + localServerInspectionComplete: "Trusted local service facts were read.", + localServerInspectionUnavailable: + "Local service management resources are currently unavailable.", + localServerInspectionFailed: "The local service status could not be read.", + serviceSupport: "Platform support", + serviceRegistration: "Registration", + serviceDefinition: "Service definition", + serviceOwnership: "Ownership", + serviceManager: "Service manager", + serviceLiveness: "Liveness", + serviceReadiness: "Readiness", + serviceMaintenance: "Maintenance", connectionUpdating: "Updating connection state…", connectionUpdatingHint: "You can browse other pages. Memory editing, saving, searching, exact reads and Scope switching are unavailable until the connection operation finishes. Then verify the current connection and exact Scope.", diff --git a/desktop/ui/tests/local-server.test.tsx b/desktop/ui/tests/local-server.test.tsx index 9cceffbf0c..545282e453 100644 --- a/desktop/ui/tests/local-server.test.tsx +++ b/desktop/ui/tests/local-server.test.tsx @@ -20,6 +20,7 @@ import userEvent from "@testing-library/user-event"; import { answerConfirmation } from "./confirmation"; import { App } from "../src/app/App"; import { desktopApi } from "../src/shared/ipc"; +import { opsApi } from "../src/shared/ops"; import { messages } from "../src/app/messages"; import type { DesktopState } from "../src/generated/ipc"; @@ -33,6 +34,11 @@ vi.mock("../src/shared/ipc", () => ({ cancelMemory: vi.fn().mockResolvedValue(undefined), }, })); +vi.mock("../src/shared/ops", () => ({ + opsApi: { + inspect: vi.fn(), + }, +})); const disconnected: DesktopState = { generation: 0, profiles: [], @@ -44,6 +50,18 @@ const disconnected: DesktopState = { }; beforeEach(() => { vi.mocked(desktopApi.state).mockResolvedValue(disconnected); + vi.mocked(opsApi.inspect).mockResolvedValue({ + outcome: "succeeded", + components: { + support: "supported", + registration: "installed", + definition: "current", + managerOwnership: "owned", + manager: "active", + liveness: "live", + readiness: "verified_during_operation", + }, + }); }); afterEach(() => { cleanup(); @@ -94,7 +112,17 @@ test.each(["zh", "en"] as const)( ).toBeTruthy(); expect(within(region).getByText(t.localServerUnavailableHint)).toBeTruthy(); expect(within(region).getAllByRole("listitem")).toHaveLength(3); - expect(within(region).getAllByRole("button")).toHaveLength(1); + expect(within(region).getAllByRole("button")).toHaveLength(2); + await user.click( + within(region).getByRole("button", { name: t.localServerInspect }), + ); + expect(opsApi.inspect).toHaveBeenCalledOnce(); + expect( + within(region).getByRole("heading", { + name: t.localServerInspectionTitle, + }), + ).toBeTruthy(); + expect(within(region).getByText("active")).toBeTruthy(); expect( screen .getByRole("button", { name: t.settings }) diff --git a/scripts/managed_resource_interpreters.json b/scripts/managed_resource_interpreters.json index 6c347c3696..9fbcb0e6f5 100644 --- a/scripts/managed_resource_interpreters.json +++ b/scripts/managed_resource_interpreters.json @@ -3,6 +3,16 @@ "upstream": "https://github.com/astral-sh/python-build-standalone/tree/20261026", "license": "https://github.com/astral-sh/python-build-standalone/blob/20261026/LICENSE", "targets": { + "linux-x86_64": { + "platform": "linux", + "architecture": "x86_64", + "python_version": "3.12.16", + "python_relative": "bin/python3", + "server_python_relative": "bin/python3", + "site_relative": "lib/python3.12/site-packages", + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20261026/cpython-3.12.16%2B20261026-x86_64-unknown-linux-gnu-install_only.tar.gz", + "sha256": "6a79e922e50006b601f29e688407659da1c2b86ebcd4e9c7254cf234be8b89de" + }, "macos-arm64": { "platform": "macos", "architecture": "arm64", diff --git a/scripts/prepare_managed_resources.py b/scripts/prepare_managed_resources.py index 405ed7df8f..405be4da69 100644 --- a/scripts/prepare_managed_resources.py +++ b/scripts/prepare_managed_resources.py @@ -96,7 +96,7 @@ def target_spec(target: str, *, native: bool = True) -> dict[str, Any]: lock = json.loads(_INTERPRETERS.read_bytes()) spec = lock["targets"][target] current = ( - {"Darwin": "macos", "Windows": "windows"}.get(platform.system()), + {"Darwin": "macos", "Linux": "linux", "Windows": "windows"}.get(platform.system()), {"amd64": "x86_64", "x86_64": "x86_64", "arm64": "arm64", "aarch64": "arm64"}.get(platform.machine().lower()), ) if native and current != (spec["platform"], spec["architecture"]): diff --git a/tests/native/test_personal_service_lifecycle.py b/tests/native/test_personal_service_lifecycle.py index 75a2517134..64a2af0c4e 100644 --- a/tests/native/test_personal_service_lifecycle.py +++ b/tests/native/test_personal_service_lifecycle.py @@ -19,6 +19,7 @@ import os import plistlib import re +import shutil import socket import sqlite3 import subprocess @@ -715,6 +716,148 @@ def test_native_service_rejects_foreign_registration(tmp_path: Path) -> None: _remove_foreign_registration(adapter) +@pytest.mark.skipif(sys.platform != "linux", reason="systemd user-service isolation is Linux-specific") +def test_native_linux_lifecycle_does_not_touch_an_unrelated_user_service(tmp_path: Path) -> None: + adapter = _native_adapter() + controller = ServiceController(adapter) + environment = _environment_file(tmp_path) + unrelated = f"powercontext-unrelated-{uuid.uuid4().hex}.service" + + def unrelated_active() -> bool: + result = subprocess.run( + ["systemctl", "--user", "is-active", unrelated], # noqa: S607 - native manager command + capture_output=True, + text=True, + timeout=10, + check=False, + ) + return result.returncode == 0 and result.stdout.strip() == "active" + + try: + _run("systemd-run", "--user", f"--unit={unrelated}", "/bin/sleep", "300") + _wait_for(unrelated_active) + + assert controller.install(env_file=environment).ok + stopped = controller.stop() + assert stopped.manager is ManagerState.INACTIVE + assert unrelated_active() + + removed = controller.uninstall() + assert removed.registration is RegistrationState.NOT_INSTALLED + assert unrelated_active() + finally: + _capture_native_failure(adapter, tmp_path) + with suppress(Exception): + controller.uninstall() + _cleanup(adapter) + _run_ignoring_failure("systemctl", "--user", "stop", unrelated) + _run_ignoring_failure("systemctl", "--user", "reset-failed", unrelated) + + +@pytest.mark.skipif(sys.platform != "linux", reason="systemd user-service recovery is Linux-specific") +def test_native_linux_service_recovers_after_external_stop(tmp_path: Path) -> None: + adapter = _native_adapter() + controller = ServiceController(adapter) + environment = _environment_file(tmp_path) + + try: + installed = controller.install(env_file=environment) + assert installed.ok, f"{installed}\n\n{_failure_diagnostics(adapter, tmp_path)}" + + # Simulate an external stop (for example, a service manager action or + # another local control surface). The controller must not mistake this + # for ownership loss, and recovery must use the registered definition. + adapter.stop() + assert adapter.manager_state() in {ManagerState.INACTIVE, ManagerState.FAILED} + + recovered = controller.start() + assert recovered.ok, f"{recovered}\n\n{_failure_diagnostics(adapter, tmp_path)}" + assert recovered.manager is ManagerState.ACTIVE + assert not controller.maintenance_path.exists() + finally: + _capture_native_failure(adapter, tmp_path) + with suppress(Exception): + controller.uninstall() + _cleanup(adapter) + + +@pytest.mark.skipif(sys.platform != "linux", reason="systemd user-service support is Linux-specific") +def test_native_linux_unavailable_user_manager_is_reported_without_mutation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A missing user bus is actionable and never becomes a system-service fallback.""" + + if shutil.which("systemctl") is None: + pytest.skip("systemctl is not installed") + runtime = tmp_path / "runtime" + runtime.mkdir(mode=0o700) + monkeypatch.setenv("XDG_RUNTIME_DIR", str(runtime)) + monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", f"unix:path={runtime / 'missing-bus'}") + adapter = SystemdUserAdapter( + config_home=tmp_path / "config", + identifier=f"powercontext-native-unavailable-{uuid.uuid4().hex}.service", + ) + + manager_probe = subprocess.run( + ["systemctl", "--user", "show-environment"], # noqa: S607 - intentional unavailable-manager probe + capture_output=True, + text=True, + timeout=10, + check=False, + ) + assert manager_probe.returncode != 0 + + support, detail = adapter.support() + + assert support is SupportState.UNSUPPORTED + assert "no available systemd user manager" in detail + assert not adapter.artifact_path.exists() + assert not (Path("/etc/systemd/system") / adapter.identifier).exists() + + +@pytest.mark.skipif(sys.platform != "linux", reason="systemd user-session behavior is Linux-specific") +def test_native_linux_user_session_scope_preserves_linger_and_system_boundary(tmp_path: Path) -> None: + """Login activation is user-scoped and does not alter logout/linger policy.""" + + if shutil.which("loginctl") is None: + pytest.skip("loginctl is not installed") + adapter = _native_adapter(suffix="session-boundary") + controller = ServiceController(adapter) + environment = _environment_file(tmp_path) + system_unit = Path("/etc/systemd/system") / adapter.identifier + initial_linger = _user_linger_state() + assert not system_unit.exists() + + try: + installed = controller.install(env_file=environment) + assert installed.ok, f"{installed}\n\n{_failure_diagnostics(adapter, tmp_path)}" + + unit = adapter.artifact_path.read_text(encoding="utf-8") + assert adapter.artifact_path.parent.name == "user" + assert adapter.artifact_path.parent.parent.name == "systemd" + assert "WantedBy=default.target" in unit + assert not system_unit.exists() + enabled_link = adapter.artifact_path.parent / "default.target.wants" / adapter.identifier + assert enabled_link.is_symlink() + assert enabled_link.resolve() == adapter.artifact_path.resolve() + assert _user_linger_state() == initial_linger + + controller.stop() + assert adapter.manager_state() is ManagerState.INACTIVE + assert enabled_link.is_symlink() + assert _user_linger_state() == initial_linger + + assert controller.start().ok, _failure_diagnostics(adapter, tmp_path) + assert adapter.manager_state() is ManagerState.ACTIVE + finally: + _capture_native_failure(adapter, tmp_path) + with suppress(Exception): + controller.uninstall() + _cleanup(adapter) + assert not system_unit.exists() + assert _user_linger_state() == initial_linger + + @pytest.mark.skipif(sys.platform != "darwin", reason="launchd retry classification is macOS-specific") def test_native_service_retry_and_exit_classification(tmp_path: Path) -> None: adapter = _native_adapter(suffix="retry") @@ -1112,6 +1255,11 @@ def _run(*arguments: str) -> subprocess.CompletedProcess[str]: ) +def _user_linger_state() -> str: + result = _run("loginctl", "show-user", str(os.getuid()), "--property=Linger", "--value") + return result.stdout.strip() + + def _run_ignoring_failure(*arguments: str) -> None: subprocess.run( list(arguments), diff --git a/tests/test_ci_pipeline.py b/tests/test_ci_pipeline.py index ea808a474c..7c9abdd2dc 100644 --- a/tests/test_ci_pipeline.py +++ b/tests/test_ci_pipeline.py @@ -158,7 +158,12 @@ def test_compatibility_and_upgrade_matrices_have_bounded_runner_bursts(): assert upgrade["fail-fast"] is False # Complete upgrade evidence remains mandatory. native = workflow("managed-resource-candidates.yml")["jobs"]["candidate"]["strategy"] assert int(native["max-parallel"]) <= 2 - assert {row["target"] for row in native["matrix"]["include"]} == {"windows-x86_64", "macos-arm64", "macos-x86_64"} + assert {row["target"] for row in native["matrix"]["include"]} == { + "linux-x86_64", + "windows-x86_64", + "macos-arm64", + "macos-x86_64", + } def test_cancelled_or_draft_runs_cannot_start_terminal_runner_jobs(): diff --git a/tests/test_managed_resource_producer.py b/tests/test_managed_resource_producer.py index 3665c294e8..643fcf8fd0 100644 --- a/tests/test_managed_resource_producer.py +++ b/tests/test_managed_resource_producer.py @@ -243,9 +243,9 @@ def test_windows_path_budget_covers_real_default_staging_and_utf16() -> None: def test_foreign_target_is_not_a_cross_compilation_request() -> None: - for target in ("macos-arm64", "macos-x86_64", "windows-x86_64"): + for target in ("linux-x86_64", "macos-arm64", "macos-x86_64", "windows-x86_64"): target_spec(target, native=False) - matching = {"darwin": "macos", "win32": "windows"}.get(sys.platform) + matching = {"darwin": "macos", "linux": "linux", "win32": "windows"}.get(sys.platform) foreign = "windows-x86_64" if matching != "windows" else "macos-arm64" with pytest.raises(ValueError, match="matching native"): target_spec(foreign) From d8e816dea0c0957d792729cc569c7ad6a955d7e5 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Wed, 4 Nov 2026 15:45:31 +0800 Subject: [PATCH 03/19] fix(desktop): keep macOS Ops fixtures on private temp --- desktop/src-tauri/src/ops_bridge/tests.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/desktop/src-tauri/src/ops_bridge/tests.rs b/desktop/src-tauri/src/ops_bridge/tests.rs index 301466ef06..5221a896ff 100644 --- a/desktop/src-tauri/src/ops_bridge/tests.rs +++ b/desktop/src-tauri/src/ops_bridge/tests.rs @@ -22,6 +22,12 @@ use std::{ }; fn fixture_dir() -> tempfile::TempDir { + #[cfg(target_os = "macos")] + return tempfile::Builder::new() + .prefix("ops-bridge-") + .tempdir_in("/private/tmp") + .unwrap(); + #[cfg(not(target_os = "macos"))] tempfile::Builder::new() .prefix("ops-bridge-") .tempdir() From c254b1cc5cc1aee6c6f2a5d148ddaa59a9c142a5 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Thu, 5 Nov 2026 12:57:05 +0800 Subject: [PATCH 04/19] fix(desktop): complete Linux resource and session support --- desktop/src-tauri/src/ops_bridge/tests.rs | 16 ++++++++++++ desktop/src-tauri/src/ops_bridge/trust.rs | 32 +++++++++++++++++++++++ scripts/prepare_managed_resources.py | 13 +++++---- scripts/produce_managed_resources.py | 4 +-- src/powercontext/ops/installer.py | 22 ++++++++++++++++ src/powercontext/ops/resources.py | 25 +++++++++++------- tests/test_managed_resource_producer.py | 18 +++++++++++++ tests/test_ops_installer.py | 16 ++++++++++++ tests/test_ops_resources.py | 10 +++++++ 9 files changed, 139 insertions(+), 17 deletions(-) diff --git a/desktop/src-tauri/src/ops_bridge/tests.rs b/desktop/src-tauri/src/ops_bridge/tests.rs index fe6df125b2..5dd6770119 100644 --- a/desktop/src-tauri/src/ops_bridge/tests.rs +++ b/desktop/src-tauri/src/ops_bridge/tests.rs @@ -879,6 +879,22 @@ fn managed_catalog_copy_is_private_immutable_and_pinned_across_launches() { )); } +#[cfg(target_os = "linux")] +#[test] +fn linux_runtime_environment_is_preserved_only_for_a_private_owned_directory() { + use std::os::unix::fs::PermissionsExt; + + let dir = fixture_dir(); + private(dir.path()); + assert_eq!( + trust::trusted_linux_runtime_dir_from(Some(dir.path().to_path_buf())).as_deref(), + dir.path().to_str() + ); + + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(trust::trusted_linux_runtime_dir_from(Some(dir.path().to_path_buf())).is_none()); +} + #[test] fn database_admission_v1_extensions_remain_bounded_and_actionable() { let r = request(OpsAction::Inspect, json!({})); diff --git a/desktop/src-tauri/src/ops_bridge/trust.rs b/desktop/src-tauri/src/ops_bridge/trust.rs index 8c7da3fe9a..f8de732315 100644 --- a/desktop/src-tauri/src/ops_bridge/trust.rs +++ b/desktop/src-tauri/src/ops_bridge/trust.rs @@ -233,6 +233,10 @@ impl Trust { env.insert("PATH".into(), "/usr/bin:/bin:/usr/sbin:/sbin".into()); env.insert("TMPDIR".into(), "/tmp".into()); } + #[cfg(target_os = "linux")] + if let Some(runtime_dir) = trusted_linux_runtime_dir() { + env.insert("XDG_RUNTIME_DIR".into(), runtime_dir); + } Ok(Launch { executable, args, @@ -241,6 +245,34 @@ impl Trust { }) } } +#[cfg(target_os = "linux")] +fn trusted_linux_runtime_dir() -> Option { + trusted_linux_runtime_dir_from(std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from)) +} + +#[cfg(target_os = "linux")] +pub(super) fn trusted_linux_runtime_dir_from(configured: Option) -> Option { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let uid = unsafe { libc::geteuid() }; + let path = configured.or_else(|| Some(PathBuf::from(format!("/run/user/{uid}"))))?; + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return None; + } + let metadata = fs::symlink_metadata(&path).ok()?; + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != uid + || metadata.permissions().mode() & 0o077 != 0 + { + return None; + } + path_string(&path).ok() +} #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Target { diff --git a/scripts/prepare_managed_resources.py b/scripts/prepare_managed_resources.py index 405be4da69..a19beb4178 100644 --- a/scripts/prepare_managed_resources.py +++ b/scripts/prepare_managed_resources.py @@ -151,7 +151,9 @@ def acquire(item: dict[str, Any], destination: Path, cache: Path | None = None) partial.rename(destination) -def extract_runtime(archive: Path, output: Path) -> list[dict[str, str]]: # noqa: C901 - one archive validation boundary +def extract_runtime( # noqa: C901 - one archive validation boundary + archive: Path, output: Path, *, case_sensitive: bool = False +) -> list[dict[str, str]]: """Materialize regular files and safe internal file aliases, never archive links. ResourceStore deliberately rejects links. Directory aliases, absolute/external @@ -162,7 +164,7 @@ def extract_runtime(archive: Path, output: Path) -> list[dict[str, str]]: # noq aliases: list[dict[str, str]] = [] with tarfile.open(archive, "r:gz") as source: members: dict[str, tarfile.TarInfo] = {} - casefolded: set[str] = set() + seen_names: set[str] = set() total = 0 for member in source: name = member.name.rstrip("/") @@ -172,12 +174,13 @@ def extract_runtime(archive: Path, output: Path) -> list[dict[str, str]]: # noq raise ValueError("Interpreter archive must contain only the python/ install tree") relative = name.removeprefix("python/") ResourceFile(path=relative, size=member.size if member.isfile() else 0, sha256="0" * 64) - if relative.casefold() in casefolded or not ( + key = relative if case_sensitive else relative.casefold() + if key in seen_names or not ( member.isfile() or member.isdir() or member.issym() or member.islnk() ): raise ValueError("Interpreter archive has duplicate paths or unsupported file types") members[relative] = member - casefolded.add(relative.casefold()) + seen_names.add(key) total += member.size if len(members) > MAX_FILES or total > MAX_UNPACKED_BYTES: raise ValueError("Interpreter archive exceeds resource limits") @@ -330,7 +333,7 @@ def prepare(args: argparse.Namespace) -> None: # noqa: C901 - ordered acquisiti with tempfile.TemporaryDirectory(prefix="pc-resource-prepare-") as temporary: work = Path(temporary).resolve() runtime = work / "runtime" - aliases = extract_runtime(output / "python.tar.gz", runtime) + aliases = extract_runtime(output / "python.tar.gz", runtime, case_sensitive=spec["platform"] == "linux") build_lock = json.loads(_BUILD_TOOLS.read_bytes()) tooling = work / "tooling" tooling.mkdir() diff --git a/scripts/produce_managed_resources.py b/scripts/produce_managed_resources.py index d58021a5cc..f131af2a0b 100644 --- a/scripts/produce_managed_resources.py +++ b/scripts/produce_managed_resources.py @@ -227,7 +227,7 @@ def _server_probe(root: Path, spec: dict[str, Any], version: str, environment_va identity["architecture"].lower() ) if ( - identity["platform"] != {"macos": "darwin", "windows": "win32"}[spec["platform"]] + identity["platform"] != {"macos": "darwin", "windows": "win32", "linux": "linux"}[spec["platform"]] or architecture != spec["architecture"] ): raise ValueError("Installed runtime platform does not match its resource identity") @@ -299,7 +299,7 @@ def produce(args: argparse.Namespace) -> None: with tempfile.TemporaryDirectory(prefix="pc-candidate-") as temporary: work = Path(temporary).resolve() runtime = work / "python" - aliases = extract_runtime(inputs / "python.tar.gz", runtime) + aliases = extract_runtime(inputs / "python.tar.gz", runtime, case_sensitive=spec["platform"] == "linux") if aliases != metadata["normalized_runtime_aliases"]: raise ValueError("Runtime alias normalization differs from the prepared input") licenses = _licenses(inputs, runtime, metadata) diff --git a/src/powercontext/ops/installer.py b/src/powercontext/ops/installer.py index 857082b3b1..fc06490f4d 100644 --- a/src/powercontext/ops/installer.py +++ b/src/powercontext/ops/installer.py @@ -94,6 +94,28 @@ def current_user(cls) -> ManagedLayout: Path.home() / "Library/Caches/PowerContextManaged", default_data_dir(), ) + if sys.platform == "linux": + home = Path.home() + + def xdg_home(name: str, fallback: Path) -> Path: + value = os.environ.get(name) + candidate = Path(value) if value else fallback + if not candidate.is_absolute() or ".." in candidate.parts: + raise OpsError("layout_invalid") + return candidate + + data = xdg_home("XDG_DATA_HOME", home / ".local/share") + config = xdg_home("XDG_CONFIG_HOME", home / ".config") + state = xdg_home("XDG_STATE_HOME", home / ".local/state") + cache = xdg_home("XDG_CACHE_HOME", home / ".cache") + root = data / "PowerContextManaged" + return cls( + root / "programs", + config / "PowerContextManaged", + state / "PowerContextManaged", + cache / "PowerContextManaged", + default_data_dir(), + ) raise OpsError("unsupported_platform") def as_dict(self) -> dict[str, str]: diff --git a/src/powercontext/ops/resources.py b/src/powercontext/ops/resources.py index abb425d557..672e40fb99 100644 --- a/src/powercontext/ops/resources.py +++ b/src/powercontext/ops/resources.py @@ -120,7 +120,7 @@ class ResourceSpec(BaseModel): resource_id: StrictStr server_version: StrictStr = Field(min_length=1, max_length=128) python_version: StrictStr = Field(min_length=1, max_length=128) - platform: Literal["windows", "macos"] + platform: Literal["windows", "macos", "linux"] architecture: Literal["x86_64", "arm64"] archive_format: Literal["zip", "tar.gz"] url: StrictStr = Field(max_length=4096) @@ -154,7 +154,7 @@ def consistent(self) -> Self: raise ResourceError("resource_invalid") if any(not reference.strip() or len(reference) > 4096 for reference in (*self.source_refs, *self.license_refs)): raise ResourceError("resource_invalid") - _validate_file_paths(self.files) + _validate_file_paths(self.files, case_sensitive=self.platform == "linux") executable = next((file for file in self.files if file.path == self.python_relative), None) if executable is None or not executable.executable: raise ResourceError("resource_invalid") @@ -193,8 +193,11 @@ def as_dict(self) -> dict[str, object]: } -def _validate_file_paths(manifest: tuple[ResourceFile, ...]) -> None: - files = {file.path.casefold(): file for file in manifest} +def _validate_file_paths(manifest: tuple[ResourceFile, ...], *, case_sensitive: bool) -> None: + def key(name: str) -> str: + return name if case_sensitive else name.casefold() + + files = {key(file.path): file for file in manifest} if len(files) != len(manifest): raise ResourceError("resource_invalid") directories: dict[str, str] = {} @@ -203,10 +206,10 @@ def _validate_file_paths(manifest: tuple[ResourceFile, ...]) -> None: if parent == PurePosixPath("."): continue name = str(parent) - key = name.casefold() - if key in files or (key in directories and directories[key] != name): + normalized = key(name) + if normalized in files or (normalized in directories and directories[normalized] != name): raise ResourceError("resource_invalid") - directories[key] = name + directories[normalized] = name @dataclass(frozen=True) @@ -267,7 +270,7 @@ def select_current(self) -> ResourceSpec: def _current_platform() -> tuple[str, str]: - operating_system = {"win32": "windows", "darwin": "macos"}.get(sys.platform) + operating_system = {"win32": "windows", "darwin": "macos", "linux": "linux"}.get(sys.platform) architecture = {"amd64": "x86_64", "x86_64": "x86_64", "arm64": "arm64", "aarch64": "arm64"}.get( platform.machine().lower() ) @@ -587,6 +590,7 @@ def __init__(self, spec: ResourceSpec, stage: Path, cancelled: Callable[[], bool self.expected = {file.path: file for file in spec.files} self.directories = {str(parent) for file in spec.files for parent in PurePosixPath(file.path).parents} self.seen: set[str] = set() + self.case_sensitive = spec.platform == "linux" self.stage = stage self.cancelled = cancelled # Include tar headers, padding and bounded producer metadata, without @@ -596,9 +600,10 @@ def __init__(self, spec: ResourceSpec, stage: Path, cancelled: Callable[[], bool def entry(self, name: str, size: int, directory: bool) -> ResourceFile | None: _cancel(self.cancelled) name = _relative(name.removesuffix("/") if directory else name) - if len(self.seen) >= MAX_FILES * 2 or name.casefold() in self.seen: + key = name if self.case_sensitive else name.casefold() + if len(self.seen) >= MAX_FILES * 2 or key in self.seen: raise ResourceError("resource_archive_invalid") - self.seen.add(name.casefold()) + self.seen.add(key) if directory: if name not in self.directories or size != 0: raise ResourceError("resource_archive_invalid") diff --git a/tests/test_managed_resource_producer.py b/tests/test_managed_resource_producer.py index 643fcf8fd0..84e1338575 100644 --- a/tests/test_managed_resource_producer.py +++ b/tests/test_managed_resource_producer.py @@ -175,6 +175,24 @@ def test_internal_file_alias_is_materialized_and_consumed_with_pinned_catalog( catalog.select_current() +def test_linux_interpreter_keeps_case_distinct_runtime_paths(tmp_path: Path) -> None: + source = tmp_path / "python.tar.gz" + with tarfile.open(source, "w:gz") as archive: + for name in ("python/bin/python3", "python/lib/TermInfo", "python/lib/terminfo"): + content = name.encode() + member = tarfile.TarInfo(name) + member.size = len(content) + member.mode = 0o755 if name.endswith("python3") else 0o644 + archive.addfile(member, io.BytesIO(content)) + + with pytest.raises(ValueError, match="duplicate"): + extract_runtime(source, tmp_path / "windows-runtime") + extract_runtime(source, tmp_path / "linux-runtime", case_sensitive=True) + assert (tmp_path / "linux-runtime/lib/TermInfo").read_bytes() != ( + tmp_path / "linux-runtime/lib/terminfo" + ).read_bytes() + + @pytest.mark.parametrize( "alias,directory", [("../outside", False), ("/absolute/python.exe", False), ("lib", True), ("pythonw.exe", False)] ) diff --git a/tests/test_ops_installer.py b/tests/test_ops_installer.py index 7240ffc79b..d2ede27667 100644 --- a/tests/test_ops_installer.py +++ b/tests/test_ops_installer.py @@ -26,6 +26,7 @@ import pytest from powercontext.ops import Ops +from powercontext.ops import installer as installer_module from powercontext.ops.installer import ManagedInstaller, ManagedLayout from powercontext.ops.resources import PreparedResource, ResourceCatalog, validate_owned_tree, verify from powercontext.service.controller import ServiceController @@ -72,6 +73,21 @@ def prepare(spec, staging_parent, *, cancelled): return Ops(target, layout.state, controller=controller, installer=installer), adapter, installer +def test_linux_layout_uses_user_xdg_locations(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(installer_module.sys, "platform", "linux") + monkeypatch.setattr(installer_module, "default_data_dir", lambda: tmp_path / "application-data") + for name in ("XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"): + monkeypatch.setenv(name, str(tmp_path / name.lower())) + + layout = ManagedLayout.current_user() + + assert layout.programs == tmp_path / "xdg_data_home/PowerContextManaged/programs" + assert layout.configuration == tmp_path / "xdg_config_home/PowerContextManaged" + assert layout.state == tmp_path / "xdg_state_home/PowerContextManaged" + assert layout.cache == tmp_path / "xdg_cache_home/PowerContextManaged" + assert layout.data == tmp_path / "application-data" + + @pytest.fixture def managed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Ops, FakeAdapter, ManagedInstaller]: return make_managed_ops(tmp_path, monkeypatch) diff --git a/tests/test_ops_resources.py b/tests/test_ops_resources.py index a7bd69cd17..56de9924af 100644 --- a/tests/test_ops_resources.py +++ b/tests/test_ops_resources.py @@ -90,6 +90,16 @@ def _spec(archive: bytes, **overrides: object) -> ResourceSpec: return ResourceSpec.model_validate(values) +def test_linux_resource_paths_are_case_sensitive() -> None: + manifest = tuple( + resources.ResourceFile(path=path, size=1, sha256="0" * 64) + for path in ("bin/python3", "lib/TermInfo", "lib/terminfo") + ) + resources._validate_file_paths(manifest, case_sensitive=True) + with pytest.raises(ResourceError, match="resource_invalid"): + resources._validate_file_paths(manifest, case_sensitive=False) + + @pytest.fixture(autouse=True) def platform_fixture(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(resources, "_current_platform", lambda: ("windows", "x86_64")) From 911d6f8845126d1a474167f0e818f2b2aeb87085 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Thu, 5 Nov 2026 13:36:32 +0800 Subject: [PATCH 05/19] style: format managed resource preparation --- scripts/prepare_managed_resources.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/prepare_managed_resources.py b/scripts/prepare_managed_resources.py index a19beb4178..3b1c82841f 100644 --- a/scripts/prepare_managed_resources.py +++ b/scripts/prepare_managed_resources.py @@ -175,9 +175,7 @@ def extract_runtime( # noqa: C901 - one archive validation boundary relative = name.removeprefix("python/") ResourceFile(path=relative, size=member.size if member.isfile() else 0, sha256="0" * 64) key = relative if case_sensitive else relative.casefold() - if key in seen_names or not ( - member.isfile() or member.isdir() or member.issym() or member.islnk() - ): + if key in seen_names or not (member.isfile() or member.isdir() or member.issym() or member.islnk()): raise ValueError("Interpreter archive has duplicate paths or unsupported file types") members[relative] = member seen_names.add(key) From 4bd2a70aa62299b6ca7244437f3bfc122cc89ab1 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Thu, 5 Nov 2026 15:17:28 +0800 Subject: [PATCH 06/19] fix(desktop): preserve Linux XDG service environment --- desktop/src-tauri/src/ops_bridge/tests.rs | 23 ++++++++++++++ desktop/src-tauri/src/ops_bridge/trust.rs | 38 +++++++++++++++++++++-- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/ops_bridge/tests.rs b/desktop/src-tauri/src/ops_bridge/tests.rs index 5dd6770119..8cfcf33b6c 100644 --- a/desktop/src-tauri/src/ops_bridge/tests.rs +++ b/desktop/src-tauri/src/ops_bridge/tests.rs @@ -895,6 +895,29 @@ fn linux_runtime_environment_is_preserved_only_for_a_private_owned_directory() { assert!(trust::trusted_linux_runtime_dir_from(Some(dir.path().to_path_buf())).is_none()); } +#[cfg(target_os = "linux")] +#[test] +fn linux_xdg_home_environment_preserves_a_non_default_layout() { + let dir = fixture_dir(); + let values = [ + ("XDG_CONFIG_HOME", dir.path().join("config")), + ("XDG_DATA_HOME", dir.path().join("data")), + ("XDG_STATE_HOME", dir.path().join("state")), + ("XDG_CACHE_HOME", dir.path().join("cache")), + ] + .into_iter() + .collect::>(); + let environment = + trust::trusted_linux_xdg_environment_from(|name| values.get(name).cloned()).unwrap(); + for (name, path) in values { + assert_eq!(environment.get(name).map(String::as_str), path.to_str()); + } + + let invalid = + std::collections::BTreeMap::from([("XDG_CONFIG_HOME", PathBuf::from("relative/config"))]); + assert!(trust::trusted_linux_xdg_environment_from(|name| invalid.get(name).cloned()).is_err()); +} + #[test] fn database_admission_v1_extensions_remain_bounded_and_actionable() { let r = request(OpsAction::Inspect, json!({})); diff --git a/desktop/src-tauri/src/ops_bridge/trust.rs b/desktop/src-tauri/src/ops_bridge/trust.rs index f8de732315..bbff7611b0 100644 --- a/desktop/src-tauri/src/ops_bridge/trust.rs +++ b/desktop/src-tauri/src/ops_bridge/trust.rs @@ -234,8 +234,11 @@ impl Trust { env.insert("TMPDIR".into(), "/tmp".into()); } #[cfg(target_os = "linux")] - if let Some(runtime_dir) = trusted_linux_runtime_dir() { - env.insert("XDG_RUNTIME_DIR".into(), runtime_dir); + { + if let Some(runtime_dir) = trusted_linux_runtime_dir() { + env.insert("XDG_RUNTIME_DIR".into(), runtime_dir); + } + env.extend(trusted_linux_xdg_environment()?); } Ok(Launch { executable, @@ -273,6 +276,37 @@ pub(super) fn trusted_linux_runtime_dir_from(configured: Option) -> Opt } path_string(&path).ok() } + +#[cfg(target_os = "linux")] +fn trusted_linux_xdg_environment() -> Result, Error> { + trusted_linux_xdg_environment_from(|name| std::env::var_os(name).map(PathBuf::from)) +} + +#[cfg(target_os = "linux")] +pub(super) fn trusted_linux_xdg_environment_from( + configured: impl Fn(&str) -> Option, +) -> Result, Error> { + let mut environment = BTreeMap::new(); + for name in [ + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "XDG_CACHE_HOME", + ] { + let Some(path) = configured(name) else { + continue; + }; + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(Error::Unavailable); + } + environment.insert(name.to_owned(), path_string(&path)?); + } + Ok(environment) +} #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Target { From 2e168b1acec09c7427c471854cab6bf679032094 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Thu, 5 Nov 2026 20:11:14 +0800 Subject: [PATCH 07/19] test(resources): run case-sensitive path regression on Linux only --- tests/test_managed_resource_producer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_managed_resource_producer.py b/tests/test_managed_resource_producer.py index 84e1338575..f8ee01ae4f 100644 --- a/tests/test_managed_resource_producer.py +++ b/tests/test_managed_resource_producer.py @@ -175,6 +175,7 @@ def test_internal_file_alias_is_materialized_and_consumed_with_pinned_catalog( catalog.select_current() +@pytest.mark.skipif(sys.platform != "linux", reason="case-distinct materialization requires a Linux filesystem") def test_linux_interpreter_keeps_case_distinct_runtime_paths(tmp_path: Path) -> None: source = tmp_path / "python.tar.gz" with tarfile.open(source, "w:gz") as archive: From 861dacc661a0af2987a8a9be2120f0086c7a81f6 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Fri, 6 Nov 2026 13:35:26 +0800 Subject: [PATCH 08/19] fix(desktop): bound macOS workflow diagnostics --- .github/workflows/desktop.yml | 29 ++++++++++++++++++----- desktop/src-tauri/src/ops_bridge/tests.rs | 18 ++++++++++++++ desktop/src-tauri/src/ops_bridge/trust.rs | 6 +++-- tests/test_desktop_macos_workflow.py | 5 ++++ 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index fdb98c14e7..0a04c0e734 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -211,24 +211,41 @@ jobs: set -euo pipefail capture_macos_state() { state_log="$1" + platform="$(uname -s)" { printf 'captured_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo '## macOS' - sw_vers || true uname -a || true - echo '## Xcode' - xcodebuild -version || true + if [ "$platform" = "Darwin" ]; then + sw_vers || true + echo '## Xcode' + xcodebuild -version || true + else + echo 'macOS diagnostics unavailable on a non-macOS runner' + fi echo '## toolchain' node --version || true pnpm --version || true rustc --version || true cargo --version || true echo '## attached disk images' - hdiutil info || true + if [ "$platform" = "Darwin" ]; then + hdiutil info || true + else + echo 'hdiutil unavailable on a non-macOS runner' + fi echo '## disks' - diskutil list || true + if [ "$platform" = "Darwin" ]; then + diskutil list || true + else + echo 'diskutil unavailable on a non-macOS runner' + fi echo '## mounts' - mount || true + if [ "$platform" = "Darwin" ]; then + mount || true + else + echo 'mount diagnostics unavailable on a non-macOS runner' + fi echo '## bundle files' find src-tauri/target/release/bundle -maxdepth 4 -print || true } >"$state_log" 2>&1 diff --git a/desktop/src-tauri/src/ops_bridge/tests.rs b/desktop/src-tauri/src/ops_bridge/tests.rs index 8cfcf33b6c..13d390deb0 100644 --- a/desktop/src-tauri/src/ops_bridge/tests.rs +++ b/desktop/src-tauri/src/ops_bridge/tests.rs @@ -890,6 +890,10 @@ fn linux_runtime_environment_is_preserved_only_for_a_private_owned_directory() { trust::trusted_linux_runtime_dir_from(Some(dir.path().to_path_buf())).as_deref(), dir.path().to_str() ); + assert_eq!( + trust::trusted_linux_runtime_dir_from(Some(PathBuf::new())), + trust::trusted_linux_runtime_dir_from(None) + ); fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap(); assert!(trust::trusted_linux_runtime_dir_from(Some(dir.path().to_path_buf())).is_none()); @@ -916,6 +920,20 @@ fn linux_xdg_home_environment_preserves_a_non_default_layout() { let invalid = std::collections::BTreeMap::from([("XDG_CONFIG_HOME", PathBuf::from("relative/config"))]); assert!(trust::trusted_linux_xdg_environment_from(|name| invalid.get(name).cloned()).is_err()); + + let empty = [ + ("XDG_CONFIG_HOME", PathBuf::new()), + ("XDG_DATA_HOME", PathBuf::new()), + ("XDG_STATE_HOME", PathBuf::new()), + ("XDG_CACHE_HOME", PathBuf::new()), + ] + .into_iter() + .collect::>(); + assert!( + trust::trusted_linux_xdg_environment_from(|name| empty.get(name).cloned()) + .unwrap() + .is_empty() + ); } #[test] diff --git a/desktop/src-tauri/src/ops_bridge/trust.rs b/desktop/src-tauri/src/ops_bridge/trust.rs index bbff7611b0..5726a700c2 100644 --- a/desktop/src-tauri/src/ops_bridge/trust.rs +++ b/desktop/src-tauri/src/ops_bridge/trust.rs @@ -258,7 +258,9 @@ pub(super) fn trusted_linux_runtime_dir_from(configured: Option) -> Opt use std::os::unix::fs::{MetadataExt, PermissionsExt}; let uid = unsafe { libc::geteuid() }; - let path = configured.or_else(|| Some(PathBuf::from(format!("/run/user/{uid}"))))?; + let path = configured + .filter(|path| !path.as_os_str().is_empty()) + .or_else(|| Some(PathBuf::from(format!("/run/user/{uid}"))))?; if !path.is_absolute() || path .components() @@ -293,7 +295,7 @@ pub(super) fn trusted_linux_xdg_environment_from( "XDG_STATE_HOME", "XDG_CACHE_HOME", ] { - let Some(path) = configured(name) else { + let Some(path) = configured(name).filter(|path| !path.as_os_str().is_empty()) else { continue; }; if !path.is_absolute() diff --git a/tests/test_desktop_macos_workflow.py b/tests/test_desktop_macos_workflow.py index c0139b7df0..1d36d9978d 100644 --- a/tests/test_desktop_macos_workflow.py +++ b/tests/test_desktop_macos_workflow.py @@ -119,3 +119,8 @@ def test_macos_dmg_build_preserves_transient_failure_evidence( assert len(diagnostics) == state_logs assert all("## attached disk images" in path.read_text(encoding="utf-8", errors="replace") for path in diagnostics) assert all("## toolchain" in path.read_text(encoding="utf-8", errors="replace") for path in diagnostics) + if os.uname().sysname != "Darwin": + assert all( + "unavailable on a non-macOS runner" in path.read_text(encoding="utf-8", errors="replace") + for path in diagnostics + ) From 6b2f53bdb17234665fd669c5ea0e33fe1958da20 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Fri, 6 Nov 2026 13:42:22 +0800 Subject: [PATCH 09/19] fix(gateway): add missing license header --- gateway/crates/pc-gateway-core/src/lifecycle.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/gateway/crates/pc-gateway-core/src/lifecycle.rs b/gateway/crates/pc-gateway-core/src/lifecycle.rs index aa0c25af7d..b56696ccaf 100644 --- a/gateway/crates/pc-gateway-core/src/lifecycle.rs +++ b/gateway/crates/pc-gateway-core/src/lifecycle.rs @@ -1,3 +1,17 @@ +// 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. + use crate::ManagedOutcome; use std::sync::{Arc, Mutex, MutexGuard}; use tokio::sync::watch; From e4e3dd99d4c082d23334a1a02785ff271ed60281 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Sat, 7 Nov 2026 19:25:13 +0800 Subject: [PATCH 10/19] test(desktop): validate optional Linux CI gates --- .github/workflows/desktop.yml | 3 +++ .../workflows/managed-resource-candidates.yml | 8 ++++++++ .github/workflows/native-service.yml | 3 +++ tests/test_ci_pipeline.py | 19 ++++++++++++++++++- tests/test_upgrade_release_gate.py | 2 +- 5 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 0a04c0e734..a6347f5865 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -288,6 +288,9 @@ jobs: if: >- ${{ !cancelled() && (github.event_name != 'workflow_dispatch' || inputs.installer_run == '') && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').desktop) }} + # Ubuntu Desktop qualification is optional under #2112; required Windows + # and macOS validation must remain independent of a Linux failure. + continue-on-error: true runs-on: ubuntu-24.04 timeout-minutes: 40 defaults: diff --git a/.github/workflows/managed-resource-candidates.yml b/.github/workflows/managed-resource-candidates.yml index 748f87bd54..0f9bc0f3d5 100644 --- a/.github/workflows/managed-resource-candidates.yml +++ b/.github/workflows/managed-resource-candidates.yml @@ -28,6 +28,10 @@ jobs: needs: changes if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').managed_resources) }} name: Candidate ${{ matrix.target }} + # Linux candidate qualification is optional for #2110. Keep its result and + # diagnostics visible without allowing it to fail the required Windows/macOS + # candidate aggregate. + continue-on-error: ${{ matrix.optional }} strategy: max-parallel: 2 fail-fast: false @@ -35,12 +39,16 @@ jobs: include: - target: linux-x86_64 runner: ubuntu-24.04 + optional: true - target: windows-x86_64 runner: windows-2025 + optional: false - target: macos-arm64 runner: macos-15 + optional: false - target: macos-x86_64 runner: macos-15-intel + optional: false runs-on: ${{ matrix.runner }} timeout-minutes: 90 defaults: diff --git a/.github/workflows/native-service.yml b/.github/workflows/native-service.yml index c8b3f8404a..8f6c645b29 100644 --- a/.github/workflows/native-service.yml +++ b/.github/workflows/native-service.yml @@ -27,6 +27,9 @@ jobs: linux-systemd-user: needs: changes if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').native) }} + # Linux native service qualification is optional under #2112; do not make + # it block the required macOS and Windows service checks. + continue-on-error: true runs-on: ubuntu-24.04 timeout-minutes: 30 env: diff --git a/tests/test_ci_pipeline.py b/tests/test_ci_pipeline.py index a2b1539203..b4fbf82035 100644 --- a/tests/test_ci_pipeline.py +++ b/tests/test_ci_pipeline.py @@ -262,13 +262,30 @@ def test_compatibility_and_upgrade_matrices_have_bounded_runner_bursts(): assert int(upgrade["max-parallel"]) <= 3 assert upgrade["fail-fast"] is False # Complete upgrade evidence remains mandatory. native = workflow("managed-resource-candidates.yml")["jobs"]["candidate"]["strategy"] + native_job = workflow("managed-resource-candidates.yml")["jobs"]["candidate"] assert int(native["max-parallel"]) <= 2 - assert {row["target"] for row in native["matrix"]["include"]} == { + rows = {row["target"]: row for row in native["matrix"]["include"]} + assert set(rows) == { "linux-x86_64", "windows-x86_64", "macos-arm64", "macos-x86_64", } + assert rows["linux-x86_64"]["optional"] is True + assert all(rows[target]["optional"] is False for target in rows if target != "linux-x86_64") + assert native_job["continue-on-error"] == "${{ matrix.optional }}" + + +def test_linux_desktop_and_native_validation_are_optional_platform_gates(): + desktop = workflow("desktop.yml")["jobs"] + native = workflow("native-service.yml")["jobs"] + + assert desktop["linux"]["continue-on-error"] is True + assert "continue-on-error" not in desktop["windows"] + assert "continue-on-error" not in desktop["macos"] + assert native["linux-systemd-user"]["continue-on-error"] is True + assert "continue-on-error" not in native["macos-launchagent"] + assert "continue-on-error" not in native["windows-task-scheduler"] def test_cancelled_or_draft_runs_cannot_start_terminal_runner_jobs(): diff --git a/tests/test_upgrade_release_gate.py b/tests/test_upgrade_release_gate.py index e0296dec1a..8e062ce62c 100644 --- a/tests/test_upgrade_release_gate.py +++ b/tests/test_upgrade_release_gate.py @@ -960,7 +960,7 @@ def _process_identity(pid: int) -> str | None: # PID reuse during the parallel suite must not look like a surviving descendant. try: stat = Path(f"/proc/{pid}/stat").read_text() - except FileNotFoundError: + except (FileNotFoundError, ProcessLookupError): return None fields = stat[stat.rfind(")") + 2 :].split() return fields[19] if len(fields) > 19 else None From 37e1e36e1b07fcd1dbe54c4689134c516abe5aa3 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Sat, 7 Nov 2026 21:03:36 +0800 Subject: [PATCH 11/19] ci(desktop): isolate optional Linux candidate --- .../workflows/managed-resource-candidates.yml | 11 ++++- .github/workflows/master.yml | 16 ++++++++ tests/test_ci_pipeline.py | 40 ++++++++++++++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/.github/workflows/managed-resource-candidates.yml b/.github/workflows/managed-resource-candidates.yml index 0f9bc0f3d5..787aeeb6c9 100644 --- a/.github/workflows/managed-resource-candidates.yml +++ b/.github/workflows/managed-resource-candidates.yml @@ -7,6 +7,11 @@ on: description: Scope already selected by the Main orchestrator required: true type: string + targets: + description: JSON array of resource targets to qualify; empty selects every target + required: false + type: string + default: '' workflow_dispatch: permissions: @@ -26,7 +31,11 @@ jobs: candidate: needs: changes - if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').managed_resources) }} + if: >- + ${{ !cancelled() && + (inputs.targets == '' || contains(fromJSON(inputs.targets), matrix.target)) && + ((inputs.suites == '' && needs.changes.result != 'success') || + fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').managed_resources) }} name: Candidate ${{ matrix.target }} # Linux candidate qualification is optional for #2110. Keep its result and # diagnostics visible without allowing it to fail the required Windows/macOS diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index d99412a6b0..57a20e189d 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -1173,6 +1173,22 @@ jobs: uses: ./.github/workflows/managed-resource-candidates.yml with: suites: ${{ needs.quality.outputs.suites }} + # Windows and macOS are the required managed-resource platforms. Linux + # qualification runs in a separate optional caller below. + targets: '["windows-x86_64","macos-arm64","macos-x86_64"]' + + managed-resource-candidates-linux: + needs: [quality, tests] + if: >- + ${{ !cancelled() && needs.quality.result == 'success' && + contains(fromJSON('["success","skipped"]'), needs.tests.result) && + (fromJSON(needs.quality.outputs.suites || '{}').managed_resources) }} + uses: ./.github/workflows/managed-resource-candidates.yml + with: + suites: ${{ needs.quality.outputs.suites }} + # Linux is optional under #2112. Its diagnostics and artifacts remain + # visible, but it is deliberately outside the required result gate. + targets: '["linux-x86_64"]' native-service: needs: [quality, tests] diff --git a/tests/test_ci_pipeline.py b/tests/test_ci_pipeline.py index b4fbf82035..81bc7e6fff 100644 --- a/tests/test_ci_pipeline.py +++ b/tests/test_ci_pipeline.py @@ -183,8 +183,9 @@ def test_selected_scopes_have_a_complete_terminal_result_contract(): main = workflow("master.yml")["jobs"] assert RULES["checks"].keys() == RULES["suites"].keys() covered = {job for jobs in RULES["checks"].values() for job in jobs} - assert covered | {"quality", "upgrade-release-gate", "ci-result"} == set(main) - assert set(main["ci-result"]["needs"]) == set(main) - {"ci-result"} + assert covered | {"quality", "upgrade-release-gate", "ci-result"} <= set(main) + assert "managed-resource-candidates-linux" in set(main) - set(main["ci-result"]["needs"]) + assert set(main["ci-result"]["needs"]) == set(main) - {"ci-result", "managed-resource-candidates-linux"} def test_preflight_uses_one_runner_and_keeps_all_admission_checks_mandatory(): @@ -276,6 +277,41 @@ def test_compatibility_and_upgrade_matrices_have_bounded_runner_bursts(): assert native_job["continue-on-error"] == "${{ matrix.optional }}" +def test_managed_resource_linux_is_outside_required_main_result_gate(): + main = workflow("master.yml")["jobs"] + required = main["managed-resource-candidates"] + optional = main["managed-resource-candidates-linux"] + assert required["with"]["targets"] == '["windows-x86_64","macos-arm64","macos-x86_64"]' + assert optional["with"]["targets"] == '["linux-x86_64"]' + assert "managed-resource-candidates-linux" not in main["ci-result"]["needs"] + assert main["ci-result"]["needs"].count("managed-resource-candidates") == 1 + + +def test_managed_resource_workflow_filters_called_targets(): + jobs = workflow("managed-resource-candidates.yml")["jobs"] + candidate = jobs["candidate"] + condition = candidate["if"] + assert "inputs.targets == ''" in condition + assert "contains(fromJSON(inputs.targets), matrix.target)" in condition + + +def test_ci_result_ignores_only_the_separate_optional_linux_call(): + script = workflow("master.yml")["jobs"]["ci-result"]["steps"][0]["run"] + script = script.split("<<'PYTHON'\n", 1)[1].rsplit("PYTHON", 1)[0] + required = ["quality", "upgrade-release-gate", "managed-resource-candidates"] + results = {job: {"result": "success"} for job in required} + # The optional caller is intentionally absent because GitHub only exposes + # jobs listed in ci-result.needs to the terminal aggregation script. + run = subprocess.run( + [sys.executable, "-c", script], + env={**os.environ, "RESULTS": json.dumps(results), "REQUIRED": json.dumps(required)}, + capture_output=True, + text=True, + check=False, + ) + assert run.returncode == 0, run.stdout + run.stderr + + def test_linux_desktop_and_native_validation_are_optional_platform_gates(): desktop = workflow("desktop.yml")["jobs"] native = workflow("native-service.yml")["jobs"] From 24018fb0cc2fca958c411284c932d2762534ef01 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Mon, 9 Nov 2026 16:23:40 +0800 Subject: [PATCH 12/19] ci(desktop): fix managed resource matrix selection --- .../workflows/managed-resource-candidates.yml | 52 +++++++++++++------ tests/test_ci_pipeline.py | 20 +++---- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/.github/workflows/managed-resource-candidates.yml b/.github/workflows/managed-resource-candidates.yml index 787aeeb6c9..fb8f0f1f94 100644 --- a/.github/workflows/managed-resource-candidates.yml +++ b/.github/workflows/managed-resource-candidates.yml @@ -29,11 +29,44 @@ jobs: with: baseline-workflow: managed-resource-candidates.yml + select-targets: + name: Select candidate targets + runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.select.outputs.matrix }} + steps: + - id: select + env: + TARGETS: ${{ inputs.targets }} + run: | + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json + import os + + rows = [ + {"target": "linux-x86_64", "runner": "ubuntu-24.04", "optional": True}, + {"target": "windows-x86_64", "runner": "windows-2025", "optional": False}, + {"target": "macos-arm64", "runner": "macos-15", "optional": False}, + {"target": "macos-x86_64", "runner": "macos-15-intel", "optional": False}, + ] + requested = os.environ["TARGETS"].strip() + selected = set(json.loads(requested)) if requested else {row["target"] for row in rows} + if not selected: + raise SystemExit("at least one managed-resource target must be selected") + known = {row["target"] for row in rows} + unknown = selected - known + if unknown: + raise SystemExit(f"unsupported managed-resource target(s): {sorted(unknown)}") + print("matrix=" + json.dumps( + {"include": [row for row in rows if row["target"] in selected]}, + separators=(",", ":"), + )) + PY + candidate: - needs: changes + needs: [changes, select-targets] if: >- ${{ !cancelled() && - (inputs.targets == '' || contains(fromJSON(inputs.targets), matrix.target)) && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').managed_resources) }} name: Candidate ${{ matrix.target }} @@ -44,20 +77,7 @@ jobs: strategy: max-parallel: 2 fail-fast: false - matrix: - include: - - target: linux-x86_64 - runner: ubuntu-24.04 - optional: true - - target: windows-x86_64 - runner: windows-2025 - optional: false - - target: macos-arm64 - runner: macos-15 - optional: false - - target: macos-x86_64 - runner: macos-15-intel - optional: false + matrix: ${{ fromJSON(needs.select-targets.outputs.matrix) }} runs-on: ${{ matrix.runner }} timeout-minutes: 90 defaults: diff --git a/tests/test_ci_pipeline.py b/tests/test_ci_pipeline.py index a829042577..07d911d9ed 100644 --- a/tests/test_ci_pipeline.py +++ b/tests/test_ci_pipeline.py @@ -262,18 +262,13 @@ def test_compatibility_and_upgrade_matrices_have_bounded_runner_bursts(): upgrade = workflow("upgrade-release.yml")["jobs"]["cases"]["strategy"] assert int(upgrade["max-parallel"]) <= 3 assert upgrade["fail-fast"] is False # Complete upgrade evidence remains mandatory. - native = workflow("managed-resource-candidates.yml")["jobs"]["candidate"]["strategy"] + jobs = workflow("managed-resource-candidates.yml")["jobs"] + native = jobs["candidate"]["strategy"] native_job = workflow("managed-resource-candidates.yml")["jobs"]["candidate"] assert int(native["max-parallel"]) <= 2 - rows = {row["target"]: row for row in native["matrix"]["include"]} - assert set(rows) == { - "linux-x86_64", - "windows-x86_64", - "macos-arm64", - "macos-x86_64", - } - assert rows["linux-x86_64"]["optional"] is True - assert all(rows[target]["optional"] is False for target in rows if target != "linux-x86_64") + assert native["matrix"] == "${{ fromJSON(needs.select-targets.outputs.matrix) }}" + selector = jobs["select-targets"] + assert selector["outputs"]["matrix"] == "${{ steps.select.outputs.matrix }}" assert native_job["continue-on-error"] == "${{ matrix.optional }}" @@ -290,9 +285,8 @@ def test_managed_resource_linux_is_outside_required_main_result_gate(): def test_managed_resource_workflow_filters_called_targets(): jobs = workflow("managed-resource-candidates.yml")["jobs"] candidate = jobs["candidate"] - condition = candidate["if"] - assert "inputs.targets == ''" in condition - assert "contains(fromJSON(inputs.targets), matrix.target)" in condition + assert "matrix.target" not in candidate["if"] + assert "inputs.targets" in jobs["select-targets"]["steps"][0]["env"]["TARGETS"] def test_ci_result_ignores_only_the_separate_optional_linux_call(): From d52ec0067b83003c7152198714e76fb045338a7c Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Tue, 10 Nov 2026 19:17:42 +0800 Subject: [PATCH 13/19] ci(desktop): isolate managed resource caller concurrency --- .github/workflows/managed-resource-candidates.yml | 13 ++++++++++++- .github/workflows/master.yml | 2 ++ tests/test_ci_pipeline.py | 3 +++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/managed-resource-candidates.yml b/.github/workflows/managed-resource-candidates.yml index fb8f0f1f94..b4f42a2284 100644 --- a/.github/workflows/managed-resource-candidates.yml +++ b/.github/workflows/managed-resource-candidates.yml @@ -12,14 +12,25 @@ on: required: false type: string default: '' + concurrency_suffix: + description: Suffix that isolates independent callers of this reusable workflow + required: false + type: string + default: default workflow_dispatch: + inputs: + concurrency_suffix: + description: Suffix that isolates this manual run from other callers + required: false + type: string + default: manual permissions: actions: read contents: read concurrency: - group: managed-resource-candidates-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} + group: managed-resource-candidates-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }}-${{ inputs.concurrency_suffix || 'default' }} cancel-in-progress: true jobs: diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 7d05c96b14..a592ee2880 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -1191,6 +1191,7 @@ jobs: # Windows and macOS are the required managed-resource platforms. Linux # qualification runs in a separate optional caller below. targets: '["windows-x86_64","macos-arm64","macos-x86_64"]' + concurrency_suffix: required managed-resource-candidates-linux: needs: [quality, tests] @@ -1204,6 +1205,7 @@ jobs: # Linux is optional under #2112. Its diagnostics and artifacts remain # visible, but it is deliberately outside the required result gate. targets: '["linux-x86_64"]' + concurrency_suffix: linux script-installation: needs: [quality, tests] diff --git a/tests/test_ci_pipeline.py b/tests/test_ci_pipeline.py index 07d911d9ed..7166e8a78b 100644 --- a/tests/test_ci_pipeline.py +++ b/tests/test_ci_pipeline.py @@ -278,6 +278,8 @@ def test_managed_resource_linux_is_outside_required_main_result_gate(): optional = main["managed-resource-candidates-linux"] assert required["with"]["targets"] == '["windows-x86_64","macos-arm64","macos-x86_64"]' assert optional["with"]["targets"] == '["linux-x86_64"]' + assert required["with"]["concurrency_suffix"] == "required" + assert optional["with"]["concurrency_suffix"] == "linux" assert "managed-resource-candidates-linux" not in main["ci-result"]["needs"] assert main["ci-result"]["needs"].count("managed-resource-candidates") == 1 @@ -287,6 +289,7 @@ def test_managed_resource_workflow_filters_called_targets(): candidate = jobs["candidate"] assert "matrix.target" not in candidate["if"] assert "inputs.targets" in jobs["select-targets"]["steps"][0]["env"]["TARGETS"] + assert "inputs.concurrency_suffix" in workflow("managed-resource-candidates.yml")["concurrency"]["group"] def test_ci_result_ignores_only_the_separate_optional_linux_call(): From dcf60c392cf1c8d77e85db5b37a15bc31a19d7b1 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Wed, 11 Nov 2026 10:50:06 +0800 Subject: [PATCH 14/19] style: format CI pipeline test --- tests/test_ci_pipeline.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/test_ci_pipeline.py b/tests/test_ci_pipeline.py index 265c364fda..a65dbee244 100644 --- a/tests/test_ci_pipeline.py +++ b/tests/test_ci_pipeline.py @@ -184,12 +184,7 @@ def test_explicit_full_pr_profile_overrides_the_small_matrix(): def test_selected_scopes_have_a_complete_terminal_result_contract(): main = workflow("master.yml")["jobs"] assert RULES["checks"].keys() == RULES["suites"].keys() - covered = { - job - for table in ("checks", "nightly_checks") - for jobs in RULES[table].values() - for job in jobs - } + covered = {job for table in ("checks", "nightly_checks") for jobs in RULES[table].values() for job in jobs} assert covered | {"quality", "upgrade-release-gate", "ci-result"} <= set(main) assert "managed-resource-candidates-linux" in set(main) - set(main["ci-result"]["needs"]) assert set(main["ci-result"]["needs"]) == set(main) - {"ci-result", "managed-resource-candidates-linux"} From 824e95a0e80a3e7de50525b691bea44c5de5ef55 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Thu, 12 Nov 2026 23:52:24 +0800 Subject: [PATCH 15/19] docs(desktop): clarify Linux service validation --- desktop/VALIDATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 564cd178e0..c11ea9469d 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -459,7 +459,7 @@ export POWERCONTEXT_NATIVE_SERVICE_IDENTIFIER="powercontext-local-validation-$(i uv run --no-sync pytest -q tests/native/test_personal_service_lifecycle.py ``` -The Desktop page currently performs a read-only status inspection through `ops.inspect`; it does not install, start, stop or restart a service. The product uses the current user's `systemctl --user` manager and does not enable linger, write system units or require root. A passing Rust, lifecycle or candidate-bundle test does not by itself qualify a signed package or a normal user's login-session configuration. +The Desktop page uses `ops.inspect` for read-only status inspection and dispatches explicitly approved lifecycle plans through `ops.apply`; with a valid Linux binding, the page can install, repair, start, stop or restart the personal service. These operations use the current user's `systemctl --user` manager and do not enable linger, write system units or require root. A passing Rust, lifecycle or candidate-bundle test does not by itself qualify a signed package or a normal user's login-session configuration. ## Deployment context validation From c5b9a19adef9bca9183c1649fd985c77cf05df82 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Fri, 13 Nov 2026 11:53:42 +0800 Subject: [PATCH 16/19] fix(desktop): preserve Linux lifecycle and XDG boundaries --- .../workflows/managed-resource-candidates.yml | 2 +- .github/workflows/native-service.yml | 3 --- desktop/README.md | 2 +- desktop/README.zh.md | 2 +- desktop/src-tauri/src/ops_bridge/tests.rs | 14 +++++++++--- desktop/src-tauri/src/ops_bridge/trust.rs | 12 ++++++---- src/powercontext/ops/installer.py | 8 +++++-- tests/test_ci_pipeline.py | 4 ++-- tests/test_ops_installer.py | 22 +++++++++++++++++++ 9 files changed, 52 insertions(+), 17 deletions(-) diff --git a/.github/workflows/managed-resource-candidates.yml b/.github/workflows/managed-resource-candidates.yml index b4f42a2284..9af8774fa1 100644 --- a/.github/workflows/managed-resource-candidates.yml +++ b/.github/workflows/managed-resource-candidates.yml @@ -170,7 +170,7 @@ jobs: { echo "bundle=$POWERCONTEXT_OPS_TEST_BUNDLE" echo "bundle_manifest_sha256=$POWERCONTEXT_OPS_TEST_BUNDLE_SHA256" - CARGO_BUILD_JOBS=2 cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib actual_independent_bundle_runs_through_native_trust_and_read_only_bridge -- --ignored + CARGO_BUILD_JOBS=2 rustup run 1.95.0 cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib actual_independent_bundle_runs_through_native_trust_and_read_only_bridge -- --ignored } 2>&1 | tee build/resource-evidence/native-ops-bridge.txt - name: Retain unqualified candidate evidence if: success() diff --git a/.github/workflows/native-service.yml b/.github/workflows/native-service.yml index 8f6c645b29..c8b3f8404a 100644 --- a/.github/workflows/native-service.yml +++ b/.github/workflows/native-service.yml @@ -27,9 +27,6 @@ jobs: linux-systemd-user: needs: changes if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').native) }} - # Linux native service qualification is optional under #2112; do not make - # it block the required macOS and Windows service checks. - continue-on-error: true runs-on: ubuntu-24.04 timeout-minutes: 30 env: diff --git a/desktop/README.md b/desktop/README.md index 70e3821a1d..a02a2620bd 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -200,7 +200,7 @@ The Windows CI workflow runs on PRs and the preview branch, verifies native vaul On Linux, the Local Server settings page can inspect the PowerContext user service through the trusted Ops Bridge. This check is read-only: it reports supported, registration, ownership, manager, liveness, readiness and maintenance status, while keeping paths, commands, environment values and raw process output out of the UI. -The bridge only addresses PowerContext's registered service target and rejects untrusted windows, remote origins and arbitrary renderer input. A missing or unavailable local service does not disable remote Server connections. This workstream does not add install, start, stop or restart controls; those mutations remain in the service-management boundary and must use the same ownership and approval rules. +The bridge only addresses PowerContext's registered service target and rejects untrusted windows, remote origins and arbitrary renderer input. A missing or unavailable local service does not disable remote Server connections. The page uses `ops.inspect` for read-only status inspection; with a valid Linux binding, explicitly approved plans dispatched through `ops.apply` can install, repair, start, stop or restart the personal service. These lifecycle operations retain the same ownership and approval rules. The unit is written to the current user's `$XDG_CONFIG_HOME/systemd/user/` directory, or `~/.config/systemd/user/` when `XDG_CONFIG_HOME` is unset, and all manager calls use `systemctl --user`. Normal installation and recovery do not require `sudo`, never write a system unit under `/etc/systemd/system/`, and never call `loginctl enable-linger`. At login, the user's existing systemd manager may activate the enabled unit through `default.target`; at logout, the service follows that user manager's normal lifetime. If the account already has linger enabled by the user or administrator, PowerContext does not change that setting or silently create system-wide persistence. diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 25931a2dac..d07f9365e2 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -152,7 +152,7 @@ pnpm --dir desktop desktop:dev 在 Linux 上,「本地 Server」设置页可以通过可信的 Ops Bridge 检查 PowerContext 的用户服务。这个检查是只读的:它只显示是否支持、是否注册、管理归属、管理器状态、存活状态、就绪状态和维护状态,不会把路径、命令、环境值或原始进程输出显示给界面。 -Ops Bridge 只处理已登记的 PowerContext 服务目标,并拒绝不可信窗口、远程来源和渲染器传入的任意输入。本地服务缺失或暂时不可用时,远程 Server 连接仍然可以使用。本工作流不增加安装、启动、停止或重启按钮;这些修改操作仍由服务管理边界负责,并且必须继续遵守归属和审批规则。 +Ops Bridge 只处理已登记的 PowerContext 服务目标,并拒绝不可信窗口、远程来源和渲染器传入的任意输入。本地服务缺失或暂时不可用时,远程 Server 连接仍然可以使用。页面通过 `ops.inspect` 执行只读状态检查;在有效的 Linux binding 下,经过明确批准并通过 `ops.apply` 派发的计划可以安装、修复、启动、停止或重启个人服务。这些生命周期操作仍然必须遵守相同的归属和审批规则。 服务单元写入当前用户的 `$XDG_CONFIG_HOME/systemd/user/`;未设置 `XDG_CONFIG_HOME` 时使用 `~/.config/systemd/user/`,所有管理器调用都使用 `systemctl --user`。正常安装和恢复不需要 `sudo`,不会写入 `/etc/systemd/system/` 之类的系统级 unit,也不会调用 `loginctl enable-linger`。用户登录时,已有的用户级 systemd manager 可以通过 `default.target` 激活已启用的服务;用户注销时,服务遵循该用户 manager 的正常生命周期。如果账户本来已经由用户或管理员启用了 linger,PowerContext 不会修改它,也不会默默创建系统级持久化。 diff --git a/desktop/src-tauri/src/ops_bridge/tests.rs b/desktop/src-tauri/src/ops_bridge/tests.rs index 94662a1c17..3a33e59272 100644 --- a/desktop/src-tauri/src/ops_bridge/tests.rs +++ b/desktop/src-tauri/src/ops_bridge/tests.rs @@ -992,9 +992,17 @@ fn linux_xdg_home_environment_preserves_a_non_default_layout() { assert_eq!(environment.get(name).map(String::as_str), path.to_str()); } - let invalid = - std::collections::BTreeMap::from([("XDG_CONFIG_HOME", PathBuf::from("relative/config"))]); - assert!(trust::trusted_linux_xdg_environment_from(|name| invalid.get(name).cloned()).is_err()); + let relative = std::collections::BTreeMap::from([ + ("XDG_CONFIG_HOME", PathBuf::from("relative/config")), + ("XDG_DATA_HOME", dir.path().join("data")), + ]); + let environment = + trust::trusted_linux_xdg_environment_from(|name| relative.get(name).cloned()).unwrap(); + assert!(!environment.contains_key("XDG_CONFIG_HOME")); + assert_eq!( + environment.get("XDG_DATA_HOME").map(String::as_str), + relative.get("XDG_DATA_HOME").and_then(|path| path.to_str()) + ); let empty = [ ("XDG_CONFIG_HOME", PathBuf::new()), diff --git a/desktop/src-tauri/src/ops_bridge/trust.rs b/desktop/src-tauri/src/ops_bridge/trust.rs index eb9e2d385a..47bd348c55 100644 --- a/desktop/src-tauri/src/ops_bridge/trust.rs +++ b/desktop/src-tauri/src/ops_bridge/trust.rs @@ -366,10 +366,14 @@ pub(super) fn trusted_linux_xdg_environment_from( let Some(path) = configured(name).filter(|path| !path.as_os_str().is_empty()) else { continue; }; - if !path.is_absolute() - || path - .components() - .any(|component| matches!(component, Component::ParentDir)) + if !path.is_absolute() { + // The XDG Base Directory Specification says to ignore relative + // values and use the platform default instead. + continue; + } + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) { return Err(Error::Unavailable); } diff --git a/src/powercontext/ops/installer.py b/src/powercontext/ops/installer.py index fc06490f4d..5ddd30a3a1 100644 --- a/src/powercontext/ops/installer.py +++ b/src/powercontext/ops/installer.py @@ -99,9 +99,13 @@ def current_user(cls) -> ManagedLayout: def xdg_home(name: str, fallback: Path) -> Path: value = os.environ.get(name) - candidate = Path(value) if value else fallback + if not value: + return fallback + candidate = Path(value) + # The XDG Base Directory Specification says relative values are + # ignored. Keep the default layout instead of disabling Ops. if not candidate.is_absolute() or ".." in candidate.parts: - raise OpsError("layout_invalid") + return fallback return candidate data = xdg_home("XDG_DATA_HOME", home / ".local/share") diff --git a/tests/test_ci_pipeline.py b/tests/test_ci_pipeline.py index df37ab8cf3..f9da1b1e6b 100644 --- a/tests/test_ci_pipeline.py +++ b/tests/test_ci_pipeline.py @@ -337,14 +337,14 @@ def test_ci_result_ignores_only_the_separate_optional_linux_call(): assert run.returncode == 0, run.stdout + run.stderr -def test_linux_desktop_and_native_validation_are_optional_platform_gates(): +def test_linux_desktop_only_is_an_optional_platform_gate(): desktop = workflow("desktop.yml")["jobs"] native = workflow("native-service.yml")["jobs"] assert desktop["linux"]["continue-on-error"] is True assert "continue-on-error" not in desktop["windows"] assert "continue-on-error" not in desktop["macos"] - assert native["linux-systemd-user"]["continue-on-error"] is True + assert "continue-on-error" not in native["linux-systemd-user"] assert "continue-on-error" not in native["macos-launchagent"] assert "continue-on-error" not in native["windows-task-scheduler"] diff --git a/tests/test_ops_installer.py b/tests/test_ops_installer.py index d2ede27667..288ebc333a 100644 --- a/tests/test_ops_installer.py +++ b/tests/test_ops_installer.py @@ -88,6 +88,28 @@ def test_linux_layout_uses_user_xdg_locations(monkeypatch: pytest.MonkeyPatch, t assert layout.data == tmp_path / "application-data" +def test_linux_layout_ignores_relative_xdg_locations(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(installer_module.sys, "platform", "linux") + monkeypatch.setattr(installer_module, "default_data_dir", lambda: tmp_path / "application-data") + relative = { + "XDG_DATA_HOME": "relative-data", + "XDG_CONFIG_HOME": "relative-config", + "XDG_STATE_HOME": "relative-state", + "XDG_CACHE_HOME": "relative-cache", + } + for name, value in relative.items(): + monkeypatch.setenv(name, value) + + layout = ManagedLayout.current_user() + home = Path.home() + + assert layout.programs == home / ".local/share/PowerContextManaged/programs" + assert layout.configuration == home / ".config/PowerContextManaged" + assert layout.state == home / ".local/state/PowerContextManaged" + assert layout.cache == home / ".cache/PowerContextManaged" + assert layout.data == tmp_path / "application-data" + + @pytest.fixture def managed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Ops, FakeAdapter, ManagedInstaller]: return make_managed_ops(tmp_path, monkeypatch) From 03216c2cd0bb616a577a8f3799dba7208eaa6239 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Fri, 13 Nov 2026 14:55:11 +0800 Subject: [PATCH 17/19] fix(ops): align Linux XDG fallback paths --- desktop/src-tauri/src/ops_bridge/tests.rs | 12 ++++++++++ desktop/src-tauri/src/ops_bridge/trust.rs | 16 ++++++------- src/powercontext/ops/installer.py | 2 +- tests/test_ops_installer.py | 28 +++++++++++++++++++---- 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/desktop/src-tauri/src/ops_bridge/tests.rs b/desktop/src-tauri/src/ops_bridge/tests.rs index 3a33e59272..f760ef2708 100644 --- a/desktop/src-tauri/src/ops_bridge/tests.rs +++ b/desktop/src-tauri/src/ops_bridge/tests.rs @@ -1004,6 +1004,18 @@ fn linux_xdg_home_environment_preserves_a_non_default_layout() { relative.get("XDG_DATA_HOME").and_then(|path| path.to_str()) ); + let parent = std::collections::BTreeMap::from([ + ("XDG_CONFIG_HOME", dir.path().join("config/../config")), + ("XDG_DATA_HOME", dir.path().join("data")), + ]); + let environment = + trust::trusted_linux_xdg_environment_from(|name| parent.get(name).cloned()).unwrap(); + assert!(!environment.contains_key("XDG_CONFIG_HOME")); + assert_eq!( + environment.get("XDG_DATA_HOME").map(String::as_str), + parent.get("XDG_DATA_HOME").and_then(|path| path.to_str()) + ); + let empty = [ ("XDG_CONFIG_HOME", PathBuf::new()), ("XDG_DATA_HOME", PathBuf::new()), diff --git a/desktop/src-tauri/src/ops_bridge/trust.rs b/desktop/src-tauri/src/ops_bridge/trust.rs index 47bd348c55..deb7c4f4f0 100644 --- a/desktop/src-tauri/src/ops_bridge/trust.rs +++ b/desktop/src-tauri/src/ops_bridge/trust.rs @@ -366,16 +366,14 @@ pub(super) fn trusted_linux_xdg_environment_from( let Some(path) = configured(name).filter(|path| !path.as_os_str().is_empty()) else { continue; }; - if !path.is_absolute() { - // The XDG Base Directory Specification says to ignore relative - // values and use the platform default instead. - continue; - } - if path - .components() - .any(|component| matches!(component, Component::ParentDir)) + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir)) { - return Err(Error::Unavailable); + // Ignore unusable XDG values and let the Python consumer use its + // platform default instead of disabling Local Server. + continue; } environment.insert(name.to_owned(), path_string(&path)?); } diff --git a/src/powercontext/ops/installer.py b/src/powercontext/ops/installer.py index 5ddd30a3a1..1055fbdaab 100644 --- a/src/powercontext/ops/installer.py +++ b/src/powercontext/ops/installer.py @@ -118,7 +118,7 @@ def xdg_home(name: str, fallback: Path) -> Path: config / "PowerContextManaged", state / "PowerContextManaged", cache / "PowerContextManaged", - default_data_dir(), + data / "powercontext", ) raise OpsError("unsupported_platform") diff --git a/tests/test_ops_installer.py b/tests/test_ops_installer.py index 288ebc333a..3cfea57854 100644 --- a/tests/test_ops_installer.py +++ b/tests/test_ops_installer.py @@ -37,13 +37,15 @@ from tests.test_service import FakeAdapter, _manager_probe -def make_managed_ops(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Ops, FakeAdapter, ManagedInstaller]: +def make_managed_ops( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, layout: ManagedLayout | None = None +) -> tuple[Ops, FakeAdapter, ManagedInstaller]: for key in tuple(os.environ): if key == "POWERCONTEXT_HOME" or key.startswith("POWERCONTEXT_SERVER_"): monkeypatch.delenv(key) monkeypatch.setattr("powercontext.ops.resources._current_platform", lambda: ("windows", "x86_64")) root = protect_directory(tmp_path / "managed", create=True) - layout = ManagedLayout(*(root / name for name in ("programs", "config", "state", "cache", "data"))) + layout = layout or ManagedLayout(*(root / name for name in ("programs", "config", "state", "cache", "data"))) spec = _spec(_archive()) installer = ManagedInstaller(ResourceCatalog("a" * 64, (spec,)), spec, layout) prepared_count = 0 @@ -85,12 +87,11 @@ def test_linux_layout_uses_user_xdg_locations(monkeypatch: pytest.MonkeyPatch, t assert layout.configuration == tmp_path / "xdg_config_home/PowerContextManaged" assert layout.state == tmp_path / "xdg_state_home/PowerContextManaged" assert layout.cache == tmp_path / "xdg_cache_home/PowerContextManaged" - assert layout.data == tmp_path / "application-data" + assert layout.data == tmp_path / "xdg_data_home/powercontext" def test_linux_layout_ignores_relative_xdg_locations(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr(installer_module.sys, "platform", "linux") - monkeypatch.setattr(installer_module, "default_data_dir", lambda: tmp_path / "application-data") relative = { "XDG_DATA_HOME": "relative-data", "XDG_CONFIG_HOME": "relative-config", @@ -107,7 +108,24 @@ def test_linux_layout_ignores_relative_xdg_locations(monkeypatch: pytest.MonkeyP assert layout.configuration == home / ".config/PowerContextManaged" assert layout.state == home / ".local/state/PowerContextManaged" assert layout.cache == home / ".cache/PowerContextManaged" - assert layout.data == tmp_path / "application-data" + assert layout.data == home / ".local/share/powercontext" + + +def test_linux_preflight_uses_validated_xdg_data_for_target_summary( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(installer_module.sys, "platform", "linux") + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_DATA_HOME", "relative-data") + for name in ("XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"): + monkeypatch.delenv(name, raising=False) + + ops, _adapter, _installer = make_managed_ops(tmp_path, monkeypatch, layout=ManagedLayout.current_user()) + plan = _plan(ops, "install", http_port=_free_port()) + + assert plan["target_summary"]["data_dir"] == str(home / ".local/share/powercontext") @pytest.fixture From 7289b3d15c4c7c0ee9d04beef07c9822bc3da309 Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Fri, 13 Nov 2026 16:49:03 +0800 Subject: [PATCH 18/19] fix(desktop): align Linux XDG support paths --- desktop/src-tauri/src/lib.rs | 13 +++++---- desktop/src-tauri/src/ops_bridge.rs | 19 ++++++++++++ desktop/src-tauri/src/ops_bridge/tests.rs | 26 +++++++++++++++++ desktop/src-tauri/src/ops_bridge/trust.rs | 35 +++++++++++++++++------ tests/test_ops_installer.py | 35 +++++++++++++++++++++++ 5 files changed, 113 insertions(+), 15 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 294239deff..4215b7ccf0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -101,18 +101,19 @@ pub fn run() { .as_ref() .map(|manager| manager.observations().clone()) .unwrap_or_default(); + let home = app.path().home_dir().ok(); + let local_data = ops_bridge::resolve_local_data_dir( + app.path().local_data_dir().ok(), + home.as_deref(), + ); app.manage(observations.clone()); app.manage(commands::HostState { manager }); app.manage(diagnostics::DiagnosticHost::new( app.path().app_data_dir().ok(), )); app.manage( - ops_bridge::OpsHost::new( - app.path().resource_dir().ok(), - app.path().local_data_dir().ok(), - app.path().home_dir().ok(), - ) - .with_observations(observations), + ops_bridge::OpsHost::new(app.path().resource_dir().ok(), local_data, home) + .with_observations(observations), ); let config = &app.config().app.windows[0]; tauri::WebviewWindowBuilder::from_config(app, config)? diff --git a/desktop/src-tauri/src/ops_bridge.rs b/desktop/src-tauri/src/ops_bridge.rs index e21c2a6c0e..a14ccf9dd1 100644 --- a/desktop/src-tauri/src/ops_bridge.rs +++ b/desktop/src-tauri/src/ops_bridge.rs @@ -39,6 +39,25 @@ const APPROVAL_TTL: Duration = Duration::from_secs(600); const MAX_APPROVALS: usize = 32; pub const PROGRESS_EVENT: &str = "ops-progress"; +pub(crate) fn resolve_local_data_dir( + local_data: Option, + home: Option<&std::path::Path>, +) -> Option { + #[cfg(target_os = "linux")] + { + let _ = local_data; + return trust::trusted_linux_local_data_dir_from( + home, + std::env::var_os("XDG_DATA_HOME").map(PathBuf::from), + ); + } + #[cfg(not(target_os = "linux"))] + { + let _ = home; + local_data + } +} + #[derive(Clone)] pub struct OpsHost(Arc, Option); struct Host { diff --git a/desktop/src-tauri/src/ops_bridge/tests.rs b/desktop/src-tauri/src/ops_bridge/tests.rs index f760ef2708..5f2ef824c8 100644 --- a/desktop/src-tauri/src/ops_bridge/tests.rs +++ b/desktop/src-tauri/src/ops_bridge/tests.rs @@ -1031,6 +1031,32 @@ fn linux_xdg_home_environment_preserves_a_non_default_layout() { ); } +#[cfg(target_os = "linux")] +#[test] +fn linux_desktop_support_dir_uses_the_validated_xdg_data_base() { + let dir = fixture_dir(); + let home = dir.path().join("home"); + fs::create_dir_all(&home).unwrap(); + + for configured in [ + Some(dir.path().join("pc-xdg/child/../data")), + Some(PathBuf::from("relative-data")), + Some(PathBuf::new()), + None, + ] { + let fallback = trust::trusted_linux_local_data_dir_from(Some(&home), configured) + .unwrap() + .join("Ops"); + assert_eq!(fallback, home.join(".local/share/PowerContext/Ops")); + } + + let override_dir = dir.path().join("custom-data"); + let custom = trust::trusted_linux_local_data_dir_from(Some(&home), Some(override_dir.clone())) + .unwrap() + .join("Ops"); + assert_eq!(custom, override_dir.join("PowerContext/Ops")); +} + #[test] fn database_admission_v1_extensions_remain_bounded_and_actionable() { let r = request(OpsAction::Inspect, json!({})); diff --git a/desktop/src-tauri/src/ops_bridge/trust.rs b/desktop/src-tauri/src/ops_bridge/trust.rs index deb7c4f4f0..a974485e10 100644 --- a/desktop/src-tauri/src/ops_bridge/trust.rs +++ b/desktop/src-tauri/src/ops_bridge/trust.rs @@ -352,6 +352,30 @@ fn trusted_linux_xdg_environment() -> Result, Error> { trusted_linux_xdg_environment_from(|name| std::env::var_os(name).map(PathBuf::from)) } +#[cfg(target_os = "linux")] +fn trusted_linux_xdg_path(configured: Option) -> Option { + let path = configured.filter(|path| !path.as_os_str().is_empty())?; + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return None; + } + Some(path) +} + +#[cfg(target_os = "linux")] +pub(super) fn trusted_linux_local_data_dir_from( + home: Option<&Path>, + configured_data_home: Option, +) -> Option { + let home = home?; + let data_home = + trusted_linux_xdg_path(configured_data_home).unwrap_or_else(|| home.join(".local/share")); + Some(data_home.join("PowerContext")) +} + #[cfg(target_os = "linux")] pub(super) fn trusted_linux_xdg_environment_from( configured: impl Fn(&str) -> Option, @@ -363,18 +387,11 @@ pub(super) fn trusted_linux_xdg_environment_from( "XDG_STATE_HOME", "XDG_CACHE_HOME", ] { - let Some(path) = configured(name).filter(|path| !path.as_os_str().is_empty()) else { - continue; - }; - if !path.is_absolute() - || path - .components() - .any(|component| matches!(component, Component::ParentDir)) - { + let Some(path) = trusted_linux_xdg_path(configured(name)) else { // Ignore unusable XDG values and let the Python consumer use its // platform default instead of disabling Local Server. continue; - } + }; environment.insert(name.to_owned(), path_string(&path)?); } Ok(environment) diff --git a/tests/test_ops_installer.py b/tests/test_ops_installer.py index 3cfea57854..7008b0e4a1 100644 --- a/tests/test_ops_installer.py +++ b/tests/test_ops_installer.py @@ -111,6 +111,23 @@ def test_linux_layout_ignores_relative_xdg_locations(monkeypatch: pytest.MonkeyP assert layout.data == home / ".local/share/powercontext" +def test_linux_layout_ignores_parent_xdg_locations(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(installer_module.sys, "platform", "linux") + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + for name in ("DATA", "CONFIG", "STATE", "CACHE"): + monkeypatch.setenv(f"XDG_{name}_HOME", str(tmp_path / "xdg/child/../" / name.lower())) + + layout = ManagedLayout.current_user() + + assert layout.programs == home / ".local/share/PowerContextManaged/programs" + assert layout.configuration == home / ".config/PowerContextManaged" + assert layout.state == home / ".local/state/PowerContextManaged" + assert layout.cache == home / ".cache/PowerContextManaged" + assert layout.data == home / ".local/share/powercontext" + + def test_linux_preflight_uses_validated_xdg_data_for_target_summary( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -128,6 +145,24 @@ def test_linux_preflight_uses_validated_xdg_data_for_target_summary( assert plan["target_summary"]["data_dir"] == str(home / ".local/share/powercontext") +def test_linux_preflight_preserves_absolute_xdg_data_for_target_summary( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(installer_module.sys, "platform", "linux") + home = tmp_path / "home" + home.mkdir() + data = tmp_path / "custom-data" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_DATA_HOME", str(data)) + for name in ("XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"): + monkeypatch.delenv(name, raising=False) + + ops, _adapter, _installer = make_managed_ops(tmp_path, monkeypatch, layout=ManagedLayout.current_user()) + plan = _plan(ops, "install", http_port=_free_port()) + + assert plan["target_summary"]["data_dir"] == str(data / "powercontext") + + @pytest.fixture def managed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Ops, FakeAdapter, ManagedInstaller]: return make_managed_ops(tmp_path, monkeypatch) From eb0967d1f539e54690c9f69ec525cc16f390d3af Mon Sep 17 00:00:00 2001 From: CX33071 <3541053286@qq.com> Date: Fri, 13 Nov 2026 17:48:13 +0800 Subject: [PATCH 19/19] fix(desktop): preserve Linux Ops support path --- desktop/src-tauri/src/ops_bridge.rs | 14 ++++++++++++-- desktop/src-tauri/src/ops_bridge/tests.rs | 22 ++++++++++++++-------- desktop/src-tauri/src/ops_bridge/trust.rs | 6 ++---- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/desktop/src-tauri/src/ops_bridge.rs b/desktop/src-tauri/src/ops_bridge.rs index a14ccf9dd1..7ebb1c95c5 100644 --- a/desktop/src-tauri/src/ops_bridge.rs +++ b/desktop/src-tauri/src/ops_bridge.rs @@ -45,8 +45,8 @@ pub(crate) fn resolve_local_data_dir( ) -> Option { #[cfg(target_os = "linux")] { - let _ = local_data; - return trust::trusted_linux_local_data_dir_from( + return resolve_local_data_dir_from( + local_data, home, std::env::var_os("XDG_DATA_HOME").map(PathBuf::from), ); @@ -58,6 +58,16 @@ pub(crate) fn resolve_local_data_dir( } } +#[cfg(target_os = "linux")] +fn resolve_local_data_dir_from( + local_data: Option, + home: Option<&std::path::Path>, + configured_data_home: Option, +) -> Option { + let _ = local_data; + trust::trusted_linux_data_home_from(home, configured_data_home) +} + #[derive(Clone)] pub struct OpsHost(Arc, Option); struct Host { diff --git a/desktop/src-tauri/src/ops_bridge/tests.rs b/desktop/src-tauri/src/ops_bridge/tests.rs index 5f2ef824c8..29efc9a4cc 100644 --- a/desktop/src-tauri/src/ops_bridge/tests.rs +++ b/desktop/src-tauri/src/ops_bridge/tests.rs @@ -1044,17 +1044,23 @@ fn linux_desktop_support_dir_uses_the_validated_xdg_data_base() { Some(PathBuf::new()), None, ] { - let fallback = trust::trusted_linux_local_data_dir_from(Some(&home), configured) - .unwrap() - .join("Ops"); - assert_eq!(fallback, home.join(".local/share/PowerContext/Ops")); + let data_home = super::resolve_local_data_dir_from( + Some(dir.path().join("tauri-local-data")), + Some(&home), + configured, + ) + .unwrap(); + let host = OpsHost::new(None, Some(data_home), Some(home.clone())); + let expected = home.join(".local/share/PowerContext/Ops"); + assert_eq!(host.0.trust.support.as_deref(), Some(expected.as_path())); } let override_dir = dir.path().join("custom-data"); - let custom = trust::trusted_linux_local_data_dir_from(Some(&home), Some(override_dir.clone())) - .unwrap() - .join("Ops"); - assert_eq!(custom, override_dir.join("PowerContext/Ops")); + let data_home = + trust::trusted_linux_data_home_from(Some(&home), Some(override_dir.clone())).unwrap(); + let host = OpsHost::new(None, Some(data_home), Some(home)); + let expected = override_dir.join("PowerContext/Ops"); + assert_eq!(host.0.trust.support.as_deref(), Some(expected.as_path())); } #[test] diff --git a/desktop/src-tauri/src/ops_bridge/trust.rs b/desktop/src-tauri/src/ops_bridge/trust.rs index a974485e10..97409ae92f 100644 --- a/desktop/src-tauri/src/ops_bridge/trust.rs +++ b/desktop/src-tauri/src/ops_bridge/trust.rs @@ -366,14 +366,12 @@ fn trusted_linux_xdg_path(configured: Option) -> Option { } #[cfg(target_os = "linux")] -pub(super) fn trusted_linux_local_data_dir_from( +pub(super) fn trusted_linux_data_home_from( home: Option<&Path>, configured_data_home: Option, ) -> Option { let home = home?; - let data_home = - trusted_linux_xdg_path(configured_data_home).unwrap_or_else(|| home.join(".local/share")); - Some(data_home.join("PowerContext")) + Some(trusted_linux_xdg_path(configured_data_home).unwrap_or_else(|| home.join(".local/share"))) } #[cfg(target_os = "linux")]