Skip to content

Commit 86de262

Browse files
authored
Refactor engine handling behind a dedicated Engine trait and Copilot implementation (#284)
1 parent a400274 commit 86de262

3 files changed

Lines changed: 239 additions & 175 deletions

File tree

src/compile/common.rs

Lines changed: 3 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::path::Path;
77
use super::types::{FrontMatter, PipelineParameter, Repository, TriggerConfig};
88
use super::extensions::{CompilerExtension, Extension, McpgServerConfig, McpgGatewayConfig, McpgConfig, CompileContext};
99
use crate::compile::types::McpConfig;
10+
use crate::engine::Engine as _;
1011
use crate::fuzzy_schedule;
1112
use crate::allowed_hosts::{CORE_ALLOWED_HOSTS, mcp_required_hosts};
1213
use crate::ecosystem_domains::{get_ecosystem_domains, is_ecosystem_identifier, is_known_ecosystem};
@@ -463,176 +464,7 @@ pub fn generate_copilot_params(
463464
front_matter: &FrontMatter,
464465
extensions: &[super::extensions::Extension],
465466
) -> Result<String> {
466-
// Check if bash triggers --allow-all-tools. This happens when:
467-
// 1. Bash has an explicit wildcard entry (":*" or "*"), OR
468-
// 2. Bash is not specified at all (None) — ado-aw agents always run in AWF sandbox,
469-
// and gh-aw defaults to bash: ["*"] when sandbox is enabled (applyDefaultTools).
470-
//
471-
// Note: wildcard detection requires exactly one entry (cmds.len() == 1). Mixing a
472-
// wildcard with other commands (e.g. bash: [":*", "cat"]) is not supported and will
473-
// fall through to the restricted path, emitting "shell(:*)" literally.
474-
let bash_config = front_matter.tools.as_ref().and_then(|t| t.bash.as_ref());
475-
let use_allow_all_tools = match bash_config {
476-
Some(cmds) if cmds.len() == 1 && (cmds[0] == ":*" || cmds[0] == "*") => true,
477-
None => true, // default: all tools (matches gh-aw sandbox default)
478-
_ => false,
479-
};
480-
481-
// Edit tool: enabled by default, can be disabled with `edit: false`
482-
let edit_enabled = front_matter
483-
.tools
484-
.as_ref()
485-
.and_then(|t| t.edit)
486-
.unwrap_or(true);
487-
488-
// When --allow-all-tools is active, skip individual tool collection entirely.
489-
// --allow-all-tools is a superset that permits all tool calls regardless.
490-
let mut allowed_tools: Vec<String> = Vec::new();
491-
492-
if !use_allow_all_tools {
493-
// Collect tool permissions from extensions (github, safeoutputs, azure-devops, etc.)
494-
for ext in extensions {
495-
for tool in ext.allowed_copilot_tools() {
496-
if !allowed_tools.contains(&tool) {
497-
allowed_tools.push(tool);
498-
}
499-
}
500-
}
501-
502-
// Collect tool permissions from user-defined MCP servers (sorted for deterministic output).
503-
// Only add --allow-tool for MCPs that will actually produce an MCPG entry (i.e.,
504-
// WithOptions that have a container or url). McpConfig::Enabled(true) has no backing
505-
// server in MCPG, so granting the permission would cause confusing runtime errors.
506-
let mut sorted_mcps: Vec<_> = front_matter.mcp_servers.iter().collect();
507-
sorted_mcps.sort_by(|(a, _), (b, _)| a.cmp(b));
508-
for (name, config) in sorted_mcps {
509-
// Skip servers already provided by extensions (case-insensitive to match
510-
// generate_mcpg_config's eq_ignore_ascii_case guard for reserved names)
511-
if allowed_tools.iter().any(|t| t.eq_ignore_ascii_case(name)) {
512-
continue;
513-
}
514-
// Only add MCPs that have a backing server (container or url)
515-
let has_backing_server = match config {
516-
crate::compile::types::McpConfig::Enabled(_) => false,
517-
crate::compile::types::McpConfig::WithOptions(opts) => {
518-
opts.enabled.unwrap_or(true)
519-
&& (opts.container.is_some() || opts.url.is_some())
520-
}
521-
};
522-
if !has_backing_server {
523-
continue;
524-
}
525-
allowed_tools.push(name.clone());
526-
}
527-
528-
// Intentional: with restricted bash, both --allow-tool write (tool identity)
529-
// and --allow-all-paths (path scope) are emitted. --allow-all-tools subsumes
530-
// --allow-tool write, so only --allow-all-paths is needed on that path.
531-
if edit_enabled {
532-
allowed_tools.push("write".to_string());
533-
}
534-
535-
// Bash tool: use the explicitly configured list.
536-
// When bash is None (not specified), use_allow_all_tools is true and this
537-
// block is skipped entirely (gh-aw sandbox default = wildcard).
538-
let mut bash_commands: Vec<String> =
539-
match front_matter.tools.as_ref().and_then(|t| t.bash.as_ref()) {
540-
Some(cmds) if cmds.is_empty() => {
541-
// Explicitly disabled: no bash commands
542-
vec![]
543-
}
544-
Some(cmds) => {
545-
// Explicit list of commands
546-
cmds.clone()
547-
}
548-
None => {
549-
// Invariant: bash=None → use_allow_all_tools=true → this block is
550-
// skipped. Panic if the invariant is ever broken.
551-
unreachable!("bash=None should imply use_allow_all_tools=true")
552-
}
553-
};
554-
555-
// Auto-add extension-declared bash commands (runtimes + first-party tools)
556-
for ext in extensions {
557-
for cmd in ext.required_bash_commands() {
558-
if !bash_commands.contains(&cmd) {
559-
bash_commands.push(cmd);
560-
}
561-
}
562-
}
563-
564-
for cmd in &bash_commands {
565-
// Reject single quotes in bash commands — copilot_params are embedded inside
566-
// a single-quoted bash string in the AWF command.
567-
if cmd.contains('\'') {
568-
anyhow::bail!(
569-
"Bash command '{}' contains a single quote, which is not allowed \
570-
(would break AWF shell quoting).",
571-
cmd
572-
);
573-
}
574-
allowed_tools.push(format!("shell({})", cmd));
575-
}
576-
}
577-
578-
let mut params = Vec::new();
579-
580-
// Validate model name to prevent shell injection — copilot_params are embedded
581-
// inside a single-quoted bash string in the AWF command.
582-
let model = front_matter.engine.model();
583-
if model.is_empty()
584-
|| !model
585-
.chars()
586-
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-'))
587-
{
588-
anyhow::bail!(
589-
"Model name '{}' contains invalid characters. \
590-
Only ASCII alphanumerics, '.', '_', ':', and '-' are allowed.",
591-
model
592-
);
593-
}
594-
params.push(format!("--model {}", model));
595-
if front_matter.engine.max_turns().is_some() {
596-
eprintln!(
597-
"Warning: Agent '{}' has max-turns set, but max-turns is not supported by Copilot CLI \
598-
and will be ignored. Consider removing it from the engine configuration.",
599-
front_matter.name
600-
);
601-
}
602-
if let Some(timeout_minutes) = front_matter.engine.timeout_minutes() {
603-
if timeout_minutes == 0 {
604-
eprintln!(
605-
"Warning: Agent '{}' has timeout-minutes: 0, which means no time is allowed. \
606-
The agent job will time out immediately. \
607-
Consider setting timeout-minutes to at least 1.",
608-
front_matter.name
609-
);
610-
}
611-
}
612-
params.push("--disable-builtin-mcps".to_string());
613-
params.push("--no-ask-user".to_string());
614-
615-
if use_allow_all_tools {
616-
params.push("--allow-all-tools".to_string());
617-
} else {
618-
for tool in allowed_tools {
619-
if tool.contains('(') || tool.contains(')') || tool.contains(' ') {
620-
// Use double quotes - the copilot_params are embedded inside a single-quoted
621-
// bash string in the AWF command, so single quotes would break quoting.
622-
params.push(format!("--allow-tool \"{}\"", tool));
623-
} else {
624-
params.push(format!("--allow-tool {}", tool));
625-
}
626-
}
627-
}
628-
629-
// --allow-all-paths when edit is enabled — lets the agent write to any file path.
630-
// Emitted independently of --allow-all-tools (matches gh-aw behavior).
631-
if edit_enabled {
632-
params.push("--allow-all-paths".to_string());
633-
}
634-
635-
Ok(params.join(" "))
467+
crate::engine::GITHUB_COPILOT_CLI_ENGINE.generate_cli_params(front_matter, extensions)
636468
}
637469

638470
/// Compute the effective workspace based on explicit setting and checkout configuration.
@@ -1082,11 +914,7 @@ pub fn generate_acquire_ado_token(service_connection: Option<&str>, variable_nam
1082914
/// Uses the read-only token from the read service connection.
1083915
/// When not configured, omits ADO access tokens entirely.
1084916
pub fn generate_copilot_ado_env(read_service_connection: Option<&str>) -> String {
1085-
match read_service_connection {
1086-
Some(_) => "AZURE_DEVOPS_EXT_PAT: $(SC_READ_TOKEN)\nSYSTEM_ACCESSTOKEN: $(SC_READ_TOKEN)"
1087-
.to_string(),
1088-
None => String::new(),
1089-
}
917+
crate::engine::GITHUB_COPILOT_CLI_ENGINE.generate_agent_ado_env(read_service_connection)
1090918
}
1091919

1092920
/// Generate the env block entries for the executor step (Stage 3 Execution).

0 commit comments

Comments
 (0)