feat(hololake): add human-confirmed PNCC mounts
This commit is contained in:
parent
4d2415a35b
commit
e70c9201c2
10 changed files with 524 additions and 15 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<Option<PnccRepositoryCandidateReceipt>, 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<PnccRepositoryMountReceipt, String> {
|
||||
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<PathBuf, String> {
|
||||
let root = app
|
||||
.path()
|
||||
|
|
@ -77,6 +165,19 @@ pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result<PathBuf, Str
|
|||
.map_err(|error| format!("PNCC_REPOSITORY_MOUNT_STORAGE_UNAVAILABLE: {error}"))
|
||||
}
|
||||
|
||||
fn pncc_repository_candidate_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
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<usize, String> {
|
||||
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<usize, String>
|
|||
Ok(count)
|
||||
}
|
||||
|
||||
fn inspect_repository_candidate(
|
||||
repository: &Path,
|
||||
) -> Result<PnccRepositoryInspectionReceipt, String> {
|
||||
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<PnccRepositoryCandidateReceipt, String> {
|
||||
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<PnccRepositoryMountReceipt, String> {
|
||||
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<std::fs::File, String> {
|
||||
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<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
||||
read_json_labeled(path, "MOUNT")
|
||||
}
|
||||
|
||||
fn read_json_labeled<T: for<'de> Deserialize<'de>>(path: &Path, label: &str) -> Result<T, String> {
|
||||
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<T: Serialize>(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<u128, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Panel>(null)
|
||||
const [ticket, setTicket] = useState<DiscoveryTicketReceipt | null>(null)
|
||||
const [receipts, setReceipts] = useState<ReceiptEvent[]>([])
|
||||
const [repositoryCandidate, setRepositoryCandidate] = useState<RepositoryCandidate | null>(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<ReceiptProjection>('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<RepositoryCandidate | null>('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() {
|
|||
<p className="panel-kicker">可核验的发生</p>
|
||||
<h2 id="panel-title">代码通道回执</h2>
|
||||
<p className="panel-intro">这里是只读投影,不是权限来源。没有回执,也不代表服务器离线。</p>
|
||||
{receipts.length ? <ol className="receipt-list">{receipts.map((receipt) => <li key={receipt.sequence}><span>{receipt.sequence.toString().padStart(2, '0')}</span><div><b>{receipt.kind === 'REPOSITORY_BINDING_REVALIDATED' ? '仓库绑定已复核' : '远端对象读取已验证'}</b><small>{new Date(Number(receipt.observedAtUnixMs)).toLocaleString('zh-CN')} · {receipt.eventHash.slice(0, 12)}</small></div></li>)}</ol> : <div className="empty-state"><i /><b>湖面还没有新回执</b><span>完成代码仓库绑定或读取后,这里会出现可核验记录。</span></div>}
|
||||
{repositoryCandidate ? <div className="candidate-card">
|
||||
<div className="candidate-heading"><span className="status-light waiting" /><div><b>等待你确认</b><span>HoloLake 已只读检查这个 Git 仓库</span></div></div>
|
||||
<dl className="candidate-facts">
|
||||
<div><dt>人格</dt><dd>{repositoryCandidate.inspection.personaId}</dd></div>
|
||||
<div><dt>责任人</dt><dd>{repositoryCandidate.inspection.humanResponsibilitySubject}</dd></div>
|
||||
<div><dt>固定提交</dt><dd>{repositoryCandidate.inspection.gitHead.slice(0, 12)}</dd></div>
|
||||
<div><dt>工作区</dt><dd>{repositoryCandidate.inspection.repositoryClean ? '干净' : '有未提交变化 · 不读取'}</dd></div>
|
||||
<div><dt>检查点</dt><dd>{repositoryCandidate.inspection.currentCheckpoint.relativePath}</dd></div>
|
||||
</dl>
|
||||
<p>确认后只绑定这一份已提交证据;不会启动模型、不会取得人格租约、不会执行现实动作。</p>
|
||||
<div className="candidate-actions"><button className="primary-action" type="button" disabled={repositoryBusy} onClick={() => void confirmRepository()}>确认只读绑定<Icon name="arrow" /></button><button className="text-action" type="button" disabled={repositoryBusy} onClick={() => setRepositoryCandidate(null)}>重新选择</button></div>
|
||||
</div> : <button className="repository-picker" type="button" disabled={repositoryBusy} onClick={() => void chooseRepository()}><span><b>{status.codeRepositoryMountCount > 0 ? '再绑定一个代码仓库' : '绑定代码通道仓库'}</b><small>从 macOS 原生文件夹选择器进入</small></span><Icon name="arrow" /></button>}
|
||||
{repositoryMessage && <p className="panel-message" aria-live="polite">{repositoryMessage}</p>}
|
||||
{receipts.length ? <ol className="receipt-list">{receipts.map((receipt) => <li key={receipt.sequence}><span>{receipt.sequence.toString().padStart(2, '0')}</span><div><b>{receipt.kind === 'REPOSITORY_BINDING_REVALIDATED' ? '仓库绑定已复核' : '远端对象读取已验证'}</b><small>{new Date(Number(receipt.observedAtUnixMs)).toLocaleString('zh-CN')} · {receipt.eventHash.slice(0, 12)}</small></div></li>)}</ol> : <div className="empty-state compact"><i /><b>湖面还没有新回执</b><span>完成代码仓库绑定或读取后,这里会出现可核验记录。</span></div>}
|
||||
</>}
|
||||
{panel === 'settings' && <>
|
||||
<p className="panel-kicker">湖面设置</p>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue