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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,11 @@ Every compiled pipeline runs as three sequential jobs:
│ │ └── upload_workitem_attachment.rs
│ ├── runtimes/ # Runtime environment implementations (one dir per runtime)
│ │ ├── mod.rs # Module entry point
│ │ └── lean/ # Lean 4 theorem prover runtime
│ │ ├── mod.rs # Config types, install helpers
│ │ ├── lean/ # Lean 4 theorem prover runtime
│ │ │ ├── mod.rs # Config types, install helpers
│ │ │ └── extension.rs # CompilerExtension impl
│ │ └── node/ # Node.js runtime
│ │ ├── mod.rs # Config types, install/feed helpers
│ │ └── extension.rs # CompilerExtension impl
│ ├── data/
│ │ ├── base.yml # Base pipeline template for standalone
Expand Down
46 changes: 46 additions & 0 deletions docs/runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,52 @@ The `runtimes` field configures language environments that are installed before

Aligned with [gh-aw's `runtimes:` front matter field](https://github.github.com/gh-aw/reference/frontmatter/#runtimes-runtimes).

### Node.js (`node:`)

Node.js runtime. Installs Node.js via `NodeTool@0` (the same ADO task used internally by the `gate.js` and `prompt.js` ado-script bundles), adds npm registry domains to the network allowlist, extends the bash command allow-list, and appends a prompt supplement informing the agent that Node.js is available.

Optionally configures npm to use a private/internal registry (e.g., an Azure Artifacts feed) with bearer-token authentication.

```yaml
# Simple enablement (installs Node.js 20.x LTS)
runtimes:
node: true

# Pin to a specific LTS major version
runtimes:
node:
version: "22.x"

# With an internal npm feed (Azure Artifacts)
runtimes:
node:
version: "20.x"
internal-feed:
registry: "https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/npm/registry/"
auth-token-var: "SC_READ_TOKEN"
```

When enabled, the compiler:
- Injects a `NodeTool@0` step into `{{ prepare_steps }}` (runs before the agent)
- Defaults to Node.js `20.x` (current LTS); accepts any `NodeTool@0` version spec (e.g., `"22.x"`)
- Auto-adds `node`, `npm`, and `npx` to the bash command allow-list
- Adds npm registry domains to the network allowlist (expands the `"node"` ecosystem identifier)
- Appends a prompt supplement informing the agent about Node.js availability and basic commands
- Emits a compile-time warning if `tools.bash` is empty (Node.js requires bash access)

When `internal-feed` is configured, the compiler also injects a `bash` step that:
1. Runs `npm config set registry <registry-url>` to redirect all npm commands to the private registry.
2. If `auth-token-var` is set: runs `npm config set //<registry-path>/:_authToken "$TOKEN"` to authenticate. The token is read from the named pipeline variable at runtime — it is never embedded in the compiled YAML.

#### `internal-feed` options

| Field | Type | Description |
|-------|------|-------------|
| `registry` | string (required) | Full npm registry URL, e.g., `https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/npm/registry/` |
| `auth-token-var` | string (optional) | Pipeline variable name holding the auth token (e.g., `"SC_READ_TOKEN"`). When set, `npm config` is updated with the per-registry `_authToken` so authenticated feeds work without a pre-existing `.npmrc`. |

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

### Lean 4 (`lean:`)

Lean 4 theorem prover runtime. Auto-installs the Lean toolchain via elan, extends the bash command allow-list, adds Lean-specific domains to the network allowlist, and appends a prompt supplement informing the agent that Lean is available.
Expand Down
3 changes: 3 additions & 0 deletions src/compile/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,7 @@ pub fn generate_prepare_agent_prompt(

let node_step = super::extensions::node_tool_step(
"Install Node.js 20.x for prompt renderer",
"20.x",
);
let download_step = super::extensions::scripts_download_step();

Expand Down Expand Up @@ -2659,6 +2660,7 @@ mod tests {
});
fm.runtimes = Some(crate::compile::types::RuntimesConfig {
lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(true)),
node: 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 @@ -2679,6 +2681,7 @@ mod tests {
});
fm.runtimes = Some(crate::compile::types::RuntimesConfig {
lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(true)),
node: 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
25 changes: 16 additions & 9 deletions src/compile/extensions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,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::node::NodeExtension;
pub use safe_outputs::SafeOutputsExtension;
pub use trigger_filters::TriggerFiltersExtension;

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

// ── First-party tools (ExtensionPhase::Tool) ──
if let Some(tools) = front_matter.tools.as_ref() {
Expand Down Expand Up @@ -692,16 +699,16 @@ pub fn wrap_prompt_append(content: &str, display_name: &str) -> Result<String> {
/// Base URL for ado-aw release artifacts (used by `scripts_download_step`).
const SCRIPTS_RELEASE_BASE_URL: &str = "https://github.com/githubnext/ado-aw/releases/download";

/// `NodeTool@0` step that installs Node 20.x. Required by any
/// `ado-script` bundle (currently `gate.js` and `prompt.js`). Pin to LTS
/// major; ado-aw only requires basic Node features, so any 20.x patch
/// release is acceptable. NodeTool@0 is preinstalled on
/// Microsoft-hosted and 1ES images and idempotent across multiple
/// invocations in the same job, so emitting it more than once per job
/// is safe.
pub fn node_tool_step(display_name: &str) -> String {
/// `NodeTool@0` step that installs Node.js at the requested version. Required
/// by any `ado-script` bundle (currently `gate.js` and `prompt.js`) and by
/// the Node runtime extension when `runtimes.node` is enabled.
///
/// NodeTool@0 is preinstalled on Microsoft-hosted and 1ES images and is
/// idempotent across multiple invocations in the same job, so emitting it
/// more than once per job is safe.
pub fn node_tool_step(display_name: &str, version_spec: &str) -> String {
format!(
"- task: NodeTool@0\n inputs:\n versionSpec: \"20.x\"\n displayName: \"{display_name}\"\n condition: succeeded()"
"- task: NodeTool@0\n inputs:\n versionSpec: \"{version_spec}\"\n displayName: \"{display_name}\"\n condition: succeeded()"
)
}

Expand Down
1 change: 1 addition & 0 deletions src/compile/extensions/trigger_filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ impl CompilerExtension for TriggerFiltersExtension {
// in lockstep on URL/version.
steps.push(super::node_tool_step(
"Install Node.js 20.x for gate evaluator",
"20.x",
));
steps.push(super::scripts_download_step());
steps.extend(gate_steps);
Expand Down
2 changes: 2 additions & 0 deletions src/compile/standalone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ mod tests {
let mut fm = minimal_front_matter();
fm.runtimes = Some(crate::compile::types::RuntimesConfig {
lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(true)),
node: None,
});
let exts = super::super::extensions::collect_extensions(&fm);
let domains = generate_allowed_domains(&fm, &exts).unwrap();
Expand All @@ -191,6 +192,7 @@ mod tests {
let mut fm = minimal_front_matter();
fm.runtimes = Some(crate::compile::types::RuntimesConfig {
lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(false)),
node: None,
});
let exts = super::super::extensions::collect_extensions(&fm);
let domains = generate_allowed_domains(&fm, &exts).unwrap();
Expand Down
9 changes: 9 additions & 0 deletions src/compile/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,13 +499,22 @@ pub struct RuntimesConfig {
/// extends the bash command allow-list, and appends a prompt supplement.
#[serde(default)]
pub lean: Option<crate::runtimes::lean::LeanRuntimeConfig>,
/// Node.js runtime.
/// Installs Node.js via `NodeTool@0`, adds npm registry domains to the
/// network allowlist, extends the bash command allow-list, and appends
/// a prompt supplement. Supports optional internal-feed configuration.
#[serde(default)]
pub node: Option<crate::runtimes::node::NodeRuntimeConfig>,
}

impl SanitizeConfigTrait for RuntimesConfig {
fn sanitize_config_fields(&mut self) {
if let Some(ref mut lean) = self.lean {
lean.sanitize_config_fields();
}
if let Some(ref mut node) = self.node {
node.sanitize_config_fields();
}
}
}

Expand Down
1 change: 1 addition & 0 deletions src/runtimes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
//! Aligned with gh-aw's `runtimes:` front matter field.

pub mod lean;
pub mod node;
86 changes: 86 additions & 0 deletions src/runtimes/node/extension.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// ─── Node.js runtime ─────────────────────────────────────────────────

use crate::compile::extensions::{CompileContext, CompilerExtension, ExtensionPhase};
use super::{NODE_BASH_COMMANDS, NodeRuntimeConfig, generate_node_feed_config, generate_node_install};
use anyhow::Result;

/// Node.js runtime extension.
///
/// Injects: network hosts (npm registry domains), bash commands (`node`,
/// `npm`, `npx`), install steps (`NodeTool@0` + optional internal-feed
/// configuration), and a prompt supplement.
pub struct NodeExtension {
config: NodeRuntimeConfig,
}

impl NodeExtension {
pub fn new(config: NodeRuntimeConfig) -> Self {
Self { config }
}
}

impl CompilerExtension for NodeExtension {
fn name(&self) -> &str {
"Node.js"
}

fn phase(&self) -> ExtensionPhase {
ExtensionPhase::Runtime
}

fn required_hosts(&self) -> Vec<String> {
vec!["node".to_string()]
}

fn required_bash_commands(&self) -> Vec<String> {
NODE_BASH_COMMANDS
.iter()
.map(|c| (*c).to_string())
.collect()
}

fn prompt_supplement(&self) -> Option<String> {
Some(
"\n\
---\n\
\n\
## Node.js Runtime\n\
\n\
Node.js is installed and available. Use `node` to run JavaScript files, \
`npm` for package management, and `npx` to execute package binaries. \
The `node_modules` directory is available after `npm install`.\n"
.to_string(),
)
}

fn prepare_steps(&self) -> Vec<String> {
let mut steps = vec![generate_node_install(&self.config)];

if let Some(feed) = self.config.internal_feed() {
steps.push(generate_node_feed_config(feed));
}

steps
}

fn validate(&self, ctx: &CompileContext) -> Result<Vec<String>> {
let mut warnings = Vec::new();

let is_bash_disabled = ctx
.front_matter
.tools
.as_ref()
.and_then(|t| t.bash.as_ref())
.is_some_and(|cmds| cmds.is_empty());

if is_bash_disabled {
warnings.push(format!(
"Agent '{}' has runtimes.node enabled but tools.bash is empty. \
Node.js requires bash access (node, npm, npx commands).",
ctx.agent_name
));
}

Ok(warnings)
}
}
Loading