Skip to content
Draft
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
55 changes: 55 additions & 0 deletions docs/runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,58 @@ When enabled, the compiler:
- Emits a compile-time warning if `tools.bash` is empty (Lean requires bash access)

**Note:** In the 1ES target, the bash command allow-list is updated but elan installation must be done manually via `steps:` front matter. The 1ES target handles network isolation separately.

### Python (`python:`)

Python runtime. Optionally installs a specific Python version via the `UsePythonVersion@0` ADO task, adds PyPI domains to the network allowlist, extends the bash command allow-list (`python`, `python3`, `pip`, `pip3`), and optionally injects package feed environment variables to override the default PyPI registry with an internal feed.

```yaml
# Simple enablement (uses system Python, no install step emitted)
runtimes:
python: true

# Install a specific Python version
runtimes:
python:
version: "3.12"

# Install a version and redirect pip/uv to an internal ADO Artifacts feed
runtimes:
python:
version: "3.12"
index-url: "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/pypi/simple/"

# Internal primary feed with a public fallback
runtimes:
python:
version: "3.x"
index-url: "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/pypi/simple/"
extra-index-url: "https://pypi.org/simple/"
```

When enabled, the compiler:
- Injects a `UsePythonVersion@0` task step into `{{ prepare_steps }}` when `version:` is specified (runs before AWF network isolation); when only `true` is used, relies on the system Python
- Auto-adds `python`, `python3`, `pip`, `pip3` to the bash command allow-list
- Adds the `python` ecosystem identifier to the AWF network allowlist (expands to PyPI domains — `pypi.org`, `files.pythonhosted.org`, etc.)
- Appends a prompt supplement informing the agent about Python availability
- Emits a compile-time warning if `tools.bash` is empty (Python requires bash access)

#### Internal feed configuration

When `index-url:` is specified, the compiler injects the following environment variables into the AWF agent step:

| Environment variable | Tool | Purpose |
|---|---|---|
| `PIP_INDEX_URL` | pip | Overrides the primary pip package index |
| `UV_DEFAULT_INDEX` | uv | Overrides the primary uv package index |

When `extra-index-url:` is specified additionally:

| Environment variable | Tool | Purpose |
|---|---|---|
| `PIP_EXTRA_INDEX_URL` | pip | Adds a secondary fallback pip package index |

These variables are injected into the `env:` block of the AWF step so they are visible to the agent process inside the network-isolated sandbox. This allows `pip install` and `uv` commands run by the agent to resolve packages from the internal feed.

**Tip:** If you want to prevent the agent from falling back to public PyPI entirely, set `network.blocked` to block `pypi.org` and `files.pythonhosted.org` after pointing the index URL to your internal feed.

97 changes: 97 additions & 0 deletions src/compile/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1912,6 +1912,32 @@ pub fn collect_awf_path_prepends(extensions: &[super::extensions::Extension]) ->
.collect()
}

/// Collects `agent_env_vars()` from all extensions and formats them as YAML
/// `env:` block lines ready to be appended to the engine env block.
///
/// Returns an empty string when no extensions declare env vars.
/// Each entry is formatted as `KEY: "VALUE"` with backslash and double-quote
/// escaping applied to the value. Other YAML special characters (newlines,
/// tabs, control characters) are rejected at validation time by each extension
/// before this function is called.
pub fn collect_agent_env_vars(extensions: &[super::extensions::Extension]) -> String {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut lines: Vec<String> = Vec::new();

for ext in extensions {
for (key, value) in ext.agent_env_vars() {
if seen.contains(&key) {
continue;
}
seen.insert(key.clone());
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
lines.push(format!("{key}: \"{escaped}\""));
}
}

lines.join("\n")
}

// ==================== Shared compile flow ====================

/// Target-specific overrides for the shared compile flow.
Expand Down Expand Up @@ -2104,6 +2130,12 @@ pub async fn compile_shared(
if !awf_path_env.is_empty() {
engine_env = format!("{engine_env}\n{awf_path_env}");
}

// Append extension-contributed agent env vars (e.g., Python feed overrides)
let ext_env = collect_agent_env_vars(extensions);
if !ext_env.is_empty() {
engine_env = format!("{engine_env}\n{ext_env}");
}
let engine_log_dir = ctx.engine.log_dir();
let acquire_write_token = generate_acquire_ado_token(
front_matter
Expand Down Expand Up @@ -2505,6 +2537,7 @@ mod tests {
});
fm.runtimes = Some(crate::compile::types::RuntimesConfig {
lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(true)),
python: None,
});
let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap();
assert!(params.contains("shell(lean)"), "lean command should be allowed");
Expand All @@ -2525,6 +2558,7 @@ mod tests {
});
fm.runtimes = Some(crate::compile::types::RuntimesConfig {
lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(true)),
python: None,
});
let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap();
assert!(params.contains("--allow-all-tools"), "wildcard should use --allow-all-tools");
Expand Down Expand Up @@ -4272,6 +4306,69 @@ mod tests {
assert_eq!(result, "GITHUB_PATH: $(GITHUB_PATH)");
}

// ─── collect_agent_env_vars ──────────────────────────────────────────────

#[test]
fn test_collect_agent_env_vars_no_extensions() {
use crate::compile::extensions::collect_extensions;
let fm = minimal_front_matter();
let exts = collect_extensions(&fm);
let result = collect_agent_env_vars(&exts);
assert!(result.is_empty(), "no extensions with env vars should produce empty string");
}

