Skip to content

Commit 7a317b1

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 7a317b1

10 files changed

Lines changed: 199 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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,30 @@ 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+
cat > "$AWF_PATH_FILE" << AWF_PATH_EOF
390+
$HOME/.elan/bin
391+
AWF_PATH_EOF
392+
echo "##vso[task.setvariable variable=GITHUB_PATH]$AWF_PATH_FILE"
393+
displayName: "Generate GITHUB_PATH file"
394+
```
395+
396+
The heredoc uses an unquoted delimiter so shell variables like `$HOME` are expanded by bash at write time — AWF reads the file as literal resolved paths and does not perform shell expansion itself.
397+
398+
The `GITHUB_PATH` pipeline variable is also explicitly passed through the AWF step's `env:` block (appended to `{{ engine_env }}`) as `GITHUB_PATH: $(GITHUB_PATH)` for robust environment passthrough.
399+
376400
## {{ enabled_tools_args }}
377401

378402
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: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1700,6 +1700,68 @@ 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(awf_paths: &[String]) -> String {
1719+
if awf_paths.is_empty() {
1720+
return String::new();
1721+
}
1722+
1723+
let path_lines = awf_paths
1724+
.iter()
1725+
.map(|p| format!(" {p}"))
1726+
.collect::<Vec<_>>()
1727+
.join("\n");
1728+
1729+
format!(
1730+
"\
1731+
- bash: |
1732+
AWF_PATH_FILE=\"/tmp/awf-tools/ado-path-entries\"
1733+
cat > \"$AWF_PATH_FILE\" << AWF_PATH_EOF
1734+
{path_lines}
1735+
AWF_PATH_EOF
1736+
echo \"##vso[task.setvariable variable=GITHUB_PATH]$AWF_PATH_FILE\"
1737+
displayName: \"Generate GITHUB_PATH file\""
1738+
)
1739+
}
1740+
1741+
/// Generates the `env:` block entry that passes `GITHUB_PATH` to the AWF
1742+
/// invocation step.
1743+
///
1744+
/// ADO pipeline variables set via `##vso[task.setvariable]` are auto-mapped
1745+
/// as environment variables in subsequent steps, but we explicitly pass
1746+
/// `GITHUB_PATH` via the `env:` block for clarity and robustness.
1747+
///
1748+
/// When no path prepends exist, returns an empty string.
1749+
pub fn generate_awf_path_env(awf_paths: &[String]) -> String {
1750+
if awf_paths.is_empty() {
1751+
return String::new();
1752+
}
1753+
1754+
"GITHUB_PATH: $(GITHUB_PATH)".to_string()
1755+
}
1756+
1757+
/// Collects `awf_path_prepends()` from all extensions into a single `Vec`.
1758+
pub fn collect_awf_path_prepends(extensions: &[super::extensions::Extension]) -> Vec<String> {
1759+
extensions
1760+
.iter()
1761+
.flat_map(|ext| ext.awf_path_prepends())
1762+
.collect()
1763+
}
1764+
17031765
// ==================== Shared compile flow ====================
17041766

