Skip to content

Commit 4576bc3

Browse files
authored
feat(compile): add awf_path_prepends for chroot PATH injection (#359)
1 parent d9c5502 commit 4576bc3

11 files changed

Lines changed: 218 additions & 1 deletion

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: 126 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(has_awf_paths: bool) -> String {
1750+
if !has_awf_paths {
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.
@@ -1719,6 +1781,10 @@ pub struct CompileConfig {
17191781
/// backend probe step) are included in the generated pipeline.
17201782
/// Gated behind `cfg(debug_assertions)` at the CLI level.
17211783
pub debug_pipeline: bool,
1784+
/// Whether any extension declared AWF path prepends. Used by `compile_shared`
1785+
/// to append `GITHUB_PATH: $(GITHUB_PATH)` to the engine env block without
1786+
/// re-collecting path prepends from extensions.
1787+
pub has_awf_paths: bool,
17221788
}
17231789

17241790
/// Shared compilation flow used by both standalone and 1ES compilers.
@@ -1826,7 +1892,13 @@ pub async fn compile_shared(
18261892
.and_then(|p| p.read.as_deref()),
18271893
"SC_READ_TOKEN",
18281894
);
1829-
let engine_env = ctx.engine.env(&front_matter.engine)?;
1895+
let mut engine_env = ctx.engine.env(&front_matter.engine)?;
1896+
1897+
// Append GITHUB_PATH env mapping when extensions declare path prepends
1898+
let awf_path_env = generate_awf_path_env(config.has_awf_paths);
1899+
if !awf_path_env.is_empty() {
1900+
engine_env = format!("{engine_env}\n{awf_path_env}");
1901+
}
18301902
let engine_log_dir = ctx.engine.log_dir();
18311903
let acquire_write_token = generate_acquire_ado_token(
18321904
front_matter
@@ -3775,6 +3847,59 @@ mod tests {
37753847
assert!(!result.contains(" "), "should not contain hard-coded indent");
37763848
}
37773849

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

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 & 0 deletions
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,6 +53,8 @@ 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);
56+
let awf_paths = collect_awf_path_prepends(&extensions);
57+
let awf_path_step = generate_awf_path_step(&awf_paths);
5458
let enabled_tools_args = generate_enabled_tools_args(front_matter);
5559

5660
let mcpg_config = generate_mcpg_config(front_matter, &ctx, &extensions)?;
@@ -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),
@@ -84,6 +89,7 @@ impl Compiler for OneESCompiler {
8489
],
8590
skip_integrity,
8691
debug_pipeline,
92+
has_awf_paths: !awf_paths.is_empty(),
8793
};
8894

8995
compile_shared(input_path, output_path, front_matter, markdown_body, &extensions, &ctx, config).await

src/compile/standalone.rs

Lines changed: 6 additions & 0 deletions
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,6 +54,8 @@ 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);
57+
let awf_paths = collect_awf_path_prepends(&extensions);
58+
let awf_path_step = generate_awf_path_step(&awf_paths);
5559
let enabled_tools_args = generate_enabled_tools_args(front_matter);
5660

5761
let config_obj = generate_mcpg_config(front_matter, &ctx, &extensions)?;
@@ -70,13 +74,15 @@ 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),
7681
("{{ mcpg_step_env }}".into(), mcpg_step_env),
7782
],
7883
skip_integrity,
7984
debug_pipeline,
85+
has_awf_paths: !awf_paths.is_empty(),
8086
};
8187

8288
compile_shared(input_path, output_path, front_matter, markdown_body, &extensions, &ctx, config).await

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)