#[test]
fn test_collect_agent_env_vars_python_index_url() {
use crate::compile::extensions::collect_extensions;
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\nruntimes:\n python:\n index-url: \"https://internal.example.com/pypi/simple/\"\n---\n",
).unwrap();
let exts = collect_extensions(&fm);
let result = collect_agent_env_vars(&exts);
assert!(result.contains("PIP_INDEX_URL"), "should contain PIP_INDEX_URL");
assert!(result.contains("UV_DEFAULT_INDEX"), "should contain UV_DEFAULT_INDEX");
assert!(result.contains("https://internal.example.com/pypi/simple/"));
}

#[test]
fn test_collect_agent_env_vars_deduplicates_keys() {
// If two extensions somehow declare the same key, only the first wins.
use crate::compile::extensions::{Extension, collect_extensions};
use crate::runtimes::python::{PythonExtension, PythonRuntimeConfig, PythonOptions};
let ext1 = Extension::Python(PythonExtension::new(PythonRuntimeConfig::WithOptions(PythonOptions {
version: None,
index_url: Some("https://first.example.com/pypi/simple/".to_string()),
extra_index_url: None,
})));
let ext2 = Extension::Python(PythonExtension::new(PythonRuntimeConfig::WithOptions(PythonOptions {
version: None,
index_url: Some("https://second.example.com/pypi/simple/".to_string()),
extra_index_url: None,
})));
let exts = vec![ext1, ext2];
let result = collect_agent_env_vars(&exts);
// Only the first occurrence of each key should appear
assert_eq!(result.matches("PIP_INDEX_URL").count(), 1, "each key should appear at most once");
assert!(result.contains("first.example.com"), "first value should win");
assert!(!result.contains("second.example.com"), "second value should not appear");
}

#[test]
fn test_collect_agent_env_vars_quotes_special_chars() {
use crate::compile::extensions::Extension;
use crate::runtimes::python::{PythonExtension, PythonRuntimeConfig, PythonOptions};
let ext = Extension::Python(PythonExtension::new(PythonRuntimeConfig::WithOptions(PythonOptions {
version: None,
// Value contains a backslash and double-quote that should be YAML-escaped
index_url: Some("https://example.com/path\\with\"quotes".to_string()),
extra_index_url: None,
})));
let exts = vec![ext];
let result = collect_agent_env_vars(&exts);
assert!(result.contains("\\\\"), "backslash should be escaped");
assert!(result.contains("\\\""), "double-quote should be escaped");
}

// ═══════════════════════════════════════════════════════════════════════
// Tests moved from standalone.rs — MCPG config, docker env, validation
// ═══════════════════════════════════════════════════════════════════════
Expand Down
23 changes: 23 additions & 0 deletions src/compile/extensions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,19 @@ pub trait CompilerExtension {
fn awf_path_prepends(&self) -> Vec<String> {
vec![]
}

/// Additional environment variables to inject into the AWF agent step.
///
/// Returned key-value pairs are appended to the `env:` block of the AWF
/// step that runs the AI agent. Use this to set runtime-specific env vars
/// such as package feed overrides (e.g., `PIP_INDEX_URL` for Python) that
/// must be visible to the agent process inside the AWF container.
///
/// Keys must be valid environment variable names (`[A-Za-z_][A-Za-z0-9_]*`).
/// Values are YAML-quoted by the compiler — no escaping is required here.
fn agent_env_vars(&self) -> Vec<(String, String)> {
vec![]
}
}

/// Mount access mode for an AWF bind mount.
Expand Down Expand Up @@ -534,6 +547,9 @@ macro_rules! extension_enum {
fn awf_path_prepends(&self) -> Vec<String> {
match self { $( $Enum::$Variant(e) => e.awf_path_prepends(), )+ }
}
fn agent_env_vars(&self) -> Vec<(String, String)> {
match self { $( $Enum::$Variant(e) => e.agent_env_vars(), )+ }
}
}
};
}
Expand All @@ -547,6 +563,7 @@ pub use crate::tools::azure_devops::AzureDevOpsExtension;
pub use crate::tools::cache_memory::CacheMemoryExtension;
pub use github::GitHubExtension;
pub use crate::runtimes::lean::LeanExtension;
pub use crate::runtimes::python::PythonExtension;
pub use safe_outputs::SafeOutputsExtension;
pub use trigger_filters::TriggerFiltersExtension;

Expand All @@ -559,6 +576,7 @@ extension_enum! {
GitHub(GitHubExtension),
SafeOutputs(SafeOutputsExtension),
Lean(LeanExtension),
Python(PythonExtension),
AzureDevOps(AzureDevOpsExtension),
CacheMemory(CacheMemoryExtension),
TriggerFilters(TriggerFiltersExtension),
Expand Down Expand Up @@ -593,6 +611,11 @@ pub fn collect_extensions(front_matter: &FrontMatter) -> Vec<Extension> {
extensions.push(Extension::Lean(LeanExtension::new(lean.clone())));
}
}
if let Some(python) = front_matter.runtimes.as_ref().and_then(|r| r.python.as_ref()) {
if python.is_enabled() {
extensions.push(Extension::Python(PythonExtension::new(python.clone())));
}
}

// ── First-party tools (ExtensionPhase::Tool) ──
if let Some(tools) = front_matter.tools.as_ref() {
Expand Down
Loading