17051767
/// Target-specific overrides for the shared compile flow.
@@ -1826,7 +1888,14 @@ pub async fn compile_shared(
18261888
.and_then(|p| p.read.as_deref()),
18271889
"SC_READ_TOKEN",
18281890
);
1829-
let engine_env = ctx.engine.env(&front_matter.engine)?;
1891+
let mut engine_env = ctx.engine.env(&front_matter.engine)?;
1892+
1893+
// Append GITHUB_PATH env mapping when extensions declare path prepends
1894+
let awf_paths = collect_awf_path_prepends(extensions);
1895+
let awf_path_env = generate_awf_path_env(&awf_paths);
1896+
if !awf_path_env.is_empty() {
1897+
engine_env = format!("{engine_env}\n{awf_path_env}");
1898+
}
18301899
let engine_log_dir = ctx.engine.log_dir();
18311900
let acquire_write_token = generate_acquire_ado_token(
18321901
front_matter
@@ -3775,6 +3844,59 @@ mod tests {
37753844
assert!(!result.contains(" "), "should not contain hard-coded indent");
37763845
}
37773846

3847+
// ─── generate_awf_path_step ──────────────────────────────────────────────
3848+
3849+
#[test]
3850+
fn test_generate_awf_path_step_no_paths() {
3851+
let result = generate_awf_path_step(&[]);
3852+
assert!(result.is_empty(), "no path prepends should produce empty string");
3853+
}
3854+
3855+
#[test]
3856+
fn test_generate_awf_path_step_with_lean() {
3857+
let paths = collect_awf_path_prepends(
3858+
&crate::compile::extensions::collect_extensions(
3859+
&parse_markdown("---\nname: test\ndescription: test\nruntimes:\n lean: true\n---\n").unwrap().0,
3860+
),
3861+
);
3862+
let result = generate_awf_path_step(&paths);
3863+
assert!(result.contains("ado-path-entries"), "should reference path entries file");
3864+
assert!(result.contains(".elan/bin"), "should include elan bin path");
3865+
assert!(result.contains("GITHUB_PATH"), "should set GITHUB_PATH variable");
3866+
assert!(result.contains("displayName"), "should be a complete pipeline step");
3867+
assert!(result.contains("AWF_PATH_EOF"), "should use heredoc markers");
3868+
}
3869+
3870+
#[test]
3871+
fn test_generate_awf_path_step_multi_path_indentation() {
3872+
let paths = vec![
3873+
"$HOME/.elan/bin".to_string(),
3874+
"$HOME/.other-tool/bin".to_string(),
3875+
];
3876+
let result = generate_awf_path_step(&paths);
3877+
// Every path line must have consistent 4-space indentation
3878+
for path in &paths {
3879+
assert!(
3880+
result.contains(&format!(" {path}")),
3881+
"path '{path}' should have 4-space indentation"
3882+
);
3883+
}
3884+
}
3885+
3886+
// ─── generate_awf_path_env ──────────────────────────────────────────────
3887+
3888+
#[test]
3889+
fn test_generate_awf_path_env_no_paths() {
3890+
let result = generate_awf_path_env(&[]);
3891+
assert!(result.is_empty(), "no path prepends should produce empty string");
3892+
}
3893+
3894+
#[test]
3895+
fn test_generate_awf_path_env_with_paths() {
3896+
let result = generate_awf_path_env(&["$HOME/.elan/bin".to_string()]);
3897+
assert_eq!(result, "GITHUB_PATH: $(GITHUB_PATH)");
3898+
}
3899+
37783900
// ═══════════════════════════════════════════════════════════════════════
37793901
// Tests moved from standalone.rs — MCPG config, docker env, validation
37803902
// ═══════════════════════════════════════════════════════════════════════

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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ use super::common::{
1515
CompileConfig, compile_shared,
1616
generate_allowed_domains,
1717
generate_awf_mounts,
18+
generate_awf_path_step,
19+
collect_awf_path_prepends,
1820
generate_enabled_tools_args,
1921
generate_mcpg_config, generate_mcpg_docker_env, generate_mcpg_step_env,
2022
format_steps_yaml_indented,
@@ -51,7 +53,9 @@ impl Compiler for OneESCompiler {
5153
// Generate values shared with standalone that are passed as extra replacements
5254
let allowed_domains = generate_allowed_domains(front_matter, &extensions)?;
5355
let awf_mounts = generate_awf_mounts(&extensions);
54-
let enabled_tools_args = generate_enabled_tools_args(front_matter);
56+
let awf_paths = collect_awf_path_prepends(&extensions);
57+
let awf_path_step = generate_awf_path_step(&awf_paths);
58+
let enabled_tools_args= generate_enabled_tools_args(front_matter);
5559

5660
let mcpg_config = generate_mcpg_config(front_matter, &ctx, &extensions)?;
5761
let mcpg_config_json = serde_json::to_string_pretty(&mcpg_config)
@@ -75,6 +79,7 @@ impl Compiler for OneESCompiler {
7579
("{{ mcpg_domain }}".into(), MCPG_DOMAIN.into()),
7680
("{{ allowed_domains }}".into(), allowed_domains),
7781
("{{ awf_mounts }}".into(), awf_mounts),
82+
("{{ awf_path_step }}".into(), awf_path_step),
7883
("{{ enabled_tools_args }}".into(), enabled_tools_args),
7984
("{{ mcpg_config }}".into(), mcpg_config_json),
8085
("{{ mcpg_docker_env }}".into(), mcpg_docker_env),

src/compile/standalone.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ use super::common::{
1717
CompileConfig, compile_shared,
1818
generate_allowed_domains,
1919
generate_awf_mounts,
20+
generate_awf_path_step,
21+
collect_awf_path_prepends,
2022
generate_enabled_tools_args,
2123
generate_mcpg_config, generate_mcpg_docker_env, generate_mcpg_step_env,
2224
};
@@ -52,7 +54,9 @@ impl Compiler for StandaloneCompiler {
5254
// Standalone-specific values
5355
let allowed_domains = generate_allowed_domains(front_matter, &extensions)?;
5456
let awf_mounts = generate_awf_mounts(&extensions);
55-
let enabled_tools_args = generate_enabled_tools_args(front_matter);
57+
let awf_paths = collect_awf_path_prepends(&extensions);
58+
let awf_path_step = generate_awf_path_step(&awf_paths);
59+
let enabled_tools_args= generate_enabled_tools_args(front_matter);
5660

5761
let config_obj = generate_mcpg_config(front_matter, &ctx, &extensions)?;
5862
let mcpg_config_json =
@@ -70,6 +74,7 @@ impl Compiler for StandaloneCompiler {
7074
("{{ mcpg_domain }}".into(), MCPG_DOMAIN.into()),
7175
("{{ allowed_domains }}".into(), allowed_domains),
7276
("{{ awf_mounts }}".into(), awf_mounts),
77+
("{{ awf_path_step }}".into(), awf_path_step),
7378
("{{ enabled_tools_args }}".into(), enabled_tools_args),
7479
("{{ mcpg_config }}".into(), mcpg_config_json),
7580
("{{ 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)