From cc047e64c292cc1eb647eb8e2ae95b15abb7d4a2 Mon Sep 17 00:00:00 2001 From: limityan Date: Tue, 30 Jun 2026 20:10:09 +0800 Subject: [PATCH] refactor: move remote disabled surfaces into services --- docs/plans/core-decomposition-completed.md | 2 +- docs/plans/core-decomposition-plan.md | 2 +- .../rules/source/forbidden-rules.mjs | 22 +++ .../rules/source/required-rules.mjs | 42 +++-- scripts/core-boundaries/self-test.mjs | 21 ++- .../core/src/service/remote_ssh/mod.rs | 24 ++- .../src/service/remote_ssh/workspace_state.rs | 168 +----------------- .../assembly/core/src/service/search/mod.rs | 8 +- .../services/services-integrations/AGENTS.md | 11 +- .../services/services-integrations/Cargo.toml | 2 +- .../src}/remote_ssh/disabled.rs | 12 +- .../src/remote_ssh/mod.rs | 13 ++ .../src/remote_ssh/paths.rs | 51 ++++++ .../remote_ssh/workspace_search/disabled.rs} | 6 +- .../src/remote_ssh/workspace_search/mod.rs | 2 + .../tests/remote_ssh_contracts.rs | 39 +++- .../tests/remote_ssh_disabled_contracts.rs | 92 ++++++++++ ...ote_workspace_search_disabled_contracts.rs | 33 ++++ 18 files changed, 327 insertions(+), 223 deletions(-) rename src/crates/{assembly/core/src/service => services/services-integrations/src}/remote_ssh/disabled.rs (97%) rename src/crates/{assembly/core/src/service/search/remote_disabled.rs => services/services-integrations/src/remote_ssh/workspace_search/disabled.rs} (89%) create mode 100644 src/crates/services/services-integrations/tests/remote_ssh_disabled_contracts.rs create mode 100644 src/crates/services/services-integrations/tests/remote_workspace_search_disabled_contracts.rs diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index 1aba9f2c13..0a74d915c1 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -19,7 +19,7 @@ - `services-core` 已承接 workspace-runtime legacy session-store merge、metadata 冲突选择、index rebuild 和 legacy path copy/move fallback;core workspace-runtime 只保留路径计算、runtime layout ensure 和错误兼容映射。 - `runtime-services` 已承接 typed runtime service assembly、capability availability、provider registry、capability validation、无副作用 capability marker ports 和 backend event delivery;core backend event system 只保留兼容 re-export。 - `bitfun-events` 已承接 backend event DTO、agentic event DTO 和 platform-neutral `EventEmitter` trait。 -- `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、IM bot provider-neutral config / persistence / file auto-push / locale / menu / state / command parsing、workspace search concrete owner、remote SSH/SFTP/PTY owner、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO。 +- `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、IM bot provider-neutral config / persistence / file auto-push / locale / menu / state / command parsing、workspace search concrete owner、remote SSH/SFTP/PTY owner、Remote SSH disabled runtime surface、Remote SSH workspace/session identity helper、remote workspace-search disabled surface、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO。 - `tool-contracts` 已承接 provider-neutral tool DTO、manifest/catalog/admission/result presentation、Computer Use DTO/input parser/screenshot payload、confirmation facts、truncation recovery presentation、runtime restriction policy 和 provider-entry materialization;core 只保留 Computer Use 旧 public path re-export / compatibility shim 与产品执行入口。 - `tool-execution` 已承接 local / remote IO helper、Bash shell helper、batching plan、retry policy、state counting、tool state event payload shaping / result redaction、cancellation-state/token-store policy、background exec output capture、ExecCommand provider-neutral 呈现 / control facts / completion shape、prompt-safe tool context facts / custom-data materialization、Computer Use loop detection / screenshot hash / verification / retry policy,以及 File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本;core 只保留 ToolResult 包装、权限、checkpoint、runtime handles、process manager / host adapter 调用、read-state adapter、remote shell/FS 调用和旧工具入口。 - `agent-runtime` 已承接 scheduler/background delivery 纯决策、dialog lifecycle port contracts、runtime event queue/router、session management/cancellation port contracts、session/config/summary facts、persisted session state sidecar / processing-state sanitization、session state facts / event-label projection、session state manager / event emission owner、dialog-turn id / stats facts、side-question runtime-only tracking、thread-goal facts、context profile / model capability policy、prompt markup / prompt / prompt-cache facts 与持久化写入决策、remote file delivery prompt facts、turn skill/agent snapshot DTO/diff/render/store、file-read session state / prior-read guardrail / freshness 决策、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation gate / wait channel state、user-question wait channel state、custom agent / mode / subagent schema、默认值、discovery/loading、markdown IO、validation、review 工具过滤、skill catalog/root specs、mode policy、selection/shadow/mode-info 规则、assistant payload rendering、post-call hook routing、DeepReview provider-neutral policy/queue/retry/diagnostics shaping 与 queue event payload shaping、DeepResearch citation renumber 与 report post-process gate,并建立不暴露 `bitfun-core` / `product-full` / concrete manager 的内部 SDK facade。SDK facade 已支持注入 fake runtime services、tool registry、harness registry、hook registry 和 agent registry。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index e560ceaaf8..167aec79b4 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -24,7 +24,7 @@ - Desktop / CLI / ACP 仍通过 `bitfun-core/product-full` 获取完整能力;Server / Remote / Web / Mobile Web 不直接依赖 core。 - Product Assembly 已按入口矩阵裁剪能力计划:完整兼容入口保留 product-full 能力,无直接 core 入口不再 materialize product-full capability packs、feature groups、runtime services、tool groups 或 harness routes。 - Runtime Services、Agent Runtime、Tool Contracts、Tool Execution、Harness、Product Domains、Services Core、Services Integrations 等 owner crate 已建立;部分 concrete 生命周期仍由 core concrete manager 或产品命令路径持有。 -- Custom agent / mode / skill、Agent lifecycle、tool side-effect、Computer Use、file tool、MiniApp、DeepReview、DeepResearch、remote-connect、workspace search、remote SSH/SFTP/PTY 等多批 provider-neutral 或 concrete owner 已迁出。 +- Custom agent / mode / skill、Agent lifecycle、tool side-effect、Computer Use、file tool、MiniApp、DeepReview、DeepResearch、remote-connect、workspace search、remote SSH/SFTP/PTY、Remote SSH disabled surface、remote workspace identity 和 remote search disabled surface 等多批 provider-neutral 或 concrete owner 已迁出。 - Root boundary scripts 已覆盖核心 owner 防回流、six-layer path 解析、facade-only 文件、custom agent owner / custom subagent wrapper 保护和重点 feature gate。 - Agent Runtime session workspace resolution、Cron / SessionControl / SessionMessage / SessionHistory 的 target session/workspace owner routing、`/goal` tool management runtime-port routing、session/config/context/lifecycle fact owner 收口,以及 `services-integrations` workspace search preview/result conversion 已纳入已完成摘要;后续计划只保留仍需迁移的 feature/kernel、security/control-plane、execution、extension 和 cross-platform adapter 主体工作。 - MiniApp built-in seed orchestration 已进入 `product-domains`,core 只保留 concrete host adapter;session state manager 已进入 `agent-runtime`,core 只保留兼容 re-export。 diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index cf8618af6c..8401355c47 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -911,6 +911,11 @@ export const forbiddenContentRules = [ { path: 'src/crates/assembly/core/src/service/search/mod.rs', patterns: [ + { + regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*mod remote_disabled\b/s, + message: + 'core workspace search facade must not own disabled remote search stubs; re-export services-integrations remote_ssh workspace_search disabled surface', + }, { regex: /\bbitfun_services_integrations::workspace_search::flashgrep\b/, message: @@ -2657,6 +2662,15 @@ export const forbiddenContentRules = [ }, ], }, + { + path: 'src/crates/assembly/core/src/service/remote_ssh/mod.rs', + patterns: [ + { + regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*mod disabled\b/s, + message: 'core remote SSH facade must not own disabled runtime stubs; re-export services-integrations remote_ssh', + }, + ], + }, { path: 'src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs', patterns: [ @@ -2700,6 +2714,14 @@ export const forbiddenContentRules = [ regex: /\bpub fn unresolved_remote_session_storage_key\b/, message: 'core remote SSH workspace runtime must not redefine unresolved session keys; use the integrations contract', }, + { + regex: /\bpub struct WorkspaceSessionIdentity\b/, + message: 'core remote SSH workspace runtime must not redefine workspace session identity; use the integrations path helper', + }, + { + regex: /\bpub fn workspace_session_identity\b/, + message: 'core remote SSH workspace runtime must not redefine workspace session identity construction; use the integrations path helper', + }, { regex: /\bstruct RegisteredRemoteWorkspace\b/, message: 'core remote SSH workspace runtime must not own workspace registrations; use the integrations registry', diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 8dcf395787..977ab2d7da 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -3771,11 +3771,11 @@ export const requiredContentRules = [ { path: 'src/crates/assembly/core/src/service/remote_ssh/mod.rs', reason: - 'core remote SSH compatibility facade must keep service-backed SSH surfaces behind the ssh-remote feature while preserving lightweight workspace identity helpers', + 'core remote SSH compatibility facade must keep concrete SSH surfaces behind the ssh-remote feature and re-export services-owned disabled stubs for lightweight builds', patterns: [ { - regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*mod disabled\b/s, - message: 'missing disabled remote SSH runtime surface for no-default builds', + regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*pub use bitfun_services_integrations::remote_ssh::\{/s, + message: 'missing services-owned disabled remote SSH re-export for no-default builds', }, { regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*pub mod manager\b/s, @@ -3796,9 +3796,9 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/core/src/service/remote_ssh/disabled.rs', + path: 'src/crates/services/services-integrations/src/remote_ssh/disabled.rs', reason: - 'no-default core builds must expose explicit unsupported remote SSH stubs instead of compiling russh-backed runtime code', + 'services-integrations must own explicit unsupported remote SSH stubs for lightweight builds', patterns: [ { regex: /Remote SSH support is disabled; enable the `ssh-remote` feature/, @@ -3823,6 +3823,14 @@ export const requiredContentRules = [ reason: 'services-integrations remote_ssh must own concrete SSH/SFTP/PTY runtime behind the remote-ssh-concrete feature while keeping lightweight path/type contracts separate', patterns: [ + { + regex: /#\[cfg\(not\(feature = "remote-ssh-concrete"\)\)\]\s*mod disabled\b/s, + message: 'missing disabled remote SSH owner module', + }, + { + regex: /#\[cfg\(not\(feature = "remote-ssh-concrete"\)\)\]\s*pub use disabled::\{/s, + message: 'missing disabled remote SSH owner exports', + }, { regex: /#\[cfg\(feature = "remote-ssh-concrete"\)\]\s*pub mod manager\b/s, message: 'missing concrete SSH manager owner module', @@ -7062,6 +7070,10 @@ export const requiredContentRules = [ reason: 'remote SSH workspace_search strategy helpers must stay crate-internal and keep behavior-equivalence tests near the owner', patterns: [ + { + regex: /#\[cfg\(not\(feature = "remote-ssh-concrete"\)\)\]\s*pub mod disabled\b/s, + message: 'missing gated service-owned disabled remote workspace search surface', + }, { regex: /\bpub\(crate\)\s+fn\s+build_remote_scope\b/, message: 'remote scope helper must stay crate-internal', @@ -7160,19 +7172,15 @@ export const requiredContentRules = [ message: 'missing ssh-remote gate for real remote search implementation', }, { - regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*mod remote_disabled\b/s, - message: 'missing disabled remote search implementation for no-default builds', - }, - { - regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*pub use remote_disabled/s, - message: 'missing disabled remote search export', + regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*pub use bitfun_services_integrations::remote_ssh::workspace_search::disabled/s, + message: 'missing service-owned disabled remote search export', }, ], }, { - path: 'src/crates/assembly/core/src/service/search/remote_disabled.rs', + path: 'src/crates/services/services-integrations/src/remote_ssh/workspace_search/disabled.rs', reason: - 'no-default core builds must keep remote search unavailable with an explicit diagnostic', + 'services-integrations must own disabled remote workspace search diagnostics for lightweight SSH builds', patterns: [ { regex: /Remote SSH search is disabled; enable the `ssh-remote` feature/, @@ -7647,6 +7655,14 @@ export const requiredContentRules = [ reason: 'services-integrations remote-ssh owns workspace path/session identity helpers that do not require concrete SSH runtime handles', patterns: [ + { + regex: /\bpub struct WorkspaceSessionIdentity\b/, + message: 'missing workspace session identity contract', + }, + { + regex: /\bpub fn workspace_session_identity\b/, + message: 'missing workspace session identity builder', + }, { regex: /\bpub fn remote_workspace_runtime_root\b/, message: 'missing remote workspace runtime root helper', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index feb1181bf5..4d68eaa15f 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -2639,6 +2639,8 @@ export function runManifestParserSelfTest({ { path: 'src/crates/services/services-integrations/src/remote_ssh/workspace_search/mod.rs', contracts: [ + 'not(feature = "remote-ssh-concrete")', + 'pub mod disabled', 'build_remote_scope', 'shell_escape', 'should_retry_remote_scan_fallback_as_files_with_matches', @@ -2652,10 +2654,13 @@ export function runManifestParserSelfTest({ }, { path: 'src/crates/assembly/core/src/service/search/mod.rs', - contracts: ['mod remote_disabled', 'feature = "ssh-remote"', 'pub use remote_disabled'], + contracts: [ + 'feature = "ssh-remote"', + 'bitfun_services_integrations::remote_ssh::workspace_search::disabled', + ], }, { - path: 'src/crates/assembly/core/src/service/search/remote_disabled.rs', + path: 'src/crates/services/services-integrations/src/remote_ssh/workspace_search/disabled.rs', contracts: ['Remote SSH search is disabled', 'RemoteWorkspaceSearchService', 'remote_workspace_search_service_for_path'], }, { @@ -2979,15 +2984,15 @@ export function runManifestParserSelfTest({ }, { path: 'src/crates/assembly/core/src/service/remote_ssh/mod.rs', - contracts: ['mod disabled', 'pub mod manager', 'pub mod remote_fs', 'pub mod remote_terminal', 'pub mod workspace_state'], + contracts: ['bitfun_services_integrations::remote_ssh', 'pub mod manager', 'pub mod remote_fs', 'pub mod remote_terminal', 'pub mod workspace_state'], }, { - path: 'src/crates/assembly/core/src/service/remote_ssh/disabled.rs', - contracts: ['Remote SSH support is disabled', 'SSHConnectionManager', 'RemoteFileService', 'RemoteTerminalManager'], + path: 'src/crates/services/services-integrations/src/remote_ssh/mod.rs', + contracts: ['mod disabled', 'pub use disabled', 'remote-ssh-concrete', 'pub mod manager', 'mod remote_exec', 'pub mod remote_fs', 'pub mod remote_terminal'], }, { - path: 'src/crates/services/services-integrations/src/remote_ssh/mod.rs', - contracts: ['remote-ssh-concrete', 'pub mod manager', 'mod remote_exec', 'pub mod remote_fs', 'pub mod remote_terminal'], + path: 'src/crates/services/services-integrations/src/remote_ssh/disabled.rs', + contracts: ['Remote SSH support is disabled', 'SSHConnectionManager', 'RemoteFileService', 'RemoteTerminalManager'], }, { path: 'src/crates/services/services-integrations/src/remote_ssh/manager.rs', @@ -3008,6 +3013,8 @@ export function runManifestParserSelfTest({ { path: 'src/crates/services/services-integrations/src/remote_ssh/paths.rs', contracts: [ + 'WorkspaceSessionIdentity', + 'workspace_session_identity', 'remote_workspace_runtime_root', 'remote_workspace_session_mirror_dir', 'canonicalize_local_workspace_root', diff --git a/src/crates/assembly/core/src/service/remote_ssh/mod.rs b/src/crates/assembly/core/src/service/remote_ssh/mod.rs index 7b60cc476b..5bf1989fc6 100644 --- a/src/crates/assembly/core/src/service/remote_ssh/mod.rs +++ b/src/crates/assembly/core/src/service/remote_ssh/mod.rs @@ -4,8 +4,6 @@ //! This allows BitFun to work with files on remote servers via SSH, //! similar to VSCode's Remote SSH extension. -#[cfg(not(feature = "ssh-remote"))] -mod disabled; #[cfg(feature = "ssh-remote")] pub mod manager; #[cfg(feature = "ssh-remote")] @@ -15,6 +13,17 @@ pub mod remote_terminal; pub mod types; pub mod workspace_state; +#[cfg(not(feature = "ssh-remote"))] +pub use bitfun_services_integrations::remote_ssh::{ + get_global_remote_exec_process_manager, KnownHostEntry, PTYSession, PortForward, + PortForwardDirection, PortForwardManager, RemoteExecCommandRequest, RemoteExecCommandResponse, + RemoteExecControlAction, RemoteExecControlOrigin, RemoteExecControlRequest, RemoteExecError, + RemoteExecProcessLifecycleEvent, RemoteExecProcessLifecycleStatus, RemoteExecProcessManager, + RemoteExecResult, RemoteExecSessionCompletion, RemoteExecSessionCompletionSource, + RemoteExecSessionCompletionStatus, RemoteFileService, RemoteSendStdinRequest, + RemoteTerminalManager, RemoteTerminalSession, RemoteWriteStdinRequest, SSHConnectionManager, + SessionStatus, +}; #[cfg(feature = "ssh-remote")] pub use bitfun_services_integrations::remote_ssh::{ get_global_remote_exec_process_manager, RemoteExecCommandRequest, RemoteExecCommandResponse, @@ -28,17 +37,6 @@ pub use bitfun_services_integrations::remote_ssh::{ KnownHostEntry, PTYSession, PortForward, PortForwardDirection, PortForwardManager, SSHConnectionManager, }; -#[cfg(not(feature = "ssh-remote"))] -pub use disabled::{ - get_global_remote_exec_process_manager, KnownHostEntry, PTYSession, PortForward, - PortForwardDirection, PortForwardManager, RemoteExecCommandRequest, RemoteExecCommandResponse, - RemoteExecControlAction, RemoteExecControlOrigin, RemoteExecControlRequest, RemoteExecError, - RemoteExecProcessLifecycleEvent, RemoteExecProcessLifecycleStatus, RemoteExecProcessManager, - RemoteExecResult, RemoteExecSessionCompletion, RemoteExecSessionCompletionSource, - RemoteExecSessionCompletionStatus, RemoteFileService, RemoteSendStdinRequest, - RemoteTerminalManager, RemoteTerminalSession, RemoteWriteStdinRequest, SSHConnectionManager, - SessionStatus, -}; #[cfg(feature = "ssh-remote")] pub use remote_fs::RemoteFileService; #[cfg(feature = "ssh-remote")] diff --git a/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs b/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs index 8bfe3f17c3..06b8725154 100644 --- a/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs +++ b/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs @@ -13,70 +13,13 @@ pub use bitfun_services_integrations::remote_ssh::{ remote_root_to_mirror_subpath, remote_workspace_stable_id, sanitize_remote_mirror_path_component, sanitize_ssh_connection_id_for_local_dir, sanitize_ssh_hostname_for_mirror, unresolved_remote_session_storage_key, workspace_logical_key, - RemoteWorkspaceEntry, RemoteWorkspaceRegistry, RemoteWorkspaceState, LOCAL_WORKSPACE_SSH_HOST, + workspace_session_identity, RemoteWorkspaceEntry, RemoteWorkspaceRegistry, + RemoteWorkspaceState, WorkspaceSessionIdentity, LOCAL_WORKSPACE_SSH_HOST, }; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::RwLock; -/// Unified workspace identity used to resolve session persistence for both -/// local and remote workspaces. The only semantic difference is `hostname`: -/// local workspaces use [`LOCAL_WORKSPACE_SSH_HOST`], while remote workspaces -/// use the SSH host from connection metadata. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct WorkspaceSessionIdentity { - pub hostname: String, - /// Canonical local root or normalized remote root used to identify the - /// logical workspace. This is not always the on-disk session storage path. - pub logical_workspace_path: String, - pub remote_connection_id: Option, -} - -impl WorkspaceSessionIdentity { - pub fn is_remote(&self) -> bool { - self.hostname != LOCAL_WORKSPACE_SSH_HOST - } - - pub fn logical_workspace_path(&self) -> &str { - &self.logical_workspace_path - } -} - -/// Build a unified session identity for local or remote workspaces. -/// -/// Local: `hostname=localhost`, `logical_workspace_path=canonical local root` -/// Remote: `hostname=ssh_host`, `logical_workspace_path=normalized remote root` -pub fn workspace_session_identity( - workspace_path: &str, - remote_connection_id: Option<&str>, - remote_ssh_host: Option<&str>, -) -> Option { - let remote_connection_id = remote_connection_id - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string); - - if let Some(connection_id) = remote_connection_id { - let hostname = remote_ssh_host - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string)?; - return Some(WorkspaceSessionIdentity { - hostname, - logical_workspace_path: normalize_remote_workspace_path(workspace_path), - remote_connection_id: Some(connection_id), - }); - } - - let local_root = - normalize_local_workspace_root_for_stable_id(Path::new(workspace_path)).ok()?; - Some(WorkspaceSessionIdentity { - hostname: LOCAL_WORKSPACE_SSH_HOST.to_string(), - logical_workspace_path: local_root, - remote_connection_id: None, - }) -} - /// Resolve a session identity while tolerating temporarily unresolved remote hosts. /// If the remote host is unknown, fall back to the dedicated unresolved session tree. pub async fn resolve_workspace_session_identity( @@ -459,11 +402,7 @@ pub async fn is_remote_workspace_active() -> bool { #[cfg(test)] mod tests { - use super::{ - normalize_remote_workspace_path, remote_workspace_session_mirror_dir, - sanitize_ssh_connection_id_for_local_dir, workspace_session_identity, - LOCAL_WORKSPACE_SSH_HOST, - }; + use super::remote_workspace_session_mirror_dir; use crate::infrastructure::PathManager; #[tokio::test] @@ -542,107 +481,6 @@ mod tests { assert_eq!(x.connection_id, "c2"); } - #[test] - fn sanitize_connection_id_port_colon_on_windows_only() { - #[cfg(windows)] - assert_eq!( - sanitize_ssh_connection_id_for_local_dir("ssh-root@1.95.50.146:22"), - "ssh-root@1.95.50.146-22" - ); - #[cfg(not(windows))] - assert_eq!( - sanitize_ssh_connection_id_for_local_dir("ssh-root@1.95.50.146:22"), - "ssh-root@1.95.50.146:22" - ); - } - - #[test] - fn normalize_remote_collapses_slashes_and_backslashes() { - assert_eq!( - normalize_remote_workspace_path(r"\\home\\user\\repo//src"), - "/home/user/repo/src" - ); - } - - #[test] - fn normalize_remote_root_unchanged() { - assert_eq!(normalize_remote_workspace_path("/"), "/"); - assert_eq!(normalize_remote_workspace_path("///"), "/"); - } - - #[test] - fn normalize_remote_trims_trailing_slash() { - assert_eq!( - normalize_remote_workspace_path("/home/user/repo/"), - "/home/user/repo" - ); - } - - #[test] - fn local_stable_id_is_deterministic_and_prefixed() { - let id1 = super::local_workspace_stable_storage_id("/Users/foo/BitFun"); - let id2 = super::local_workspace_stable_storage_id("/Users/foo/BitFun"); - assert_eq!(id1, id2); - assert!(id1.starts_with("local_")); - assert_eq!(id1.len(), 6 + 32); - } - - #[test] - fn workspace_logical_key_joins_host_and_path() { - assert_eq!( - super::workspace_logical_key("localhost", "/Users/p/w"), - "localhost:/Users/p/w" - ); - } - - #[test] - fn remote_stable_id_unchanged_shape() { - let id = super::remote_workspace_stable_id("myhost", "/root/proj"); - assert!(id.starts_with("remote_")); - assert_eq!(id.len(), 7 + 32); - } - - #[test] - fn unresolved_session_dir_is_stable_and_under_remote_ssh_mirror() { - let a = super::unresolved_remote_session_storage_dir("conn-1", "/home/u/p"); - let b = super::unresolved_remote_session_storage_dir("conn-1", "/home/u/p"); - assert_eq!(a, b); - let name = a.file_name().and_then(|n| n.to_str()).unwrap(); - assert_eq!(name, "sessions"); - assert!(a.to_string_lossy().contains("_unresolved")); - } - - #[test] - fn remote_workspace_session_identity_tracks_logical_root() { - let identity = workspace_session_identity( - "/home/wsp/projects/test", - Some("conn-1"), - Some("127.0.0.1"), - ) - .expect("remote identity should resolve"); - - assert_eq!(identity.hostname, "127.0.0.1"); - assert_eq!(identity.logical_workspace_path(), "/home/wsp/projects/test"); - assert!(identity.is_remote()); - } - - #[test] - fn local_workspace_session_identity_tracks_logical_root() { - let workspace_root = std::env::temp_dir().join(format!( - "bitfun-workspace-identity-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&workspace_root).expect("workspace should exist"); - - let identity = workspace_session_identity(&workspace_root.to_string_lossy(), None, None) - .expect("local identity should resolve"); - - assert_eq!(identity.hostname, LOCAL_WORKSPACE_SSH_HOST); - assert!(!identity.is_remote()); - - let _ = std::fs::remove_dir_all(workspace_root); - } - #[tokio::test] async fn effective_session_path_returns_local_sessions_dir() { let workspace_root = std::env::temp_dir().join(format!( diff --git a/src/crates/assembly/core/src/service/search/mod.rs b/src/crates/assembly/core/src/service/search/mod.rs index 933534ca1b..b3bf7b8be6 100644 --- a/src/crates/assembly/core/src/service/search/mod.rs +++ b/src/crates/assembly/core/src/service/search/mod.rs @@ -1,9 +1,11 @@ #[cfg(feature = "ssh-remote")] mod remote; -#[cfg(not(feature = "ssh-remote"))] -mod remote_disabled; pub mod service; +#[cfg(not(feature = "ssh-remote"))] +pub use bitfun_services_integrations::remote_ssh::workspace_search::disabled::{ + remote_workspace_search_service_for_path, RemoteWorkspaceSearchService, +}; pub use bitfun_services_integrations::workspace_search::{ ContentSearchOutputMode, ContentSearchRequest, ContentSearchResult, GlobSearchRequest, GlobSearchResult, IndexTaskHandle, WorkspaceIndexStatus, WorkspaceSearchBackend, @@ -15,8 +17,6 @@ pub use bitfun_services_integrations::workspace_search::{ }; #[cfg(feature = "ssh-remote")] pub use remote::{remote_workspace_search_service_for_path, RemoteWorkspaceSearchService}; -#[cfg(not(feature = "ssh-remote"))] -pub use remote_disabled::{remote_workspace_search_service_for_path, RemoteWorkspaceSearchService}; pub use service::{ get_global_workspace_search_service, resolve_workspace_search_daemon_program_path, set_global_workspace_search_service, workspace_search_daemon_available, diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 25a8e3ca00..4f9b84045d 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -28,14 +28,15 @@ slices that are outside pure product logic but still platform-neutral. concrete scheduler/session restore, terminal pre-warm adapters, and product execution remain core-owned unless a reviewed port/provider moves them with equivalence tests. -- Remote-SSH path/session identity helpers, SSH channels, SFTP, remote FS, - remote terminal, and manager assembly live here behind explicit remote SSH - features. +- Remote-SSH path/session identity helpers, disabled surfaces, SSH channels, + SFTP, remote FS, remote terminal, and manager assembly live here behind + explicit remote SSH features. - Workspace search owns the local flashgrep daemon/session lifecycle and indexed-search result conversion behind `workspace-search`; product config and workspace bootstrap stay in the core facade as injected hooks. -- Remote SSH workspace-search owns path/scope/probe/bundle/retry strategy plus - flashgrep session/context lifecycle behind a provider boundary. +- Remote SSH workspace-search owns the disabled surface, path/scope/probe, + bundle/retry strategy, and flashgrep session/context lifecycle behind a + provider boundary. - MiniApp runtime here may own host primitive dispatch, built-in seed file writes, marker IO, storage/import bundle filesystem IO, and JS worker process/pool lifecycle. Manager workflow orchestration remains outside this crate until diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 1db4886526..eab66ed08d 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -119,7 +119,7 @@ remote-connect = [ "uuid", "x25519-dalek", ] -remote-ssh = ["dunce", "sha2", "tokio-util"] +remote-ssh = ["anyhow", "dunce", "sha2", "terminal-core", "thiserror", "tokio-util"] remote-ssh-concrete = [ "remote-ssh", "aes-gcm", diff --git a/src/crates/assembly/core/src/service/remote_ssh/disabled.rs b/src/crates/services/services-integrations/src/remote_ssh/disabled.rs similarity index 97% rename from src/crates/assembly/core/src/service/remote_ssh/disabled.rs rename to src/crates/services/services-integrations/src/remote_ssh/disabled.rs index d0780f9751..f585b52599 100644 --- a/src/crates/assembly/core/src/service/remote_ssh/disabled.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/disabled.rs @@ -4,14 +4,14 @@ //! `ssh-remote` feature. Concrete SSH, SFTP, PTY, and port-forward operations //! stay behind `ssh-remote` and return explicit unsupported errors here. -use crate::service::remote_ssh::types::{ +use crate::remote_ssh::types::{ RemoteDirEntry, RemoteFileEntry, RemoteTreeNode, SSHCommandOptions, SSHCommandResult, SSHConfigEntry, SSHConfigLookupResult, SSHConnectionConfig, SSHConnectionResult, SavedConnection, ServerInfo, }; -use crate::service::terminal::session::SessionSource; use std::path::PathBuf; use std::sync::{Arc, OnceLock}; +use terminal_core::SessionSource; fn unsupported() -> anyhow::Error { anyhow::anyhow!("Remote SSH support is disabled; enable the `ssh-remote` feature") @@ -233,14 +233,12 @@ impl SSHConnectionManager { pub async fn set_remote_workspace( &self, - _workspace: crate::service::remote_ssh::types::RemoteWorkspace, + _workspace: crate::remote_ssh::types::RemoteWorkspace, ) -> anyhow::Result<()> { Err(unsupported()) } - pub async fn get_remote_workspaces( - &self, - ) -> Vec { + pub async fn get_remote_workspaces(&self) -> Vec { Vec::new() } @@ -253,7 +251,7 @@ impl SSHConnectionManager { pub async fn get_remote_workspace( &self, _connection_id: &str, - ) -> Option { + ) -> Option { None } diff --git a/src/crates/services/services-integrations/src/remote_ssh/mod.rs b/src/crates/services/services-integrations/src/remote_ssh/mod.rs index 6561199f9f..758cfc2492 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/mod.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/mod.rs @@ -9,6 +9,8 @@ pub mod workspace_registry; #[cfg(feature = "workspace-search")] pub mod workspace_search; +#[cfg(not(feature = "remote-ssh-concrete"))] +mod disabled; #[cfg(feature = "remote-ssh-concrete")] pub mod manager; #[cfg(feature = "remote-ssh-concrete")] @@ -24,6 +26,17 @@ pub use paths::*; pub use types::*; pub use workspace_registry::*; +#[cfg(not(feature = "remote-ssh-concrete"))] +pub use disabled::{ + get_global_remote_exec_process_manager, KnownHostEntry, PTYSession, PortForward, + PortForwardDirection, PortForwardManager, RemoteExecCommandRequest, RemoteExecCommandResponse, + RemoteExecControlAction, RemoteExecControlOrigin, RemoteExecControlRequest, RemoteExecError, + RemoteExecProcessLifecycleEvent, RemoteExecProcessLifecycleStatus, RemoteExecProcessManager, + RemoteExecResult, RemoteExecSessionCompletion, RemoteExecSessionCompletionSource, + RemoteExecSessionCompletionStatus, RemoteFileService, RemoteSendStdinRequest, + RemoteTerminalManager, RemoteTerminalSession, RemoteWriteStdinRequest, SSHConnectionManager, + SessionStatus, +}; #[cfg(feature = "remote-ssh-concrete")] pub use manager::{ KnownHostEntry, PTYSession, PortForward, PortForwardDirection, PortForwardManager, diff --git a/src/crates/services/services-integrations/src/remote_ssh/paths.rs b/src/crates/services/services-integrations/src/remote_ssh/paths.rs index 3fad5c41d7..d3b115bbc7 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/paths.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/paths.rs @@ -6,6 +6,25 @@ use std::path::{Path, PathBuf}; /// SSH host label for local disk workspaces (`Normal` / `Assistant`). pub const LOCAL_WORKSPACE_SSH_HOST: &str = "localhost"; +/// Unified workspace identity used to resolve session persistence for local and remote workspaces. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WorkspaceSessionIdentity { + pub hostname: String, + /// Canonical local root or normalized remote root used to identify the logical workspace. + pub logical_workspace_path: String, + pub remote_connection_id: Option, +} + +impl WorkspaceSessionIdentity { + pub fn is_remote(&self) -> bool { + self.hostname != LOCAL_WORKSPACE_SSH_HOST + } + + pub fn logical_workspace_path(&self) -> &str { + &self.logical_workspace_path + } +} + /// Normalize a remote POSIX workspace path for registry lookup on any client OS. pub fn normalize_remote_workspace_path(path: &str) -> String { let mut s = path.replace('\\', "/"); @@ -139,6 +158,38 @@ pub fn local_workspace_roots_equal(a: &Path, b: &Path) -> bool { } } +/// Build a unified session identity for local or remote workspaces. +pub fn workspace_session_identity( + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, +) -> Option { + let remote_connection_id = remote_connection_id + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + + if let Some(connection_id) = remote_connection_id { + let hostname = remote_ssh_host + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string)?; + return Some(WorkspaceSessionIdentity { + hostname, + logical_workspace_path: normalize_remote_workspace_path(workspace_path), + remote_connection_id: Some(connection_id), + }); + } + + let local_root = + normalize_local_workspace_root_for_stable_id(Path::new(workspace_path)).ok()?; + Some(WorkspaceSessionIdentity { + hostname: LOCAL_WORKSPACE_SSH_HOST.to_string(), + logical_workspace_path: local_root, + remote_connection_id: None, + }) +} + /// Human-readable logical key: `{host}:{normalized_absolute_root}`. pub fn workspace_logical_key(ssh_host: &str, root_norm: &str) -> String { format!("{}:{}", ssh_host.trim(), root_norm) diff --git a/src/crates/assembly/core/src/service/search/remote_disabled.rs b/src/crates/services/services-integrations/src/remote_ssh/workspace_search/disabled.rs similarity index 89% rename from src/crates/assembly/core/src/service/search/remote_disabled.rs rename to src/crates/services/services-integrations/src/remote_ssh/workspace_search/disabled.rs index 221469c5c4..4302b51378 100644 --- a/src/crates/assembly/core/src/service/search/remote_disabled.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/workspace_search/disabled.rs @@ -1,7 +1,7 @@ -//! Disabled remote workspace search surface for lightweight feature builds. +//! Disabled remote workspace-search surface for lightweight feature builds. -use crate::service::remote_ssh::workspace_state::RemoteWorkspaceEntry; -use crate::service::search::{ +use crate::remote_ssh::RemoteWorkspaceEntry; +use crate::workspace_search::{ ContentSearchRequest, ContentSearchResult, GlobSearchRequest, GlobSearchResult, IndexTaskHandle, WorkspaceIndexStatus, }; diff --git a/src/crates/services/services-integrations/src/remote_ssh/workspace_search/mod.rs b/src/crates/services/services-integrations/src/remote_ssh/workspace_search/mod.rs index 711aa035b0..baad1c1146 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/workspace_search/mod.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/workspace_search/mod.rs @@ -13,6 +13,8 @@ use crate::workspace_search::ContentSearchOutputMode; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; +#[cfg(not(feature = "remote-ssh-concrete"))] +pub mod disabled; mod service; pub use service::{ diff --git a/src/crates/services/services-integrations/tests/remote_ssh_contracts.rs b/src/crates/services/services-integrations/tests/remote_ssh_contracts.rs index 11e6612f6f..b046a5b088 100644 --- a/src/crates/services/services-integrations/tests/remote_ssh_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_ssh_contracts.rs @@ -7,9 +7,9 @@ use bitfun_services_integrations::remote_ssh::{ remote_workspace_session_mirror_dir, remote_workspace_stable_id, sanitize_remote_mirror_path_component, sanitize_ssh_connection_id_for_local_dir, sanitize_ssh_hostname_for_mirror, unresolved_remote_session_storage_dir, - unresolved_remote_session_storage_key, workspace_logical_key, RemoteWorkspace, - RemoteWorkspaceRegistry, SSHAuthMethod, SSHConnectionConfig, SavedAuthType, SavedConnection, - LOCAL_WORKSPACE_SSH_HOST, + unresolved_remote_session_storage_key, workspace_logical_key, workspace_session_identity, + RemoteWorkspace, RemoteWorkspaceRegistry, SSHAuthMethod, SSHConnectionConfig, SavedAuthType, + SavedConnection, LOCAL_WORKSPACE_SSH_HOST, }; #[test] @@ -176,6 +176,39 @@ fn local_workspace_identity_helpers_preserve_canonical_root_contract() { let _ = std::fs::remove_dir_all(workspace_root); } +#[test] +fn workspace_session_identity_preserves_local_and_remote_contracts() { + let workspace_root = std::env::temp_dir().join(format!( + "bitfun-services-workspace-identity-{}", + std::process::id() + )); + std::fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + + let local = + workspace_session_identity(&workspace_root.to_string_lossy(), None, None).expect("local"); + assert_eq!(local.hostname, LOCAL_WORKSPACE_SSH_HOST); + assert!(!local.is_remote()); + assert_eq!(local.remote_connection_id, None); + + let remote = workspace_session_identity( + r"\\home\\wsp\\project//", + Some(" conn-1 "), + Some(" ssh.dev "), + ) + .expect("remote"); + assert_eq!(remote.hostname, "ssh.dev"); + assert_eq!(remote.logical_workspace_path(), "/home/wsp/project"); + assert_eq!(remote.remote_connection_id.as_deref(), Some("conn-1")); + assert!(remote.is_remote()); + + assert!( + workspace_session_identity("/home/wsp/project", Some("conn-1"), None).is_none(), + "remote identity requires a resolvable SSH host" + ); + + let _ = std::fs::remove_dir_all(workspace_root); +} + #[tokio::test] async fn remote_workspace_registry_preserves_ambiguous_root_resolution_contract() { let registry = RemoteWorkspaceRegistry::new(); diff --git a/src/crates/services/services-integrations/tests/remote_ssh_disabled_contracts.rs b/src/crates/services/services-integrations/tests/remote_ssh_disabled_contracts.rs new file mode 100644 index 0000000000..6ba5da58ee --- /dev/null +++ b/src/crates/services/services-integrations/tests/remote_ssh_disabled_contracts.rs @@ -0,0 +1,92 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use bitfun_services_integrations::remote_ssh::{ + get_global_remote_exec_process_manager, RemoteExecCommandRequest, RemoteExecError, + RemoteFileService, RemoteTerminalManager, SSHAuthMethod, SSHConnectionConfig, + SSHConnectionManager, +}; + +fn test_config() -> SSHConnectionConfig { + SSHConnectionConfig { + id: "conn-1".to_string(), + name: "Connection 1".to_string(), + host: "example.test".to_string(), + port: 22, + username: "user".to_string(), + auth: SSHAuthMethod::Password { + password: "secret".to_string(), + }, + default_workspace: Some("/repo".to_string()), + } +} + +fn assert_disabled_error(error: impl std::fmt::Display) { + assert!( + error.to_string().contains("Remote SSH support is disabled"), + "unexpected error: {error}" + ); +} + +#[tokio::test] +async fn remote_ssh_disabled_connection_manager_preserves_unsupported_contract() { + let manager = SSHConnectionManager::new(PathBuf::from("remote-ssh-disabled")); + + assert!(manager.list_known_hosts().await.is_empty()); + assert!(manager.get_saved_connections().await.is_empty()); + assert!(!manager.is_connected("conn-1").await); + assert!(manager.get_server_info("conn-1").await.is_none()); + + let error = manager.connect(test_config()).await.unwrap_err(); + assert_disabled_error(error); +} + +#[tokio::test] +async fn remote_ssh_disabled_file_terminal_and_exec_paths_return_unsupported() { + let manager = SSHConnectionManager::new(PathBuf::from("remote-ssh-disabled")); + let manager_slot = Arc::new(tokio::sync::RwLock::new(Some(manager.clone()))); + let file_service = RemoteFileService::new(manager_slot); + let terminal_manager = RemoteTerminalManager::new(manager.clone()); + let exec_manager = get_global_remote_exec_process_manager(); + + let read_error = file_service + .read_file("conn-1", "/repo/file.txt") + .await + .unwrap_err(); + assert_disabled_error(read_error); + + let terminal_result = terminal_manager + .create_session( + Some("session-1".to_string()), + Some("Remote terminal".to_string()), + "conn-1", + 80, + 24, + Some("/repo"), + None, + ) + .await; + let terminal_error = match terminal_result { + Ok(_) => panic!("disabled remote terminal should return an unsupported error"), + Err(error) => error, + }; + assert_disabled_error(terminal_error); + + let exec_error = exec_manager + .exec_command(RemoteExecCommandRequest { + ssh_manager: manager, + connection_id: "conn-1".to_string(), + command: "pwd".to_string(), + tty: false, + yield_time_ms: None, + max_output_chars: None, + lifecycle_tx: None, + output_capture_tx: None, + }) + .await + .unwrap_err(); + match exec_error { + RemoteExecError::Other(error) => assert_disabled_error(error), + other => panic!("unexpected exec error: {other:?}"), + } +} diff --git a/src/crates/services/services-integrations/tests/remote_workspace_search_disabled_contracts.rs b/src/crates/services/services-integrations/tests/remote_workspace_search_disabled_contracts.rs new file mode 100644 index 0000000000..9328595a12 --- /dev/null +++ b/src/crates/services/services-integrations/tests/remote_workspace_search_disabled_contracts.rs @@ -0,0 +1,33 @@ +#![cfg(feature = "workspace-search")] + +use bitfun_services_integrations::remote_ssh::workspace_search::disabled::{ + remote_workspace_search_service_for_path, RemoteWorkspaceSearchService, +}; + +fn assert_disabled_error(error: String) { + assert!( + error.contains("Remote SSH search is disabled"), + "unexpected error: {error}" + ); +} + +#[tokio::test] +async fn disabled_remote_workspace_search_returns_explicit_unsupported_errors() { + let service = RemoteWorkspaceSearchService; + + let status_error = service.get_index_status("/remote/repo").await.unwrap_err(); + assert_disabled_error(status_error); + + let resolve_error = service + .resolve_remote_workspace_entry("/remote/repo") + .await + .unwrap_err(); + assert_disabled_error(resolve_error); + + let resolver_error = match remote_workspace_search_service_for_path("/remote/repo", None).await + { + Ok(_) => panic!("disabled remote search resolver should return an unsupported error"), + Err(error) => error, + }; + assert_disabled_error(resolver_error); +}