From 727602c171f5f3009ff61c901bfa6979db1fbd6e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 08:01:16 +0000 Subject: [PATCH 1/2] feat(compile): add Node.js runtime extension with optional internal-feed config Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/95561fa8-831b-4585-aa32-5f91ed749208 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- AGENTS.md | 7 +- docs/runtimes.md | 46 ++++++ src/compile/common.rs | 3 + src/compile/extensions/mod.rs | 25 +-- src/compile/extensions/trigger_filters.rs | 1 + src/compile/standalone.rs | 2 + src/compile/types.rs | 9 ++ src/runtimes/mod.rs | 1 + src/runtimes/node/extension.rs | 86 ++++++++++ src/runtimes/node/mod.rs | 184 ++++++++++++++++++++++ 10 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 src/runtimes/node/extension.rs create mode 100644 src/runtimes/node/mod.rs diff --git a/AGENTS.md b/AGENTS.md index 516a6b58..5fe2a6e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/runtimes.md b/docs/runtimes.md index 47d78463..40dd68d3 100644 --- a/docs/runtimes.md +++ b/docs/runtimes.md @@ -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 ` to redirect all npm commands to the private registry. +2. If `auth-token-var` is set: runs `npm config set ///:_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. diff --git a/src/compile/common.rs b/src/compile/common.rs index 48b8f086..14870a1e 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -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(); @@ -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"); @@ -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"); diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index e3d2a281..185887ec 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -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; @@ -559,6 +560,7 @@ extension_enum! { GitHub(GitHubExtension), SafeOutputs(SafeOutputsExtension), Lean(LeanExtension), + Node(NodeExtension), AzureDevOps(AzureDevOpsExtension), CacheMemory(CacheMemoryExtension), TriggerFilters(TriggerFiltersExtension), @@ -593,6 +595,11 @@ pub fn collect_extensions(front_matter: &FrontMatter) -> Vec { 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() { @@ -692,16 +699,16 @@ pub fn wrap_prompt_append(content: &str, display_name: &str) -> Result { /// 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()" ) } diff --git a/src/compile/extensions/trigger_filters.rs b/src/compile/extensions/trigger_filters.rs index e6e4eb9c..126324d7 100644 --- a/src/compile/extensions/trigger_filters.rs +++ b/src/compile/extensions/trigger_filters.rs @@ -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); diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 40248826..13e8cab6 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -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(); @@ -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(); diff --git a/src/compile/types.rs b/src/compile/types.rs index e07124a0..c086fc10 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -499,6 +499,12 @@ pub struct RuntimesConfig { /// extends the bash command allow-list, and appends a prompt supplement. #[serde(default)] pub lean: Option, + /// 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, } impl SanitizeConfigTrait for RuntimesConfig { @@ -506,6 +512,9 @@ impl SanitizeConfigTrait for RuntimesConfig { if let Some(ref mut lean) = self.lean { lean.sanitize_config_fields(); } + if let Some(ref mut node) = self.node { + node.sanitize_config_fields(); + } } } diff --git a/src/runtimes/mod.rs b/src/runtimes/mod.rs index 34189761..ebfa74bd 100644 --- a/src/runtimes/mod.rs +++ b/src/runtimes/mod.rs @@ -10,3 +10,4 @@ //! Aligned with gh-aw's `runtimes:` front matter field. pub mod lean; +pub mod node; diff --git a/src/runtimes/node/extension.rs b/src/runtimes/node/extension.rs new file mode 100644 index 00000000..a4941d3e --- /dev/null +++ b/src/runtimes/node/extension.rs @@ -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 { + vec!["node".to_string()] + } + + fn required_bash_commands(&self) -> Vec { + NODE_BASH_COMMANDS + .iter() + .map(|c| (*c).to_string()) + .collect() + } + + fn prompt_supplement(&self) -> Option { + 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 { + 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> { + 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) + } +} diff --git a/src/runtimes/node/mod.rs b/src/runtimes/node/mod.rs new file mode 100644 index 00000000..3f140381 --- /dev/null +++ b/src/runtimes/node/mod.rs @@ -0,0 +1,184 @@ +//! Node.js runtime support for the ado-aw compiler. +//! +//! When enabled via `runtimes: node:`, the compiler auto-installs the Node.js +//! toolchain via `NodeTool@0` (the same ADO task used internally by the +//! `gate.js` and `prompt.js` ado-script bundles), adds Node-specific domains +//! to the AWF network allowlist, extends the bash command allow-list, and +//! appends a prompt supplement informing the agent that Node.js is available. +//! +//! Optional `internal-feed` configuration replaces the public npm registry with +//! a private feed (e.g., an Azure Artifacts feed), with optional bearer-token +//! authentication injected from a pipeline variable. + +pub mod extension; + +pub use extension::NodeExtension; + +use ado_aw_derive::SanitizeConfig; +use serde::Deserialize; + +use crate::sanitize::SanitizeConfig as SanitizeConfigTrait; + +/// Node.js runtime configuration — accepts both `true` and object formats +/// +/// Examples: +/// ```yaml +/// # Simple enablement (installs Node.js 20.x LTS) +/// runtimes: +/// node: true +/// +/// # Pin to a specific LTS version +/// runtimes: +/// node: +/// version: "22.x" +/// +/// # With an internal npm feed +/// 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" +/// ``` +#[derive(Debug, Deserialize, Clone)] +#[serde(untagged)] +pub enum NodeRuntimeConfig { + /// Simple boolean enablement + Enabled(bool), + /// Full configuration with options + WithOptions(NodeOptions), +} + +/// Default Node.js version spec installed when no `version` is specified. +const DEFAULT_NODE_VERSION: &str = "20.x"; + +impl NodeRuntimeConfig { + /// Whether the Node.js runtime is enabled. + pub fn is_enabled(&self) -> bool { + match self { + NodeRuntimeConfig::Enabled(enabled) => *enabled, + NodeRuntimeConfig::WithOptions(_) => true, + } + } + + /// The Node.js version spec to install (e.g., `"20.x"`, `"22.x"`). + /// Defaults to [`DEFAULT_NODE_VERSION`] if not specified. + pub fn version(&self) -> &str { + match self { + NodeRuntimeConfig::Enabled(_) => DEFAULT_NODE_VERSION, + NodeRuntimeConfig::WithOptions(opts) => { + opts.version.as_deref().unwrap_or(DEFAULT_NODE_VERSION) + } + } + } + + /// Optional internal npm feed configuration. + pub fn internal_feed(&self) -> Option<&NodeInternalFeedConfig> { + match self { + NodeRuntimeConfig::Enabled(_) => None, + NodeRuntimeConfig::WithOptions(opts) => opts.internal_feed.as_ref(), + } + } +} + +impl SanitizeConfigTrait for NodeRuntimeConfig { + fn sanitize_config_fields(&mut self) { + match self { + NodeRuntimeConfig::Enabled(_) => {} + NodeRuntimeConfig::WithOptions(opts) => opts.sanitize_config_fields(), + } + } +} + +/// Node.js runtime options. +#[derive(Debug, Deserialize, Clone, Default, SanitizeConfig)] +pub struct NodeOptions { + /// Node.js version spec to install (e.g., `"20.x"`, `"22.x"`). + /// Defaults to `"20.x"` (current LTS) if not specified. + #[serde(default)] + pub version: Option, + /// Optional internal npm feed configuration. + /// When set, the agent's `npm` commands will use this registry instead + /// of the default public registry. + #[serde(default, rename = "internal-feed")] + pub internal_feed: Option, +} + +/// Internal npm feed configuration for using private/enterprise registries. +/// +/// Example (Azure Artifacts feed): +/// ```yaml +/// runtimes: +/// node: +/// internal-feed: +/// registry: "https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/npm/registry/" +/// auth-token-var: "SC_READ_TOKEN" +/// ``` +#[derive(Debug, Deserialize, Clone, SanitizeConfig)] +pub struct NodeInternalFeedConfig { + /// The npm registry URL (e.g., `"https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/npm/registry/"`). + /// Replaces the public `https://registry.npmjs.org/` for all `npm install` and `npm publish` commands. + pub registry: String, + /// Pipeline variable name holding the npm authentication token. + /// + /// When provided, the generated step passes the token to `npm config set` + /// so the registry URL is authenticated. The variable is read at pipeline + /// runtime via the ADO `$(VAR)` syntax and is never embedded in the + /// compiled YAML. + /// + /// Example: `"SC_READ_TOKEN"` (a pipeline variable configured in the + /// ADO pipeline settings, typically backed by a service connection PAT). + #[serde(default, rename = "auth-token-var")] + pub auth_token_var: Option, +} + +/// Bash commands that the Node.js runtime adds to the allow-list. +pub const NODE_BASH_COMMANDS: &[&str] = &["node", "npm", "npx"]; + +/// Generate the `NodeTool@0` installation step for the Node.js runtime. +/// +/// Uses the shared [`crate::compile::extensions::node_tool_step`] helper +/// introduced in ado-aw PR #395, ensuring the version/display-name stay in +/// lockstep with the internal ado-script bundles. +pub fn generate_node_install(config: &NodeRuntimeConfig) -> String { + crate::compile::extensions::node_tool_step("Install Node.js for agent runtime", config.version()) +} + +/// Generate an npm registry configuration step for an internal feed. +/// +/// Emits a `bash` step that runs `npm config set registry` to redirect all +/// npm commands to the specified private registry. If `auth-token-var` is +/// also configured, the step additionally configures the per-registry +/// `_authToken` so authenticated feeds work without a pre-existing `.npmrc`. +/// +/// The auth token is read from the pipeline variable at runtime via the +/// `$(VAR)` ADO syntax — it is never embedded in the compiled YAML. +pub fn generate_node_feed_config(feed: &NodeInternalFeedConfig) -> String { + let registry = &feed.registry; + + if let Some(token_var) = &feed.auth_token_var { + // Derive the npm per-registry auth key by stripping the URL scheme. + // npm expects: //host/path/:_authToken (no https: prefix) + let auth_key = registry + .trim_start_matches("https:") + .trim_start_matches("http:"); + // Ensure the key ends with /:_authToken (with a trailing slash on the path) + let auth_key = auth_key.trim_end_matches('/'); + let auth_setting = format!("{auth_key}/:_authToken"); + + format!( + r#"- bash: | + npm config set registry "{registry}" + npm config set "{auth_setting}" "$NPM_FEED_TOKEN" + displayName: "Configure internal npm feed" + env: + NPM_FEED_TOKEN: $({token_var})"# + ) + } else { + format!( + r#"- bash: | + npm config set registry "{registry}" + displayName: "Configure internal npm feed""# + ) + } +} From f5e571d503ceab94a7b7b4226969c69c16f8264e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 08:06:03 +0000 Subject: [PATCH 2/2] fix(compile): use strip_prefix for npm registry URL scheme stripping Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/95561fa8-831b-4585-aa32-5f91ed749208 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/runtimes/node/mod.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/runtimes/node/mod.rs b/src/runtimes/node/mod.rs index 3f140381..e4d3bde1 100644 --- a/src/runtimes/node/mod.rs +++ b/src/runtimes/node/mod.rs @@ -158,12 +158,14 @@ pub fn generate_node_feed_config(feed: &NodeInternalFeedConfig) -> String { if let Some(token_var) = &feed.auth_token_var { // Derive the npm per-registry auth key by stripping the URL scheme. - // npm expects: //host/path/:_authToken (no https: prefix) - let auth_key = registry - .trim_start_matches("https:") - .trim_start_matches("http:"); - // Ensure the key ends with /:_authToken (with a trailing slash on the path) - let auth_key = auth_key.trim_end_matches('/'); + // npm expects: //host/path/:_authToken (no https: or http: prefix) + let without_scheme = registry + .strip_prefix("https:") + .or_else(|| registry.strip_prefix("http:")) + .unwrap_or(registry.as_str()); + // Strip trailing slashes, then append /:_authToken so the key is + // always in the canonical form npm expects (one slash before the colon). + let auth_key = without_scheme.trim_end_matches('/'); let auth_setting = format!("{auth_key}/:_authToken"); format!(