diff --git a/.agents/rules/safety.md b/.agents/rules/safety.md index bcbcc0b..646cc01 100644 --- a/.agents/rules/safety.md +++ b/.agents/rules/safety.md @@ -4,7 +4,7 @@ - 严格遵守“只读”“只出报告”“不改代码”等范围限制。 - API Key、OAuth token、Keychain、SSH 私钥、账号数据库和私人日志不得读取、打印、复制、修改或删除。 -- 不检查或修改用户真实 `~/.claude-science`;诊断时也不改真实 `~/.csswitch/skills` 或其他用户 Skill 数据。 +- 不枚举、读取或修改用户真实 `~/.claude-science` 数据。唯一的 runtime 例外是按产品合同只读校验并执行精确文件 `~/.claude-science/bin/claude-science`;只能检查该文件的路径边界、metadata、可执行性和 `--version`,启动时必须注入 CSSwitch 独立 HOME/data-dir,不能访问同目录中的账号、OAuth、项目、会话、设置或数据库。诊断时也不改真实 `~/.csswitch/skills` 或其他用户 Skill 数据。 - 自动 runtime 检查必须使用临时外层 `HOME`、隔离 data-dir、假密钥 / 假 `security`、动态端口和可精确归属的进程。 - `8765` 视为用户真实 Science 的保留端口;只有护栏需要确认监听身份时才只读观察。 - 未经明确授权,不把 `/Applications/CSSwitch.app` 当开发测试目标,也不替换它。 diff --git a/.agents/rules/science-runtime.md b/.agents/rules/science-runtime.md index a6d9976..99a934b 100644 --- a/.agents/rules/science-runtime.md +++ b/.agents/rules/science-runtime.md @@ -1,9 +1,9 @@ # Science runtime 规则 - Science executable、持久 data-dir、版本 runtime 资源、组织数据和监听进程是不同事实。 -- 新启动通常使用用户当前安装的官方 Claude Science App executable,并复用 CSSwitch 隔离 data-dir。 +- 新启动优先只读复用官方 updater 已下载的 `~/.claude-science/bin/claude-science`,不存在或未通过安全版本预检时才使用官方 Claude Science App executable;两者都必须复用 CSSwitch 隔离 data-dir。 - `SCIENCE_BIN` 仅是显式开发 override;无效时 fail closed。历史缓存绝不能隐式回退。 -- 不从真实 `~/.claude-science` 复制 runtime 资产,不下载或升级 Science;保持 `--no-auto-update`,除非产品合同另行批准。 +- 不从真实 `~/.claude-science` 复制 runtime 资产,不读取 executable 之外的真实数据,不下载或升级 Science;隔离模式保持 `--no-auto-update`,更新由官方模式负责。 - Science 与 CSSwitch Gateway 均绑定 loopback;引入或暗示 `0.0.0.0` 需要单独的安全和产品决策。 - 端口占用或 `status` 成功不能单独证明 runtime 身份;需结合 executable、data-dir、监听 PID 和受管启动身份。 - 已健康 daemon 不因版本探测或可选功能漂移而强制重启。 diff --git a/.agents/rules/system-ssh.md b/.agents/rules/system-ssh.md index ec89ffc..6cbd6da 100644 --- a/.agents/rules/system-ssh.md +++ b/.agents/rules/system-ssh.md @@ -1,11 +1,12 @@ # 系统 SSH 规则 - 系统 SSH 配置复用默认关闭,必须由用户明确 opt-in。 -- bridge 只能让系统 OpenSSH 读取真实 `~/.ssh/config`;不得复制或链接整个 `.ssh`,不得暴露私钥内容。为满足 Science 启动前 Host 校验,opt-in 时可在隔离 HOME 写一个 CSSwitch 管理的 `0600` 普通 config stub,其内容仅为指向真实 config 的绝对 `Include`。 -- config stub 不得是 symlink,不得复制真实 config 内容。启用时遇到外来文件、symlink 或特殊文件必须 fail closed;关闭授权时只删除能严格识别为 CSSwitch 生成的 stub。 +- bridge 的执行面只能让系统 OpenSSH 读取真实 `~/.ssh/config`;不得复制或链接整个 `.ssh`,不得暴露私钥内容。为兼容不展开 `Include` 的 Science Host 枚举器,opt-in 时可在隔离 HOME 写一个 CSSwitch 管理的 `0600` 普通 config stub:先写指向真实 config 的绝对 `Include`,再只投影真实顶层 config 中满足安全 token 语法的显式 Host alias。 +- config stub 不得是 symlink,不得复制 HostName、User、Port、IdentityFile、ProxyCommand、Match 或其他真实连接配置。Host alias 投影必须过滤通配符/否定 pattern、稳定去重并有数量/大小上限;提取失败必须阻断 opt-in 启动,不能静默退回 Include-only。 +- 启用时遇到外来文件、伪造 marker、symlink 或特殊文件必须 fail closed;v1 Include-only stub 可原子迁移,关闭授权时只删除能严格识别为 CSSwitch 生成的 v1/v2 stub。 - 启用设置时必须先验证真实 config;后续启动必须再次验证 config 与 packaged wrapper,任何一项缺失或不安全都应 fail closed。Science 启动后的单次 `ssh` 命令失败才只影响该命令。 - 不启动 `sshd`、不开启 Remote Login、不改防火墙,也不增加公网监听。 - 这是行为授权而非单文件授权:OpenSSH 的 `Include`、`IdentityFile`、`IdentityAgent`、`ProxyCommand` 和 `Match exec` 可能按原生语义访问其他文件、Agent 或命令。 - SSH 能力必须与 inference Gateway 暴露、真实账号凭证处理分离。 - 特定真实服务器或用户 SSH 配置的连通性需要独立授权和证据,不能由 wrapper 测试推断。 -- 错误与证据不得打印私钥路径、config 内容、agent 数据或其他敏感信息。 +- 错误与证据不得打印真实 Host alias、私钥路径、config 连接内容、agent 数据或其他敏感信息。 diff --git a/README.md b/README.md index c722e5c..814b981 100644 --- a/README.md +++ b/README.md @@ -120,10 +120,10 @@ CSSwitch 当前聚焦于安全地把外部 Skill 接入隔离 Science,而不 ## 安全与隔离 -- 第三方模式使用独立 HOME、data-dir 和本地回环 Gateway,不读取或修改真实 Claude 登录与 Science 数据。 +- 第三方模式使用独立 HOME、data-dir 和本地回环 Gateway,不读取或修改真实 Claude 登录与 Science 数据;它可只读复用官方已下载的同一 Science executable,但不共享登录、项目或会话。 - API Key 保存在本机 `~/.csswitch/config.json`,文件权限为 `0600`;凭据不会写入日志。 - 官方 Claude 模式会停止第三方代理链路,再打开真实 Science。 -- CSSwitch 不下载、固定或自动升级 Claude Science;启动时优先使用当前安装的官方 App。 +- CSSwitch 不下载、固定或自动升级 Claude Science;启动时优先复用官方已下载的 runtime,校验失败时才回退当前安装的官方 App。 ## 当前边界 diff --git a/desktop/src-tauri/src/runtime/science.rs b/desktop/src-tauri/src/runtime/science.rs index c058746..f97c681 100644 --- a/desktop/src-tauri/src/runtime/science.rs +++ b/desktop/src-tauri/src/runtime/science.rs @@ -20,6 +20,7 @@ pub(crate) const CACHED_ONCE_CHOICE: &str = "cached_once"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ScienceRuntimeSource { Explicit, + OfficialDownloaded, InstalledApp, CachedOnce, } @@ -28,6 +29,7 @@ impl ScienceRuntimeSource { pub(crate) fn code(self) -> &'static str { match self { Self::Explicit => "explicit", + Self::OfficialDownloaded => "official_downloaded", Self::InstalledApp => "installed_app", Self::CachedOnce => "cached_once", } @@ -237,6 +239,28 @@ fn cached_science_bin(data_dir: &Path) -> PathBuf { data_dir.join("bin").join("claude-science") } +fn official_downloaded_science_bin_for_home(home: &Path) -> Option { + if !home.is_absolute() { + return None; + } + let path = cached_science_bin(&home.join(".claude-science")); + is_executable_file(&path).then_some(path) +} + +#[cfg(not(feature = "acceptance-build"))] +fn official_downloaded_science_bin() -> Option { + let home = std::env::var_os("HOME").map(PathBuf::from)?; + official_downloaded_science_bin_for_home(&home) +} + +// Acceptance artifacts must never inspect the real user's Science data root. +// Path-injected unit tests below cover this candidate without weakening that +// build-time isolation contract. +#[cfg(feature = "acceptance-build")] +fn official_downloaded_science_bin() -> Option { + None +} + fn safe_science_version(path: &Path) -> Option { let output = Command::new(path) .arg("--version") @@ -291,6 +315,7 @@ fn science_runtime_preflight_for_paths( science_runtime_preflight_for_paths_cached( data_dir, explicit_bin, + None, app_bin, &ScienceVersionCache::default(), ) @@ -299,6 +324,7 @@ fn science_runtime_preflight_for_paths( fn science_runtime_preflight_for_paths_cached( data_dir: &Path, explicit_bin: Option<&Path>, + official_downloaded_bin: Option<&Path>, app_bin: &Path, version_cache: &ScienceVersionCache, ) -> Result { @@ -317,6 +343,17 @@ fn science_runtime_preflight_for_paths_cached( "download_url": SCIENCE_DOWNLOAD_URL, })); } + if let Some(bin) = official_downloaded_bin { + if let Some(version) = version_cache.version(bin) { + return Ok(json!({ + "status": "installed_ready", + "selected_source": ScienceRuntimeSource::OfficialDownloaded.code(), + "selected_version": version, + "cached_version": Value::Null, + "download_url": SCIENCE_DOWNLOAD_URL, + })); + } + } if let Some(version) = version_cache.version(app_bin) { return Ok(json!({ "status": "installed_ready", @@ -346,25 +383,35 @@ fn science_runtime_preflight_for_paths_cached( })) } +fn available_science_runtime_preflight( + version_cache: &ScienceVersionCache, +) -> Result { + let data_dir = sandbox_data_dir(); + let explicit = explicit_science_bin()?; + let official_downloaded = if explicit.is_none() { + official_downloaded_science_bin() + } else { + None + }; + science_runtime_preflight_for_paths_cached( + &data_dir, + explicit.as_deref(), + official_downloaded.as_deref(), + Path::new(SCIENCE_BIN), + version_cache, + ) +} + pub(crate) fn science_runtime_preflight( version_cache: &ScienceVersionCache, confirmed_stopped: Option<&ScienceRuntimeIdentity>, ) -> Result { if let Ok(cfg) = config::load_from(&config::default_dir()) { - if let Some(runtime) = confirmed_stopped { - if runtime.source != ScienceRuntimeSource::CachedOnce - && !loopback_port_accepts_tcp(cfg.sandbox_port) - { - if let Some(version) = version_cache.version(&runtime.path) { - return Ok(json!({ - "status": "installed_ready", - "selected_source": runtime.source.code(), - "selected_version": version, - "cached_version": Value::Null, - "download_url": SCIENCE_DOWNLOAD_URL, - })); - } - } + if confirmed_stopped.is_some() && !loopback_port_accepts_tcp(cfg.sandbox_port) { + // A stopped identity proves ownership, not future selection. Resolve + // candidates again so an official update that appeared since the + // prior stop wins over a remembered App fallback. + return available_science_runtime_preflight(version_cache); } let (state, runtime) = probe_sandbox_runtime_cached(cfg.sandbox_port, version_cache)?; if state == SandboxScienceState::RunningHealthy { @@ -378,14 +425,7 @@ pub(crate) fn science_runtime_preflight( })); } } - let data_dir = sandbox_data_dir(); - let explicit = explicit_science_bin()?; - science_runtime_preflight_for_paths_cached( - &data_dir, - explicit.as_deref(), - Path::new(SCIENCE_BIN), - version_cache, - ) + available_science_runtime_preflight(version_cache) } #[cfg(test)] @@ -398,6 +438,7 @@ fn select_science_runtime_for_paths( select_science_runtime_for_paths_cached( data_dir, explicit_bin, + None, app_bin, choice, &ScienceVersionCache::default(), @@ -407,6 +448,7 @@ fn select_science_runtime_for_paths( fn select_science_runtime_for_paths_cached( data_dir: &Path, explicit_bin: Option<&Path>, + official_downloaded_bin: Option<&Path>, app_bin: &Path, choice: Option<&str>, version_cache: &ScienceVersionCache, @@ -424,6 +466,15 @@ fn select_science_runtime_for_paths_cached( version: Some(version), }); } + if let Some(bin) = official_downloaded_bin { + if let Some(version) = version_cache.version(bin) { + return Ok(ScienceRuntimeIdentity { + path: bin.to_path_buf(), + source: ScienceRuntimeSource::OfficialDownloaded, + version: Some(version), + }); + } + } if let Some(version) = version_cache.version(app_bin) { return Ok(ScienceRuntimeIdentity { path: app_bin.to_path_buf(), @@ -454,9 +505,15 @@ pub(crate) fn select_science_runtime_cached( ) -> Result { let data_dir = sandbox_data_dir(); let explicit = explicit_science_bin()?; + let official_downloaded = if explicit.is_none() { + official_downloaded_science_bin() + } else { + None + }; select_science_runtime_for_paths_cached( &data_dir, explicit.as_deref(), + official_downloaded.as_deref(), Path::new(SCIENCE_BIN), choice, version_cache, @@ -474,6 +531,15 @@ fn runtime_probe_candidates( )]); } let mut candidates = Vec::new(); + if let Some(official_downloaded) = official_downloaded_science_bin() { + if version_cache.version(&official_downloaded).is_some() { + candidates.push(runtime_identity( + official_downloaded, + ScienceRuntimeSource::OfficialDownloaded, + version_cache, + )); + } + } let app = PathBuf::from(SCIENCE_BIN); if version_cache.version(&app).is_some() { candidates.push(runtime_identity( @@ -834,8 +900,9 @@ mod tests { use std::process::{ExitStatus, Output}; use super::{ - classify_known_runtime_state, classify_sandbox_state, first_http_url, runtime_status_value, - sandbox_home, sandbox_running_ours, sandbox_url, science_runtime_preflight_for_paths, + classify_known_runtime_state, classify_sandbox_state, first_http_url, + official_downloaded_science_bin_for_home, runtime_status_value, sandbox_home, + sandbox_running_ours, sandbox_url, science_runtime_preflight_for_paths, science_runtime_preflight_for_paths_cached, science_status_running, select_science_runtime_for_paths, select_science_runtime_for_paths_cached, settings_change_needs_teardown, stop_runtime_from_probe, trusted_science_status, @@ -901,16 +968,16 @@ mod tests { let cache = ScienceVersionCache::default(); let preflight = - science_runtime_preflight_for_paths_cached(&data_dir, None, &app_bin, &cache)?; + science_runtime_preflight_for_paths_cached(&data_dir, None, None, &app_bin, &cache)?; assert_eq!(preflight["selected_version"], "claude-science cache-v1"); let selected = - select_science_runtime_for_paths_cached(&data_dir, None, &app_bin, None, &cache)?; + select_science_runtime_for_paths_cached(&data_dir, None, None, &app_bin, None, &cache)?; assert_eq!(selected.version.as_deref(), Some("claude-science cache-v1")); assert_eq!(fs::read_to_string(&count)?, "1"); write_counted_version_bin(&app_bin, &count, "claude-science cache-version-two")?; let selected = - select_science_runtime_for_paths_cached(&data_dir, None, &app_bin, None, &cache)?; + select_science_runtime_for_paths_cached(&data_dir, None, None, &app_bin, None, &cache)?; assert_eq!( selected.version.as_deref(), Some("claude-science cache-version-two") @@ -926,6 +993,76 @@ mod tests { Ok(()) } + #[test] + fn official_downloaded_runtime_wins_without_sharing_official_data( + ) -> Result<(), Box> { + let root = unique_temp_dir("science-official-downloaded-selection")?; + let official_home = root.join("official-home"); + let official_bin = official_home + .join(".claude-science") + .join("bin") + .join("claude-science"); + let data_dir = root.join("isolated-home").join(".claude-science"); + let cached_bin = data_dir.join("bin").join("claude-science"); + let app_bin = root.join("app-claude-science"); + let isolated_marker = data_dir.join("isolated-state.txt"); + write_fake_version_bin(&official_bin, 0o755, "fake-official-downloaded-2")?; + write_fake_version_bin(&app_bin, 0o755, "fake-app-1")?; + write_fake_version_bin(&cached_bin, 0o755, "fake-cache-3")?; + fs::write(&isolated_marker, "keep-isolated")?; + let cache = ScienceVersionCache::default(); + + assert_eq!( + official_downloaded_science_bin_for_home(&official_home).as_deref(), + Some(official_bin.as_path()) + ); + assert!( + official_downloaded_science_bin_for_home(std::path::Path::new("relative-home")) + .is_none() + ); + + let preflight = science_runtime_preflight_for_paths_cached( + &data_dir, + None, + Some(&official_bin), + &app_bin, + &cache, + )?; + assert_eq!(preflight["selected_source"], "official_downloaded"); + assert_eq!(preflight["selected_version"], "fake-official-downloaded-2"); + + let selected = select_science_runtime_for_paths_cached( + &data_dir, + None, + Some(&official_bin), + &app_bin, + Some(CACHED_ONCE_CHOICE), + &cache, + )?; + assert_eq!(selected.path, official_bin); + assert_eq!(selected.source, ScienceRuntimeSource::OfficialDownloaded); + assert_eq!( + selected.version.as_deref(), + Some("fake-official-downloaded-2") + ); + assert_eq!(fs::read_to_string(&isolated_marker)?, "keep-isolated"); + + fs::set_permissions(&selected.path, fs::Permissions::from_mode(0o644))?; + let fallback = select_science_runtime_for_paths_cached( + &data_dir, + None, + Some(&selected.path), + &app_bin, + None, + &cache, + )?; + assert_eq!(fallback.source, ScienceRuntimeSource::InstalledApp); + assert_eq!(fallback.version.as_deref(), Some("fake-app-1")); + + fs::remove_dir_all(root)?; + Ok(()) + } + #[test] fn science_status_running_accepts_compact_and_spaced_json() { assert!(science_status_running(&status_output( diff --git a/desktop/src-tauri/src/runtime/settings.rs b/desktop/src-tauri/src/runtime/settings.rs index e3870e5..455aa62 100644 --- a/desktop/src-tauri/src/runtime/settings.rs +++ b/desktop/src-tauri/src/runtime/settings.rs @@ -1,8 +1,12 @@ +use std::collections::HashSet; use std::io::Read; use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; -const SSH_STUB_MARKER: &str = "# CSSwitch managed system SSH config bridge v1"; +const SSH_STUB_V1_MARKER: &str = "# CSSwitch managed system SSH config bridge v1"; +const SSH_STUB_V2_MARKER: &str = "# CSSwitch managed system SSH config bridge v2"; +const SSH_STUB_MAX_ALIASES: usize = 512; +const SSH_STUB_MAX_BYTES: u64 = 65_536; fn system_ssh_config_path_for_home(home: &Path) -> Result { if !home.is_absolute() { @@ -79,7 +83,7 @@ fn remove_managed_sandbox_ssh_stub_for_config( let metadata = file .metadata() .map_err(|error| format!("检查隔离 SSH config 失败:{error}"))?; - if !metadata.is_file() || metadata.uid() != uid || metadata.len() > 4096 { + if !metadata.is_file() || metadata.uid() != uid || metadata.len() > SSH_STUB_MAX_BYTES { return Err("隔离 SSH config 不是 CSSwitch 管理的安全普通文件".into()); } let mut text = String::new(); @@ -89,8 +93,7 @@ fn remove_managed_sandbox_ssh_stub_for_config( .to_string_lossy() .replace('\\', "\\\\") .replace('"', "\\\""); - let expected = format!("{SSH_STUB_MARKER}\nInclude \"{escaped}\"\n"); - if text != expected { + if !is_managed_sandbox_ssh_stub_text(&text, &escaped) { return Err("隔离 SSH config 不是 CSSwitch 管理的入口,拒绝删除".into()); } std::fs::remove_file(&config).map_err(|error| format!("撤销隔离 SSH config 失败:{error}"))?; @@ -98,6 +101,55 @@ fn remove_managed_sandbox_ssh_stub_for_config( Ok(()) } +fn is_managed_sandbox_ssh_stub_text(text: &str, escaped_system_config: &str) -> bool { + let include = format!("Include \"{escaped_system_config}\""); + let v1 = format!("{SSH_STUB_V1_MARKER}\n{include}\n"); + if text == v1 { + return true; + } + if text.len() as u64 > SSH_STUB_MAX_BYTES || !text.ends_with('\n') { + return false; + } + + let mut lines = text.lines(); + if lines.next() != Some(SSH_STUB_V2_MARKER) || lines.next() != Some(include.as_str()) { + return false; + } + + let mut aliases = HashSet::new(); + for line in lines { + let Some(alias) = line.strip_prefix("Host ") else { + return false; + }; + if !is_safe_projected_ssh_alias(alias) + || !aliases.insert(alias) + || aliases.len() > SSH_STUB_MAX_ALIASES + { + return false; + } + } + true +} + +fn is_safe_projected_ssh_alias(alias: &str) -> bool { + !alias.is_empty() + && alias.bytes().all(|byte| { + matches!( + byte, + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'.' + | b'_' + | b':' + | b'@' + | b'%' + | b'+' + ) + }) +} + pub(crate) fn validate_runtime_ports(proxy_port: u16, sandbox_port: u16) -> Result<(), String> { crate::config::validate_runtime_ports(proxy_port, sandbox_port)?; let preview_port = sandbox_port @@ -117,8 +169,9 @@ mod tests { use std::os::unix::fs::FileTypeExt; use super::{ - remove_managed_sandbox_ssh_stub_for_config, system_ssh_config_path_for_home, - validate_runtime_ports, SSH_STUB_MARKER, + is_managed_sandbox_ssh_stub_text, remove_managed_sandbox_ssh_stub_for_config, + system_ssh_config_path_for_home, validate_runtime_ports, SSH_STUB_V1_MARKER, + SSH_STUB_V2_MARKER, }; #[test] @@ -176,7 +229,19 @@ mod tests { std::fs::write( &config, format!( - "{SSH_STUB_MARKER}\nInclude \"{}\"\n", + "{SSH_STUB_V1_MARKER}\nInclude \"{}\"\n", + expected_system_config.display() + ), + ) + .unwrap(); + remove_managed_sandbox_ssh_stub_for_config(&home, &expected_system_config).unwrap(); + assert!(!config.exists()); + + std::fs::create_dir_all(home.join(".ssh")).unwrap(); + std::fs::write( + &config, + format!( + "{SSH_STUB_V2_MARKER}\nInclude \"{}\"\nHost alpha\nHost beta-2\n", expected_system_config.display() ), ) @@ -187,7 +252,7 @@ mod tests { std::fs::create_dir_all(home.join(".ssh")).unwrap(); std::fs::write( &config, - format!("{SSH_STUB_MARKER}\nInclude \"/different/config\"\n\nHost foreign\n"), + format!("{SSH_STUB_V1_MARKER}\nInclude \"/different/config\"\n\nHost foreign\n"), ) .unwrap(); assert!( @@ -222,7 +287,7 @@ mod tests { std::fs::create_dir_all(outside.join(".ssh")).unwrap(); std::fs::write( outside.join(".ssh/config"), - format!("{SSH_STUB_MARKER}\nInclude \"{}\"\n", expected.display()), + format!("{SSH_STUB_V1_MARKER}\nInclude \"{}\"\n", expected.display()), ) .unwrap(); let linked_home = base.join("linked-home"); @@ -231,4 +296,28 @@ mod tests { assert!(outside.join(".ssh/config").is_file()); let _ = std::fs::remove_dir_all(base); } + + #[test] + fn managed_v2_stub_accepts_only_canonical_safe_unique_aliases() { + let escaped = "/tmp/real-home/.ssh/config"; + let valid = + format!("{SSH_STUB_V2_MARKER}\nInclude \"{escaped}\"\nHost alpha\nHost beta-2\n"); + assert!(is_managed_sandbox_ssh_stub_text(&valid, escaped)); + + for invalid in [ + format!("{SSH_STUB_V2_MARKER}\nInclude \"{escaped}\"\nHost alpha\nHost alpha\n"), + format!("{SSH_STUB_V2_MARKER}\nInclude \"{escaped}\"\nHost *.example\n"), + format!("{SSH_STUB_V2_MARKER}\nInclude \"{escaped}\"\nHost alpha\nUser foreign\n"), + format!("{SSH_STUB_V2_MARKER}\nInclude \"/different/config\"\nHost alpha\n"), + format!("{SSH_STUB_V2_MARKER}\nInclude \"{escaped}\"\nHost alpha"), + ] { + assert!(!is_managed_sandbox_ssh_stub_text(&invalid, escaped)); + } + + let too_many_aliases = (0..=super::SSH_STUB_MAX_ALIASES) + .map(|index| format!("Host alias-{index}\n")) + .collect::(); + let over_limit = format!("{SSH_STUB_V2_MARKER}\nInclude \"{escaped}\"\n{too_many_aliases}"); + assert!(!is_managed_sandbox_ssh_stub_text(&over_limit, escaped)); + } } diff --git a/desktop/src/index.html b/desktop/src/index.html index 34ed503..0bb2232 100644 --- a/desktop/src/index.html +++ b/desktop/src/index.html @@ -175,7 +175,7 @@

