diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md
index 7db5e322d7..d7fb2c3eef 100644
--- a/docs/architecture/agent-runtime-deployment-design.md
+++ b/docs/architecture/agent-runtime-deployment-design.md
@@ -11,29 +11,35 @@ Agent Runtime 的模块职责见 [`agent-runtime-services-design.md`](agent-runt
BitFun 只有一套 Agent Runtime 行为。`Embedded` 和 `Shared` 只描述同一套 Runtime 的物理部署方式,不是两套实现。
```mermaid
-flowchart LR
+flowchart TB
subgraph "产品入口"
- GUI["GUI / TUI"]
- CLI["Headless CLI"]
- SDK["Agent SDK"]
+ GUI["Desktop GUI"]
+ TUI["TUI / Headless CLI"]
+ ACP["ACP"]
+ SDK["Agent SDK · SDK Host"]
+ Server["Server agent bootstrap"]
end
- GUI --> Adapter["first-party adapter"]
- CLI --> Adapter
- SDK --> SDKAdapter["SDK Host adapter"]
+ GUI --> Adapter["同级 first-party adapters"]
+ TUI --> Adapter
+ ACP --> Adapter
+ SDK --> Adapter
+ Server --> Adapter
Adapter --> API["Agent Runtime API"]
- SDKAdapter --> API
- API --> Owners["Session / Tool / Permission / MCP owners"]
+ API --> Coordinator["ConversationCoordinator"]
+ Coordinator --> Owners["Session / Tool / Permission / MCP owners"]
+ Coordinator -. "local attach / mutation" .-> Ownership["CoreRuntimeOwnership"]
```
当前代码状态必须和目标设计分开阅读:
| 范围 | 当前状态 |
|---|---|
-| Embedded Desktop GUI | 继续使用现有 Desktop 事件投影和 Tauri adapter;本设计没有改变其依赖或生命周期 |
+| Embedded Desktop GUI | 继续使用 Desktop 事件投影和 Tauri adapter;按实际打开的本机 workspace 延迟取得并持有 Embedded ownership,不增加后台进程 |
| Embedded TUI/Headless CLI/Peer Host | Session、Turn、Permission 和事件订阅统一通过同一个 Rust Runtime SDK(当前 preview);CLI crate 只保留第一方 adapter 和各形态自己的展示/断流策略 |
| ACP/SDK Host | 使用同一个 Runtime 事件入口的 session-scoped 订阅;各自协议和进程生命周期保持独立 |
-| Runtime ownership | CLI 的 Embedded deployment 取得共享锁;Shared TUI deployment 取得独占锁,二者在同一 workspace 互斥;其他产品入口尚未接入该锁 |
+| Runtime ownership | Desktop、CLI、ACP、SDK Host 和现有 Server agent bootstrap 共用 Core owner;Embedded 取得共享锁,Shared TUI 取得独占锁,同一 workspace 上两种 deployment 互斥 |
+| 当前 HTTP Server | 只提供 health/info/WebSocket 外壳,未装配 Agent Runtime,因此不取得 workspace ownership;`bootstrap.rs` 仅保持 agent-enabled composition 的一致边界,不由当前入口启动 |
| Shared local IPC | 未发布的本机协议已有 discovery、实例锁、严格握手、Session 控制租约、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI adapter |
| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,读取 transcript,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded |
| Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、Controller transfer、Session delete/fork 同样不在当前协议中 |
@@ -95,19 +101,37 @@ flowchart LR
### 4.1 Runtime ownership
-`services-core::runtime_ownership` 提供进程级 RAII 文件锁:
+ownership 分成“产品决策”和“文件锁原语”两层;入口不再各自拼 key、目录或锁模式:
```mermaid
-flowchart LR
- E1["Embedded A"] -->|"shared lock"| Key["workspace + product ownership key"]
- E2["Embedded B"] -->|"shared lock"| Key
- S["Shared deployment"] -->|"exclusive lock"| Key
+flowchart TB
+ Entrypoints["Desktop · CLI · ACP · SDK Host · Server bootstrap"]
+ Entrypoints --> Core["CoreRuntimeOwnership
deployment · product identity · process leases"]
+ Core --> Primitive["services-core::runtime_ownership
canonical key · RAII file lock"]
+ Primitive --> E["Embedded · shared lock"]
+ Primitive --> S["Shared · exclusive lock"]
+```
+
+```mermaid
+flowchart TD
+ Op["Session operation"] --> Read{"read-only view/list?"}
+ Read -->|"yes"| NoLock["不取得 ownership"]
+ Read -->|"no · attach/mutate/turn"| Remote{"structured remote facts?"}
+ Remote -->|"yes"| RemoteHost["由目标 execution host 负责"]
+ Remote -->|"no"| Gate["Coordinator → CoreRuntimeOwnership"]
+ Gate --> Lease["按 canonical workspace 保留进程期 lease"]
```
-- 多个 Embedded 进程可继续并存。
-- 在当前 CLI 边界内,Shared TUI 与 Embedded CLI Runtime 互斥;多个 Embedded CLI 进程仍可并存。
-- CLI 每次初始化 Runtime 时都调用该原语;Desktop、SDK Host、Server 等入口尚未接入,也不会被误报为已共享或已互斥。
-- 该锁不选择 workspace、不启动 Runtime、不缓存实例,也不替代 Session 写入权或文件冲突控制。
+| 场景 | 行为 | 原因 |
+|---|---|---|
+| 多个 Embedded 进程访问同一 workspace | 共享锁允许并存 | 保持单实例、CI 和隔离测试的既有成本模型 |
+| Shared 与任一 Embedded 访问同一 workspace | 后启动者返回稳定错误码和启动建议 | 防止同一 workspace 同时存在两种 Runtime deployment |
+| Desktop 打开多个 workspace | 首次 attach/write 时逐个取得并保留 lease | 不把窗口数、Session 数等同于 Runtime 进程数 |
+| 只读 list/view | 不加锁 | ownership 只管理 Runtime deployment,不扩大成读取权限 |
+| 已解析且带有效 `connection_id` 的 remote workspace | 本机不加锁 | 与 Session storage 的远端判据一致;`host` 提示本身不能绕过本地锁 |
+| 当前只读 HTTP Server | 不创建 Core owner | 没有 Agent Runtime 就没有 ownership 可声明 |
+
+`CoreRuntimeOwnership` 只选择 deployment、产品 identity 并保留进程期 lease;`services-core` 只负责 canonical key 和跨进程锁。二者都不选择 workspace、不启动 Runtime,也不替代 Session 单写、数据库事务、文件冲突控制或安全沙箱。
### 4.2 私有本机 IPC
@@ -163,18 +187,23 @@ sequenceDiagram
## 5. 产品入口保持同级
```mermaid
-flowchart LR
- GUI["GUI"] --> GA["GUI adapter"]
- TUI["TUI"] --> TA["TUI adapter"]
- CLI["Headless CLI"] --> CA["CLI adapter"]
- SDK["Agent SDK"] --> SA["SDK Host adapter"]
- ACP["ACP"] --> AA["ACP adapter"]
-
- GA --> API["Agent Runtime API"]
- TA --> API
- CA --> API
- SA --> API
- AA --> API
+flowchart TB
+ GUI["GUI adapter"] --> API["Agent Runtime API"]
+ TUI["TUI adapter"] --> API
+ CLI["Headless CLI adapter"] --> API
+ SDK["SDK Host adapter"] --> API
+ ACP["ACP adapter"] --> API
+ Server["Server adapter · when assembled"] --> API
+ API --> Coordinator["ConversationCoordinator"]
+ Coordinator --> Behavior["single behavior owners"]
+
+ GUI -. "composition" .-> Ownership["CoreRuntimeOwnership"]
+ TUI -. "Embedded / opt-in Shared" .-> Ownership
+ CLI -. "Embedded" .-> Ownership
+ SDK -. "Embedded" .-> Ownership
+ ACP -. "Embedded" .-> Ownership
+ Server -. "only when Runtime is assembled" .-> Ownership
+ Ownership -. "injected once" .-> Coordinator
```
- CLI 不依赖 SDK Host,GUI/TUI 也不依赖公开 SDK package。
@@ -238,7 +267,7 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理
- 只有一套 Agent Runtime 业务实现;部署差异不能产生第二套 Session、Tool、Permission 或 MCP owner。
- Client、窗口、Session 或 workspace 数量不会自动等量增加 Runtime 或 Plugin Host 进程。
- 私有 IPC 不成为公开 SDK、Remote、Peer、HTTP 或浏览器协议。
-- 默认 GUI/TUI/Headless CLI 保持 Embedded;只有交互式 TUI 的显式 `--shared` 选择 Shared,当前互斥范围也只覆盖 CLI deployment。
+- 默认 GUI/TUI/Headless CLI、ACP 与 SDK Host 保持 Embedded;只有交互式 TUI 的显式 `--shared` 选择 Shared。互斥按 `workspace + product` 生效,不再按入口名称缩窄。
- Account/session cloud sync 仍使用既有 Core compatibility 边界,不属于 Shared Runtime 支持。
- Remote workspace 的文件、凭据、进程和 Runtime 位于目标执行域,禁止静默回落本机。
- 未经真实 consumer 验证的接口不进入 wire;当前 wire 只包含表中列出的 Shared TUI 操作。
diff --git a/docs/architecture/agent-sdk-product-architecture.md b/docs/architecture/agent-sdk-product-architecture.md
index 8696771329..54f03fb519 100644
--- a/docs/architecture/agent-sdk-product-architecture.md
+++ b/docs/architecture/agent-sdk-product-architecture.md
@@ -171,27 +171,29 @@ Python SDK、TypeScript SDK、managed Host 和连接预启动 Host 不是四种
### 4.1 产品入口
```mermaid
-flowchart LR
+flowchart TB
GUI["GUI / TUI"] --> UIA["UI adapter"]
CLI["bitfun exec"] --> CLIA["CLI adapter"]
SDK["Agent SDK"] --> SDKA["SDK Host"]
+ ACP["ACP"] --> ACPA["ACP adapter"]
+ Server["Server / Remote"] --> RemoteA["Server / Remote adapter"]
UIA --> API["Runtime API"]
CLIA --> API
SDKA --> API
- API --> Runtime["Agent Runtime owners"]
+ ACPA --> API["Runtime API"]
+ RemoteA --> API
+ API --> Coordinator["ConversationCoordinator"]
+ Coordinator --> Runtime["Agent Runtime owners"]
+
+ Composition["first-party composition roots"] -. "inject once" .-> Ownership["CoreRuntimeOwnership"]
+ Ownership -. "local attach / mutation gate" .-> Coordinator
```
### 4.2 互操作入口
-```mermaid
-flowchart LR
- ACP["ACP"] --> ACPA["ACP adapter"]
- Remote["Server / Remote"] --> RemoteA["Remote adapter"]
- ACPA --> API["Runtime API"]
- RemoteA --> API
-```
+上图中的 ACP、Server/Remote 和 SDK Host 都是同级 adapter;虚线只表示第一方进程装配 ownership,不表示某个入口依赖另一个入口。
-以上两图固定四条架构结论:
+上图固定四条架构结论:
- GUI/TUI/CLI 同样使用 Query、MCP、Permission 和 Hook,但它们直接经过各自 adapter 调用共享 Runtime API,
不依赖 Python/TypeScript SDK,也不依赖 SDK Host。
@@ -203,8 +205,9 @@ flowchart LR
一次性 Headless CLI 继续 Embedded;公开 SDK 默认连接私有 SDK Host。Shared Agent Runtime process 和 SDK Host 都是 Rust 产品进程,
与运行第三方 JS/TS 的 Node/Bun Plugin Host 不同;三者不能共享名称或业务归属。
-当前代码只具备 Shared deployment 的本机 IPC、身份、握手、Health 和 ownership 基础;没有 GUI/TUI/Remote consumer,
-也没有 Shared Session/Turn 协议。图中 Shared deployment 是目标架构,不是已交付产品能力。
+当前代码已经交付显式启用的 Shared TUI 最小切片,包含本机 IPC、身份、握手、Session/Turn、Permission/UserInput、
+ownership 和生命周期治理;GUI、Headless CLI、ACP、SDK Host、Server/Remote 仍没有 Shared consumer。该图中的多入口逻辑复用是
+当前事实,除 Shared TUI 外的跨进程 Shared deployment 仍是目标架构。
### 4.3 各形态能做什么
@@ -242,6 +245,7 @@ flowchart LR
| `bitfun-sdk-host` | 独立组装入口,选择 SDK profile | 依赖 CLI crate;成为第二个 Server 或 Runtime |
| SDK Host adapter | 协议、能力协商、连接/Query 资源清理责任和 DTO 转换 | stdin/stdout 入口、Agent 业务状态、Tool/MCP 注册表 |
| Python/TypeScript SDK | 管理或连接匹配 Host,提供一致公开 API | 要求用户安装 `bitfun` CLI;暴露内部 wire DTO |
+| `CoreRuntimeOwnership` | 第一方 Rust 入口选择 Embedded/Shared,并把本机 workspace lease 注入 Coordinator | 进入公开 SDK/wire;成为 Session 单写或 Server 路由 owner |
### 5.2 一次 Query 的运行时序
@@ -468,6 +472,7 @@ CLI 和 SDK 共享能力事实,但不是上下层关系:
因此:
- CLI 不默认依赖 SDK Host,也不通过 SDK package 运行。
+- CLI、ACP、Desktop 与 SDK Host 只共享 Core ownership 和 Runtime 行为 owner;共享这些内部 owner 不构成产品依赖,也不新增第二种 SDK。
- 一次性 `bitfun exec` 默认使用 Embedded Runtime;只有恢复或控制 Shared Agent Runtime 中的共享 Session 时,才使用第一方
client adapter attach,且不经过 SDK Host。
- SDK 不解析 CLI `stream-json` 作为正式双向协议。
@@ -481,6 +486,10 @@ CLI 和 SDK 共享能力事实,但不是上下层关系:
```mermaid
flowchart LR
Runtime["Runtime domain contracts"] --> HostSchema["SDK Host schema\nstable + experimental"]
+ Runtime --> SessionCreate["AgentSessionCreateResult\nshared session-create facts"]
+ SessionCreate --> HostSchema
+ SessionCreate --> CLIProjection
+ SessionCreate --> UIProjection
HostSchema --> TSClient["generated internal TS wire client"]
HostSchema --> PyClient["generated internal Python wire client"]
TSClient --> TSApi["curated TypeScript public API"]
@@ -494,6 +503,11 @@ flowchart LR
Fixtures --> CLIProjection
```
+会话创建是这条规则的当前实例:`AgentSessionCreateResult` 由 Session owner 生成并携带规范化的
+workspace 与 execution-target 事实;Desktop 的 `CreateSessionResponse` 只是该类型的宿主命名,SDK Host 的
+`SessionCreateResult` 则保留 `agent`、`lifetime` 等协议字段并从同一结果转换。adapter 可以改变 wire 形状,
+但不能重新计算或持有第二份 Session 创建事实。
+
生成的 wire 类型保持 SDK 内部;公开 API 必须经过人工策划,不能把协议 DTO 原样暴露给用户。
### 10.2 防止持续迭代造成不一致
diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md
index efb9fbb7a3..ac4d77bdd1 100644
--- a/docs/architecture/product-architecture.md
+++ b/docs/architecture/product-architecture.md
@@ -375,12 +375,20 @@ flowchart LR
当前本机入口组装:
```mermaid
-flowchart LR
+flowchart TB
Desktop["Desktop"] --> Full["product-full"]
CLI["CLI / TUI"] --> Full
ACP["ACP"] --> Parts["Runtime Parts"]
+ SDKHost["SDK Host"] --> Parts
+ ServerBootstrap["Server agent bootstrap · dormant"] --> Full
+
+ Full --> Coordinator["ConversationCoordinator"]
+ Parts --> Coordinator
+ Ownership["CoreRuntimeOwnership"] -. "first-party composition injects once" .-> Coordinator
```
+当前公开 HTTP Server 不调用 agent bootstrap,因此不创建 Runtime 或 workspace ownership;图中的 Server 节点只记录已有 agent-enabled composition 边界,不能据此宣称 Server Agent API 已交付。
+
当前 Peer 运行连接:
```mermaid
@@ -400,7 +408,7 @@ flowchart LR
| Desktop | 使用 `product-full`;显示外部来源、审批、冲突、诊断和 Host 能力 | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 |
| CLI / TUI | 使用 `product-full`;提供 `/extensions`、`/hooks_external`、`/tools` 和 `/agents` | 不解析生态文件,不启动第二套 Agent Runtime;远程能力未接入时不回退本机 |
| ACP | 使用 `DeliveryProfile::Acp` 和 Runtime Parts | load 成功后才发布活动状态;close 排空后再卸载;完整历史和配置仍由 Core/ACP 管理 |
-| Peer / Server | Server 提供 control/catalog;Peer Host 执行真实工作区操作 | 控制端不替远端发现或执行;旧 Host 明确降级,SSH Remote 未接入时返回不支持 |
+| Peer / Server | Server 提供 control/catalog;Peer Host 执行真实工作区操作;当前 HTTP Server 不装配 Agent Runtime | 控制端不替远端发现或执行;旧 Host 明确降级,SSH Remote 未接入时返回不支持;只读 Server 不声明 Runtime ownership |
| Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 |
| HarmonyOS 手机 Remote | phone-only ArkTS 远程入口 | 不等于 HarmonyOS PC 本地 Runtime、CLI/TUI 或 GUI |
diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs
index 8ffbf0acdf..06ecbc3d55 100644
--- a/scripts/core-boundaries/rules/source/required-rules.mjs
+++ b/scripts/core-boundaries/rules/source/required-rules.mjs
@@ -1482,8 +1482,9 @@ export const requiredContentRules = [
'the standalone SDK Host must inject its selected delivery profile into the Core tool owner before agentic system construction',
patterns: [
{
- regex: /\binit_agentic_system_for_profile\b/,
- message: 'SDK Host runtime must initialize Core with its selected delivery profile',
+ regex: /\binit_agentic_system_for_profile_with_runtime_ownership\b/,
+ message:
+ 'SDK Host runtime must initialize Core with its selected delivery profile and Runtime ownership owner',
},
{
regex: /\bselect_agentic_system_profile\b/,
diff --git a/src/apps/cli/src/agent/agentic_system.rs b/src/apps/cli/src/agent/agentic_system.rs
index 27713295ce..5c4cf3702c 100644
--- a/src/apps/cli/src/agent/agentic_system.rs
+++ b/src/apps/cli/src/agent/agentic_system.rs
@@ -2,6 +2,8 @@ use anyhow::{Context, Result};
use bitfun_core::product_assembly::DeliveryProfile;
use bitfun_core::product_runtime::CoreRuntimeServicesProvider;
+use bitfun_core::runtime_ownership::CoreRuntimeOwnership;
+use std::sync::Arc;
pub(crate) use bitfun_core::agentic::system::AgenticSystem;
@@ -10,8 +12,15 @@ pub(crate) fn select_agentic_system_profile(profile: DeliveryProfile) -> Result<
.context("Failed to select agentic system delivery profile")
}
-pub(crate) async fn init_agentic_system(profile: DeliveryProfile) -> Result {
- let system = bitfun_core::agentic::system::init_agentic_system_for_profile(profile)
+pub(crate) async fn init_agentic_system(
+ profile: DeliveryProfile,
+ runtime_ownership: Arc,
+) -> Result {
+ let system =
+ bitfun_core::agentic::system::init_agentic_system_for_profile_with_runtime_ownership(
+ profile,
+ runtime_ownership,
+ )
.await
.context("Failed to initialize agentic system")?;
system
diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs
index 68fde2f3bf..0b7146e1ff 100644
--- a/src/apps/cli/src/main.rs
+++ b/src/apps/cli/src/main.rs
@@ -652,7 +652,26 @@ async fn initialize_core_services_for_deployment(
.await
.map_err(|error| anyhow!("Failed to initialize global config service: {error}"))?;
tracing::info!("Global config service initialized");
- let runtime_ownership = shared_runtime::acquire_ownership(workspace_root, deployment)?;
+ let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc()
+ .map_err(|error| anyhow!(error.to_string()))?;
+ let entrypoint = match (deployment, bootstrap_profile) {
+ (
+ bitfun_services_core::runtime_ownership::RuntimeDeployment::Embedded,
+ BootstrapProfile::Interactive,
+ ) => "cli-interactive",
+ (bitfun_services_core::runtime_ownership::RuntimeDeployment::Embedded, _) => "cli-headless",
+ (bitfun_services_core::runtime_ownership::RuntimeDeployment::Shared, _) => {
+ "shared-tui-runtime"
+ }
+ };
+ let runtime_ownership = bitfun_core::runtime_ownership::CoreRuntimeOwnership::fixed_workspace(
+ path_manager.as_ref(),
+ entrypoint,
+ workspace_root,
+ deployment,
+ )
+ .map_err(|error| anyhow!(error.startup_message(deployment, entrypoint)))?;
+ let runtime_ownership = std::sync::Arc::new(runtime_ownership);
let config_service = bitfun_core::service::config::get_global_config_service()
.await
@@ -667,6 +686,7 @@ async fn initialize_core_services_for_deployment(
let agentic_system = agent::agentic_system::init_agentic_system(
bitfun_core::product_assembly::DeliveryProfile::Cli,
+ runtime_ownership,
)
.await
.map_err(|error| anyhow!("Failed to initialize agentic system: {error}"))?;
@@ -676,7 +696,6 @@ async fn initialize_core_services_for_deployment(
agentic_system,
workspace_root,
approval_policy,
- runtime_ownership,
)?);
debug_assert!(runtime
.product()
diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs
index 5ba6460f92..4f8426d717 100644
--- a/src/apps/cli/src/peer_host/commands/session.rs
+++ b/src/apps/cli/src/peer_host/commands/session.rs
@@ -32,13 +32,32 @@ fn session_storage_request(request: &Value) -> Result Result {
+ let scope = session_storage_request(request)?;
+ state
+ .compatibility
+ .ensure_workspace_runtime_ownership(&scope)
+ .map_err(|error| format!("Agent Runtime ownership is unavailable: {error}"))?;
+ Ok(scope)
+}
+
pub(super) async fn resolved_session_storage_path(
state: &PeerHostState,
request: &Value,
+) -> Result {
+ resolved_session_storage_scope(state, session_storage_request(request)?).await
+}
+
+pub(super) async fn resolved_session_storage_scope(
+ state: &PeerHostState,
+ scope: SessionStoragePathRequest,
) -> Result {
state
.compatibility
- .resolve_persisted_session_storage_path(session_storage_request(request)?)
+ .resolve_persisted_session_storage_path(scope)
.await
.map_err(|error| format!("Failed to resolve session storage path: {error}"))
}
@@ -366,7 +385,8 @@ pub(crate) async fn touch_session_activity(
) -> Result {
let request = request_value(args);
let session_id = validated_session_id(request)?;
- let workspace_path = resolved_session_storage_path(state, request).await?;
+ let scope = ensure_session_workspace_runtime_ownership(state, request)?;
+ let workspace_path = resolved_session_storage_scope(state, scope).await?;
let _mutation = state
.compatibility
.begin_persisted_session_mutation(&workspace_path, &session_id)
@@ -435,6 +455,7 @@ pub(crate) async fn ensure_coordinator_session(
) -> Result {
let request = request_value(args);
let session_id = validated_session_id(request)?;
+ let scope = ensure_session_workspace_runtime_ownership(state, request)?;
if state
.compatibility
.is_session_loaded_in_memory(&session_id)
@@ -442,7 +463,7 @@ pub(crate) async fn ensure_coordinator_session(
{
return Ok(Value::Null);
}
- let storage = resolved_session_storage_path(state, request).await?;
+ let storage = resolved_session_storage_scope(state, scope).await?;
let include_internal = optional_bool(request, "includeInternal").unwrap_or(false);
state
@@ -517,7 +538,8 @@ pub(crate) async fn save_session_turn(
args: &Value,
) -> Result {
let request = request_value(args);
- let workspace_path = resolved_session_storage_path(state, request).await?;
+ let scope = ensure_session_workspace_runtime_ownership(state, request)?;
+ let workspace_path = resolved_session_storage_scope(state, scope).await?;
let turn_data = request
.get("turnData")
.or_else(|| request.get("turn_data"))
@@ -557,6 +579,45 @@ mod tests {
ProcessingPhase, Session as CoreSession, SessionConfig, SessionState as CoreSessionState,
};
+ #[test]
+ fn peer_attach_and_raw_mutations_reuse_core_runtime_ownership() {
+ let session_source = include_str!("session.rs");
+ for mutation in [
+ "pub(crate) async fn touch_session_activity",
+ "pub(crate) async fn ensure_coordinator_session",
+ "pub(crate) async fn save_session_turn",
+ ] {
+ let body = session_source
+ .split_once(mutation)
+ .unwrap_or_else(|| panic!("missing Peer mutation: {mutation}"))
+ .1
+ .split_once("pub(crate) async fn")
+ .unwrap_or_else(|| panic!("missing Peer mutation boundary: {mutation}"))
+ .0;
+ assert!(body.contains("ensure_session_workspace_runtime_ownership"));
+ }
+
+ let workspace_source = include_str!("workspace.rs");
+ let open = workspace_source
+ .split_once("pub(crate) async fn open_workspace")
+ .expect("Peer workspace open")
+ .1
+ .split_once("pub(crate) async fn reload_config")
+ .expect("Peer workspace open boundary")
+ .0;
+ assert!(open.contains("ensure_workspace_runtime_ownership"));
+
+ let snapshot_source = include_str!("snapshot.rs");
+ let rollback = snapshot_source
+ .split_once("pub(crate) async fn rollback_to_turn")
+ .expect("Peer rollback")
+ .1
+ .split_once("#[cfg(test)]")
+ .expect("Peer rollback boundary")
+ .0;
+ assert!(rollback.contains("ensure_session_workspace_runtime_ownership"));
+ }
+
#[test]
fn basic_restore_keeps_peer_host_session_shape() {
let value = restored_session_to_json(AgentSessionRestoreResult {
diff --git a/src/apps/cli/src/peer_host/commands/snapshot.rs b/src/apps/cli/src/peer_host/commands/snapshot.rs
index 48d942a2f0..d37d27e6a0 100644
--- a/src/apps/cli/src/peer_host/commands/snapshot.rs
+++ b/src/apps/cli/src/peer_host/commands/snapshot.rs
@@ -17,7 +17,7 @@ use crate::peer_host::args::{
use crate::peer_host::fanout::fanout_peer_device_event;
use crate::peer_host::state::PeerHostState;
-use super::session::resolved_session_storage_path;
+use super::session::{ensure_session_workspace_runtime_ownership, resolved_session_storage_scope};
pub(super) async fn require_local_snapshot_workspace(
request: &Value,
@@ -168,7 +168,8 @@ pub(crate) async fn rollback_to_turn(state: &PeerHostState, args: &Value) -> Res
bitfun_agent_runtime::session_control::validate_session_id(&session_id)?;
require_local_snapshot_workspace(request, &workspace_path).await?;
let workspace = PathBuf::from(&workspace_path);
- let session_storage_path = resolved_session_storage_path(state, request).await?;
+ let scope = ensure_session_workspace_runtime_ownership(state, request)?;
+ let session_storage_path = resolved_session_storage_scope(state, scope).await?;
if delete_turns {
state
.compatibility
diff --git a/src/apps/cli/src/peer_host/commands/workspace.rs b/src/apps/cli/src/peer_host/commands/workspace.rs
index f02b929b1e..09608fc8e8 100644
--- a/src/apps/cli/src/peer_host/commands/workspace.rs
+++ b/src/apps/cli/src/peer_host/commands/workspace.rs
@@ -2,6 +2,7 @@
use std::path::PathBuf;
+use bitfun_runtime_ports::SessionStoragePathRequest;
use serde_json::{json, Value};
use crate::peer_host::args::{get_string, request_value};
@@ -51,6 +52,14 @@ pub(crate) async fn get_current_workspace(state: &PeerHostState) -> Result Result {
let request = request_value(args);
let path = get_string(request, "path")?;
+ state
+ .compatibility
+ .ensure_workspace_runtime_ownership(&SessionStoragePathRequest {
+ workspace_path: PathBuf::from(&path),
+ remote_connection_id: None,
+ remote_ssh_host: None,
+ })
+ .map_err(|error| format!("Agent Runtime ownership is unavailable: {error}"))?;
let info = state
.workspace_service
.open_workspace(PathBuf::from(path))
diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs
index 48604f7eb1..d5e01b520c 100644
--- a/src/apps/cli/src/root_handlers.rs
+++ b/src/apps/cli/src/root_handlers.rs
@@ -807,6 +807,7 @@ pub(crate) fn handle_health_command() -> Result<()> {
pub(crate) async fn serve_acp_stdio() -> Result<()> {
crate::setup_workspace();
+ let workspace_root = std::env::current_dir().context("Failed to resolve ACP workspace")?;
crate::agent::agentic_system::select_agentic_system_profile(
bitfun_core::product_assembly::DeliveryProfile::Acp,
@@ -824,14 +825,25 @@ pub(crate) async fn serve_acp_stdio() -> Result<()> {
crate::initialize_terminal_service().await;
+ let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc()
+ .map_err(|error| anyhow::anyhow!(error.to_string()))?;
+ let deployment = bitfun_services_core::runtime_ownership::RuntimeDeployment::Embedded;
+ let runtime_ownership = bitfun_core::runtime_ownership::CoreRuntimeOwnership::fixed_workspace(
+ path_manager.as_ref(),
+ "acp",
+ &workspace_root,
+ deployment,
+ )
+ .map_err(|error| anyhow::anyhow!(error.startup_message(deployment, "acp")))?;
+
let agentic_system = crate::agent::agentic_system::init_agentic_system(
bitfun_core::product_assembly::DeliveryProfile::Acp,
+ std::sync::Arc::new(runtime_ownership),
)
.await
.context("Failed to initialize agentic system")?;
tracing::info!("Agentic system initialized");
- let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let runtime = crate::runtime::AcpRuntimeContext::build(agentic_system, workspace_root)?;
let (agent_runtime, compatibility) = runtime.parts();
bitfun_acp::BitfunAcpRuntime::serve_stdio(agent_runtime, compatibility).await?;
diff --git a/src/apps/cli/src/runtime/mod.rs b/src/apps/cli/src/runtime/mod.rs
index 45693f9b60..2ceec6f876 100644
--- a/src/apps/cli/src/runtime/mod.rs
+++ b/src/apps/cli/src/runtime/mod.rs
@@ -12,7 +12,6 @@ use bitfun_core::product_runtime::{
use bitfun_core::runtime_ports::PluginRuntimeAvailability;
use bitfun_runtime_ports::LocalWorkspaceSnapshotPort;
use bitfun_runtime_services::RuntimeServices;
-use bitfun_services_core::runtime_ownership::WorkspaceRuntimeOwnership;
use crate::product_assembly::{assemble_acp_runtime_parts, assemble_cli_runtime_parts};
@@ -58,7 +57,6 @@ pub(crate) struct CliRuntimeContext {
services: RuntimeServices,
product: CliProductRuntimeState,
approval_policy: CliApprovalPolicy,
- _runtime_ownership: Arc,
}
impl CliRuntimeContext {
@@ -66,7 +64,6 @@ impl CliRuntimeContext {
agentic_system: AgenticSystem,
workspace_root: impl AsRef,
approval_policy: CliApprovalPolicy,
- runtime_ownership: WorkspaceRuntimeOwnership,
) -> Result {
let scheduler = ensure_product_dialog_scheduler(&agentic_system);
let (workspace_root, services) =
@@ -120,7 +117,6 @@ impl CliRuntimeContext {
services,
product,
approval_policy,
- _runtime_ownership: Arc::new(runtime_ownership),
})
}
diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs
index 41e79160c9..fba5fcbf99 100644
--- a/src/apps/cli/src/shared_runtime.rs
+++ b/src/apps/cli/src/shared_runtime.rs
@@ -10,10 +10,9 @@ use bitfun_agent_runtime_ipc::{
RuntimeIpcRequestHandler, RuntimeIpcServer, RuntimeIpcServerConfig,
RuntimeIpcStreamInvalidationReason, PROTOCOL_VERSION,
};
+use bitfun_core::runtime_ownership::CoreRuntimeOwnership;
use bitfun_events::{AgenticEvent, ToolEventData};
-use bitfun_services_core::runtime_ownership::{
- RuntimeDeployment, RuntimeOwnershipError, RuntimeOwnershipKey, WorkspaceRuntimeOwnership,
-};
+use bitfun_services_core::runtime_ownership::RuntimeDeployment;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
@@ -677,7 +676,16 @@ pub(crate) async fn connect_or_start(workspace: &Path) -> Result last_connect_error = Some(error),
}
if let Some(status) = child.try_wait().context("poll Shared Runtime startup")? {
- if !runtime_owner_present(workspace)? {
+ if embedded_runtime_owner_present(workspace)? {
+ return Err(anyhow!(
+ "Agent Runtime ownership failed (runtime_ownership_unavailable): an Embedded Runtime owns this workspace; close it before starting Shared TUI ({status})"
+ ));
+ }
+ if runtime_owner_present(workspace)? {
+ // Another Shared child may still be initializing and has not
+ // published discovery yet. Keep connecting until the normal
+ // bounded startup timeout instead of mislabeling it Embedded.
+ } else {
if respawned {
return Err(anyhow!(
"Shared Runtime exited before becoming ready ({status})"
@@ -690,7 +698,7 @@ pub(crate) async fn connect_or_start(workspace: &Path) -> Result= STARTUP_TIMEOUT {
let owner_guidance = if runtime_owner_present(workspace)? {
- "; another local Runtime still owns this workspace, so close its clients and wait up to 30 seconds"
+ "; Agent Runtime ownership failed (runtime_ownership_unavailable): another local Runtime still owns this workspace, so close its clients and wait up to 30 seconds"
} else {
""
};
@@ -708,16 +716,13 @@ pub(crate) async fn connect_or_start(workspace: &Path) -> Result Result {
- let key = RuntimeOwnershipKey::for_workspace(workspace, product_identity())?;
- match WorkspaceRuntimeOwnership::try_acquire(
- &ownership_root()?,
- &key,
- RuntimeDeployment::Shared,
- ) {
- Ok(_) => Ok(false),
- Err(RuntimeOwnershipError::OwnershipUnavailable { .. }) => Ok(true),
- Err(error) => Err(error.into()),
- }
+ CoreRuntimeOwnership::runtime_owner_present(path_manager()?.as_ref(), workspace)
+ .map_err(anyhow::Error::from)
+}
+
+fn embedded_runtime_owner_present(workspace: &Path) -> Result {
+ CoreRuntimeOwnership::embedded_runtime_owner_present(path_manager()?.as_ref(), workspace)
+ .map_err(anyhow::Error::from)
}
fn require_interactive_tui(client: RuntimeIpcClient) -> Result {
@@ -739,25 +744,6 @@ async fn prepare_client_environment() -> Result<()> {
.map_err(|error| anyhow!("Failed to initialize Shared TUI configuration: {error}"))
}
-pub(crate) fn acquire_ownership(
- workspace: &Path,
- deployment: RuntimeDeployment,
-) -> Result {
- let key = RuntimeOwnershipKey::for_workspace(workspace, product_identity())
- .context("resolve Runtime ownership key")?;
- WorkspaceRuntimeOwnership::try_acquire(&ownership_root()?, &key, deployment).map_err(|error| {
- let guidance = match deployment {
- RuntimeDeployment::Embedded => {
- "A Shared TUI Runtime owns this CLI workspace; use `bitfun chat --shared`, or close its clients and wait up to 30 seconds"
- }
- RuntimeDeployment::Shared => {
- "An Embedded CLI process owns this workspace; close it before using `--shared`"
- }
- };
- anyhow!("{guidance}: {error}")
- })
-}
-
async fn connect_existing(
store: &DiscoveryStore,
runtime_root: &Path,
@@ -851,7 +837,7 @@ fn instance_identity(workspace: &Path) -> Result {
let user_root = path_manager()?.user_data_dir();
RuntimeInstanceIdentity::for_workspace(
workspace,
- product_identity(),
+ CoreRuntimeOwnership::distribution_identity(),
RELEASE_CHANNEL,
&user_root.to_string_lossy(),
PROTOCOL_VERSION,
@@ -859,10 +845,6 @@ fn instance_identity(workspace: &Path) -> Result {
.context("resolve Shared Runtime identity")
}
-fn product_identity() -> &'static str {
- option_env!("BITFUN_PRODUCT_BINARY_NAME").unwrap_or("bitfun")
-}
-
fn ipc_root() -> Result {
Ok(path_manager()?
.user_data_dir()
@@ -870,13 +852,6 @@ fn ipc_root() -> Result {
.join(format!("ipc-v{PROTOCOL_VERSION}")))
}
-fn ownership_root() -> Result {
- Ok(path_manager()?
- .user_data_dir()
- .join("agent-runtime")
- .join("ownership"))
-}
-
fn path_manager() -> Result> {
bitfun_core::infrastructure::try_get_path_manager_arc()
.map_err(|error| anyhow!(error.to_string()))
@@ -989,6 +964,23 @@ mod tests {
.is_err());
}
+ #[test]
+ fn exited_shared_child_reports_embedded_owner_without_waiting_for_timeout() {
+ let source = include_str!("shared_runtime.rs");
+ let exited_child = source
+ .split_once("if let Some(status) = child.try_wait()")
+ .expect("Shared Runtime child exit branch")
+ .1
+ .split_once("if started.elapsed() >= STARTUP_TIMEOUT")
+ .expect("startup timeout boundary")
+ .0;
+
+ assert!(exited_child.contains("embedded_runtime_owner_present"));
+ assert!(exited_child.contains("runtime_ownership_unavailable"));
+ assert!(exited_child.contains("Embedded Runtime owns this workspace"));
+ assert!(exited_child.contains("return Err"));
+ }
+
fn delegated_permission(session_id: &str, parent_session_id: &str) -> PermissionRequest {
PermissionRequest {
request_id: "permission-1".to_string(),
diff --git a/src/apps/cli/tests/product_assembly_cli.rs b/src/apps/cli/tests/product_assembly_cli.rs
index 6ddba35906..c63d7cb6a6 100644
--- a/src/apps/cli/tests/product_assembly_cli.rs
+++ b/src/apps/cli/tests/product_assembly_cli.rs
@@ -370,3 +370,34 @@ fn interactive_tui_agent_operations_stay_behind_cli_runtime_client() {
"interactive composition changes must preserve product-aware CLI identity and MCP import"
);
}
+
+#[test]
+fn runtime_ownership_policy_is_assembled_once_in_core() {
+ const SHARED_RUNTIME: &str = include_str!("../src/shared_runtime.rs");
+ const CLI_RUNTIME: &str = include_str!("../src/runtime/mod.rs");
+ const CLI_MAIN: &str = include_str!("../src/main.rs");
+ const AGENTIC_SYSTEM: &str = include_str!("../src/agent/agentic_system.rs");
+
+ for private_policy in [
+ "RuntimeOwnershipKey::for_workspace",
+ "WorkspaceRuntimeOwnership::try_acquire",
+ "fn ownership_root",
+ "fn product_identity",
+ "pub(crate) fn acquire_ownership",
+ ] {
+ assert!(
+ !SHARED_RUNTIME.contains(private_policy),
+ "CLI must not duplicate Core ownership policy: {private_policy}"
+ );
+ }
+ assert!(
+ !CLI_RUNTIME.contains("WorkspaceRuntimeOwnership")
+ && !CLI_RUNTIME.contains("_runtime_ownership"),
+ "Coordinator must retain the Core owner; CliRuntimeContext must not keep a second guard"
+ );
+ assert!(
+ CLI_MAIN.contains("CoreRuntimeOwnership")
+ && AGENTIC_SYSTEM.contains("init_agentic_system_for_profile_with_runtime_ownership"),
+ "CLI must select a deployment and inject the single Core owner"
+ );
+}
diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs
index 6e1ee50c14..def58f79ae 100644
--- a/src/apps/desktop/src/api/agentic_api.rs
+++ b/src/apps/desktop/src/api/agentic_api.rs
@@ -16,9 +16,9 @@ use crate::runtime::{
use crate::startup_trace::DesktopStartupTrace;
use bitfun_agent_runtime::deep_review::sanitize_focused_review_public_metadata;
use bitfun_agent_runtime::sdk::{
- AgentDialogTurnRequest, AgentInputAttachment, AgentSessionModelUpdateRequest,
- AgentSubmissionSource, AgentTurnCancellationRequest, PermissionAuditRecord, PermissionGrant,
- PermissionGrantKey, PermissionReply, PermissionRequest,
+ AgentDialogTurnRequest, AgentInputAttachment, AgentSessionCreateResult,
+ AgentSessionModelUpdateRequest, AgentSubmissionSource, AgentTurnCancellationRequest,
+ PermissionAuditRecord, PermissionGrant, PermissionGrantKey, PermissionReply, PermissionRequest,
};
use bitfun_core::agentic::agents::AgentSource;
use bitfun_core::agentic::coordination::{
@@ -145,21 +145,7 @@ pub struct SessionConfigDTO {
pub remote_ssh_host: Option,
}
-#[derive(Debug, Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct CreateSessionResponse {
- pub session_id: String,
- pub session_name: String,
- pub agent_type: String,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub workspace_path: Option,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub workspace_id: Option,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub project_workspace_path: Option,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub execution_target: Option,
-}
+pub type CreateSessionResponse = AgentSessionCreateResult;
fn existing_session_create_response(
request: &CreateSessionRequest,
@@ -204,15 +190,16 @@ fn existing_session_create_response(
));
}
- Ok(CreateSessionResponse {
- session_id: metadata.session_id.clone(),
- session_name: metadata.session_name.clone(),
- agent_type: metadata.agent_type.clone(),
- workspace_path: metadata.workspace_path.clone(),
- workspace_id: None,
- project_workspace_path: metadata.project_workspace_path.clone(),
- execution_target: metadata.execution_target.clone(),
- })
+ let mut response = AgentSessionCreateResult::new(
+ metadata.session_id.clone(),
+ metadata.session_name.clone(),
+ metadata.agent_type.clone(),
+ );
+ response.workspace_path = metadata.workspace_path.clone();
+ response.workspace_id = request.workspace_id.clone();
+ response.project_workspace_path = metadata.project_workspace_path.clone();
+ response.execution_target = metadata.execution_target.clone();
+ Ok(response)
}
fn is_idempotent_review_create(request: &CreateSessionRequest) -> bool {
@@ -1248,6 +1235,7 @@ pub struct GenerateSessionTitleRequest {
pub async fn create_session(
coordinator: State<'_, Arc>,
app_state: State<'_, AppState>,
+ runtime: State<'_, DesktopRuntimeContext>,
mut request: CreateSessionRequest,
) -> Result {
fn norm_conn(s: Option) -> Option {
@@ -1267,6 +1255,18 @@ pub async fn create_session(
.and_then(|c| norm_conn(c.remote_ssh_host.clone()))
});
+ if remote_conn.is_some() {
+ runtime
+ .session_application()
+ .ensure_workspace_runtime_ownership(desktop_session_scope(
+ request.workspace_path.clone(),
+ remote_conn.clone(),
+ remote_ssh_host.clone(),
+ ))
+ .await
+ .map_err(|error| error.to_string())?;
+ }
+
let source_workspace_path = request.workspace_path.clone();
let is_idempotent_managed_create = matches!(
request.execution_target.as_ref(),
@@ -1442,9 +1442,7 @@ pub async fn create_session(
"Session ID {session_id} already exists with a different worktree target"
));
}
- let mut response = existing_session_create_response(&request, &metadata)?;
- response.workspace_id = request.workspace_id.clone();
- return Ok(response);
+ return existing_session_create_response(&request, &metadata);
}
}
@@ -1484,6 +1482,13 @@ pub async fn create_session(
repaired = true;
}
if repaired {
+ coordinator
+ .ensure_workspace_runtime_ownership(
+ Path::new(&project_workspace_path),
+ remote_conn.as_deref(),
+ remote_ssh_host.as_deref(),
+ )
+ .map_err(|error| error.to_string())?;
let relationship = request.relationship.clone();
let deep_review_run_manifest = request.deep_review_run_manifest.clone();
let review_target_evidence = request.review_target_evidence.clone();
@@ -1631,15 +1636,7 @@ pub async fn create_session(
.map_err(|e| format!("Failed to persist Review target evidence: {}", e))?;
}
- Ok(CreateSessionResponse {
- session_id: session.session_id,
- session_name: session.session_name,
- agent_type: session.agent_type,
- workspace_path: session.config.workspace_path,
- workspace_id: session.config.workspace_id,
- project_workspace_path: session.config.project_workspace_path,
- execution_target: session.config.execution_target,
- })
+ Ok(session.into())
}
#[tauri::command]
@@ -1840,6 +1837,13 @@ pub async fn compact_session(
.ok_or_else(|| {
"workspace_path is required when the session is not loaded".to_string()
})?;
+ coordinator
+ .ensure_workspace_runtime_ownership(
+ Path::new(workspace_path),
+ request.remote_connection_id.as_deref(),
+ request.remote_ssh_host.as_deref(),
+ )
+ .map_err(|error| error.to_string())?;
let effective = desktop_effective_session_storage_path(
&app_state,
workspace_path,
@@ -1888,6 +1892,13 @@ pub async fn activate_session_goal(
.ok_or_else(|| {
"workspace_path is required when the session is not loaded".to_string()
})?;
+ coordinator
+ .ensure_workspace_runtime_ownership(
+ Path::new(workspace_path),
+ request.remote_connection_id.as_deref(),
+ request.remote_ssh_host.as_deref(),
+ )
+ .map_err(|error| error.to_string())?;
let effective = desktop_effective_session_storage_path(
&app_state,
workspace_path,
@@ -1938,6 +1949,13 @@ async fn ensure_session_for_thread_goal(
.ok_or_else(|| {
"workspace_path is required when the session is not loaded".to_string()
})?;
+ coordinator
+ .ensure_workspace_runtime_ownership(
+ Path::new(workspace_path),
+ remote_connection_id,
+ remote_ssh_host,
+ )
+ .map_err(|error| error.to_string())?;
let effective = desktop_effective_session_storage_path(
app_state,
workspace_path,
@@ -2076,6 +2094,31 @@ pub async fn set_session_memory_mode(
}
other => return Err(format!("unsupported memory mode: {other}")),
};
+ if coordinator
+ .get_session_manager()
+ .get_session(session_id)
+ .is_some()
+ {
+ coordinator
+ .ensure_session_runtime_ownership(session_id, None)
+ .map_err(|error| error.to_string())?;
+ } else {
+ let workspace_path = request
+ .workspace_path
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .ok_or_else(|| {
+ "workspace_path is required when the session is not loaded".to_string()
+ })?;
+ coordinator
+ .ensure_workspace_runtime_ownership(
+ Path::new(workspace_path),
+ request.remote_connection_id.as_deref(),
+ request.remote_ssh_host.as_deref(),
+ )
+ .map_err(|error| error.to_string())?;
+ }
let storage_path = resolve_thread_goal_storage_path(
coordinator.inner(),
app_state.inner(),
@@ -2224,6 +2267,13 @@ pub async fn run_init_agents_md(
.ok_or_else(|| {
"workspace_path is required when the session is not loaded".to_string()
})?;
+ coordinator
+ .ensure_workspace_runtime_ownership(
+ Path::new(workspace_path),
+ request.remote_connection_id.as_deref(),
+ request.remote_ssh_host.as_deref(),
+ )
+ .map_err(|error| error.to_string())?;
let effective = desktop_effective_session_storage_path(
&app_state,
workspace_path,
@@ -3366,7 +3416,8 @@ mod tests {
#[test]
fn existing_create_session_retry_returns_the_matching_session() {
- let request = idempotent_create_request();
+ let mut request = idempotent_create_request();
+ request.workspace_id = Some("workspace-1".to_string());
let mut metadata = SessionMetadata::new(
"review_child_request-1".to_string(),
"Review fixes".to_string(),
@@ -3378,11 +3429,13 @@ mod tests {
relationship.parent_dialog_turn_id = Some("turn-2".to_string());
relationship.parent_turn_index = Some(2);
- let response = existing_session_create_response(&request, &metadata)
- .expect("matching retry should reuse the session");
+ let response: AgentSessionCreateResult =
+ existing_session_create_response(&request, &metadata)
+ .expect("matching retry should reuse the session");
assert_eq!(response.session_id, "review_child_request-1");
assert_eq!(response.agent_type, "CodeReview");
+ assert_eq!(response.workspace_id.as_deref(), Some("workspace-1"));
}
#[test]
diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs
index 4805b69669..040a18c0eb 100644
--- a/src/apps/desktop/src/api/remote_connect_api.rs
+++ b/src/apps/desktop/src/api/remote_connect_api.rs
@@ -2,6 +2,7 @@
use crate::api::session_storage_path::desktop_effective_session_storage_path;
use crate::embedded_relay_host::DesktopEmbeddedRelayHost;
+use bitfun_core::agentic::coordination::{get_global_coordinator, ConversationCoordinator};
use bitfun_core::agentic::persistence::PersistenceManager;
use bitfun_core::agentic::tools::account_login_capability::set_account_login_available;
use bitfun_core::agentic::tools::page_deploy_host::set_page_deploy_handler;
@@ -16,6 +17,8 @@ use bitfun_core::service::remote_connect::{
PairingState, RemoteConnectConfig, RemoteConnectService,
};
use bitfun_core::service::session::{DialogTurnData, SessionMetadata};
+use bitfun_core::service::workspace::{get_global_workspace_service, WorkspaceKind};
+use bitfun_core::service::workspace_runtime::WorkspaceRuntimeService;
use bitfun_services_integrations::remote_connect::account::{
error_indicates_expired_token, validate_relay_base_url,
};
@@ -3160,6 +3163,7 @@ pub async fn account_export_all_sessions(
#[tauri::command]
pub async fn account_import_remote_sessions(
workspace_path: String,
+ coordinator: State<'_, Arc>,
app_state: State<'_, crate::api::app_state::AppState>,
path_manager: State<'_, Arc>,
) -> Result, String> {
@@ -3167,6 +3171,10 @@ pub async fn account_import_remote_sessions(
let _sync_guard = lock_account_sync(generation).await?;
let (acct_session, relay_url) = read_account_context().await?;
+ coordinator
+ .ensure_workspace_runtime_ownership(std::path::Path::new(&workspace_path), None, None)
+ .map_err(|error| error.to_string())?;
+
let storage_path =
desktop_effective_session_storage_path(&app_state, &workspace_path, None, None).await;
@@ -3233,6 +3241,7 @@ pub async fn account_import_remote_sessions(
pub async fn account_fetch_session_turns(
session_id: String,
workspace_path: String,
+ coordinator: State<'_, Arc>,
app_state: State<'_, crate::api::app_state::AppState>,
path_manager: State<'_, Arc>,
) -> Result {
@@ -3244,6 +3253,10 @@ pub async fn account_fetch_session_turns(
return Ok(false);
}
+ coordinator
+ .ensure_workspace_runtime_ownership(std::path::Path::new(&workspace_path), None, None)
+ .map_err(|error| error.to_string())?;
+
let storage_path =
desktop_effective_session_storage_path(&app_state, &workspace_path, None, None).await;
let manager = PersistenceManager::new(path_manager.inner().clone())
@@ -4306,28 +4319,23 @@ async fn import_session_bundle(bundle_json: &str, account_generation: u64) -> an
let path_manager = std::sync::Arc::new(bitfun_core::infrastructure::PathManager::new()?);
let manager = PersistenceManager::new(path_manager.clone())?;
-
- // Find the first workspace sessions dir that exists
- let projects_root = path_manager.projects_root();
- let entries = std::fs::read_dir(&projects_root)?;
- let mut target_dir: Option = None;
- for entry in entries.flatten() {
- if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
- continue;
- }
- let sessions = entry.path().join("sessions");
- if sessions.is_dir() {
- target_dir = Some(sessions);
- break;
- }
+ let workspace = get_global_workspace_service()
+ .ok_or_else(|| anyhow::anyhow!("workspace service is unavailable"))?
+ .get_current_workspace()
+ .await
+ .ok_or_else(|| anyhow::anyhow!("no active workspace is available for session import"))?;
+ if workspace.workspace_kind == WorkspaceKind::Remote {
+ return Err(anyhow::anyhow!(
+ "session import requires an active local workspace"
+ ));
}
-
- // If no workspace sessions dir exists, create one under a "synced" workspace
- let target_dir = target_dir.unwrap_or_else(|| {
- let dir = projects_root.join("synced").join("sessions");
- let _ = std::fs::create_dir_all(&dir);
- dir
- });
+ get_global_coordinator()
+ .ok_or_else(|| anyhow::anyhow!("Agent Runtime coordinator is unavailable"))?
+ .ensure_workspace_runtime_ownership(&workspace.root_path, None, None)
+ .map_err(|error| anyhow::anyhow!(error.to_string()))?;
+ let target_dir = WorkspaceRuntimeService::new(path_manager.clone())
+ .context_for_local_workspace(&workspace.root_path)
+ .sessions_dir;
let mut metadata: SessionMetadata = serde_json::from_value(bundle.metadata.clone())?;
if metadata.session_id != bundle.session_id {
diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs
index 119a4ab489..512b57a564 100644
--- a/src/apps/desktop/src/api/remote_workspace_policy.rs
+++ b/src/apps/desktop/src/api/remote_workspace_policy.rs
@@ -346,7 +346,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
("editor_ai_stream", RemoteWorkspacePolicy::LegacyUnaudited),
(
"ensure_assistant_bootstrap",
- RemoteWorkspacePolicy::LegacyUnaudited,
+ RemoteWorkspacePolicy::RemoteUnsupported,
),
(
"ensure_coordinator_session",
diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs
index c65f7b3ca3..018edc08e1 100644
--- a/src/apps/desktop/src/api/session_api.rs
+++ b/src/apps/desktop/src/api/session_api.rs
@@ -432,7 +432,17 @@ pub async fn save_session_turn(
request: SaveSessionTurnRequest,
app_state: State<'_, AppState>,
path_manager: State<'_, Arc>,
+ runtime: State<'_, DesktopRuntimeContext>,
) -> Result<(), String> {
+ runtime
+ .session_application()
+ .ensure_workspace_runtime_ownership(desktop_session_scope(
+ request.workspace_path.clone(),
+ request.remote_connection_id.clone(),
+ request.remote_ssh_host.clone(),
+ ))
+ .await
+ .map_err(desktop_session_error)?;
let workspace_path = desktop_effective_session_storage_path(
&app_state,
&request.workspace_path,
@@ -674,7 +684,17 @@ pub async fn archive_all_sessions(
request: ArchiveAllSessionsRequest,
app_state: State<'_, AppState>,
path_manager: State<'_, Arc>,
+ runtime: State<'_, DesktopRuntimeContext>,
) -> Result {
+ runtime
+ .session_application()
+ .ensure_workspace_runtime_ownership(desktop_session_scope(
+ request.workspace_path.clone(),
+ request.remote_connection_id.clone(),
+ request.remote_ssh_host.clone(),
+ ))
+ .await
+ .map_err(desktop_session_error)?;
let workspace_path = desktop_effective_session_storage_path(
&app_state,
&request.workspace_path,
@@ -732,7 +752,17 @@ pub async fn delete_all_archived_sessions(
request: DeleteAllArchivedSessionsRequest,
app_state: State<'_, AppState>,
path_manager: State<'_, Arc>,
+ runtime: State<'_, DesktopRuntimeContext>,
) -> Result {
+ runtime
+ .session_application()
+ .ensure_workspace_runtime_ownership(desktop_session_scope(
+ request.workspace_path.clone(),
+ request.remote_connection_id.clone(),
+ request.remote_ssh_host.clone(),
+ ))
+ .await
+ .map_err(desktop_session_error)?;
let workspace_path = desktop_effective_session_storage_path(
&app_state,
&request.workspace_path,
diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs
index dbec2514a4..6cfff7981b 100644
--- a/src/apps/desktop/src/api/snapshot_service.rs
+++ b/src/apps/desktop/src/api/snapshot_service.rs
@@ -4,7 +4,8 @@ use bitfun_core::infrastructure::try_get_path_manager_arc;
use bitfun_core::service::remote_ssh::workspace_state::is_remote_path;
use bitfun_core::service::snapshot::{
ensure_snapshot_manager_for_workspace, get_snapshot_manager_for_workspace,
- initialize_snapshot_manager_for_workspace, OperationType, SnapshotConfig, SnapshotManager,
+ initialize_snapshot_manager_for_workspace, open_snapshot_manager_for_view, OperationType,
+ SnapshotConfig, SnapshotManager,
};
use bitfun_runtime_ports::{
LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest,
@@ -16,13 +17,50 @@ use std::collections::HashSet;
use std::{path::PathBuf, sync::Arc, time::Duration};
use tauri::{AppHandle, Emitter, State};
-use crate::runtime::DesktopRuntimeContext;
+use crate::runtime::{DesktopRuntimeContext, DesktopSessionScopeRequest};
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct SnapshotRemoteScope {
+ #[serde(default, alias = "remoteConnectionId")]
+ pub remote_connection_id: Option,
+ #[serde(default, alias = "remoteSshHost")]
+ pub remote_ssh_host: Option,
+}
+
+impl SnapshotRemoteScope {
+ fn declares_remote(&self) -> bool {
+ self.remote_connection_id
+ .as_deref()
+ .is_some_and(|value| !value.trim().is_empty())
+ || self
+ .remote_ssh_host
+ .as_deref()
+ .is_some_and(|value| !value.trim().is_empty())
+ }
+}
+
+async fn ensure_local_runtime_ownership(
+ runtime: &DesktopRuntimeContext,
+ workspace_path: &str,
+) -> Result<(), String> {
+ runtime
+ .session_application()
+ .ensure_workspace_runtime_ownership(DesktopSessionScopeRequest {
+ workspace_path: workspace_path.to_string(),
+ remote_connection_id: None,
+ remote_ssh_host: None,
+ })
+ .await
+ .map_err(|error| error.to_string())
+}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotInitRequest {
#[serde(alias = "workspacePath")]
pub workspace_path: String,
pub config: Option,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -39,6 +77,8 @@ pub struct RecordFileChangeRequest {
pub tool_name: String,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -50,6 +90,8 @@ pub struct RollbackSessionRequest {
pub delete_session: bool, // Whether to also delete the session (default false)
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -62,6 +104,8 @@ pub struct RollbackTurnRequest {
pub delete_turns: bool,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -70,6 +114,8 @@ pub struct AcceptSessionRequest {
pub session_id: String,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -80,6 +126,8 @@ pub struct AcceptFileRequest {
pub file_path: String,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -88,6 +136,8 @@ pub struct GetSessionFilesRequest {
pub session_id: String,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -96,6 +146,8 @@ pub struct GetSessionTurnsRequest {
pub session_id: String,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -106,6 +158,8 @@ pub struct GetTurnFilesRequest {
pub turn_index: usize,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -119,6 +173,8 @@ pub struct GetFileDiffRequest {
pub operation_id: Option,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -127,6 +183,8 @@ pub struct GetBaselineSnapshotDiffRequest {
pub file_path: String,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -137,6 +195,8 @@ pub struct GetOperationDiffRequest {
pub operationId: Option,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -145,6 +205,8 @@ pub struct GetSessionFileDiffStatsRequest {
pub filePath: String,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -153,6 +215,8 @@ pub struct GetOperationSummaryRequest {
pub operationId: String,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -161,6 +225,8 @@ pub struct GetSessionStatsRequest {
pub session_id: String,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -169,32 +235,40 @@ pub struct GetFileChangeHistoryRequest {
pub file_path: String,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetAllModifiedFilesRequest {
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotWorkspaceRequest {
#[serde(alias = "workspacePath")]
pub workspace_path: String,
+ #[serde(flatten)]
+ pub remote_scope: SnapshotRemoteScope,
}
#[tauri::command]
pub async fn initialize_snapshot(
app_handle: AppHandle,
+ runtime: State<'_, DesktopRuntimeContext>,
request: SnapshotInitRequest,
) -> Result {
// Remote workspaces don't support snapshot system
- if is_remote_path(&request.workspace_path).await {
+ if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await {
return Ok(serde_json::json!({
"success": true,
"message": "Snapshot system skipped for remote workspace"
}));
}
+ ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?;
let workspace_dir = PathBuf::from(&request.workspace_path);
@@ -371,17 +445,43 @@ async fn ensure_snapshot_manager_ready_for(
Ok(manager)
}
-async fn ensure_snapshot_manager_ready(
+async fn ensure_local_snapshot_mutation_path(
workspace_path: &str,
+ remote_scope: &SnapshotRemoteScope,
+) -> Result<(), String> {
+ if remote_scope.declares_remote() || is_remote_path(workspace_path).await {
+ return Err(format!(
+ "Snapshot system not supported for remote workspace: {}",
+ workspace_path
+ ));
+ }
+ Ok(())
+}
+
+async fn snapshot_manager_for_view(
+ workspace_path: &str,
+ remote_scope: &SnapshotRemoteScope,
) -> Result, String> {
- ensure_snapshot_manager_ready_for(workspace_path, "unspecified").await
+ if remote_scope.declares_remote() || is_remote_path(workspace_path).await {
+ return Err(format!(
+ "Snapshot view unavailable (snapshot_remote_workspace_unavailable): remote workspace {} has no local snapshot runtime",
+ workspace_path
+ ));
+ }
+ let workspace_dir = resolve_workspace_dir(workspace_path).await?;
+ open_snapshot_manager_for_view(&workspace_dir)
+ .await
+ .map_err(|error| format!("Failed to open snapshot view: {error}"))
}
#[tauri::command]
pub async fn record_file_change(
app_handle: AppHandle,
+ runtime: State<'_, DesktopRuntimeContext>,
request: RecordFileChangeRequest,
) -> Result {
+ ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?;
+ ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?;
let manager =
ensure_snapshot_manager_ready_for(&request.workspace_path, "record_file_change").await?;
@@ -425,12 +525,12 @@ pub async fn record_file_change(
#[tauri::command]
pub async fn rollback_session(
app_handle: AppHandle,
+ runtime: State<'_, DesktopRuntimeContext>,
request: RollbackSessionRequest,
) -> Result, String> {
// Remote workspaces have no local snapshots — nothing to roll back
- if is_remote_path(&request.workspace_path).await {
- return Ok(vec![]);
- }
+ ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?;
+ ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?;
let manager =
ensure_snapshot_manager_ready_for(&request.workspace_path, "rollback_session").await?;
@@ -464,9 +564,8 @@ pub async fn rollback_to_turn(
request: RollbackTurnRequest,
) -> Result, String> {
// Remote workspaces have no local snapshots — nothing to roll back
- if is_remote_path(&request.workspace_path).await {
- return Ok(vec![]);
- }
+ ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?;
+ ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?;
let workspace_path = resolve_workspace_dir(&request.workspace_path).await?;
{
@@ -625,8 +724,11 @@ pub async fn rollback_to_turn(
#[tauri::command]
pub async fn accept_session(
app_handle: AppHandle,
+ runtime: State<'_, DesktopRuntimeContext>,
request: AcceptSessionRequest,
) -> Result {
+ ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?;
+ ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?;
let manager =
ensure_snapshot_manager_ready_for(&request.workspace_path, "accept_session").await?;
@@ -651,9 +753,12 @@ pub async fn accept_session(
#[tauri::command]
pub async fn accept_file(
app_handle: AppHandle,
+ runtime: State<'_, DesktopRuntimeContext>,
request: AcceptFileRequest,
) -> Result {
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?;
+ ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?;
+ let manager = ensure_snapshot_manager_ready_for(&request.workspace_path, "accept_file").await?;
manager
.accept_file(&request.session_id, &request.file_path)
@@ -677,9 +782,12 @@ pub async fn accept_file(
#[tauri::command]
pub async fn reject_file(
app_handle: AppHandle,
+ runtime: State<'_, DesktopRuntimeContext>,
request: AcceptFileRequest,
) -> Result {
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?;
+ ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?;
+ let manager = ensure_snapshot_manager_ready_for(&request.workspace_path, "reject_file").await?;
let restored_files = manager
.reject_file(&request.session_id, &request.file_path)
@@ -711,7 +819,7 @@ pub async fn get_session_files(
runtime: State<'_, DesktopRuntimeContext>,
request: GetSessionFilesRequest,
) -> Result, String> {
- if is_remote_path(&request.workspace_path).await {
+ if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await {
return Ok(vec![]);
}
let workspace_path = resolve_workspace_dir(&request.workspace_path).await?;
@@ -732,6 +840,10 @@ pub async fn get_session_turns(
) -> Result, String> {
use bitfun_core::agentic::persistence::PersistenceManager;
+ if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await {
+ return Ok(vec![]);
+ }
+
let workspace_path = PathBuf::from(&request.workspace_path);
if let Ok(path_manager) = try_get_path_manager_arc() {
match PersistenceManager::new(path_manager) {
@@ -762,7 +874,7 @@ pub async fn get_session_turns(
}
}
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?;
let turns = manager
.get_session_turns(&request.session_id)
@@ -774,7 +886,10 @@ pub async fn get_session_turns(
#[tauri::command]
pub async fn get_turn_files(request: GetTurnFilesRequest) -> Result, String> {
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await {
+ return Ok(vec![]);
+ }
+ let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?;
let files = manager
.get_turn_files(&request.session_id, request.turn_index)
@@ -789,7 +904,7 @@ pub async fn get_turn_files(request: GetTurnFilesRequest) -> Result,
#[tauri::command]
pub async fn get_file_diff(request: GetFileDiffRequest) -> Result {
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?;
let diff = manager
.get_file_diff(
@@ -807,7 +922,7 @@ pub async fn get_file_diff(request: GetFileDiffRequest) -> Result Result {
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?;
let diff = manager
.get_file_diff(
@@ -849,9 +964,7 @@ pub async fn get_operation_diff(
pub async fn get_session_file_diff_stats(
request: GetSessionFileDiffStatsRequest,
) -> Result {
- let manager =
- ensure_snapshot_manager_ready_for(&request.workspace_path, "get_session_file_diff_stats")
- .await?;
+ let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?;
let stats = manager
.get_session_file_diff_stats(&request.sessionId, &request.filePath)
@@ -865,8 +978,7 @@ pub async fn get_session_file_diff_stats(
pub async fn get_operation_summary(
request: GetOperationSummaryRequest,
) -> Result {
- let manager =
- ensure_snapshot_manager_ready_for(&request.workspace_path, "get_operation_summary").await?;
+ let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?;
let summary = manager
.get_operation_summary(&request.sessionId, &request.operationId)
@@ -890,7 +1002,10 @@ pub async fn get_operation_summary(
pub async fn get_session_operations(
request: GetSessionFilesRequest,
) -> Result {
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await {
+ return Ok(serde_json::Value::Array(Vec::new()));
+ }
+ let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?;
let session = manager
.get_session(&request.session_id)
@@ -933,9 +1048,13 @@ pub async fn get_session_operations(
#[tauri::command]
pub async fn accept_operation(
app_handle: AppHandle,
+ runtime: State<'_, DesktopRuntimeContext>,
request: GetOperationSummaryRequest,
) -> Result {
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?;
+ ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?;
+ let manager =
+ ensure_snapshot_manager_ready_for(&request.workspace_path, "accept_operation").await?;
let summary = manager
.get_operation_summary(&request.sessionId, &request.operationId)
@@ -969,9 +1088,13 @@ pub async fn accept_operation(
#[tauri::command]
pub async fn reject_operation(
app_handle: AppHandle,
+ runtime: State<'_, DesktopRuntimeContext>,
request: GetOperationSummaryRequest,
) -> Result {
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?;
+ ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?;
+ let manager =
+ ensure_snapshot_manager_ready_for(&request.workspace_path, "reject_operation").await?;
let summary = manager
.get_operation_summary(&request.sessionId, &request.operationId)
@@ -1013,7 +1136,7 @@ pub async fn get_session_stats(
runtime: State<'_, DesktopRuntimeContext>,
request: GetSessionStatsRequest,
) -> Result {
- if is_remote_path(&request.workspace_path).await {
+ if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await {
return Ok(serde_json::json!({
"session_id": request.session_id,
"total_files": 0,
@@ -1036,7 +1159,7 @@ pub async fn get_session_stats(
pub async fn get_snapshot_system_stats(
request: SnapshotWorkspaceRequest,
) -> Result {
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?;
let stats = manager
.get_system_stats()
@@ -1050,7 +1173,10 @@ pub async fn get_snapshot_system_stats(
pub async fn get_snapshot_sessions(
request: SnapshotWorkspaceRequest,
) -> Result, String> {
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await {
+ return Ok(vec![]);
+ }
+ let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?;
manager
.list_sessions()
@@ -1062,7 +1188,7 @@ pub async fn get_snapshot_sessions(
pub async fn check_git_isolation(
request: SnapshotWorkspaceRequest,
) -> Result {
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?;
let is_isolated = manager
.check_git_isolation()
@@ -1079,7 +1205,10 @@ pub async fn check_git_isolation(
pub async fn get_file_change_history(
request: GetFileChangeHistoryRequest,
) -> Result {
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await {
+ return Ok(serde_json::Value::Array(Vec::new()));
+ }
+ let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?;
let file_path = PathBuf::from(&request.file_path);
let changes = manager
@@ -1094,7 +1223,10 @@ pub async fn get_file_change_history(
pub async fn get_all_modified_files(
request: GetAllModifiedFilesRequest,
) -> Result, String> {
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await {
+ return Ok(vec![]);
+ }
+ let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?;
let files = manager
.get_all_modified_files()
@@ -1111,7 +1243,7 @@ pub async fn get_all_modified_files(
pub async fn get_baseline_snapshot_diff(
request: GetBaselineSnapshotDiffRequest,
) -> Result {
- let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;
+ let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?;
let file_path = PathBuf::from(&request.file_path);
@@ -1153,10 +1285,143 @@ mod tests {
};
use super::{
+ ensure_local_snapshot_mutation_path, get_snapshot_manager_for_workspace,
local_snapshot_command_error, local_snapshot_session_files, local_snapshot_session_stats,
- rollback_local_workspace_files,
+ rollback_local_workspace_files, snapshot_manager_for_view, RollbackTurnRequest,
+ SnapshotRemoteScope,
};
+ #[test]
+ fn snapshot_mutation_dto_preserves_structured_remote_facts() {
+ let request: RollbackTurnRequest = serde_json::from_value(serde_json::json!({
+ "sessionId": "remote-session",
+ "turnIndex": 2,
+ "workspacePath": "/srv/project",
+ "remoteConnectionId": "ssh:user@example.com:22",
+ "remoteSshHost": "example.com"
+ }))
+ .expect("deserialize snapshot mutation scope");
+
+ assert_eq!(
+ request.remote_scope.remote_connection_id.as_deref(),
+ Some("ssh:user@example.com:22")
+ );
+ assert_eq!(
+ request.remote_scope.remote_ssh_host.as_deref(),
+ Some("example.com")
+ );
+ }
+
+ #[tokio::test]
+ async fn remote_snapshot_mutation_is_rejected_before_writer_initialization() {
+ let workspace = tempfile::tempdir().expect("create workspace");
+ let workspace_path = workspace.path().to_string_lossy().to_string();
+ let remote =
+ bitfun_core::service::remote_ssh::workspace_state::init_remote_workspace_manager();
+ remote
+ .register_remote_workspace(
+ workspace_path.clone(),
+ "snapshot-remote-test".to_string(),
+ "Snapshot remote test".to_string(),
+ "snapshot-test-host".to_string(),
+ )
+ .await;
+
+ let error = ensure_local_snapshot_mutation_path(&workspace_path, &Default::default())
+ .await
+ .expect_err("remote mutation must fail closed");
+
+ assert!(error.contains("not supported for remote workspace"));
+ assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none());
+ assert_eq!(
+ std::fs::read_dir(workspace.path())
+ .expect("workspace remains readable")
+ .count(),
+ 0
+ );
+ remote
+ .unregister_remote_workspace("snapshot-remote-test", &workspace_path)
+ .await;
+
+ let disconnected_scope = SnapshotRemoteScope {
+ remote_connection_id: Some("snapshot-test-connection".to_string()),
+ remote_ssh_host: Some("snapshot-test-host".to_string()),
+ };
+ let disconnected_error =
+ ensure_local_snapshot_mutation_path(&workspace_path, &disconnected_scope)
+ .await
+ .expect_err("structured session facts remain remote after registry removal");
+ assert!(disconnected_error.contains("not supported for remote workspace"));
+ assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none());
+ }
+
+ #[test]
+ fn rollback_commands_reject_remote_workspaces_before_local_side_effects() {
+ let source = include_str!("snapshot_service.rs");
+ let rollback_session = source
+ .split_once("pub async fn rollback_session")
+ .expect("rollback_session remains present")
+ .1
+ .split_once("pub async fn rollback_to_turn")
+ .expect("rollback_to_turn remains present")
+ .0;
+ let rollback_to_turn = source
+ .split_once("pub async fn rollback_to_turn")
+ .expect("rollback_to_turn remains present")
+ .1
+ .split_once("pub async fn accept_session")
+ .expect("accept_session remains present")
+ .0;
+
+ let assert_remote_guard_precedes = |body: &str, side_effect: &str| {
+ let guard = body
+ .find("ensure_local_snapshot_mutation_path")
+ .expect("remote mutation guard remains present");
+ let effect = body
+ .find(side_effect)
+ .unwrap_or_else(|| panic!("expected side effect remains present: {side_effect}"));
+ assert!(guard < effect, "remote guard must precede {side_effect}");
+ };
+
+ assert_remote_guard_precedes(rollback_session, "ensure_local_runtime_ownership");
+ assert_remote_guard_precedes(rollback_session, "ensure_snapshot_manager_ready_for");
+ assert_remote_guard_precedes(rollback_to_turn, "ensure_local_runtime_ownership");
+ assert_remote_guard_precedes(rollback_to_turn, "cancel_active_turn_for_session");
+ }
+
+ #[tokio::test]
+ async fn snapshot_view_does_not_initialize_a_writer() {
+ let workspace = tempfile::tempdir().expect("create workspace");
+ assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none());
+
+ snapshot_manager_for_view(
+ &workspace.path().to_string_lossy(),
+ &SnapshotRemoteScope::default(),
+ )
+ .await
+ .expect("an empty read-only view remains available");
+ assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none());
+ }
+
+ #[tokio::test]
+ async fn snapshot_view_rejects_structured_remote_scope_after_registry_disconnect() {
+ let workspace = tempfile::tempdir().expect("create colliding local workspace");
+ let scope = SnapshotRemoteScope {
+ remote_connection_id: Some("connection-1".to_string()),
+ remote_ssh_host: Some("host-1".to_string()),
+ };
+
+ let error = match snapshot_manager_for_view(&workspace.path().to_string_lossy(), &scope)
+ .await
+ {
+ Ok(_) => panic!("structured remote scope must not read the colliding local Snapshot"),
+ Err(error) => error,
+ };
+
+ assert!(error.contains("snapshot_remote_workspace_unavailable"));
+ assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none());
+ }
+
#[derive(Default)]
struct RecordingSnapshotPort {
file_calls: AtomicUsize,
diff --git a/src/apps/desktop/src/api/workspace_activation.rs b/src/apps/desktop/src/api/workspace_activation.rs
index 3d946361d9..1fcff53acd 100644
--- a/src/apps/desktop/src/api/workspace_activation.rs
+++ b/src/apps/desktop/src/api/workspace_activation.rs
@@ -1,5 +1,4 @@
use crate::api::app_state::AppState;
-use bitfun_core::service::remote_ssh::workspace_state::is_remote_path;
use bitfun_core::service::search::workspace_search_runtime_available;
use bitfun_core::service::workspace::{WorkspaceInfo, WorkspaceKind};
use log::{debug, info, warn};
@@ -32,32 +31,6 @@ async fn warm_workspace_background_services(
) {
let started_at = Instant::now();
let target_path = workspace_info.root_path.clone();
- let root_str = target_path.to_string_lossy().to_string();
- let skip_local_snapshot = workspace_info.workspace_kind == WorkspaceKind::Remote
- || is_remote_path(root_str.trim()).await;
-
- if !skip_local_snapshot && is_workspace_active(&workspace_path, &target_path).await {
- let snapshot_started_at = Instant::now();
- if let Err(error) =
- bitfun_core::service::snapshot::initialize_snapshot_manager_for_workspace(
- target_path.clone(),
- None,
- )
- .await
- {
- warn!(
- "Failed to initialize snapshot system during workspace warmup: path={}, error={}",
- target_path.display(),
- error
- );
- } else {
- debug!(
- "Workspace snapshot warmup completed: path={}, elapsed_ms={}",
- target_path.display(),
- snapshot_started_at.elapsed().as_millis()
- );
- }
- }
if is_workspace_active(&workspace_path, &target_path).await {
let subagents_started_at = Instant::now();
diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs
index c0a34328ec..6e242f3ae5 100644
--- a/src/apps/desktop/src/lib.rs
+++ b/src/apps/desktop/src/lib.rs
@@ -1849,12 +1849,19 @@ async fn init_agentic_system() -> anyhow::Result<(
exec_config,
));
+ let runtime_ownership = Arc::new(
+ bitfun_core::runtime_ownership::CoreRuntimeOwnership::embedded(
+ path_manager.as_ref(),
+ "desktop",
+ ),
+ );
let coordinator = Arc::new(coordination::ConversationCoordinator::new(
session_manager.clone(),
execution_engine,
tool_pipeline,
event_queue.clone(),
event_router.clone(),
+ runtime_ownership,
));
coordinator.set_terminal_port(
bitfun_core::product_runtime::CoreRuntimeServicesProvider::terminal_port(),
diff --git a/src/apps/desktop/src/runtime/mod.rs b/src/apps/desktop/src/runtime/mod.rs
index 0f7f3e0c37..f5e8899dfb 100644
--- a/src/apps/desktop/src/runtime/mod.rs
+++ b/src/apps/desktop/src/runtime/mod.rs
@@ -178,13 +178,13 @@ mod tests {
3,
"only file listing, typed stats, and workspace rollback use the local owner port"
);
- assert!(snapshot_commands.contains("is_remote_path(&request.workspace_path).await"));
+ assert!(snapshot_commands.contains("ensure_local_snapshot_mutation_path"));
let rollback_source = &snapshot_commands[snapshot_commands
.find("pub async fn rollback_to_turn")
.expect("rollback command must exist")..];
let remote_guard = rollback_source
- .find("if is_remote_path(&request.workspace_path).await")
+ .find("ensure_local_snapshot_mutation_path")
.expect("remote rollback guard must remain host-owned");
let cancellation = rollback_source
.find("cancel_active_turn_for_session")
@@ -225,4 +225,140 @@ mod tests {
assert!(!runtime_source.contains(&runtime_services));
assert!(!runtime_source.contains(&desktop_services_provider));
}
+
+ #[test]
+ fn desktop_session_writes_reuse_the_coordinator_ownership_owner() {
+ let application = include_str!("session_application.rs");
+ let app_entrypoint = include_str!("../lib.rs");
+ let agentic_api = include_str!("../api/agentic_api.rs");
+ let remote_connect_api = include_str!("../api/remote_connect_api.rs");
+ let snapshot_api = include_str!("../api/snapshot_service.rs");
+ let workspace_activation = include_str!("../api/workspace_activation.rs");
+
+ assert!(
+ !workspace_activation.contains("initialize_snapshot_manager_for_workspace"),
+ "read-only workspace activation must not attach the snapshot Runtime"
+ );
+
+ assert!(
+ app_entrypoint.contains("CoreRuntimeOwnership::embedded"),
+ "Desktop composition must inject one lazy multi-workspace Core owner"
+ );
+ assert!(
+ application
+ .matches(".ensure_workspace_runtime_ownership(")
+ .count()
+ == 1,
+ "Desktop application must delegate ownership to one Coordinator gate"
+ );
+ assert!(
+ application
+ .matches("self.ensure_runtime_ownership(&scope)")
+ .count()
+ >= 6,
+ "Desktop attach and mutation paths must reuse one application helper"
+ );
+ assert!(
+ !application.contains("RuntimeOwnershipKey")
+ && !application.contains("WorkspaceRuntimeOwnership"),
+ "Desktop application must not duplicate ownership primitives"
+ );
+
+ let create_session = agentic_api
+ .split_once("pub async fn create_session")
+ .expect("create_session")
+ .1
+ .split_once("pub async fn update_session_model")
+ .expect("create_session boundary")
+ .0;
+ assert!(
+ create_session.contains("session_application()")
+ && create_session.contains("ensure_workspace_runtime_ownership"),
+ "Desktop session creation must validate remote facts through the shared application scope resolver"
+ );
+
+ let view = application
+ .split_once("pub(crate) async fn restore_session_view")
+ .expect("view restore")
+ .1
+ .split_once("pub(crate) async fn restore_session_with_turns")
+ .expect("view restore boundary")
+ .0;
+ assert!(
+ !view.contains("ensure_workspace_runtime_ownership"),
+ "read-only view restore must remain available without acquiring runtime ownership"
+ );
+
+ for (mutation, end) in [
+ ("if is_idempotent_review_create", "let config = request"),
+ (
+ "pub async fn set_session_memory_mode",
+ "pub async fn clear_session_thread_goal",
+ ),
+ ] {
+ let source = agentic_api
+ .split_once(mutation)
+ .unwrap_or_else(|| panic!("missing Desktop mutation: {mutation}"))
+ .1
+ .split_once(end)
+ .unwrap_or_else(|| panic!("missing Desktop mutation boundary: {end}"))
+ .0;
+ assert!(
+ source.contains("ensure_workspace_runtime_ownership")
+ || source.contains("ensure_session_runtime_ownership"),
+ "Desktop mutation {mutation} must pass through the Core ownership owner"
+ );
+ }
+
+ for (mutation, end) in [
+ (
+ "pub async fn account_import_remote_sessions",
+ "pub async fn account_fetch_session_turns",
+ ),
+ (
+ "pub async fn account_fetch_session_turns",
+ "pub async fn account_execute_on_device",
+ ),
+ (
+ "async fn import_session_bundle",
+ "async fn pull_and_reconcile",
+ ),
+ ] {
+ let source = remote_connect_api
+ .split_once(mutation)
+ .unwrap_or_else(|| panic!("missing relay mutation: {mutation}"))
+ .1
+ .split_once(end)
+ .unwrap_or_else(|| panic!("missing relay mutation boundary: {end}"))
+ .0;
+ assert!(
+ source.contains("ensure_workspace_runtime_ownership"),
+ "relay mutation {mutation} must pass through the Core ownership owner"
+ );
+ }
+
+ for mutation in [
+ "pub async fn initialize_snapshot",
+ "pub async fn record_file_change",
+ "pub async fn rollback_session",
+ "pub async fn rollback_to_turn",
+ "pub async fn accept_session",
+ "pub async fn accept_file",
+ "pub async fn reject_file",
+ "pub async fn accept_operation",
+ "pub async fn reject_operation",
+ ] {
+ let source = snapshot_api
+ .split_once(mutation)
+ .unwrap_or_else(|| panic!("missing snapshot mutation: {mutation}"))
+ .1
+ .split_once("#[tauri::command]")
+ .unwrap_or_else(|| panic!("missing snapshot mutation boundary: {mutation}"))
+ .0;
+ assert!(
+ source.contains("ensure_local_runtime_ownership"),
+ "snapshot mutation {mutation} must acquire ownership before side effects"
+ );
+ }
+ }
}
diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs
index fdf64317bb..18298f7199 100644
--- a/src/apps/desktop/src/runtime/session_application.rs
+++ b/src/apps/desktop/src/runtime/session_application.rs
@@ -4,7 +4,7 @@
//! Rich Desktop persistence views remain on Core's compatibility facade while
//! stable lifecycle operations use the Agent Runtime SDK.
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
@@ -98,6 +98,7 @@ struct ResolvedDesktopSessionScope {
remote_connection_id: Option,
requested_remote_ssh_host: Option,
resolved_remote_ssh_host: Option,
+ remote_binding_verified: bool,
}
#[derive(Clone)]
@@ -110,15 +111,22 @@ impl DesktopSessionScopeResolver {
async fn resolve(&self, request: DesktopSessionScopeRequest) -> ResolvedDesktopSessionScope {
let remote_connection_id = normalized_optional(request.remote_connection_id.as_deref());
let requested_remote_ssh_host = normalized_optional(request.remote_ssh_host.as_deref());
- let mut registered_remote_ssh_host = None;
- if requested_remote_ssh_host.is_none() {
+ let registered_remote_ssh_host =
if let Some(connection_id) = remote_connection_id.as_deref() {
- registered_remote_ssh_host = self
- .workspace_service
+ self.workspace_service
.remote_ssh_host_for_remote_workspace(connection_id, &request.workspace_path)
- .await;
- }
- }
+ .await
+ } else {
+ None
+ };
+ let remote_binding_verified = remote_connection_id.is_some()
+ && registered_remote_ssh_host
+ .as_deref()
+ .is_some_and(|registered| {
+ requested_remote_ssh_host
+ .as_deref()
+ .map_or(true, |requested| requested.eq_ignore_ascii_case(registered))
+ });
let mut saved_remote_ssh_host = None;
if requested_remote_ssh_host.is_none() && registered_remote_ssh_host.is_none() {
if let Some(connection_id) = remote_connection_id.as_deref() {
@@ -148,6 +156,7 @@ impl DesktopSessionScopeResolver {
remote_connection_id,
requested_remote_ssh_host,
resolved_remote_ssh_host,
+ remote_binding_verified,
}
}
}
@@ -178,6 +187,7 @@ pub(crate) trait DesktopSessionHostEffects: Send + Sync {
#[derive(Clone)]
pub(crate) struct DesktopSessionApplication {
+ coordinator: Arc,
agent_runtime: AgentRuntime,
compatibility: CoreAgentRuntimeCompatibility,
scope_resolver: DesktopSessionScopeResolver,
@@ -198,9 +208,10 @@ impl DesktopSessionApplication {
scheduler.clone(),
token_usage_service,
)?;
- let compatibility = CoreAgentRuntimeCompatibility::build(coordinator, scheduler);
+ let compatibility = CoreAgentRuntimeCompatibility::build(coordinator.clone(), scheduler);
Ok(Self {
+ coordinator,
agent_runtime,
compatibility,
scope_resolver: DesktopSessionScopeResolver {
@@ -226,6 +237,38 @@ impl DesktopSessionApplication {
scope.effective_storage_path.clone()
}
+ fn ensure_runtime_ownership(
+ &self,
+ scope: &ResolvedDesktopSessionScope,
+ ) -> DesktopSessionApplicationResult<()> {
+ let result = if scope.remote_binding_verified {
+ self.coordinator
+ .ensure_verified_remote_workspace_runtime_ownership(
+ Path::new(&scope.workspace_path),
+ scope
+ .remote_connection_id
+ .as_deref()
+ .expect("verified Remote scope has a connection id"),
+ scope.resolved_remote_ssh_host.as_deref(),
+ )
+ } else {
+ self.coordinator.ensure_workspace_runtime_ownership(
+ Path::new(&scope.workspace_path),
+ scope.remote_connection_id.as_deref(),
+ scope.resolved_remote_ssh_host.as_deref(),
+ )
+ };
+ result.map_err(|error| DesktopSessionApplicationError::Core(error.to_string()))
+ }
+
+ pub(crate) async fn ensure_workspace_runtime_ownership(
+ &self,
+ request: DesktopSessionScopeRequest,
+ ) -> DesktopSessionApplicationResult<()> {
+ let scope = self.resolved_scope(request).await;
+ self.ensure_runtime_ownership(&scope)
+ }
+
pub(crate) async fn list_persisted_sessions(
&self,
request: DesktopSessionScopeRequest,
@@ -296,6 +339,7 @@ impl DesktopSessionApplication {
session_id: &str,
) -> DesktopSessionApplicationResult<()> {
let scope = self.resolved_scope(request).await;
+ self.ensure_runtime_ownership(&scope)?;
let storage_path = self.storage_path(&scope);
self.compatibility
.touch_persisted_session(&storage_path, session_id)
@@ -316,6 +360,7 @@ impl DesktopSessionApplication {
}
let workspace_path = request.workspace_path.clone();
let scope = self.resolved_scope(request).await;
+ self.ensure_runtime_ownership(&scope)?;
let storage_path = self.storage_path(&scope);
let session_id = incoming.session_id.clone();
self.compatibility
@@ -361,10 +406,11 @@ impl DesktopSessionApplication {
source_turn_id: String,
) -> DesktopSessionApplicationResult {
let scope = self.resolved_scope(request).await;
+ self.ensure_runtime_ownership(&scope)?;
let result = self
.agent_runtime
.fork_session_at_turn(AgentSessionForkAtTurnRequest {
- workspace_path: scope.effective_storage_path.to_string_lossy().into_owned(),
+ workspace_path: scope.workspace_path.clone(),
source_session_id,
source_turn_id,
remote_connection_id: scope.remote_connection_id,
@@ -386,9 +432,10 @@ impl DesktopSessionApplication {
archived: bool,
) -> DesktopSessionApplicationResult<()> {
let scope = self.resolved_scope(request).await;
+ self.ensure_runtime_ownership(&scope)?;
self.agent_runtime
.set_session_archived(AgentSessionArchiveStateRequest {
- workspace_path: scope.effective_storage_path.to_string_lossy().into_owned(),
+ workspace_path: scope.workspace_path.clone(),
session_id,
archived,
remote_connection_id: scope.remote_connection_id,
@@ -404,6 +451,7 @@ impl DesktopSessionApplication {
session_id: String,
) -> DesktopSessionApplicationResult<()> {
let scope = self.resolved_scope(request).await;
+ self.ensure_runtime_ownership(&scope)?;
delete_session_with_host_effects(
&self.agent_runtime,
self.host_effects.as_ref(),
@@ -422,6 +470,7 @@ impl DesktopSessionApplication {
let normalized_title = title.trim().to_string();
if let Some(request) = request {
let scope = self.resolved_scope(request).await;
+ self.ensure_runtime_ownership(&scope)?;
if !self
.compatibility
.is_session_loaded_in_memory(&session_id)
@@ -437,7 +486,7 @@ impl DesktopSessionApplication {
}
self.agent_runtime
.rename_session(AgentSessionRenameRequest {
- workspace_path: scope.effective_storage_path.to_string_lossy().into_owned(),
+ workspace_path: scope.workspace_path.clone(),
session_id: session_id.clone(),
session_name: title,
remote_connection_id: scope.remote_connection_id,
@@ -487,6 +536,7 @@ impl DesktopSessionApplication {
));
}
let scope = self.resolved_scope(request).await;
+ self.ensure_runtime_ownership(&scope)?;
let storage_path = self.storage_path(&scope);
self.compatibility
.ensure_session_loaded_from_storage_path(&storage_path, session_id, include_internal)
@@ -501,6 +551,7 @@ impl DesktopSessionApplication {
include_internal: bool,
) -> DesktopSessionApplicationResult {
let scope = self.resolved_scope(request).await;
+ self.ensure_runtime_ownership(&scope)?;
let storage_path = self.storage_path(&scope);
self.compatibility
.restore_session_from_storage_path(&storage_path, session_id, include_internal)
@@ -561,6 +612,7 @@ impl DesktopSessionApplication {
{
let path_started_at = Instant::now();
let scope = self.resolved_scope(request).await;
+ self.ensure_runtime_ownership(&scope)?;
let storage_path = self.storage_path(&scope);
let resolve_storage_path_duration_ms =
path_started_at.elapsed().as_millis().min(u64::MAX as u128) as u64;
@@ -587,7 +639,7 @@ async fn delete_session_with_host_effects(
host_effects.release_session(&session_id).await;
agent_runtime
.delete_session(AgentSessionDeleteRequest {
- workspace_path: scope.effective_storage_path.to_string_lossy().into_owned(),
+ workspace_path: scope.workspace_path.clone(),
session_id: session_id.clone(),
remote_connection_id: scope.remote_connection_id,
remote_ssh_host: scope.resolved_remote_ssh_host,
@@ -657,6 +709,7 @@ mod tests {
struct RecordingDeletePort {
events: Arc>>,
+ workspace_path: Arc>>,
fail_delete: bool,
}
@@ -668,11 +721,11 @@ mod tests {
&self,
request: AgentSessionCreateRequest,
) -> PortResult {
- Ok(AgentSessionCreateResult {
- session_id: "unused".to_string(),
- session_name: request.session_name,
- agent_type: request.agent_type,
- })
+ Ok(AgentSessionCreateResult::new(
+ "unused",
+ request.session_name,
+ request.agent_type,
+ ))
}
async fn submit_message(
@@ -702,8 +755,9 @@ mod tests {
Ok(Vec::new())
}
- async fn delete_session(&self, _request: AgentSessionDeleteRequest) -> PortResult<()> {
+ async fn delete_session(&self, request: AgentSessionDeleteRequest) -> PortResult<()> {
self.events.lock().unwrap().push("durable_delete");
+ *self.workspace_path.lock().unwrap() = Some(request.workspace_path);
if self.fail_delete {
return Err(PortError::new(PortErrorKind::Backend, "delete failed"));
}
@@ -742,17 +796,20 @@ mod tests {
remote_connection_id: None,
requested_remote_ssh_host: None,
resolved_remote_ssh_host: None,
+ remote_binding_verified: false,
}
}
fn delete_test_runtime(
events: Arc>>,
+ workspace_path: Arc>>,
fail_delete: bool,
) -> AgentRuntime {
AgentRuntimeBuilder::new()
.with_submission_port(Arc::new(NoopSubmissionPort))
.with_session_management_port(Arc::new(RecordingDeletePort {
events,
+ workspace_path,
fail_delete,
}))
.build()
@@ -901,7 +958,8 @@ mod tests {
#[tokio::test]
async fn delete_orders_host_release_durable_delete_and_relay_tombstone() {
let events = Arc::new(Mutex::new(Vec::new()));
- let runtime = delete_test_runtime(events.clone(), false);
+ let workspace_path = Arc::new(Mutex::new(None));
+ let runtime = delete_test_runtime(events.clone(), workspace_path.clone(), false);
let host_effects = RecordingHostEffects {
events: events.clone(),
};
@@ -919,12 +977,16 @@ mod tests {
events.lock().unwrap().as_slice(),
["release", "durable_delete", "relay_delete"]
);
+ assert_eq!(
+ workspace_path.lock().unwrap().as_deref(),
+ Some("D:/workspace/project")
+ );
}
#[tokio::test]
async fn delete_failure_does_not_publish_relay_tombstone() {
let events = Arc::new(Mutex::new(Vec::new()));
- let runtime = delete_test_runtime(events.clone(), true);
+ let runtime = delete_test_runtime(events.clone(), Arc::new(Mutex::new(None)), true);
let host_effects = RecordingHostEffects {
events: events.clone(),
};
diff --git a/src/apps/sdk-host/src/runtime.rs b/src/apps/sdk-host/src/runtime.rs
index 9a38ad5171..f9da5654ac 100644
--- a/src/apps/sdk-host/src/runtime.rs
+++ b/src/apps/sdk-host/src/runtime.rs
@@ -8,6 +8,8 @@ use bitfun_core::product_runtime::{
build_local_runtime_services, ensure_product_dialog_scheduler, CoreProductAgentRuntime,
CoreProductEventQueueOwner, CoreRuntimeServicesProvider,
};
+use bitfun_core::runtime_ownership::{CoreRuntimeOwnership, RuntimeDeployment};
+use std::sync::Arc;
const RUNTIME_EVENT_BUFFER: usize = 256;
const DELIVERY_PROFILE: DeliveryProfile = DeliveryProfile::Sdk;
@@ -27,16 +29,28 @@ impl SdkHostRuntime {
pub(crate) async fn build(workspace_root: impl AsRef) -> Result {
let (workspace_root, services) =
build_local_runtime_services(workspace_root, RUNTIME_EVENT_BUFFER)?;
+ let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc()
+ .map_err(|error| anyhow::anyhow!(error.to_string()))?;
+ let deployment = RuntimeDeployment::Embedded;
+ let runtime_ownership = CoreRuntimeOwnership::fixed_workspace(
+ path_manager.as_ref(),
+ "sdk-host",
+ &workspace_root,
+ deployment,
+ )
+ .map_err(|error| anyhow::anyhow!(error.startup_message(deployment, "sdk-host")))?;
- // The SDK Host keeps its own product identity. The SDK and CLI profiles
- // currently select the same assembly-plan ceiling from shared facts.
- // The Host's effective wire capability set remains a strict subset.
+ // SDK Host keeps its own delivery profile while sharing the product-wide
+ // workspace ownership identity with every first-party entrypoint.
let parts = ProductAssembler::new()
.assemble(ProductAssemblyInput::new(DELIVERY_PROFILE, services))
.context("Failed to assemble SDK Host product runtime")?;
- let agentic_system = system::init_agentic_system_for_profile(parts.plan().profile())
- .await
- .context("Failed to initialize agentic system")?;
+ let agentic_system = system::init_agentic_system_for_profile_with_runtime_ownership(
+ parts.plan().profile(),
+ Arc::new(runtime_ownership),
+ )
+ .await
+ .context("Failed to initialize agentic system")?;
bind_core_execution_ports(&agentic_system);
let scheduler = ensure_product_dialog_scheduler(&agentic_system);
let (services, harness_registry, _disabled_plugin_runtime) = parts.into_runtime_parts();
diff --git a/src/apps/sdk-host/tests/process_initialization.rs b/src/apps/sdk-host/tests/process_initialization.rs
index 2cc267da9e..8a5300f7d3 100644
--- a/src/apps/sdk-host/tests/process_initialization.rs
+++ b/src/apps/sdk-host/tests/process_initialization.rs
@@ -28,3 +28,18 @@ fn sdk_host_process_keeps_cleanup_warnings_on_stderr() {
assert!(entrypoint.contains(".with_max_level(tracing::Level::WARN)"));
assert!(entrypoint.contains(".with_writer(std::io::stderr)"));
}
+
+#[test]
+fn sdk_host_injects_core_ownership_before_runtime_initialization() {
+ let runtime = include_str!("../src/runtime.rs");
+ let ownership = runtime
+ .find("CoreRuntimeOwnership::fixed_workspace")
+ .expect("SDK Host Core ownership assembly");
+ let initialize = runtime
+ .find("init_agentic_system_for_profile_with_runtime_ownership")
+ .expect("SDK Host ownership-aware AgenticSystem initialization");
+
+ assert!(ownership < initialize);
+ assert!(runtime.contains("RuntimeDeployment::Embedded"));
+ assert!(!runtime.contains("WorkspaceRuntimeOwnership"));
+}
diff --git a/src/apps/sdk-host/tests/stdio_transport.rs b/src/apps/sdk-host/tests/stdio_transport.rs
index f072c107f5..b30a8c5625 100644
--- a/src/apps/sdk-host/tests/stdio_transport.rs
+++ b/src/apps/sdk-host/tests/stdio_transport.rs
@@ -16,6 +16,19 @@ use tokio::time::{timeout, Duration};
struct MinimalOwner;
+fn created_session_result(
+ session_id: impl Into,
+ request: AgentSessionCreateRequest,
+) -> AgentSessionCreateResult {
+ let mut result =
+ AgentSessionCreateResult::new(session_id, request.session_name, request.agent_type);
+ result.workspace_path = request.workspace_path;
+ result.workspace_id = Some("workspace-fixture".to_string());
+ result.project_workspace_path = request.project_workspace_path;
+ result.execution_target = request.execution_target;
+ result
+}
+
struct BlockingCreateOwner {
calls: AtomicUsize,
deleted: AtomicUsize,
@@ -38,11 +51,7 @@ impl AgentSubmissionPort for MinimalOwner {
&self,
request: AgentSessionCreateRequest,
) -> PortResult {
- Ok(AgentSessionCreateResult {
- session_id: "unused".to_string(),
- session_name: request.session_name,
- agent_type: request.agent_type,
- })
+ Ok(created_session_result("unused", request))
}
async fn create_session_with_id(
@@ -50,11 +59,7 @@ impl AgentSubmissionPort for MinimalOwner {
session_id: String,
request: AgentSessionCreateRequest,
) -> PortResult {
- Ok(AgentSessionCreateResult {
- session_id,
- session_name: request.session_name,
- agent_type: request.agent_type,
- })
+ Ok(created_session_result(session_id, request))
}
async fn create_transient_session_with_id(
@@ -99,11 +104,7 @@ impl AgentSubmissionPort for BlockingCreateOwner {
if self.calls.fetch_add(1, Ordering::AcqRel) == 0 {
self.release.notified().await;
}
- Ok(AgentSessionCreateResult {
- session_id: "session-blocking".to_string(),
- session_name: request.session_name,
- agent_type: request.agent_type,
- })
+ Ok(created_session_result("session-blocking", request))
}
async fn create_session_with_id(
@@ -114,11 +115,7 @@ impl AgentSubmissionPort for BlockingCreateOwner {
if self.calls.fetch_add(1, Ordering::AcqRel) == 0 {
self.release.notified().await;
}
- Ok(AgentSessionCreateResult {
- session_id,
- session_name: request.session_name,
- agent_type: request.agent_type,
- })
+ Ok(created_session_result(session_id, request))
}
async fn create_transient_session_with_id(
@@ -362,6 +359,8 @@ async fn transport_accepts_input_while_an_owner_call_is_pending_and_bounds_reque
let created: serde_json::Value =
serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
assert_eq!(created["id"], 2);
+ assert_eq!(created["result"]["workspacePath"], "D:/workspace/project");
+ assert_eq!(created["result"]["workspaceId"], "workspace-fixture");
client_write
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"session/create\",\"params\":{}}\n")
.await
diff --git a/src/apps/server/src/bootstrap.rs b/src/apps/server/src/bootstrap.rs
index 379727cfaf..2b41c1404f 100644
--- a/src/apps/server/src/bootstrap.rs
+++ b/src/apps/server/src/bootstrap.rs
@@ -55,6 +55,12 @@ pub async fn initialize(workspace: Option) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result {
log::info!(
"Workspace opened: name={}, path={}",
info.name,
info.root_path.display()
);
-
- // Initialize snapshot for workspace
- if let Err(e) =
- bitfun_core::service::snapshot::initialize_snapshot_manager_for_workspace(
- info.root_path.clone(),
- None,
- )
- .await
- {
- log::warn!("Failed to initialize snapshot system: {}", e);
- }
-
Some(info.root_path)
}
Err(e) => {
diff --git a/src/apps/server/src/main.rs b/src/apps/server/src/main.rs
index c6603f0cb5..037fdc6199 100644
--- a/src/apps/server/src/main.rs
+++ b/src/apps/server/src/main.rs
@@ -175,4 +175,35 @@ mod tests {
assert!(normalize_browser_origin(invalid).is_err(), "{invalid}");
}
}
+
+ #[test]
+ fn agent_bootstrap_reuses_core_ownership_without_activating_the_http_shell() {
+ let bootstrap = include_str!("bootstrap.rs");
+ assert!(bootstrap.contains("CoreRuntimeOwnership::embedded"));
+ let coordinator = bootstrap
+ .split("ConversationCoordinator::new")
+ .nth(1)
+ .and_then(|source| source.split(");").next())
+ .expect("Server agent bootstrap Coordinator assembly");
+ assert!(coordinator.contains("runtime_ownership"));
+ assert!(bootstrap.contains("open_workspace_with_runtime_ownership"));
+ assert!(!bootstrap.contains("initialize_snapshot_manager_for_workspace"));
+
+ let rpc = include_str!("rpc_dispatcher.rs");
+ let delete = rpc
+ .split("\"delete_session\" =>")
+ .nth(1)
+ .and_then(|source| source.split("\"start_dialog_turn\" =>").next())
+ .expect("Server delete RPC");
+ assert!(delete.contains("ensure_workspace_runtime_ownership"));
+
+ let main_source = include_str!("main.rs")
+ .split("#[cfg(test)]")
+ .next()
+ .expect("Server production entrypoint");
+ assert!(
+ !main_source.contains("bootstrap::initialize"),
+ "the current read-only HTTP shell must not silently start an Agent Runtime"
+ );
+ }
}
diff --git a/src/apps/server/src/rpc_dispatcher.rs b/src/apps/server/src/rpc_dispatcher.rs
index 2335103fd9..084f16a342 100644
--- a/src/apps/server/src/rpc_dispatcher.rs
+++ b/src/apps/server/src/rpc_dispatcher.rs
@@ -388,6 +388,14 @@ pub async fn dispatch(
let request = extract_request(¶ms)?;
let session_id = get_string(&request, "sessionId")?;
let workspace_path = get_string(&request, "workspacePath")?;
+ state
+ .coordinator
+ .ensure_workspace_runtime_ownership(
+ std::path::Path::new(&workspace_path),
+ None,
+ None,
+ )
+ .map_err(|e| anyhow!("{}", e))?;
state
.coordinator
.delete_session(&PathBuf::from(workspace_path), &session_id)
diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs
index 8dbba23c91..74f8d8890c 100644
--- a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs
+++ b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs
@@ -126,16 +126,14 @@ impl RuntimeIpcRequestHandler for CreateRaceHandler {
operation: RuntimeIpcOperation,
) -> Result {
match operation {
- RuntimeIpcOperation::CreateSession { request: _ } => {
+ RuntimeIpcOperation::CreateSession { request } => {
self.create_started.notify_one();
self.allow_create.notified().await;
- Ok(RuntimeIpcOperationResult::SessionCreated {
- session: AgentSessionCreateResult {
- session_id: "session-a".to_string(),
- session_name: "Created session".to_string(),
- agent_type: "agentic".to_string(),
- },
- })
+ let mut session =
+ AgentSessionCreateResult::new("session-a", "Created session", "agentic");
+ session.workspace_path = request.workspace_path;
+ session.workspace_id = Some("workspace-fixture".to_string());
+ Ok(RuntimeIpcOperationResult::SessionCreated { session })
}
RuntimeIpcOperation::RestoreSession { request } => Ok(restored(&request.session_id)),
_ => Ok(RuntimeIpcOperationResult::Unit),
@@ -350,6 +348,7 @@ async fn generated_session_is_claimed_before_another_connection_can_restore_it()
let mut creator = server.connect("creator").await;
let mut restorer = server.connect("restorer").await;
let workspace_path = server.workspace.path().to_string_lossy().to_string();
+ let expected_workspace_path = workspace_path.clone();
let create_task = tokio::spawn(async move {
request(
@@ -380,13 +379,19 @@ async fn generated_session_is_claimed_before_another_connection_can_restore_it()
"restore must wait until create has claimed its generated Session"
);
allow_create.notify_one();
- assert!(matches!(
- create_task.await.expect("create task"),
+ match create_task.await.expect("create task") {
RuntimeIpcFrame::Response {
- result: RuntimeIpcOperationResult::SessionCreated { .. },
+ result: RuntimeIpcOperationResult::SessionCreated { session },
..
+ } => {
+ assert_eq!(
+ session.workspace_path.as_deref(),
+ Some(expected_workspace_path.as_str())
+ );
+ assert_eq!(session.workspace_id.as_deref(), Some("workspace-fixture"));
}
- ));
+ other => panic!("unexpected create response: {other:?}"),
+ }
assert!(matches!(
restore_task.await.expect("restore task"),
RuntimeIpcFrame::Error { error, .. }
diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml
index cfab5d0c39..4aab1a7f07 100644
--- a/src/crates/assembly/core/Cargo.toml
+++ b/src/crates/assembly/core/Cargo.toml
@@ -98,8 +98,9 @@ bitfun-tool-packs = { path = "../../execution/tool-provider-groups", default-fea
bitfun-services-core = { path = "../../services/services-core", default-features = false, features = [
"lsp",
"markdown",
- "workspace-runtime",
"permission",
+ "runtime-ownership",
+ "workspace-runtime",
] }
# Integration service owner crate
diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs
index 1c3ec4c382..879d71a35b 100644
--- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs
+++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs
@@ -50,6 +50,7 @@ use crate::agentic::tools::{
use crate::agentic::workspace::WorkspaceServices;
use crate::agentic::WorkspaceBinding;
use crate::native_hooks::{self, NativeHookSessionFacts};
+use crate::runtime_ownership::CoreRuntimeOwnership;
use crate::service::bootstrap::{
ensure_workspace_persona_files_for_prompt, is_workspace_bootstrap_pending,
};
@@ -62,7 +63,8 @@ use crate::service::session::{
SessionMemoryMode, SessionRelationship, SessionRelationshipKind, SessionStatus,
};
use crate::service::workspace::{
- get_global_workspace_service, WorkspaceActivityMode, WorkspaceCreateOptions, WorkspaceKind,
+ get_global_workspace_service, WorkspaceActivityMode, WorkspaceCreateOptions, WorkspaceInfo,
+ WorkspaceKind, WorkspaceService,
};
use crate::service_agent_runtime::CoreServiceAgentRuntime;
use crate::util::errors::{BitFunError, BitFunResult};
@@ -79,8 +81,8 @@ use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY;
use bitfun_runtime_ports::{
AgentSessionWorkspaceBinding, AgentThreadGoalDeliveryKind, AgentThreadGoalDeliveryRequest,
DelegationPolicy, PermissionDelegationContext, PermissionRuntimeCeiling, RemoteExecPort,
- SessionStoragePathRequest, SessionStorePort, SubagentContextMode, TerminalPort, ThreadGoal,
- ThreadGoalContinuationPlan, ThreadGoalStatus,
+ SessionStoragePathRequest, SessionStoragePathResolution, SessionStorePort, SubagentContextMode,
+ TerminalPort, ThreadGoal, ThreadGoalContinuationPlan, ThreadGoalStatus,
};
use dashmap::DashMap;
use log::{debug, error, info, warn};
@@ -870,6 +872,7 @@ impl SubagentTimeoutHandle {
/// Conversation coordinator
pub struct ConversationCoordinator {
session_manager: Arc,
+ runtime_ownership: Arc,
execution_engine: Arc,
tool_pipeline: Arc,
event_queue: Arc,
@@ -1581,6 +1584,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
tool_pipeline: Arc,
event_queue: Arc,
event_router: Arc,
+ runtime_ownership: Arc,
) -> Self {
let coordination_database_file = session_manager
.path_manager()
@@ -1592,6 +1596,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
event_queue,
event_router,
coordination_database_file,
+ runtime_ownership,
)
}
@@ -1602,6 +1607,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
event_queue: Arc,
event_router: Arc,
coordination_database_file: PathBuf,
+ runtime_ownership: Arc,
) -> Self {
let coordination_store = Arc::new(CoordinationStore::new(coordination_database_file));
let background_subagent_outcomes = Arc::new(BackgroundSubagentOutcomeStore::new(
@@ -1610,6 +1616,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
));
Self {
session_manager,
+ runtime_ownership,
execution_engine,
tool_pipeline,
event_queue,
@@ -1630,6 +1637,130 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
}
}
+ fn ensure_runtime_ownership(
+ &self,
+ workspace_path: &Path,
+ remote_connection_id: Option<&str>,
+ remote_ssh_host: Option<&str>,
+ ) -> BitFunResult<()> {
+ self.runtime_ownership
+ .ensure_workspace_scope(workspace_path, remote_connection_id, remote_ssh_host)
+ .map_err(|error| BitFunError::Service(self.runtime_ownership.error_message(&error)))
+ }
+
+ /// Ensures that this process may attach or mutate one workspace Runtime.
+ pub fn ensure_workspace_runtime_ownership(
+ &self,
+ workspace_path: &Path,
+ remote_connection_id: Option<&str>,
+ remote_ssh_host: Option<&str>,
+ ) -> BitFunResult<()> {
+ self.ensure_runtime_ownership(workspace_path, remote_connection_id, remote_ssh_host)
+ }
+
+ /// Accepts a Remote scope only after the Workspace owner has matched its
+ /// path and connection identity against persisted Workspace facts.
+ pub fn ensure_verified_remote_workspace_runtime_ownership(
+ &self,
+ workspace_path: &Path,
+ remote_connection_id: &str,
+ remote_ssh_host: Option<&str>,
+ ) -> BitFunResult<()> {
+ self.runtime_ownership
+ .register_verified_remote_scope(workspace_path, remote_connection_id, remote_ssh_host)
+ .map_err(|error| BitFunError::Service(self.runtime_ownership.error_message(&error)))?;
+ self.ensure_runtime_ownership(workspace_path, Some(remote_connection_id), remote_ssh_host)
+ }
+
+ /// Gates workspace attachment before opening it, then prepares local
+ /// Snapshot ownership without treating remote workspaces as local paths.
+ pub async fn open_workspace_with_runtime_ownership(
+ &self,
+ workspace_service: &WorkspaceService,
+ path: PathBuf,
+ remote_connection_id: Option<&str>,
+ remote_ssh_host: Option<&str>,
+ snapshot_log_context: &str,
+ ) -> BitFunResult {
+ let known_remote = workspace_service
+ .find_known_remote_workspace_for_path(
+ &path.to_string_lossy(),
+ remote_connection_id,
+ remote_ssh_host,
+ )
+ .await;
+ if known_remote.is_none() && !path.exists() {
+ return Err(BitFunError::service(format!(
+ "Workspace path does not exist locally and is not a known remote SSH workspace: {}. Open it once from the desktop SSH remote UI so BitFun can remember the connection, then try again.",
+ path.display()
+ )));
+ }
+ // Caller-provided remote facts only select a known workspace. They are
+ // not authority to bypass the local Runtime ownership lease.
+ let resolved_connection_id = known_remote
+ .as_ref()
+ .and_then(WorkspaceInfo::remote_ssh_connection_id)
+ .map(ToOwned::to_owned);
+ let resolved_ssh_host = known_remote.as_ref().and_then(|workspace| {
+ workspace
+ .metadata
+ .get("sshHost")
+ .and_then(|value| value.as_str())
+ .map(ToOwned::to_owned)
+ });
+ if let Some(connection_id) = resolved_connection_id.as_deref() {
+ self.ensure_verified_remote_workspace_runtime_ownership(
+ &path,
+ connection_id,
+ resolved_ssh_host.as_deref(),
+ )?;
+ } else {
+ self.ensure_runtime_ownership(&path, None, None)?;
+ }
+ let info = workspace_service
+ .open_workspace_after_known_resolution(path, known_remote)
+ .await?;
+ if info.workspace_kind != WorkspaceKind::Remote {
+ if let Err(error) = crate::service::snapshot::initialize_snapshot_manager_for_workspace(
+ info.root_path.clone(),
+ None,
+ )
+ .await
+ {
+ error!(
+ "Failed to initialize snapshot after {}: {}",
+ snapshot_log_context, error
+ );
+ }
+ }
+ Ok(info)
+ }
+
+ /// Ensures ownership from the loaded session binding, or from a local
+ /// fallback workspace before a session is restored.
+ pub fn ensure_session_runtime_ownership(
+ &self,
+ session_id: &str,
+ fallback_workspace: Option<&Path>,
+ ) -> BitFunResult<()> {
+ if let Some(session) = self.session_manager.get_session(session_id) {
+ let workspace_path = session.config.workspace_path.as_deref().ok_or_else(|| {
+ BitFunError::Validation(format!("Session workspace_path is missing: {session_id}"))
+ })?;
+ return self.ensure_runtime_ownership(
+ Path::new(workspace_path),
+ session.config.remote_connection_id.as_deref(),
+ session.config.remote_ssh_host.as_deref(),
+ );
+ }
+ match fallback_workspace {
+ Some(workspace_path) => self.ensure_runtime_ownership(workspace_path, None, None),
+ None => Err(BitFunError::NotFound(format!(
+ "Session not found: {session_id}"
+ ))),
+ }
+ }
+
pub fn thread_goal_runtime(&self) -> Arc {
Arc::clone(&self.thread_goal_runtime)
}
@@ -1767,6 +1898,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
}
pub async fn update_session_model(&self, session_id: &str, model_id: &str) -> BitFunResult<()> {
+ self.ensure_session_runtime_ownership(session_id, None)?;
let normalized_model_id = normalize_model_selection(model_id).await?;
self.session_manager
@@ -1818,6 +1950,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
// Persist the workspace binding inside the session config so execution can
// consistently restore the correct workspace regardless of the entry point.
config.workspace_path = Some(workspace_path.clone());
+ self.ensure_runtime_ownership(
+ Path::new(&workspace_path),
+ config.remote_connection_id.as_deref(),
+ config.remote_ssh_host.as_deref(),
+ )?;
config.workspace_id = Self::resolve_workspace_id_for_config(&config).await;
let defaults = Self::agent_model_defaults().await;
snapshot_normal_session_model(&mut config, &defaults);
@@ -1928,6 +2065,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
created_by: Option,
) -> BitFunResult {
config.workspace_path = Some(workspace_path);
+ self.ensure_runtime_ownership(
+ Path::new(
+ config
+ .workspace_path
+ .as_deref()
+ .expect("workspace path was assigned above"),
+ ),
+ config.remote_connection_id.as_deref(),
+ config.remote_ssh_host.as_deref(),
+ )?;
config.workspace_id = Self::resolve_workspace_id_for_config(&config).await;
let agent_type = Self::normalize_agent_type(&agent_type);
self.create_hidden_subagent_session(
@@ -2592,6 +2739,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
workspace_path: String,
) -> BitFunResult {
let workspace_root = PathBuf::from(&workspace_path);
+ // Assistant workspaces are local-only. Ownership must be established
+ // before persona files are created or a persisted Session is attached.
+ self.ensure_runtime_ownership(&workspace_root, None, None)?;
// Empty or partial assistant dirs may never have run create_assistant_workspace; fill only
// missing persona stubs (never overwrite), while preserving completed bootstrap state.
ensure_workspace_persona_files_for_prompt(&workspace_root).await?;
@@ -2841,11 +2991,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
ThreadGoalStore::new(self.session_manager.as_ref())
}
- async fn resolve_session_restore_path(
+ async fn resolve_session_restore_scope(
workspace_path: &str,
remote_connection_id: Option<&str>,
remote_ssh_host: Option<&str>,
- ) -> BitFunResult {
+ ) -> BitFunResult {
let request = SessionStoragePathRequest {
workspace_path: PathBuf::from(workspace_path),
remote_connection_id: remote_connection_id.map(ToOwned::to_owned),
@@ -2855,10 +3005,19 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
CoreSessionStorePort::default()
.resolve_session_storage_path(request)
.await
- .map(|resolution| resolution.effective_storage_path)
.map_err(|error| BitFunError::Session(error.to_string()))
}
+ async fn resolve_session_restore_path(
+ workspace_path: &str,
+ remote_connection_id: Option<&str>,
+ remote_ssh_host: Option<&str>,
+ ) -> BitFunResult {
+ Self::resolve_session_restore_scope(workspace_path, remote_connection_id, remote_ssh_host)
+ .await
+ .map(|resolution| resolution.effective_storage_path)
+ }
+
fn require_main_session_workspace(&self, session_id: &str) -> BitFunResult {
let session = self
.session_manager
@@ -3619,9 +3778,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
.as_ref()
.and_then(|session| session.config.project_workspace_path.as_deref()),
);
- let requested_restore_path = match storage_workspace_path.as_deref() {
+ let requested_restore = match storage_workspace_path.as_deref() {
Some(workspace_path) => Some(
- Self::resolve_session_restore_path(
+ Self::resolve_session_restore_scope(
workspace_path,
remote_connection_id.as_deref(),
remote_ssh_host.as_deref(),
@@ -3636,9 +3795,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
// the same storage identity as this invocation.
let session = match loaded_session {
Some(session) => {
- if let Some(restore_path) = requested_restore_path.as_deref() {
- self.session_manager
- .ensure_session_storage_path(&session_id, restore_path)?;
+ if let Some(restore) = requested_restore.as_ref() {
+ self.session_manager.ensure_session_storage_path(
+ &session_id,
+ &restore.effective_storage_path,
+ )?;
}
session
}
@@ -3647,17 +3808,21 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
"Session not found in memory, attempting restore before starting dialog: session_id={}",
session_id
);
- let restore_path = requested_restore_path.ok_or_else(|| {
+ let restore = requested_restore.ok_or_else(|| {
BitFunError::Validation(format!(
"workspace_path is required when restoring session: {}",
session_id
))
})?;
+ if !restore.is_remote_storage() {
+ self.ensure_runtime_ownership(&restore.requested_workspace_path, None, None)?;
+ }
self.session_manager
- .restore_session_from_storage_path(&restore_path, &session_id)
+ .restore_session_from_storage_path(&restore.effective_storage_path, &session_id)
.await?
}
};
+ self.ensure_session_runtime_ownership(&session_id, None)?;
let previous_agent_type = session.last_user_dialog_agent_type.clone();
let requested_agent_type = agent_type.trim().to_string();
@@ -4978,6 +5143,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
workspace_path: &Path,
session_id: &str,
) -> BitFunResult {
+ self.ensure_runtime_ownership(workspace_path, None, None)?;
self.session_manager
.restore_session(workspace_path, session_id)
.await
@@ -5008,6 +5174,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
request: SessionStoragePathRequest,
session_id: &str,
) -> BitFunResult {
+ self.ensure_runtime_ownership(
+ &request.workspace_path,
+ request.remote_connection_id.as_deref(),
+ request.remote_ssh_host.as_deref(),
+ )?;
self.session_manager
.restore_session_for_workspace(request, session_id)
.await
@@ -5018,6 +5189,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
request: SessionStoragePathRequest,
session_id: &str,
) -> BitFunResult {
+ self.ensure_runtime_ownership(
+ &request.workspace_path,
+ request.remote_connection_id.as_deref(),
+ request.remote_ssh_host.as_deref(),
+ )?;
self.session_manager
.restore_internal_session_for_workspace(request, session_id)
.await
@@ -5028,6 +5204,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
workspace_path: &Path,
session_id: &str,
) -> BitFunResult {
+ self.ensure_runtime_ownership(workspace_path, None, None)?;
self.session_manager
.restore_internal_session(workspace_path, session_id)
.await
@@ -5039,6 +5216,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
workspace_path: &Path,
session_id: &str,
) -> BitFunResult<(Session, Vec)> {
+ self.ensure_runtime_ownership(workspace_path, None, None)?;
self.session_manager
.restore_session_with_turns(workspace_path, session_id)
.await
@@ -5069,6 +5247,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
request: SessionStoragePathRequest,
session_id: &str,
) -> BitFunResult<(Session, Vec)> {
+ self.ensure_runtime_ownership(
+ &request.workspace_path,
+ request.remote_connection_id.as_deref(),
+ request.remote_ssh_host.as_deref(),
+ )?;
self.session_manager
.restore_session_with_turns_for_workspace(request, session_id)
.await
@@ -5079,6 +5262,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
request: SessionStoragePathRequest,
session_id: &str,
) -> BitFunResult<(Session, Vec)> {
+ self.ensure_runtime_ownership(
+ &request.workspace_path,
+ request.remote_connection_id.as_deref(),
+ request.remote_ssh_host.as_deref(),
+ )?;
self.session_manager
.restore_internal_session_with_turns_for_workspace(request, session_id)
.await
@@ -5089,6 +5277,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
workspace_path: &Path,
session_id: &str,
) -> BitFunResult<(Session, Vec)> {
+ self.ensure_runtime_ownership(workspace_path, None, None)?;
self.session_manager
.restore_internal_session_with_turns(workspace_path, session_id)
.await
@@ -8279,6 +8468,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
user_message: &str,
max_length: Option,
) -> BitFunResult {
+ self.ensure_session_runtime_ownership(session_id, None)?;
let allow_ai = is_ai_session_title_generation_enabled().await;
let resolved = self
.session_manager
@@ -8309,6 +8499,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
session_id: &str,
title: &str,
) -> BitFunResult {
+ self.ensure_session_runtime_ownership(session_id, None)?;
let normalized = title.trim().to_string();
if normalized.is_empty() {
return Err(BitFunError::validation(
@@ -8328,6 +8519,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
session_id: &str,
agent_type: &str,
) -> BitFunResult<()> {
+ self.ensure_session_runtime_ownership(session_id, None)?;
let normalized = Self::normalize_agent_type(agent_type);
self.session_manager
.update_session_agent_type(session_id, &normalized)
@@ -8335,6 +8527,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
}
pub async fn update_session_mode(&self, session_id: &str, mode_id: &str) -> BitFunResult<()> {
+ self.ensure_session_runtime_ownership(session_id, None)?;
let mode_id = mode_id.trim();
if mode_id.is_empty() {
return Err(BitFunError::Validation(
@@ -8365,6 +8558,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
session_id: &str,
agent_type: &str,
) -> BitFunResult<()> {
+ self.ensure_session_runtime_ownership(session_id, None)?;
let normalized = Self::normalize_agent_type(agent_type);
self.session_manager
.update_last_submitted_agent_type(session_id, &normalized)
@@ -8514,11 +8708,7 @@ async fn create_agent_session_from_runtime_request(
.await
.map_err(map_core_error)?;
- Ok(bitfun_runtime_ports::AgentSessionCreateResult {
- session_id: session.session_id,
- session_name: session.session_name,
- agent_type: session.agent_type,
- })
+ Ok(session.into())
}
#[async_trait::async_trait]
@@ -8645,7 +8835,17 @@ impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator {
return Ok(None);
};
- self.restore_session_from_storage_path(&binding.session_storage_dir(), session_id)
+ let restore_request = SessionStoragePathRequest {
+ workspace_path: PathBuf::from(binding.root_path_string()),
+ remote_connection_id: binding.connection_id().map(ToOwned::to_owned),
+ remote_ssh_host: if binding.is_remote() {
+ Some(binding.session_identity.hostname.clone())
+ .filter(|value| !value.trim().is_empty())
+ } else {
+ None
+ },
+ };
+ self.restore_session_for_workspace(restore_request, session_id)
.await
.map(|session| Some(session.agent_type))
.map_err(|error| {
@@ -8779,6 +8979,12 @@ impl bitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordinato
message,
)
})?;
+ self.ensure_runtime_ownership(
+ Path::new(&request.workspace_path),
+ request.remote_connection_id.as_deref(),
+ request.remote_ssh_host.as_deref(),
+ )
+ .map_err(runtime_port_error_preserving_message)?;
let effective_storage_path = Self::resolve_session_restore_path(
&request.workspace_path,
request.remote_connection_id.as_deref(),
@@ -8812,6 +9018,12 @@ impl bitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordinato
message,
)
})?;
+ self.ensure_runtime_ownership(
+ Path::new(&request.workspace_path),
+ request.remote_connection_id.as_deref(),
+ request.remote_ssh_host.as_deref(),
+ )
+ .map_err(runtime_port_error_preserving_message)?;
let effective_storage_path = Self::resolve_session_restore_path(
&request.workspace_path,
request.remote_connection_id.as_deref(),
@@ -8862,6 +9074,12 @@ impl bitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordinato
message,
)
})?;
+ self.ensure_runtime_ownership(
+ Path::new(&request.workspace_path),
+ request.remote_connection_id.as_deref(),
+ request.remote_ssh_host.as_deref(),
+ )
+ .map_err(runtime_port_error_preserving_message)?;
let effective_storage_path = Self::resolve_session_restore_path(
&request.workspace_path,
request.remote_connection_id.as_deref(),
@@ -8978,6 +9196,8 @@ impl bitfun_runtime_ports::AgentLocalCommandTurnPort for ConversationCoordinator
&self,
request: bitfun_runtime_ports::AgentLocalCommandTurnRecordRequest,
) -> bitfun_runtime_ports::PortResult<()> {
+ self.ensure_session_runtime_ownership(&request.session_id, None)
+ .map_err(runtime_port_error_preserving_message)?;
let metadata = if request.metadata.is_empty() {
None
} else {
@@ -9060,6 +9280,11 @@ impl bitfun_runtime_ports::AgentThreadGoalManagementPort for ConversationCoordin
&self,
request: bitfun_runtime_ports::AgentThreadGoalCreateRequest,
) -> bitfun_runtime_ports::PortResult {
+ self.ensure_session_runtime_ownership(
+ &request.session_id,
+ Some(Path::new(&request.workspace_path)),
+ )
+ .map_err(runtime_port_error_preserving_message)?;
self.create_thread_goal(
&request.session_id,
std::path::Path::new(&request.workspace_path),
@@ -9074,6 +9299,11 @@ impl bitfun_runtime_ports::AgentThreadGoalManagementPort for ConversationCoordin
&self,
request: bitfun_runtime_ports::AgentThreadGoalUpdateStatusRequest,
) -> bitfun_runtime_ports::PortResult {
+ self.ensure_session_runtime_ownership(
+ &request.session_id,
+ Some(Path::new(&request.workspace_path)),
+ )
+ .map_err(runtime_port_error_preserving_message)?;
self.update_thread_goal_status(
&request.session_id,
std::path::Path::new(&request.workspace_path),
@@ -9396,18 +9626,21 @@ mod tests {
use crate::agentic::tools::{ToolPipeline, ToolStateManager};
use crate::agentic::TurnSkillAgentSnapshot;
use crate::infrastructure::PathManager;
+ use crate::runtime_ownership::CoreRuntimeOwnership;
use crate::service::config::{AgentModelDefaultsConfig, SubagentModelSelection};
use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager;
use crate::service::session::{SessionMetadata, SessionStatus};
+ use crate::service::workspace::WorkspaceKind;
use bitfun_agent_runtime::permission::AUTO_APPROVE_ASK_CONTEXT_KEY;
use bitfun_runtime_ports::{
AgentSessionArchiveRequest, AgentSessionCreateRequest, AgentSessionManagementPort,
AgentSessionRenameRequest, AgentSubmissionPort, AgentSubmissionRequest,
AgentSubmissionSource, AgentThreadGoalGetRequest, AgentThreadGoalManagementPort,
DelegationPolicy, PermissionEffect, PermissionRule, PermissionRuntimeCeiling,
- SubagentContextMode, ThreadGoal, ThreadGoalStatus,
+ SessionStoragePathRequest, SubagentContextMode, ThreadGoal, ThreadGoalStatus,
};
use std::collections::HashMap;
+ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
@@ -9890,9 +10123,10 @@ mod tests {
}
use tokio::sync::RwLock as TokioRwLock;
- fn test_coordinator_with_config(
+ fn test_coordinator_with_config_and_ownership(
max_active_sessions: usize,
enable_persistence: bool,
+ runtime_ownership: Arc,
) -> (ConversationCoordinator, Arc) {
let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default()));
let coordination_database_file = std::env::temp_dir()
@@ -9935,6 +10169,7 @@ mod tests {
event_queue,
Arc::new(EventRouter::new()),
coordination_database_file,
+ runtime_ownership,
);
coordinator.set_terminal_port(
bitfun_runtime_services::test_support::FakeRuntimeServicesProvider::terminal_port(),
@@ -9946,6 +10181,25 @@ mod tests {
(coordinator, session_manager)
}
+ fn test_coordinator_with_config(
+ max_active_sessions: usize,
+ enable_persistence: bool,
+ ) -> (ConversationCoordinator, Arc) {
+ let ownership_root = std::env::temp_dir().join(format!(
+ "bitfun-runtime-ownership-test-{}",
+ uuid::Uuid::new_v4()
+ ));
+ test_coordinator_with_config_and_ownership(
+ max_active_sessions,
+ enable_persistence,
+ Arc::new(CoreRuntimeOwnership::embedded_with_facts(
+ ownership_root,
+ "bitfun".to_string(),
+ "test",
+ )),
+ )
+ }
+
fn test_coordinator_with_max_active_sessions(
max_active_sessions: usize,
) -> (ConversationCoordinator, Arc) {
@@ -9960,6 +10214,279 @@ mod tests {
test_coordinator_with_max_active_sessions(100)
}
+ #[tokio::test]
+ async fn create_session_checks_runtime_ownership_before_persisting() {
+ let ownership_root = tempfile::tempdir().expect("ownership root");
+ let workspace = tempfile::tempdir().expect("workspace");
+ let key = bitfun_services_core::runtime_ownership::RuntimeOwnershipKey::for_workspace(
+ workspace.path(),
+ "bitfun",
+ )
+ .expect("ownership key");
+ let _shared =
+ bitfun_services_core::runtime_ownership::WorkspaceRuntimeOwnership::try_acquire(
+ ownership_root.path(),
+ &key,
+ bitfun_services_core::runtime_ownership::RuntimeDeployment::Shared,
+ )
+ .expect("shared owner");
+ let owner = Arc::new(CoreRuntimeOwnership::embedded_with_facts(
+ ownership_root.path().to_path_buf(),
+ "bitfun".to_string(),
+ "test",
+ ));
+ let (coordinator, session_manager) =
+ test_coordinator_with_config_and_ownership(100, true, owner);
+
+ let error = coordinator
+ .create_session_with_id(
+ Some("ownership-conflict".to_string()),
+ "blocked".to_string(),
+ "agentic".to_string(),
+ SessionConfig {
+ workspace_path: Some(workspace.path().to_string_lossy().to_string()),
+ ..Default::default()
+ },
+ )
+ .await
+ .expect_err("Shared owner must block local session creation");
+
+ assert!(error.to_string().contains("ownership"));
+ assert!(session_manager.get_session("ownership-conflict").is_none());
+ }
+
+ #[tokio::test]
+ async fn assistant_bootstrap_checks_runtime_ownership_before_files_or_attach() {
+ let ownership_root = tempfile::tempdir().expect("ownership root");
+ let workspace = tempfile::tempdir().expect("workspace");
+ let key = bitfun_services_core::runtime_ownership::RuntimeOwnershipKey::for_workspace(
+ workspace.path(),
+ "bitfun",
+ )
+ .expect("ownership key");
+ let _shared =
+ bitfun_services_core::runtime_ownership::WorkspaceRuntimeOwnership::try_acquire(
+ ownership_root.path(),
+ &key,
+ bitfun_services_core::runtime_ownership::RuntimeDeployment::Shared,
+ )
+ .expect("shared owner");
+ let owner = Arc::new(CoreRuntimeOwnership::embedded_with_facts(
+ ownership_root.path().to_path_buf(),
+ "bitfun".to_string(),
+ "test",
+ ));
+ let (coordinator, session_manager) =
+ test_coordinator_with_config_and_ownership(100, true, owner);
+
+ let error = coordinator
+ .ensure_assistant_bootstrap(
+ "assistant-bootstrap-conflict".to_string(),
+ workspace.path().to_string_lossy().to_string(),
+ )
+ .await
+ .expect_err("Shared owner must block assistant bootstrap");
+
+ assert!(error.to_string().contains("ownership"));
+ assert!(session_manager
+ .get_session("assistant-bootstrap-conflict")
+ .is_none());
+ assert_eq!(
+ std::fs::read_dir(workspace.path())
+ .expect("workspace remains readable")
+ .count(),
+ 0,
+ "ownership failure must happen before persona or gitignore writes"
+ );
+ }
+
+ #[test]
+ fn workspace_open_owner_gates_before_open_and_guards_snapshot_by_kind() {
+ let source = include_str!("coordinator.rs");
+ let helper = source
+ .split("pub async fn open_workspace_with_runtime_ownership")
+ .nth(1)
+ .and_then(|source| {
+ source
+ .split("pub fn ensure_session_runtime_ownership")
+ .next()
+ })
+ .expect("workspace open owner");
+ let ownership_gate = helper
+ .find("ensure_runtime_ownership")
+ .expect("workspace ownership gate");
+ let workspace_open = helper
+ .find("open_workspace_after_known_resolution")
+ .expect("workspace open call");
+ assert!(ownership_gate < workspace_open);
+ assert!(helper.contains("WorkspaceKind::Remote"));
+ assert!(helper.contains("initialize_snapshot_manager_for_workspace"));
+
+ let bot_router = include_str!("../../service/remote_connect/bot/command_router.rs");
+ assert!(bot_router.contains("open_workspace_with_runtime_ownership"));
+ assert!(!bot_router.contains("initialize_snapshot_manager_for_workspace"));
+ }
+
+ #[tokio::test]
+ async fn workspace_open_owner_resolves_known_remote_before_ownership_gate() {
+ let root = tempfile::tempdir().expect("test root");
+ let path_manager = Arc::new(PathManager::with_user_root_for_tests(
+ root.path().join("user-root"),
+ ));
+ let workspace_service =
+ crate::service::workspace::WorkspaceService::new_for_test_path_manager(path_manager)
+ .await;
+ let remote_path = PathBuf::from(format!(
+ "/bitfun-tests/known-remote-{}",
+ uuid::Uuid::new_v4()
+ ));
+ workspace_service
+ .track_workspace_activity(
+ remote_path.clone(),
+ crate::service::workspace::WorkspaceCreateOptions {
+ workspace_kind: WorkspaceKind::Remote,
+ remote_connection_id: Some("conn-known-remote".to_string()),
+ remote_ssh_host: Some("known-host".to_string()),
+ ..Default::default()
+ },
+ crate::service::workspace::WorkspaceActivityMode::RefreshMetadata,
+ )
+ .await
+ .expect("remember remote workspace");
+ let owner = Arc::new(CoreRuntimeOwnership::embedded_with_facts(
+ root.path().join("ownership"),
+ "bitfun".to_string(),
+ "test",
+ ));
+ let (coordinator, _) = test_coordinator_with_config_and_ownership(100, false, owner);
+
+ let opened = coordinator
+ .open_workspace_with_runtime_ownership(
+ &workspace_service,
+ remote_path,
+ None,
+ None,
+ "known remote test",
+ )
+ .await
+ .expect("path-only known remote must not acquire a local lease");
+
+ assert_eq!(opened.workspace_kind, WorkspaceKind::Remote);
+ assert_eq!(opened.remote_ssh_connection_id(), Some("conn-known-remote"));
+ }
+
+ #[tokio::test]
+ async fn unverified_remote_hint_cannot_bypass_local_workspace_ownership() {
+ let ownership_root = tempfile::tempdir().expect("ownership root");
+ let workspace = tempfile::tempdir().expect("workspace");
+ let key = bitfun_services_core::runtime_ownership::RuntimeOwnershipKey::for_workspace(
+ workspace.path(),
+ "bitfun",
+ )
+ .expect("ownership key");
+ let _shared =
+ bitfun_services_core::runtime_ownership::WorkspaceRuntimeOwnership::try_acquire(
+ ownership_root.path(),
+ &key,
+ bitfun_services_core::runtime_ownership::RuntimeDeployment::Shared,
+ )
+ .expect("shared owner");
+ let owner = Arc::new(CoreRuntimeOwnership::embedded_with_facts(
+ ownership_root.path().to_path_buf(),
+ "bitfun".to_string(),
+ "test",
+ ));
+ let (coordinator, _) = test_coordinator_with_config_and_ownership(100, false, owner);
+ let path_manager = Arc::new(PathManager::with_user_root_for_tests(
+ workspace.path().join("user-root"),
+ ));
+ let workspace_service =
+ crate::service::workspace::WorkspaceService::new_for_test_path_manager(path_manager)
+ .await;
+
+ let error = coordinator
+ .open_workspace_with_runtime_ownership(
+ &workspace_service,
+ workspace.path().to_path_buf(),
+ Some("bogus-connection"),
+ Some("bogus-host"),
+ "unverified remote hint test",
+ )
+ .await
+ .expect_err("unverified hints must not bypass local ownership");
+
+ assert!(error.to_string().contains("ownership"));
+ }
+
+ #[tokio::test]
+ async fn attach_and_mutation_paths_check_runtime_ownership_before_side_effects() {
+ let ownership_root = tempfile::tempdir().expect("ownership root");
+ let workspace = tempfile::tempdir().expect("workspace");
+ let key = bitfun_services_core::runtime_ownership::RuntimeOwnershipKey::for_workspace(
+ workspace.path(),
+ "bitfun",
+ )
+ .expect("ownership key");
+ let _shared =
+ bitfun_services_core::runtime_ownership::WorkspaceRuntimeOwnership::try_acquire(
+ ownership_root.path(),
+ &key,
+ bitfun_services_core::runtime_ownership::RuntimeDeployment::Shared,
+ )
+ .expect("shared owner");
+ let owner = Arc::new(CoreRuntimeOwnership::embedded_with_facts(
+ ownership_root.path().to_path_buf(),
+ "bitfun".to_string(),
+ "test",
+ ));
+ let (coordinator, session_manager) =
+ test_coordinator_with_config_and_ownership(100, true, owner);
+ let workspace_path = workspace.path().to_string_lossy().to_string();
+
+ let hidden_error = coordinator
+ .create_hidden_subagent_session_with_workspace(
+ Some("hidden-ownership-conflict".to_string()),
+ "hidden".to_string(),
+ "agentic".to_string(),
+ SessionConfig::default(),
+ workspace_path.clone(),
+ None,
+ )
+ .await
+ .expect_err("Hidden session creation must honor runtime ownership");
+ assert!(hidden_error.to_string().contains("ownership"));
+
+ let restore_error = coordinator
+ .restore_session_for_workspace(
+ SessionStoragePathRequest {
+ workspace_path: workspace.path().to_path_buf(),
+ remote_connection_id: None,
+ remote_ssh_host: None,
+ },
+ "missing-session",
+ )
+ .await
+ .expect_err("Runtime attach must honor ownership before reading persistence");
+ assert!(restore_error.to_string().contains("ownership"));
+
+ let archive_error = bitfun_runtime_ports::AgentSessionManagementPort::set_session_archived(
+ &coordinator,
+ bitfun_runtime_ports::AgentSessionArchiveStateRequest {
+ workspace_path,
+ session_id: "missing-session".to_string(),
+ archived: true,
+ remote_connection_id: None,
+ remote_ssh_host: None,
+ },
+ )
+ .await
+ .expect_err("Metadata mutation must honor ownership before touching persistence");
+ assert!(archive_error.message.contains("ownership"));
+ assert!(session_manager
+ .get_session("hidden-ownership-conflict")
+ .is_none());
+ }
+
async fn register_test_background_task(
coordinator: &ConversationCoordinator,
parent_session_id: &str,
@@ -10921,6 +11448,13 @@ mod tests {
}
let loaded_session_id = format!("loaded-remote-goal-{fixture_id}");
+ coordinator
+ .ensure_verified_remote_workspace_runtime_ownership(
+ std::path::Path::new(logical_workspace_path),
+ &remote_identities[0].0,
+ Some(&remote_identities[0].1),
+ )
+ .expect("Workspace owner should verify the remote binding before loading a session");
coordinator
.create_session_with_id(
Some(loaded_session_id.clone()),
@@ -11019,6 +11553,70 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn thread_goal_mutations_use_loaded_remote_workspace_facts() {
+ let (coordinator, session_manager) = test_persistent_coordinator();
+ let fixture_id = uuid::Uuid::new_v4();
+ let session_id = format!("remote-goal-mutation-{fixture_id}");
+ let logical_workspace_path = format!("/workspace/remote-goal-{fixture_id}");
+ let remote_connection_id = format!("connection-{fixture_id}");
+ let remote_ssh_host = format!("host-{fixture_id}");
+
+ coordinator
+ .ensure_verified_remote_workspace_runtime_ownership(
+ std::path::Path::new(&logical_workspace_path),
+ &remote_connection_id,
+ Some(&remote_ssh_host),
+ )
+ .expect("Workspace owner should verify the remote binding before loading a session");
+ coordinator
+ .create_session_with_id(
+ Some(session_id.clone()),
+ "Remote goal mutation".to_string(),
+ "agentic".to_string(),
+ SessionConfig {
+ workspace_path: Some(logical_workspace_path.clone()),
+ remote_connection_id: Some(remote_connection_id),
+ remote_ssh_host: Some(remote_ssh_host),
+ ..Default::default()
+ },
+ )
+ .await
+ .expect("remote session should load without local ownership");
+
+ let created = AgentThreadGoalManagementPort::create_thread_goal(
+ &coordinator,
+ bitfun_runtime_ports::AgentThreadGoalCreateRequest {
+ session_id: session_id.clone(),
+ workspace_path: logical_workspace_path.clone(),
+ objective: "Keep remote ownership structured".to_string(),
+ token_budget: None,
+ },
+ )
+ .await
+ .expect("remote goal creation must not acquire a local workspace lock");
+ let updated = AgentThreadGoalManagementPort::update_thread_goal_status(
+ &coordinator,
+ bitfun_runtime_ports::AgentThreadGoalUpdateStatusRequest {
+ session_id: session_id.clone(),
+ workspace_path: logical_workspace_path,
+ status: ThreadGoalStatus::Complete,
+ turn_id: None,
+ },
+ )
+ .await
+ .expect("remote goal update must not acquire a local workspace lock");
+
+ assert_eq!(created.session_id, session_id);
+ assert_eq!(updated.status, ThreadGoalStatus::Complete);
+ if let Some(binding) = session_manager
+ .resolve_session_workspace_binding(&session_id)
+ .await
+ {
+ let _ = std::fs::remove_dir_all(binding.session_storage_dir());
+ }
+ }
+
#[tokio::test]
async fn normal_sessions_keep_the_mode_default_snapshotted_at_creation() {
let (coordinator, session_manager) = test_coordinator();
diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs
index f5c4f90632..2ca87e7591 100644
--- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs
+++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs
@@ -2436,6 +2436,16 @@ mod tests {
tool_pipeline,
event_queue.clone(),
Arc::new(EventRouter::new()),
+ Arc::new(
+ crate::runtime_ownership::CoreRuntimeOwnership::embedded_with_facts(
+ std::env::temp_dir().join(format!(
+ "bitfun-scheduler-ownership-test-{}",
+ uuid::Uuid::new_v4()
+ )),
+ "bitfun".to_string(),
+ "test",
+ ),
+ ),
));
(
DialogScheduler::new(coordinator, session_manager.clone()),
diff --git a/src/crates/assembly/core/src/agentic/mod.rs b/src/crates/assembly/core/src/agentic/mod.rs
index 3a8c8aef21..84edde742e 100644
--- a/src/crates/assembly/core/src/agentic/mod.rs
+++ b/src/crates/assembly/core/src/agentic/mod.rs
@@ -75,5 +75,8 @@ pub use round_preempt::{
pub use session::*;
pub use side_question::*;
pub use skill_agent_snapshot::*;
-pub use system::{init_agentic_system, init_agentic_system_for_profile, AgenticSystem};
+pub use system::{
+ init_agentic_system, init_agentic_system_for_profile,
+ init_agentic_system_for_profile_with_runtime_ownership, AgenticSystem,
+};
pub use workspace::{WorkspaceBackend, WorkspaceBinding};
diff --git a/src/crates/assembly/core/src/agentic/system.rs b/src/crates/assembly/core/src/agentic/system.rs
index f4212a16b5..71ae188663 100644
--- a/src/crates/assembly/core/src/agentic/system.rs
+++ b/src/crates/assembly/core/src/agentic/system.rs
@@ -14,6 +14,7 @@ use crate::agentic::session;
use crate::agentic::tools;
use crate::infrastructure::ai::AIClientFactory;
use crate::infrastructure::try_get_path_manager_arc;
+use crate::runtime_ownership::CoreRuntimeOwnership;
use crate::service::token_usage::{TokenUsageService, TokenUsageSubscriber};
use bitfun_product_capabilities::DeliveryProfile;
@@ -44,6 +45,22 @@ pub fn select_agentic_system_profile(delivery_profile: DeliveryProfile) -> Resul
/// Initialize the single process-wide agentic runtime for one product profile.
pub async fn init_agentic_system_for_profile(
delivery_profile: DeliveryProfile,
+) -> Result {
+ let path_manager = try_get_path_manager_arc()?;
+ let runtime_ownership = Arc::new(CoreRuntimeOwnership::embedded(
+ path_manager.as_ref(),
+ "embedded-host",
+ ));
+ init_agentic_system_for_profile_with_runtime_ownership(delivery_profile, runtime_ownership)
+ .await
+}
+
+/// Initializes one product runtime with an explicitly selected ownership
+/// deployment. First-party fixed-workspace hosts use this before protocol/UI
+/// readiness; public Agent Runtime contracts remain unchanged.
+pub async fn init_agentic_system_for_profile_with_runtime_ownership(
+ delivery_profile: DeliveryProfile,
+ runtime_ownership: Arc,
) -> Result {
info!("Initializing agentic system for profile {delivery_profile}");
@@ -103,6 +120,7 @@ pub async fn init_agentic_system_for_profile(
tool_pipeline,
event_queue.clone(),
event_router.clone(),
+ runtime_ownership,
));
coordination::ConversationCoordinator::set_global(coordinator.clone());
diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs
index 85fb1ea9ff..9eb613c1e4 100644
--- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs
+++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs
@@ -321,6 +321,11 @@ impl PathManager {
.join("coordination.sqlite")
}
+ /// Process-level ownership locks for local Agent Runtime deployments.
+ pub fn agent_runtime_ownership_dir(&self) -> PathBuf {
+ self.user_data_dir().join("agent-runtime").join("ownership")
+ }
+
/// Get user memory workspace root directory: ~/.bitfun/memories/
pub fn memories_root_dir(&self) -> PathBuf {
self.bitfun_home_dir().join("memories")
@@ -730,6 +735,20 @@ mod tests {
static ENV_LOCK: Mutex<()> = Mutex::new(());
+ #[test]
+ fn runtime_ownership_lives_under_the_agent_runtime_data_root() {
+ let user_root = std::env::temp_dir().join("bitfun-runtime-ownership-path-test");
+ let path_manager = PathManager::with_user_root_for_tests(user_root);
+
+ assert_eq!(
+ path_manager.agent_runtime_ownership_dir(),
+ path_manager
+ .user_data_dir()
+ .join("agent-runtime")
+ .join("ownership")
+ );
+ }
+
#[test]
fn strict_path_access_rejects_a_cached_temporary_fallback() {
let state = GlobalPathManagerState::fallback(
diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs
index 882ae93df3..99f8e180ff 100644
--- a/src/crates/assembly/core/src/lib.rs
+++ b/src/crates/assembly/core/src/lib.rs
@@ -41,6 +41,10 @@ pub mod product_assembly;
pub(crate) mod product_domain_runtime;
#[cfg(feature = "product-full")]
pub mod product_runtime;
+#[cfg(feature = "product-full")]
+pub mod runtime_ownership;
+#[cfg(all(test, feature = "product-full"))]
+mod runtime_ownership_tests;
pub mod service; // Workspace, Config, FileSystem, Terminal, Git
#[cfg(feature = "service-integrations")]
pub(crate) mod service_agent_runtime;
diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs
index 83baefca9f..d22586eb14 100644
--- a/src/crates/assembly/core/src/product_runtime.rs
+++ b/src/crates/assembly/core/src/product_runtime.rs
@@ -39,8 +39,8 @@ use crate::agentic::session::CoreSessionStorePort;
use crate::service::session::{DialogTurnData, SessionMetadata};
use crate::service::session_usage::{generate_session_usage_report, SessionUsageReport};
use crate::service::snapshot::{
- get_snapshot_manager_for_workspace, initialize_snapshot_manager_for_workspace, SnapshotError,
- SnapshotManager,
+ get_snapshot_manager_for_workspace, initialize_snapshot_manager_for_workspace,
+ open_snapshot_manager_for_view, SnapshotError, SnapshotManager,
};
use crate::service::token_usage::TokenUsageService;
use crate::service_agent_runtime::CoreServiceAgentRuntime;
@@ -279,6 +279,15 @@ async fn ensure_local_snapshot_manager(workspace_path: &Path) -> PortResult PortResult> {
+ validate_local_snapshot_workspace(workspace_path)?;
+ open_snapshot_manager_for_view(workspace_path)
+ .await
+ .map_err(snapshot_port_error)
+}
+
/// Core-backed access to the existing local workspace snapshot owner.
///
/// The returned port is intentionally separate from the Agent Runtime SDK and
@@ -303,8 +312,8 @@ impl LocalWorkspaceSnapshotPort for CoreLocalWorkspaceSnapshot {
request: LocalWorkspaceSnapshotSessionRequest,
) -> PortResult> {
validate_persisted_session_id(&request.session_id).map_err(runtime_port_error)?;
- ensure_local_snapshot_manager(&request.workspace_path)
- .await?
+ let manager = local_snapshot_manager_for_view(&request.workspace_path).await?;
+ manager
.get_session_files(&request.session_id)
.await
.map_err(snapshot_port_error)
@@ -315,8 +324,8 @@ impl LocalWorkspaceSnapshotPort for CoreLocalWorkspaceSnapshot {
request: LocalWorkspaceSnapshotSessionRequest,
) -> PortResult {
validate_persisted_session_id(&request.session_id).map_err(runtime_port_error)?;
- let stats = ensure_local_snapshot_manager(&request.workspace_path)
- .await?
+ let manager = local_snapshot_manager_for_view(&request.workspace_path).await?;
+ let stats = manager
.get_session_stats_fact(&request.session_id)
.await
.map_err(snapshot_port_error)?;
@@ -503,6 +512,19 @@ impl CoreAgentRuntimeCompatibility {
}
}
+ /// Applies the same Core deployment owner before a product compatibility
+ /// path attaches to or mutates a structured workspace scope.
+ pub fn ensure_workspace_runtime_ownership(
+ &self,
+ request: &SessionStoragePathRequest,
+ ) -> BitFunResult<()> {
+ self.coordinator.ensure_workspace_runtime_ownership(
+ &request.workspace_path,
+ request.remote_connection_id.as_deref(),
+ request.remote_ssh_host.as_deref(),
+ )
+ }
+
pub async fn restore_session_from_storage_path(
&self,
storage_path: &Path,
@@ -1033,6 +1055,13 @@ impl AgentSessionForkPort for CoreSessionOperationsPort {
remote_connection_id,
remote_ssh_host,
} = request;
+ self.coordinator
+ .ensure_workspace_runtime_ownership(
+ Path::new(&workspace_path),
+ remote_connection_id.as_deref(),
+ remote_ssh_host.as_deref(),
+ )
+ .map_err(runtime_port_error)?;
let storage_path = self
.resolve_fork_storage_path(workspace_path, remote_connection_id, remote_ssh_host)
.await?;
@@ -1050,6 +1079,13 @@ impl AgentSessionForkPort for CoreSessionOperationsPort {
&self,
request: AgentSessionForkAtTurnRequest,
) -> PortResult {
+ self.coordinator
+ .ensure_workspace_runtime_ownership(
+ Path::new(&request.workspace_path),
+ request.remote_connection_id.as_deref(),
+ request.remote_ssh_host.as_deref(),
+ )
+ .map_err(runtime_port_error)?;
let storage_path = self
.resolve_fork_storage_path(
request.workspace_path,
@@ -1122,10 +1158,10 @@ mod tests {
#[allow(deprecated)]
use super::CoreProductAgentEventSource;
use super::{
- generate_core_session_usage_report, latest_persisted_turn_id, runtime_port_error,
- validate_latest_turn_fork_scope, validate_persisted_session_id,
- CoreAgentRuntimeCompatibility, CoreLocalWorkspaceSnapshot, CoreProductAgentRuntime,
- CoreProductEventQueueOwner, CoreSessionOperationsPort,
+ generate_core_session_usage_report, get_snapshot_manager_for_workspace,
+ latest_persisted_turn_id, runtime_port_error, validate_latest_turn_fork_scope,
+ validate_persisted_session_id, CoreAgentRuntimeCompatibility, CoreLocalWorkspaceSnapshot,
+ CoreProductAgentRuntime, CoreProductEventQueueOwner, CoreSessionOperationsPort,
};
use crate::agentic::coordination::{ConversationCoordinator, DialogScheduler};
use crate::agentic::events::{EventQueue, EventQueueConfig, EventRouter};
@@ -1307,6 +1343,26 @@ mod tests {
let _ = build;
}
+ #[test]
+ fn sdk_session_forks_reuse_the_coordinator_runtime_owner() {
+ let source = include_str!("product_runtime.rs");
+ let fork_impl = source
+ .split("impl AgentSessionForkPort for CoreSessionOperationsPort")
+ .nth(1)
+ .and_then(|source| source.split("impl AgentSessionUsagePort").next())
+ .expect("session fork implementation");
+
+ assert_eq!(
+ fork_impl
+ .matches("ensure_workspace_runtime_ownership")
+ .count(),
+ 2,
+ "latest-turn and explicit-turn forks must share the Coordinator ownership gate"
+ );
+ assert!(!fork_impl.contains("RuntimeOwnershipKey"));
+ assert!(!fork_impl.contains("try_acquire"));
+ }
+
#[test]
fn remaining_compatibility_operations_have_one_core_owned_facade() {
fn build(
@@ -1373,6 +1429,31 @@ mod tests {
.is_empty());
}
+ #[tokio::test]
+ async fn local_workspace_snapshot_views_do_not_initialize_a_writer() {
+ let workspace = TestWorkspace::new();
+ let port = CoreLocalWorkspaceSnapshot::build();
+ let request = LocalWorkspaceSnapshotSessionRequest {
+ workspace_path: workspace.path().to_path_buf(),
+ session_id: "session-view-only".to_string(),
+ };
+
+ assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none());
+ assert!(port
+ .get_session_files(request.clone())
+ .await
+ .expect("view-only files")
+ .is_empty());
+ assert_eq!(
+ port.get_session_stats(request)
+ .await
+ .expect("view-only stats")
+ .total_changes,
+ 0
+ );
+ assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none());
+ }
+
#[tokio::test]
async fn local_workspace_snapshot_port_rejects_non_local_inputs_before_backend_access() {
let workspace = TestWorkspace::new();
@@ -1514,6 +1595,16 @@ mod tests {
tool_pipeline,
event_queue,
Arc::new(EventRouter::new()),
+ Arc::new(
+ crate::runtime_ownership::CoreRuntimeOwnership::embedded_with_facts(
+ std::env::temp_dir().join(format!(
+ "bitfun-product-runtime-ownership-test-{}",
+ uuid::Uuid::new_v4()
+ )),
+ "bitfun".to_string(),
+ "test",
+ ),
+ ),
));
let token_usage_service = Arc::new(
TokenUsageService::new_in_base_dir(workspace.path().join("tokens"))
diff --git a/src/crates/assembly/core/src/runtime_ownership.rs b/src/crates/assembly/core/src/runtime_ownership.rs
new file mode 100644
index 0000000000..1b20b43dc8
--- /dev/null
+++ b/src/crates/assembly/core/src/runtime_ownership.rs
@@ -0,0 +1,386 @@
+//! First-party product assembly for local Agent Runtime ownership.
+//!
+//! The reusable lock primitive lives in `bitfun-services-core`. This owner
+//! selects one deployment for the process, retains acquired workspace leases,
+//! and keeps that deployment fact out of Agent Runtime SDK and wire contracts.
+
+use std::collections::{HashMap, HashSet};
+use std::path::{Path, PathBuf};
+use std::sync::Mutex;
+
+pub use bitfun_services_core::runtime_ownership::RuntimeDeployment;
+use bitfun_services_core::runtime_ownership::{
+ RuntimeOwnershipError, RuntimeOwnershipKey, WorkspaceRuntimeOwnership,
+};
+use log::{info, warn};
+
+use crate::infrastructure::PathManager;
+
+const DEFAULT_PRODUCT_IDENTITY: &str = "bitfun";
+
+enum CoreRuntimeOwnershipDeployment {
+ Embedded {
+ leases: Mutex>,
+ },
+ Shared {
+ key: RuntimeOwnershipKey,
+ _lease: WorkspaceRuntimeOwnership,
+ },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+struct VerifiedRemoteRuntimeScope {
+ workspace_path: String,
+ connection_id: String,
+ ssh_host: Option,
+}
+
+/// Process-lifetime owner for first-party local Agent Runtime workspaces.
+pub struct CoreRuntimeOwnership {
+ ownership_root: PathBuf,
+ product_identity: String,
+ entrypoint: &'static str,
+ deployment: CoreRuntimeOwnershipDeployment,
+ verified_remote_scopes: Mutex>,
+}
+
+impl CoreRuntimeOwnership {
+ /// Builds and acquires the process owner for a fixed local workspace.
+ pub fn fixed_workspace(
+ path_manager: &PathManager,
+ entrypoint: &'static str,
+ workspace: &Path,
+ deployment: RuntimeDeployment,
+ ) -> Result {
+ match deployment {
+ RuntimeDeployment::Embedded => {
+ let owner = Self::embedded(path_manager, entrypoint);
+ owner.ensure_local_workspace(workspace)?;
+ Ok(owner)
+ }
+ RuntimeDeployment::Shared => Self::shared(path_manager, entrypoint, workspace),
+ }
+ }
+
+ /// Builds the normal first-party Embedded deployment.
+ pub fn embedded(path_manager: &PathManager, entrypoint: &'static str) -> Self {
+ Self::embedded_with_facts(
+ path_manager.agent_runtime_ownership_dir(),
+ product_identity().to_string(),
+ entrypoint,
+ )
+ }
+
+ /// Builds the opt-in single-workspace Shared deployment and acquires its
+ /// exclusive lease before any Agent Runtime is initialized.
+ pub fn shared(
+ path_manager: &PathManager,
+ entrypoint: &'static str,
+ workspace: &Path,
+ ) -> Result {
+ Self::shared_with_facts(
+ path_manager.agent_runtime_ownership_dir(),
+ product_identity().to_string(),
+ entrypoint,
+ workspace,
+ )
+ }
+
+ pub(crate) fn embedded_with_facts(
+ ownership_root: PathBuf,
+ product_identity: String,
+ entrypoint: &'static str,
+ ) -> Self {
+ Self {
+ ownership_root,
+ product_identity,
+ entrypoint,
+ deployment: CoreRuntimeOwnershipDeployment::Embedded {
+ leases: Mutex::new(HashMap::new()),
+ },
+ verified_remote_scopes: Mutex::new(HashSet::new()),
+ }
+ }
+
+ pub(crate) fn shared_with_facts(
+ ownership_root: PathBuf,
+ product_identity: String,
+ entrypoint: &'static str,
+ workspace: &Path,
+ ) -> Result {
+ let key = RuntimeOwnershipKey::for_workspace(workspace, &product_identity)?;
+ let lease = WorkspaceRuntimeOwnership::try_acquire(
+ &ownership_root,
+ &key,
+ RuntimeDeployment::Shared,
+ )
+ .map_err(|error| {
+ log_acquisition_failure(entrypoint, RuntimeDeployment::Shared, &key, &error);
+ error
+ })?;
+ log_acquired(entrypoint, RuntimeDeployment::Shared, &key);
+ Ok(Self {
+ ownership_root,
+ product_identity,
+ entrypoint,
+ deployment: CoreRuntimeOwnershipDeployment::Shared { key, _lease: lease },
+ verified_remote_scopes: Mutex::new(HashSet::new()),
+ })
+ }
+
+ /// Records a Remote workspace binding resolved by the Workspace owner.
+ /// Raw transport strings are never sufficient to bypass local ownership.
+ pub(crate) fn register_verified_remote_scope(
+ &self,
+ workspace: &Path,
+ connection_id: &str,
+ ssh_host: Option<&str>,
+ ) -> Result<(), CoreRuntimeOwnershipError> {
+ let scope = verified_remote_scope(workspace, connection_id, ssh_host)?;
+ self.verified_remote_scopes
+ .lock()
+ .map_err(|_| CoreRuntimeOwnershipError::OwnershipStateUnavailable)?
+ .insert(scope);
+ Ok(())
+ }
+
+ /// Acquires the local workspace unless structured remote facts assign
+ /// execution ownership to another host.
+ pub fn ensure_workspace_scope(
+ &self,
+ workspace: &Path,
+ remote_connection_id: Option<&str>,
+ remote_ssh_host: Option<&str>,
+ ) -> Result<(), CoreRuntimeOwnershipError> {
+ if let Some(connection_id) = remote_connection_id
+ .map(str::trim)
+ .filter(|connection_id| !connection_id.is_empty())
+ {
+ let requested = verified_remote_scope(workspace, connection_id, remote_ssh_host)?;
+ let verified = self
+ .verified_remote_scopes
+ .lock()
+ .map_err(|_| CoreRuntimeOwnershipError::OwnershipStateUnavailable)?
+ .iter()
+ .any(|known| remote_scope_matches(known, &requested));
+ if verified {
+ return Ok(());
+ }
+ return Err(CoreRuntimeOwnershipError::UnverifiedRemoteWorkspaceScope);
+ }
+ self.ensure_local_workspace(workspace)
+ }
+
+ /// Idempotently retains ownership of one local workspace for this process.
+ pub fn ensure_local_workspace(
+ &self,
+ workspace: &Path,
+ ) -> Result<(), CoreRuntimeOwnershipError> {
+ let key = RuntimeOwnershipKey::for_workspace(workspace, &self.product_identity)?;
+ match &self.deployment {
+ CoreRuntimeOwnershipDeployment::Embedded { leases } => {
+ let mut leases = leases
+ .lock()
+ .map_err(|_| CoreRuntimeOwnershipError::OwnershipStateUnavailable)?;
+ if leases.contains_key(&key) {
+ return Ok(());
+ }
+ let lease = WorkspaceRuntimeOwnership::try_acquire(
+ &self.ownership_root,
+ &key,
+ RuntimeDeployment::Embedded,
+ )
+ .map_err(|error| {
+ log_acquisition_failure(
+ self.entrypoint,
+ RuntimeDeployment::Embedded,
+ &key,
+ &error,
+ );
+ error
+ })?;
+ log_acquired(self.entrypoint, RuntimeDeployment::Embedded, &key);
+ leases.insert(key, lease);
+ Ok(())
+ }
+ CoreRuntimeOwnershipDeployment::Shared {
+ key: shared_key, ..
+ } if shared_key == &key => Ok(()),
+ CoreRuntimeOwnershipDeployment::Shared { .. } => {
+ warn!(
+ "Shared Agent Runtime rejected a second local workspace: entrypoint={}, error_code=shared_runtime_workspace_mismatch",
+ self.entrypoint
+ );
+ Err(CoreRuntimeOwnershipError::SharedRuntimeWorkspaceMismatch)
+ }
+ }
+ }
+
+ /// Tests whether another local Runtime currently owns this workspace.
+ pub fn runtime_owner_present(
+ path_manager: &PathManager,
+ workspace: &Path,
+ ) -> Result {
+ let key = RuntimeOwnershipKey::for_workspace(workspace, product_identity())?;
+ match WorkspaceRuntimeOwnership::try_acquire(
+ &path_manager.agent_runtime_ownership_dir(),
+ &key,
+ RuntimeDeployment::Shared,
+ ) {
+ Ok(_) => Ok(false),
+ Err(RuntimeOwnershipError::OwnershipUnavailable { .. }) => Ok(true),
+ Err(error) => Err(error.into()),
+ }
+ }
+
+ /// Distinguishes compatible Embedded shared locks from a Shared Runtime's
+ /// exclusive lock without publishing another deployment protocol.
+ pub fn embedded_runtime_owner_present(
+ path_manager: &PathManager,
+ workspace: &Path,
+ ) -> Result {
+ let key = RuntimeOwnershipKey::for_workspace(workspace, product_identity())?;
+ let ownership_root = path_manager.agent_runtime_ownership_dir();
+ match WorkspaceRuntimeOwnership::try_acquire(
+ &ownership_root,
+ &key,
+ RuntimeDeployment::Shared,
+ ) {
+ Ok(_) => Ok(false),
+ Err(RuntimeOwnershipError::OwnershipUnavailable { .. }) => {
+ match WorkspaceRuntimeOwnership::try_acquire(
+ &ownership_root,
+ &key,
+ RuntimeDeployment::Embedded,
+ ) {
+ Ok(_) => Ok(true),
+ Err(RuntimeOwnershipError::OwnershipUnavailable { .. }) => Ok(false),
+ Err(error) => Err(error.into()),
+ }
+ }
+ Err(error) => Err(error.into()),
+ }
+ }
+
+ /// Product-wide identity used by ownership and private first-party IPC.
+ pub fn distribution_identity() -> &'static str {
+ product_identity()
+ }
+
+ pub fn error_message(&self, error: &CoreRuntimeOwnershipError) -> String {
+ let deployment = match &self.deployment {
+ CoreRuntimeOwnershipDeployment::Embedded { .. } => RuntimeDeployment::Embedded,
+ CoreRuntimeOwnershipDeployment::Shared { .. } => RuntimeDeployment::Shared,
+ };
+ error.startup_message(deployment, self.entrypoint)
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum CoreRuntimeOwnershipError {
+ #[error(transparent)]
+ Primitive(#[from] RuntimeOwnershipError),
+ #[error("runtime ownership state is unavailable")]
+ OwnershipStateUnavailable,
+ #[error("Shared Agent Runtime is limited to its startup workspace")]
+ SharedRuntimeWorkspaceMismatch,
+ #[error("remote workspace binding was not verified by the Workspace owner")]
+ UnverifiedRemoteWorkspaceScope,
+}
+
+impl CoreRuntimeOwnershipError {
+ pub fn code(&self) -> &'static str {
+ match self {
+ Self::Primitive(error) => error.code(),
+ Self::OwnershipStateUnavailable => "ownership_state_unavailable",
+ Self::SharedRuntimeWorkspaceMismatch => "shared_runtime_workspace_mismatch",
+ Self::UnverifiedRemoteWorkspaceScope => "unverified_remote_workspace_scope",
+ }
+ }
+
+ pub fn startup_message(&self, deployment: RuntimeDeployment, entrypoint: &str) -> String {
+ let prefix = format!("Agent Runtime ownership failed ({}): {self}", self.code());
+ if !matches!(
+ self,
+ Self::Primitive(RuntimeOwnershipError::OwnershipUnavailable { .. })
+ ) {
+ return prefix;
+ }
+ let guidance = match deployment {
+ RuntimeDeployment::Embedded if entrypoint == "cli-interactive" => "A Shared TUI Runtime owns this workspace; use `bitfun chat --shared`, or close its clients and wait up to 30 seconds",
+ RuntimeDeployment::Embedded => "A Shared TUI Runtime owns this workspace; close its clients and wait up to 30 seconds before retrying this application",
+ RuntimeDeployment::Shared => "An Embedded BitFun process owns this workspace; close it before using `--shared`",
+ };
+ format!("{prefix}. {guidance}")
+ }
+}
+
+fn verified_remote_scope(
+ workspace: &Path,
+ connection_id: &str,
+ ssh_host: Option<&str>,
+) -> Result {
+ let connection_id = connection_id.trim();
+ if connection_id.is_empty() {
+ return Err(CoreRuntimeOwnershipError::UnverifiedRemoteWorkspaceScope);
+ }
+ let mut workspace_path = workspace.to_string_lossy().replace('\\', "/");
+ while workspace_path.len() > 1 && workspace_path.ends_with('/') {
+ workspace_path.pop();
+ }
+ if workspace_path.is_empty() {
+ return Err(CoreRuntimeOwnershipError::UnverifiedRemoteWorkspaceScope);
+ }
+ Ok(VerifiedRemoteRuntimeScope {
+ workspace_path,
+ connection_id: connection_id.to_string(),
+ ssh_host: ssh_host
+ .map(str::trim)
+ .filter(|host| !host.is_empty())
+ .map(str::to_ascii_lowercase),
+ })
+}
+
+fn remote_scope_matches(
+ known: &VerifiedRemoteRuntimeScope,
+ requested: &VerifiedRemoteRuntimeScope,
+) -> bool {
+ known.workspace_path == requested.workspace_path
+ && known.connection_id == requested.connection_id
+ && requested
+ .ssh_host
+ .as_ref()
+ .map_or(true, |host| known.ssh_host.as_ref() == Some(host))
+}
+
+fn product_identity() -> &'static str {
+ option_env!("BITFUN_PRODUCT_BINARY_NAME").unwrap_or(DEFAULT_PRODUCT_IDENTITY)
+}
+
+fn log_acquired(entrypoint: &str, deployment: RuntimeDeployment, key: &RuntimeOwnershipKey) {
+ info!(
+ "Agent Runtime ownership acquired: deployment={}, entrypoint={}, ownership_key_prefix={}",
+ deployment,
+ entrypoint,
+ key_prefix(key)
+ );
+}
+
+fn log_acquisition_failure(
+ entrypoint: &str,
+ deployment: RuntimeDeployment,
+ key: &RuntimeOwnershipKey,
+ error: &RuntimeOwnershipError,
+) {
+ warn!(
+ "Agent Runtime ownership unavailable: deployment={}, entrypoint={}, error_code={}, ownership_key_prefix={}",
+ deployment,
+ entrypoint,
+ error.code(),
+ key_prefix(key)
+ );
+}
+
+fn key_prefix(key: &RuntimeOwnershipKey) -> &str {
+ key.as_str().get(..12).unwrap_or(key.as_str())
+}
diff --git a/src/crates/assembly/core/src/runtime_ownership_tests.rs b/src/crates/assembly/core/src/runtime_ownership_tests.rs
new file mode 100644
index 0000000000..eba6d90846
--- /dev/null
+++ b/src/crates/assembly/core/src/runtime_ownership_tests.rs
@@ -0,0 +1,215 @@
+use std::sync::{Arc, Barrier};
+
+use bitfun_services_core::runtime_ownership::{
+ RuntimeDeployment, RuntimeOwnershipKey, WorkspaceRuntimeOwnership,
+};
+use tempfile::tempdir;
+
+use crate::runtime_ownership::CoreRuntimeOwnership;
+
+#[test]
+fn embedded_owner_is_idempotent_and_keeps_one_workspace_lease() {
+ let ownership_root = tempdir().expect("ownership root");
+ let workspace = tempdir().expect("workspace");
+ let owner = CoreRuntimeOwnership::embedded_with_facts(
+ ownership_root.path().to_path_buf(),
+ "bitfun".to_string(),
+ "test",
+ );
+
+ owner
+ .ensure_local_workspace(workspace.path())
+ .expect("first acquisition");
+ owner
+ .ensure_local_workspace(&workspace.path().join("."))
+ .expect("idempotent acquisition");
+
+ let key =
+ RuntimeOwnershipKey::for_workspace(workspace.path(), "bitfun").expect("ownership key");
+ assert!(WorkspaceRuntimeOwnership::try_acquire(
+ ownership_root.path(),
+ &key,
+ RuntimeDeployment::Shared,
+ )
+ .is_err());
+}
+
+#[test]
+fn embedded_owner_serializes_concurrent_first_acquisition() {
+ let ownership_root = tempdir().expect("ownership root");
+ let workspace = tempdir().expect("workspace");
+ let owner = Arc::new(CoreRuntimeOwnership::embedded_with_facts(
+ ownership_root.path().to_path_buf(),
+ "bitfun".to_string(),
+ "test",
+ ));
+ let barrier = Arc::new(Barrier::new(5));
+ let mut threads = Vec::new();
+ for _ in 0..4 {
+ let owner = Arc::clone(&owner);
+ let barrier = Arc::clone(&barrier);
+ let workspace = workspace.path().to_path_buf();
+ threads.push(std::thread::spawn(move || {
+ barrier.wait();
+ owner.ensure_local_workspace(&workspace)
+ }));
+ }
+ barrier.wait();
+ for thread in threads {
+ thread
+ .join()
+ .expect("acquisition thread")
+ .expect("concurrent acquisition");
+ }
+
+ let key =
+ RuntimeOwnershipKey::for_workspace(workspace.path(), "bitfun").expect("ownership key");
+ assert!(WorkspaceRuntimeOwnership::try_acquire(
+ ownership_root.path(),
+ &key,
+ RuntimeDeployment::Shared,
+ )
+ .is_err());
+}
+
+#[test]
+fn shared_owner_accepts_only_its_startup_workspace() {
+ let ownership_root = tempdir().expect("ownership root");
+ let workspace = tempdir().expect("workspace");
+ let other_workspace = tempdir().expect("other workspace");
+ let owner = CoreRuntimeOwnership::shared_with_facts(
+ ownership_root.path().to_path_buf(),
+ "bitfun".to_string(),
+ "test",
+ workspace.path(),
+ )
+ .expect("shared owner");
+
+ owner
+ .ensure_local_workspace(workspace.path())
+ .expect("same workspace");
+ let error = owner
+ .ensure_local_workspace(other_workspace.path())
+ .expect_err("second workspace must fail closed");
+
+ assert_eq!(error.code(), "shared_runtime_workspace_mismatch");
+}
+
+#[test]
+fn unverified_remote_workspace_cannot_bypass_local_ownership() {
+ let ownership_root = tempdir().expect("ownership root");
+ let missing_local_path = ownership_root.path().join("remote-path-is-not-local");
+ let owner = CoreRuntimeOwnership::embedded_with_facts(
+ ownership_root.path().to_path_buf(),
+ "bitfun".to_string(),
+ "test",
+ );
+
+ let error = owner
+ .ensure_workspace_scope(&missing_local_path, Some("connection"), Some("host"))
+ .expect_err("raw remote facts are not execution authority");
+ assert_eq!(error.code(), "unverified_remote_workspace_scope");
+ assert_eq!(
+ std::fs::read_dir(ownership_root.path())
+ .expect("read ownership root")
+ .count(),
+ 0
+ );
+}
+
+#[test]
+fn verified_remote_workspace_does_not_touch_local_ownership() {
+ let ownership_root = tempdir().expect("ownership root");
+ let missing_local_path = ownership_root.path().join("remote-path-is-not-local");
+ let owner = CoreRuntimeOwnership::embedded_with_facts(
+ ownership_root.path().to_path_buf(),
+ "bitfun".to_string(),
+ "test",
+ );
+
+ owner
+ .register_verified_remote_scope(&missing_local_path, "connection", Some("host"))
+ .expect("verified remote scope");
+ owner
+ .ensure_workspace_scope(&missing_local_path, Some("connection"), Some("host"))
+ .expect("verified remote scope must skip local ownership");
+ assert_eq!(
+ std::fs::read_dir(ownership_root.path())
+ .expect("read ownership root")
+ .count(),
+ 0
+ );
+}
+
+#[test]
+fn ssh_host_without_connection_id_cannot_bypass_local_ownership() {
+ let ownership_root = tempdir().expect("ownership root");
+ let workspace = tempdir().expect("workspace");
+ let shared = CoreRuntimeOwnership::shared_with_facts(
+ ownership_root.path().to_path_buf(),
+ "bitfun".to_string(),
+ "shared-test",
+ workspace.path(),
+ )
+ .expect("shared owner");
+ let embedded = CoreRuntimeOwnership::embedded_with_facts(
+ ownership_root.path().to_path_buf(),
+ "bitfun".to_string(),
+ "embedded-test",
+ );
+
+ let error = embedded
+ .ensure_workspace_scope(workspace.path(), None, Some("host-only"))
+ .expect_err("host-only facts must still protect local storage");
+
+ assert_eq!(error.code(), "runtime_ownership_unavailable");
+ drop(shared);
+}
+
+#[test]
+fn startup_errors_expose_codes_without_mislabeling_path_failures_as_conflicts() {
+ let ownership_root = tempdir().expect("ownership root");
+ let owner = CoreRuntimeOwnership::embedded_with_facts(
+ ownership_root.path().to_path_buf(),
+ "bitfun".to_string(),
+ "test",
+ );
+ let missing = ownership_root.path().join("missing-workspace");
+
+ let error = owner
+ .ensure_local_workspace(&missing)
+ .expect_err("missing workspace must fail");
+ let message = error.startup_message(RuntimeDeployment::Embedded, "sdk-host");
+
+ assert!(message.contains("canonicalize_workspace_failed"));
+ assert!(!message.contains("Shared TUI Runtime owns"));
+}
+
+#[test]
+fn ownership_conflict_guidance_matches_the_calling_product_surface() {
+ let ownership_root = tempdir().expect("ownership root");
+ let workspace = tempdir().expect("workspace");
+ let shared = CoreRuntimeOwnership::shared_with_facts(
+ ownership_root.path().to_path_buf(),
+ "bitfun".to_string(),
+ "shared-tui-runtime",
+ workspace.path(),
+ )
+ .expect("shared owner");
+ let error = CoreRuntimeOwnership::embedded_with_facts(
+ ownership_root.path().to_path_buf(),
+ "bitfun".to_string(),
+ "sdk-host",
+ )
+ .ensure_local_workspace(workspace.path())
+ .expect_err("Shared owner must block an Embedded SDK Host");
+
+ let tui_message = error.startup_message(RuntimeDeployment::Embedded, "cli-interactive");
+ assert!(tui_message.contains("bitfun chat --shared"));
+ for entrypoint in ["cli-headless", "acp", "sdk-host", "desktop"] {
+ let message = error.startup_message(RuntimeDeployment::Embedded, entrypoint);
+ assert!(!message.contains("bitfun chat --shared"), "{entrypoint}");
+ assert!(message.contains("close its clients"), "{entrypoint}");
+ }
+ drop(shared);
+}
diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs
index 2763aba94c..34fe3db3d3 100644
--- a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs
+++ b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs
@@ -455,6 +455,27 @@ async fn select_model(
// ── Public entry points ────────────────────────────────────────────
+async fn open_bot_workspace(
+ workspace_service: &crate::service::workspace::WorkspaceService,
+ path: std::path::PathBuf,
+ remote_connection_id: Option<&str>,
+ remote_ssh_host: Option<&str>,
+ log_context: &str,
+) -> Result {
+ let coordinator = crate::agentic::coordination::get_global_coordinator()
+ .ok_or_else(|| "Conversation coordinator not initialized".to_string())?;
+ coordinator
+ .open_workspace_with_runtime_ownership(
+ workspace_service,
+ path,
+ remote_connection_id,
+ remote_ssh_host,
+ log_context,
+ )
+ .await
+ .map_err(|error| error.to_string())
+}
+
/// IM pairing bootstrap: assistant mode + default assistant workspace + new
/// Claw session. Mutates `state.display_mode/current_assistant/
/// current_session_id` on success.
@@ -488,14 +509,16 @@ pub async fn bootstrap_im_chat_after_pairing(state: &mut BotChatState) -> String
return s.bootstrap_workspace_unavailable.to_string();
};
- let path_buf = ws_info.root_path.clone();
- if let Err(e) = ws_service.open_workspace(path_buf.clone()).await {
- return format!("{}{e}", s.workspace_open_failed_prefix);
- }
- if let Err(e) =
- crate::service::snapshot::initialize_snapshot_manager_for_workspace(path_buf, None).await
+ if let Err(e) = open_bot_workspace(
+ ws_service.as_ref(),
+ ws_info.root_path.clone(),
+ None,
+ None,
+ "IM bot pairing",
+ )
+ .await
{
- error!("IM bot bootstrap: snapshot init after pairing: {e}");
+ return format!("{}{e}", s.workspace_open_failed_prefix);
}
state.current_assistant = Some(ws_info.root_path.to_string_lossy().to_string());
@@ -1205,23 +1228,16 @@ async fn select_workspace(
}
};
let path_buf = std::path::PathBuf::from(&choice.path);
- match ws_service
- .open_workspace_resolving_known(
- path_buf,
- choice.remote_connection_id.as_deref(),
- choice.remote_ssh_host.as_deref(),
- )
- .await
+ match open_bot_workspace(
+ ws_service.as_ref(),
+ path_buf,
+ choice.remote_connection_id.as_deref(),
+ choice.remote_ssh_host.as_deref(),
+ "bot workspace switch",
+ )
+ .await
{
Ok(info) => {
- if let Err(e) = crate::service::snapshot::initialize_snapshot_manager_for_workspace(
- info.root_path.clone(),
- None,
- )
- .await
- {
- error!("Failed to init snapshot after bot workspace switch: {e}");
- }
let workspace_path = info.root_path.to_string_lossy().to_string();
let remote_connection_id = info
.remote_ssh_connection_id()
@@ -1280,16 +1296,16 @@ async fn select_assistant(
}
};
let path_buf = std::path::PathBuf::from(path);
- match ws_service.open_workspace(path_buf).await {
- Ok(info) => {
- if let Err(e) = crate::service::snapshot::initialize_snapshot_manager_for_workspace(
- info.root_path.clone(),
- None,
- )
- .await
- {
- error!("Failed to init snapshot after bot assistant switch: {e}");
- }
+ match open_bot_workspace(
+ ws_service.as_ref(),
+ path_buf,
+ None,
+ None,
+ "bot assistant switch",
+ )
+ .await
+ {
+ Ok(_info) => {
state.current_assistant = Some(path.to_string());
state.current_assistant_name = Some(name.to_string());
state.current_session_id = None;
diff --git a/src/crates/assembly/core/src/service/snapshot/isolation_manager.rs b/src/crates/assembly/core/src/service/snapshot/isolation_manager.rs
index cb8c62a07d..47c044b0f7 100644
--- a/src/crates/assembly/core/src/service/snapshot/isolation_manager.rs
+++ b/src/crates/assembly/core/src/service/snapshot/isolation_manager.rs
@@ -142,20 +142,29 @@ impl IsolationManager {
/// Validates that a file path is safe (does not impact Git).
pub fn is_path_safe_for_modification(&self, path: &Path) -> bool {
- if !path.starts_with(&self.workspace_dir) {
- return false;
- }
-
let git_dir = self.workspace_dir.join(".git");
- if path.starts_with(&git_dir) {
+ if path_starts_with_scope(path, &git_dir)
+ || path_starts_with_scope(path, &self.runtime_context.runtime_root)
+ {
return false;
}
- if path.starts_with(&self.runtime_context.runtime_root) {
+ let Some(path) = canonicalize_for_scope(path) else {
return false;
- }
+ };
+ let Some(workspace_dir) = canonicalize_for_scope(&self.workspace_dir) else {
+ return false;
+ };
+ let Some(git_dir) = canonicalize_for_scope(&git_dir) else {
+ return false;
+ };
+ let Some(runtime_root) = canonicalize_for_scope(&self.runtime_context.runtime_root) else {
+ return false;
+ };
- true
+ path_starts_with_scope(&path, &workspace_dir)
+ && !path_starts_with_scope(&path, &git_dir)
+ && !path_starts_with_scope(&path, &runtime_root)
}
/// Returns a path relative to the workspace directory.
@@ -171,3 +180,149 @@ impl IsolationManager {
})
}
}
+
+fn canonicalize_for_scope(path: &Path) -> Option {
+ let mut ancestor = path;
+ let mut missing_suffix = Vec::new();
+
+ loop {
+ if let Ok(mut resolved) = dunce::canonicalize(ancestor) {
+ for component in missing_suffix.iter().rev() {
+ resolved.push(component);
+ }
+ return Some(resolved);
+ }
+
+ missing_suffix.push(ancestor.file_name()?.to_os_string());
+ ancestor = ancestor.parent()?;
+ }
+}
+
+#[cfg(not(windows))]
+fn path_starts_with_scope(path: &Path, root: &Path) -> bool {
+ path.starts_with(root)
+}
+
+#[cfg(windows)]
+fn path_starts_with_scope(path: &Path, root: &Path) -> bool {
+ use std::os::windows::ffi::OsStrExt;
+
+ fn lower_ascii(unit: u16) -> u16 {
+ if (u16::from(b'A')..=u16::from(b'Z')).contains(&unit) {
+ unit + u16::from(b'a' - b'A')
+ } else {
+ unit
+ }
+ }
+
+ let mut path_components = path.components();
+ root.components().all(|root_component| {
+ path_components.next().is_some_and(|path_component| {
+ path_component
+ .as_os_str()
+ .encode_wide()
+ .map(lower_ascii)
+ .eq(root_component.as_os_str().encode_wide().map(lower_ascii))
+ })
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::IsolationManager;
+ use crate::service::workspace_runtime::{WorkspaceRuntimeContext, WorkspaceRuntimeTarget};
+ use std::path::{Path, PathBuf};
+
+ fn manager(workspace_dir: PathBuf, runtime_root: PathBuf) -> IsolationManager {
+ let runtime_context = WorkspaceRuntimeContext::new(
+ WorkspaceRuntimeTarget::LocalWorkspace {
+ workspace_root: workspace_dir.clone(),
+ },
+ runtime_root,
+ );
+ IsolationManager::new(workspace_dir, runtime_context)
+ }
+
+ fn aliased_workspace(root: &Path) -> PathBuf {
+ let anchor = root.join("alias-anchor");
+ std::fs::create_dir_all(&anchor).expect("alias anchor");
+ anchor.join("..")
+ }
+
+ #[test]
+ fn accepts_existing_file_through_workspace_alias() {
+ let workspace = tempfile::tempdir().expect("workspace");
+ let workspace_root = dunce::canonicalize(workspace.path()).expect("canonical workspace");
+ let alias = aliased_workspace(workspace.path());
+ let file = workspace.path().join("tracked.txt");
+ std::fs::write(&file, "tracked").expect("tracked file");
+ let manager = manager(workspace_root, workspace.path().join(".bitfun"));
+
+ assert!(manager.is_path_safe_for_modification(&alias.join("tracked.txt")));
+ }
+
+ #[test]
+ fn accepts_nested_new_file_through_workspace_alias() {
+ let workspace = tempfile::tempdir().expect("workspace");
+ let workspace_root = dunce::canonicalize(workspace.path()).expect("canonical workspace");
+ let alias = aliased_workspace(workspace.path());
+ let manager = manager(workspace_root, workspace.path().join(".bitfun"));
+
+ assert!(manager.is_path_safe_for_modification(&alias.join("new/deep/file.txt")));
+ }
+
+ #[test]
+ fn rejects_runtime_path_before_alias_resolution() {
+ let workspace = tempfile::tempdir().expect("workspace");
+ let workspace_root = dunce::canonicalize(workspace.path()).expect("canonical workspace");
+ let alias = aliased_workspace(workspace.path());
+ let runtime_root = alias.join(".bitfun");
+ std::fs::create_dir_all(&runtime_root).expect("runtime root");
+ let manager = manager(workspace_root, runtime_root.clone());
+
+ assert!(!manager.is_path_safe_for_modification(&runtime_root.join("state.json")));
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn rejects_case_variant_missing_git_directory() {
+ let workspace = tempfile::tempdir().expect("workspace");
+ let workspace_root = dunce::canonicalize(workspace.path()).expect("canonical workspace");
+ let manager = manager(workspace_root, workspace.path().join(".bitfun"));
+
+ assert!(!manager.is_path_safe_for_modification(&workspace.path().join(".GIT/config")));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn rejects_git_symlink_target_inside_workspace() {
+ use std::os::unix::fs::symlink;
+
+ let workspace = tempfile::tempdir().expect("workspace");
+ let metadata = workspace.path().join("metadata");
+ std::fs::create_dir_all(&metadata).expect("metadata target");
+ std::fs::write(metadata.join("config"), "config").expect("git config");
+ symlink(&metadata, workspace.path().join(".git")).expect("git symlink");
+ let workspace_root = dunce::canonicalize(workspace.path()).expect("canonical workspace");
+ let manager = manager(workspace_root, workspace.path().join(".bitfun"));
+
+ assert!(!manager.is_path_safe_for_modification(&workspace.path().join(".git/config")));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn rejects_workspace_symlink_that_escapes_scope() {
+ use std::os::unix::fs::symlink;
+
+ let workspace = tempfile::tempdir().expect("workspace");
+ let outside = tempfile::tempdir().expect("outside");
+ std::fs::write(outside.path().join("outside.txt"), "outside").expect("outside file");
+ symlink(outside.path(), workspace.path().join("escape")).expect("escape symlink");
+ let workspace_root = dunce::canonicalize(workspace.path()).expect("canonical workspace");
+ let manager = manager(workspace_root, workspace.path().join(".bitfun"));
+
+ assert!(
+ !manager.is_path_safe_for_modification(&workspace.path().join("escape/outside.txt"))
+ );
+ }
+}
diff --git a/src/crates/assembly/core/src/service/snapshot/manager.rs b/src/crates/assembly/core/src/service/snapshot/manager.rs
index b45af79a6c..8dc144d96e 100644
--- a/src/crates/assembly/core/src/service/snapshot/manager.rs
+++ b/src/crates/assembly/core/src/service/snapshot/manager.rs
@@ -37,7 +37,7 @@ impl SnapshotManager {
config: Option,
) -> SnapshotResult {
#[cfg(test)]
- record_snapshot_manager_new_for_test().await;
+ record_snapshot_manager_new_for_test(&workspace_dir).await;
info!(
"Creating snapshot manager: workspace={}",
@@ -329,20 +329,44 @@ fn snapshot_manager_init_locks() -> &'static AsyncMutex Arc> {
+ let workspace_key = snapshot_workspace_key(workspace_dir);
let mut locks = snapshot_manager_init_locks().lock().await;
locks
- .entry(workspace_dir.to_path_buf())
+ .entry(workspace_key)
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
.clone()
}
+fn snapshot_workspace_key(workspace_dir: &Path) -> PathBuf {
+ dunce::canonicalize(workspace_dir).unwrap_or_else(|_| workspace_dir.to_path_buf())
+}
+
#[cfg(test)]
static SNAPSHOT_MANAGER_NEW_COUNT_FOR_TEST: AtomicUsize = AtomicUsize::new(0);
#[cfg(test)]
static SNAPSHOT_MANAGER_NEW_DELAY_MS_FOR_TEST: AtomicU64 = AtomicU64::new(0);
#[cfg(test)]
-async fn record_snapshot_manager_new_for_test() {
+fn snapshot_manager_observed_workspace_for_test() -> &'static StdRwLock