feat(hololake): add human-confirmed PNCC mounts

This commit is contained in:
冰朔 2026-08-13 14:01:43 +08:00
commit e70c9201c2
10 changed files with 524 additions and 15 deletions

View file

@ -2,7 +2,7 @@
"schema": "hololake.pncc-stage-one-contract/v1", "schema": "hololake.pncc-stage-one-contract/v1",
"record_id": "HLP-PNCC-STAGE-ONE-001", "record_id": "HLP-PNCC-STAGE-ONE-001",
"development_id": "DEV-20260813-004", "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", "source_provenance": "audit/pncc-migration-provenance.json",
"transport": { "transport": {
"primary": "AUTHENTICATED_DIRECT_LOCAL_BROKER", "primary": "AUTHENTICATED_DIRECT_LOCAL_BROKER",
@ -41,7 +41,10 @@
"webview_arbitrary_path_or_url_registration_allowed": false, "webview_arbitrary_path_or_url_registration_allowed": false,
"external_ai_registration_allowed": false, "external_ai_registration_allowed": false,
"native_file_picker_exact_confirmation_required": true, "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": [ "forbidden": [
"INTERNAL_AI_CHAT", "INTERNAL_AI_CHAT",
@ -55,5 +58,5 @@
"WHOLE_DONOR_TREE_COPY" "WHOLE_DONOR_TREE_COPY"
], ],
"read_only_core_implemented": true, "read_only_core_implemented": true,
"implementation_complete": false "implementation_complete": true
} }

View file

@ -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. 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.

View file

@ -41,8 +41,8 @@
"pncc_remote_incremental_object_channel_implemented": true, "pncc_remote_incremental_object_channel_implemented": true,
"pncc_receipt_projection_implemented": true, "pncc_receipt_projection_implemented": true,
"pncc_authenticated_direct_broker_integration_implemented": true, "pncc_authenticated_direct_broker_integration_implemented": true,
"pncc_human_mount_registration_implemented": false, "pncc_human_mount_registration_implemented": true,
"pncc_human_mount_registration_gate": "NATIVE_FILE_PICKER_EXACT_CONFIRMATION_REQUIRED", "pncc_human_mount_registration_gate": "SATISFIED_NATIVE_FILE_PICKER_EXACT_CONFIRMATION",
"pncc_internal_model_inference_implemented": false, "pncc_internal_model_inference_implemented": false,
"pncc_execution_limb_implemented": false, "pncc_execution_limb_implemented": false,
"pncc_persona_lease_acquisition_implemented": false, "pncc_persona_lease_acquisition_implemented": false,

View file

