Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ Instruction、Skill、Hook 和显式复制成 BitFun 原生配置的内容继续

### 5.1 首页

首页沿用现有约 600px 正文最大宽度,按以下顺序纵向排列:
首页沿用现有 `ConfigPageLayout` 的 760px 正文最大宽度,按以下顺序纵向排列:

1. 标题和一句说明;
2. “需要处理”摘要,仅在有真实待办时显示;
Expand Down
2 changes: 1 addition & 1 deletion docs/plans/external-ai-app-connection-experience-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ pnpm run type-check:web
- `ExternalAppReview`;
- `ExternalAdvancedSettings`;
- 无策略判断的 presentation helpers。
3. 首页使用现有约 600px 单列阅读轴:标题、真实待办、应用列表、高级设置。
3. 首页使用现有 `ConfigPageLayout` 的 760px 单列阅读轴:标题、应用列表、高级设置。真实的任务相关待办通过就地提示或状态变化处理,不把无法归属的系统诊断聚合成首页数量
4. 每个应用行只显示应用名、一个状态、一句结果摘要和唯一主操作;有工作区时主操作明确标注“仅当前工作区”,没有工作区时先进入详情选择范围。来源路径、能力清单、冲突和诊断进入详情。
5. 详情按“结果优先、控制后置”排列;连接完成显示生效范围、已启用、待确认和受限摘要。`user_default` 只在详情/高级设置中提供,并在提交前再次展示会影响同一执行域的所有工作区。
6. 批量确认页面先使用快照摘要,再按需分页读取项目引用;按类别展示数量、主要风险、共享推荐状态和安全上限,技术详情按需展开,高风险默认未选。提交使用同代推荐/空集合基线和用户改动项,不为提交强制读取全部页面;首页轮询不读取项目页面。
Expand Down
17 changes: 17 additions & 0 deletions scripts/core-boundaries/rules/source/public-api-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,23 @@ export const externalSourceCorePublicApiEntries = [
'Desktop external-source configuration host adapter',
true,
),
...[
'unacknowledged_external_ecosystems',
'acknowledge_external_ecosystems',
].map((symbol) => ({
symbol,
owner: 'bitfun-core external source composition facade',
consumer: 'Desktop settings navigation and CLI/TUI external application entry points',
verification:
'core acknowledgement persistence and execution-domain scoping tests, plus Desktop and TUI first-discovery hint tests',
p0: 'first-discovery hint for external applications shared by GUI and TUI',
contractSlice: contractSlices.externalSourceCommandContract,
wireImpact: true,
rationale:
'both surfaces must derive "an external application the user has not seen" from one owner, otherwise GUI and TUI drift; awareness stays outside the preference-revision contract because it grants nothing and only suppresses a hint',
exit:
'remove once the versioned application-level read model owns notice state, together with its cross-surface deduplication tests',
})),
...[
'ExternalToolActivationState',
'ExternalToolApprovalRequest',
Expand Down
72 changes: 62 additions & 10 deletions src/apps/desktop/src/api/external_sources_api.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
//! Desktop host API for ecosystem-neutral external AI application sources.

use bitfun_core::external_sources::{
apply_external_source_control_action, choose_external_mcp_conflict,
choose_external_subagent_conflict, expand_external_prompt_command,
external_source_location_for_host_action, external_source_snapshot,
acknowledge_external_ecosystems, apply_external_source_control_action,
choose_external_mcp_conflict, choose_external_subagent_conflict,
expand_external_prompt_command, external_source_location_for_host_action,
external_source_snapshot,
get_external_source_control_snapshot as core_get_external_source_control_snapshot,
native_prompt_command_conflicts, set_external_mcp_server_decision,
set_external_prompt_command_conflict_choice, set_external_source_enabled,
set_external_subagent_activation, set_external_subagent_model_binding,
set_external_tool_conflict_choice, set_external_tool_target_decision,
set_native_prompt_command_conflict_choice, update_external_integration_policy,
workspace_reference_snapshot, ExternalIntegrationPolicyMutation,
ExternalSourceControlRequestV1, ExternalSourceHostCapabilities, ExternalSourceOperationError,
ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot,
ExternalSourceSurfaceSnapshotV1, ExternalSubagentModelBindingTarget,
NativePromptCommandConflictSnapshot, NativePromptCommandDescriptor,
PromptCommandInvocationOutcome, PromptCommandShellReviewDecision,
set_native_prompt_command_conflict_choice, unacknowledged_external_ecosystems,
update_external_integration_policy, workspace_reference_snapshot,
ExternalIntegrationPolicyMutation, ExternalSourceControlRequestV1,
ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode,
ExternalSourceOperationResult, ExternalSourcePublicSnapshot, ExternalSourceSurfaceSnapshotV1,
ExternalSubagentModelBindingTarget, NativePromptCommandConflictSnapshot,
NativePromptCommandDescriptor, PromptCommandInvocationOutcome,
PromptCommandShellReviewDecision,
};
use bitfun_core::service::remote_ssh::workspace_state::is_remote_path;
use bitfun_core::service::remote_ssh::workspace_state::{
Expand Down Expand Up @@ -73,6 +75,25 @@ pub struct RevealExternalSourceLocationRequest {
pub source_key: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExternalEcosystemAwarenessRequest {
pub workspace_path: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEcosystemAwarenessResponse {
pub unacknowledged_ecosystem_ids: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AcknowledgeExternalEcosystemsRequest {
pub workspace_path: Option<String>,
pub ecosystem_ids: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateExternalIntegrationPolicyRequest {
Expand Down Expand Up @@ -407,6 +428,37 @@ pub async fn apply_external_source_control_action_command(
apply_external_source_control_action(workspace, request.control).await
}

/// External applications discovered on this host that the user has never been
/// told about. Surfaces use it to show a low-key "something new" affordance.
#[tauri::command]
pub async fn get_external_ecosystem_awareness_command(
request: ExternalEcosystemAwarenessRequest,
) -> ExternalSourceOperationResult<ExternalEcosystemAwarenessResponse> {
let workspace = require_local_workspace(request.workspace_path.as_deref()).await?;
unacknowledged_external_ecosystems(workspace)
.await
.map(
|unacknowledged_ecosystem_ids| ExternalEcosystemAwarenessResponse {
unacknowledged_ecosystem_ids,
},
)
.map_err(bitfun_core::external_sources::sanitize_external_source_operation_error)
}

/// Records that the user has seen these external applications.
///
/// This only clears the "new application" hint. It grants nothing, so it takes
/// no expected preference revision and leaves approvals and policy untouched.
#[tauri::command]
pub async fn acknowledge_external_ecosystems_command(
request: AcknowledgeExternalEcosystemsRequest,
) -> ExternalSourceOperationResult<()> {
let workspace = require_local_workspace(request.workspace_path.as_deref()).await?;
acknowledge_external_ecosystems(workspace, request.ecosystem_ids)
.await
.map_err(bitfun_core::external_sources::sanitize_external_source_operation_error)
}

#[tauri::command]
pub async fn set_external_source_enabled_command(
request: SetExternalSourceEnabledRequest,
Expand Down
8 changes: 8 additions & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,14 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
"apply_external_source_control_action_command",
RemoteWorkspacePolicy::RemoteUnsupported,
),
(
"get_external_ecosystem_awareness_command",
RemoteWorkspacePolicy::RemoteUnsupported,
),
(
"acknowledge_external_ecosystems_command",
RemoteWorkspacePolicy::RemoteUnsupported,
),
("apply_patch", RemoteWorkspacePolicy::LegacyUnaudited),
(
"archive_all_sessions",
Expand Down
2 changes: 2 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,8 @@ pub async fn run() {
reveal_external_source_location,
get_external_source_control_snapshot,
apply_external_source_control_action_command,
get_external_ecosystem_awareness_command,
acknowledge_external_ecosystems_command,
update_external_integration_policy_command,
set_external_source_enabled_command,
set_external_source_conflict_choice_command,
Expand Down
159 changes: 159 additions & 0 deletions src/crates/assembly/core/src/external_sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ const MAX_PROMPT_COMMAND_SHELL_OUTPUT_CHARS: usize = 256 * 1024;
const PROMPT_COMMAND_SHELL_TIMEOUT_MS: u64 = 30_000;
const PROMPT_COMMAND_SHELL_KILL_YIELD_MS: u64 = 5_000;
const MAX_APPROVED_PROMPT_COMMAND_SHELL_PLANS: usize = 512;
/// Awareness records are tiny and bounded by the number of registered
/// ecosystems, but the cap keeps a corrupted or hostile file from growing
/// without limit.
const MAX_ACKNOWLEDGED_ECOSYSTEMS: usize = 256;

#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedPromptCommandShell {
Expand Down Expand Up @@ -922,6 +926,17 @@ struct ExternalSourcesConfig {
mcp_server_decisions: BTreeMap<String, ExternalMcpDecision>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
mcp_conflict_choices: BTreeMap<String, String>,
/// Ecosystems the user has already been told about, as
/// `execution_domain_id` + unit separator + `ecosystem_id`.
///
/// This records awareness, not a policy decision: it only suppresses the
/// "a new external application was found" hint. It deliberately carries no
/// content version, because discovering more commands inside an ecosystem
/// the user already knows about is not new information. Awareness is also
/// user-wide rather than per workspace, so opening another project does not
/// re-announce the same application.
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
acknowledged_ecosystems: BTreeSet<String>,
/// Preserves fields written by a newer preferences schema.
#[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
extensions: BTreeMap<String, serde_json::Value>,
Expand Down Expand Up @@ -971,6 +986,7 @@ impl std::fmt::Debug for ExternalSourcesConfig {
.field("subagent_model_bindings", &self.subagent_model_bindings)
.field("mcp_server_decisions", &self.mcp_server_decisions)
.field("mcp_conflict_choices", &self.mcp_conflict_choices)
.field("acknowledged_ecosystems", &self.acknowledged_ecosystems)
.field("extensions", &self.extensions)
.finish()
}
Expand Down Expand Up @@ -4579,6 +4595,88 @@ async fn read_external_sources_config() -> Result<ExternalSourcesConfig, String>
ExternalSourcePreferenceStore::global()?.read().await
}

fn acknowledged_ecosystem_key(execution_domain_id: &str, ecosystem_id: &str) -> String {
format!("{execution_domain_id}\u{1f}{ecosystem_id}")
}

/// Ecosystems that have configuration on this host but have never been
/// announced to the user.
///
/// Both the desktop settings navigation and the TUI read this same result, so
/// neither surface derives "is there something new" on its own and they cannot
/// drift apart. An ecosystem only qualifies once discovery actually found a
/// source for it: a registered adapter with nothing to offer is not news.
pub async fn unacknowledged_external_ecosystems(
workspace_root: Option<&Path>,
) -> Result<Vec<String>, String> {
let service = read_only_service_for(workspace_root).await?;
let execution_domain_id = service.execution_domain_id.clone();
let discovered = service
.snapshot()
.sources
.iter()
.map(|source| source.record.ecosystem_id.to_string())
.collect::<BTreeSet<_>>();
if discovered.is_empty() {
return Ok(Vec::new());
}
let config = read_external_sources_config().await?;
Ok(discovered
.into_iter()
.filter(|ecosystem_id| {
!config
.acknowledged_ecosystems
.contains(&acknowledged_ecosystem_key(
execution_domain_id.as_str(),
ecosystem_id,
))
})
.collect())
}

/// Records that the user has seen the given ecosystems.
///
/// Awareness is not part of the preference-revision contract. The set only
/// grows, insertion is idempotent, and no policy or approval decision reads it,
/// so concurrent writers cannot lose each other's decisions here. Taking an
/// expected revision would therefore add fencing failures without protecting
/// anything, and bumping the revision would invalidate unrelated in-flight
/// mutations every time a user opens the settings page.
///
/// The execution domain is resolved from the workspace's own service so hosts
/// never pass an identity that disagrees with the one discovery recorded.
pub async fn acknowledge_external_ecosystems(
workspace_root: Option<&Path>,
ecosystem_ids: Vec<String>,
) -> Result<(), String> {
if ecosystem_ids.is_empty() {
return Ok(());
}
let execution_domain_id = read_only_service_for(workspace_root)
.await?
.execution_domain_id
.clone();
let keys = ecosystem_ids
.iter()
.map(|ecosystem_id| acknowledged_ecosystem_key(execution_domain_id.as_str(), ecosystem_id))
.collect::<Vec<_>>();
ExternalSourcePreferenceStore::global()?
.update(move |config| {
for key in &keys {
if config.acknowledged_ecosystems.contains(key) {
continue;
}
if config.acknowledged_ecosystems.len() >= MAX_ACKNOWLEDGED_ECOSYSTEMS {
break;
}
config.acknowledged_ecosystems.insert(key.clone());
}
true
})
.await
.map(|_| ())
}

async fn persist_prompt_command_shell_plan_approval(
fingerprint: &str,
expected_preference_revision: u64,
Expand Down Expand Up @@ -8723,6 +8821,67 @@ mod tests {
);
}

#[tokio::test]
async fn acknowledging_an_ecosystem_survives_a_reload_and_stays_idempotent() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("external-sources.json");
let store = ExternalSourcePreferenceStore::new(path.clone());
let key = acknowledged_ecosystem_key(LEGACY_LOCAL_EXECUTION_DOMAIN_ID, "opencode");

store
.update(|config| {
config.acknowledged_ecosystems.insert(key.clone());
})
.await
.unwrap();
store
.update(|config| {
config.acknowledged_ecosystems.insert(key.clone());
})
.await
.unwrap();

// A fresh store proves the record came back from disk, not from memory.
let reloaded = ExternalSourcePreferenceStore::new(path)
.read()
.await
.unwrap();
assert_eq!(reloaded.acknowledged_ecosystems, BTreeSet::from([key]));
// Awareness is not a policy decision, so it must not consume a revision.
assert_eq!(reloaded.preference_revision, 0);
}

#[tokio::test]
async fn acknowledgement_is_scoped_to_its_execution_domain() {
let temp = tempfile::tempdir().unwrap();
let store = ExternalSourcePreferenceStore::new(temp.path().join("external-sources.json"));
let local = acknowledged_ecosystem_key(LEGACY_LOCAL_EXECUTION_DOMAIN_ID, "opencode");
let remote = acknowledged_ecosystem_key("remote-host", "opencode");

store
.update(|config| {
config.acknowledged_ecosystems.insert(local.clone());
})
.await
.unwrap();

let persisted = store.read().await.unwrap();
assert!(persisted.acknowledged_ecosystems.contains(&local));
assert!(!persisted.acknowledged_ecosystems.contains(&remote));
}

#[test]
fn acknowledgement_keys_never_collide_across_domains_or_ecosystems() {
assert_ne!(
acknowledged_ecosystem_key("local-user", "opencode"),
acknowledged_ecosystem_key("local-user", "codex")
);
assert_ne!(
acknowledged_ecosystem_key("local-user", "opencode"),
acknowledged_ecosystem_key("remote-host", "opencode")
);
}

#[test]
fn opencode_registry_owns_low_friction_defaults_and_safety_ceilings() {
let mut config = ExternalSourcesConfig::default();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const settingsNavAppearanceDescriptor: AppearanceSurfaceDescriptor = {
{ id: 'categoryHeader', visualRole: 'toolbar' },
{ id: 'items', visualRole: 'content' },
{ id: 'item', propertyProfile: 'control', visualRole: 'control' },
{ id: 'itemUnseen', propertyProfile: 'paint', visualRole: 'decoration' },
{ id: 'highlight', propertyProfile: 'paint', visualRole: 'decoration' },
],
states: [
Expand Down
10 changes: 10 additions & 0 deletions src/web-ui/src/app/scenes/settings/SettingsNav.scss
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,16 @@
text-transform: uppercase;
}

/* Marks a tab holding something the user has not seen yet. */
&__item-unseen {
flex-shrink: 0;
width: 6px;
height: 6px;
margin-left: $size-gap-2;
border-radius: 50%;
background: var(--bf-appearance-token-color-accent-500);
}

&__item {
display: flex;
align-items: center;
Expand Down
Loading