Skip to content

Commit a400274

Browse files
authored
Refactor compile extensions into module-based layout with per-extension impl files (#285)
1 parent 3ef5f19 commit a400274

8 files changed

Lines changed: 1204 additions & 1201 deletions

File tree

src/compile/extensions.rs

Lines changed: 0 additions & 1201 deletions
This file was deleted.
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// ─── Azure DevOps MCP ────────────────────────────────────────────────
2+
3+
use super::{
4+
CompileContext, CompilerExtension, ExtensionPhase, McpgServerConfig, PipelineEnvMapping,
5+
};
6+
use crate::allowed_hosts::mcp_required_hosts;
7+
use crate::compile::common::{
8+
ADO_MCP_ENTRYPOINT, ADO_MCP_IMAGE, ADO_MCP_PACKAGE, ADO_MCP_SERVER_NAME,
9+
};
10+
use crate::compile::types::AzureDevOpsToolConfig;
11+
use anyhow::Result;
12+
use std::collections::HashMap;
13+
14+
/// Azure DevOps first-party tool extension.
15+
///
16+
/// Injects: network hosts (ADO domains), MCPG server entry (containerized
17+
/// ADO MCP), and compile-time validation (org inference, duplicate MCP).
18+
pub struct AzureDevOpsExtension {
19+
config: AzureDevOpsToolConfig,
20+
auth_mode: AdoAuthMode,
21+
}
22+
23+
/// Authentication mode for the ADO MCP server.
24+
///
25+
/// Pipelines use bearer tokens (JWT from ARM service connections).
26+
/// Local development uses PATs (Personal Access Tokens).
27+
#[derive(Debug, Clone, Copy, Default)]
28+
pub enum AdoAuthMode {
29+
/// `-a envvar` + `ADO_MCP_AUTH_TOKEN` — bearer JWT from ARM (pipeline default)
30+
#[default]
31+
Bearer,
32+
/// `-a pat` + `AZURE_DEVOPS_EXT_PAT` — Personal Access Token (local dev)
33+
Pat,
34+
}
35+
36+
impl AzureDevOpsExtension {
37+
pub fn new(config: AzureDevOpsToolConfig) -> Self {
38+
Self {
39+
config,
40+
auth_mode: AdoAuthMode::default(),
41+
}
42+
}
43+
44+
/// Set the authentication mode (e.g., `AdoAuthMode::Pat` for local runs).
45+
pub fn with_auth_mode(mut self, mode: AdoAuthMode) -> Self {
46+
self.auth_mode = mode;
47+
self
48+
}
49+
}
50+
51+
impl CompilerExtension for AzureDevOpsExtension {
52+
fn name(&self) -> &str {
53+
"Azure DevOps MCP"
54+
}
55+
56+
fn phase(&self) -> ExtensionPhase {
57+
ExtensionPhase::Tool
58+
}
59+
60+
fn required_hosts(&self) -> Vec<String> {
61+
let mut hosts: Vec<String> = mcp_required_hosts("ado")
62+
.iter()
63+
.map(|h| (*h).to_string())
64+
.collect();
65+
// The ADO MCP runs in a container via `npx -y @azure-devops/mcp`.
66+
// npx needs npm registry access to resolve and install the package.
67+
hosts.push("node".to_string());
68+
hosts
69+
}
70+
71+
fn allowed_copilot_tools(&self) -> Vec<String> {
72+
vec![ADO_MCP_SERVER_NAME.to_string()]
73+
}
74+
75+
fn mcpg_servers(&self, ctx: &CompileContext) -> Result<Vec<(String, McpgServerConfig)>> {
76+
// Build entrypoint args: npx -y @azure-devops/mcp <org> [-d toolset1 toolset2 ...]
77+
let mut entrypoint_args = vec!["-y".to_string(), ADO_MCP_PACKAGE.to_string()];
78+
79+
// Org: use explicit override, then inferred from git remote, then fail
80+
let org = self
81+
.config
82+
.org()
83+
.map(|s| s.to_string())
84+
.or_else(|| ctx.ado_org().map(|s| s.to_string()))
85+
.ok_or_else(|| {
86+
anyhow::anyhow!(
87+
"Agent '{}' has tools.azure-devops enabled but no ADO organization could be \
88+
determined. Either set tools.azure-devops.org explicitly, or compile from \
89+
within a git repository with an Azure DevOps remote URL.",
90+
ctx.agent_name
91+
)
92+
})?;
93+
if !org.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
94+
anyhow::bail!(
95+
"Invalid ADO org name '{}': must contain only alphanumerics and hyphens",
96+
org
97+
);
98+
}
99+
entrypoint_args.push(org);
100+
101+
// Toolsets: passed as -d flag followed by space-separated toolset names
102+
if !self.config.toolsets().is_empty() {
103+
entrypoint_args.push("-d".to_string());
104+
for toolset in self.config.toolsets() {
105+
if !toolset
106+
.chars()
107+
.all(|c| c.is_ascii_alphanumeric() || c == '-')
108+
{
109+
anyhow::bail!(
110+
"Invalid ADO toolset name '{}': must contain only alphanumerics and hyphens",
111+
toolset
112+
);
113+
}
114+
entrypoint_args.push(toolset.clone());
115+
}
116+
}
117+
118+
// Tool allow-list for MCPG filtering
119+
let tools = if self.config.allowed().is_empty() {
120+
None
121+
} else {
122+
Some(self.config.allowed().to_vec())
123+
};
124+
125+
// ADO MCP authentication: the @azure-devops/mcp npm package accepts
126+
// auth type via CLI arg (-a) and token via env var.
127+
// Bearer: `-a envvar` reads ADO_MCP_AUTH_TOKEN (pipeline JWT from ARM)
128+
// Pat: `-a pat` reads PERSONAL_ACCESS_TOKEN (base64-encoded PAT)
129+
let (auth_flag, token_var) = match self.auth_mode {
130+
AdoAuthMode::Bearer => ("envvar", "ADO_MCP_AUTH_TOKEN"),
131+
AdoAuthMode::Pat => ("pat", "PERSONAL_ACCESS_TOKEN"),
132+
};
133+
entrypoint_args.extend(["-a".to_string(), auth_flag.to_string()]);
134+
135+
let env = Some(HashMap::from([(
136+
token_var.to_string(),
137+
String::new(), // Passthrough from MCPG process env
138+
)]));
139+
140+
// --network host: AWF's DOCKER-USER iptables rules block outbound from
141+
// containers on Docker's default bridge. Host networking bypasses FORWARD
142+
// chain rules so the ADO MCP can reach dev.azure.com.
143+
// This matches gh-aw's approach for its built-in agentic-workflows MCP.
144+
let args = Some(vec!["--network".to_string(), "host".to_string()]);
145+
146+
Ok(vec![(
147+
ADO_MCP_SERVER_NAME.to_string(),
148+
McpgServerConfig {
149+
server_type: "stdio".to_string(),
150+
container: Some(ADO_MCP_IMAGE.to_string()),
151+
entrypoint: Some(ADO_MCP_ENTRYPOINT.to_string()),
152+
entrypoint_args: Some(entrypoint_args),
153+
mounts: None,
154+
args,
155+
url: None,
156+
headers: None,
157+
env,
158+
tools,
159+
},
160+
)])
161+
}
162+
163+
fn validate(&self, ctx: &CompileContext) -> Result<Vec<String>> {
164+
let mut warnings = Vec::new();
165+
166+
// Warn if user also has a manual mcp-servers entry for azure-devops
167+
if ctx
168+
.front_matter
169+
.mcp_servers
170+
.contains_key(ADO_MCP_SERVER_NAME)
171+
{
172+
warnings.push(format!(
173+
"Agent '{}' has both tools.azure-devops and mcp-servers.azure-devops configured. \
174+
The tools.azure-devops auto-configuration takes precedence. \
175+
Remove the mcp-servers entry to silence this warning.",
176+
ctx.agent_name
177+
));
178+
}
179+
180+
Ok(warnings)
181+
}
182+
fn required_pipeline_vars(&self) -> Vec<PipelineEnvMapping> {
183+
match self.auth_mode {
184+
AdoAuthMode::Bearer => vec![PipelineEnvMapping {
185+
container_var: "ADO_MCP_AUTH_TOKEN".to_string(),
186+
pipeline_var: "SC_READ_TOKEN".to_string(),
187+
}],
188+
// PAT mode: no pipeline var mapping needed — the PAT is passed
189+
// directly via AZURE_DEVOPS_EXT_PAT in the MCPG env file.
190+
AdoAuthMode::Pat => vec![],
191+
}
192+
}
193+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
use super::{CompilerExtension, ExtensionPhase};
2+
use crate::compile::types::CacheMemoryToolConfig;
3+
4+
/// Cache memory tool extension.
5+
///
6+
/// Injects: prepare steps (download/restore previous memory), and a
7+
/// prompt supplement informing the agent about its memory directory.
8+
pub struct CacheMemoryExtension {
9+
/// Config options (e.g., `allowed-extensions`) are consumed at Stage 3
10+
/// execution time, not at compile time. Retained here for potential
11+
/// future compile-time validation.
12+
#[allow(dead_code)]
13+
config: CacheMemoryToolConfig,
14+
}
15+
16+
impl CacheMemoryExtension {
17+
pub fn new(config: CacheMemoryToolConfig) -> Self {
18+
Self { config }
19+
}
20+
}
21+
22+
impl CompilerExtension for CacheMemoryExtension {
23+
fn name(&self) -> &str {
24+
"Cache Memory"
25+
}
26+
27+
fn phase(&self) -> ExtensionPhase {
28+
ExtensionPhase::Tool
29+
}
30+
31+
fn prepare_steps(&self) -> Vec<String> {
32+
vec![generate_memory_download()]
33+
}
34+
35+
fn prompt_supplement(&self) -> Option<String> {
36+
Some(
37+
"\n\
38+
---\n\
39+
\n\
40+
## Agent Memory\n\
41+
\n\
42+
You have persistent memory across runs. Your memory directory is located at `/tmp/awf-tools/staging/agent_memory/`.\n\
43+
\n\
44+
- **Read** previous memory files from this directory to recall context from prior runs.\n\
45+
- **Write** new files or update existing ones in this directory to persist knowledge for future runs.\n\
46+
- Use this memory to track patterns, accumulate findings, remember decisions, and improve over time.\n\
47+
- The memory directory is yours to organize as you see fit (files, subdirectories, any structure).\n\
48+
- Memory files are sanitized between runs for security; avoid including pipeline commands or secrets.\n"
49+
.to_string(),
50+
)
51+
}
52+
}
53+
54+
/// Generate the steps to download agent memory from the previous successful run
55+
/// and restore it to the staging directory.
56+
fn generate_memory_download() -> String {
57+
r#"- task: DownloadPipelineArtifact@2
58+
displayName: "Download previous agent memory"
59+
condition: eq(${{ parameters.clearMemory }}, false)
60+
continueOnError: true
61+
inputs:
62+
source: "specific"
63+
project: "$(System.TeamProject)"
64+
pipeline: "$(System.DefinitionId)"
65+
runVersion: "latestFromBranch"
66+
branchName: "$(Build.SourceBranch)"
67+
artifact: "safe_outputs"
68+
targetPath: "$(Agent.TempDirectory)/previous_memory"
69+
allowPartiallySucceededBuilds: true
70+
71+
- bash: |
72+
mkdir -p /tmp/awf-tools/staging/agent_memory
73+
if [ -d "$(Agent.TempDirectory)/previous_memory/agent_memory" ]; then
74+
cp -a "$(Agent.TempDirectory)/previous_memory/agent_memory/." /tmp/awf-tools/staging/agent_memory/ 2>/dev/null || true
75+
echo "Previous agent memory restored to /tmp/awf-tools/staging/agent_memory"
76+
ls -laR /tmp/awf-tools/staging/agent_memory
77+
else
78+
echo "No previous agent memory found - empty memory directory created"
79+
fi
80+
displayName: "Restore previous agent memory"
81+
condition: eq(${{ parameters.clearMemory }}, false)
82+
continueOnError: true
83+
84+
- bash: |
85+
mkdir -p /tmp/awf-tools/staging/agent_memory
86+
echo "Memory cleared by pipeline parameter - starting fresh"
87+
displayName: "Initialize empty agent memory (clearMemory=true)"
88+
condition: eq(${{ parameters.clearMemory }}, true)"#
89+
.to_string()
90+
}

src/compile/extensions/github.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
use super::{CompilerExtension, ExtensionPhase};
2+
3+
// ─── GitHub (always-on, internal) ────────────────────────────────────
4+
5+
/// GitHub MCP extension.
6+
///
7+
/// Always-on internal extension that grants the agent access to the
8+
/// Copilot CLI built-in GitHub MCP server via `--allow-tool github`.
9+
/// The GitHub MCP uses `GITHUB_TOKEN` from the pipeline environment.
10+
pub struct GitHubExtension;
11+
12+
impl CompilerExtension for GitHubExtension {
13+
fn name(&self) -> &str {
14+
"GitHub"
15+
}
16+
17+
fn phase(&self) -> ExtensionPhase {
18+
ExtensionPhase::Tool
19+
}
20+
21+
fn allowed_copilot_tools(&self) -> Vec<String> {
22+
vec!["github".to_string()]
23+
}
24+
}

src/compile/extensions/lean.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// ─── Lean 4 ──────────────────────────────────────────────────────────
2+
3+
use super::{CompileContext, CompilerExtension, ExtensionPhase};
4+
use crate::runtimes::lean::{self, LEAN_BASH_COMMANDS, LeanRuntimeConfig};
5+
use anyhow::Result;
6+
7+
/// Lean 4 runtime extension.
8+
///
9+
/// Injects: network hosts (elan, lean-lang), bash commands (lean, lake,
10+
/// elan), install steps (elan + toolchain), and a prompt supplement.
11+
pub struct LeanExtension {
12+
config: LeanRuntimeConfig,
13+
}
14+
15+
impl LeanExtension {
16+
pub fn new(config: LeanRuntimeConfig) -> Self {
17+
Self { config }
18+
}
19+
}
20+
21+
impl CompilerExtension for LeanExtension {
22+
fn name(&self) -> &str {
23+
"Lean 4"
24+
}
25+
26+
fn phase(&self) -> ExtensionPhase {
27+
ExtensionPhase::Runtime
28+
}
29+
30+
fn required_hosts(&self) -> Vec<String> {
31+
vec!["lean".to_string()]
32+
}
33+
34+
fn required_bash_commands(&self) -> Vec<String> {
35+
LEAN_BASH_COMMANDS
36+
.iter()
37+
.map(|c| (*c).to_string())
38+
.collect()
39+
}
40+
41+
fn prompt_supplement(&self) -> Option<String> {
42+
Some(
43+
"\n\
44+
---\n\
45+
\n\
46+
## Lean 4 Formal Verification\n\
47+
\n\
48+
Lean 4 is installed and available. Use `lean` to typecheck `.lean` files, \
49+
`lake build` to build Lake projects, and `lake env printPaths` to inspect \
50+
the toolchain. Lean files use the `.lean` extension.\n"
51+
.to_string(),
52+
)
53+
}
54+
55+
fn prepare_steps(&self) -> Vec<String> {
56+
vec![lean::generate_lean_install(&self.config)]
57+
}
58+
59+
fn validate(&self, ctx: &CompileContext) -> Result<Vec<String>> {
60+
let mut warnings = Vec::new();
61+
62+
let is_bash_disabled = ctx
63+
.front_matter
64+
.tools
65+
.as_ref()
66+
.and_then(|t| t.bash.as_ref())
67+
.is_some_and(|cmds| cmds.is_empty());
68+
69+
if is_bash_disabled {
70+
warnings.push(format!(
71+
"Agent '{}' has runtimes.lean enabled but tools.bash is empty. \
72+
Lean requires bash access (lean, lake, elan commands).",
73+
ctx.agent_name
74+
));
75+
}
76+
77+
Ok(warnings)
78+
}
79+
}

0 commit comments

Comments
 (0)