diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 75c786ec99..0dc4bb1e77 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 @@ -244,6 +245,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 59bbb90619..9af8774fa1 100644 --- a/.github/workflows/managed-resource-candidates.yml +++ b/.github/workflows/managed-resource-candidates.yml @@ -7,14 +7,30 @@ 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: '' + 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: @@ -24,21 +40,55 @@ 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 - if: ${{ !cancelled() && ((inputs.suites == '' && needs.changes.result != 'success') || fromJSON(inputs.suites || needs.changes.outputs.suites || '{}').managed_resources) }} + needs: [changes, select-targets] + 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 - matrix: - include: - - target: windows-x86_64 - runner: windows-2025 - - target: macos-arm64 - runner: macos-15 - - target: macos-x86_64 - runner: macos-15-intel + matrix: ${{ fromJSON(needs.select-targets.outputs.matrix) }} runs-on: ${{ matrix.runner }} timeout-minutes: 90 defaults: @@ -91,6 +141,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 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() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 4a8d872acc..ad8d9c926e 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -1248,6 +1248,24 @@ 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"]' + concurrency_suffix: required + + 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"]' + concurrency_suffix: linux script-installation: needs: [quality, tests] 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 14ea351ea6..a02a2620bd 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -196,6 +196,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. 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. + +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 ff37893602..d07f9365e2 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -148,6 +148,16 @@ pnpm --dir desktop desktop:dev [安全边界](SECURITY.md)记录具体约束;[验证说明](VALIDATION.md)列出测试范围和仍未满足的 P0 门槛。构建成功、Mock 测试和当前用户机器上的运行都不能代替标准用户、签名、通知冷启动激活、独立服务和 Agent 宿主验收。 +## 本地服务状态 + +在 Linux 上,「本地 Server」设置页可以通过可信的 Ops Bridge 检查 PowerContext 的用户服务。这个检查是只读的:它只显示是否支持、是否注册、管理归属、管理器状态、存活状态、就绪状态和维护状态,不会把路径、命令、环境值或原始进程输出显示给界面。 + +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 不会修改它,也不会默默创建系统级持久化。 + +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 cb39853f81..c11ea9469d 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -444,6 +444,23 @@ Product qualification records remain unchanged. These isolated local processes d external production deployments, managed installation/apply, installed Windows/macOS UI, login/reboot, signing or notarization. UI tests mock IPC, and native unit HTTP fixtures are synthetic. +### 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 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 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/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 21a295e8e3..7ebb1c95c5 100644 --- a/desktop/src-tauri/src/ops_bridge.rs +++ b/desktop/src-tauri/src/ops_bridge.rs @@ -39,6 +39,35 @@ 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")] + { + return resolve_local_data_dir_from( + local_data, + home, + std::env::var_os("XDG_DATA_HOME").map(PathBuf::from), + ); + } + #[cfg(not(target_os = "linux"))] + { + let _ = home; + local_data + } +} + +#[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 { @@ -527,5 +556,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 5fab8d8181..29efc9a4cc 100644 --- a/desktop/src-tauri/src/ops_bridge/tests.rs +++ b/desktop/src-tauri/src/ops_bridge/tests.rs @@ -22,9 +22,15 @@ use std::{ }; fn fixture_dir() -> tempfile::TempDir { - tempfile::Builder::new() + #[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() .unwrap() } #[cfg(unix)] @@ -78,7 +84,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(); @@ -168,6 +181,7 @@ else: "#; #[test] +#[cfg(target_os = "macos")] fn session_diagnostics_project_real_helper_failure_without_reading_its_journal() { let f = Fixture::new(); let observations = SessionObservations::default(); @@ -208,7 +222,7 @@ fn session_diagnostics_project_real_helper_failure_without_reading_its_journal() } #[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(); @@ -256,7 +270,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(); @@ -299,7 +313,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"); @@ -345,7 +359,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(); @@ -416,7 +430,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(); @@ -449,7 +463,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(); @@ -580,7 +594,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!( @@ -718,7 +732,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() @@ -940,6 +954,115 @@ 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() + ); + 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()); +} + +#[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 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 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()), + ("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() + ); +} + +#[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 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 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] 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 2d8e59c6ad..97409ae92f 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)?; @@ -301,6 +301,13 @@ 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); + } + env.extend(trusted_linux_xdg_environment()?); + } Ok(Launch { executable, args, @@ -309,6 +316,84 @@ 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 + .filter(|path| !path.as_os_str().is_empty()) + .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() +} + +#[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")] +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_data_home_from( + home: Option<&Path>, + configured_data_home: Option, +) -> Option { + let home = home?; + Some(trusted_linux_xdg_path(configured_data_home).unwrap_or_else(|| home.join(".local/share"))) +} + +#[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) = 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) +} #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Target { @@ -427,6 +512,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 fc211d9eca..653dcb0b0a 100644 --- a/desktop/src-tauri/tests/ipc.rs +++ b/desktop/src-tauri/tests/ipc.rs @@ -467,7 +467,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/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 fee9db701b..fae0b54eea 100644 --- a/scripts/prepare_managed_resources.py +++ b/scripts/prepare_managed_resources.py @@ -97,7 +97,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"]): @@ -163,7 +163,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 @@ -174,7 +176,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("/") @@ -184,12 +186,11 @@ 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 ( - member.isfile() or member.isdir() or member.issym() or member.islnk() - ): + 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") @@ -342,7 +343,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..1055fbdaab 100644 --- a/src/powercontext/ops/installer.py +++ b/src/powercontext/ops/installer.py @@ -94,6 +94,32 @@ 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) + 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: + return fallback + 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", + data / "powercontext", + ) 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/native/test_personal_service_lifecycle.py b/tests/native/test_personal_service_lifecycle.py index af304fa261..7ae55fa3d9 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 fe9bd61eed..f9da1b1e6b 100644 --- a/tests/test_ci_pipeline.py +++ b/tests/test_ci_pipeline.py @@ -199,8 +199,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 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 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(): @@ -289,9 +290,63 @@ 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 - assert {row["target"] for row in native["matrix"]["include"]} == {"windows-x86_64", "macos-arm64", "macos-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 }}" + + +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 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 + + +def test_managed_resource_workflow_filters_called_targets(): + jobs = workflow("managed-resource-candidates.yml")["jobs"] + 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(): + 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_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 "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"] 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 e897d5273c..edb8f1fdb6 100644 --- a/tests/test_managed_resource_producer.py +++ b/tests/test_managed_resource_producer.py @@ -175,6 +175,25 @@ 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: + 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)] ) @@ -297,9 +316,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) diff --git a/tests/test_ops_installer.py b/tests/test_ops_installer.py index 7240ffc79b..7008b0e4a1 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 @@ -36,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 @@ -72,6 +75,94 @@ 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 / "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") + 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 == 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: + 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") + + +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) 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"))