Skip to content
Merged
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
4 changes: 4 additions & 0 deletions docs/front-matter.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ runtimes: # optional runtime configuration (language enviro
# node: # Alternative object format (pin version, configure internal feed)
# version: "22.x"
# feed-url: "https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/npm/registry/"
# dotnet: true # .NET runtime — auto-installs via UseDotNet@2 (see docs/runtimes.md)
# dotnet: # Alternative object format (pin version, configure internal feed via nuget.config)
# version: "8.0.x" # use "global.json" to pin from the repo's global.json
# feed-url: "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json"
# env: # RESERVED: workflow-level environment variables (not yet implemented)
# CUSTOM_VAR: "value"
mcp-servers:
Expand Down
64 changes: 64 additions & 0 deletions docs/runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,68 @@ When enabled, the compiler:
- No AWF mounts or PATH prepends needed — `NodeTool@0` installs to `/opt/hostedtoolcache` (auto-mounted by AWF) and publishes PATH entries that AWF merges via `$GITHUB_PATH`
- Note: AWF overlays `~/.npmrc` with `/dev/null` for credential security — the `NPM_CONFIG_REGISTRY` env var approach avoids conflicting with this overlay

### .NET (`dotnet:`)
.NET runtime. Auto-installs the .NET SDK via `UseDotNet@2`, emits `NuGetAuthenticate@1` for internal feed access, adds .NET ecosystem domains to the AWF network allowlist, and extends the bash command allow-list with `dotnet`.

```yaml
# Simple enablement (installs default .NET SDK, currently 8.0.x)
runtimes:
dotnet: true

# With options (pin version, configure internal feed)
runtimes:
dotnet:
version: "8.0.x"
feed-url: "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json"

# Or point at a checked-in nuget.config
runtimes:
dotnet:
version: "8.0.x"
config: "nuget.config"

# Pin SDK from the repo's global.json (UseDotNet@2 useGlobalJson mode)
runtimes:
dotnet:
version: "global.json"
```

**Fields:**

| Field | Type | Description |
|-------|------|-------------|
| `version` | string | .NET SDK version to install (e.g., `"8.0.x"`, `"9.0.x"`). Passed to `UseDotNet@2` `version` with `packageType: 'sdk'`. Defaults to `"8.0.x"`. The special value `"global.json"` (case-insensitive) emits `useGlobalJson: true` instead, which discovers and installs every SDK referenced by `global.json` files in the workspace. |
| `feed-url` | string | Internal NuGet feed URL (typically the v3 `index.json` of an Azure Artifacts feed). When set, the compiler creates a minimal `nuget.config` if none exists and runs `NuGetAuthenticate@1`. |
| `config` | string | Path to a checked-in `nuget.config` in the repo. When set, the compiler runs `NuGetAuthenticate@1` (which auto-discovers `nuget.config` files in the workspace). Mutually exclusive with `feed-url`. |

**`global.json` precedence.** A `global.json` file in the repo is the canonical
way to pin the .NET SDK. The compiler enforces a single source of truth:

- If a `global.json` exists at the agent's compile directory **and** the front
matter sets a concrete `version`, compilation **errors out**. Either remove
the front-matter version or set it to the literal string `"global.json"` to
opt into `UseDotNet@2`'s `useGlobalJson: true` mode.
- If `version: "global.json"` is set, the compiler emits
`useGlobalJson: true` (no explicit `version:` input) so the install task
walks the workspace for `global.json` files itself.
- If no `version` is set and a `global.json` exists, the compiler does not
auto-promote — the default `"8.0.x"` is used. Opt in explicitly with the
sentinel.

