docs: 将仓库代码注释统一为中文
CI / docs-check (push) Canceled after 0s
CI / backend-test (push) Canceled after 0s
CI / service-test (push) Canceled after 0s
CI / frontend-test (push) Canceled after 0s
CI / rust-core (push) Canceled after 0s
CI / docs-check (pull_request) Canceled after 0s
CI / backend-test (pull_request) Canceled after 0s
CI / service-test (pull_request) Canceled after 0s
CI / frontend-test (pull_request) Canceled after 0s
CI / rust-core (pull_request) Canceled after 0s

This commit is contained in:
2026-09-10 00:40:56 +08:00
parent 51c592841d
commit d703ab64e3
249 changed files with 707 additions and 900 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
// Usage: node scripts/generate-language-icons.mjs /path/to/@iconify-json/vscode-icons
// Source: @iconify-json/vscode-icons 1.2.76 (MIT). No runtime network requests.
// 用法:node scripts/generate-language-icons.mjs /path/to/@iconify-json/vscode-icons
// 来源:@iconify-json/vscode-icons 1.2.76MIT);运行时不会发起网络请求。
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { bundledLanguagesInfo } from 'shiki/langs'
@@ -36,7 +36,7 @@ const svgUrl = icon => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${item.width ?? data.width ?? 32} ${item.height ?? data.height ?? 32}">${item.body}</svg>`
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
}
let css = `/* Generated by scripts/generate-language-icons.mjs. VSCode Icons (MIT); see language-icons-LICENSE.txt. */\n${base} { display: flex; align-items: center; gap: 8px; }\n${base}::before { content: ''; flex: 0 0 20px; width: 20px; height: 20px; background: center / contain no-repeat ${svgUrl('default-file')}; }\n`
let css = `/* scripts/generate-language-icons.mjs 自动生成。VSCode Icons 采用 MIT 许可证;详情见 language-icons-LICENSE.txt */\n${base} { display: flex; align-items: center; gap: 8px; }\n${base}::before { content: ''; flex: 0 0 20px; width: 20px; height: 20px; background: center / contain no-repeat ${svgUrl('default-file')}; }\n`
for (const [icon, ids] of groups) {
css += ids.map(id => `${base}[data-language="${id}"]::before`).join(',\n') + ` { background-image: ${svgUrl(icon)}; }\n`
}
+1 -2
View File
@@ -53,7 +53,6 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Securit
[build-dependencies]
tauri-build = { version = "2", optional = true , features = [] }
# Cryptographic KDFs retain their production work factors in debug/test runs.
# Optimize dependencies rather than weakening those factors for local execution.
# 加密 KDF 在调试/测试运行中保留其生产工作因素。优化依赖关系,而不是削弱本地执行的这些因素。
[profile.dev.package."*"]
opt-level = 2
+7 -8
View File
@@ -1,4 +1,4 @@
//! Trusted Core process supervisor. The WebView never receives session material.
//! 可信的 Core 进程监管器;WebView 永远不会接触会话材料。
use command_group::{CommandGroup, GroupChild};
use hmac::{Hmac, Mac};
use serde::Deserialize;
@@ -14,7 +14,7 @@ use zeroize::Zeroizing;
type Result<T> = std::result::Result<T, String>;
pub type Broker = Arc<dyn Fn(&serde_json::Value) -> Result<serde_json::Value> + Send + Sync>;
/// The manifest is embedded in the Host at build time, never loaded from the installation.
/// 清单在构建时嵌入到 Host 中,从未从安装中加载。
pub fn verify_bundle(root: &Path, manifest: &str) -> Result<()> {
use sha2::Digest;
use std::collections::BTreeMap;
@@ -182,7 +182,7 @@ impl Drop for Session {
}
std::thread::sleep(Duration::from_millis(25));
}
// Kill the entire group even if its leader has exited.
// 即使主进程已经退出,也要终止整个进程组。
let _ = self.child.kill();
let _ = self.child.wait();
}
@@ -200,7 +200,7 @@ pub struct CoreSupervisor {
bundle_manifest: Option<String>,
}
/// Host-only request context; deliberately neither Serialize nor Debug.
/// Host 请求上下文;特意既不序列化也不调试。
pub struct RequestSession {
pub url: String,
pub authorization: Zeroizing<String>,
@@ -304,7 +304,7 @@ impl CoreSupervisor {
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
// Runtime requirements only; never copy Provider tokens or general PATH.
// 这里只复制运行时必需项,绝不复制提供商令牌或通用 PATH
for key in [
"SystemRoot",
"WINDIR",
@@ -323,7 +323,7 @@ impl CoreSupervisor {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000); // CREATE_NO_WINDOW
command.creation_flags(0x08000000); // 使用 CREATE_NO_WINDOW
}
let child = {
#[cfg(windows)]
@@ -397,8 +397,7 @@ impl CoreSupervisor {
}
continue;
}
// A child has no broker authority until its ready frame has
// passed the protocol, identity, generation, and HMAC checks.
// 子级在其就绪帧通过协议、身份、生成和 HMAC 检查之前没有代理权限。
if !activated {
break;
}
+18 -26
View File
@@ -1,7 +1,7 @@
//! Device-local Stronghold broker. No public IPC returns secret bytes.
//! 设备本地 Stronghold 代理;任何公开 IPC 都不会返回机密字节。
//!
//! Stronghold Store contains AEAD ciphertext, including while unlocked. Snapshot
//! and salt are one atomic envelope, so password changes cannot tear two files.
//! Stronghold 存储即使在解锁期间也只包含 AEAD 密文。快照与盐构成一个原子整体,
//! 避免密码变更使两个文件处于不一致状态。
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::{
aead::{Aead, Payload},
@@ -157,8 +157,7 @@ pub struct CredentialId {
}
impl CredentialId {
/// Preserve opaque legacy references. Hashed Plugin/MCP IDs remain isolated
/// from Provider IDs; only the trusted Core adapter can use these aliases.
/// 保留不透明的遗留引用。散列 Plugin/MCP ID 与提供商 ID 保持隔离;只有受信任的 Core 适配器才能使用这些别名。
pub fn legacy(id: &str) -> Self {
let scope = if let Some(owner) = id.strip_prefix("plugin.") {
Scope::Plugin(owner.into())
@@ -348,16 +347,14 @@ fn verify_migration_backup(
pub struct CredentialBroker {
path: PathBuf,
unlocked: Option<Unlocked>,
// Separate stable inode: snapshots are atomically replaced, so locking the
// snapshot itself would not protect the next writer after replacement.
// 单独的稳定索引节点:快照被原子替换,因此锁定快照本身不会保护替换后的下一个写入者。
ownership: Option<fs::File>,
lock_epoch: Arc<AtomicU64>,
unlocked_epoch: u64,
}
impl CredentialBroker {
/// Source comes from the native file picker, never a raw WebView path.
/// Import is idempotent; conflicting IDs stop the entire transaction.
/// 源来自本机文件选择器,而不是原始 WebView 路径。导入是幂等的;冲突的 ID 会停止整个事务。
pub fn import_fernet(
&mut self,
directory: &Path,
@@ -459,8 +456,7 @@ impl CredentialBroker {
}
}
fs::create_dir_all(&backup).map_err(|_| "MIGRATION_BACKUP_FAILED")?;
// Backups contain ciphertext; the legacy key is sealed under the already
// unlocked device key, rather than adding another plaintext master.key.
// 备份包含密文;旧密钥被密封在已解锁的设备密钥下,而不是添加另一个明文 master.key。
let mut nonce = [0u8; 12];
rand::rngs::OsRng
.try_fill_bytes(&mut nonce)
@@ -506,7 +502,7 @@ impl CredentialBroker {
state.state = "copied".into();
persist_state(&journal, &state)?;
checkpoint("copied")?;
// Re-open the committed Stronghold snapshot, not the in-memory cache.
// 重新打开提交的 Stronghold 快照,而不是内存缓存。
let envelope = fs::read(&self.path).map_err(|_| "MIGRATION_VERIFY_FAILED")?;
let mut temporary =
tempfile::NamedTempFile::new_in(&backup).map_err(|_| "MIGRATION_VERIFY_FAILED")?;
@@ -552,8 +548,7 @@ impl CredentialBroker {
result
}
/// Deletes only the verified legacy files and this migration's encrypted backup.
/// The native Host must obtain explicit user confirmation for source_sha256 first.
/// 仅删除已验证的旧文件和此迁移的加密备份。本机 Host 必须首先获得 source_sha256 的明确用户确认。
pub fn cleanup_fernet(
&mut self,
directory: &Path,
@@ -733,7 +728,7 @@ impl CredentialBroker {
let source = CredentialId::legacy(old);
let target =
CredentialId::legacy(new.as_str().ok_or("HOST_REQUEST_INVALID")?);
// ID migrations cannot change Provider/Plugin/MCP families.
// ID 迁移无法更改提供商/Plugin/MCP 系列。
if std::mem::discriminant(&source.scope)
!= std::mem::discriminant(&target.scope)
{
@@ -750,8 +745,7 @@ impl CredentialBroker {
moves.push((source_key, target_key, value));
}
}
// Reject cycles/overlapping source+destination rather than deleting
// a newly written value midway through a multi-ID migration.
// 拒绝循环/重叠源+目标,而不是在多 ID 迁移中途删除新写入的值。
if moves.iter().any(|(old, new, _)| {
old != new && moves.iter().any(|(source, _, _)| source == new)
}) {
@@ -879,7 +873,7 @@ impl CredentialBroker {
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.share_mode(0x1 | 0x2); // Do not allow replacing the held lock file.
options.share_mode(0x1 | 0x2); // 不允许替换保留的锁定文件。
}
#[cfg(unix)]
{
@@ -906,7 +900,7 @@ impl CredentialBroker {
let mut salt = [0u8; 32];
salt.copy_from_slice(&data[8..40]);
let session = Unlocked::derive(password, salt)?;
// Backups can be on read-only media. This temporary file contains ciphertext only.
// 备份可以位于只读介质上。该临时文件仅包含密文。
let mut temp = tempfile::NamedTempFile::new().map_err(|_| "CREDENTIAL_IO_FAILED")?;
temp.write_all(&data[40..])
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
@@ -921,7 +915,7 @@ impl CredentialBroker {
Ok(session)
}
/// Native picker selected destination; backup is encrypted and never overwrites.
/// 本机选择器选择的目的地;备份已加密并且永远不会覆盖。
pub fn backup(&self, destination: &Path) -> Result<()> {
self.session()?;
let parent = destination.parent().ok_or("CREDENTIAL_PATH_INVALID")?;
@@ -938,8 +932,7 @@ impl CredentialBroker {
Ok(())
}
/// Validate every record before atomic replacement; preserve the previous encrypted file.
/// Caller must obtain explicit confirmation through the native dialog.
/// 在原子替换之前验证每条记录;保留之前的加密文件。调用者必须通过本机对话框获得明确的确认。
pub fn restore(&mut self, source: &Path, password: Zeroizing<Vec<u8>>) -> Result<usize> {
if !self.is_locked() {
return Err("CREDENTIALS_MUST_LOCK".into());
@@ -976,7 +969,7 @@ impl CredentialBroker {
previous.keep().map_err(|_| "CREDENTIAL_IO_FAILED")?;
}
session.persist(&self.path)?;
// Restoration deliberately leaves the vault locked; no implicit permission grant.
// 恢复特意将金库锁定;没有隐式许可授予。
Ok(keys.len())
}
@@ -996,7 +989,7 @@ impl CredentialBroker {
.and_then(|_| session.persist(&self.path));
if result.is_err() {
self.lock();
} // Never serve uncommitted memory after disk failure.
} // 磁盘故障后切勿服务未提交的内存。
result
}
pub fn delete(&mut self, id: &CredentialId) -> Result<()> {
@@ -1011,8 +1004,7 @@ impl CredentialBroker {
}
result
}
/// Internal consumers must supply the scope established by the Host dispatcher.
/// This method must never be registered as a Tauri command.
/// 内部消费者必须提供 Host 调度程序建立的范围。此方法绝不能注册为 Tauri 命令。
pub fn resolve(&self, caller: &Scope, id: &CredentialId) -> Result<Option<Zeroizing<Vec<u8>>>> {
if caller != &id.scope {
return Err("CREDENTIAL_SCOPE_DENIED".into());
@@ -1,5 +1,5 @@
//! Host-memory call reviews. The UI/registry must establish actual user consent
//! before confirm; no renderer command or automatic-consent policy is added here.
//! Host 内存中的调用审核。UI 或注册表必须先确认用户确已授权,再执行确认;
//! 此处不提供渲染进程命令,也不设置自动授权策略。
use crate::{
extension_mcp_tools::{Description, Tool},
extension_permit::{Claims, ExecutionKind},
@@ -26,7 +26,7 @@ pub struct Identity {
execution_digest: String,
}
impl Identity {
/// Only called after launch permit/entry/context validation.
/// 仅在启动许可/条目/上下文验证后调用。
pub(crate) fn from_claims(claims: &Claims) -> Result<Self> {
let bytes = Zeroizing::new(
serde_json::to_vec(claims)
@@ -64,8 +64,7 @@ struct Pending {
expires: Instant,
bytes: usize,
}
/// An in-process, non-cloneable, non-serializable, single-consumption capability.
/// Tool name and arguments cannot be replaced after review confirmation.
/// 进程内、不可克隆、不可序列化、单次消耗功能。审核确认后,工具名称和参数无法更换。
pub struct ApprovedCall {
instance: String,
epoch: String,
@@ -127,7 +126,7 @@ impl Gate {
valid_for_seconds: 120,
})
}
/// The authenticated Host approval route must verify user consent first.
/// 经过身份验证的 Host 批准路线必​​须首先验证用户同意。
pub(crate) fn confirm(&mut self, review_id: &str) -> Result<ApprovedCall> {
let call = self
.pending
+1 -1
View File
@@ -1,4 +1,4 @@
//! Only the local main window may review Host trust or prepared installations.
//! 只有本地主窗口可以检查 Host 信任或准备的安装。
use super::Host;
use notesagent_host::extension_store::{ExtensionStore, InstallRequest, TrustSetting};
use serde::Deserialize;
+4 -5
View File
@@ -1,4 +1,4 @@
//! Signed, offline configuration schemas. Credentials belong to the vault broker.
//! 已签名的离线配置结构;凭据由 Vault 代理管理。
use crate::workspace::{HostError, Result};
use serde_json::{json, Value};
@@ -11,7 +11,7 @@ fn walk(value: &Value, depth: usize, nodes: &mut usize, schema: bool) -> Result<
Value::Object(map) => {
for (key, value) in map {
if schema && matches!(key.as_str(), "$ref" | "$dynamicRef" | "$recursiveRef") {
// Recursive/unbounded schema execution is not allowed in the Host.
// Host 中不允许递归/无界模式执行。
return Err(HostError::new("EXTENSION_CONFIG_REFERENCE"));
}
if !schema
@@ -44,8 +44,7 @@ fn walk(value: &Value, depth: usize, nodes: &mut usize, schema: bool) -> Result<
Ok(())
}
// Examine every applicable schema branch; an alternative branch must not
// turn a secret declaration back into persistable plaintext.
// 检查每个适用的模式分支;替代分支不得将秘密声明转回可持久的明文。
fn reject_secrets(schema: &Value, instance: &Value) -> Result<()> {
let Some(map) = schema.as_object() else {
return Ok(());
@@ -124,7 +123,7 @@ fn reject_secrets(schema: &Value, instance: &Value) -> Result<()> {
Ok(())
}
/// Schema is read from the verified manifest, never from the proposed configuration.
/// 架构是从已验证的清单中读取的,而不是从建议的配置中读取的。
pub fn validate(manifest: &Value, configuration: &Value) -> Result<()> {
if !configuration.is_object() {
return Err(HostError::new("EXTENSION_CONFIG_INVALID"));
+23 -33
View File
@@ -1,4 +1,4 @@
//! Per-instance AppContainer profile ownership. No existing profile is adopted.
//! 每个实例独占其 AppContainer 配置,不接管任何已有配置。
use crate::workspace::{HostError, Result};
use windows_sys::Win32::Security::{
FreeSid, IsValidSid,
@@ -34,7 +34,7 @@ impl Profile {
)
};
if status < 0 {
// In particular, ERROR_ALREADY_EXISTS must not transfer ownership.
// 特别是,ERROR_ALREADY_EXISTS 不得转让所有权。
if !sid.is_null() {
unsafe {
FreeSid(sid);
@@ -52,25 +52,22 @@ impl Profile {
}
Ok(profile)
}
/// Borrowed SID for SECURITY_CAPABILITIES. Valid only while this owner lives.
/// SECURITY_CAPABILITIES 借用的 SID,仅在此所有者对象存续期间有效。
pub fn sid(&self) -> PSID {
self.sid
}
/// Grant this instance read/execute access to one Host-owned package object.
/// The caller must open it without following reparse points and retain the
/// verified package handles for the entire launch. No recursive inheritance
/// is used: every directory and file must be checked and granted separately.
/// This adds an ACE; it does not sanitize pre-existing permissions.
/// 授予当前实例读取和执行一个 Host 所有包对象的权限。调用方打开对象时不得跟随重解析点,
/// 并须在整个启动期间持有已验证的包句柄。这里不使用递归继承,每个目录和文件都要分别检查、授权。
/// 此操作只会添加一条 ACE,不会清理已有权限。
pub fn grant_package_read_execute(&self, object: &std::fs::File) -> Result<()> {
self.update_package_access(object, false)
}
/// Remove only this freshly-created instance's allowed ACEs, using the
/// original held object handle. Other principals keep their current ACLs.
/// 使用最初持有的对象句柄,仅移除这个新实例对应的允许 ACE;其他安全主体的 ACL 保持不变。
pub fn revoke_package_access(&self, object: &std::fs::File) -> Result<()> {
self.update_package_access(object, true)
}
fn update_package_access(&self, object: &std::fs::File, revoke: bool) -> Result<()> {
// Serialize Host read/merge/write operations across concurrent instances.
// 跨并发实例序列化 Host 读/合并/写操作。
let _lock = PACKAGE_ACL_LOCK
.lock()
.map_err(|_| HostError::new("EXTENSION_CONTAINER_ACL_FAILED"))?;
@@ -125,8 +122,7 @@ impl Profile {
)
};
let _descriptor = LocalAllocation(descriptor);
// A null DACL grants everyone full access, so fail closed rather than
// silently treating it as a suitably isolated package object.
// 空 DACL 会向所有人授予完全访问权限,因此这里必须拒绝处理,不能误判为已妥善隔离的包对象。
if status != 0 || old_acl.is_null() {
return Err(HostError::new("EXTENSION_CONTAINER_ACL_FAILED"));
}
@@ -205,7 +201,7 @@ impl Profile {
}
result
}
/// Stop all container processes and close their handles before removal.
/// 在删除之前停止所有容器进程并关闭其句柄。
pub fn remove(mut self) -> Result<()> {
self.remove_inner()
}
@@ -221,7 +217,7 @@ impl Profile {
}
impl Drop for Profile {
fn drop(&mut self) {
// Explicit remove reports failures; drop is a final best-effort retry.
// 显式删除报告失败; drop 是最后的尽力重试。
let _ = self.remove_inner();
if !self.sid.is_null() {
unsafe {
@@ -302,7 +298,7 @@ mod tests {
assert_eq!(unsafe { EqualSid(one.sid(), two.sid()) }, 0);
let name = String::from_utf16(&one.name[..one.name.len() - 1]).unwrap();
assert!(Profile::create_named(name.clone()).is_err());
// Repeated collision must still fail: the failing owner did not delete it.
// 重复发生名称冲突时仍须失败:创建失败的一方并不拥有该配置,也无权删除它。
assert!(Profile::create_named(name.clone()).is_err());
one.remove().unwrap();
Profile::create_named(name).unwrap().remove().unwrap();
@@ -362,8 +358,7 @@ mod tests {
profile.remove().unwrap();
}
// Only tests use cmd.exe, with fixed commands and controlled temporary paths.
// A production extension launcher must use a verified entry, never a shell.
// 只有测试会通过 cmd.exe 运行固定命令和受控临时路径;生产扩展启动器必须使用已验证入口,不能调用 shell。
fn checked_process(profile: &Profile, command: Option<&str>) -> Option<u32> {
let executable = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap())
.join("System32/cmd.exe");
@@ -391,7 +386,7 @@ mod tests {
if let Some(data) = data.take() {
let suspended =
crate::extension_process::Suspended::create(profile, executable, data).unwrap();
// Controlled test fixture only; no user extension is authorized here.
// 仅用于受控测试夹具;此处没有授权任何用户扩展。
let running = unsafe { suspended.resume().unwrap() };
let result = running.wait(std::time::Duration::from_secs(10)).unwrap();
assert!(result.is_some());
@@ -612,7 +607,7 @@ mod tests {
let read = format!("set /p value=<\"{}\"", payload.display());
assert_ne!(checked_process(&profile, Some(&read)), Some(0));
profile.grant_package_read_execute(&root_handle).unwrap();
// The directory ACE does not propagate to existing children.
// 目录 ACE 不会传播到现有子级。
assert_ne!(checked_process(&profile, Some(&read)), Some(0));
profile.grant_package_read_execute(&file_handle).unwrap();
assert_eq!(checked_process(&profile, Some(&read)), Some(0));
@@ -759,8 +754,7 @@ mod tests {
profile.grant_package_read_execute(&root).unwrap();
profile.grant_package_read_execute(&entry).unwrap();
}
// Exercise the actual builder, not a test-side quote decoder. The child
// compares argv and its entire environment without logging values.
// 测试真实构建器,而不是在测试侧另写引号解码器;子进程会比较 argv 和完整环境,但不会记录具体值。
let args = [
"launch",
"",
@@ -822,9 +816,8 @@ mod tests {
vault_id: uuid::Uuid::new_v4().to_string(),
platform: "windows".into(),
policy_version: "1".into(),
// This probe checks argv/environment, not expiry. Keep its permit
// longer than the bounded process observation under parallel load.
// The dedicated expiry probe below still uses a two-second lease.
// 此探测器检查 argv 与环境,不验证过期行为。许可有效期须覆盖并行负载下的有界进程观测;
// 下方专用的过期探测仍使用两秒租期。
expires_at_ms: 120_000,
};
broker
@@ -858,8 +851,7 @@ mod tests {
running.active_test_processes().ok(),
);
drop(running);
// Actual native RPC: the child cannot name an identity or connect to
// a shared endpoint; only its own stdio pipe reaches this broker.
// 实际本机 RPC:子进程无法命名身份或连接到共享端点;只有它自己的 stdio 管道才能到达该代理。
{
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
@@ -1335,7 +1327,7 @@ mod tests {
"expiry" => {}
_ => drop(issuer),
}
// The monitor must act without check_authorization or tool polling.
// 监视器必须在没有 check_authorization 或工具轮询的情况下运行。
assert!(running
.wait(std::time::Duration::from_secs(5))
.unwrap()
@@ -1397,7 +1389,7 @@ mod tests {
let deadline = running
.start_test_tool_call(std::time::Duration::from_millis(100))
.unwrap();
// No check() or finish() drives expiration; wait only on the OS process.
// 过期处理不依赖 check() finish() 驱动;这里只等待 OS 进程退出。
assert!(running
.wait(std::time::Duration::from_secs(5))
.unwrap()
@@ -1444,7 +1436,7 @@ mod tests {
("udp", udp.local_addr().unwrap()),
] {
let address = address.to_string();
// The exact executable and target work outside containment.
// 验证同一个可执行文件与目标在容器隔离之外能够正常工作。
assert!(std::process::Command::new(&executable)
.args([mode, &address])
.status()
@@ -1470,9 +1462,7 @@ mod tests {
&std::collections::BTreeMap::new(),
)
.unwrap();
// Loopback isolation can silently drop packets. TCP must
// explicitly report denial or timeout; UDP send may succeed,
// but no datagram may reach the controlled listener below.
// 环回隔离可以静默丢弃数据包。 TCP必须明确报告拒绝或超时; UDP 发送可能会成功,但没有数据报可能到达下面的受控侦听器。
let exit = checked_executable_data(&profile, &executable, None, Some(data));
eprintln!("container network probe {mode} {address}: {exit:?}");
if mode == "tcp" {
+4 -8
View File
@@ -1,4 +1,4 @@
//! Per-tool-call deadline. A persistent server does not have a 60-second lifetime.
//! 每个工具调用的截止时间。持久服务器的生命周期不是 60 秒。
use crate::{
extension_job::Job,
workspace::{HostError, Result},
@@ -18,10 +18,7 @@ struct State {
wake: Condvar,
outcome: AtomicU8,
}
/// Host-owned guard, created before dispatching a tool call. Finish it only when
/// the call completes. Expiration kills the entire instance group even if the
/// caller never polls. Dropping without finish aborts the instance; only explicit
/// successful completion cancels the timer while preserving the server.
/// Host 拥有的防护,在调度工具调用之前创建。仅当呼叫完成时才完成。即使调用者从不轮询,过期也会杀死整个实例组。未完成就丢弃会中止实例;只有显式成功完成才能取消计时器,同时保留服务器。
pub struct ToolDeadline {
state: Arc<State>,
job: Arc<Job>,
@@ -91,8 +88,7 @@ impl ToolDeadline {
_ => Ok(()),
}
}
/// Completion cannot cancel an already elapsed budget, even if the timer
/// thread has not yet been scheduled to observe expiration.
/// 完成无法取消已用完的预算,即使尚未安排计时器线程来观察到期情况。
pub fn finish(mut self) -> Result<()> {
self.stop();
match self.state.outcome.load(Ordering::Acquire) {
@@ -102,7 +98,7 @@ impl ToolDeadline {
_ => Err(HostError::new("EXTENSION_TOOL_DEADLINE_EXCEEDED")),
}
}
/// Explicit abandonment terminates the instance and reports kill failures.
/// 显式放弃终止实例并报告终止失败。
pub fn cancel(mut self) -> Result<()> {
let result = self.job.terminate();
self.stop();
@@ -1,4 +1,4 @@
//! Deterministic, bounded dependency planning; no mutation, activation or automatic downloads.
//! 确定性、有界依赖规划;没有突变、激活或自动下载。
use crate::{
extension_package::Release,
workspace::{hash, HostError, Result},
@@ -410,7 +410,7 @@ mod tests {
resolve(&candidates).unwrap_err().code,
"EXTENSION_DEPENDENCY_COMPLEXITY"
);
// A valid-size graph with oversized permission metadata must not create an unbounded IPC result.
// 节点数合法但权限元数据过大的依赖图,也不能产生无界的 IPC 结果。
let mut wide = vec![candidate("root", "1.0.0", &[])];
for i in 0..100 {
let id = format!("leaf-{i:03}");
@@ -421,7 +421,7 @@ mod tests {
.collect();
wide.push(leaf);
}
// Split the direct dependencies to respect the per-release 64-entry limit.
// 拆分直接依赖项以遵守每个版本 64 个条目的限制。
let second = wide[0].release.dependencies.split_off("leaf-050");
wide[0]
.release
@@ -1,6 +1,4 @@
//! Instance-bound file RPC policy. Transport must bind one Broker to one
//! authenticated instance. Write commits currently use Workspace's transaction;
//! full handle-relative write hardening is required before untrusted activation.
//! 实例绑定文件 RPC 策略。 Transport 必须将一个 Broker 绑定到一个经过身份验证的实例。写入提交当前使用 Workspace 的事务;在不受信任的激活之前,需要完全的句柄相关的写强化。
use crate::{
credentials::CredentialBroker,
extension_permit::{Authority, Claims, Lease, Permit},
@@ -45,7 +43,7 @@ pub struct Broker {
requests: u32,
}
impl Broker {
/// Called by the Host after instance identity binding; never an IPC command.
/// 实例身份绑定后由Host调用;绝不是 IPC 命令。
pub fn bind(
authority: &Authority,
permit: &Permit,
@@ -108,8 +106,7 @@ impl Broker {
}
self.lease.check()
}
/// The transport must apply MAX_FRAME_BYTES while reading, before allocation.
/// Hold the Host workspace lock throughout dispatch and commit.
/// 传输在分配之前读取时必须应用 MAX_FRAME_BYTES。在整个调度和提交过程中保持 Host 工作区锁。
pub fn dispatch(&mut self, workspace: &mut Workspace, bytes: &[u8]) -> Result<Value> {
self.lease.check()?;
if workspace.vault_id != self.vault {
@@ -295,7 +292,7 @@ mod tests {
call(&mut broker, &mut ws, altered).unwrap_err().code,
"OPERATION_PAYLOAD_CONFLICT"
);
// Build a stale CAS with a genuinely new operation ID.
// 使用真正的新操作 ID 构建过期的 CAS。
let mut stale = write.clone();
stale["operation_id"] = json!(uuid::Uuid::new_v4().to_string());
assert_eq!(
+3 -4
View File
@@ -1,5 +1,5 @@
//! Native instance workers. Construct/drop this manager off the UI thread.
//! Production callers must still satisfy the complete launch policy contract.
//! 原生实例工作线程。必须在 UI 线程之外创建和销毁此管理器;
//! 生产调用方仍须满足完整的启动策略约定。
use crate::{
credentials::CredentialBroker,
extension_call_authorization::{Identity, Review},
@@ -46,8 +46,7 @@ pub struct LaunchSpec {
pub vault_id: String,
pub policy_version: String,
pub system_root: PathBuf,
/// Revalidate active install/current trust and all external policy immediately
/// before resume, after expensive package checks. Errors prohibit execution.
/// 在昂贵的软件包检查之后,在恢复之前立即重新验证活动安装/当前信任和所有外部策略。错误禁止执行。
pub before_resume: ResumeCheck,
}
#[derive(Clone, Copy, Serialize, PartialEq, Eq, Debug)]
+1 -2
View File
@@ -1,5 +1,4 @@
//! Bounded IO for Host-created anonymous pipes. Never run shutdown on the UI
//! thread: cancellation waits for the native pipe operations to acknowledge it.
//! 用于 Host 创建的匿名管道的有界 IO。切勿在 UI 线程上运行关闭:取消等待本机管道操作确认它。
use crate::{
extension_job::Job,
extension_stdio::{write_frame, Frames, HostIo, MAX_FRAME_BYTES},
+5 -5
View File
@@ -1,4 +1,4 @@
//! Windows resource containment only. This is NOT a filesystem/network sandbox.
//! 仅负责 Windows 资源隔离,不构成文件系统或网络沙箱。
use crate::workspace::{HostError, Result};
use std::{
mem::size_of,
@@ -91,11 +91,11 @@ impl Job {
}
Ok(())
}
/// Attach before any extension instruction executes. No breakaway flags are enabled.
/// 在任何扩展指令执行之前附加。没有启用任何分离标志。
///
/// # Safety
/// Caller must own an unresumed CREATE_SUSPENDED process and terminate it on
/// any error. Resume only after all AppContainer/handle/permission checks pass.
/// # 安全性
/// 调用方必须拥有尚未恢复执行的 CREATE_SUSPENDED 进程,并在出现任何错误时终止该进程。
/// 只有 AppContainer、句柄与权限检查全部通过后,才能恢复执行。
pub unsafe fn assign_suspended(&self, process: BorrowedHandle<'_>) -> Result<()> {
self.check_resources()?;
if unsafe { AssignProcessToJobObject(self.handle.as_raw_handle(), process.as_raw_handle()) }
@@ -1,5 +1,5 @@
//! Derive launch bytes from a verified permit and Host-bound entry/context.
//! This preparation step does not authorize resume or establish sandbox readiness.
//! 根据已验证许可及 Host 绑定的入口和上下文生成启动数据;
//! 此准备步骤既不授权恢复执行,也不表示沙箱已经就绪。
use crate::{
credentials::{CredentialBroker, CredentialId, Scope},
extension_launch_data::LaunchData,
@@ -11,7 +11,7 @@ use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, path::Path, sync::atomic::Ordering};
use zeroize::Zeroize;
/// Only the Host's selected workspace, policy and container supply these values.
/// 只有 Host 选定的工作区、策略和容器提供这些值。
pub struct Context<'a> {
pub vault_id: &'a str,
pub policy_version: &'a str,
@@ -80,9 +80,7 @@ impl PreparedLaunch {
}
}
impl<'a> LeasedSuspended<'a> {
/// # Safety
/// Live trust, active installation, broker and all sandbox resource policy
/// requirements must also hold. A lease does not establish those conditions.
/// # Safety Live 信任、主动安装、代理和所有沙箱资源策略要求也必须满足。租约不规定这些条件。
pub unsafe fn resume(self) -> Result<crate::extension_process::Running<'a>> {
unsafe { self.process.resume_with_lease(self.lease, self.identity) }
}
@@ -146,8 +144,7 @@ impl Context<'_> {
now_ms: u64,
) -> Result<PreparedLaunch> {
let mut lease = authority.lease(permit, claims, now_ms)?;
// Locking the Host session gates all third-party execution, including
// packages that do not request environment secrets.
// 锁定 Host 会话会限制所有第三方执行,包括不请求环境机密的包。
lease.bind_credential(broker.lock_signal());
if broker.is_locked() {
return Err(HostError::new("CREDENTIALS_LOCKED"));
@@ -163,8 +160,7 @@ impl Context<'_> {
tree: entry.tree_sha256().to_owned(),
})
}
/// Caller must still recheck live trust/permit/session state immediately
/// before resume; returning encoded data is not an execution lease.
/// 调用者仍必须在恢复之前立即重新检查实时信任/许可/会话状态;返回编码数据不是执行租约。
fn build(
&self,
authority: &Authority,
@@ -1,4 +1,4 @@
//! Native argv/environment encoding. This does not authorize or launch a process.
//! 本机 ​​argv/环境编码。这不会授权或启动进程。
use crate::workspace::{HostError, Result};
use std::{collections::BTreeMap, os::windows::ffi::OsStrExt, path::Path};
use zeroize::Zeroize;
@@ -14,9 +14,7 @@ impl Drop for LaunchData {
}
}
impl LaunchData {
/// The Host supplies verified absolute paths and explicitly declared/resolved
/// environment values. Never reads the parent environment. CRT argv rules
/// apply to native executables, not cmd.exe, batch files or shell interpreters.
/// Host 提供经过验证的绝对路径和显式声明/解析的环境值。从不读取父环境。 CRT argv 规则适用于本机可执行文件,而不是 cmd.exe、批处理文件或 shell 解释器。
pub fn new(
executable: &Path,
arguments: &[String],
@@ -50,8 +48,7 @@ impl LaunchData {
if argument.len() > 8192 || argument.contains('\0') {
return Err(bad());
}
// Reserve the full bounded buffers once: do not leave earlier
// copies of resolved values behind through Vec reallocations.
// 保留一次完整的有界缓冲区:不要通过 Vec 重新分配留下解析值的早期副本。
let mut encoded_len = 0;
let mut trailing = 0;
for unit in argument.encode_utf16() {
@@ -94,8 +91,7 @@ impl LaunchData {
if result.command.len() > 32767 {
return Err(bad());
}
// ASCII names give a deterministic Windows case-insensitive order.
// Values remain borrowed until encoded so there are no secret clones.
// ASCII 名称给出确定性的 Windows 不区分大小写的顺序。值在编码之前一直是借用的,因此不存在秘密克隆。
let mut fields: BTreeMap<String, &std::ffi::OsStr> = BTreeMap::new();
for (name, path) in [
("SYSTEMROOT", system_root),
@@ -146,7 +142,7 @@ impl LaunchData {
pub(crate) fn command_mut(&mut self) -> &mut [u16] {
&mut self.command
}
/// Pass with CREATE_UNICODE_ENVIRONMENT; never substitute a null pointer.
/// 通过CREATE_UNICODE_ENVIRONMENT;切勿替换空指针。
pub fn environment(&self) -> &[u16] {
&self.environment
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Bounded declarative manifest inspection. No includes, environment interpolation or code execution.
//! 有界声明性清单检查。无包含、环境插值或代码执行。
use crate::{
extension_package::{Inventory, Release},
workspace::{HostError, Result},
+1 -2
View File
@@ -1,5 +1,4 @@
//! Serial MCP session over an already-authorized native instance. The Host
//! approval route still must establish user consent and current installation/trust.
//! 通过已授权的本机实例进行串行 MCP 会话。 Host 批准途径仍必须建立用户同意和当前安装/信任。
use crate::{
extension_io::{Event, Pump},
extension_process::Running,
@@ -1,5 +1,4 @@
//! Bounded, offline MCP tool contracts. Descriptions/annotations are untrusted
//! data and never confer permissions. URI content is validated, never fetched.
//! 有界、离线 MCP 工具约定。描述/注释是不受信任的数据,永远不会授予权限。 URI 内容经过验证,从未获取。
use crate::workspace::{HostError, Result};
use base64::Engine;
use serde::Serialize;
+7 -8
View File
@@ -1,4 +1,4 @@
//! Offline verification primitives. Passing these checks does not authorize installation or execution.
//! 离线验证原语。通过这些检查并不意味着授权安装或执行。
use crate::workspace::{hash, HostError, Result};
use base64::{engine::general_purpose::STANDARD, Engine};
use ed25519_dalek::{Signature, VerifyingKey};
@@ -66,7 +66,7 @@ fn version(s: &str) -> bool {
})
}
impl Release {
/// Full offline package check. Online revocation freshness and runtime permissions remain Host responsibilities.
/// 完整执行离线包检查;在线撤销信息的时效性与运行时权限仍由 Host 负责。
pub fn verify_package(
&self,
pinned: &[u8; 32],
@@ -165,7 +165,7 @@ impl Release {
}
Ok(canonical(&value).into_bytes())
}
/// `pinned` must come from the Host trust store, never from the archive or a WebView assertion.
/// `pinned` 必须来自 Host 信任存储,不能取自归档内容或 WebView 声明。
pub fn verify(
&self,
pinned: &[u8; 32],
@@ -204,8 +204,8 @@ pub struct Inventory {
pub expanded_size: u64,
pub manifest: String,
}
// ZipArchive stores names in a map and can hide duplicate central entries. Inspect the
// bounded central directory before handing the archive to its decompressor.
// ZipArchive 使用映射保存名称,可能掩盖重复的中央目录条目。因此在将归档交给解压器前,
// 必须先检查有界的中央目录。
fn directory(bytes: &[u8], max_entries: usize) -> Result<()> {
let bad = || HostError::new("EXTENSION_ZIP_INVALID");
let u16at = |p: usize| -> Result<usize> {
@@ -311,8 +311,7 @@ fn path(name: &str) -> Result<String> {
}
Ok(name.to_owned())
}
/// Checks all bytes (including CRC), without creating any package files.
/// Type-specific manifest schema/identity validation must follow before staging.
/// 检查全部字节(包括 CRC),且不创建任何包文件。暂存前还必须执行对应类型的清单结构与身份校验。
pub fn inspect(release: &Release, bytes: &[u8]) -> Result<Inventory> {
release.validate()?;
if bytes.len() as u64 != release.size || hash(bytes) != release.sha256 {
@@ -340,7 +339,7 @@ pub fn inspect(release: &Release, bytes: &[u8]) -> Result<Inventory> {
.by_index(index)
.map_err(|_| HostError::new("EXTENSION_ZIP_INVALID"))?;
let name = path(file.name())?;
// Include current Rust Unicode case mappings as well as full multi-character folds.
// 包括当前的 Rust Unicode 大小写映射以及完整的多字符折叠。
let folded: String = name
.case_fold()
.flat_map(char::to_uppercase)
+7 -10
View File
@@ -1,5 +1,5 @@
//! Host-only execution permit binding. No IPC caller can mint these permits.
//! The installer must complete user consent and current trust checks before issue.
//! Host 可以绑定执行许可,IPC 调用方无法伪造许可;
//! 安装器签发许可前必须完成用户授权与当前信任检查。
use crate::workspace::{HostError, Result};
use hmac::{Hmac, Mac};
use rand::RngCore;
@@ -19,7 +19,7 @@ use zeroize::Zeroize;
#[serde(tag = "kind", content = "value", deny_unknown_fields)]
pub enum Environment {
Literal(String),
// An opaque credential reference in the Host-derived package scope, never plaintext.
// Host 派生包范围中的不透明凭证引用,绝不是明文。
CredentialScope(String),
}
@@ -51,8 +51,7 @@ pub struct Claims {
pub expires_at_ms: u64,
}
/// Opaque authenticator; the Host retains claims separately. No paths, arguments
/// or credential declarations need to be passed to a renderer with the token.
/// 不透明验证器; Host 保留单独的权利要求。不需要使用令牌将路径、参数或凭据声明传递给渲染器。
pub struct Permit {
mac: [u8; 32],
generation: u64,
@@ -198,15 +197,14 @@ impl Claims {
}
}
impl Authority {
/// Host event wiring only; never expose this signal through IPC.
/// Host 事件接线;切勿通过 IPC 暴露此信号。
pub fn revocation_signal(&self) -> Arc<AtomicU64> {
Arc::clone(&self.generation)
}
pub fn revoke(&self) {
self.generation.fetch_add(1, Ordering::SeqCst);
}
/// Call only after consent and live trust validation. This authenticates the
/// decision; it does not establish sandbox availability or grant broker access.
/// 仅在同意和实时信任验证后才能调用。这证实了该决定;它不会建立沙箱可用性或授予代理访问权限。
pub fn issue(&self, claims: &Claims, now_ms: u64) -> Result<Permit> {
let generation = self.generation.load(Ordering::SeqCst);
let encoded = claims.encoded(now_ms)?;
@@ -250,8 +248,7 @@ impl Authority {
lease.check()?;
Ok(lease)
}
/// Lock/logout/policy invalidation may discard all permits. Restart creates a
/// fresh key, so an old process token cannot silently revive authorization.
/// 锁定/注销/策略失效可能会丢弃所有许可。重新启动会创建一个新密钥,因此旧进程令牌无法静默恢复授权。
pub fn invalidate_all(&mut self) {
self.revoke();
self.key.zeroize();
+6 -12
View File
@@ -1,5 +1,4 @@
//! Windows package handles retained across verification and launch. This pins
//! existing objects; it is not a read-only filesystem mount.
//! Windows 包句柄在验证和启动过程中保留。这会固定现有对象;它不是只读文件系统挂载。
use crate::{
extension_container::Profile,
extension_package::Inventory,
@@ -15,8 +14,8 @@ pub struct PinnedPackage {
files: BTreeMap<String, File>,
tree_sha256: String,
}
/// Scoped ACL ownership, created before any mutation. Release only after all
/// instance processes/handles have closed; drop retries cleanup on error/unwind.
/// 在任何变更前建立限定作用域的 ACL 所有权。只有所有实例进程与句柄均已关闭后才能释放;
/// 若发生错误或栈展开,Drop 会再次尝试清理。
pub struct PackageAccess<'a> {
package: &'a PinnedPackage,
profile: &'a Profile,
@@ -119,9 +118,7 @@ fn directory(parent: &Dir, name: &str) -> Result<Dir> {
Ok(Dir::from_std_file(handle))
}
impl PinnedPackage {
/// `root` and inventory originate from the verified Host store. Keep this
/// owner until the instance stops; no renderer-supplied filesystem path is
/// accepted here. Callers must also constrain ancestors used by native launch.
/// `root` 与清单来自已验证的 Host 存储。实例停止前必须保留此所有者;此处不接受渲染进程提供的文件系统路径。调用方还必须限制原生启动所使用的祖先目录。
pub fn open(root: &Dir, inventory: &Inventory, expected_tree: &str) -> Result<Self> {
let bad = || HostError::new("EXTENSION_STORE_CORRUPT");
if inventory.files.is_empty()
@@ -179,8 +176,7 @@ impl PinnedPackage {
}
pinned.files.insert(path.clone(), handle);
}
// All existing objects are already pinned when verification reopens
// them. Sharing violations or hash mismatches release the entire set.
// 当验证重新打开它们时,所有现有对象都已被固定。共享违规或哈希不匹配会释放整个集合。
pinned.tree_sha256 =
crate::extension_unpack::verify_tree(&pinned.directories[""], inventory)?;
if pinned.tree_sha256 != expected_tree {
@@ -188,9 +184,7 @@ impl PinnedPackage {
}
Ok(pinned)
}
/// Resolve through the owned file handle, then pin the volume-rooted path
/// component by component and compare native file identity. No drive-letter
/// or UNC fallback is permitted if volume GUID lookup is unavailable.
/// 通过拥有的文件句柄进行解析,然后逐个组件固定卷根路径并比较本机文件标识。如果卷 GUID 查找不可用,则不允许驱动器号或 UNC 回退。
pub fn bind_entry(&self, name: &str) -> Result<BoundEntry<'_>> {
use std::{
os::windows::fs::OpenOptionsExt as _,
+9 -14
View File
@@ -1,5 +1,4 @@
//! Windows process ownership primitive. A suspended process is not execution
//! authorization; the extension runtime must complete its checks before resume.
//! Windows 进程所有权原语。暂停的进程不是执行授权;扩展运行时必须在恢复之前完成其检查。
use crate::{
extension_container::Profile,
extension_job::Job,
@@ -51,8 +50,7 @@ impl Attributes {
return Err(bad());
}
value.initialized = true;
// The attribute stores a pointer to SECURITY_CAPABILITIES. The caller
// updates it with storage that remains alive until CreateProcessW.
// 该属性存储指向SECURITY_CAPABILITIES的指针。调用者使用在 CreateProcessW 之前保持活动状态的存储来更新它。
Ok(value)
}
}
@@ -223,8 +221,7 @@ impl<'a> Suspended<'a> {
value.0._bound_entry = Some(entry);
Ok(value)
}
/// Create instance-specific stdio without exposing an address or trusting a
/// self-reported process/package identity. Host endpoints are never inherited.
/// 创建特定于实例的 stdio,而不暴露地址或信任自我报告的进程/包身份。 Host 端点永远不会被继承。
#[cfg(feature = "desktop")]
pub fn create_bound_with_stdio(
profile: &'a Profile,
@@ -254,9 +251,8 @@ impl<'a> Suspended<'a> {
identity: None,
})
}
/// # Safety
/// The same complete resource/broker/trust preconditions as resume apply.
/// This additionally arms revocation monitoring before any instruction resumes.
/// # 安全性
/// 必须满足与 resume 相同的完整资源、代理与信任前提;此外还要在恢复执行任何指令前启用撤销监控。
#[cfg(feature = "desktop")]
pub(crate) unsafe fn resume_with_lease(
self,
@@ -311,8 +307,7 @@ impl Running<'_> {
pub(crate) fn active_test_processes(&self) -> Result<u32> {
self.process.job.active_processes()
}
/// Arm before dispatching a tool request; finish after receiving its result.
/// Failure to arm must prevent dispatch. This does not time server lifetime.
/// 发送工具请求前启动计时,收到结果后结束。若启动计时失败,必须阻止调度;该计时不限制服务器生命周期。
pub fn start_tool_call(&self) -> Result<crate::extension_deadline::ToolDeadline> {
self.check_authorization()?;
crate::extension_deadline::ToolDeadline::arm(&self.process.job)
@@ -324,7 +319,7 @@ impl Running<'_> {
) -> Result<crate::extension_deadline::ToolDeadline> {
crate::extension_deadline::ToolDeadline::arm_test(&self.process.job, budget)
}
/// A bounded observation only. The runtime must enforce the tool deadline.
/// 此处只进行有界观测;工具截止时间必须由运行时强制执行。
pub fn wait(&self, timeout: Duration) -> Result<Option<u32>> {
let milliseconds = u32::try_from(timeout.as_millis())
.ok()
@@ -347,7 +342,7 @@ impl Running<'_> {
_ => Err(HostError::new("EXTENSION_PROCESS_WAIT_FAILED")),
}
}
/// Terminates the entire managed group, including descendants.
/// 终止整个托管组,包括后代。
pub fn terminate(&self) -> Result<()> {
self.process.job.terminate()
}
@@ -458,7 +453,7 @@ mod tests {
unsafe { WaitForSingleObject(observer.as_raw_handle(), 0) },
WAIT_TIMEOUT
);
drop(suspended); // Never resumed any command interpreter instruction.
drop(suspended); // 从未恢复任何命令解释器指令。
assert_eq!(
unsafe { WaitForSingleObject(observer.as_raw_handle(), 5000) },
WAIT_OBJECT_0
@@ -1,4 +1,4 @@
//! Native instance monitor; revocation does not depend on the caller polling.
//! 本机实例监视器;撤销不依赖于调用者轮询。
use crate::{
extension_job::Job,
extension_permit::Lease,
+3 -4
View File
@@ -1,5 +1,4 @@
//! Per-launch anonymous pipes. Only child ends enter the explicit inheritance
//! list. The runtime owns Host ends and must bound frames and cancel blocked IO.
//! 每次启动的匿名管道。只有子端进入显式继承列表。运行时拥有 Host 端,必须绑定帧并取消阻塞的 IO。
use crate::workspace::{HostError, Result};
#[cfg(any(feature = "desktop", test))]
use std::os::windows::io::FromRawHandle;
@@ -55,8 +54,8 @@ impl ChildIo {
},
))
}
/// Own both the pipe ends and the launch lock. Field drop order closes all
/// inheritable ends before allowing a competing Host launch to proceed.
/// 同时持有管道端点与启动锁。字段的销毁顺序会先关闭所有可继承端点,
/// 再允许其他并发 Host 启动继续执行。
pub(crate) fn inherit(self) -> Result<InheritedIo> {
let lock = crate::process_creation::lock().map_err(HostError::new)?;
let guarded = InheritedIo {
+12 -17
View File
@@ -1,4 +1,4 @@
//! Durable verified-package staging. Staging never enables a package or grants permissions.
//! 持久的验证包暂存。暂存从不启用包或授予权限。
use crate::{
extension_package::Release,
workspace::{hash, HostError, Result},
@@ -161,7 +161,7 @@ fn source(value: &str) -> Result<String> {
Ok(url.to_string())
}
impl ExtensionStore {
/// Creates a lock preview from local staged packages. This does not replace online revocation checks.
/// 从本地暂存包创建锁定预览。这不会取代在线撤销检查。
pub fn dependency_plan(
&self,
root_key: &str,
@@ -250,7 +250,7 @@ impl ExtensionStore {
})?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
/// Host supplies an existing application-owned directory, never a package-supplied path.
/// Host 提供现有应用程序拥有的目录,而不是包提供的路径。
pub fn open(root: &Path) -> Result<Self> {
ordinary(root)?;
if !root.is_dir() {
@@ -338,8 +338,7 @@ impl ExtensionStore {
})
.transpose()
}
/// Called after the user confirms the displayed fingerprint. A concurrent
/// setting change requires a fresh review; refresh never calls this method.
/// 用户确认显示的指纹后调用。并发设置更改需要重新审核;刷新从不调用此方法。
pub fn confirm_trust(
&mut self,
setting: &TrustSetting,
@@ -358,7 +357,7 @@ impl ExtensionStore {
if old_revision.as_deref() != expected_revision {
return Err(HostError::new("EXTENSION_TRUST_CONFLICT"));
}
// Prevent one canonical URL from silently acquiring a second source identity.
// 防止同一个规范化 URL 在无提示的情况下获得第二个来源标识。
let mut statement = self
.db
.prepare("SELECT setting FROM extension_trust WHERE source=?1")?;
@@ -433,7 +432,7 @@ impl ExtensionStore {
};
Ok(hash(&serde_json::to_vec(&identity).unwrap()))
}
/// A persisted denial is independent of rollback and renewed source consent.
/// 持续拒绝与回滚和更新源同意无关。
pub fn check_not_revoked(
&self,
source_url: &str,
@@ -481,8 +480,7 @@ impl ExtensionStore {
)?;
Ok(())
}
/// Builds the complete consent payload; staging work is allowed, but active
/// pointers, running instances and permissions are untouched.
/// 构建完整的同意有效负载;允许暂存工作,但活动指针、运行实例和权限不受影响。
pub fn installation_preview(&mut self, request: &InstallRequest) -> Result<InstallPreview> {
use crate::extension_transaction::{Change, Target};
let vault = Uuid::parse_str(&request.vault_id)
@@ -586,8 +584,7 @@ impl ExtensionStore {
changes,
})
}
/// Recompute the exact reviewed payload before online checks. The main-window
/// confirmation UI must supply this digest; this method alone is not consent.
/// 在线检查之前重新计算准确的已审核有效负载。主窗口确认 UI 必须提供此摘要;仅此方法并不表示同意。
pub async fn install_confirmed(
&mut self,
operation: &str,
@@ -698,7 +695,7 @@ impl ExtensionStore {
|_| checkpoint(),
)
}
/// Online installation gate, using confirmed Host trust settings only.
/// 在线安装检查点,只使用已确认的 Host 信任设置。
pub async fn switch_online(
&mut self,
operation: &str,
@@ -760,8 +757,7 @@ impl ExtensionStore {
Ok(())
})
}
/// Atomically selects a prepared group after installer policy checks. This
/// method does not stop processes, validate configuration schemas or issue permits.
/// 安装器策略检查通过后,以原子方式选定已准备的分组。此方法不会停止进程、验证配置结构或颁发许可。
pub fn switch_prepared(
&mut self,
operation: &str,
@@ -844,8 +840,7 @@ impl ExtensionStore {
) -> Result<Option<crate::extension_transaction::Active>> {
crate::extension_transaction::active(&self.db, slot)
}
/// Prepare a verified staged package. The caller supplies current signer/revocation
/// policy; persisted preparation does not bypass that policy on replay.
/// 准备经过验证的暂存包。调用者提供当前的签名者/撤销策略;持久准备不会在重放时绕过该策略。
pub fn prepare(
&mut self,
package_key: &str,
@@ -899,7 +894,7 @@ impl ExtensionStore {
)
.optional()?;
if let Some((directory, tree_sha256)) = existing {
// Database data never supplies an arbitrary relative path.
// 数据库数据从不提供任意相对路径。
if Uuid::parse_str(&directory)
.map(|id| id.to_string())
.ok()
@@ -1,4 +1,4 @@
//! Atomic package/configuration pointers. A pointer is never a runtime permission.
//! 原子包/配置指针。指针从来都不是运行时权限。
use crate::workspace::{hash, HostError, Result};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
@@ -143,8 +143,7 @@ fn switch_inner(
})
}
/// `healthy` must come from the Host's matching package/config health probe.
/// Recovery calls this with false; it never reissues any execution permits.
/// healthy”必须来自 Host 的匹配包/配置运行状况探测。恢复称其为 false;它从不重新签发任何执行许可证。
pub fn finish(db: &mut Connection, operation: &str, healthy: bool) -> Result<Receipt> {
let tx = db.transaction()?;
let (before, after, state): (String, String, String) = tx.query_row(
+2 -2
View File
@@ -1,4 +1,4 @@
//! Fresh Community state checked against Host-pinned keys; never TOFU on refresh.
//! 根据 Host 固定密钥检查新的社区状态;刷新时绝不会 TOFU。
use crate::{
extension_package::Release,
workspace::{hash, HostError, Result},
@@ -413,7 +413,7 @@ mod tests {
} else {
"200 OK"
};
// No content length: exercise the streaming cap independently.
// 没有 Content-Length 时,单独验证流式传输上限。
write!(stream, "HTTP/1.1 {status}\r\nConnection: close\r\n\r\n").unwrap();
stream.write_all(&body).unwrap();
});
+4 -5
View File
@@ -1,4 +1,4 @@
//! Private, capability-relative extraction. Prepared trees are not executable installs.
//! 私有的、与能力相关的提取。准备好的树不是可执行安装。
use crate::{
extension_package::{Inventory, Release},
workspace::{HostError, Result},
@@ -95,7 +95,7 @@ fn collect(
Ok(())
}
/// Re-read the exact file set and all content through directory capabilities.
/// 通过目录功能重新读取确切的文件集和所有内容。
pub fn verify_tree(root: &Dir, inventory: &Inventory) -> Result<String> {
let mut found = BTreeSet::new();
collect(root, "", &mut found, &mut 10000, inventory)?;
@@ -162,8 +162,7 @@ pub fn verify_tree(root: &Dir, inventory: &Inventory) -> Result<String> {
Ok(format!("{:x}", tree.finalize()))
}
/// `root` must be an application-owned private staging directory. Trust freshness
/// and permission grants remain installation-layer responsibilities.
/// root”必须是应用程序拥有的私有暂存目录。信任新鲜度和权限授予仍然是安装层的责任。
pub fn prepare(
root: &Dir,
release: &Release,
@@ -207,7 +206,7 @@ pub fn prepare(
}
}
let tree_sha256 = verify_tree(&target, &inventory)?;
// Failed preparations remain isolated UUID directories; never expose them as current.
// 失败的准备工作仍然隔离UUID目录;切勿将它们暴露为当前状态。
Ok(Prepared {
directory,
tree_sha256,
+2 -2
View File
@@ -49,7 +49,7 @@ impl Host {
*active = next;
}
fn lock_credentials(&self) -> Result<(), String> {
// These do not wait for an in-flight unlock/KDF or credential operation.
// 这些不等待进行中解锁/KDF 或凭证操作。
self.extension_authority.revoke();
if let Some(signal) = self.credential_signal.get() {
signal.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
@@ -1159,7 +1159,7 @@ mod lifecycle_tests {
}
let observed = credentials.load(Ordering::SeqCst);
let revoked = extension.load(Ordering::SeqCst);
// Release before asserting, so an assertion cannot deadlock scope join.
// 在断言之前释放,因此断言不能死锁作用域连接。
drop(held);
worker.join().unwrap().unwrap();
assert!(observed > 0);
+5 -6
View File
@@ -1,4 +1,4 @@
//! Immutable payloads are fsynced before any SQLite reference becomes visible.
//! 在任何 SQLite 引用变得可见之前,不可变的有效负载会被 fsync。
use crate::workspace::{hash, HostError, Result, Workspace};
use rusqlite::{params, OptionalExtension};
use sha2::{Digest, Sha256};
@@ -104,7 +104,7 @@ impl Workspace {
.optional()?)
}
}
/// A new write either supplies bytes or references an existing immutable spool.
/// 新写入要么直接提供字节,要么引用现有的不可变暂存文件。
pub(crate) enum WritePayload<'a> {
Inline(&'a [u8]),
Stored { digest: &'a str, size: u64 },
@@ -200,8 +200,8 @@ fn hash_file_info(path: &Path) -> Result<(String, u64)> {
pub(crate) fn verify(path: &Path, digest: &str, size: u64) -> Result<()> {
open_verified(path, digest, size).map(drop)
}
/// Return the verified handle, rewound for use by a streaming caller.
/// Path containment is the caller's responsibility; this is not a sandbox opener.
/// 返回已经验证并回绕到起始位置的句柄,供流式调用方使用。
/// 路径约束由调用方负责;此函数并不负责建立沙箱。
pub(crate) fn open_verified(path: &Path, digest: &str, size: u64) -> Result<fs::File> {
let meta = fs::symlink_metadata(path)?;
if meta.file_type().is_symlink() || !meta.is_file() || meta.len() != size {
@@ -236,8 +236,7 @@ pub(crate) fn copy_verified(
let mut buffer = vec![0; VERIFY_BUFFER_BYTES];
let mut length = 0u64;
loop {
// Even if a file grows after metadata inspection, consume at most the
// declared payload plus one byte, never an unbounded changing stream.
// 即使文件在元数据检查后增长,最多消耗声明的有效负载加上一个字节,而不是无限变化的流。
let limit = size
.saturating_sub(length)
.saturating_add(1)
+1 -1
View File
@@ -1,4 +1,4 @@
//! Portable settings may declare required permissions, but never carry device grants, paths or secrets.
//! 可移植设置可以声明所需的权限,但绝不携带设备授权、路径或秘密。
use crate::workspace::{HostError, Result};
use serde::Deserialize;
use serde_json::Value;
+1 -2
View File
@@ -1,5 +1,4 @@
//! Coordinate Host-controlled Windows launches while inheritable handles exist.
//! This does not serialize foreign libraries that bypass this Host boundary.
//! 在存在可继承手柄的情况下协调 Host 控制的 Windows 启动。这不会序列化绕过此 Host 边界的外部库。
use std::sync::{Mutex, MutexGuard};
static CREATION: Mutex<()> = Mutex::new(());
+1 -1
View File
@@ -1,4 +1,4 @@
//! Main-window preference records; no generic credential or application-state accessor.
//! 主窗口偏好记录;没有通用凭证或应用程序状态访问器。
use super::{with_workspace, Host};
use notesagent_host::{
records,
+1 -1
View File
@@ -1,4 +1,4 @@
//! Versioned logical records: explicit fields only, never raw application databases/config.
//! 版本化逻辑记录:仅显式字段,从不原始应用程序数据库/配置。
use crate::workspace::{HostError, Result, Workspace};
use rusqlite::OptionalExtension;
use serde::{Deserialize, Serialize};
+2 -2
View File
@@ -1,4 +1,4 @@
//! Reserve before dispatch so cancellation cannot race a delayed IPC invocation.
//! 调度前保留,因此取消不能与延迟的 IPC 调用竞争。
use std::{
collections::HashMap,
future::Future,
@@ -71,7 +71,7 @@ impl Requests {
}
}
impl Lease {
/// Recheck after synchronous encoding/validation, immediately before network IO.
/// 同步编码/验证后、紧接网络 IO 之前重新检查。
pub fn checkpoint(&self) -> impl Fn() -> Result<(), String> + Send + 'static {
let cancel = self.cancel.clone();
let deadline = self.deadline;
+3 -7
View File
@@ -1,7 +1,4 @@
//! C23 compatibility for the MSVCRT-based Windows GNU development target.
//! Recent libsodium archives reference memset_explicit, absent in MSVCRT.
//! Volatile stores preserve its non-elidable wipe semantics; MSVC/UCRT release
//! builds use their native runtime and do not compile this compatibility symbol.
//! C23 与基于 MSVCRT Windows GNU 开发目标的兼容性。最近的 libsodium 档案参考 memset_explicitMSVCRT 中不存在。易失性存储保留其不可消除的擦除语义; MSVC/UCRT 发行版本使用其本机运行时,并且不编译此兼容性符号。
#[cfg(all(windows, target_env = "gnu"))]
#[no_mangle]
@@ -11,8 +8,7 @@ unsafe extern "C" fn memset_explicit(
count: usize,
) -> *mut std::ffi::c_void {
for offset in 0..count {
// SAFETY: the C ABI caller must supply a writable region of count bytes,
// exactly as for memset. Volatile stores cannot be removed as dead writes.
// SAFETYC ABI 调用者必须提供 count 字节的可写区域,与 memset 完全相同。易失性存储无法作为死写删除。
unsafe {
destination
.cast::<u8>()
@@ -30,7 +26,7 @@ mod tests {
fn explicit_memset_preserves_surrounding_bytes_and_return_pointer() {
let mut data = [0x55u8; 34];
let pointer = data[1..33].as_mut_ptr().cast();
// SAFETY: the subslice contains exactly 32 writable bytes.
// SAFETY: 子片恰好包含 32 个可写字节。
assert_eq!(unsafe { super::memset_explicit(pointer, 0, 32) }, pointer);
assert_eq!(data[0], 0x55);
assert_eq!(data[33], 0x55);
+2 -3
View File
@@ -1,5 +1,4 @@
//! Windows session notifications. Revocation is atomic and never waits for a KDF.
//! https://learn.microsoft.com/en-us/windows/win32/termserv/wm-wtssession-change
//! Windows 会话通知。撤销是原子性的,永远不会等待 KDF。 https://learn.microsoft.com/en-us/windows/win32/termserv/wm-wtssession-change
use std::cell::RefCell;
use std::sync::{
atomic::{AtomicU64, Ordering},
@@ -152,7 +151,7 @@ mod tests {
fn native_message_revokes_without_unlocking_on_session_return() {
let signal = Arc::new(AtomicU64::new(0));
let monitor = SessionMonitor::start(signal.clone()).unwrap();
// Inject only into our hidden test window; never lock the user's desktop.
// 仅注入到我们的隐藏测试窗口中;永远不要锁定用户的桌面。
unsafe {
SendMessageW(
monitor.window as HWND,
+5 -6
View File
@@ -1,4 +1,4 @@
//! Device-local Sync sessions. The serialized record never crosses IPC.
//! 设备本地同步会话。序列化记录从未跨越IPC
use crate::{
credentials::{CredentialBroker, CredentialId, Scope},
sync_client::{Session, SyncClient, SyncError},
@@ -64,7 +64,7 @@ pub fn available(credentials: &Credentials, endpoint: &str, account: &str) -> Re
.map(|v| v.is_some())
})
}
/// Dropping a guarded HTTP future closes the in-flight operation on any lock epoch change.
/// 删除受保护的 HTTP 未来会关闭任何锁定纪元更改的正在进行的操作。
pub async fn guarded<T>(
credentials: &Credentials,
future: impl Future<Output = Result<T>>,
@@ -123,7 +123,7 @@ pub async fn login(
})
.await
}
/// The caller serializes refreshes with the coordinator gate.
/// 调用者使用协调器门来串行刷新。
pub async fn client(
credentials: &Credentials,
endpoint: &str,
@@ -158,8 +158,7 @@ pub async fn client(
saved.allow_test_http,
)
}
/// The coordinator must serialize calls. Only use for read-only or durably idempotent work:
/// a 401 repeats the operation once with the same device after rotating its session.
/// 协调器必须序列化调用。仅用于只读或持久幂等工作:401 在轮换其会话后使用同一设备重复该操作一次。
pub async fn authenticated<T, F, Fut>(
credentials: &Credentials,
endpoint: &str,
@@ -181,7 +180,7 @@ where
}
pub async fn logout(credentials: &Credentials, endpoint: &str, account: &str) -> Result<()> {
let client = client(credentials, endpoint, account, false).await?;
// A failed server revocation is reported; the encrypted record remains available for retry.
// 报告服务器吊销失败;加密记录仍可供重试。
guarded(
credentials,
client.json(reqwest::Method::DELETE, "sync/v1/auth/sessions", None),
+2 -2
View File
@@ -1,4 +1,4 @@
//! Bounded Sync v1 transport. No redirects, no token-bearing URLs, no implicit retries.
//! 有界同步 v1 传输。没有重定向,没有带有令牌的 URL,没有隐式重试。
use crate::{
sync_state::{Binding, Job},
workspace::Workspace,
@@ -83,7 +83,7 @@ impl From<std::io::Error> for SyncError {
}
}
/// Persist only via the Stronghold Sync scope, never as an IPC response.
/// 仅通过 Stronghold Sync 范围持续,绝不作为 IPC 响应。
#[derive(Serialize, Deserialize)]
pub struct Session {
pub access_token: String,
+2 -2
View File
@@ -1,4 +1,4 @@
//! Main-window commands. Every ongoing run is bound to one Workspace and one account.
//! 主窗口命令。每次正在进行的运行都绑定到一个工作区和一个帐户。
use super::{with_workspace, Host};
use notesagent_host::{
sync_auth,
@@ -420,7 +420,7 @@ async fn cycle(host: &Host, binding: &Binding) -> Result<(), SyncError> {
break;
}
}
// Finish the fixed incoming window before freezing any new remote base.
// 在冻结任何新的远程基地之前完成固定的传入窗口。
if host
.workspace
.access(|ws| ws.sync_boundary(&binding.id))?
+3 -3
View File
@@ -1,9 +1,9 @@
//! Reconcile externally edited files against committed snapshots, never against UI read caches.
//! 根据已提交的快照协调外部编辑的文件,而不是根据 UI 读取缓存。
use crate::workspace::{HostError, Result, Workspace};
use rusqlite::{params, OptionalExtension};
use std::{collections::HashSet, fs, path::Path};
use uuid::Uuid;
/// File transport only. Logical records receive their own versioned whitelist separately.
/// 仅文件传输。逻辑记录单独接收自己的版本白名单。
pub fn allowed(path: &str) -> bool {
if crate::records::is_record(path) {
return crate::records::allowed(path);
@@ -115,7 +115,7 @@ impl Workspace {
{
continue;
}
// Confirm the snapshot without writing back over an external editor.
// 确认快照而不通过外部编辑器回写。
let operation = Uuid::new_v4().to_string();
self.store_payload_file(&operation, &target, &digest)?;
let file_id = previous.map_or_else(|| Uuid::new_v4().to_string(), |e| e.file_id);
+1 -1
View File
@@ -1,4 +1,4 @@
//! Persist received revisions before Workspace writes; cursor advancement follows application.
//! 在Workspace写入之前保留收到的修订;光标前进跟随应用程序。
use crate::{
sync_state::{Binding, Job},
workspace::{hash, HostError, Result, Workspace},
+1 -1
View File
@@ -1,4 +1,4 @@
//! Initial merge uses a confirmed fixed remote snapshot and never replays obsolete paths.
//! 初始合并使用已确认的固定远程快照,并且从不重播过时的路径。
use crate::{
sync_inbox::RemoteRevision,
sync_state::Binding,
+7 -11
View File
@@ -1,4 +1,4 @@
//! User decisions are durable before changing files; journal IDs make restart replay safe.
//! 用户决策在更改文件之前是持久的;日志 ID 使重新启动重放变得安全。
use crate::workspace::hash;
use crate::{
sync_inbox::RemoteRevision,
@@ -79,8 +79,7 @@ impl Workspace {
if !crate::sync_discovery::allowed(destination) {
return Err(HostError::new("SYNC_PATH_DENIED"));
}
// Validate the intended record path before freezing the decision.
// A typo must not leave an unchangeable, unappliable resolution.
// 在冻结决定之前验证预期的记录路径。拼写错误不得留下不可更改、不适用的解决方案。
if crate::records::is_record(destination) {
let spool = self.sync_spool(&current)?;
let size = fs::metadata(&spool)?.len();
@@ -161,8 +160,7 @@ impl Workspace {
if head != sequence {
return Err(HostError::new("SYNC_CONFLICT_CHANGED"));
}
// local_path is frozen when the conflict is recorded. Recomputing it from
// file identity after a partial resolution can select a different file.
// 记录冲突时local_path被冻结。部分解析后根据文件标识重新计算可以选择不同的文件。
let path = stored_path;
if !self
.operation(&operation)?
@@ -216,10 +214,8 @@ impl Workspace {
&& !source_hash.is_empty()
&& !source_renamed
{
// The deleted target still owns its unique database path until the
// final identity-aware write retires that tombstone. Retire the
// incoming identity at its old path, then resurrect it at the
// target with the frozen remote or chosen-local bytes.
// 被删除的目标仍占用其唯一数据库路径,直到最后一次感知身份的写入撤销该逻辑删除记录。
// 先在旧路径停用传入标识,再用冻结的远程内容或用户选择的本地内容在目标路径恢复它。
self.mutate_with_origin(
"delete",
&source_path,
@@ -640,7 +636,7 @@ mod tests {
],
)
.unwrap();
// Simulate a crash after the filesystem journal commits but before the resolution transaction.
// 在文件系统日志提交之后但在解析事务之前模拟崩溃。
if choice == "copy" {
ws.write_operation("copy.md", "", b"local", "local", &copy)
.unwrap();
@@ -732,7 +728,7 @@ mod tests {
remote.operation_id = Uuid::new_v4().to_string();
receive(&mut ws, &binding.id, &remote);
assert_eq!(ws.read("a.md").unwrap().entry.file_id, remote.file_id);
// A tombstoned identity can reappear at another free path.
// 已标记为删除的标识可以在另一个空闲路径重新出现。
remote.sequence = 4;
remote.base_revision = 2;
remote.file_id = original.clone();
+2 -2
View File
@@ -1,4 +1,4 @@
//! Binding-scoped retry decisions survive restart; wall time is bounded after clock changes.
//! 绑定范围内的重试决定在重启后仍然有效;系统时钟变化后也会约束实际经过时间。
use crate::workspace::{Result, Workspace};
use rusqlite::{params, OptionalExtension};
use serde::Serialize;
@@ -105,7 +105,7 @@ impl Workspace {
if code == "SYNC_CANCELLED" {
return Ok(());
}
// Only retain a bounded machine code, never an arbitrary remote response string.
// 仅保留有界机器代码,从不保留任意远程响应字符串。
let code = machine_code(code);
let failures = if code == "CREDENTIALS_LOCKED" {
0
+2 -2
View File
@@ -1,4 +1,4 @@
//! Device-local choices for optional logical data; never exported as sync records.
//! 可选逻辑数据的设备本地选择;从未导出为同步记录。
use crate::workspace::{HostError, Result, Workspace};
use rusqlite::OptionalExtension;
use serde::{Deserialize, Serialize};
@@ -91,7 +91,7 @@ mod tests {
.sync_bind_empty("https://sync.example", "remote", "account")
.unwrap();
let job = ws.sync_next(&binding.id).unwrap().unwrap();
// Models a pre-scope database's pending job after schema migration.
// 对模式迁移后范围内数据库的待处理作业进行建模。
ws.db
.execute("DELETE FROM sync_optional_scope", [])
.unwrap();
+4 -4
View File
@@ -1,4 +1,4 @@
//! Durable queue state. Network code never invents a remote base from a local revision.
//! 持久队列状态。网络代码永远不会从本地版本创建远程基础。
use crate::workspace::{HostError, Result, Workspace};
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};
@@ -56,7 +56,7 @@ impl Workspace {
}
Ok(())
}
/// Caller verifies an empty remote and obtains a reconciliation confirmation first.
/// 调用者验证空远程并首先获得协调确认。
pub fn sync_bind_empty(
&mut self,
endpoint: &str,
@@ -73,7 +73,7 @@ impl Workspace {
})?;
let paths = self.sync_paths()?;
let id = Uuid::new_v4().to_string();
// Rebinding explicitly starts from the current snapshot, never an old account's queue.
// 重新绑定显式从当前快照开始,而不是旧帐户的队列。
if had_binding {
self.db.execute(
"UPDATE outbox SET state='archived' WHERE state IN ('pending','queued')",
@@ -246,7 +246,7 @@ impl Workspace {
}
pub fn sync_commit_payload(&self, job: &Job) -> Result<serde_json::Value> {
self.check_job(job)?;
// The base is frozen exactly once. A response loss reuses the byte-equivalent payload.
// 基准快照只冻结一次;响应丢失后复用字节完全相同的负载。
self.db.execute("UPDATE sync_jobs SET state='committing',base_revision=COALESCE((SELECT revision FROM sync_heads WHERE binding=?1 AND file_id=?3),0) WHERE binding=?1 AND operation_id=?2 AND base_revision IS NULL",
params![job.binding,job.operation_id,job.file_id])?;
let base: i64 = self.db.query_row(
+2 -5
View File
@@ -152,7 +152,7 @@ impl Workspace {
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
}
if (1..13).contains(&version) {
// Independent, complete SQLite backup before the schema ownership change.
// 模式所有权更改之前独立、完整的 SQLite 备份。
let backup = managed.join(format!("host-schema{version}-{}.sqlite3", Uuid::new_v4()));
db.execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?;
}
@@ -476,10 +476,7 @@ impl Workspace {
)
}
/// Revalidate the caller before work and immediately before committing the
/// durable write intent. Once accepted, recovery must finish that intent.
/// The caller must serialize the authorization boundary if strict atomic
/// ordering with concurrent revocation is required.
/// 在工作之前和提交持久写入意图之前重新验证调用者。一旦接受,恢复必须完成该意图。如果需要并发撤销的严格原子排序,调用者必须序列化授权边界。
#[cfg(any(test, all(windows, feature = "desktop")))]
pub(crate) fn write_operation_guarded(
&mut self,
+1 -1
View File
@@ -1,4 +1,4 @@
//! Narrow Core RPC. Every request is bound to the Vault captured by the Host transport.
//! 受限的 Core RPC;每个请求都绑定到 Host 传输捕获的 Vault。
use crate::workspace::Workspace;
use serde::Deserialize;
use serde_json::{json, Value};
+1 -1
View File
@@ -1,4 +1,4 @@
//! Executes the real worktree Core, with no personal data or external Provider.
//! 执行真实的工作树Core,没有个人数据或外部提供者。
use notesagent_host::core::CoreSupervisor;
use notesagent_host::credentials::{CredentialBroker, CredentialId, Scope};
use std::path::Path;
+3 -5
View File
@@ -1,5 +1,5 @@
#![cfg(feature = "desktop")]
//! Real Python Core + Host pipes + isolated Workspace; no personal data or Provider.
//! 真正的Python Core + Host管道+隔离工作区;没有个人数据或提供商。
use notesagent_host::{core::CoreSupervisor, workspace::Workspace, workspace_broker};
use serde_json::{json, Value};
use std::{
@@ -289,8 +289,7 @@ async fn real_core_notes_roundtrip_only_through_bound_host_and_confirm_commits()
.path()
.join("core/unbound-vault/Core fixture.md")
.exists());
// The actual Python HTTP handler must round-trip revision through Host pipes,
// including repeat requests after a successful commit.
// 实际的 Python HTTP 处理程序必须通过 Host 管道进行往返修订,包括成功提交后的重复请求。
let path = "/api/settings/persona";
let (status, empty) = request(
&mut core,
@@ -369,8 +368,7 @@ async fn real_core_notes_roundtrip_only_through_bound_host_and_confirm_commits()
.unwrap();
assert_eq!(stored["hash"], first["revision"]);
// User-created Skills are Vault records, use Host CAS/idempotency, and are
// resolved by the actual Agent route without copying package paths or grants.
// 用户创建的Skills是Vault记录,使用Host CAS/幂等性,并通过实际的Agent路由解析,无需复制包路径或授权。
let create_skill_operation = uuid::Uuid::new_v4();
let skill_body = json!({
"revision":"", "name":"Vault reviewer", "description":"portable",
@@ -25,7 +25,7 @@ fn stale_writer_is_rejected_then_handoff_preserves_both_commits() {
assert!(two.resolve(&Scope::Provider, &a).unwrap().is_some());
two.put(&b, Zeroizing::new(b"fixture-b".to_vec())).unwrap();
two.lock();
// A failed password attempt must release its ownership too.
// 失败的密码尝试也必须释放其所有权。
assert!(one
.unlock(Zeroizing::new(b"wrong-fixture-password".to_vec()))
.is_err());
+3 -4
View File
@@ -1,4 +1,4 @@
//! Standalone native test probe; never shipped or used to launch extensions.
//! 独立本机测试探针;从未发货或用于启动扩展。
use std::net::{SocketAddr, TcpStream, UdpSocket};
use std::time::Duration;
fn main() {
@@ -84,7 +84,7 @@ fn main() {
if !stale.is_empty() { reply(id(&stale), r#"{"content":[],"structuredContent":{"ok":true}}"#); }
return;
}
// MCP server remains alive between calls until Host closes stdin.
// MCP 服务器在调用之间保持活动状态,直到 Host 关闭标准输入。
while !read(&mut input).is_empty() {}
return;
}
@@ -107,8 +107,7 @@ fn main() {
}
let sentinel: usize = args[2].parse().unwrap();
let mut identity = FileIdentity { volume: 0, id: [0; 16] };
// Numeric handles may alias unrelated child objects. Compare the actual
// file identity without reading from a possibly aliased pipe handle.
// 数字句柄可能会为不相关的子对象起别名。比较实际文件标识,而不读取可能有别名的管道句柄。
if unsafe { GetFileInformationByHandleEx(sentinel as *mut _, 18,
(&mut identity as *mut FileIdentity).cast(),
std::mem::size_of::<FileIdentity>() as u32) } != 0 {
+14 -16
View File
@@ -108,8 +108,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
.sync_next(&binding.id)
.unwrap()
.unwrap();
// Commit the first revision, then kill the client before the response can
// acknowledge the local journal. Reopen must keep all pending operations.
// 提交第一个修订,然后在响应确认本地日志之前终止客户端。重新打开必须保留所有挂起的操作。
std::fs::write(
root.path().join("interrupt-revision"),
b"controlled-fixture",
@@ -255,7 +254,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
.file_id,
first.file_id
);
// Receiving one's historical commits never rolls back newer local edits.
// 接收历史提交永远不会回滚较新的本地编辑。
{
let mut ws = workspace.lock().unwrap();
let current = ws.read("note.md").unwrap();
@@ -310,7 +309,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
.cursor,
21
);
// All three explicit choices converge; the local copy gets an independent file ID.
// 所有三个显式选择都收敛;本地副本获得独立文件 ID
for (iteration, choice) in ["local", "remote", "copy"].into_iter().enumerate() {
if iteration > 0 {
for (ws, content) in [(&workspace, "next-a"), (&workspace_b, "next-b")] {
@@ -351,7 +350,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
)
.unwrap();
assert!(ws.sync_conflicts(&binding_b.id).unwrap().is_empty());
// Repeating a persisted decision is harmless.
// 重复提交同一个已持久化决定不会产生副作用。
ws.sync_resolve(
&binding_b.id,
sequence,
@@ -447,7 +446,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
.file_id
);
// Initial merge reviews a fixed snapshot and preserves conflicting local content.
// 初始合并会检查固定快照并保留冲突的本地内容。
let merge_root = tempfile::tempdir().unwrap();
let merge_ws = Arc::new(Mutex::new(Workspace::open(merge_root.path()).unwrap()));
let snapshot = client.snapshot(remote).await.unwrap();
@@ -625,8 +624,8 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
.unwrap()["record"]["data"]["fontEditorSize"],
24
);
// Portable records traverse real HTTP, including equal display versions with
// different contents. A numeric persona version cannot replace content CAS.
// 可移植记录通过真实 HTTP 传输,包括显示版本相同但内容不同的情况;
// 数值型角色版本不能取代内容 CAS
for (kind, id, data, field) in [
(
"persona",
@@ -760,8 +759,8 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
);
}
// A default-off device consumes history metadata without downloading either
// optional record. Rebinding after opt-in must fetch the already-seen heads.
// 默认关闭该功能的设备只读取历史元数据,不下载任何可选记录;启用后重新绑定时,
// 必须获取先前已经见过的最新版本。
let excluded_root = tempfile::tempdir().unwrap();
let excluded_ws = Arc::new(Mutex::new(Workspace::open(excluded_root.path()).unwrap()));
let excluded_binding = excluded_ws
@@ -853,8 +852,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
.await
.unwrap());
// Kill the actual client process after each durable 10 MiB server offset,
// before its response reaches the client. The next process must query offset.
// 在每个持久的 10 MiB 服务器偏移之后,在其响应到达客户端之前,终止实际的客户端进程。下一个进程必须查询偏移量。
use sha2::{Digest, Sha256};
let large_remote = client
.json(
@@ -981,7 +979,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
.unwrap(),
0
);
// SQLite stores metadata, never the 100 MiB body.
// SQLite 存储元数据,而不是 100 MiB 主体。
for directory in [large_root.path(), download_root.path()] {
let managed = directory.join(".ainote");
for item in std::fs::read_dir(managed).unwrap().flatten() {
@@ -993,7 +991,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
}
}
}
// Host sessions survive encrypted storage reopen and refresh on the actual service.
// Host 会话可以在实际服务上重新打开加密存储并刷新。
use notesagent_host::{credentials::CredentialBroker, sync_auth};
let credential_root = tempfile::tempdir().unwrap();
let credential_path = credential_root.path().join("credentials.onxcred");
@@ -1032,7 +1030,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
.unwrap()
.unlock(Zeroizing::new(b"fixture-stronghold-password".to_vec()))
.unwrap();
// Expire the access token early in this isolated fixture; the refresh token stays valid.
// 在此隔离装置中尽早使访问令牌过期;刷新令牌保持有效。
let database = rusqlite::Connection::open(root.path().join("sync.sqlite3")).unwrap();
database
.execute("UPDATE sessions SET expires=0", [])
@@ -1054,7 +1052,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
.unwrap()
.iter()
.any(|v| v["id"] == remote));
// Even a repeated 401 must stop after one rotation, rather than refresh indefinitely.
// 即使再次收到 401,也只能轮换一次令牌,不能无限刷新。
attempts.store(0, std::sync::atomic::Ordering::SeqCst);
let denied = sync_auth::authenticated(&credentials, &canonical, "rust-fixture", |_client| {
attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+1 -1
View File
@@ -32,7 +32,7 @@ const desktop = isDesktop()
let statusTimer: ReturnType<typeof setTimeout> | undefined
let disposed = false
async function pollIndex() {
try { const status = await getIndexStatus(); if (!disposed) settingsStore.indexStatus = status } catch { /* retain last status; retry */ }
try { const status = await getIndexStatus(); if (!disposed) settingsStore.indexStatus = status } catch { /* 保留最后状态;重试 */ }
const busy = settingsStore.indexStatus.status === 'indexing' || settingsStore.indexStatus.active_searches
if (!disposed) statusTimer = setTimeout(pollIndex, busy || route.name === 'settings' || route.name === 'search' ? 1000 : 5000)
}
@@ -34,8 +34,7 @@ function anchorZoom(svg: SVGSVGElement, event: WheelEvent) {
anchorUntil = performance.now() + 240
const follow = () => {
if (!svg.isConnected) return
// Inner horizontal overflow and the editor's outer vertical scroll may differ.
// Re-measure after each scroll, letting the outer container take the remainder.
//
for (const node of scrollers) {
const current = svg.getBoundingClientRect()
node.scrollLeft += current.left + x * current.width - screenX
@@ -134,8 +133,7 @@ async function interact(event: MouseEvent) {
disarm()
opener = button
const intrinsicWidth = widthOf(svg)
// Mermaid HTML labels live in SVG foreignObject nodes. Preserve that
// integration point while still sanitizing the embedded HTML and handlers.
// Mermaid HTML SVGforeignObject HTML
const copy = svg.cloneNode(true) as SVGSVGElement
for (const label of copy.querySelectorAll('foreignObject, foreignobject')) {
label.innerHTML = DOMPurify.sanitize(label.innerHTML, { USE_PROFILES: { html: true } })
@@ -151,8 +149,7 @@ async function interact(event: MouseEvent) {
const viewport = viewer.value?.querySelector<HTMLElement>('.diagram-viewer-scroll')
const box = svg.getAttribute('viewBox')?.trim().split(/[ ,]+/).map(Number)
const intrinsicHeight = box?.length === 4 && box[3]! > 0 ? box[3]! : svg.getBoundingClientRect().height
// Opening is independent of the inline preview's zoom and any previous modal scroll.
// Keep native size for small diagrams; fit wide/tall diagrams completely at 100%.
// 100% /
baseWidth.value = Math.min(intrinsicWidth, viewport?.clientWidth || intrinsicWidth,
intrinsicHeight > 0 && viewport?.clientHeight ? viewport.clientHeight * intrinsicWidth / intrinsicHeight : intrinsicWidth)
await nextTick()
@@ -203,7 +200,7 @@ function close() { disarm(); viewer.value?.close(); svgHtml.value = ''; opener?.
.diagram-viewer header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-sm); flex-shrink: 0; }
.diagram-viewer header .diagram-controls { flex-wrap: wrap; }
.diagram-viewer-scroll { display: flex; flex: 1; min-height: 0; overflow: auto; }
/* Auto margins center small diagrams and become zero on overflow, keeping all edges reachable. */
/* 自动边距使小图居中并在溢出时变为零,从而保持所有边缘可达。 */
.diagram-viewer-image { flex: 0 0 auto; margin: auto; transition: width 180ms ease-out; }
.diagram-viewer-image svg { display: block; width: 100% !important; max-width: none !important; height: auto !important; }
</style>
@@ -1,4 +1,4 @@
// Reference counts keep the underlying page locked when dialogs are nested.
// 当对话框嵌套时,引用计数会锁定底层页面。
const locks = new WeakMap<HTMLElement, { count: number; value: string; priority: string }>()
export function lockDialogScroll(dialog: HTMLElement): () => void {
const elements: HTMLElement[] = []
+2 -2
View File
@@ -2,7 +2,7 @@ import { nextTick, onBeforeUnmount, shallowRef } from 'vue'
export interface ActionDialogRequest { message: string; mode: 'confirm' | 'prompt'; initialValue: string }
/** Requests belong to the invoking view; leaving it cancels pending work. */
/** 请求属于调用视图;离开它会取消待处理的工作。 */
export function useActionDialog() {
const actionDialog = shallowRef<ActionDialogRequest | null>(null)
let pending: ((value: string | null) => void) | undefined
@@ -11,7 +11,7 @@ export function useActionDialog() {
const resolve = pending
pending = undefined
actionDialog.value = null
await nextTick() // Restore focus and release the modal before the caller continues.
await nextTick() // 在调用者继续之前恢复焦点并释放模式。
resolve?.(disposed ? null : value)
}
function request(mode: ActionDialogRequest['mode'], message: string, initialValue = '') {
@@ -2,7 +2,7 @@ import { onMounted, onUnmounted } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
import { useEditorStore } from '@/stores/editor'
/** Web fallback until the desktop host supplies filesystem events. No overlapping polls. */
/** Web 回退,直到桌面主机提供文件系统事件。没有重叠的民意调查。 */
export function useWorkspaceRefresh() {
const workspace = useWorkspaceStore()
const editor = useEditorStore()
@@ -21,7 +21,7 @@ export function useWorkspaceRefresh() {
else await editor.checkExternalFile()
}
}
} catch { /* Keep the existing tree; the store exposes the error and retries. */ }
} catch { /* 保留现有树;商店暴露错误并重试。 */ }
finally {
running = false
if (!stopped) timer = setTimeout(refresh, 2000)
+18 -18
View File
@@ -1,4 +1,4 @@
// ============ Notes & Blocks ============
// ============ 笔记与内容块 ============
export interface Note {
note_id: string
@@ -34,7 +34,7 @@ export interface FileNode {
is_external_changed?: boolean
}
// ============ Search ============
// ============ 搜索 ============
export interface SearchRequest {
query: string
@@ -58,7 +58,7 @@ export interface SearchResult {
tags?: string[]
}
// ============ Chat ============
// ============ 对话 ============
export interface Conversation {
conversation_id: string
@@ -101,7 +101,7 @@ export interface Citation {
}
}
// ============ Model Events (SSE) ============
// ============ 模型事件(SSE ============
export type ModelEventType =
| 'ContextStatus'
@@ -122,7 +122,7 @@ export interface ModelEvent {
timestamp: string
}
// ============ Agent ============
// ============ 智能体 ============
export type AgentRunStatus =
| 'queued'
@@ -221,7 +221,7 @@ export interface TokenUsage {
total_tokens: number
}
// ============ Skill ============
// ============ Skill(技能) ============
export type SkillStatus =
| 'installed'
@@ -288,7 +288,7 @@ export interface UserSkillWriteRequest {
required_capabilities: string[]
}
// ============ Plugin ============
// ============ Plugin(插件) ============
export type PluginStatus =
| 'installed'
@@ -420,7 +420,7 @@ export interface Plugin {
dependent_skills?: string[]
}
// ============ Provider ============
// ============ 提供商 ============
export type ProviderType = ApiProviderType
@@ -514,7 +514,7 @@ export interface ModelRoutingResponse {
}>
}
// ============ Tasks ============
// ============ 任务 ============
export type TaskStatus = 'todo' | 'in_progress' | 'done' | 'cancelled'
export type TaskPriority = 'low' | 'medium' | 'high'
@@ -534,7 +534,7 @@ export interface TaskItem {
updated_at: string
}
// ============ Theme ============
// ============ 主题 ============
export interface ThemeConfig {
theme_id: string
@@ -547,7 +547,7 @@ export interface ThemeConfig {
code_theme?: 'github-light' | 'github-dark'
}
// ============ Index ============
// ============ 索引 ============
export interface IndexStatus {
running_jobs?: number
@@ -568,7 +568,7 @@ export interface IndexStatus {
error?: string
}
// ============ System ============
// ============ 系统 ============
export interface ApiError {
code: string
@@ -598,9 +598,9 @@ export type SaveStatus =
export type AiCoreStatus = 'unknown' | 'starting' | 'running' | 'stopped' | 'error'
// ============ FastAPI wire contracts ============
// UI view models above may contain presentation-only fields. Services must use
// these DTOs at the HTTP boundary and explicitly map them to view models.
// ============ FastAPI 传输契约 ============
// 上方的 UI 视图模型可能含有仅用于展示的字段。服务必须在 HTTP 边界使用这些 DTO,
// 并将其显式映射为视图模型。
export interface PageMeta {
total: number
@@ -871,7 +871,7 @@ export interface ApiIndexJob {
created_at: string
}
// ============ Theme Package (Phase 2) ============
// ============ 主题包(第二阶段) ============
export interface ThemeManifest {
theme_id: string
@@ -924,7 +924,7 @@ export type ThemeErrorCode =
| 'THEME_INSTALL_FAILED'
| 'THEME_UNINSTALL_FAILED'
// ============ Mermaid Renderer (Phase 2) ============
// ============ Mermaid 渲染器(第二阶段) ============
export interface MermaidRenderResult {
svg: string
@@ -939,7 +939,7 @@ export interface MermaidParseError {
column?: number
}
// ============ Agent Trace Node (Phase 2 visualization) ============
// ============ Agent Trace 节点(第二阶段可视化) ============
export type TraceNodeType =
| 'run'
+1 -2
View File
@@ -106,8 +106,7 @@ const toolDescriptions: Record<string, string> = {
'text.uppercase': '将输入文本中的字母转换为大写。',
}
// MCP IDs contain a server-specific namespace. Localize the remote tool name
// for presentation only; requests must keep using the complete original ID.
// MCP ID 包含特定于服务器的命名空间。本地化远程工具名称仅用于演示;要求必须继续使用完整的原装ID。
const mcpTools: Record<string, { label: string; description: string }> = {
web_search: {
label: '网页搜索',
+2 -2
View File
@@ -10,8 +10,8 @@ const panel = ref<HTMLElement | null>(null)
const storageKey = 'notes-agent.workspace-chat.bounds.v1'
const width = ref(640), height = ref(680)
const x = ref(Math.max(8, window.innerWidth - 660)), y = ref(64)
try { const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null'); if (saved && [saved.x,saved.y,saved.width,saved.height].every(Number.isFinite)) { x.value=saved.x; y.value=saved.y; width.value=saved.width; height.value=saved.height } } catch { /* storage unavailable */ }
function save() { try { localStorage.setItem(storageKey, JSON.stringify({x:x.value,y:y.value,width:width.value,height:height.value})) } catch { /* storage unavailable */ } }
try { const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null'); if (saved && [saved.x,saved.y,saved.width,saved.height].every(Number.isFinite)) { x.value=saved.x; y.value=saved.y; width.value=saved.width; height.value=saved.height } } catch { /* 存储不可用 */ }
function save() { try { localStorage.setItem(storageKey, JSON.stringify({x:x.value,y:y.value,width:width.value,height:height.value})) } catch { /* 存储不可用 */ } }
function reset() { width.value=640; height.value=680; x.value=window.innerWidth-660; y.value=32; clamp(); save() }
let resizing: { x:number; y:number; width:number; height:number } | null = null
function resizeStart(e: PointerEvent) { if (e.button !== 0) return; resizing={x:e.clientX,y:e.clientY,width:width.value,height:height.value}; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); e.preventDefault() }
@@ -52,7 +52,7 @@ describe('EditorPane file switching', () => {
expect(store.currentFilePath).toBe('/数据结构/红黑树.md')
expect(wrapper.text()).not.toContain('祝你写作愉快')
}, 15000) // Real Milkdown is now imported lazily; cold module transforms count toward this integration test.
}, 15000) // 真正的Milkdown现在被延迟导入;冷模块将计数转换为此集成测试。
it('applies the saved spell-check and language settings to source mode', async () => {
const editor = useEditorStore()
@@ -68,5 +68,5 @@ describe('EditorPane file switching', () => {
expect(textarea.attributes('spellcheck')).toBe('true')
expect(textarea.attributes('lang')).toBe('en')
expect(textarea.attributes('aria-label')).toBe('Markdown source editor')
}, 15000) // Lazy source-editor module transforms need the same cold-start budget.
}, 15000) // 惰性源编辑器模块转换需要相同的冷启动预算。
})
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// The application has a doctype; happy-dom otherwise reports quirks mode to KaTeX.
// 应用程序有一个文档类型; happy-dom 否则会向 KaTeX 报告怪癖模式。
vi.hoisted(() => { Object.defineProperty(document, 'compatMode', {value:'CSS1Compat',configurable:true}) })
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
@@ -29,7 +29,7 @@ async function waitForEditor(wrapper: VueWrapper): Promise<Editor> {
try {
editor.action(getMarkdown())
return editor
} catch { /* editor is still creating */ }
} catch { /* 编辑器仍在创建 */ }
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
@@ -263,7 +263,7 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
// Chromium/IME can omit data and commit its DOM change after the input event.
// Chromium/IME 可以省略数据并在输入事件后提交其 DOM 更改。
await wrapper.get('.ProseMirror').trigger('input', {inputType, data:null})
await new Promise(resolve => setTimeout(resolve, 10))
editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('`s`')))
@@ -292,8 +292,7 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
view.dispatch(view.state.tr.insertText('``'))
await wrapper.get('.ProseMirror').trigger('input', {inputType:'insertText', data:'`'})
await new Promise(resolve => setTimeout(resolve, 60))
// Empty pairs are serialized as escaped literal text, but that must not
// prevent recognition after the user moves back and fills in the content.
// 空对被序列化为转义文字文本,但这不得妨碍用户向后移动并填写内容后的识别。
expect(editor.action(getMarkdown())).toContain('\\`')
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 2)).insertText('s'))
await wrapper.get('.ProseMirror').trigger('input', {inputType:'insertText', data:'s'})
@@ -350,8 +350,8 @@ onMounted(async () => {
},
},
})
// Crepe's defaultsDeep merges language arrays and theme extension internals.
// Replace both AFTER feature configuration to avoid default grammar collisions.
// Crepe defaultsDeep
//
crepe.editor.config(ctx => ctx.update(codeBlockConfig.key, config => ({
...config,
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
@@ -391,7 +391,7 @@ onMounted(async () => {
const sections = headingSections(current.state.doc)
const folded = headingFoldKey.getState(current.state)
hasFoldableHeadings.value = sections.length > 0
// Hidden descendants retain their own state but are not visible expanded sections.
//
let hiddenUntil = -1
allHeadingsFolded.value = sections.length > 0 && sections.every(section => {
if (section.from < hiddenUntil) return true
@@ -585,8 +585,7 @@ defineExpose({ getEditor: () => crepe?.editor })
.milkdown-host :deep(.ProseMirror) { box-sizing: border-box; width: min(100%, var(--editor-line-width, 80ch)); min-height: 100%; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); outline: none; font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); caret-color: var(--color-accent-primary); }
.milkdown-host :deep(.ProseMirror-selectednode) { outline-color: var(--color-accent-primary); }
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
/* Mermaid measures HTML labels outside the editor. Crepe's paragraph padding
must not enlarge them after insertion into fixed-size SVG foreignObjects. */
/* Mermaid 在编辑器外部测量 HTML 标签。 Crepe 的段落填充在插入固定大小的 SVGforeignObjects 后不得放大它们。 */
.milkdown-host :deep(.editor-mermaid-preview svg foreignObject p) { margin: 0; padding: 0; line-height: inherit; font-weight: inherit; }
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
.milkdown-host :deep(.font-size-marker) { display: none; }
@@ -16,7 +16,7 @@ export const configureCalloutSerialization: Parameters<Editor['config']>[0] = ct
tracker.shift(2)
const result = state.indentLines(state.containerFlow(node, tracker.current()), (line, _index, blank) => `>${blank ? '' : ' '}${line}`)
exit()
// Only remove escaping from a leading callout marker, never body literals.
// 仅删除前导标注标记的转义,绝不删除正文文字。
return result.replace(/^(> )\\\[!([\w-]+)\\?\]/, '$1[!$2]')
} },
}))
@@ -36,8 +36,7 @@ function calloutMarkers(doc: ProseNode) {
return markers
}
// Keep native blockquotes in the document: typing, undo and Markdown serialization
// remain Milkdown transactions; the view never rewrites a user's callout source.
// 在文档中保留本机块引用:键入、撤消和 Markdown 序列化保留 Milkdown 事务;该视图永远不会重写用户的标注源。
export const calloutPlugin = $prose(() => new Plugin({
props: {
decorations(state) {
@@ -1,4 +1,4 @@
/** Mirror changed language labels without rescanning every code block on each DOM mutation. */
/** 镜像更改的语言标签,无需重新扫描每个 DOM 突变上的每个代码块。 */
export function installCodeBlockLabels(root: HTMLElement): () => void {
const sync = (block: HTMLElement) => {
const label = block.querySelector('.language-button')?.textContent?.trim() || 'Plain text'
@@ -13,7 +13,7 @@ export function installCodeBlockLabels(root: HTMLElement): () => void {
const changed = new Set<HTMLElement>()
for (const record of records) {
const element = record.target instanceof Element ? record.target : record.target.parentElement
// CodeMirror viewport/text changes do not change the footer's language.
// CodeMirror 视口/文本更改不会更改页脚的语言。
const label = element?.closest('.language-button')
const block = label?.closest<HTMLElement>('.milkdown-code-block')
if (block) changed.add(block)
@@ -8,7 +8,7 @@ export const headingFoldKey = new PluginKey<Set<number>>('heading-folding')
type Section = { from: number; body: number; end: number; level: number }
const sectionCache = new WeakMap<Node, Section[]>()
const decorationCache = new WeakMap<Node, WeakMap<Set<number>, DecorationSet>>()
/** A section ends at the next sibling heading of the same or a higher rank. */
/** 节以相同或更高级别的下一个同级标题结束。 */
export function headingSections(doc: Node): Section[] {
const cached = sectionCache.get(doc)
if (cached) return cached
@@ -79,7 +79,7 @@ export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
const result = tr.mapping.mapResult(old, 1)
if (!result.deleted && positions.has(result.pos)) mapped.add(result.pos)
}
// Outline jumps, find and keyboard navigation must never leave a hidden caret.
// 轮廓跳转、查找和键盘导航绝不能留下隐藏的插入符号。
if (tr.selectionSet || tr.docChanged) {
for (const section of sections) if (tr.selection.to >= section.body && tr.selection.from < section.end) mapped.delete(section.from)
}
@@ -7,8 +7,7 @@ function reconcile(view: EditorView) {
const { $from } = view.state.selection
if (!$from.parent.isTextblock || $from.parent.type.spec.code) return
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\ufffc')
// Also inspect the closing delimiter AFTER the caret: users commonly type
// a pair of backticks first, move left, and then fill in the code.
// 还要检查结束分隔符 AFTER 插入符号:用户通常首先键入一对反引号,向左移动,然后填写代码。
const spans = /(^|[^\\`])`([^`\n\ufffc]+)`(?!`)/g
let candidate: { start: number; end: number } | undefined
for (const match of text.matchAll(spans)) {
@@ -1,4 +1,4 @@
/* Generated by scripts/generate-language-icons.mjs. VSCode Icons (MIT); see language-icons-LICENSE.txt. */
/* scripts/generate-language-icons.mjs 自动生成。VSCode Icons 采用 MIT 许可证;详情见 language-icons-LICENSE.txt */
.milkdown-host .language-list-item[data-language] { display: flex; align-items: center; gap: 8px; }
.milkdown-host .language-list-item[data-language]::before { content: ''; flex: 0 0 20px; width: 20px; height: 20px; background: center / contain no-repeat url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23c5c5c5%22%20d%3D%22M20.414%202H5v28h22V8.586ZM7%2028V4h12v6h6v18Z%22%2F%3E%3C%2Fsvg%3E"); }
.milkdown-host .language-list-item[data-language][data-language="actionscript-3"]::before { background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23c41718%22%20d%3D%22M2%2015.281c1.918%200%202.11-1.055%202.11-1.918a17%2017%200%200%200-.192-2.205a19%2019%200%200%201-.192-2.205c0-2.4%201.63-3.452%203.836-3.452h.575v1.437h-.479c-1.534%200-2.11.767-2.11%202.205a14%2014%200%200%200%20.192%201.918a14%2014%200%200%201%20.192%202.014c0%201.726-.671%202.493-1.918%202.877v.1c1.151.288%201.918%201.151%201.918%202.877a14%2014%200%200%201-.192%202.014a13%2013%200%200%200-.192%201.918c0%201.438.575%202.3%202.11%202.3h.479V26.6h-.575c-2.205%200-3.836-.959-3.836-3.644a19%2019%200%200%201%20.192-2.205a16%2016%200%200%200%20.192-2.11c0-.863-.288-1.918-2.11-1.918Z%22%2F%3E%3Cpath%20fill%3D%22%23c41718%22%20d%3D%22M9.479%2018.062L8.233%2021.8H6.6l4.03-11.889h1.822L16.479%2021.8h-1.534L13.7%2018.062Zm3.932-1.151l-1.151-3.452a9.4%209.4%200%200%201-.575-2.205c-.192.671-.384%201.438-.575%202.11l-1.151%203.451h3.452Zm4.507%203.068a5.94%205.94%200%200%200%202.781.767c1.534%200%202.493-.863%202.493-2.014s-.671-1.726-2.205-2.4c-1.918-.671-3.164-1.726-3.164-3.356c0-1.822%201.534-3.26%203.836-3.26a5.14%205.14%200%200%201%202.589.575l-.384%201.247a5.5%205.5%200%200%200-2.3-.479c-1.63%200-2.205.959-2.205%201.822c0%201.151.767%201.63%202.4%202.3c2.014.767%203.068%201.726%203.068%203.452c0%201.822-1.342%203.452-4.123%203.452a5.8%205.8%200%200%201-3.068-.767Z%22%2F%3E%3Cpath%20fill%3D%22%23c41718%22%20d%3D%22M30%2016.623c-1.918%200-2.11%201.151-2.11%201.918a16%2016%200%200%200%20.192%202.11a16%2016%200%200%201%20.192%202.205c0%202.685-1.63%203.644-3.836%203.644h-.575v-1.438h.479c1.438%200%202.11-.863%202.11-2.3a13%2013%200%200%200-.192-1.918a14%2014%200%200%201-.192-2.014c0-1.726.767-2.589%201.918-2.877v-.1c-1.151-.288-1.918-1.151-1.918-2.877a14%2014%200%200%201%20.192-2.014a13%2013%200%200%200%20.192-1.918c0-1.438-.575-2.205-2.11-2.3h-.479V5.4h.575c2.205%200%203.836%201.055%203.836%203.452a17%2017%200%200%201-.192%202.205a17%2017%200%200%200-.192%202.205c0%20.959.288%201.918%202.11%201.918Z%22%2F%3E%3C%2Fsvg%3E"); }
@@ -1,4 +1,4 @@
/** Promote menus to the top layer; only open menus need scroll measurements. */
/** 将菜单提升到顶层;只有打开的菜单才需要滚动测量。 */
export function installLanguagePickerPopover(root: HTMLElement): () => void {
const menus = new Set<HTMLElement>()
const openMenus = new Set<HTMLElement>()
@@ -55,7 +55,7 @@ export function installLanguagePickerPopover(root: HTMLElement): () => void {
}
root.querySelectorAll<HTMLElement>('.language-picker').forEach(sync)
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-expanded'] })
// The outer editor viewport is an ancestor of root, so listen in capture on the document.
// 外部编辑器视口是根的祖先,因此在文档上侦听捕获。
document.addEventListener('scroll', positionOpenMenus, { capture: true, passive: true })
window.addEventListener('resize', positionOpenMenus)
return () => {
@@ -1,4 +1,4 @@
/** Editable anchors need explicit navigation; plain clicks keep editing the link. */
/** 可编辑锚点需要显式导航;简单的点击即可继续编辑链接。 */
export function installLinkNavigation(root: HTMLElement): () => void {
const navigate = (event: MouseEvent) => {
if (event.button !== 0 || !(event.ctrlKey || event.metaKey) || event.altKey) return
@@ -7,7 +7,7 @@ export function installLinkNavigation(root: HTMLElement): () => void {
if (!link || !root.contains(link)) return
const href = link.getAttribute('href')?.trim()
if (!href) return
// Consume modified clicks before Milkdown's link editor or native navigation.
// 在 Milkdown 的链接编辑器或本机导航之前消耗修改的点击。
event.preventDefault()
event.stopPropagation()
let url: URL
@@ -6,9 +6,7 @@ import { appendDiagramControls } from '@/utils/diagramControls'
let previewId = 0
export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void, kind = 'mermaid', themeId = 'light'): HTMLElement {
// Each revision owns its element, so a slow render cannot replace newer content.
// Milkdown sanitizes Element input to its inner HTML; retain the revision
// marker and controls inside an otherwise disposable envelope.
// 每个修订版本都拥有其元素,因此缓慢的渲染无法替换较新的内容。 Milkdown 清理其内部 HTML 的 Element 输入;将修订标记和控件保留在一次性信封内。
const envelope = document.createElement('div')
const container = document.createElement('div')
envelope.append(container)
@@ -19,12 +17,10 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
container.textContent = t('正在渲染图表…', 'Rendering diagram…')
const publish = async () => {
await nextTick()
// Milkdown sanitizes and copies this element. Publish only if its revision
// still exists; edits, language changes and unmounts remove the old marker.
// Milkdown 清理并复制该元素。仅当其修订版本仍然存在时才发布;编辑、语言更改和卸载会删除旧标记。
const visible = document.getElementById(container.id)
if (visible) {
// PreviewPanel copies HTML instead of retaining the supplied element.
// Update the current copy through Milkdown's reactive callback.
// PreviewPanel 复制 HTML,而不是保留提供的元素。通过 Milkdown 的反应式回调更新当前副本。
applyPreview(envelope.cloneNode(true) as HTMLElement)
}
}
@@ -38,7 +34,7 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
void publish()
return
}
// Mermaid runs in strict mode; Milkdown sanitizes the preview before insertion.
// Mermaid以严格模式运行; Milkdown 在插入之前清理预览。
container.innerHTML = result.svg
appendDiagramControls(container)
if (result.warnings.length) { const warning = document.createElement('p'); warning.textContent = result.warnings.join('\n'); warning.setAttribute('role', 'status'); container.append(warning) }
@@ -7,8 +7,7 @@ type CodeTheme = 'github-light' | 'github-dark'
export async function shikiLanguage(language: string, theme: CodeTheme): Promise<LanguageSupport> {
const tokenize = await getCodeTokenizer(theme, language)
// Milkdown recreates off-screen CodeMirror views. Reuse immutable ranges for
// identical code within this language/theme, with a bounded retention budget.
// Milkdown 重新创建屏幕外 CodeMirror 视图。在该语言/主题内重复使用相同代码的不可变范围,并保留有限的预算。
const cache = new Map<string, DecorationSet>()
let cachedCharacters = 0
const highlights = ViewPlugin.fromClass(class {
@@ -53,7 +52,7 @@ export async function shikiLanguage(language: string, theme: CodeTheme): Promise
}
}, { decorations: value => value.decorations })
// CodeMirror still owns selection, input and undo. Shiki owns token colors.
// CodeMirror仍然拥有选择、输入和撤消功能。 Shiki拥有令牌颜色。
const parser = StreamLanguage.define({ token(stream) { stream.skipToEnd(); return null } })
return new LanguageSupport(parser, highlights)
}
+2 -2
View File
@@ -22,7 +22,7 @@ async function load(reset = false, older = false) {
const viewport = scroller.value
const oldHeight = viewport?.scrollHeight ?? 0
const oldTop = viewport?.scrollTop ?? 0
// Preserve a visible row when adding history and trimming the opposite edge.
//
const anchor = older && viewport ? [...viewport.querySelectorAll<HTMLElement>('[data-log-id]')].find(row => row.getBoundingClientRect().bottom > viewport.getBoundingClientRect().top) : undefined
const anchorTop = anchor?.getBoundingClientRect().top
const anchorId = anchor?.dataset.logId
@@ -30,7 +30,7 @@ async function load(reset = false, older = false) {
try {
const result = await apiClient.get<LogPage>('/api/logs', { params: { limit: 50, before: older ? page.value.next_cursor ?? undefined : undefined, ...applied } })
if (version !== revision) return
// If the reader scrolled away during a refresh, leave their view untouched.
//
if (!reset && !older && !following.value) return
const previous = page.value.items
const overlaps = result.items.some(item => previous.some(old => old.id === item.id))
+2 -4
View File
@@ -115,8 +115,7 @@ function formPayload(): McpServerInput {
function payload(requireConnection = true): McpServerInput {
const { config, secrets } = editorMode.value === 'form'
? normalizeMcpConfig(formPayload(), '', requireConnection) : parseMcpJson(rawConfig.value, form.name, requireConnection)
// Keep only still-declared drafts. A mode switch must not discard imported keys,
// and editing the declaration must not later send a removed key to the Secret API.
// 稿 Secret API
importedSecrets.value = mergeImportedSecrets(config, importedSecrets.value, secrets)
if (editingId.value) config.version = form.version
if (editorMode.value === 'json') rawConfig.value = JSON.stringify(config, null, 2)
@@ -149,8 +148,7 @@ async function save() {
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !(await askConfirm(t('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?', 'The command, address, or authentication settings changed. Previous tests and authorization will be invalidated. Save?')))) return
busy.value = 'save'
saved = editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
// Commit the returned ID/version before saving secrets so a partial failure can
// retry this server instead of creating a duplicate or sending a stale version.
// ID/便
editingId.value = saved.server_id
editingOriginal.value = saved
resetEditor({ ...input, version: saved.version })
+5 -8
View File
@@ -11,8 +11,7 @@ export function mergeImportedSecrets(config: McpServerInput, previous: ImportedS
const keys = item.kind === 'header' ? config.secret_header_keys : config.secret_environment_keys
const declared = keys.find(key => normalize(key) === normalize(item.key))
if (declared === undefined) continue
// HTTP identity is case-insensitive, but the Secret API requires the current
// declared spelling. New inline values replace older drafts of that identity.
// HTTP 身份不区分大小写,但 Secret API 需要当前声明的拼写。新的内联值取代了该身份的旧草稿。
merged.set(`${item.kind}:${normalize(declared)}`, { ...item, key: declared })
}
return [...merged.values()]
@@ -50,7 +49,7 @@ function timeout(value: unknown, fallback: number, max: number, label: string):
return value
}
// Do not silently rewrite executable arguments or secret values copied from chat.
// 不要默默地重写从聊天复制的可执行参数或秘密值。
function checkUrl(value: string, label: string) {
if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}${t('请填写纯 URL,不要粘贴 Markdown 链接', ': enter a plain URL instead of a Markdown link')}`)
}
@@ -62,9 +61,7 @@ export function parseMcpJson(raw: string, fallbackName = '', requireConnection =
return normalizeMcpConfig(parsed, fallbackName, requireConnection)
}
/** Normalize external client JSON before it reaches either the form or the API.
* Inline secrets leave the public config here and are sent only to the Secret API.
*/
/** 在外部客户端 JSON 到达表单或 API 之前对其进行标准化。内联机密在此处保留公共配置,并且仅发送到机密 API。 */
export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireConnection = true) {
let raw = object(parsed, t('服务器配置', 'Server configuration'))
if ('mcpServers' in raw) {
@@ -75,7 +72,7 @@ export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireCo
}
const allowed = new Set([...Object.keys(emptyMcpConfig()), 'version', 'env', 'type', 'timeout', 'sse_read_timeout'])
if (Object.keys(raw).some(key => !allowed.has(key))) {
// Never echo arbitrary unknown keys: pasted secrets sometimes become JSON keys.
// 永远不要回显任意未知密钥:粘贴的秘密有时会变成 JSON 密钥。
throw new Error(t('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层', 'The server configuration contains unsupported fields. Put API keys in the corresponding env/environment variables, not at the top level.'))
}
if (raw.env !== undefined && raw.environment !== undefined) throw new Error(t('env 与 environment 请只保留一个,避免覆盖配置', 'Keep either env or environment, not both'))
@@ -100,7 +97,7 @@ export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireCo
config.secret_header_keys = [...new Set(strings(raw.secret_header_keys, 'secret_header_keys'))]
config.permissions = strings(raw.permissions, 'permissions')
config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, t('启动超时', 'Startup timeout'))
// Compatibility policy: legacy read timeout becomes the tool wait budget, not an SSE transport setting.
// 兼容性策略:旧的读取超时成为工具等待预算,而不是 SSE 传输设置。
config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, t('工具超时', 'Tool timeout'))
if (config.transport === 'stdio') {
if (requireConnection && !config.command) throw new Error(t('stdio 配置必须填写 command', 'stdio configuration requires command'))
@@ -21,7 +21,7 @@ const documents: Record<string, string> = {
minimax: 'https://platform.minimaxi.com/docs/api-reference/text-openai-api',
stepfun: 'https://platform.stepfun.com/docs/zh/guides/models/overview',
}
// Exact documented model IDs only; an unrecognised model is always manual.
// ID
const documentedWindow = computed(() => {
if (props.preset === 'minimax') {
if (props.model === 'MiniMax-M3') return 1000000
@@ -165,10 +165,10 @@ async function save() {
if (!form.name.trim() || !form.base_url.trim()) throw new Error(t('请填写名称和 Base URL。', 'Enter a name and Base URL.'))
if (!requestJsonValid.value) throw new Error(t('请先修正自定义请求 JSON。', 'Fix the custom request JSON first.'))
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error(t('请输入 API Key。密钥将由后端加密保存。', 'Enter an API key. It will be encrypted by the backend.'))
// Snapshot before awaiting: closing/unmounting must never create a provider with a changed draft.
// /稿
const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false, request_overrides: requestOverrides.value, context_policies: JSON.parse(JSON.stringify(contextPolicies.value)) }
if (apiKey.value.trim()) {
// Rotate even an existing reference: older installations may share preset credential IDs.
// ID
const nextId = newCredentialId()
const request = service.putCredential(nextId, apiKey.value.trim())
apiKey.value = ''
@@ -178,7 +178,7 @@ async function save() {
configured.value = true
}
const reference = configured.value ? credentialId.value : undefined
// A failed status check must not silently unlink the provider's existing credential.
//
if (credentialError.value && !reference) throw new Error(credentialError.value)
const saved = props.provider
? await service.updateProvider(props.provider.provider_id, { ...data, version: props.provider.version, credential_id: reference ?? null })
@@ -29,7 +29,7 @@ it('shades by consumed metric and gives equal usage equal shades', async () => {
expect(segments[0]!.attributes('style')).not.toBe(segments[1]!.attributes('style'))
expect(wrapper.get('.model-legend').text()).toContain('model-1')
expect(segments[0]!.attributes('title')).toContain('100')
// happy-dom drops color-mix declarations; inspect the bound color values.
// happy-dom 删除颜色混合声明;检查绑定的颜色值。
const colors = wrapper.vm as unknown as {modelColor:(key:string,source:'api') => string}
expect(colors.modelColor('m0','api')).toContain('67.5%')
expect(colors.modelColor('m1','api')).toContain('95%')
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
// Vitest disables CSS by default, including CSS raw imports. Load the real files here.
// Vitest 默认禁用 CSS,包括 CSS 原始导入。在这里加载真实的文件。
vi.mock('@/styles/features.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/features.css', 'utf8') }))
vi.mock('@/styles/tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/tokens.css', 'utf8') }))
vi.mock('@/styles/callouts.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/callouts.css', 'utf8') }))
@@ -29,7 +29,7 @@ it.each(themes)('previews shared component states safely for $theme_id', theme =
expect(doc.querySelector('style')!.textContent).toContain('.button-primary:hover')
expect(doc.querySelector('style')!.textContent).not.toContain('color:white')
const rules = Array.from(doc.styleSheets[0]!.cssRules) as CSSStyleRule[]
// The sandbox cannot inherit MarkdownContent's component stylesheet.
// 沙箱无法继承MarkdownContent的组件样式表。
const codeRule = rules.find(rule => rule.selectorText === '.markdown-content .shiki code')!
const lineRule = rules.find(rule => rule.selectorText === '.markdown-content .shiki .line')!
expect(codeRule.style.getPropertyValue('display')).toBe('block')
@@ -37,7 +37,7 @@ it.each(themes)('previews shared component states safely for $theme_id', theme =
expect(lineRule.style.getPropertyValue('min-height')).toBe('1lh')
const rootRule = rules.filter(rule => rule.selectorText === 'html').pop()!
const bodyRule = rules.filter(rule => rule.selectorText === 'body').pop()!
// The embedded document must override the app-shell overflow lock.
// 嵌入文档必须覆盖应用程序外壳溢出锁定。
expect(rootRule.style.getPropertyValue('overflow-y')).toBe('auto')
expect(rootRule.style.getPropertyPriority('overflow-y')).toBe('important')
expect(bodyRule.style.getPropertyValue('height')).toBe('auto')
@@ -15,8 +15,7 @@ const props = defineProps<{ themeId: string; name?: string; css?: string }>()
const emit = defineEmits<{ (event: 'close'): void }>()
const theme = computed(() => props.name ? { name: props.name } : mockCommunityThemes.find(item => item.theme_id === props.themeId))
const previewDocument = computed(() => {
// Both imported and bundled CSS are previewed in a script-free isolated document.
// Previewing never installs a theme or changes application styles/storage.
// CSS /
const doc = document.implementation.createHTMLDocument(theme.value?.name ?? '')
doc.documentElement.dataset.theme = props.themeId
const policy = doc.createElement('meta')
@@ -53,7 +53,7 @@ async function run() {
if (missingRequiredFields(command, args.value).length) { error.value = t('请填写必填参数', 'Complete required fields'); return }
busy.value = true; error.value = ''
try {
// Runtime rechecks enabled state, schema, when conditions and permissions.
//
const result = await executePluginCommand(command.command_id, cleanArguments(args.value), { ...snapshot.value })
await applyCommandEffect(result.effect, {
navigate: path => router.push(path),
+1 -1
View File
@@ -18,7 +18,7 @@ watch(appLocale, (value) => {
if (typeof document !== 'undefined') document.documentElement.lang = value
}, { immediate: true })
/** Keep the Chinese source beside its English translation while the UI is migrated. */
/** 迁移 UI 时,将中文源保留在英文翻译旁边。 */
export function t(zh: string, en: string): string {
return appLocale.value === 'en' ? en : zh
}
+1 -1
View File
@@ -135,7 +135,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
try {
errBody = (await resp.json()) as ErrorResponse
} catch {
/* ignore */
/* 忽略 */
}
const code = errBody?.error?.code || `HTTP_${resp.status}`
+1 -2
View File
@@ -37,8 +37,7 @@ export const mediaService = {
},
}
// Keep one identity until the input/options change, including a lost HTTP response.
// Payloads remain in memory; durable uploads/jobs are owned by the backend.
// 保留一个身份,直到输入/选项发生变化,包括丢失 HTTP 响应。有效负载保留在内存中;持久上传/作业归后端所有。
export function createMediaSubmission() {
let pending: {file: File; options: string; uploadKey: string; jobKey: string; attachmentId?: string} | null = null
return {
+1 -1
View File
@@ -5,7 +5,7 @@ export function getModelRouting(): Promise<ModelRoutingResponse> {
return apiClient.get('/api/model-routing')
}
// version is the last version read from the server (optimistic concurrency).
// 版本是从服务器读取的最后一个版本(乐观并发)。
export function saveModelRouting(config: ModelRoutingConfig): Promise<ModelRoutingResponse> {
return apiClient.put('/api/model-routing', config)
}
@@ -2,7 +2,7 @@ import { hostInvoke } from './desktop'
export interface RequestProgress { issued: boolean; requestId?: string }
/** A reservation makes cancel-before-dispatch definitive even across IPC ordering. */
/** 即使在 IPC 订购中,预订也可以确保发货前取消。 */
export function coreRequest<T>(args: Record<string, unknown>, signal: AbortSignal, timeoutMs: number, progress: RequestProgress): Promise<T> {
return new Promise((resolve, reject) => {
let settled = false
@@ -31,7 +31,7 @@ export function coreRequest<T>(args: Record<string, unknown>, signal: AbortSigna
if (!settled) { settled = true; reject(error) }
} finally {
signal.removeEventListener('abort', abort)
// Also discard a reservation if dispatch failed before Rust claimed it.
// 如果在 Rust 声明保留之前调度失败,则也丢弃保留。
cancel()
}
})()
+2 -2
View File
@@ -4,7 +4,7 @@ import { hostInvoke } from './desktop'
type Message = { kind: 'headers'; status: number } | { kind: 'chunk'; data: string }
| { kind: 'done' } | { kind: 'error'; code: string }
/** Native session credentials stay in Rust; this channel carries response bytes only. */
/** 本机会话凭证保留在 Rust 中;该通道仅承载响应字节。 */
export function coreStream(path: string, init: RequestInit): Promise<Response> {
return new Promise((resolve, reject) => {
const requestId = crypto.randomUUID()
@@ -33,7 +33,7 @@ export function coreStream(path: string, init: RequestInit): Promise<Response> {
if (message.kind === 'chunk') {
const bytes = Uint8Array.from(atob(message.data), c => c.charCodeAt(0))
controller.enqueue(bytes)
// Bound queued data if a consumer stops reading without cancelling.
// 如果消费者停止读取而不取消,则绑定排队数据。
if ((controller.desiredSize ?? 0) < -4096) fail(new Error('CORE_STREAM_BACKPRESSURE'))
}
if (message.kind === 'error') fail(new Error(message.code))
@@ -1,4 +1,4 @@
/** Portable preference records are bound to the active Vault; local drafts retain their own Vault key. */
/** 可移植偏好记录与主用Vault绑定;本地草稿保留自己的 Vault 密钥。 */
import { ref, watch, nextTick } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
import { useThemeStore } from '@/stores/theme'
@@ -1,4 +1,4 @@
/** A durable preference draft keeps its original CAS base until the user resolves a conflict. */
/** 持久偏好草案保留其原始 CAS 基础,直到用户解决冲突。 */
export interface LogicalRecord<T> { schema: 1; kind: string; id: string; data: T }
export interface RecordDocument<T> { record: LogicalRecord<T>; hash: string; file_id: string }
interface Draft<T> { record: LogicalRecord<T>; expected: string; operation_id: string }
@@ -40,7 +40,7 @@ export class RecordBinding<T> {
if (this.stopped) return
const data = JSON.parse(JSON.stringify(this.options.read())) as T
if (!this.draft && this.remote && JSON.stringify(data) === JSON.stringify(this.remote.record.data)) return
// New edits while a request runs get a new operation, but preserve the unresolved base.
// 请求运行时的新编辑会获取新操作,但保留未解析的基础。
this.draft = { record: { schema: 1, kind: this.options.kind, id: this.options.id, data }, expected: this.draft?.expected ?? this.remote?.hash ?? '', operation_id: crypto.randomUUID() }
this.restored = false; this.invalidDraft = false
try { this.persist(); if (this.error === 'PREFERENCE_DRAFT_STORE_FAILED') this.error = '' } catch { this.error = 'PREFERENCE_DRAFT_STORE_FAILED'; this.options.changed?.() }
@@ -71,7 +71,7 @@ export class RecordBinding<T> {
if (this.draft.operation_id === draft.operation_id) {
this.persist(null); this.draft = null; this.appliedHash = committed.hash
} else {
// The next local edit follows the just-confirmed predecessor, not its older CAS base.
// 下一个本地编辑遵循刚刚确认的前身,而不是其较旧的 CAS 基础。
this.draft.expected = committed.hash; this.persist()
}
} else if (this.remote && this.remote.hash !== this.appliedHash) {
+1 -1
View File
@@ -67,7 +67,7 @@ export function coerceArgument(field: CommandField, raw: string): unknown {
return Number.isNaN(parsed) ? undefined : parsed
}
if (field.type === 'object' || field.type === 'array') {
try { return JSON.parse(raw) } catch { return raw } // Backend reports the schema error without discarding the input.
try { return JSON.parse(raw) } catch { return raw } // 后端报告模式错误而不丢弃输入。
}
return raw
}
+4 -7
View File
@@ -6,10 +6,7 @@ import { isMap, parseDocument } from 'yaml'
export const THEME_APP_VERSION = appPackage.version
/**
* Semantic colors every page and component may consume. Theme packages can
* override any subset; the compatibility layer supplies the rest.
*/
/** 每个页面和组件可能消耗的语义颜色。主题包可以覆盖任何子集;兼容层提供其余部分。 */
export const REQUIRED_THEME_COLOR_TOKENS = [
'background-primary', 'background-secondary', 'background-tertiary', 'background-hover', 'background-active', 'background-overlay',
'surface-primary', 'surface-secondary', 'surface-elevated',
@@ -29,7 +26,7 @@ const STORAGE_KEY = 'installed-themes'
const ACTIVE_CUSTOM_KEY = 'active-custom-theme'
export const MAX_THEME_BYTES = 5 * 1024 * 1024
/** Normalize all transports to the existing single-file inspection format. */
/** 将所有传输标准化为现有的单文件检查格式。 */
export async function decodeThemePackage(bytes: Uint8Array): Promise<string> {
if (bytes.length > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
const decode = (data: Uint8Array) => new TextDecoder('utf-8', { fatal: true }).decode(data)
@@ -180,7 +177,7 @@ function applyThemeCss(themeId: string, css: string) {
const THEME_CONTRACT_MARKER = '/* opennexus-theme-contract */'
/** Fill incomplete third-party themes with an accessible semantic palette. */
/** 使用可访问的语义调色板填充不完整的第三方主题。 */
export function withThemeContract(themeId: string, isDark: boolean, css: string): string {
if (css.includes(THEME_CONTRACT_MARKER)) return css
const selector = `[data-theme="${themeId}"]`
@@ -408,7 +405,7 @@ export function setActiveCustomTheme(themeId: string | null) {
const theme = themeId ? loadStoredThemes().find(item => item.theme_id === themeId) : undefined
const storedCss = themeId ? localStorage.getItem(`${STORAGE_KEY}-css-${themeId}`) : null
const css = themeId && theme && storedCss ? withThemeContract(themeId, theme.is_dark, storedCss) : storedCss
// Validate before changing the current page. Only the selected theme owns a style node.
// 更改当前页面之前进行验证。只有选定的主题才拥有样式节点。
if (css) validateCssSafety(css)
document.head.querySelectorAll('style[id^="theme-style-"]').forEach(style => style.remove())
if (themeId && css) applyThemeCss(themeId, css)
+2 -2
View File
@@ -147,7 +147,7 @@ export async function readFileContent(filePath: string): Promise<string> {
return note.markdown
}
/** Resolve the backend note identity already associated with a workspace path. */
/** 解析已与工作空间路径关联的后端笔记标识。 */
export async function getNoteId(filePath: string): Promise<string> {
return requireNoteId(filePath)
}
@@ -163,7 +163,7 @@ export async function saveFileContent(filePath: string, content: string, expecte
await noteService.updateNote(await requireNoteId(filePath), {
markdown: content,
...(expectedHash ? { expected_content_hash: expectedHash } : {}),
// Explicit [] clears the index; absent tags retain API-managed tags.
// 显式[]清除索引;缺失的标签保留 API 管理的标签。
...(metadata?.hasTags ? { tags: metadata.tags } : {}),
})
}
+1 -1
View File
@@ -385,7 +385,7 @@ it('restores each answer context after history reload, including explicitly abse
vi.mocked(streamChat).mock.calls.at(-1)![1].onDone?.()
await s.retryMessage(s.messages[1]!.message_id,undefined,null)
vi.mocked(streamChat).mock.calls.at(-1)![1].onDone?.()
// API serializes absent captured context as null; do not fall back to the original user snapshot.
// API 将缺失的捕获上下文序列化为 null;不要回退到原始用户快照。
vi.mocked(listConversationMessages).mockResolvedValue({items:JSON.parse(JSON.stringify(s.messages)),page:{total:2,limit:500,offset:0}})
await s.setActiveConversation(s.activeConversationId!)
await s.sendMessage('continue')
+3 -3
View File
@@ -175,7 +175,7 @@ export const useChatStore = defineStore('chat', () => {
historyError.value = ''
contextNotice.value = ''
const conversation = addLocalConversation(t('新对话', 'New conversation'))
try { await persistConversation(conversation) } catch { /* exposed through historyError */ }
try { await persistConversation(conversation) } catch { /* 通过historyError暴露 */ }
}
async function sendMessage(text: string, retryMessageId?: string, workspaceContext?: WorkspaceContext | null) {
@@ -206,7 +206,7 @@ export const useChatStore = defineStore('chat', () => {
finally {
if (version === streamVersion) isPreparing.value = false
}
// Switching, stopping or deleting cancels sends still waiting for creation.
// 切换、停止或删除会取消仍在等待创建的发送。
if (version !== streamVersion || activeConversationId.value !== conversation.conversation_id) return
const conversationId = conversation.conversation_id
@@ -279,7 +279,7 @@ export const useChatStore = defineStore('chat', () => {
if (call && typeof event.data.arguments_delta === 'string') {
const buffer = (argumentBuffers.get(call.tool_call_id) ?? '') + event.data.arguments_delta
argumentBuffers.set(call.tool_call_id, buffer)
try { call.parameters = JSON.parse(buffer) } catch { /* incomplete JSON fragment */ }
try { call.parameters = JSON.parse(buffer) } catch { /* 不完整的JSON片段 */ }
}
if (call && event.data.arguments && typeof event.data.arguments === 'object') Object.assign(call.parameters, event.data.arguments)
}
+1 -1
View File
@@ -13,7 +13,7 @@ function validate(value: ChatPreferences) {
}
export const useChatPreferences = defineStore('chatPreferences', () => {
const settings = ref<ChatPreferences>(empty())
try { const stored = localStorage.getItem(storageKey); if (stored) settings.value = validate(JSON.parse(stored)) } catch { /* Invalid or unavailable local settings use defaults. */ }
try { const stored = localStorage.getItem(storageKey); if (stored) settings.value = validate(JSON.parse(stored)) } catch { /* 无效或不可用的本地设置使用默认值。 */ }
function save(value: ChatPreferences) {
const next = validate(value)
localStorage.setItem(storageKey, JSON.stringify(next))
+1 -1
View File
@@ -15,7 +15,7 @@ export const useLayoutPreferencesStore = defineStore('layoutPreferences', () =>
localStorage.setItem('primary-sidebar-expanded', String(primaryExpanded.value))
localStorage.setItem('workspace-sidebar-width', String(workspaceWidth.value))
localStorage.setItem('chat-sidebar-width', String(chatWidth.value))
} catch { /* Keep the current layout usable when local storage is unavailable. */ }
} catch { /* 当本地存储不可用时,保持当前布局可用。 */ }
}, { flush: 'sync' })
return { primaryExpanded, workspaceWidth, chatWidth }
})
+1 -1
View File
@@ -29,7 +29,7 @@ export const markdownPresets = {
const key = 'markdown-preferences'
export const useMarkdownPreferencesStore = defineStore('markdown-preferences', () => {
let saved: Record<string, unknown> = {}
try { saved = JSON.parse(localStorage.getItem(key) ?? '{}') ?? {} } catch { /* defaults */ }
try { saved = JSON.parse(localStorage.getItem(key) ?? '{}') ?? {} } catch { /* 默认值 */ }
const preferences = ref(normalizeMarkdownPreferences(saved.preferences))
const customPresets = ref<{ name: string; preferences: MarkdownPreferences }[]>(Array.isArray(saved.presets)
? saved.presets.filter(item => item && typeof item.name === 'string').slice(0, 20).map(item => ({ name: item.name.slice(0, 40), preferences: normalizeMarkdownPreferences(item.preferences) })) : [])

Some files were not shown because too many files have changed in this diff Show More