fix(core/mcp): honor per-server startup_timeout_ms and make tools/list failures non-fatal; add test MCP server + smoke harness to validate slow/fast scenarios

- Add  to  and parse/write in config
- Apply per-server timeout to  and initial
- Treat per-server  errors as non-fatal; record and skip those servers
- Keep manager creation successful even if some servers time out
- Export  so local smoke tool can exercise it
- Add  (stdio JSON-RPC) and  binary for manual validation
- Update TUI  to set  by default

This resolves GitHub issue #91 where a slow MCP server caused manager creation to time out and fail entirely.
This commit is contained in:
James Peter
2025-09-12 00:10:56 +10:00
parent 0ae8848bda
commit f69ea8b525
13 changed files with 560 additions and 20 deletions
+17
View File
@@ -1047,6 +1047,23 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-mcp-smoke"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-core",
"tokio",
]
[[package]]
name = "codex-mcp-test-server"
version = "0.0.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "codex-ollama"
version = "0.0.0"
+2
View File
@@ -14,6 +14,8 @@ members = [
"login",
"mcp-client",
"mcp-server",
"mcp-test-server",
"mcp-smoke",
"mcp-types",
"protocol",
"ollama",
+12 -1
View File
@@ -663,7 +663,15 @@ pub fn list_mcp_servers(codex_home: &Path) -> anyhow::Result<(
.filter_map(|(k, v)| v.as_str().map(|s| (k.to_string(), s.to_string())))
.collect()
});
out.push((name.to_string(), McpServerConfig { command, args, env }));
let startup_timeout_ms = t
.get("startup_timeout_ms")
.and_then(|v| v.as_integer())
.map(|i| i as u64);
out.push((
name.to_string(),
McpServerConfig { command, args, env, startup_timeout_ms },
));
}
}
out
@@ -731,6 +739,9 @@ pub fn add_mcp_server(
}
server_tbl.insert("env", TomlItem::Value(toml_edit::Value::InlineTable(it)));
}
if let Some(ms) = cfg.startup_timeout_ms {
server_tbl.insert("startup_timeout_ms", TomlItem::Value(toml_edit::Value::from(ms as i64)));
}
// Write into enabled table
tbl.insert(name, TomlItem::Table(server_tbl));
+6
View File
@@ -66,6 +66,12 @@ pub struct McpServerConfig {
#[serde(default)]
pub env: Option<HashMap<String, String>>,
/// Optional per-server startup timeout in milliseconds.
/// Applies to both the initial `initialize` handshake and the first
/// `tools/list` request during startup. If unset, defaults to 10_000ms.
#[serde(default)]
pub startup_timeout_ms: Option<u64>,
}
#[derive(Deserialize, Debug, Copy, Clone, PartialEq)]
+1 -1
View File
@@ -37,7 +37,7 @@ pub mod git_worktree;
mod image_comparison;
mod is_safe_command;
pub mod landlock;
mod mcp_connection_manager;
pub mod mcp_connection_manager;
mod mcp_tool_call;
mod message_history;
mod model_provider_info;
+51 -18
View File
@@ -37,7 +37,7 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__";
const MAX_TOOL_NAME_LENGTH: usize = 64;
/// Timeout for the `tools/list` request.
const LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
/// Map that holds a startup error for every MCP server that could **not** be
/// spawned successfully.
@@ -83,7 +83,7 @@ struct ToolInfo {
/// A thin wrapper around a set of running [`McpClient`] instances.
#[derive(Default)]
pub(crate) struct McpConnectionManager {
pub struct McpConnectionManager {
/// Server-name -> client instance.
///
/// The server name originates from the keys of the `mcp_servers` map in
@@ -115,6 +115,10 @@ impl McpConnectionManager {
let mut join_set = JoinSet::new();
let mut errors = ClientStartErrors::new();
// Keep a lookup of per-server startup timeouts so we can also apply it to
// the initial `tools/list` step below.
let mut per_server_timeout: HashMap<String, Duration> = HashMap::new();
for (server_name, cfg) in mcp_servers {
// Validate server name before spawning
if !is_valid_mcp_server_name(&server_name) {
@@ -126,8 +130,14 @@ impl McpConnectionManager {
continue;
}
let timeout = cfg
.startup_timeout_ms
.map(Duration::from_millis)
.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
per_server_timeout.insert(server_name.clone(), timeout);
join_set.spawn(async move {
let McpServerConfig { command, args, env } = cfg;
let McpServerConfig { command, args, env, .. } = cfg;
let client_res = McpClient::new_stdio_client(
command.into(),
args.into_iter().map(OsString::from).collect(),
@@ -158,7 +168,7 @@ impl McpConnectionManager {
protocol_version: mcp_types::MCP_SCHEMA_VERSION.to_owned(),
};
let initialize_notification_params = None;
let timeout = Some(Duration::from_secs(10));
let timeout = Some(timeout);
match client
.initialize(params, initialize_notification_params, timeout)
.await
@@ -188,7 +198,16 @@ impl McpConnectionManager {
}
}
let all_tools = list_all_tools(&clients).await?;
// Query tools from each server. Do not fail the entire manager if a
// server fails to list tools within its startup timeout; instead,
// record the error and continue.
let (all_tools, list_errors) = list_all_tools(&clients, &per_server_timeout).await;
// Remove clients that failed to list tools so they are not used later.
for (server_name, err) in list_errors {
errors.insert(server_name.clone(), err);
clients.remove(&server_name);
}
let tools = qualify_tools(all_tools);
@@ -235,7 +254,8 @@ impl McpConnectionManager {
/// contains **all** tools. Each key is the fully-qualified name for the tool.
async fn list_all_tools(
clients: &HashMap<String, std::sync::Arc<McpClient>>,
) -> Result<Vec<ToolInfo>> {
timeouts: &HashMap<String, Duration>,
) -> (Vec<ToolInfo>, HashMap<String, anyhow::Error>) {
let mut join_set = JoinSet::new();
// Spawn one task per server so we can query them concurrently. This
@@ -244,27 +264,40 @@ async fn list_all_tools(
for (server_name, client) in clients {
let server_name_cloned = server_name.clone();
let client_clone = client.clone();
let timeout = timeouts
.get(server_name)
.copied()
.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
join_set.spawn(async move {
let res = client_clone
.list_tools(None, Some(LIST_TOOLS_TIMEOUT))
.list_tools(None, Some(timeout))
.await;
(server_name_cloned, res)
});
}
let mut aggregated: Vec<ToolInfo> = Vec::with_capacity(join_set.len());
let mut errors: HashMap<String, anyhow::Error> = HashMap::new();
while let Some(join_res) = join_set.join_next().await {
let (server_name, list_result) = join_res?;
let list_result = list_result?;
for tool in list_result.tools {
let tool_info = ToolInfo {
server_name: server_name.clone(),
tool_name: tool.name.clone(),
tool,
};
aggregated.push(tool_info);
match join_res {
Ok((server_name, Ok(list_result))) => {
for tool in list_result.tools {
let tool_info = ToolInfo {
server_name: server_name.clone(),
tool_name: tool.name.clone(),
tool,
};
aggregated.push(tool_info);
}
}
Ok((server_name, Err(e))) => {
errors.insert(server_name, e);
}
Err(e) => {
// JoinError – associate with an unknown server; log and continue.
warn!("failed to join list_tools task: {e:#}");
}
}
}
@@ -274,7 +307,7 @@ async fn list_all_tools(
clients.len()
);
Ok(aggregated)
(aggregated, errors)
}
fn is_valid_mcp_server_name(server_name: &str) -> bool {
+100
View File
@@ -0,0 +1,100 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use codex_core::config_types::McpServerConfig;
use codex_core::mcp_connection_manager::McpConnectionManager;
fn server_bin_path() -> PathBuf {
// Compute workspace root from this crate dir
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
let mut root = PathBuf::from(manifest_dir);
// codex-rs/core -> codex-rs
root.pop();
// prefer debug profile location for tests
let mut p = root;
p.push("target");
p.push("debug");
#[cfg(windows)]
p.push("codex-mcp-test-server.exe");
#[cfg(not(windows))]
p.push("codex-mcp-test-server");
p
}
#[tokio::test]
async fn mcp_manager_skips_slow_server_on_timeout() {
// Ensure test server binary exists
let server = server_bin_path();
assert!(server.exists(), "expected test server at {}", server.display());
// Slow server exceeds timeout (init/list 200ms vs 100ms timeout)
let slow_cfg = McpServerConfig {
command: "bash".to_string(),
args: vec![
"-lc".to_string(),
format!(
"SLOW_INIT_MS=200 SLOW_LIST_MS=200 {}",
server.display()
),
],
env: None,
startup_timeout_ms: Some(100),
};
// Fast server responds immediately
let fast_cfg = McpServerConfig {
command: server.to_string_lossy().to_string(),
args: vec![],
env: None,
startup_timeout_ms: Some(500),
};
let mut servers = HashMap::new();
servers.insert("slow".to_string(), slow_cfg);
servers.insert("fast".to_string(), fast_cfg);
let (mgr, errs) = McpConnectionManager::new(servers)
.await
.expect("manager creation should not fail entirely");
// Slow should be reported as error; fast should be available.
assert!(errs.contains_key("slow"));
assert!(!errs.contains_key("fast"));
let tools = mgr.list_all_tools();
// Expect tool echo from fast server only: qualified name fast__echo
assert!(tools.keys().any(|k| k.starts_with("fast__")));
assert!(!tools.keys().any(|k| k.starts_with("slow__")));
}
#[tokio::test]
async fn mcp_manager_respects_extended_startup_timeout() {
// Ensure test server binary exists
let server = server_bin_path();
assert!(server.exists(), "expected test server at {}", server.display());
// Slow server within extended timeout (init/list 200ms vs 500ms)
let slow_ok = McpServerConfig {
command: "bash".to_string(),
args: vec![
"-lc".to_string(),
format!(
"SLOW_INIT_MS=200 SLOW_LIST_MS=200 {}",
server.display()
),
],
env: None,
startup_timeout_ms: Some(500),
};
let mut servers = HashMap::new();
servers.insert("slow_ok".to_string(), slow_ok);
let (mgr, errs) = McpConnectionManager::new(servers)
.await
.expect("manager creation should not fail");
assert!(errs.is_empty(), "no errors expected, got: {errs:?}");
let tools = mgr.list_all_tools();
assert!(tools.keys().any(|k| k.starts_with("slow_ok__")));
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "codex-mcp-smoke"
version = { workspace = true }
edition = "2024"
[dependencies]
anyhow = "1"
codex-core = { path = "../core" }
tokio = { version = "1", features = ["full"] }
+79
View File
@@ -0,0 +1,79 @@
use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::Result;
use codex_core::config_types::McpServerConfig;
use codex_core::mcp_connection_manager::McpConnectionManager;
fn server_bin_path() -> PathBuf {
let mut root = std::env::current_dir().unwrap();
// Ensure we run from workspace root `codex-rs` for relative path to work.
if root.ends_with("codex-rs") {
// ok
} else if root.ends_with("code") {
root.push("codex-rs");
}
let mut p = root;
p.push("target");
p.push("debug");
#[cfg(windows)]
p.push("codex-mcp-test-server.exe");
#[cfg(not(windows))]
p.push("codex-mcp-test-server");
p
}
#[tokio::main]
async fn main() -> Result<()> {
let server = server_bin_path();
if !server.exists() {
eprintln!("Build the test server first: cargo build -p codex-mcp-test-server");
std::process::exit(2);
}
// Fast server
let fast = McpServerConfig {
command: server.to_string_lossy().to_string(),
args: vec![],
env: None,
startup_timeout_ms: Some(500),
};
// Slow-one: 2s but we allow 3s
let slow_ok = McpServerConfig {
command: "bash".to_string(),
args: vec![
"-lc".to_string(),
format!("SLOW_INIT_MS=500 SLOW_LIST_MS=2000 {}", server.display()),
],
env: None,
startup_timeout_ms: Some(3000),
};
// Slow-two: 3s but we allow 1s (should fail)
let slow_fail = McpServerConfig {
command: "bash".to_string(),
args: vec![
"-lc".to_string(),
format!("SLOW_INIT_MS=500 SLOW_LIST_MS=3000 {}", server.display()),
],
env: None,
startup_timeout_ms: Some(1000),
};
let mut servers = HashMap::new();
servers.insert("fast".to_string(), fast);
servers.insert("slow_ok".to_string(), slow_ok);
servers.insert("slow_fail".to_string(), slow_fail);
let (mgr, errs) = McpConnectionManager::new(servers).await?;
println!("Errors: {}", errs.len());
for (name, e) in &errs {
println!(" {}: {}", name, e);
}
let tools = mgr.list_all_tools();
println!("Tools ({}):", tools.len());
for k in tools.keys() {
println!(" {}", k);
}
Ok(())
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "codex-mcp-test-server"
version = { workspace = true }
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+91
View File
@@ -0,0 +1,91 @@
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::env;
use std::io::{self, BufRead, Write};
use std::thread;
use std::time::Duration;
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum Id {
Int(i64),
Str(String),
}
#[derive(Deserialize)]
struct JsonRpcReq {
jsonrpc: String,
method: String,
#[allow(dead_code)]
params: Option<serde_json::Value>,
id: Option<Id>,
}
#[derive(Serialize)]
struct JsonRpcResp {
jsonrpc: String,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<Id>,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<serde_json::Value>,
}
fn sleep_env_ms(var: &str) {
if let Ok(v) = env::var(var) {
if let Ok(ms) = v.parse::<u64>() {
thread::sleep(Duration::from_millis(ms));
}
}
}
fn main() {
let stdin = io::stdin();
let mut stdout = io::stdout();
for line in stdin.lock().lines() {
let line = match line { Ok(l) => l, Err(_) => break };
let Ok(req) = serde_json::from_str::<JsonRpcReq>(&line) else {
continue;
};
match req.method.as_str() {
"initialize" => {
sleep_env_ms("SLOW_INIT_MS");
let result = json!({
"capabilities": { "tools": { "listChanged": true } },
"serverInfo": { "name": "codex-mcp-test-server", "version": "0.0.1" },
"protocolVersion": "2025-06-18"
});
let resp = JsonRpcResp { jsonrpc: "2.0".into(), id: req.id, result: Some(result), error: None };
let _ = writeln!(stdout, "{}", serde_json::to_string(&resp).unwrap());
let _ = stdout.flush();
}
"notifications/initialized" => {
// No-op
}
"tools/list" => {
sleep_env_ms("SLOW_LIST_MS");
let tools = json!({
"tools": [
{
"name": "echo",
"description": "Echo back provided text",
"inputSchema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] }
}
]
});
let resp = JsonRpcResp { jsonrpc: "2.0".into(), id: req.id, result: Some(tools), error: None };
let _ = writeln!(stdout, "{}", serde_json::to_string(&resp).unwrap());
let _ = stdout.flush();
}
_ => {
// Unknown method -> echo minimal error structure
let err = json!({ "code": -32601, "message": "Method not found" });
let resp = JsonRpcResp { jsonrpc: "2.0".into(), id: req.id, result: None, error: Some(err) };
let _ = writeln!(stdout, "{}", serde_json::to_string(&resp).unwrap());
let _ = stdout.flush();
}
}
}
}
+1
View File
@@ -7479,6 +7479,7 @@ impl ChatWidget<'_> {
} else {
Some(env.clone())
},
startup_timeout_ms: None,
};
match codex_core::config::add_mcp_server(&home, &name, cfg.clone()) {
Ok(()) => {
+182
View File
@@ -0,0 +1,182 @@
param(
[string]$Version = "latest",
[switch]$FixCache
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Step($msg) { Write-Host ("---- " + $msg) -ForegroundColor Cyan }
function Info($msg) { Write-Host $msg }
function Warn($msg) { Write-Host $msg -ForegroundColor Yellow }
function Err ($msg) { Write-Host $msg -ForegroundColor Red }
# 1) Basics
Step "Environment"
$envs = [ordered]@{
"Node" = (node -v 2>$null)
"npm" = (npm -v 2>$null)
"PowerShell" = $PSVersionTable.PSVersion.ToString()
"OSArch" = $env:PROCESSOR_ARCHITECTURE
"APPDATA" = $env:APPDATA
"LOCALAPPDATA" = $env:LOCALAPPDATA
}
$envs.GetEnumerator() | ForEach-Object { Info ("{0} = {1}" -f $_.Key, $_.Value) }
Step "npm config (registry/proxy/flags)"
try {
Info ("registry = " + (npm config get registry))
Info ("ignore-scripts = " + (npm config get ignore-scripts))
Info ("strict-ssl = " + (npm config get strict-ssl))
$hp = npm config get https-proxy; if ($hp -ne "null") { Info ("https-proxy = " + $hp) }
$p = npm config get proxy; if ($p -ne "null") { Info ("proxy = " + $p) }
} catch { Warn ("npm config read failed: " + $_) }
# 2) Resolve versions and tarballs from NPM
Step "Resolve @just-every/code@$Version (npm)"
$mainVersion = $null
try { $mainVersion = (npm view "@just-every/code@$Version" version) } catch {}
if (-not $mainVersion) { Err "Could not resolve @just-every/code@$Version"; exit 1 }
$mainTar = $null
try { $mainTar = (npm view "@just-every/code@$mainVersion" dist.tarball) } catch {}
Info ("main version = " + $mainVersion)
Info ("main tarball = " + ($mainTar ?? "<none>"))
Step "Resolve @just-every/code-win32-x64@$mainVersion (npm)"
$winVersion = $null; $winTar = $null
try { $winVersion = (npm view "@just-every/code-win32-x64@$mainVersion" version) } catch {}
if ($winVersion) {
try { $winTar = (npm view "@just-every/code-win32-x64@$mainVersion" dist.tarball) } catch {}
Info ("win version = " + $winVersion)
Info ("win tarball = " + ($winTar ?? "<none>"))
} else {
Warn ("@just-every/code-win32-x64@$mainVersion not found on npm")
}
# 3) Download + inspect npm tarballs
$tmp = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ("code-diag-" + [guid]::NewGuid().ToString("N"))) -Force
Step ("Work dir: " + $tmp.FullName)
Set-Location $tmp.FullName
function Fetch($url, $dst) {
if (-not $url) { return $false }
try {
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $dst -TimeoutSec 120
return $true
} catch {
Err ("Download failed: " + $url)
Err ($_.Exception.Message)
return $false
}
}
$t1 = Join-Path $pwd "code.tgz"
$t2 = Join-Path $pwd "win.tgz"
$gotMain = Fetch $mainTar $t1
$gotWin = $false
if ($winTar) { $gotWin = Fetch $winTar $t2 }
if ($gotMain) {
try {
mkdir p1 | Out-Null
tar -xzf $t1 -C p1
Step ("@just-every/code@" + $mainVersion + " contents")
dir p1/package
Info "\nHead of bin/coder.js:"
Get-Content p1/package/bin/coder.js -TotalCount 30 | ForEach-Object { " " + $_ }
$isESM = Select-String -Path p1/package/package.json -Pattern '"type"\s*:\s*"module"' -Quiet
Info ("package.json type=module? " + [string]$isESM)
} catch {
Warn ("Could not extract or inspect main tarball: " + $_)
}
} else {
Err "Could not fetch main tarball from npm"
}
if ($gotWin) {
try {
mkdir p2 | Out-Null
tar -xzf $t2 -C p2
Step ("@just-every/code-win32-x64@" + $mainVersion + " contents")
dir p2/package/bin
} catch {
Warn ("Could not extract or inspect win tarball: " + $_)
}
}
# 4) Compute target triple and cache paths
$triple = "x86_64-pc-windows-msvc.exe" # Windows x64
$cacheDir = Join-Path $env:LOCALAPPDATA ("just-every\code\" + $mainVersion)
$expectOK = Join-Path $cacheDir ("code-" + $triple) # correct
$expectBAD = $expectOK + ".exe" # incorrect (double .exe)
Step "Cache expectations"
Info ("cache dir = " + $cacheDir)
Info ("expect OK = " + $expectOK)
Info ("expect BAD = " + $expectBAD)
Info ("exists OK? " + ([string](Test-Path $expectOK)))
Info ("exists BAD? " + ([string](Test-Path $expectBAD)))
# 5) GitHub release asset (fallback path)
$ghZip = "https://github.com/just-every/code/releases/download/v$mainVersion/code-$triple.zip"
Step "GitHub asset (fallback) HEAD check"
try {
$resp = Invoke-WebRequest -UseBasicParsing -Uri $ghZip -Method Head
Info ("GitHub asset reachable? " + [string]$resp.StatusCode)
} catch {
Warn ("GitHub HEAD failed: " + $_)
}
# Optional: download and test unzip to cache
$zipTmp = Join-Path $pwd ("code-" + $triple + ".zip")
$unzipOk = $false
$existedBefore = Test-Path $expectOK
try {
if (Fetch $ghZip $zipTmp) {
Step "Test Expand-Archive to cache dir"
if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir | Out-Null }
$sysRoot = $env:SystemRoot
$psFull = Join-Path $sysRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
$psCmd = "Expand-Archive -Path `"$zipTmp`" -DestinationPath `"$cacheDir`" -Force"
$ok = $false
try { & "$psFull" -NoProfile -NonInteractive -Command $psCmd 2>$null; $ok = $true } catch {}
if (-not $ok) { try { powershell -NoProfile -NonInteractive -Command $psCmd 2>$null; $ok = $true } catch {} }
if (-not $ok) { try { pwsh -NoProfile -NonInteractive -Command $psCmd 2>$null; $ok = $true } catch {} }
if (-not $ok) { try { tar -xf $zipTmp -C $cacheDir; $ok = $true } catch {} }
$unzipOk = $ok
Info ("unzip ok? " + [string]$unzipOk)
Info ("post-unzip OK exists? " + ([string](Test-Path $expectOK)))
Info ("post-unzip BAD exists? " + ([string](Test-Path $expectBAD)))
}
} catch {
Warn ("Unzip test failed: " + $_)
} finally {
try { Remove-Item -Force $zipTmp } catch {}
if (-not $FixCache -and -not $existedBefore) {
try { if (Test-Path $expectOK) { Remove-Item -Force $expectOK } } catch {}
}
}
# 6) Global locations that the error messages referenced
Step "Global npm prefix + expected node_modules bin path"
$prefix = $null
try { $prefix = (npm prefix -g 2>$null) } catch {}
$nmBin = if ($prefix) { Join-Path $prefix "node_modules\@just-every\code\bin" } else { "<unknown>" }
Info ("prefix = " + ($prefix ?? "<none>"))
Info ("bin dir= " + $nmBin)
if ($prefix -and (Test-Path $nmBin)) { dir $nmBin } else { Info "(bin dir not present - expected for npx, present for global installs)" }
# 7) Summary
Step "RESULTS SUMMARY"
$lines = @()
$lines += ("main version: " + $mainVersion)
$lines += ("platform pkg present on npm: " + ([string]([bool]$winVersion)))
$lines += ("cache ok path exists: " + ([string](Test-Path $expectOK)))
$lines += ("cache bad path exists: " + ([string](Test-Path $expectBAD)))
$lines += ("github asset unzip ok: " + ([string]$unzipOk))
$esm = $false; try { $esm = Select-String -Path p1/package/package.json -Pattern '"type"\s*:\s*"module"' -Quiet } catch {}
$lines += ("esm bin (type:module) in npm tarball: " + ([string]$esm))
$lines | ForEach-Object { Info $_ }
Info ""
Info "Done. Paste the RESULTS SUMMARY back if anything looks off."