diff --git a/desktop/SECURITY.md b/desktop/SECURITY.md index 07b4c90f0c..e659b31880 100644 --- a/desktop/SECURITY.md +++ b/desktop/SECURITY.md @@ -33,8 +33,42 @@ Typed adapters separate liveness, readiness, identity and capabilities. Native c Session observation commands require the same trusted main-window boundary and work independently of connection state. Rust projects only fixed operation/unit/outcome enums, elapsed milliseconds, UTC timestamps, counts, HTTP status and existing safe error codes. It never serializes connection or write state for export; content, queries, credentials, endpoints, private paths and target identities never enter the collector. The preview serializes only that projection. Retention/reset and process-lifetime loss metadata expose incomplete coverage; nonblocking recording cannot change business results or suppress cancellation. HTTP response success and native write certainty are separate measurements. Installer/Ops and local diagnostic output are outside collection. The [session observation contract](../docs/en/development/desktop-session-observations.md) defines the complete field allowlist and coverage. -Error responses are enum codes. Transport exception text, endpoint URLs, bodies and credentials are never returned or logged. No product logging, telemetry, crash uploader or persistent business cache is configured. The app does not read databases, start a Server or inspect process environments. Explicit local diagnostics verify a pinned absolute executable and digest, then run one of two native-defined diagnostic commands. Registration requires no version, and legacy version fields are ignored. Diagnostic responses must match the supported output schema. A subsequent fixed `--version` query collects bounded display metadata; failure, unsupported output or an unavailable version does not reject an otherwise valid diagnostic result. Each invocation has a combined 256 KiB output budget and a deadline. Windows helpers enter an owned kill-on-close Job before resuming. macOS runs a read-only snapshot of the digest-verified launcher in a private process group, which is terminated after completion, cancellation or timeout; raw output is projected to allowlisted status fields and discarded. Normal application exit closes diagnostic admission and waits for active worker process cleanup before Tauri exits. A local CLI pin does not authenticate its publisher or freeze its Python dependencies; the configured installation must be trusted. +Error responses are enum codes. Transport exception text, endpoint URLs, bodies and credentials are never returned or logged. No product logging, telemetry, crash uploader or persistent business cache is configured. The app does not read databases or inspect process environments. Authorized local service changes use the independent Ops boundary described below. Explicit local diagnostics verify a pinned absolute executable and digest, then run one of two native-defined diagnostic commands. Registration requires no version, and legacy version fields are ignored. Diagnostic responses must match the supported output schema. A subsequent fixed `--version` query collects bounded display metadata; failure, unsupported output or an unavailable version does not reject an otherwise valid diagnostic result. Each invocation has a combined 256 KiB output budget and a deadline. Windows helpers enter an owned kill-on-close Job before resuming. macOS runs a read-only snapshot of the digest-verified launcher in a private process group, which is terminated after completion, cancellation or timeout; raw output is projected to allowlisted status fields and discarded. Normal application exit closes diagnostic admission and waits for active worker process cleanup before Tauri exits. A local CLI pin does not authenticate its publisher or freeze its Python dependencies; the configured installation must be trusted. Tests cover actual TLS fixtures and adversarial HTTP responses, shared loopback policy, mocked Tauri permission resolution with the real capability configuration, navigation rejection, unavailable vault behavior and the explicit session path. The opt-in Windows vault probe separately verifies persistence across processes. See the [validation guide](VALIDATION.md) for packaged checks, CI artifacts and remaining platform qualification. Framework references: [Tauri capabilities](https://v2.tauri.app/security/capabilities/), [Windows installer options](https://v2.tauri.app/distribute/windows-installer/). + +## Independent Ops boundary + +Only the packaged main window may use the seven fixed Ops commands and the retained-reference reader. Inputs are typed +operation/options or native-issued approval/reference IDs; executable, binding paths, URLs, service names, and complete +plans cannot cross renderer IPC. The main window receives validated progress through `ops-progress`; no global +renderer event-emission permission is granted. Preflight retains the exact plan for ten minutes and apply/resume consume +the approval once against the same distribution and target binding. + +Packaged `ops-provision.json` pins the independent bundle manifest and optional managed catalog. Every invocation +verifies the complete bundle inventory and contents, including safe runtime-local file aliases. Missing provision is +unavailable; a candidate manifest cannot authorize itself and system/Server Python is never a fallback. Immutable +digest-named private binding copies prevent an in-flight helper from observing a subsequently selected binding. +The fixed stable root is current-user `PowerContext/Ops` under local application data, independent of Desktop removal. +Native code writes private files atomically (0600/0700 on POSIX; current-user protected ACLs on Windows), rejects path +links/reparse points, and validates ownership/protection on existing control files. This same-user bridge is not a +privilege boundary against another process running as that user. + +The native reference file stores only operation IDs and binding fingerprints before dispatch. Ops journals and locks +remain the sole recovery authority. Four ordinary and two reserved status/cancel helpers bound native concurrency; +timed-out children retain their slot until exit. Each invocation bounds JSONL messages to 64 KiB, aggregate output to +1 MiB and 128 messages, with 30-second passive-read, 180-second preflight and 30-minute mutation deadlines. Stderr is discarded. Invalid schema, +identity, sequence or enums never reach the renderer; valid structured nonzero exits preserve unhealthy outcomes. +Nonblocking drains finish at helper exit even when a descendant holds a pipe open. + +Timeout/lost response/Quit retain the operation reference and report unknown. Normal Quit closes admission and waits +at most two seconds without signalling Ops, Server or manager processes. Pipe closure can produce a durable safe-boundary +progress failure; accepted work may continue. Cancellation calls Ops and does not imply rollback. Recovery reads the +existing operation, then requires a newly approved plan; no automatic replay or parallel outcome journal exists. + +See [Local Server Ops](../docs/en/docs/operate/local-server-ops.md#desktop-native-bridge) for the exact optional Tauri +resource configuration, producer pins, stable paths and recovery API. The bridge does not ship production resources or +an installation wizard. Real service mutations, signing/notarization and clean-machine acceptance require separate +platform evidence. diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index b9d7ec787c..ea6050baa6 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -631,3 +631,42 @@ and checks digest rejection and allowlisted projection; it does not qualify a re Use a disposable Server and Scope for installed save/search/read/copy checks. Unit tests do not prove those installed workflows. Validate actual Chinese IME, accessibility, Gatekeeper, signing and notarization independently before distributing outside the internal preview. + +## Independent Ops bridge checks + +The native Ops tests exercise streaming with real child processes, malformed/oversized output, deadlines, inherited +pipe handles, retained references, one-shot approvals, cancellation, resume, binding integrity and Quit continuation. +Synthetic helpers establish these bridge boundaries; they do not qualify native service-manager mutations. + +Two opt-in macOS tests add distinct read-only integration evidence. Prepare the checkout's `ops` dependencies and run +from the repository root: + +```sh +uv sync --locked --extra ops --extra cli +POWERCONTEXT_OPS_TEST_PYTHON="$PWD/.venv/bin/python" \ + cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml \ + --lib real_current_ops_inspect_and_preflight_with_missing_server_runtime -- --ignored +``` + +This test runs the current Python Ops entry in a subprocess with a disposable home, binding and missing Server Python. +An unavailable native-manager ownership observation must remain a rejected preflight, not become successful cleanup. +The following test instead copies a complete independently built Ops bundle into a disposable native bridge layout, +uses the separately supplied approved manifest digest as its packaged pin, and checks inspection/preflight and dependency +tampering through the actual native verifier and process runner: + +```sh +POWERCONTEXT_OPS_TEST_BUNDLE=/absolute/approved/ops \ +POWERCONTEXT_OPS_TEST_BUNDLE_SHA256="" \ + cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml \ + --lib actual_independent_bundle_runs_through_native_trust_and_read_only_bridge -- --ignored +``` + +Neither command applies a service operation or touches the real user's Server configuration/data. Supply a trusted +producer artifact and an independently approved digest; calculating a pin from an arbitrary candidate does not establish +release trust. These checks do not establish signing/notarization, Windows Task Scheduler or macOS LaunchAgent mutation, +packaged provisioning, or clean-machine acceptance. The optional packaging hook and stable independent helper location +are documented in [Local Server Ops](../docs/en/docs/operate/local-server-ops.md#provisioning-the-bridge). + +上述两项可选检查分别验证源码 Ops 子进程和完整独立分发包与原生桥接的只读兼容性。测试使用临时 home、绑定和状态目录, +不执行服务修改;真实服务管理器归属无法确认时保留预检拒绝。分发包测试要求另行提供已批准的清单摘要,并检查依赖被改动时 +原生端拒绝启动。这些证据不代替签名/公证、真实服务修改、随包配置或干净机器验收。 diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 141df9b315..f54d8e47bb 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -47,11 +47,13 @@ tempfile = "=3.27.0" tauri-plugin-single-instance = "=2.4.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading", "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_Storage_FileSystem"] } +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_Storage_FileSystem"] } keyring = { version = "=3.6.3", default-features = false, features = ["windows-native"] } [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "=3.6.3", default-features = false, features = ["apple-native"] } + +[target.'cfg(unix)'.dependencies] libc = "=0.2.189" [dev-dependencies] diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 50e9dda36d..3a87ac0b73 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -35,6 +35,14 @@ fn main() { tauri_build::AppManifest::new().commands(&[ "foundation_info", "local_diagnostics", + "ops_references", + "ops_inspect", + "ops_config_validate", + "ops_preflight", + "ops_apply", + "ops_operation_status", + "ops_cancel", + "ops_resume", "remember_memory", "search_memory", "atomic_memory_entry", diff --git a/desktop/src-tauri/capabilities/main.json b/desktop/src-tauri/capabilities/main.json index b523012ec5..a75e82e713 100644 --- a/desktop/src-tauri/capabilities/main.json +++ b/desktop/src-tauri/capabilities/main.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main", - "description": "Only the packaged main window can use typed connection, Scope and local diagnostic commands.", + "description": "Only the packaged main window can use typed connection, Scope, diagnostics and constrained Ops commands.", "windows": [ "main" ], @@ -24,6 +24,16 @@ "allow-select-scope", "allow-session-summary", "allow-reset-session-observations", - "allow-preview-session-diagnostics" + "allow-preview-session-diagnostics", + "allow-ops-references", + "allow-ops-inspect", + "allow-ops-config-validate", + "allow-ops-preflight", + "allow-ops-apply", + "allow-ops-operation-status", + "allow-ops-cancel", + "allow-ops-resume", + "core:event:allow-listen", + "core:event:allow-unlisten" ] } diff --git a/desktop/src-tauri/examples/export_ipc.rs b/desktop/src-tauri/examples/export_ipc.rs index 40f0532799..08b30b9034 100644 --- a/desktop/src-tauri/examples/export_ipc.rs +++ b/desktop/src-tauri/examples/export_ipc.rs @@ -30,6 +30,7 @@ use powercontext_desktop::{ diagnostics::{DiagnosticItem, DiagnosticKind, DiagnosticReport, HostDiagnostic}, error::SafeError, ipc::FoundationInfo, + ops_bridge::*, session_observations::{ ObservationOutcome, ObservationUnit, ObservedOperation, OperationSummary, OutcomeCounts, SessionCoverage, SessionObservation, SessionSummary, @@ -58,6 +59,55 @@ fn main() { CredentialWriteReceipt::decl(&config) ); for declaration in [ + OpsOperation::decl(&config), + OpsOutcome::decl(&config), + OpsBridgeError::decl(&config), + OpsErrorCode::decl(&config), + OpsAction::decl(&config), + OpsOptions::decl(&config), + OpsResumeReference::decl(&config), + OpsPreflightInput::decl(&config), + OpsApprovalInput::decl(&config), + OpsStatusInput::decl(&config), + OpsCancelInput::decl(&config), + OpsOperationReference::decl(&config), + OpsReferenceBindingStatus::decl(&config), + OpsReferences::decl(&config), + OpsInspectionReply::decl(&config), + OpsValidationReply::decl(&config), + OpsConfigurationState::decl(&config), + OpsPlanApproval::decl(&config), + OpsPreflightReply::decl(&config), + OpsTargetSummary::decl(&config), + OpsPlanEffects::decl(&config), + OpsInstallationSummary::decl(&config), + OpsInstallationClassification::decl(&config), + OpsInstallationResources::decl(&config), + OpsDatabaseClassification::decl(&config), + OpsComponents::decl(&config), + OpsProgress::decl(&config), + OpsOperationReply::decl(&config), + OpsOperationStatus::decl(&config), + OpsExecutor::decl(&config), + OpsObservation::decl(&config), + OpsAttemptSummary::decl(&config), + OpsStoredResult::decl(&config), + OpsMaintenancePhase::decl(&config), + OpsRuntimeResources::decl(&config), + OpsConnectionCompatibility::decl(&config), + OpsSupportState::decl(&config), + OpsRegistrationState::decl(&config), + OpsDefinitionState::decl(&config), + OpsManagerOwnership::decl(&config), + OpsManagerState::decl(&config), + OpsLivenessState::decl(&config), + OpsReadinessState::decl(&config), + OpsServicePhase::decl(&config), + OpsAttemptPhase::decl(&config), + OpsExecutionState::decl(&config), + OpsExecutorObservation::decl(&config), + OpsObservationState::decl(&config), + OpsRecoveryAction::decl(&config), DiagnosticKind::decl(&config), DiagnosticItem::decl(&config), HostDiagnostic::decl(&config), diff --git a/desktop/src-tauri/src/commands.rs b/desktop/src-tauri/src/commands.rs index 82bd80ab3e..38648ed696 100644 --- a/desktop/src-tauri/src/commands.rs +++ b/desktop/src-tauri/src/commands.rs @@ -234,3 +234,115 @@ pub fn cancel_memory_reads( .cancel_memory_reads(generation) .map_err(Into::into) } + +#[tauri::command] +pub async fn ops_references( + window: tauri::WebviewWindow, + state: tauri::State<'_, crate::ops_bridge::OpsHost>, +) -> Result { + authorize_window(window.label()).map_err(|_| crate::ops_bridge::OpsBridgeError::Forbidden)?; + let host = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || host.references()) + .await + .map_err(|_| crate::ops_bridge::OpsBridgeError::ProcessFailure)? +} +#[tauri::command] +pub async fn ops_inspect( + window: tauri::WebviewWindow, + state: tauri::State<'_, crate::ops_bridge::OpsHost>, +) -> Result { + authorize_window(window.label()).map_err(|_| crate::ops_bridge::OpsBridgeError::Forbidden)?; + let host = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || host.inspect()) + .await + .map_err(|_| crate::ops_bridge::OpsBridgeError::ProcessFailure) +} +#[tauri::command] +pub async fn ops_config_validate( + window: tauri::WebviewWindow, + state: tauri::State<'_, crate::ops_bridge::OpsHost>, +) -> Result { + authorize_window(window.label()).map_err(|_| crate::ops_bridge::OpsBridgeError::Forbidden)?; + let host = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || host.config_validate()) + .await + .map_err(|_| crate::ops_bridge::OpsBridgeError::ProcessFailure) +} +#[tauri::command] +pub async fn ops_preflight( + window: tauri::WebviewWindow, + state: tauri::State<'_, crate::ops_bridge::OpsHost>, + input: crate::ops_bridge::OpsPreflightInput, +) -> Result { + authorize_window(window.label()).map_err(|_| crate::ops_bridge::OpsBridgeError::Forbidden)?; + let host = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || host.preflight(input)) + .await + .map_err(|_| crate::ops_bridge::OpsBridgeError::ProcessFailure)? +} +#[tauri::command] +pub async fn ops_apply( + window: tauri::WebviewWindow, + state: tauri::State<'_, crate::ops_bridge::OpsHost>, + input: crate::ops_bridge::OpsApprovalInput, +) -> Result { + use tauri::Emitter; + authorize_window(window.label()).map_err(|_| crate::ops_bridge::OpsBridgeError::Forbidden)?; + let host = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + host.apply(input, move |event| { + let _ = window.emit_to( + tauri::EventTarget::webview_window("main"), + crate::ops_bridge::PROGRESS_EVENT, + event, + ); + }) + }) + .await + .map_err(|_| crate::ops_bridge::OpsBridgeError::ProcessFailure)? +} +#[tauri::command] +pub async fn ops_resume( + window: tauri::WebviewWindow, + state: tauri::State<'_, crate::ops_bridge::OpsHost>, + input: crate::ops_bridge::OpsApprovalInput, +) -> Result { + use tauri::Emitter; + authorize_window(window.label()).map_err(|_| crate::ops_bridge::OpsBridgeError::Forbidden)?; + let host = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + host.resume(input, move |event| { + let _ = window.emit_to( + tauri::EventTarget::webview_window("main"), + crate::ops_bridge::PROGRESS_EVENT, + event, + ); + }) + }) + .await + .map_err(|_| crate::ops_bridge::OpsBridgeError::ProcessFailure)? +} +#[tauri::command] +pub async fn ops_operation_status( + window: tauri::WebviewWindow, + state: tauri::State<'_, crate::ops_bridge::OpsHost>, + input: crate::ops_bridge::OpsStatusInput, +) -> Result { + authorize_window(window.label()).map_err(|_| crate::ops_bridge::OpsBridgeError::Forbidden)?; + let host = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || host.operation_status(input)) + .await + .map_err(|_| crate::ops_bridge::OpsBridgeError::ProcessFailure)? +} +#[tauri::command] +pub async fn ops_cancel( + window: tauri::WebviewWindow, + state: tauri::State<'_, crate::ops_bridge::OpsHost>, + input: crate::ops_bridge::OpsCancelInput, +) -> Result { + authorize_window(window.label()).map_err(|_| crate::ops_bridge::OpsBridgeError::Forbidden)?; + let host = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || host.cancel(input)) + .await + .map_err(|_| crate::ops_bridge::OpsBridgeError::ProcessFailure)? +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 787f646619..47f8b231c1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ mod diagnostic_process; pub mod diagnostics; pub mod error; pub mod ipc; +pub mod ops_bridge; pub mod session_observations; pub mod transport; @@ -43,6 +44,14 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ ipc::foundation_info, commands::local_diagnostics, + commands::ops_references, + commands::ops_inspect, + commands::ops_config_validate, + commands::ops_preflight, + commands::ops_apply, + commands::ops_operation_status, + commands::ops_cancel, + commands::ops_resume, commands::remember_memory, commands::search_memory, commands::atomic_memory_entry, @@ -89,6 +98,11 @@ pub fn run() { 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(), + )); let config = &app.config().app.windows[0]; tauri::WebviewWindowBuilder::from_config(app, config)? .on_navigation(ipc::allowed_navigation) @@ -105,5 +119,12 @@ pub fn run() { // cannot supervise child processes once the application exits. host.shutdown(); } + if matches!(event, tauri::RunEvent::Exit) + && let Some(host) = app.try_state::() + { + // Ops owns its durable operation journal and service locks. Closing Desktop + // stops new IPC admission but never signals a service-manager process. + host.shutdown(); + } }); } diff --git a/desktop/src-tauri/src/ops_bridge.rs b/desktop/src-tauri/src/ops_bridge.rs new file mode 100644 index 0000000000..6156faf775 --- /dev/null +++ b/desktop/src-tauri/src/ops_bridge.rs @@ -0,0 +1,430 @@ +/* + * 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. + */ + +//! Native-only approvals and durable references for independent Ops v1 invocations. + +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + path::PathBuf, + sync::{ + Arc, Condvar, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; +use uuid::Uuid; + +mod process; +mod trust; +mod types; +mod wire; +pub use types::*; + +const APPROVAL_TTL: Duration = Duration::from_secs(600); +const MAX_APPROVALS: usize = 32; +pub const PROGRESS_EVENT: &str = "ops-progress"; + +#[derive(Clone)] +pub struct OpsHost(Arc); +struct Host { + trust: trust::Trust, + approvals: Mutex>, + references: Mutex, + active: Arc, + #[cfg(test)] + deadline: Option, +} +struct Approval { + plan: Value, + binding: String, + resume: Option, + created: Instant, +} +#[derive(Default)] +struct Active { + counts: Mutex<[usize; 2]>, + ready: Condvar, + closing: AtomicBool, +} +struct CallGuard { + active: Arc, + lane: usize, +} +impl Drop for CallGuard { + fn drop(&mut self) { + if let Ok(mut counts) = self.active.counts.lock() { + counts[self.lane] -= 1; + self.active.ready.notify_all(); + } + } +} + +impl OpsHost { + pub fn new( + resources: Option, + local_data: Option, + home: Option, + ) -> Self { + let trust = trust::Trust::new(resources, local_data, home); + Self(Arc::new(Host { + references: Mutex::new(trust::References::new(trust.support.clone())), + trust, + approvals: Mutex::new(HashMap::new()), + active: Arc::new(Active::default()), + #[cfg(test)] + deadline: None, + })) + } + pub fn shutdown(&self) { + self.0.active.closing.store(true, Ordering::Release); + if let Ok(counts) = self.0.active.counts.lock() { + let _ = self + .0 + .active + .ready + .wait_timeout_while(counts, Duration::from_secs(2), |c| { + c.iter().sum::() > 0 + }); + } + } + fn enter(&self, action: OpsAction) -> Result { + let lane = usize::from(matches!( + action, + OpsAction::OperationStatus | OpsAction::Cancel + )); + let mut counts = self + .0 + .active + .counts + .lock() + .map_err(|_| OpsBridgeError::Storage)?; + if self.0.active.closing.load(Ordering::Acquire) { + return Err(OpsBridgeError::ShuttingDown); + } + // Two reserved control slots stay available while ordinary requests are running. + if counts[lane] >= [4, 2][lane] { + return Err(OpsBridgeError::Busy); + } + counts[lane] += 1; + Ok(CallGuard { + active: self.0.active.clone(), + lane, + }) + } + pub fn references(&self) -> Result { + let binding = self.0.trust.binding().ok(); + let items = self + .0 + .references + .lock() + .map_err(|_| OpsBridgeError::Storage)? + .read()?; + Ok(OpsReferences { + binding_available: binding.is_some(), + operations: items + .into_iter() + .map(|item| OpsOperationReference { + operation_id: item.operation_id, + binding_status: match &binding { + Some(b) if b.fingerprint == item.binding_id => { + OpsReferenceBindingStatus::Current + } + Some(_) => OpsReferenceBindingStatus::Different, + None => OpsReferenceBindingStatus::Unavailable, + }, + }) + .collect(), + }) + } + fn require_reference(&self, id: &str, binding: &str) -> Result<(), OpsBridgeError> { + wire::identifier(id).map_err(|_| OpsBridgeError::InvalidInput)?; + self.0 + .references + .lock() + .map_err(|_| OpsBridgeError::Storage)? + .require(id, binding) + } + pub fn inspect(&self) -> OpsInspectionReply { + let result = self.read(OpsAction::Inspect); + match result.and_then(|(v, code)| wire::inspection(&v, code)) { + Ok(reply) => reply, + Err(error) => OpsInspectionReply { + outcome: OpsOutcome::Unknown, + components: None, + error: None, + bridge_error: Some(error), + exit_code: None, + }, + } + } + pub fn config_validate(&self) -> OpsValidationReply { + match self + .read(OpsAction::ConfigValidate) + .and_then(|(v, code)| wire::validation(&v, code)) + { + Ok(reply) => reply, + Err(error) => OpsValidationReply { + outcome: OpsOutcome::Unknown, + configuration: OpsConfigurationState::Unknown, + error: None, + bridge_error: Some(error), + exit_code: None, + }, + } + } + fn read(&self, action: OpsAction) -> Result<(Value, i32), OpsBridgeError> { + let guard = self.enter(action)?; + let binding = self.0.trust.binding()?; + self.invoke(&binding, action, request(action, json!({})), guard, |_| {}) + } + pub fn preflight(&self, input: OpsPreflightInput) -> Result { + input.options.validate(input.operation)?; + let guard = self.enter(OpsAction::Preflight)?; + let binding = self.0.trust.binding()?; + if let Some(resume) = &input.resume { + if !(1..64).contains(&resume.expected_attempt) { + return Err(OpsBridgeError::InvalidInput); + } + self.require_reference(&resume.operation_id, &binding.fingerprint)?; + } + let body = request( + OpsAction::Preflight, + json!({"operation":input.operation.wire_name(), "options":input.options.wire_value()}), + ); + let response = self.invoke(&binding, OpsAction::Preflight, body, guard, |_| {}); + let (message, code) = match response { + Ok(value) => value, + Err(error) => { + return Ok(OpsPreflightReply { + outcome: OpsOutcome::Unknown, + approval: None, + error: None, + bridge_error: Some(error), + exit_code: None, + }); + } + }; + if message["outcome"] == "rejected" { + return Ok(OpsPreflightReply { + outcome: OpsOutcome::Rejected, + approval: None, + error: Some(wire::error(&message["error"])?), + bridge_error: None, + exit_code: Some(code), + }); + } + let plan = &message["plan"]; + let mut summary = wire::plan(plan, input.operation, &input.options)?; + if binding + .target_digest + .as_ref() + .is_some_and(|digest| plan["binding_id"] != *digest) + { + return Err(OpsBridgeError::InvalidResponse); + } + if binding + .target_id + .as_ref() + .is_some_and(|id| id != &summary.target.target_id) + { + return Err(OpsBridgeError::InvalidResponse); + } + if let Some(digest) = binding.catalog_digest.as_ref() + && plan + .get("installation") + .is_some_and(|v| v["catalog_sha256"] != *digest) + { + return Err(OpsBridgeError::InvalidResponse); + } + let mut approvals = self + .0 + .approvals + .lock() + .map_err(|_| OpsBridgeError::Storage)?; + approvals.retain(|_, a| a.created.elapsed() < APPROVAL_TTL); + if approvals.len() >= MAX_APPROVALS { + return Err(OpsBridgeError::Busy); + } + let id = Uuid::new_v4().to_string(); + summary.approval_id = id.clone(); + approvals.insert( + id, + Approval { + plan: plan.clone(), + binding: binding.fingerprint, + resume: input.resume, + created: Instant::now(), + }, + ); + Ok(OpsPreflightReply { + outcome: OpsOutcome::Succeeded, + approval: Some(summary), + error: None, + bridge_error: None, + exit_code: Some(code), + }) + } + pub fn apply( + &self, + input: OpsApprovalInput, + emit: impl Fn(OpsProgress) + Send + 'static, + ) -> Result { + self.approved(input, false, emit) + } + pub fn resume( + &self, + input: OpsApprovalInput, + emit: impl Fn(OpsProgress) + Send + 'static, + ) -> Result { + self.approved(input, true, emit) + } + fn approved( + &self, + input: OpsApprovalInput, + resume: bool, + emit: impl Fn(OpsProgress) + Send + 'static, + ) -> Result { + if Uuid::parse_str(&input.approval_id).is_err() || !trust::is_digest(&input.plan_id) { + return Err(OpsBridgeError::InvalidInput); + } + let action = if resume { + OpsAction::Resume + } else { + OpsAction::Apply + }; + let guard = self.enter(action)?; + let binding = self.0.trust.binding()?; + let approval = self + .0 + .approvals + .lock() + .map_err(|_| OpsBridgeError::Storage)? + .remove(&input.approval_id) + .ok_or(OpsBridgeError::StaleApproval)?; + if approval.created.elapsed() >= APPROVAL_TTL + || approval.binding != binding.fingerprint + || approval.plan["plan_id"] != input.plan_id + || approval.resume.is_some() != resume + { + return Err(OpsBridgeError::StaleApproval); + } + let id = approval + .resume + .as_ref() + .map(|r| r.operation_id.clone()) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + if resume { + self.require_reference(&id, &binding.fingerprint)?; + } + // The reference is durable before any possibility of dispatch. It is not an outcome. + self.0 + .references + .lock() + .map_err(|_| OpsBridgeError::Storage)? + .add(&id, &binding.fingerprint)?; + let mut fields = json!({"operation_id":id, "plan":approval.plan, "authorization":{"plan_id":input.plan_id,"approved":true}}); + if let Some(reference) = approval.resume { + fields["expected_attempt"] = reference.expected_attempt.into(); + } + self.operation(&binding, action, id, fields, guard, emit) + } + pub fn operation_status( + &self, + input: OpsStatusInput, + ) -> Result { + let guard = self.enter(OpsAction::OperationStatus)?; + let binding = self.0.trust.binding()?; + self.require_reference(&input.operation_id, &binding.fingerprint)?; + self.operation( + &binding, + OpsAction::OperationStatus, + input.operation_id.clone(), + json!({"operation_id":input.operation_id}), + guard, + |_| {}, + ) + } + pub fn cancel(&self, input: OpsCancelInput) -> Result { + if !(1..=64).contains(&input.attempt) { + return Err(OpsBridgeError::InvalidInput); + } + let guard = self.enter(OpsAction::Cancel)?; + let binding = self.0.trust.binding()?; + self.require_reference(&input.operation_id, &binding.fingerprint)?; + self.operation( + &binding, + OpsAction::Cancel, + input.operation_id.clone(), + json!({"operation_id":input.operation_id,"attempt":input.attempt}), + guard, + |_| {}, + ) + } + fn operation( + &self, + binding: &trust::Binding, + action: OpsAction, + id: String, + fields: Value, + guard: CallGuard, + emit: impl Fn(OpsProgress) + Send + 'static, + ) -> Result { + match self + .invoke(binding, action, request(action, fields), guard, emit) + .and_then(|(v, c)| wire::operation(&v, &id, c)) + { + Ok(reply) => Ok(reply), + Err(error) => Ok(OpsOperationReply { + operation_id: id, + outcome: OpsOutcome::Unknown, + attempt: None, + exit_code: None, + error: None, + bridge_error: Some(error), + status: None, + }), + } + } + fn invoke( + &self, + binding: &trust::Binding, + action: OpsAction, + body: Value, + guard: CallGuard, + emit: impl Fn(OpsProgress) + Send + 'static, + ) -> Result<(Value, i32), OpsBridgeError> { + let launch = self.0.trust.launch(binding)?; + let deadline = if matches!(action, OpsAction::Apply | OpsAction::Resume) { + Duration::from_secs(1800) + } else if action == OpsAction::Preflight { + Duration::from_secs(180) + } else { + Duration::from_secs(30) + }; + #[cfg(test)] + let deadline = self.0.deadline.unwrap_or(deadline); + process::run(launch, body, deadline, guard, emit) + } +} +fn request(action: OpsAction, mut fields: Value) -> Value { + fields["protocol_version"] = 1.into(); + fields["request_id"] = Uuid::new_v4().to_string().into(); + fields["action"] = action.wire_name().into(); + fields +} +#[cfg(all(test, target_os = "macos"))] +mod tests; diff --git a/desktop/src-tauri/src/ops_bridge/process.rs b/desktop/src-tauri/src/ops_bridge/process.rs new file mode 100644 index 0000000000..6b67718762 --- /dev/null +++ b/desktop/src-tauri/src/ops_bridge/process.rs @@ -0,0 +1,245 @@ +/* + * 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. + */ + +//! A bounded, nonblocking JSONL pump. Never signals Ops or its service descendants. +use super::{CallGuard, OpsBridgeError as Error, OpsProgress, trust::Launch, wire}; +use serde_json::Value; +use std::{ + io::{Read, Seek, Write}, + process::{Command, Stdio}, + sync::atomic::Ordering, + time::{Duration, Instant}, +}; +const MAX_OUTPUT: usize = 1024 * 1024; +const MAX_LINE: usize = 64 * 1024; + +pub(super) fn run( + launch: Launch, + request: Value, + deadline: Duration, + guard: CallGuard, + emit: impl Fn(OpsProgress) + Send + 'static, +) -> Result<(Value, i32), Error> { + let mut bytes = serde_json::to_vec(&request).map_err(|_| Error::InvalidInput)?; + bytes.push(b'\n'); + if bytes.len() > MAX_LINE { + return Err(Error::InvalidInput); + } + // An already-complete anonymous private file avoids a blocking stdin writer if + // a broken helper never reads its request. The child still consumes one stdin line. + let mut input = tempfile::tempfile_in(&launch.cwd).map_err(|_| Error::Storage)?; + input + .write_all(&bytes) + .and_then(|_| input.rewind()) + .map_err(|_| Error::Storage)?; + let mut command = Command::new(&launch.executable); + command + .args(&launch.args) + .current_dir(&launch.cwd) + .env_clear() + .envs(&launch.env) + .stdin(Stdio::from(input)) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + let mut child = command.spawn().map_err(|_| Error::ProcessFailure)?; + let result = (|| { + let mut stdout = child.stdout.take().ok_or(Error::ProcessFailure)?; + let mut stderr = child.stderr.take().ok_or(Error::ProcessFailure)?; + nonblocking(&stdout)?; + nonblocking(&stderr)?; + let mut stream = wire::Stream::new(&request)?; + let mut line = Vec::new(); + let mut total = 0usize; + let started = Instant::now(); + loop { + if guard.active.closing.load(Ordering::Acquire) { + return Err(Error::ShuttingDown); + } + if started.elapsed() >= deadline { + return Err(Error::Timeout); + } + let mut buffer = [0u8; 8192]; + // Work per poll is bounded so a noisy stream cannot starve the deadline or stderr. + for _ in 0..32 { + let n = available(&mut stdout, &mut buffer)?; + if n == 0 { + break; + } + total += n; + if total > MAX_OUTPUT { + return Err(Error::OutputTooLarge); + } + for byte in &buffer[..n] { + if *byte == b'\n' { + let progress = stream.line(&line)?; + line.clear(); + if let Some(progress) = progress { + emit(progress); + } + } else { + if line.len() >= MAX_LINE { + return Err(Error::OutputTooLarge); + } + line.push(*byte); + } + } + } + for _ in 0..32 { + let n = available(&mut stderr, &mut buffer)?; + if n == 0 { + break; + } + total += n; + if total > MAX_OUTPUT { + return Err(Error::OutputTooLarge); + } + } + if let Some(status) = child.try_wait().map_err(|_| Error::ProcessFailure)? { + // Parent exit is the boundary, not EOF: a descendant may retain either pipe. + // Drain the final finite burst before deciding whether a complete result exists. + loop { + let n = available(&mut stdout, &mut buffer)?; + if n == 0 { + break; + } + total += n; + if total > MAX_OUTPUT { + return Err(Error::OutputTooLarge); + } + for byte in &buffer[..n] { + if *byte == b'\n' { + let event = stream.line(&line)?; + line.clear(); + if let Some(event) = event { + emit(event); + } + } else { + if line.len() >= MAX_LINE { + return Err(Error::OutputTooLarge); + } + line.push(*byte); + } + } + } + loop { + let n = available(&mut stderr, &mut buffer)?; + if n == 0 { + break; + } + total += n; + if total > MAX_OUTPUT { + return Err(Error::OutputTooLarge); + } + } + if !line.is_empty() { + return Err(Error::InvalidResponse); + } + return Ok(( + stream.finish()?, + status.code().ok_or(Error::ProcessFailure)?, + )); + } + std::thread::sleep(Duration::from_millis(5)); + } + })(); + if child.try_wait().ok().flatten().is_none() { + // At most six native admission guards can own a helper/reaper. Retain its + // slot until exit, even after timeout. Closing pipes lets Ops record a + // progress failure at its next safe boundary; no write is replayed here. + std::thread::spawn(move || { + let _ = child.wait(); + drop(guard); + }); + } + result +} +#[cfg(unix)] +fn nonblocking(pipe: &T) -> Result<(), Error> { + let fd = pipe.as_raw_fd(); + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags < 0 || libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 { + return Err(Error::ProcessFailure); + } + } + Ok(()) +} +#[cfg(unix)] +fn available(pipe: &mut T, buffer: &mut [u8]) -> Result { + match pipe.read(buffer) { + Ok(n) => Ok(n), + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted + ) => + { + Ok(0) + } + Err(_) => Err(Error::ProcessFailure), + } +} +#[cfg(windows)] +fn nonblocking(_: &T) -> Result<(), Error> { + Ok(()) +} +#[cfg(windows)] +fn available( + pipe: &mut T, + buffer: &mut [u8], +) -> Result { + use windows_sys::Win32::{ + Foundation::{ERROR_BROKEN_PIPE, GetLastError}, + System::Pipes::PeekNamedPipe, + }; + let mut count = 0; + unsafe { + if PeekNamedPipe( + pipe.as_raw_handle(), + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut count, + std::ptr::null_mut(), + ) == 0 + { + return if GetLastError() == ERROR_BROKEN_PIPE { + Ok(0) + } else { + Err(Error::ProcessFailure) + }; + } + } + if count == 0 { + return Ok(0); + } + let n = (count as usize).min(buffer.len()); + pipe.read(&mut buffer[..n]) + .map_err(|_| Error::ProcessFailure) +} +#[cfg(not(any(unix, windows)))] +fn nonblocking(_: &T) -> Result<(), Error> { + Err(Error::UnsupportedPlatform) +} +#[cfg(not(any(unix, windows)))] +fn available(_: &mut T, _: &mut [u8]) -> Result { + Err(Error::UnsupportedPlatform) +} diff --git a/desktop/src-tauri/src/ops_bridge/tests.rs b/desktop/src-tauri/src/ops_bridge/tests.rs new file mode 100644 index 0000000000..bb680e1dbb --- /dev/null +++ b/desktop/src-tauri/src/ops_bridge/tests.rs @@ -0,0 +1,933 @@ +/* + * 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 super::*; +use std::{ + fs, + path::{Path, PathBuf}, + sync::mpsc, +}; + +fn fixture_dir() -> tempfile::TempDir { + tempfile::Builder::new() + .prefix("ops-bridge-") + .tempdir_in("/private/tmp") + .unwrap() +} +#[cfg(unix)] +fn executable(path: &Path, bytes: &[u8]) { + use std::os::unix::fs::PermissionsExt; + fs::write(path, bytes).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); +} +fn private(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + path, + fs::Permissions::from_mode(if path.is_dir() { 0o700 } else { 0o600 }), + ) + .unwrap(); + } +} +struct Fixture { + _dir: tempfile::TempDir, + resources: PathBuf, + local: PathBuf, + home: PathBuf, + support: PathBuf, +} +impl Fixture { + fn new() -> Self { + let dir = fixture_dir(); + let root = dir.path(); + let resources = root.join("resources"); + let local = root.join("local"); + let home = root.join("home"); + let support = local.join("PowerContext/Ops"); + for d in [&resources, &home, &support.join("bundle/runtime")] { + fs::create_dir_all(d).unwrap(); + } + private(&support); + executable(&support.join("bundle/runtime/helper"), HELPER.as_bytes()); + fs::write(support.join("bundle/entry.py"), b"# synthetic test entry\n").unwrap(); + let files = ["runtime/helper", "entry.py"] + .map(|p| { + ( + p.to_owned(), + trust::sha256(&fs::read(support.join("bundle").join(p)).unwrap()), + ) + }) + .into_iter() + .collect::>(); + let size = files + .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 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(); + fs::write(support.join("target.json"),serde_json::to_vec(&json!({"format_version":1,"target_id":"test-server","python_executable":home.join("missing-server-python"),"package_version":"1.0.0","env_file":home.join("missing.env"),"configuration_owned":true})).unwrap()).unwrap(); + private(&support.join("target.json")); + Self { + _dir: dir, + resources, + local, + home, + support, + } + } + fn host(&self) -> OpsHost { + OpsHost::new( + Some(self.resources.clone()), + Some(self.local.clone()), + Some(self.home.clone()), + ) + } + fn approve(&self, host: &OpsHost) -> OpsApprovalInput { + let reply = host + .preflight(OpsPreflightInput { + operation: OpsOperation::RemoveRegistration, + options: OpsOptions::default(), + resume: None, + }) + .unwrap(); + let a = reply.approval.expect("validated preflight"); + OpsApprovalInput { + approval_id: a.approval_id, + plan_id: a.plan_id, + } + } + fn behavior(&self, value: &str) { + fs::write(self.support.join("behavior"), value).unwrap(); + } +} +const HELPER: &str = r#"#!/usr/bin/python3 +import sys,json,hashlib,time,pathlib,os +root=pathlib.Path(__file__).resolve().parents[2] +request=json.loads(sys.stdin.readline()) +def emit(**fields): + print(json.dumps(dict(protocol_version=1,request_id=request['request_id'],type='result',**fields),sort_keys=True,separators=(',',':')),flush=True) +def digest(v): return hashlib.sha256(json.dumps(v,sort_keys=True,separators=(',',':'),ensure_ascii=False).encode()).hexdigest() +components=dict(support='supported',registration='not_installed',definition='unknown',manager_ownership='not_loaded',manager='inactive',liveness='unreachable',readiness='not_probed',endpoint=None,maintenance=None,resources='missing_executable',configuration='missing_or_invalid') +action=request['action'] +behavior=(root/'behavior').read_text() if (root/'behavior').exists() else '' +if action=='inspect': + emit(outcome='succeeded',components=components) +elif action=='config_validate': + emit(outcome='rejected',error=dict(code='configuration_invalid'));sys.exit(1) +elif action=='preflight': + target=json.loads(pathlib.Path(sys.argv[sys.argv.index('--target-file')+1]).read_text());target.pop('format_version') + p=dict(protocol_version=1,target_id=target['target_id'],binding_id=digest(target),operation=request['operation'],options=request['options'],before='b'*64,effects=dict(may_start=False,preserves_data=True),target_summary={k:target[k] for k in ('python_executable','package_version','env_file')}) + p['target_summary'].update(endpoint=None,data_dir=None,start_on_login=True);p['plan_id']=digest(p);emit(outcome='succeeded',plan=p) +else: + op=request['operation_id'];file=root/(op+'.test-result.json');cancel=root/(op+'.test-cancel') + def save(value): + temporary=file.with_suffix('.tmp');temporary.write_text(json.dumps(value));os.replace(temporary,file) + def status(result=None,attempt=1): + return dict(operation_id=op,attempt=attempt,phase='finished' if result else 'executing',executor=dict(pid=os.getpid(),observation='not_confirmed_exited'),execution_state='finished' if result else 'unknown',outcome=result['outcome'] if result else 'pending',cancel_requested=cancel.exists(),result=result,observation=components,history=[dict(attempt=n,outcome=None if n {} + OpsOutcome::Failed => assert_eq!( + status.status.unwrap().result.unwrap().error, + Some(OpsErrorCode::ProgressUnavailable) + ), + other => panic!("unexpected terminal outcome: {other:?}"), + } + assert_eq!(status.attempt, Some(1)); +} + +#[test] +fn broken_progress_pipe_records_failure_and_can_be_queried_without_replay() { + let f = Fixture::new(); + f.behavior("broken_progress"); + let host = f.host(); + let reply = host.apply(f.approve(&host), |_| {}).unwrap(); + assert_eq!(reply.outcome, OpsOutcome::Unknown); + let reopened = f.host(); + let references = reopened.references().unwrap(); + assert_eq!(references.operations.len(), 1); + assert_eq!(references.operations[0].operation_id, reply.operation_id); + let status = await_terminal_status(&reopened, &reply.operation_id); + assert_eq!(status.outcome, OpsOutcome::Failed); + assert_eq!(status.attempt, Some(1)); + assert_eq!( + status.status.unwrap().result.unwrap().error, + Some(OpsErrorCode::ProgressUnavailable) + ); +} + +fn await_terminal_status(host: &OpsHost, operation_id: &str) -> OpsOperationReply { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let status = host + .operation_status(OpsStatusInput { + operation_id: operation_id.into(), + }) + .unwrap(); + if status.outcome != OpsOutcome::Pending + && status.error != Some(OpsErrorCode::OperationMissing) + { + return status; + } + assert!( + Instant::now() < deadline, + "helper did not settle: {status:?}" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} +#[test] +#[cfg(target_os = "macos")] +fn malformed_reference_store_fails_before_dispatch_and_foreign_binding_cannot_resume() { + let f = Fixture::new(); + let host = f.host(); + let a = f.approve(&host); + let path = f.support.join("bridge-operations.json"); + fs::write(&path, b"broken").unwrap(); + private(&path); + assert!(host.apply(a, |_| {}).is_err()); + fs::remove_file(path).unwrap(); + let a = f.approve(&host); + let reply = host.apply(a, |_| {}).unwrap(); + let mut target: Value = + serde_json::from_slice(&fs::read(f.support.join("target.json")).unwrap()).unwrap(); + target["target_id"] = "other".into(); + fs::write( + f.support.join("target.json"), + serde_json::to_vec(&target).unwrap(), + ) + .unwrap(); + assert_eq!( + host.references().unwrap().operations[0].binding_status, + OpsReferenceBindingStatus::Different + ); + assert_eq!( + host.operation_status(OpsStatusInput { + operation_id: reply.operation_id + }) + .unwrap_err(), + OpsBridgeError::ForeignBinding + ); +} +#[test] +#[cfg(target_os = "macos")] +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(); + assert_eq!( + f.host().inspect().bridge_error, + Some(OpsBridgeError::IntegrityFailure) + ); + let f = Fixture::new(); + fs::write(f.support.join("bundle/packages-extra.py"), b"extra").unwrap(); + assert_eq!( + f.host().inspect().bridge_error, + Some(OpsBridgeError::IntegrityFailure) + ); + let f = Fixture::new(); + fs::write(f.resources.join("ops-provision.json"), b"{}").unwrap(); + assert_eq!( + f.host().inspect().bridge_error, + Some(OpsBridgeError::IntegrityFailure) + ); +} +fn child( + script: &str, + deadline: Duration, + request: Value, + emit: impl Fn(OpsProgress) + Send + 'static, +) -> Result<(Value, i32), OpsBridgeError> { + let dir = fixture_dir(); + let file = dir.path().join("child.py"); + fs::write(&file, script).unwrap(); + let host = OpsHost::new(None, None, None); + let action = if request["action"] == "apply" { + OpsAction::Apply + } else { + OpsAction::Inspect + }; + let guard = host.enter(action).unwrap(); + process::run( + trust::Launch { + executable: PathBuf::from("/usr/bin/python3"), + args: vec![ + "-I".into(), + "-S".into(), + "-B".into(), + file.to_str().unwrap().into(), + ], + env: Default::default(), + cwd: dir.path().into(), + }, + request, + deadline, + guard, + emit, + ) +} +#[test] +#[cfg(target_os = "macos")] +fn real_children_bound_streams_deadlines_and_inherited_pipe_descendants() { + for stream in ["stdout", "stderr"] { + let script = format!( + "import sys,time\nsys.{stream}.write('x'*1100000)\nsys.{stream}.flush()\ntime.sleep(1)\n" + ); + assert_eq!( + child( + &script, + Duration::from_secs(2), + request(OpsAction::Inspect, json!({})), + |_| {} + ) + .unwrap_err(), + OpsBridgeError::OutputTooLarge + ); + } + let started = Instant::now(); + assert_eq!( + child( + "import time\ntime.sleep(1)", + Duration::from_millis(50), + request(OpsAction::Inspect, json!({})), + |_| {} + ) + .unwrap_err(), + OpsBridgeError::Timeout + ); + assert!(started.elapsed() < Duration::from_secs(1)); + let started = Instant::now(); + let result=child("import os,time,json,sys\nr=json.loads(sys.stdin.readline())\nif os.fork()==0:\n time.sleep(2);os._exit(0)\nprint(json.dumps(dict(protocol_version=1,request_id=r['request_id'],type='result',outcome='rejected',error=dict(code='configuration_invalid'))),flush=True)\nsys.exit(1)\n",Duration::from_secs(4),request(OpsAction::Inspect,json!({})),|_|{}).unwrap(); + assert_eq!(result.1, 1); + assert!(started.elapsed() < Duration::from_secs(1)); +} +#[test] +fn stream_rejects_wrong_identity_duplicate_keys_unknown_enums_and_sequence() { + let request = request(OpsAction::Apply, json!({"operation_id":"op"})); + let id = request["request_id"].as_str().unwrap(); + let accepted = json!({"protocol_version":1,"request_id":id,"type":"accepted","operation_id":"op","attempt":1}); + let progress = json!({"protocol_version":1,"request_id":id,"type":"progress","operation_id":"op","attempt":1,"sequence":1,"phase":"service"}); + let mut s = wire::Stream::new(&request).unwrap(); + assert!(s.line(&serde_json::to_vec(&progress).unwrap()).is_err()); + let mut s = wire::Stream::new(&request).unwrap(); + assert!( + s.line(&serde_json::to_vec(&accepted).unwrap()) + .unwrap() + .is_some() + ); + assert!( + s.line(&serde_json::to_vec(&progress).unwrap()) + .unwrap() + .is_some() + ); + assert!(s.line(&serde_json::to_vec(&progress).unwrap()).is_err()); + for changed in [ + json!({"request_id":"foreign"}), + json!({"protocol_version":2}), + json!({"operation_id":"foreign"}), + json!({"attempt":2}), + ] { + let mut v = accepted.clone(); + for (k, x) in changed.as_object().unwrap() { + v[k] = x.clone(); + } + assert!( + wire::Stream::new(&request) + .unwrap() + .line(&serde_json::to_vec(&v).unwrap()) + .is_err() + ); + } + let duplicate = format!( + "{{\"protocol_version\":1,\"protocol_version\":1,\"request_id\":\"{id}\",\"type\":\"accepted\",\"operation_id\":\"op\",\"attempt\":1}}" + ); + assert!( + wire::Stream::new(&request) + .unwrap() + .line(duplicate.as_bytes()) + .is_err() + ); + let r = request_from_inspect(); + let invalid = json!({"protocol_version":1,"request_id":r["request_id"],"type":"result","outcome":"rejected","error":{"code":"secret_provider_exception"}}); + assert!( + wire::Stream::new(&r) + .unwrap() + .line(&serde_json::to_vec(&invalid).unwrap()) + .is_err() + ); +} +fn request_from_inspect() -> Value { + request(OpsAction::Inspect, json!({})) +} +#[test] +fn renderer_inputs_cannot_supply_paths_commands_or_plan_bodies() { + for v in [ + json!({"operation":"stop","options":{},"executable":"/bin/sh"}), + json!({"operation":"shell","options":{}}), + json!({"operation":"stop","options":{"url":"https://example.invalid"}}), + ] { + assert!(serde_json::from_value::(v).is_err()); + } + assert!( + serde_json::from_value::( + json!({"approvalId":"abc","planId":"a".repeat(64),"plan":{}}) + ) + .is_err() + ); + assert!( + OpsOptions { + http_port: Some(1234), + ..Default::default() + } + .validate(OpsOperation::Register) + .is_err() + ); +} +#[test] +fn control_admission_remains_available_when_ordinary_helpers_are_reserved() { + let h = OpsHost::new(None, None, None); + let guards = (0..4) + .map(|_| h.enter(OpsAction::Apply).unwrap()) + .collect::>(); + assert!(matches!( + h.enter(OpsAction::Inspect), + Err(OpsBridgeError::Busy) + )); + let status = h.enter(OpsAction::OperationStatus).unwrap(); + let cancel = h.enter(OpsAction::Cancel).unwrap(); + drop((guards, status, cancel)); + h.shutdown(); + assert!(matches!( + h.enter(OpsAction::Inspect), + Err(OpsBridgeError::ShuttingDown) + )); +} + +#[test] +#[ignore = "requires a prepared local Python environment with Ops dependencies; read-only real subprocess"] +#[cfg(target_os = "macos")] +fn real_current_ops_inspect_and_preflight_with_missing_server_runtime() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .unwrap(); + let python = PathBuf::from( + std::env::var_os("POWERCONTEXT_OPS_TEST_PYTHON").expect("set explicit prepared Python"), + ); + let probe = std::process::Command::new(&python) + .args([ + "-c", + "import sysconfig;print(sysconfig.get_path('purelib'))", + ]) + .output() + .unwrap(); + assert!(probe.status.success()); + let site = String::from_utf8(probe.stdout).unwrap().trim().to_owned(); + let dir = fixture_dir(); + let home = dir.path().join("home"); + fs::create_dir(&home).unwrap(); + private(&home); + let target = dir.path().join("target.json"); + fs::write(&target,serde_json::to_vec(&json!({"format_version":1,"target_id":"disposable-native-bridge-test","python_executable":home.join("missing-python"),"package_version":"1.0.0","env_file":home.join("missing.env"),"configuration_owned":true})).unwrap()).unwrap(); + private(&target); + let entry = dir.path().join("entry.py"); + fs::write(&entry,format!("import sys,runpy\nsys.path[:0]=[{},{}]\nrunpy.run_module('powercontext.ops',run_name='__main__')\n",serde_json::to_string(&root.join("src").to_str().unwrap()).unwrap(),serde_json::to_string(&site).unwrap())).unwrap(); + for action in [OpsAction::Inspect, OpsAction::Preflight] { + let h = OpsHost::new(None, None, None); + let guard = h.enter(action).unwrap(); + let fields = if action == OpsAction::Preflight { + json!({"operation":"remove_registration","options":{}}) + } else { + json!({}) + }; + let (v, code) = process::run( + trust::Launch { + executable: python.clone(), + args: vec![ + "-I".into(), + "-S".into(), + "-B".into(), + entry.to_str().unwrap().into(), + "--target-file".into(), + target.to_str().unwrap().into(), + "--state-dir".into(), + dir.path().join("state").to_str().unwrap().into(), + ], + env: std::collections::BTreeMap::from([ + ("HOME".into(), home.to_str().unwrap().into()), + ("PATH".into(), "/usr/bin:/bin:/usr/sbin:/sbin".into()), + ]), + cwd: dir.path().into(), + }, + request(action, fields), + Duration::from_secs(30), + guard, + |_| {}, + ) + .unwrap(); + if action == OpsAction::Preflight && v["outcome"] == "rejected" { + // A headless host can observe launchd ownership as unknown. The real + // runtime must retain its refusal rather than claim a supported removal. + assert_eq!(code, 1); + assert_eq!(v["error"]["code"], "ownership_conflict"); + continue; + } + assert_eq!(code, 0, "{v}"); + assert_eq!(v["outcome"], "succeeded"); + if action == OpsAction::Inspect { + assert_eq!(v["components"]["resources"], "missing_executable"); + } else { + assert_eq!(v["plan"]["operation"], "remove_registration"); + assert_eq!(v["plan"]["effects"]["may_start"], false); + } + } + assert!(!dir.path().join("state").exists()); + assert!(!home.join("missing-python").exists()); +} + +#[test] +fn fresh_resume_approval_retains_operation_identity_and_advances_attempt() { + let f = Fixture::new(); + f.behavior("await_cancel"); + let h = f.host(); + let a = f.approve(&h); + let (send, recv) = mpsc::channel(); + let worker = h.clone(); + let thread = std::thread::spawn(move || { + worker + .apply(a, move |e| { + let _ = send.send(e); + }) + .unwrap() + }); + let e = recv.recv_timeout(Duration::from_secs(3)).unwrap(); + h.cancel(OpsCancelInput { + operation_id: e.operation_id.clone(), + attempt: 1, + }) + .unwrap(); + assert_eq!(thread.join().unwrap().outcome, OpsOutcome::Cancelled); + fs::remove_file(f.support.join(format!("{}.test-cancel", e.operation_id))).unwrap(); + f.behavior(""); + let approval = h + .preflight(OpsPreflightInput { + operation: OpsOperation::RemoveRegistration, + options: OpsOptions::default(), + resume: Some(OpsResumeReference { + operation_id: e.operation_id.clone(), + expected_attempt: 1, + }), + }) + .unwrap() + .approval + .unwrap(); + let result = h + .resume( + OpsApprovalInput { + approval_id: approval.approval_id, + plan_id: approval.plan_id, + }, + |_| {}, + ) + .unwrap(); + assert_eq!(result.operation_id, e.operation_id); + assert_eq!(result.attempt, Some(2)); + assert_eq!(result.outcome, OpsOutcome::Succeeded); + assert_eq!(h.references().unwrap().operations.len(), 1); +} + +#[test] +fn quit_closes_admission_and_preserves_accepted_helper_outcome() { + let f = Fixture::new(); + f.behavior("await_release"); + let h = f.host(); + let a = f.approve(&h); + let (send, recv) = mpsc::channel(); + let worker = h.clone(); + let thread = std::thread::spawn(move || { + worker + .apply(a, move |e| { + let _ = send.send(e); + }) + .unwrap() + }); + let event = recv.recv_timeout(Duration::from_secs(3)).unwrap(); + h.shutdown(); + let result = thread.join().unwrap(); + assert_eq!(result.outcome, OpsOutcome::Unknown); + assert_eq!(result.bridge_error, Some(OpsBridgeError::ShuttingDown)); + assert_eq!(h.inspect().bridge_error, Some(OpsBridgeError::ShuttingDown)); + fs::write( + f.support + .join(format!("{}.test-release", event.operation_id)), + b"continue", + ) + .unwrap(); + let status = await_terminal_status(&f.host(), &event.operation_id); + assert_eq!(status.outcome, OpsOutcome::Succeeded); +} + +#[test] +fn original_builder_runtime_file_aliases_are_supported_but_external_links_are_rejected() { + use std::os::unix::fs::symlink; + let f = Fixture::new(); + let root = f.support.join("bundle"); + symlink("helper", root.join("runtime/alias")).unwrap(); + let mut manifest: Value = + serde_json::from_slice(&fs::read(root.join("bundle.json")).unwrap()).unwrap(); + manifest["python_relative"] = "runtime/alias".into(); + manifest["files_sha256"]["runtime/alias"] = manifest["files_sha256"]["runtime/helper"].clone(); + manifest["installed_bytes"] = (manifest["installed_bytes"].as_u64().unwrap() + + fs::metadata(root.join("runtime/helper")).unwrap().len()) + .into(); + let bytes = serde_json::to_vec(&manifest).unwrap(); + fs::write(root.join("bundle.json"), &bytes).unwrap(); + fs::write(f.resources.join("ops-provision.json"),serde_json::to_vec(&json!({"format_version":1,"binding_mode":"target_file","bundle_manifest_sha256":trust::sha256(&bytes)})).unwrap()).unwrap(); + assert_eq!(f.host().inspect().outcome, OpsOutcome::Succeeded); + fs::copy(root.join("runtime/helper"), f.support.join("outside")).unwrap(); + fs::remove_file(root.join("runtime/alias")).unwrap(); + symlink("../../outside", root.join("runtime/alias")).unwrap(); + assert_eq!( + f.host().inspect().bridge_error, + Some(OpsBridgeError::IntegrityFailure) + ); +} + +#[test] +fn managed_catalog_copy_is_private_immutable_and_pinned_across_launches() { + let f = Fixture::new(); + fs::create_dir(f.resources.join("server")).unwrap(); + let bytes = b"{\"synthetic_catalog\":1}"; + let digest = trust::sha256(bytes); + fs::write(f.resources.join("server/catalog.json"), bytes).unwrap(); + let mut provision: Value = + serde_json::from_slice(&fs::read(f.resources.join("ops-provision.json")).unwrap()).unwrap(); + provision["binding_mode"] = "managed_catalog".into(); + provision["catalog_sha256"] = digest.clone().into(); + fs::write( + f.resources.join("ops-provision.json"), + serde_json::to_vec(&provision).unwrap(), + ) + .unwrap(); + let h = f.host(); + let binding = h.0.trust.binding().unwrap(); + let launch = h.0.trust.launch(&binding).unwrap(); + let copy = PathBuf::from(&launch.args[5]); + assert_eq!(copy, f.support.join(format!("catalog-{digest}.json"))); + assert_eq!(fs::read(©).unwrap(), bytes); + fs::write(©, b"changed").unwrap(); + assert!(matches!( + h.0.trust.launch(&binding), + Err(OpsBridgeError::IntegrityFailure) + )); + fs::write(f.resources.join("server/catalog.json"), b"changed").unwrap(); + assert!(matches!( + h.0.trust.binding(), + Err(OpsBridgeError::IntegrityFailure) + )); +} + +#[test] +fn database_admission_v1_extensions_remain_bounded_and_actionable() { + let r = request(OpsAction::Inspect, json!({})); + let v = json!({"protocol_version":1,"request_id":r["request_id"],"type":"result","outcome":"succeeded","components":{"support":"supported","registration":"not_installed","definition":"unknown","manager_ownership":"not_loaded","manager":"inactive","liveness":"unknown","readiness":"not_probed","endpoint":null,"maintenance":null,"resources":"missing_executable","configuration":"missing_or_invalid","database":{"classification":"existing_ready","allowed_to_start":true,"fingerprint":"a".repeat(64),"reason":"server_ready"}}}); + let mut stream = wire::Stream::new(&r).unwrap(); + stream.line(&serde_json::to_vec(&v).unwrap()).unwrap(); + assert_eq!( + wire::inspection(&stream.finish().unwrap(), 0) + .unwrap() + .components + .unwrap() + .database, + Some(OpsDatabaseClassification::ExistingReady) + ); + for code in [ + "database_recovery_required", + "database_migration_required", + "database_configuration_mismatch", + "database_snapshot_too_large", + "database_snapshot_space_insufficient", + "database_admission_timeout", + "database_changed_during_admission", + ] { + let r = request( + OpsAction::Preflight, + json!({"operation":"start","options":{}}), + ); + let v = json!({"protocol_version":1,"request_id":r["request_id"],"type":"result","outcome":"rejected","error":{"code":code}}); + let mut stream = wire::Stream::new(&r).unwrap(); + stream.line(&serde_json::to_vec(&v).unwrap()).unwrap(); + assert_eq!( + serde_json::to_value(wire::error(&stream.finish().unwrap()["error"]).unwrap()).unwrap(), + code + ); + } +} + +#[test] +#[ignore = "requires an independently produced native Ops bundle and externally supplied manifest pin"] +fn actual_independent_bundle_runs_through_native_trust_and_read_only_bridge() { + fn copy(source: &Path, destination: &Path) { + fs::create_dir_all(destination).unwrap(); + for item in fs::read_dir(source).unwrap() { + let item = item.unwrap(); + let path = item.path(); + let to = destination.join(item.file_name()); + let metadata = fs::symlink_metadata(&path).unwrap(); + if metadata.file_type().is_symlink() { + std::os::unix::fs::symlink(fs::read_link(path).unwrap(), to).unwrap(); + } else if metadata.is_dir() { + copy(&path, &to); + } else { + assert!(metadata.is_file()); + fs::copy(path, to).unwrap(); + } + } + } + let source = PathBuf::from( + std::env::var_os("POWERCONTEXT_OPS_TEST_BUNDLE").expect("set produced bundle directory"), + ); + let pin = std::env::var("POWERCONTEXT_OPS_TEST_BUNDLE_SHA256") + .expect("set externally approved manifest pin"); + assert!(trust::is_digest(&pin)); + let f = Fixture::new(); + let bundle = f.support.join("bundle"); + fs::remove_dir_all(&bundle).unwrap(); + copy(&source, &bundle); + fs::write( + f.resources.join("ops-provision.json"), + serde_json::to_vec( + &json!({"format_version":1,"binding_mode":"target_file","bundle_manifest_sha256":pin}), + ) + .unwrap(), + ) + .unwrap(); + let host = f.host(); + let inspection = host.inspect(); + assert_eq!(inspection.outcome, OpsOutcome::Succeeded, "{inspection:?}"); + assert_eq!( + inspection.components.unwrap().resources, + Some(OpsRuntimeResources::MissingExecutable) + ); + let preflight = host + .preflight(OpsPreflightInput { + operation: OpsOperation::RemoveRegistration, + options: OpsOptions::default(), + resume: None, + }) + .unwrap(); + assert!(preflight.bridge_error.is_none(), "{preflight:?}"); + if preflight.outcome == OpsOutcome::Succeeded { + assert!(!preflight.approval.unwrap().effects.may_start); + } else { + assert_eq!(preflight.outcome, OpsOutcome::Rejected); + assert_eq!(preflight.error, Some(OpsErrorCode::OwnershipConflict)); + } + assert!(host.references().unwrap().operations.is_empty()); + assert!(!f.support.join("state").exists()); + let dependency = bundle.join("packages/powercontext/ops/__init__.py"); + let mut bytes = fs::read(&dependency).unwrap(); + bytes.extend_from_slice(b"\n# changed disposable dependency\n"); + fs::write(dependency, bytes).unwrap(); + assert_eq!( + host.inspect().bridge_error, + Some(OpsBridgeError::IntegrityFailure) + ); +} diff --git a/desktop/src-tauri/src/ops_bridge/trust.rs b/desktop/src-tauri/src/ops_bridge/trust.rs new file mode 100644 index 0000000000..10f934514a --- /dev/null +++ b/desktop/src-tauri/src/ops_bridge/trust.rs @@ -0,0 +1,659 @@ +/* + * 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. + */ + +//! Distribution pins, fixed launch paths, and private reference storage. +use super::{OpsBridgeError as Error, wire}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + io::{Read, Write}, + path::{Component, Path, PathBuf}, +}; +mod security; + +const MAX_MANIFEST: usize = 16 * 1024 * 1024; +const MAX_FILES: usize = 100_000; +const MAX_BUNDLE_BYTES: u64 = 8 * 1024 * 1024 * 1024; + +pub(super) struct Trust { + resources: Option, + pub support: Option, + local_data: Option, + home: Option, + // Only packaged configuration is an authority. Bundle metadata cannot set this pin. + provision: Result, +} +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct Provision { + format_version: u8, + bundle_manifest_sha256: String, + binding_mode: Mode, + #[serde(default)] + catalog_sha256: Option, +} +#[derive(Clone, Copy, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +enum Mode { + TargetFile, + ManagedCatalog, +} +#[derive(Clone)] +pub(super) struct Binding { + pub fingerprint: String, + pub target_id: Option, + pub catalog_digest: Option, + pub target_digest: Option, + bytes: Vec, + digest: String, +} +pub(super) struct Launch { + pub executable: PathBuf, + pub args: Vec, + pub env: BTreeMap, + pub cwd: PathBuf, +} +impl Trust { + pub fn new( + resources: Option, + local_data: Option, + home: Option, + ) -> Self { + let provision = resources + .as_ref() + .ok_or(Error::Unavailable) + .and_then(|r| read(&r.join("ops-provision.json"), 16 * 1024)) + .and_then(|bytes| { + let p: Provision = + serde_json::from_slice(&bytes).map_err(|_| Error::IntegrityFailure)?; + if p.format_version != 1 + || !is_digest(&p.bundle_manifest_sha256) + || match p.binding_mode { + Mode::TargetFile => p.catalog_sha256.is_some(), + Mode::ManagedCatalog => !p.catalog_sha256.as_deref().is_some_and(is_digest), + } + { + return Err(Error::IntegrityFailure); + } + Ok(p) + }); + Self { + support: local_data.as_ref().map(|d| d.join("PowerContext/Ops")), + resources, + local_data, + home, + provision, + } + } + pub fn binding(&self) -> Result { + if !cfg!(any(windows, target_os = "macos")) { + return Err(Error::UnsupportedPlatform); + } + let p = self.provision.as_ref().map_err(|e| *e)?; + let support = self.support.as_ref().ok_or(Error::Unavailable)?; + let bytes = match p.binding_mode { + Mode::TargetFile => { + private_directory(support, false)?; + read_private(&support.join("target.json"), 16 * 1024)? + } + Mode::ManagedCatalog => read( + &self + .resources + .as_ref() + .ok_or(Error::Unavailable)? + .join("server/catalog.json"), + 32 * 1024 * 1024, + )?, + }; + let digest = sha256(&bytes); + let mut target_digest = None; + let target_id = if p.binding_mode == Mode::TargetFile { + let target: Target = + serde_json::from_slice(&bytes).map_err(|_| Error::IntegrityFailure)?; + target.validate()?; + let mut value: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|_| Error::IntegrityFailure)?; + let fields = value.as_object_mut().ok_or(Error::IntegrityFailure)?; + fields.remove("format_version"); + if fields.get("isolated_runtime").is_some_and(|v| v == false) { + fields.remove("isolated_runtime"); + } + value.sort_all_objects(); + target_digest = Some(sha256( + &serde_json::to_vec(&value).map_err(|_| Error::IntegrityFailure)?, + )); + Some(target.target_id) + } else { + if p.catalog_sha256.as_ref() != Some(&digest) { + return Err(Error::IntegrityFailure); + } + None + }; + let fingerprint = sha256(&serde_json::to_vec(&serde_json::json!({ + "bundle":p.bundle_manifest_sha256,"mode":if p.binding_mode == Mode::TargetFile {"target"} else {"catalog"}, + "binding":digest,"support":path_string(support)?, + "home":self.home.as_deref().map(path_string).transpose()?, + "local_data":self.local_data.as_deref().map(path_string).transpose()?, + })).map_err(|_| Error::Storage)?); + Ok(Binding { + fingerprint, + target_id, + target_digest, + catalog_digest: p.catalog_sha256.clone(), + bytes, + digest, + }) + } + pub fn launch(&self, binding: &Binding) -> Result { + if self.binding()?.fingerprint != binding.fingerprint { + return Err(Error::StaleApproval); + } + let p = self.provision.as_ref().map_err(|e| *e)?; + let support = self.support.as_ref().ok_or(Error::Unavailable)?; + private_directory(support, true)?; + let root = support.join("bundle"); + let executable = verify_bundle(&root, &p.bundle_manifest_sha256)?; + // Digest-named copies remain immutable across concurrent operations and catalog updates. + let mode = if p.binding_mode == Mode::TargetFile { + "target" + } else { + "catalog" + }; + let file = support.join(format!("{mode}-{}.json", binding.digest)); + if file.try_exists().map_err(|_| Error::Storage)? { + if read_private(&file, binding.bytes.len())? != binding.bytes { + return Err(Error::IntegrityFailure); + } + } else { + atomic(support, &file, &binding.bytes)?; + } + let mut args = vec![ + "-I".into(), + "-S".into(), + "-B".into(), + path_string(&root.join("entry.py"))?, + ]; + match p.binding_mode { + Mode::TargetFile => args.extend([ + "--target-file".into(), + path_string(&file)?, + "--state-dir".into(), + path_string(&support.join("state"))?, + ]), + Mode::ManagedCatalog => args.extend([ + "--catalog-file".into(), + path_string(&file)?, + "--catalog-sha256".into(), + binding.digest.clone(), + ]), + } + let home = self + .home + .as_ref() + .filter(|p| p.is_absolute()) + .ok_or(Error::Unavailable)?; + let mut env = BTreeMap::from([("HOME".into(), path_string(home)?)]); + #[cfg(windows)] + { + let system = PathBuf::from(std::env::var_os("SystemRoot").ok_or(Error::Unavailable)?); + if !system.is_absolute() { + return Err(Error::Unavailable); + } + env.insert("SystemRoot".into(), path_string(&system)?); + env.insert("PATH".into(), path_string(&system.join("System32"))?); + env.insert("USERPROFILE".into(), path_string(home)?); + env.insert( + "LOCALAPPDATA".into(), + path_string(self.local_data.as_ref().ok_or(Error::Unavailable)?)?, + ); + env.insert( + "APPDATA".into(), + path_string(&home.join("AppData/Roaming"))?, + ); + env.insert("TEMP".into(), path_string(&std::env::temp_dir())?); + env.insert("TMP".into(), path_string(&std::env::temp_dir())?); + } + #[cfg(not(windows))] + { + env.insert("PATH".into(), "/usr/bin:/bin:/usr/sbin:/sbin".into()); + env.insert("TMPDIR".into(), "/tmp".into()); + } + Ok(Launch { + executable, + args, + env, + cwd: support.clone(), + }) + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Target { + format_version: u8, + target_id: String, + python_executable: String, + package_version: String, + env_file: String, + configuration_owned: bool, + #[serde(default)] + isolated_runtime: bool, +} +impl Target { + fn validate(&self) -> Result<(), Error> { + wire::identifier(&self.target_id).map_err(|_| Error::IntegrityFailure)?; + if self.format_version != 1 + || self.package_version.is_empty() + || self.package_version.len() > 128 + || [&self.python_executable, &self.env_file] + .iter() + .any(|s| s.len() > 4096 || !Path::new(s).is_absolute() || s.contains('\0')) + { + return Err(Error::IntegrityFailure); + } + let _ = (self.configuration_owned, self.isolated_runtime); + Ok(()) + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + format_version: u8, + python_version: String, + platform: String, + architecture: String, + python_relative: String, + arguments: Vec, + requirements_sha256: String, + installed_bytes: u64, + files_sha256: BTreeMap, +} +fn verify_bundle(root: &Path, pin: &str) -> Result { + no_links(root)?; + let manifest_bytes = read(&root.join("bundle.json"), MAX_MANIFEST)?; + if sha256(&manifest_bytes) != pin { + return Err(Error::IntegrityFailure); + } + let m: Manifest = + serde_json::from_slice(&manifest_bytes).map_err(|_| Error::IntegrityFailure)?; + if m.format_version != 1 + || m.python_version.is_empty() + || m.python_version.len() > 128 + || m.arguments != ["-I", "-S", "-B", "entry.py"] + || !is_digest(&m.requirements_sha256) + || m.files_sha256.is_empty() + || m.files_sha256.len() > MAX_FILES + || m.installed_bytes > MAX_BUNDLE_BYTES + { + return Err(Error::IntegrityFailure); + } + let platform = if cfg!(windows) { + "win32" + } else if cfg!(target_os = "macos") { + "darwin" + } else { + "unsupported" + }; + let arch = m.architecture.to_ascii_lowercase(); + if m.platform != platform + || !match std::env::consts::ARCH { + "aarch64" => matches!(arch.as_str(), "arm64" | "aarch64"), + "x86_64" => matches!(arch.as_str(), "x86_64" | "amd64"), + _ => false, + } + { + return Err(Error::UnsupportedPlatform); + } + if !m.python_relative.starts_with("runtime/") + || !m.files_sha256.contains_key(&m.python_relative) + || !m.files_sha256.contains_key("entry.py") + { + return Err(Error::IntegrityFailure); + } + let mut actual = BTreeSet::new(); + inventory(root, root, &mut actual)?; + let expected = m + .files_sha256 + .keys() + .cloned() + .chain(["bundle.json".into()]) + .collect::>(); + if actual != expected { + return Err(Error::IntegrityFailure); + } + let mut bytes = 0u64; + for (name, digest) in &m.files_sha256 { + relative(name)?; + if !is_digest(digest) { + return Err(Error::IntegrityFailure); + } + let path = root.join(name); + let meta = fs::symlink_metadata(&path).map_err(|_| Error::Unavailable)?; + let resolved = if meta.file_type().is_symlink() { + // The original builder inventories file aliases by their dereferenced content. + // Allow only runtime-local relative aliases to a separately inventoried regular file. + if !name.starts_with("runtime/") + || fs::read_link(&path) + .map_err(|_| Error::IntegrityFailure)? + .is_absolute() + { + return Err(Error::IntegrityFailure); + } + let resolved = path.canonicalize().map_err(|_| Error::IntegrityFailure)?; + let runtime = root.join("runtime"); + if !resolved.starts_with(&runtime) || !resolved.is_file() { + return Err(Error::IntegrityFailure); + } + let dest = resolved + .strip_prefix(root) + .map_err(|_| Error::IntegrityFailure)? + .to_str() + .ok_or(Error::IntegrityFailure)? + .replace('\\', "/"); + if m.files_sha256.get(&dest) != Some(digest) { + return Err(Error::IntegrityFailure); + } + no_links(&resolved)?; + resolved + } else { + no_links(&path)?; + path + }; + let mut file = fs::File::open(&resolved).map_err(|_| Error::Unavailable)?; + let meta = file.metadata().map_err(|_| Error::Unavailable)?; + if !meta.is_file() || meta.len() > MAX_BUNDLE_BYTES { + return Err(Error::IntegrityFailure); + } + bytes = bytes + .checked_add(meta.len()) + .filter(|b| *b <= MAX_BUNDLE_BYTES) + .ok_or(Error::IntegrityFailure)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + let mut size = 0u64; + loop { + let n = file + .read(&mut buffer) + .map_err(|_| Error::IntegrityFailure)?; + if n == 0 { + break; + } + size += n as u64; + if size > meta.len() { + return Err(Error::IntegrityFailure); + } + hasher.update(&buffer[..n]); + } + if size != meta.len() || format!("{:x}", hasher.finalize()) != *digest { + return Err(Error::IntegrityFailure); + } + } + if bytes != m.installed_bytes { + return Err(Error::IntegrityFailure); + } + let executable = root.join(relative(&m.python_relative)?); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if fs::metadata(&executable) + .map_err(|_| Error::Unavailable)? + .permissions() + .mode() + & 0o111 + == 0 + { + return Err(Error::IntegrityFailure); + } + } + Ok(executable) +} +fn inventory(root: &Path, path: &Path, files: &mut BTreeSet) -> Result<(), Error> { + if path.components().count() - root.components().count() > 32 { + return Err(Error::IntegrityFailure); + } + for entry in fs::read_dir(path).map_err(|_| Error::Unavailable)? { + let entry = entry.map_err(|_| Error::Unavailable)?; + let p = entry.path(); + let m = fs::symlink_metadata(&p).map_err(|_| Error::Unavailable)?; + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if m.file_attributes() & 0x400 != 0 { + return Err(Error::IntegrityFailure); + } + } + if m.is_dir() { + inventory(root, &p, files)?; + } else if m.is_file() || m.file_type().is_symlink() { + if m.file_type().is_symlink() && !p.is_file() { + return Err(Error::IntegrityFailure); + } + let n = p + .strip_prefix(root) + .map_err(|_| Error::IntegrityFailure)? + .to_str() + .ok_or(Error::IntegrityFailure)? + .replace('\\', "/"); + files.insert(n); + if files.len() > MAX_FILES + 1 { + return Err(Error::IntegrityFailure); + } + } else { + return Err(Error::IntegrityFailure); + } + } + Ok(()) +} +fn relative(name: &str) -> Result { + if name.is_empty() + || name.len() > 1024 + || name.contains(['\\', ':', '\0']) + || name + .split('/') + .any(|p| p.is_empty() || p == "." || p == "..") + { + return Err(Error::IntegrityFailure); + } + let p = Path::new(name); + if !p.components().all(|p| matches!(p, Component::Normal(_))) { + return Err(Error::IntegrityFailure); + } + Ok(p.into()) +} +fn path_string(path: &Path) -> Result { + path.to_str() + .filter(|s| !s.contains('\0')) + .map(str::to_owned) + .ok_or(Error::Unavailable) +} +pub(super) fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +pub(super) fn is_digest(s: &str) -> bool { + s.len() == 64 + && s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +fn no_links(path: &Path) -> Result<(), Error> { + if !path.is_absolute() || path.components().any(|p| matches!(p, Component::ParentDir)) { + return Err(Error::IntegrityFailure); + } + for part in path.ancestors() { + match fs::symlink_metadata(part) { + Ok(m) => { + if m.file_type().is_symlink() { + return Err(Error::IntegrityFailure); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if m.file_attributes() & 0x400 != 0 { + return Err(Error::IntegrityFailure); + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(Error::Unavailable), + } + } + Ok(()) +} +fn read(path: &Path, max: usize) -> Result, Error> { + no_links(path)?; + let mut f = fs::File::open(path).map_err(|_| Error::Unavailable)?; + let m = f.metadata().map_err(|_| Error::Unavailable)?; + if !m.is_file() || m.len() > max as u64 { + return Err(Error::IntegrityFailure); + } + let mut bytes = Vec::new(); + Read::by_ref(&mut f) + .take(max as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| Error::Unavailable)?; + if bytes.len() > max { + return Err(Error::IntegrityFailure); + } + Ok(bytes) +} +fn read_private(path: &Path, max: usize) -> Result, Error> { + no_links(path)?; + if !path.try_exists().map_err(|_| Error::Storage)? { + return Err(Error::Unavailable); + } + security::validate(path)?; + read(path, max) +} +fn private_directory(path: &Path, create: bool) -> Result<(), Error> { + no_links(path)?; + if !path.try_exists().map_err(|_| Error::Storage)? { + if !create { + return Err(Error::Unavailable); + } + if let Some(parent) = path.parent() + && !parent.exists() + { + private_directory(parent, true)?; + } + match fs::create_dir(path) { + Ok(()) => security::restrict(path, true)?, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => return Err(Error::Storage), + } + } + if !path.is_dir() { + return Err(Error::Storage); + } + security::validate(path) +} +fn atomic(dir: &Path, path: &Path, bytes: &[u8]) -> Result<(), Error> { + private_directory(dir, true)?; + no_links(path)?; + if path.exists() { + security::validate(path)?; + } + let mut temp = tempfile::NamedTempFile::new_in(dir).map_err(|_| Error::Storage)?; + security::restrict(temp.path(), false)?; + temp.write_all(bytes) + .and_then(|_| temp.as_file().sync_all()) + .map_err(|_| Error::Storage)?; + temp.persist(path).map_err(|_| Error::Storage)?; + #[cfg(unix)] + fs::File::open(dir) + .and_then(|f| f.sync_all()) + .map_err(|_| Error::Storage)?; + Ok(()) +} +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Document { + format_version: u8, + operations: Vec, +} +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Reference { + pub operation_id: String, + pub binding_id: String, +} +pub(super) struct References { + support: Option, +} +impl References { + pub fn new(support: Option) -> Self { + Self { support } + } + pub fn read(&self) -> Result, Error> { + let Some(dir) = &self.support else { + return Ok(vec![]); + }; + let path = dir.join("bridge-operations.json"); + no_links(&path).map_err(|_| Error::Storage)?; + if !path.try_exists().map_err(|_| Error::Storage)? { + return Ok(vec![]); + } + private_directory(dir, false)?; + let d: Document = serde_json::from_slice(&read_private(&path, 1024 * 1024)?) + .map_err(|_| Error::Storage)?; + if d.format_version != 1 || d.operations.len() > 4096 { + return Err(Error::Storage); + } + let mut ids = BTreeSet::new(); + for r in &d.operations { + if wire::identifier(&r.operation_id).is_err() + || !is_digest(&r.binding_id) + || !ids.insert(&r.operation_id) + { + return Err(Error::Storage); + } + } + Ok(d.operations) + } + pub fn require(&self, id: &str, binding: &str) -> Result<(), Error> { + match self.read()?.iter().find(|r| r.operation_id == id) { + Some(r) if r.binding_id == binding => Ok(()), + Some(_) => Err(Error::ForeignBinding), + None => Err(Error::UnknownOperation), + } + } + pub fn add(&self, id: &str, binding: &str) -> Result<(), Error> { + let dir = self.support.as_ref().ok_or(Error::Storage)?; + let mut operations = self.read()?; + if let Some(r) = operations.iter().find(|r| r.operation_id == id) { + return if r.binding_id == binding { + Ok(()) + } else { + Err(Error::ForeignBinding) + }; + } + if operations.len() >= 4096 { + return Err(Error::ReferenceLimit); + } + operations.push(Reference { + operation_id: id.into(), + binding_id: binding.into(), + }); + atomic( + dir, + &dir.join("bridge-operations.json"), + &serde_json::to_vec(&Document { + format_version: 1, + operations, + }) + .map_err(|_| Error::Storage)?, + ) + } +} diff --git a/desktop/src-tauri/src/ops_bridge/trust/security.rs b/desktop/src-tauri/src/ops_bridge/trust/security.rs new file mode 100644 index 0000000000..d8cb0b6b2e --- /dev/null +++ b/desktop/src-tauri/src/ops_bridge/trust/security.rs @@ -0,0 +1,177 @@ +/* + * 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 super::Error; +use std::path::Path; + +#[cfg(unix)] +pub(super) fn validate(path: &Path) -> Result<(), Error> { + use std::os::unix::fs::MetadataExt; + let m = std::fs::symlink_metadata(path).map_err(|_| Error::Storage)?; + if m.file_type().is_symlink() || m.uid() != unsafe { libc::getuid() } || m.mode() & 0o077 != 0 { + return Err(Error::Storage); + } + Ok(()) +} +#[cfg(unix)] +pub(super) fn restrict(path: &Path, directory: bool) -> Result<(), Error> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + path, + std::fs::Permissions::from_mode(if directory { 0o700 } else { 0o600 }), + ) + .map_err(|_| Error::Storage)?; + validate(path) +} +#[cfg(windows)] +mod windows { + use super::*; + use std::{os::windows::ffi::OsStrExt, ptr}; + use windows_sys::Win32::{ + Foundation::{CloseHandle, LocalFree}, + Security::{Authorization::*, *}, + System::Threading::{GetCurrentProcess, OpenProcessToken}, + }; + struct Local(*mut std::ffi::c_void); + impl Drop for Local { + fn drop(&mut self) { + unsafe { + LocalFree(self.0); + } + } + } + fn token() -> Result, Error> { + unsafe { + let mut handle = ptr::null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut handle) == 0 { + return Err(Error::Storage); + } + let mut size = 0; + GetTokenInformation(handle, TokenUser, ptr::null_mut(), 0, &mut size); + let mut data = vec![0u64; (size as usize).div_ceil(8)]; + let ok = + GetTokenInformation(handle, TokenUser, data.as_mut_ptr().cast(), size, &mut size); + CloseHandle(handle); + if ok == 0 { + return Err(Error::Storage); + } + Ok(data) + } + } + fn wide(path: &Path) -> Vec { + path.as_os_str().encode_wide().chain([0]).collect() + } + pub(crate) fn validate(path: &Path) -> Result<(), Error> { + let data = token()?; + unsafe { + let sid = (*(data.as_ptr().cast::())).User.Sid; + let mut owner = ptr::null_mut(); + let mut acl = ptr::null_mut(); + let mut descriptor = ptr::null_mut(); + if GetNamedSecurityInfoW( + wide(path).as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut owner, + ptr::null_mut(), + &mut acl, + ptr::null_mut(), + &mut descriptor, + ) != 0 + { + return Err(Error::Storage); + } + let _owned = Local(descriptor); + if owner.is_null() || acl.is_null() || EqualSid(owner, sid) == 0 || IsValidAcl(acl) == 0 + { + return Err(Error::Storage); + } + for i in 0..(*acl).AceCount { + let mut ace = ptr::null_mut(); + if GetAce(acl, u32::from(i), &mut ace) == 0 + || (*(ace.cast::())).AceType != 0 + // ACCESS_ALLOWED_ACE_TYPE + { + return Err(Error::Storage); + } + let allowed = ace.cast::(); + let who = ptr::addr_of!((*allowed).SidStart).cast_mut().cast(); + if IsValidSid(who) == 0 + || (EqualSid(who, sid) == 0 + && IsWellKnownSid(who, WinLocalSystemSid) == 0 + && IsWellKnownSid(who, WinBuiltinAdministratorsSid) == 0) + { + return Err(Error::Storage); + } + } + } + Ok(()) + } + pub(crate) fn restrict(path: &Path, directory: bool) -> Result<(), Error> { + let data = token()?; + unsafe { + let sid = (*(data.as_ptr().cast::())).User.Sid; + let mut sid_text = ptr::null_mut(); + if ConvertSidToStringSidW(sid, &mut sid_text) == 0 { + return Err(Error::Storage); + } + let _sid = Local(sid_text.cast()); + let mut len = 0; + while *sid_text.add(len) != 0 { + len += 1; + } + let owner = String::from_utf16(std::slice::from_raw_parts(sid_text, len)) + .map_err(|_| Error::Storage)?; + let flags = if directory { "OICI" } else { "" }; + let sddl = format!("O:{owner}D:P(A;{flags};FA;;;{owner})") + .encode_utf16() + .chain([0]) + .collect::>(); + let mut descriptor = ptr::null_mut(); + if ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + 1, + &mut descriptor, + ptr::null_mut(), + ) == 0 + { + return Err(Error::Storage); + } + let _descriptor = Local(descriptor); + if SetFileSecurityW( + wide(path).as_ptr(), + OWNER_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor, + ) == 0 + { + return Err(Error::Storage); + } + } + validate(path) + } +} +#[cfg(windows)] +pub(super) use windows::{restrict, validate}; +#[cfg(not(any(unix, windows)))] +pub(super) fn validate(_: &Path) -> Result<(), Error> { + Err(Error::UnsupportedPlatform) +} +#[cfg(not(any(unix, windows)))] +pub(super) fn restrict(_: &Path, _: bool) -> Result<(), Error> { + Err(Error::UnsupportedPlatform) +} diff --git a/desktop/src-tauri/src/ops_bridge/types.rs b/desktop/src-tauri/src/ops_bridge/types.rs new file mode 100644 index 0000000000..fe8225437c --- /dev/null +++ b/desktop/src-tauri/src/ops_bridge/types.rs @@ -0,0 +1,620 @@ +/* + * 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 serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use ts_rs::TS; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[serde(rename_all = "snake_case")] +pub enum OpsOperation { + Register, + Start, + Stop, + Restart, + RemoveRegistration, + ConfigApply, + Install, + Repair, +} + +impl OpsOperation { + pub(super) fn wire_name(self) -> &'static str { + match self { + Self::Register => "register", + Self::Start => "start", + Self::Stop => "stop", + Self::Restart => "restart", + Self::RemoveRegistration => "remove_registration", + Self::ConfigApply => "config_apply", + Self::Install => "install", + Self::Repair => "repair", + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[serde(rename_all = "snake_case")] +pub enum OpsOutcome { + Succeeded, + Pending, + Cancelled, + Failed, + PartialFailure, + Uncertain, + Rejected, + Unknown, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[serde(rename_all = "snake_case")] +pub enum OpsBridgeError { + Unavailable, + UnsupportedPlatform, + IntegrityFailure, + InvalidInput, + InvalidResponse, + ProcessFailure, + Timeout, + OutputTooLarge, + Storage, + Busy, + ShuttingDown, + StaleApproval, + UnknownOperation, + Forbidden, + ForeignBinding, + ReferenceLimit, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[serde(rename_all = "snake_case")] +pub enum OpsErrorCode { + AttemptConflict, + AttemptLimit, + AuthorizationRequired, + ConfigurationConflict, + ConfigurationInvalid, + ConfigurationNotLocal, + ConfigurationNotOwned, + ConfigurationPathUnsupported, + DatabaseBackendUnsupported, + DatabaseIncompatible, + DatabaseCompatibilityUnknown, + DatabaseRecoveryRequired, + DatabaseMigrationRequired, + DatabaseConfigurationMismatch, + DatabaseSnapshotTooLarge, + DatabaseSnapshotSpaceInsufficient, + DatabaseAdmissionTimeout, + DatabaseChangedDuringAdmission, + InvalidInputOrBinding, + ResourceArchiveInvalid, + ResourceCatalogInvalid, + ResourcePrepareFailed, + ResourceProtocolUnsupported, + ResourceTimeout, + ResourceUnavailable, + ResourceUnsafePath, + ResourceCancelled, + ResourceCorrupt, + ResourceDownloadFailed, + ResourceInvalid, + ResourcePathUnsupported, + ResourcePlatformUnsupported, + ResourceSourceInvalid, + DatabaseLocationUnsupported, + ExistingIndependent, + InstallationMissing, + InstallationRecordInvalid, + InsufficientSpaceOrPermissions, + InvalidManagedBinding, + InvalidRequest, + InvalidTargetBinding, + LayoutInvalid, + LockingUnsupported, + ManagedInstallationUnavailable, + OperationBusy, + OperationCompleted, + OperationConflict, + OperationMissing, + OperationSuperseded, + OptionUnsupported, + OutcomeUncertain, + OwnershipConflict, + PlanInvalid, + PlanStale, + PortConflict, + ProgressUnavailable, + RecordInvalid, + RecordUnsupported, + RegistrationMissing, + RepairOwnershipConflict, + RepairRequired, + RequestTooLarge, + ResourceIdentityChanged, + ResourceUntrusted, + ResponseTooLarge, + RuntimeDamaged, + TargetConflict, + TargetInvalid, + TargetUnsupported, + UnsupportedAction, + UnsupportedPlatform, + UnsupportedProtocol, + CheckpointFailed, + InspectionFailed, + OperationFailed, + Unknown, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[serde(rename_all = "snake_case")] +pub enum OpsAction { + Inspect, + ConfigValidate, + Preflight, + Apply, + OperationStatus, + Cancel, + Resume, +} + +impl OpsAction { + pub(super) fn wire_name(self) -> &'static str { + match self { + Self::Inspect => "inspect", + Self::ConfigValidate => "config_validate", + Self::Preflight => "preflight", + Self::Apply => "apply", + Self::OperationStatus => "operation_status", + Self::Cancel => "cancel", + Self::Resume => "resume", + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, TS)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct OpsOptions { + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub http_port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub dashboard_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub start_on_login: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub restart: Option, +} + +impl OpsOptions { + pub(super) fn validate(&self, operation: OpsOperation) -> Result<(), OpsBridgeError> { + let allowed = match operation { + OpsOperation::Register => [false, false, true, false], + OpsOperation::ConfigApply => [true, true, true, true], + OpsOperation::Install => [true, false, true, false], + _ => [false, false, false, false], + }; + if (!allowed[0] && self.http_port.is_some()) + || (!allowed[1] && self.dashboard_enabled.is_some()) + || (!allowed[2] && self.start_on_login.is_some()) + || (!allowed[3] && self.restart.is_some()) + || self.http_port == Some(0) + { + return Err(OpsBridgeError::InvalidInput); + } + Ok(()) + } + + pub(super) fn wire_value(&self) -> Value { + let mut value = Map::new(); + for (key, item) in [ + ("http_port", self.http_port.map(Value::from)), + ("dashboard_enabled", self.dashboard_enabled.map(Value::from)), + ("start_on_login", self.start_on_login.map(Value::from)), + ("restart", self.restart.map(Value::from)), + ] { + if let Some(item) = item { + value.insert(key.into(), item); + } + } + Value::Object(value) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct OpsResumeReference { + pub operation_id: String, + pub expected_attempt: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct OpsPreflightInput { + pub operation: OpsOperation, + #[serde(default)] + pub options: OpsOptions, + #[serde(default)] + #[ts(optional)] + pub resume: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct OpsApprovalInput { + pub approval_id: String, + pub plan_id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct OpsStatusInput { + pub operation_id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct OpsCancelInput { + pub operation_id: String, + pub attempt: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct OpsOperationReference { + pub operation_id: String, + pub binding_status: OpsReferenceBindingStatus, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[serde(rename_all = "snake_case")] +pub enum OpsReferenceBindingStatus { + Current, + Different, + Unavailable, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsReferences { + pub binding_available: bool, + pub operations: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsInspectionReply { + pub outcome: OpsOutcome, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub components: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub bridge_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub exit_code: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsValidationReply { + pub outcome: OpsOutcome, + pub configuration: OpsConfigurationState, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub bridge_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub exit_code: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[serde(rename_all = "snake_case")] +pub enum OpsConfigurationState { + Valid, + MissingOrInvalid, + Unknown, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsPlanApproval { + pub approval_id: String, + pub plan_id: String, + pub operation: OpsOperation, + pub options: OpsOptions, + pub target: OpsTargetSummary, + pub effects: OpsPlanEffects, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub installation: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsPreflightReply { + pub outcome: OpsOutcome, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub approval: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub bridge_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub exit_code: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsTargetSummary { + pub target_id: String, + pub python_executable: String, + pub package_version: String, + pub env_file: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub endpoint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub data_dir: Option, + pub start_on_login: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsPlanEffects { + pub may_start: bool, + pub preserves_data: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsInstallationSummary { + pub classification: OpsInstallationClassification, + pub resources: OpsInstallationResources, + pub database: OpsDatabaseClassification, + pub space_required: u64, + pub verify_running: bool, + pub preserve_stopped: bool, + pub registration_present: bool, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[serde(rename_all = "snake_case")] +pub enum OpsInstallationClassification { + Missing, + OwnedCurrent, + OwnedRepairable, + PendingOperation, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[serde(rename_all = "snake_case")] +pub enum OpsInstallationResources { + Missing, + Damaged, + Verified, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[serde(rename_all = "snake_case")] +pub enum OpsDatabaseClassification { + New, + ExistingUnknown, + ExistingReady, + Incompatible, + Unknown, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsComponents { + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub support: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub registration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub definition: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub manager_ownership: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub manager: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub liveness: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub readiness: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub endpoint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub maintenance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub resources: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub configuration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub installation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub resource_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub resource_digest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub database: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub connection_compatibility: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub installation_stage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub installation_observation: Option, +} + +macro_rules! status_enum { + ($name:ident { $($variant:ident => $wire:literal),+ $(,)? }) => { + #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] + #[serde(rename_all = "snake_case")] + pub enum $name { $($variant),+ } + + }; +} + +status_enum!(OpsMaintenancePhase { ManualStopped => "manual_stopped", StartFailed => "start_failed", ConfigurationSwitching => "configuration_switching", ManualSwitching => "manual_switching", RuntimeRepair => "runtime_repair", Stopping => "stopping", Stopped => "stopped", Switching => "switching", DatabaseReady => "database_ready" }); +status_enum!(OpsRuntimeResources { Present => "present", MissingExecutable => "missing_executable" }); +status_enum!(OpsConnectionCompatibility { NotChecked => "not_checked" }); +status_enum!(OpsSupportState { Supported => "supported", Unsupported => "unsupported", Unknown => "unknown" }); +status_enum!(OpsRegistrationState { Installed => "installed", NotInstalled => "not_installed", Invalid => "invalid", Unknown => "unknown" }); +status_enum!(OpsDefinitionState { Current => "current", Stale => "stale", MissingExecutable => "missing_executable", Unknown => "unknown" }); +status_enum!(OpsManagerOwnership { NotLoaded => "not_loaded", Owned => "owned", Foreign => "foreign", Unknown => "unknown" }); +status_enum!(OpsManagerState { Active => "active", Inactive => "inactive", Failed => "failed", Unknown => "unknown" }); +status_enum!(OpsLivenessState { Live => "live", Unreachable => "unreachable", Unknown => "unknown" }); +status_enum!(OpsReadinessState { Failed => "failed", NotProbed => "not_probed", VerifiedDuringOperation => "verified_during_operation", Unknown => "unknown" }); +status_enum!(OpsServicePhase { Accepted => "accepted", RegistrationDispatching => "registration_dispatching", Service => "service", Preparing => "preparing", Prepared => "prepared", Publishing => "publishing", ResourcesPublished => "resources_published", ConfigurationPrepared => "configuration_prepared", Configured => "configured", Registering => "registering", Complete => "complete" }); +status_enum!(OpsAttemptPhase { Accepted => "accepted", Executing => "executing", Finished => "finished" }); +status_enum!(OpsExecutionState { Finished => "finished", Interrupted => "interrupted", Unknown => "unknown" }); +status_enum!(OpsExecutorObservation { Exited => "exited", NotConfirmedExited => "not_confirmed_exited", Unknown => "unknown" }); +status_enum!(OpsObservationState { Unknown => "unknown" }); +status_enum!(OpsRecoveryAction { Inspect => "inspect", Preflight => "preflight", OperationStatus => "operation_status" }); + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsProgress { + pub request_id: String, + pub operation_id: String, + pub attempt: u32, + pub sequence: Option, + pub phase: Option, + pub accepted: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsOperationReply { + pub operation_id: String, + pub outcome: OpsOutcome, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub attempt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub bridge_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub status: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsOperationStatus { + pub operation_id: String, + pub attempt: u32, + pub phase: OpsAttemptPhase, + pub executor: OpsExecutor, + pub execution_state: OpsExecutionState, + pub outcome: OpsOutcome, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub cancel_requested: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub result: Option, + pub observation: OpsObservation, + pub history: Vec, + pub recovery_actions: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsExecutor { + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub pid: Option, + pub observation: OpsExecutorObservation, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsObservation { + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub components: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsAttemptSummary { + pub attempt: u32, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub outcome: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct OpsStoredResult { + pub outcome: OpsOutcome, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub components: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub recovery_actions: Vec, +} diff --git a/desktop/src-tauri/src/ops_bridge/wire.rs b/desktop/src-tauri/src/ops_bridge/wire.rs new file mode 100644 index 0000000000..78bb833398 --- /dev/null +++ b/desktop/src-tauri/src/ops_bridge/wire.rs @@ -0,0 +1,892 @@ +/* + * 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. + */ + +//! Strict Ops v1 parsing and allowlisted renderer projections. +use super::{ + trust::{is_digest, sha256}, + *, +}; +use serde::{ + Deserialize, + de::{self, DeserializeOwned, MapAccess, SeqAccess, Visitor}, +}; +use serde_json::{Map, Value}; +type Result = std::result::Result; +const INVALID: OpsBridgeError = OpsBridgeError::InvalidResponse; + +pub(super) fn identifier(s: &str) -> Result<()> { + if s.is_empty() + || s.len() > 96 + || !s.as_bytes()[0].is_ascii_alphanumeric() + || !s + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + Err(INVALID) + } else { + Ok(()) + } +} +fn object<'a>( + v: &'a Value, + required: &[&str], + optional: &[&str], +) -> Result<&'a Map> { + let o = v.as_object().ok_or(INVALID)?; + if required.iter().any(|k| !o.contains_key(*k)) + || o.keys() + .any(|k| !required.contains(&k.as_str()) && !optional.contains(&k.as_str())) + { + return Err(INVALID); + } + Ok(o) +} +fn parse(v: &Value) -> Result { + serde_json::from_value(v.clone()).map_err(|_| INVALID) +} +fn optional(v: &Value, f: impl FnOnce(&Value) -> Result) -> Result> { + if v.is_null() { + Ok(None) + } else { + f(v).map(Some) + } +} +fn text(v: &Value, max: usize) -> Result { + let s = v.as_str().ok_or(INVALID)?; + if s.is_empty() || s.len() > max || s.chars().any(char::is_control) { + Err(INVALID) + } else { + Ok(s.into()) + } +} +fn id(v: &Value) -> Result { + let s = text(v, 96)?; + identifier(&s)?; + Ok(s) +} +fn hash(v: &Value) -> Result { + let s = text(v, 64)?; + if !is_digest(&s) { + return Err(INVALID); + } + Ok(s) +} +fn boolean(v: &Value) -> Result { + v.as_bool().ok_or(INVALID) +} +fn attempt(v: &Value) -> Result { + let n = v.as_u64().filter(|v| (1..=64).contains(v)).ok_or(INVALID)?; + Ok(n as u32) +} +fn endpoint(v: &Value) -> Result { + let s = text(v, 2048)?; + let u = url::Url::parse(&s).map_err(|_| INVALID)?; + if u.scheme() != "http" + || !u.username().is_empty() + || u.password().is_some() + || u.query().is_some() + || u.fragment().is_some() + || !matches!(u.host_str(), Some("127.0.0.1" | "localhost" | "[::1]")) + { + return Err(INVALID); + } + Ok(s) +} +pub(super) fn error(v: &Value) -> Result { + object(v, &["code"], &[])?; + parse(&v["code"]) +} +fn recovery(v: &Value) -> Result> { + if v.is_null() { + return Ok(vec![]); + } + let list = v.as_array().filter(|a| a.len() <= 3).ok_or(INVALID)?; + list.iter().map(parse).collect() +} +fn database(v: &Value) -> Result { + object( + v, + &[ + "classification", + "allowed_to_start", + "fingerprint", + "reason", + ], + &[], + )?; + boolean(&v["allowed_to_start"])?; + hash(&v["fingerprint"])?; + identifier(v["reason"].as_str().ok_or(INVALID)?)?; + parse(&v["classification"]) +} +fn components(v: &Value) -> Result { + object( + v, + &[ + "support", + "registration", + "definition", + "manager_ownership", + "manager", + "liveness", + "readiness", + "endpoint", + "maintenance", + "resources", + "configuration", + ], + &[ + "installation_id", + "resource_id", + "resource_digest", + "database", + "connection_compatibility", + "installation_stage", + "installation_observation", + ], + )?; + Ok(OpsComponents { + support: Some(parse(&v["support"])?), + registration: Some(parse(&v["registration"])?), + definition: Some(parse(&v["definition"])?), + manager_ownership: Some(parse(&v["manager_ownership"])?), + manager: Some(parse(&v["manager"])?), + liveness: Some(parse(&v["liveness"])?), + readiness: Some(parse(&v["readiness"])?), + endpoint: optional(&v["endpoint"], endpoint)?, + maintenance: optional(&v["maintenance"], parse)?, + resources: Some(parse(&v["resources"])?), + configuration: Some(parse(&v["configuration"])?), + installation_id: optional(&v["installation_id"], id)?, + resource_id: optional(&v["resource_id"], id)?, + resource_digest: optional(&v["resource_digest"], hash)?, + database: optional(&v["database"], database)?, + connection_compatibility: optional(&v["connection_compatibility"], parse)?, + installation_stage: optional(&v["installation_stage"], parse)?, + installation_observation: optional(&v["installation_observation"], parse)?, + }) +} +const ENVELOPE: &[&str] = &["protocol_version", "request_id", "type", "outcome"]; +fn rejected(v: &Value) -> Result<()> { + object(v, ENVELOPE, &["error", "recovery_actions"])?; + if v["outcome"] != "rejected" { + return Err(INVALID); + } + error(&v["error"])?; + recovery(&v["recovery_actions"])?; + Ok(()) +} +pub(super) fn inspection(v: &Value, code: i32) -> Result { + let (outcome, components, error) = if v["outcome"] == "rejected" { + rejected(v)?; + (OpsOutcome::Rejected, None, Some(error(&v["error"])?)) + } else { + object(v, ENVELOPE, &["components"])?; + if v["outcome"] != "succeeded" { + return Err(INVALID); + } + ( + OpsOutcome::Succeeded, + Some(components(&v["components"])?), + None, + ) + }; + Ok(OpsInspectionReply { + outcome, + components, + error, + bridge_error: None, + exit_code: Some(code), + }) +} +pub(super) fn validation(v: &Value, code: i32) -> Result { + let (outcome, configuration, error) = if v["outcome"] == "rejected" { + rejected(v)?; + ( + OpsOutcome::Rejected, + OpsConfigurationState::Unknown, + Some(error(&v["error"])?), + ) + } else { + object(v, ENVELOPE, &["components"])?; + object(&v["components"], &["configuration"], &[])?; + if v["outcome"] != "succeeded" || v["components"]["configuration"] != "valid" { + return Err(INVALID); + } + (OpsOutcome::Succeeded, OpsConfigurationState::Valid, None) + }; + Ok(OpsValidationReply { + outcome, + configuration, + error, + bridge_error: None, + exit_code: Some(code), + }) +} +pub(super) fn plan( + v: &Value, + operation: OpsOperation, + options: &OpsOptions, +) -> Result { + let install = matches!(operation, OpsOperation::Install | OpsOperation::Repair); + object( + v, + &[ + "protocol_version", + "target_id", + "binding_id", + "operation", + "options", + "before", + "effects", + "target_summary", + "plan_id", + ], + if install { &["installation"] } else { &[] }, + )?; + if v["protocol_version"] != 1 + || v["operation"] != operation.wire_name() + || v["options"] != options.wire_value() + { + return Err(INVALID); + } + let plan_id = hash(&v["plan_id"])?; + hash(&v["binding_id"])?; + hash(&v["before"])?; + let mut unsigned = v.clone(); + unsigned.as_object_mut().ok_or(INVALID)?.remove("plan_id"); + if sha256(&serde_json::to_vec(&unsigned).map_err(|_| INVALID)?) != plan_id { + return Err(INVALID); + } + object(&v["effects"], &["may_start", "preserves_data"], &[])?; + if !boolean(&v["effects"]["preserves_data"])? { + return Err(INVALID); + } + let t = &v["target_summary"]; + object( + t, + &[ + "python_executable", + "package_version", + "env_file", + "endpoint", + "data_dir", + "start_on_login", + ], + &[], + )?; + let target = OpsTargetSummary { + target_id: id(&v["target_id"])?, + python_executable: text(&t["python_executable"], 4096)?, + package_version: text(&t["package_version"], 128)?, + env_file: text(&t["env_file"], 4096)?, + endpoint: optional(&t["endpoint"], endpoint)?, + data_dir: optional(&t["data_dir"], |v| text(v, 4096))?, + start_on_login: boolean(&t["start_on_login"])?, + }; + let installation = if install { + let i = &v["installation"]; + object( + i, + &[ + "catalog_sha256", + "resource", + "layout", + "installation_id", + "classification", + "resources", + "resource_identity", + "configuration", + "database", + "service_before", + "space_required", + "verify_running", + "preserve_stopped", + "repair_owner", + "registration_present", + ], + &["registration_removal"], + )?; + hash(&i["catalog_sha256"])?; + hash(&i["service_before"])?; + for field in ["installation_id", "repair_owner", "registration_removal"] { + optional(&i[field], id)?; + } + for field in ["resource", "layout", "configuration"] { + if !i[field].is_object() { + return Err(INVALID); + } + } + if !i["resource_identity"].is_null() && !i["resource_identity"].is_object() { + return Err(INVALID); + } + Some(OpsInstallationSummary { + classification: parse(&i["classification"])?, + resources: parse(&i["resources"])?, + database: database(&i["database"])?, + space_required: i["space_required"] + .as_u64() + .filter(|n| *n <= 64 * 1024 * 1024 * 1024) + .ok_or(INVALID)?, + verify_running: boolean(&i["verify_running"])?, + preserve_stopped: boolean(&i["preserve_stopped"])?, + registration_present: boolean(&i["registration_present"])?, + }) + } else { + None + }; + Ok(OpsPlanApproval { + approval_id: String::new(), + plan_id, + operation, + options: options.clone(), + target, + effects: OpsPlanEffects { + may_start: boolean(&v["effects"]["may_start"])?, + preserves_data: true, + }, + installation, + }) +} +fn stored(v: &Value) -> Result { + object( + v, + &["outcome"], + &["components", "error", "recovery_actions"], + )?; + let outcome = parse(&v["outcome"])?; + if !matches!( + outcome, + OpsOutcome::Succeeded + | OpsOutcome::Cancelled + | OpsOutcome::Failed + | OpsOutcome::PartialFailure + | OpsOutcome::Uncertain + ) { + return Err(INVALID); + } + Ok(OpsStoredResult { + outcome, + components: optional(&v["components"], components)?, + error: optional(&v["error"], error)?, + recovery_actions: recovery(&v["recovery_actions"])?, + }) +} +pub(super) fn operation(v: &Value, id: &str, code: i32) -> Result { + if v["outcome"] == "rejected" { + rejected(v)?; + return Ok(OpsOperationReply { + operation_id: id.into(), + outcome: OpsOutcome::Rejected, + attempt: None, + exit_code: Some(code), + error: Some(error(&v["error"])?), + bridge_error: None, + status: None, + }); + } + if v["operation_id"] != id { + return Err(INVALID); + } + let number = attempt(&v["attempt"])?; + let outcome: OpsOutcome = parse(&v["outcome"])?; + let status = if v.get("phase").is_some() { + let status_fields = [ + ENVELOPE, + &[ + "operation_id", + "attempt", + "phase", + "executor", + "execution_state", + "cancel_requested", + "result", + "observation", + "history", + "recovery_actions", + ], + ] + .concat(); + object(v, &status_fields, &[])?; + object(&v["executor"], &["pid", "observation"], &[])?; + let executor = OpsExecutor { + pid: optional(&v["executor"]["pid"], |v| { + v.as_u64() + .filter(|n| *n > 0 && *n <= u32::MAX as u64) + .ok_or(INVALID) + })?, + observation: parse(&v["executor"]["observation"])?, + }; + let result = optional(&v["result"], stored)?; + if result + .as_ref() + .map_or(outcome != OpsOutcome::Pending, |r| r.outcome != outcome) + { + return Err(INVALID); + } + let execution_state: OpsExecutionState = parse(&v["execution_state"])?; + let expected_execution = if result.is_some() { + OpsExecutionState::Finished + } else if executor.observation == OpsExecutorObservation::Exited { + OpsExecutionState::Interrupted + } else { + OpsExecutionState::Unknown + }; + if execution_state != expected_execution { + return Err(INVALID); + } + let observation = if v["observation"].get("state").is_some() { + object(&v["observation"], &["state", "error"], &[])?; + OpsObservation { + state: Some(parse(&v["observation"]["state"])?), + components: None, + error: Some(error(&v["observation"]["error"])?), + } + } else { + OpsObservation { + state: None, + components: Some(components(&v["observation"])?), + error: None, + } + }; + let history = v["history"] + .as_array() + .filter(|a| !a.is_empty() && a.len() <= 64 && number as usize <= a.len()) + .ok_or(INVALID)? + .iter() + .enumerate() + .map(|(index, item)| { + object(item, &["attempt", "outcome"], &[])?; + if attempt(&item["attempt"])? as usize != index + 1 { + return Err(INVALID); + } + let historical = optional(&item["outcome"], parse)?; + if historical.is_some_and(|o| { + !matches!( + o, + OpsOutcome::Succeeded + | OpsOutcome::Cancelled + | OpsOutcome::Failed + | OpsOutcome::PartialFailure + | OpsOutcome::Uncertain + ) + }) { + return Err(INVALID); + } + Ok(OpsAttemptSummary { + attempt: (index + 1) as u32, + outcome: historical, + }) + }) + .collect::>>()?; + if history[number as usize - 1].outcome != result.as_ref().map(|r| r.outcome) { + return Err(INVALID); + } + Some(OpsOperationStatus { + operation_id: id.into(), + attempt: number, + phase: parse(&v["phase"])?, + executor, + execution_state, + outcome, + cancel_requested: optional(&v["cancel_requested"], boolean)?, + result, + observation, + history, + recovery_actions: recovery(&v["recovery_actions"])?, + }) + } else { + object( + v, + ENVELOPE, + &["operation_id", "attempt", "error", "recovery_actions"], + )?; + if !matches!(outcome, OpsOutcome::Failed | OpsOutcome::Uncertain) + || error(&v["error"])? != OpsErrorCode::CheckpointFailed + { + return Err(INVALID); + } + recovery(&v["recovery_actions"])?; + None + }; + Ok(OpsOperationReply { + operation_id: id.into(), + outcome, + attempt: Some(number), + exit_code: Some(code), + error: optional(&v["error"], error)?, + bridge_error: None, + status, + }) +} + +pub(super) struct Stream { + request: Value, + expected_attempt: Option, + accepted: Option, + sequence: u32, + result: Option, + messages: usize, +} +impl Stream { + pub fn new(request: &Value) -> Result { + let expected_attempt = match request["action"].as_str() { + // Native apply always allocates a fresh operation ID; it never replays apply. + Some("apply") => Some(1), + Some("resume") => Some( + attempt(&request["expected_attempt"])? + .checked_add(1) + .filter(|n| *n <= 64) + .ok_or(INVALID)?, + ), + Some("cancel") => Some(attempt(&request["attempt"])?), + _ => None, + }; + Ok(Self { + request: request.clone(), + expected_attempt, + accepted: None, + sequence: 0, + result: None, + messages: 0, + }) + } + pub fn line(&mut self, bytes: &[u8]) -> Result> { + self.messages += 1; + if self.messages > 128 || self.result.is_some() || bytes.is_empty() { + return Err(INVALID); + } + let v: Unique = serde_json::from_slice(bytes).map_err(|_| INVALID)?; + let v = v.0; + if v["protocol_version"].as_u64() != Some(1) + || v["request_id"] != self.request["request_id"] + { + return Err(INVALID); + } + match v["type"].as_str() { + Some("accepted" | "progress") => { + if !matches!(self.request["action"].as_str(), Some("apply" | "resume")) + || v["operation_id"] != self.request["operation_id"] + { + return Err(INVALID); + } + let accepted = v["type"] == "accepted"; + object( + &v, + &[ + "protocol_version", + "request_id", + "type", + "operation_id", + "attempt", + ], + if accepted { + &[] + } else { + &["sequence", "phase"] + }, + )?; + let number = attempt(&v["attempt"])?; + if Some(number) != self.expected_attempt { + return Err(INVALID); + } + let (sequence, phase) = if accepted { + if self.accepted.replace(number).is_some() { + return Err(INVALID); + } + (None, None) + } else { + if self.accepted != Some(number) { + return Err(INVALID); + } + let sequence = v["sequence"] + .as_u64() + .filter(|n| *n > u64::from(self.sequence) && *n <= 8) + .ok_or(INVALID)? as u32; + let phase: OpsServicePhase = parse(&v["phase"])?; + let expected = match phase { + OpsServicePhase::Service | OpsServicePhase::Preparing => 1, + OpsServicePhase::Prepared => 2, + OpsServicePhase::Publishing => 3, + OpsServicePhase::ResourcesPublished => 4, + OpsServicePhase::ConfigurationPrepared => 5, + OpsServicePhase::Configured => 6, + OpsServicePhase::Registering => 7, + OpsServicePhase::Complete => 8, + _ => return Err(INVALID), + }; + if sequence != expected { + return Err(INVALID); + } + self.sequence = sequence; + (Some(sequence), Some(phase)) + }; + Ok(Some(OpsProgress { + request_id: id(&v["request_id"])?, + operation_id: id(&v["operation_id"])?, + attempt: number, + sequence, + phase, + accepted, + })) + } + Some("result") => { + match self.request["action"].as_str() { + Some("inspect") => { + inspection(&v, 0)?; + } + Some("config_validate") => { + validation(&v, 0)?; + } + Some("preflight") => { + if v["outcome"] == "rejected" { + rejected(&v)?; + } else { + object(&v, ENVELOPE, &["plan"])?; + if v["outcome"] != "succeeded" { + return Err(INVALID); + } + let op = parse(&self.request["operation"])?; + let options = wire_options(&self.request["options"])?; + plan(&v["plan"], op, &options)?; + } + } + Some("apply" | "resume" | "operation_status" | "cancel") => { + let result = operation( + &v, + self.request["operation_id"].as_str().ok_or(INVALID)?, + 0, + )?; + // Duplicate resume and checkpoint failures can omit accepted. + // Their final result still belongs to the requested attempt. + if result + .attempt + .zip(self.expected_attempt) + .is_some_and(|(number, expected)| number != expected) + { + return Err(INVALID); + } + } + _ => return Err(INVALID), + } + self.result = Some(v); + Ok(None) + } + _ => Err(INVALID), + } + } + pub fn finish(self) -> Result { + self.result.ok_or(INVALID) + } +} +fn wire_options(v: &Value) -> Result { + object( + v, + &[], + &[ + "http_port", + "dashboard_enabled", + "start_on_login", + "restart", + ], + )?; + Ok(OpsOptions { + http_port: optional(&v["http_port"], parse)?, + dashboard_enabled: optional(&v["dashboard_enabled"], boolean)?, + start_on_login: optional(&v["start_on_login"], boolean)?, + restart: optional(&v["restart"], boolean)?, + }) +} +// serde_json::Value normally accepts duplicate object keys. Reject them at every +// nesting level before validating the protocol or retaining a plan. +struct Unique(Value); +impl<'de> Deserialize<'de> for Unique { + fn deserialize>(d: D) -> std::result::Result { + struct V; + impl<'de> Visitor<'de> for V { + type Value = Unique; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("unique-key JSON") + } + fn visit_bool(self, v: bool) -> std::result::Result { + Ok(Unique(v.into())) + } + fn visit_i64(self, v: i64) -> std::result::Result { + Ok(Unique(v.into())) + } + fn visit_u64(self, v: u64) -> std::result::Result { + Ok(Unique(v.into())) + } + fn visit_f64(self, _: f64) -> std::result::Result { + Err(E::custom("non-integer protocol number")) + } + fn visit_str(self, v: &str) -> std::result::Result { + Ok(Unique(v.into())) + } + fn visit_none(self) -> std::result::Result { + Ok(Unique(Value::Null)) + } + fn visit_unit(self) -> std::result::Result { + Ok(Unique(Value::Null)) + } + fn visit_seq>( + self, + mut a: A, + ) -> std::result::Result { + let mut v = Vec::new(); + while let Some(Unique(item)) = a.next_element()? { + v.push(item); + } + Ok(Unique(v.into())) + } + fn visit_map>( + self, + mut a: A, + ) -> std::result::Result { + let mut v = Map::new(); + while let Some((k, Unique(item))) = a.next_entry::()? { + if v.insert(k, item).is_some() { + return Err(de::Error::custom("duplicate key")); + } + } + Ok(Unique(Value::Object(v))) + } + } + d.deserialize_any(V) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn status(number: u32) -> Value { + json!({ + "protocol_version": 1, "request_id": "request", "type": "result", + "operation_id": "operation", "attempt": number, "phase": "finished", + "executor": {"pid": null, "observation": "exited"}, + "execution_state": "finished", "outcome": "succeeded", + "cancel_requested": false, "result": {"outcome": "succeeded"}, + "observation": { + "support": "supported", "registration": "installed", "definition": "current", + "manager_ownership": "owned", "manager": "active", "liveness": "live", + "readiness": "not_probed", "endpoint": null, "maintenance": null, + "resources": "present", "configuration": "valid" + }, + "history": (1..=number).map(|attempt| json!({ + "attempt": attempt, "outcome": if attempt == number { "succeeded" } else { "failed" } + })).collect::>(), + "recovery_actions": [] + }) + } + + fn parsed(request: &Value, response: &Value) -> Result { + let mut stream = Stream::new(request)?; + stream.line(&serde_json::to_vec(response).unwrap())?; + stream.finish() + } + + #[test] + fn final_results_bind_attempt_without_accepted_and_status_can_read_latest() { + for (action, expected) in [("apply", 1), ("resume", 3), ("cancel", 2)] { + let mut request = json!({ + "protocol_version": 1, "request_id": "request", "action": action, + "operation_id": "operation" + }); + if action == "resume" { + request["expected_attempt"] = (expected - 1).into(); + } else if action == "cancel" { + request["attempt"] = expected.into(); + } + // A duplicate resume or completed cancel returns status without progress. + assert_eq!( + parsed(&request, &status(expected)).unwrap(), + status(expected) + ); + let wrong = if expected == 1 { 2 } else { 1 }; + assert_eq!(parsed(&request, &status(wrong)), Err(INVALID)); + + let rejected = json!({ + "protocol_version": 1, "request_id": "request", "type": "result", + "outcome": "rejected", "error": {"code": "attempt_conflict"} + }); + assert_eq!(parsed(&request, &rejected).unwrap(), rejected); + + if action != "cancel" { + let mut checkpoint = json!({ + "protocol_version": 1, "request_id": "request", "type": "result", + "operation_id": "operation", "attempt": expected, + "outcome": "uncertain", "error": {"code": "checkpoint_failed"}, + "recovery_actions": ["operation_status"] + }); + assert_eq!(parsed(&request, &checkpoint).unwrap(), checkpoint); + checkpoint["attempt"] = wrong.into(); + assert_eq!(parsed(&request, &checkpoint), Err(INVALID)); + } + } + let request = json!({ + "protocol_version": 1, "request_id": "request", "action": "operation_status", + "operation_id": "operation" + }); + assert_eq!(parsed(&request, &status(4)).unwrap(), status(4)); + } + + #[test] + fn inspection_and_operation_status_preserve_controller_maintenance_phases() { + for (phase, expected) in [ + ("stopping", OpsMaintenancePhase::Stopping), + ("stopped", OpsMaintenancePhase::Stopped), + ("switching", OpsMaintenancePhase::Switching), + ("database_ready", OpsMaintenancePhase::DatabaseReady), + ] { + let mut response = status(1); + response["observation"]["maintenance"] = phase.into(); + let request = json!({ + "protocol_version": 1, "request_id": "request", "action": "operation_status", + "operation_id": "operation" + }); + let value = parsed(&request, &response).unwrap(); + let reply = operation(&value, "operation", 0).unwrap(); + assert_eq!( + reply + .status + .unwrap() + .observation + .components + .unwrap() + .maintenance, + Some(expected) + ); + let inspection_response = json!({ + "protocol_version": 1, "request_id": "request", "type": "result", + "outcome": "succeeded", "components": response["observation"] + }); + let inspection_request = json!({ + "protocol_version": 1, "request_id": "request", "action": "inspect" + }); + let value = parsed(&inspection_request, &inspection_response).unwrap(); + assert_eq!( + inspection(&value, 0) + .unwrap() + .components + .unwrap() + .maintenance, + Some(expected) + ); + } + } +} diff --git a/desktop/src-tauri/tauri.ops.conf.json b/desktop/src-tauri/tauri.ops.conf.json new file mode 100644 index 0000000000..b2087e71d6 --- /dev/null +++ b/desktop/src-tauri/tauri.ops.conf.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "resources": { + "../.artifacts/ops-resource/ops-provision.json": "ops-provision.json", + "../.artifacts/ops-resource/server/catalog.json": "server/catalog.json" + } + } +} diff --git a/desktop/src-tauri/tests/ipc.rs b/desktop/src-tauri/tests/ipc.rs index 85a56aa72e..f3c4507592 100644 --- a/desktop/src-tauri/tests/ipc.rs +++ b/desktop/src-tauri/tests/ipc.rs @@ -275,3 +275,108 @@ fn session_commands_work_without_a_connection_and_reject_untrusted_callers() { } } } + +#[test] +fn ops_commands_require_the_packaged_main_window_and_reject_arbitrary_input() { + use powercontext_desktop::{commands, ops_bridge::OpsHost}; + use serde_json::json; + use tauri::Manager; + let app = mock_builder() + .manage(OpsHost::new(None, None, None)) + .invoke_handler(tauri::generate_handler![ + commands::ops_inspect, + commands::ops_config_validate, + commands::ops_preflight, + commands::ops_apply, + commands::ops_resume, + commands::ops_operation_status, + commands::ops_cancel, + commands::ops_references + ]) + .build(test_context()) + .unwrap(); + let invoke = |label: &str, origin: &str, command: &str, body| { + let window = app.get_webview_window(label).unwrap_or_else(|| { + tauri::WebviewWindowBuilder::new(&app, label, Default::default()) + .build() + .unwrap() + }); + get_ipc_response( + &window, + InvokeRequest { + cmd: command.into(), + callback: CallbackFn(0), + error: CallbackFn(1), + url: origin.parse().unwrap(), + body: InvokeBody::Json(body), + headers: Default::default(), + invoke_key: INVOKE_KEY.into(), + }, + ) + }; + let missing_host = if cfg!(any(windows, target_os = "macos")) { + "unavailable" + } else { + "unsupported_platform" + }; + for (command, body, expected_error) in [ + ("ops_inspect", json!({}), None), + ("ops_config_validate", json!({}), None), + ("ops_references", json!({}), None), + ( + "ops_preflight", + json!({"input":{"operation":"start","options":{"restart":true}}}), + Some("invalid_input"), + ), + ( + "ops_apply", + json!({"input":{"approvalId":"invalid","planId":"a".repeat(64)}}), + Some("invalid_input"), + ), + ( + "ops_resume", + json!({"input":{"approvalId":"invalid","planId":"a".repeat(64)}}), + Some("invalid_input"), + ), + ( + "ops_operation_status", + json!({"input":{"operationId":"operation"}}), + Some(missing_host), + ), + ( + "ops_cancel", + json!({"input":{"operationId":"operation","attempt":0}}), + Some("invalid_input"), + ), + ] { + let permitted = invoke("main", LOCAL_ORIGIN, command, body.clone()); + if let Some(expected) = expected_error { + assert_eq!(permitted.unwrap_err(), json!(expected), "{command}"); + } else { + assert!(permitted.is_ok(), "{command}: {permitted:?}"); + } + for (label, origin) in [("other", LOCAL_ORIGIN), ("main", "https://evil.example")] { + let error = invoke(label, origin, command, body.clone()).unwrap_err(); + assert_ne!(error, json!(expected_error), "{command} {label} {origin}"); + } + } + for (input, expected) in [ + ( + json!({"operation":"stop","options":{},"executable":"/bin/sh"}), + "unknown field `executable`", + ), + ( + json!({"operation":"shell","options":{}}), + "unknown variant `shell`", + ), + ] { + let error = invoke( + "main", + LOCAL_ORIGIN, + "ops_preflight", + json!({"input":input}), + ) + .unwrap_err(); + assert!(error.as_str().unwrap().contains(expected), "{error}"); + } +} diff --git a/desktop/ui/src/generated/ipc.ts b/desktop/ui/src/generated/ipc.ts index f8e172fc9b..be6f239d43 100644 --- a/desktop/ui/src/generated/ipc.ts +++ b/desktop/ui/src/generated/ipc.ts @@ -20,6 +20,55 @@ export type FoundationInfo = { version: string, phase: string, credentialBackend export type StorageChoice = "persistent" | "session_only"; export type CredentialWriteRequest = { secret: string, storage: StorageChoice, }; export type CredentialWriteReceipt = { storage: StorageChoice, }; +export type OpsOperation = "register" | "start" | "stop" | "restart" | "remove_registration" | "config_apply" | "install" | "repair"; +export type OpsOutcome = "succeeded" | "pending" | "cancelled" | "failed" | "partial_failure" | "uncertain" | "rejected" | "unknown"; +export type OpsBridgeError = "unavailable" | "unsupported_platform" | "integrity_failure" | "invalid_input" | "invalid_response" | "process_failure" | "timeout" | "output_too_large" | "storage" | "busy" | "shutting_down" | "stale_approval" | "unknown_operation" | "forbidden" | "foreign_binding" | "reference_limit"; +export type OpsErrorCode = "attempt_conflict" | "attempt_limit" | "authorization_required" | "configuration_conflict" | "configuration_invalid" | "configuration_not_local" | "configuration_not_owned" | "configuration_path_unsupported" | "database_backend_unsupported" | "database_incompatible" | "database_compatibility_unknown" | "database_recovery_required" | "database_migration_required" | "database_configuration_mismatch" | "database_snapshot_too_large" | "database_snapshot_space_insufficient" | "database_admission_timeout" | "database_changed_during_admission" | "invalid_input_or_binding" | "resource_archive_invalid" | "resource_catalog_invalid" | "resource_prepare_failed" | "resource_protocol_unsupported" | "resource_timeout" | "resource_unavailable" | "resource_unsafe_path" | "resource_cancelled" | "resource_corrupt" | "resource_download_failed" | "resource_invalid" | "resource_path_unsupported" | "resource_platform_unsupported" | "resource_source_invalid" | "database_location_unsupported" | "existing_independent" | "installation_missing" | "installation_record_invalid" | "insufficient_space_or_permissions" | "invalid_managed_binding" | "invalid_request" | "invalid_target_binding" | "layout_invalid" | "locking_unsupported" | "managed_installation_unavailable" | "operation_busy" | "operation_completed" | "operation_conflict" | "operation_missing" | "operation_superseded" | "option_unsupported" | "outcome_uncertain" | "ownership_conflict" | "plan_invalid" | "plan_stale" | "port_conflict" | "progress_unavailable" | "record_invalid" | "record_unsupported" | "registration_missing" | "repair_ownership_conflict" | "repair_required" | "request_too_large" | "resource_identity_changed" | "resource_untrusted" | "response_too_large" | "runtime_damaged" | "target_conflict" | "target_invalid" | "target_unsupported" | "unsupported_action" | "unsupported_platform" | "unsupported_protocol" | "checkpoint_failed" | "inspection_failed" | "operation_failed" | "unknown"; +export type OpsAction = "inspect" | "config_validate" | "preflight" | "apply" | "operation_status" | "cancel" | "resume"; +export type OpsOptions = { httpPort?: number, dashboardEnabled?: boolean, startOnLogin?: boolean, restart?: boolean, }; +export type OpsResumeReference = { operationId: string, expectedAttempt: number, }; +export type OpsPreflightInput = { operation: OpsOperation, options: OpsOptions, resume?: OpsResumeReference, }; +export type OpsApprovalInput = { approvalId: string, planId: string, }; +export type OpsStatusInput = { operationId: string, }; +export type OpsCancelInput = { operationId: string, attempt: number, }; +export type OpsOperationReference = { operationId: string, bindingStatus: OpsReferenceBindingStatus, }; +export type OpsReferenceBindingStatus = "current" | "different" | "unavailable"; +export type OpsReferences = { bindingAvailable: boolean, operations: Array, }; +export type OpsInspectionReply = { outcome: OpsOutcome, components?: OpsComponents, error?: OpsErrorCode, bridgeError?: OpsBridgeError, exitCode?: number, }; +export type OpsValidationReply = { outcome: OpsOutcome, configuration: OpsConfigurationState, error?: OpsErrorCode, bridgeError?: OpsBridgeError, exitCode?: number, }; +export type OpsConfigurationState = "valid" | "missing_or_invalid" | "unknown"; +export type OpsPlanApproval = { approvalId: string, planId: string, operation: OpsOperation, options: OpsOptions, target: OpsTargetSummary, effects: OpsPlanEffects, installation?: OpsInstallationSummary, }; +export type OpsPreflightReply = { outcome: OpsOutcome, approval?: OpsPlanApproval, error?: OpsErrorCode, bridgeError?: OpsBridgeError, exitCode?: number, }; +export type OpsTargetSummary = { targetId: string, pythonExecutable: string, packageVersion: string, envFile: string, endpoint?: string, dataDir?: string, startOnLogin: boolean, }; +export type OpsPlanEffects = { mayStart: boolean, preservesData: boolean, }; +export type OpsInstallationSummary = { classification: OpsInstallationClassification, resources: OpsInstallationResources, database: OpsDatabaseClassification, spaceRequired: number, verifyRunning: boolean, preserveStopped: boolean, registrationPresent: boolean, }; +export type OpsInstallationClassification = "missing" | "owned_current" | "owned_repairable" | "pending_operation"; +export type OpsInstallationResources = "missing" | "damaged" | "verified"; +export type OpsDatabaseClassification = "new" | "existing_unknown" | "existing_ready" | "incompatible" | "unknown"; +export type OpsComponents = { support?: OpsSupportState, registration?: OpsRegistrationState, definition?: OpsDefinitionState, managerOwnership?: OpsManagerOwnership, manager?: OpsManagerState, liveness?: OpsLivenessState, readiness?: OpsReadinessState, endpoint?: string, maintenance?: OpsMaintenancePhase, resources?: OpsRuntimeResources, configuration?: OpsConfigurationState, installationId?: string, resourceId?: string, resourceDigest?: string, database?: OpsDatabaseClassification, connectionCompatibility?: OpsConnectionCompatibility, installationStage?: OpsServicePhase, installationObservation?: OpsObservationState, }; +export type OpsProgress = { requestId: string, operationId: string, attempt: number, sequence: number | null, phase: OpsServicePhase | null, accepted: boolean, }; +export type OpsOperationReply = { operationId: string, outcome: OpsOutcome, attempt?: number, exitCode?: number, error?: OpsErrorCode, bridgeError?: OpsBridgeError, status?: OpsOperationStatus, }; +export type OpsOperationStatus = { operationId: string, attempt: number, phase: OpsAttemptPhase, executor: OpsExecutor, executionState: OpsExecutionState, outcome: OpsOutcome, cancelRequested?: boolean, result?: OpsStoredResult, observation: OpsObservation, history: Array, recoveryActions: Array, }; +export type OpsExecutor = { pid?: number, observation: OpsExecutorObservation, }; +export type OpsObservation = { state?: OpsObservationState, components?: OpsComponents, error?: OpsErrorCode, }; +export type OpsAttemptSummary = { attempt: number, outcome?: OpsOutcome, }; +export type OpsStoredResult = { outcome: OpsOutcome, components?: OpsComponents, error?: OpsErrorCode, recoveryActions?: Array, }; +export type OpsMaintenancePhase = "manual_stopped" | "start_failed" | "configuration_switching" | "manual_switching" | "runtime_repair" | "stopping" | "stopped" | "switching" | "database_ready"; +export type OpsRuntimeResources = "present" | "missing_executable"; +export type OpsConnectionCompatibility = "not_checked"; +export type OpsSupportState = "supported" | "unsupported" | "unknown"; +export type OpsRegistrationState = "installed" | "not_installed" | "invalid" | "unknown"; +export type OpsDefinitionState = "current" | "stale" | "missing_executable" | "unknown"; +export type OpsManagerOwnership = "not_loaded" | "owned" | "foreign" | "unknown"; +export type OpsManagerState = "active" | "inactive" | "failed" | "unknown"; +export type OpsLivenessState = "live" | "unreachable" | "unknown"; +export type OpsReadinessState = "failed" | "not_probed" | "verified_during_operation" | "unknown"; +export type OpsServicePhase = "accepted" | "registration_dispatching" | "service" | "preparing" | "prepared" | "publishing" | "resources_published" | "configuration_prepared" | "configured" | "registering" | "complete"; +export type OpsAttemptPhase = "accepted" | "executing" | "finished"; +export type OpsExecutionState = "finished" | "interrupted" | "unknown"; +export type OpsExecutorObservation = "exited" | "not_confirmed_exited" | "unknown"; +export type OpsObservationState = "unknown"; +export type OpsRecoveryAction = "inspect" | "preflight" | "operation_status"; export type DiagnosticKind = "service" | "integrations"; export type DiagnosticItem = { field: string, status: string, }; export type HostDiagnostic = { host: string, status: string, checks: Array, }; diff --git a/desktop/ui/src/shared/ops.ts b/desktop/ui/src/shared/ops.ts new file mode 100644 index 0000000000..915b408c38 --- /dev/null +++ b/desktop/ui/src/shared/ops.ts @@ -0,0 +1,53 @@ +/* + * 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. + */ + +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import type { + OpsApprovalInput, + OpsCancelInput, + OpsInspectionReply, + OpsOperationReply, + OpsPreflightInput, + OpsPreflightReply, + OpsProgress, + OpsReferences, + OpsStatusInput, + OpsValidationReply, +} from "../generated/ipc"; + +/** Subscribe before dispatch; operation references survive a missed event or reply. */ +export const subscribeOpsProgress = (receive: (event: OpsProgress) => void) => + getCurrentWebviewWindow().listen("ops-progress", (event) => + receive(event.payload), + ); + +/** Fixed Ops v1 operations; executable, binding and complete plans remain native. */ +export const opsApi = { + references: () => invoke("ops_references"), + inspect: () => invoke("ops_inspect"), + configValidate: () => invoke("ops_config_validate"), + preflight: (input: OpsPreflightInput) => + invoke("ops_preflight", { input }), + apply: (input: OpsApprovalInput) => + invoke("ops_apply", { input }), + operationStatus: (input: OpsStatusInput) => + invoke("ops_operation_status", { input }), + cancel: (input: OpsCancelInput) => + invoke("ops_cancel", { input }), + resume: (input: OpsApprovalInput) => + invoke("ops_resume", { input }), +}; diff --git a/desktop/ui/tests/ops.test.tsx b/desktop/ui/tests/ops.test.tsx new file mode 100644 index 0000000000..708bcb804e --- /dev/null +++ b/desktop/ui/tests/ops.test.tsx @@ -0,0 +1,75 @@ +/* + * 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. + */ + +import { describe, expect, it, vi } from "vitest"; +import { opsApi, subscribeOpsProgress } from "../src/shared/ops"; +import type { OpsProgress } from "../src/generated/ipc"; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn(), listen: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); +vi.mock("@tauri-apps/api/webviewWindow", () => ({ + getCurrentWebviewWindow: () => ({ listen: mocks.listen }), +})); + +describe("constrained Ops client", () => { + it("uses only fixed commands and typed references", async () => { + mocks.invoke.mockResolvedValue({ outcome: "unknown" }); + await opsApi.references(); + await opsApi.inspect(); + await opsApi.configValidate(); + await opsApi.preflight({ operation: "stop", options: {} }); + const approval = { approvalId: "native-approval", planId: "native-plan" }; + await opsApi.apply(approval); + await opsApi.operationStatus({ operationId: "retained-operation" }); + await opsApi.cancel({ operationId: "retained-operation", attempt: 1 }); + await opsApi.resume(approval); + expect(mocks.invoke.mock.calls).toEqual([ + ["ops_references"], + ["ops_inspect"], + ["ops_config_validate"], + ["ops_preflight", { input: { operation: "stop", options: {} } }], + ["ops_apply", { input: approval }], + [ + "ops_operation_status", + { input: { operationId: "retained-operation" } }, + ], + [ + "ops_cancel", + { input: { operationId: "retained-operation", attempt: 1 } }, + ], + ["ops_resume", { input: approval }], + ]); + }); + it("subscribes to the current window and exposes cleanup", async () => { + const received = vi.fn(); + const stop = vi.fn(); + mocks.listen.mockResolvedValue(stop); + const cleanup = await subscribeOpsProgress(received); + const event: OpsProgress = { + requestId: "request", + operationId: "operation", + attempt: 1, + sequence: 1, + phase: "service", + accepted: false, + }; + expect(mocks.listen.mock.calls.at(-1)?.[0]).toBe("ops-progress"); + mocks.listen.mock.calls.at(-1)?.[1]({ payload: event }); + expect(received).toHaveBeenCalledWith(event); + cleanup(); + expect(stop).toHaveBeenCalledOnce(); + }); +}); diff --git a/docs/en/docs/operate/local-server-ops.md b/docs/en/docs/operate/local-server-ops.md index b9946524be..0161b0958e 100644 --- a/docs/en/docs/operate/local-server-ops.md +++ b/docs/en/docs/operate/local-server-ops.md @@ -11,8 +11,7 @@ Its own Python and dependencies must be distributed separately from the Server r interpreter therefore does not prevent the tool itself from starting or inspecting the registered service. This service boundary supports prepared Server runtimes. For fixed managed resources and the install/repair policy, -see [Managed local Server installation](managed-local-server.md). The Desktop native bridge and installation UI are -separate capabilities. Existing human-facing CLI commands remain available. +see [Managed local Server installation](managed-local-server.md). The constrained Desktop native bridge is described below; installation UI is a separate capability. Existing human-facing CLI commands remain available. Ops is not an HTTP API and does not add a daemon. Managed registrations carrying `isolated_runtime=true` require the bundled Ops or a matching-version service tool; an older CLI that does not support this definition field rejects it instead of managing the registration. @@ -177,3 +176,100 @@ silently remove the maintenance tool or user data. Signing, macOS notarization, service/login/reboot acceptance are separate release checks; a successful bundle build does not establish them. See [Deploy the Server](deploy-server.md) for the existing service behavior and platform restrictions. + +## Desktop native bridge + +Desktop exposes typed `ops_inspect`, `ops_config_validate`, `ops_preflight`, `ops_apply`, +`ops_operation_status`, `ops_cancel`, `ops_resume`, and `ops_references` commands only to its packaged main window. +`desktop/ui/src/shared/ops.ts` provides the typed client and `subscribeOpsProgress` subscription. Subscribe before +applying a plan. Validated `accepted` and `progress` JSONL messages arrive while the helper runs; each event includes +the native request ID, operation ID, attempt, and validated stage/sequence. Inspection does not depend on the target +Server Python being present. Linux returns `unsupported_platform`. + +The renderer selects an operation and its supported options. It cannot supply a command, executable, service name, +configuration path, download URL, catalog, or complete plan. Native preflight retains the exact Ops plan and returns +its display summary, `planId`, and opaque `approvalId`. An approval expires after ten minutes and is consumed once. +Apply requires that exact approval and plan ID. Resume requires a fresh preflight with `resume: { operationId, +expectedAttempt }`, followed by `ops_resume` with the resulting approval. Binding changes invalidate approval and +prevent querying or resuming a reference against a different target. + +Before dispatch, native code atomically saves the operation ID and binding fingerprint in private +`PowerContext/Ops/bridge-operations.json`. `ops_references` reads these references after the window or Desktop reopens; +`ops_operation_status` then queries the original Ops journal. The reference file contains no plans, results, credentials, +or second recovery state machine. Its failure prevents new mutations; it is never silently reset. References are retained +without automatic eviction, with a 4096-reference limit. Ops remains the authority for attempts, locks, cancellation, +reconciliation, and native service effects. Changing a target file is not a supported operation-journal migration. + +A valid structured nonzero helper exit retains its rejected or unhealthy result. A timeout, malformed output, lost +response, or shutdown reports an unknown bridge result with the retained operation ID. Query that operation before +requesting a fresh recovery plan; never infer rollback or automatically replay a write. Status and cancellation use +separate helpers and two reserved admission slots; four other helpers may run concurrently. A timed-out helper keeps +its admission slot until it exits. Native code does not signal Ops, Server, or service-manager processes. On normal Quit, +Desktop closes admission and waits at most two seconds. Closing its pipes can make Ops record a progress failure at a +safe boundary; already accepted native work may continue and save its result. Cancellation is an Ops request, not a +process kill or a rollback promise. + +Each helper receives one bounded stdin request, an isolated environment, and fixed `-I -S -B ` +arguments. Passive reads have a 30-second process deadline, preflight has a 180-second deadline to accommodate bounded target +database admission, and mutations have a 30-minute deadline. Each JSONL message is limited +to 64 KiB, aggregate stdout/stderr to 1 MiB, and a request to 128 messages. Both streams are drained without forwarding +raw text. Completion follows the helper's exit rather than waiting indefinitely for inherited pipe handles held by a +descendant. Protocol version, request/operation identity, attempt, stage sequence, outcome, and allowlisted fields are +validated before exposure. No raw stderr, provider exception, configuration contents, or credentials are rendered. + +### Provisioning the bridge + +A trusted native installer/packager publishes the independent Ops bundle at the fixed current-user location: + +- Windows: `%LOCALAPPDATA%\PowerContext\Ops\bundle`. +- macOS: `~/Library/Application Support/PowerContext/Ops/bundle`. + +Keep this directory separate from Desktop and Server; Desktop removal must preserve it and the Ops reference/journal +files. The existing `scripts/build_ops_bundle.py` output is used unchanged: `runtime/`, `packages/`, `entry.py`, and +`bundle.json`. Every launch verifies the exact manifest digest, platform/architecture, complete file inventory, and +file contents. Original-builder relative runtime file aliases are allowed only when they resolve to a separately +inventoried digest-matching regular file inside the runtime. External/absolute links, directory aliases, reparse points, +extra files, and damaged dependencies are rejected. Producer-normalized bundles may contain only regular files. +These checks read the complete bundle on each launch; no performance improvement or release budget is claimed. + +The packaged resource directory supplies `ops-provision.json`: + +```json +{ + "format_version": 1, + "bundle_manifest_sha256": "", + "binding_mode": "managed_catalog", + "catalog_sha256": "" +} +``` + +Replace both placeholders with lowercase 64-digit digests fixed by the trusted producer and packager. Candidate bundle +metadata and checksum sidecars cannot grant themselves trust. The packaged Desktop resource pins are the trust anchor; +production authenticity still requires qualified distribution and signing. Native code verifies packaged +`server/catalog.json` and atomically copies it into private `PowerContext/Ops/catalog-.json`. The immutable copy +and catalog digest are passed to the independent Ops entry; the renderer cannot replace either. Managed locations remain +those selected by `ManagedLayout.current_user()` in Ops. + +The optional real Tauri merge configuration `desktop/src-tauri/tauri.ops.conf.json` packages those two files from +`desktop/.artifacts/ops-resource/`. A packager places the approved producer's `ops-provision.json` and +`server/catalog.json` there, independently provisions the exact Ops bundle at the fixed location, and builds from +`desktop/` with: + +```sh +pnpm tauri build --config src-tauri/tauri.ops.conf.json --ci -- --locked +``` + +This hook does not download resources, install the independent helper, or qualify a candidate. Default builds omit the +optional metadata and report `unavailable` instead of starting a system Python. Invalid pins or changed files report +`integrity_failure`; there is no fallback to a target Server environment. + +For an operator-prepared Server, package `binding_mode: "target_file"` and omit `catalog_sha256`. Provision the private +`PowerContext/Ops/target.json` described above; native code passes a verified digest-named private snapshot plus fixed +`PowerContext/Ops/state`. The supplied merge configuration is for managed catalogs; target-file packagers include only +`ops-provision.json` in their Tauri resource mapping. Native paths come from the OS, with an explicit home directory; +ambient Python/Server configuration variables are not inherited. + +This bridge supplies no installation wizard or automatic connection/Scope handoff. Actual Windows Task Scheduler and +macOS LaunchAgent changes, published resources, signing/notarization, clean-machine installation, and release acceptance +remain separate. Native synthetic helpers and real read-only repository Ops subprocesses establish their respective +protocol/process boundaries, not those platform or release results. diff --git a/docs/zh/docs/operate/local-server-ops.md b/docs/zh/docs/operate/local-server-ops.md index 34522b3539..31a84fd150 100644 --- a/docs/zh/docs/operate/local-server-ops.md +++ b/docs/zh/docs/operate/local-server-ops.md @@ -8,7 +8,7 @@ description: 使用独立 Ops 工具配置和管理已准备好的个人 Server Ops 为个人 Server 提供非交互的配置和服务管理入口,复用 `powercontext service` 使用的当前用户服务控制器,调用时不需要已有的 Server HTTP 会话。 Ops 自带的 Python 与依赖必须和受管 Server 的运行环境分开分发。因此,Server 解释器缺失不会妨碍工具自身启动和检查服务注册。 -此服务入口管理已准备好的 Server 环境。固定受管资源及安装、修复策略见[受管本地 Server 安装](managed-local-server.md)。Desktop 原生桥接和安装界面是另外的能力,原有的人用 CLI 命令继续可用。 +此服务入口管理已准备好的 Server 环境。固定受管资源及安装、修复策略见[受管本地 Server 安装](managed-local-server.md)。受约束的 Desktop 原生桥接见下文;安装界面是另外的能力,原有的人用 CLI 命令继续可用。 Ops 不是 HTTP API,也不新增常驻进程。 包含 `isolated_runtime=true` 的受管注册需使用随包 Ops 或匹配版本的服务工具;不支持该定义字段的旧 CLI 会拒绝它,不再继续管理该注册。 @@ -147,3 +147,86 @@ Windows/macOS 打包(#2108/#2109)需要将 Ops 发布到受用户权限保 签名、macOS notarization、干净机器执行以及原生服务、登录和重启验收是单独的发布检查,构建成功不能代替这些证据。 原有服务行为与平台限制见[部署 Server](deploy-server.md)。 + +## Desktop 原生桥接 + +Desktop 仅向随包主窗口开放类型化的 `ops_inspect`、`ops_config_validate`、`ops_preflight`、`ops_apply`、 +`ops_operation_status`、`ops_cancel`、`ops_resume` 和 `ops_references` 命令。 +`desktop/ui/src/shared/ops.ts` 提供类型化客户端及 `subscribeOpsProgress` 订阅;应先订阅,再执行批准的计划。 +通过校验的 `accepted` 和 `progress` JSONL 消息会在工具运行期间实时到达,包含原生请求 ID、操作 ID、尝试编号、阶段及序号。 +检查不依赖目标 Server 的 Python 是否存在。Linux 返回 `unsupported_platform`。 + +Renderer 只能选择支持的操作与选项,不能提供命令、解释器、服务名、配置路径、下载 URL、catalog 或完整计划。 +原生 preflight 保存 Ops 返回的完整计划,只返回供核对的摘要、`planId` 和不透明的 `approvalId`。 +批准十分钟后过期,且只能使用一次。Apply 要求准确匹配批准 ID 与计划 ID。 +Resume 先通过包含 `resume: { operationId, expectedAttempt }` 的新 preflight 获取计划,再把新批准传给 `ops_resume`。 +绑定变化会使批准失效,也不能把旧操作引用用于查询或恢复另一目标。 + +原生端在派发修改前,将操作 ID 与绑定摘要原子写入私有的 `PowerContext/Ops/bridge-operations.json`。 +窗口或 Desktop 重开后,`ops_references` 找回引用,再由 `ops_operation_status` 查询原 Ops 日志。 +引用文件不保存计划、结果、凭据,也不建立第二套恢复状态机;文件损坏时拒绝新修改,不会静默重置。 +引用不会自动清除,上限为 4096 条。尝试、锁、取消、恢复判断和原生服务效果仍由 Ops 管理。 +修改 target 文件不构成受支持的操作日志迁移。 + +有效的结构化非零退出结果仍会保留被拒绝或不健康的事实。超时、响应格式错误、响应丢失或退出会返回结果未知及保留的操作 ID。 +应先查询原操作,再请求新的恢复计划;不能推断已回滚,也不能自动重放修改。 +查询与取消通过独立工具进程执行,并保留两个专用并发名额;其他请求最多同时运行四个工具进程。 +超时的工具在退出前继续占用名额。原生端不向 Ops、Server 或服务管理器进程发送终止信号。 +正常退出 Desktop 时停止接收新请求,最多等待两秒。关闭管道可能使 Ops 在安全边界记录进度通道失败; +已经接受的原生工作仍可能继续并保存结果。取消是 Ops 协议请求,不等于杀进程,也不承诺回滚。 + +每个工具接收一条有界 stdin 请求、隔离的环境及固定 `-I -S -B <绝对 entry.py>` 参数。 +被动查询的进程时限为 30 秒,preflight 为 180 秒以容纳有界的目标数据库准入检查,修改为 30 分钟;每条 JSONL 上限 64 KiB,stdout/stderr 合计上限 1 MiB,消息数上限 128。 +两条输出流持续读取,不转发原始文本。完成以工具进程退出为边界,不会因后代保留管道句柄而无限等待 EOF。 +协议版本、请求/操作身份、尝试编号、阶段序列、结果和允许字段在展示前校验。 +原始 stderr、提供方异常、配置内容和凭据不会进入 renderer。 + +### 配置桥接资源 + +可信原生安装器或打包器将独立 Ops 分发包发布到固定当前用户目录: + +- Windows:`%LOCALAPPDATA%\PowerContext\Ops\bundle`。 +- macOS:`~/Library/Application Support/PowerContext/Ops/bundle`。 + +该目录与 Desktop、Server 保持独立;卸载 Desktop 时保留工具、引用和 Ops 日志。 +桥接直接使用现有 `scripts/build_ops_bundle.py` 的 `runtime/`、`packages/`、`entry.py` 和 `bundle.json` 格式。 +每次启动校验准确清单摘要、平台/架构、完整文件清单和文件内容。原始构建器的相对 runtime 文件别名,仅在解析结果仍位于 +runtime 内、指向单独列出的普通文件且摘要一致时允许。外部/绝对链接、目录别名、reparse point、额外文件和依赖损坏均被拒绝。 +生产方规范化的分发包可以全部为普通文件。每次启动都会读取完整分发包,此处不宣称性能提升或满足发布性能预算。 + +随包资源目录提供 `ops-provision.json`: + +```json +{ + "format_version": 1, + "bundle_manifest_sha256": "<已批准 bundle.json 原始字节的 SHA-256>", + "binding_mode": "managed_catalog", + "catalog_sha256": "<已批准 server/catalog.json 原始字节的 SHA-256>" +} +``` + +两个占位符必须替换为可信生产方和打包器固定的 64 位小写十六进制摘要。候选分发包自己的清单或校验文件不能自行授予信任。 +Desktop 随包资源中的固定摘要是信任起点;生产分发的真实性仍要求合格的发布和签名流程。 +原生端校验随包 `server/catalog.json`,原子写入私有 `PowerContext/Ops/catalog-.json`,再把不可变副本及摘要传给独立 Ops 入口。 +Renderer 不能替换它们。受管目录仍由 Ops 的 `ManagedLayout.current_user()` 决定。 + +可选的真实 Tauri 合并配置 `desktop/src-tauri/tauri.ops.conf.json` 从 `desktop/.artifacts/ops-resource/` 打包上述两个文件。 +打包器将已批准生产产物中的 `ops-provision.json` 和 `server/catalog.json` 放到该目录,另行将准确 Ops 分发包配置到固定位置, +然后在 `desktop/` 中运行: + +```sh +pnpm tauri build --config src-tauri/tauri.ops.conf.json --ci -- --locked +``` + +此构建入口不下载资源、不安装独立工具,也不对候选产物作资格认定。 +默认构建不包含这些可选资源,返回 `unavailable`,不会启动系统 Python。 +固定摘要无效或文件变化时返回 `integrity_failure`,不会退回目标 Server 环境执行。 + +对于由操作者准备的 Server,使用 `binding_mode: "target_file"` 并省略 `catalog_sha256`,在私有的 +`PowerContext/Ops/target.json` 放置前文定义的绑定。原生端传递校验后的摘要命名私有快照及固定 `PowerContext/Ops/state`。 +提供的合并配置面向受管 catalog;target-file 打包器仅把 `ops-provision.json` 加入 Tauri 资源映射。 +原生路径来自操作系统,并明确传入用户 home;不继承环境中的 Python/Server 配置变量。 + +桥接不提供安装向导,也不自动建立连接或选择 Scope。真实 Windows Task Scheduler、macOS LaunchAgent 修改、正式资源发布、 +签名/公证、干净机器安装和发布验收仍是单独工作。合成工具进程及真实的源码 Ops 只读子进程分别验证对应协议和进程边界, +不能证明这些平台或发布结果。