Skill

PROXY

本地代理

负责把 Claude Science 请求路由到当前第三方模型。

检查中
-
SANDBOX

隔离 Science

运行在 CSSwitch 管理的独立 HOME 与数据目录中。

检查中
+
SANDBOX

隔离 Science

运行在 CSSwitch 管理的独立 HOME 与数据目录中。

更新由官方 Claude Science 管理;隔离实例不登录或检查更新。
检查中
UPSTREAM

模型上游

仅代表当前代理探测到的连接健康状态。

检查中
diff --git a/desktop/src/main.js b/desktop/src/main.js index 9984383..2b2c20e 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -1,5 +1,10 @@ import { CODEX_AUTH_REASONS, formatCodexAuthCommandError, parseCodexAuthCommandError } from "./codex-auth-protocol.js"; -import { RUNTIME_STATUS_LABELS, aggregateRuntimeStatus, normalizeRuntimeLight } from "./runtime-status-state.js"; +import { + RUNTIME_STATUS_LABELS, + aggregateRuntimeStatus, + normalizeRuntimeLight, + scienceRuntimeStatusText, +} from "./runtime-status-state.js"; import { buildSimpleModelSubmission, mergeCatalogCandidates, @@ -338,7 +343,7 @@ function mockInvoke(cmd, args) { case "science_runtime_preflight": return Promise.resolve(PREVIEW_RUNTIME_CACHE ? { status: "cached_choice_required", selected_source: null, selected_version: null, cached_version: "0.0.0-preview-cache", download_url: "https://claude.com/download" } - : { status: "installed_ready", selected_source: "installed_app", selected_version: "0.0.0-preview", cached_version: null, download_url: "https://claude.com/download" }); + : { status: "installed_ready", selected_source: "official_downloaded", selected_version: "0.0.0-preview", cached_version: null, download_url: "https://claude.com/download" }); case "install_local_skill_package": if (!mockImportedSkills.some((item) => item.skill_id === "demo-reader")) { mockImportedSkills.push({ skill_id: "demo-reader", display_name: "Demo reader", description: "刚刚从本地包导入的示例 Skill。", source_kind: "csswitch_local", bundle_name: "demo-bundle", attachment_state: "attached" }); @@ -351,9 +356,9 @@ function mockInvoke(cmd, args) { return Promise.resolve(null); case "status": if (QUERY.get("status") === "error") return Promise.reject(new Error("预览注入:运行状态查询失败")); - if (QUERY.get("status") === "partial") return Promise.resolve({ proxy: "green", sandbox: "green", upstream: "amber" }); - if (QUERY.get("status") === "stopped") return Promise.resolve({ proxy: "amber", sandbox: "amber", upstream: "amber" }); - return Promise.resolve({ proxy: "green", sandbox: "green", upstream: "green" }); + if (QUERY.get("status") === "partial") return Promise.resolve({ proxy: "green", sandbox: "green", upstream: "amber", science: { runtime: { source: "official_downloaded", version: "claude-science preview" } } }); + if (QUERY.get("status") === "stopped") return Promise.resolve({ proxy: "amber", sandbox: "amber", upstream: "amber", science: {} }); + return Promise.resolve({ proxy: "green", sandbox: "green", upstream: "green", science: { runtime: { source: "official_downloaded", version: "claude-science preview" } } }); case "boot_error": return Promise.resolve(null); case "app_version": @@ -2353,8 +2358,8 @@ function showRuntimeChoice(preflight) { const canUseCache = preflight && preflight.status === "cached_choice_required" && !!cachedVersion; els.runtimeUseCacheBtn.hidden = !canUseCache; els.runtimeChoiceText.textContent = canUseCache - ? "未找到通过安全预检的 Claude Science App。发现可确认版本的历史缓存:" + cachedVersion + "。你可以仅本次使用它,或前往官方页面安装 / 更新 Science。此选择不会保存。" - : "未找到通过安全预检的 Claude Science App,历史缓存也无法确认版本。请先从官方页面安装 / 更新 Science。"; + ? "未找到通过安全预检的官方 downloaded runtime 或 Claude Science App。发现可确认版本的历史缓存:" + cachedVersion + "。你可以仅本次使用它,或前往官方页面安装 / 更新 Science。此选择不会保存。" + : "未找到通过安全预检的官方 downloaded runtime 或 Claude Science App,历史缓存也无法确认版本。请先从官方页面安装 / 更新 Science。"; els.runtimeChoiceSec.hidden = false; runtimeChoiceActiveId = configState.active_id || null; } @@ -2514,8 +2519,8 @@ async function oneClick() { } showRuntimeChoice(preflight || { status: "missing" }); setMsg(preflight && preflight.status === "cached_choice_required" - ? "Claude Science App 不可用或未通过预检。请选择是否仅本次使用已确认版本的缓存。" - : "Claude Science App 不可用或未通过预检,且没有可安全启动的缓存版本。", "err"); + ? "官方 downloaded runtime 与 Claude Science App 均不可用或未通过预检。请选择是否仅本次使用已确认版本的缓存。" + : "官方 downloaded runtime 与 Claude Science App 均不可用或未通过预检,且没有可安全启动的缓存版本。", "err"); } catch (e) { setMsg("Science 运行环境检查失败:" + e, "err"); } finally { @@ -2643,11 +2648,13 @@ async function refreshStatus() { setStatusText("proxyStateText", s.proxy); setStatusText("sandboxStateText", s.sandbox); setStatusText("upstreamStateText", s.upstream); + els.scienceRuntimeMeta.textContent = scienceRuntimeStatusText(s.science); els.brandDot.className = "dot " + aggregateRuntimeStatus(s, { mode, officialState: officialRuntimeState }); setStatusRecoveryMsg(proxyRecoveryMessage(s)); } catch (e) { [els.ltProxy, els.ltSandbox, els.ltUpstream].forEach((l) => setLight(l, "unknown")); ["proxyStateText", "sandboxStateText", "upstreamStateText"].forEach((id) => setStatusText(id, "unknown")); + els.scienceRuntimeMeta.textContent = "Runtime 状态读取失败;未改变官方或隔离实例。"; els.brandDot.className = "dot gray"; } } @@ -2669,7 +2676,7 @@ function wire() { "metaSec", "metaName", "metaNotes", "metaSaveBtn", "metaCancelBtn", "themeBtn", "pageEyebrow", "pageTitle", "pageSubtitle", "currentProfileIcon", "currentProfileName", "currentProfileState", "currentRouteMode", "currentProfileModel", "currentProfileMeta", - "proxyStateText", "sandboxStateText", "upstreamStateText", + "proxyStateText", "sandboxStateText", "upstreamStateText", "scienceRuntimeMeta", ].forEach((id) => (els[id] = $(id))); els.panel = document.querySelector(".panel"); diff --git a/desktop/src/runtime-status-state.js b/desktop/src/runtime-status-state.js index 030868e..75b7266 100644 --- a/desktop/src/runtime-status-state.js +++ b/desktop/src/runtime-status-state.js @@ -22,3 +22,25 @@ export function aggregateRuntimeStatus(status, { mode = "proxy", officialState = if (applicable.every((value) => value === "green")) return "green"; return "amber"; } + +export function scienceRuntimeStatusText(science) { + const runtime = science && typeof science === "object" ? science.runtime : null; + if (!runtime || typeof runtime !== "object") { + return "更新由官方 Claude Science 管理;隔离实例不登录或检查更新。"; + } + const version = typeof runtime.version === "string" && runtime.version.trim() + ? runtime.version.trim() + : "版本未知"; + switch (runtime.source) { + case "official_downloaded": + return `官方已下载 Runtime · ${version};隔离实例仅复用程序文件。`; + case "installed_app": + return `App 内置备用 Runtime · ${version};官方下载 Runtime 当前不可用。`; + case "explicit": + return `开发 Override Runtime · ${version};更新不由隔离实例管理。`; + case "cached_once": + return `一次性缓存 Runtime · ${version};不会保存为默认选择。`; + default: + return "Runtime 来源无法确认;更新仍由官方 Claude Science 管理。"; + } +} diff --git a/desktop/src/styles.css b/desktop/src/styles.css index b03aac0..59a23d2 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -233,6 +233,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); backgro .status-page-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } .status-detail-card p { min-height: 50px; margin-top: 7px; font-size: 10.5px; } .status-detail-card h2 { margin-top: 5px; } +.science-runtime-meta { margin-top: 8px; color: var(--muted); font-size: 9.5px; line-height: 1.45; } .large-state { display: flex; align-items: center; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--ink); font-size: 11px; font-weight: 700; } .diagnostics-card { margin-top: 12px; } .diagnostic-actions { margin-top: 0; } diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 611ca4f..625881a 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -44,7 +44,7 @@ CSSwitch profile(API key 或 CSSwitch OAuth) | Codex OAuth / thinking / generation records | CSSwitch data root 下的私有 `codex-*.v1.json` | CSSwitch Gateway | | v0.4.2 / v0.4.3 legacy Skill store / inventory | 原样保留但不参与当前 runtime | 非当前运行路径 | -持久 data-dir 提供状态连续性,不固定 executable 版本。正常新启动优先用户当前安装的 App;历史缓存只有在 App 不可用、版本可读且用户仅本次授权时才可使用。详见 [Science runtime 合同](science-runtime.md)。 +持久 data-dir 提供状态连续性,不固定 executable 版本。正常新启动优先只读复用官方 updater 已下载并通过预检的 `~/.claude-science/bin/claude-science`,不可用时回退当前 App;历史缓存只有在二者都不可用、版本可读且用户仅本次授权时才可使用。详见 [Science runtime 合同](science-runtime.md)。 ## 组件边界 @@ -66,7 +66,7 @@ CSSwitch profile(API key 或 CSSwitch OAuth) ### 系统 SSH bridge -默认关闭。启用后先用只含绝对 `Include` 的隔离 config stub 满足 Science 的 Host 前置校验,再通过窄 wrapper 执行 `/usr/bin/ssh -F /.ssh/config`。不复制 `.ssh` 或真实 config 内容、不启动服务、不开放监听;stub、真实 config 或 wrapper 校验失败会 fail closed。详见[系统 SSH 文档](../features/system-ssh.md)。 +默认关闭。启用后,隔离 config stub 以绝对 `Include` 保留 OpenSSH 语义,并只投影真实顶层 config 中安全的显式 Host alias,供不展开 Include 的 Science UI 枚举;窄 wrapper 仍执行 `/usr/bin/ssh -F /.ssh/config`。不复制 `.ssh` 或真实连接参数、不启动服务、不开放监听;stub、alias 提取、真实 config 或 wrapper 校验失败会 fail closed。详见[系统 SSH 文档](../features/system-ssh.md)。 ### Codex 实验能力 @@ -78,7 +78,7 @@ CSSwitch profile(API key 或 CSSwitch OAuth) - Science preview port 由 CSSwitch 显式分配并检查冲突、保留端口和溢出。 - 原始 Science `serve` 输出可能含 data-dir 或一次性 URL,因此不直接进入 CSSwitch 日志。 - 一次性 Science URL、nonce 与 CSRF 状态只在 backend 内存和限界控制流中使用,不序列化到普通 Tauri status。 -- 第三方模式不读取或复制真实 Claude 登录数据。 +- 第三方模式不读取或复制真实 Claude 登录数据;只按 runtime 合同校验和执行精确官方 downloaded executable,并为其注入独立 HOME/data-dir。 - 系统浏览器使用自身网络配置;CSSwitch 的 Codex route 只控制 sidecar / Gateway 的 HTTPS socket,不声称检测系统 TUN。 ## 失败边界 diff --git a/docs/architecture/science-runtime.md b/docs/architecture/science-runtime.md index bc387bc..eefec1d 100644 --- a/docs/architecture/science-runtime.md +++ b/docs/architecture/science-runtime.md @@ -1,6 +1,6 @@ # Science runtime 合同 -本文描述 v0.7.0 的稳定选择与身份合同。2026-07-13 的具体版本、错误 binary 事故与 E2E 证据见[日期化调查](../evidence/investigations/2026-07-13-science-runtime-and-skill-bridge.md)。 +本文描述当前稳定选择与身份合同。2026-07-13 的具体版本、错误 binary 事故与 E2E 证据见[日期化调查](../evidence/investigations/2026-07-13-science-runtime-and-skill-bridge.md)。 ## 分离四个事实 @@ -14,11 +14,12 @@ data-dir 持久化组织、项目、Skills 和 Science 自己的 runtime 数据 ## 新启动选择顺序 1. 如果设置了 `SCIENCE_BIN`,它必须是绝对、非 symlink、可执行且能安全读取版本的开发 override;无效时 fail closed,不继续猜其他 binary。 -2. 否则使用当前安装在 `/Applications/Claude Science.app` 中的官方 executable。 -3. 只有 App 不可用、`/bin/claude-science` 可执行且版本可确认时,preflight 才返回 `cached_choice_required`;用户可授权 `cached_once`。 -4. cache 授权只在本次启动的内存中生效,不写成偏好。未知版本或缺失 cache 不可启动。 +2. 否则只读检查官方 updater 下载的精确 executable `~/.claude-science/bin/claude-science`;路径边界、regular executable 与版本都可确认时使用 `official_downloaded`。 +3. 官方 downloaded runtime 不存在或未通过预检时,使用当前安装在 `/Applications/Claude Science.app` 中的官方 executable,并标记 `installed_app` fallback。 +4. 只有以上两个来源均不可用、`/bin/claude-science` 可执行且版本可确认时,preflight 才返回 `cached_choice_required`;用户可授权 `cached_once`。 +5. cache 授权只在本次启动的内存中生效,不写成偏好。未知版本或缺失 cache 不可启动。 -只要 App 可用,它始终优先于 cache。CSSwitch 不下载 Science、不调用 updater、不覆盖 cache,也不从真实 `~/.claude-science` 复制 `bin`、`conda`、`runtime` 或 `seed-assets`。 +官方 downloaded runtime 优先于 App,App 始终优先于 cache。CSSwitch 只校验并执行精确官方 downloaded executable,不枚举它的父 data-dir;不下载 Science、不调用 updater、不覆盖 cache,也不从真实 `~/.claude-science` 复制 `bin`、`conda`、`runtime` 或 `seed-assets`。Acceptance 构建禁用这一真实 HOME candidate,只使用隔离 fixture / App 来源。 ## 启动与网络参数 @@ -33,7 +34,7 @@ Gateway 同样只监听 loopback。端口健康不等于身份健康;公共网 ## 运行中身份与恢复 -CSSwitch 在内存中记录实际 launch binary path、来源(`explicit`、`installed_app` 或 `cached_once`)和版本。启动、复用、恢复、生成受管 URL 与停止操作使用这份 runtime metadata,并在需要控制 daemon 的边界做强身份检查。 +CSSwitch 在内存中记录实际 launch binary path、来源(`explicit`、`official_downloaded`、`installed_app` 或 `cached_once`)和版本。启动、复用、恢复、生成受管 URL 与停止操作使用这份 runtime metadata,并在需要控制 daemon 的边界做强身份检查。 高频 UI `status` 是例外:它只对 sandbox port 做短超时 HTTP health,并把内存中的 path / source / version metadata 投影到诊断结果;它不反复调用 `claude-science status`,不重新核对监听 PID,也不能证明当前监听者就是已记录 runtime。 @@ -47,12 +48,12 @@ CSSwitch 自身重启后,只能在以下条件同时满足时接管已有 daem ## 升级合同 -用户更新 Claude Science App 后,下一次 stopped-to-started 启动使用新 App executable,同时继续复用原 CSSwitch data-dir。CSSwitch 不迁移或覆盖组织、项目和 Skill 数据。 +官方模式独占 Claude.ai 登录、更新检查和更新应用,并把新版 executable 管理在 `~/.claude-science/bin/claude-science`。隔离模式不登录、不检查更新,保持 `--no-auto-update`;下一次 stopped-to-started 启动只读复用官方已经更新的同一 executable,同时继续使用原 CSSwitch HOME/data-dir。共享 executable 不共享进程、端口、登录、组织、项目、会话或 Skill 数据。 每次上游 App 更新后,分别验证: -1. 实际 selected binary 与 `--version`; -2. data-dir 复用且没有读取真实 HOME runtime 资产; +1. 实际 selected binary、source 与 `--version`;官方 downloaded candidate 不可用时必须明确显示 App fallback; +2. 官方与隔离进程可执行同一 runtime 文件,但 PID、端口、HOME/data-dir 和受管身份保持独立;除精确 executable 校验外不读取真实 HOME 数据; 3. live PID、executable、runtime directory、data-dir 与端口属于同一运行; 4. start / reopen / recovery / url / stop 的强身份一致,并单独确认 UI status 只表示 HTTP health; 5. 外部 Skill route、install / attach / load / restart / uninstall / detach; diff --git a/docs/features/system-ssh.md b/docs/features/system-ssh.md index 5d229fb..537e8db 100644 --- a/docs/features/system-ssh.md +++ b/docs/features/system-ssh.md @@ -1,12 +1,14 @@ # 系统 SSH 配置复用 -该功能自 v0.5.0 起提供,v0.8.1 补充了隔离 Science 的 SSH 前置校验桥接。它让隔离 Science 在用户明确授权后,按系统 OpenSSH 语义复用真实 `~/.ssh/config`;它不是 SSH server、端口转发 UI 或公网暴露功能。 +该功能自 v0.5.0 起提供,v0.8.1 补充了隔离 Science 的 SSH 前置校验桥接。它让隔离 Science 在用户明确授权后,发现系统 SSH Host alias,并按系统 OpenSSH 语义复用真实 `~/.ssh/config`;它不是 SSH server、端口转发 UI 或公网暴露功能。 ## 默认与 opt-in `reuse_system_ssh` 默认关闭。关闭时,CSSwitch 不把真实系统 SSH 配置注入隔离 Science。 -启用时,CSSwitch 会在隔离 HOME 的 `.ssh/config` 创建一个 `0600` 普通入口文件。该文件不复制真实配置,只包含一条指向真实 `~/.ssh/config` 的绝对 `Include`,用于满足 Science 在真正执行 SSH 前的 Host 校验;实际连接仍由下述 wrapper 固定使用真实配置。 +启用时,CSSwitch 会在隔离 HOME 的 `.ssh/config` 创建一个 `0600` 普通入口文件。该文件先包含一条指向真实 `~/.ssh/config` 的绝对 `Include`,再包含真实顶层 config 中经过安全过滤、去重和限额的显式 `Host` alias。字面 Host 行用于兼容不会展开 `Include` 的 Science Host 枚举器;实际连接仍由下述 wrapper 固定使用真实配置。 + +投影不会复制 `HostName`、`User`、`Port`、`IdentityFile`、`ProxyCommand`、`Match` 或其他连接参数,也不会投影通配符和否定 pattern。真实 config 通过 `Include` 引入的其他文件仍由 OpenSSH 在连接时解析;其中的 alias 不会被 CSSwitch 递归投影,必要时仍可在 Science 中手工输入。 启用后,CSSwitch 在隔离环境 PATH 前放置一个窄 wrapper,最终执行: @@ -30,9 +32,9 @@ ## 不会做的事 -- 不复制或 symlink 整个 `.ssh`,也不复制真实 config 内容; +- 不复制或 symlink 整个 `.ssh`,也不复制真实 config 的连接参数; - 隔离 config 不是指向真实文件的 symlink,避免 Science 写穿真实配置; -- 不把 private key、config 内容或 ssh-agent 数据传到 CSSwitch UI; +- 不把 private key、连接参数或 ssh-agent 数据传到 CSSwitch UI;隔离 Science 只看到用户已授权投影的 Host alias; - 不启动 `sshd`,不开启 macOS Remote Login; - 不修改防火墙或建立 `0.0.0.0` listener; - 不把 SSH 访问与 CSSwitch inference Gateway 混成同一服务; @@ -42,7 +44,7 @@ 默认关闭时,SSH 不是普通 Science 启动的前置条件。用户启用该设置时,CSSwitch 先验证真实 `~/.ssh/config`;SSH 授权状态变化会先停止仍使用旧授权的隔离 Science,再保存新设置。关闭授权会撤销 CSSwitch 管理的隔离 config;若该位置是外来文件、symlink 或特殊文件,CSSwitch 会拒绝覆盖或删除并据实报错。 -启用后的每次启动都会再次校验 config 与 packaged wrapper。config 缺失、wrapper 缺失或路径不安全时,启动 fail closed 并清理部分启动,不能以 warning 略过。只有 Science 已成功启动后的某次 `/usr/bin/ssh` 命令失败,才只影响该命令。 +启用后的每次启动都会再次校验 config 与 packaged wrapper,并原子刷新 alias 投影。config 缺失、wrapper 缺失、alias 提取失败、投影超限或路径不安全时,启动 fail closed 并清理部分启动,不能以 warning 略过。旧 v1 Include-only stub 会在下一次 opt-in 启动时升级;关闭授权仍可撤销 v1/v2。只有 Science 已成功启动后的某次 `/usr/bin/ssh` 命令失败,才只影响该命令。 错误报告不得打印私钥路径、config 内容、ssh-agent 数据或其他敏感信息,也不得为了诊断读取真实 private key。 @@ -51,11 +53,12 @@ 1. 配置默认关闭; 2. opt-in 保存时缺失 config 会被拒绝; 3. 启用后启动时 wrapper 内容、权限与 config 再次通过 fail-closed 校验; -4. 隔离 Science PATH 选择 wrapper; -5. wrapper 将参数转给 `/usr/bin/ssh -F`; -6. 没有 `.ssh` 复制、`sshd`、防火墙或公网 listener; -7. 特定真实 server 连通性只在单独授权后验证。 +4. 隔离 config 只出现安全的字面 Host alias,不出现连接参数; +5. 隔离 Science PATH 选择 wrapper; +6. wrapper 将参数转给 `/usr/bin/ssh -F`; +7. 没有 `.ssh` 复制、`sshd`、防火墙或公网 listener; +8. 特定真实 server 连通性只在单独授权后验证。 -其中第 1~6 项可由本地 fixture 和系统 OpenSSH 自动验证;Claude Science 自己是否接受 `Include` 必须使用当前安装版本做一次隔离 UI 验收,不能由 `/usr/bin/ssh -G` 的结果代替。 +其中第 1~7 项可由本地 fixture 和系统 OpenSSH 自动验证;Claude Science 是否枚举字面 `Host` 行仍必须使用当前安装版本做一次隔离 UI 验收,不能由 `/usr/bin/ssh -G` 的结果代替。 -源码 / 合同测试不能替代第 7 层;第 7 层也不能泛化为所有用户配置可用。 +源码 / 合同测试不能替代第 8 层;第 8 层也不能泛化为所有用户配置可用。 diff --git a/docs/operations/real-machine-acceptance.md b/docs/operations/real-machine-acceptance.md index 6cb19f4..c012718 100644 --- a/docs/operations/real-machine-acceptance.md +++ b/docs/operations/real-machine-acceptance.md @@ -5,7 +5,7 @@ ## 1. 安全护栏 - Test 编译期固定使用 `$HOME/.csswitch-acceptance`,正式构建使用 `$HOME/.csswitch`;即使都从 Finder 启动也不得互相迁移、覆盖或读取配置。自动验收仍使用每次全新的独立 `HOME`、独立 Science data-dir 和动态测试端口,形成第二层隔离。 -- 准备环境时不读取、修改或删除真实 `~/.claude-science`、任何 Keychain / OAuth、SSH 私钥或真实 `~/.csswitch`。 +- 准备环境时不枚举、修改或删除真实 `~/.claude-science`、任何 Keychain / OAuth、SSH 私钥或真实 `~/.csswitch`。Production installed-runtime 验收经单独授权后,只能校验/执行精确 `~/.claude-science/bin/claude-science`;Acceptance artifact 仍禁用该 candidate。 - Codex OAuth 只写入 Acceptance data root 下的 `codex-oauth.v1.json` 与 `codex-thinking.v1.json`;guard 不创建、不选择、不解锁任何 Keychain。只有用户在 Acceptance app 中明确点击 Codex 登录 / 退出后,才允许写入或删除这些文件;不得读取、覆盖或删除正式 CSSwitch、原生 Codex 的 `~/.codex` 会话或任何 macOS Keychain 项。 - 真实 Science 的 `8765` 端口只用 `lsof` 观察基线 PID,不停止或接管。 - 已安装 CSSwitch 正在运行时,不强退用户实例;构建独立 bundle ID 的 Acceptance app。 @@ -71,11 +71,12 @@ DASHSCOPE_API_KEY='csswitch-migration-fixture-qwen' \ eval "$(bash test/real_machine_guard.sh env)" ``` -验证 SSH opt-in 时,在这个隔离 HOME 内创建空的普通 config fixture;它只用于 wrapper / fail-closed 合同,不证明真实服务器连通: +验证 SSH opt-in 时,在这个隔离 HOME 内创建只含假 alias 的普通 config fixture;它用于 Host 枚举、wrapper 和 fail-closed 合同,不证明真实服务器连通: ```bash install -d -m 700 "$HOME/.ssh" -install -m 600 /dev/null "$HOME/.ssh/config" +printf 'Host acceptance-fixture\n HostName 127.0.0.1\n Port 9\n' > "$HOME/.ssh/config" +chmod 600 "$HOME/.ssh/config" ``` 启动独立 Test app: @@ -125,14 +126,15 @@ RM-01~RM-34 保留历史编号;Codex 场景从 RM-35 继续,0.8.1 新增 p | RM-16 | 重启恢复 | 同一隔离 HOME 重开 | profiles / active / notes / ports 持久;不自动启动;恢复不能仅凭端口冒认 runtime | | RM-17 | 包资源 | 从 `.app` 与挂载 DMG 启动 | `Contents/MacOS/{desktop,csswitch-gateway}` 与 `Contents/Resources/scripts` 齐全;无旧 `Resources/proxy`;正式包无需 `CSSWITCH_REPO` | | RM-18 | 发布安全 | hash、codesign、spctl、stapler | 签名完整性、身份、公证、ticket、Gatekeeper 分栏;不把 ad-hoc 写成已公证 | -| RM-19 | installed App 优先 | App 与 stale cache 同时存在 | 选择 App executable,复用原 data-dir,cache 不被改写 | +| RM-19 | 官方 downloaded runtime 优先 | 官方 downloaded runtime、App 与 stale cache 同时存在 | 选择 `official_downloaded`,复用隔离 data-dir,App/cache 不被改写;candidate 无效时明确回退 App | | RM-20 | explicit / cache preflight | 合法 / 非法 `SCIENCE_BIN`,App 缺失与 cache 组合 | override 无效 fail closed;cache 仅版本可读时提供 one-shot;选择不持久化 | +| RM-53 | 官方 / 隔离并行共享 executable | 官方 8765 已运行,启动隔离动态端口或 8990 | 两个 PID 可解析到同一 official downloaded executable/build;官方 PID 不变,隔离 HOME/data-dir、端口与 managed identity 独立;隔离保持 `--no-auto-update` | | RM-21 | Science 升级与强身份 | 替换测试 App 后 stopped-to-started;再恢复 / stop | 新 executable + 原 data-dir;启动 / 恢复 / stop 核对 PID、binary、data-dir、port;UI status 仍只代表 HTTP health | | RM-22 | Skill Agent 控制面 | 首次配置、重复启动、注入中途失败 | 管理固定 route / connector / `customize` / prompt;成功 marker 后跳过重复;失败 warning 且如实报告可能的部分配置 | | RM-23 | 外部 Skill 安装 | 精确公开 GitHub URL | connector -> host approval -> commit -> native attach -> `skill()` load,各阶段分开记录 | | RM-24 | Skill 重启 / 卸载 | 同 data-dir 重启,再卸载 | 重启仍 load;只 quarantine 有 marker 的导入;native detach;不走 catalog / shell | | RM-25 | 运行中 Skill 配置漂移 | Science 运行时改变 MCP / route 预期 | 只读检查并返回 `RESTART_REQUIRED`;不并发改写;普通 Science 继续 | -| RM-26 | 系统 SSH 默认 / opt-in | 无 fixture、创建 fixture、再移除 fixture | 默认关闭不阻断;启用时 wrapper 使用 `/usr/bin/ssh -F`;启用后 config / wrapper 缺失必须 fail closed | +| RM-26 | 系统 SSH 默认 / opt-in | 无 fixture、创建假 alias fixture、再移除 fixture | 默认关闭不阻断;启用后 Science 能枚举 `acceptance-fixture`,sandbox config 不含 HostName / Port,wrapper 使用 `/usr/bin/ssh -F`;config / alias 投影 / wrapper 缺失或不安全必须 fail closed | | RM-27 | SSH 非目标 | 检查文件与监听状态 | 不复制 `.ssh`、不启动 `sshd`、不改防火墙、不监听 `0.0.0.0`;真实 server 另行授权 | | RM-28 | GitHub 单请求进度 | 固定 commit 的慢速 bundle 安装 | 只生成一个 request;archive / fallback 复用同一 ID;进度持续更新;最终 response 唯一;status 与 `.processing` 清理 | | RM-29 | GitHub 重复复用 | 再安装 RM-28 的同一 URL | 返回 verified reuse;不重新下载、不重复提交、不覆盖已装内容;OPERON 绑定回读仍正确 | diff --git a/docs/operations/release.md b/docs/operations/release.md index 84fc7a4..184f835 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -51,7 +51,7 @@ DMG 必须从一次性创建的空 staging 目录生成,只复制本次 clean 使用临时 HOME / data-dir、动态端口、假凭证验证: - Gateway ownership、启动 / 停止; -- installed App 优先、无效 `SCIENCE_BIN` fail-closed、cache one-shot; +- official downloaded runtime 优先、App fallback、无效 `SCIENCE_BIN` fail-closed、cache one-shot; - Science start / reopen / recovery / url / stop 的强 runtime identity,并确认高频 UI status 只报告 HTTP health 与已记 metadata; - 外部 Skill route、install / attach / load / restart / uninstall / detach; - 外部 Skill bridge 失败只 warning;系统 SSH 默认关闭不影响启动,但 opt-in 后缺失 / 不安全 config 或 wrapper 必须 fail closed。 diff --git a/scripts/launch-virtual-sandbox.sh b/scripts/launch-virtual-sandbox.sh index f9e5d5c..ecd04a4 100755 --- a/scripts/launch-virtual-sandbox.sh +++ b/scripts/launch-virtual-sandbox.sh @@ -3,7 +3,7 @@ # Safety boundaries: # - 独立 HOME + 独立 data-dir + 独立端口,绝不修改/删除真实 ~/.claude-science,绝不用端口 8765 # - data-dir 只承载持久化状态;不从真实 Science HOME 读取或复制 runtime 或用户数据 -# - 系统 SSH 配置仅在用户显式授权时由 OpenSSH 读取;不复制或链接整个 ~/.ssh +# - 系统 SSH 仅在显式授权时投影安全 Host alias;连接仍由 OpenSSH 读取真实配置 # - 只使用应用在隔离目录中生成的本地状态,与真实账号无关 # - 使用独立的本地钥匙串 # @@ -27,7 +27,10 @@ SSH_BRIDGE_DIR="$PROJ/scripts/ssh-bridge" SSH_BRIDGE_BIN="$SSH_BRIDGE_DIR/ssh" SANDBOX_SSH_DIR="$SANDBOX_HOME/.ssh" SANDBOX_SSH_CONFIG="$SANDBOX_SSH_DIR/config" -SSH_STUB_MARKER="# CSSwitch managed system SSH config bridge v1" +SSH_STUB_V1_MARKER="# CSSwitch managed system SSH config bridge v1" +SSH_STUB_V2_MARKER="# CSSwitch managed system SSH config bridge v2" +SSH_STUB_MAX_ALIASES=512 +SSH_STUB_MAX_BYTES=65536 PORT=8990 PROXY_URL="${CSSWITCH_PROXY_URL:-http://127.0.0.1:18991}" EMAIL="virtual@localhost.invalid" @@ -54,12 +57,86 @@ path_contains_symlink() { return 1 } +escaped_system_ssh_include() { + local escaped + escaped="$(printf '%s' "$SYSTEM_SSH_CONFIG" | /usr/bin/sed 's/\\/\\\\/g; s/"/\\"/g')" + printf 'Include "%s"\n' "$escaped" +} + +is_managed_ssh_stub_v1() { + local target="$1" expected_include + expected_include="$(escaped_system_ssh_include)" + printf '%s\n%s\n' "$SSH_STUB_V1_MARKER" "$expected_include" | /usr/bin/cmp -s - "$target" +} + +is_managed_ssh_stub_v2() { + local target="$1" expected_include last_byte size + size="$(/usr/bin/stat -f '%z' "$target" 2>/dev/null)" || return 1 + [[ "$size" =~ ^[0-9]+$ ]] || return 1 + (( 10#${size} <= SSH_STUB_MAX_BYTES )) || return 1 + last_byte="$(/usr/bin/tail -c 1 "$target" 2>/dev/null | /usr/bin/od -An -tuC | /usr/bin/tr -d '[:space:]')" + [[ "$last_byte" == "10" ]] || return 1 + expected_include="$(escaped_system_ssh_include)" + LC_ALL=C /usr/bin/awk \ + -v marker="$SSH_STUB_V2_MARKER" \ + -v include="$expected_include" \ + -v max_aliases="$SSH_STUB_MAX_ALIASES" ' + NR == 1 { if ($0 != marker) exit 1; next } + NR == 2 { if ($0 != include) exit 1; next } + { + if ($0 !~ /^Host [-A-Za-z0-9._:@%+]+$/) exit 1 + alias = substr($0, 6) + if (seen[alias]++) exit 1 + count++ + if (count > max_aliases) exit 1 + } + END { if (NR < 2) exit 1 } + ' "$target" >/dev/null 2>&1 +} + is_managed_ssh_stub() { - local target="$1" escaped + local target="$1" [[ -f "$target" && ! -L "$target" ]] || return 1 [[ "$(/usr/bin/stat -f '%u' "$target" 2>/dev/null)" == "$(/usr/bin/id -u)" ]] || return 1 - escaped="$(printf '%s' "$SYSTEM_SSH_CONFIG" | /usr/bin/sed 's/\\/\\\\/g; s/"/\\"/g')" - printf '%s\nInclude "%s"\n' "$SSH_STUB_MARKER" "$escaped" | /usr/bin/cmp -s - "$target" + is_managed_ssh_stub_v1 "$target" || is_managed_ssh_stub_v2 "$target" +} + +append_projected_ssh_aliases() { + local target="$1" + LC_ALL=C /usr/bin/awk -v max_aliases="$SSH_STUB_MAX_ALIASES" ' + function emit(candidate) { + if (candidate !~ /^[-A-Za-z0-9._:@%+]+$/) return + if (seen[candidate]) return + seen[candidate] = 1 + count++ + if (count > max_aliases) { + overflow = 1 + exit 42 + } + print "Host " candidate + } + { + line = $0 + sub(/^[[:space:]]+/, "", line) + if (line == "" || substr(line, 1, 1) == "#") next + fields = split(line, token, /[[:space:]]+/) + keyword = tolower(token[1]) + start = 0 + if (keyword == "host") { + start = 2 + } else if (substr(keyword, 1, 5) == "host=") { + emit(substr(token[1], 6)) + start = 2 + } else { + next + } + for (i = start; i <= fields; i++) { + if (substr(token[i], 1, 1) == "#") break + emit(token[i]) + } + } + END { if (overflow) exit 42 } + ' "$SYSTEM_SSH_CONFIG" >> "$target" } prepare_sandbox_ssh_config() { @@ -85,8 +162,8 @@ prepare_sandbox_ssh_config() { return 1 fi fi - local escaped tmp - escaped="$(printf '%s' "$SYSTEM_SSH_CONFIG" | /usr/bin/sed 's/\\/\\\\/g; s/"/\\"/g')" + local expected_include size tmp + expected_include="$(escaped_system_ssh_include)" tmp="$(/usr/bin/mktemp "$SANDBOX_SSH_DIR/.csswitch-config.XXXXXX" 2>/dev/null)" || { echo "拒绝:无法创建隔离 SSH 临时配置" return 1 @@ -96,11 +173,26 @@ prepare_sandbox_ssh_config() { echo "拒绝:无法收紧隔离 SSH 临时配置权限" return 1 } - printf '%s\nInclude "%s"\n' "$SSH_STUB_MARKER" "$escaped" > "$tmp" 2>/dev/null || { + printf '%s\n%s\n' "$SSH_STUB_V2_MARKER" "$expected_include" > "$tmp" 2>/dev/null || { /bin/rm -f "$tmp" 2>/dev/null || true echo "拒绝:无法写入隔离 SSH 配置" return 1 } + if ! append_projected_ssh_aliases "$tmp"; then + /bin/rm -f "$tmp" 2>/dev/null || true + echo "拒绝:无法安全提取系统 SSH Host alias" + return 1 + fi + size="$(/usr/bin/stat -f '%z' "$tmp" 2>/dev/null)" || { + /bin/rm -f "$tmp" 2>/dev/null || true + echo "拒绝:无法检查隔离 SSH 配置大小" + return 1 + } + if [[ ! "$size" =~ ^[0-9]+$ ]] || (( 10#${size} > SSH_STUB_MAX_BYTES )); then + /bin/rm -f "$tmp" 2>/dev/null || true + echo "拒绝:隔离 SSH Host alias 索引超出安全上限" + return 1 + fi /bin/mv -f "$tmp" "$SANDBOX_SSH_CONFIG" 2>/dev/null || { /bin/rm -f "$tmp" 2>/dev/null || true echo "拒绝:无法提交隔离 SSH 配置" @@ -244,7 +336,7 @@ echo " 端口 = $PORT (真实实例 8765 不受影响)" echo " 预览端口 = $PREVIEW_PORT (显式固定,供本机 Science 预览使用)" echo " 二进制 = $BIN_SOURCE" if [[ "$REUSE_SYSTEM_SSH" == "1" ]]; then - echo " 系统 SSH = 已显式授权(OpenSSH 读取 ~/.ssh/config;不复制或链接 .ssh)" + echo " 系统 SSH = 已显式授权(仅投影 Host alias;连接由 OpenSSH 读取真实配置)" else echo " 系统 SSH = 未授权" fi diff --git a/test/runtime_status_state.test.mjs b/test/runtime_status_state.test.mjs index c4f84cf..eb19948 100644 --- a/test/runtime_status_state.test.mjs +++ b/test/runtime_status_state.test.mjs @@ -6,6 +6,7 @@ import { RUNTIME_STATUS_LABELS, aggregateRuntimeStatus, normalizeRuntimeLight, + scienceRuntimeStatusText, } from "../desktop/src/runtime-status-state.js"; test("Codex 无独立 upstream 时只聚合适用层", () => { @@ -24,6 +25,23 @@ test("未知状态保持中性,明确失败才变红", () => { assert.equal(RUNTIME_STATUS_LABELS.unknown, "状态未知"); }); +test("隔离 Science 只展示 runtime 来源且不声称自己检查更新", () => { + assert.equal( + scienceRuntimeStatusText({ + runtime: { source: "official_downloaded", version: "claude-science 0.1.15" }, + }), + "官方已下载 Runtime · claude-science 0.1.15;隔离实例仅复用程序文件。", + ); + assert.match( + scienceRuntimeStatusText({ + runtime: { source: "installed_app", version: "claude-science 0.1.0" }, + }), + /App 内置备用 Runtime.*官方下载 Runtime 当前不可用/, + ); + assert.match(scienceRuntimeStatusText(null), /隔离实例不登录或检查更新/); + assert.doesNotMatch(scienceRuntimeStatusText(null), /已是最新|up to date/i); +}); + test("运行反馈统一显示在右上角且不会触发页面滚动", () => { const css = readFileSync(new URL("../desktop/src/styles.css", import.meta.url), "utf8"); const js = readFileSync(new URL("../desktop/src/main.js", import.meta.url), "utf8"); diff --git a/test/test_scripts.sh b/test/test_scripts.sh index 3fc8702..9f6c5a1 100755 --- a/test/test_scripts.sh +++ b/test/test_scripts.sh @@ -84,7 +84,7 @@ FAKE_CAPTURE="$T/fake-capture" mkdir -p "$OUTER_HOME/.claude-science/runtime" mkdir -p "$OUTER_HOME/.ssh" printf 'must-not-copy\n' > "$OUTER_HOME/.claude-science/runtime/real-user-sentinel" -printf 'Host test-only\n HostName 127.0.0.1\n' > "$OUTER_HOME/.ssh/config" +printf 'Host test-only second-alias test-only\n HostName 127.0.0.1\n Port 2222\n User must-not-project\nHost *.wildcard !blocked\nHost=equals-alias\nhOsT lower-alias\nIdentityFile /must/not/project\n' > "$OUTER_HOME/.ssh/config" printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "$CAPTURE_FILE"\nif [ -n "${CAPTURE_ENV:-}" ]; then printf "PATH=%%s\\nSSH_CONFIG=%%s\\nHOME=%%s\\n" "$PATH" "${CSSWITCH_SYSTEM_SSH_CONFIG:-}" "$HOME" > "$CAPTURE_ENV"; fi\nexit 0\n' > "$FAKE_CAPTURE" chmod +x "$FAKE_CAPTURE" out="$(HOME="$OUTER_HOME" SANDBOX_HOME="$T/vh-capture" SCIENCE_BIN="$FAKE_CAPTURE" CAPTURE_FILE="$CAPTURE_FILE" CAPTURE_ENV="$CAPTURE_ENV" CSSWITCH_REUSE_SYSTEM_SSH=1 "$ROOT/scripts/launch-virtual-sandbox.sh" --port 9940 --skip-oauth-forge 2>&1)"; rc=$? @@ -93,14 +93,32 @@ if grep -Fq "PATH=$ROOT/scripts/ssh-bridge:" "$CAPTURE_ENV" && grep -Fxq "SSH_CO SANDBOX_SSH_CONFIG="$T/vh-capture/.ssh/config" SANDBOX_SSH_MODE="$(stat -f '%Lp' "$SANDBOX_SSH_CONFIG" 2>/dev/null || true)" if [ -f "$SANDBOX_SSH_CONFIG" ] && [ ! -L "$SANDBOX_SSH_CONFIG" ] && [ "$SANDBOX_SSH_MODE" = "600" ]; then ok "opt-in creates a narrow regular 0600 sandbox SSH config"; else no "opt-in did not create a safe sandbox SSH config"; fi +EXPECTED_ALIASES="$T/expected-host-aliases" +printf 'Host test-only\nHost second-alias\nHost equals-alias\nHost lower-alias\n' > "$EXPECTED_ALIASES" +ACTUAL_ALIASES="$T/actual-host-aliases" +grep '^Host ' "$SANDBOX_SSH_CONFIG" > "$ACTUAL_ALIASES" || true +if grep -Fxq '# CSSwitch managed system SSH config bridge v2' "$SANDBOX_SSH_CONFIG" && cmp -s "$EXPECTED_ALIASES" "$ACTUAL_ALIASES"; then ok "sandbox SSH config projects literal explicit Host aliases for Science"; else no "sandbox SSH config did not project the expected Host aliases"; fi +if ! grep -Eq 'HostName|Port|User|IdentityFile|must-not-project|must/not/project|wildcard|blocked' "$SANDBOX_SSH_CONFIG"; then ok "sandbox SSH alias projection excludes connection details and patterns"; else no "sandbox SSH alias projection copied non-alias config content"; fi out_ssh="$(/usr/bin/ssh -F "$SANDBOX_SSH_CONFIG" -G test-only 2>/dev/null)"; rc_ssh=$? -if [ $rc_ssh -eq 0 ] && echo "$out_ssh" | grep -qx 'hostname 127.0.0.1'; then ok "sandbox SSH config Include resolves Host with system OpenSSH"; else no "sandbox SSH config did not resolve included Host (rc=$rc_ssh)"; fi +if [ $rc_ssh -eq 0 ] && echo "$out_ssh" | grep -qx 'hostname 127.0.0.1' && echo "$out_ssh" | grep -qx 'port 2222'; then ok "sandbox SSH config Include resolves Host with system OpenSSH"; else no "sandbox SSH config did not resolve included Host (rc=$rc_ssh)"; fi if [ ! -e "$T/vh-capture/.claude-science/runtime/real-user-sentinel" ]; then ok "launch never copies real Science runtime data"; else no "launch copied real Science data into sandbox"; fi if ! echo "$out" | grep -Fq "$T/vh-capture" && ! echo "$out" | grep -Fq "$FAKE_CAPTURE"; then ok "launch log redacts sandbox and binary paths"; else no "launch log exposed sensitive paths: $out"; fi out="$(HOME="$OUTER_HOME" SANDBOX_HOME="$T/vh-capture" SCIENCE_BIN="$FAKE_CAPTURE" CAPTURE_FILE="$CAPTURE_FILE" CAPTURE_ENV="$CAPTURE_ENV" CSSWITCH_REUSE_SYSTEM_SSH=0 "$ROOT/scripts/launch-virtual-sandbox.sh" --port 9940 --skip-oauth-forge 2>&1)"; rc=$? if [ $rc -eq 0 ] && [ ! -e "$SANDBOX_SSH_CONFIG" ] && [ ! -L "$SANDBOX_SSH_CONFIG" ]; then ok "disabling SSH removes only the managed sandbox config"; else no "disabling SSH left the managed sandbox config or failed (rc=$rc): $out"; fi +V1_SANDBOX="$T/vh-v1-ssh" +mkdir -p "$V1_SANDBOX/.ssh" +printf '# CSSwitch managed system SSH config bridge v1\nInclude "%s"\n' "$OUTER_HOME/.ssh/config" > "$V1_SANDBOX/.ssh/config" +out="$(HOME="$OUTER_HOME" SANDBOX_HOME="$V1_SANDBOX" SCIENCE_BIN="$FAKE_CAPTURE" CSSWITCH_REUSE_SYSTEM_SSH=1 "$ROOT/scripts/launch-virtual-sandbox.sh" --port 9945 --skip-oauth-forge 2>&1)"; rc=$? +if [ $rc -eq 0 ] && grep -Fxq '# CSSwitch managed system SSH config bridge v2' "$V1_SANDBOX/.ssh/config" && grep -Fxq 'Host test-only' "$V1_SANDBOX/.ssh/config"; then ok "SSH opt-in atomically migrates the managed v1 stub to v2 aliases"; else no "SSH opt-in did not migrate the managed v1 stub (rc=$rc): $out"; fi + +V1_REVOKE_SANDBOX="$T/vh-v1-revoke-ssh" +mkdir -p "$V1_REVOKE_SANDBOX/.ssh" +printf '# CSSwitch managed system SSH config bridge v1\nInclude "%s"\n' "$OUTER_HOME/.ssh/config" > "$V1_REVOKE_SANDBOX/.ssh/config" +out="$(HOME="$OUTER_HOME" SANDBOX_HOME="$V1_REVOKE_SANDBOX" SCIENCE_BIN="$FAKE_CAPTURE" CSSWITCH_REUSE_SYSTEM_SSH=0 "$ROOT/scripts/launch-virtual-sandbox.sh" --port 9946 --skip-oauth-forge 2>&1)"; rc=$? +if [ $rc -eq 0 ] && [ ! -e "$V1_REVOKE_SANDBOX/.ssh/config" ]; then ok "disabling SSH still revokes a managed v1 stub"; else no "disabling SSH did not revoke the managed v1 stub (rc=$rc): $out"; fi + FOREIGN_SANDBOX="$T/vh-foreign-ssh" mkdir -p "$FOREIGN_SANDBOX/.ssh" printf 'Host foreign\n' > "$FOREIGN_SANDBOX/.ssh/config" @@ -113,6 +131,25 @@ printf '# CSSwitch managed system SSH config bridge v1\nInclude "/different/conf out="$(HOME="$OUTER_HOME" SANDBOX_HOME="$FORGED_SANDBOX" SCIENCE_BIN="$FAKE_CAPTURE" CSSWITCH_REUSE_SYSTEM_SSH=1 "$ROOT/scripts/launch-virtual-sandbox.sh" --port 9943 --skip-oauth-forge 2>&1)"; rc=$? if [ $rc -ne 0 ] && grep -q 'Host must-survive' "$FORGED_SANDBOX/.ssh/config"; then ok "SSH opt-in preserves a forged marker with a different Include"; else no "SSH opt-in trusted or overwrote a forged marker (rc=$rc): $out"; fi +FORGED_V2_SANDBOX="$T/vh-forged-v2-ssh" +mkdir -p "$FORGED_V2_SANDBOX/.ssh" +printf '# CSSwitch managed system SSH config bridge v2\nInclude "%s"\nHost plausible\nUser must-survive\n' "$OUTER_HOME/.ssh/config" > "$FORGED_V2_SANDBOX/.ssh/config" +out="$(HOME="$OUTER_HOME" SANDBOX_HOME="$FORGED_V2_SANDBOX" SCIENCE_BIN="$FAKE_CAPTURE" CSSWITCH_REUSE_SYSTEM_SSH=1 "$ROOT/scripts/launch-virtual-sandbox.sh" --port 9947 --skip-oauth-forge 2>&1)"; rc=$? +if [ $rc -ne 0 ] && grep -q 'User must-survive' "$FORGED_V2_SANDBOX/.ssh/config"; then ok "SSH opt-in preserves a malformed v2 managed-looking config"; else no "SSH opt-in trusted or overwrote malformed v2 content (rc=$rc): $out"; fi + +NO_NEWLINE_V2_SANDBOX="$T/vh-no-newline-v2-ssh" +mkdir -p "$NO_NEWLINE_V2_SANDBOX/.ssh" +printf '# CSSwitch managed system SSH config bridge v2\nInclude "%s"\nHost must-survive' "$OUTER_HOME/.ssh/config" > "$NO_NEWLINE_V2_SANDBOX/.ssh/config" +out="$(HOME="$OUTER_HOME" SANDBOX_HOME="$NO_NEWLINE_V2_SANDBOX" SCIENCE_BIN="$FAKE_CAPTURE" CSSWITCH_REUSE_SYSTEM_SSH=1 "$ROOT/scripts/launch-virtual-sandbox.sh" --port 9948 --skip-oauth-forge 2>&1)"; rc=$? +if [ $rc -ne 0 ] && grep -q 'Host must-survive' "$NO_NEWLINE_V2_SANDBOX/.ssh/config"; then ok "SSH opt-in preserves a noncanonical v2 config without final newline"; else no "SSH opt-in trusted or overwrote a noncanonical v2 config (rc=$rc): $out"; fi + +OVERFLOW_HOME="$T/overflow-home" +OVERFLOW_SANDBOX="$T/vh-overflow-ssh" +mkdir -p "$OVERFLOW_HOME/.ssh" +for index in $(seq 0 512); do printf 'Host overflow-%s\n' "$index"; done > "$OVERFLOW_HOME/.ssh/config" +out="$(HOME="$OVERFLOW_HOME" SANDBOX_HOME="$OVERFLOW_SANDBOX" SCIENCE_BIN="$FAKE_CAPTURE" CSSWITCH_REUSE_SYSTEM_SSH=1 "$ROOT/scripts/launch-virtual-sandbox.sh" --port 9949 --skip-oauth-forge 2>&1)"; rc=$? +if [ $rc -ne 0 ] && echo "$out" | grep -q '无法安全提取系统 SSH Host alias' && [ ! -e "$OVERFLOW_SANDBOX/.ssh/config" ]; then ok "SSH alias projection fails closed above the alias limit"; else no "SSH alias projection accepted or hid an oversized alias set (rc=$rc): $out"; fi + LINK_SANDBOX="$T/vh-linked-ssh" mkdir -p "$LINK_SANDBOX" "$T/ssh-link-target" ln -s "$T/ssh-link-target" "$LINK_SANDBOX/.ssh" diff --git a/test/test_skill_runtime_boundary.py b/test/test_skill_runtime_boundary.py index 481d988..3bf1df7 100644 --- a/test/test_skill_runtime_boundary.py +++ b/test/test_skill_runtime_boundary.py @@ -158,6 +158,32 @@ def test_ui_cache_authorization_is_explicit_and_not_persisted(self): self.assertIn("version_cache.version(app_bin)", science) self.assertIn('"cached_choice_required"', science) + def test_official_downloaded_runtime_is_first_choice_without_sharing_data(self): + science = (ROOT / "desktop/src-tauri/src/runtime/science.rs").read_text() + launch = (ROOT / "scripts/launch-virtual-sandbox.sh").read_text() + status_state = (ROOT / "desktop/src/runtime-status-state.js").read_text() + + selection = science.split("fn select_science_runtime_for_paths_cached", 1)[1].split( + "pub(crate) fn select_science_runtime_cached", 1 + )[0] + self.assertLess( + selection.index("ScienceRuntimeSource::OfficialDownloaded"), + selection.index("ScienceRuntimeSource::InstalledApp"), + ) + candidates = science.split("fn runtime_probe_candidates", 1)[1].split( + "fn science_status_value", 1 + )[0] + self.assertLess( + candidates.index("ScienceRuntimeSource::OfficialDownloaded"), + candidates.index("ScienceRuntimeSource::InstalledApp"), + ) + self.assertIn('join(".claude-science")', science) + self.assertIn('join("bin")', science) + self.assertIn('join("claude-science")', science) + self.assertIn('.env("HOME", sandbox_home())', science) + self.assertIn("--no-auto-update", launch) + self.assertIn("隔离实例不登录或检查更新", status_state) + def test_manual_science_open_refreshes_url_and_has_visible_feedback(self): js = (ROOT / "desktop/src/main.js").read_text() runtime = (ROOT / "desktop/src-tauri/src/commands/runtime.rs").read_text()