From e70c9201c29cf3adc98389086ff28f4e358d5497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Thu, 13 Aug 2026 14:01:43 +0800 Subject: [PATCH] feat(hololake): add human-confirmed PNCC mounts --- .../contracts/pncc-stage-one.json | 9 +- .../docs/ARCHITECTURE.md | 2 +- .../hololake-native-desktop/foundation.json | 4 +- .../scripts/pncc-stage-one.test.mjs | 6 +- .../src-tauri/Cargo.lock | 69 ++++ .../src-tauri/Cargo.toml | 1 + .../src-tauri/src/lib.rs | 3 + .../src-tauri/src/pncc_repository_binding.rs | 345 +++++++++++++++++- .../hololake-native-desktop/src/main.tsx | 78 +++- .../hololake-native-desktop/src/styles.css | 22 ++ 10 files changed, 524 insertions(+), 15 deletions(-) diff --git a/product-source/hololake-native-desktop/contracts/pncc-stage-one.json b/product-source/hololake-native-desktop/contracts/pncc-stage-one.json index ca957ba..0c9dcb6 100644 --- a/product-source/hololake-native-desktop/contracts/pncc-stage-one.json +++ b/product-source/hololake-native-desktop/contracts/pncc-stage-one.json @@ -2,7 +2,7 @@ "schema": "hololake.pncc-stage-one-contract/v1", "record_id": "HLP-PNCC-STAGE-ONE-001", "development_id": "DEV-20260813-004", - "state": "READ_ONLY_CORE_IMPLEMENTED_HUMAN_MOUNT_REGISTRATION_GATED", + "state": "READ_ONLY_CORE_WITH_HUMAN_CONFIRMED_LOCAL_MOUNT", "source_provenance": "audit/pncc-migration-provenance.json", "transport": { "primary": "AUTHENTICATED_DIRECT_LOCAL_BROKER", @@ -41,7 +41,10 @@ "webview_arbitrary_path_or_url_registration_allowed": false, "external_ai_registration_allowed": false, "native_file_picker_exact_confirmation_required": true, - "implemented": false + "candidate_expires_after_ms": 900000, + "confirmation_token_stored_plaintext": false, + "evidence_revalidated_before_write": true, + "implemented": true }, "forbidden": [ "INTERNAL_AI_CHAT", @@ -55,5 +58,5 @@ "WHOLE_DONOR_TREE_COPY" ], "read_only_core_implemented": true, - "implementation_complete": false + "implementation_complete": true } diff --git a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md index d74f107..0285240 100644 --- a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md +++ b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md @@ -22,4 +22,4 @@ HoloLake routes work but does not host the user's private work lake, pool users Stage one now contains a read-only GH-PNCC core beneath the public surface. A local persona repository is bound to an exact Git root and full committed head. A remote persona object is read through a credential-free HTTPS Git v2 partial-object channel with an app-owned bare `blob:none` cache and a separate durable ancestry cursor. Verified reads append to a bounded hash-chained projection for the human panel and authenticated external programming AIs. -This core does not run a model, configure an API, acquire a persona lease, mutate memory or execute reality actions. Arbitrary repository paths and remote URLs cannot be registered by the WebView or external AI. Native human-confirmed source registration remains an explicit UI-stage gate. +This core does not run a model, configure an API, acquire a persona lease, mutate memory or execute reality actions. Arbitrary repository paths and remote URLs cannot be registered by the WebView or external AI. A human may choose a folder through the native operating-system picker; HoloLake then displays the committed persona, responsibility subject, full head and checkpoint. A short-lived hashed confirmation token is required before the exact revalidated evidence becomes a read-only mount. diff --git a/product-source/hololake-native-desktop/foundation.json b/product-source/hololake-native-desktop/foundation.json index 0f05169..f218369 100644 --- a/product-source/hololake-native-desktop/foundation.json +++ b/product-source/hololake-native-desktop/foundation.json @@ -41,8 +41,8 @@ "pncc_remote_incremental_object_channel_implemented": true, "pncc_receipt_projection_implemented": true, "pncc_authenticated_direct_broker_integration_implemented": true, - "pncc_human_mount_registration_implemented": false, - "pncc_human_mount_registration_gate": "NATIVE_FILE_PICKER_EXACT_CONFIRMATION_REQUIRED", + "pncc_human_mount_registration_implemented": true, + "pncc_human_mount_registration_gate": "SATISFIED_NATIVE_FILE_PICKER_EXACT_CONFIRMATION", "pncc_internal_model_inference_implemented": false, "pncc_execution_limb_implemented": false, "pncc_persona_lease_acquisition_implemented": false, diff --git a/product-source/hololake-native-desktop/scripts/pncc-stage-one.test.mjs b/product-source/hololake-native-desktop/scripts/pncc-stage-one.test.mjs index 6ebc2b8..044c59c 100644 --- a/product-source/hololake-native-desktop/scripts/pncc-stage-one.test.mjs +++ b/product-source/hololake-native-desktop/scripts/pncc-stage-one.test.mjs @@ -48,7 +48,9 @@ test('neither webview nor external AI can register an arbitrary PNCC source', () assert.equal(contract.mount_registration.webview_arbitrary_path_or_url_registration_allowed, false) assert.equal(contract.mount_registration.external_ai_registration_allowed, false) assert.equal(contract.mount_registration.native_file_picker_exact_confirmation_required, true) - assert.equal(contract.mount_registration.implemented, false) + assert.equal(contract.mount_registration.confirmation_token_stored_plaintext, false) + assert.equal(contract.mount_registration.evidence_revalidated_before_write, true) + assert.equal(contract.mount_registration.implemented, true) const native = readText('src-tauri/src/lib.rs') const broker = readText('src-tauri/src/direct_local_broker.rs') assert.doesNotMatch(native, /register_pncc_(repository|remote)_mount/) @@ -60,7 +62,7 @@ test('foundation and Rust modules report the implemented PNCC boundary', () => { assert.equal(foundation.pncc_repository_binding_implemented, true) assert.equal(foundation.pncc_remote_incremental_object_channel_implemented, true) assert.equal(foundation.pncc_receipt_projection_implemented, true) - assert.equal(foundation.pncc_human_mount_registration_implemented, false) + assert.equal(foundation.pncc_human_mount_registration_implemented, true) assert.equal(foundation.pncc_internal_model_inference_implemented, false) assert.equal(foundation.pncc_execution_limb_implemented, false) for (const relative of [ diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.lock b/product-source/hololake-native-desktop/src-tauri/Cargo.lock index 58fb054..9f44d28 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.lock +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.lock @@ -659,6 +659,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.1", + "block2", + "libc", "objc2", ] @@ -1353,6 +1355,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-updater", "tauri-runtime", "tauri-runtime-wry", @@ -2986,6 +2989,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -3867,6 +3894,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.toml b/product-source/hololake-native-desktop/src-tauri/Cargo.toml index 1587cec..9905249 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.toml +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.toml @@ -25,6 +25,7 @@ tauri = { version = "=2.10.2", features = [] } tauri-runtime = "=2.10.0" tauri-runtime-wry = "=2.10.0" tauri-plugin-updater = "2.10.0" +tauri-plugin-dialog = "=2.7.2" uuid = { version = "1", features = ["v4"] } [dev-dependencies] diff --git a/product-source/hololake-native-desktop/src-tauri/src/lib.rs b/product-source/hololake-native-desktop/src-tauri/src/lib.rs index c4e2eda..015f0f0 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -17,6 +17,7 @@ pub fn run_connector() -> Result<(), String> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ home_status::get_hololake_home_status, direct_local_session::issue_direct_local_discovery_ticket, @@ -27,6 +28,8 @@ pub fn run() { local_development_bridge::inspect_development_write_lane, local_development_bridge::release_development_write_lane, pncc_repository_binding::inspect_mounted_pncc_repository, + pncc_repository_binding::select_pncc_repository_candidate, + pncc_repository_binding::confirm_pncc_repository_mount, pncc_receipt_projection::query_pncc_receipt_projection, ]) .setup(|app| { diff --git a/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs b/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs index 5a15102..9162ebb 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs @@ -2,13 +2,19 @@ // Clean-room stage-one implementation. Contract evidence is recorded in // audit/pncc-migration-provenance.json; no donor source was copied. +use fs2::FileExt; use ring::digest::{digest, SHA256}; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; -use std::fs; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; use std::path::{Component, Path, PathBuf}; use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Manager}; +use tauri_plugin_dialog::DialogExt; +use uuid::Uuid; const MANIFEST_PATH: &str = ".hololake/persona/manifest.json"; const MANIFEST_SCHEMA: &str = "hololake.persona/v1"; @@ -16,6 +22,8 @@ const MAX_MANIFEST_BYTES: usize = 512 * 1024; const MAX_EVIDENCE_OBJECT_BYTES: usize = 2 * 1024 * 1024; const MAX_DECLARED_ARTIFACTS: usize = 256; const MOUNT_SCHEMA: &str = "hololake.pncc-stage-one-repository-mount/v1"; +const CANDIDATE_SCHEMA: &str = "hololake.pncc-stage-one-repository-candidate/v1"; +const CANDIDATE_TTL_MS: u128 = 15 * 60 * 1000; #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -44,6 +52,41 @@ pub struct PnccRepositoryMountReceipt { pub binding: PnccRepositoryInspectionReceipt, } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ConfirmPnccRepositoryMountInput { + pub candidate_id: String, + pub confirmation_token: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PnccRepositoryCandidateRecord { + schema: String, + state: String, + candidate_id: String, + confirmation_token_sha256: String, + repository_path: String, + expected_persona_id: String, + expected_human_responsibility_subject: String, + expected_head: String, + approved_receipt_id: String, + issued_at_unix_ms: u128, + expires_at_unix_ms: u128, + confirmed_mount_id: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PnccRepositoryCandidateReceipt { + pub schema: &'static str, + pub state: &'static str, + pub candidate_id: String, + pub confirmation_token: String, + pub expires_at_unix_ms: u128, + pub inspection: PnccRepositoryInspectionReceipt, +} + #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct InspectPnccRepositoryInput { @@ -64,6 +107,51 @@ pub async fn inspect_mounted_pncc_repository( .map_err(|error| format!("PNCC_REPOSITORY_MOUNT_JOIN_FAILED: {error}"))? } +#[tauri::command] +pub async fn select_pncc_repository_candidate( + app: AppHandle, +) -> Result, String> { + let dialog_app = app.clone(); + let selected = tauri::async_runtime::spawn_blocking(move || { + dialog_app + .dialog() + .file() + .set_title("选择代码通道 Git 仓库") + .blocking_pick_folder() + }) + .await + .map_err(|error| format!("PNCC_REPOSITORY_PICKER_JOIN_FAILED: {error}"))?; + let Some(selected) = selected else { + return Ok(None); + }; + let repository = selected + .into_path() + .map_err(|error| format!("PNCC_REPOSITORY_PICKER_PATH_INVALID: {error}"))?; + let inspection = + tauri::async_runtime::spawn_blocking(move || inspect_repository_candidate(&repository)) + .await + .map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_JOIN_FAILED: {error}"))??; + let candidate_root = pncc_repository_candidate_root(&app)?; + create_candidate_at(&candidate_root, inspection).map(Some) +} + +#[tauri::command] +pub async fn confirm_pncc_repository_mount( + app: AppHandle, + input: ConfirmPnccRepositoryMountInput, +) -> Result { + let candidate_root = pncc_repository_candidate_root(&app)?; + let mount_root = pncc_repository_mount_root(&app)?; + let projection_root = crate::pncc_receipt_projection::pncc_projection_root(&app)?; + tauri::async_runtime::spawn_blocking(move || { + let receipt = confirm_candidate_at(&candidate_root, &mount_root, input)?; + crate::pncc_receipt_projection::append_repository_binding_at(&projection_root, &receipt)?; + Ok(receipt) + }) + .await + .map_err(|error| format!("PNCC_REPOSITORY_CONFIRM_JOIN_FAILED: {error}"))? +} + pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result { let root = app .path() @@ -77,6 +165,19 @@ pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result Result { + let root = app + .path() + .app_data_dir() + .map_err(|error| format!("PNCC_APP_DATA_UNAVAILABLE: {error}"))? + .join("pncc-stage-one-v1") + .join("repository-candidates"); + fs::create_dir_all(&root) + .map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_STORAGE_UNAVAILABLE: {error}"))?; + root.canonicalize() + .map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_STORAGE_UNAVAILABLE: {error}")) +} + pub(crate) fn mounted_repository_count_at(root: &Path) -> Result { let mut count = 0; for entry in fs::read_dir(root) @@ -103,6 +204,151 @@ pub(crate) fn mounted_repository_count_at(root: &Path) -> Result Ok(count) } +fn inspect_repository_candidate( + repository: &Path, +) -> Result { + let repository = exact_repository_root(repository)?; + let observed_head = git_text(&repository, &["rev-parse", "HEAD"], "PNCC_GIT_HEAD_READ")? + .trim() + .to_ascii_lowercase(); + validate_head(&observed_head)?; + let (_, manifest_bytes) = read_committed_regular_blob( + &repository, + &observed_head, + MANIFEST_PATH, + MAX_MANIFEST_BYTES, + )?; + let manifest: PersonaManifestProjection = serde_json::from_slice(&manifest_bytes) + .map_err(|error| format!("PNCC_PERSONA_MANIFEST_INVALID: {error}"))?; + inspect_repository(InspectPnccRepositoryInput { + repository_path: repository.to_string_lossy().into_owned(), + expected_persona_id: manifest.persona_id, + expected_human_responsibility_subject: manifest.human_responsibility_subject, + expected_head: observed_head, + }) +} + +fn create_candidate_at( + root: &Path, + inspection: PnccRepositoryInspectionReceipt, +) -> Result { + let _lock = lock_candidates(root)?; + let issued_at_unix_ms = now_unix_ms()?; + let expires_at_unix_ms = issued_at_unix_ms + CANDIDATE_TTL_MS; + let candidate_id = format!("candidate-{}", Uuid::new_v4()); + let confirmation_token = format!("confirm-{}-{}", Uuid::new_v4(), Uuid::new_v4()); + let record = PnccRepositoryCandidateRecord { + schema: CANDIDATE_SCHEMA.into(), + state: "PENDING_HUMAN_CONFIRMATION".into(), + candidate_id: candidate_id.clone(), + confirmation_token_sha256: sha256_hex(confirmation_token.as_bytes()), + repository_path: inspection.repository_path.clone(), + expected_persona_id: inspection.persona_id.clone(), + expected_human_responsibility_subject: inspection.human_responsibility_subject.clone(), + expected_head: inspection.git_head.clone(), + approved_receipt_id: inspection.receipt_id.clone(), + issued_at_unix_ms, + expires_at_unix_ms, + confirmed_mount_id: None, + }; + write_json_atomic(&candidate_path(root, &candidate_id), &record, "CANDIDATE")?; + Ok(PnccRepositoryCandidateReceipt { + schema: CANDIDATE_SCHEMA, + state: "AWAITING_HUMAN_CONFIRMATION", + candidate_id, + confirmation_token, + expires_at_unix_ms, + inspection, + }) +} + +fn confirm_candidate_at( + candidate_root: &Path, + mount_root: &Path, + input: ConfirmPnccRepositoryMountInput, +) -> Result { + validate_machine_id(&input.candidate_id, "CANDIDATE_ID")?; + if input.confirmation_token.len() < 32 || input.confirmation_token.len() > 256 { + return Err("PNCC_CONFIRMATION_TOKEN_INVALID".into()); + } + let _lock = lock_candidates(candidate_root)?; + let path = candidate_path(candidate_root, &input.candidate_id); + let mut record: PnccRepositoryCandidateRecord = read_json_labeled(&path, "CANDIDATE")?; + if record.schema != CANDIDATE_SCHEMA || record.candidate_id != input.candidate_id { + return Err("PNCC_REPOSITORY_CANDIDATE_INVALID".into()); + } + if record.confirmation_token_sha256 != sha256_hex(input.confirmation_token.as_bytes()) { + return Err("PNCC_REPOSITORY_CANDIDATE_NOT_AUTHORIZED".into()); + } + if record.state == "CONFIRMED" { + let mount_id = record + .confirmed_mount_id + .ok_or("PNCC_REPOSITORY_CANDIDATE_INVALID")?; + return inspect_mounted_at(mount_root, InspectMountedPnccRepositoryInput { mount_id }); + } + if record.state != "PENDING_HUMAN_CONFIRMATION" || now_unix_ms()? > record.expires_at_unix_ms { + return Err("PNCC_REPOSITORY_CANDIDATE_EXPIRED".into()); + } + let binding = inspect_repository(InspectPnccRepositoryInput { + repository_path: record.repository_path.clone(), + expected_persona_id: record.expected_persona_id.clone(), + expected_human_responsibility_subject: record.expected_human_responsibility_subject.clone(), + expected_head: record.expected_head.clone(), + })?; + if binding.receipt_id != record.approved_receipt_id { + return Err("PNCC_REPOSITORY_CANDIDATE_EVIDENCE_DRIFT".into()); + } + let mount_id = format!("pncc-{}", &binding.receipt_id[..16]); + let mount = PnccRepositoryMountRecord { + schema: MOUNT_SCHEMA.into(), + mount_id: mount_id.clone(), + repository_path: binding.repository_path.clone(), + expected_persona_id: binding.persona_id.clone(), + expected_human_responsibility_subject: binding.human_responsibility_subject.clone(), + expected_head: binding.git_head.clone(), + approved_receipt_id: binding.receipt_id.clone(), + }; + let mount_record_path = mount_path(mount_root, &mount_id); + if mount_record_path.exists() { + let existing: PnccRepositoryMountRecord = read_json(&mount_record_path)?; + if existing.repository_path != mount.repository_path + || existing.expected_head != mount.expected_head + || existing.approved_receipt_id != mount.approved_receipt_id + { + return Err("PNCC_REPOSITORY_MOUNT_ID_CONFLICT".into()); + } + } else { + write_json_atomic(&mount_record_path, &mount, "MOUNT")?; + } + record.state = "CONFIRMED".into(); + record.confirmed_mount_id = Some(mount_id.clone()); + write_json_atomic(&path, &record, "CANDIDATE")?; + Ok(PnccRepositoryMountReceipt { + schema: MOUNT_SCHEMA, + state: "HUMAN_CONFIRMED_READ_ONLY", + mount_id, + binding, + }) +} + +fn candidate_path(root: &Path, candidate_id: &str) -> PathBuf { + root.join(format!("{candidate_id}.json")) +} + +fn lock_candidates(root: &Path) -> Result { + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(root.join("candidates.lock")) + .map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_LOCK_FAILED: {error}"))?; + lock.lock_exclusive() + .map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_LOCK_FAILED: {error}"))?; + Ok(lock) +} + pub(crate) fn inspect_mounted_at( root: &Path, input: InspectMountedPnccRepositoryInput, @@ -479,20 +725,53 @@ fn validate_machine_id(value: &str, label: &str) -> Result<(), String> { } fn read_json Deserialize<'de>>(path: &Path) -> Result { + read_json_labeled(path, "MOUNT") +} + +fn read_json_labeled Deserialize<'de>>(path: &Path, label: &str) -> Result { let metadata = fs::symlink_metadata(path).map_err(|error| { if error.kind() == std::io::ErrorKind::NotFound { - "PNCC_REPOSITORY_MOUNT_NOT_FOUND".to_string() + format!("PNCC_REPOSITORY_{label}_NOT_FOUND") } else { - format!("PNCC_REPOSITORY_MOUNT_READ_FAILED: {error}") + format!("PNCC_REPOSITORY_{label}_READ_FAILED: {error}") } })?; if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err("PNCC_REPOSITORY_MOUNT_RECORD_INVALID".into()); + return Err(format!("PNCC_REPOSITORY_{label}_RECORD_INVALID")); } serde_json::from_slice( - &fs::read(path).map_err(|error| format!("PNCC_REPOSITORY_MOUNT_READ_FAILED: {error}"))?, + &fs::read(path).map_err(|error| format!("PNCC_REPOSITORY_{label}_READ_FAILED: {error}"))?, ) - .map_err(|error| format!("PNCC_REPOSITORY_MOUNT_RECORD_INVALID: {error}")) + .map_err(|error| format!("PNCC_REPOSITORY_{label}_RECORD_INVALID: {error}")) +} + +fn write_json_atomic(path: &Path, value: &T, label: &str) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("PNCC_REPOSITORY_{label}_PATH_INVALID"))?; + fs::create_dir_all(parent) + .map_err(|error| format!("PNCC_REPOSITORY_{label}_WRITE_FAILED: {error}"))?; + let temporary = parent.join(format!(".{label}-{}.tmp", Uuid::new_v4())); + let bytes = serde_json::to_vec_pretty(value) + .map_err(|error| format!("PNCC_REPOSITORY_{label}_RECORD_INVALID: {error}"))?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temporary) + .map_err(|error| format!("PNCC_REPOSITORY_{label}_WRITE_FAILED: {error}"))?; + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("PNCC_REPOSITORY_{label}_WRITE_FAILED: {error}"))?; + fs::rename(&temporary, path) + .map_err(|error| format!("PNCC_REPOSITORY_{label}_WRITE_FAILED: {error}")) +} + +fn now_unix_ms() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .map_err(|error| format!("PNCC_SYSTEM_CLOCK_INVALID: {error}")) } fn trusted_git() -> Command { @@ -690,4 +969,58 @@ mod tests { "PNCC_REPOSITORY_RELATIVE_PATH_INVALID" ); } + + #[test] + fn human_confirmation_token_is_required_before_a_mount_is_written() { + let (_temp, repository, _head) = fixture(); + let storage = TempDir::new().unwrap(); + let candidates = storage.path().join("candidates"); + let mounts = storage.path().join("mounts"); + fs::create_dir_all(&candidates).unwrap(); + fs::create_dir_all(&mounts).unwrap(); + let candidate = create_candidate_at( + &candidates, + inspect_repository_candidate(&repository).unwrap(), + ) + .unwrap(); + assert!(fs::read_dir(&mounts).unwrap().next().is_none()); + + let wrong = confirm_candidate_at( + &candidates, + &mounts, + ConfirmPnccRepositoryMountInput { + candidate_id: candidate.candidate_id.clone(), + confirmation_token: "wrong-confirmation-token-long-enough-123".into(), + }, + ) + .unwrap_err(); + assert_eq!(wrong, "PNCC_REPOSITORY_CANDIDATE_NOT_AUTHORIZED"); + assert!(fs::read_dir(&mounts).unwrap().next().is_none()); + + let confirmed = confirm_candidate_at( + &candidates, + &mounts, + ConfirmPnccRepositoryMountInput { + candidate_id: candidate.candidate_id.clone(), + confirmation_token: candidate.confirmation_token.clone(), + }, + ) + .unwrap(); + assert_eq!(confirmed.state, "HUMAN_CONFIRMED_READ_ONLY"); + assert_eq!(confirmed.binding.persona_id, "ICE-P-ZY001"); + assert!(!confirmed.binding.model_inference_started); + assert!(!confirmed.binding.reality_execution_allowed); + + let retry = confirm_candidate_at( + &candidates, + &mounts, + ConfirmPnccRepositoryMountInput { + candidate_id: candidate.candidate_id, + confirmation_token: candidate.confirmation_token, + }, + ) + .unwrap(); + assert_eq!(retry.mount_id, confirmed.mount_id); + assert_eq!(mounted_repository_count_at(&mounts).unwrap(), 1); + } } diff --git a/product-source/hololake-native-desktop/src/main.tsx b/product-source/hololake-native-desktop/src/main.tsx index ad47a1d..247e6d0 100644 --- a/product-source/hololake-native-desktop/src/main.tsx +++ b/product-source/hololake-native-desktop/src/main.tsx @@ -43,6 +43,26 @@ interface ReceiptProjection { lastSequence: number } +interface RepositoryInspection { + personaId: string + humanResponsibilitySubject: string + repositoryPath: string + gitHead: string + repositoryClean: boolean + currentCheckpoint: { relativePath: string; sha256: string } + modelInferenceStarted: boolean + realityExecutionAllowed: boolean +} + +interface RepositoryCandidate { + schema: string + state: string + candidateId: string + confirmationToken: string + expiresAtUnixMs: number + inspection: RepositoryInspection +} + const themes: Array<{ id: ThemeId; name: string }> = [ { id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, @@ -88,6 +108,9 @@ function HoloLakeApp() { const [panel, setPanel] = useState(null) const [ticket, setTicket] = useState(null) const [receipts, setReceipts] = useState([]) + const [repositoryCandidate, setRepositoryCandidate] = useState(null) + const [repositoryBusy, setRepositoryBusy] = useState(false) + const [repositoryMessage, setRepositoryMessage] = useState('') const [message, setMessage] = useState('') const refreshStatus = useCallback(async () => { @@ -117,6 +140,10 @@ function HoloLakeApp() { const openReceipts = async () => { setPanel('receipts') + await loadReceipts() + } + + const loadReceipts = async () => { try { const projection = await invoke('query_pncc_receipt_projection', { input: { afterSequence: 0, limit: 25 } }) setReceipts(projection.events) @@ -125,6 +152,42 @@ function HoloLakeApp() { } } + const chooseRepository = async () => { + setRepositoryBusy(true) + setRepositoryMessage('') + try { + const candidate = await invoke('select_pncc_repository_candidate') + setRepositoryCandidate(candidate) + if (!candidate) setRepositoryMessage('已取消选择,没有写入任何绑定。') + } catch (error) { + setRepositoryCandidate(null) + setRepositoryMessage(`这个文件夹不能作为代码通道:${String(error)}`) + } finally { + setRepositoryBusy(false) + } + } + + const confirmRepository = async () => { + if (!repositoryCandidate) return + setRepositoryBusy(true) + setRepositoryMessage('') + try { + await invoke('confirm_pncc_repository_mount', { + input: { + candidateId: repositoryCandidate.candidateId, + confirmationToken: repositoryCandidate.confirmationToken, + }, + }) + setRepositoryCandidate(null) + setRepositoryMessage('代码仓库已按当前固定提交绑定为只读通道。') + await Promise.all([refreshStatus(), loadReceipts()]) + } catch (error) { + setRepositoryMessage(`绑定没有写入:${String(error)}`) + } finally { + setRepositoryBusy(false) + } + } + const issueInvitation = async () => { setMessage('') try { @@ -238,7 +301,20 @@ function HoloLakeApp() {

