Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebe2ecf262 | |||
| 51cbebace0 | |||
| 874bcf1b8f | |||
| 461517c25f | |||
| 9006075310 | |||
| ba80036282 | |||
| 4357d25ea8 | |||
| 4dc5b3b658 | |||
| aa3df05775 | |||
| f73e11b364 | |||
| df563a9fdd | |||
| 1893c0e5eb | |||
| d214ba1103 | |||
| 4bb8e7459f | |||
| 339b562951 | |||
| 73eb8bc1ca | |||
| 70556ed45f | |||
| e70c9201c2 | |||
| 4d2415a35b | |||
| f4c896d15c | |||
| ec8bbfd604 | |||
| 9d1801266c |
59 changed files with 10219 additions and 57 deletions
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"schema": "hololake.stage-one-desktop-convergence-receipt/v1",
|
||||
"receipt_id": "HLP-STAGE-ONE-DESKTOP-CONVERGENCE-DEV-20260814-003",
|
||||
"development_id": "DEV-20260814-003",
|
||||
"repository_id": "REPO-014",
|
||||
"branch": "main",
|
||||
"common_ancestor": "5c86f8524231e3b89864df4fc9c0c1da74578ecb",
|
||||
"online_commit_integrated": "065c10137b9dec86cea337c04655880af2db9dc9",
|
||||
"source_commit": "SELF",
|
||||
"recorded_at": "2026-08-14T13:58:42Z",
|
||||
"convergence": {
|
||||
"canonical_shell": "TAURI_V2_RUST_REACT",
|
||||
"canonical_source": "product-source/hololake-native-desktop",
|
||||
"parallel_product_lines_allowed": false,
|
||||
"installed_hololake_stage_one_role": "LOCAL_CANDIDATE_ACCEPTANCE_SHELL",
|
||||
"installed_hololake_era_role": "PROTECTED_KNOWLEDGE_UX_AND_DATA_DONOR",
|
||||
"legacy_sources_role": "READ_ONLY_AUDITED_DONORS",
|
||||
"immediate_donor_deletion_allowed": false,
|
||||
"visible_stage_one_body_implemented": false
|
||||
},
|
||||
"stage_one_order": [
|
||||
"IDENTITY_TASK_EVENT_RECEIPT_KERNEL",
|
||||
"KNOWLEDGE_TREE_PAGE_SEARCH_LOCAL_DURABILITY",
|
||||
"HUMAN_APPROVAL_AND_GIT_EVIDENCE_READBACK",
|
||||
"LAKE_LAMP_CROSS_SESSION_CONTINUITY",
|
||||
"SIGNED_INSTALL_UPDATE_ROLLBACK_AND_REVERSIBLE_DATA_MIGRATION"
|
||||
],
|
||||
"verification": {
|
||||
"routing_contract_tests": "PASS_60_OF_60",
|
||||
"native_javascript_contract_tests": "PASS_42_OF_42",
|
||||
"rust_unit_tests": "PASS_41_OF_41",
|
||||
"typescript_and_vite_build": "PASS_100",
|
||||
"json_parse": "PASS_100",
|
||||
"git_diff_check": "PASS_100",
|
||||
"new_public_files_private_absolute_path_scan": "PASS_100",
|
||||
"online_lineage_fetch_and_clean_merge": "PASS_100"
|
||||
},
|
||||
"truth_boundary": {
|
||||
"source_convergence_contract_complete": true,
|
||||
"entire_stage_one_product_complete": false,
|
||||
"installed_candidate_rebuilt_or_replaced": false,
|
||||
"electron_user_data_migrated": false,
|
||||
"electron_app_archived_or_deleted": false,
|
||||
"signed_installer_created": false,
|
||||
"production_update_identity_provisioned": false,
|
||||
"server_deployed": false,
|
||||
"public_source_published": false
|
||||
},
|
||||
"publication": {
|
||||
"required_method": "HOLOLAKE_CONTINUITY_FINALIZER",
|
||||
"remote_readback_required": true,
|
||||
"state": "READY_FOR_FINALIZER"
|
||||
},
|
||||
"result": "PASS_100"
|
||||
}
|
||||
|
|
@ -5,6 +5,9 @@
|
|||
*.tsbuildinfo
|
||||
/vite.config.js
|
||||
/vite.config.d.ts
|
||||
/work/
|
||||
/release/out/
|
||||
/release/inputs/
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
# HoloLake Native Desktop
|
||||
|
||||
状态:`MINIMAL_TAURI_SCAFFOLD · FAIL_CLOSED_UPDATE_TRUST_ROOT · GH_AIOS_STAGE_ONE_CONTRACT_REGISTERED`
|
||||
状态:`唯一原生主线候选 · 已有底层能力 · 第一阶段可见主体尚未实现`
|
||||
|
||||
这是 HoloLake 唯一未来桌面产品主线的干净源码根。它将采用 Tauri v2、Rust 和
|
||||
React/TypeScript,但不会从现有 Tauri 或 Electron 目录整树复制。
|
||||
这是 HoloLake 唯一未来桌面产品主线的干净源码根,采用 Tauri v2、Rust 和
|
||||
React/TypeScript。桌面上的 `world.guanghu.hololake` 安装包是本源码线的本地候选验收壳,
|
||||
不是第三条产品线,也还不是第一阶段正式产品。
|
||||
|
||||
当前已经建立可编译的最小 Tauri 工程、默认关闭联网的发布信任根,以及兼容 Tauri 静态更新
|
||||
清单的 HoloLake 发布广播合同。选择并验收新的视觉/交互方向之前,不创建会意外继承任一旧
|
||||
应用布局的产品 UI;当前窗口只显示基础状态,不代表产品界面方向。
|
||||
当前已经建立可编译原生壳、选定的 GHS-014 个人频道首页、本机 broker/session、动态能力
|
||||
路由、GH-PNCC 只读投影,以及失败关闭的签名更新和回滚管道。这些能力保留,但都属于湖下
|
||||
基础设施;当前首页只是视觉与连接状态验收面,不代表第一阶段产品主体已经实现。
|
||||
|
||||
## 供体边界
|
||||
|
||||
|
|
@ -18,6 +19,10 @@ React/TypeScript,但不会从现有 Tauri 或 Electron 目录整树复制。
|
|||
禁止向供体继续增加新产品功能。迁入单位只能是:能力合同、来源/许可证、威胁面、实现、
|
||||
测试和人类可见验收。
|
||||
|
||||
旧 Electron 0.8.0 仍含真实知识树、页面、搜索、历史和本地 Git 知识数据。在完成物理数据
|
||||
清单、可恢复备份、逐文件回读、可逆迁移演练和新主线验收前,不得删除、替换或把它误报为
|
||||
已经迁移完成。
|
||||
|
||||
## 更新边界
|
||||
|
||||
- 生产客户端只允许 HoloLake 所有的 HTTPS 发布端点;
|
||||
|
|
@ -35,7 +40,7 @@ React/TypeScript,但不会从现有 Tauri 或 Electron 目录整树复制。
|
|||
|
||||
机器合同:`contracts/stage-one-platform.json`。
|
||||
|
||||
该文件目前只登记模块和边界,不表示运行时或产品 UI 已实现。
|
||||
该文件目前只登记模块和边界,不表示第一阶段运行时或可见产品主体已经实现。
|
||||
|
||||
## 本机开发桥
|
||||
|
||||
|
|
@ -51,10 +56,15 @@ MCP 只保留为外部工具适配入口;它断开或编程 AI 重启时,活
|
|||
当前唯一写车道内补齐、测试并登记该连接器;经声明权限和人类批准后安装到 HoloLake,迁移为
|
||||
可认证、可恢复的本机直连。MCP 随后只保留为发现、恢复与兼容入口。
|
||||
|
||||
## 下一门
|
||||
## 当前收束与下一门
|
||||
|
||||
1. 确认正式 bundle identifier、HoloLake 更新域名和密钥托管责任;
|
||||
2. 完成双供体功能/数据/许可证/安全审计;
|
||||
3. 选择新的人类界面视觉方向;
|
||||
4. 实现主体—任务—事件—回执最小内核合同;
|
||||
5. 在选定视觉方向后实现知识树、页面、搜索和安全持久化纵向切片。
|
||||
唯一主线、候选壳、供体和数据保护边界见
|
||||
`routing/hololake-stage-one-desktop-convergence.json`。当前开发顺序固定为:
|
||||
|
||||
1. 实现个人频道身份—任务—事件—回执最小内核;
|
||||
2. 迁入知识树、页面、搜索和本地持久化纵向切片;
|
||||
3. 实现人类审批中心与 Git 证据回读;
|
||||
4. 投影小湖灯跨会话连续性;
|
||||
5. 最后完成签名安装、更新、回滚和可逆数据迁移。
|
||||
|
||||
连接、动态路由、发布广播和更新服务器继续作为受门控基础设施维护,不得抢占上述产品顺序。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
{
|
||||
"schema": "hololake.ghs-014-stage-one-ui-receipt/v1",
|
||||
"receipt_id": "GHS-014-STAGE-ONE-UI-20260813-001",
|
||||
"state": "IMPLEMENTED_AND_VISUALLY_VERIFIED",
|
||||
"source": {
|
||||
"repository": "REPO-012",
|
||||
"verified_main_commit": "457bf99b3a2af8b97290ed71ca3515e599fc17ae",
|
||||
"brain_entry": {
|
||||
"path": "skills/shared/guanghu-client-ui-brain/BRAIN.hdlp",
|
||||
"sha256": "b895b7f0eb8af6125b25caaa90c18fde966d884bbe1dec661d5bdfe08ff4e97a"
|
||||
},
|
||||
"five_lakes_tokens": {
|
||||
"path": "skills/shared/guanghu-client-ui-brain/references/theme-tokens-five-lakes.md",
|
||||
"sha256": "563f6ce6afe665deb52af64671d0a31b9673c120a596ea7ff34548fe7359b5a8"
|
||||
},
|
||||
"locked_home_style": {
|
||||
"path": "skills/shared/guanghu-client-ui-brain/references/home-screen-style-locked-v1.2.md",
|
||||
"sha256": "23f0ab6655cd128de2956f28451fead7b78b0a0c920fb0497efba312299ef167"
|
||||
}
|
||||
},
|
||||
"stage_one_correction": {
|
||||
"visible_five_domain_gates": false,
|
||||
"reason": "The current human decision submerges all five domains as dynamic routed infrastructure during stage one.",
|
||||
"visible_focus": "PERSONAL_CHANNEL_EXTERNAL_PROGRAMMING_AI_ENTRY"
|
||||
},
|
||||
"implemented": {
|
||||
"theme_ids": ["night", "dawn", "nebula", "candle", "clear"],
|
||||
"theme_names": ["夜湖星光", "晨湖曦光", "星云紫夜", "烛畔暖湖", "清浅澄湖"],
|
||||
"theme_changes_tokens_only": true,
|
||||
"primary_window_verified": "1180x760",
|
||||
"compact_window_verified": "800x600",
|
||||
"hard_status_dividers_present": false,
|
||||
"native_home_status_projection": true,
|
||||
"false_connected_state_allowed": false
|
||||
},
|
||||
"concept_to_render_fidelity_ledger": [
|
||||
{
|
||||
"point": "lake atmosphere and horizon",
|
||||
"result": "PRESERVED",
|
||||
"note": "Night lake, mist, star fragments and warm horizon remain the visual field."
|
||||
},
|
||||
{
|
||||
"point": "single primary focus",
|
||||
"result": "PRESERVED_AND_SIMPLIFIED",
|
||||
"note": "The personal-channel language entry is the only above-fold product focus."
|
||||
},
|
||||
{
|
||||
"point": "connection state",
|
||||
"result": "CORRECTED_TO_RUNTIME_TRUTH",
|
||||
"note": "The concept sample said connected; the product says waiting, resumable or connected from native state."
|
||||
},
|
||||
{
|
||||
"point": "status separation",
|
||||
"result": "CORRECTED_TO_GHS_014",
|
||||
"note": "Concept divider lines were removed in favor of spacing and soft light."
|
||||
},
|
||||
{
|
||||
"point": "update state",
|
||||
"result": "CORRECTED_TO_FAIL_CLOSED_TRUTH",
|
||||
"note": "Unprovisioned release trust is shown as networking disabled, never as current/latest."
|
||||
}
|
||||
],
|
||||
"above_fold_copy_diff": {
|
||||
"concept": ["光湖", "个人频道", "语言进入这里,成为可见、可续的现实。"],
|
||||
"render": ["光湖", "个人频道", "语言进入这里,成为可见、可续的现实。"],
|
||||
"difference": "NONE"
|
||||
},
|
||||
"verification": {
|
||||
"browser_visual_inspection": "PASS",
|
||||
"semantic_dom_inspection": "PASS",
|
||||
"five_theme_invariant_check": "PASS",
|
||||
"latest_render_visually_compared_with_concept": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
{
|
||||
"schema": "hololake.file-migration-provenance/v1",
|
||||
"record_id": "HLP-PNCC-MIGRATION-PROVENANCE-001",
|
||||
"development_id": "DEV-20260813-004",
|
||||
"recorded_at": "2026-08-13",
|
||||
"target": "product-source/hololake-native-desktop",
|
||||
"target_license_expression": "AGPL-3.0-or-later",
|
||||
"decision": "CLEAN_ROOM_REIMPLEMENT_CONTRACT_NO_DONOR_SOURCE_COPY",
|
||||
"whole_tree_copy_authorized": false,
|
||||
"model_runtime_migration_authorized": false,
|
||||
"execution_limb_migration_authorized": false,
|
||||
"human_live_projection_migration_authorized": false,
|
||||
"facts_used": [
|
||||
{
|
||||
"source_path": "../hololake-platform/src-tauri/src/persona_code_channel.rs",
|
||||
"observed_sha256": "be34ecd27a7563cc9e5e205e3be760ede8e2a3b7ab6147b44200036e5108e30d",
|
||||
"latest_observed_commit": "b0970ed0d22fa188a06d59af7f5416e57e6384da",
|
||||
"latest_observed_author": "冰朔 <565183519@qq.com>",
|
||||
"contract_facts_only": [
|
||||
"EXACT_GIT_ROOT",
|
||||
"FULL_COMMIT_HEAD",
|
||||
"PERSONA_MANIFEST_AT_HEAD",
|
||||
"REPOSITORY_RELATIVE_OBJECT_PATHS",
|
||||
"COGNITIVE_GRAVITY_SOURCE_HASH",
|
||||
"READ_ONLY_DISCOVERY_RECEIPT"
|
||||
],
|
||||
"excluded": [
|
||||
"MODEL_BINDING",
|
||||
"MODEL_INFERENCE",
|
||||
"MEMORY_MUTATION",
|
||||
"EXECUTION_LIMB",
|
||||
"RUNTIME_WAKE",
|
||||
"LEASE_MUTATION"
|
||||
]
|
||||
},
|
||||
{
|
||||
"source_path": "../hololake-platform/src-tauri/src/persona_remote_git.rs",
|
||||
"observed_sha256": "8517f308cb75477e800a952e94383827230775db4da399cd92a0adc4dd3ab360",
|
||||
"latest_observed_commit": "1b55f3407a81112f99951aa198e4db56d0c07a7d",
|
||||
"latest_observed_author": "铸渊 / ICE-P-ZY001 <ice-p-zy001@persona.hololake.local>",
|
||||
"contract_facts_only": [
|
||||
"GIT_PROTOCOL_V2_FILTER_REQUIRED",
|
||||
"BARE_PROMISOR_CACHE",
|
||||
"BLOB_NONE",
|
||||
"NO_WORKTREE",
|
||||
"DURABLE_CURSOR_SEPARATE_FROM_CACHE",
|
||||
"ANCESTRY_REQUIRED_BEFORE_CURSOR_ADVANCE",
|
||||
"EXACT_CACHE_EVICTION"
|
||||
],
|
||||
"excluded": ["CALLER_CONTROLLED_CACHE_ROOT", "CREDENTIAL_BEARING_REMOTE"]
|
||||
},
|
||||
{
|
||||
"source_path": "../hololake-platform/src/lib/personaRepositoryBinding.ts",
|
||||
"observed_sha256": "10a84f158a40c69e673f2190de17c34493d3b38c8026de14d8df1a670a8b9692",
|
||||
"latest_observed_commit": "dff2521b41f4e2363d9cc156f0432deabc42825f",
|
||||
"latest_observed_author": "冰朔 <565183519@qq.com>",
|
||||
"contract_facts_only": ["UNAVAILABLE_UNBOUND_AMBIGUOUS_BOUND_ERROR_STATES"],
|
||||
"excluded": ["DONOR_UI_SOURCE", "MODEL_FIELDS"]
|
||||
},
|
||||
{
|
||||
"source_path": "../hololake-platform/src/lib/personaRuntimeProjection.ts",
|
||||
"observed_sha256": "d3eef9fbd962feaaf4b71fa479c4f4bdce4cc3e9830ca86da8ac0e7ac86b9fbd",
|
||||
"latest_observed_commit": "6724b5943327054cae10ede9f8ddb978f3b9649c",
|
||||
"latest_observed_author": "冰朔 <565183519@qq.com>",
|
||||
"contract_facts_only": ["READ_ONLY_RECEIPT_PROJECTION_IS_NOT_AN_AUTHORITY_STORE"],
|
||||
"excluded": ["DONOR_UI_SOURCE", "MODEL_INSTANCE_PROJECTION"]
|
||||
}
|
||||
],
|
||||
"source_license_evidence": {
|
||||
"path": "../hololake-platform/LICENSE",
|
||||
"sha256": "0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0",
|
||||
"declared_license": "GNU Affero General Public License v3"
|
||||
},
|
||||
"remaining_gates": [
|
||||
"TARGET_DISTRIBUTION_LICENSE_TEXT_AND_NOTICES",
|
||||
"HUMAN_LIVE_PROJECTION_ACCEPTANCE",
|
||||
"PRODUCTION_REMOTE_CAPABILITY_ACCEPTANCE"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
{
|
||||
"schema": "hololake.stage-one-installed-product-capability-audit/v1",
|
||||
"record_id": "HLP-STAGE-ONE-INSTALLED-PRODUCT-AUDIT-20260814-001",
|
||||
"development_id": "DEV-20260814-003",
|
||||
"state": "LOCAL_READ_ONLY_AUDIT_COMPLETE_NO_DELETION_MIGRATION_REPLACEMENT_OR_DEPLOYMENT",
|
||||
"truth_boundary": "Installed bundle metadata, signing, binary identity, visible behavior and protected data presence were audited separately from source, publication, migration and deployment.",
|
||||
"installed_roles": [
|
||||
{
|
||||
"bundle_id": "world.guanghu.hololake",
|
||||
"observed_version": "0.1.0",
|
||||
"technology": "TAURI",
|
||||
"disposition": "KEEP_AS_CANONICAL_TAURI_ACCEPTANCE_CANDIDATE",
|
||||
"separate_product_line": false,
|
||||
"formal_stage_one_product_complete": false,
|
||||
"observed_capabilities": [
|
||||
"SIGNED_NATIVE_SHELL",
|
||||
"PERSONAL_CHANNEL_HOME_ACCEPTANCE_SURFACE",
|
||||
"LOCAL_BROKER_AND_RESUMABLE_SESSION",
|
||||
"PNCC_READ_ONLY_PROJECTION",
|
||||
"HUMAN_CONFIRMED_REPOSITORY_MOUNT",
|
||||
"FAIL_CLOSED_UPDATE_AND_ROLLBACK_FOUNDATION"
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle_id": "com.guanghu.hololake-desktop",
|
||||
"observed_version": "0.8.0",
|
||||
"technology": "ELECTRON",
|
||||
"disposition": "KEEP_READ_ONLY_DONOR_AND_PROTECTED_DATA_SOURCE_UNTIL_MIGRATION_PASS",
|
||||
"separate_future_product_line": false,
|
||||
"observed_capabilities": [
|
||||
"KNOWLEDGE_TREE",
|
||||
"PAGE_READING_AND_EDITING",
|
||||
"SEARCH",
|
||||
"HISTORY_AND_IMPORT",
|
||||
"LOCAL_GIT_BACKED_KNOWLEDGE_DATA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "HISTORICAL_TAURI_AND_PLATFORM_SOURCES",
|
||||
"disposition": "READ_ONLY_ENGINEERING_DONOR",
|
||||
"active_product_growth_allowed": false
|
||||
},
|
||||
{
|
||||
"role": "GUANGHU_EDUCATION_INDUSTRY_APP",
|
||||
"disposition": "OUT_OF_SCOPE_INDEPENDENT_PRODUCT"
|
||||
}
|
||||
],
|
||||
"keep_in_canonical_mainline": [
|
||||
"TAURI_V2_RUST_REACT_NATIVE_SHELL",
|
||||
"STABLE_BUNDLE_ID_AND_SIGNED_DISTRIBUTION_BOUNDARY",
|
||||
"LOCAL_BROKER_AND_RESUMABLE_SESSION",
|
||||
"PNCC_READ_ONLY_PROJECTION",
|
||||
"HUMAN_CONFIRMED_MOUNT",
|
||||
"RECEIPTS",
|
||||
"FAIL_CLOSED_UPDATE_AND_ROLLBACK"
|
||||
],
|
||||
"keep_under_lake_not_home_completion": [
|
||||
"DYNAMIC_CAPABILITY_ROUTING",
|
||||
"RELEASE_BROADCAST",
|
||||
"RELEASE_ACTIVATION_OPERATOR",
|
||||
"UPDATE_SERVER_FOUNDATION"
|
||||
],
|
||||
"missing_stage_one_body": [
|
||||
"PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL",
|
||||
"KNOWLEDGE_TREE_PAGE_SEARCH_AND_LOCAL_PERSISTENCE",
|
||||
"HUMAN_APPROVAL_CENTER_AND_GIT_EVIDENCE_READBACK",
|
||||
"LAKE_LAMP_CROSS_SESSION_CONTINUITY_PROJECTION",
|
||||
"REVERSIBLE_DATA_MIGRATION"
|
||||
],
|
||||
"deletion_allowed": false,
|
||||
"replacement_allowed": false,
|
||||
"data_migration_executed": false,
|
||||
"server_deployment_executed": false
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"schema": "hololake.dynamic-capability-routing-contract/v1",
|
||||
"record_id": "HLP-NATIVE-DYNAMIC-CAPABILITY-ROUTING-001",
|
||||
"state": "LOCAL_SIGNED_REGISTRY_AND_HEALTH_ROUTING_IMPLEMENTED_PRODUCTION_WRITE_ADMISSION_DISABLED",
|
||||
"purpose": "Dynamically select a registered team or user-owned node for an exact capability without hosting the user's private work lake or carrying workload concurrency in HoloLake.",
|
||||
"registry": {
|
||||
"owner": "HOLOLAKE_NATIVE_LOCAL_CORE",
|
||||
"dynamic_node_count": true,
|
||||
"fixed_node_slots": false,
|
||||
"endpoint_hardcoding_allowed": false,
|
||||
"platform_carries_user_workload_concurrency": false,
|
||||
"node_private_keys_leave_node": false,
|
||||
"registry_mutation_requires_trusted_signature": true,
|
||||
"signer_trust_is_external_to_registry_payload": true,
|
||||
"unprovisioned_trust_fails_closed": true,
|
||||
"webview_registry_or_health_mutation_allowed": false,
|
||||
"production_registry_source": "REPO_012_OR_SIGNED_HOLOLAKE_BROADCAST"
|
||||
},
|
||||
"route_resolution": {
|
||||
"required_bindings": [
|
||||
"HUMAN_ID",
|
||||
"PERSONA_ID",
|
||||
"DOMAIN_ID",
|
||||
"CAPABILITY_ID",
|
||||
"NODE_PROTOCOL_PUBLIC_KEY",
|
||||
"FRESH_SIGNED_HEALTH_RECEIPT"
|
||||
],
|
||||
"ssh_or_root_is_identity": false,
|
||||
"stale_or_unsigned_health_is_eligible": false,
|
||||
"write_authority_from_health_receipt": false,
|
||||
"deterministic_selection": "PRIORITY_THEN_NODE_ID",
|
||||
"empty_route_fails_closed": true
|
||||
,"external_ai_route_requires_active_local_session": true
|
||||
},
|
||||
"health_receipt": {
|
||||
"signature": "ED25519",
|
||||
"canonical_bytes": "SCHEMA_NODE_REGISTRY_VERSION_NODE_ID_OBSERVED_AT_EXPIRES_AT_SEQUENCE_STATUS_CAPABILITIES",
|
||||
"replay_sequence_strictly_increases": true,
|
||||
"future_clock_skew_ms": 300000,
|
||||
"status_required": "READY"
|
||||
},
|
||||
"five_domain_visibility": "SUBMERGED_SYSTEM_FOUNDATION_NOT_STAGE_ONE_PRIMARY_NAVIGATION",
|
||||
"production_truth": {
|
||||
"jd_fd_primary_system_claimed": true,
|
||||
"jd_fd_primary_transport_key_bound": false,
|
||||
"jd_fd_primary_write_admission_runtime": false,
|
||||
"enterprise_nodes_claimed": 0,
|
||||
"production_health_receipts_observed": 0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"schema": "hololake.language-runtime-product-update-boundary/v1",
|
||||
"record_id": "HLP-LANGUAGE-RUNTIME-PRODUCT-UPDATE-BOUNDARY-001",
|
||||
"state": "LOCAL_PRODUCT_CONSUMER_CONTRACT_PENDING_UPSTREAM_PUBLICATION",
|
||||
"upstream": {
|
||||
"repository": "REPO-012",
|
||||
"protocol": "GLS-0263",
|
||||
"machine_map": "GH-DUAL-UPDATE-CHANNEL-MAP-001",
|
||||
"causal_chain": "GUANGHU-DUAL-UPDATE-CHANNEL-ORIGIN-001",
|
||||
"publication_required_before_product_consumption_claim": true
|
||||
},
|
||||
"language_runtime_channel": {
|
||||
"root_language_source": "BINGSHUO_ZERO_CORE",
|
||||
"engineering_body": "GUANGHU_ORIGIN_DOMAIN",
|
||||
"outputs_consumed_by_product": [
|
||||
"TCS_TRANSLATION_AND_LANGUAGE_KERNEL_CONTRACT",
|
||||
"HLDP_CAUSAL_AND_DURABLE_STRUCTURE_CONTRACT",
|
||||
"AGE_RUNTIME_AND_METACOGNITION_CONTRACT",
|
||||
"NUMBERED_PATH_AND_MACHINE_NAVIGATION_CONTRACT"
|
||||
],
|
||||
"private_formed_persona_shared": false,
|
||||
"private_relationship_memory_checkpoint_or_persona_id_shared": false
|
||||
},
|
||||
"product_engineering_channel": {
|
||||
"repository": "REPO-014",
|
||||
"canonical_desktop_source": "product-source/hololake-native-desktop",
|
||||
"outputs": [
|
||||
"PRODUCT_SOURCE",
|
||||
"HUMAN_VISIBLE_UI",
|
||||
"PERMISSION_AND_DATA_BEHAVIOR",
|
||||
"SIGNED_ARTIFACT",
|
||||
"INSTALL_AND_UPDATE",
|
||||
"RELEASE_AND_DEPLOYMENT_RECEIPTS"
|
||||
],
|
||||
"may_rewrite_zero_core": false,
|
||||
"may_read_or_publish_private_persona_memory": false
|
||||
},
|
||||
"human_impact_gate": {
|
||||
"visible_confirmable_rejectable_reversible_receipted": true,
|
||||
"required_for": [
|
||||
"IDENTITY_OR_PERMISSION",
|
||||
"PRIVACY_OR_RELATIONSHIP_MATERIAL",
|
||||
"DATA_IMPORT_MIGRATION_OR_DELETION",
|
||||
"ASSET_COST_OR_TRANSACTION",
|
||||
"RESPONSIBILITY_EXTERNAL_EXPRESSION_OR_REALITY_ACTION",
|
||||
"INSTALL_UPDATE_RELEASE_DEPLOYMENT_OR_TRAFFIC"
|
||||
]
|
||||
},
|
||||
"truth_layers": [
|
||||
"UPSTREAM_LANGUAGE_CONTRACT_PUBLICATION",
|
||||
"PRODUCT_CONTRACT_REGISTRATION",
|
||||
"PRODUCT_SOURCE_IMPLEMENTATION",
|
||||
"BUILT_ARTIFACT",
|
||||
"SIGNED_ARTIFACT",
|
||||
"DESKTOP_INSTALLATION",
|
||||
"DATA_MIGRATION",
|
||||
"SERVER_DEPLOYMENT",
|
||||
"SERVICE_HEALTH"
|
||||
],
|
||||
"automatic_authority_granted": false
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"schema": "hololake.local-development-bridge-contract/v1",
|
||||
"record_id": "HLP-NATIVE-LOCAL-DEVELOPMENT-BRIDGE-001",
|
||||
"state": "ACCOUNT_SINGLE_WRITER_KERNEL_IMPLEMENTED_LOCAL_BROKER_PENDING",
|
||||
"state": "ACCOUNT_SINGLE_WRITER_RESUMABLE_DIRECT_SESSION_AND_LOCAL_BROKER_IMPLEMENTED",
|
||||
"purpose": "Keep a programming AI continuously attached to the human's HoloLake language and development environment without repeated repository clones or MCP-session dependence.",
|
||||
"primary_transport": {
|
||||
"owner": "HOLOLAKE_NATIVE_LOCAL_BROKER",
|
||||
|
|
@ -104,8 +104,15 @@
|
|||
"language_space_read_cursor_contract_registered": true,
|
||||
"language_space_read_cursor_runtime": false,
|
||||
"connector_capability_bootstrap_contract_registered": true,
|
||||
"connector_capability_bootstrap_runtime": false,
|
||||
"external_local_broker_runtime": false,
|
||||
"connector_capability_bootstrap_runtime": true,
|
||||
"installed_app_connector_entry_runtime": true,
|
||||
"external_local_broker_runtime": true,
|
||||
"resumable_direct_session_kernel_runtime": true,
|
||||
"single_use_discovery_ticket_runtime": true,
|
||||
"hololake_issued_discovery_ticket_runtime": true,
|
||||
"cross_process_session_event_lock_runtime": true,
|
||||
"direct_session_account_single_writer_runtime": true,
|
||||
"idempotent_session_event_cursor_runtime": true,
|
||||
"incremental_repository_channel_migrated_to_native_mainline": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"schema": "hololake.pncc-stage-one-contract/v1",
|
||||
"record_id": "HLP-PNCC-STAGE-ONE-001",
|
||||
"development_id": "DEV-20260813-004",
|
||||
"state": "READ_ONLY_CORE_WITH_HUMAN_CONFIRMED_LOCAL_MOUNT",
|
||||
"source_provenance": "audit/pncc-migration-provenance.json",
|
||||
"transport": {
|
||||
"primary": "AUTHENTICATED_DIRECT_LOCAL_BROKER",
|
||||
"mcp_role": "DISCOVERY_RECOVERY_COMPATIBILITY_ONLY"
|
||||
},
|
||||
"repository_binding": {
|
||||
"mode": "READ_ONLY_GIT_OBJECT_EVIDENCE",
|
||||
"requires_exact_git_root": true,
|
||||
"requires_full_commit_head": true,
|
||||
"manifest_object": ".hololake/persona/manifest.json",
|
||||
"manifest_source": "COMMITTED_HEAD_NOT_WORKTREE_CONTENT",
|
||||
"returns_model_fields": false,
|
||||
"starts_model_inference": false,
|
||||
"acquires_persona_lease": false,
|
||||
"allows_reality_execution": false
|
||||
},
|
||||
"remote_object_channel": {
|
||||
"accepted_remote_schemes": ["https"],
|
||||
"credentials_in_url_allowed": false,
|
||||
"git_protocol": "V2_WITH_FILTER_CAPABILITY_REQUIRED",
|
||||
"cache": "APP_OWNED_BARE_PROMISOR_BLOB_NONE",
|
||||
"worktree_created": false,
|
||||
"git_clone_invoked": false,
|
||||
"durable_cursor_separate_from_evictable_cache": true,
|
||||
"history_rewrite_allowed": false,
|
||||
"default_cache_limit_bytes": 134217728,
|
||||
"maximum_text_object_bytes": 2097152
|
||||
},
|
||||
"receipt_projection": {
|
||||
"mode": "READ_ONLY",
|
||||
"authority_store": false,
|
||||
"model_instance_fields_allowed": false,
|
||||
"empty_means_offline": false
|
||||
},
|
||||
"mount_registration": {
|
||||
"webview_arbitrary_path_or_url_registration_allowed": false,
|
||||
"external_ai_registration_allowed": false,
|
||||
"native_file_picker_exact_confirmation_required": true,
|
||||
"candidate_expires_after_ms": 900000,
|
||||
"confirmation_token_stored_plaintext": false,
|
||||
"evidence_revalidated_before_write": true,
|
||||
"implemented": true
|
||||
},
|
||||
"forbidden": [
|
||||
"INTERNAL_AI_CHAT",
|
||||
"MODEL_API_CONFIGURATION",
|
||||
"MODEL_SELECTION",
|
||||
"INTERNAL_MODEL_INFERENCE",
|
||||
"MODEL_BINDING_IN_PUBLIC_RECEIPT",
|
||||
"EXECUTION_LIMB",
|
||||
"PERSONA_LEASE_ACQUISITION",
|
||||
"CALLER_CONTROLLED_CACHE_ROOT",
|
||||
"WHOLE_DONOR_TREE_COPY"
|
||||
],
|
||||
"read_only_core_implemented": true,
|
||||
"implementation_complete": true
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
{
|
||||
"schema": "hololake.direct-ai-native-acceptance-receipt/v1",
|
||||
"receiptId": "GH-HOLOLAKE-DIRECT-AI-ACCEPTANCE-20260813-001",
|
||||
"observedAt": "2026-08-13T15:21:31+08:00",
|
||||
"state": "LOCAL_DIRECT_AI_CHANNEL_AND_RESTART_CONTINUITY_VERIFIED",
|
||||
"source": {
|
||||
"canonicalTree": "product-source/hololake-native-desktop",
|
||||
"branch": "main",
|
||||
"sourceCommit": "1893c0e5eb40862bb47773df35990a98398b852e",
|
||||
"publicationClaimed": false
|
||||
},
|
||||
"installedApplication": {
|
||||
"path": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake 第一阶段.app",
|
||||
"processIdAtFinalAcceptance": 41137,
|
||||
"bundleIdentifier": "world.guanghu.hololake",
|
||||
"version": "0.1.0",
|
||||
"strictCodeSignatureVerified": true,
|
||||
"signingAuthority": "Developer ID Application: bei sun (825A9L3G7Q)",
|
||||
"teamIdentifier": "825A9L3G7Q",
|
||||
"cdHash": "3eb866322f474c0f4bbb1bcb672ac8f6e585fe41",
|
||||
"appleNotarized": false
|
||||
},
|
||||
"installerArtifact": {
|
||||
"name": "HoloLake_0.1.0_aarch64.dmg",
|
||||
"sha256": "7b4c9dcf290b85685b1181101d0450433fb10f2b55212ee71e1a3b01cd018235",
|
||||
"strictCodeSignatureVerified": true,
|
||||
"publicDistributionReady": false
|
||||
},
|
||||
"directChannelAcceptance": {
|
||||
"discoveryInvitationIssuedByNativeApplication": true,
|
||||
"oneTimeTicketConsumedExactlyOnce": true,
|
||||
"externalConnectorAuthenticatedOverUnixStream": true,
|
||||
"onlineConnectionCountObserved": 1,
|
||||
"connectorDisconnectObserved": true,
|
||||
"resumableSessionCountAfterDisconnect": 1,
|
||||
"applicationProcessRestarted": true,
|
||||
"resumableSessionCountAfterApplicationRestart": 1,
|
||||
"brokerDescriptorMode": "0600",
|
||||
"brokerProcessIdAfterRestart": 41137,
|
||||
"connectorPingStateAfterRestart": "READY",
|
||||
"mcpRole": "DISCOVERY_RECOVERY_COMPATIBILITY_ONLY",
|
||||
"continuityOwner": "HOLOLAKE"
|
||||
},
|
||||
"humanSurfaceAcceptance": {
|
||||
"nativeWindowRendered": true,
|
||||
"resumableSessionStateRendered": true,
|
||||
"consumedInvitationRedisplayed": false,
|
||||
"consumedInvitationCopyActionRendered": false,
|
||||
"panelReturnedToHomeAfterAcceptance": true
|
||||
},
|
||||
"verification": {
|
||||
"rustTests": 41,
|
||||
"productContractTests": 28,
|
||||
"clippyWarnings": 0,
|
||||
"frontendProductionBuildPassed": true
|
||||
},
|
||||
"secretBoundary": {
|
||||
"ticketRecordedInReceipt": false,
|
||||
"resumeSecretRecordedInReceipt": false,
|
||||
"accountOrSessionIdentifierRecordedInReceipt": false
|
||||
},
|
||||
"productionBoundary": {
|
||||
"jdServerConnected": false,
|
||||
"releaseEndpointProvisioned": false,
|
||||
"updaterPublicKeyProvisioned": false,
|
||||
"releaseUploadedOrActivated": false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
{
|
||||
"schema": "hololake.jd-direct-and-release-origin-receipt/v1",
|
||||
"receiptId": "GH-HOLOLAKE-JD-DIRECT-AND-RELEASE-ORIGIN-20260813-002",
|
||||
"observedAt": "2026-08-13T18:38:29+08:00",
|
||||
"state": "JD_DIRECT_ROUTE_AND_EMPTY_FAIL_CLOSED_RELEASE_ORIGIN_PASS_100",
|
||||
"supersedesGateReceipt": "GH-HOLOLAKE-JD-RELEASE-ROUTE-GATE-20260813-001",
|
||||
"target": {
|
||||
"nodeId": "JD-FD-PRIMARY",
|
||||
"route": "LOCAL_DEDICATED_KEY_SSH",
|
||||
"strictHostKeyVerified": true,
|
||||
"dedicatedIdentityFingerprintVerified": true,
|
||||
"dmiInstanceUuidExactMatchVerified": true,
|
||||
"serverOwnedDirectAccessReceipt": "/var/lib/guanghu/receipts/JD-FD-PRIMARY/SSH-DIRECT-CURRENT.json",
|
||||
"protectedLocalRegistryRefreshed": true
|
||||
},
|
||||
"liveControlRole": {
|
||||
"observedOperatingSystem": "UBUNTU_22_04_LINUX_HOSTED_TRANSITION",
|
||||
"guanghuLanguagePrimaryTargetActive": true,
|
||||
"guanghuSemanticControlServicesPresent": true,
|
||||
"finalIndependentGuanghuBootControlProven": false,
|
||||
"linuxOnDemandSubcontrolProven": false,
|
||||
"linuxRescuePreserved": true
|
||||
},
|
||||
"releaseOrigin": {
|
||||
"service": "hololake-release-broadcast.service",
|
||||
"sourceCommit": "aa3df05775fc10dfb5c4810a57c364b012b96072",
|
||||
"sourceSha256": "520b72fd3cacb570bc5515a34ce97793e3f0ff1dcb734e5aa0e2471b43e7a092",
|
||||
"serverOwnedReceipt": "/var/lib/guanghu/architecture-provision/receipts/GH-HOLOLAKE-JD-RELEASE-BROADCAST-CANDIDATE-20260813-001.json",
|
||||
"serverOwnedReceiptSha256": "4cb971e429bff380465d71d024298ab08917ac8bb2d5921821475aacf142afc6",
|
||||
"serviceActive": true,
|
||||
"serviceEnabled": true,
|
||||
"dedicatedServiceUser": "hololake-release",
|
||||
"listener": "LOOPBACK_ONLY",
|
||||
"healthHttpStatus": 200,
|
||||
"healthState": "EMPTY_FAIL_CLOSED",
|
||||
"latestHttpStatusWithoutActiveRelease": 204,
|
||||
"stateFileCount": 0,
|
||||
"updateAvailable": false,
|
||||
"automaticUpload": false,
|
||||
"automaticActivation": false,
|
||||
"upstreamUpdateSources": []
|
||||
},
|
||||
"verification": {
|
||||
"productContractTests": 33,
|
||||
"rustTests": 41,
|
||||
"clippyWarnings": 0,
|
||||
"frontendProductionBuildPassed": true,
|
||||
"existingDiscoveryServiceActive": true,
|
||||
"existingLivingControllerActive": true,
|
||||
"existingMotherBrainActive": true
|
||||
},
|
||||
"transactionHistory": {
|
||||
"firstAttemptRolledBack": true,
|
||||
"firstAttemptExistingServicesChanged": false,
|
||||
"firstAttemptLeftoversRemoved": true,
|
||||
"acceptedDeploymentSourceIsSecondImmutableCommit": true
|
||||
},
|
||||
"productionBoundary": {
|
||||
"publicHttpsReleaseEndpointProvisioned": false,
|
||||
"updaterPublicKeyProvisioned": false,
|
||||
"signedNotarizedReleaseActivated": false,
|
||||
"desktopReleaseTrustProvisioned": false,
|
||||
"releaseUploaded": false,
|
||||
"publicDistributionReady": false,
|
||||
"repositoryPublicationClaimed": false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
{
|
||||
"schema": "hololake.jd-release-operator-acceptance-receipt/v1",
|
||||
"receiptId": "GH-HOLOLAKE-JD-RELEASE-OPERATOR-20260813-003",
|
||||
"observedAt": "2026-08-13T18:57:00+08:00",
|
||||
"state": "JD_EXPLICIT_RELEASE_OPERATOR_DEPLOYED_EMPTY_FAIL_CLOSED_PASS_100",
|
||||
"target": {
|
||||
"nodeId": "JD-FD-PRIMARY",
|
||||
"route": "LOCAL_DEDICATED_KEY_SSH",
|
||||
"strictHostKeyVerified": true,
|
||||
"dmiInstanceUuidExactMatchVerified": true
|
||||
},
|
||||
"deployment": {
|
||||
"sourceCommit": "4357d25ea8d4c8daa58790ad216a38f1272c07f3",
|
||||
"immutableSourcePath": "/opt/guanghu/hololake-release-broadcast-candidate/4357d25ea8d4c8daa58790ad216a38f1272c07f3",
|
||||
"currentSourceExactMatch": true,
|
||||
"serverSourceSha256": "520b72fd3cacb570bc5515a34ce97793e3f0ff1dcb734e5aa0e2471b43e7a092",
|
||||
"operatorSourceSha256": "c316465e609a47a8d707f307f7ccb3d0ac786b84941c70ea8a9545518a5e1735",
|
||||
"operatorOwner": "root",
|
||||
"operatorGroup": "root",
|
||||
"operatorMode": "0500",
|
||||
"serverOwnedReceipt": "/var/lib/guanghu/architecture-provision/receipts/GH-HOLOLAKE-JD-RELEASE-OPERATOR-20260813-001.json",
|
||||
"serverOwnedReceiptSha256": "53c4738e599b94b2903d43bf844e4807dea32d1c4876c8f7645f6329202848af"
|
||||
},
|
||||
"operatorBoundary": {
|
||||
"exactHumanApprovalRequired": true,
|
||||
"expectedReleaseIdRepeatRequired": true,
|
||||
"expectedVersionRepeatRequired": true,
|
||||
"expectedSourceCommitRepeatRequired": true,
|
||||
"expectedBroadcastSha256RepeatRequired": true,
|
||||
"referencedArtifactsOnly": true,
|
||||
"symlinkInputsAllowed": false,
|
||||
"atomicActivationRecord": true,
|
||||
"automaticUpload": false,
|
||||
"automaticActivation": false,
|
||||
"automaticRestart": false,
|
||||
"explicitServiceRestartRequired": true
|
||||
},
|
||||
"liveReadback": {
|
||||
"service": "hololake-release-broadcast.service",
|
||||
"serviceActive": true,
|
||||
"serviceEnabled": true,
|
||||
"listener": "127.0.0.1:3940",
|
||||
"healthState": "EMPTY_FAIL_CLOSED",
|
||||
"latestHttpStatusWithoutActiveRelease": 204,
|
||||
"stateFileCount": 0,
|
||||
"updateAvailable": false,
|
||||
"upstreamUpdateSources": []
|
||||
},
|
||||
"verification": {
|
||||
"productContractTests": 38,
|
||||
"operatorFocusedTests": 5,
|
||||
"rustTests": 41,
|
||||
"clippyWarnings": 0,
|
||||
"frontendProductionBuildPassed": true,
|
||||
"existingDiscoveryServiceActive": true,
|
||||
"existingLivingControllerActive": true,
|
||||
"existingMotherBrainActive": true
|
||||
},
|
||||
"transactionHistory": {
|
||||
"firstAttemptRolledBack": true,
|
||||
"firstAttemptReason": "BOUNDED_HEALTH_PROBE_STARTED_BEFORE_LOOPBACK_LISTENER_ACCEPTED_CONNECTIONS",
|
||||
"firstAttemptExistingServicesChanged": false,
|
||||
"firstAttemptTargetRemoved": true,
|
||||
"firstAttemptReceiptWritten": false,
|
||||
"secondAttemptBoundedHealthRetryPassed": true
|
||||
},
|
||||
"remainingProductionGates": {
|
||||
"publicHttpsReleaseEndpointProvisioned": false,
|
||||
"updaterPublicKeyProvisioned": false,
|
||||
"signedNotarizedReleaseActivated": false,
|
||||
"desktopReleaseTrustProvisioned": false,
|
||||
"publicDistributionReady": false,
|
||||
"repositoryPublicationClaimed": false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"schema": "hololake.jd-release-public-path-acceptance-receipt/v1",
|
||||
"receiptId": "GH-HOLOLAKE-JD-RELEASE-PUBLIC-PATH-20260813-004",
|
||||
"observedAt": "2026-08-13T19:13:32+08:00",
|
||||
"state": "JD_LOOPBACK_PUBLIC_NAMESPACE_READY_FRONT_DOOR_NOT_DEPLOYED",
|
||||
"target": "JD-FD-PRIMARY",
|
||||
"sourceCommit": "9006075310729cb7c266decff529066053969f18",
|
||||
"immutableSourcePath": "/opt/guanghu/hololake-release-broadcast-candidate/9006075310729cb7c266decff529066053969f18",
|
||||
"serverOwnedReceipt": "/var/lib/guanghu/architecture-provision/receipts/GH-HOLOLAKE-JD-RELEASE-PUBLIC-PATH-20260813-001.json",
|
||||
"serverOwnedReceiptSha256": "923564c7f624921bcab247e9e27fc007953bbd6c06733356be1bdd9420f5ef1d",
|
||||
"liveReadback": {
|
||||
"serviceActive": true,
|
||||
"serviceEnabled": true,
|
||||
"listener": "127.0.0.1:3940",
|
||||
"publicPathPrefix": "/hololake/releases",
|
||||
"releaseEndpointPath": "/hololake/releases/latest.json",
|
||||
"legacyLatestStatus": 204,
|
||||
"publicLatestStatus": 204,
|
||||
"broadcastState": "EMPTY_FAIL_CLOSED",
|
||||
"updateAvailable": false,
|
||||
"stateFileCount": 0,
|
||||
"automaticUpload": false,
|
||||
"automaticActivation": false,
|
||||
"automaticRestart": false,
|
||||
"existingDiscoveryServiceActive": true,
|
||||
"existingLivingControllerActive": true,
|
||||
"existingMotherBrainActive": true
|
||||
},
|
||||
"publicObservation": {
|
||||
"url": "https://guanghulab.com/hololake/releases/latest.json",
|
||||
"httpStatus": 200,
|
||||
"contentType": "text/html",
|
||||
"matchesReleaseProtocol": false,
|
||||
"inference": "BS_GZ_006_HOMEPAGE_FALLBACK_NO_HOLOLAKE_PROXY"
|
||||
},
|
||||
"productionBoundary": {
|
||||
"frontDoorLoopbackPortAssigned": false,
|
||||
"frontDoorProxyDeployed": false,
|
||||
"publicHttpsReleaseEndpointProvisioned": false,
|
||||
"updaterPublicKeyProvisioned": false,
|
||||
"desktopReleaseTrustProvisioned": false,
|
||||
"publicDistributionReady": false,
|
||||
"repositoryPublicationClaimed": false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"schema": "hololake.jd-release-route-gate-receipt/v1",
|
||||
"receiptId": "GH-HOLOLAKE-JD-RELEASE-ROUTE-GATE-20260813-001",
|
||||
"observedAt": "2026-08-13T15:10:55+08:00",
|
||||
"state": "BLOCKED_BEFORE_SERVER_CONNECTION",
|
||||
"requestedCapability": "HOST_HOLOLAKE_SIGNED_RELEASE_BROADCAST_ON_JD_CONTROLLER",
|
||||
"target": {
|
||||
"nodeId": "JD-FD-PRIMARY",
|
||||
"targetResolvedExactly": true,
|
||||
"route": "INTENT-JD-SERVER-OPERATION-001",
|
||||
"transport": "LOCAL_DEDICATED_KEY_SSH",
|
||||
"authority": "CURRENT_USER_TASK_SCOPE_ONLY_NOT_BLANKET_SERVER_AUTHORITY"
|
||||
},
|
||||
"localPrivateRegistryReadback": {
|
||||
"exactRouteCount": 1,
|
||||
"dedicatedIdentityReadable": true,
|
||||
"strictHostKeyCheckingRequired": true,
|
||||
"expectedDmiInstanceUuidRegistered": true,
|
||||
"hostKeyFingerprintRegistered": false,
|
||||
"directAccessReceiptRegistered": false
|
||||
},
|
||||
"stopCondition": "STRICT_HOST_KEY_VERIFICATION_CANNOT_BE_PROVEN_FROM_CURRENT_REGISTRY",
|
||||
"actions": {
|
||||
"sshSessionEstablished": false,
|
||||
"serverMutationAttempted": false,
|
||||
"releaseEndpointProvisioned": false,
|
||||
"updaterPublicKeyProvisioned": false,
|
||||
"releaseUploadAttempted": false,
|
||||
"productionCutoverAttempted": false,
|
||||
"workorderEmailCreated": false
|
||||
},
|
||||
"requiredBeforeRetry": [
|
||||
"REGISTER_CURRENT_JD_HOST_KEY_FINGERPRINT",
|
||||
"REGISTER_OR_REFRESH_DIRECT_ACCESS_RECEIPT",
|
||||
"VERIFY_DMI_INSTANCE_UUID_AFTER_STRICT_LOGIN",
|
||||
"READ_BACK_CURRENT_SERVER_RUNTIME_AND_RELEASE_SERVICE_STATE",
|
||||
"BIND_ANY_WRITE_TO_IMMUTABLE_SOURCE_ROLLBACK_AND_SERVER_OWNED_RECEIPT"
|
||||
],
|
||||
"truthBoundary": "A local signed-notarized release pipeline is implemented, but no JD release service, endpoint, public key, upload, activation or server health is claimed."
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"schema": "hololake.stage-one-local-delivery-receipt/v1",
|
||||
"receiptId": "GH-HOLOLAKE-STAGE1-LOCAL-20260813-001",
|
||||
"state": "LOCAL_STAGE_ONE_APP_INSTALLED_RUNNING_AND_VERIFIED",
|
||||
"observedAt": "2026-08-13T14:52:52+08:00",
|
||||
"source": {
|
||||
"canonicalTree": "product-source/hololake-native-desktop",
|
||||
"branch": "main",
|
||||
"sourceCommit": "73eb8bc1caf3d4c268dae909fdb7ebeafaf77da8",
|
||||
"publicationClaimed": false
|
||||
},
|
||||
"installedApplication": {
|
||||
"path": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake 第一阶段.app",
|
||||
"bundleIdentifier": "world.guanghu.hololake",
|
||||
"version": "0.1.0",
|
||||
"processIdAtAcceptance": 3201,
|
||||
"strictCodeSignatureVerified": true,
|
||||
"signingAuthority": "Developer ID Application: bei sun (825A9L3G7Q)",
|
||||
"teamIdentifier": "825A9L3G7Q",
|
||||
"cdHash": "16e1c88452f31b83555fdac76c6109c3c078e05c",
|
||||
"appleNotarized": false
|
||||
},
|
||||
"installerArtifact": {
|
||||
"name": "HoloLake_0.1.0_aarch64.dmg",
|
||||
"sha256": "372b872b4db30ae5307d88c9e30977e2e55aec3247ca0f820828ba1bb28d3086",
|
||||
"strictCodeSignatureVerified": true,
|
||||
"mountedApplicationStrictCodeSignatureVerified": true,
|
||||
"publicDistributionReady": false
|
||||
},
|
||||
"runtimeAcceptance": {
|
||||
"nativeWindowRendered": true,
|
||||
"tauriResourceRoute": "tauri://localhost",
|
||||
"ghs014PersonalChannelSurfaceRendered": true,
|
||||
"directLocalBrokerState": "READY",
|
||||
"directLocalBrokerDescriptorMode": "0600",
|
||||
"mcpRole": "DISCOVERY_RECOVERY_COMPATIBILITY_ONLY",
|
||||
"unprovisionedUpdateCheckStayedOffline": true,
|
||||
"upstreamAutomaticUpdateEndpoints": []
|
||||
},
|
||||
"verification": {
|
||||
"rustTestsPerRun": 41,
|
||||
"rustStressRuns": 10,
|
||||
"rustStressAssertionsPassed": 410,
|
||||
"productContractTests": 25,
|
||||
"clippyWarnings": 0,
|
||||
"frontendProductionBuildPassed": true,
|
||||
"browserRecoveryPanelAccepted": true,
|
||||
"nativeHomeAndUpdatePanelsAccepted": true
|
||||
},
|
||||
"preservation": {
|
||||
"oldHoloLakeEraAppPreserved": true,
|
||||
"oldHoloLakeEraAppPath": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake Era.app",
|
||||
"previousStageOneBuildMovedToTrash": true,
|
||||
"previousStageOneBuildRecoverablePath": "/Users/bingshuolingdianyuanhe/.Trash/HoloLake 第一阶段.previous-73eb8bc.app"
|
||||
},
|
||||
"productionActivation": {
|
||||
"state": "BLOCKED_PENDING_JD_TRUST_SIGNED_PIPELINE_AND_APPLE_NOTARIZATION",
|
||||
"jdControllerReleaseEndpointProvisioned": false,
|
||||
"jdControllerUpdaterPublicKeyProvisioned": false,
|
||||
"signedReleasePipelineEvidenced": false,
|
||||
"appleNotarizationCredentialsAvailable": false,
|
||||
"networkUpdateActivationAttempted": false
|
||||
}
|
||||
}
|
||||
55
product-source/hololake-native-desktop/docs/ARCHITECTURE.md
Normal file
55
product-source/hololake-native-desktop/docs/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# HoloLake Native Desktop Architecture
|
||||
|
||||
The stage-one shell is a Tauri v2 application with a Rust-owned local core and a React human projection. The five domains and their server fleet remain submerged system infrastructure rather than primary stage-one navigation.
|
||||
|
||||
## Stage-one human projection
|
||||
|
||||
The home surface follows the verified GHS-014 five-lakes visual grammar while applying the current stage-one correction: it exposes one personal-channel entry, not five domain gates. The five named lake themes are token groups only and cannot change layout, copy, routing or authority. The surface reads a native status projection; only an authenticated direct connector can illuminate the live-connected state. A stored resumable session remains visibly distinct from a live connection.
|
||||
|
||||
## External programming AI entry
|
||||
|
||||
MCP may discover HoloLake, but it does not own continuity. The installed application starts a user-only Unix socket broker. A programming AI opens or resumes a HoloLake-issued local session, then uses the installed executable's `--connector` mode for newline-delimited protocol traffic. Session secrets are stored only as hashes. Events use exact cursors and idempotency keys.
|
||||
|
||||
## Dynamic capability routing
|
||||
|
||||
HoloLake stores a signed node capability registry rather than fixed server slots. A candidate route must bind the exact human, persona, domain and capability. It is eligible only while a fresh node-signed health receipt reports the capability as ready. Selection is deterministic by priority and node id.
|
||||
|
||||
The registry signer trust root is embedded independently from registry payloads and is currently `UNPROVISIONED_FAIL_CLOSED`. Registry and health mutation are not exposed to the webview. External AI route queries require possession of the active HoloLake local session secret. A route receipt never grants write authority; production node write admission remains a separate unimplemented gate.
|
||||
|
||||
HoloLake routes work but does not host the user's private work lake, pool users into a multi-tenant runtime, or carry server workload concurrency.
|
||||
|
||||
## GH-PNCC under-lake code channel
|
||||
|
||||
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. 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.
|
||||
|
||||
## HoloLake-owned release broadcast
|
||||
|
||||
No upstream product endpoint is inherited. When the embedded release trust is unprovisioned, even a human check returns locally without a network request. Provisioning requires one HTTPS endpoint, one matching HoloLake-owned host and the updater public key. A human check creates a short-lived candidate showing HoloLake metadata; install requires a second confirmation, exact broadcast revalidation, same-host package URL, Tauri signature verification, declared byte length and SHA-256. Startup checking, automatic download, automatic installation and automatic restart remain disabled.
|
||||
|
||||
Before an update replaces the application, the runtime verifies and keeps one bounded last-known-good application bundle with its bundle identifier, Team ID and CDHash. The next startup requires a human health confirmation; until that decision, another update is blocked. A human may restore the verified previous bundle without automatic restart, and cleanup is confined to HoloLake-owned recovery paths and the exact current application parent.
|
||||
|
||||
The rollback executor is implemented, but production updater activation remains blocked until the JD controller publishes the exact trust endpoint and public key, the signed release pipeline is evidenced, and the public macOS build is Apple-notarized.
|
||||
|
||||
## Release pipeline
|
||||
|
||||
`npm run release:macos -- release/inputs/<version>.json` is the only product-owned macOS release entry. It fails before building unless the embedded trust contains the exact registered HoloLake HTTPS endpoint and updater public key, the immutable `v<version>` tag equals the clean `main` head, and the Developer ID, Tauri updater-signing and Apple notarization credential sets are supplied at runtime. The pipeline runs all product and Rust gates, creates updater artifacts through a temporary Tauri override, then requires strict code-signature verification, Gatekeeper acceptance and stapled Apple notarization before writing the HoloLake broadcast and receipts.
|
||||
|
||||
Generated packages, private release inputs and credentials are not committed. The pipeline never uploads or activates a release; its terminal artifact is a bounded folder ready for a separately authorized JD-controller upload and server-owned readback receipt.
|
||||
|
||||
## Stage-one convergence verdict
|
||||
|
||||
The Tauri source in this directory is the only future HoloLake desktop mainline. An installed build of it is an acceptance candidate, not a separate product line and not proof that stage one exists. The Electron 0.8.0 product and the legacy Tauri/platform sources remain read-only UX, behavior, engineering and protected-data donors until inventory, backup, readback, reversible migration rehearsal and signed installed-runtime acceptance all pass.
|
||||
|
||||
The current candidate already has useful under-lake organs: the signed native shell, local broker and resumable session, authenticated read-only PNCC projection, human-confirmed repository mount, deterministic routing, and fail-closed update/rollback machinery. They remain in the architecture, but their existence does not satisfy the visible stage-one body.
|
||||
|
||||
The implementation order is now fixed:
|
||||
|
||||
1. personal-channel identity, task, event and receipt kernel;
|
||||
2. knowledge tree, page, search and local persistence;
|
||||
3. human approval center and Git evidence readback;
|
||||
4. lake-lamp cross-session continuity projection;
|
||||
5. signed installation, opt-in update, rollback and reversible data migration.
|
||||
|
||||
Architecture registration, source implementation, built artifact, signed artifact, desktop installation, data migration, server deployment and service health remain separate receipts. The machine contract is `routing/hololake-stage-one-desktop-convergence.json`.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# ADR 0001: Signed dynamic capability routing
|
||||
|
||||
Status: Accepted locally; production trust provisioning pending.
|
||||
|
||||
## Context
|
||||
|
||||
Stage one must connect a changing fleet of team and user-owned nodes without hardcoded endpoints. SSH access, root access, a URL, or a health probe cannot establish Guanghu node identity or write authority. The client must also preserve the one-human-one-owned-root-node boundary and avoid becoming a hosted concurrency layer.
|
||||
|
||||
## Decision
|
||||
|
||||
Use a Rust-owned, file-backed dynamic registry whose complete canonical payload is verified with an independently embedded Ed25519 trust root. Each node carries a protocol public key and explicit human, persona, domain and capability bindings. Runtime eligibility additionally requires a fresh Ed25519 health receipt from that node with a strictly increasing sequence.
|
||||
|
||||
Route resolution is available to external programming AI only through an authenticated active local HoloLake session. It returns a non-authorizing route receipt. Webview code cannot install registries or submit health receipts.
|
||||
|
||||
The embedded trust file initially contains no signer. This makes production registry installation fail closed until the registered HoloLake/JD-controller signing identity is provisioned.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The fleet can grow without client code changes or fixed server slots.
|
||||
- Stale, replayed, unsigned or incorrectly bound nodes are not routable.
|
||||
- Registry publication, health, route selection and write admission remain separate facts.
|
||||
- Production node enrollment and write execution still require their own source, signing, deployment and readback receipts.
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# ADR 0002: GH-PNCC first-stage read-only core
|
||||
|
||||
## Status
|
||||
|
||||
Accepted for the local engineering checkpoint on 2026-08-13. Human source registration and product UI acceptance remain open.
|
||||
|
||||
## Decision
|
||||
|
||||
The new native container reimplements only three first-stage GH-PNCC boundaries:
|
||||
|
||||
1. an exact local Git-root binding that reads the persona manifest, brain entry, B0 cognitive-gravity source, current checkpoint and declared artifacts from a caller-pinned committed head;
|
||||
2. an app-owned incremental remote object channel using Git protocol v2, a bare promisor cache and `blob:none`, with its verified ancestry cursor stored outside the evictable cache;
|
||||
3. a hash-chained, bounded, read-only human receipt projection over verified repository bindings and remote object reads.
|
||||
|
||||
All external-AI PNCC reads use the authenticated resident Unix-socket broker after the source has been registered. MCP is not the continuity owner. Neither the broker nor the WebView may register an arbitrary local path or remote URL. Registration stays closed until the native file picker, exact human confirmation receipt and UI acceptance are implemented.
|
||||
|
||||
## Exclusions
|
||||
|
||||
The first-stage PNCC core contains no internal chat, model selection, model endpoint or API-key configuration, model inference, persona lease acquisition, memory mutation, execution limb or reality action. It reads committed Git objects even if the local worktree is dirty, and reports that dirtiness rather than treating uncommitted bytes as persona evidence.
|
||||
|
||||
The migration provenance ledger records exact donor paths, hashes and observed authors. The implementation is a contract-level clean-room rebuild; it does not copy the donor runtime module or donor UI source.
|
||||
|
||||
## Trust and continuity
|
||||
|
||||
Remote URLs must be credential-free HTTPS. The server must advertise partial-object filtering. The durable cursor advances only after the new head is read and proved to descend from the previous head. A rewritten history, cache/cursor disagreement, unsupported server, unsafe path, non-regular object or oversized content fails closed. The cache may be evicted without deleting the last verified continuity cursor.
|
||||
|
||||
The receipt projection is evidence for people and external AIs. It is not a node registry, authorization store, persona runtime lease or proof that an empty view means a node is offline.
|
||||
|
|
@ -1,16 +1,56 @@
|
|||
{
|
||||
"schema": "hololake.native-desktop-foundation/v1",
|
||||
"record_id": "HLP-NATIVE-DESKTOP-FOUNDATION-001",
|
||||
"state": "MINIMAL_TAURI_SCAFFOLD_WITH_ACCOUNT_SINGLE_WRITER_KERNEL",
|
||||
"state": "TAURI_CANDIDATE_FOUNDATION_REORIENTED_TO_STAGE_ONE_VISIBLE_BODY",
|
||||
"canonical_shell": "TAURI_V2_RUST_REACT",
|
||||
"product_ui_implementation_started": false,
|
||||
"selected_visual_direction_present": false,
|
||||
"candidate_role": "INSTALLED_LOCAL_ACCEPTANCE_CANDIDATE_NOT_SEPARATE_PRODUCT_LINE",
|
||||
"parallel_product_line_created": false,
|
||||
"stage_one_convergence_contract": "routing/hololake-stage-one-desktop-convergence.json",
|
||||
"language_runtime_product_boundary_contract": "contracts/language-runtime-product-update-boundary.json",
|
||||
"installed_product_capability_audit": "audit/stage-one-installed-product-capability-audit.json",
|
||||
"product_ui_implementation_started": true,
|
||||
"selected_visual_direction_present": true,
|
||||
"selected_visual_direction": "GHS-014_FIVE_LAKES_PERSONAL_CHANNEL_STAGE_ONE",
|
||||
"selected_visual_direction_receipt": "audit/ghs-014-stage-one-ui-receipt.json",
|
||||
"home_status_is_native_projection": true,
|
||||
"home_surface_role": "SELECTED_VISUAL_ACCEPTANCE_SURFACE_NOT_STAGE_ONE_PRODUCT_COMPLETION",
|
||||
"stage_one_visible_body_state": "NOT_IMPLEMENTED",
|
||||
"stage_one_next_slice": "PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL",
|
||||
"stage_one_implementation_order": [
|
||||
"PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL",
|
||||
"KNOWLEDGE_TREE_PAGE_SEARCH_AND_LOCAL_PERSISTENCE",
|
||||
"HUMAN_APPROVAL_CENTER_AND_GIT_EVIDENCE_READBACK",
|
||||
"LAKE_LAMP_CROSS_SESSION_CONTINUITY_PROJECTION",
|
||||
"SIGNED_INSTALL_UPDATE_ROLLBACK_AND_REVERSIBLE_DATA_MIGRATION"
|
||||
],
|
||||
"supporting_infrastructure_role": "UNDER_LAKE_FOUNDATION_NOT_PRODUCT_COMPLETION",
|
||||
"home_online_state_requires_authenticated_direct_connection": true,
|
||||
"five_lake_themes_implemented": true,
|
||||
"upstream_product_update_endpoints": [],
|
||||
"production_update_endpoint_owner": "HOLOLAKE_ONLY",
|
||||
"release_broadcast_contract": "contracts/release-broadcast.schema.json",
|
||||
"release_trust_source": "src-tauri/release-trust.json",
|
||||
"release_trust_state": "UNPROVISIONED_FAIL_CLOSED",
|
||||
"tauri_update_artifacts_enabled": true,
|
||||
"release_manual_check_runtime_implemented": true,
|
||||
"release_candidate_human_confirmation_runtime_implemented": true,
|
||||
"release_package_signature_size_sha256_verification_implemented": true,
|
||||
"release_persistent_rollback_executor_implemented": true,
|
||||
"release_signed_notarized_pipeline_implemented": true,
|
||||
"release_broadcast_candidate_service_implemented": true,
|
||||
"release_broadcast_candidate_service_default_state": "EMPTY_FAIL_CLOSED_LOOPBACK_ONLY",
|
||||
"release_broadcast_candidate_service_source": "server/release-broadcast/server.mjs",
|
||||
"release_public_route_contract": "server/release-broadcast/public-route.json",
|
||||
"release_public_path_prefix": "/hololake/releases",
|
||||
"release_public_route_deployed": false,
|
||||
"release_front_door_bounded_config_renderer_implemented": true,
|
||||
"release_broadcast_explicit_operator_activation_implemented": true,
|
||||
"release_broadcast_activation_requires_exact_human_approval": true,
|
||||
"release_broadcast_activation_requires_repeated_expected_facts": true,
|
||||
"release_broadcast_operator_automatic_restart_allowed": false,
|
||||
"release_pipeline_automatic_upload_allowed": false,
|
||||
"release_production_activation_state": "BLOCKED_PENDING_PUBLIC_HTTPS_TRUST_UPDATER_KEY_PIPELINE_EXECUTION_AND_APPLE_NOTARIZATION",
|
||||
"tauri_update_artifacts_enabled": false,
|
||||
"tauri_update_artifacts_enablement_gate": "JD_CONTROLLER_PUBLIC_KEY_AND_SIGNED_RELEASE_PIPELINE_REQUIRED",
|
||||
"automatic_update_check_on_startup": false,
|
||||
"human_opt_in_download_install_required": true,
|
||||
"automatic_restart_allowed": false,
|
||||
|
|
@ -21,7 +61,26 @@
|
|||
"stage_one_product_contract": "contracts/stage-one-platform.json",
|
||||
"local_development_bridge_contract": "contracts/local-development-bridge.json",
|
||||
"account_single_writer_kernel_implemented": true,
|
||||
"external_local_broker_implemented": false,
|
||||
"external_local_broker_implemented": true,
|
||||
"resumable_direct_local_session_kernel_implemented": true,
|
||||
"dynamic_capability_routing_contract": "contracts/dynamic-capability-routing.json",
|
||||
"dynamic_capability_registry_implemented": true,
|
||||
"signed_node_health_routing_implemented": true,
|
||||
"dynamic_routing_trust_state": "UNPROVISIONED_FAIL_CLOSED",
|
||||
"dynamic_routing_webview_mutation_exposed": false,
|
||||
"production_node_write_admission_implemented": false,
|
||||
"pncc_stage_one_contract": "contracts/pncc-stage-one.json",
|
||||
"pncc_migration_provenance": "audit/pncc-migration-provenance.json",
|
||||
"pncc_repository_binding_implemented": true,
|
||||
"pncc_remote_incremental_object_channel_implemented": true,
|
||||
"pncc_receipt_projection_implemented": true,
|
||||
"pncc_authenticated_direct_broker_integration_implemented": true,
|
||||
"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,
|
||||
"pncc_reality_execution_implemented": false,
|
||||
"stage_one_product_contract_state": "TCS_UNIVERSAL_LANGUAGE_PERSONAL_CHANNEL_CONTRACT_ONLY_RUNTIME_NOT_IMPLEMENTED",
|
||||
"stage_one_internal_ai_interaction": false,
|
||||
"stage_one_model_api_configuration": false,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "node --test scripts/*.test.mjs",
|
||||
"release:macos": "node scripts/release-pipeline.mjs",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"schema": "hololake.release-pipeline-input/v1",
|
||||
"releaseId": "GH-HOLOLAKE-RELEASE-0.1.0",
|
||||
"version": "0.1.0",
|
||||
"previousVersion": "0.0.0",
|
||||
"minimumVersion": "0.0.0",
|
||||
"releaseTag": "v0.1.0",
|
||||
"sourceCommit": "0000000000000000000000000000000000000000",
|
||||
"platformCode": "darwin-aarch64",
|
||||
"packageUrl": "https://REPLACE_WITH_REGISTERED_HOLOLAKE_HOST/releases/0.1.0/HoloLake.app.tar.gz",
|
||||
"appleTeamIdentifier": "REPLACE_WITH_REGISTERED_APPLE_TEAM",
|
||||
"notes": "HoloLake 第一阶段正式发布说明",
|
||||
"features": [
|
||||
"个人频道与外部编程 AI 本机直连",
|
||||
"只读代码通道、动态节点路由与人类确认更新"
|
||||
],
|
||||
"fixes": [],
|
||||
"dataMigrationRequired": false,
|
||||
"compatibilityNotes": "首次安装不迁移旧 HoloLake 应用数据。",
|
||||
"restartMessage": "安装完成后,由你手动重新打开 HoloLake。"
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const contract = JSON.parse(
|
||||
fs.readFileSync(new URL('../contracts/dynamic-capability-routing.json', import.meta.url), 'utf8'),
|
||||
)
|
||||
const foundation = JSON.parse(
|
||||
fs.readFileSync(new URL('../foundation.json', import.meta.url), 'utf8'),
|
||||
)
|
||||
|
||||
test('server capability routing is dynamic and never hardcodes a finite fleet', () => {
|
||||
assert.equal(contract.registry.dynamic_node_count, true)
|
||||
assert.equal(contract.registry.fixed_node_slots, false)
|
||||
assert.equal(contract.registry.endpoint_hardcoding_allowed, false)
|
||||
assert.equal(contract.registry.signer_trust_is_external_to_registry_payload, true)
|
||||
assert.equal(contract.registry.unprovisioned_trust_fails_closed, true)
|
||||
assert.equal(contract.registry.webview_registry_or_health_mutation_allowed, false)
|
||||
assert.equal(contract.registry.platform_carries_user_workload_concurrency, false)
|
||||
})
|
||||
|
||||
test('routing requires exact human persona domain capability and signed health bindings', () => {
|
||||
assert.deepEqual(contract.route_resolution.required_bindings, [
|
||||
'HUMAN_ID',
|
||||
'PERSONA_ID',
|
||||
'DOMAIN_ID',
|
||||
'CAPABILITY_ID',
|
||||
'NODE_PROTOCOL_PUBLIC_KEY',
|
||||
'FRESH_SIGNED_HEALTH_RECEIPT',
|
||||
])
|
||||
assert.equal(contract.route_resolution.ssh_or_root_is_identity, false)
|
||||
assert.equal(contract.route_resolution.stale_or_unsigned_health_is_eligible, false)
|
||||
assert.equal(contract.route_resolution.external_ai_route_requires_active_local_session, true)
|
||||
})
|
||||
|
||||
test('the native mainline reports only implemented dynamic routing capabilities', () => {
|
||||
assert.equal(foundation.dynamic_capability_registry_implemented, true)
|
||||
assert.equal(foundation.signed_node_health_routing_implemented, true)
|
||||
assert.equal(foundation.production_node_write_admission_implemented, false)
|
||||
})
|
||||
|
|
@ -14,6 +14,7 @@ const tauriConfig = readJson('src-tauri/tauri.conf.json')
|
|||
const capability = readJson('src-tauri/capabilities/default.json')
|
||||
const broadcast = readJson('contracts/release-broadcast.schema.json')
|
||||
const stageOne = readJson('contracts/stage-one-platform.json')
|
||||
const publicRoute = readJson('server/release-broadcast/public-route.json')
|
||||
|
||||
test('clean Tauri foundation contains no inherited product updater endpoint', () => {
|
||||
assert.deepEqual(foundation.upstream_product_update_endpoints, [])
|
||||
|
|
@ -30,6 +31,11 @@ test('clean Tauri foundation contains no inherited product updater endpoint', ()
|
|||
const source = readText(relative)
|
||||
assert.doesNotMatch(source, /refactoringhq|tolaria|outline/i)
|
||||
}
|
||||
assert.equal(publicRoute.releaseEndpoint, 'https://guanghulab.com/hololake/releases/latest.json')
|
||||
assert.equal(publicRoute.allowedMethods.join(','), 'GET,HEAD')
|
||||
assert.equal(publicRoute.requestBodyAllowed, false)
|
||||
assert.equal(publicRoute.automaticUpload, false)
|
||||
assert.equal(publicRoute.automaticActivation, false)
|
||||
})
|
||||
|
||||
test('release activation remains explicitly human controlled', () => {
|
||||
|
|
@ -40,10 +46,34 @@ test('release activation remains explicitly human controlled', () => {
|
|||
assert.equal(foundation.automatic_update_check_on_startup, false)
|
||||
assert.equal(foundation.human_opt_in_download_install_required, true)
|
||||
assert.equal(foundation.automatic_restart_allowed, false)
|
||||
assert.equal(foundation.release_manual_check_runtime_implemented, true)
|
||||
assert.equal(foundation.release_candidate_human_confirmation_runtime_implemented, true)
|
||||
assert.equal(foundation.release_package_signature_size_sha256_verification_implemented, true)
|
||||
assert.equal(foundation.release_persistent_rollback_executor_implemented, true)
|
||||
assert.equal(foundation.release_signed_notarized_pipeline_implemented, true)
|
||||
assert.equal(foundation.release_broadcast_candidate_service_implemented, true)
|
||||
assert.equal(foundation.release_broadcast_candidate_service_default_state, 'EMPTY_FAIL_CLOSED_LOOPBACK_ONLY')
|
||||
assert.equal(foundation.release_broadcast_explicit_operator_activation_implemented, true)
|
||||
assert.equal(foundation.release_broadcast_activation_requires_exact_human_approval, true)
|
||||
assert.equal(foundation.release_broadcast_activation_requires_repeated_expected_facts, true)
|
||||
assert.equal(foundation.release_broadcast_operator_automatic_restart_allowed, false)
|
||||
assert.equal(foundation.release_public_path_prefix, '/hololake/releases')
|
||||
assert.equal(foundation.release_public_route_deployed, false)
|
||||
assert.equal(publicRoute.publicPathPrefix, foundation.release_public_path_prefix)
|
||||
assert.equal(publicRoute.proxyRequestUriPolicy, 'PRESERVE_FULL_PUBLIC_PATH')
|
||||
assert.equal(publicRoute.frontDoorLoopbackPort, null)
|
||||
assert.equal(publicRoute.deployed, false)
|
||||
assert.equal(foundation.release_front_door_bounded_config_renderer_implemented, true)
|
||||
assert.equal(foundation.release_pipeline_automatic_upload_allowed, false)
|
||||
assert.equal(
|
||||
foundation.release_production_activation_state,
|
||||
'BLOCKED_PENDING_PUBLIC_HTTPS_TRUST_UPDATER_KEY_PIPELINE_EXECUTION_AND_APPLE_NOTARIZATION',
|
||||
)
|
||||
})
|
||||
|
||||
test('Tauri can emit signed updater artifacts without exposing updater IPC', () => {
|
||||
assert.equal(tauriConfig.bundle.createUpdaterArtifacts, true)
|
||||
test('unprovisioned builds cannot emit updater artifacts or expose updater IPC', () => {
|
||||
assert.equal(tauriConfig.bundle.createUpdaterArtifacts, false)
|
||||
assert.equal(foundation.tauri_update_artifacts_enablement_gate, 'JD_CONTROLLER_PUBLIC_KEY_AND_SIGNED_RELEASE_PIPELINE_REQUIRED')
|
||||
assert.deepEqual(capability.permissions, ['core:default'])
|
||||
assert.equal(capability.permissions.includes('updater:default'), false)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const readJson = (relative) => JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8'))
|
||||
const readText = (relative) => fs.readFileSync(path.join(root, relative), 'utf8')
|
||||
|
||||
const contract = readJson('contracts/pncc-stage-one.json')
|
||||
const provenance = readJson('audit/pncc-migration-provenance.json')
|
||||
const foundation = readJson('foundation.json')
|
||||
|
||||
test('PNCC migration is file-evidenced and excludes donor runtime source copying', () => {
|
||||
assert.equal(provenance.decision, 'CLEAN_ROOM_REIMPLEMENT_CONTRACT_NO_DONOR_SOURCE_COPY')
|
||||
assert.equal(provenance.whole_tree_copy_authorized, false)
|
||||
assert.equal(provenance.model_runtime_migration_authorized, false)
|
||||
assert.equal(provenance.execution_limb_migration_authorized, false)
|
||||
assert.equal(provenance.facts_used.length, 4)
|
||||
})
|
||||
|
||||
test('stage-one PNCC contains no internal model or execution authority', () => {
|
||||
assert.equal(contract.repository_binding.returns_model_fields, false)
|
||||
assert.equal(contract.repository_binding.starts_model_inference, false)
|
||||
assert.equal(contract.repository_binding.acquires_persona_lease, false)
|
||||
assert.equal(contract.repository_binding.allows_reality_execution, false)
|
||||
for (const forbidden of [
|
||||
'INTERNAL_MODEL_INFERENCE',
|
||||
'MODEL_BINDING_IN_PUBLIC_RECEIPT',
|
||||
'EXECUTION_LIMB',
|
||||
'PERSONA_LEASE_ACQUISITION',
|
||||
]) assert.ok(contract.forbidden.includes(forbidden))
|
||||
})
|
||||
|
||||
test('remote channel is incremental, app-owned and refuses clone fallback', () => {
|
||||
const remote = contract.remote_object_channel
|
||||
assert.deepEqual(remote.accepted_remote_schemes, ['https'])
|
||||
assert.equal(remote.credentials_in_url_allowed, false)
|
||||
assert.equal(remote.cache, 'APP_OWNED_BARE_PROMISOR_BLOB_NONE')
|
||||
assert.equal(remote.worktree_created, false)
|
||||
assert.equal(remote.git_clone_invoked, false)
|
||||
assert.equal(remote.durable_cursor_separate_from_evictable_cache, true)
|
||||
assert.equal(remote.history_rewrite_allowed, false)
|
||||
})
|
||||
|
||||
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.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/)
|
||||
assert.doesNotMatch(broker, /RegisterPncc(Repository|Remote)Mount/)
|
||||
})
|
||||
|
||||
test('foundation and Rust modules report the implemented PNCC boundary', () => {
|
||||
assert.equal(foundation.pncc_stage_one_contract, 'contracts/pncc-stage-one.json')
|
||||
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, true)
|
||||
assert.equal(foundation.pncc_internal_model_inference_implemented, false)
|
||||
assert.equal(foundation.pncc_execution_limb_implemented, false)
|
||||
for (const relative of [
|
||||
'src-tauri/src/pncc_repository_binding.rs',
|
||||
'src-tauri/src/pncc_remote_git.rs',
|
||||
]) assert.equal(fs.existsSync(path.join(root, relative)), true)
|
||||
const productionBindingSource = readText('src-tauri/src/pncc_repository_binding.rs').split('\n#[cfg(test)]\nmod tests')[0]
|
||||
assert.doesNotMatch(productionBindingSource, /modelProvider|model_id|api[_-]?key/i)
|
||||
})
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { activateRelease, verifyReleaseBundle } from '../server/release-broadcast/operator.mjs'
|
||||
import { loadRuntimeState } from '../server/release-broadcast/server.mjs'
|
||||
|
||||
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex')
|
||||
const writeJson = (file, value) => fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
|
||||
|
||||
function buildOperatorFixture() {
|
||||
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hololake-release-operator-fixture-'))
|
||||
const source = path.join(fixtureRoot, 'source')
|
||||
const approvals = path.join(fixtureRoot, 'approvals')
|
||||
const stateRoot = path.join(fixtureRoot, 'state')
|
||||
for (const directory of [source, approvals, stateRoot]) fs.mkdirSync(directory, { mode: 0o700 })
|
||||
const packageName = 'HoloLake.app.tar.gz'
|
||||
const packageBytes = Buffer.from('signed-notarized-updater-package')
|
||||
const sourceCommit = '2'.repeat(40)
|
||||
fs.writeFileSync(path.join(source, packageName), packageBytes, { mode: 0o600 })
|
||||
writeJson(path.join(source, 'HOLOLAKE-CODESIGN.json'), {
|
||||
schema: 'hololake.platform-code-signature-receipt/v1',
|
||||
state: 'DEVELOPER_ID_SIGNATURE_STRICT_AND_GATEKEEPER_ACCEPTED',
|
||||
sourceCommit,
|
||||
})
|
||||
writeJson(path.join(source, 'HOLOLAKE-NOTARIZATION.json'), {
|
||||
schema: 'hololake.apple-notarization-receipt/v1',
|
||||
state: 'APPLE_NOTARIZATION_ACCEPTED_AND_STAPLED',
|
||||
sourceCommit,
|
||||
})
|
||||
const broadcast = {
|
||||
schema: 'hololake.release-broadcast/v1',
|
||||
releaseId: 'GH-HOLOLAKE-RELEASE-0.2.0',
|
||||
version: '0.2.0',
|
||||
pub_date: '2026-08-13T10:00:00.000Z',
|
||||
notes: 'Signed release',
|
||||
platforms: {
|
||||
'darwin-aarch64': {
|
||||
url: `https://guanghulab.com/hololake/releases/0.2.0/${packageName}`,
|
||||
signature: 'trusted-updater-signature',
|
||||
size: packageBytes.length,
|
||||
sha256: sha256(packageBytes),
|
||||
platformCodeSignatureReceipt: 'HOLOLAKE-CODESIGN',
|
||||
notarizationReceipt: 'HOLOLAKE-NOTARIZATION',
|
||||
},
|
||||
},
|
||||
hololake: {
|
||||
features: ['Stage one'],
|
||||
fixes: [],
|
||||
compatibility: { minimumVersion: '0.1.0', dataMigrationRequired: false },
|
||||
restart: { required: true, automaticAllowed: false },
|
||||
rollback: { supported: true, healthReceiptRequired: true, previousVersion: '0.1.0' },
|
||||
},
|
||||
}
|
||||
const broadcastPath = path.join(source, 'latest.json')
|
||||
writeJson(broadcastPath, broadcast)
|
||||
const broadcastSha256 = sha256(fs.readFileSync(broadcastPath))
|
||||
writeJson(path.join(source, 'pipeline-receipt.json'), {
|
||||
schema: 'hololake.signed-release-pipeline-receipt/v1',
|
||||
state: 'SIGNED_NOTARIZED_RELEASE_BROADCAST_READY_FOR_JD_CONTROLLER_UPLOAD',
|
||||
sourceCommit,
|
||||
broadcastSha256,
|
||||
automaticUpload: false,
|
||||
automaticActivation: false,
|
||||
})
|
||||
const approval = path.join(approvals, 'approval.json')
|
||||
writeJson(approval, {
|
||||
schema: 'hololake.release-broadcast-human-approval/v1',
|
||||
state: 'HUMAN_APPROVED_EXACT_SIGNED_NOTARIZED_RELEASE',
|
||||
approvalId: 'GH-HUMAN-RELEASE-APPROVAL-002',
|
||||
releaseId: broadcast.releaseId,
|
||||
version: broadcast.version,
|
||||
broadcastSha256,
|
||||
})
|
||||
return {
|
||||
fixtureRoot,
|
||||
source,
|
||||
approval,
|
||||
stateRoot,
|
||||
expected: { releaseId: broadcast.releaseId, version: broadcast.version, sourceCommit, broadcastSha256 },
|
||||
}
|
||||
}
|
||||
|
||||
test('verify reconstructs and validates an exact private candidate without mutating state', () => {
|
||||
const fixture = buildOperatorFixture()
|
||||
try {
|
||||
const result = verifyReleaseBundle(fixture.source, fixture.approval)
|
||||
assert.equal(result.state, 'EXACT_SIGNED_NOTARIZED_HUMAN_APPROVED_BUNDLE_VERIFIED')
|
||||
assert.equal(result.broadcastSha256, fixture.expected.broadcastSha256)
|
||||
assert.equal(result.artifacts.some((artifact) => artifact.name === 'HoloLake.app.tar.gz'), true)
|
||||
assert.deepEqual(fs.readdirSync(fixture.stateRoot), [])
|
||||
} finally {
|
||||
fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('activation repeats expected facts, commits atomically, and still requires explicit restart', () => {
|
||||
const fixture = buildOperatorFixture()
|
||||
try {
|
||||
const receipt = activateRelease({
|
||||
sourceDirectory: fixture.source,
|
||||
humanApprovalPath: fixture.approval,
|
||||
stateRoot: fixture.stateRoot,
|
||||
expected: fixture.expected,
|
||||
enforceRootOwner: false,
|
||||
})
|
||||
assert.equal(receipt.state, 'ACTIVATED_EXPLICIT_SERVICE_RESTART_REQUIRED')
|
||||
assert.equal(receipt.automaticUpload, false)
|
||||
assert.equal(receipt.automaticActivation, false)
|
||||
assert.equal(receipt.automaticRestart, false)
|
||||
assert.equal(loadRuntimeState(fixture.stateRoot).state, 'READY_SIGNED_NOTARIZED_BROADCAST')
|
||||
assert.equal(fs.existsSync(path.join(fixture.stateRoot, '.operator-lock')), false)
|
||||
} finally {
|
||||
fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('expected-fact mismatch performs no activation or release staging', () => {
|
||||
const fixture = buildOperatorFixture()
|
||||
try {
|
||||
assert.throws(() => activateRelease({
|
||||
sourceDirectory: fixture.source,
|
||||
humanApprovalPath: fixture.approval,
|
||||
stateRoot: fixture.stateRoot,
|
||||
expected: { ...fixture.expected, broadcastSha256: '3'.repeat(64) },
|
||||
enforceRootOwner: false,
|
||||
}), /HOLOLAKE_RELEASE_OPERATOR_EXPECTED_BROADCAST_SHA256_MISMATCH/)
|
||||
assert.deepEqual(fs.readdirSync(fixture.stateRoot), [])
|
||||
} finally {
|
||||
fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('human approval cannot be reused after broadcast bytes change', () => {
|
||||
const fixture = buildOperatorFixture()
|
||||
try {
|
||||
const latestPath = path.join(fixture.source, 'latest.json')
|
||||
const latest = JSON.parse(fs.readFileSync(latestPath, 'utf8'))
|
||||
latest.notes = 'Changed after approval'
|
||||
writeJson(latestPath, latest)
|
||||
assert.throws(
|
||||
() => verifyReleaseBundle(fixture.source, fixture.approval),
|
||||
/HOLOLAKE_RELEASE_OPERATOR_PIPELINE_RECEIPT_INVALID/,
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('symlinked package inputs are rejected instead of followed', () => {
|
||||
const fixture = buildOperatorFixture()
|
||||
try {
|
||||
const packagePath = path.join(fixture.source, 'HoloLake.app.tar.gz')
|
||||
const moved = path.join(fixture.fixtureRoot, 'moved-package')
|
||||
fs.renameSync(packagePath, moved)
|
||||
fs.symlinkSync(moved, packagePath)
|
||||
assert.throws(
|
||||
() => verifyReleaseBundle(fixture.source, fixture.approval),
|
||||
/HOLOLAKE_RELEASE_OPERATOR_FILE_INVALID/,
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('operator refuses a bundle whose packages escape the registered public path', () => {
|
||||
const fixture = buildOperatorFixture()
|
||||
try {
|
||||
const latestPath = path.join(fixture.source, 'latest.json')
|
||||
const latest = JSON.parse(fs.readFileSync(latestPath, 'utf8'))
|
||||
latest.platforms['darwin-aarch64'].url = 'https://guanghulab.com/updates/0.2.0/HoloLake.app.tar.gz'
|
||||
writeJson(latestPath, latest)
|
||||
const broadcastSha256 = sha256(fs.readFileSync(latestPath))
|
||||
const pipelinePath = path.join(fixture.source, 'pipeline-receipt.json')
|
||||
const pipeline = JSON.parse(fs.readFileSync(pipelinePath, 'utf8'))
|
||||
pipeline.broadcastSha256 = broadcastSha256
|
||||
writeJson(pipelinePath, pipeline)
|
||||
const approval = JSON.parse(fs.readFileSync(fixture.approval, 'utf8'))
|
||||
approval.broadcastSha256 = broadcastSha256
|
||||
writeJson(fixture.approval, approval)
|
||||
assert.throws(
|
||||
() => verifyReleaseBundle(fixture.source, fixture.approval),
|
||||
/HOLOLAKE_RELEASE_OPERATOR_PACKAGE_PUBLIC_PREFIX_MISMATCH/,
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { createReleaseServer, isMainModule, loadRuntimeState } from '../server/release-broadcast/server.mjs'
|
||||
|
||||
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex')
|
||||
|
||||
const writeJson = (file, value) => fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
|
||||
|
||||
async function withServer(state, callback) {
|
||||
const server = createReleaseServer(state)
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
try {
|
||||
return await callback(`http://127.0.0.1:${address.port}`)
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
||||
}
|
||||
}
|
||||
|
||||
function buildReleaseRoot() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hololake-release-broadcast-'))
|
||||
const releaseDirectory = path.join(root, 'releases', '0.2.0')
|
||||
fs.mkdirSync(releaseDirectory, { recursive: true })
|
||||
const packageBytes = Buffer.from('signed-notarized-updater-placeholder')
|
||||
const packageName = 'HoloLake.app.tar.gz'
|
||||
fs.writeFileSync(path.join(releaseDirectory, packageName), packageBytes, { mode: 0o600 })
|
||||
writeJson(path.join(releaseDirectory, 'HOLOLAKE-CODESIGN.json'), {
|
||||
schema: 'hololake.platform-code-signature-receipt/v1',
|
||||
state: 'DEVELOPER_ID_SIGNATURE_STRICT_AND_GATEKEEPER_ACCEPTED',
|
||||
sourceCommit: '1'.repeat(40),
|
||||
})
|
||||
writeJson(path.join(releaseDirectory, 'HOLOLAKE-NOTARIZATION.json'), {
|
||||
schema: 'hololake.apple-notarization-receipt/v1',
|
||||
state: 'APPLE_NOTARIZATION_ACCEPTED_AND_STAPLED',
|
||||
sourceCommit: '1'.repeat(40),
|
||||
})
|
||||
const broadcast = {
|
||||
schema: 'hololake.release-broadcast/v1',
|
||||
releaseId: 'GH-HOLOLAKE-RELEASE-0.2.0',
|
||||
version: '0.2.0',
|
||||
pub_date: '2026-08-13T10:00:00.000Z',
|
||||
notes: 'Signed release',
|
||||
platforms: {
|
||||
'darwin-aarch64': {
|
||||
url: `https://release.guanghu.test/hololake/releases/0.2.0/${packageName}`,
|
||||
signature: 'trusted-updater-signature',
|
||||
size: packageBytes.length,
|
||||
sha256: sha256(packageBytes),
|
||||
platformCodeSignatureReceipt: 'HOLOLAKE-CODESIGN',
|
||||
notarizationReceipt: 'HOLOLAKE-NOTARIZATION',
|
||||
},
|
||||
},
|
||||
hololake: {
|
||||
features: ['Stage one'],
|
||||
fixes: [],
|
||||
compatibility: { minimumVersion: '0.1.0', dataMigrationRequired: false },
|
||||
restart: { required: true, automaticAllowed: false },
|
||||
rollback: { supported: true, healthReceiptRequired: true, previousVersion: '0.1.0' },
|
||||
},
|
||||
}
|
||||
const broadcastPath = path.join(releaseDirectory, 'latest.json')
|
||||
writeJson(broadcastPath, broadcast)
|
||||
const broadcastSha256 = sha256(fs.readFileSync(broadcastPath))
|
||||
writeJson(path.join(releaseDirectory, 'pipeline-receipt.json'), {
|
||||
schema: 'hololake.signed-release-pipeline-receipt/v1',
|
||||
state: 'SIGNED_NOTARIZED_RELEASE_BROADCAST_READY_FOR_JD_CONTROLLER_UPLOAD',
|
||||
sourceCommit: '1'.repeat(40),
|
||||
broadcastSha256,
|
||||
automaticUpload: false,
|
||||
automaticActivation: false,
|
||||
})
|
||||
writeJson(path.join(root, 'ACTIVE.json'), {
|
||||
schema: 'hololake.release-broadcast-activation/v1',
|
||||
state: 'HUMAN_APPROVED_SIGNED_NOTARIZED_RELEASE_ACTIVE',
|
||||
releaseId: broadcast.releaseId,
|
||||
version: broadcast.version,
|
||||
broadcastRelativePath: 'releases/0.2.0/latest.json',
|
||||
broadcastSha256,
|
||||
pipelineReceiptRelativePath: 'releases/0.2.0/pipeline-receipt.json',
|
||||
humanApprovalReceipt: 'GH-HUMAN-RELEASE-APPROVAL-001',
|
||||
humanApprovalReceiptRelativePath: 'releases/0.2.0/human-approval.json',
|
||||
})
|
||||
writeJson(path.join(releaseDirectory, 'human-approval.json'), {
|
||||
schema: 'hololake.release-broadcast-human-approval/v1',
|
||||
state: 'HUMAN_APPROVED_EXACT_SIGNED_NOTARIZED_RELEASE',
|
||||
approvalId: 'GH-HUMAN-RELEASE-APPROVAL-001',
|
||||
releaseId: broadcast.releaseId,
|
||||
version: broadcast.version,
|
||||
broadcastSha256,
|
||||
})
|
||||
return { root, packageBytes }
|
||||
}
|
||||
|
||||
test('empty release root stays healthy but returns Tauri-compatible 204 no update', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hololake-release-empty-'))
|
||||
const state = loadRuntimeState(root)
|
||||
assert.equal(state.state, 'EMPTY_FAIL_CLOSED')
|
||||
await withServer(state, async (base) => {
|
||||
const health = await fetch(`${base}/health`)
|
||||
assert.equal(health.status, 200)
|
||||
assert.deepEqual((await health.json()).upstreamUpdateSources, [])
|
||||
assert.equal((await fetch(`${base}/latest.json`)).status, 204)
|
||||
assert.equal((await fetch(`${base}/hololake/releases/latest.json`)).status, 204)
|
||||
const publicHealth = await fetch(`${base}/hololake/releases/health`)
|
||||
assert.equal(publicHealth.status, 200)
|
||||
assert.equal((await publicHealth.json()).releaseEndpointPath, '/hololake/releases/latest.json')
|
||||
})
|
||||
})
|
||||
|
||||
test('invalid activation locks the endpoint instead of falling back to an update', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hololake-release-invalid-'))
|
||||
writeJson(path.join(root, 'ACTIVE.json'), { schema: 'wrong', state: 'ACTIVE' })
|
||||
const state = loadRuntimeState(root)
|
||||
assert.equal(state.state, 'LOCKED_INVALID_RELEASE_EVIDENCE')
|
||||
await withServer(state, async (base) => {
|
||||
assert.equal((await fetch(`${base}/health`)).status, 503)
|
||||
assert.equal((await fetch(`${base}/latest.json`)).status, 503)
|
||||
})
|
||||
})
|
||||
|
||||
test('only an exact human-approved signed notarized evidence chain becomes readable', async () => {
|
||||
const fixture = buildReleaseRoot()
|
||||
const state = loadRuntimeState(fixture.root)
|
||||
assert.equal(state.state, 'READY_SIGNED_NOTARIZED_BROADCAST')
|
||||
await withServer(state, async (base) => {
|
||||
const latest = await fetch(`${base}/latest.json`)
|
||||
assert.equal(latest.status, 200)
|
||||
assert.equal((await latest.json()).releaseId, 'GH-HOLOLAKE-RELEASE-0.2.0')
|
||||
const publicLatest = await fetch(`${base}/hololake/releases/latest.json`)
|
||||
assert.equal(publicLatest.status, 200)
|
||||
assert.equal((await publicLatest.json()).releaseId, 'GH-HOLOLAKE-RELEASE-0.2.0')
|
||||
const updater = await fetch(`${base}/hololake/releases/0.2.0/HoloLake.app.tar.gz`)
|
||||
assert.equal(updater.status, 200)
|
||||
assert.deepEqual(Buffer.from(await updater.arrayBuffer()), fixture.packageBytes)
|
||||
assert.equal((await fetch(`${base}/unknown`)).status, 404)
|
||||
})
|
||||
})
|
||||
|
||||
test('package tampering after pipeline output locks the whole release at startup', () => {
|
||||
const fixture = buildReleaseRoot()
|
||||
fs.appendFileSync(path.join(fixture.root, 'releases', '0.2.0', 'HoloLake.app.tar.gz'), 'tampered')
|
||||
assert.equal(loadRuntimeState(fixture.root).state, 'LOCKED_INVALID_RELEASE_EVIDENCE')
|
||||
})
|
||||
|
||||
test('a symlinked immutable current directory is recognized as the intended executable', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hololake-release-symlink-'))
|
||||
const versionDirectory = path.join(root, 'immutable')
|
||||
fs.mkdirSync(versionDirectory)
|
||||
const source = fs.readFileSync(new URL('../server/release-broadcast/server.mjs', import.meta.url), 'utf8')
|
||||
const copy = path.join(versionDirectory, 'server.mjs')
|
||||
fs.writeFileSync(copy, source, { mode: 0o500 })
|
||||
const current = path.join(root, 'current')
|
||||
fs.symlinkSync(versionDirectory, current)
|
||||
assert.equal(isMainModule(path.join(current, 'server.mjs'), new URL(`file://${copy}`)), true)
|
||||
})
|
||||
|
||||
test('a package outside the registered public prefix locks the release', () => {
|
||||
const fixture = buildReleaseRoot()
|
||||
const latestPath = path.join(fixture.root, 'releases', '0.2.0', 'latest.json')
|
||||
const latest = JSON.parse(fs.readFileSync(latestPath, 'utf8'))
|
||||
latest.platforms['darwin-aarch64'].url = 'https://release.guanghu.test/releases/0.2.0/HoloLake.app.tar.gz'
|
||||
writeJson(latestPath, latest)
|
||||
const broadcastSha256 = sha256(fs.readFileSync(latestPath))
|
||||
const pipelinePath = path.join(fixture.root, 'releases', '0.2.0', 'pipeline-receipt.json')
|
||||
const pipeline = JSON.parse(fs.readFileSync(pipelinePath, 'utf8'))
|
||||
pipeline.broadcastSha256 = broadcastSha256
|
||||
writeJson(pipelinePath, pipeline)
|
||||
const approvalPath = path.join(fixture.root, 'releases', '0.2.0', 'human-approval.json')
|
||||
const approval = JSON.parse(fs.readFileSync(approvalPath, 'utf8'))
|
||||
approval.broadcastSha256 = broadcastSha256
|
||||
writeJson(approvalPath, approval)
|
||||
const activationPath = path.join(fixture.root, 'ACTIVE.json')
|
||||
const activation = JSON.parse(fs.readFileSync(activationPath, 'utf8'))
|
||||
activation.broadcastSha256 = broadcastSha256
|
||||
writeJson(activationPath, activation)
|
||||
const state = loadRuntimeState(fixture.root)
|
||||
assert.equal(state.state, 'LOCKED_INVALID_RELEASE_EVIDENCE')
|
||||
assert.equal(state.reasonCode, 'HOLOLAKE_RELEASE_PACKAGE_PUBLIC_PREFIX_MISMATCH')
|
||||
})
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { renderFrontDoorSnippet } from '../server/release-broadcast/render-front-door.mjs'
|
||||
|
||||
test('front-door renderer refuses an unassigned or privileged loopback port', () => {
|
||||
assert.throws(() => renderFrontDoorSnippet(null), /PORT_REQUIRED/)
|
||||
assert.throws(() => renderFrontDoorSnippet(443), /PORT_REQUIRED|PORT_INVALID/)
|
||||
assert.throws(() => renderFrontDoorSnippet('not-a-port'), /PORT_REQUIRED/)
|
||||
})
|
||||
|
||||
test('rendered proxy preserves the public path and strips request authority', () => {
|
||||
const snippet = renderFrontDoorSnippet(23940)
|
||||
assert.match(snippet, /location \^~ \/hololake\/releases\//)
|
||||
assert.match(snippet, /proxy_pass http:\/\/127\.0\.0\.1:23940;/)
|
||||
assert.doesNotMatch(snippet, /proxy_pass http:\/\/127\.0\.0\.1:23940\//)
|
||||
assert.match(snippet, /limit_except GET HEAD \{ deny all; \}/)
|
||||
assert.match(snippet, /proxy_pass_request_body off;/)
|
||||
assert.match(snippet, /proxy_set_header Authorization "";/)
|
||||
assert.match(snippet, /proxy_set_header Cookie "";/)
|
||||
assert.match(snippet, /proxy_set_header X-Forwarded-For "";/)
|
||||
})
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const PUBLIC_RELEASE_ENDPOINT_PATH = '/hololake/releases/latest.json'
|
||||
const PUBLIC_RELEASE_PREFIX = '/hololake/releases'
|
||||
|
||||
const fail = (code) => {
|
||||
throw new Error(code)
|
||||
}
|
||||
|
||||
const requireText = (value, code) => {
|
||||
if (typeof value !== 'string' || value.trim() === '') fail(code)
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
const sha256File = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
|
||||
|
||||
export function validateReleaseTrust(trust) {
|
||||
if (trust?.schema !== 'hololake.release-trust/v1') fail('HOLOLAKE_RELEASE_PIPELINE_TRUST_SCHEMA_INVALID')
|
||||
if (trust.state !== 'PROVISIONED') fail('HOLOLAKE_RELEASE_PIPELINE_TRUST_UNPROVISIONED')
|
||||
if (!Array.isArray(trust.endpoints) || trust.endpoints.length !== 1) fail('HOLOLAKE_RELEASE_PIPELINE_EXACT_ENDPOINT_REQUIRED')
|
||||
if (!Array.isArray(trust.allowedReleaseHosts) || trust.allowedReleaseHosts.length !== 1) fail('HOLOLAKE_RELEASE_PIPELINE_EXACT_HOST_REQUIRED')
|
||||
const endpoint = new URL(trust.endpoints[0])
|
||||
const host = requireText(trust.allowedReleaseHosts[0], 'HOLOLAKE_RELEASE_PIPELINE_HOST_REQUIRED')
|
||||
if (
|
||||
endpoint.protocol !== 'https:' ||
|
||||
endpoint.hostname !== host ||
|
||||
endpoint.username ||
|
||||
endpoint.password ||
|
||||
endpoint.search ||
|
||||
endpoint.hash ||
|
||||
endpoint.pathname !== PUBLIC_RELEASE_ENDPOINT_PATH
|
||||
) {
|
||||
fail('HOLOLAKE_RELEASE_PIPELINE_ENDPOINT_NOT_HOLOLAKE_HTTPS')
|
||||
}
|
||||
if (requireText(trust.publicKey, 'HOLOLAKE_RELEASE_PIPELINE_UPDATER_PUBLIC_KEY_REQUIRED').length < 32) {
|
||||
fail('HOLOLAKE_RELEASE_PIPELINE_UPDATER_PUBLIC_KEY_INVALID')
|
||||
}
|
||||
if (trust.automaticCheckOnStartup || trust.automaticDownload || trust.automaticRestart || !trust.humanOptInInstallRequired) {
|
||||
fail('HOLOLAKE_RELEASE_PIPELINE_HUMAN_CONTROL_POLICY_INVALID')
|
||||
}
|
||||
return { endpoint, host }
|
||||
}
|
||||
|
||||
export function validateReleaseInput(input, trustFacts) {
|
||||
if (input?.schema !== 'hololake.release-pipeline-input/v1') fail('HOLOLAKE_RELEASE_PIPELINE_INPUT_SCHEMA_INVALID')
|
||||
const version = requireText(input.version, 'HOLOLAKE_RELEASE_PIPELINE_VERSION_REQUIRED')
|
||||
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
|
||||
fail('HOLOLAKE_RELEASE_PIPELINE_VERSION_INVALID')
|
||||
}
|
||||
if (input.platformCode !== 'darwin-aarch64') fail('HOLOLAKE_RELEASE_PIPELINE_PLATFORM_NOT_IMPLEMENTED')
|
||||
if (!/^[a-f0-9]{40}$/.test(input.sourceCommit || '')) fail('HOLOLAKE_RELEASE_PIPELINE_SOURCE_COMMIT_INVALID')
|
||||
if (input.releaseTag !== `v${version}`) fail('HOLOLAKE_RELEASE_PIPELINE_IMMUTABLE_TAG_INVALID')
|
||||
const packageUrl = new URL(requireText(input.packageUrl, 'HOLOLAKE_RELEASE_PIPELINE_PACKAGE_URL_REQUIRED'))
|
||||
if (
|
||||
packageUrl.protocol !== 'https:' ||
|
||||
packageUrl.hostname !== trustFacts.host ||
|
||||
packageUrl.username ||
|
||||
packageUrl.password ||
|
||||
packageUrl.search ||
|
||||
packageUrl.hash ||
|
||||
!packageUrl.pathname.startsWith(`${PUBLIC_RELEASE_PREFIX}/`)
|
||||
) {
|
||||
fail('HOLOLAKE_RELEASE_PIPELINE_PACKAGE_HOST_NOT_TRUSTED')
|
||||
}
|
||||
if (!Array.isArray(input.features) || input.features.length === 0 || input.features.some((item) => typeof item !== 'string' || !item.trim())) {
|
||||
fail('HOLOLAKE_RELEASE_PIPELINE_FEATURES_REQUIRED')
|
||||
}
|
||||
if (!Array.isArray(input.fixes) || input.fixes.some((item) => typeof item !== 'string' || !item.trim())) {
|
||||
fail('HOLOLAKE_RELEASE_PIPELINE_FIXES_INVALID')
|
||||
}
|
||||
for (const [field, code] of [
|
||||
['releaseId', 'HOLOLAKE_RELEASE_PIPELINE_RELEASE_ID_REQUIRED'],
|
||||
['previousVersion', 'HOLOLAKE_RELEASE_PIPELINE_PREVIOUS_VERSION_REQUIRED'],
|
||||
['minimumVersion', 'HOLOLAKE_RELEASE_PIPELINE_MINIMUM_VERSION_REQUIRED'],
|
||||
['notes', 'HOLOLAKE_RELEASE_PIPELINE_NOTES_REQUIRED'],
|
||||
['restartMessage', 'HOLOLAKE_RELEASE_PIPELINE_RESTART_MESSAGE_REQUIRED'],
|
||||
['appleTeamIdentifier', 'HOLOLAKE_RELEASE_PIPELINE_APPLE_TEAM_REQUIRED'],
|
||||
]) requireText(input[field], code)
|
||||
if (typeof input.dataMigrationRequired !== 'boolean') fail('HOLOLAKE_RELEASE_PIPELINE_MIGRATION_FLAG_REQUIRED')
|
||||
return { ...input, version, packageUrl }
|
||||
}
|
||||
|
||||
export function validateCredentialEnvironment(env) {
|
||||
const required = [
|
||||
'APPLE_SIGNING_IDENTITY',
|
||||
'TAURI_SIGNING_PRIVATE_KEY_PASSWORD',
|
||||
]
|
||||
const missing = required.filter((name) => typeof env[name] !== 'string' || env[name].trim() === '')
|
||||
if (!env.TAURI_SIGNING_PRIVATE_KEY && !env.TAURI_SIGNING_PRIVATE_KEY_PATH) {
|
||||
missing.push('TAURI_SIGNING_PRIVATE_KEY_OR_PATH')
|
||||
} else if (env.TAURI_SIGNING_PRIVATE_KEY_PATH && !fs.statSync(env.TAURI_SIGNING_PRIVATE_KEY_PATH, { throwIfNoEntry: false })?.isFile()) {
|
||||
missing.push('TAURI_SIGNING_PRIVATE_KEY_PATH_NOT_READABLE')
|
||||
}
|
||||
const appleIdFlow = env.APPLE_ID && env.APPLE_PASSWORD && env.APPLE_TEAM_ID
|
||||
const appleApiFlow = env.APPLE_API_KEY && env.APPLE_API_ISSUER && env.APPLE_API_KEY_PATH
|
||||
if (!appleIdFlow && !appleApiFlow) missing.push('APPLE_NOTARIZATION_CREDENTIAL_SET')
|
||||
if (missing.length) fail(`HOLOLAKE_RELEASE_PIPELINE_CREDENTIALS_MISSING:${[...new Set(missing)].sort().join(',')}`)
|
||||
}
|
||||
|
||||
function run(program, args, options = {}) {
|
||||
return execFileSync(program, args, {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
||||
env: process.env,
|
||||
})
|
||||
}
|
||||
|
||||
function captureAll(program, args) {
|
||||
const result = spawnSync(program, args, { cwd: root, encoding: 'utf8', env: process.env })
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) fail(`HOLOLAKE_RELEASE_PIPELINE_COMMAND_FAILED:${program}`)
|
||||
return `${result.stdout || ''}\n${result.stderr || ''}`
|
||||
}
|
||||
|
||||
function requireCleanImmutableSource(input) {
|
||||
if (run('git', ['status', '--porcelain'], { capture: true }).trim()) fail('HOLOLAKE_RELEASE_PIPELINE_SOURCE_NOT_CLEAN')
|
||||
if (run('git', ['branch', '--show-current'], { capture: true }).trim() !== 'main') fail('HOLOLAKE_RELEASE_PIPELINE_MAIN_BRANCH_REQUIRED')
|
||||
const head = run('git', ['rev-parse', 'HEAD'], { capture: true }).trim()
|
||||
if (head !== input.sourceCommit) fail('HOLOLAKE_RELEASE_PIPELINE_SOURCE_COMMIT_MISMATCH')
|
||||
const tagCommit = run('git', ['rev-list', '-n', '1', input.releaseTag], { capture: true }).trim()
|
||||
if (tagCommit !== head) fail('HOLOLAKE_RELEASE_PIPELINE_TAG_COMMIT_MISMATCH')
|
||||
const packageDocument = readJson(path.join(root, 'package.json'))
|
||||
const tauriDocument = readJson(path.join(root, 'src-tauri/tauri.conf.json'))
|
||||
if (packageDocument.version !== input.version || tauriDocument.version !== input.version) {
|
||||
fail('HOLOLAKE_RELEASE_PIPELINE_VERSION_FILES_MISMATCH')
|
||||
}
|
||||
}
|
||||
|
||||
function findOne(rootPath, predicate, code) {
|
||||
const matches = []
|
||||
const visit = (directory) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) visit(file)
|
||||
else if (entry.isFile() && predicate(file)) matches.push(file)
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(rootPath)) visit(rootPath)
|
||||
if (matches.length !== 1) fail(`${code}:${matches.length}`)
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
function codeSignatureEvidence(appPath, input) {
|
||||
run('/usr/bin/codesign', ['--verify', '--deep', '--strict', '--verbose=4', appPath])
|
||||
const display = captureAll('/usr/bin/codesign', ['-dv', '--verbose=4', appPath])
|
||||
const value = (name) => display.split('\n').find((line) => line.startsWith(`${name}=`))?.slice(name.length + 1)
|
||||
if (value('Identifier') !== 'world.guanghu.hololake' || value('TeamIdentifier') !== input.appleTeamIdentifier) {
|
||||
fail('HOLOLAKE_RELEASE_PIPELINE_CODE_SIGNATURE_IDENTITY_MISMATCH')
|
||||
}
|
||||
run('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath])
|
||||
run('/usr/bin/xcrun', ['stapler', 'validate', appPath])
|
||||
return {
|
||||
schema: 'hololake.platform-code-signature-receipt/v1',
|
||||
state: 'DEVELOPER_ID_SIGNATURE_STRICT_AND_GATEKEEPER_ACCEPTED',
|
||||
identifier: value('Identifier'),
|
||||
teamIdentifier: value('TeamIdentifier'),
|
||||
cdHash: value('CDHash'),
|
||||
sourceCommit: input.sourceCommit,
|
||||
}
|
||||
}
|
||||
|
||||
function notarizationEvidence(appPath, dmgPath, input) {
|
||||
run('/usr/sbin/spctl', ['--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=4', dmgPath])
|
||||
run('/usr/bin/xcrun', ['stapler', 'validate', dmgPath])
|
||||
return {
|
||||
schema: 'hololake.apple-notarization-receipt/v1',
|
||||
state: 'APPLE_NOTARIZATION_ACCEPTED_AND_STAPLED',
|
||||
appName: path.basename(appPath),
|
||||
dmgName: path.basename(dmgPath),
|
||||
appleTeamIdentifier: input.appleTeamIdentifier,
|
||||
sourceCommit: input.sourceCommit,
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(file, value) {
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' })
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const inputArgument = argv[0]
|
||||
if (!inputArgument) fail('HOLOLAKE_RELEASE_PIPELINE_INPUT_PATH_REQUIRED')
|
||||
const inputPath = path.resolve(inputArgument)
|
||||
const trust = readJson(path.join(root, 'src-tauri/release-trust.json'))
|
||||
const trustFacts = validateReleaseTrust(trust)
|
||||
const input = validateReleaseInput(readJson(inputPath), trustFacts)
|
||||
validateCredentialEnvironment(process.env)
|
||||
requireCleanImmutableSource(input)
|
||||
if (process.platform !== 'darwin' || process.arch !== 'arm64') fail('HOLOLAKE_RELEASE_PIPELINE_BUILD_HOST_MISMATCH')
|
||||
if (!process.env.APPLE_SIGNING_IDENTITY.includes(input.appleTeamIdentifier)) fail('HOLOLAKE_RELEASE_PIPELINE_SIGNING_TEAM_MISMATCH')
|
||||
|
||||
run('npm', ['test'])
|
||||
run('cargo', ['clippy', '--manifest-path', 'src-tauri/Cargo.toml', '--all-targets', '--all-features', '--', '-D', 'warnings'])
|
||||
run('cargo', ['test', '--manifest-path', 'src-tauri/Cargo.toml', '--all-targets', '--all-features'])
|
||||
|
||||
const buildConfig = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'hololake-release-config-')), 'tauri.release.json')
|
||||
fs.writeFileSync(buildConfig, `${JSON.stringify({ bundle: { createUpdaterArtifacts: true } })}\n`, { mode: 0o600 })
|
||||
try {
|
||||
run('npm', ['run', 'tauri', '--', 'build', '--ci', '--config', buildConfig])
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(buildConfig), { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const bundleRoot = path.join(root, 'src-tauri/target/release/bundle')
|
||||
const appExecutable = findOne(path.join(bundleRoot, 'macos'), (file) => file.endsWith('/Contents/MacOS/hololake-native-desktop'), 'HOLOLAKE_RELEASE_PIPELINE_APP_NOT_UNIQUE')
|
||||
const application = path.resolve(appExecutable, '../../../..')
|
||||
const dmg = findOne(path.join(bundleRoot, 'dmg'), (file) => file.endsWith('.dmg'), 'HOLOLAKE_RELEASE_PIPELINE_DMG_NOT_UNIQUE')
|
||||
const updater = findOne(bundleRoot, (file) => file.endsWith('.app.tar.gz'), 'HOLOLAKE_RELEASE_PIPELINE_UPDATER_NOT_UNIQUE')
|
||||
const updaterSignature = `${updater}.sig`
|
||||
if (!fs.statSync(updaterSignature, { throwIfNoEntry: false })?.isFile()) fail('HOLOLAKE_RELEASE_PIPELINE_UPDATER_SIGNATURE_MISSING')
|
||||
if (decodeURIComponent(input.packageUrl.pathname.split('/').pop()) !== path.basename(updater)) {
|
||||
fail('HOLOLAKE_RELEASE_PIPELINE_PACKAGE_URL_FILENAME_MISMATCH')
|
||||
}
|
||||
|
||||
const codeReceipt = codeSignatureEvidence(application, input)
|
||||
const notarizationReceipt = notarizationEvidence(application, dmg, input)
|
||||
const outputRoot = path.join(root, 'release/out', input.version)
|
||||
if (fs.existsSync(outputRoot)) fail('HOLOLAKE_RELEASE_PIPELINE_OUTPUT_ALREADY_EXISTS')
|
||||
fs.mkdirSync(outputRoot, { recursive: true, mode: 0o700 })
|
||||
const codeReceiptId = `HOLOLAKE-CODESIGN-${input.version}-${input.sourceCommit.slice(0, 12)}`
|
||||
const notarizationReceiptId = `HOLOLAKE-NOTARIZATION-${input.version}-${input.sourceCommit.slice(0, 12)}`
|
||||
writeJson(path.join(outputRoot, `${codeReceiptId}.json`), codeReceipt)
|
||||
writeJson(path.join(outputRoot, `${notarizationReceiptId}.json`), notarizationReceipt)
|
||||
fs.copyFileSync(updater, path.join(outputRoot, path.basename(updater)), fs.constants.COPYFILE_EXCL)
|
||||
fs.copyFileSync(updaterSignature, path.join(outputRoot, path.basename(updaterSignature)), fs.constants.COPYFILE_EXCL)
|
||||
fs.copyFileSync(dmg, path.join(outputRoot, path.basename(dmg)), fs.constants.COPYFILE_EXCL)
|
||||
|
||||
const broadcast = {
|
||||
schema: 'hololake.release-broadcast/v1',
|
||||
releaseId: input.releaseId,
|
||||
version: input.version,
|
||||
pub_date: new Date().toISOString(),
|
||||
notes: input.notes,
|
||||
platforms: {
|
||||
[input.platformCode]: {
|
||||
url: input.packageUrl.toString(),
|
||||
signature: fs.readFileSync(updaterSignature, 'utf8').trim(),
|
||||
size: fs.statSync(updater).size,
|
||||
sha256: sha256File(updater),
|
||||
platformCodeSignatureReceipt: codeReceiptId,
|
||||
notarizationReceipt: notarizationReceiptId,
|
||||
},
|
||||
},
|
||||
hololake: {
|
||||
features: input.features,
|
||||
fixes: input.fixes,
|
||||
compatibility: {
|
||||
minimumVersion: input.minimumVersion,
|
||||
dataMigrationRequired: input.dataMigrationRequired,
|
||||
...(input.compatibilityNotes ? { notes: input.compatibilityNotes } : {}),
|
||||
},
|
||||
restart: { required: true, automaticAllowed: false, message: input.restartMessage },
|
||||
rollback: { supported: true, healthReceiptRequired: true, previousVersion: input.previousVersion },
|
||||
},
|
||||
}
|
||||
writeJson(path.join(outputRoot, 'latest.json'), broadcast)
|
||||
writeJson(path.join(outputRoot, 'pipeline-receipt.json'), {
|
||||
schema: 'hololake.signed-release-pipeline-receipt/v1',
|
||||
state: 'SIGNED_NOTARIZED_RELEASE_BROADCAST_READY_FOR_JD_CONTROLLER_UPLOAD',
|
||||
sourceCommit: input.sourceCommit,
|
||||
releaseTag: input.releaseTag,
|
||||
releaseEndpoint: trustFacts.endpoint.toString(),
|
||||
broadcastSha256: sha256File(path.join(outputRoot, 'latest.json')),
|
||||
automaticUpload: false,
|
||||
automaticActivation: false,
|
||||
})
|
||||
process.stdout.write(`HOLOLAKE_RELEASE_PIPELINE_READY:${outputRoot}\n`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
validateCredentialEnvironment,
|
||||
validateReleaseInput,
|
||||
validateReleaseTrust,
|
||||
} from './release-pipeline.mjs'
|
||||
|
||||
const readyTrust = () => ({
|
||||
schema: 'hololake.release-trust/v1',
|
||||
state: 'PROVISIONED',
|
||||
endpoints: ['https://release.guanghu.test/hololake/releases/latest.json'],
|
||||
publicKey: 'A'.repeat(64),
|
||||
allowedReleaseHosts: ['release.guanghu.test'],
|
||||
automaticCheckOnStartup: false,
|
||||
automaticDownload: false,
|
||||
humanOptInInstallRequired: true,
|
||||
automaticRestart: false,
|
||||
})
|
||||
|
||||
const readyInput = () => ({
|
||||
schema: 'hololake.release-pipeline-input/v1',
|
||||
releaseId: 'GH-HOLOLAKE-RELEASE-0.2.0',
|
||||
version: '0.2.0',
|
||||
previousVersion: '0.1.0',
|
||||
minimumVersion: '0.1.0',
|
||||
releaseTag: 'v0.2.0',
|
||||
sourceCommit: 'a'.repeat(40),
|
||||
platformCode: 'darwin-aarch64',
|
||||
packageUrl: 'https://release.guanghu.test/hololake/releases/0.2.0/HoloLake.app.tar.gz',
|
||||
appleTeamIdentifier: '825A9L3G7Q',
|
||||
notes: 'Signed release',
|
||||
features: ['Persistent direct connection'],
|
||||
fixes: [],
|
||||
dataMigrationRequired: false,
|
||||
restartMessage: 'Restart manually',
|
||||
})
|
||||
|
||||
test('release pipeline refuses an unprovisioned or upstream-owned trust document', () => {
|
||||
const unprovisioned = readyTrust()
|
||||
unprovisioned.state = 'UNPROVISIONED_FAIL_CLOSED'
|
||||
assert.throws(() => validateReleaseTrust(unprovisioned), /TRUST_UNPROVISIONED/)
|
||||
|
||||
const upstream = readyTrust()
|
||||
upstream.endpoints = ['https://updates.vendor.test/latest.json']
|
||||
assert.throws(() => validateReleaseTrust(upstream), /ENDPOINT_NOT_HOLOLAKE_HTTPS/)
|
||||
})
|
||||
|
||||
test('release package must use the exact registered HoloLake HTTPS host and immutable tag', () => {
|
||||
const trust = validateReleaseTrust(readyTrust())
|
||||
const wrongHost = readyInput()
|
||||
wrongHost.packageUrl = 'https://github.com/example/HoloLake.app.tar.gz'
|
||||
assert.throws(() => validateReleaseInput(wrongHost, trust), /PACKAGE_HOST_NOT_TRUSTED/)
|
||||
|
||||
const wrongPath = readyInput()
|
||||
wrongPath.packageUrl = 'https://release.guanghu.test/updates/HoloLake.app.tar.gz'
|
||||
assert.throws(() => validateReleaseInput(wrongPath, trust), /PACKAGE_HOST_NOT_TRUSTED/)
|
||||
|
||||
const wrongTag = readyInput()
|
||||
wrongTag.releaseTag = 'latest'
|
||||
assert.throws(() => validateReleaseInput(wrongTag, trust), /IMMUTABLE_TAG_INVALID/)
|
||||
assert.equal(validateReleaseInput(readyInput(), trust).version, '0.2.0')
|
||||
})
|
||||
|
||||
test('release pipeline requires updater signing, Developer ID and Apple notarization credentials together', () => {
|
||||
assert.throws(() => validateCredentialEnvironment({}), /CREDENTIALS_MISSING/)
|
||||
assert.doesNotThrow(() => validateCredentialEnvironment({
|
||||
APPLE_SIGNING_IDENTITY: 'Developer ID Application: HoloLake (TEAM)',
|
||||
TAURI_SIGNING_PRIVATE_KEY: 'runtime-secret-material',
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: 'provided-at-runtime',
|
||||
APPLE_ID: 'release@example.test',
|
||||
APPLE_PASSWORD: 'provided-at-runtime',
|
||||
APPLE_TEAM_ID: 'TEAM',
|
||||
}))
|
||||
})
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# HoloLake release broadcast candidate
|
||||
|
||||
This is the HoloLake-owned, loopback-only origin for signed update broadcasts.
|
||||
It has no upstream software feed and never uploads or activates a release.
|
||||
|
||||
Without `ACTIVE.json`, `/health` reports `EMPTY_FAIL_CLOSED` and `/latest.json`
|
||||
returns HTTP 204, the updater protocol's explicit no-update result. An active
|
||||
release is accepted only when the human activation record, immutable broadcast,
|
||||
pipeline receipt, package bytes, Developer ID receipt, and Apple notarization
|
||||
receipt form one exact evidence chain. Invalid evidence locks the whole release
|
||||
endpoint until an operator fixes the evidence and explicitly restarts the
|
||||
service.
|
||||
|
||||
The service listens only on `127.0.0.1`. Public HTTPS routing, updater trust-key
|
||||
provisioning, artifact upload, activation, and desktop rollout are independent
|
||||
deployment gates.
|
||||
|
||||
The registered public namespace is `/hololake/releases`. The loopback origin
|
||||
accepts the manifest at both its operator health-check path `/latest.json` and
|
||||
the public updater path `/hololake/releases/latest.json`; release package URLs
|
||||
must remain under that same public prefix. The front-door proxy must therefore
|
||||
preserve the full request URI. Its loopback tunnel port remains deliberately
|
||||
unassigned until the BS-GZ-006 route owner returns the live, non-conflicting
|
||||
topology.
|
||||
|
||||
Once that port is returned and independently verified, `render-front-door.mjs
|
||||
--loopback-port PORT` renders the bounded Nginx location. It refuses an omitted
|
||||
port, preserves the full public request path by leaving `proxy_pass` without a
|
||||
URI suffix, accepts only GET and HEAD, forwards no request body, and strips
|
||||
authorization, cookies, and client forwarding headers. Rendering is not
|
||||
deployment; Nginx validation, transactional install, public certificate/readback,
|
||||
and a separate server receipt remain required.
|
||||
|
||||
`operator.mjs` supplies the separate, root-operated verification and activation
|
||||
boundary. `verify` reconstructs a private candidate tree and accepts it only when
|
||||
the broadcast, pipeline receipt, package bytes, Developer ID receipt, Apple
|
||||
notarization receipt, and exact human approval all agree. `activate` additionally
|
||||
requires the operator to repeat the expected release id, version, source commit,
|
||||
and broadcast SHA-256. It copies only referenced immutable artifacts, commits
|
||||
`ACTIVE.json` atomically, and reports that an explicit service restart is still
|
||||
required. It never uploads, activates, or restarts on its own.
|
||||
|
||||
```text
|
||||
node operator.mjs verify \
|
||||
--source /secure/release/out/0.2.0 \
|
||||
--human-approval /secure/approvals/0.2.0.json
|
||||
|
||||
sudo node operator.mjs activate \
|
||||
--source /secure/release/out/0.2.0 \
|
||||
--human-approval /secure/approvals/0.2.0.json \
|
||||
--state-root /var/lib/guanghu/hololake-release-broadcast \
|
||||
--expect-release-id GH-HOLOLAKE-RELEASE-0.2.0 \
|
||||
--expect-version 0.2.0 \
|
||||
--expect-source-commit 0000000000000000000000000000000000000000 \
|
||||
--expect-broadcast-sha256 0000000000000000000000000000000000000000000000000000000000000000
|
||||
```
|
||||
|
||||
The zeros above are placeholders, not deployable values. A real activation must
|
||||
use the exact facts printed by `verify` and a separately issued human approval.
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
[Unit]
|
||||
Description=HoloLake signed release broadcast candidate
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=hololake-release
|
||||
Group=hololake-release
|
||||
WorkingDirectory=/opt/guanghu/hololake-release-broadcast-candidate/current
|
||||
Environment=HOLOLAKE_RELEASE_HOST=127.0.0.1
|
||||
Environment=HOLOLAKE_RELEASE_PORT=3940
|
||||
Environment=HOLOLAKE_RELEASE_STATE_ROOT=/var/lib/guanghu/hololake-release-broadcast
|
||||
Environment=HOLOLAKE_RELEASE_ENFORCE_ROOT_OWNER=1
|
||||
Environment=HOLOLAKE_RELEASE_PUBLIC_PREFIX=/hololake/releases
|
||||
ExecStart=/usr/bin/node /opt/guanghu/hololake-release-broadcast-candidate/current/server.mjs
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
UMask=0027
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
ReadOnlyPaths=/opt/guanghu/hololake-release-broadcast-candidate
|
||||
ReadOnlyPaths=/var/lib/guanghu/hololake-release-broadcast
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
431
product-source/hololake-native-desktop/server/release-broadcast/operator.mjs
Executable file
431
product-source/hololake-native-desktop/server/release-broadcast/operator.mjs
Executable file
|
|
@ -0,0 +1,431 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { DEFAULT_PUBLIC_PREFIX, isMainModule, loadRuntimeState, normalizePublicPrefix } from './server.mjs'
|
||||
|
||||
const MAX_CONTROL_BYTES = 1024 * 1024
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
||||
const SOURCE_COMMIT_PATTERN = /^[a-f0-9]{40}$/
|
||||
const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
||||
|
||||
const fail = (code) => {
|
||||
throw new Error(code)
|
||||
}
|
||||
|
||||
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex')
|
||||
|
||||
const requireText = (value, code) => {
|
||||
if (typeof value !== 'string' || value.trim() === '') fail(code)
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function requireTrustedDirectory(directory, { enforceRootOwner = false } = {}) {
|
||||
const info = fs.lstatSync(directory)
|
||||
if (!info.isDirectory() || info.isSymbolicLink()) fail('HOLOLAKE_RELEASE_OPERATOR_DIRECTORY_INVALID')
|
||||
if ((info.mode & 0o022) !== 0) fail('HOLOLAKE_RELEASE_OPERATOR_DIRECTORY_WRITABLE_BY_NON_OWNER')
|
||||
if (enforceRootOwner && info.uid !== 0) fail('HOLOLAKE_RELEASE_OPERATOR_DIRECTORY_NOT_ROOT_OWNED')
|
||||
return info
|
||||
}
|
||||
|
||||
function requireTrustedFile(file, { enforceRootOwner = false, maxBytes = null } = {}) {
|
||||
const info = fs.lstatSync(file)
|
||||
if (!info.isFile() || info.isSymbolicLink()) fail('HOLOLAKE_RELEASE_OPERATOR_FILE_INVALID')
|
||||
if ((info.mode & 0o022) !== 0) fail('HOLOLAKE_RELEASE_OPERATOR_FILE_WRITABLE_BY_NON_OWNER')
|
||||
if (enforceRootOwner && info.uid !== 0) fail('HOLOLAKE_RELEASE_OPERATOR_FILE_NOT_ROOT_OWNED')
|
||||
if (maxBytes !== null && info.size > maxBytes) fail('HOLOLAKE_RELEASE_OPERATOR_CONTROL_FILE_TOO_LARGE')
|
||||
return info
|
||||
}
|
||||
|
||||
function readJson(file, { enforceRootOwner = false } = {}) {
|
||||
requireTrustedFile(file, { enforceRootOwner, maxBytes: MAX_CONTROL_BYTES })
|
||||
const bytes = fs.readFileSync(file)
|
||||
return { bytes, value: JSON.parse(bytes.toString('utf8')) }
|
||||
}
|
||||
|
||||
function directFile(directory, name, code) {
|
||||
const value = requireText(name, code)
|
||||
if (value !== path.basename(value) || value === '.' || value === '..' || value.includes('\0')) fail(code)
|
||||
const resolved = path.resolve(directory, value)
|
||||
if (path.dirname(resolved) !== directory) fail(code)
|
||||
return resolved
|
||||
}
|
||||
|
||||
function packageNameFromUrl(value, publicPrefix) {
|
||||
const url = new URL(requireText(value, 'HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_REQUIRED'))
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
|
||||
fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_INVALID')
|
||||
}
|
||||
let packageName
|
||||
try {
|
||||
packageName = decodeURIComponent(path.posix.basename(url.pathname))
|
||||
} catch {
|
||||
fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_URL_INVALID')
|
||||
}
|
||||
if (!packageName || packageName === '.' || packageName === '..') fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_NAME_INVALID')
|
||||
if (!url.pathname.startsWith(`${publicPrefix}/`) || url.pathname === `${publicPrefix}/latest.json` || url.pathname === `${publicPrefix}/health`) {
|
||||
fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_PUBLIC_PREFIX_MISMATCH')
|
||||
}
|
||||
return packageName
|
||||
}
|
||||
|
||||
function addArtifact(artifacts, source, name, type) {
|
||||
const previous = artifacts.get(name)
|
||||
if (previous && previous.source !== source) fail('HOLOLAKE_RELEASE_OPERATOR_ARTIFACT_NAME_COLLISION')
|
||||
artifacts.set(name, { source, name, type })
|
||||
}
|
||||
|
||||
export function inspectReleaseBundle(
|
||||
sourceDirectory,
|
||||
humanApprovalPath,
|
||||
{ enforceRootOwner = false, publicPrefix = DEFAULT_PUBLIC_PREFIX } = {},
|
||||
) {
|
||||
const source = path.resolve(sourceDirectory)
|
||||
const normalizedPublicPrefix = normalizePublicPrefix(publicPrefix)
|
||||
requireTrustedDirectory(source, { enforceRootOwner })
|
||||
const latestPath = directFile(source, 'latest.json', 'HOLOLAKE_RELEASE_OPERATOR_BROADCAST_PATH_INVALID')
|
||||
const pipelinePath = directFile(source, 'pipeline-receipt.json', 'HOLOLAKE_RELEASE_OPERATOR_PIPELINE_PATH_INVALID')
|
||||
const approvalPath = path.resolve(humanApprovalPath)
|
||||
const latestDocument = readJson(latestPath, { enforceRootOwner })
|
||||
const pipelineReceipt = readJson(pipelinePath, { enforceRootOwner }).value
|
||||
const approvalReceipt = readJson(approvalPath, { enforceRootOwner }).value
|
||||
const broadcast = latestDocument.value
|
||||
const broadcastSha256 = sha256(latestDocument.bytes)
|
||||
|
||||
if (broadcast?.schema !== 'hololake.release-broadcast/v1') fail('HOLOLAKE_RELEASE_OPERATOR_BROADCAST_INVALID')
|
||||
const releaseId = requireText(broadcast.releaseId, 'HOLOLAKE_RELEASE_OPERATOR_RELEASE_ID_REQUIRED')
|
||||
const version = requireText(broadcast.version, 'HOLOLAKE_RELEASE_OPERATOR_VERSION_REQUIRED')
|
||||
if (!VERSION_PATTERN.test(version)) fail('HOLOLAKE_RELEASE_OPERATOR_VERSION_INVALID')
|
||||
if (
|
||||
broadcast?.hololake?.restart?.required !== true ||
|
||||
broadcast?.hololake?.restart?.automaticAllowed !== false ||
|
||||
broadcast?.hololake?.rollback?.supported !== true ||
|
||||
broadcast?.hololake?.rollback?.healthReceiptRequired !== true ||
|
||||
!broadcast.platforms ||
|
||||
typeof broadcast.platforms !== 'object' ||
|
||||
Array.isArray(broadcast.platforms) ||
|
||||
Object.keys(broadcast.platforms).length === 0
|
||||
) fail('HOLOLAKE_RELEASE_OPERATOR_BROADCAST_POLICY_INVALID')
|
||||
|
||||
if (
|
||||
pipelineReceipt?.schema !== 'hololake.signed-release-pipeline-receipt/v1' ||
|
||||
pipelineReceipt?.state !== 'SIGNED_NOTARIZED_RELEASE_BROADCAST_READY_FOR_JD_CONTROLLER_UPLOAD' ||
|
||||
pipelineReceipt?.broadcastSha256 !== broadcastSha256 ||
|
||||
!SOURCE_COMMIT_PATTERN.test(pipelineReceipt?.sourceCommit || '') ||
|
||||
pipelineReceipt?.automaticUpload !== false ||
|
||||
pipelineReceipt?.automaticActivation !== false
|
||||
) fail('HOLOLAKE_RELEASE_OPERATOR_PIPELINE_RECEIPT_INVALID')
|
||||
|
||||
const approvalId = requireText(approvalReceipt?.approvalId, 'HOLOLAKE_RELEASE_OPERATOR_APPROVAL_ID_REQUIRED')
|
||||
if (
|
||||
approvalReceipt?.schema !== 'hololake.release-broadcast-human-approval/v1' ||
|
||||
approvalReceipt?.state !== 'HUMAN_APPROVED_EXACT_SIGNED_NOTARIZED_RELEASE' ||
|
||||
approvalReceipt?.releaseId !== releaseId ||
|
||||
approvalReceipt?.version !== version ||
|
||||
approvalReceipt?.broadcastSha256 !== broadcastSha256
|
||||
) fail('HOLOLAKE_RELEASE_OPERATOR_HUMAN_APPROVAL_INVALID')
|
||||
|
||||
const artifacts = new Map()
|
||||
addArtifact(artifacts, latestPath, 'latest.json', 'control')
|
||||
addArtifact(artifacts, pipelinePath, 'pipeline-receipt.json', 'control')
|
||||
addArtifact(artifacts, approvalPath, 'human-approval.json', 'control')
|
||||
for (const platform of Object.values(broadcast.platforms)) {
|
||||
const packageName = packageNameFromUrl(platform?.url, normalizedPublicPrefix)
|
||||
const packagePath = directFile(source, packageName, 'HOLOLAKE_RELEASE_OPERATOR_PACKAGE_PATH_INVALID')
|
||||
const packageInfo = requireTrustedFile(packagePath, { enforceRootOwner })
|
||||
if (!Number.isSafeInteger(platform.size) || platform.size <= 0 || platform.size !== packageInfo.size) {
|
||||
fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_SIZE_MISMATCH')
|
||||
}
|
||||
if (!SHA256_PATTERN.test(platform.sha256 || '') || sha256(fs.readFileSync(packagePath)) !== platform.sha256) {
|
||||
fail('HOLOLAKE_RELEASE_OPERATOR_PACKAGE_SHA256_MISMATCH')
|
||||
}
|
||||
requireText(platform.signature, 'HOLOLAKE_RELEASE_OPERATOR_PACKAGE_SIGNATURE_REQUIRED')
|
||||
const codeReceiptId = requireText(platform.platformCodeSignatureReceipt, 'HOLOLAKE_RELEASE_OPERATOR_CODESIGN_RECEIPT_REQUIRED')
|
||||
const notarizationReceiptId = requireText(platform.notarizationReceipt, 'HOLOLAKE_RELEASE_OPERATOR_NOTARIZATION_RECEIPT_REQUIRED')
|
||||
const codeReceiptName = `${codeReceiptId}.json`
|
||||
const notarizationReceiptName = `${notarizationReceiptId}.json`
|
||||
const codeReceiptPath = directFile(source, codeReceiptName, 'HOLOLAKE_RELEASE_OPERATOR_CODESIGN_RECEIPT_PATH_INVALID')
|
||||
const notarizationReceiptPath = directFile(source, notarizationReceiptName, 'HOLOLAKE_RELEASE_OPERATOR_NOTARIZATION_RECEIPT_PATH_INVALID')
|
||||
const codeReceipt = readJson(codeReceiptPath, { enforceRootOwner }).value
|
||||
const notarizationReceipt = readJson(notarizationReceiptPath, { enforceRootOwner }).value
|
||||
if (
|
||||
codeReceipt?.schema !== 'hololake.platform-code-signature-receipt/v1' ||
|
||||
codeReceipt?.state !== 'DEVELOPER_ID_SIGNATURE_STRICT_AND_GATEKEEPER_ACCEPTED' ||
|
||||
codeReceipt?.sourceCommit !== pipelineReceipt.sourceCommit
|
||||
) fail('HOLOLAKE_RELEASE_OPERATOR_CODESIGN_RECEIPT_INVALID')
|
||||
if (
|
||||
notarizationReceipt?.schema !== 'hololake.apple-notarization-receipt/v1' ||
|
||||
notarizationReceipt?.state !== 'APPLE_NOTARIZATION_ACCEPTED_AND_STAPLED' ||
|
||||
notarizationReceipt?.sourceCommit !== pipelineReceipt.sourceCommit
|
||||
) fail('HOLOLAKE_RELEASE_OPERATOR_NOTARIZATION_RECEIPT_INVALID')
|
||||
addArtifact(artifacts, packagePath, packageName, 'package')
|
||||
addArtifact(artifacts, codeReceiptPath, codeReceiptName, 'control')
|
||||
addArtifact(artifacts, notarizationReceiptPath, notarizationReceiptName, 'control')
|
||||
}
|
||||
|
||||
return {
|
||||
schema: 'hololake.release-broadcast-operator-plan/v1',
|
||||
state: 'EXACT_SIGNED_NOTARIZED_HUMAN_APPROVED_BUNDLE_VERIFIED',
|
||||
releaseId,
|
||||
version,
|
||||
sourceCommit: pipelineReceipt.sourceCommit,
|
||||
broadcastSha256,
|
||||
approvalId,
|
||||
publicPrefix: normalizedPublicPrefix,
|
||||
artifacts: [...artifacts.values()],
|
||||
}
|
||||
}
|
||||
|
||||
function copyFileNoFollow(source, destination, mode) {
|
||||
const sourceDescriptor = fs.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
||||
let destinationDescriptor = null
|
||||
try {
|
||||
const sourceInfo = fs.fstatSync(sourceDescriptor)
|
||||
if (!sourceInfo.isFile() || (sourceInfo.mode & 0o022) !== 0) fail('HOLOLAKE_RELEASE_OPERATOR_SOURCE_CHANGED')
|
||||
destinationDescriptor = fs.openSync(destination, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, mode)
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
||||
let position = 0
|
||||
while (position < sourceInfo.size) {
|
||||
const read = fs.readSync(sourceDescriptor, buffer, 0, Math.min(buffer.length, sourceInfo.size - position), position)
|
||||
if (read <= 0) fail('HOLOLAKE_RELEASE_OPERATOR_SOURCE_TRUNCATED')
|
||||
let written = 0
|
||||
while (written < read) written += fs.writeSync(destinationDescriptor, buffer, written, read - written)
|
||||
position += read
|
||||
}
|
||||
fs.fsyncSync(destinationDescriptor)
|
||||
} finally {
|
||||
if (destinationDescriptor !== null) fs.closeSync(destinationDescriptor)
|
||||
fs.closeSync(sourceDescriptor)
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonExclusive(file, value, mode) {
|
||||
const descriptor = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, mode)
|
||||
try {
|
||||
fs.writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`)
|
||||
fs.fsyncSync(descriptor)
|
||||
} finally {
|
||||
fs.closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
function activationFor(plan) {
|
||||
return {
|
||||
schema: 'hololake.release-broadcast-activation/v1',
|
||||
state: 'HUMAN_APPROVED_SIGNED_NOTARIZED_RELEASE_ACTIVE',
|
||||
releaseId: plan.releaseId,
|
||||
version: plan.version,
|
||||
broadcastRelativePath: `releases/${plan.version}/latest.json`,
|
||||
broadcastSha256: plan.broadcastSha256,
|
||||
pipelineReceiptRelativePath: `releases/${plan.version}/pipeline-receipt.json`,
|
||||
humanApprovalReceipt: plan.approvalId,
|
||||
humanApprovalReceiptRelativePath: `releases/${plan.version}/human-approval.json`,
|
||||
}
|
||||
}
|
||||
|
||||
function prepareCandidate(candidateRoot, plan, { enforceRootOwner, trustedGroupId }) {
|
||||
const releases = path.join(candidateRoot, 'releases')
|
||||
const releaseDirectory = path.join(releases, plan.version)
|
||||
fs.mkdirSync(releaseDirectory, { recursive: true, mode: enforceRootOwner ? 0o750 : 0o700 })
|
||||
for (const artifact of plan.artifacts) {
|
||||
const destination = path.join(releaseDirectory, artifact.name)
|
||||
copyFileNoFollow(artifact.source, destination, enforceRootOwner ? 0o640 : 0o600)
|
||||
if (enforceRootOwner) fs.chownSync(destination, 0, trustedGroupId)
|
||||
}
|
||||
const activationPath = path.join(candidateRoot, 'ACTIVE.json')
|
||||
writeJsonExclusive(activationPath, activationFor(plan), enforceRootOwner ? 0o640 : 0o600)
|
||||
if (enforceRootOwner) {
|
||||
for (const directory of [candidateRoot, releases, releaseDirectory]) {
|
||||
fs.chownSync(directory, 0, trustedGroupId)
|
||||
fs.chmodSync(directory, 0o750)
|
||||
}
|
||||
fs.chownSync(activationPath, 0, trustedGroupId)
|
||||
}
|
||||
const state = loadRuntimeState(candidateRoot, { enforceRootOwner, publicPrefix: plan.publicPrefix })
|
||||
if (state.state !== 'READY_SIGNED_NOTARIZED_BROADCAST') {
|
||||
fail(`HOLOLAKE_RELEASE_OPERATOR_CANDIDATE_INVALID:${state.reasonCode || state.state}`)
|
||||
}
|
||||
return { releaseDirectory, activationPath }
|
||||
}
|
||||
|
||||
function requireExpected(plan, expected) {
|
||||
for (const [field, code] of [
|
||||
['releaseId', 'HOLOLAKE_RELEASE_OPERATOR_EXPECTED_RELEASE_ID_MISMATCH'],
|
||||
['version', 'HOLOLAKE_RELEASE_OPERATOR_EXPECTED_VERSION_MISMATCH'],
|
||||
['sourceCommit', 'HOLOLAKE_RELEASE_OPERATOR_EXPECTED_SOURCE_COMMIT_MISMATCH'],
|
||||
['broadcastSha256', 'HOLOLAKE_RELEASE_OPERATOR_EXPECTED_BROADCAST_SHA256_MISMATCH'],
|
||||
]) {
|
||||
if (requireText(expected?.[field], `${code}_REQUIRED`) !== plan[field]) fail(code)
|
||||
}
|
||||
}
|
||||
|
||||
function replaceActivation(stateRoot, candidateActivation, previousBytes, enforceRootOwner, trustedGroupId, publicPrefix) {
|
||||
const tempActivation = path.join(stateRoot, `.ACTIVE.${crypto.randomUUID()}.tmp`)
|
||||
copyFileNoFollow(candidateActivation, tempActivation, enforceRootOwner ? 0o640 : 0o600)
|
||||
if (enforceRootOwner) fs.chownSync(tempActivation, 0, trustedGroupId)
|
||||
fs.renameSync(tempActivation, path.join(stateRoot, 'ACTIVE.json'))
|
||||
const committed = loadRuntimeState(stateRoot, { enforceRootOwner, publicPrefix })
|
||||
if (committed.state === 'READY_SIGNED_NOTARIZED_BROADCAST') return committed
|
||||
const failedActive = path.join(stateRoot, 'ACTIVE.json')
|
||||
if (previousBytes === null) {
|
||||
fs.unlinkSync(failedActive)
|
||||
} else {
|
||||
const rollback = path.join(stateRoot, `.ACTIVE.rollback.${crypto.randomUUID()}.tmp`)
|
||||
const descriptor = fs.openSync(rollback, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, enforceRootOwner ? 0o640 : 0o600)
|
||||
try {
|
||||
fs.writeFileSync(descriptor, previousBytes)
|
||||
fs.fsyncSync(descriptor)
|
||||
} finally {
|
||||
fs.closeSync(descriptor)
|
||||
}
|
||||
if (enforceRootOwner) fs.chownSync(rollback, 0, trustedGroupId)
|
||||
fs.renameSync(rollback, failedActive)
|
||||
}
|
||||
fail(`HOLOLAKE_RELEASE_OPERATOR_POST_COMMIT_INVALID:${committed.reasonCode || committed.state}`)
|
||||
}
|
||||
|
||||
function acquireOperatorLock(stateRoot, enforceRootOwner, trustedGroupId) {
|
||||
const lockDirectory = path.join(stateRoot, '.operator-lock')
|
||||
try {
|
||||
fs.mkdirSync(lockDirectory, { mode: enforceRootOwner ? 0o750 : 0o700 })
|
||||
} catch (error) {
|
||||
if (error?.code === 'EEXIST') fail('HOLOLAKE_RELEASE_OPERATOR_LOCKED')
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
if (enforceRootOwner) fs.chownSync(lockDirectory, 0, trustedGroupId)
|
||||
writeJsonExclusive(path.join(lockDirectory, 'owner.json'), {
|
||||
schema: 'hololake.release-broadcast-operator-lock/v1',
|
||||
pid: process.pid,
|
||||
acquiredAt: new Date().toISOString(),
|
||||
}, enforceRootOwner ? 0o640 : 0o600)
|
||||
if (enforceRootOwner) fs.chownSync(path.join(lockDirectory, 'owner.json'), 0, trustedGroupId)
|
||||
return lockDirectory
|
||||
} catch (error) {
|
||||
fs.rmSync(lockDirectory, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyReleaseBundle(sourceDirectory, humanApprovalPath) {
|
||||
const plan = inspectReleaseBundle(sourceDirectory, humanApprovalPath)
|
||||
const candidateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hololake-release-operator-verify-'))
|
||||
try {
|
||||
prepareCandidate(candidateRoot, plan, { enforceRootOwner: false, trustedGroupId: process.getgid?.() ?? 0 })
|
||||
return { ...plan, artifacts: plan.artifacts.map(({ name, type }) => ({ name, type })) }
|
||||
} finally {
|
||||
fs.rmSync(candidateRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function activateRelease({
|
||||
sourceDirectory,
|
||||
humanApprovalPath,
|
||||
stateRoot,
|
||||
expected,
|
||||
enforceRootOwner = true,
|
||||
publicPrefix = DEFAULT_PUBLIC_PREFIX,
|
||||
}) {
|
||||
if (enforceRootOwner && process.getuid?.() !== 0) fail('HOLOLAKE_RELEASE_OPERATOR_ROOT_REQUIRED')
|
||||
const root = path.resolve(stateRoot)
|
||||
const rootInfo = requireTrustedDirectory(root, { enforceRootOwner })
|
||||
const trustedGroupId = rootInfo.gid
|
||||
const lockDirectory = acquireOperatorLock(root, enforceRootOwner, trustedGroupId)
|
||||
let candidateRoot = null
|
||||
try {
|
||||
const plan = inspectReleaseBundle(sourceDirectory, humanApprovalPath, { enforceRootOwner, publicPrefix })
|
||||
requireExpected(plan, expected)
|
||||
candidateRoot = fs.mkdtempSync(path.join(root, '.candidate-'))
|
||||
const candidate = prepareCandidate(candidateRoot, plan, { enforceRootOwner, trustedGroupId })
|
||||
const releasesRoot = path.join(root, 'releases')
|
||||
if (!fs.existsSync(releasesRoot)) fs.mkdirSync(releasesRoot, { mode: enforceRootOwner ? 0o750 : 0o700 })
|
||||
requireTrustedDirectory(releasesRoot, { enforceRootOwner })
|
||||
if (enforceRootOwner) {
|
||||
fs.chownSync(releasesRoot, 0, trustedGroupId)
|
||||
fs.chmodSync(releasesRoot, 0o750)
|
||||
}
|
||||
const finalRelease = path.join(releasesRoot, plan.version)
|
||||
if (fs.existsSync(finalRelease)) fail('HOLOLAKE_RELEASE_OPERATOR_VERSION_ALREADY_STAGED')
|
||||
const activePath = path.join(root, 'ACTIVE.json')
|
||||
const previousBytes = fs.existsSync(activePath) ? readJson(activePath, { enforceRootOwner }).bytes : null
|
||||
fs.renameSync(candidate.releaseDirectory, finalRelease)
|
||||
let committed
|
||||
try {
|
||||
committed = replaceActivation(
|
||||
root,
|
||||
candidate.activationPath,
|
||||
previousBytes,
|
||||
enforceRootOwner,
|
||||
trustedGroupId,
|
||||
plan.publicPrefix,
|
||||
)
|
||||
} catch (error) {
|
||||
fs.rmSync(finalRelease, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
schema: 'hololake.release-broadcast-operator-receipt/v1',
|
||||
state: 'ACTIVATED_EXPLICIT_SERVICE_RESTART_REQUIRED',
|
||||
releaseId: plan.releaseId,
|
||||
version: plan.version,
|
||||
sourceCommit: plan.sourceCommit,
|
||||
broadcastSha256: plan.broadcastSha256,
|
||||
approvalId: plan.approvalId,
|
||||
publicPrefix: plan.publicPrefix,
|
||||
automaticUpload: false,
|
||||
automaticActivation: false,
|
||||
automaticRestart: false,
|
||||
runtimeStateAfterDiskCommit: committed.state,
|
||||
}
|
||||
} finally {
|
||||
if (candidateRoot) fs.rmSync(candidateRoot, { recursive: true, force: true })
|
||||
fs.rmSync(lockDirectory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const command = argv[0]
|
||||
const values = {}
|
||||
for (let index = 1; index < argv.length; index += 1) {
|
||||
const key = argv[index]
|
||||
if (!key.startsWith('--') || index + 1 >= argv.length) fail('HOLOLAKE_RELEASE_OPERATOR_ARGUMENT_INVALID')
|
||||
values[key.slice(2)] = argv[++index]
|
||||
}
|
||||
return { command, values }
|
||||
}
|
||||
|
||||
export function main(argv = process.argv.slice(2)) {
|
||||
const { command, values } = parseArguments(argv)
|
||||
const sourceDirectory = requireText(values.source, 'HOLOLAKE_RELEASE_OPERATOR_SOURCE_REQUIRED')
|
||||
const humanApprovalPath = requireText(values['human-approval'], 'HOLOLAKE_RELEASE_OPERATOR_HUMAN_APPROVAL_PATH_REQUIRED')
|
||||
if (command === 'verify') {
|
||||
process.stdout.write(`${JSON.stringify(verifyReleaseBundle(sourceDirectory, humanApprovalPath), null, 2)}\n`)
|
||||
return
|
||||
}
|
||||
if (command !== 'activate') fail('HOLOLAKE_RELEASE_OPERATOR_COMMAND_INVALID:verify|activate')
|
||||
const receipt = activateRelease({
|
||||
sourceDirectory,
|
||||
humanApprovalPath,
|
||||
stateRoot: requireText(values['state-root'], 'HOLOLAKE_RELEASE_OPERATOR_STATE_ROOT_REQUIRED'),
|
||||
publicPrefix: values['public-prefix'] || DEFAULT_PUBLIC_PREFIX,
|
||||
expected: {
|
||||
releaseId: values['expect-release-id'],
|
||||
version: values['expect-version'],
|
||||
sourceCommit: values['expect-source-commit'],
|
||||
broadcastSha256: values['expect-broadcast-sha256'],
|
||||
},
|
||||
})
|
||||
process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`)
|
||||
}
|
||||
|
||||
if (isMainModule(process.argv[1], import.meta.url)) {
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"schema": "hololake.release-public-route/v1",
|
||||
"state": "REGISTERED_PENDING_FRONT_DOOR_DEPLOYMENT",
|
||||
"publicBaseUrl": "https://guanghulab.com/hololake/releases",
|
||||
"releaseEndpoint": "https://guanghulab.com/hololake/releases/latest.json",
|
||||
"publicPathPrefix": "/hololake/releases",
|
||||
"originNode": "JD-FD-PRIMARY",
|
||||
"originListener": "127.0.0.1:3940",
|
||||
"frontDoorNode": "BS-GZ-006",
|
||||
"frontDoorLoopbackPort": null,
|
||||
"proxyRequestUriPolicy": "PRESERVE_FULL_PUBLIC_PATH",
|
||||
"allowedMethods": ["GET", "HEAD"],
|
||||
"requestBodyAllowed": false,
|
||||
"latestCachePolicy": "NO_STORE",
|
||||
"packageCachePolicy": "PUBLIC_IMMUTABLE",
|
||||
"automaticUpload": false,
|
||||
"automaticActivation": false,
|
||||
"deployed": false
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const PORT_PATTERN = /^(?:[1-9]\d{3,4})$/
|
||||
|
||||
const fail = (code) => {
|
||||
throw new Error(code)
|
||||
}
|
||||
|
||||
export function renderFrontDoorSnippet(portValue) {
|
||||
const text = String(portValue ?? '')
|
||||
if (!PORT_PATTERN.test(text)) fail('HOLOLAKE_RELEASE_FRONT_DOOR_PORT_REQUIRED')
|
||||
const port = Number(text)
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) fail('HOLOLAKE_RELEASE_FRONT_DOOR_PORT_INVALID')
|
||||
return `location = /hololake/releases {
|
||||
return 308 /hololake/releases/;
|
||||
}
|
||||
|
||||
location ^~ /hololake/releases/ {
|
||||
limit_except GET HEAD { deny all; }
|
||||
client_max_body_size 0;
|
||||
proxy_pass http://127.0.0.1:${port};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Authorization "";
|
||||
proxy_set_header Cookie "";
|
||||
proxy_set_header X-Forwarded-For "";
|
||||
proxy_set_header X-Forwarded-Host "";
|
||||
proxy_set_header X-Forwarded-Proto "";
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_connect_timeout 2s;
|
||||
proxy_send_timeout 10s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export function main(argv = process.argv.slice(2)) {
|
||||
const portIndex = argv.indexOf('--loopback-port')
|
||||
if (portIndex < 0 || portIndex + 1 >= argv.length || argv.length !== 2) {
|
||||
fail('HOLOLAKE_RELEASE_FRONT_DOOR_USAGE:--loopback-port PORT')
|
||||
}
|
||||
process.stdout.write(renderFrontDoorSnippet(argv[portIndex + 1]))
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import http from 'node:http'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const MAX_CONTROL_BYTES = 1024 * 1024
|
||||
export const DEFAULT_PUBLIC_PREFIX = '/hololake/releases'
|
||||
const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
||||
|
||||
const fail = (code) => {
|
||||
throw new Error(code)
|
||||
}
|
||||
|
||||
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex')
|
||||
|
||||
const requireText = (value, code) => {
|
||||
if (typeof value !== 'string' || value.trim() === '') fail(code)
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
export function normalizePublicPrefix(value) {
|
||||
const prefix = requireText(value, 'HOLOLAKE_RELEASE_PUBLIC_PREFIX_REQUIRED')
|
||||
if (!prefix.startsWith('/') || prefix.endsWith('/') || prefix.includes('//') || prefix.includes('?') || prefix.includes('#')) {
|
||||
fail('HOLOLAKE_RELEASE_PUBLIC_PREFIX_INVALID')
|
||||
}
|
||||
const normalized = path.posix.normalize(prefix)
|
||||
if (normalized !== prefix || normalized === '/' || normalized.split('/').some((segment) => segment === '..')) {
|
||||
fail('HOLOLAKE_RELEASE_PUBLIC_PREFIX_INVALID')
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
function trustedRegularFile(file, { enforceRootOwner, maxBytes = null }) {
|
||||
const info = fs.lstatSync(file)
|
||||
if (!info.isFile() || info.isSymbolicLink()) fail('HOLOLAKE_RELEASE_FILE_NOT_REGULAR')
|
||||
if ((info.mode & 0o022) !== 0) fail('HOLOLAKE_RELEASE_FILE_WRITABLE_BY_NON_OWNER')
|
||||
if (enforceRootOwner && info.uid !== 0) fail('HOLOLAKE_RELEASE_FILE_NOT_ROOT_OWNED')
|
||||
if (maxBytes !== null && info.size > maxBytes) fail('HOLOLAKE_RELEASE_CONTROL_FILE_TOO_LARGE')
|
||||
return info
|
||||
}
|
||||
|
||||
function safeRelative(root, value, code) {
|
||||
const relative = requireText(value, code)
|
||||
if (path.isAbsolute(relative) || relative.includes('\0')) fail(code)
|
||||
const resolved = path.resolve(root, relative)
|
||||
if (!resolved.startsWith(`${root}${path.sep}`)) fail(code)
|
||||
return resolved
|
||||
}
|
||||
|
||||
function readTrustedJson(file, options) {
|
||||
trustedRegularFile(file, { ...options, maxBytes: MAX_CONTROL_BYTES })
|
||||
return { bytes: fs.readFileSync(file), value: JSON.parse(fs.readFileSync(file, 'utf8')) }
|
||||
}
|
||||
|
||||
function validatePlatform(platform, releaseDirectory, enforceRootOwner, sourceCommit, publicPrefix) {
|
||||
const url = new URL(requireText(platform?.url, 'HOLOLAKE_RELEASE_PACKAGE_URL_REQUIRED'))
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
|
||||
fail('HOLOLAKE_RELEASE_PACKAGE_URL_INVALID')
|
||||
}
|
||||
const packageName = decodeURIComponent(path.posix.basename(url.pathname))
|
||||
if (!packageName || packageName === '.' || packageName === '..') fail('HOLOLAKE_RELEASE_PACKAGE_NAME_INVALID')
|
||||
if (!url.pathname.startsWith(`${publicPrefix}/`) || url.pathname === `${publicPrefix}/latest.json` || url.pathname === `${publicPrefix}/health`) {
|
||||
fail('HOLOLAKE_RELEASE_PACKAGE_PUBLIC_PREFIX_MISMATCH')
|
||||
}
|
||||
const packagePath = safeRelative(releaseDirectory, packageName, 'HOLOLAKE_RELEASE_PACKAGE_PATH_INVALID')
|
||||
const packageInfo = trustedRegularFile(packagePath, { enforceRootOwner })
|
||||
if (!Number.isSafeInteger(platform.size) || platform.size <= 0 || platform.size !== packageInfo.size) {
|
||||
fail('HOLOLAKE_RELEASE_PACKAGE_SIZE_MISMATCH')
|
||||
}
|
||||
if (!SHA256_PATTERN.test(platform.sha256 || '') || sha256(fs.readFileSync(packagePath)) !== platform.sha256) {
|
||||
fail('HOLOLAKE_RELEASE_PACKAGE_SHA256_MISMATCH')
|
||||
}
|
||||
requireText(platform.signature, 'HOLOLAKE_RELEASE_PACKAGE_SIGNATURE_REQUIRED')
|
||||
const codeReceiptId = requireText(platform.platformCodeSignatureReceipt, 'HOLOLAKE_RELEASE_CODESIGN_RECEIPT_REQUIRED')
|
||||
const notarizationReceiptId = requireText(platform.notarizationReceipt, 'HOLOLAKE_RELEASE_NOTARIZATION_RECEIPT_REQUIRED')
|
||||
const codeReceipt = readTrustedJson(
|
||||
safeRelative(releaseDirectory, `${codeReceiptId}.json`, 'HOLOLAKE_RELEASE_CODESIGN_RECEIPT_PATH_INVALID'),
|
||||
{ enforceRootOwner },
|
||||
).value
|
||||
const notarizationReceipt = readTrustedJson(
|
||||
safeRelative(releaseDirectory, `${notarizationReceiptId}.json`, 'HOLOLAKE_RELEASE_NOTARIZATION_RECEIPT_PATH_INVALID'),
|
||||
{ enforceRootOwner },
|
||||
).value
|
||||
if (
|
||||
codeReceipt?.schema !== 'hololake.platform-code-signature-receipt/v1' ||
|
||||
codeReceipt?.state !== 'DEVELOPER_ID_SIGNATURE_STRICT_AND_GATEKEEPER_ACCEPTED' ||
|
||||
codeReceipt?.sourceCommit !== sourceCommit
|
||||
) fail('HOLOLAKE_RELEASE_CODESIGN_RECEIPT_INVALID')
|
||||
if (
|
||||
notarizationReceipt?.schema !== 'hololake.apple-notarization-receipt/v1' ||
|
||||
notarizationReceipt?.state !== 'APPLE_NOTARIZATION_ACCEPTED_AND_STAPLED' ||
|
||||
notarizationReceipt?.sourceCommit !== sourceCommit
|
||||
) fail('HOLOLAKE_RELEASE_NOTARIZATION_RECEIPT_INVALID')
|
||||
return { pathname: url.pathname, file: packagePath, size: packageInfo.size }
|
||||
}
|
||||
|
||||
function loadActiveRelease(stateRoot, enforceRootOwner, publicPrefix) {
|
||||
const activationPath = path.join(stateRoot, 'ACTIVE.json')
|
||||
if (!fs.existsSync(activationPath)) {
|
||||
return { state: 'EMPTY_FAIL_CLOSED', release: null, reasonCode: null, publicPrefix }
|
||||
}
|
||||
const activation = readTrustedJson(activationPath, { enforceRootOwner }).value
|
||||
if (
|
||||
activation?.schema !== 'hololake.release-broadcast-activation/v1' ||
|
||||
activation?.state !== 'HUMAN_APPROVED_SIGNED_NOTARIZED_RELEASE_ACTIVE'
|
||||
) fail('HOLOLAKE_RELEASE_ACTIVATION_INVALID')
|
||||
const releaseId = requireText(activation.releaseId, 'HOLOLAKE_RELEASE_ID_REQUIRED')
|
||||
const version = requireText(activation.version, 'HOLOLAKE_RELEASE_VERSION_REQUIRED')
|
||||
if (!VERSION_PATTERN.test(version)) fail('HOLOLAKE_RELEASE_VERSION_INVALID')
|
||||
if (!SHA256_PATTERN.test(activation.broadcastSha256 || '')) fail('HOLOLAKE_RELEASE_BROADCAST_SHA256_INVALID')
|
||||
requireText(activation.humanApprovalReceipt, 'HOLOLAKE_RELEASE_HUMAN_APPROVAL_REQUIRED')
|
||||
const broadcastPath = safeRelative(stateRoot, activation.broadcastRelativePath, 'HOLOLAKE_RELEASE_BROADCAST_PATH_INVALID')
|
||||
const pipelinePath = safeRelative(stateRoot, activation.pipelineReceiptRelativePath, 'HOLOLAKE_RELEASE_PIPELINE_PATH_INVALID')
|
||||
const approvalPath = safeRelative(stateRoot, activation.humanApprovalReceiptRelativePath, 'HOLOLAKE_RELEASE_HUMAN_APPROVAL_PATH_INVALID')
|
||||
const broadcastDocument = readTrustedJson(broadcastPath, { enforceRootOwner })
|
||||
const pipelineReceipt = readTrustedJson(pipelinePath, { enforceRootOwner }).value
|
||||
const approvalReceipt = readTrustedJson(approvalPath, { enforceRootOwner }).value
|
||||
if (sha256(broadcastDocument.bytes) !== activation.broadcastSha256) fail('HOLOLAKE_RELEASE_BROADCAST_SHA256_MISMATCH')
|
||||
if (
|
||||
pipelineReceipt?.schema !== 'hololake.signed-release-pipeline-receipt/v1' ||
|
||||
pipelineReceipt?.state !== 'SIGNED_NOTARIZED_RELEASE_BROADCAST_READY_FOR_JD_CONTROLLER_UPLOAD' ||
|
||||
pipelineReceipt?.broadcastSha256 !== activation.broadcastSha256 ||
|
||||
!/^[a-f0-9]{40}$/.test(pipelineReceipt?.sourceCommit || '') ||
|
||||
pipelineReceipt?.automaticUpload !== false ||
|
||||
pipelineReceipt?.automaticActivation !== false
|
||||
) fail('HOLOLAKE_RELEASE_PIPELINE_RECEIPT_INVALID')
|
||||
if (
|
||||
approvalReceipt?.schema !== 'hololake.release-broadcast-human-approval/v1' ||
|
||||
approvalReceipt?.state !== 'HUMAN_APPROVED_EXACT_SIGNED_NOTARIZED_RELEASE' ||
|
||||
approvalReceipt?.approvalId !== activation.humanApprovalReceipt ||
|
||||
approvalReceipt?.releaseId !== releaseId ||
|
||||
approvalReceipt?.version !== version ||
|
||||
approvalReceipt?.broadcastSha256 !== activation.broadcastSha256
|
||||
) fail('HOLOLAKE_RELEASE_HUMAN_APPROVAL_INVALID')
|
||||
const broadcast = broadcastDocument.value
|
||||
if (
|
||||
broadcast?.schema !== 'hololake.release-broadcast/v1' ||
|
||||
broadcast?.releaseId !== releaseId ||
|
||||
broadcast?.version !== version ||
|
||||
broadcast?.hololake?.restart?.required !== true ||
|
||||
broadcast?.hololake?.restart?.automaticAllowed !== false ||
|
||||
broadcast?.hololake?.rollback?.supported !== true ||
|
||||
broadcast?.hololake?.rollback?.healthReceiptRequired !== true ||
|
||||
!broadcast.platforms || typeof broadcast.platforms !== 'object' || Array.isArray(broadcast.platforms) ||
|
||||
Object.keys(broadcast.platforms).length === 0
|
||||
) fail('HOLOLAKE_RELEASE_BROADCAST_INVALID')
|
||||
const releaseDirectory = path.dirname(broadcastPath)
|
||||
const packages = new Map()
|
||||
for (const platform of Object.values(broadcast.platforms)) {
|
||||
const accepted = validatePlatform(platform, releaseDirectory, enforceRootOwner, pipelineReceipt.sourceCommit, publicPrefix)
|
||||
if (packages.has(accepted.pathname)) fail('HOLOLAKE_RELEASE_PACKAGE_ROUTE_COLLISION')
|
||||
packages.set(accepted.pathname, accepted)
|
||||
}
|
||||
return {
|
||||
state: 'READY_SIGNED_NOTARIZED_BROADCAST',
|
||||
release: { releaseId, version, broadcastBytes: broadcastDocument.bytes, packages },
|
||||
reasonCode: null,
|
||||
publicPrefix,
|
||||
}
|
||||
}
|
||||
|
||||
export function loadRuntimeState(stateRoot, { enforceRootOwner = false, publicPrefix = DEFAULT_PUBLIC_PREFIX } = {}) {
|
||||
const resolvedRoot = path.resolve(stateRoot)
|
||||
try {
|
||||
const normalizedPrefix = normalizePublicPrefix(publicPrefix)
|
||||
return loadActiveRelease(resolvedRoot, enforceRootOwner, normalizedPrefix)
|
||||
} catch (error) {
|
||||
return {
|
||||
state: 'LOCKED_INVALID_RELEASE_EVIDENCE',
|
||||
release: null,
|
||||
reasonCode: error instanceof Error ? error.message : 'HOLOLAKE_RELEASE_UNKNOWN_FAILURE',
|
||||
publicPrefix: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(response, status, value) {
|
||||
const body = Buffer.from(`${JSON.stringify(value)}\n`)
|
||||
response.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': body.length,
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
})
|
||||
response.end(body)
|
||||
}
|
||||
|
||||
export function createReleaseServer(runtimeState) {
|
||||
return http.createServer((request, response) => {
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
if (!['GET', 'HEAD'].includes(request.method || '')) {
|
||||
response.writeHead(405, { Allow: 'GET, HEAD', 'Cache-Control': 'no-store' })
|
||||
return response.end()
|
||||
}
|
||||
const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname
|
||||
const publicHealthPath = runtimeState.publicPrefix ? `${runtimeState.publicPrefix}/health` : null
|
||||
const publicLatestPath = runtimeState.publicPrefix ? `${runtimeState.publicPrefix}/latest.json` : null
|
||||
if (pathname === '/health' || pathname === publicHealthPath) {
|
||||
const status = runtimeState.state === 'LOCKED_INVALID_RELEASE_EVIDENCE' ? 503 : 200
|
||||
return jsonResponse(response, status, {
|
||||
schema: 'hololake.release-broadcast-health/v1',
|
||||
state: runtimeState.state,
|
||||
updateAvailable: runtimeState.release !== null,
|
||||
automaticUpload: false,
|
||||
automaticActivation: false,
|
||||
upstreamUpdateSources: [],
|
||||
...(runtimeState.publicPrefix ? {
|
||||
publicPathPrefix: runtimeState.publicPrefix,
|
||||
releaseEndpointPath: `${runtimeState.publicPrefix}/latest.json`,
|
||||
} : {}),
|
||||
...(runtimeState.release ? { releaseId: runtimeState.release.releaseId, version: runtimeState.release.version } : {}),
|
||||
...(runtimeState.reasonCode ? { reasonCode: runtimeState.reasonCode } : {}),
|
||||
})
|
||||
}
|
||||
if (pathname === '/latest.json' || pathname === publicLatestPath) {
|
||||
if (runtimeState.state === 'EMPTY_FAIL_CLOSED') {
|
||||
response.writeHead(204, { 'Cache-Control': 'no-store' })
|
||||
return response.end()
|
||||
}
|
||||
if (!runtimeState.release) {
|
||||
response.writeHead(503, { 'Cache-Control': 'no-store' })
|
||||
return response.end()
|
||||
}
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': runtimeState.release.broadcastBytes.length,
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
})
|
||||
return response.end(request.method === 'HEAD' ? undefined : runtimeState.release.broadcastBytes)
|
||||
}
|
||||
const packageFile = runtimeState.release?.packages.get(pathname)
|
||||
if (packageFile) {
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': packageFile.size,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
})
|
||||
if (request.method === 'HEAD') return response.end()
|
||||
return fs.createReadStream(packageFile.file).on('error', () => response.destroy()).pipe(response)
|
||||
}
|
||||
response.writeHead(404, { 'Cache-Control': 'no-store' })
|
||||
response.end()
|
||||
})
|
||||
}
|
||||
|
||||
export function main(env = process.env) {
|
||||
const host = env.HOLOLAKE_RELEASE_HOST || '127.0.0.1'
|
||||
if (host !== '127.0.0.1') fail('HOLOLAKE_RELEASE_LOOPBACK_BIND_REQUIRED')
|
||||
const port = Number(env.HOLOLAKE_RELEASE_PORT || 3940)
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) fail('HOLOLAKE_RELEASE_PORT_INVALID')
|
||||
const stateRoot = path.resolve(env.HOLOLAKE_RELEASE_STATE_ROOT || '/var/lib/guanghu/hololake-release-broadcast')
|
||||
const publicPrefix = env.HOLOLAKE_RELEASE_PUBLIC_PREFIX || DEFAULT_PUBLIC_PREFIX
|
||||
const runtimeState = loadRuntimeState(stateRoot, {
|
||||
enforceRootOwner: env.HOLOLAKE_RELEASE_ENFORCE_ROOT_OWNER === '1',
|
||||
publicPrefix,
|
||||
})
|
||||
const server = createReleaseServer(runtimeState)
|
||||
server.on('clientError', (_error, socket) => socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'))
|
||||
server.listen(port, host, () => {
|
||||
process.stdout.write(`${JSON.stringify({ schema: 'hololake.release-broadcast-startup/v1', host, port, state: runtimeState.state })}\n`)
|
||||
})
|
||||
return server
|
||||
}
|
||||
|
||||
export function isMainModule(argvPath, moduleUrl) {
|
||||
return Boolean(argvPath) && fs.realpathSync(path.resolve(argvPath)) === fs.realpathSync(fileURLToPath(moduleUrl))
|
||||
}
|
||||
|
||||
if (isMainModule(process.argv[1], import.meta.url)) {
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
|
@ -659,6 +659,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
|
|
@ -908,6 +910,16 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
|
|
@ -1335,11 +1347,15 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
|||
name = "hololake-native-desktop"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"fs2",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-runtime",
|
||||
"tauri-runtime-wry",
|
||||
|
|
@ -2973,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"
|
||||
|
|
@ -3854,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"
|
||||
|
|
|
|||
|
|
@ -15,13 +15,17 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||
tauri-build = { version = "2.5.4", features = [] }
|
||||
|
||||
[dependencies]
|
||||
dirs = "6"
|
||||
fs2 = "0.4"
|
||||
ring = "0.17"
|
||||
base64 = "0.22"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"schema": "hololake.dynamic-routing-trust/v1",
|
||||
"state": "UNPROVISIONED_FAIL_CLOSED",
|
||||
"signers": []
|
||||
}
|
||||
|
|
@ -0,0 +1,959 @@
|
|||
use crate::direct_local_session::{
|
||||
append_event_at, authenticate_at, direct_session_root, open_at, resume_at,
|
||||
AppendSessionEventInput, AuthenticateSessionInput, OpenSessionInput, ResumeSessionInput,
|
||||
};
|
||||
use crate::dynamic_capability_routing::{
|
||||
install_trusted_registry_at, record_health_at, resolve_at as resolve_capability_route_at,
|
||||
routing_root as dynamic_routing_root, DynamicNodeRegistry, ResolveCapabilityRouteInput,
|
||||
SignedNodeHealth,
|
||||
};
|
||||
use crate::pncc_receipt_projection::{
|
||||
append_remote_read_at as project_pncc_remote_read_at,
|
||||
append_repository_binding_at as project_pncc_repository_binding_at,
|
||||
query_at as query_pncc_projection_at, QueryPnccReceiptProjectionInput,
|
||||
};
|
||||
use crate::pncc_remote_git::{
|
||||
read_mounted_remote_object_at as read_mounted_pncc_remote_at, ReadMountedPnccRemoteObjectInput,
|
||||
};
|
||||
use crate::pncc_repository_binding::{
|
||||
inspect_mounted_at as inspect_mounted_pncc_at, InspectMountedPnccRepositoryInput,
|
||||
};
|
||||
use ring::digest::{digest, SHA256};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{self, BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
mpsc, Arc,
|
||||
};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
const BROKER_SCHEMA: &str = "hololake.direct-local-broker/v1";
|
||||
const MAX_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
pub struct DirectLocalBrokerHandle {
|
||||
shutdown: Arc<AtomicBool>,
|
||||
authenticated_connections: Arc<AtomicUsize>,
|
||||
socket_path: PathBuf,
|
||||
worker: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl DirectLocalBrokerHandle {
|
||||
pub fn active_connection_count(&self) -> usize {
|
||||
self.authenticated_connections.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthenticatedConnectionGuard(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for AuthenticatedConnectionGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
struct BrokerStorageRoots {
|
||||
session: PathBuf,
|
||||
routing: PathBuf,
|
||||
pncc_mount: PathBuf,
|
||||
pncc_remote: PathBuf,
|
||||
pncc_projection: PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for DirectLocalBrokerHandle {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown.store(true, Ordering::Release);
|
||||
let _ = UnixStream::connect(&self.socket_path);
|
||||
if let Some(worker) = self.worker.take() {
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(
|
||||
tag = "operation",
|
||||
content = "input",
|
||||
rename_all = "SCREAMING_SNAKE_CASE"
|
||||
)]
|
||||
enum BrokerRequest {
|
||||
Ping,
|
||||
OpenSession(OpenSessionInput),
|
||||
ResumeSession(ResumeSessionInput),
|
||||
AppendEvent(AppendSessionEventInput),
|
||||
ResolveCapabilityRoute(AuthenticatedRouteInput),
|
||||
InstallDynamicNodeRegistry(AuthenticatedRegistryInput),
|
||||
RecordSignedNodeHealth(AuthenticatedHealthInput),
|
||||
InspectMountedPnccRepository(AuthenticatedPnccMountInput),
|
||||
ReadMountedPnccRemoteObject(AuthenticatedPnccRemoteReadInput),
|
||||
QueryPnccReceiptProjection(AuthenticatedPnccProjectionQueryInput),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct AuthenticatedRouteInput {
|
||||
session: AuthenticateSessionInput,
|
||||
route: ResolveCapabilityRouteInput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct AuthenticatedRegistryInput {
|
||||
session: AuthenticateSessionInput,
|
||||
registry: DynamicNodeRegistry,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct AuthenticatedHealthInput {
|
||||
session: AuthenticateSessionInput,
|
||||
health: SignedNodeHealth,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct AuthenticatedPnccMountInput {
|
||||
session: AuthenticateSessionInput,
|
||||
mount: InspectMountedPnccRepositoryInput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct AuthenticatedPnccRemoteReadInput {
|
||||
session: AuthenticateSessionInput,
|
||||
read: ReadMountedPnccRemoteObjectInput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct AuthenticatedPnccProjectionQueryInput {
|
||||
session: AuthenticateSessionInput,
|
||||
query: QueryPnccReceiptProjectionInput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BrokerResponse {
|
||||
schema: &'static str,
|
||||
request_id: String,
|
||||
ok: bool,
|
||||
result: Option<Value>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BrokerDescriptor {
|
||||
schema: &'static str,
|
||||
state: &'static str,
|
||||
transport: &'static str,
|
||||
socket_path: String,
|
||||
max_request_bytes: u64,
|
||||
process_id: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ConnectorDescriptor {
|
||||
schema: String,
|
||||
state: String,
|
||||
transport: String,
|
||||
socket_path: String,
|
||||
process_id: u32,
|
||||
}
|
||||
|
||||
pub fn start(app: &AppHandle) -> Result<DirectLocalBrokerHandle, Box<dyn std::error::Error>> {
|
||||
let session_root = direct_session_root(app).map_err(std::io::Error::other)?;
|
||||
let routing_root = dynamic_routing_root(app).map_err(std::io::Error::other)?;
|
||||
let app_data = app.path().app_data_dir()?;
|
||||
let descriptor_path = app_data.join("direct-local-broker-v1.json");
|
||||
let socket_path = short_socket_path(&app_data);
|
||||
start_at(session_root, routing_root, descriptor_path, socket_path).map_err(|error| error.into())
|
||||
}
|
||||
|
||||
pub fn run_connector() -> Result<(), String> {
|
||||
let descriptor_path = connector_descriptor_path()?;
|
||||
let descriptor: ConnectorDescriptor = serde_json::from_slice(
|
||||
&fs::read(&descriptor_path)
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_DESCRIPTOR_UNAVAILABLE: {error}"))?,
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_DESCRIPTOR_INVALID: {error}"))?;
|
||||
if descriptor.schema != BROKER_SCHEMA
|
||||
|| descriptor.state != "LISTENING"
|
||||
|| descriptor.transport != "UNIX_STREAM_JSON_LINES"
|
||||
{
|
||||
return Err("HOLOLAKE_BROKER_DESCRIPTOR_UNSUPPORTED".into());
|
||||
}
|
||||
let mut stream = connect_with_startup_grace(&descriptor)?;
|
||||
let read_stream = stream
|
||||
.try_clone()
|
||||
.map_err(|error| format!("HOLOLAKE_CONNECTOR_CLONE_FAILED: {error}"))?;
|
||||
let output = thread::Builder::new()
|
||||
.name("hololake-connector-output".into())
|
||||
.spawn(move || -> Result<(), String> {
|
||||
let mut reader = BufReader::new(read_stream);
|
||||
let mut stdout = io::stdout().lock();
|
||||
io::copy(&mut reader, &mut stdout)
|
||||
.and_then(|_| stdout.flush())
|
||||
.map_err(|error| format!("HOLOLAKE_CONNECTOR_OUTPUT_FAILED: {error}"))?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|error| format!("HOLOLAKE_CONNECTOR_OUTPUT_THREAD_FAILED: {error}"))?;
|
||||
|
||||
let mut stdin = io::stdin().lock();
|
||||
io::copy(&mut stdin, &mut stream)
|
||||
.and_then(|_| stream.shutdown(std::net::Shutdown::Write))
|
||||
.map_err(|error| format!("HOLOLAKE_CONNECTOR_INPUT_FAILED: {error}"))?;
|
||||
output
|
||||
.join()
|
||||
.map_err(|_| "HOLOLAKE_CONNECTOR_OUTPUT_THREAD_PANICKED".to_string())??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn connect_with_startup_grace(descriptor: &ConnectorDescriptor) -> Result<UnixStream, String> {
|
||||
if descriptor.process_id == 0 {
|
||||
return Err("HOLOLAKE_BROKER_DESCRIPTOR_UNSUPPORTED".into());
|
||||
}
|
||||
let mut last_error = None;
|
||||
for _ in 0..50 {
|
||||
match UnixStream::connect(&descriptor.socket_path) {
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
|
||||
) =>
|
||||
{
|
||||
last_error = Some(error);
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(error) => return Err(format!("HOLOLAKE_DIRECT_LOCAL_BROKER_NOT_RUNNING: {error}")),
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"HOLOLAKE_DIRECT_LOCAL_BROKER_NOT_RUNNING: {}",
|
||||
last_error
|
||||
.map(|error| error.to_string())
|
||||
.unwrap_or_else(|| "startup grace exhausted".into())
|
||||
))
|
||||
}
|
||||
|
||||
fn connector_descriptor_path() -> Result<PathBuf, String> {
|
||||
if let Some(path) = std::env::var_os("HOLOLAKE_BROKER_DESCRIPTOR") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
dirs::data_dir()
|
||||
.map(|root| {
|
||||
root.join("world.guanghu.hololake")
|
||||
.join("direct-local-broker-v1.json")
|
||||
})
|
||||
.ok_or_else(|| "HOLOLAKE_APP_DATA_UNAVAILABLE".into())
|
||||
}
|
||||
|
||||
fn start_at(
|
||||
session_root: PathBuf,
|
||||
routing_root: PathBuf,
|
||||
descriptor_path: PathBuf,
|
||||
socket_path: PathBuf,
|
||||
) -> Result<DirectLocalBrokerHandle, String> {
|
||||
let pncc_mount_root = session_root
|
||||
.parent()
|
||||
.ok_or("HOLOLAKE_BROKER_PNCC_STORAGE_BOUNDARY_INVALID")?
|
||||
.join("pncc-stage-one-v1")
|
||||
.join("repository-mounts");
|
||||
fs::create_dir_all(&pncc_mount_root)
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_PNCC_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
let pncc_remote_root = session_root
|
||||
.parent()
|
||||
.ok_or("HOLOLAKE_BROKER_PNCC_STORAGE_BOUNDARY_INVALID")?
|
||||
.join("pncc-stage-one-v1")
|
||||
.join("remote-object-channel");
|
||||
fs::create_dir_all(&pncc_remote_root)
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_PNCC_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
let pncc_projection_root = session_root
|
||||
.parent()
|
||||
.ok_or("HOLOLAKE_BROKER_PNCC_STORAGE_BOUNDARY_INVALID")?
|
||||
.join("pncc-stage-one-v1")
|
||||
.join("receipt-projection");
|
||||
fs::create_dir_all(&pncc_projection_root)
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_PNCC_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
if let Some(parent) = socket_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_RUNTIME_DIR_FAILED: {error}"))?;
|
||||
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_RUNTIME_PERMISSIONS_FAILED: {error}"))?;
|
||||
}
|
||||
if socket_path.exists() {
|
||||
if UnixStream::connect(&socket_path).is_ok() {
|
||||
return Err("HOLOLAKE_DIRECT_LOCAL_BROKER_ALREADY_RUNNING".into());
|
||||
}
|
||||
fs::remove_file(&socket_path)
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_STALE_SOCKET_REMOVE_FAILED: {error}"))?;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(&socket_path)
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_BIND_FAILED: {error}"))?;
|
||||
fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_SOCKET_PERMISSIONS_FAILED: {error}"))?;
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_NONBLOCKING_FAILED: {error}"))?;
|
||||
|
||||
write_descriptor(
|
||||
&descriptor_path,
|
||||
&BrokerDescriptor {
|
||||
schema: BROKER_SCHEMA,
|
||||
state: "LISTENING",
|
||||
transport: "UNIX_STREAM_JSON_LINES",
|
||||
socket_path: socket_path.to_string_lossy().into_owned(),
|
||||
max_request_bytes: MAX_REQUEST_BYTES,
|
||||
process_id: std::process::id(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let authenticated_connections = Arc::new(AtomicUsize::new(0));
|
||||
let worker_shutdown = Arc::clone(&shutdown);
|
||||
let worker_authenticated_connections = Arc::clone(&authenticated_connections);
|
||||
let worker_socket = socket_path.clone();
|
||||
let (ready_sender, ready_receiver) = mpsc::sync_channel(1);
|
||||
let worker = thread::Builder::new()
|
||||
.name("hololake-direct-local-broker".into())
|
||||
.spawn(move || {
|
||||
let _ = ready_sender.send(());
|
||||
serve(
|
||||
listener,
|
||||
BrokerStorageRoots {
|
||||
session: session_root,
|
||||
routing: routing_root,
|
||||
pncc_mount: pncc_mount_root,
|
||||
pncc_remote: pncc_remote_root,
|
||||
pncc_projection: pncc_projection_root,
|
||||
},
|
||||
&worker_shutdown,
|
||||
&worker_authenticated_connections,
|
||||
);
|
||||
let _ = fs::remove_file(worker_socket);
|
||||
})
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_THREAD_FAILED: {error}"))?;
|
||||
ready_receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_STARTUP_FAILED: {error}"))?;
|
||||
|
||||
Ok(DirectLocalBrokerHandle {
|
||||
shutdown,
|
||||
authenticated_connections,
|
||||
socket_path,
|
||||
worker: Some(worker),
|
||||
})
|
||||
}
|
||||
|
||||
fn serve(
|
||||
listener: UnixListener,
|
||||
roots: BrokerStorageRoots,
|
||||
shutdown: &AtomicBool,
|
||||
authenticated_connections: &Arc<AtomicUsize>,
|
||||
) {
|
||||
while !shutdown.load(Ordering::Acquire) {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
if shutdown.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
if stream.set_nonblocking(false).is_err() {
|
||||
continue;
|
||||
}
|
||||
let root = roots.session.clone();
|
||||
let routes = roots.routing.clone();
|
||||
let pncc_mounts = roots.pncc_mount.clone();
|
||||
let pncc_remote = roots.pncc_remote.clone();
|
||||
let pncc_projection = roots.pncc_projection.clone();
|
||||
let client_authenticated_connections = Arc::clone(authenticated_connections);
|
||||
let _ = thread::Builder::new()
|
||||
.name("hololake-direct-local-client".into())
|
||||
.spawn(move || {
|
||||
serve_connection(
|
||||
stream,
|
||||
&root,
|
||||
&routes,
|
||||
&pncc_mounts,
|
||||
&pncc_remote,
|
||||
&pncc_projection,
|
||||
client_authenticated_connections,
|
||||
)
|
||||
});
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_connection(
|
||||
mut stream: UnixStream,
|
||||
session_root: &Path,
|
||||
routing_root: &Path,
|
||||
pncc_mount_root: &Path,
|
||||
pncc_remote_root: &Path,
|
||||
pncc_projection_root: &Path,
|
||||
authenticated_connections: Arc<AtomicUsize>,
|
||||
) {
|
||||
let read_stream = match stream.try_clone() {
|
||||
Ok(stream) => stream,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut reader = BufReader::new(read_stream);
|
||||
let mut authenticated_connection = None;
|
||||
loop {
|
||||
let mut bytes = Vec::new();
|
||||
let read = match reader
|
||||
.by_ref()
|
||||
.take(MAX_REQUEST_BYTES + 1)
|
||||
.read_until(b'\n', &mut bytes)
|
||||
{
|
||||
Ok(read) => read,
|
||||
Err(_) => return,
|
||||
};
|
||||
if read == 0 {
|
||||
return;
|
||||
}
|
||||
let request_authenticates = request_establishes_authenticated_connection(&bytes);
|
||||
let response = if read as u64 > MAX_REQUEST_BYTES || !bytes.ends_with(b"\n") {
|
||||
BrokerResponse::error("HOLOLAKE_BROKER_REQUEST_TOO_LARGE")
|
||||
} else {
|
||||
dispatch(
|
||||
session_root,
|
||||
routing_root,
|
||||
pncc_mount_root,
|
||||
pncc_remote_root,
|
||||
pncc_projection_root,
|
||||
&bytes[..bytes.len() - 1],
|
||||
)
|
||||
};
|
||||
if response.ok && request_authenticates && authenticated_connection.is_none() {
|
||||
authenticated_connections.fetch_add(1, Ordering::AcqRel);
|
||||
authenticated_connection = Some(AuthenticatedConnectionGuard(Arc::clone(
|
||||
&authenticated_connections,
|
||||
)));
|
||||
}
|
||||
if serde_json::to_writer(&mut stream, &response).is_err()
|
||||
|| stream.write_all(b"\n").is_err()
|
||||
|| stream.flush().is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if response.error.as_deref() == Some("HOLOLAKE_BROKER_REQUEST_TOO_LARGE") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_establishes_authenticated_connection(bytes: &[u8]) -> bool {
|
||||
serde_json::from_slice::<serde_json::Value>(bytes)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("operation")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.is_some_and(|operation| matches!(operation.as_str(), "OPEN_SESSION" | "RESUME_SESSION"))
|
||||
}
|
||||
|
||||
fn dispatch(
|
||||
session_root: &Path,
|
||||
routing_root: &Path,
|
||||
pncc_mount_root: &Path,
|
||||
pncc_remote_root: &Path,
|
||||
pncc_projection_root: &Path,
|
||||
bytes: &[u8],
|
||||
) -> BrokerResponse {
|
||||
let request: BrokerRequest = match serde_json::from_slice(bytes) {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
return BrokerResponse::error(&format!("HOLOLAKE_BROKER_REQUEST_INVALID: {error}"))
|
||||
}
|
||||
};
|
||||
let result = match request {
|
||||
BrokerRequest::Ping => serde_json::to_value(serde_json::json!({
|
||||
"state": "READY",
|
||||
"continuityOwner": "HOLOLAKE",
|
||||
"mcpRole": "DISCOVERY_RECOVERY_COMPATIBILITY_ONLY"
|
||||
}))
|
||||
.map_err(|error| error.to_string()),
|
||||
BrokerRequest::OpenSession(input) => open_at(session_root, input)
|
||||
.and_then(|receipt| serde_json::to_value(receipt).map_err(|error| error.to_string())),
|
||||
BrokerRequest::ResumeSession(input) => resume_at(session_root, input)
|
||||
.and_then(|receipt| serde_json::to_value(receipt).map_err(|error| error.to_string())),
|
||||
BrokerRequest::AppendEvent(input) => append_event_at(session_root, input)
|
||||
.and_then(|receipt| serde_json::to_value(receipt).map_err(|error| error.to_string())),
|
||||
BrokerRequest::ResolveCapabilityRoute(input) => {
|
||||
if let Err(error) = authenticate_at(session_root, &input.session) {
|
||||
return BrokerResponse::error(&error);
|
||||
}
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.map_err(|error| format!("HOLOLAKE_SYSTEM_CLOCK_INVALID: {error}"));
|
||||
now.and_then(|now| resolve_capability_route_at(routing_root, input.route, now))
|
||||
.and_then(|receipt| {
|
||||
serde_json::to_value(receipt).map_err(|error| error.to_string())
|
||||
})
|
||||
}
|
||||
BrokerRequest::InstallDynamicNodeRegistry(input) => {
|
||||
if let Err(error) = authenticate_at(session_root, &input.session) {
|
||||
return BrokerResponse::error(&error);
|
||||
}
|
||||
install_trusted_registry_at(routing_root, input.registry).and_then(|receipt| {
|
||||
serde_json::to_value(receipt).map_err(|error| error.to_string())
|
||||
})
|
||||
}
|
||||
BrokerRequest::RecordSignedNodeHealth(input) => {
|
||||
if let Err(error) = authenticate_at(session_root, &input.session) {
|
||||
return BrokerResponse::error(&error);
|
||||
}
|
||||
record_health_at(routing_root, input.health).and_then(|receipt| {
|
||||
serde_json::to_value(receipt).map_err(|error| error.to_string())
|
||||
})
|
||||
}
|
||||
BrokerRequest::InspectMountedPnccRepository(input) => {
|
||||
if let Err(error) = authenticate_at(session_root, &input.session) {
|
||||
return BrokerResponse::error(&error);
|
||||
}
|
||||
inspect_mounted_pncc_at(pncc_mount_root, input.mount).and_then(|receipt| {
|
||||
project_pncc_repository_binding_at(pncc_projection_root, &receipt)?;
|
||||
serde_json::to_value(receipt).map_err(|error| error.to_string())
|
||||
})
|
||||
}
|
||||
BrokerRequest::ReadMountedPnccRemoteObject(input) => {
|
||||
if let Err(error) = authenticate_at(session_root, &input.session) {
|
||||
return BrokerResponse::error(&error);
|
||||
}
|
||||
read_mounted_pncc_remote_at(pncc_remote_root, input.read).and_then(|receipt| {
|
||||
project_pncc_remote_read_at(pncc_projection_root, &receipt)?;
|
||||
serde_json::to_value(receipt).map_err(|error| error.to_string())
|
||||
})
|
||||
}
|
||||
BrokerRequest::QueryPnccReceiptProjection(input) => {
|
||||
if let Err(error) = authenticate_at(session_root, &input.session) {
|
||||
return BrokerResponse::error(&error);
|
||||
}
|
||||
query_pncc_projection_at(pncc_projection_root, input.query).and_then(|receipt| {
|
||||
serde_json::to_value(receipt).map_err(|error| error.to_string())
|
||||
})
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(value) => BrokerResponse::success(value),
|
||||
Err(error) => BrokerResponse::error(&error),
|
||||
}
|
||||
}
|
||||
|
||||
impl BrokerResponse {
|
||||
fn success(result: Value) -> Self {
|
||||
Self {
|
||||
schema: BROKER_SCHEMA,
|
||||
request_id: Uuid::new_v4().to_string(),
|
||||
ok: true,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn error(error: &str) -> Self {
|
||||
Self {
|
||||
schema: BROKER_SCHEMA,
|
||||
request_id: Uuid::new_v4().to_string(),
|
||||
ok: false,
|
||||
result: None,
|
||||
error: Some(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn short_socket_path(app_data: &Path) -> PathBuf {
|
||||
let key = digest(&SHA256, app_data.to_string_lossy().as_bytes())
|
||||
.as_ref()
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
std::env::temp_dir()
|
||||
.join(format!("hololake-{key}"))
|
||||
.join("broker.sock")
|
||||
}
|
||||
|
||||
fn write_descriptor(path: &Path, descriptor: &BrokerDescriptor) -> Result<(), String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or("HOLOLAKE_BROKER_DESCRIPTOR_PATH_INVALID")?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_DESCRIPTOR_DIR_FAILED: {error}"))?;
|
||||
let temporary = parent.join(format!(".broker-{}.tmp", Uuid::new_v4()));
|
||||
let bytes = serde_json::to_vec_pretty(descriptor)
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_DESCRIPTOR_INVALID: {error}"))?;
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temporary)
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_DESCRIPTOR_WRITE_FAILED: {error}"))?;
|
||||
file.write_all(&bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_DESCRIPTOR_WRITE_FAILED: {error}"))?;
|
||||
fs::rename(&temporary, path)
|
||||
.map_err(|error| format!("HOLOLAKE_BROKER_DESCRIPTOR_WRITE_FAILED: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::direct_local_session::{issue_ticket_at, IssueDiscoveryTicketInput};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn request(socket: &Path, value: Value) -> Value {
|
||||
let mut stream = UnixStream::connect(socket).unwrap();
|
||||
serde_json::to_writer(&mut stream, &value).unwrap();
|
||||
stream.write_all(b"\n").unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream).read_line(&mut line).unwrap();
|
||||
serde_json::from_str(&line).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_resumes_after_socket_disconnect() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let socket = temp.path().join("runtime/broker.sock");
|
||||
let descriptor = temp.path().join("broker.json");
|
||||
let sessions = temp.path().join("sessions");
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
let routes = temp.path().join("routes");
|
||||
fs::create_dir_all(&routes).unwrap();
|
||||
let _broker = start_at(sessions.clone(), routes, descriptor, socket.clone()).unwrap();
|
||||
let ticket = issue_ticket_at(
|
||||
&sessions,
|
||||
IssueDiscoveryTicketInput {
|
||||
account_id: "human-1".into(),
|
||||
lane_id: "DEV-1".into(),
|
||||
client_instance_id: "codex-1".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let opened = request(
|
||||
&socket,
|
||||
serde_json::json!({
|
||||
"operation": "OPEN_SESSION",
|
||||
"input": {
|
||||
"accountId": "human-1",
|
||||
"laneId": "DEV-1",
|
||||
"clientInstanceId": "codex-1",
|
||||
"discoveryTicket": ticket.discovery_ticket
|
||||
}
|
||||
}),
|
||||
);
|
||||
assert_eq!(opened["ok"], true);
|
||||
|
||||
let resumed = request(
|
||||
&socket,
|
||||
serde_json::json!({
|
||||
"operation": "RESUME_SESSION",
|
||||
"input": {
|
||||
"accountId": "human-1",
|
||||
"sessionId": opened["result"]["sessionId"],
|
||||
"clientInstanceId": "codex-restarted",
|
||||
"resumeSecret": opened["result"]["resumeSecret"]
|
||||
}
|
||||
}),
|
||||
);
|
||||
assert_eq!(resumed["ok"], true);
|
||||
assert_eq!(resumed["result"]["state"], "RESUMED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_can_restart_and_resume_persisted_session() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let sessions = temp.path().join("sessions");
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
let socket = temp.path().join("runtime/broker.sock");
|
||||
let descriptor = temp.path().join("broker.json");
|
||||
let ticket = issue_ticket_at(
|
||||
&sessions,
|
||||
IssueDiscoveryTicketInput {
|
||||
account_id: "human-1".into(),
|
||||
lane_id: "DEV-1".into(),
|
||||
client_instance_id: "codex-1".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let routes = temp.path().join("routes");
|
||||
fs::create_dir_all(&routes).unwrap();
|
||||
let first = start_at(
|
||||
sessions.clone(),
|
||||
routes.clone(),
|
||||
descriptor.clone(),
|
||||
socket.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let opened = request(
|
||||
&socket,
|
||||
serde_json::json!({
|
||||
"operation": "OPEN_SESSION",
|
||||
"input": {
|
||||
"accountId": "human-1",
|
||||
"laneId": "DEV-1",
|
||||
"clientInstanceId": "codex-1",
|
||||
"discoveryTicket": ticket.discovery_ticket
|
||||
}
|
||||
}),
|
||||
);
|
||||
drop(first);
|
||||
for _ in 0..40 {
|
||||
if !socket.exists() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let _second = start_at(sessions, routes, descriptor, socket.clone()).unwrap();
|
||||
let resumed = request(
|
||||
&socket,
|
||||
serde_json::json!({
|
||||
"operation": "RESUME_SESSION",
|
||||
"input": {
|
||||
"accountId": "human-1",
|
||||
"sessionId": opened["result"]["sessionId"],
|
||||
"clientInstanceId": "codex-after-hololake-restart",
|
||||
"resumeSecret": opened["result"]["resumeSecret"]
|
||||
}
|
||||
}),
|
||||
);
|
||||
assert_eq!(resumed["result"]["state"], "RESUMED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broker_route_resolution_requires_the_active_session_secret() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let socket = temp.path().join("runtime/broker.sock");
|
||||
let descriptor = temp.path().join("broker.json");
|
||||
let sessions = temp.path().join("sessions");
|
||||
let routes = temp.path().join("routes");
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
fs::create_dir_all(&routes).unwrap();
|
||||
let _broker = start_at(sessions.clone(), routes, descriptor, socket.clone()).unwrap();
|
||||
let response = request(
|
||||
&socket,
|
||||
serde_json::json!({
|
||||
"operation": "RESOLVE_CAPABILITY_ROUTE",
|
||||
"input": {
|
||||
"session": {
|
||||
"accountId": "human-1",
|
||||
"sessionId": "invented-session",
|
||||
"resumeSecret": "invented-secret-long-enough"
|
||||
},
|
||||
"route": {
|
||||
"humanId": "human-1",
|
||||
"personaId": "ICE-P-ZY001",
|
||||
"domainId": "DOM-FIFTH-0001",
|
||||
"capabilityId": "GH-PNCC-READ"
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
assert_eq!(response["ok"], false);
|
||||
assert_eq!(
|
||||
response["error"],
|
||||
"HOLOLAKE_DIRECT_ACTIVE_SESSION_NOT_FOUND"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_ai_still_cannot_install_registry_before_trust_is_provisioned() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let socket = temp.path().join("runtime/broker.sock");
|
||||
let descriptor = temp.path().join("broker.json");
|
||||
let sessions = temp.path().join("sessions");
|
||||
let routes = temp.path().join("routes");
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
fs::create_dir_all(&routes).unwrap();
|
||||
let _broker = start_at(sessions.clone(), routes, descriptor, socket.clone()).unwrap();
|
||||
let ticket = issue_ticket_at(
|
||||
&sessions,
|
||||
IssueDiscoveryTicketInput {
|
||||
account_id: "human-1".into(),
|
||||
lane_id: "DEV-1".into(),
|
||||
client_instance_id: "codex-1".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let opened = request(
|
||||
&socket,
|
||||
serde_json::json!({
|
||||
"operation": "OPEN_SESSION",
|
||||
"input": {
|
||||
"accountId": "human-1",
|
||||
"laneId": "DEV-1",
|
||||
"clientInstanceId": "codex-1",
|
||||
"discoveryTicket": ticket.discovery_ticket
|
||||
}
|
||||
}),
|
||||
);
|
||||
let response = request(
|
||||
&socket,
|
||||
serde_json::json!({
|
||||
"operation": "INSTALL_DYNAMIC_NODE_REGISTRY",
|
||||
"input": {
|
||||
"session": {
|
||||
"accountId": "human-1",
|
||||
"sessionId": opened["result"]["sessionId"],
|
||||
"resumeSecret": opened["result"]["resumeSecret"]
|
||||
},
|
||||
"registry": {
|
||||
"schema": "hololake.dynamic-node-registry/v1",
|
||||
"registryId": "candidate-registry",
|
||||
"version": 1,
|
||||
"nodes": [],
|
||||
"signerId": "invented-signer",
|
||||
"signature": "invented-signature"
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
assert_eq!(response["ok"], false);
|
||||
assert_eq!(
|
||||
response["error"],
|
||||
"HOLOLAKE_DYNAMIC_ROUTING_TRUST_UNPROVISIONED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pncc_projection_is_read_only_and_requires_the_active_session_secret() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let socket = temp.path().join("runtime/broker.sock");
|
||||
let descriptor = temp.path().join("broker.json");
|
||||
let sessions = temp.path().join("sessions");
|
||||
let routes = temp.path().join("routes");
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
fs::create_dir_all(&routes).unwrap();
|
||||
let _broker = start_at(sessions.clone(), routes, descriptor, socket.clone()).unwrap();
|
||||
let ticket = issue_ticket_at(
|
||||
&sessions,
|
||||
IssueDiscoveryTicketInput {
|
||||
account_id: "human-1".into(),
|
||||
lane_id: "DEV-1".into(),
|
||||
client_instance_id: "codex-1".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let opened = request(
|
||||
&socket,
|
||||
serde_json::json!({
|
||||
"operation": "OPEN_SESSION",
|
||||
"input": {
|
||||
"accountId": "human-1",
|
||||
"laneId": "DEV-1",
|
||||
"clientInstanceId": "codex-1",
|
||||
"discoveryTicket": ticket.discovery_ticket
|
||||
}
|
||||
}),
|
||||
);
|
||||
let query = |secret: Value| {
|
||||
request(
|
||||
&socket,
|
||||
serde_json::json!({
|
||||
"operation": "QUERY_PNCC_RECEIPT_PROJECTION",
|
||||
"input": {
|
||||
"session": {
|
||||
"accountId": "human-1",
|
||||
"sessionId": opened["result"]["sessionId"],
|
||||
"resumeSecret": secret
|
||||
},
|
||||
"query": {"afterSequence": 0, "limit": 25}
|
||||
}
|
||||
}),
|
||||
)
|
||||
};
|
||||
let rejected = query(Value::String("wrong-secret-long-enough".into()));
|
||||
assert_eq!(rejected["ok"], false);
|
||||
assert_eq!(rejected["error"], "HOLOLAKE_DIRECT_SESSION_NOT_AUTHORIZED");
|
||||
|
||||
let verified = query(opened["result"]["resumeSecret"].clone());
|
||||
assert_eq!(verified["ok"], true);
|
||||
assert_eq!(verified["result"]["authorityStore"], false);
|
||||
assert_eq!(verified["result"]["writeAuthority"], false);
|
||||
assert_eq!(verified["result"]["modelInstanceFieldsPresent"], false);
|
||||
assert_eq!(verified["result"]["emptyMeansOffline"], false);
|
||||
assert_eq!(verified["result"]["returnedEventCount"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_authenticated_persistent_connector_counts_as_online() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let socket = temp.path().join("runtime/broker.sock");
|
||||
let descriptor = temp.path().join("broker.json");
|
||||
let sessions = temp.path().join("sessions");
|
||||
let routes = temp.path().join("routes");
|
||||
fs::create_dir_all(&sessions).unwrap();
|
||||
fs::create_dir_all(&routes).unwrap();
|
||||
let broker = start_at(sessions.clone(), routes, descriptor, socket.clone()).unwrap();
|
||||
|
||||
let probe = UnixStream::connect(&socket).unwrap();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
assert_eq!(broker.active_connection_count(), 0);
|
||||
drop(probe);
|
||||
|
||||
let ticket = issue_ticket_at(
|
||||
&sessions,
|
||||
IssueDiscoveryTicketInput {
|
||||
account_id: "human-1".into(),
|
||||
lane_id: "DEV-1".into(),
|
||||
client_instance_id: "codex-1".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut connector = UnixStream::connect(&socket).unwrap();
|
||||
serde_json::to_writer(
|
||||
&mut connector,
|
||||
&serde_json::json!({
|
||||
"operation": "OPEN_SESSION",
|
||||
"input": {
|
||||
"accountId": "human-1",
|
||||
"laneId": "DEV-1",
|
||||
"clientInstanceId": "codex-1",
|
||||
"discoveryTicket": ticket.discovery_ticket
|
||||
}
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
connector.write_all(b"\n").unwrap();
|
||||
assert!(request_establishes_authenticated_connection(
|
||||
br#"{"operation":"OPEN_SESSION"}"#
|
||||
));
|
||||
let mut response = String::new();
|
||||
let mut response_reader = BufReader::new(connector.try_clone().unwrap());
|
||||
response_reader.read_line(&mut response).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&response).unwrap()["ok"],
|
||||
true
|
||||
);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
assert_eq!(broker.active_connection_count(), 1);
|
||||
|
||||
drop(connector);
|
||||
drop(response_reader);
|
||||
for _ in 0..20 {
|
||||
if broker.active_connection_count() == 0 {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(broker.active_connection_count(), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,852 @@
|
|||
use fs2::FileExt;
|
||||
use ring::digest::{digest, SHA256};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
const SESSION_SCHEMA: &str = "hololake.direct-local-session/v1";
|
||||
const MAX_ID_BYTES: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenSessionInput {
|
||||
pub account_id: String,
|
||||
pub lane_id: String,
|
||||
pub client_instance_id: String,
|
||||
pub discovery_ticket: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IssueDiscoveryTicketInput {
|
||||
pub account_id: String,
|
||||
pub lane_id: String,
|
||||
pub client_instance_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiscoveryTicketReceipt {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub account_key: String,
|
||||
pub lane_id: String,
|
||||
pub client_instance_id: String,
|
||||
pub discovery_ticket: String,
|
||||
pub issued_at_unix_ms: u128,
|
||||
pub receipt_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResumeSessionInput {
|
||||
pub account_id: String,
|
||||
pub session_id: String,
|
||||
pub client_instance_id: String,
|
||||
pub resume_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppendSessionEventInput {
|
||||
pub account_id: String,
|
||||
pub session_id: String,
|
||||
pub resume_secret: String,
|
||||
pub idempotency_key: String,
|
||||
pub event_kind: String,
|
||||
pub payload_sha256: String,
|
||||
pub expected_previous_sequence: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthenticateSessionInput {
|
||||
pub account_id: String,
|
||||
pub session_id: String,
|
||||
pub resume_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectSessionReceipt {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub account_key: String,
|
||||
pub session_id: String,
|
||||
pub lane_id: String,
|
||||
pub client_instance_id: String,
|
||||
pub opened_at_unix_ms: u128,
|
||||
pub observed_at_unix_ms: u128,
|
||||
pub last_event_sequence: u64,
|
||||
pub resume_secret: Option<String>,
|
||||
pub receipt_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionEventReceipt {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub session_id: String,
|
||||
pub sequence: u64,
|
||||
pub idempotency_key: String,
|
||||
pub event_kind: String,
|
||||
pub payload_sha256: String,
|
||||
pub observed_at_unix_ms: u128,
|
||||
pub receipt_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SessionRecord {
|
||||
schema: String,
|
||||
account_key: String,
|
||||
session_id: String,
|
||||
lane_id: String,
|
||||
client_instance_id: String,
|
||||
resume_secret_sha256: String,
|
||||
discovery_ticket_sha256: String,
|
||||
opened_at_unix_ms: u128,
|
||||
observed_at_unix_ms: u128,
|
||||
last_event_sequence: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EventRecord {
|
||||
schema: String,
|
||||
session_id: String,
|
||||
sequence: u64,
|
||||
idempotency_key: String,
|
||||
event_kind: String,
|
||||
payload_sha256: String,
|
||||
observed_at_unix_ms: u128,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DiscoveryTicketRecord {
|
||||
schema: String,
|
||||
account_key: String,
|
||||
lane_id: String,
|
||||
client_instance_id: String,
|
||||
discovery_ticket_sha256: String,
|
||||
issued_at_unix_ms: u128,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ActiveSessionRecord {
|
||||
schema: String,
|
||||
account_key: String,
|
||||
session_id: String,
|
||||
lane_id: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn issue_direct_local_discovery_ticket(
|
||||
app: AppHandle,
|
||||
input: IssueDiscoveryTicketInput,
|
||||
) -> Result<DiscoveryTicketReceipt, String> {
|
||||
let root = direct_session_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || issue_ticket_at(&root, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_direct_local_session(
|
||||
app: AppHandle,
|
||||
input: OpenSessionInput,
|
||||
) -> Result<DirectSessionReceipt, String> {
|
||||
let root = direct_session_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || open_at(&root, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn resume_direct_local_session(
|
||||
app: AppHandle,
|
||||
input: ResumeSessionInput,
|
||||
) -> Result<DirectSessionReceipt, String> {
|
||||
let root = direct_session_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || resume_at(&root, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn append_direct_local_session_event(
|
||||
app: AppHandle,
|
||||
input: AppendSessionEventInput,
|
||||
) -> Result<SessionEventReceipt, String> {
|
||||
let root = direct_session_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || append_event_at(&root, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
pub(crate) fn direct_session_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let app_data = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?;
|
||||
let root = app_data.join("direct-local-session-v1");
|
||||
fs::create_dir_all(&root)
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
root.canonicalize()
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_STORAGE_UNAVAILABLE: {error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn active_session_count_at(root: &Path) -> Result<usize, String> {
|
||||
let accounts = root.join("accounts");
|
||||
if !accounts.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut count = 0;
|
||||
for entry in fs::read_dir(&accounts)
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_STORAGE_UNAVAILABLE: {error}"))?
|
||||
{
|
||||
let entry = entry
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
let kind = entry
|
||||
.file_type()
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
if !kind.is_dir() || kind.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
let active_path = entry.path().join("active-session.json");
|
||||
if !active_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let metadata = fs::symlink_metadata(&active_path)
|
||||
.map_err(|error| format!("HOLOLAKE_ACTIVE_SESSION_INVALID: {error}"))?;
|
||||
if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
|
||||
return Err("HOLOLAKE_ACTIVE_SESSION_INVALID".into());
|
||||
}
|
||||
let active: ActiveSessionRecord = read_json(&active_path, "ACTIVE_SESSION")?;
|
||||
if active.schema != SESSION_SCHEMA
|
||||
|| active.account_key != entry.file_name().to_string_lossy()
|
||||
{
|
||||
return Err("HOLOLAKE_ACTIVE_SESSION_INVALID".into());
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub(crate) fn open_at(
|
||||
root: &Path,
|
||||
input: OpenSessionInput,
|
||||
) -> Result<DirectSessionReceipt, String> {
|
||||
validate_identifier(&input.account_id, "ACCOUNT")?;
|
||||
validate_identifier(&input.lane_id, "LANE")?;
|
||||
validate_identifier(&input.client_instance_id, "CLIENT")?;
|
||||
validate_secret(&input.discovery_ticket, "DISCOVERY_TICKET")?;
|
||||
|
||||
let account_key = sha256_hex(input.account_id.as_bytes());
|
||||
let _account_lock = lock_account(root, &account_key)?;
|
||||
let active_path = active_session_path(root, &account_key);
|
||||
let tickets_root = root.join("discovery-tickets");
|
||||
let used_tickets_root = root.join("used-discovery-tickets");
|
||||
fs::create_dir_all(&used_tickets_root)
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
let discovery_ticket_sha256 = sha256_hex(input.discovery_ticket.as_bytes());
|
||||
let ticket_path = tickets_root.join(format!("{discovery_ticket_sha256}.json"));
|
||||
let used_ticket_path = used_tickets_root.join(format!("{discovery_ticket_sha256}.json"));
|
||||
if used_ticket_path.exists() {
|
||||
return Err("HOLOLAKE_DISCOVERY_TICKET_ALREADY_USED".into());
|
||||
}
|
||||
if active_path.exists() {
|
||||
let active: ActiveSessionRecord = read_json(&active_path, "ACTIVE_SESSION")?;
|
||||
if active.schema != SESSION_SCHEMA || active.account_key != account_key {
|
||||
return Err("HOLOLAKE_ACTIVE_SESSION_INVALID".into());
|
||||
}
|
||||
return Err(format!(
|
||||
"HOLOLAKE_ACCOUNT_ALREADY_HAS_ACTIVE_SESSION:{}:{}",
|
||||
active.lane_id, active.session_id
|
||||
));
|
||||
}
|
||||
let ticket: DiscoveryTicketRecord = read_json(&ticket_path, "DISCOVERY_TICKET")?;
|
||||
if ticket.account_key != account_key
|
||||
|| ticket.lane_id != input.lane_id
|
||||
|| ticket.client_instance_id != input.client_instance_id
|
||||
|| ticket.discovery_ticket_sha256 != discovery_ticket_sha256
|
||||
{
|
||||
return Err("HOLOLAKE_DISCOVERY_TICKET_NOT_AUTHORIZED".into());
|
||||
}
|
||||
fs::rename(&ticket_path, &used_ticket_path).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound && used_ticket_path.exists() {
|
||||
"HOLOLAKE_DISCOVERY_TICKET_ALREADY_USED".to_string()
|
||||
} else {
|
||||
format!("HOLOLAKE_DISCOVERY_TICKET_CONSUME_FAILED: {error}")
|
||||
}
|
||||
})?;
|
||||
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
let resume_secret = format!("{}{}", Uuid::new_v4(), Uuid::new_v4());
|
||||
let observed_at_unix_ms = now_unix_ms()?;
|
||||
let record = SessionRecord {
|
||||
schema: SESSION_SCHEMA.into(),
|
||||
account_key: account_key.clone(),
|
||||
session_id: session_id.clone(),
|
||||
lane_id: input.lane_id,
|
||||
client_instance_id: input.client_instance_id,
|
||||
resume_secret_sha256: sha256_hex(resume_secret.as_bytes()),
|
||||
discovery_ticket_sha256,
|
||||
opened_at_unix_ms: observed_at_unix_ms,
|
||||
observed_at_unix_ms,
|
||||
last_event_sequence: 0,
|
||||
};
|
||||
write_record_atomic(&session_path(root, &account_key, &session_id), &record)?;
|
||||
write_record_create_new(
|
||||
&active_path,
|
||||
&ActiveSessionRecord {
|
||||
schema: SESSION_SCHEMA.into(),
|
||||
account_key: account_key.clone(),
|
||||
session_id: session_id.clone(),
|
||||
lane_id: record.lane_id.clone(),
|
||||
},
|
||||
"ACTIVE_SESSION",
|
||||
)?;
|
||||
Ok(session_receipt("OPENED", &record, Some(resume_secret)))
|
||||
}
|
||||
|
||||
pub(crate) fn issue_ticket_at(
|
||||
root: &Path,
|
||||
input: IssueDiscoveryTicketInput,
|
||||
) -> Result<DiscoveryTicketReceipt, String> {
|
||||
validate_identifier(&input.account_id, "ACCOUNT")?;
|
||||
validate_identifier(&input.lane_id, "LANE")?;
|
||||
validate_identifier(&input.client_instance_id, "CLIENT")?;
|
||||
let account_key = sha256_hex(input.account_id.as_bytes());
|
||||
let discovery_ticket = format!("{}{}", Uuid::new_v4(), Uuid::new_v4());
|
||||
let discovery_ticket_sha256 = sha256_hex(discovery_ticket.as_bytes());
|
||||
let issued_at_unix_ms = now_unix_ms()?;
|
||||
let record = DiscoveryTicketRecord {
|
||||
schema: SESSION_SCHEMA.into(),
|
||||
account_key: account_key.clone(),
|
||||
lane_id: input.lane_id.clone(),
|
||||
client_instance_id: input.client_instance_id.clone(),
|
||||
discovery_ticket_sha256: discovery_ticket_sha256.clone(),
|
||||
issued_at_unix_ms,
|
||||
};
|
||||
let path = root
|
||||
.join("discovery-tickets")
|
||||
.join(format!("{discovery_ticket_sha256}.json"));
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
}
|
||||
write_record_create_new(&path, &record, "DISCOVERY_TICKET")?;
|
||||
Ok(DiscoveryTicketReceipt {
|
||||
schema: SESSION_SCHEMA,
|
||||
state: "ISSUED",
|
||||
account_key,
|
||||
lane_id: input.lane_id,
|
||||
client_instance_id: input.client_instance_id,
|
||||
discovery_ticket,
|
||||
issued_at_unix_ms,
|
||||
receipt_id: sha256_hex(
|
||||
format!("ISSUED\n{discovery_ticket_sha256}\n{issued_at_unix_ms}").as_bytes(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn resume_at(
|
||||
root: &Path,
|
||||
input: ResumeSessionInput,
|
||||
) -> Result<DirectSessionReceipt, String> {
|
||||
validate_identifier(&input.account_id, "ACCOUNT")?;
|
||||
validate_identifier(&input.session_id, "SESSION")?;
|
||||
validate_identifier(&input.client_instance_id, "CLIENT")?;
|
||||
validate_secret(&input.resume_secret, "RESUME_SECRET")?;
|
||||
let account_key = sha256_hex(input.account_id.as_bytes());
|
||||
require_active_session(root, &account_key, &input.session_id)?;
|
||||
let path = session_path(root, &account_key, &input.session_id);
|
||||
let _lock = lock_session(&path)?;
|
||||
let mut record = read_session(&path)?;
|
||||
authorize(
|
||||
&record,
|
||||
&account_key,
|
||||
&input.session_id,
|
||||
&input.resume_secret,
|
||||
)?;
|
||||
record.client_instance_id = input.client_instance_id;
|
||||
record.observed_at_unix_ms = now_unix_ms()?;
|
||||
write_record_atomic(&path, &record)?;
|
||||
Ok(session_receipt("RESUMED", &record, None))
|
||||
}
|
||||
|
||||
pub(crate) fn append_event_at(
|
||||
root: &Path,
|
||||
input: AppendSessionEventInput,
|
||||
) -> Result<SessionEventReceipt, String> {
|
||||
validate_identifier(&input.account_id, "ACCOUNT")?;
|
||||
validate_identifier(&input.session_id, "SESSION")?;
|
||||
validate_identifier(&input.idempotency_key, "IDEMPOTENCY")?;
|
||||
validate_identifier(&input.event_kind, "EVENT_KIND")?;
|
||||
validate_sha256(&input.payload_sha256)?;
|
||||
validate_secret(&input.resume_secret, "RESUME_SECRET")?;
|
||||
let account_key = sha256_hex(input.account_id.as_bytes());
|
||||
require_active_session(root, &account_key, &input.session_id)?;
|
||||
let path = session_path(root, &account_key, &input.session_id);
|
||||
let _lock = lock_session(&path)?;
|
||||
let mut record = read_session(&path)?;
|
||||
authorize(
|
||||
&record,
|
||||
&account_key,
|
||||
&input.session_id,
|
||||
&input.resume_secret,
|
||||
)?;
|
||||
|
||||
let events_root = path
|
||||
.parent()
|
||||
.ok_or("HOLOLAKE_DIRECT_SESSION_PATH_INVALID")?
|
||||
.join("events");
|
||||
fs::create_dir_all(&events_root)
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
let idempotency_path = events_root.join(format!("idempotency-{}.json", input.idempotency_key));
|
||||
if idempotency_path.exists() {
|
||||
let existing: EventRecord = read_json(&idempotency_path, "EVENT")?;
|
||||
if existing.event_kind == input.event_kind
|
||||
&& existing.payload_sha256 == input.payload_sha256
|
||||
{
|
||||
return Ok(event_receipt("DUPLICATE_CONFIRMED", &existing));
|
||||
}
|
||||
return Err("HOLOLAKE_EVENT_IDEMPOTENCY_CONFLICT".into());
|
||||
}
|
||||
if input.expected_previous_sequence != record.last_event_sequence {
|
||||
return Err("HOLOLAKE_EVENT_CURSOR_CONFLICT".into());
|
||||
}
|
||||
|
||||
let observed_at_unix_ms = now_unix_ms()?;
|
||||
let event = EventRecord {
|
||||
schema: SESSION_SCHEMA.into(),
|
||||
session_id: record.session_id.clone(),
|
||||
sequence: record.last_event_sequence + 1,
|
||||
idempotency_key: input.idempotency_key,
|
||||
event_kind: input.event_kind,
|
||||
payload_sha256: input.payload_sha256,
|
||||
observed_at_unix_ms,
|
||||
};
|
||||
write_record_create_new(&idempotency_path, &event, "EVENT")?;
|
||||
record.last_event_sequence = event.sequence;
|
||||
record.observed_at_unix_ms = observed_at_unix_ms;
|
||||
write_record_atomic(&path, &record)?;
|
||||
Ok(event_receipt("APPENDED", &event))
|
||||
}
|
||||
|
||||
pub(crate) fn authenticate_at(root: &Path, input: &AuthenticateSessionInput) -> Result<(), String> {
|
||||
validate_identifier(&input.account_id, "ACCOUNT")?;
|
||||
validate_identifier(&input.session_id, "SESSION")?;
|
||||
validate_secret(&input.resume_secret, "RESUME_SECRET")?;
|
||||
let account_key = sha256_hex(input.account_id.as_bytes());
|
||||
require_active_session(root, &account_key, &input.session_id)?;
|
||||
let path = session_path(root, &account_key, &input.session_id);
|
||||
let _lock = lock_session(&path)?;
|
||||
let record = read_session(&path)?;
|
||||
authorize(
|
||||
&record,
|
||||
&account_key,
|
||||
&input.session_id,
|
||||
&input.resume_secret,
|
||||
)
|
||||
}
|
||||
|
||||
fn session_path(root: &Path, account_key: &str, session_id: &str) -> PathBuf {
|
||||
root.join("accounts")
|
||||
.join(account_key)
|
||||
.join("sessions")
|
||||
.join(session_id)
|
||||
.join("session.json")
|
||||
}
|
||||
|
||||
fn active_session_path(root: &Path, account_key: &str) -> PathBuf {
|
||||
root.join("accounts")
|
||||
.join(account_key)
|
||||
.join("active-session.json")
|
||||
}
|
||||
|
||||
fn require_active_session(root: &Path, account_key: &str, session_id: &str) -> Result<(), String> {
|
||||
let active: ActiveSessionRecord =
|
||||
read_json(&active_session_path(root, account_key), "ACTIVE_SESSION")?;
|
||||
if active.schema != SESSION_SCHEMA
|
||||
|| active.account_key != account_key
|
||||
|| active.session_id != session_id
|
||||
{
|
||||
return Err("HOLOLAKE_SESSION_IS_NOT_ACCOUNT_ACTIVE_SESSION".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn lock_account(root: &Path, account_key: &str) -> Result<std::fs::File, String> {
|
||||
lock_file(
|
||||
&root.join("accounts").join(account_key).join("account.lock"),
|
||||
"ACCOUNT",
|
||||
)
|
||||
}
|
||||
|
||||
fn read_session(path: &Path) -> Result<SessionRecord, String> {
|
||||
let record: SessionRecord = read_json(path, "SESSION")?;
|
||||
if record.schema != SESSION_SCHEMA {
|
||||
return Err("HOLOLAKE_DIRECT_SESSION_SCHEMA_UNSUPPORTED".into());
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn lock_session(path: &Path) -> Result<std::fs::File, String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or("HOLOLAKE_DIRECT_SESSION_PATH_INVALID")?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
lock_file(&parent.join("session.lock"), "SESSION")
|
||||
}
|
||||
|
||||
fn lock_file(lock_path: &Path, kind: &str) -> Result<std::fs::File, String> {
|
||||
let parent = lock_path
|
||||
.parent()
|
||||
.ok_or("HOLOLAKE_DIRECT_SESSION_PATH_INVALID")?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(lock_path)
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_{kind}_LOCK_FAILED: {error}"))?;
|
||||
file.lock_exclusive()
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_{kind}_LOCK_FAILED: {error}"))?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn authorize(
|
||||
record: &SessionRecord,
|
||||
account_key: &str,
|
||||
session_id: &str,
|
||||
secret: &str,
|
||||
) -> Result<(), String> {
|
||||
if record.account_key != account_key
|
||||
|| record.session_id != session_id
|
||||
|| record.resume_secret_sha256 != sha256_hex(secret.as_bytes())
|
||||
{
|
||||
return Err("HOLOLAKE_DIRECT_SESSION_NOT_AUTHORIZED".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_record_atomic<T: Serialize>(path: &Path, record: &T) -> Result<(), String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or("HOLOLAKE_DIRECT_SESSION_PATH_INVALID")?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
let temporary = parent.join(format!(".{}.tmp", Uuid::new_v4()));
|
||||
write_bytes_create_new(&temporary, record, "SESSION")?;
|
||||
fs::rename(&temporary, path)
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_WRITE_FAILED: {error}"))
|
||||
}
|
||||
|
||||
fn write_record_create_new<T: Serialize>(
|
||||
path: &Path,
|
||||
record: &T,
|
||||
kind: &str,
|
||||
) -> Result<(), String> {
|
||||
write_bytes_create_new(path, record, kind)
|
||||
}
|
||||
|
||||
fn write_bytes_create_new<T: Serialize>(path: &Path, record: &T, kind: &str) -> Result<(), String> {
|
||||
let bytes = serde_json::to_vec_pretty(record)
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_{kind}_INVALID: {error}"))?;
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_{kind}_WRITE_FAILED: {error}"))?;
|
||||
file.write_all(&bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_{kind}_WRITE_FAILED: {error}"))
|
||||
}
|
||||
|
||||
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path, kind: &str) -> Result<T, String> {
|
||||
let bytes = fs::read(path).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
format!("HOLOLAKE_DIRECT_{kind}_NOT_FOUND")
|
||||
} else {
|
||||
format!("HOLOLAKE_DIRECT_{kind}_UNREADABLE: {error}")
|
||||
}
|
||||
})?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("HOLOLAKE_DIRECT_{kind}_INVALID: {error}"))
|
||||
}
|
||||
|
||||
fn session_receipt(
|
||||
state: &'static str,
|
||||
record: &SessionRecord,
|
||||
resume_secret: Option<String>,
|
||||
) -> DirectSessionReceipt {
|
||||
DirectSessionReceipt {
|
||||
schema: SESSION_SCHEMA,
|
||||
state,
|
||||
account_key: record.account_key.clone(),
|
||||
session_id: record.session_id.clone(),
|
||||
lane_id: record.lane_id.clone(),
|
||||
client_instance_id: record.client_instance_id.clone(),
|
||||
opened_at_unix_ms: record.opened_at_unix_ms,
|
||||
observed_at_unix_ms: record.observed_at_unix_ms,
|
||||
last_event_sequence: record.last_event_sequence,
|
||||
resume_secret,
|
||||
receipt_id: sha256_hex(
|
||||
format!(
|
||||
"{state}\n{}\n{}\n{}\n{}",
|
||||
record.account_key, record.session_id, record.lane_id, record.observed_at_unix_ms
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn event_receipt(state: &'static str, event: &EventRecord) -> SessionEventReceipt {
|
||||
SessionEventReceipt {
|
||||
schema: SESSION_SCHEMA,
|
||||
state,
|
||||
session_id: event.session_id.clone(),
|
||||
sequence: event.sequence,
|
||||
idempotency_key: event.idempotency_key.clone(),
|
||||
event_kind: event.event_kind.clone(),
|
||||
payload_sha256: event.payload_sha256.clone(),
|
||||
observed_at_unix_ms: event.observed_at_unix_ms,
|
||||
receipt_id: sha256_hex(
|
||||
format!(
|
||||
"{}\n{}\n{}\n{}",
|
||||
event.session_id, event.sequence, event.idempotency_key, event.payload_sha256
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_identifier(value: &str, kind: &str) -> Result<(), String> {
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_ID_BYTES
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
|
||||
{
|
||||
return Err(format!("HOLOLAKE_{kind}_ID_INVALID"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_secret(value: &str, kind: &str) -> Result<(), String> {
|
||||
if value.len() < 16 || value.len() > 512 {
|
||||
return Err(format!("HOLOLAKE_{kind}_INVALID"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_sha256(value: &str) -> Result<(), String> {
|
||||
if value.len() != 64
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||
{
|
||||
return Err("HOLOLAKE_PAYLOAD_SHA256_INVALID".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> Result<u128, String> {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.map_err(|error| format!("HOLOLAKE_SYSTEM_CLOCK_INVALID: {error}"))
|
||||
}
|
||||
|
||||
fn sha256_hex(value: &[u8]) -> String {
|
||||
digest(&SHA256, value)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn open_input(ticket: &str) -> OpenSessionInput {
|
||||
OpenSessionInput {
|
||||
account_id: "human-BS-0001".into(),
|
||||
lane_id: "DEV-001".into(),
|
||||
client_instance_id: "codex-1".into(),
|
||||
discovery_ticket: ticket.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn issued_open_input(temp: &TempDir, suffix: &str) -> OpenSessionInput {
|
||||
let issued = issue_ticket_at(
|
||||
temp.path(),
|
||||
IssueDiscoveryTicketInput {
|
||||
account_id: "human-BS-0001".into(),
|
||||
lane_id: "DEV-001".into(),
|
||||
client_instance_id: "codex-1".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut input = open_input(&issued.discovery_ticket);
|
||||
input.client_instance_id = format!("codex-{suffix}");
|
||||
if suffix != "1" {
|
||||
let issued = issue_ticket_at(
|
||||
temp.path(),
|
||||
IssueDiscoveryTicketInput {
|
||||
account_id: input.account_id.clone(),
|
||||
lane_id: input.lane_id.clone(),
|
||||
client_instance_id: input.client_instance_id.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
input.discovery_ticket = issued.discovery_ticket;
|
||||
}
|
||||
input
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_ticket_is_single_use_and_secret_is_not_stored_plaintext() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let input = issued_open_input(&temp, "1");
|
||||
let ticket = input.discovery_ticket.clone();
|
||||
let opened = open_at(temp.path(), input).unwrap();
|
||||
assert_eq!(opened.state, "OPENED");
|
||||
assert!(open_at(temp.path(), open_input(&ticket))
|
||||
.unwrap_err()
|
||||
.contains("ALREADY_USED"));
|
||||
let stored = fs::read_to_string(session_path(
|
||||
temp.path(),
|
||||
&opened.account_key,
|
||||
&opened.session_id,
|
||||
))
|
||||
.unwrap();
|
||||
assert!(!stored.contains(opened.resume_secret.as_ref().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_restart_resumes_the_same_session_and_cursor() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let opened = open_at(temp.path(), issued_open_input(&temp, "2")).unwrap();
|
||||
let event = append_event_at(
|
||||
temp.path(),
|
||||
AppendSessionEventInput {
|
||||
account_id: "human-BS-0001".into(),
|
||||
session_id: opened.session_id.clone(),
|
||||
resume_secret: opened.resume_secret.clone().unwrap(),
|
||||
idempotency_key: "event-1".into(),
|
||||
event_kind: "TASK_OBSERVED".into(),
|
||||
payload_sha256: "a".repeat(64),
|
||||
expected_previous_sequence: 0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(event.sequence, 1);
|
||||
let resumed = resume_at(
|
||||
temp.path(),
|
||||
ResumeSessionInput {
|
||||
account_id: "human-BS-0001".into(),
|
||||
session_id: opened.session_id,
|
||||
client_instance_id: "codex-2".into(),
|
||||
resume_secret: opened.resume_secret.unwrap(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resumed.state, "RESUMED");
|
||||
assert_eq!(resumed.last_event_sequence, 1);
|
||||
assert_eq!(resumed.client_instance_id, "codex-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_retries_are_idempotent_but_conflicts_fail_closed() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let opened = open_at(temp.path(), issued_open_input(&temp, "3")).unwrap();
|
||||
let secret = opened.resume_secret.unwrap();
|
||||
let input = AppendSessionEventInput {
|
||||
account_id: "human-BS-0001".into(),
|
||||
session_id: opened.session_id,
|
||||
resume_secret: secret,
|
||||
idempotency_key: "event-1".into(),
|
||||
event_kind: "TASK_OBSERVED".into(),
|
||||
payload_sha256: "b".repeat(64),
|
||||
expected_previous_sequence: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
append_event_at(temp.path(), input.clone()).unwrap().state,
|
||||
"APPENDED"
|
||||
);
|
||||
assert_eq!(
|
||||
append_event_at(temp.path(), input.clone()).unwrap().state,
|
||||
"DUPLICATE_CONFIRMED"
|
||||
);
|
||||
let conflicting = AppendSessionEventInput {
|
||||
payload_sha256: "c".repeat(64),
|
||||
..input
|
||||
};
|
||||
assert_eq!(
|
||||
append_event_at(temp.path(), conflicting).unwrap_err(),
|
||||
"HOLOLAKE_EVENT_IDEMPOTENCY_CONFLICT"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_secret_and_stale_cursor_fail_closed() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let opened = open_at(temp.path(), issued_open_input(&temp, "4")).unwrap();
|
||||
let wrong = resume_at(
|
||||
temp.path(),
|
||||
ResumeSessionInput {
|
||||
account_id: "human-BS-0001".into(),
|
||||
session_id: opened.session_id.clone(),
|
||||
client_instance_id: "codex-2".into(),
|
||||
resume_secret: "wrong-resume-secret-0000".into(),
|
||||
},
|
||||
);
|
||||
assert_eq!(wrong.unwrap_err(), "HOLOLAKE_DIRECT_SESSION_NOT_AUTHORIZED");
|
||||
let stale = append_event_at(
|
||||
temp.path(),
|
||||
AppendSessionEventInput {
|
||||
account_id: "human-BS-0001".into(),
|
||||
session_id: opened.session_id,
|
||||
resume_secret: opened.resume_secret.unwrap(),
|
||||
idempotency_key: "event-2".into(),
|
||||
event_kind: "TASK_OBSERVED".into(),
|
||||
payload_sha256: "d".repeat(64),
|
||||
expected_previous_sequence: 9,
|
||||
},
|
||||
);
|
||||
assert_eq!(stale.unwrap_err(), "HOLOLAKE_EVENT_CURSOR_CONFLICT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_cannot_invent_a_discovery_ticket() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
assert_eq!(
|
||||
open_at(temp.path(), open_input("invented-ticket-0000")).unwrap_err(),
|
||||
"HOLOLAKE_DIRECT_DISCOVERY_TICKET_NOT_FOUND"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_account_cannot_open_two_direct_write_sessions() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let first = open_at(temp.path(), issued_open_input(&temp, "1")).unwrap();
|
||||
let second = open_at(temp.path(), issued_open_input(&temp, "2")).unwrap_err();
|
||||
assert!(second.contains("ACCOUNT_ALREADY_HAS_ACTIVE_SESSION"));
|
||||
assert!(second.contains(&first.session_id));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,690 @@
|
|||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use fs2::FileExt;
|
||||
use ring::signature::{UnparsedPublicKey, ED25519};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
const REGISTRY_SCHEMA: &str = "hololake.dynamic-node-registry/v1";
|
||||
const HEALTH_SCHEMA: &str = "hololake.signed-node-health/v1";
|
||||
const ROUTE_SCHEMA: &str = "hololake.capability-route-receipt/v1";
|
||||
const MAX_IDENTIFIER_BYTES: usize = 128;
|
||||
const MAX_CLOCK_SKEW_MS: u64 = 300_000;
|
||||
const MAX_NODES: usize = 4096;
|
||||
const MAX_BINDINGS_PER_NODE: usize = 256;
|
||||
const EMBEDDED_ROUTING_TRUST: &str = include_str!("../dynamic-routing-trust.json");
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(crate) struct DynamicNodeRegistry {
|
||||
schema: String,
|
||||
registry_id: String,
|
||||
version: u64,
|
||||
nodes: Vec<DynamicNodeRecord>,
|
||||
signer_id: String,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct DynamicRoutingTrust {
|
||||
schema: String,
|
||||
state: RoutingTrustState,
|
||||
signers: Vec<RoutingTrustSigner>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
enum RoutingTrustState {
|
||||
UnprovisionedFailClosed,
|
||||
Provisioned,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct RoutingTrustSigner {
|
||||
signer_id: String,
|
||||
public_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct DynamicNodeRecord {
|
||||
node_id: String,
|
||||
domain_id: String,
|
||||
endpoint: String,
|
||||
protocol_public_key: String,
|
||||
human_ids: Vec<String>,
|
||||
persona_ids: Vec<String>,
|
||||
capabilities: Vec<String>,
|
||||
priority: u32,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(crate) struct SignedNodeHealth {
|
||||
schema: String,
|
||||
registry_version: u64,
|
||||
node_id: String,
|
||||
observed_at_unix_ms: u64,
|
||||
expires_at_unix_ms: u64,
|
||||
sequence: u64,
|
||||
status: HealthStatus,
|
||||
capabilities: Vec<String>,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
enum HealthStatus {
|
||||
Ready,
|
||||
Degraded,
|
||||
Offline,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ResolveCapabilityRouteInput {
|
||||
human_id: String,
|
||||
persona_id: String,
|
||||
domain_id: String,
|
||||
capability_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CapabilityRouteReceipt {
|
||||
schema: &'static str,
|
||||
state: &'static str,
|
||||
registry_id: String,
|
||||
registry_version: u64,
|
||||
node_id: String,
|
||||
domain_id: String,
|
||||
endpoint: String,
|
||||
capability_id: String,
|
||||
human_id: String,
|
||||
persona_id: String,
|
||||
health_sequence: u64,
|
||||
health_expires_at_unix_ms: u64,
|
||||
write_authority_granted: bool,
|
||||
receipt_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct RegistryInstallReceipt {
|
||||
schema: &'static str,
|
||||
state: &'static str,
|
||||
registry_id: String,
|
||||
version: u64,
|
||||
node_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct HealthRecordReceipt {
|
||||
schema: &'static str,
|
||||
state: &'static str,
|
||||
node_id: String,
|
||||
sequence: u64,
|
||||
expires_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn install_trusted_registry_at(
|
||||
root: &Path,
|
||||
registry: DynamicNodeRegistry,
|
||||
) -> Result<RegistryInstallReceipt, String> {
|
||||
let public_key = trusted_registry_key(EMBEDDED_ROUTING_TRUST, ®istry.signer_id)?;
|
||||
install_registry_at(root, registry, &public_key)
|
||||
}
|
||||
|
||||
pub(crate) fn routing_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let root = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?
|
||||
.join("dynamic-capability-routing-v1");
|
||||
fs::create_dir_all(&root)
|
||||
.map_err(|error| format!("HOLOLAKE_DYNAMIC_ROUTING_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
fn install_registry_at(
|
||||
root: &Path,
|
||||
registry: DynamicNodeRegistry,
|
||||
trusted_public_key: &str,
|
||||
) -> Result<RegistryInstallReceipt, String> {
|
||||
let _lock = lock_file(&root.join("registry.lock"), "REGISTRY")?;
|
||||
validate_registry(®istry)?;
|
||||
verify_signature(
|
||||
trusted_public_key,
|
||||
®istry.signature,
|
||||
canonical_registry_bytes(®istry).as_bytes(),
|
||||
"REGISTRY",
|
||||
)?;
|
||||
let path = root.join("registry.json");
|
||||
if path.exists() {
|
||||
let current: DynamicNodeRegistry = read_json(&path, "REGISTRY")?;
|
||||
if registry.registry_id != current.registry_id || registry.version <= current.version {
|
||||
return Err("HOLOLAKE_DYNAMIC_REGISTRY_VERSION_NOT_INCREASING".into());
|
||||
}
|
||||
}
|
||||
write_json_atomic(&path, ®istry, "REGISTRY")?;
|
||||
Ok(RegistryInstallReceipt {
|
||||
schema: REGISTRY_SCHEMA,
|
||||
state: "INSTALLED",
|
||||
registry_id: registry.registry_id,
|
||||
version: registry.version,
|
||||
node_count: registry.nodes.len(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn record_health_at(
|
||||
root: &Path,
|
||||
health: SignedNodeHealth,
|
||||
) -> Result<HealthRecordReceipt, String> {
|
||||
validate_identifier(&health.node_id, "NODE")?;
|
||||
let _lock = lock_file(
|
||||
&root.join("health").join(format!("{}.lock", health.node_id)),
|
||||
"NODE_HEALTH",
|
||||
)?;
|
||||
validate_capabilities(&health.capabilities)?;
|
||||
if health.schema != HEALTH_SCHEMA || health.expires_at_unix_ms <= health.observed_at_unix_ms {
|
||||
return Err("HOLOLAKE_NODE_HEALTH_INVALID".into());
|
||||
}
|
||||
let registry: DynamicNodeRegistry = read_json(&root.join("registry.json"), "REGISTRY")?;
|
||||
if health.registry_version != registry.version {
|
||||
return Err("HOLOLAKE_NODE_HEALTH_REGISTRY_VERSION_MISMATCH".into());
|
||||
}
|
||||
let node = registry
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.node_id == health.node_id)
|
||||
.ok_or("HOLOLAKE_NODE_NOT_REGISTERED")?;
|
||||
verify_signature(
|
||||
&node.protocol_public_key,
|
||||
&health.signature,
|
||||
canonical_health_bytes(&health).as_bytes(),
|
||||
"NODE_HEALTH",
|
||||
)?;
|
||||
let path = root.join("health").join(format!("{}.json", health.node_id));
|
||||
if path.exists() {
|
||||
let current: SignedNodeHealth = read_json(&path, "NODE_HEALTH")?;
|
||||
if health.sequence <= current.sequence {
|
||||
return Err("HOLOLAKE_NODE_HEALTH_REPLAYED".into());
|
||||
}
|
||||
}
|
||||
write_json_atomic(&path, &health, "NODE_HEALTH")?;
|
||||
Ok(HealthRecordReceipt {
|
||||
schema: HEALTH_SCHEMA,
|
||||
state: "RECORDED",
|
||||
node_id: health.node_id,
|
||||
sequence: health.sequence,
|
||||
expires_at_unix_ms: health.expires_at_unix_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_at(
|
||||
root: &Path,
|
||||
input: ResolveCapabilityRouteInput,
|
||||
now: u64,
|
||||
) -> Result<CapabilityRouteReceipt, String> {
|
||||
validate_identifier(&input.human_id, "HUMAN")?;
|
||||
validate_identifier(&input.persona_id, "PERSONA")?;
|
||||
validate_identifier(&input.domain_id, "DOMAIN")?;
|
||||
validate_identifier(&input.capability_id, "CAPABILITY")?;
|
||||
let registry: DynamicNodeRegistry = read_json(&root.join("registry.json"), "REGISTRY")?;
|
||||
let mut eligible = Vec::new();
|
||||
for node in registry.nodes.iter().filter(|node| {
|
||||
node.enabled
|
||||
&& node.domain_id == input.domain_id
|
||||
&& node.human_ids.contains(&input.human_id)
|
||||
&& node.persona_ids.contains(&input.persona_id)
|
||||
&& node.capabilities.contains(&input.capability_id)
|
||||
}) {
|
||||
let path = root.join("health").join(format!("{}.json", node.node_id));
|
||||
let Ok(health) = read_json::<SignedNodeHealth>(&path, "NODE_HEALTH") else {
|
||||
continue;
|
||||
};
|
||||
if health.registry_version != registry.version
|
||||
|| health.status != HealthStatus::Ready
|
||||
|| health.observed_at_unix_ms > now.saturating_add(MAX_CLOCK_SKEW_MS)
|
||||
|| health.expires_at_unix_ms < now
|
||||
|| !health.capabilities.contains(&input.capability_id)
|
||||
|| verify_signature(
|
||||
&node.protocol_public_key,
|
||||
&health.signature,
|
||||
canonical_health_bytes(&health).as_bytes(),
|
||||
"NODE_HEALTH",
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
eligible.push((node, health));
|
||||
}
|
||||
eligible.sort_by(
|
||||
|(left, _), (right, _)| match left.priority.cmp(&right.priority) {
|
||||
Ordering::Equal => left.node_id.cmp(&right.node_id),
|
||||
ordering => ordering,
|
||||
},
|
||||
);
|
||||
let (node, health) = eligible
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or("HOLOLAKE_NO_HEALTHY_CAPABILITY_ROUTE")?;
|
||||
let receipt_id = sha256_hex(
|
||||
format!(
|
||||
"{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
|
||||
registry.registry_id,
|
||||
registry.version,
|
||||
node.node_id,
|
||||
input.human_id,
|
||||
input.persona_id,
|
||||
input.domain_id,
|
||||
input.capability_id,
|
||||
health.sequence
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
Ok(CapabilityRouteReceipt {
|
||||
schema: ROUTE_SCHEMA,
|
||||
state: "ROUTE_SELECTED",
|
||||
registry_id: registry.registry_id,
|
||||
registry_version: registry.version,
|
||||
node_id: node.node_id.clone(),
|
||||
domain_id: node.domain_id.clone(),
|
||||
endpoint: node.endpoint.clone(),
|
||||
capability_id: input.capability_id,
|
||||
human_id: input.human_id,
|
||||
persona_id: input.persona_id,
|
||||
health_sequence: health.sequence,
|
||||
health_expires_at_unix_ms: health.expires_at_unix_ms,
|
||||
write_authority_granted: false,
|
||||
receipt_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_registry(registry: &DynamicNodeRegistry) -> Result<(), String> {
|
||||
if registry.schema != REGISTRY_SCHEMA
|
||||
|| registry.version == 0
|
||||
|| registry.nodes.is_empty()
|
||||
|| registry.nodes.len() > MAX_NODES
|
||||
{
|
||||
return Err("HOLOLAKE_DYNAMIC_REGISTRY_INVALID".into());
|
||||
}
|
||||
validate_identifier(®istry.registry_id, "REGISTRY")?;
|
||||
validate_identifier(®istry.signer_id, "SIGNER")?;
|
||||
let mut node_ids = BTreeSet::new();
|
||||
for node in ®istry.nodes {
|
||||
validate_identifier(&node.node_id, "NODE")?;
|
||||
validate_identifier(&node.domain_id, "DOMAIN")?;
|
||||
validate_identifiers(&node.human_ids, "HUMAN")?;
|
||||
validate_identifiers(&node.persona_ids, "PERSONA")?;
|
||||
validate_capabilities(&node.capabilities)?;
|
||||
if !node_ids.insert(node.node_id.as_str()) {
|
||||
return Err("HOLOLAKE_DYNAMIC_REGISTRY_DUPLICATE_NODE".into());
|
||||
}
|
||||
let endpoint = tauri::Url::parse(&node.endpoint)
|
||||
.map_err(|error| format!("HOLOLAKE_NODE_ENDPOINT_INVALID: {error}"))?;
|
||||
if endpoint.scheme() != "https"
|
||||
|| endpoint.host_str().is_none()
|
||||
|| !endpoint.username().is_empty()
|
||||
|| endpoint.password().is_some()
|
||||
|| endpoint.query().is_some()
|
||||
|| endpoint.fragment().is_some()
|
||||
{
|
||||
return Err("HOLOLAKE_NODE_ENDPOINT_MUST_USE_HTTPS".into());
|
||||
}
|
||||
decode_exact(&node.protocol_public_key, 32, "NODE_PROTOCOL_PUBLIC_KEY")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_capabilities(values: &[String]) -> Result<(), String> {
|
||||
validate_identifiers(values, "CAPABILITY")
|
||||
}
|
||||
|
||||
fn validate_identifiers(values: &[String], kind: &str) -> Result<(), String> {
|
||||
if values.is_empty() || values.len() > MAX_BINDINGS_PER_NODE {
|
||||
return Err(format!("HOLOLAKE_{kind}_BINDING_REQUIRED"));
|
||||
}
|
||||
let mut unique = BTreeSet::new();
|
||||
for value in values {
|
||||
validate_identifier(value, kind)?;
|
||||
if !unique.insert(value.as_str()) {
|
||||
return Err(format!("HOLOLAKE_{kind}_BINDING_DUPLICATE"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_identifier(value: &str, kind: &str) -> Result<(), String> {
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_IDENTIFIER_BYTES
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
|
||||
{
|
||||
return Err(format!("HOLOLAKE_{kind}_ID_INVALID"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_registry_bytes(registry: &DynamicNodeRegistry) -> String {
|
||||
let mut lines = vec![
|
||||
registry.schema.clone(),
|
||||
registry.registry_id.clone(),
|
||||
registry.version.to_string(),
|
||||
registry.signer_id.clone(),
|
||||
];
|
||||
for node in ®istry.nodes {
|
||||
lines.extend([
|
||||
node.node_id.clone(),
|
||||
node.domain_id.clone(),
|
||||
node.endpoint.clone(),
|
||||
node.protocol_public_key.clone(),
|
||||
node.human_ids.join(","),
|
||||
node.persona_ids.join(","),
|
||||
node.capabilities.join(","),
|
||||
node.priority.to_string(),
|
||||
node.enabled.to_string(),
|
||||
]);
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn canonical_health_bytes(health: &SignedNodeHealth) -> String {
|
||||
[
|
||||
health.schema.clone(),
|
||||
health.registry_version.to_string(),
|
||||
health.node_id.clone(),
|
||||
health.observed_at_unix_ms.to_string(),
|
||||
health.expires_at_unix_ms.to_string(),
|
||||
health.sequence.to_string(),
|
||||
match health.status {
|
||||
HealthStatus::Ready => "READY",
|
||||
HealthStatus::Degraded => "DEGRADED",
|
||||
HealthStatus::Offline => "OFFLINE",
|
||||
}
|
||||
.into(),
|
||||
health.capabilities.join(","),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn verify_signature(
|
||||
public_key: &str,
|
||||
signature: &str,
|
||||
bytes: &[u8],
|
||||
kind: &str,
|
||||
) -> Result<(), String> {
|
||||
let public_key = decode_exact(public_key, 32, &format!("{kind}_PUBLIC_KEY"))?;
|
||||
let signature = decode_exact(signature, 64, &format!("{kind}_SIGNATURE"))?;
|
||||
UnparsedPublicKey::new(&ED25519, public_key)
|
||||
.verify(bytes, &signature)
|
||||
.map_err(|_| format!("HOLOLAKE_{kind}_SIGNATURE_INVALID"))
|
||||
}
|
||||
|
||||
fn trusted_registry_key(raw: &str, signer_id: &str) -> Result<String, String> {
|
||||
let trust: DynamicRoutingTrust = serde_json::from_str(raw)
|
||||
.map_err(|error| format!("HOLOLAKE_DYNAMIC_ROUTING_TRUST_INVALID: {error}"))?;
|
||||
if trust.schema != "hololake.dynamic-routing-trust/v1" {
|
||||
return Err("HOLOLAKE_DYNAMIC_ROUTING_TRUST_SCHEMA_UNSUPPORTED".into());
|
||||
}
|
||||
match trust.state {
|
||||
RoutingTrustState::UnprovisionedFailClosed => {
|
||||
if !trust.signers.is_empty() {
|
||||
return Err("HOLOLAKE_DYNAMIC_ROUTING_TRUST_INVALID".into());
|
||||
}
|
||||
Err("HOLOLAKE_DYNAMIC_ROUTING_TRUST_UNPROVISIONED".into())
|
||||
}
|
||||
RoutingTrustState::Provisioned => {
|
||||
if trust.signers.is_empty() {
|
||||
return Err("HOLOLAKE_DYNAMIC_ROUTING_TRUST_INVALID".into());
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
for signer in trust.signers {
|
||||
validate_identifier(&signer.signer_id, "SIGNER")?;
|
||||
decode_exact(&signer.public_key, 32, "REGISTRY_PUBLIC_KEY")?;
|
||||
if !ids.insert(signer.signer_id.clone()) {
|
||||
return Err("HOLOLAKE_DYNAMIC_ROUTING_TRUST_DUPLICATE_SIGNER".into());
|
||||
}
|
||||
if signer.signer_id == signer_id {
|
||||
return Ok(signer.public_key);
|
||||
}
|
||||
}
|
||||
Err("HOLOLAKE_DYNAMIC_ROUTING_SIGNER_NOT_TRUSTED".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_exact(value: &str, expected: usize, kind: &str) -> Result<Vec<u8>, String> {
|
||||
let decoded = BASE64
|
||||
.decode(value)
|
||||
.map_err(|_| format!("HOLOLAKE_{kind}_INVALID"))?;
|
||||
if decoded.len() != expected {
|
||||
return Err(format!("HOLOLAKE_{kind}_INVALID"));
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
fn write_json_atomic<T: Serialize>(path: &Path, value: &T, kind: &str) -> Result<(), String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or("HOLOLAKE_DYNAMIC_ROUTING_PATH_INVALID")?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("HOLOLAKE_DYNAMIC_ROUTING_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
let temporary = parent.join(format!(".{}.tmp", Uuid::new_v4()));
|
||||
let bytes = serde_json::to_vec_pretty(value)
|
||||
.map_err(|error| format!("HOLOLAKE_{kind}_INVALID: {error}"))?;
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temporary)
|
||||
.map_err(|error| format!("HOLOLAKE_{kind}_WRITE_FAILED: {error}"))?;
|
||||
file.write_all(&bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| format!("HOLOLAKE_{kind}_WRITE_FAILED: {error}"))?;
|
||||
fs::rename(&temporary, path).map_err(|error| format!("HOLOLAKE_{kind}_WRITE_FAILED: {error}"))
|
||||
}
|
||||
|
||||
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path, kind: &str) -> Result<T, String> {
|
||||
let bytes = fs::read(path).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
format!("HOLOLAKE_{kind}_NOT_FOUND")
|
||||
} else {
|
||||
format!("HOLOLAKE_{kind}_UNREADABLE: {error}")
|
||||
}
|
||||
})?;
|
||||
serde_json::from_slice(&bytes).map_err(|error| format!("HOLOLAKE_{kind}_INVALID: {error}"))
|
||||
}
|
||||
|
||||
fn lock_file(path: &Path, kind: &str) -> Result<std::fs::File, String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or("HOLOLAKE_DYNAMIC_ROUTING_PATH_INVALID")?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("HOLOLAKE_DYNAMIC_ROUTING_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path)
|
||||
.map_err(|error| format!("HOLOLAKE_{kind}_LOCK_FAILED: {error}"))?;
|
||||
file.lock_exclusive()
|
||||
.map_err(|error| format!("HOLOLAKE_{kind}_LOCK_FAILED: {error}"))?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn sha256_hex(value: &[u8]) -> String {
|
||||
ring::digest::digest(&ring::digest::SHA256, value)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ring::rand::SystemRandom;
|
||||
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn key_pair() -> Ed25519KeyPair {
|
||||
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
|
||||
Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap()
|
||||
}
|
||||
|
||||
fn node(node_id: &str, key: &Ed25519KeyPair, priority: u32) -> DynamicNodeRecord {
|
||||
DynamicNodeRecord {
|
||||
node_id: node_id.into(),
|
||||
domain_id: "DOM-FIFTH-0001".into(),
|
||||
endpoint: format!("https://{node_id}.example.test/hololake"),
|
||||
protocol_public_key: BASE64.encode(key.public_key().as_ref()),
|
||||
human_ids: vec!["human-BS-0001".into()],
|
||||
persona_ids: vec!["ICE-P-ZY001".into()],
|
||||
capabilities: vec!["GH-PNCC-READ".into()],
|
||||
priority,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn registry(nodes: Vec<DynamicNodeRecord>, signer: &Ed25519KeyPair) -> DynamicNodeRegistry {
|
||||
let mut registry = DynamicNodeRegistry {
|
||||
schema: REGISTRY_SCHEMA.into(),
|
||||
registry_id: "registry-1".into(),
|
||||
version: 1,
|
||||
nodes,
|
||||
signer_id: "jd-controller-release-registry".into(),
|
||||
signature: String::new(),
|
||||
};
|
||||
registry.signature = BASE64.encode(
|
||||
signer
|
||||
.sign(canonical_registry_bytes(®istry).as_bytes())
|
||||
.as_ref(),
|
||||
);
|
||||
registry
|
||||
}
|
||||
|
||||
fn health(node_id: &str, signer: &Ed25519KeyPair, now: u64) -> SignedNodeHealth {
|
||||
let mut health = SignedNodeHealth {
|
||||
schema: HEALTH_SCHEMA.into(),
|
||||
registry_version: 1,
|
||||
node_id: node_id.into(),
|
||||
observed_at_unix_ms: now,
|
||||
expires_at_unix_ms: now + 60_000,
|
||||
sequence: 1,
|
||||
status: HealthStatus::Ready,
|
||||
capabilities: vec!["GH-PNCC-READ".into()],
|
||||
signature: String::new(),
|
||||
};
|
||||
health.signature = BASE64.encode(
|
||||
signer
|
||||
.sign(canonical_health_bytes(&health).as_bytes())
|
||||
.as_ref(),
|
||||
);
|
||||
health
|
||||
}
|
||||
|
||||
fn input() -> ResolveCapabilityRouteInput {
|
||||
ResolveCapabilityRouteInput {
|
||||
human_id: "human-BS-0001".into(),
|
||||
persona_id: "ICE-P-ZY001".into(),
|
||||
domain_id: "DOM-FIFTH-0001".into(),
|
||||
capability_id: "GH-PNCC-READ".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_a_signed_dynamic_registry_and_selects_by_priority() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let registry_signer = key_pair();
|
||||
let node_a = key_pair();
|
||||
let node_b = key_pair();
|
||||
let registry = registry(
|
||||
vec![node("node-b", &node_b, 20), node("node-a", &node_a, 10)],
|
||||
®istry_signer,
|
||||
);
|
||||
install_registry_at(
|
||||
temp.path(),
|
||||
registry,
|
||||
&BASE64.encode(registry_signer.public_key().as_ref()),
|
||||
)
|
||||
.unwrap();
|
||||
let now = 1_000_000;
|
||||
record_health_at(temp.path(), health("node-a", &node_a, now)).unwrap();
|
||||
record_health_at(temp.path(), health("node-b", &node_b, now)).unwrap();
|
||||
let route = resolve_at(temp.path(), input(), now + 1).unwrap();
|
||||
assert_eq!(route.node_id, "node-a");
|
||||
assert!(!route.write_authority_granted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsigned_registry_and_replayed_health() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let registry_signer = key_pair();
|
||||
let node_key = key_pair();
|
||||
let mut invalid = registry(vec![node("node-a", &node_key, 10)], ®istry_signer);
|
||||
invalid.signature = BASE64.encode([0_u8; 64]);
|
||||
assert!(install_registry_at(
|
||||
temp.path(),
|
||||
invalid,
|
||||
&BASE64.encode(registry_signer.public_key().as_ref())
|
||||
)
|
||||
.is_err());
|
||||
|
||||
install_registry_at(
|
||||
temp.path(),
|
||||
registry(vec![node("node-a", &node_key, 10)], ®istry_signer),
|
||||
&BASE64.encode(registry_signer.public_key().as_ref()),
|
||||
)
|
||||
.unwrap();
|
||||
let current = health("node-a", &node_key, 1_000_000);
|
||||
record_health_at(temp.path(), current.clone()).unwrap();
|
||||
assert_eq!(
|
||||
record_health_at(temp.path(), current).unwrap_err(),
|
||||
"HOLOLAKE_NODE_HEALTH_REPLAYED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_unsigned_or_wrong_binding_nodes_are_not_routable() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let registry_signer = key_pair();
|
||||
let node_key = key_pair();
|
||||
install_registry_at(
|
||||
temp.path(),
|
||||
registry(vec![node("node-a", &node_key, 10)], ®istry_signer),
|
||||
&BASE64.encode(registry_signer.public_key().as_ref()),
|
||||
)
|
||||
.unwrap();
|
||||
record_health_at(temp.path(), health("node-a", &node_key, 1_000_000)).unwrap();
|
||||
assert_eq!(
|
||||
resolve_at(temp.path(), input(), 2_000_000).unwrap_err(),
|
||||
"HOLOLAKE_NO_HEALTHY_CAPABILITY_ROUTE"
|
||||
);
|
||||
let mut wrong_persona = input();
|
||||
wrong_persona.persona_id = "ICE-P-OTHER".into();
|
||||
assert_eq!(
|
||||
resolve_at(temp.path(), wrong_persona, 1_000_001).unwrap_err(),
|
||||
"HOLOLAKE_NO_HEALTHY_CAPABILITY_ROUTE"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_unprovisioned_trust_fails_closed() {
|
||||
assert_eq!(
|
||||
trusted_registry_key(EMBEDDED_ROUTING_TRUST, "any-signer").unwrap_err(),
|
||||
"HOLOLAKE_DYNAMIC_ROUTING_TRUST_UNPROVISIONED"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::direct_local_broker::DirectLocalBrokerHandle;
|
||||
use crate::direct_local_session::{active_session_count_at, direct_session_root};
|
||||
use crate::pncc_receipt_projection::{pncc_projection_root, projection_event_count_at};
|
||||
use crate::pncc_repository_binding::{mounted_repository_count_at, pncc_repository_mount_root};
|
||||
use crate::release_trust::release_trust_state;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HoloLakeHomeStatus {
|
||||
pub schema: &'static str,
|
||||
pub direct_local_broker_state: &'static str,
|
||||
pub direct_connection_count: usize,
|
||||
pub resumable_session_count: usize,
|
||||
pub code_repository_mount_count: usize,
|
||||
pub pncc_receipt_count: usize,
|
||||
pub update_state: &'static str,
|
||||
pub release_recovery_state: String,
|
||||
pub automatic_upstream_updates: bool,
|
||||
pub mcp_role: &'static str,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_hololake_home_status(
|
||||
app: AppHandle,
|
||||
broker: State<'_, DirectLocalBrokerHandle>,
|
||||
) -> Result<HoloLakeHomeStatus, String> {
|
||||
let session_root = direct_session_root(&app)?;
|
||||
let mount_root = pncc_repository_mount_root(&app)?;
|
||||
let projection_root = pncc_projection_root(&app)?;
|
||||
Ok(HoloLakeHomeStatus {
|
||||
schema: "hololake.home-status/v1",
|
||||
direct_local_broker_state: "READY",
|
||||
direct_connection_count: broker.active_connection_count(),
|
||||
resumable_session_count: active_session_count_at(&session_root)?,
|
||||
code_repository_mount_count: mounted_repository_count_at(&mount_root)?,
|
||||
pncc_receipt_count: projection_event_count_at(&projection_root)?,
|
||||
update_state: release_trust_state()?,
|
||||
release_recovery_state: crate::release_update::release_recovery_state(&app)?,
|
||||
automatic_upstream_updates: false,
|
||||
mcp_role: "DISCOVERY_RECOVERY_COMPATIBILITY_ONLY",
|
||||
})
|
||||
}
|
||||
|
|
@ -1,16 +1,50 @@
|
|||
mod direct_local_broker;
|
||||
mod direct_local_session;
|
||||
mod dynamic_capability_routing;
|
||||
mod home_status;
|
||||
mod local_development_bridge;
|
||||
mod pncc_receipt_projection;
|
||||
mod pncc_remote_git;
|
||||
mod pncc_repository_binding;
|
||||
mod release_trust;
|
||||
mod release_update;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
pub fn run_connector() -> Result<(), String> {
|
||||
direct_local_broker::run_connector()
|
||||
}
|
||||
|
||||
#[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,
|
||||
release_update::check_hololake_update,
|
||||
release_update::confirm_hololake_update_install,
|
||||
release_update::get_hololake_release_recovery_status,
|
||||
release_update::confirm_hololake_release_health,
|
||||
release_update::rollback_hololake_update,
|
||||
direct_local_session::issue_direct_local_discovery_ticket,
|
||||
direct_local_session::open_direct_local_session,
|
||||
direct_local_session::resume_direct_local_session,
|
||||
direct_local_session::append_direct_local_session_event,
|
||||
local_development_bridge::acquire_development_write_lane,
|
||||
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| {
|
||||
let broker = direct_local_broker::start(app.handle())?;
|
||||
app.manage(broker);
|
||||
release_trust::install_updater_if_provisioned(app.handle())?;
|
||||
if let Err(error) = release_update::observe_release_startup(app.handle()) {
|
||||
eprintln!("HoloLake update recovery requires maintenance: {error}");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
if std::env::args().any(|argument| argument == "--connector") {
|
||||
if let Err(error) = hololake_native_desktop_lib::run_connector() {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
hololake_native_desktop_lib::run();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,481 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::pncc_remote_git::PnccRemoteObjectReceipt;
|
||||
use crate::pncc_repository_binding::PnccRepositoryMountReceipt;
|
||||
use fs2::FileExt;
|
||||
use ring::digest::{digest, SHA256};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
const JOURNAL_SCHEMA: &str = "hololake.pncc-stage-one-receipt-projection-journal/v1";
|
||||
const EVENT_SCHEMA: &str = "hololake.pncc-stage-one-receipt-projection-event/v1";
|
||||
const MAX_EVENTS: usize = 4096;
|
||||
const MAX_QUERY_LIMIT: usize = 100;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct QueryPnccReceiptProjectionInput {
|
||||
pub after_sequence: Option<u64>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ProjectionJournal {
|
||||
schema: String,
|
||||
events: Vec<PnccReceiptProjectionEvent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PnccReceiptProjectionEvent {
|
||||
pub schema: String,
|
||||
pub sequence: u64,
|
||||
pub kind: String,
|
||||
pub mount_id: String,
|
||||
pub persona_id: Option<String>,
|
||||
pub human_responsibility_subject: Option<String>,
|
||||
pub git_head: String,
|
||||
pub relative_path: Option<String>,
|
||||
pub object_sha256: String,
|
||||
pub source_receipt_id: String,
|
||||
pub observed_at_unix_ms: u128,
|
||||
pub previous_event_hash: String,
|
||||
pub event_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PnccReceiptProjectionReceipt {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub authority_store: bool,
|
||||
pub write_authority: bool,
|
||||
pub model_instance_fields_present: bool,
|
||||
pub empty_means_offline: bool,
|
||||
pub after_sequence: u64,
|
||||
pub returned_event_count: usize,
|
||||
pub last_sequence: u64,
|
||||
pub has_more: bool,
|
||||
pub events: Vec<PnccReceiptProjectionEvent>,
|
||||
pub receipt_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ProjectionInput {
|
||||
kind: &'static str,
|
||||
mount_id: String,
|
||||
persona_id: Option<String>,
|
||||
human_responsibility_subject: Option<String>,
|
||||
git_head: String,
|
||||
relative_path: Option<String>,
|
||||
object_sha256: String,
|
||||
source_receipt_id: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn query_pncc_receipt_projection(
|
||||
app: AppHandle,
|
||||
input: QueryPnccReceiptProjectionInput,
|
||||
) -> Result<PnccReceiptProjectionReceipt, String> {
|
||||
let root = pncc_projection_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || query_at(&root, input))
|
||||
.await
|
||||
.map_err(|error| format!("PNCC_PROJECTION_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
pub(crate) fn pncc_projection_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("receipt-projection");
|
||||
fs::create_dir_all(&root)
|
||||
.map_err(|error| format!("PNCC_PROJECTION_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
root.canonicalize()
|
||||
.map_err(|error| format!("PNCC_PROJECTION_STORAGE_UNAVAILABLE: {error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn projection_event_count_at(root: &Path) -> Result<usize, String> {
|
||||
let _lock = lock_projection(root)?;
|
||||
Ok(load_and_verify(root)?.events.len())
|
||||
}
|
||||
|
||||
pub(crate) fn append_repository_binding_at(
|
||||
root: &Path,
|
||||
receipt: &PnccRepositoryMountReceipt,
|
||||
) -> Result<PnccReceiptProjectionEvent, String> {
|
||||
append_at(
|
||||
root,
|
||||
ProjectionInput {
|
||||
kind: "REPOSITORY_BINDING_REVALIDATED",
|
||||
mount_id: receipt.mount_id.clone(),
|
||||
persona_id: Some(receipt.binding.persona_id.clone()),
|
||||
human_responsibility_subject: Some(
|
||||
receipt.binding.human_responsibility_subject.clone(),
|
||||
),
|
||||
git_head: receipt.binding.git_head.clone(),
|
||||
relative_path: Some(receipt.binding.manifest.relative_path.clone()),
|
||||
object_sha256: receipt.binding.manifest.sha256.clone(),
|
||||
source_receipt_id: receipt.binding.receipt_id.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn append_remote_read_at(
|
||||
root: &Path,
|
||||
receipt: &PnccRemoteObjectReceipt,
|
||||
) -> Result<PnccReceiptProjectionEvent, String> {
|
||||
append_at(
|
||||
root,
|
||||
ProjectionInput {
|
||||
kind: "REMOTE_OBJECT_READ_VERIFIED",
|
||||
mount_id: receipt.mount_id.clone(),
|
||||
persona_id: None,
|
||||
human_responsibility_subject: None,
|
||||
git_head: receipt.remote_head.clone(),
|
||||
relative_path: Some(receipt.relative_path.clone()),
|
||||
object_sha256: receipt.content_sha256.clone(),
|
||||
source_receipt_id: receipt.receipt_id.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn append_at(root: &Path, input: ProjectionInput) -> Result<PnccReceiptProjectionEvent, String> {
|
||||
let _lock = lock_projection(root)?;
|
||||
let mut journal = load_and_verify(root)?;
|
||||
if let Some(existing) = journal
|
||||
.events
|
||||
.iter()
|
||||
.find(|event| event.source_receipt_id == input.source_receipt_id)
|
||||
{
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
if journal.events.len() >= MAX_EVENTS {
|
||||
return Err("PNCC_PROJECTION_CAPACITY_REACHED".into());
|
||||
}
|
||||
validate_sha256(&input.object_sha256)?;
|
||||
validate_sha256(&input.source_receipt_id)?;
|
||||
validate_head(&input.git_head)?;
|
||||
let sequence = journal.events.len() as u64 + 1;
|
||||
let observed_at_unix_ms = now_unix_ms()?;
|
||||
let previous_event_hash = journal
|
||||
.events
|
||||
.last()
|
||||
.map(|event| event.event_hash.clone())
|
||||
.unwrap_or_else(|| "0".repeat(64));
|
||||
let event_hash = hash_event(
|
||||
sequence,
|
||||
input.kind,
|
||||
&input.mount_id,
|
||||
input.persona_id.as_deref(),
|
||||
input.human_responsibility_subject.as_deref(),
|
||||
&input.git_head,
|
||||
input.relative_path.as_deref(),
|
||||
&input.object_sha256,
|
||||
&input.source_receipt_id,
|
||||
observed_at_unix_ms,
|
||||
&previous_event_hash,
|
||||
);
|
||||
let event = PnccReceiptProjectionEvent {
|
||||
schema: EVENT_SCHEMA.into(),
|
||||
sequence,
|
||||
kind: input.kind.into(),
|
||||
mount_id: input.mount_id,
|
||||
persona_id: input.persona_id,
|
||||
human_responsibility_subject: input.human_responsibility_subject,
|
||||
git_head: input.git_head,
|
||||
relative_path: input.relative_path,
|
||||
object_sha256: input.object_sha256,
|
||||
source_receipt_id: input.source_receipt_id,
|
||||
observed_at_unix_ms,
|
||||
previous_event_hash,
|
||||
event_hash,
|
||||
};
|
||||
journal.events.push(event.clone());
|
||||
write_journal(root, &journal)?;
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
pub(crate) fn query_at(
|
||||
root: &Path,
|
||||
input: QueryPnccReceiptProjectionInput,
|
||||
) -> Result<PnccReceiptProjectionReceipt, String> {
|
||||
let _lock = lock_projection(root)?;
|
||||
let journal = load_and_verify(root)?;
|
||||
let after_sequence = input.after_sequence.unwrap_or(0);
|
||||
let limit = input.limit.unwrap_or(25);
|
||||
if limit == 0 || limit > MAX_QUERY_LIMIT {
|
||||
return Err("PNCC_PROJECTION_QUERY_LIMIT_INVALID".into());
|
||||
}
|
||||
let last_sequence = journal.events.len() as u64;
|
||||
if after_sequence > last_sequence {
|
||||
return Err("PNCC_PROJECTION_CURSOR_INVALID".into());
|
||||
}
|
||||
let mut available = journal
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.sequence > after_sequence);
|
||||
let events = available.by_ref().take(limit).cloned().collect::<Vec<_>>();
|
||||
let has_more = available.next().is_some();
|
||||
let receipt_id = sha256_hex(
|
||||
format!(
|
||||
"{}\n{}\n{}\n{}",
|
||||
after_sequence,
|
||||
events.len(),
|
||||
last_sequence,
|
||||
events
|
||||
.last()
|
||||
.map(|event| event.event_hash.as_str())
|
||||
.unwrap_or("EMPTY")
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
Ok(PnccReceiptProjectionReceipt {
|
||||
schema: "hololake.pncc-stage-one-receipt-projection/v1",
|
||||
state: "VERIFIED_READ_ONLY_PROJECTION",
|
||||
authority_store: false,
|
||||
write_authority: false,
|
||||
model_instance_fields_present: false,
|
||||
empty_means_offline: false,
|
||||
after_sequence,
|
||||
returned_event_count: events.len(),
|
||||
last_sequence,
|
||||
has_more,
|
||||
events,
|
||||
receipt_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_and_verify(root: &Path) -> Result<ProjectionJournal, String> {
|
||||
let path = journal_path(root);
|
||||
if !path.exists() {
|
||||
return Ok(ProjectionJournal {
|
||||
schema: JOURNAL_SCHEMA.into(),
|
||||
events: Vec::new(),
|
||||
});
|
||||
}
|
||||
let metadata = fs::symlink_metadata(&path)
|
||||
.map_err(|error| format!("PNCC_PROJECTION_READ_FAILED: {error}"))?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err("PNCC_PROJECTION_JOURNAL_INVALID".into());
|
||||
}
|
||||
let journal: ProjectionJournal = serde_json::from_slice(
|
||||
&fs::read(&path).map_err(|error| format!("PNCC_PROJECTION_READ_FAILED: {error}"))?,
|
||||
)
|
||||
.map_err(|error| format!("PNCC_PROJECTION_JOURNAL_INVALID: {error}"))?;
|
||||
if journal.schema != JOURNAL_SCHEMA || journal.events.len() > MAX_EVENTS {
|
||||
return Err("PNCC_PROJECTION_JOURNAL_INVALID".into());
|
||||
}
|
||||
let mut previous = "0".repeat(64);
|
||||
for (index, event) in journal.events.iter().enumerate() {
|
||||
if event.schema != EVENT_SCHEMA
|
||||
|| event.sequence != index as u64 + 1
|
||||
|| event.previous_event_hash != previous
|
||||
|| event.event_hash
|
||||
!= hash_event(
|
||||
event.sequence,
|
||||
&event.kind,
|
||||
&event.mount_id,
|
||||
event.persona_id.as_deref(),
|
||||
event.human_responsibility_subject.as_deref(),
|
||||
&event.git_head,
|
||||
event.relative_path.as_deref(),
|
||||
&event.object_sha256,
|
||||
&event.source_receipt_id,
|
||||
event.observed_at_unix_ms,
|
||||
&event.previous_event_hash,
|
||||
)
|
||||
{
|
||||
return Err("PNCC_PROJECTION_EVENT_CHAIN_INVALID".into());
|
||||
}
|
||||
previous = event.event_hash.clone();
|
||||
}
|
||||
Ok(journal)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn hash_event(
|
||||
sequence: u64,
|
||||
kind: &str,
|
||||
mount_id: &str,
|
||||
persona_id: Option<&str>,
|
||||
human: Option<&str>,
|
||||
git_head: &str,
|
||||
relative_path: Option<&str>,
|
||||
object_sha256: &str,
|
||||
source_receipt_id: &str,
|
||||
observed_at_unix_ms: u128,
|
||||
previous: &str,
|
||||
) -> String {
|
||||
sha256_hex(
|
||||
format!(
|
||||
"{sequence}\n{kind}\n{mount_id}\n{}\n{}\n{git_head}\n{}\n{object_sha256}\n{source_receipt_id}\n{observed_at_unix_ms}\n{previous}",
|
||||
persona_id.unwrap_or(""),
|
||||
human.unwrap_or(""),
|
||||
relative_path.unwrap_or("")
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
}
|
||||
|
||||
fn lock_projection(root: &Path) -> Result<std::fs::File, String> {
|
||||
fs::create_dir_all(root)
|
||||
.map_err(|error| format!("PNCC_PROJECTION_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(root.join("projection.lock"))
|
||||
.map_err(|error| format!("PNCC_PROJECTION_LOCK_FAILED: {error}"))?;
|
||||
file.lock_exclusive()
|
||||
.map_err(|error| format!("PNCC_PROJECTION_LOCK_FAILED: {error}"))?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn write_journal(root: &Path, journal: &ProjectionJournal) -> Result<(), String> {
|
||||
let temporary = root.join(format!(".journal-{}.tmp", Uuid::new_v4()));
|
||||
let bytes = serde_json::to_vec_pretty(journal)
|
||||
.map_err(|error| format!("PNCC_PROJECTION_JOURNAL_INVALID: {error}"))?;
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temporary)
|
||||
.map_err(|error| format!("PNCC_PROJECTION_WRITE_FAILED: {error}"))?;
|
||||
file.write_all(&bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| format!("PNCC_PROJECTION_WRITE_FAILED: {error}"))?;
|
||||
fs::rename(&temporary, journal_path(root))
|
||||
.map_err(|error| format!("PNCC_PROJECTION_WRITE_FAILED: {error}"))
|
||||
}
|
||||
|
||||
fn journal_path(root: &Path) -> PathBuf {
|
||||
root.join("journal.json")
|
||||
}
|
||||
|
||||
fn validate_sha256(value: &str) -> Result<(), String> {
|
||||
if value.len() == 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err("PNCC_PROJECTION_SHA256_INVALID".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_head(value: &str) -> Result<(), String> {
|
||||
if value.len() == 40
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err("PNCC_PROJECTION_GIT_HEAD_INVALID".into())
|
||||
}
|
||||
}
|
||||
|
||||
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 sha256_hex(bytes: &[u8]) -> String {
|
||||
digest(&SHA256, bytes)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pncc_remote_git::PnccRemoteObjectReceipt;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn remote_receipt(id: char, head: char) -> PnccRemoteObjectReceipt {
|
||||
PnccRemoteObjectReceipt {
|
||||
schema: "hololake.pncc-stage-one-remote-object-read/v1",
|
||||
mount_id: "persona-primary".into(),
|
||||
remote_identity_hash: "a".repeat(64),
|
||||
remote_head: head.to_string().repeat(40),
|
||||
previous_verified_head: None,
|
||||
cursor_advanced: true,
|
||||
continuity_verified: true,
|
||||
fetched_incremental_objects: true,
|
||||
cache_rehydrated: false,
|
||||
relative_path: "brain/CORE.hldp".into(),
|
||||
git_object_id: "b".repeat(40),
|
||||
content: "content".into(),
|
||||
content_sha256: "c".repeat(64),
|
||||
cache_bytes: 1,
|
||||
cache_limit_bytes: 1024,
|
||||
cache_state: "BOUNDED_PARTIAL_OBJECT_CACHE",
|
||||
worktree_created: false,
|
||||
full_history_requested: false,
|
||||
model_inference_started: false,
|
||||
reality_execution_allowed: false,
|
||||
receipt_id: id.to_string().repeat(64),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_is_hash_chained_bounded_and_never_an_authority_store() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
append_remote_read_at(temp.path(), &remote_receipt('d', '1')).unwrap();
|
||||
append_remote_read_at(temp.path(), &remote_receipt('e', '2')).unwrap();
|
||||
let projection = query_at(
|
||||
temp.path(),
|
||||
QueryPnccReceiptProjectionInput {
|
||||
after_sequence: Some(0),
|
||||
limit: Some(1),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!projection.authority_store);
|
||||
assert!(!projection.write_authority);
|
||||
assert!(!projection.model_instance_fields_present);
|
||||
assert!(!projection.empty_means_offline);
|
||||
assert_eq!(projection.returned_event_count, 1);
|
||||
assert!(projection.has_more);
|
||||
let source = serde_json::to_string(&projection.events).unwrap();
|
||||
assert!(!source.to_ascii_lowercase().contains("modelinstance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_retry_is_idempotent_and_tampering_is_rejected() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let receipt = remote_receipt('d', '1');
|
||||
let first = append_remote_read_at(temp.path(), &receipt).unwrap();
|
||||
let duplicate = append_remote_read_at(temp.path(), &receipt).unwrap();
|
||||
assert_eq!(first.event_hash, duplicate.event_hash);
|
||||
let path = journal_path(temp.path());
|
||||
let mut journal: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
journal["events"][0]["kind"] = serde_json::json!("FORGED");
|
||||
fs::write(&path, serde_json::to_vec_pretty(&journal).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
query_at(
|
||||
temp.path(),
|
||||
QueryPnccReceiptProjectionInput {
|
||||
after_sequence: None,
|
||||
limit: None,
|
||||
}
|
||||
)
|
||||
.unwrap_err(),
|
||||
"PNCC_PROJECTION_EVENT_CHAIN_INVALID"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,944 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 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::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use uuid::Uuid;
|
||||
|
||||
const REMOTE_MOUNT_SCHEMA: &str = "hololake.pncc-stage-one-remote-mount/v1";
|
||||
const CURSOR_SCHEMA: &str = "hololake.pncc-stage-one-remote-cursor/v1";
|
||||
#[cfg(test)]
|
||||
const DEFAULT_BRANCH: &str = "main";
|
||||
const DEFAULT_CACHE_LIMIT_BYTES: u64 = 128 * 1024 * 1024;
|
||||
const MIN_CACHE_LIMIT_BYTES: u64 = 1024 * 1024;
|
||||
const MAX_CACHE_LIMIT_BYTES: u64 = 4 * 1024 * 1024 * 1024;
|
||||
const MAX_TEXT_OBJECT_BYTES: usize = 2 * 1024 * 1024;
|
||||
const MAX_CONTINUITY_DEPTH: usize = 4096;
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct RegisterPnccRemoteMountInput {
|
||||
pub mount_id: String,
|
||||
pub remote_url: String,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ReadMountedPnccRemoteObjectInput {
|
||||
pub mount_id: String,
|
||||
pub relative_path: String,
|
||||
pub expected_head: Option<String>,
|
||||
pub max_cache_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PnccRemoteObjectReceipt {
|
||||
pub schema: &'static str,
|
||||
pub mount_id: String,
|
||||
pub remote_identity_hash: String,
|
||||
pub remote_head: String,
|
||||
pub previous_verified_head: Option<String>,
|
||||
pub cursor_advanced: bool,
|
||||
pub continuity_verified: bool,
|
||||
pub fetched_incremental_objects: bool,
|
||||
pub cache_rehydrated: bool,
|
||||
pub relative_path: String,
|
||||
pub git_object_id: String,
|
||||
pub content: String,
|
||||
pub content_sha256: String,
|
||||
pub cache_bytes: u64,
|
||||
pub cache_limit_bytes: u64,
|
||||
pub cache_state: &'static str,
|
||||
pub worktree_created: bool,
|
||||
pub full_history_requested: bool,
|
||||
pub model_inference_started: bool,
|
||||
pub reality_execution_allowed: bool,
|
||||
pub receipt_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct RemoteMountRecord {
|
||||
schema: String,
|
||||
mount_id: String,
|
||||
remote_url: String,
|
||||
remote_identity_hash: String,
|
||||
branch: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct CursorRecord {
|
||||
schema: String,
|
||||
remote_identity_hash: String,
|
||||
branch: String,
|
||||
verified_head: String,
|
||||
previous_verified_head: Option<String>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn register_remote_mount_at(
|
||||
root: &Path,
|
||||
input: RegisterPnccRemoteMountInput,
|
||||
allow_file_remote: bool,
|
||||
) -> Result<(), String> {
|
||||
validate_machine_id(&input.mount_id, "REMOTE_MOUNT_ID")?;
|
||||
validate_remote_url(&input.remote_url, allow_file_remote)?;
|
||||
let branch = input.branch.unwrap_or_else(|| DEFAULT_BRANCH.into());
|
||||
validate_branch(&branch)?;
|
||||
let identity = remote_identity(&input.remote_url, &branch);
|
||||
let record = RemoteMountRecord {
|
||||
schema: REMOTE_MOUNT_SCHEMA.into(),
|
||||
mount_id: input.mount_id.clone(),
|
||||
remote_url: input.remote_url,
|
||||
remote_identity_hash: identity.clone(),
|
||||
branch: branch.clone(),
|
||||
};
|
||||
let mounts = child_root(root, "mounts")?;
|
||||
write_json_atomic(&mounts.join(format!("{}.json", input.mount_id)), &record)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn read_mounted_remote_object_at(
|
||||
root: &Path,
|
||||
input: ReadMountedPnccRemoteObjectInput,
|
||||
) -> Result<PnccRemoteObjectReceipt, String> {
|
||||
read_mounted_remote_object_with_file_policy(root, input, false)
|
||||
}
|
||||
|
||||
fn read_mounted_remote_object_with_file_policy(
|
||||
root: &Path,
|
||||
input: ReadMountedPnccRemoteObjectInput,
|
||||
allow_file_remote: bool,
|
||||
) -> Result<PnccRemoteObjectReceipt, String> {
|
||||
validate_machine_id(&input.mount_id, "REMOTE_MOUNT_ID")?;
|
||||
validate_relative_path(&input.relative_path)?;
|
||||
let maximum = input.max_cache_bytes.unwrap_or(DEFAULT_CACHE_LIMIT_BYTES);
|
||||
if !(MIN_CACHE_LIMIT_BYTES..=MAX_CACHE_LIMIT_BYTES).contains(&maximum) {
|
||||
return Err("PNCC_REMOTE_CACHE_LIMIT_INVALID".into());
|
||||
}
|
||||
let expected_head = input
|
||||
.expected_head
|
||||
.map(|head| validate_head(&head))
|
||||
.transpose()?;
|
||||
let mounts = child_root(root, "mounts")?;
|
||||
let record: RemoteMountRecord = read_json(&mounts.join(format!("{}.json", input.mount_id)))?;
|
||||
if record.schema != REMOTE_MOUNT_SCHEMA || record.mount_id != input.mount_id {
|
||||
return Err("PNCC_REMOTE_MOUNT_RECORD_INVALID".into());
|
||||
}
|
||||
validate_remote_url(&record.remote_url, allow_file_remote)?;
|
||||
validate_branch(&record.branch)?;
|
||||
let identity = remote_identity(&record.remote_url, &record.branch);
|
||||
if identity != record.remote_identity_hash {
|
||||
return Err("PNCC_REMOTE_MOUNT_IDENTITY_MISMATCH".into());
|
||||
}
|
||||
|
||||
let locks = child_root(root, "locks")?;
|
||||
let _lock = lock_file(&locks.join(format!("{identity}.lock")))?;
|
||||
let remote_head = read_remote_head(&record.remote_url, &record.branch)?;
|
||||
if expected_head
|
||||
.as_deref()
|
||||
.is_some_and(|head| head != remote_head)
|
||||
{
|
||||
return Err("PNCC_REMOTE_HEAD_MISMATCH".into());
|
||||
}
|
||||
|
||||
let object_root = child_root(root, "objects")?;
|
||||
let cursor_root = child_root(root, "continuity")?;
|
||||
let cache_path = object_root.join(format!("{identity}.git"));
|
||||
let cursor_path = cursor_root.join(format!("{identity}.json"));
|
||||
let cursor = read_cursor(&cursor_path, &identity, &record.branch)?;
|
||||
let previous = cursor.as_ref().map(|value| value.verified_head.clone());
|
||||
|
||||
ensure_bare_cache(&cache_path, &record.remote_url)?;
|
||||
let remote_ref = format!("refs/remotes/origin/{}", record.branch);
|
||||
let cached = optional_ref(&cache_path, &remote_ref)?;
|
||||
match (previous.as_deref(), cached.as_deref()) {
|
||||
(None, Some(_)) => return Err("PNCC_REMOTE_CACHE_WITHOUT_CONTINUITY_CURSOR".into()),
|
||||
(Some(expected), Some(observed)) if expected != observed => {
|
||||
return Err("PNCC_REMOTE_CACHE_CURSOR_MISMATCH".into())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let fetched_incremental_objects = cached.as_deref() != Some(remote_head.as_str());
|
||||
let cache_rehydrated = cached.is_none() && previous.is_some();
|
||||
if fetched_incremental_objects {
|
||||
if let Err(error) = fetch_continuity(
|
||||
&cache_path,
|
||||
&record.branch,
|
||||
previous.as_deref(),
|
||||
cached.is_some(),
|
||||
) {
|
||||
if cache_path.exists() {
|
||||
remove_exact_cache(&object_root, &cache_path)?;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let fetched_head = required_ref(&cache_path, &remote_ref)?;
|
||||
if fetched_head != remote_head {
|
||||
return Err("PNCC_REMOTE_FETCH_HEAD_MISMATCH".into());
|
||||
}
|
||||
if let Some(previous_head) = previous.as_deref() {
|
||||
if previous_head != remote_head && !is_ancestor(&cache_path, previous_head, &remote_head)? {
|
||||
remove_exact_cache(&object_root, &cache_path)?;
|
||||
return Err("PNCC_REMOTE_HISTORY_REWRITE_REJECTED".into());
|
||||
}
|
||||
}
|
||||
|
||||
let (object_id, content_bytes) = read_regular_blob(
|
||||
&cache_path,
|
||||
&remote_head,
|
||||
&input.relative_path,
|
||||
MAX_TEXT_OBJECT_BYTES,
|
||||
)?;
|
||||
let content =
|
||||
String::from_utf8(content_bytes).map_err(|_| "PNCC_REMOTE_OBJECT_NOT_UTF8".to_string())?;
|
||||
let content_sha256 = sha256_hex(content.as_bytes());
|
||||
let cursor_advanced = previous.as_deref() != Some(remote_head.as_str());
|
||||
let next = CursorRecord {
|
||||
schema: CURSOR_SCHEMA.into(),
|
||||
remote_identity_hash: identity.clone(),
|
||||
branch: record.branch,
|
||||
verified_head: remote_head.clone(),
|
||||
previous_verified_head: previous.clone(),
|
||||
generation: cursor
|
||||
.as_ref()
|
||||
.map(|value| value.generation.saturating_add(u64::from(cursor_advanced)))
|
||||
.unwrap_or(1),
|
||||
};
|
||||
write_json_atomic(&cursor_path, &next)?;
|
||||
|
||||
let observed_cache_bytes = directory_size(&cache_path)?;
|
||||
let (cache_bytes, cache_state) = if observed_cache_bytes > maximum {
|
||||
remove_exact_cache(&object_root, &cache_path)?;
|
||||
(0, "EVICTED_AFTER_BOUNDED_READ")
|
||||
} else {
|
||||
(observed_cache_bytes, "BOUNDED_PARTIAL_OBJECT_CACHE")
|
||||
};
|
||||
let receipt_id = sha256_hex(
|
||||
format!(
|
||||
"{}\n{}\n{}\n{}\n{}",
|
||||
input.mount_id, remote_head, input.relative_path, object_id, content_sha256
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
Ok(PnccRemoteObjectReceipt {
|
||||
schema: "hololake.pncc-stage-one-remote-object-read/v1",
|
||||
mount_id: input.mount_id,
|
||||
remote_identity_hash: identity,
|
||||
remote_head,
|
||||
previous_verified_head: previous,
|
||||
cursor_advanced,
|
||||
continuity_verified: true,
|
||||
fetched_incremental_objects,
|
||||
cache_rehydrated,
|
||||
relative_path: input.relative_path,
|
||||
git_object_id: object_id,
|
||||
content,
|
||||
content_sha256,
|
||||
cache_bytes,
|
||||
cache_limit_bytes: maximum,
|
||||
cache_state,
|
||||
worktree_created: false,
|
||||
full_history_requested: false,
|
||||
model_inference_started: false,
|
||||
reality_execution_allowed: false,
|
||||
receipt_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_remote_url(remote_url: &str, allow_file_remote: bool) -> Result<(), String> {
|
||||
if remote_url.len() > 4096 || remote_url.contains(['\n', '\r', '\0']) {
|
||||
return Err("PNCC_REMOTE_URL_UNSUPPORTED".into());
|
||||
}
|
||||
if allow_file_remote && remote_url.starts_with("file://") {
|
||||
return Ok(());
|
||||
}
|
||||
let url =
|
||||
tauri::Url::parse(remote_url).map_err(|_| "PNCC_REMOTE_URL_UNSUPPORTED".to_string())?;
|
||||
if url.scheme() != "https"
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err("PNCC_REMOTE_URL_MUST_BE_CREDENTIAL_FREE_HTTPS".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_branch(branch: &str) -> Result<(), String> {
|
||||
if branch.is_empty() || branch.len() > 200 || branch.starts_with('-') {
|
||||
return Err("PNCC_REMOTE_BRANCH_INVALID".into());
|
||||
}
|
||||
let output = trusted_git()
|
||||
.args(["check-ref-format", "--branch", branch])
|
||||
.output()
|
||||
.map_err(|error| format!("PNCC_GIT_UNAVAILABLE: {error}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("PNCC_REMOTE_BRANCH_INVALID".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_relative_path(relative: &str) -> Result<(), String> {
|
||||
let path = Path::new(relative);
|
||||
if relative.is_empty()
|
||||
|| relative.len() > 1024
|
||||
|| path.is_absolute()
|
||||
|| path.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
})
|
||||
|| path
|
||||
.components()
|
||||
.any(|component| component.as_os_str() == ".git")
|
||||
{
|
||||
return Err("PNCC_REMOTE_PATH_INVALID".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_head(head: &str) -> Result<String, String> {
|
||||
if head.len() == 40
|
||||
&& head
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
Ok(head.into())
|
||||
} else {
|
||||
Err("PNCC_REMOTE_HEAD_INVALID".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_machine_id(value: &str, label: &str) -> Result<(), String> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 128
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
|
||||
{
|
||||
return Err(format!("PNCC_{label}_INVALID"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remote_identity(remote_url: &str, branch: &str) -> String {
|
||||
sha256_hex(format!("{remote_url}\n{branch}").as_bytes())
|
||||
}
|
||||
|
||||
fn read_remote_head(remote_url: &str, branch: &str) -> Result<String, String> {
|
||||
let full_ref = format!("refs/heads/{branch}");
|
||||
let output = trusted_git()
|
||||
.env("GIT_TRACE_PACKET", "1")
|
||||
.args([
|
||||
"-c",
|
||||
"protocol.version=2",
|
||||
"ls-remote",
|
||||
"--exit-code",
|
||||
"--heads",
|
||||
remote_url,
|
||||
&full_ref,
|
||||
])
|
||||
.output()
|
||||
.map_err(|error| format!("PNCC_REMOTE_HEAD_READ_FAILED: {error}"))?;
|
||||
let output = require_success(output, "PNCC_REMOTE_HEAD_READ")?;
|
||||
let trace = String::from_utf8_lossy(&output.stderr);
|
||||
if !trace.lines().any(|line| {
|
||||
line.split_whitespace().any(|word| word == "filter")
|
||||
&& line
|
||||
.split_whitespace()
|
||||
.any(|word| word.starts_with("fetch="))
|
||||
}) {
|
||||
return Err("PNCC_REMOTE_PARTIAL_OBJECT_PROTOCOL_REQUIRED".into());
|
||||
}
|
||||
let response = String::from_utf8(output.stdout)
|
||||
.map_err(|_| "PNCC_REMOTE_HEAD_RESPONSE_NOT_UTF8".to_string())?;
|
||||
let mut lines = response.lines();
|
||||
let first = lines
|
||||
.next()
|
||||
.ok_or_else(|| "PNCC_REMOTE_BRANCH_NOT_FOUND".to_string())?;
|
||||
if lines.next().is_some() {
|
||||
return Err("PNCC_REMOTE_HEAD_AMBIGUOUS".into());
|
||||
}
|
||||
let mut fields = first.split_whitespace();
|
||||
let head = fields.next().unwrap_or_default().to_ascii_lowercase();
|
||||
if fields.next() != Some(full_ref.as_str()) || fields.next().is_some() {
|
||||
return Err("PNCC_REMOTE_HEAD_RESPONSE_INVALID".into());
|
||||
}
|
||||
validate_head(&head)
|
||||
}
|
||||
|
||||
fn ensure_bare_cache(cache: &Path, remote_url: &str) -> Result<(), String> {
|
||||
if cache.exists() {
|
||||
if git_text(
|
||||
cache,
|
||||
&["rev-parse", "--is-bare-repository"],
|
||||
"PNCC_CACHE_PROBE",
|
||||
)?
|
||||
.trim()
|
||||
!= "true"
|
||||
{
|
||||
return Err("PNCC_REMOTE_CACHE_NOT_BARE".into());
|
||||
}
|
||||
if git_text(
|
||||
cache,
|
||||
&["remote", "get-url", "origin"],
|
||||
"PNCC_CACHE_ORIGIN_READ",
|
||||
)?
|
||||
.trim()
|
||||
!= remote_url
|
||||
{
|
||||
return Err("PNCC_REMOTE_CACHE_ORIGIN_MISMATCH".into());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
fs::create_dir(cache).map_err(|error| format!("PNCC_REMOTE_CACHE_CREATE_FAILED: {error}"))?;
|
||||
git_status(
|
||||
trusted_git().args(["init", "--bare", &cache.to_string_lossy()]),
|
||||
"PNCC_REMOTE_CACHE_INIT",
|
||||
)?;
|
||||
git_status(
|
||||
git_at(cache).args(["remote", "add", "origin", remote_url]),
|
||||
"PNCC_REMOTE_CACHE_ORIGIN_ADD",
|
||||
)?;
|
||||
git_status(
|
||||
git_at(cache).args(["config", "remote.origin.promisor", "true"]),
|
||||
"PNCC_REMOTE_CACHE_PROMISOR_CONFIG",
|
||||
)?;
|
||||
git_status(
|
||||
git_at(cache).args(["config", "remote.origin.partialclonefilter", "blob:none"]),
|
||||
"PNCC_REMOTE_CACHE_FILTER_CONFIG",
|
||||
)
|
||||
}
|
||||
|
||||
fn fetch_continuity(
|
||||
cache: &Path,
|
||||
branch: &str,
|
||||
previous: Option<&str>,
|
||||
cache_has_previous: bool,
|
||||
) -> Result<(), String> {
|
||||
let refspec = format!("+refs/heads/{branch}:refs/remotes/origin/{branch}");
|
||||
if previous.is_none() || !cache_has_previous {
|
||||
fetch_depth(cache, &refspec, 1)?;
|
||||
} else {
|
||||
fetch_incremental(cache, &refspec)?;
|
||||
}
|
||||
let Some(previous) = previous else {
|
||||
return Ok(());
|
||||
};
|
||||
let current = required_ref(cache, &format!("refs/remotes/origin/{branch}"))?;
|
||||
if previous == current || is_ancestor(cache, previous, ¤t)? {
|
||||
return Ok(());
|
||||
}
|
||||
if cache_has_previous {
|
||||
return Err("PNCC_REMOTE_HISTORY_REWRITE_REJECTED".into());
|
||||
}
|
||||
let mut depth = 2;
|
||||
while depth <= MAX_CONTINUITY_DEPTH {
|
||||
fetch_depth(cache, &refspec, depth)?;
|
||||
if is_ancestor(cache, previous, ¤t)? {
|
||||
return Ok(());
|
||||
}
|
||||
depth = depth.saturating_mul(2);
|
||||
}
|
||||
Err("PNCC_REMOTE_CONTINUITY_WINDOW_EXCEEDED".into())
|
||||
}
|
||||
|
||||
fn fetch_depth(cache: &Path, refspec: &str, depth: usize) -> Result<(), String> {
|
||||
let depth = format!("--depth={depth}");
|
||||
let output = git_at(cache)
|
||||
.args([
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
&depth,
|
||||
"--filter=blob:none",
|
||||
"origin",
|
||||
refspec,
|
||||
])
|
||||
.output()
|
||||
.map_err(|error| format!("PNCC_REMOTE_INCREMENTAL_FETCH_FAILED: {error}"))?;
|
||||
require_partial_fetch(output)
|
||||
}
|
||||
|
||||
fn fetch_incremental(cache: &Path, refspec: &str) -> Result<(), String> {
|
||||
let output = git_at(cache)
|
||||
.args([
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--update-shallow",
|
||||
"--filter=blob:none",
|
||||
"origin",
|
||||
refspec,
|
||||
])
|
||||
.output()
|
||||
.map_err(|error| format!("PNCC_REMOTE_INCREMENTAL_FETCH_FAILED: {error}"))?;
|
||||
require_partial_fetch(output)
|
||||
}
|
||||
|
||||
fn require_partial_fetch(output: Output) -> Result<(), String> {
|
||||
let output = require_success(output, "PNCC_REMOTE_INCREMENTAL_FETCH")?;
|
||||
let diagnostic = String::from_utf8_lossy(&output.stderr);
|
||||
if diagnostic.contains("filtering not recognized") || diagnostic.contains("filter-spec") {
|
||||
Err("PNCC_REMOTE_PARTIAL_OBJECT_PROTOCOL_REQUIRED".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn read_regular_blob(
|
||||
cache: &Path,
|
||||
head: &str,
|
||||
relative: &str,
|
||||
maximum: usize,
|
||||
) -> Result<(String, Vec<u8>), String> {
|
||||
validate_head(head)?;
|
||||
validate_relative_path(relative)?;
|
||||
let tree = git_text(
|
||||
cache,
|
||||
&["ls-tree", head, "--", relative],
|
||||
"PNCC_REMOTE_OBJECT_MODE_READ",
|
||||
)?;
|
||||
let mut fields = tree.split_whitespace();
|
||||
let mode = fields.next().unwrap_or_default();
|
||||
let kind = fields.next().unwrap_or_default();
|
||||
let object_id = fields.next().unwrap_or_default().to_ascii_lowercase();
|
||||
if !matches!(mode, "100644" | "100755") || kind != "blob" {
|
||||
return Err("PNCC_REMOTE_OBJECT_NOT_REGULAR_FILE".into());
|
||||
}
|
||||
validate_head(&object_id)?;
|
||||
let bytes = git_bytes(
|
||||
cache,
|
||||
&["cat-file", "blob", &object_id],
|
||||
"PNCC_REMOTE_OBJECT_READ",
|
||||
)?;
|
||||
if bytes.is_empty() || bytes.len() > maximum {
|
||||
return Err("PNCC_REMOTE_OBJECT_SIZE_INVALID".into());
|
||||
}
|
||||
Ok((object_id, bytes))
|
||||
}
|
||||
|
||||
fn is_ancestor(cache: &Path, ancestor: &str, descendant: &str) -> Result<bool, String> {
|
||||
validate_head(ancestor)?;
|
||||
validate_head(descendant)?;
|
||||
let output = git_at(cache)
|
||||
.args(["merge-base", "--is-ancestor", ancestor, descendant])
|
||||
.output()
|
||||
.map_err(|error| format!("PNCC_REMOTE_CONTINUITY_CHECK_FAILED: {error}"))?;
|
||||
match output.status.code() {
|
||||
Some(0) => Ok(true),
|
||||
Some(1) => Ok(false),
|
||||
_ => Err(format!(
|
||||
"PNCC_REMOTE_CONTINUITY_CHECK_FAILED: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_ref(cache: &Path, reference: &str) -> Result<Option<String>, String> {
|
||||
let output = git_at(cache)
|
||||
.args(["rev-parse", "--verify", reference])
|
||||
.output()
|
||||
.map_err(|error| format!("PNCC_REMOTE_CURSOR_READ_FAILED: {error}"))?;
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
let value = String::from_utf8(output.stdout)
|
||||
.map_err(|_| "PNCC_REMOTE_CURSOR_NOT_UTF8".to_string())?
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
validate_head(&value).map(Some)
|
||||
}
|
||||
|
||||
fn required_ref(cache: &Path, reference: &str) -> Result<String, String> {
|
||||
optional_ref(cache, reference)?.ok_or_else(|| "PNCC_REMOTE_FETCH_HEAD_MISSING".into())
|
||||
}
|
||||
|
||||
fn read_cursor(path: &Path, identity: &str, branch: &str) -> Result<Option<CursorRecord>, String> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let record: CursorRecord = read_json(path)?;
|
||||
if record.schema != CURSOR_SCHEMA
|
||||
|| record.remote_identity_hash != identity
|
||||
|| record.branch != branch
|
||||
|| record.generation == 0
|
||||
{
|
||||
return Err("PNCC_REMOTE_CURSOR_RECORD_INVALID".into());
|
||||
}
|
||||
validate_head(&record.verified_head)?;
|
||||
if let Some(previous) = record.previous_verified_head.as_deref() {
|
||||
validate_head(previous)?;
|
||||
}
|
||||
Ok(Some(record))
|
||||
}
|
||||
|
||||
fn child_root(root: &Path, child: &str) -> Result<PathBuf, String> {
|
||||
let canonical_root = root
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("PNCC_REMOTE_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
let path = root.join(child);
|
||||
fs::create_dir_all(&path)
|
||||
.map_err(|error| format!("PNCC_REMOTE_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
let canonical = path
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("PNCC_REMOTE_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
if canonical.parent() != Some(canonical_root.as_path()) || !canonical.is_dir() {
|
||||
return Err("PNCC_REMOTE_STORAGE_BOUNDARY_INVALID".into());
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn lock_file(path: &Path) -> Result<std::fs::File, String> {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path)
|
||||
.map_err(|error| format!("PNCC_REMOTE_LOCK_FAILED: {error}"))?;
|
||||
file.lock_exclusive()
|
||||
.map_err(|error| format!("PNCC_REMOTE_LOCK_FAILED: {error}"))?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or("PNCC_REMOTE_STORAGE_BOUNDARY_INVALID")?;
|
||||
let temporary = parent.join(format!(".pncc-{}.tmp", Uuid::new_v4()));
|
||||
let bytes = serde_json::to_vec_pretty(value)
|
||||
.map_err(|error| format!("PNCC_REMOTE_RECORD_INVALID: {error}"))?;
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temporary)
|
||||
.map_err(|error| format!("PNCC_REMOTE_RECORD_WRITE_FAILED: {error}"))?;
|
||||
file.write_all(&bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| format!("PNCC_REMOTE_RECORD_WRITE_FAILED: {error}"))?;
|
||||
fs::rename(&temporary, path)
|
||||
.map_err(|error| format!("PNCC_REMOTE_RECORD_WRITE_FAILED: {error}"))
|
||||
}
|
||||
|
||||
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
"PNCC_REMOTE_RECORD_NOT_FOUND".to_string()
|
||||
} else {
|
||||
format!("PNCC_REMOTE_RECORD_READ_FAILED: {error}")
|
||||
}
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err("PNCC_REMOTE_RECORD_INVALID".into());
|
||||
}
|
||||
serde_json::from_slice(
|
||||
&fs::read(path).map_err(|error| format!("PNCC_REMOTE_RECORD_READ_FAILED: {error}"))?,
|
||||
)
|
||||
.map_err(|error| format!("PNCC_REMOTE_RECORD_INVALID: {error}"))
|
||||
}
|
||||
|
||||
fn directory_size(root: &Path) -> Result<u64, String> {
|
||||
let mut total = 0_u64;
|
||||
let mut pending = vec![root.to_path_buf()];
|
||||
while let Some(path) = pending.pop() {
|
||||
let metadata = fs::symlink_metadata(&path)
|
||||
.map_err(|error| format!("PNCC_REMOTE_CACHE_MEASURE_FAILED: {error}"))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err("PNCC_REMOTE_CACHE_SYMLINK_REJECTED".into());
|
||||
}
|
||||
if metadata.is_file() {
|
||||
total = total.saturating_add(metadata.len());
|
||||
} else if metadata.is_dir() {
|
||||
for entry in fs::read_dir(&path)
|
||||
.map_err(|error| format!("PNCC_REMOTE_CACHE_MEASURE_FAILED: {error}"))?
|
||||
{
|
||||
pending.push(
|
||||
entry
|
||||
.map_err(|error| format!("PNCC_REMOTE_CACHE_MEASURE_FAILED: {error}"))?
|
||||
.path(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn remove_exact_cache(root: &Path, cache: &Path) -> Result<(), String> {
|
||||
if cache.parent() != Some(root) || cache == root || !cache.starts_with(root) {
|
||||
return Err("PNCC_REMOTE_CACHE_BOUNDARY_INVALID".into());
|
||||
}
|
||||
fs::remove_dir_all(cache).map_err(|error| format!("PNCC_REMOTE_CACHE_EVICTION_FAILED: {error}"))
|
||||
}
|
||||
|
||||
fn trusted_git() -> Command {
|
||||
let mut command = Command::new("/usr/bin/git");
|
||||
command
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GCM_INTERACTIVE", "Never")
|
||||
.args(["-c", "protocol.file.allow=never"]);
|
||||
if cfg!(test) {
|
||||
command.args(["-c", "protocol.file.allow=always"]);
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
fn git_at(path: &Path) -> Command {
|
||||
let mut command = trusted_git();
|
||||
command.arg("-C").arg(path);
|
||||
command
|
||||
}
|
||||
|
||||
fn git_status(command: &mut Command, label: &str) -> Result<(), String> {
|
||||
let output = command
|
||||
.output()
|
||||
.map_err(|error| format!("{label}_FAILED: {error}"))?;
|
||||
require_success(output, label).map(|_| ())
|
||||
}
|
||||
|
||||
fn git_text(path: &Path, args: &[&str], label: &str) -> Result<String, String> {
|
||||
let output = git_at(path)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|error| format!("{label}_FAILED: {error}"))?;
|
||||
String::from_utf8(require_success(output, label)?.stdout)
|
||||
.map_err(|_| format!("{label}_NOT_UTF8"))
|
||||
}
|
||||
|
||||
fn git_bytes(path: &Path, args: &[&str], label: &str) -> Result<Vec<u8>, String> {
|
||||
let output = git_at(path)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|error| format!("{label}_FAILED: {error}"))?;
|
||||
Ok(require_success(output, label)?.stdout)
|
||||
}
|
||||
|
||||
fn require_success(output: Output, label: &str) -> Result<Output, String> {
|
||||
if output.status.success() {
|
||||
Ok(output)
|
||||
} else {
|
||||
Err(format!(
|
||||
"{label}_FAILED: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
digest(&SHA256, bytes)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn fixture() -> (TempDir, PathBuf, PathBuf, String) {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let source = temp.path().join("source");
|
||||
let remote = temp.path().join("remote.git");
|
||||
let storage = temp.path().join("storage");
|
||||
fs::create_dir_all(source.join("brain")).unwrap();
|
||||
fs::create_dir(&storage).unwrap();
|
||||
test_git(&source, &["init", "-b", "main"]);
|
||||
fs::write(source.join("brain/CORE.hldp"), "first\n").unwrap();
|
||||
test_git(&source, &["add", "."]);
|
||||
test_git(
|
||||
&source,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=PNCC Test",
|
||||
"-c",
|
||||
"user.email=pncc@test.invalid",
|
||||
"commit",
|
||||
"-m",
|
||||
"first",
|
||||
],
|
||||
);
|
||||
let output = trusted_git()
|
||||
.args(["init", "--bare", &remote.to_string_lossy()])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success());
|
||||
test_git(&remote, &["config", "uploadpack.allowFilter", "true"]);
|
||||
test_git(
|
||||
&remote,
|
||||
&["config", "uploadpack.allowAnySHA1InWant", "true"],
|
||||
);
|
||||
test_git(
|
||||
&source,
|
||||
&["remote", "add", "origin", &remote.to_string_lossy()],
|
||||
);
|
||||
test_git(&source, &["push", "-u", "origin", "main"]);
|
||||
let remote_url = format!("file://{}", remote.display());
|
||||
register_remote_mount_at(
|
||||
&storage,
|
||||
RegisterPnccRemoteMountInput {
|
||||
mount_id: "persona-primary".into(),
|
||||
remote_url: remote_url.clone(),
|
||||
branch: None,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
(temp, source, storage, remote_url)
|
||||
}
|
||||
|
||||
fn test_git(path: &Path, args: &[&str]) -> String {
|
||||
let mut command = trusted_git();
|
||||
command
|
||||
.env("GIT_ALLOW_PROTOCOL", "file")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(args);
|
||||
let output = command.output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {args:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&output.stdout).trim().into()
|
||||
}
|
||||
|
||||
fn read_input() -> ReadMountedPnccRemoteObjectInput {
|
||||
ReadMountedPnccRemoteObjectInput {
|
||||
mount_id: "persona-primary".into(),
|
||||
relative_path: "brain/CORE.hldp".into(),
|
||||
expected_head: None,
|
||||
max_cache_bytes: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_incrementally_without_worktree_or_model_execution() {
|
||||
let (_temp, _source, storage, _url) = fixture();
|
||||
let first =
|
||||
read_mounted_remote_object_with_file_policy(&storage, read_input(), true).unwrap();
|
||||
assert_eq!(first.content, "first\n");
|
||||
assert!(first.cursor_advanced);
|
||||
assert!(first.continuity_verified);
|
||||
assert!(first.fetched_incremental_objects);
|
||||
assert!(!first.worktree_created);
|
||||
assert!(!first.full_history_requested);
|
||||
assert!(!first.model_inference_started);
|
||||
assert!(!first.reality_execution_allowed);
|
||||
let second =
|
||||
read_mounted_remote_object_with_file_policy(&storage, read_input(), true).unwrap();
|
||||
assert!(!second.cursor_advanced);
|
||||
assert!(!second.fetched_incremental_objects);
|
||||
assert_eq!(second.previous_verified_head, Some(first.remote_head));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_only_a_descendant_segment_and_rejects_rewritten_history() {
|
||||
let (_temp, source, storage, _url) = fixture();
|
||||
let first =
|
||||
read_mounted_remote_object_with_file_policy(&storage, read_input(), true).unwrap();
|
||||
fs::write(source.join("brain/CORE.hldp"), "second\n").unwrap();
|
||||
test_git(&source, &["add", "."]);
|
||||
test_git(
|
||||
&source,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=PNCC Test",
|
||||
"-c",
|
||||
"user.email=pncc@test.invalid",
|
||||
"commit",
|
||||
"-m",
|
||||
"second",
|
||||
],
|
||||
);
|
||||
test_git(&source, &["push", "origin", "main"]);
|
||||
let second =
|
||||
read_mounted_remote_object_with_file_policy(&storage, read_input(), true).unwrap();
|
||||
assert_eq!(second.previous_verified_head, Some(first.remote_head));
|
||||
assert_eq!(second.content, "second\n");
|
||||
|
||||
test_git(&source, &["checkout", "--orphan", "rewrite"]);
|
||||
test_git(&source, &["rm", "-rf", "."]);
|
||||
fs::create_dir_all(source.join("brain")).unwrap();
|
||||
fs::write(source.join("brain/CORE.hldp"), "rewrite\n").unwrap();
|
||||
test_git(&source, &["add", "."]);
|
||||
test_git(
|
||||
&source,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=PNCC Test",
|
||||
"-c",
|
||||
"user.email=pncc@test.invalid",
|
||||
"commit",
|
||||
"-m",
|
||||
"rewrite",
|
||||
],
|
||||
);
|
||||
test_git(&source, &["branch", "-M", "main"]);
|
||||
test_git(&source, &["push", "--force", "origin", "main"]);
|
||||
assert_eq!(
|
||||
read_mounted_remote_object_with_file_policy(&storage, read_input(), true).unwrap_err(),
|
||||
"PNCC_REMOTE_HISTORY_REWRITE_REJECTED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_registration_rejects_credentials_and_non_https_remotes() {
|
||||
assert_eq!(
|
||||
validate_remote_url("https://token@example.invalid/repo.git", false).unwrap_err(),
|
||||
"PNCC_REMOTE_URL_MUST_BE_CREDENTIAL_FREE_HTTPS"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_remote_url("http://example.invalid/repo.git", false).unwrap_err(),
|
||||
"PNCC_REMOTE_URL_MUST_BE_CREDENTIAL_FREE_HTTPS"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_relative_path("../brain.hldp").unwrap_err(),
|
||||
"PNCC_REMOTE_PATH_INVALID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_eviction_keeps_the_durable_continuity_cursor() {
|
||||
let (_temp, _source, storage, _url) = fixture();
|
||||
let mut input = read_input();
|
||||
input.max_cache_bytes = Some(MIN_CACHE_LIMIT_BYTES);
|
||||
let objects = child_root(&storage, "objects").unwrap();
|
||||
let first =
|
||||
read_mounted_remote_object_with_file_policy(&storage, input.clone(), true).unwrap();
|
||||
let cache = objects.join(format!("{}.git", first.remote_identity_hash));
|
||||
fs::write(
|
||||
cache.join("force-budget-overrun"),
|
||||
vec![0_u8; MIN_CACHE_LIMIT_BYTES as usize],
|
||||
)
|
||||
.unwrap();
|
||||
let second = read_mounted_remote_object_with_file_policy(&storage, input, true).unwrap();
|
||||
assert_eq!(second.cache_state, "EVICTED_AFTER_BOUNDED_READ");
|
||||
assert!(!cache.exists());
|
||||
assert!(storage
|
||||
.join("continuity")
|
||||
.join(format!("{}.json", second.remote_identity_hash))
|
||||
.is_file());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,7 @@ use tauri::{AppHandle, Runtime, Url};
|
|||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
const EMBEDDED_RELEASE_TRUST: &str = include_str!("../release-trust.json");
|
||||
const PUBLIC_RELEASE_ENDPOINT_PATH: &str = "/hololake/releases/latest.json";
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
|
|
@ -33,6 +34,31 @@ enum ValidatedReleaseTrust {
|
|||
},
|
||||
}
|
||||
|
||||
pub(crate) struct ProvisionedReleaseTrust {
|
||||
pub endpoints: Vec<Url>,
|
||||
pub allowed_release_host: String,
|
||||
}
|
||||
|
||||
pub(crate) fn provisioned_release_trust() -> Result<Option<ProvisionedReleaseTrust>, String> {
|
||||
match validate_release_trust(EMBEDDED_RELEASE_TRUST)? {
|
||||
ValidatedReleaseTrust::Disabled => Ok(None),
|
||||
ValidatedReleaseTrust::Enabled { endpoints, .. } => Ok(Some(ProvisionedReleaseTrust {
|
||||
allowed_release_host: endpoints[0]
|
||||
.host_str()
|
||||
.ok_or("the HoloLake release endpoint host is missing")?
|
||||
.to_owned(),
|
||||
endpoints,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn release_trust_state() -> Result<&'static str, String> {
|
||||
match validate_release_trust(EMBEDDED_RELEASE_TRUST)? {
|
||||
ValidatedReleaseTrust::Disabled => Ok("UNPROVISIONED_FAIL_CLOSED"),
|
||||
ValidatedReleaseTrust::Enabled { .. } => Ok("READY_HUMAN_CONFIRMATION_REQUIRED"),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_release_trust(raw: &str) -> Result<ValidatedReleaseTrust, String> {
|
||||
let trust: ReleaseTrust = serde_json::from_str(raw)
|
||||
.map_err(|error| format!("invalid HoloLake release trust document: {error}"))?;
|
||||
|
|
@ -83,6 +109,12 @@ fn validate_release_trust(raw: &str) -> Result<ValidatedReleaseTrust, String> {
|
|||
"the updater endpoint is not owned by the configured HoloLake host".into(),
|
||||
);
|
||||
}
|
||||
if endpoint.path() != PUBLIC_RELEASE_ENDPOINT_PATH
|
||||
|| endpoint.query().is_some()
|
||||
|| endpoint.fragment().is_some()
|
||||
{
|
||||
return Err("the HoloLake release endpoint path is not registered".into());
|
||||
}
|
||||
Ok(ValidatedReleaseTrust::Enabled {
|
||||
endpoints: vec![endpoint],
|
||||
public_key: trust.public_key,
|
||||
|
|
@ -104,7 +136,7 @@ pub fn install_updater_if_provisioned<R: Runtime>(
|
|||
.pubkey(public_key)
|
||||
.build();
|
||||
app.plugin(updater)?;
|
||||
app.updater_builder().endpoints(endpoints)?.build()?;
|
||||
let _ = app.updater_builder().endpoints(endpoints)?.build()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -141,7 +173,7 @@ mod tests {
|
|||
fn accepts_https_shape_but_rejects_insecure_endpoints() {
|
||||
let owned_shape = document(
|
||||
"PROVISIONED",
|
||||
"\"https://releases.example.test/latest.json\"",
|
||||
"\"https://releases.example.test/hololake/releases/latest.json\"",
|
||||
"public",
|
||||
"\"releases.example.test\"",
|
||||
);
|
||||
|
|
@ -151,18 +183,25 @@ mod tests {
|
|||
);
|
||||
let insecure = document(
|
||||
"PROVISIONED",
|
||||
"\"http://releases.example.test/latest.json\"",
|
||||
"\"http://releases.example.test/hololake/releases/latest.json\"",
|
||||
"public",
|
||||
"\"releases.example.test\"",
|
||||
);
|
||||
assert!(validate_release_trust(&insecure).is_err());
|
||||
let mismatched_host = document(
|
||||
"PROVISIONED",
|
||||
"\"https://upstream.example.test/latest.json\"",
|
||||
"\"https://upstream.example.test/hololake/releases/latest.json\"",
|
||||
"public",
|
||||
"\"releases.example.test\"",
|
||||
);
|
||||
assert!(validate_release_trust(&mismatched_host).is_err());
|
||||
let mismatched_path = document(
|
||||
"PROVISIONED",
|
||||
"\"https://releases.example.test/latest.json\"",
|
||||
"public",
|
||||
"\"releases.example.test\"",
|
||||
);
|
||||
assert!(validate_release_trust(&mismatched_path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -27,7 +27,7 @@
|
|||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"createUpdaterArtifacts": false,
|
||||
"category": "Productivity"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
167
product-source/hololake-native-desktop/src/design-tokens.css
Normal file
167
product-source/hololake-native-desktop/src/design-tokens.css
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
:root,
|
||||
[data-theme='night'] {
|
||||
/* Primitive tokens — GHS-014 five-lakes v1.3, night lake. */
|
||||
--primitive-bg-a: #0a1020;
|
||||
--primitive-bg-b: #0b1220;
|
||||
--primitive-bg-c: #070c16;
|
||||
--primitive-bg-d: #04070d;
|
||||
--primitive-star-core: #f7ebc8;
|
||||
--primitive-star-bright: #ffefc2;
|
||||
--primitive-text-primary: rgba(234, 238, 248, 0.96);
|
||||
--primitive-text-body: rgba(222, 228, 242, 0.88);
|
||||
--primitive-text-aux: rgba(196, 206, 228, 0.62);
|
||||
--primitive-text-faint: rgba(180, 192, 220, 0.4);
|
||||
--primitive-nebula-a: rgba(91, 111, 196, 0.13);
|
||||
--primitive-nebula-b: rgba(138, 111, 201, 0.05);
|
||||
--primitive-glass: rgba(178, 198, 255, 0.045);
|
||||
--primitive-glass-hover: rgba(178, 198, 255, 0.075);
|
||||
--primitive-glass-edge: rgba(202, 216, 255, 0.13);
|
||||
--primitive-glass-top: rgba(255, 255, 255, 0.06);
|
||||
--primitive-mist-a: rgba(157, 180, 255, 0.1);
|
||||
--primitive-mist-b: rgba(247, 235, 200, 0.04);
|
||||
--primitive-cool-glow: rgba(157, 180, 255, 0.42);
|
||||
--primitive-warm-glow: rgba(247, 235, 200, 0.5);
|
||||
--primitive-shadow: rgba(0, 0, 0, 0.34);
|
||||
--primitive-scene-opacity: 0.76;
|
||||
|
||||
/* Semantic tokens. */
|
||||
--surface-sky: var(--primitive-bg-a);
|
||||
--surface-horizon: var(--primitive-bg-b);
|
||||
--surface-lake: var(--primitive-bg-c);
|
||||
--surface-depth: var(--primitive-bg-d);
|
||||
--content-primary: var(--primitive-text-primary);
|
||||
--content-secondary: var(--primitive-text-body);
|
||||
--content-muted: var(--primitive-text-aux);
|
||||
--content-faint: var(--primitive-text-faint);
|
||||
--accent-light: var(--primitive-star-core);
|
||||
--accent-light-strong: var(--primitive-star-bright);
|
||||
--state-ready: #b8d7c4;
|
||||
--state-waiting: #cfbddf;
|
||||
--state-quiet: var(--primitive-text-aux);
|
||||
--focus-ring: rgba(247, 235, 200, 0.7);
|
||||
|
||||
/* Component tokens. */
|
||||
--button-primary-bg: rgba(247, 235, 200, 0.92);
|
||||
--button-primary-text: #15181f;
|
||||
--button-primary-shadow: 0 12px 42px rgba(247, 235, 200, 0.14);
|
||||
--button-secondary-bg: var(--primitive-glass);
|
||||
--button-secondary-text: var(--content-secondary);
|
||||
--button-secondary-edge: var(--primitive-glass-edge);
|
||||
--panel-bg: rgba(8, 13, 25, 0.72);
|
||||
--panel-edge: var(--primitive-glass-edge);
|
||||
--status-dot: var(--accent-light);
|
||||
}
|
||||
|
||||
[data-theme='dawn'] {
|
||||
--primitive-bg-a: #f5f7fc;
|
||||
--primitive-bg-b: #eaeff8;
|
||||
--primitive-bg-c: #dfe8f4;
|
||||
--primitive-bg-d: #d3e0ef;
|
||||
--primitive-star-core: #c9a24b;
|
||||
--primitive-star-bright: #a96f2c;
|
||||
--primitive-text-primary: rgba(22, 33, 58, 0.94);
|
||||
--primitive-text-body: rgba(36, 49, 76, 0.86);
|
||||
--primitive-text-aux: rgba(72, 88, 120, 0.62);
|
||||
--primitive-text-faint: rgba(98, 112, 142, 0.48);
|
||||
--primitive-nebula-a: rgba(122, 144, 220, 0.12);
|
||||
--primitive-nebula-b: rgba(240, 196, 150, 0.12);
|
||||
--primitive-glass: rgba(255, 255, 255, 0.34);
|
||||
--primitive-glass-hover: rgba(255, 255, 255, 0.52);
|
||||
--primitive-glass-edge: rgba(30, 50, 90, 0.12);
|
||||
--primitive-glass-top: rgba(255, 255, 255, 0.65);
|
||||
--primitive-mist-a: rgba(255, 255, 255, 0.55);
|
||||
--primitive-mist-b: rgba(250, 214, 150, 0.3);
|
||||
--primitive-cool-glow: rgba(96, 124, 205, 0.35);
|
||||
--primitive-warm-glow: rgba(201, 162, 75, 0.5);
|
||||
--primitive-shadow: rgba(42, 66, 105, 0.16);
|
||||
--primitive-scene-opacity: 0.11;
|
||||
--state-ready: #3f7058;
|
||||
--state-waiting: #725b82;
|
||||
--panel-bg: rgba(247, 250, 255, 0.76);
|
||||
--button-primary-bg: rgba(45, 59, 84, 0.92);
|
||||
--button-primary-text: #f8f4e9;
|
||||
--button-primary-shadow: 0 12px 36px rgba(45, 59, 84, 0.16);
|
||||
}
|
||||
|
||||
[data-theme='nebula'] {
|
||||
--primitive-bg-a: #150e26;
|
||||
--primitive-bg-b: #160f28;
|
||||
--primitive-bg-c: #0e0919;
|
||||
--primitive-bg-d: #080510;
|
||||
--primitive-star-core: #ede4f6;
|
||||
--primitive-star-bright: #f8f1ff;
|
||||
--primitive-text-primary: rgba(238, 234, 248, 0.96);
|
||||
--primitive-text-body: rgba(226, 220, 240, 0.88);
|
||||
--primitive-text-aux: rgba(202, 194, 226, 0.62);
|
||||
--primitive-text-faint: rgba(186, 178, 214, 0.42);
|
||||
--primitive-nebula-a: rgba(138, 111, 201, 0.24);
|
||||
--primitive-nebula-b: rgba(91, 111, 196, 0.11);
|
||||
--primitive-glass: rgba(196, 178, 255, 0.05);
|
||||
--primitive-glass-hover: rgba(196, 178, 255, 0.09);
|
||||
--primitive-glass-edge: rgba(210, 198, 255, 0.14);
|
||||
--primitive-glass-top: rgba(255, 255, 255, 0.07);
|
||||
--primitive-mist-a: rgba(170, 150, 255, 0.12);
|
||||
--primitive-mist-b: rgba(239, 217, 168, 0.05);
|
||||
--primitive-cool-glow: rgba(170, 150, 255, 0.45);
|
||||
--primitive-warm-glow: rgba(239, 217, 168, 0.5);
|
||||
--primitive-shadow: rgba(0, 0, 0, 0.38);
|
||||
--primitive-scene-opacity: 0.44;
|
||||
--panel-bg: rgba(18, 12, 34, 0.76);
|
||||
}
|
||||
|
||||
[data-theme='candle'] {
|
||||
--primitive-bg-a: #1b130b;
|
||||
--primitive-bg-b: #181108;
|
||||
--primitive-bg-c: #100b05;
|
||||
--primitive-bg-d: #0a0703;
|
||||
--primitive-star-core: #ffd9a0;
|
||||
--primitive-star-bright: #ffe7be;
|
||||
--primitive-text-primary: rgba(246, 238, 226, 0.96);
|
||||
--primitive-text-body: rgba(236, 226, 210, 0.88);
|
||||
--primitive-text-aux: rgba(214, 198, 176, 0.62);
|
||||
--primitive-text-faint: rgba(196, 180, 158, 0.42);
|
||||
--primitive-nebula-a: rgba(214, 150, 80, 0.13);
|
||||
--primitive-nebula-b: rgba(160, 110, 70, 0.07);
|
||||
--primitive-glass: rgba(255, 214, 160, 0.045);
|
||||
--primitive-glass-hover: rgba(255, 214, 160, 0.08);
|
||||
--primitive-glass-edge: rgba(255, 220, 170, 0.13);
|
||||
--primitive-glass-top: rgba(255, 255, 255, 0.06);
|
||||
--primitive-mist-a: rgba(255, 196, 110, 0.09);
|
||||
--primitive-mist-b: rgba(255, 214, 140, 0.04);
|
||||
--primitive-cool-glow: rgba(214, 178, 140, 0.4);
|
||||
--primitive-warm-glow: rgba(255, 196, 110, 0.55);
|
||||
--primitive-shadow: rgba(0, 0, 0, 0.38);
|
||||
--primitive-scene-opacity: 0.4;
|
||||
--panel-bg: rgba(24, 17, 9, 0.78);
|
||||
}
|
||||
|
||||
[data-theme='clear'] {
|
||||
--primitive-bg-a: #f1f5f8;
|
||||
--primitive-bg-b: #e8eef3;
|
||||
--primitive-bg-c: #dee7ed;
|
||||
--primitive-bg-d: #d2dee7;
|
||||
--primitive-star-core: #5f7e9f;
|
||||
--primitive-star-bright: #41628a;
|
||||
--primitive-text-primary: rgba(20, 36, 52, 0.94);
|
||||
--primitive-text-body: rgba(34, 50, 68, 0.86);
|
||||
--primitive-text-aux: rgba(66, 86, 106, 0.62);
|
||||
--primitive-text-faint: rgba(92, 110, 128, 0.48);
|
||||
--primitive-nebula-a: rgba(100, 140, 180, 0.1);
|
||||
--primitive-nebula-b: rgba(140, 170, 200, 0.08);
|
||||
--primitive-glass: rgba(255, 255, 255, 0.32);
|
||||
--primitive-glass-hover: rgba(255, 255, 255, 0.5);
|
||||
--primitive-glass-edge: rgba(20, 50, 80, 0.12);
|
||||
--primitive-glass-top: rgba(255, 255, 255, 0.65);
|
||||
--primitive-mist-a: rgba(255, 255, 255, 0.55);
|
||||
--primitive-mist-b: rgba(190, 214, 234, 0.35);
|
||||
--primitive-cool-glow: rgba(78, 112, 150, 0.35);
|
||||
--primitive-warm-glow: rgba(170, 138, 70, 0.5);
|
||||
--primitive-shadow: rgba(37, 66, 90, 0.14);
|
||||
--primitive-scene-opacity: 0.08;
|
||||
--state-ready: #3f7058;
|
||||
--state-waiting: #6a5b7a;
|
||||
--panel-bg: rgba(246, 250, 253, 0.76);
|
||||
--button-primary-bg: rgba(45, 66, 85, 0.92);
|
||||
--button-primary-text: #f6f2e9;
|
||||
--button-primary-shadow: 0 12px 36px rgba(45, 66, 85, 0.15);
|
||||
}
|
||||
|
|
@ -1,24 +1,489 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { StrictMode, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import './design-tokens.css'
|
||||
import './styles.css'
|
||||
|
||||
function FoundationScreen() {
|
||||
type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear'
|
||||
type Panel = 'channel' | 'receipts' | 'updates' | 'settings' | null
|
||||
|
||||
interface HomeStatus {
|
||||
schema: string
|
||||
directLocalBrokerState: string
|
||||
directConnectionCount: number
|
||||
resumableSessionCount: number
|
||||
codeRepositoryMountCount: number
|
||||
pnccReceiptCount: number
|
||||
updateState: string
|
||||
releaseRecoveryState: string
|
||||
automaticUpstreamUpdates: boolean
|
||||
mcpRole: string
|
||||
}
|
||||
|
||||
interface DiscoveryTicketReceipt {
|
||||
schema: string
|
||||
state: string
|
||||
accountKey: string
|
||||
laneId: string
|
||||
clientInstanceId: string
|
||||
discoveryTicket: string
|
||||
issuedAtUnixMs: number
|
||||
receiptId: string
|
||||
}
|
||||
|
||||
interface ReceiptEvent {
|
||||
sequence: number
|
||||
kind: string
|
||||
observedAtUnixMs: number
|
||||
eventHash: string
|
||||
}
|
||||
|
||||
interface ReceiptProjection {
|
||||
events: ReceiptEvent[]
|
||||
returnedEventCount: 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
|
||||
}
|
||||
|
||||
interface ReleaseCandidate {
|
||||
candidateId: string
|
||||
confirmationToken: string
|
||||
releaseId: string
|
||||
currentVersion: string
|
||||
version: string
|
||||
notes: string
|
||||
features: string[]
|
||||
fixes: string[]
|
||||
minimumVersion: string
|
||||
dataMigrationRequired: boolean
|
||||
compatibilityNotes?: string
|
||||
restartMessage?: string
|
||||
rollbackSupported: boolean
|
||||
previousVersion?: string
|
||||
expiresAtUnixMs: number
|
||||
}
|
||||
|
||||
interface ReleaseCheckReceipt {
|
||||
state: string
|
||||
candidate?: ReleaseCandidate
|
||||
}
|
||||
|
||||
const themes: Array<{ id: ThemeId; name: string }> = [
|
||||
{ id: 'night', name: '夜湖星光' },
|
||||
{ id: 'dawn', name: '晨湖曦光' },
|
||||
{ id: 'nebula', name: '星云紫夜' },
|
||||
{ id: 'candle', name: '烛畔暖湖' },
|
||||
{ id: 'clear', name: '清浅澄湖' },
|
||||
]
|
||||
|
||||
const recoveryPreviewStates = [
|
||||
'NONE',
|
||||
'AWAITING_HUMAN_HEALTH_CONFIRMATION',
|
||||
'ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT',
|
||||
'INSTALL_FAILED_BACKUP_RETAINED',
|
||||
'INSTALL_INTERRUPTED_BACKUP_RETAINED',
|
||||
'INSTALL_REPLACED_AWAITING_MANUAL_RESTART',
|
||||
'ROLLBACK_REPLACED_AWAITING_MANUAL_RESTART',
|
||||
'RECOVERY_STATE_UNREADABLE_ACTION_REQUIRED',
|
||||
]
|
||||
const requestedRecoveryPreview = import.meta.env.DEV
|
||||
? new URLSearchParams(window.location.search).get('releaseRecoveryState')
|
||||
: null
|
||||
const recoveryPreviewState = requestedRecoveryPreview && recoveryPreviewStates.includes(requestedRecoveryPreview)
|
||||
? requestedRecoveryPreview
|
||||
: 'NONE'
|
||||
|
||||
const previewStatus: HomeStatus = {
|
||||
schema: 'hololake.home-status/preview',
|
||||
directLocalBrokerState: 'PREVIEW',
|
||||
directConnectionCount: 0,
|
||||
resumableSessionCount: 0,
|
||||
codeRepositoryMountCount: 0,
|
||||
pnccReceiptCount: 0,
|
||||
updateState: 'UNPROVISIONED_FAIL_CLOSED',
|
||||
releaseRecoveryState: recoveryPreviewState,
|
||||
automaticUpstreamUpdates: false,
|
||||
mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY',
|
||||
}
|
||||
|
||||
function getOrCreateLocalId(key: string, prefix: string) {
|
||||
const existing = window.localStorage.getItem(key)
|
||||
if (existing) return existing
|
||||
const generated = `${prefix}-${crypto.randomUUID()}`
|
||||
window.localStorage.setItem(key, generated)
|
||||
return generated
|
||||
}
|
||||
|
||||
function Icon({ name }: { name: 'settings' | 'arrow' | 'receipt' | 'close' | 'copy' }) {
|
||||
const paths = {
|
||||
settings: <><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-1.9 1.9-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.04 1.56V20h-2.7v-.08a1.7 1.7 0 0 0-1.05-1.56 1.7 1.7 0 0 0-1.88.34l-.06.06-1.9-1.9.06-.06A1.7 1.7 0 0 0 7.72 15a1.7 1.7 0 0 0-1.56-1.04H6v-2.7h.08A1.7 1.7 0 0 0 7.64 10a1.7 1.7 0 0 0-.34-1.88l-.06-.06 1.9-1.9.06.06a1.7 1.7 0 0 0 1.88.34A1.7 1.7 0 0 0 12.12 5V5h2.7v.08a1.7 1.7 0 0 0 1.04 1.56 1.7 1.7 0 0 0 1.88-.34l.06-.06 1.9 1.9-.06.06a1.7 1.7 0 0 0-.34 1.88 1.7 1.7 0 0 0 1.56 1.04H21v2.7h-.08A1.7 1.7 0 0 0 19.4 15Z"/></>,
|
||||
arrow: <><path d="M5 12h13"/><path d="m14 7 5 5-5 5"/></>,
|
||||
receipt: <><path d="M7 3h10v18l-2.5-1.5L12 21l-2.5-1.5L7 21Z"/><path d="M10 8h4M10 12h4"/></>,
|
||||
close: <><path d="m7 7 10 10M17 7 7 17"/></>,
|
||||
copy: <><rect x="8" y="8" width="10" height="11" rx="2"/><path d="M15 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2"/></>,
|
||||
}
|
||||
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>
|
||||
}
|
||||
|
||||
function HoloLakeApp() {
|
||||
const [theme, setTheme] = useState<ThemeId>(() => (window.localStorage.getItem('hololake-theme') as ThemeId) || 'night')
|
||||
const [status, setStatus] = useState<HomeStatus>(previewStatus)
|
||||
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 [releaseCandidate, setReleaseCandidate] = useState<ReleaseCandidate | null>(null)
|
||||
const [releaseBusy, setReleaseBusy] = useState(false)
|
||||
const [releaseMessage, setReleaseMessage] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
setStatus(await invoke<HomeStatus>('get_hololake_home_status'))
|
||||
} catch {
|
||||
setStatus(previewStatus)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshStatus()
|
||||
const timer = window.setInterval(() => void refreshStatus(), 3000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [refreshStatus])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme
|
||||
window.localStorage.setItem('hololake-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const connectionState = useMemo(() => {
|
||||
if (status.directConnectionCount > 0) return { label: '已连接', detail: `${status.directConnectionCount} 个直连通道在线`, tone: 'ready' }
|
||||
if (status.resumableSessionCount > 0) return { label: '可续接', detail: `${status.resumableSessionCount} 个会话等待续接`, tone: 'waiting' }
|
||||
return { label: '等待连接', detail: '本机直连核心已就绪', tone: 'quiet' }
|
||||
}, [status])
|
||||
|
||||
const releaseRecoveryIdle = useMemo(() => [
|
||||
'NONE',
|
||||
'HEALTH_CONFIRMED_BACKUP_REMOVED',
|
||||
'ROLLBACK_CONFIRMED_BACKUP_REMOVED',
|
||||
'INSTALL_FAILURE_ACKNOWLEDGED_BACKUP_REMOVED',
|
||||
].includes(status.releaseRecoveryState), [status.releaseRecoveryState])
|
||||
|
||||
const releaseRecoveryNeedsAttention = !releaseRecoveryIdle
|
||||
|
||||
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)
|
||||
} catch {
|
||||
setReceipts([])
|
||||
}
|
||||
}
|
||||
|
||||
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 checkUpdate = async () => {
|
||||
setReleaseBusy(true)
|
||||
setReleaseMessage('')
|
||||
try {
|
||||
const receipt = await invoke<ReleaseCheckReceipt>('check_hololake_update')
|
||||
setReleaseCandidate(receipt.candidate || null)
|
||||
if (!receipt.candidate) setReleaseMessage('当前已经是最新版本。')
|
||||
} catch (error) {
|
||||
const reason = String(error)
|
||||
setReleaseCandidate(null)
|
||||
setReleaseMessage(reason.includes('UNPROVISIONED_NO_NETWORK_REQUEST')
|
||||
? '京东主控的正式发布地址和更新公钥还没有登记。HoloLake 保持离线,不会向任何上游检查更新。'
|
||||
: `更新检查没有完成:${reason}`)
|
||||
} finally {
|
||||
setReleaseBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmUpdateInstall = async () => {
|
||||
if (!releaseCandidate) return
|
||||
setReleaseBusy(true)
|
||||
setReleaseMessage('正在重新核对广播并验证安装包;完成后由你手动重启 HoloLake。')
|
||||
try {
|
||||
await invoke('confirm_hololake_update_install', {
|
||||
input: {
|
||||
candidateId: releaseCandidate.candidateId,
|
||||
confirmationToken: releaseCandidate.confirmationToken,
|
||||
},
|
||||
})
|
||||
setReleaseCandidate(null)
|
||||
setReleaseMessage('签名安装包已验证并安装。请在准备好后手动重启 HoloLake。')
|
||||
await refreshStatus()
|
||||
} catch (error) {
|
||||
setReleaseMessage(`更新没有安装:${String(error)}`)
|
||||
} finally {
|
||||
setReleaseBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveReleaseRecovery = async (action: 'keep' | 'rollback') => {
|
||||
setReleaseBusy(true)
|
||||
setReleaseMessage(action === 'rollback' ? '正在复核上一版签名并恢复;完成后仍由你手动重启。' : '正在确认当前版本健康并清理上一版副本。')
|
||||
try {
|
||||
if (action === 'rollback') {
|
||||
await invoke('rollback_hololake_update')
|
||||
setReleaseMessage('上一版已恢复到应用位置。请手动重启 HoloLake,启动后再确认恢复正常。')
|
||||
} else {
|
||||
await invoke('confirm_hololake_release_health')
|
||||
setReleaseMessage('当前运行版本已由你确认,临时回退副本已经清理。')
|
||||
}
|
||||
await refreshStatus()
|
||||
} catch (error) {
|
||||
setReleaseMessage(`没有改变当前应用:${String(error)}`)
|
||||
} finally {
|
||||
setReleaseBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const issueInvitation = async () => {
|
||||
setMessage('')
|
||||
try {
|
||||
const receipt = await invoke<DiscoveryTicketReceipt>('issue_direct_local_discovery_ticket', {
|
||||
input: {
|
||||
accountId: getOrCreateLocalId('hololake-local-account', 'human-local'),
|
||||
laneId: 'personal-channel',
|
||||
clientInstanceId: getOrCreateLocalId('hololake-external-ai-client', 'external-ai'),
|
||||
},
|
||||
})
|
||||
setTicket(receipt)
|
||||
setMessage('连接邀请已生成。它只在这台电脑上有效。')
|
||||
} catch (error) {
|
||||
setMessage(`暂时无法生成连接邀请:${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const invitationText = ticket ? JSON.stringify({
|
||||
schema: 'hololake.direct-local-invitation/v1',
|
||||
transport: 'INSTALLED_HOLOLAKE_EXECUTABLE_WITH_CONNECTOR_FLAG',
|
||||
connectorArgument: '--connector',
|
||||
openSession: {
|
||||
operation: 'OPEN_SESSION',
|
||||
input: {
|
||||
accountId: window.localStorage.getItem('hololake-local-account'),
|
||||
laneId: ticket.laneId,
|
||||
clientInstanceId: ticket.clientInstanceId,
|
||||
discoveryTicket: ticket.discoveryTicket,
|
||||
},
|
||||
},
|
||||
mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY',
|
||||
}, null, 2) : ''
|
||||
|
||||
const copyInvitation = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(invitationText)
|
||||
setMessage('已复制。把它交给桌面上的编程 AI 即可。')
|
||||
} catch {
|
||||
setMessage('复制没有成功,请选中下方邀请内容后复制。')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="eyebrow">HoloLake Native Desktop</p>
|
||||
<h1>新的光湖桌面主线正在建立信任根</h1>
|
||||
<p>
|
||||
当前只完成最小 Tauri 外壳与更新安全边界。知识工作区将在双供体审计和视觉方向确定后开始。
|
||||
</p>
|
||||
<dl>
|
||||
<div><dt>上游更新</dt><dd>已关闭</dd></div>
|
||||
<div><dt>光湖发布广播</dt><dd>等待正式端点与公钥</dd></div>
|
||||
<div><dt>下载与安装</dt><dd>必须由人类选择</dd></div>
|
||||
</dl>
|
||||
</main>
|
||||
<div className="world-shell">
|
||||
<div className="scene-image" aria-hidden="true" />
|
||||
<div className="nebula nebula-one" aria-hidden="true" />
|
||||
<div className="nebula nebula-two" aria-hidden="true" />
|
||||
<div className="mist" aria-hidden="true" />
|
||||
<div className="stars" aria-hidden="true">{Array.from({ length: 18 }, (_, index) => <i key={index} style={{ '--star-index': index } as React.CSSProperties} />)}</div>
|
||||
<div className="glimmers" aria-hidden="true">{Array.from({ length: 11 }, (_, index) => <i key={index} style={{ '--glimmer-index': index } as React.CSSProperties} />)}</div>
|
||||
|
||||
<header className="world-header">
|
||||
<div className="world-mark">
|
||||
<span className="world-name">光湖</span>
|
||||
<span className="world-route">个人频道</span>
|
||||
</div>
|
||||
<button className="icon-button" type="button" aria-label="打开设置" onClick={() => setPanel('settings')}><Icon name="settings" /></button>
|
||||
</header>
|
||||
|
||||
<main className="home-content">
|
||||
<section className="hero" aria-labelledby="home-title">
|
||||
<p className="route-kicker">HOLOLAKE · PERSONAL CHANNEL</p>
|
||||
<h1 id="home-title">语言进入这里,<br />成为可见、可续的现实。</h1>
|
||||
<p className="hero-copy">外部编程 AI 从本机进入。HoloLake 保存路线、边界与回执;五个域安静地运行在湖底。</p>
|
||||
<div className="hero-actions">
|
||||
<button className="primary-action" type="button" onClick={() => setPanel('channel')}>
|
||||
{status.directConnectionCount > 0 ? '已连接 · 查看通道' : '连接编程 AI'}
|
||||
<Icon name="arrow" />
|
||||
</button>
|
||||
<button className="secondary-action" type="button" onClick={() => void openReceipts()}><Icon name="receipt" />查看回执</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="status-cluster" aria-label="光湖运行状态">
|
||||
<article className="status-item">
|
||||
<span className={`status-light ${connectionState.tone}`} aria-hidden="true" />
|
||||
<div><h2>本机直连通道</h2><p>{connectionState.detail}</p></div>
|
||||
</article>
|
||||
<article className="status-item">
|
||||
<span className={`status-light ${status.codeRepositoryMountCount > 0 ? 'ready' : 'quiet'}`} aria-hidden="true" />
|
||||
<div><h2>代码通道仓库</h2><p>{status.codeRepositoryMountCount > 0 ? `已验证 · ${status.codeRepositoryMountCount} 个仓库` : '等待人类确认绑定'}</p></div>
|
||||
</article>
|
||||
<button className="status-item status-button" type="button" onClick={() => setPanel('updates')}>
|
||||
<span className={`status-light ${releaseRecoveryNeedsAttention ? 'waiting' : status.updateState.startsWith('READY') ? 'ready' : 'quiet'}`} aria-hidden="true" />
|
||||
<div><h2>更新广播</h2><p>{releaseRecoveryNeedsAttention ? '更新结果等待你的确认' : status.updateState.startsWith('READY') ? '仅接收光湖签名广播' : '信任根未配置 · 已关闭联网'}</p></div>
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="world-footer">
|
||||
<p><b>GH-AIOS</b><span>光湖人工智能操作系统</span></p>
|
||||
<p className="trust-note"><i />你的工作留在本机 · 连接与更新均需确认</p>
|
||||
</footer>
|
||||
|
||||
{panel && <div className="panel-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setPanel(null)}>
|
||||
<section className="detail-panel" role="dialog" aria-modal="true" aria-labelledby="panel-title">
|
||||
<button className="panel-close" type="button" aria-label="关闭" onClick={() => setPanel(null)}><Icon name="close" /></button>
|
||||
{panel === 'channel' && <>
|
||||
<p className="panel-kicker">个人频道</p>
|
||||
<h2 id="panel-title">让编程 AI 从本机进入</h2>
|
||||
<p className="panel-intro">MCP 只负责发现和恢复。进入后,编程 AI 使用 HoloLake 的本机直连通道;电脑不关,驻留核心就一直在。</p>
|
||||
<div className="connection-summary"><span className={`status-light ${connectionState.tone}`} /><div><b>{connectionState.label}</b><span>{connectionState.detail}</span></div></div>
|
||||
{status.directConnectionCount > 0 ? <div className="session-guidance">
|
||||
<b>通道已建立</b>
|
||||
<span>编程 AI 正在使用本机直连通道。一次性邀请已经失效,不会再次显示。</span>
|
||||
</div> : status.resumableSessionCount > 0 ? <div className="session-guidance">
|
||||
<b>等待原会话续接</b>
|
||||
<span>请让原编程 AI 使用它保存的续接凭据重新进入。HoloLake 不会重新展示已经消费的邀请。</span>
|
||||
</div> : ticket ? <>
|
||||
<button className="primary-action panel-action" type="button" onClick={() => void copyInvitation()}><Icon name="copy" />复制给编程 AI</button>
|
||||
<pre className="invitation-preview">{invitationText}</pre>
|
||||
</> : <button className="primary-action panel-action" type="button" onClick={() => void issueInvitation()}>生成一次性连接邀请<Icon name="arrow" /></button>}
|
||||
{message && <p className="panel-message" aria-live="polite">{message}</p>}
|
||||
<p className="boundary-note">连接邀请不赋予服务器权限。写入、发布与更新仍分别确认并留下回执。</p>
|
||||
</>}
|
||||
{panel === 'receipts' && <>
|
||||
<p className="panel-kicker">可核验的发生</p>
|
||||
<h2 id="panel-title">代码通道回执</h2>
|
||||
<p className="panel-intro">这里是只读投影,不是权限来源。没有回执,也不代表服务器离线。</p>
|
||||
{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 === 'updates' && <>
|
||||
<p className="panel-kicker">光湖签名广播</p>
|
||||
<h2 id="panel-title">更新由你决定何时进入</h2>
|
||||
<p className="panel-intro">HoloLake 不接收开源上游自动推送。只有登记到本机的光湖主控地址和公钥可以参与更新,并且检查、安装、重启是三件分开的事。</p>
|
||||
{status.releaseRecoveryState === 'AWAITING_HUMAN_HEALTH_CONFIRMATION' ? <div className="recovery-card">
|
||||
<div className="candidate-heading"><span className="status-light waiting" /><div><b>新版本已启动,等待你的判断</b><span>上一版仍作为已验签副本保留</span></div></div>
|
||||
<p>如果当前湖面、连接和代码通道都正常,确认保留当前版本;如果不正常,恢复上一版。两个动作都不会自动重启。</p>
|
||||
<div className="recovery-actions"><button className="primary-action" type="button" disabled={releaseBusy} onClick={() => void resolveReleaseRecovery('keep')}>当前版本正常</button><button className="secondary-action" type="button" disabled={releaseBusy} onClick={() => void resolveReleaseRecovery('rollback')}>恢复上一版</button></div>
|
||||
</div> : status.releaseRecoveryState === 'ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT' ? <div className="recovery-card">
|
||||
<div className="candidate-heading"><span className="status-light waiting" /><div><b>上一版已经恢复</b><span>请确认湖面与通道运行正常</span></div></div>
|
||||
<p>确认后,HoloLake 会清理这次更新留下的临时回退副本,并重新开放下一次更新检查。</p>
|
||||
<button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void resolveReleaseRecovery('keep')}>确认恢复正常<Icon name="arrow" /></button>
|
||||
</div> : ['INSTALL_FAILED_BACKUP_RETAINED', 'INSTALL_INTERRUPTED_BACKUP_RETAINED'].includes(status.releaseRecoveryState) ? <div className="recovery-card">
|
||||
<div className="candidate-heading"><span className="status-light waiting" /><div><b>更新没有替换当前版本</b><span>上一版保护副本仍保留</span></div></div>
|
||||
<p>当前 HoloLake 仍在原版本上运行。确认运行正常后,清理保护副本并重新开放更新。</p>
|
||||
<button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void resolveReleaseRecovery('keep')}>确认当前版本正常<Icon name="arrow" /></button>
|
||||
</div> : ['INSTALL_REPLACED_AWAITING_MANUAL_RESTART', 'ROLLBACK_REPLACED_AWAITING_MANUAL_RESTART', 'LAST_KNOWN_GOOD_VERIFIED_INSTALLING'].includes(status.releaseRecoveryState) ? <div className="recovery-card">
|
||||
<div className="candidate-heading"><span className="status-light waiting" /><div><b>应用已替换,等待手动重启</b><span>HoloLake 不会替你关闭或重启</span></div></div>
|
||||
<p>关闭并重新打开 HoloLake 后,这里会要求你确认新版本健康,或确认上一版已经恢复。</p>
|
||||
</div> : !releaseRecoveryIdle ? <div className="recovery-card">
|
||||
<div className="candidate-heading"><span className="status-light waiting" /><div><b>更新保护状态需要维护</b><span>下一次更新已经锁住,现有湖面仍可使用</span></div></div>
|
||||
<p>HoloLake 没有删除或猜测恢复记录,也不会联网继续更新。由光湖维护人员核对本机回执后再恢复更新通道。</p>
|
||||
</div> : releaseCandidate ? <div className="release-card">
|
||||
<div className="release-version"><span>新版本</span><b>{releaseCandidate.version}</b><small>当前 {releaseCandidate.currentVersion}</small></div>
|
||||
<p>{releaseCandidate.notes}</p>
|
||||
<ul>{releaseCandidate.features.map((feature) => <li key={feature}>{feature}</li>)}{releaseCandidate.fixes.map((fix) => <li key={fix}>{fix}</li>)}</ul>
|
||||
<dl className="candidate-facts">
|
||||
<div><dt>数据迁移</dt><dd>{releaseCandidate.dataMigrationRequired ? '需要' : '不需要'}</dd></div>
|
||||
<div><dt>最低版本</dt><dd>{releaseCandidate.minimumVersion}</dd></div>
|
||||
<div><dt>重启</dt><dd>安装后由你手动完成</dd></div>
|
||||
<div><dt>回滚声明</dt><dd>{releaseCandidate.rollbackSupported ? `支持 · ${releaseCandidate.previousVersion || '上一版本'}` : '不支持'}</dd></div>
|
||||
</dl>
|
||||
<p className="release-warning">安装前会再次读取同一广播,验证下载域名、Tauri 签名、文件大小和 SHA-256;内容变化就停止并要求重新确认。</p>
|
||||
<button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void confirmUpdateInstall()}>确认下载并安装<Icon name="arrow" /></button>
|
||||
</div> : <div className="update-state-card"><span className={`status-light ${status.updateState.startsWith('READY') ? 'ready' : 'quiet'}`} /><div><b>{status.updateState.startsWith('READY') ? '主控信任根已登记' : '更新网络保持关闭'}</b><span>{status.updateState.startsWith('READY') ? '可以由你手动检查签名广播' : '没有正式地址和公钥时,不发起网络请求'}</span></div></div>}
|
||||
{!releaseCandidate && releaseRecoveryIdle && <button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void checkUpdate()}>{releaseBusy ? '正在核对…' : '检查光湖更新'}<Icon name="arrow" /></button>}
|
||||
{releaseMessage && <p className="panel-message" aria-live="polite">{releaseMessage}</p>}
|
||||
<p className="boundary-note">持久回退底座已进入本机容器。公共更新仍需京东主控正式地址、公钥、签名发布流水线与苹果公证全部到位后才会启用。</p>
|
||||
</>}
|
||||
{panel === 'settings' && <>
|
||||
<p className="panel-kicker">湖面设置</p>
|
||||
<h2 id="panel-title">选择你看见光湖的方式</h2>
|
||||
<p className="panel-intro">只改变光与色。布局、文案、连接边界和底层路由保持不变。</p>
|
||||
<div className="theme-list">{themes.map((choice) => <button key={choice.id} type="button" className={theme === choice.id ? 'selected' : ''} onClick={() => setTheme(choice.id)}><i className={`theme-swatch ${choice.id}`} /><span><b>{choice.name}</b><small>{choice.id === 'night' ? '当前定版基色' : '五色湖定版主题'}</small></span><em>{theme === choice.id ? '已选择' : ''}</em></button>)}</div>
|
||||
<p className="boundary-note">上游自动更新保持关闭。正式更新只接受 HoloLake 自有信任根的签名广播,并由你确认。</p>
|
||||
</>}
|
||||
</section>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode><FoundationScreen /></StrictMode>,
|
||||
)
|
||||
createRoot(document.getElementById('root')!).render(<StrictMode><HoloLakeApp /></StrictMode>)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,180 @@
|
|||
* { box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: #eaf0ff;
|
||||
background: #07101f;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei UI", sans-serif;
|
||||
color: var(--content-primary);
|
||||
background: var(--surface-depth);
|
||||
font-synthesis: none;
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||
main { max-width: 720px; margin: 0 auto; padding: 12vh 32px; }
|
||||
.eyebrow { color: #82a7ff; letter-spacing: .08em; text-transform: uppercase; }
|
||||
h1 { font-size: clamp(2rem, 5vw, 4rem); line-height: 1.05; }
|
||||
p { color: #b7c5e5; line-height: 1.7; }
|
||||
dl { margin-top: 48px; border-top: 1px solid #263554; }
|
||||
dl div { display: flex; justify-content: space-between; gap: 24px; padding: 16px 0; border-bottom: 1px solid #263554; }
|
||||
dt { color: #8fa4cf; }
|
||||
dd { margin: 0; text-align: right; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; overflow: hidden; }
|
||||
button { font: inherit; }
|
||||
button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px; }
|
||||
|
||||
.world-shell {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background: linear-gradient(180deg, var(--surface-sky) 0%, var(--surface-horizon) 40%, var(--surface-lake) 65%, var(--surface-depth) 100%);
|
||||
transition: background 450ms ease, color 300ms ease;
|
||||
}
|
||||
|
||||
.scene-image {
|
||||
position: absolute;
|
||||
z-index: -4;
|
||||
inset: 25% 0 0;
|
||||
background-image: linear-gradient(180deg, transparent 0%, var(--surface-depth) 100%), url('./assets/hololake-night-lake.png');
|
||||
background-size: cover;
|
||||
background-position: center 42%;
|
||||
opacity: var(--primitive-scene-opacity);
|
||||
mix-blend-mode: screen;
|
||||
transition: opacity 450ms ease;
|
||||
}
|
||||
|
||||
.nebula { position: absolute; z-index: -3; border-radius: 50%; filter: blur(42px); pointer-events: none; }
|
||||
.nebula-one { width: 58vw; height: 38vw; left: -12vw; top: -18vw; background: radial-gradient(circle, var(--primitive-nebula-a), transparent 68%); }
|
||||
.nebula-two { width: 48vw; height: 32vw; right: -12vw; top: 4vh; background: radial-gradient(circle, var(--primitive-nebula-b), transparent 70%); }
|
||||
.mist { position: absolute; z-index: -2; left: 0; right: 0; top: 38%; height: 36%; background: radial-gradient(ellipse at 50% 52%, var(--primitive-mist-a), var(--primitive-mist-b) 45%, transparent 72%); filter: blur(24px); pointer-events: none; }
|
||||
|
||||
.stars i, .glimmers i { position: absolute; display: block; border-radius: 50%; pointer-events: none; }
|
||||
.stars i { width: calc(1px + (var(--star-index) % 3) * 1px); height: calc(1px + (var(--star-index) % 3) * 1px); left: calc(4% + (var(--star-index) * 5.2%)); top: calc(5% + (var(--star-index) % 5) * 5.4%); background: var(--accent-light); opacity: calc(.16 + (var(--star-index) % 4) * .09); box-shadow: 0 0 8px var(--primitive-warm-glow); animation: breathe calc(4s + (var(--star-index) % 5) * .7s) ease-in-out infinite; }
|
||||
.glimmers i { width: calc(2px + (var(--glimmer-index) % 3) * 2px); height: 2px; left: calc(9% + (var(--glimmer-index) * 8.1%)); top: calc(52% + (var(--glimmer-index) % 4) * 6.4%); background: var(--accent-light); box-shadow: 0 0 12px var(--primitive-cool-glow); opacity: .18; animation: glimmer calc(5s + (var(--glimmer-index) % 4) * 1.2s) ease-in-out infinite; }
|
||||
@keyframes breathe { 50% { opacity: .75; transform: scale(1.5); } }
|
||||
@keyframes glimmer { 50% { opacity: .7; transform: translateX(16px) scaleX(2.4); } }
|
||||
|
||||
.world-header { height: 78px; display: flex; align-items: center; justify-content: space-between; padding: 0 clamp(28px, 5vw, 70px); }
|
||||
.world-mark { display: flex; align-items: baseline; gap: 17px; }
|
||||
.world-name { font-size: 21px; font-weight: 610; letter-spacing: .18em; }
|
||||
.world-route { color: var(--content-muted); font-size: 12px; letter-spacing: .16em; }
|
||||
.icon-button, .panel-close { border: 0; color: var(--content-muted); background: transparent; display: grid; place-items: center; cursor: pointer; border-radius: 50%; transition: color 180ms ease, background 180ms ease; }
|
||||
.icon-button { width: 42px; height: 42px; }
|
||||
.icon-button:hover, .panel-close:hover { color: var(--accent-light); background: var(--primitive-glass-hover); }
|
||||
.icon-button svg, .panel-close svg, .hero-actions svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.45; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.home-content { min-height: calc(100vh - 156px); display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(285px, .45fr); align-items: center; gap: clamp(48px, 7vw, 112px); padding: 0 clamp(54px, 8vw, 126px) 28px; }
|
||||
.hero { max-width: 680px; transform: translateY(-2vh); }
|
||||
.route-kicker, .panel-kicker { margin: 0 0 21px; color: var(--accent-light); font-size: 11px; font-weight: 630; letter-spacing: .22em; }
|
||||
.hero h1 { margin: 0; max-width: 700px; color: var(--content-primary); font-size: clamp(40px, 4.15vw, 58px); font-weight: 430; line-height: 1.22; letter-spacing: -.035em; text-wrap: balance; }
|
||||
.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; white-space: nowrap; }
|
||||
.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); }
|
||||
.secondary-action:hover { background: var(--primitive-glass-hover); }
|
||||
|
||||
.status-cluster { display: grid; gap: 30px; width: min(100%, 330px); justify-self: end; }
|
||||
.status-item { display: grid; grid-template-columns: 10px 1fr; align-items: start; gap: 15px; padding: 5px 0; }
|
||||
.status-button { width: 100%; border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; }
|
||||
.status-button:hover h2 { color: var(--accent-light); }
|
||||
.status-item h2 { margin: 0 0 8px; color: var(--content-secondary); font-size: 13px; font-weight: 590; letter-spacing: .06em; }
|
||||
.status-item p { margin: 0; color: var(--content-muted); opacity: .72; font-size: 11.5px; line-height: 1.5; letter-spacing: .04em; }
|
||||
.status-light { width: 6px; height: 6px; margin-top: 5px; flex: 0 0 auto; border-radius: 50%; background: var(--state-quiet); box-shadow: 0 0 14px currentColor; }
|
||||
.status-light.ready { color: var(--state-ready); background: currentColor; }
|
||||
.status-light.waiting { color: var(--state-waiting); background: currentColor; }
|
||||
.status-light.quiet { color: var(--state-quiet); background: currentColor; }
|
||||
|
||||
.world-footer { height: 78px; display: flex; align-items: center; justify-content: space-between; padding: 0 clamp(28px, 5vw, 70px); color: var(--content-faint); font-size: 10.5px; letter-spacing: .09em; }
|
||||
.world-footer p { margin: 0; display: flex; align-items: center; gap: 13px; }
|
||||
.world-footer b { color: var(--accent-light); font-size: 11.5px; letter-spacing: .14em; }
|
||||
.trust-note i { width: 4px; height: 4px; border-radius: 50%; background: var(--accent-light); opacity: .65; }
|
||||
|
||||
.panel-backdrop { position: fixed; z-index: 20; inset: 0; display: flex; align-items: stretch; justify-content: flex-end; background: rgba(2, 4, 9, .28); backdrop-filter: blur(5px); }
|
||||
.detail-panel { position: relative; width: min(500px, 92vw); height: 100%; overflow-y: auto; padding: 80px 54px 50px; color: var(--content-primary); background: var(--panel-bg); box-shadow: -26px 0 80px var(--primitive-shadow); backdrop-filter: blur(38px) saturate(115%); animation: panel-in 240ms ease-out; }
|
||||
@keyframes panel-in { from { transform: translateX(24px); opacity: 0; } }
|
||||
.panel-close { position: absolute; right: 34px; top: 30px; width: 38px; height: 38px; }
|
||||
.detail-panel h2 { margin: 0; max-width: 380px; font-size: 31px; font-weight: 480; line-height: 1.28; letter-spacing: -.025em; }
|
||||
.panel-intro { margin: 19px 0 30px; color: var(--content-muted); font-size: 13px; line-height: 1.85; }
|
||||
.connection-summary { display: flex; align-items: flex-start; gap: 15px; margin: 32px 0; padding: 20px 22px; border-radius: 20px; background: var(--primitive-glass); box-shadow: inset 0 1px var(--primitive-glass-top); }
|
||||
.connection-summary div { display: grid; gap: 6px; }
|
||||
.connection-summary b { color: var(--content-secondary); font-size: 13px; }
|
||||
.connection-summary span { color: var(--content-faint); font-size: 11.5px; }
|
||||
.session-guidance { display: grid; gap: 8px; margin: 0 0 20px; padding: 18px 20px; border: 1px solid var(--primitive-line); border-radius: 18px; background: rgba(255, 255, 255, .035); }
|
||||
.session-guidance b { color: var(--content-secondary); font-size: 13px; }
|
||||
.session-guidance span { color: var(--content-muted); font-size: 12px; line-height: 1.7; }
|
||||
.panel-action { min-height: 46px; }
|
||||
.panel-message { color: var(--content-muted); font-size: 11.5px; line-height: 1.6; }
|
||||
.invitation-preview { max-height: 180px; overflow: auto; margin: 18px 0; padding: 17px; border-radius: 16px; color: var(--content-faint); background: rgba(0, 0, 0, .13); font: 10px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-all; }
|
||||
.boundary-note { margin-top: 34px; color: var(--content-faint); font-size: 10.5px; line-height: 1.75; }
|
||||
|
||||
.receipt-list { list-style: none; display: grid; gap: 14px; margin: 30px 0 0; padding: 0; }
|
||||
.receipt-list li { display: grid; grid-template-columns: 34px 1fr; gap: 14px; align-items: start; padding: 16px 18px; border-radius: 17px; background: var(--primitive-glass); }
|
||||
.receipt-list li > span { color: var(--accent-light); font: 11px ui-monospace, monospace; }
|
||||
.receipt-list li div { display: grid; gap: 7px; }
|
||||
.receipt-list b { color: var(--content-secondary); font-size: 12px; font-weight: 580; }
|
||||
.receipt-list small { color: var(--content-faint); font-size: 10px; }
|
||||
.empty-state { min-height: 260px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
|
||||
.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; }
|
||||
.update-state-card { display: flex; align-items: flex-start; gap: 15px; margin: 30px 0; padding: 20px 22px; border-radius: 20px; background: var(--primitive-glass); box-shadow: inset 0 1px var(--primitive-glass-top); }
|
||||
.update-state-card div { display: grid; gap: 6px; }
|
||||
.update-state-card b { color: var(--content-secondary); font-size: 12.5px; }
|
||||
.update-state-card span { color: var(--content-faint); font-size: 10.5px; line-height: 1.6; }
|
||||
.release-card { margin-top: 27px; }
|
||||
.release-version { display: grid; grid-template-columns: 1fr auto; align-items: baseline; gap: 6px 16px; }
|
||||
.release-version span { color: var(--content-faint); font-size: 10px; letter-spacing: .12em; }
|
||||
.release-version b { grid-row: 1 / 3; grid-column: 2; color: var(--accent-light); font-size: 30px; font-weight: 480; }
|
||||
.release-version small { color: var(--content-muted); font-size: 11px; }
|
||||
.release-card > p { color: var(--content-muted); font-size: 11.5px; line-height: 1.7; }
|
||||
.release-card ul { margin: 18px 0; padding-left: 18px; color: var(--content-secondary); font-size: 11px; line-height: 1.8; }
|
||||
.release-card .release-warning { color: var(--content-faint); font-size: 10px; }
|
||||
.recovery-card { display: grid; gap: 17px; margin-top: 27px; padding: 21px; border: 1px solid var(--primitive-line); border-radius: 21px; background: var(--primitive-glass); box-shadow: inset 0 1px var(--primitive-glass-top); }
|
||||
.recovery-card > p { margin: 0; color: var(--content-muted); font-size: 11.5px; line-height: 1.7; }
|
||||
.recovery-actions { display: flex; flex-wrap: wrap; gap: 11px; }
|
||||
|
||||
.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:hover, .theme-list button.selected { background: var(--primitive-glass-hover); }
|
||||
.theme-list span { display: grid; gap: 5px; }
|
||||
.theme-list b { font-size: 12.5px; font-weight: 590; white-space: nowrap; }
|
||||
.theme-list small, .theme-list em { color: var(--content-faint); font-size: 10px; font-style: normal; }
|
||||
.theme-list button.selected em { color: var(--accent-light); }
|
||||
.theme-swatch { width: 34px; height: 34px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(255,255,255,.18), 0 6px 18px var(--primitive-shadow); }
|
||||
.theme-swatch.night { background: linear-gradient(145deg, #0a1020, #04070d 70%); }
|
||||
.theme-swatch.dawn { background: linear-gradient(145deg, #f5f7fc, #d3e0ef 70%); }
|
||||
.theme-swatch.nebula { background: linear-gradient(145deg, #281b47, #080510 70%); }
|
||||
.theme-swatch.candle { background: linear-gradient(145deg, #4a2d13, #0a0703 70%); }
|
||||
.theme-swatch.clear { background: linear-gradient(145deg, #f1f5f8, #b9ccda 70%); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.home-content { grid-template-columns: 1fr; align-content: center; gap: 50px; padding-inline: 62px; }
|
||||
.hero { transform: none; }
|
||||
.status-cluster { width: 100%; grid-template-columns: repeat(3, 1fr); justify-self: stretch; gap: 26px; }
|
||||
}
|
||||
|
||||
@media (max-height: 640px) {
|
||||
.world-header, .world-footer { height: 62px; }
|
||||
.home-content { min-height: calc(100vh - 124px); }
|
||||
.hero h1 { font-size: clamp(36px, 5vw, 56px); }
|
||||
.hero-copy { margin-top: 18px; }
|
||||
.hero-actions { margin-top: 25px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
|
|
|
|||
1
product-source/hololake-native-desktop/src/vite-env.d.ts
vendored
Normal file
1
product-source/hololake-native-desktop/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# HoloLake 第一阶段桌面收束与能力审计
|
||||
|
||||
状态:`CURRENT_LOCAL_CONVERGENCE_CONTRACT · SOURCE_NOT_YET_PUBLISHED`
|
||||
|
||||
开发编号:`DEV-20260814-003`
|
||||
|
||||
机器投影:`routing/hololake-stage-one-desktop-convergence.json`
|
||||
|
||||
## 收束结论
|
||||
|
||||
HoloLake 桌面只保留一个未来正式产品主线:`product-source/hololake-native-desktop`,技术栈为 Tauri v2、Rust 和 React。桌面上后来出现的原生“第一阶段”应用是这条主线的本地候选验收壳,不是第三条产品线,也不代表第一阶段已经完成。
|
||||
|
||||
Electron 0.8.0 和旧 Tauri/平台实现不继续作为独立产品生长,但不能直接删除。Electron 仍承载可验证的知识树、页面、搜索、历史、导入和本地 Git 知识数据;旧 Tauri/平台仍包含工程合同、测试和原生边界。它们只能作为只读供体和受保护迁移来源。
|
||||
|
||||
## 能力取舍
|
||||
|
||||
原生候选已经实现的签名壳、本机 broker/session、PNCC 只读投影、人类确认式仓库挂载、回执、更新与回滚底座继续保留。动态路由、发布广播和更新服务器下沉为受门控基础设施,不再占用第一阶段首页和开发优先级。
|
||||
|
||||
第一阶段当前缺失的主体按固定顺序实现:
|
||||
|
||||
1. 个人频道身份—任务—事件—回执内核;
|
||||
2. 知识树、页面、搜索与本地持久化;
|
||||
3. 人类审批中心与 Git 证据回读;
|
||||
4. 小湖灯跨会话连续性投影;
|
||||
5. 签名安装、人工选择更新、回滚与可逆数据迁移。
|
||||
|
||||
## 数据与退出门
|
||||
|
||||
旧应用和数据只有在物理清单、可恢复备份、逐文件哈希回读、可逆迁移演练、新主线回归测试、签名安装验收、人类确认清理范围和可恢复归档回执全部通过后,才可以退出当前机器。任何一步未知,删除和替换均为 0。
|
||||
|
||||
## 事实分层
|
||||
|
||||
本页和机器合同只证明收束判断已经写入本地源码。它不证明仓库发布、产品主体实现、制品构建、签名、公证、桌面安装、数据迁移、服务器部署或服务健康。每一层必须有独立回执。
|
||||
72
routing/hololake-stage-one-desktop-convergence.json
Normal file
72
routing/hololake-stage-one-desktop-convergence.json
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
{
|
||||
"schema": "hololake.stage-one-desktop-convergence/v1",
|
||||
"record_id": "HLP-STAGE-ONE-DESKTOP-CONVERGENCE-001",
|
||||
"version": "2026-08-14.1",
|
||||
"state": "CURRENT_LOCAL_CONVERGENCE_CONTRACT_SOURCE_NOT_YET_PUBLISHED",
|
||||
"development_id": "DEV-20260814-003",
|
||||
"architecture_page": "product-source/hololake-platform/architecture/HOLOLAKE-STAGE-ONE-DESKTOP-CONVERGENCE-AND-CAPABILITY-AUDIT-20260814.md",
|
||||
"language_runtime_product_boundary": "product-source/hololake-native-desktop/contracts/language-runtime-product-update-boundary.json",
|
||||
"installed_capability_audit": "product-source/hololake-native-desktop/audit/stage-one-installed-product-capability-audit.json",
|
||||
"canonical_mainline": {
|
||||
"source": "product-source/hololake-native-desktop",
|
||||
"technology": "TAURI_V2_RUST_REACT",
|
||||
"parallel_product_lines_allowed": false,
|
||||
"installed_candidate_role": "LOCAL_ACCEPTANCE_CANDIDATE_NOT_SEPARATE_PRODUCT_LINE",
|
||||
"formal_stage_one_product_complete": false
|
||||
},
|
||||
"donors": [
|
||||
{
|
||||
"source": "product-source/hololake-platform",
|
||||
"role": "READ_ONLY_ENGINEERING_CONTRACT_TEST_AND_NATIVE_BOUNDARY_DONOR"
|
||||
},
|
||||
{
|
||||
"source": "product-source/hololake-desktop",
|
||||
"role": "READ_ONLY_KNOWLEDGE_UX_AND_BEHAVIOR_DONOR"
|
||||
},
|
||||
{
|
||||
"source": "product-source/guanghu-knowledge-base",
|
||||
"role": "READ_ONLY_KNOWLEDGE_UX_BEHAVIOR_AND_PROTECTED_DATA_DONOR"
|
||||
}
|
||||
],
|
||||
"keep_foundation": [
|
||||
"SIGNED_NATIVE_SHELL",
|
||||
"LOCAL_BROKER_AND_RESUMABLE_SESSION",
|
||||
"PNCC_READ_ONLY_PROJECTION",
|
||||
"HUMAN_CONFIRMED_REPOSITORY_MOUNT",
|
||||
"RECEIPTS",
|
||||
"FAIL_CLOSED_UPDATE_AND_ROLLBACK"
|
||||
],
|
||||
"under_lake_foundation_not_product_completion": [
|
||||
"DYNAMIC_CAPABILITY_ROUTING",
|
||||
"RELEASE_BROADCAST",
|
||||
"RELEASE_ACTIVATION_OPERATOR",
|
||||
"UPDATE_SERVER_FOUNDATION"
|
||||
],
|
||||
"stage_one_implementation_order": [
|
||||
"PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL",
|
||||
"KNOWLEDGE_TREE_PAGE_SEARCH_AND_LOCAL_PERSISTENCE",
|
||||
"HUMAN_APPROVAL_CENTER_AND_GIT_EVIDENCE_READBACK",
|
||||
"LAKE_LAMP_CROSS_SESSION_CONTINUITY_PROJECTION",
|
||||
"SIGNED_INSTALL_UPDATE_ROLLBACK_AND_REVERSIBLE_DATA_MIGRATION"
|
||||
],
|
||||
"donor_exit_requires": [
|
||||
"PHYSICAL_USER_DATA_INVENTORY",
|
||||
"RECOVERABLE_BACKUP",
|
||||
"FILE_BY_FILE_HASH_READBACK",
|
||||
"REVERSIBLE_DATA_MIGRATION_REHEARSAL",
|
||||
"NEW_TAURI_REGRESSION_TESTS",
|
||||
"SIGNED_INSTALLED_RUNTIME_ACCEPTANCE",
|
||||
"HUMAN_VISIBLE_CLEANUP_SCOPE_CONFIRMATION",
|
||||
"RECOVERABLE_ARCHIVE_RECEIPT"
|
||||
],
|
||||
"actions": {
|
||||
"application_deletion": "NOT_AUTHORIZED_NOT_EXECUTED",
|
||||
"application_replacement": "NOT_AUTHORIZED_NOT_EXECUTED",
|
||||
"user_data_migration": "NOT_EXECUTED",
|
||||
"repository_publication": "NOT_YET_PUBLISHED",
|
||||
"desktop_artifact_build": "NOT_EXECUTED_FOR_THIS_CONTRACT",
|
||||
"desktop_installation": "NOT_EXECUTED_FOR_THIS_CONTRACT",
|
||||
"server_deployment": "NOT_EXECUTED_FOR_THIS_CONTRACT"
|
||||
},
|
||||
"truth_policy": "ARCHITECTURE_SOURCE_ARTIFACT_SIGNATURE_INSTALLATION_DATA_MIGRATION_DEPLOYMENT_AND_HEALTH_REQUIRE_SEPARATE_RECEIPTS"
|
||||
}
|
||||
75
routing/hololake-stage-one-desktop-convergence.test.mjs
Normal file
75
routing/hololake-stage-one-desktop-convergence.test.mjs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const readJson = (relative) =>
|
||||
JSON.parse(fs.readFileSync(path.join(root, relative), "utf8"));
|
||||
|
||||
const convergence = readJson("routing/hololake-stage-one-desktop-convergence.json");
|
||||
const foundation = readJson("product-source/hololake-native-desktop/foundation.json");
|
||||
const boundary = readJson(
|
||||
"product-source/hololake-native-desktop/contracts/language-runtime-product-update-boundary.json",
|
||||
);
|
||||
const audit = readJson(
|
||||
"product-source/hololake-native-desktop/audit/stage-one-installed-product-capability-audit.json",
|
||||
);
|
||||
|
||||
test("one Tauri mainline absorbs the candidate without creating a third product line", () => {
|
||||
assert.equal(convergence.canonical_mainline.source, "product-source/hololake-native-desktop");
|
||||
assert.equal(convergence.canonical_mainline.technology, "TAURI_V2_RUST_REACT");
|
||||
assert.equal(convergence.canonical_mainline.parallel_product_lines_allowed, false);
|
||||
assert.equal(convergence.canonical_mainline.formal_stage_one_product_complete, false);
|
||||
assert.equal(foundation.parallel_product_line_created, false);
|
||||
assert.equal(foundation.stage_one_visible_body_state, "NOT_IMPLEMENTED");
|
||||
});
|
||||
|
||||
test("Electron and legacy sources remain protected donors until reversible migration passes", () => {
|
||||
assert.ok(
|
||||
convergence.donors.some(
|
||||
(donor) => donor.source === "product-source/hololake-desktop" && donor.role.includes("READ_ONLY"),
|
||||
),
|
||||
);
|
||||
assert.ok(convergence.donor_exit_requires.includes("FILE_BY_FILE_HASH_READBACK"));
|
||||
assert.ok(convergence.donor_exit_requires.includes("REVERSIBLE_DATA_MIGRATION_REHEARSAL"));
|
||||
assert.equal(audit.deletion_allowed, false);
|
||||
assert.equal(audit.replacement_allowed, false);
|
||||
assert.equal(audit.data_migration_executed, false);
|
||||
});
|
||||
|
||||
test("stage-one visible body precedes routing and release infrastructure", () => {
|
||||
assert.equal(
|
||||
convergence.stage_one_implementation_order[0],
|
||||
"PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL",
|
||||
);
|
||||
assert.equal(
|
||||
convergence.stage_one_implementation_order[1],
|
||||
"KNOWLEDGE_TREE_PAGE_SEARCH_AND_LOCAL_PERSISTENCE",
|
||||
);
|
||||
assert.ok(
|
||||
convergence.under_lake_foundation_not_product_completion.includes(
|
||||
"DYNAMIC_CAPABILITY_ROUTING",
|
||||
),
|
||||
);
|
||||
assert.equal(foundation.supporting_infrastructure_role, "UNDER_LAKE_FOUNDATION_NOT_PRODUCT_COMPLETION");
|
||||
});
|
||||
|
||||
test("language runtime and product engineering stay separate without sharing a formed private persona", () => {
|
||||
assert.equal(boundary.upstream.protocol, "GLS-0263");
|
||||
assert.equal(boundary.language_runtime_channel.private_formed_persona_shared, false);
|
||||
assert.equal(boundary.product_engineering_channel.may_rewrite_zero_core, false);
|
||||
assert.equal(boundary.product_engineering_channel.may_read_or_publish_private_persona_memory, false);
|
||||
assert.equal(boundary.human_impact_gate.visible_confirmable_rejectable_reversible_receipted, true);
|
||||
assert.equal(boundary.automatic_authority_granted, false);
|
||||
});
|
||||
|
||||
test("public contracts contain no machine-private absolute paths or personal content", () => {
|
||||
for (const value of [convergence, foundation, boundary, audit]) {
|
||||
const serialized = JSON.stringify(value);
|
||||
assert.equal(serialized.includes("/Users/"), false);
|
||||
assert.equal(serialized.includes("/Volumes/"), false);
|
||||
assert.equal(serialized.includes("bingshuolingdianyuanhe"), false);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue