Skip to content

Commit 079891a

Browse files
jamesadevineCopilot
andcommitted
feat(compile): add awf_path_prepends to CompilerExtension for chroot PATH injection
Add a new CompilerExtension trait method awf_path_prepends() that lets extensions declare directories to prepend to PATH inside the AWF chroot. The compiler collects these paths and generates a dedicated pipeline step (Generate GITHUB_PATH file) that writes them to a file and sets the GITHUB_PATH env var via ##vso[task.setvariable]. AWF natively reads this file at startup and merges entries into the chroot PATH, bypassing the sudo secure_path reset that strips custom PATH entries. LeanExtension declares \C:\Users\devinejames/.elan/bin so lean, lake, and elan are discoverable by the agent without requiring absolute paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d9c5502 commit 079891a

10 files changed

Lines changed: 175 additions & 3 deletions

File tree

docs/extending.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ pub trait CompilerExtension: Send {
3939
fn prepare_steps(&self) -> Vec<String>; // Pipeline steps (install, etc.)
4040
fn mcpg_servers(&self, ctx) -> Result<Vec<(String, McpgServerConfig)>>; // MCPG entries
4141
fn required_awf_mounts(&self) -> Vec<AwfMount>; // AWF Docker volume mounts
42+
fn awf_path_prepends(&self) -> Vec<String>; // Directories to add to chroot PATH
4243
fn validate(&self, ctx) -> Result<Vec<String>>; // Compile-time warnings
4344
}
4445
```

docs/template-markers.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,24 @@ When no extensions declare mounts, this is replaced with `\` (a bare bash contin
373373

374374
AWF replaces `$HOME` with an empty directory overlay for security; only explicitly mounted subdirectories are accessible inside the chroot. Shell variables like `$HOME` are expanded at runtime by bash.
375375

376+
## {{ awf_path_step }}
377+
378+
Replaced with a dedicated pipeline step that generates a `GITHUB_PATH` file for AWF chroot PATH discovery. The step is collected from `CompilerExtension::awf_path_prepends()` — each extension can declare directories that should be on PATH inside the AWF chroot (e.g., the Lean runtime declares `$HOME/.elan/bin`).
379+
380+
AWF reads the `$GITHUB_PATH` environment variable (a path to a file) at startup, reads path entries from it (one per line), and merges them into `AWF_HOST_PATH` which becomes the chroot PATH. This bypasses the `sudo` `secure_path` reset that strips custom PATH entries.
381+
382+
When no extensions declare path prepends, this is replaced with an empty string and the step is omitted.
383+
384+
Example generated step (with Lean enabled):
385+
386+
```yaml
387+
- bash: |
388+
AWF_PATH_FILE="/tmp/awf-tools/ado-path-entries"
389+
echo "$HOME/.elan/bin" >> "$AWF_PATH_FILE"
390+
echo "##vso[task.setvariable variable=GITHUB_PATH]$AWF_PATH_FILE"
391+
displayName: "Generate GITHUB_PATH file"
392+
```
393+
376394
## {{ enabled_tools_args }}
377395

378396
Should be replaced with `--enabled-tools <name>` CLI arguments for the SafeOutputs MCP HTTP server. The tool list is derived from `safe-outputs:` front matter keys plus always-on diagnostic tools (`noop`, `missing-data`, `missing-tool`, `report-incomplete`).

src/compile/common.rs

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1700,6 +1700,65 @@ pub fn generate_awf_mounts(extensions: &[super::extensions::Extension]) -> Strin
17001700
.join("\n")
17011701
}
17021702

1703+
/// Generates a dedicated pipeline step that writes a `GITHUB_PATH` file
1704+
/// containing directories collected from `CompilerExtension::awf_path_prepends()`.
1705+
///
1706+
/// AWF reads the `$GITHUB_PATH` environment variable (a path to a file) at
1707+
/// startup and merges its entries into the chroot PATH. This mechanism was
1708+
/// designed for GitHub Actions `setup-*` actions but works identically on
1709+
/// ADO when we compose the file ourselves.
1710+
///
1711+
/// The generated step uses `##vso[task.setvariable]` to set `GITHUB_PATH`
1712+
/// as a pipeline variable visible to subsequent steps (including the AWF
1713+
/// invocation step that runs under `sudo`). This bypasses the `sudo`
1714+
/// `secure_path` reset that strips custom PATH entries.
1715+
///
1716+
/// When no extensions declare path prepends, returns an empty string and
1717+
/// the step is omitted from the pipeline.
1718+
pub fn generate_awf_path_step(extensions: &[super::extensions::Extension]) -> String {
1719+
let paths: Vec<String> = extensions
1720+
.iter()
1721+
.flat_map(|ext| ext.awf_path_prepends())
1722+
.collect();
1723+
1724+
if paths.is_empty() {
1725+
return String::new();
1726+
}
1727+
1728+
let echo_lines: String = paths
1729+
.iter()
1730+
.map(|p| format!(" echo \"{}\" >> \"$AWF_PATH_FILE\"", p))
1731+
.collect::<Vec<_>>()
1732+
.join("\n");
1733+
1734+
format!(
1735+
"\
1736+
- bash: |
1737+
AWF_PATH_FILE=\"/tmp/awf-tools/ado-path-entries\"
1738+
{echo_lines}
1739+
echo \"##vso[task.setvariable variable=GITHUB_PATH]$AWF_PATH_FILE\"
1740+
displayName: \"Generate GITHUB_PATH file\""
1741+
)
1742+
}
1743+
1744+
/// Generates the `env:` block entry that passes `GITHUB_PATH` to the AWF
1745+
/// invocation step.
1746+
///
1747+
/// ADO pipeline variables set via `##vso[task.setvariable]` are auto-mapped
1748+
/// as environment variables in subsequent steps, but we explicitly pass
1749+
/// `GITHUB_PATH` via the `env:` block for clarity and robustness.
1750+
///
1751+
/// When no extensions declare path prepends, returns an empty string.
1752+
pub fn generate_awf_path_env(extensions: &[super::extensions::Extension]) -> String {
1753+
let has_paths = extensions.iter().any(|ext| !ext.awf_path_prepends().is_empty());
1754+
1755+
if !has_paths {
1756+
return String::new();
1757+
}
1758+
1759+
"GITHUB_PATH: $(GITHUB_PATH)".to_string()
1760+
}
1761+
17031762
// ==================== Shared compile flow ====================
17041763