@ -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.webview_arbitrary_path_or_url_registration_allowed, false)
assert.equal(contract.mount_registration.external_ai_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.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 native = readText('src-tauri/src/lib.rs')
const broker = readText('src-tauri/src/direct_local_broker.rs') const broker = readText('src-tauri/src/direct_local_broker.rs')
assert.doesNotMatch(native, /register_pncc_(repository|remote)_mount/) 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_repository_binding_implemented, true)
assert.equal(foundation.pncc_remote_incremental_object_channel_implemented, true) assert.equal(foundation.pncc_remote_incremental_object_channel_implemented, true)
assert.equal(foundation.pncc_receipt_projection_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_internal_model_inference_implemented, false)
assert.equal(foundation.pncc_execution_limb_implemented, false) assert.equal(foundation.pncc_execution_limb_implemented, false)
for (const relative of [ for (const relative of [

View file

@ -659,6 +659,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2",
"libc",
"objc2", "objc2",
] ]
@ -1353,6 +1355,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-runtime", "tauri-runtime",
"tauri-runtime-wry", "tauri-runtime-wry",
@ -2986,6 +2989,30 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@ -3867,6 +3894,48 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "tauri-plugin-updater" name = "tauri-plugin-updater"
version = "2.10.1" version = "2.10.1"

View file

@ -25,6 +25,7 @@ tauri = { version = "=2.10.2", features = [] }
tauri-runtime = "=2.10.0" tauri-runtime = "=2.10.0"
tauri-runtime-wry = "=2.10.0" tauri-runtime-wry = "=2.10.0"
tauri-plugin-updater = "2.10.0" tauri-plugin-updater = "2.10.0"
tauri-plugin-dialog = "=2.7.2"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
[dev-dependencies] [dev-dependencies]

View file

@ -17,6 +17,7 @@ pub fn run_connector() -> Result<(), String> {
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
home_status::get_hololake_home_status, home_status::get_hololake_home_status,
direct_local_session::issue_direct_local_discovery_ticket, 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::inspect_development_write_lane,
local_development_bridge::release_development_write_lane, local_development_bridge::release_development_write_lane,
pncc_repository_binding::inspect_mounted_pncc_repository, 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, pncc_receipt_projection::query_pncc_receipt_projection,
]) ])
.setup(|app| { .setup(|app| {

View file

@ -2,13 +2,19 @@
// Clean-room stage-one implementation. Contract evidence is recorded in // Clean-room stage-one implementation. Contract evidence is recorded in
// audit/pncc-migration-provenance.json; no donor source was copied. // audit/pncc-migration-provenance.json; no donor source was copied.
use fs2::FileExt;
use ring::digest::{digest, SHA256}; use ring::digest::{digest, SHA256};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeSet; 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::path::{Component, Path, PathBuf};
use std::process::{Command, Output}; use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use tauri_plugin_dialog::DialogExt;
use uuid::Uuid;
const MANIFEST_PATH: &str = ".hololake/persona/manifest.json"; const MANIFEST_PATH: &str = ".hololake/persona/manifest.json";
const MANIFEST_SCHEMA: &str = "hololake.persona/v1"; 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_EVIDENCE_OBJECT_BYTES: usize = 2 * 1024 * 1024;
const MAX_DECLARED_ARTIFACTS: usize = 256; const MAX_DECLARED_ARTIFACTS: usize = 256;
const MOUNT_SCHEMA: &str = "hololake.pncc-stage-one-repository-mount/v1"; 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)] #[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)] #[serde(rename_all = "camelCase", deny_unknown_fields)]
@ -44,6 +52,41 @@ pub struct PnccRepositoryMountReceipt {
pub binding: PnccRepositoryInspectionReceipt, 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)] #[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)] #[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct InspectPnccRepositoryInput { pub struct InspectPnccRepositoryInput {
@ -64,6 +107,51 @@ pub async fn inspect_mounted_pncc_repository(
.map_err(|error| format!("PNCC_REPOSITORY_MOUNT_JOIN_FAILED: {error}"))? .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> { pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result<PathBuf, String> {
let root = app let root = app
.path() .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}")) .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> { pub(crate) fn mounted_repository_count_at(root: &Path) -> Result<usize, String> {
let mut count = 0; let mut count = 0;
for entry in fs::read_dir(root) 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) 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( pub(crate) fn inspect_mounted_at(
root: &Path, root: &Path,
input: InspectMountedPnccRepositoryInput, 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> { 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| { let metadata = fs::symlink_metadata(path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound { if error.kind() == std::io::ErrorKind::NotFound {
"PNCC_REPOSITORY_MOUNT_NOT_FOUND".to_string() format!("PNCC_REPOSITORY_{label}_NOT_FOUND")
} else { } else {
format!("PNCC_REPOSITORY_MOUNT_READ_FAILED: {error}") format!("PNCC_REPOSITORY_{label}_READ_FAILED: {error}")
} }
})?; })?;
if metadata.file_type().is_symlink() || !metadata.is_file() { 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( 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 { fn trusted_git() -> Command {
@ -690,4 +969,58 @@ mod tests {
"PNCC_REPOSITORY_RELATIVE_PATH_INVALID" "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);
}
} }

View file

@ -43,6 +43,26 @@ interface ReceiptProjection {
lastSequence: number 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 }> = [ const themes: Array<{ id: ThemeId; name: string }> = [
{ id: 'night', name: '夜湖星光' }, { id: 'night', name: '夜湖星光' },
{ id: 'dawn', name: '晨湖曦光' }, { id: 'dawn', name: '晨湖曦光' },
@ -88,6 +108,9 @@ function HoloLakeApp() {
const [panel, setPanel] = useState<Panel>(null) const [panel, setPanel] = useState<Panel>(null)
const [ticket, setTicket] = useState<DiscoveryTicketReceipt | null>(null) const [ticket, setTicket] = useState<DiscoveryTicketReceipt | null>(null)
const [receipts, setReceipts] = useState<ReceiptEvent[]>([]) 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 [message, setMessage] = useState('')
const refreshStatus = useCallback(async () => { const refreshStatus = useCallback(async () => {
@ -117,6 +140,10 @@ function HoloLakeApp() {
const openReceipts = async () => { const openReceipts = async () => {
setPanel('receipts') setPanel('receipts')
await loadReceipts()
}
const loadReceipts = async () => {
try { try {
const projection = await invoke<ReceiptProjection>('query_pncc_receipt_projection', { input: { afterSequence: 0, limit: 25 } }) const projection = await invoke<ReceiptProjection>('query_pncc_receipt_projection', { input: { afterSequence: 0, limit: 25 } })
setReceipts(projection.events) 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 () => { const issueInvitation = async () => {
setMessage('') setMessage('')
try { try {
@ -238,7 +301,20 @@ function HoloLakeApp() {
<p className="panel-kicker"></p> <p className="panel-kicker"></p>
<h2 id="panel-title"></h2> <h2 id="panel-title"></h2>
<p className="panel-intro">线</p> <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' && <> {panel === 'settings' && <>
<p className="panel-kicker"></p> <p className="panel-kicker"></p>

View file

@ -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-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; } .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, .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; } .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); } .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); } .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 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 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 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 { 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; } .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; }