fix(pi): keep device ownership local during portable sync

Route manual SQL, WebDAV, and S3 through one Pi portable-state boundary. Portable exports omit native projection and Skill deployment evidence, while imports retain the receiving device's evidence before runtime reconciliation.

Recover Pi before independent post-import projections and report aggregated Skill reconciliation failures without hiding valid Skills.
This commit is contained in:
SaladDay
2026-08-03 10:24:06 +00:00
parent 9ab526dabf
commit 1a3b97f018
8 changed files with 544 additions and 32 deletions
+7 -6
View File
@@ -25,7 +25,7 @@ pub async fn export_config_to_file(
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || {
let target_path = PathBuf::from(&filePath);
db.export_sql(&target_path)?;
db.export_portable_sql(&target_path)?;
Ok::<_, AppError>(json!({
"success": true,
"message": "SQL exported successfully",
@@ -56,11 +56,12 @@ pub async fn import_config_from_file(
.map_err(|error| format!("导入前恢复 Pi 直连投影失败: {error}"))?;
let import_path = filePath.clone();
let import_result =
tauri::async_runtime::spawn_blocking(move || db.import_sql(&PathBuf::from(import_path)))
.await
.map_err(|error| AppError::Message(format!("SQL import task failed: {error}")))
.and_then(|result| result);
let import_result = tauri::async_runtime::spawn_blocking(move || {
db.import_portable_sql(&PathBuf::from(import_path))
})
.await
.map_err(|error| AppError::Message(format!("SQL import task failed: {error}")))
.and_then(|result| result);
let backup_id = match import_result {
Ok(backup_id) => backup_id,
Err(error) => {
+60 -5
View File
@@ -6,10 +6,39 @@ use crate::store::AppState;
use serde_json::{json, Value};
pub(crate) fn run_post_import_sync(app_state: &AppState) -> Result<(), AppError> {
PromptService::reconcile_pi_portable_import(app_state)?;
ProviderService::sync_current_to_live(app_state)?;
settings::reload_settings()?;
Ok(())
// Provider synchronization reopens/reconciles Pi's runtime admission after
// the pre-import boundary closed it. Run that recovery first, then execute
// every remaining independent projection even if one of them fails.
run_post_import_steps(
|| ProviderService::sync_current_to_live(app_state),
|| PromptService::reconcile_pi_portable_import(app_state),
settings::reload_settings,
)
}
fn run_post_import_steps(
live_sync: impl FnOnce() -> Result<(), AppError>,
prompt_sync: impl FnOnce() -> Result<(), AppError>,
settings_reload: impl FnOnce() -> Result<(), AppError>,
) -> Result<(), AppError> {
let mut failures = Vec::new();
for (stage, result) in [
("live", live_sync()),
("pi_prompt", prompt_sync()),
("settings", settings_reload()),
] {
if let Err(error) = result {
failures.push(format!("{stage}={error}"));
}
}
if failures.is_empty() {
Ok(())
} else {
Err(AppError::Config(format!(
"post-import reconciliation incomplete: {}",
failures.join("; ")
)))
}
}
fn post_sync_warning<E: std::fmt::Display>(err: E) -> String {
@@ -53,8 +82,9 @@ pub(crate) fn success_payload_with_warning(backup_id: String, warning: Option<St
#[cfg(test)]
mod tests {
use super::{attach_warning, post_sync_warning_from_result};
use super::{attach_warning, post_sync_warning_from_result, run_post_import_steps};
use serde_json::json;
use std::cell::RefCell;
#[test]
fn post_sync_warning_from_result_returns_none_on_success() {
@@ -92,4 +122,29 @@ mod tests {
Some("post sync warning")
);
}
#[test]
fn post_import_steps_recover_live_first_and_do_not_short_circuit() {
let calls = RefCell::new(Vec::new());
let error = run_post_import_steps(
|| {
calls.borrow_mut().push("live");
Ok(())
},
|| {
calls.borrow_mut().push("prompt");
Err(crate::error::AppError::Config("invalid AGENTS.md".into()))
},
|| {
calls.borrow_mut().push("settings");
Err(crate::error::AppError::Config("reload failed".into()))
},
)
.expect_err("independent failures must be reported");
assert_eq!(*calls.borrow(), ["live", "prompt", "settings"]);
let message = error.to_string();
assert!(message.contains("pi_prompt="));
assert!(message.contains("settings="));
}
}
+1
View File
@@ -5,6 +5,7 @@
pub mod failover;
pub mod mcp;
pub(crate) mod pi_catalog;
pub(crate) mod pi_portable_state;
pub mod pi_projections;
pub mod profiles;
pub mod prompts;
@@ -0,0 +1,355 @@
//! Portable SQL boundary for Pi's device-local ownership evidence.
//!
//! Provider and Skill configuration is portable. Exact `models.json` claims and
//! native Skill deployment receipts are not: they describe files on one device
//! and must never become ownership proof on another. This adapter keeps that
//! policy outside the frozen generic backup/restore implementation while giving
//! manual SQL, WebDAV, and S3 one shared boundary.
#[cfg(test)]
use crate::database::SkillDeploymentMethod;
use crate::database::{lock_conn, Database, PiProviderProjection, SkillDeployment};
use crate::error::AppError;
use rusqlite::params;
use std::fs;
use std::path::Path;
const DEVICE_LOCAL_INSERT_PREFIXES: &[&str] = &[
"INSERT INTO \"pi_provider_projections\"",
"INSERT INTO \"skill_deployments\"",
];
#[derive(Debug, Clone, PartialEq, Eq)]
struct PiDeviceLocalState {
projections: Vec<PiProviderProjection>,
skill_deployments: Vec<SkillDeployment>,
}
impl Database {
/// Export a user-portable SQL backup without device-local ownership rows.
pub(crate) fn export_portable_sql_string(&self) -> Result<String, AppError> {
strip_device_local_insert_statements(&self.export_sql_string()?)
}
/// Export a cloud-sync SQL snapshot without device-local ownership rows.
pub(crate) fn export_portable_sql_string_for_sync(&self) -> Result<String, AppError> {
strip_device_local_insert_statements(&self.export_sql_string_for_sync()?)
}
pub(crate) fn export_portable_sql(&self, target_path: &Path) -> Result<(), AppError> {
let dump = self.export_portable_sql_string()?;
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).map_err(|error| AppError::io(parent, error))?;
}
crate::config::atomic_write(target_path, dump.as_bytes())
}
/// Import a user-portable SQL backup while retaining this device's evidence.
pub(crate) fn import_portable_sql(&self, source_path: &Path) -> Result<String, AppError> {
let local = self.capture_pi_device_local_state()?;
let backup_id = self.import_sql(source_path)?;
self.replace_pi_device_local_state(&local)?;
Ok(backup_id)
}
/// Import a cloud-sync snapshot while retaining this device's evidence.
pub(crate) fn import_portable_sql_string_for_sync(
&self,
sql: &str,
) -> Result<String, AppError> {
let local = self.capture_pi_device_local_state()?;
let backup_id = self.import_sql_string_for_sync(sql)?;
self.replace_pi_device_local_state(&local)?;
Ok(backup_id)
}
fn capture_pi_device_local_state(&self) -> Result<PiDeviceLocalState, AppError> {
let conn = lock_conn!(self.conn);
let projections = {
let mut statement = conn
.prepare(
"SELECT provider_id, provider_key, created_at, updated_at
FROM pi_provider_projections
ORDER BY provider_id",
)
.map_err(|error| AppError::Database(error.to_string()))?;
let rows = statement
.query_map([], |row| {
Ok(PiProviderProjection {
provider_id: row.get(0)?,
provider_key: row.get(1)?,
created_at: row.get(2)?,
updated_at: row.get(3)?,
})
})
.map_err(|error| AppError::Database(error.to_string()))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|error| AppError::Database(error.to_string()))?
};
let skill_deployments = {
let mut statement = conn
.prepare(
"SELECT skill_id, destination, destination_key, method,
source_identity, deployed_digest, created_at, updated_at
FROM skill_deployments
WHERE app_type = 'pi'
ORDER BY skill_id, destination_key",
)
.map_err(|error| AppError::Database(error.to_string()))?;
let rows = statement
.query_map([], |row| {
let method = row.get::<_, String>(3)?;
let method = method.parse().map_err(|error: AppError| {
rusqlite::Error::FromSqlConversionFailure(
3,
rusqlite::types::Type::Text,
Box::new(error),
)
})?;
Ok(SkillDeployment {
skill_id: row.get(0)?,
destination: row.get(1)?,
destination_key: row.get(2)?,
method,
source_identity: row.get(4)?,
deployed_digest: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
})
.map_err(|error| AppError::Database(error.to_string()))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|error| AppError::Database(error.to_string()))?
};
Ok(PiDeviceLocalState {
projections,
skill_deployments,
})
}
fn replace_pi_device_local_state(&self, local: &PiDeviceLocalState) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let transaction = conn
.transaction()
.map_err(|error| AppError::Database(error.to_string()))?;
transaction
.execute("DELETE FROM pi_provider_projections", [])
.map_err(|error| AppError::Database(error.to_string()))?;
transaction
.execute("DELETE FROM skill_deployments WHERE app_type = 'pi'", [])
.map_err(|error| AppError::Database(error.to_string()))?;
for projection in &local.projections {
transaction
.execute(
"INSERT INTO pi_provider_projections
(provider_id, provider_key, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4)",
params![
projection.provider_id,
projection.provider_key,
projection.created_at,
projection.updated_at
],
)
.map_err(|error| AppError::Database(error.to_string()))?;
}
for deployment in &local.skill_deployments {
transaction
.execute(
"INSERT INTO skill_deployments (
app_type, skill_id, destination, destination_key, method,
source_identity, deployed_digest, created_at, updated_at
) VALUES ('pi', ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
deployment.skill_id,
deployment.destination,
deployment.destination_key,
deployment.method.as_str(),
deployment.source_identity,
deployment.deployed_digest,
deployment.created_at,
deployment.updated_at
],
)
.map_err(|error| AppError::Database(error.to_string()))?;
}
transaction
.commit()
.map_err(|error| AppError::Database(error.to_string()))
}
}
/// Remove complete INSERT statements for the two device-local tables from SQL
/// generated by `Database::dump_sql`. Values may contain quotes, semicolons, or
/// newlines, so line filtering is insufficient; statement boundaries are found
/// only outside SQLite single-quoted literals. Unknown output fails closed.
fn strip_device_local_insert_statements(sql: &str) -> Result<String, AppError> {
let bytes = sql.as_bytes();
let mut output = String::with_capacity(sql.len());
let mut statement_start = 0;
let mut cursor = 0;
let mut in_string = false;
while cursor < bytes.len() {
match bytes[cursor] {
b'\'' if in_string && bytes.get(cursor + 1) == Some(&b'\'') => {
cursor += 2;
continue;
}
b'\'' => in_string = !in_string,
b';' if !in_string => {
let statement_end = cursor + 1;
let statement = &sql[statement_start..statement_end];
if !DEVICE_LOCAL_INSERT_PREFIXES
.iter()
.any(|prefix| statement.trim_start().starts_with(prefix))
{
output.push_str(statement);
}
statement_start = statement_end;
}
_ => {}
}
cursor += 1;
}
if in_string {
return Err(AppError::Config(
"portable SQL export ended inside a quoted value".to_string(),
));
}
output.push_str(&sql[statement_start..]);
if DEVICE_LOCAL_INSERT_PREFIXES
.iter()
.any(|prefix| output.contains(prefix))
{
return Err(AppError::Config(
"portable SQL export retained device-local Pi ownership rows".to_string(),
));
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
fn deployment(skill_id: &str, destination_key: &str) -> SkillDeployment {
SkillDeployment {
skill_id: skill_id.to_string(),
destination: format!("/device/{destination_key}"),
destination_key: destination_key.to_string(),
method: SkillDeploymentMethod::Copy,
source_identity: format!("path:/source/{skill_id};digest:sha256:one"),
deployed_digest: Some("sha256:one".to_string()),
created_at: 10,
updated_at: 20,
}
}
fn seed_portable_provider(db: &Database) -> Result<(), AppError> {
let conn = lock_conn!(db.conn);
conn.execute(
"INSERT INTO providers
(id, app_type, name, settings_config, meta, is_current)
VALUES ('portable-sentinel', 'codex', 'Portable sentinel', '{}', '{}', 0)",
[],
)
.map_err(|error| AppError::Database(error.to_string()))?;
Ok(())
}
#[test]
fn portable_sql_scrubber_handles_multiline_quoted_values() -> Result<(), AppError> {
let sql = concat!(
"-- CC Switch SQLite 导出\n",
"CREATE TABLE \"pi_provider_projections\" (value TEXT);\n",
"INSERT INTO \"pi_provider_projections\" (value) VALUES ('one;\n",
"two ''quoted''');\n",
"CREATE TABLE \"providers\" (value TEXT);\n",
"INSERT INTO \"providers\" (value) VALUES ('portable;\nvalue');\n",
"COMMIT;\n",
);
let scrubbed = strip_device_local_insert_statements(sql)?;
assert!(!scrubbed.contains("INSERT INTO \"pi_provider_projections\""));
assert!(scrubbed.contains("CREATE TABLE \"pi_provider_projections\""));
assert!(scrubbed.contains("INSERT INTO \"providers\""));
assert!(scrubbed.contains("'portable;\nvalue'"));
Ok(())
}
#[test]
fn portable_exports_keep_schema_but_omit_device_evidence() -> Result<(), AppError> {
let db = Database::memory()?;
db.claim_pi_projection_key("local-provider", "local-key")?;
db.save_pi_skill_deployment(&deployment("local-skill", "local-destination"))?;
for exported in [
db.export_portable_sql_string()?,
db.export_portable_sql_string_for_sync()?,
] {
assert!(exported.contains("CREATE TABLE pi_provider_projections"));
assert!(exported.contains("CREATE TABLE skill_deployments"));
assert!(!exported.contains("INSERT INTO \"pi_provider_projections\""));
assert!(!exported.contains("INSERT INTO \"skill_deployments\""));
}
Ok(())
}
#[test]
fn sync_import_discards_remote_evidence_and_restores_local_evidence() -> Result<(), AppError> {
let remote = Database::memory()?;
seed_portable_provider(&remote)?;
remote.claim_pi_projection_key("remote-provider", "shared-key")?;
remote.save_pi_skill_deployment(&deployment("remote-skill", "remote-destination"))?;
// Model an older remote snapshot created before portable row scrubbing.
let remote_sql = remote.export_sql_string()?;
let local = Database::memory()?;
local.claim_pi_projection_key("local-provider", "shared-key")?;
local.save_pi_skill_deployment(&deployment("local-skill", "local-destination"))?;
local.import_portable_sql_string_for_sync(&remote_sql)?;
assert_eq!(
local
.get_pi_projection_for_key("shared-key")?
.map(|projection| projection.provider_id),
Some("local-provider".to_string())
);
assert!(local.get_pi_projection("remote-provider")?.is_none());
assert_eq!(
local.get_pi_skill_deployments("local-skill")?,
vec![deployment("local-skill", "local-destination")]
);
assert!(local.get_pi_skill_deployments("remote-skill")?.is_empty());
Ok(())
}
#[test]
fn manual_sql_import_preserves_local_evidence() -> Result<(), AppError> {
let remote = Database::memory()?;
seed_portable_provider(&remote)?;
remote.claim_pi_projection_key("remote-provider", "remote-key")?;
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("portable.sql");
fs::write(&path, remote.export_sql_string()?).expect("write SQL backup");
let local = Database::memory()?;
local.claim_pi_projection_key("local-provider", "local-key")?;
local.import_portable_sql(&path)?;
assert!(local.get_pi_projection("remote-provider")?.is_none());
assert_eq!(
local
.get_pi_projection("local-provider")?
.map(|projection| projection.provider_key),
Some("local-key".to_string())
);
Ok(())
}
}
+10
View File
@@ -56,6 +56,16 @@ mod tests {
Some("SOUL.md")
);
}
#[test]
fn pi_prompt_file_uses_agents_md() {
let path = prompt_file_path(&AppType::Pi).expect("Pi prompt path");
assert_eq!(
path.file_name().and_then(|name| name.to_str()),
Some("AGENTS.md")
);
}
}
fn get_base_dir_with_fallback(
+25 -17
View File
@@ -1289,20 +1289,20 @@ fn sync_current_provider_for_app_respecting_takeover(
///
/// For additive mode apps (OpenCode), all providers are synced instead of just the current one.
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Sync providers based on mode
for app_type in AppType::all() {
if matches!(app_type, AppType::Pi) {
crate::services::pi_catalog::PiCatalogCoordinator::reconcile_portable_import(state)?;
continue;
}
// Pi's portable-import boundary closes runtime admission before replacing
// SQLite. Recover it first so an unrelated application's broken live file
// cannot strand Pi in that closed state.
crate::services::pi_catalog::PiCatalogCoordinator::reconcile_portable_import(state)?;
// Preserve the existing fail-fast behavior for all other provider views.
for app_type in AppType::all().filter(|app_type| !matches!(app_type, AppType::Pi)) {
if app_type.is_additive_mode() {
// Provider rename and every additive live mutation share this
// per-app lock. Acquire it before reading the catalog so a key
// per-app lock. Acquire it before reading the catalog so a key
// cannot be renamed after this sync captured a stale provider map.
let _guard = futures::executor::block_on(
state.proxy_service.lock_switch_for_app(app_type.as_str()),
);
// Additive mode: sync ALL providers
sync_all_providers_to_live(state, &app_type)?;
} else {
// Switch mode: sync only current provider. During proxy takeover,
@@ -1312,20 +1312,28 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
}
}
// MCP syncbest-effort 逐应用投影,内部已聚合失败)。错误暂存到
// Skill 同步之后再返回:MCP 的失败不该跳过 Skill 同步,但调用方
//(配置导入 / 云同步恢复)需要知道结果不完整。
let mcp_result = McpService::sync_all_enabled(state);
let mut failures = Vec::new();
if let Err(error) = McpService::sync_all_enabled(state) {
failures.push(format!("mcp={error}"));
}
// Skill sync
// Continue through all apps so one collision cannot hide unrelated Skills.
for app_type in AppType::all() {
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
// Continue syncing other apps, don't abort
if let Err(error) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type)
{
log::warn!("同步 Skill 到 {app_type:?} 失败: {error}");
failures.push(format!("skill:{}={error}", app_type.as_str()));
}
}
mcp_result
if failures.is_empty() {
Ok(())
} else {
Err(AppError::Config(format!(
"live synchronization incomplete: {}",
failures.join("; ")
)))
}
}
/// Read current live settings for an app type
+84 -2
View File
@@ -153,6 +153,7 @@ impl PiSkillDeploymentService {
_guard: &MutexGuard<'static, ()>,
db: &Arc<Database>,
) -> Result<(), AppError> {
let mut failures = Vec::new();
for skill in db.get_all_installed_skills()?.values() {
// Portable sync and old databases may contain a poisoned directory
// name. Reject it before any path join, but do not let that inert
@@ -166,9 +167,22 @@ impl PiSkillDeploymentService {
);
continue;
}
reconcile_skill_unlocked(db, skill)?;
if let Err(error) = reconcile_skill_unlocked(db, skill) {
log::warn!(
"Pi Skill '{}' could not be reconciled; continuing with independent Skills: {error}",
skill.id
);
failures.push(format!("{}={error}", skill.id));
}
}
if failures.is_empty() {
Ok(())
} else {
Err(AppError::Config(format!(
"Pi Skill reconciliation incomplete: {}",
failures.join("; ")
)))
}
Ok(())
}
pub(crate) fn remove_before_uninstall_under_guard(
@@ -1350,4 +1364,72 @@ mod tests {
assert!(status.effectively_discovered);
assert!(status.issue.is_some_and(|issue| issue.contains("previous")));
}
#[test]
#[serial_test::serial]
fn reconcile_all_reports_failures_after_deploying_independent_skills() {
struct EnvGuard {
key: &'static str,
previous: Option<std::ffi::OsString>,
}
impl EnvGuard {
fn set(key: &'static str, value: &Path) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.previous.take() {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
if self.key == "CC_SWITCH_TEST_HOME" {
let _ = crate::settings::reload_settings();
}
}
}
let temp = tempfile::tempdir().expect("tempdir");
let _home = EnvGuard::set("CC_SWITCH_TEST_HOME", temp.path());
crate::settings::reload_settings().expect("reload settings");
let pi_root = temp.path().join("pi");
let _pi_root = EnvGuard::set("PI_CODING_AGENT_DIR", &pi_root);
let ssot = SkillService::get_ssot_dir().expect("SSOT");
let good_source = ssot.join("good");
fs::create_dir_all(&good_source).expect("good source");
fs::write(
good_source.join("SKILL.md"),
"---\nname: good\ndescription: independent skill\n---\n",
)
.expect("manifest");
let db = Arc::new(Database::memory().expect("database"));
for (id, directory) in [("local:bad", "missing"), ("local:good", "good")] {
db.save_skill(&InstalledSkill {
id: id.to_string(),
name: directory.to_string(),
description: Some("reconciliation test".to_string()),
directory: directory.to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps::only(&AppType::Pi),
installed_at: 1,
content_hash: None,
updated_at: 1,
})
.expect("save skill");
}
let error = PiSkillDeploymentService::reconcile_all(&db)
.expect_err("missing source must remain visible");
assert!(error.to_string().contains("local:bad"));
assert!(
fs::symlink_metadata(pi_root.join("skills").join("good")).is_ok(),
"one invalid Skill must not hide an independent valid deployment"
);
}
}
+2 -2
View File
@@ -106,7 +106,7 @@ pub(crate) fn build_local_snapshot(
db: &crate::database::Database,
) -> Result<LocalSnapshot, AppError> {
// Export database to SQL string
let sql_string = db.export_sql_string_for_sync()?;
let sql_string = db.export_portable_sql_string_for_sync()?;
let db_sql = sql_string.into_bytes();
// Pack skills into deterministic ZIP
@@ -323,7 +323,7 @@ pub(crate) fn apply_snapshot(
// Replace skills first, then import database; roll back skills on DB failure.
restore_skills_zip(skills_zip)?;
if let Err(db_err) = db.import_sql_string_for_sync(sql_str) {
if let Err(db_err) = db.import_portable_sql_string_for_sync(sql_str) {
if let Err(rollback_err) = restore_skills_from_backup(&skills_backup) {
return Err(localized(
"sync.db_import_and_rollback_failed",