Skip to content
Open
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
2 changes: 1 addition & 1 deletion VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ Greenfield. Agent swarms build in parallel, integrating at the event store bound
|-|------|
| ✅ | Core relay, auth, pub/sub, search, audit |
| ✅ | MCP server — full feature surface |
| ✅ | ACP agent harness — goose, codex, claude code |
| ✅ | ACP agent harness — goose, codex, claude code, GitHub Copilot CLI |
| ✅ | Desktop client (Tauri) — Stream, Home, Forum, DMs, Agents, Workflows, Search, Settings, Profiles, Presence |
| ✅ | Channel features — messaging, threads, reactions, canvases, media uploads, editing, deletion, typing indicators, NIP-29, soft-delete |
| ✅ | Workflow engine — YAML-as-code, execution traces, message/reaction/schedule/webhook triggers |
Expand Down
41 changes: 41 additions & 0 deletions desktop/src-tauri/src/commands/agent_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};

use crate::managed_agents::{
default_agent_workdir, known_acp_runtime_exact, normalize_agent_args, resolve_command,
AuthStatus,
};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
Expand Down Expand Up @@ -77,6 +78,46 @@ fn discover_acp_auth_methods_blocking(runtime_id: &str) -> Result<AcpAuthMethods
.map_err(|error| format!("failed to parse auth methods JSON: {error}"))
}

/// Probe authentication by creating an ACP session and listing its models.
///
/// GitHub Copilot CLI has no non-interactive status command. Its ACP
/// `session/new` path returns models only when the stored account is usable.
/// Only forced catalog discovery runs this process; cheap discovery reuses the
/// in-process auth cache until the next forced refresh. The probe sends no
/// prompt.
pub(crate) fn probe_acp_runtime_auth(runtime_id: &str) -> AuthStatus {
match run_buzz_acp_auth_command(runtime_id, ["models", "--json"]) {
Ok(output) if output.status.success() => AuthStatus::LoggedIn,
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
match crate::managed_agents::readiness::cli_probe::classify_probe_output(
stderr.as_bytes(),
false,
) {
crate::managed_agents::readiness::cli_probe::ProbeOutcome::LoggedIn => {
AuthStatus::LoggedIn
}
crate::managed_agents::readiness::cli_probe::ProbeOutcome::LoggedOut => {
AuthStatus::LoggedOut
}
crate::managed_agents::readiness::cli_probe::ProbeOutcome::ConfigInvalid {
stderr_excerpt,
} => AuthStatus::ConfigInvalid {
diagnostic: stderr_excerpt,
},
}
}
Err(error) => {
tracing::debug!(
runtime_id,
error = %error,
"ACP runtime auth probe could not start"
);
AuthStatus::Unknown
}
}
}