17051764
/// Target-specific overrides for the shared compile flow.
@@ -1826,7 +1885,13 @@ pub async fn compile_shared(
18261885
.and_then(|p| p.read.as_deref()),
18271886
"SC_READ_TOKEN",
18281887
);
1829-
let engine_env = ctx.engine.env(&front_matter.engine)?;
1888+
let mut engine_env = ctx.engine.env(&front_matter.engine)?;
1889+
1890+
// Append GITHUB_PATH env mapping when extensions declare path prepends
1891+
let awf_path_env = generate_awf_path_env(extensions);
1892+
if !awf_path_env.is_empty() {
1893+
engine_env = format!("{engine_env}\n{awf_path_env}");
1894+
}
18301895
let engine_log_dir = ctx.engine.log_dir();
18311896
let acquire_write_token = generate_acquire_ado_token(
18321897
front_matter
@@ -3775,6 +3840,49 @@ mod tests {
37753840
assert!(!result.contains(" "), "should not contain hard-coded indent");
37763841
}
37773842

3843+
// ─── generate_awf_path_step ──────────────────────────────────────────────
3844+
3845+
#[test]
3846+
fn test_generate_awf_path_step_no_extensions() {
3847+
let fm = minimal_front_matter();
3848+
let exts = crate::compile::extensions::collect_extensions(&fm);
3849+
let result = generate_awf_path_step(&exts);
3850+
assert!(result.is_empty(), "no path prepends should produce empty string");
3851+
}
3852+
3853+
#[test]
3854+
fn test_generate_awf_path_step_with_lean() {
3855+
let (fm, _) = parse_markdown(
3856+
"---\nname: test\ndescription: test\nruntimes:\n lean: true\n---\n",
3857+
).unwrap();
3858+
let exts = crate::compile::extensions::collect_extensions(&fm);
3859+
let result = generate_awf_path_step(&exts);
3860+
assert!(result.contains("ado-path-entries"), "should reference path entries file");
3861+
assert!(result.contains(".elan/bin"), "should include elan bin path");
3862+
assert!(result.contains("GITHUB_PATH"), "should set GITHUB_PATH variable");
3863+
assert!(result.contains("displayName"), "should be a complete pipeline step");
3864+
}
3865+
3866+
// ─── generate_awf_path_env ──────────────────────────────────────────────
3867+
3868+
#[test]
3869+
fn test_generate_awf_path_env_no_extensions() {
3870+
let fm = minimal_front_matter();
3871+
let exts = crate::compile::extensions::collect_extensions(&fm);
3872+
let result = generate_awf_path_env(&exts);
3873+
assert!(result.is_empty(), "no path prepends should produce empty string");
3874+
}
3875+
3876+
#[test]
3877+
fn test_generate_awf_path_env_with_lean() {
3878+
let (fm, _) = parse_markdown(
3879+
"---\nname: test\ndescription: test\nruntimes:\n lean: true\n---\n",
3880+
).unwrap();
3881+
let exts = crate::compile::extensions::collect_extensions(&fm);
3882+
let result = generate_awf_path_env(&exts);
3883+
assert_eq!(result, "GITHUB_PATH: $(GITHUB_PATH)");
3884+
}
3885+
37783886
// ═══════════════════════════════════════════════════════════════════════
37793887
// Tests moved from standalone.rs — MCPG config, docker env, validation
37803888
// ═══════════════════════════════════════════════════════════════════════

src/compile/extensions/mod.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,20 @@ pub trait CompilerExtension {
300300
fn required_awf_mounts(&self) -> Vec<AwfMount> {
301301
vec![]
302302
}
303+
304+
/// Directories to prepend to `PATH` inside the AWF chroot.
305+
///
306+
/// Extensions that install toolchains outside standard system paths
307+
/// (e.g., elan installs Lean to `$HOME/.elan/bin`) should declare their
308+
/// bin directories here. The compiler collects these and generates a
309+
/// `GITHUB_PATH` file that AWF reads at startup to merge into the chroot
310+
/// PATH — bypassing the `sudo` PATH reset.
311+
///
312+
/// Shell variables like `$HOME` are expanded at runtime by bash, not at
313+
/// compile time.
314+
fn awf_path_prepends(&self) -> Vec<String> {
315+
vec![]
316+
}
303317
}
304318

305319
/// Mount access mode for an AWF bind mount.
@@ -504,6 +518,9 @@ macro_rules! extension_enum {
504518
fn required_awf_mounts(&self) -> Vec<AwfMount> {
505519
match self { $( $Enum::$Variant(e) => e.required_awf_mounts(), )+ }
506520
}
521+
fn awf_path_prepends(&self) -> Vec<String> {
522+
match self { $( $Enum::$Variant(e) => e.awf_path_prepends(), )+ }
523+
}
507524
}
508525
};
509526
}

