use std::env;
use std::pin::Pin;
use async_trait::async_trait;
use serde_json::Value;
use tokio_stream::Stream;
use crate::config::default_allowed_env_keys;
use crate::copilot::CREDENTIAL_ENV_KEYS;
use crate::types::{ChatMessage, ChatRequest, LlmProvider, MessageRole, RunnerError, TokenUsage};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PermissionPolicy {
#[default]
DenyAll,
AutoApprove,
}
impl PermissionPolicy {
#[must_use]
pub fn parse(raw: &str) -> Self {
match raw.to_lowercase().as_str() {
"auto_approve" | "autoapprove" | "approve" => Self::AutoApprove,
_ => Self::DenyAll,
}
}
}
pub(crate) const ALLOW_ALL_MCP_SERVER_INSTRUCTIONS_FLAG: &str =
"--allow-all-mcp-server-instructions";
pub const DEFAULT_MAX_HISTORY_TURNS: usize = 20;
pub const PROVIDER_ENV_KEYS: &[&str] = &[
"COPILOT_PROVIDER_BASE_URL",
"COPILOT_PROVIDER_TYPE",
"COPILOT_PROVIDER_API_KEY",
"COPILOT_PROVIDER_MAX_PROMPT_TOKENS",
"COPILOT_OFFLINE",
];
pub const NETWORK_ENV_KEYS: &[&str] = &[
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
"NO_PROXY",
"no_proxy",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"NODE_EXTRA_CA_CERTS",
];
pub const CLI_RUNTIME_ENV_KEYS: &[&str] = &[
"COPILOT_PKG_CACHE_HOME",
"COPILOT_AUTO_UPDATE",
"COPILOT_HOME",
"XDG_CONFIG_HOME",
"XDG_STATE_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
"TMPDIR",
];
#[must_use]
pub fn runtime_env_keys() -> Vec<String> {
let mut keys = default_allowed_env_keys();
for key in CREDENTIAL_ENV_KEYS
.iter()
.chain(PROVIDER_ENV_KEYS)
.chain(NETWORK_ENV_KEYS)
.chain(CLI_RUNTIME_ENV_KEYS)
{
if !keys.iter().any(|kept| kept == key) {
keys.push((*key).to_owned());
}
}
keys
}
#[must_use]
pub fn resolve_github_token() -> Option<String> {
env::var("COPILOT_GITHUB_TOKEN")
.or_else(|_| env::var("GH_TOKEN"))
.or_else(|_| env::var("GITHUB_TOKEN"))
.ok()
}
#[derive(Debug, Clone, Default)]
pub struct ObservedToolCall {
pub id: String,
pub title: String,
pub status: String,
pub name: Option<String>,
pub arguments: Option<Value>,
pub result: Option<String>,
}
#[derive(Debug, Clone)]
pub struct HeadlessToolResponse {
pub content: String,
pub model: String,
pub tool_calls: Vec<ObservedToolCall>,
pub usage: Option<TokenUsage>,
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone)]
pub enum HeadlessStreamEvent {
TextDelta(String),
ToolCall(ObservedToolCall),
Done(HeadlessToolResponse),
}
pub type HeadlessEventStream =
Pin<Box<dyn Stream<Item = Result<HeadlessStreamEvent, RunnerError>> + Send>>;
#[async_trait]
pub trait HeadlessTurnProvider: LlmProvider {
async fn converse(&self, request: &ChatRequest) -> Result<HeadlessToolResponse, RunnerError>;
async fn converse_stream(
&self,
request: &ChatRequest,
) -> Result<HeadlessEventStream, RunnerError>;
}
#[must_use]
pub fn system_prompt(request: &ChatRequest) -> Option<&str> {
request
.messages
.iter()
.find(|m| m.role == MessageRole::System)
.map(|m| m.content.as_str())
}
#[derive(Debug)]
pub struct RenderedTurn<'a> {
pub text: String,
pub last_user: Option<&'a ChatMessage>,
}
#[must_use]
pub fn render_turn(
request: &ChatRequest,
max_history_turns: usize,
inline_system: bool,
) -> RenderedTurn<'_> {
let non_system: Vec<&ChatMessage> = request
.messages
.iter()
.filter(|m| m.role != MessageRole::System)
.collect();
let (history, last_user) = if non_system.is_empty() {
(Vec::new(), None)
} else {
let last_idx = non_system.iter().rposition(|m| m.role == MessageRole::User);
match last_idx {
Some(idx) => {
let hist = non_system[..idx].to_vec();
(hist, Some(non_system[idx]))
}
None => (non_system, None),
}
};
let user_text = last_user.map(|m| m.content.as_str()).unwrap_or_default();
let truncated_history = if max_history_turns == 0 || history.is_empty() {
&[][..]
} else if history.len() > max_history_turns {
&history[history.len() - max_history_turns..]
} else {
&history
};
let history_block = if truncated_history.is_empty() {
String::new()
} else {
let mut buf = String::from("<conversation-history>\n");
for msg in truncated_history {
let role_label = match msg.role {
MessageRole::User => "User",
MessageRole::Assistant => "Assistant",
MessageRole::Tool => "Tool",
MessageRole::System => continue,
};
buf.push_str(role_label);
buf.push_str(": ");
buf.push_str(&msg.content);
buf.push('\n');
}
buf.push_str("</conversation-history>\n\n");
buf
};
let mut text = String::new();
if inline_system {
if let Some(sys) = system_prompt(request) {
text.push_str(sys);
text.push_str("\n\n");
}
}
text.push_str(&history_block);
text.push_str(user_text);
RenderedTurn { text, last_user }
}
#[cfg(test)]
mod tests {
use super::*;
fn request(messages: Vec<ChatMessage>) -> ChatRequest {
ChatRequest::new(messages)
}
#[test]
fn permission_policy_parse_denies_unless_spelled_out() {
assert_eq!(PermissionPolicy::parse(""), PermissionPolicy::DenyAll);
assert_eq!(
PermissionPolicy::parse("deny_all"),
PermissionPolicy::DenyAll
);
assert_eq!(
PermissionPolicy::parse("auto-approve"),
PermissionPolicy::DenyAll
);
assert_eq!(
PermissionPolicy::parse("auto_approve"),
PermissionPolicy::AutoApprove
);
assert_eq!(
PermissionPolicy::parse("APPROVE"),
PermissionPolicy::AutoApprove
);
}
#[test]
fn runtime_env_keys_keep_the_cli_package_cache_and_drop_host_secrets() {
let keys = runtime_env_keys();
for key in [
"HOME",
"PATH",
"COPILOT_GITHUB_TOKEN",
"COPILOT_PKG_CACHE_HOME",
"COPILOT_AUTO_UPDATE",
"COPILOT_HOME",
"TMPDIR",
] {
assert!(
keys.iter().any(|k| k == key),
"{key} must reach the runtime"
);
}
for key in ["DATABASE_URL", "PIERRE_MASTER_ENCRYPTION_KEY"] {
assert!(
!keys.iter().any(|k| k == key),
"{key} must not reach the runtime"
);
}
let mut deduped = keys.clone();
deduped.sort();
deduped.dedup();
assert_eq!(deduped.len(), keys.len(), "no key is listed twice");
}
#[test]
fn render_inlines_system_history_and_user_in_order() {
let req = request(vec![
ChatMessage::system("SYS"),
ChatMessage::user("first"),
ChatMessage::assistant("reply"),
ChatMessage::user("second"),
]);
let turn = render_turn(&req, DEFAULT_MAX_HISTORY_TURNS, true);
assert_eq!(
turn.text,
"SYS\n\n<conversation-history>\nUser: first\nAssistant: reply\n</conversation-history>\n\nsecond"
);
assert_eq!(turn.last_user.map(|m| m.content.as_str()), Some("second"));
}
#[test]
fn render_without_inline_system_leaves_the_system_slot_to_the_transport() {
let req = request(vec![ChatMessage::system("SYS"), ChatMessage::user("hi")]);
let turn = render_turn(&req, DEFAULT_MAX_HISTORY_TURNS, false);
assert_eq!(turn.text, "hi");
assert_eq!(system_prompt(&req), Some("SYS"));
}
#[test]
fn render_caps_history_to_the_most_recent_turns() {
let req = request(vec![
ChatMessage::user("u1"),
ChatMessage::assistant("a1"),
ChatMessage::user("u2"),
ChatMessage::assistant("a2"),
ChatMessage::user("u3"),
]);
let turn = render_turn(&req, 2, false);
assert_eq!(
turn.text,
"<conversation-history>\nUser: u2\nAssistant: a2\n</conversation-history>\n\nu3"
);
let none = render_turn(&req, 0, false);
assert_eq!(none.text, "u3");
}
#[test]
fn render_with_no_user_message_yields_history_only() {
let req = request(vec![ChatMessage::assistant("a1")]);
let turn = render_turn(&req, DEFAULT_MAX_HISTORY_TURNS, false);
assert_eq!(
turn.text,
"<conversation-history>\nAssistant: a1\n</conversation-history>\n\n"
);
assert!(turn.last_user.is_none());
}
}