1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
// ABOUTME: Configuration for the Copilot Headless (ACP) provider.
// ABOUTME: Reads environment variables and provides defaults for the ACP client.
//
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 dravr.ai
use std::env;
use std::path::PathBuf;
/// Policy for handling ACP permission requests from the copilot subprocess.
///
/// Controls whether tool-execution permission prompts are auto-approved or denied.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PermissionPolicy {
/// Automatically approve permission requests by selecting the best allow option.
#[default]
AutoApprove,
/// Deny all permission requests by cancelling them.
DenyAll,
}
/// Default number of conversation history turns injected into the ACP prompt.
/// Each "turn" is one user or assistant message. Override with
/// `COPILOT_HEADLESS_MAX_HISTORY_TURNS`.
pub const DEFAULT_MAX_HISTORY_TURNS: usize = 20;
/// Configuration for the Copilot Headless (ACP) provider.
#[derive(Debug, Clone)]
pub struct CopilotHeadlessConfig {
/// Override path to the copilot CLI binary (default: auto-detect via PATH).
pub cli_path: Option<PathBuf>,
/// Default model to use for completions.
pub model: String,
/// GitHub token for authentication (optional, uses stored OAuth by default).
pub github_token: Option<String>,
/// Policy for handling permission requests from the copilot subprocess.
pub permission_policy: PermissionPolicy,
/// Maximum number of prior conversation messages (user + assistant) to include
/// in the ACP prompt for multi-turn context. Set to 0 to disable history injection.
pub max_history_turns: usize,
/// Prepend the system prompt as plain text in the prompt, in addition to
/// passing it via `session/new`. This ensures the model reliably sees the
/// system instructions regardless of how the ACP provider handles `systemPrompt`.
/// Default: true.
pub inject_system_in_prompt: bool,
}
impl CopilotHeadlessConfig {
/// Create configuration from environment variables.
///
/// Environment variables:
/// - `COPILOT_CLI_PATH` — Override path to copilot binary
/// - `COPILOT_HEADLESS_MODEL` — Default model (default: `claude-opus-4.6-fast`)
/// - `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN` — GitHub auth token
/// - `COPILOT_HEADLESS_MAX_HISTORY_TURNS` — Max conversation history turns (default: 20)
/// - `COPILOT_HEADLESS_INJECT_SYSTEM_IN_PROMPT` — Re-inject system prompt in prompt text (default: false)
#[must_use]
pub fn from_env() -> Self {
let cli_path = env::var("COPILOT_CLI_PATH").ok().map(PathBuf::from);
let model = env::var("COPILOT_HEADLESS_MODEL")
.unwrap_or_else(|_| "claude-opus-4.6-fast".to_owned());
let github_token = env::var("COPILOT_GITHUB_TOKEN")
.or_else(|_| env::var("GH_TOKEN"))
.or_else(|_| env::var("GITHUB_TOKEN"))
.ok();
let permission_policy = match env::var("COPILOT_HEADLESS_PERMISSION_POLICY")
.unwrap_or_default()
.to_lowercase()
.as_str()
{
"deny_all" | "denyall" | "deny" => PermissionPolicy::DenyAll,
_ => PermissionPolicy::AutoApprove,
};
let max_history_turns = env::var("COPILOT_HEADLESS_MAX_HISTORY_TURNS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(DEFAULT_MAX_HISTORY_TURNS);
let inject_system_in_prompt = env::var("COPILOT_HEADLESS_INJECT_SYSTEM_IN_PROMPT")
.map(|v| !matches!(v.to_lowercase().as_str(), "0" | "false" | "no"))
.unwrap_or(true);
Self {
cli_path,
model,
github_token,
permission_policy,
max_history_turns,
inject_system_in_prompt,
}
}
}
impl Default for CopilotHeadlessConfig {
fn default() -> Self {
Self {
cli_path: None,
model: "claude-opus-4.6-fast".to_owned(),
github_token: None,
permission_policy: PermissionPolicy::default(),
max_history_turns: DEFAULT_MAX_HISTORY_TURNS,
inject_system_in_prompt: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_max_history_turns_is_20() {
assert_eq!(DEFAULT_MAX_HISTORY_TURNS, 20);
}
#[test]
fn default_config_uses_default_max_history_turns() {
let config = CopilotHeadlessConfig::default();
assert_eq!(config.max_history_turns, 20);
}
#[test]
fn config_max_history_turns_overridable() {
let config = CopilotHeadlessConfig {
max_history_turns: 50,
..CopilotHeadlessConfig::default()
};
assert_eq!(config.max_history_turns, 50);
}
#[test]
fn config_max_history_turns_can_be_zero() {
let config = CopilotHeadlessConfig {
max_history_turns: 0,
..CopilotHeadlessConfig::default()
};
assert_eq!(config.max_history_turns, 0);
}
#[test]
fn default_inject_system_in_prompt_is_true() {
let config = CopilotHeadlessConfig::default();
assert!(config.inject_system_in_prompt);
}
#[test]
fn config_inject_system_in_prompt_overridable() {
let config = CopilotHeadlessConfig {
inject_system_in_prompt: false,
..CopilotHeadlessConfig::default()
};
assert!(!config.inject_system_in_prompt);
}
/// Env var tests run sequentially in a single test to avoid race conditions
/// (env vars are process-global state shared across parallel test threads).
#[test]
fn from_env_max_history_turns_parsing() {
let key = "COPILOT_HEADLESS_MAX_HISTORY_TURNS";
// Default when env var is not set
env::remove_var(key);
let config = CopilotHeadlessConfig::from_env();
assert_eq!(
config.max_history_turns, DEFAULT_MAX_HISTORY_TURNS,
"should use default when env var absent"
);
// Valid integer value
env::set_var(key, "42");
let config = CopilotHeadlessConfig::from_env();
assert_eq!(config.max_history_turns, 42, "should parse valid integer");
// Invalid value falls back to default
env::set_var(key, "not_a_number");
let config = CopilotHeadlessConfig::from_env();
assert_eq!(
config.max_history_turns, DEFAULT_MAX_HISTORY_TURNS,
"should fall back to default on invalid input"
);
// Zero is a valid value (disables history)
env::set_var(key, "0");
let config = CopilotHeadlessConfig::from_env();
assert_eq!(
config.max_history_turns, 0,
"should accept zero to disable history"
);
// Cleanup
env::remove_var(key);
}
/// Env var test for `inject_system_in_prompt` — sequential to avoid race conditions.
#[test]
fn from_env_inject_system_in_prompt_parsing() {
let key = "COPILOT_HEADLESS_INJECT_SYSTEM_IN_PROMPT";
// Default when env var is not set
env::remove_var(key);
let config = CopilotHeadlessConfig::from_env();
assert!(
config.inject_system_in_prompt,
"should default to true when env var absent"
);
// Falsy values explicitly disable injection
for val in ["false", "0", "no"] {
env::set_var(key, val);
let config = CopilotHeadlessConfig::from_env();
assert!(
!config.inject_system_in_prompt,
"should be false for {val:?}"
);
}
// Truthy / unrecognized values keep injection enabled
for val in ["true", "1", "yes", "TRUE", "Yes", "random"] {
env::set_var(key, val);
let config = CopilotHeadlessConfig::from_env();
assert!(config.inject_system_in_prompt, "should be true for {val:?}");
}
// Cleanup
env::remove_var(key);
}
}