fn connect_acp_runtime_blocking(
request: &ConnectAcpRuntimeRequest,
) -> Result<ConnectAcpRuntimeResult, String> {
Expand Down
49 changes: 47 additions & 2 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub(crate) use runtime_metadata::KnownAcpRuntime;
const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png";
const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default";
const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default";
const COPILOT_CLI_AVATAR_URL: &str = "https://avatars.githubusercontent.com/u/9919?s=200&v=4";
const BUZZ_AGENT_AVATAR_URL: &str =
"https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png";
fn common_binary_paths() -> &'static [PathBuf] {
Expand Down Expand Up @@ -186,6 +187,39 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
// Verified: `codex login status` exits 0 when logged in, non-zero otherwise.
auth_probe_args: Some(&["codex", "login", "status"]),
},
KnownAcpRuntime {
id: "copilot",
label: "GitHub Copilot CLI",
commands: &["copilot"],
aliases: &["github-copilot", "gh-copilot"],
avatar_url: COPILOT_CLI_AVATAR_URL,
mcp_command: None,
mcp_hooks: false,
underlying_cli: Some("copilot"),
cli_install_commands: &[],
cli_install_commands_windows: &[],
adapter_install_commands: &[],
cli_install_instructions_url: "https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli",
adapter_install_instructions_url: "",
cli_install_hint: "Install GitHub Copilot CLI, then sign in with your GitHub account. Buzz uses its built-in ACP server directly.",
adapter_install_hint: "",
skill_dir: None,
supports_acp_model_switching: true,
model_env_var: None,
provider_env_var: None,
provider_locked: false,
default_env: &[],
config_file_path: Some("~/.copilot/config.json"),
config_file_format: Some("json"),
supports_acp_native_config: false,
thinking_env_var: None,
max_tokens_env_var: None,
context_limit_env_var: None,
max_rounds_env_var: None,
required_normalized_fields: &[],
login_hint: Some("Run `copilot login` to authenticate."),
auth_probe_args: None,
},
KnownAcpRuntime {
id: "buzz-agent",
label: "Buzz Agent",
Expand Down Expand Up @@ -451,19 +485,30 @@ pub fn try_record_agent_command(
fn default_agent_args(command: &str) -> Option<Vec<String>> {
match normalize_command_identity(command).as_str() {
"goose" => Some(vec!["acp".to_string()]),
"copilot" | "github-copilot" | "gh-copilot" => Some(vec!["--acp".to_string()]),
"codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code"
| "claudecode" | "buzz-agent" => Some(Vec::new()),
_ => None,
}
}

pub fn normalize_agent_args(command: &str, agent_args: Vec<String>) -> Vec<String> {
let normalized = agent_args
let mut normalized = agent_args
.into_iter()
.map(|arg| arg.trim().to_string())
.filter(|arg| !arg.is_empty())
.collect::<Vec<_>>();

if matches!(
normalize_command_identity(command).as_str(),
"copilot" | "github-copilot" | "gh-copilot"
) {
if !normalized.iter().any(|arg| arg == "--acp") {
normalized.insert(0, "--acp".to_string());
}
return normalized;
}

let Some(default_args) = default_agent_args(command) else {
return normalized;
};
Expand Down Expand Up @@ -1297,7 +1342,7 @@ pub fn discover_acp_runtimes_from(
if partial.entry.auth_status == AuthStatus::Unknown {
partial.entry.auth_status = if partial.entry.availability
== AcpAvailabilityStatus::Available
&& partial.runtime.auth_probe_args.is_none()
&& !auth_status_cache::runtime_is_probeable(partial.runtime)
{
AuthStatus::NotApplicable
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ pub(super) fn get(runtime_id: &str) -> AuthStatus {
.unwrap_or(AuthStatus::Unknown)
}

pub(super) fn runtime_is_probeable(runtime: &super::KnownAcpRuntime) -> bool {
runtime.id == "copilot" || runtime.auth_probe_args.is_some()
}

#[cfg(test)]
pub(crate) fn len() -> usize {
cache().lock().map(|g| g.len()).unwrap_or(0)
Expand All @@ -62,6 +66,11 @@ pub(super) fn resolve_auth_statuses(partials: &mut [super::PartialEntry], force:
if partial.entry.availability != AcpAvailabilityStatus::Available {
return None;
}
if partial.runtime.id == "copilot" {
let handle =
std::thread::spawn(|| crate::commands::probe_acp_runtime_auth("copilot"));
return Some((idx, handle));
}
let probe_args = partial.runtime.auth_probe_args?;
// Need the resolved binary path for the CLI (e.g. the actual `claude` binary).
let binary_path = super::resolve_command(probe_args[0])?;
Expand All @@ -84,7 +93,7 @@ pub(super) fn resolve_auth_statuses(partials: &mut [super::PartialEntry], force:
} else {
for partial in partials.iter_mut() {
if partial.entry.availability != AcpAvailabilityStatus::Available
|| partial.runtime.auth_probe_args.is_none()
|| !runtime_is_probeable(partial.runtime)
{
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ impl KnownAcpRuntime {

#[cfg(test)]
mod tests {
use super::super::known_acp_runtime_exact;
use super::super::{auth_status_cache, known_acp_runtime_exact};

#[test]
fn vendor_metadata_distinguishes_cli_and_adapter_guidance() {
Expand Down Expand Up @@ -122,5 +122,19 @@ mod tests {
);
assert!(codex.adapter_install_instructions_url.contains("codex-acp"));
assert!(codex.cli_install_hint.contains("Codex CLI"));

let copilot = known_acp_runtime_exact("copilot").unwrap();
assert_eq!(copilot.commands, &["copilot"]);
assert_eq!(
copilot.avatar_url,
"https://avatars.githubusercontent.com/u/9919?s=200&v=4"
);
assert!(copilot.adapter_install_commands.is_empty());
assert!(copilot.cli_install_hint.contains("built-in ACP server"));
assert!(copilot
.cli_install_instructions_url
.contains("docs.github.com"));
assert!(copilot.supports_acp_model_switching);
assert!(auth_status_cache::runtime_is_probeable(copilot));
}
}
48 changes: 47 additions & 1 deletion desktop/src-tauri/src/managed_agents/discovery/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use super::{
effective_agent_command, find_nvm_default_bin, is_login_shell_path_uninit, is_safe_nvm_tag,
managed_agent_avatar_url, normalize_agent_args, parse_semver_tag, probe_codex_acp_version,
record_agent_command, refresh_login_shell_path, try_record_agent_command,
BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, COPILOT_CLI_AVATAR_URL,
GOOSE_AVATAR_URL,
};
use crate::managed_agents::AcpAvailabilityStatus;

Expand Down Expand Up @@ -69,6 +70,51 @@ fn normalizes_claude_and_codex_args_to_empty() {
);
}

#[test]
fn normalizes_copilot_args_to_native_stdio_acp_mode() {
let expected = vec!["--acp".to_string()];
assert_eq!(normalize_agent_args("copilot", Vec::new()), expected);
assert_eq!(
normalize_agent_args(r"C:\Tools\copilot.exe", Vec::new()),
vec!["--acp".to_string()]
);
assert_eq!(
normalize_agent_args("gh-copilot", Vec::new()),
vec!["--acp".to_string()]
);
assert_eq!(
normalize_agent_args("copilot", vec!["--allow-all-tools".to_string()]),
vec!["--acp".to_string(), "--allow-all-tools".to_string()]
);
assert_eq!(
normalize_agent_args(
"copilot",
vec![
"--acp".to_string(),
"--model".to_string(),
"gpt-5.4".to_string()
]
),
vec![
"--acp".to_string(),
"--model".to_string(),
"gpt-5.4".to_string()
]
);
}

#[test]
fn resolves_copilot_avatar_for_command_and_alias() {
assert_eq!(
managed_agent_avatar_url("copilot"),
Some(COPILOT_CLI_AVATAR_URL.to_string())
);
assert_eq!(
managed_agent_avatar_url("GitHub Copilot"),
Some(COPILOT_CLI_AVATAR_URL.to_string())
);
}

#[test]
fn resolves_buzz_agent_avatar() {
assert_eq!(
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,7 @@ matches the code is worse than no rule; a new pattern that isn't written down
here will be broken by the next agent that never learns it existed. Reviewers:
treat a config-behavior diff without a matching AGENTS.md diff (or an explicit
"no rules changed" note) as incomplete.

GitHub Copilot CLI follows the existing catalog-owned capability model. Its
provider submission classification only extends the pre-catalog fallback for
CLI-login runtimes; no configuration rule changes.
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ test("resolveRuntimeProviderCapability classifies known CLI-login runtimes as lo
// The core fix: a not-yet-loaded catalog must not force these to "unknown".
assert.equal(resolveRuntimeProviderCapability("claude", false), "locked");
assert.equal(resolveRuntimeProviderCapability("codex", false), "locked");
assert.equal(resolveRuntimeProviderCapability("copilot", false), "locked");
assert.equal(resolveRuntimeProviderCapability(" claude ", false), "locked");
});

Expand Down
4 changes: 2 additions & 2 deletions desktop/src/features/agents/ui/personaRuntimeModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type ProviderRuntimeCapability = "capable" | "locked" | "unknown";
* provider. To avoid that, we resolve capability STATICALLY for known ids:
*
* - buzz-agent / goose → "capable" (`isProviderCapable`, id-based).
* - claude / codex → "locked" (CLI-login runtimes; no LLM provider selection).
* - claude / codex / copilot → "locked" (CLI-login runtimes; no LLM provider selection).
* - anything else (custom, empty, genuinely unknown) → "unknown".
*
* `isProviderCapable` is the caller-supplied {@link
Expand All @@ -33,7 +33,7 @@ export function resolveRuntimeProviderCapability(
return "capable";
}
const id = runtimeId.trim();
if (id === "claude" || id === "codex") {
if (id === "claude" || id === "codex" || id === "copilot") {
return "locked";
}
return "unknown";
Expand Down
23 changes: 22 additions & 1 deletion desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8148,6 +8148,27 @@ async function handleDiscoverAcpRuntimes(
source: "builtin",
login_hint: undefined,
},
{
id: "copilot",
label: "GitHub Copilot CLI",
avatar_url: "",
availability: "available",
command: "copilot",
binary_path: "/usr/local/bin/copilot",
default_args: ["--acp"],
mcp_command: null,
install_hint:
"Install GitHub Copilot CLI, then sign in with your GitHub account. Buzz uses its built-in ACP server directly.",
install_instructions_url:
"https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli",
can_auto_install: false,
requires_external_cli: true,
underlying_cli_path: "/usr/local/bin/copilot",
node_required: false,
auth_status: { status: "logged_in" },
source: "builtin",
login_hint: undefined,
},
{
id: "buzz-agent",
label: "Buzz Agent",
Expand All @@ -8163,7 +8184,7 @@ async function handleDiscoverAcpRuntimes(
requires_external_cli: false,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "not_applicable" },
auth_status: { status: "logged_in" },
source: "builtin",
login_hint: undefined,
},
Expand Down
24 changes: 22 additions & 2 deletions desktop/tests/e2e/harness-catalog-screenshots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const CATALOG = [
can_auto_install: false,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "not_applicable" },
auth_status: { status: "logged_in" },
},
{
id: "claude",
Expand Down Expand Up @@ -79,6 +79,26 @@ const CATALOG = [
auth_status: { status: "not_applicable" },
source: "preset",
},
{
id: "copilot",
label: "GitHub Copilot CLI",
avatar_url: "",
availability: "available",
command: "copilot",
binary_path: "/usr/local/bin/copilot",
default_args: ["--acp"],
mcp_command: null,
install_hint:
"Buzz uses GitHub Copilot CLI's built-in ACP server directly.",
install_instructions_url:
"https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli",
can_auto_install: false,
requires_external_cli: true,
underlying_cli_path: "/usr/local/bin/copilot",
node_required: false,
auth_status: { status: "logged_in" },
source: "builtin",
},
{
id: "omp",
label: "Oh My Pi",
Expand Down Expand Up @@ -163,7 +183,7 @@ test("after: consolidated harnesses panel + catalog dialog", async ({
// 3. Ready entry detail (Ready state, no install action). Ready entries
// sit in the "Installed" accordion, collapsed by default — expand it.
await page.getByTestId("harness-catalog-section-installed").click();
await page.getByTestId("harness-catalog-list-item-claude").click();
await page.getByTestId("harness-catalog-list-item-copilot").click();
await page.waitForTimeout(300);
await page.getByTestId("harness-catalog-dialog").screenshot({
path: `${SHOTS}/after-catalog-ready-detail.png`,
Expand Down