diff --git a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/README.md b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/README.md index 34d2da581..7e834dcf0 100644 --- a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/README.md +++ b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/README.md @@ -19,6 +19,8 @@ API 路由对客户端开放: - 编号解析:把 TCS-GL 人类编号路由到工作域、企业账号和私有仓库; - 关系确认:由人类确认自己与人格体的认领关系; - 责任回执:独立记录对域责任的接受、拒绝、延期或修改后接受; +- 回执入仓:关系与责任签名回执使用提交者自己的 Forgejo 会话写入本人私有工作仓库的 + `.guanghu/receipts/`,稳定路径与读回校验保证重试不重复;服务不持有管理员仓库令牌; - 仓库验证:登录凭证只透传给同机 Forgejo 验证,不写入数据库或日志。 - 首次换密:使用用户自己的一次性凭证进入 Forgejo 强制换密会话,不持有长期管理员令牌; 换密回执只记录账号、时间和成功状态,不记录旧密码或新密码。 diff --git a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.py b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.py index 8f4776d68..064126629 100644 --- a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.py +++ b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.py @@ -39,6 +39,9 @@ FORGEJO_USER_API = os.environ.get( FORGEJO_WEB_BASE = os.environ.get( "GH_ENTERPRISE_FORGEJO_WEB_BASE", "https://guanghu.chat/code" ).rstrip("/") +FORGEJO_API_BASE = os.environ.get( + "GH_ENTERPRISE_FORGEJO_API_BASE", "http://127.0.0.1:3341/api/v1" +).rstrip("/") RECEIPT_KEY = os.environ.get("GH_ENTERPRISE_RECEIPT_KEY", "") MAX_BODY = 16_384 USERNAME = re.compile(r"^[A-Za-z0-9_-]{1,40}$") @@ -210,6 +213,90 @@ def signed_receipt(payload: dict) -> dict: return {**payload, "receipt_hash": receipt_hash, "receipt_signature": signature} +def stable_receipt_id(prefix: str, human_number: str, idempotency_key: str) -> str: + """Keep one receipt path across safe client retries without exposing the key.""" + material = f"{prefix}\n{human_number}\n{idempotency_key}".encode() + digest = hmac.new(RECEIPT_KEY.encode(), material, hashlib.sha256).hexdigest()[:32] + return f"{prefix}-{digest.upper()}" + + +def repository_receipt_path(kind: str, receipt_id: str) -> str: + if kind not in {"relationship", "responsibility"} or not re.fullmatch( + r"GH-(?:REL|RESP)-[A-F0-9]{32}", receipt_id + ): + raise ValueError("repository receipt path input invalid") + return f".guanghu/receipts/{kind}/{receipt_id}.json" + + +def project_receipt_to_repository( + human: dict, username: str, password: str, kind: str, receipt: dict +) -> dict: + """Commit a signed receipt with the human's own Forgejo authority. + + No administrator token or server-side repository credential is held. A + retry that finds the deterministic path already present must read back the + exact bytes before treating the projection as idempotent. + """ + repository = str(human["repository"]) + if repository.split("/", 1)[0] != username: + raise RuntimeError("repository owner does not match authenticated user") + path = repository_receipt_path(kind, str(receipt["receipt_id"])) + endpoint = ( + f"{FORGEJO_API_BASE}/repos/{urllib.parse.quote(repository, safe='/')}" + f"/contents/{urllib.parse.quote(path, safe='/')}" + ) + content = canonical(receipt) + b"\n" + authorization = "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode() + create_body = canonical( + { + "branch": "main", + "content": base64.b64encode(content).decode(), + "message": f"receipt({kind}): {receipt['receipt_id']}", + } + ) + request = urllib.request.Request(endpoint, data=create_body, method="POST") + request.add_header("Authorization", authorization) + request.add_header("Accept", "application/json") + request.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(request, timeout=15) as response: + result = json.load(response) + if response.status != 201: + raise RuntimeError(f"repository projection returned {response.status}") + commit = str(result.get("commit", {}).get("sha", "")) + if not re.fullmatch(r"[0-9a-f]{40,64}", commit): + raise RuntimeError("repository projection commit missing") + return { + "state": "COMMITTED", + "repository": repository, + "path": path, + "commit": commit, + } + except urllib.error.HTTPError as error: + if error.code != 422: + raise RuntimeError(f"repository projection failed: {error.code}") from error + read_request = urllib.request.Request(endpoint + "?ref=main") + read_request.add_header("Authorization", authorization) + read_request.add_header("Accept", "application/json") + try: + with urllib.request.urlopen(read_request, timeout=15) as response: + existing = json.load(response) + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, ValueError) as error: + raise RuntimeError("repository projection readback failed") from error + try: + existing_content = base64.b64decode(str(existing["content"]), validate=True) + except (KeyError, ValueError) as error: + raise RuntimeError("repository projection readback invalid") from error + if not hmac.compare_digest(existing_content, content): + raise RuntimeError("repository receipt path already contains different bytes") + return { + "state": "IDEMPOTENT_READBACK", + "repository": repository, + "path": path, + "commit": str(existing.get("sha", "")), + } + + def public_projection(registry: dict, human: dict, db: sqlite3.Connection | None = None) -> dict: projection = { "status": "RESOLVED", @@ -273,7 +360,7 @@ class Handler(BaseHTTPRequestHandler): raise ValueError("JSON object required") return value - def authenticated_human(self, registry: dict, payload: dict) -> tuple[dict, str] | None: + def authenticated_human(self, registry: dict, payload: dict) -> tuple[dict, str, str] | None: credentials = parse_basic(self.headers.get("Authorization", "")) number = str(payload.get("human_number", "")) human = find_human(registry, number) @@ -282,7 +369,7 @@ class Handler(BaseHTTPRequestHandler): username, password = credentials if not hmac.compare_digest(username, human["username"]) or not verify_forgejo(username, password): return None - return human, username + return human, username, password def do_GET(self) -> None: try: @@ -349,7 +436,7 @@ class Handler(BaseHTTPRequestHandler): authenticated = self.authenticated_human(registry, payload) if not authenticated: return self.respond(401, {"ok": False, "error": "enterprise account authentication failed"}) - human, username = authenticated + human, username, password = authenticated idempotency_key = str(payload.get("idempotency_key", "")) if not re.fullmatch(r"[A-Za-z0-9_-]{16,96}", idempotency_key): return self.respond(400, {"ok": False, "error": "valid idempotency_key required"}) @@ -363,12 +450,16 @@ class Handler(BaseHTTPRequestHandler): if existing: return self.respond(200, {"ok": True, "idempotent": True, "receipt": dict(existing)}) observed = now() - receipt_id = "GH-REL-" + uuid.uuid4().hex.upper() + receipt_id = stable_receipt_id("GH-REL", human["human_number"], idempotency_key) receipt = signed_receipt({"receipt_id":receipt_id,"human_number":human["human_number"],"username":username,"registry_version":registry["version"],"decision":decision,"observed_at":observed}) + try: + projection = project_receipt_to_repository(human, username, password, "relationship", receipt) + except RuntimeError as error: + return self.respond(503, {"ok": False, "error": str(error)}) db.execute("INSERT INTO relationship_receipts VALUES (?,?,?,?,?,?,?,?,?)", (receipt_id,idempotency_key,human["human_number"],username,registry["version"],decision,observed,receipt["receipt_hash"],receipt["receipt_signature"])) db.execute("INSERT INTO audit(observed_at,kind,human_number_hash,receipt_id) VALUES (?,?,?,?)", (observed,"RELATIONSHIP_CONFIRMATION",hashlib.sha256(human["human_number"].encode()).hexdigest(),receipt_id)) db.commit() - return self.respond(201, {"ok": True, "receipt": receipt}) + return self.respond(201, {"ok": True, "receipt": receipt, "repository_projection": projection}) if self.path == "/v1/responsibility-receipts": decision = str(payload.get("decision", "")) note = str(payload.get("note", ""))[:1000] @@ -379,12 +470,16 @@ class Handler(BaseHTTPRequestHandler): if existing: return self.respond(200, {"ok": True, "idempotent": True, "receipt": dict(existing)}) observed = now() - receipt_id = "GH-RESP-" + uuid.uuid4().hex.upper() + receipt_id = stable_receipt_id("GH-RESP", human["human_number"], idempotency_key) receipt = signed_receipt({"receipt_id":receipt_id,"human_number":human["human_number"],"username":username,"responsibility_domain":human["responsibility_domain"],"responsibility_version":version,"decision":decision,"note":note,"observed_at":observed}) + try: + projection = project_receipt_to_repository(human, username, password, "responsibility", receipt) + except RuntimeError as error: + return self.respond(503, {"ok": False, "error": str(error)}) db.execute("INSERT INTO responsibility_receipts VALUES (?,?,?,?,?,?,?,?,?,?,?)", (receipt_id,idempotency_key,human["human_number"],username,human["responsibility_domain"],version,decision,note,observed,receipt["receipt_hash"],receipt["receipt_signature"])) db.execute("INSERT INTO audit(observed_at,kind,human_number_hash,receipt_id) VALUES (?,?,?,?)", (observed,"RESPONSIBILITY_RECEIPT",hashlib.sha256(human["human_number"].encode()).hexdigest(),receipt_id)) db.commit() - return self.respond(201, {"ok": True, "receipt": receipt}) + return self.respond(201, {"ok": True, "receipt": receipt, "repository_projection": projection}) if self.path == "/v1/me/entry": return self.respond(200, {"ok": True, "entry": public_projection(registry, human, db)}) return self.respond(404, {"ok": False, "error": "not found"}) diff --git a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.test.py b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.test.py index 1788453dc..5662ced62 100644 --- a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.test.py +++ b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.test.py @@ -3,7 +3,9 @@ import json import os import tempfile import unittest +import urllib.error from pathlib import Path +from unittest import mock ROOT = Path(__file__).parent REGISTRY = ROOT / "registry" / "enterprise-identity-registry.json" @@ -60,6 +62,53 @@ class EnterpriseIdentityTests(unittest.TestCase): self.assertIn("/user/settings/change_password", source) self.assertNotIn("FORGEJO_ADMIN_TOKEN", source) + def test_receipt_id_and_repository_path_are_stable_without_exposing_idempotency_key(self): + first = service.stable_receipt_id("GH-RESP", "TCS-GL-0007∞", "responsibility-1234567890") + second = service.stable_receipt_id("GH-RESP", "TCS-GL-0007∞", "responsibility-1234567890") + self.assertEqual(first, second) + self.assertRegex(first, r"^GH-RESP-[A-F0-9]{32}$") + self.assertNotIn("1234567890", first) + self.assertEqual( + service.repository_receipt_path("responsibility", first), + f".guanghu/receipts/responsibility/{first}.json", + ) + + def test_repository_projection_uses_the_humans_own_forgejo_authority(self): + registry = service.load_registry() + human = service.find_human(registry, "TCS-GL-0007∞") + receipt_id = service.stable_receipt_id("GH-REL", human["human_number"], "relationship-1234567890") + receipt = service.signed_receipt( + {"receipt_id": receipt_id, "human_number": human["human_number"], "username": "feimao"} + ) + + class Response: + status = 201 + def __enter__(self): return self + def __exit__(self, *_): return False + def read(self): + return json.dumps({"commit": {"sha": "a" * 40}}).encode() + + with mock.patch.object(service.urllib.request, "urlopen", return_value=Response()) as opened: + projection = service.project_receipt_to_repository( + human, "feimao", "one-use-secret", "relationship", receipt + ) + request = opened.call_args.args[0] + self.assertEqual(projection["repository"], "feimao/guanghu-zero-sense-work") + self.assertIn("/repos/feimao/guanghu-zero-sense-work/contents/", request.full_url) + self.assertTrue(request.headers["Authorization"].startswith("Basic ")) + self.assertNotIn("one-use-secret", request.data.decode()) + + def test_repository_projection_refuses_cross_owner_repository(self): + human = {"repository": "juzi/guanghu-zero-sense-work"} + with self.assertRaisesRegex(RuntimeError, "owner"): + service.project_receipt_to_repository( + human, + "feimao", + "secret", + "relationship", + {"receipt_id": "GH-REL-" + "A" * 32}, + ) + if __name__ == "__main__": unittest.main() diff --git a/product-source/hololake-native-desktop/scripts/enterprise-work-route.test.mjs b/product-source/hololake-native-desktop/scripts/enterprise-work-route.test.mjs new file mode 100644 index 000000000..1ffa33189 --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/enterprise-work-route.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' + +const frontend = fs.readFileSync(new URL('../src/main.tsx', import.meta.url), 'utf8') +const native = fs.readFileSync(new URL('../src-tauri/src/enterprise_work_channel.rs', import.meta.url), 'utf8') +const pncc = fs.readFileSync(new URL('../src-tauri/src/user_pncc_channel.rs', import.meta.url), 'utf8') + +test('every enterprise login enters through Zero Sense and Guanghu Channel before its responsibility domain', () => { + assert.match(frontend, /光湖零感域/) + assert.match(frontend, /公共工作入口/) + assert.match(frontend, /光湖频道/) + assert.match(frontend, /本人责任工作域/) + assert.match(frontend, /worldStage === 'enterpriseWork'/) + assert.match(native, /work_entry_domain: "ZERO_SENSE_DOMAIN"/) + assert.match(native, /work_entry_channel: "GUANGHU_CHANNEL"/) +}) + +test('enterprise work repositories are exact private read-only projections and never personal channels', () => { + for (const repository of [ + 'huaer/guanghu-branch-work', + 'yeye/guanghu-zero-work', + 'feimao/guanghu-zero-sense-work', + 'juzi/guanghu-zero-sense-work', + 'awen/guanghu-main-work', + ]) assert.match(native, new RegExp(repository.replace('/', '\\/'))) + assert.match(native, /AUTHENTICATED_USER_READ_ONLY_NO_PUSH_NO_PERSONAL_NODE_AUTHORITY/) + assert.match(frontend, /个人生活区不会由企业工作账号代建/) + assert.match(pncc, /if domain != "FIFTH_DOMAIN"/) +}) + +test('enterprise responsibility receipts report their private Git projection to the human', () => { + assert.match(frontend, /repository_projection/) + assert.match(frontend, /写入本人私有工作仓库/) +}) diff --git a/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs b/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs index 8e3c04341..d868d59a0 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs @@ -92,7 +92,7 @@ fn keychain_has(host: &str, username: &str) -> bool { } #[cfg(target_os = "macos")] -fn keychain_read(host: &str, username: &str) -> Result { +pub(crate) fn keychain_read(host: &str, username: &str) -> Result { let output = Command::new("/usr/bin/security") .args(["find-internet-password", "-w", "-s", host, "-a", username]) .output() @@ -124,7 +124,7 @@ fn keychain_has(_host: &str, _username: &str) -> bool { } #[cfg(not(target_os = "macos"))] -fn keychain_read(_host: &str, _username: &str) -> Result { +pub(crate) fn keychain_read(_host: &str, _username: &str) -> Result { Err("HOLOLAKE_PERSISTENT_CREDENTIAL_UNAVAILABLE".into()) } @@ -158,7 +158,7 @@ fn expected_account_for_human_number(number: &str) -> Option<&'static str> { } } -fn validate_number_account_binding(number: &str, username: &str) -> Result<(), String> { +pub(crate) fn validate_number_account_binding(number: &str, username: &str) -> Result<(), String> { match expected_account_for_human_number(number) { Some(expected) if username.eq_ignore_ascii_case(expected) => Ok(()), Some(_) => Err("HOLOLAKE_LOGIN_ACCOUNT_NUMBER_MISMATCH".into()), diff --git a/product-source/hololake-native-desktop/src-tauri/src/enterprise_work_channel.rs b/product-source/hololake-native-desktop/src-tauri/src/enterprise_work_channel.rs new file mode 100644 index 000000000..3e8908850 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/enterprise_work_channel.rs @@ -0,0 +1,251 @@ +//! 企业四域私有责任仓库的本机只读工作投影。 +//! +//! 企业账号先进入零感域的光湖频道,再展开本人责任工作域。仓库凭证只从 +//! 系统钥匙串进入一次 Git 子进程环境,不写入 remote URL、配置或文件。 + +use serde::Serialize; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tauri::{AppHandle, State}; + +use crate::code_channel; +use crate::code_repo_login; +use crate::zero_point::{self, ZeroPointState}; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EnterpriseWorkChannelSnapshot { + pub state: &'static str, + pub work_entry_domain: &'static str, + pub work_entry_channel: &'static str, + pub responsibility_domain: String, + pub repository: String, + pub remote_repository_url: String, + pub local_path: String, + pub channel_id: String, + pub authority: &'static str, +} + +#[tauri::command] +pub async fn ensure_enterprise_work_channel( + app: AppHandle, + state: State<'_, ZeroPointState>, +) -> Result { + let Some((human_number, domain)) = zero_point::verified_user_route(&state)? else { + return Err("HOLOLAKE_DOMAIN_ROUTE_REQUIRED".into()); + }; + if domain == "FIFTH_DOMAIN" { + return Err("HOLOLAKE_ENTERPRISE_WORK_ROUTE_REQUIRED".into()); + } + let session = code_repo_login::current_login_session_for_domain(&app, &domain)? + .ok_or_else(|| "HOLOLAKE_LOGIN_SESSION_REQUIRED".to_string())?; + code_repo_login::validate_number_account_binding(&human_number, &session.username)?; + let (repository, registered_domain) = enterprise_repository(&human_number)?; + if registered_domain != domain { + return Err("HOLOLAKE_ENTERPRISE_WORK_DOMAIN_MISMATCH".into()); + } + let password = code_repo_login::keychain_read(&session.host, &session.username)?; + let root = + crate::authenticated_storage::account_storage_root(&app, "enterprise-work-channel-v1")?; + let remote = format!("https://guanghu.chat/code/{repository}.git"); + let local = root.join("repository"); + let username = session.username.clone(); + let repository_owned = repository.to_string(); + let remote_owned = remote.clone(); + let local_for_sync = local.clone(); + tauri::async_runtime::spawn_blocking(move || { + sync_repository_at(&root, &local_for_sync, &remote_owned, &username, &password) + }) + .await + .map_err(|error| format!("HOLOLAKE_ENTERPRISE_WORK_JOIN_FAILED: {error}"))??; + let entry = code_channel::register_managed_repository( + &app, + &local, + format!("{} · 企业责任工作仓库", display_domain(&domain)), + )?; + Ok(EnterpriseWorkChannelSnapshot { + state: "READY_READ_ONLY_WORK_PROJECTION", + work_entry_domain: "ZERO_SENSE_DOMAIN", + work_entry_channel: "GUANGHU_CHANNEL", + responsibility_domain: domain, + repository: repository_owned, + remote_repository_url: remote, + local_path: local.to_string_lossy().into_owned(), + channel_id: entry.channel_id, + authority: "AUTHENTICATED_USER_READ_ONLY_NO_PUSH_NO_PERSONAL_NODE_AUTHORITY", + }) +} + +fn enterprise_repository(number: &str) -> Result<(&'static str, &'static str), String> { + match number { + "TCS-GL-0005∞" => Ok(("huaer/guanghu-branch-work", "BRANCH_DOMAIN")), + "TCS-GL-0006∞" => Ok(("yeye/guanghu-zero-work", "ZERO_DOMAIN")), + "TCS-GL-0007∞" => Ok(("feimao/guanghu-zero-sense-work", "ZERO_SENSE_DOMAIN")), + "TCS-GL-0008∞" => Ok(("juzi/guanghu-zero-sense-work", "ZERO_SENSE_DOMAIN")), + "TCS-GL-0016∞" => Ok(("awen/guanghu-main-work", "MAIN_DOMAIN")), + _ => Err("HOLOLAKE_ENTERPRISE_WORK_BINDING_UNREGISTERED".into()), + } +} + +fn display_domain(domain: &str) -> &'static str { + match domain { + "MAIN_DOMAIN" => "光湖主域", + "BRANCH_DOMAIN" => "光湖分域", + "ZERO_DOMAIN" => "光湖零域", + "ZERO_SENSE_DOMAIN" => "光湖零感域", + _ => "企业责任域", + } +} + +fn sync_repository_at( + root: &Path, + repository: &Path, + remote: &str, + username: &str, + password: &str, +) -> Result<(), String> { + fs::create_dir_all(root) + .map_err(|error| format!("HOLOLAKE_ENTERPRISE_WORK_STORAGE_FAILED: {error}"))?; + let askpass = write_askpass(root)?; + if repository.exists() { + let actual = git_output( + repository, + &askpass, + username, + password, + &["config", "--get", "remote.origin.url"], + )?; + if actual.trim() != remote { + return Err("HOLOLAKE_ENTERPRISE_WORK_REMOTE_MISMATCH".into()); + } + git( + repository, + &askpass, + username, + password, + &["fetch", "--depth", "1", "origin", "main"], + )?; + git( + repository, + &askpass, + username, + password, + &["reset", "--hard", "FETCH_HEAD"], + )?; + git(repository, &askpass, username, password, &["clean", "-fd"])?; + } else { + let destination = repository.to_string_lossy().into_owned(); + git( + root, + &askpass, + username, + password, + &[ + "clone", + "--depth", + "1", + "--branch", + "main", + remote, + &destination, + ], + )?; + } + Ok(()) +} + +fn write_askpass(root: &Path) -> Result { + let path = root.join("git-askpass.sh"); + let body = "#!/bin/sh\ncase \"$1\" in\n *Username*) printf '%s' \"$HOLOLAKE_GIT_USERNAME\" ;;\n *Password*) printf '%s' \"$HOLOLAKE_GIT_PASSWORD\" ;;\n *) exit 1 ;;\nesac\n"; + fs::write(&path, body) + .map_err(|error| format!("HOLOLAKE_ENTERPRISE_WORK_ASKPASS_FAILED: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("HOLOLAKE_ENTERPRISE_WORK_ASKPASS_FAILED: {error}"))?; + } + Ok(path) +} + +fn git( + cwd: &Path, + askpass: &Path, + username: &str, + password: &str, + args: &[&str], +) -> Result<(), String> { + let output = git_command(cwd, askpass, username, password, args) + .output() + .map_err(|error| format!("HOLOLAKE_ENTERPRISE_WORK_GIT_FAILED: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err("HOLOLAKE_ENTERPRISE_WORK_GIT_FAILED".into()) + } +} + +fn git_output( + cwd: &Path, + askpass: &Path, + username: &str, + password: &str, + args: &[&str], +) -> Result { + let output = git_command(cwd, askpass, username, password, args) + .output() + .map_err(|error| format!("HOLOLAKE_ENTERPRISE_WORK_GIT_FAILED: {error}"))?; + if !output.status.success() { + return Err("HOLOLAKE_ENTERPRISE_WORK_GIT_FAILED".into()); + } + String::from_utf8(output.stdout) + .map_err(|_| "HOLOLAKE_ENTERPRISE_WORK_GIT_OUTPUT_INVALID".into()) +} + +fn git_command( + cwd: &Path, + askpass: &Path, + username: &str, + password: &str, + args: &[&str], +) -> Command { + let mut command = Command::new("git"); + command + .current_dir(cwd) + .args(["-c", "credential.helper="]) + .args(args) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", askpass) + .env("HOLOLAKE_GIT_USERNAME", username) + .env("HOLOLAKE_GIT_PASSWORD", password); + command +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn every_enterprise_number_has_one_exact_private_repository() { + assert_eq!( + enterprise_repository("TCS-GL-0007∞").unwrap().0, + "feimao/guanghu-zero-sense-work" + ); + assert_eq!( + enterprise_repository("TCS-GL-0008∞").unwrap().0, + "juzi/guanghu-zero-sense-work" + ); + assert!(enterprise_repository("ICE-GL∞").is_err()); + } + + #[test] + fn askpass_script_contains_no_secret() { + let root = tempdir().unwrap(); + let path = write_askpass(root.path()).unwrap(); + let body = fs::read_to_string(path).unwrap(); + assert!(body.contains("HOLOLAKE_GIT_PASSWORD")); + assert!(!body.contains("temporary-secret")); + } +} 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 491f6a8c0..b55f97fe8 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod code_repo_login; mod direct_local_broker; mod direct_local_session; mod dynamic_capability_routing; +mod enterprise_work_channel; mod glp_envelope; mod home_status; mod knowledge_base; @@ -74,6 +75,7 @@ pub fn run() { code_repo_login::get_enterprise_entry, code_repo_login::confirm_enterprise_persona_relationship, code_repo_login::submit_enterprise_responsibility_receipt, + enterprise_work_channel::ensure_enterprise_work_channel, code_repo_login::sign_out_code_repo_login, user_pncc_channel::get_user_pncc_channel, user_pncc_channel::ensure_user_pncc_channel, diff --git a/product-source/hololake-native-desktop/src-tauri/src/user_pncc_channel.rs b/product-source/hololake-native-desktop/src-tauri/src/user_pncc_channel.rs index bbe08b4af..550deaf72 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/user_pncc_channel.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/user_pncc_channel.rs @@ -104,6 +104,11 @@ fn trusted_subject( let Some((number, domain)) = zero_point::verified_user_route(state)? else { return Ok(None); }; + // 企业账号的代码入口是企业责任仓库;不能自动创建一个本机个人仓库 + // 来冒充工作域或绕过之后的个人节点拥有权验证。 + if domain != "FIFTH_DOMAIN" { + return Ok(None); + } let Some(session) = code_repo_login::current_login_session_for_domain(app, &domain)? else { return Ok(None); }; diff --git a/product-source/hololake-native-desktop/src/main.tsx b/product-source/hololake-native-desktop/src/main.tsx index 2c1cf04cc..50e681eeb 100644 --- a/product-source/hololake-native-desktop/src/main.tsx +++ b/product-source/hololake-native-desktop/src/main.tsx @@ -49,7 +49,7 @@ import './styles.css' type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear' type ViewId = 'overview' | 'knowledge' | 'code' | 'receipts' | 'system' -type WorldStage = 'domain' | 'heart' | 'heartbeat' | 'lightLake' | 'love' | 'tomorrow' | 'bottle' | 'channel' | 'tool' +type WorldStage = 'domain' | 'heart' | 'heartbeat' | 'lightLake' | 'love' | 'tomorrow' | 'bottle' | 'channel' | 'enterpriseWork' | 'tool' type KnowledgeSource = 'native' | 'legacy' interface HomeStatus { @@ -239,6 +239,27 @@ interface EnterpriseEntry { responsibility_receipt?: { decision: string; observed_at: string; receipt_hash: string; responsibility_version: string } | null } interface EnterpriseEntryEnvelope { ok: boolean; entry: EnterpriseEntry } +interface EnterpriseWorkChannelSnapshot { + state: string + workEntryDomain: string + workEntryChannel: string + responsibilityDomain: string + repository: string + remoteRepositoryUrl: string + localPath: string + channelId: string + authority: string +} +interface EnterpriseReceiptProjection { + state: string + repository: string + path: string + commit: string +} +interface EnterpriseReceiptEnvelope { + ok: boolean + repository_projection?: EnterpriseReceiptProjection +} const previewStatus: HomeStatus = { directLocalBrokerState: 'UNVERIFIED', directConnectionCount: 0, resumableSessionCount: 0, codeRepositoryMountCount: 0, pnccReceiptCount: 0, updateState: 'UNPROVISIONED_FAIL_CLOSED', releaseRecoveryState: 'NONE', mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY' } const previewPersonal: PersonalChannelSnapshot = { state: 'UNAVAILABLE', recentEvents: [], integrity: { state: 'UNKNOWN', eventCount: 0, receiptCount: 0 } } @@ -461,6 +482,7 @@ function HoloLakeApp() { const [releaseCandidate, setReleaseCandidate] = useState(null) const [repoLogin, setRepoLogin] = useState(null) const [userPncc, setUserPncc] = useState(null) + const [enterpriseWork, setEnterpriseWork] = useState(null) const [userPnccBusy, setUserPnccBusy] = useState(false) const [userPnccMessage, setUserPnccMessage] = useState('') const [loginUsername, setLoginUsername] = useState('') @@ -604,22 +626,32 @@ function HoloLakeApp() { useEffect(() => { void loadZeroPoint() }, [loadZeroPoint]) - // 已验证编号与已认证账号同时存在时,后端幂等建立或恢复该用户自己的 GH-PNCC。 + // 第五域恢复本人的本机私人频道;企业成员只同步其唯一私有责任工作仓库。 + // 两条路径不能互相冒充:企业工作账号不自动取得个人生活区或个人节点权限。 useEffect(() => { if (!repoLogin || zeroPoint?.route !== 'verified') { setUserPncc(null) + setEnterpriseWork(null) return } let active = true setUserPnccBusy(true) setUserPnccMessage('') - invoke('ensure_user_pncc_channel') - .then((snapshot) => { - if (!active) return - setUserPncc(snapshot) - setUserPnccMessage('本机原生代码频道已就绪。') - void refreshCore() - }) + const channelReady = repoLogin.domain === 'FIFTH_DOMAIN' + ? invoke('ensure_user_pncc_channel').then((snapshot) => { + if (!active) return + setEnterpriseWork(null) + setUserPncc(snapshot) + setUserPnccMessage('本机原生私人频道已就绪。') + }) + : invoke('ensure_enterprise_work_channel').then((snapshot) => { + if (!active) return + setUserPncc(null) + setEnterpriseWork(snapshot) + setUserPnccMessage('本人私有责任工作仓库已接入。') + }) + channelReady + .then(() => { if (active) void refreshCore() }) .catch((error) => { if (active) setUserPnccMessage(humanError(error, 'code')) }) .finally(() => { if (active) setUserPnccBusy(false) }) return () => { active = false } @@ -947,6 +979,7 @@ function HoloLakeApp() { setActiveCodeFile(null) setReceipts([]) setEnterpriseEntry(null) + setEnterpriseWork(null) setEnterpriseReceiptMessage('') setWorldStage('domain') setRepoLogin(null) @@ -955,12 +988,15 @@ function HoloLakeApp() { setEnterpriseReceiptBusy(true) setEnterpriseReceiptMessage('') try { - await invoke('confirm_enterprise_persona_relationship', { + const receipt = await invoke('confirm_enterprise_persona_relationship', { decision, idempotencyKey: `relationship-${crypto.randomUUID().replaceAll('-', '')}`, }) await loadEnterpriseEntry() - setEnterpriseReceiptMessage(decision === 'CONFIRM' ? '人格关系确认已签署并留存。' : '人格关系异议已签署并留存。') + const projected = receipt.repository_projection?.state === 'COMMITTED' || receipt.repository_projection?.state === 'IDEMPOTENT_READBACK' + setEnterpriseReceiptMessage(decision === 'CONFIRM' + ? projected ? '人格关系确认已签署,并写入本人私有工作仓库。' : '人格关系确认已签署并留存。' + : projected ? '人格关系异议已签署,并写入本人私有工作仓库。' : '人格关系异议已签署并留存。') } catch (error) { setEnterpriseReceiptMessage(humanError(error, 'login')) } finally { @@ -972,7 +1008,7 @@ function HoloLakeApp() { setEnterpriseReceiptBusy(true) setEnterpriseReceiptMessage('') try { - await invoke('submit_enterprise_responsibility_receipt', { + const receipt = await invoke('submit_enterprise_responsibility_receipt', { decision, note: responsibilityNote.trim(), responsibilityVersion: enterpriseEntry.registry_version, @@ -980,7 +1016,10 @@ function HoloLakeApp() { }) setResponsibilityNote('') await loadEnterpriseEntry() - setEnterpriseReceiptMessage(decision === 'ACCEPT' ? '域责任已由本人接受并签署。' : '域责任拒绝回执已留存。') + const projected = receipt.repository_projection?.state === 'COMMITTED' || receipt.repository_projection?.state === 'IDEMPOTENT_READBACK' + setEnterpriseReceiptMessage(decision === 'ACCEPT' + ? projected ? '域责任已由本人接受、签署,并写入本人私有工作仓库。' : '域责任已由本人接受并签署。' + : projected ? '域责任拒绝回执已签署,并写入本人私有工作仓库。' : '域责任拒绝回执已留存。') } catch (error) { setEnterpriseReceiptMessage(humanError(error, 'login')) } finally { @@ -1153,19 +1192,20 @@ function HoloLakeApp() { const renderCode = () => (
) const renderReceipts = () => (
-
LOCAL RECEIPTS

回执

本机身份、知识与代码读取的可核验记录

+
{repoLogin?.domain === 'FIFTH_DOMAIN' ? 'LOCAL RECEIPTS' : 'ENTERPRISE RESPONSIBILITY RECEIPTS'}

回执

{repoLogin?.domain === 'FIFTH_DOMAIN' ? '本机身份、知识与代码读取的可核验记录' : '本人签署的关系确认与责任接受状态'}

-

个人空间

{personal.recentEvents.map((event) =>
{String(event.sequence).padStart(2, '0')}
{event.summary}{new Date(event.occurredAtUnixMs).toLocaleString('zh-CN')} · {event.receiptHash.slice(0, 12)}
)}
-

PNCC 读取投影

{receipts.length ? receipts.map((receipt) =>
{String(receipt.sequence).padStart(2, '0')}
{receipt.kind}{new Date(Number(receipt.observedAtUnixMs)).toLocaleString('zh-CN')} · {receipt.eventHash.slice(0, 12)}
) :
尚无 PNCC 运行投影回执
}
+ {repoLogin?.domain === 'FIFTH_DOMAIN' ? <>

个人空间

{personal.recentEvents.map((event) =>
{String(event.sequence).padStart(2, '0')}
{event.summary}{new Date(event.occurredAtUnixMs).toLocaleString('zh-CN')} · {event.receiptHash.slice(0, 12)}
)}
+

PNCC 读取投影

{receipts.length ? receipts.map((receipt) =>
{String(receipt.sequence).padStart(2, '0')}
{receipt.kind}{new Date(Number(receipt.observedAtUnixMs)).toLocaleString('zh-CN')} · {receipt.eventHash.slice(0, 12)}
) :
尚无 PNCC 运行投影回执
}
: <>

人格关系确认

本人编号
{enterpriseEntry?.canonical_id || zeroPoint?.userNumber}
确认状态
{enterpriseEntry?.relationship_confirmation?.decision || '等待确认'}
签署时间
{enterpriseEntry?.relationship_confirmation?.observed_at || '—'}
回执指纹
{enterpriseEntry?.relationship_confirmation?.receipt_hash?.slice(0, 16) || '—'}
+

域责任接受

责任域
{domainDisplayName(enterpriseEntry?.subject.domain || repoLogin?.domain || '')}
接受状态
{enterpriseEntry?.responsibility_receipt?.decision || '等待本人确认'}
责任版本
{enterpriseEntry?.responsibility_receipt?.responsibility_version || enterpriseEntry?.registry_version || '—'}
私有仓库
{enterpriseEntry?.repository_binding.repository || '—'}
}
) @@ -1244,6 +1285,23 @@ function HoloLakeApp() { setWorldStage('tool') } + const openEnterpriseRepository = async () => { + if (!enterpriseWork) { + setUserPnccMessage('本人私有责任工作仓库尚未完成可信接入。') + return + } + const channel = codeChannels.channels.find((item) => item.channelId === enterpriseWork.channelId) + if (!channel) { + setUserPnccMessage('工作仓库已完成认证,正在等待本机只读投影登记。') + await refreshCore() + return + } + await browseChannel(channel) + setView('code') + setToolReturnStage('enterpriseWork') + setWorldStage('tool') + } + const enterpriseRelationshipPending = Boolean(repoLogin && repoLogin.domain !== 'FIFTH_DOMAIN' && enterpriseEntry && enterpriseEntry.relationship_confirmation?.decision !== 'CONFIRM') const enterpriseResponsibilityPending = Boolean(repoLogin && repoLogin.domain !== 'FIFTH_DOMAIN' && enterpriseEntry && !enterpriseRelationshipPending && enterpriseEntry.responsibility_receipt?.decision !== 'ACCEPT') @@ -1315,9 +1373,9 @@ function HoloLakeApp() {
HoloLake
{repoLogin.username} · {domainDisplayName(repoLogin.domain)}
{worldStage === 'domain' &&
-

{domainDisplayName(repoLogin.domain)}

世界正在发生什么

-

光湖公共灯塔 · 在线

协议版本 · {enterpriseEntry?.registry_version || 'HLDP v1.0'}

HoloLake · V0.4.0

- setWorldStage(repoLogin.domain === 'FIFTH_DOMAIN' ? (isZhizhi ? 'heart' : 'channel') : 'channel')}/> +

{repoLogin.domain === 'FIFTH_DOMAIN' ? domainDisplayName(repoLogin.domain) : '光湖零感域'}

{repoLogin.domain === 'FIFTH_DOMAIN' ? '世界正在发生什么' : '公共工作入口 · 世界正在发生什么'}

+

光湖公共灯塔 · 在线

协议版本 · {enterpriseEntry?.registry_version || 'HLDP v1.0'}

{repoLogin.domain !== 'FIFTH_DOMAIN' &&

本人责任域 · {domainDisplayName(enterpriseEntry?.subject.domain || repoLogin.domain)}

}

HoloLake · V0.4.0

+ setWorldStage(repoLogin.domain === 'FIFTH_DOMAIN' ? (isZhizhi ? 'heart' : 'channel') : 'channel')}/> openWorldTool('system')}/>
} {worldStage === 'heart' && isZhizhi &&
@@ -1356,13 +1414,21 @@ function HoloLakeApp() { setWorldStage('love')}/> openWorldTool('system')}/> : <> - openWorldTool('overview')}/> - openWorldTool('knowledge')}/> - openWorldTool('code')}/> - openWorldTool('receipts')}/> + setWorldStage('enterpriseWork')}/> + void openEnterpriseRepository()}/> + openWorldTool('receipts')}/> + setUserPnccMessage('个人生活区不会由企业工作账号代建;登记本人服务器后,系统才会从零感域路由过去。')}/> openWorldTool('system')}/> }
} + {worldStage === 'enterpriseWork' && repoLogin.domain !== 'FIFTH_DOMAIN' &&
+ +

{domainDisplayName(enterpriseEntry?.subject.domain || repoLogin.domain)}

本人责任工作域 · 经零感域光湖频道进入

+ openWorldTool('receipts')}/> + void openEnterpriseRepository()}/> + openWorldTool('system')}/> + {userPnccMessage &&

{userPnccMessage}

} +
} {worldStage === 'heartbeat' && repoLogin.domain === 'FIFTH_DOMAIN' &&

心跳核心频道

冰朔 · ICE-GL∞ · 私人频道全景

@@ -1395,7 +1461,7 @@ function HoloLakeApp() {