src/compile/extensions/tests.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,20 @@ fn test_default_required_awf_mounts_empty() {
229229
assert!(ext.required_awf_mounts().is_empty());
230230
}
231231

232+
#[test]
233+
fn test_lean_awf_path_prepends() {
234+
let ext = LeanExtension::new(LeanRuntimeConfig::Enabled(true));
235+
let paths = ext.awf_path_prepends();
236+
assert_eq!(paths.len(), 1);
237+
assert_eq!(paths[0], "$HOME/.elan/bin");
238+
}
239+
240+
#[test]
241+
fn test_default_awf_path_prepends_empty() {
242+
let ext = GitHubExtension;
243+
assert!(ext.awf_path_prepends().is_empty());
244+
}
245+
232246
#[test]
233247
fn test_lean_validate_bash_disabled_warning() {
234248
let (fm, _) =

src/compile/onees.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use super::common::{
1515
CompileConfig, compile_shared,
1616
generate_allowed_domains,
1717
generate_awf_mounts,
18+
generate_awf_path_step,
1819
generate_enabled_tools_args,
1920
generate_mcpg_config, generate_mcpg_docker_env, generate_mcpg_step_env,
2021
format_steps_yaml_indented,
@@ -51,7 +52,8 @@ impl Compiler for OneESCompiler {
5152
// Generate values shared with standalone that are passed as extra replacements
5253
let allowed_domains = generate_allowed_domains(front_matter, &extensions)?;
5354
let awf_mounts = generate_awf_mounts(&extensions);
54-
let enabled_tools_args = generate_enabled_tools_args(front_matter);
55+
let awf_path_step = generate_awf_path_step(&extensions);
56+
let enabled_tools_args= generate_enabled_tools_args(front_matter);
5557

5658
let mcpg_config = generate_mcpg_config(front_matter, &ctx, &extensions)?;
5759
let mcpg_config_json = serde_json::to_string_pretty(&mcpg_config)
@@ -75,6 +77,7 @@ impl Compiler for OneESCompiler {
7577
("{{ mcpg_domain }}".into(), MCPG_DOMAIN.into()),
7678
("{{ allowed_domains }}".into(), allowed_domains),
7779
("{{ awf_mounts }}".into(), awf_mounts),
80+
("{{ awf_path_step }}".into(), awf_path_step),
7881
("{{ enabled_tools_args }}".into(), enabled_tools_args),
7982
("{{ mcpg_config }}".into(), mcpg_config_json),
8083
("{{ mcpg_docker_env }}".into(), mcpg_docker_env),

src/compile/standalone.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use super::common::{
1717
CompileConfig, compile_shared,
1818
generate_allowed_domains,
1919
generate_awf_mounts,
20+
generate_awf_path_step,
2021
generate_enabled_tools_args,
2122
generate_mcpg_config, generate_mcpg_docker_env, generate_mcpg_step_env,
2223
};
@@ -52,7 +53,8 @@ impl Compiler for StandaloneCompiler {
5253
// Standalone-specific values
5354
let allowed_domains = generate_allowed_domains(front_matter, &extensions)?;
5455
let awf_mounts = generate_awf_mounts(&extensions);
55-
let enabled_tools_args = generate_enabled_tools_args(front_matter);
56+
let awf_path_step = generate_awf_path_step(&extensions);
57+
let enabled_tools_args= generate_enabled_tools_args(front_matter);
5658

5759
let config_obj = generate_mcpg_config(front_matter, &ctx, &extensions)?;
5860
let mcpg_config_json =
@@ -70,6 +72,7 @@ impl Compiler for StandaloneCompiler {
7072
("{{ mcpg_domain }}".into(), MCPG_DOMAIN.into()),
7173
("{{ allowed_domains }}".into(), allowed_domains),
7274
("{{ awf_mounts }}".into(), awf_mounts),
75+
("{{ awf_path_step }}".into(), awf_path_step),
7376
("{{ enabled_tools_args }}".into(), enabled_tools_args),
7477
("{{ mcpg_config }}".into(), mcpg_config_json),
7578
("{{ mcpg_docker_env }}".into(), mcpg_docker_env),

src/data/1es-base.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ extends:
172172
173173
{{ prepare_steps }}
174174

175+
{{ awf_path_step }}
176+
175177
# Start SafeOutputs HTTP server on host (MCPG proxies to it)
176178
- bash: |
177179
SAFE_OUTPUTS_PORT=8100

src/data/base.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ jobs:
143143
144144
{{ prepare_steps }}
145145

146+
{{ awf_path_step }}
147+
146148
# Start SafeOutputs HTTP server on host (MCPG proxies to it)
147149
- bash: |
148150
SAFE_OUTPUTS_PORT=8100

src/runtimes/lean/extension.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ the toolchain. Lean files use the `.lean` extension.\n"
6060
vec![AwfMount::new("$HOME/.elan", "$HOME/.elan", AwfMountMode::ReadOnly)]
6161
}
6262

63+
fn awf_path_prepends(&self) -> Vec<String> {
64+
vec!["$HOME/.elan/bin".to_string()]
65+
}
66+
6367
fn validate(&self, ctx: &CompileContext) -> Result<Vec<String>> {
6468
let mut warnings = Vec::new();
6569

0 commit comments

Comments
 (0)