When enabled, the compiler:
- Injects `UseDotNet@2` into `{{ prepare_steps }}` (runs before AWF)
- If `feed-url` is set, injects an ensure-`nuget.config` step (writes a minimal `nuget.config` referencing the feed only when one doesn't already exist) and `NuGetAuthenticate@1`
- If `config` is set (and `feed-url` is not), injects `NuGetAuthenticate@1` only — the user-checked-in `nuget.config` is assumed to be present in the workspace
- Auto-adds `dotnet` to the bash command allow-list
- Adds .NET ecosystem domains to the network allowlist (nuget.org, dotnet.microsoft.com, pkgs.dev.azure.com, etc.)
- Appends a prompt supplement informing the agent about .NET availability
- No AWF mounts or PATH prepends needed — `UseDotNet@2` installs to `/opt/hostedtoolcache` (auto-mounted by AWF) and publishes PATH entries that AWF merges via `$GITHUB_PATH`

**Differences from the Python and Node runtimes** (called out for clarity, since this runtime intentionally diverges):
- **No agent env var is injected for `feed-url`.** Unlike `pip` (`PIP_INDEX_URL`) and `npm` (`NPM_CONFIG_REGISTRY`), NuGet has no first-class environment-variable equivalent for selecting a package source. Feed configuration always goes through a `nuget.config` file.
- **`config:` is functional, not a deferred warning.** AWF only overlays files in `$HOME` (e.g., `~/.npmrc` → `/dev/null`); workspace files such as `nuget.config` are preserved inside the agent sandbox, so a checked-in `nuget.config` works today.
- **`NuGetAuthenticate@1` requires no `workingFile:` input.** It auto-discovers `nuget.config` files anywhere in the workspace, unlike `npmAuthenticate@0` which needs an explicit path.

### Combining Runtimes

Multiple runtimes can be enabled simultaneously:
Expand All @@ -113,6 +175,8 @@ runtimes:
version: "3.12"
node:
version: "22.x"
dotnet:
version: "8.0.x"
lean: true
```

Expand Down
2 changes: 2 additions & 0 deletions src/compile/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2581,6 +2581,7 @@ mod tests {
lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(true)),
python: None,
node: None,
dotnet: 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 @@ -2603,6 +2604,7 @@ mod tests {
lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(true)),
python: None,
node: None,
dotnet: 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
29 changes: 28 additions & 1 deletion src/compile/extensions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ pub struct CompileContext<'a> {
pub ado_context: Option<AdoContext>,
/// Resolved engine based on the front matter `engine:` field.
pub engine: Engine,
/// Directory containing the agent markdown being compiled (i.e. the
/// repo-relative dir against which paths like `global.json` /
/// `nuget.config` should be resolved). `None` for unit-test contexts
/// where no on-disk repo exists.
pub compile_dir: Option<&'a Path>,
}

impl<'a> CompileContext<'a> {
Expand All @@ -112,14 +117,15 @@ impl<'a> CompileContext<'a> {
/// Resolves the engine implementation from front matter and infers ADO
/// context from the git remote in `compile_dir`. Returns an error if
/// the engine identifier is unsupported.
pub async fn new(front_matter: &'a FrontMatter, compile_dir: &Path) -> Result<Self> {
pub async fn new(front_matter: &'a FrontMatter, compile_dir: &'a Path) -> Result<Self> {
let engine = engine::get_engine(front_matter.engine.engine_id())?;
let ado_context = Self::infer_ado_context(compile_dir).await;
Ok(Self {
agent_name: &front_matter.name,
front_matter,
ado_context,
engine,
compile_dir: Some(compile_dir),
})
}

Expand Down Expand Up @@ -168,6 +174,7 @@ impl<'a> CompileContext<'a> {
front_matter,
ado_context: None,
engine: crate::engine::Engine::Copilot,
compile_dir: None,
}
}

Expand All @@ -183,6 +190,19 @@ impl<'a> CompileContext<'a> {
repo_name: "test-repo".to_string(),
}),
engine: crate::engine::Engine::Copilot,
compile_dir: None,
}
}

/// Create a context for tests with a specific compile directory.
#[cfg(test)]
pub fn for_test_with_compile_dir(front_matter: &'a FrontMatter, compile_dir: &'a Path) -> Self {
Self {
agent_name: &front_matter.name,
front_matter,
ado_context: None,
engine: crate::engine::Engine::Copilot,
compile_dir: Some(compile_dir),
}
}
}
Expand Down Expand Up @@ -560,6 +580,7 @@ pub(crate) mod trigger_filters;
pub use crate::tools::azure_devops::AzureDevOpsExtension;
pub use crate::tools::cache_memory::CacheMemoryExtension;
pub use github::GitHubExtension;
pub use crate::runtimes::dotnet::DotnetExtension;
pub use crate::runtimes::lean::LeanExtension;
pub use crate::runtimes::node::NodeExtension;
pub use crate::runtimes::python::PythonExtension;
Expand All @@ -577,6 +598,7 @@ extension_enum! {
Lean(LeanExtension),
Python(PythonExtension),
Node(NodeExtension),
Dotnet(DotnetExtension),
AzureDevOps(AzureDevOpsExtension),
CacheMemory(CacheMemoryExtension),
TriggerFilters(TriggerFiltersExtension),
Expand Down Expand Up @@ -621,6 +643,11 @@ pub fn collect_extensions(front_matter: &FrontMatter) -> Vec<Extension> {
extensions.push(Extension::Node(NodeExtension::new(node.clone())));
}
}
if let Some(dotnet) = front_matter.runtimes.as_ref().and_then(|r| r.dotnet.as_ref()) {
if dotnet.is_enabled() {
extensions.push(Extension::Dotnet(DotnetExtension::new(dotnet.clone())));
}
}

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