可核验的发生

代码通道回执

这里是只读投影,不是权限来源。没有回执,也不代表服务器离线。

- {receipts.length ?
    {receipts.map((receipt) =>
  1. {receipt.sequence.toString().padStart(2, '0')}
    {receipt.kind === 'REPOSITORY_BINDING_REVALIDATED' ? '仓库绑定已复核' : '远端对象读取已验证'}{new Date(Number(receipt.observedAtUnixMs)).toLocaleString('zh-CN')} · {receipt.eventHash.slice(0, 12)}
  2. )}
:
湖面还没有新回执完成代码仓库绑定或读取后,这里会出现可核验记录。
} + {repositoryCandidate ?
+
等待你确认HoloLake 已只读检查这个 Git 仓库
+
+
人格
{repositoryCandidate.inspection.personaId}
+
责任人
{repositoryCandidate.inspection.humanResponsibilitySubject}
+
固定提交
{repositoryCandidate.inspection.gitHead.slice(0, 12)}
+
工作区
{repositoryCandidate.inspection.repositoryClean ? '干净' : '有未提交变化 · 不读取'}
+
检查点
{repositoryCandidate.inspection.currentCheckpoint.relativePath}
+
+

确认后只绑定这一份已提交证据;不会启动模型、不会取得人格租约、不会执行现实动作。

+
+
: } + {repositoryMessage &&

{repositoryMessage}

} + {receipts.length ?
    {receipts.map((receipt) =>
  1. {receipt.sequence.toString().padStart(2, '0')}
    {receipt.kind === 'REPOSITORY_BINDING_REVALIDATED' ? '仓库绑定已复核' : '远端对象读取已验证'}{new Date(Number(receipt.observedAtUnixMs)).toLocaleString('zh-CN')} · {receipt.eventHash.slice(0, 12)}
  2. )}
:
湖面还没有新回执完成代码仓库绑定或读取后,这里会出现可核验记录。
} } {panel === 'settings' && <>

湖面设置

diff --git a/product-source/hololake-native-desktop/src/styles.css b/product-source/hololake-native-desktop/src/styles.css index 5d6650b..c6a9f21 100644 --- a/product-source/hololake-native-desktop/src/styles.css +++ b/product-source/hololake-native-desktop/src/styles.css @@ -60,6 +60,7 @@ button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px .hero-copy { max-width: 540px; margin: 27px 0 0; color: var(--content-muted); font-size: 14px; line-height: 1.9; letter-spacing: .035em; } .hero-actions { display: flex; align-items: center; gap: 14px; margin-top: 37px; } .primary-action, .secondary-action { min-height: 46px; border-radius: 999px; padding: 0 22px; display: inline-flex; align-items: center; justify-content: center; gap: 12px; cursor: pointer; transition: transform 180ms ease, background 180ms ease, box-shadow 180ms ease; } +.primary-action:disabled, .secondary-action:disabled, .repository-picker:disabled, .text-action:disabled { opacity: .55; cursor: wait; transform: none; } .primary-action { border: 0; color: var(--button-primary-text); background: var(--button-primary-bg); box-shadow: var(--button-primary-shadow); font-weight: 620; } .secondary-action { color: var(--button-secondary-text); border: 1px solid var(--button-secondary-edge); background: var(--button-secondary-bg); backdrop-filter: blur(18px); } .primary-action:hover, .secondary-action:hover { transform: translateY(-2px); } @@ -104,6 +105,27 @@ button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px .empty-state i { width: 7px; height: 7px; margin-bottom: 20px; border-radius: 50%; background: var(--accent-light); box-shadow: 0 0 32px 12px var(--primitive-warm-glow); } .empty-state b { color: var(--content-secondary); font-size: 13px; font-weight: 570; } .empty-state span { max-width: 260px; margin-top: 10px; color: var(--content-faint); font-size: 11px; line-height: 1.7; } +.empty-state.compact { min-height: 170px; } + +.repository-picker { width: 100%; min-height: 74px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 13px 19px; border: 0; border-radius: 19px; color: var(--content-secondary); background: var(--primitive-glass); box-shadow: inset 0 1px var(--primitive-glass-top); text-align: left; cursor: pointer; } +.repository-picker:hover { background: var(--primitive-glass-hover); } +.repository-picker span { display: grid; gap: 6px; } +.repository-picker b { font-size: 12.5px; font-weight: 590; } +.repository-picker small { color: var(--content-faint); font-size: 10px; } +.repository-picker svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.45; stroke-linecap: round; stroke-linejoin: round; } +.candidate-card { margin-top: 26px; padding: 21px; border-radius: 21px; background: var(--primitive-glass); box-shadow: inset 0 1px var(--primitive-glass-top); } +.candidate-heading { display: flex; align-items: flex-start; gap: 14px; } +.candidate-heading div { display: grid; gap: 5px; } +.candidate-heading b { color: var(--content-secondary); font-size: 12.5px; } +.candidate-heading span { color: var(--content-faint); font-size: 10.5px; } +.candidate-facts { display: grid; gap: 10px; margin: 20px 0; } +.candidate-facts div { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 12px; } +.candidate-facts dt { color: var(--content-faint); font-size: 10px; } +.candidate-facts dd { margin: 0; overflow: hidden; color: var(--content-secondary); font: 10.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; } +.candidate-card > p { color: var(--content-faint); font-size: 10.5px; line-height: 1.7; } +.candidate-actions { display: flex; align-items: center; gap: 16px; margin-top: 18px; } +.candidate-actions .primary-action { min-height: 42px; padding-inline: 18px; font-size: 12px; } +.text-action { border: 0; color: var(--content-muted); background: transparent; font-size: 11px; cursor: pointer; } .theme-list { display: grid; gap: 9px; margin-top: 30px; } .theme-list button { width: 100%; min-height: 66px; display: grid; grid-template-columns: 40px 1fr auto; align-items: center; gap: 14px; padding: 10px 14px; border: 0; border-radius: 17px; color: var(--content-secondary); background: transparent; text-align: left; cursor: pointer; }