@microsoft/m365-copilot-eval 1.5.0-preview.1 → 1.6.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,7 @@ A CLI for evaluating M365 Copilot agents. Send prompts to your agent, get respon
24
24
  - **M365 Copilot License** for your tenant
25
25
  - **M365 Copilot Agent** deployed to your tenant (can be created with [M365 Agents Toolkit](https://learn.microsoft.com/en-us/microsoft-365/developer/overview-m365-agents-toolkit) or any other method)
26
26
  - **Node.js 24.12.0+** (check: `node --version`)
27
+ - **Python 3.13.x** is downloaded automatically. If the download fails (e.g., network restrictions), set `PYTHON_PATH` to a local Python 3.13.x installation (see [Troubleshooting](#-troubleshooting))
27
28
  - **Environment file** with your credentials and agent ID (see [Environment Setup](#-environment-setup) below)
28
29
  - **Your Tenant ID** - get your tenant id using the instructions [here](https://learn.microsoft.com/en-us/azure/azure-portal/get-subscription-tenant-id)
29
30
  - Admin approval to run WORKIQ Client App for your tenant [here](https://github.com/microsoft/work-iq/blob/main/ADMIN-INSTRUCTIONS.md)
@@ -460,6 +461,20 @@ runevals cache-dir
460
461
  chmod -R u+w $(runevals cache-dir)
461
462
  ```
462
463
 
464
+ ### Custom Python Runtime (PYTHON_PATH)
465
+
466
+ If the automatic Python download fails (e.g., network restrictions, unsupported platform), provide your own Python installation:
467
+
468
+ ```bash
469
+ # Windows
470
+ set PYTHON_PATH=C:\Python313\python.exe
471
+
472
+ # macOS/Linux
473
+ export PYTHON_PATH=/usr/local/bin/python3.13
474
+ ```
475
+
476
+ Python 3.13.x is the tested version. If a different version is found, you'll be prompted to confirm before proceeding. In CI/CD, a version mismatch fails automatically.
477
+
463
478
  ## šŸ“š Advanced Documentation
464
479
 
465
480
  - **[CI/CD Integration](./CICD_CACHE_GUIDE.md)** - GitHub Actions, Azure DevOps caching
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@microsoft/m365-copilot-eval",
3
- "version": "1.5.0-preview.1",
3
+ "version": "1.6.0-preview.1",
4
4
  "minCliVersion": "1.0.1-preview.1",
5
5
  "description": "Zero-config Node.js wrapper for M365 Copilot Agent Evaluations CLI (Python-based Azure AI Evaluation SDK)",
6
- "publishDate": "2026-04-30",
6
+ "publishDate": "2026-05-07",
7
7
  "main": "src/clients/node-js/lib/index.js",
8
8
  "type": "module",
9
9
  "bin": {
@@ -80,8 +80,9 @@
80
80
  "README.md",
81
81
  "LICENSE"
82
82
  ],
83
+ "homepage": "https://github.com/microsoft/m365-copilot-eval",
83
84
  "repository": {
84
85
  "type": "git",
85
- "url": "https://github.com/microsoft/M365-Copilot-Agent-Evals.git"
86
+ "url": "https://github.com/microsoft/m365-copilot-eval.git"
86
87
  }
87
88
  }
@@ -5,6 +5,13 @@ All notable changes to the eval document schema will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.0](https://github.com/microsoft/M365-Copilot-Agent-Evals/compare/schema-v1.2.0...schema-v1.3.0) (2026-04-30)
9
+
10
+
11
+ ### Features
12
+
13
+ * Added similarity evaluator for compatibility with MCS Evals. ([#228](https://github.com/microsoft/M365-Copilot-Agent-Evals/issues/228)) ([0fe8315](https://github.com/microsoft/M365-Copilot-Agent-Evals/commit/0fe8315abc8e0422d1ac9117fe9f29195f29044f))
14
+
8
15
  ## [1.2.0](https://github.com/microsoft/M365-Copilot-Agent-Evals/compare/schema-v1.1.0...schema-v1.2.0) (2026-04-22)
9
16
 
10
17
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.2.0",
2
+ "version": "1.3.0",
3
3
  "releaseDate": "2026-04-02",
4
4
  "schemaId": "https://raw.githubusercontent.com/microsoft/M365-Copilot-Agent-Evals/refs/heads/main/schema/v1/eval-document.schema.json",
5
5
  "description": "M365 Copilot Eval Document Schema"
@@ -8,7 +8,7 @@ import re
8
8
  import urllib.error
9
9
  import urllib.request
10
10
  import uuid
11
- from typing import Any, Dict, List, Optional, Tuple
11
+ from typing import Any, Callable, Dict, List, Optional, Tuple
12
12
 
13
13
  from api_clients.base_agent_client import BaseAgentClient
14
14
  from cli_logging.console_diagnostics import emit_structured_log
@@ -35,6 +35,7 @@ class A2AClient(BaseAgentClient):
35
35
  access_token: str,
36
36
  logger: Optional[logging.Logger] = None,
37
37
  diagnostic_records: Optional[List[Dict[str, Any]]] = None,
38
+ token_refresh_fn: Optional[Callable[[], str]] = None,
38
39
  ) -> None:
39
40
  """
40
41
  Args:
@@ -42,11 +43,15 @@ class A2AClient(BaseAgentClient):
42
43
  access_token: Bearer token for A2A authentication.
43
44
  logger: Logger to use. Defaults to a module-level logger if not provided.
44
45
  diagnostic_records: List to accumulate structured log entries.
46
+ token_refresh_fn: Optional callable that returns a fresh access token string.
47
+ When provided, a single HTTP 401 response will trigger a token refresh
48
+ and one automatic retry, making the refresh invisible to the caller.
45
49
  """
46
50
  self._endpoint = a2a_endpoint.rstrip("/")
47
51
  self._access_token = access_token
48
52
  self._logger = logger or logging.getLogger(__name__)
49
53
  self._diagnostic_records = diagnostic_records
54
+ self._token_refresh_fn = token_refresh_fn
50
55
  self._resolved_agent_url: Optional[str] = None
51
56
 
52
57
  # ------------------------------------------------------------------ #
@@ -261,6 +266,12 @@ class A2AClient(BaseAgentClient):
261
266
  ) -> tuple[Dict[str, Any], Dict[str, Any]]:
262
267
  """Send a JSON-RPC message to the agent and parse the response.
263
268
 
269
+ When a ``token_refresh_fn`` was supplied at construction time and the
270
+ server responds with HTTP 401 (Unauthorized), the token is refreshed
271
+ automatically and the request is retried exactly once. This keeps
272
+ long-running eval sessions alive beyond the initial token lifetime
273
+ without requiring any user interaction.
274
+
264
275
  Returns:
265
276
  A tuple of (result_dict, raw_result) where result_dict is the
266
277
  normalized response dict (raw_response_text, display_response_text,
@@ -275,15 +286,51 @@ class A2AClient(BaseAgentClient):
275
286
  with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT_SECS) as resp:
276
287
  raw = resp.read().decode("utf-8", errors="replace")
277
288
  except urllib.error.HTTPError as e:
278
- body = ""
279
- try:
280
- body = e.read().decode("utf-8", errors="replace")
281
- except Exception:
282
- pass
283
- raise RuntimeError(
284
- f"A2A request failed (HTTP {e.code} {e.reason})."
285
- + (f" Body: {body[:500]}" if body else "")
286
- ) from e
289
+ if e.code == 401 and self._token_refresh_fn is not None:
290
+ emit_structured_log(
291
+ "info",
292
+ "[A2A] Access token expired (HTTP 401); refreshing token and retrying.",
293
+ Operation.AUTHENTICATE,
294
+ logger=self._logger,
295
+ diagnostic_records=self._diagnostic_records,
296
+ )
297
+ new_token = self._token_refresh_fn()
298
+ if not new_token:
299
+ raise RuntimeError(
300
+ "A2A request failed (HTTP 401 Unauthorized) and token refresh returned no token."
301
+ ) from e
302
+ self._access_token = new_token
303
+ headers["Authorization"] = f"Bearer {self._access_token}"
304
+ retry_req = urllib.request.Request(
305
+ agent_url, data=payload, headers=headers, method="POST"
306
+ )
307
+ try:
308
+ with urllib.request.urlopen(retry_req, timeout=_REQUEST_TIMEOUT_SECS) as resp:
309
+ raw = resp.read().decode("utf-8", errors="replace")
310
+ except urllib.error.HTTPError as retry_e:
311
+ body = ""
312
+ try:
313
+ body = retry_e.read().decode("utf-8", errors="replace")
314
+ except Exception:
315
+ pass
316
+ raise RuntimeError(
317
+ f"A2A request failed (HTTP {retry_e.code} {retry_e.reason}) after token refresh."
318
+ + (f" Body: {body[:500]}" if body else "")
319
+ ) from retry_e
320
+ except urllib.error.URLError as retry_e:
321
+ raise RuntimeError(
322
+ f"A2A connection error after token refresh: {getattr(retry_e, 'reason', str(retry_e))}"
323
+ ) from retry_e
324
+ else:
325
+ body = ""
326
+ try:
327
+ body = e.read().decode("utf-8", errors="replace")
328
+ except Exception:
329
+ pass
330
+ raise RuntimeError(
331
+ f"A2A request failed (HTTP {e.code} {e.reason})."
332
+ + (f" Body: {body[:500]}" if body else "")
333
+ ) from e
287
334
  except urllib.error.URLError as e:
288
335
  raise RuntimeError(
289
336
  f"A2A connection error: {getattr(e, 'reason', str(e))}"
@@ -13,7 +13,7 @@ https://github.com/AzureAD/microsoft-authentication-extensions-for-python
13
13
  import os
14
14
  import platform
15
15
  import logging
16
- from typing import Optional
16
+ from typing import Callable, Optional
17
17
  from pathlib import Path
18
18
  import jwt
19
19
  from msal import PublicClientApplication
@@ -260,3 +260,23 @@ class AuthHandler:
260
260
  return oid
261
261
  except jwt.DecodeError as e:
262
262
  raise ValueError(f"Failed to decode token: {e}")
263
+
264
+
265
+ def make_token_refresh_fn(auth_handler: "AuthHandler") -> Callable[[], str]:
266
+ """Return a callable that silently refreshes the A2A access token.
267
+
268
+ On a 401 response the caller invokes this function. It first attempts a
269
+ silent refresh (using the MSAL refresh token) and falls back to interactive
270
+ authentication only when a silent refresh is not possible. The returned
271
+ string is the new access token; an empty string signals failure.
272
+
273
+ Args:
274
+ auth_handler: An initialized AuthHandler instance to use for token acquisition.
275
+
276
+ Returns:
277
+ A zero-argument callable that returns a fresh access token string.
278
+ """
279
+ def _refresh() -> str:
280
+ result = auth_handler.acquire_token_interactive() or {}
281
+ return result.get("access_token") or ""
282
+ return _refresh
@@ -16,7 +16,7 @@ from azure.ai.evaluation import AzureOpenAIModelConfiguration
16
16
  from dotenv import load_dotenv
17
17
 
18
18
  from api_clients.A2A import A2AClient
19
- from auth.auth_handler import AuthHandler
19
+ from auth.auth_handler import AuthHandler, make_token_refresh_fn
20
20
  from evaluator_resolver import resolve_default_evaluators
21
21
  from version_check import check_min_version, get_cli_version
22
22
 
@@ -122,6 +122,7 @@ def main():
122
122
  agent_client = A2AClient(
123
123
  a2a_endpoint=a2a_endpoint,
124
124
  access_token=a2a_access_token,
125
+ token_refresh_fn=make_token_refresh_fn(a2a_auth_handler),
125
126
  logger=CLI_LOGGER,
126
127
  diagnostic_records=DIAGNOSTIC_RECORDS,
127
128
  )
@@ -9,8 +9,13 @@ import { ensureVenv, executePythonCli } from '../lib/venv-manager.js';
9
9
  import { getCacheStats, clearCache, formatBytes } from '../lib/cache-utils.js';
10
10
  import { checkPackageExpiry } from '../lib/expiry-check.js';
11
11
  import { recordAcceptance, checkAcceptance } from '../lib/eula-manager.js';
12
- import { ProgressReporter } from '../lib/progress.js';
12
+ import { ProgressReporter, isInteractiveTerminal } from '../lib/progress.js';
13
13
  import { _loadEnvFile as loadEnvFile, _loadUserEnvOverride } from '../lib/env-loader.js';
14
+ import {
15
+ _handlePythonVersionMismatch,
16
+ _buildInitializationFailureLines,
17
+ _promptForContinueWithMismatch,
18
+ } from '../lib/version-check.js';
14
19
  import { normalizeAgentId } from '../lib/agent-id.js';
15
20
 
16
21
  // Check package expiry (exits if expired, warns if close to expiry)
@@ -130,10 +135,26 @@ async function initializePythonEnvironment(verbose = false, quiet = false) {
130
135
 
131
136
  try {
132
137
  // Step 1: Ensure Python runtime is available (handles download + extract phases)
133
- await ensurePythonRuntime(verbose, onProgress);
138
+ const runtime = await ensurePythonRuntime(verbose, onProgress);
139
+
140
+ // Step 2: Handle version mismatch from PYTHON_PATH fallback.
141
+ // The decision tree (EOL block, interactive prompt, non-interactive
142
+ // auto-reject) lives in _handlePythonVersionMismatch so it is
143
+ // unit-testable without spawning the CLI; we only own the readline
144
+ // wiring and the actual process.exit here.
145
+ const mismatch = await _handlePythonVersionMismatch({
146
+ runtime,
147
+ isInteractive: isInteractiveTerminal(),
148
+ promptForContinue: _promptForContinueWithMismatch,
149
+ warn: (msg) => console.warn(msg),
150
+ error: (msg) => console.error(msg),
151
+ });
152
+ if (mismatch.shouldExit) {
153
+ process.exit(mismatch.exitCode ?? 1);
154
+ }
134
155
 
135
- // Step 2: Ensure venv with dependencies is set up (handles venv + deps phases)
136
- await ensureVenv(REQUIREMENTS_FILE, verbose, onProgress);
156
+ // Step 3: Ensure venv with dependencies is set up (handles venv + deps phases)
157
+ await ensureVenv(REQUIREMENTS_FILE, verbose, onProgress, runtime.pythonPath);
137
158
 
138
159
  // Show completion summary
139
160
  reporter.complete();
@@ -144,11 +165,12 @@ async function initializePythonEnvironment(verbose = false, quiet = false) {
144
165
  console.error('\nFull error:', error);
145
166
  }
146
167
 
147
- console.error('\nTroubleshooting:');
148
- console.error(' - Check your internet connection');
149
- console.error(' - If behind a proxy, set HTTP_PROXY/HTTPS_PROXY environment variables');
150
- console.error(' - For SSL issues, set NODE_EXTRA_CA_CERTS or PIP_CERT');
151
- console.error(' - Run with --log-level debug for detailed output');
168
+ for (const line of _buildInitializationFailureLines({
169
+ error,
170
+ platform: process.platform,
171
+ })) {
172
+ console.error(line);
173
+ }
152
174
 
153
175
  process.exit(1);
154
176
  }
@@ -2,7 +2,7 @@
2
2
  * Build-time injected default values
3
3
  * DO NOT EDIT - This file is auto-generated during build.
4
4
  *
5
- * Generated: 2026-04-30T18:03:21.788Z
5
+ * Generated: 2026-05-07T22:53:22.056Z
6
6
  *
7
7
  * @copyright Microsoft Corporation. All rights reserved.
8
8
  * @license MIT
@@ -5,6 +5,10 @@ import crypto from 'crypto';
5
5
  import { pipeline } from 'stream/promises';
6
6
  import { createWriteStream } from 'fs';
7
7
  import { Transform } from 'stream';
8
+ import { execFile } from 'child_process';
9
+ import { promisify } from 'util';
10
+
11
+ const execFileAsync = promisify(execFile);
8
12
  import fetch from 'node-fetch';
9
13
 
10
14
  /**
@@ -14,7 +18,7 @@ import fetch from 'node-fetch';
14
18
 
15
19
  // PBS release version (latest release with Python 3.13.10)
16
20
  const PBS_VERSION = '20251202';
17
- const PYTHON_VERSION = '3.13.10';
21
+ export const PYTHON_VERSION = '3.13.10';
18
22
 
19
23
  // Base URL for PBS releases
20
24
  const PBS_BASE_URL = `https://github.com/indygreg/python-build-standalone/releases/download/${PBS_VERSION}`;
@@ -212,66 +216,111 @@ async function extractTarGz(archivePath, destDir, onProgress) {
212
216
  }
213
217
 
214
218
  /**
215
- * Download and setup Python Build Standalone runtime
216
- * Returns the path to the Python executable
217
- * @param {boolean} [verbose=false] - Enable verbose output
218
- * @param {Function} [onProgress] - Optional progress callback
219
+ * Validate that a Python executable is the expected version.
220
+ * @param {string} pythonPath - Path to a Python executable
221
+ * @returns {{ isMatch: boolean, version: string }} Version info
219
222
  */
220
- export async function ensurePythonRuntime(verbose = false, onProgress) {
221
- const platformKey = getPlatformKey();
222
- const distribution = PBS_DISTRIBUTIONS[platformKey];
223
-
224
- const cacheDir = getCacheDir();
225
- const pythonDir = path.join(
226
- cacheDir,
227
- 'python',
228
- `${PYTHON_VERSION}-${platformKey}`
229
- );
230
- const archivePath = path.join(cacheDir, 'downloads', distribution.filename);
231
-
232
- // Determine Python executable path based on platform
233
- const pythonExe =
234
- process.platform === 'win32'
235
- ? path.join(pythonDir, 'python.exe')
236
- : path.join(pythonDir, 'bin', 'python3');
237
-
238
- // Check if Python is already installed (silent skip per FR-007)
223
+ export async function _validatePythonVersion(pythonPath) {
224
+ const expectedMajorMinor = PYTHON_VERSION.split('.').slice(0, 2).join('.');
239
225
  try {
240
- await fs.access(pythonExe);
241
- if (verbose) {
242
- console.log(`Using cached Python runtime: ${pythonExe}`);
226
+ const { stdout } = await execFileAsync(pythonPath, ['--version'], {
227
+ encoding: 'utf8',
228
+ timeout: 10_000,
229
+ });
230
+ // Output format: "Python 3.13.10"
231
+ const match = stdout.trim().match(/Python\s+(\d+\.\d+\.\d+)/);
232
+ if (!match) {
233
+ return { isMatch: false, version: 'unknown' };
243
234
  }
244
- // Skip both download and extract phases silently
245
- onProgress?.({ type: 'skip', phaseId: 'download' });
246
- onProgress?.({ type: 'skip', phaseId: 'extract' });
247
- return pythonExe;
235
+ const version = match[1];
236
+ const majorMinor = version.split('.').slice(0, 2).join('.');
237
+ return { isMatch: majorMinor === expectedMajorMinor, version };
248
238
  } catch {
249
- // Python not found, proceed with download and extraction
239
+ return { isMatch: false, version: 'unknown' };
250
240
  }
241
+ }
251
242
 
252
- if (!onProgress) {
253
- console.log(
254
- `Setting up Python ${PYTHON_VERSION} runtime for ${platformKey}...`
255
- );
243
+ /**
244
+ * Attempt to resolve a fallback Python from the PYTHON_PATH environment variable.
245
+ * @returns {Promise<{ pythonPath: string, version: string, isMatch: boolean }>}
246
+ */
247
+ export async function _resolveFallbackPython() {
248
+ const pythonPath = process.env.PYTHON_PATH;
249
+ if (!pythonPath) {
250
+ throw new Error('PYTHON_PATH environment variable is not set.');
256
251
  }
257
252
 
258
- // Download if not cached
259
- let needsDownload = false;
253
+ await fs.access(pythonPath);
254
+ const { isMatch, version } = await _validatePythonVersion(pythonPath);
255
+ return { pythonPath, version, isMatch };
256
+ }
257
+
258
+ /**
259
+ * Download and setup Python Build Standalone runtime
260
+ * Returns the path to the Python executable and version info.
261
+ * On download failure, falls back to PYTHON_PATH env var if available.
262
+ * @param {boolean} [verbose=false] - Enable verbose output
263
+ * @param {Function} [onProgress] - Optional progress callback
264
+ * @returns {Promise<{ pythonPath: string, version: string, isMatch: boolean }>}
265
+ */
266
+ export async function ensurePythonRuntime(verbose = false, onProgress) {
267
+ // Attempt PBS download + extraction; falls back to PYTHON_PATH on any failure
268
+ // (including unsupported platform where getPlatformKey() throws)
269
+ let currentPhase = 'download';
260
270
  try {
261
- await fs.access(archivePath);
262
- if (verbose) {
263
- console.log('Using cached download');
271
+ const platformKey = getPlatformKey();
272
+ const distribution = PBS_DISTRIBUTIONS[platformKey];
273
+
274
+ const cacheDir = getCacheDir();
275
+ const pythonDir = path.join(
276
+ cacheDir,
277
+ 'python',
278
+ `${PYTHON_VERSION}-${platformKey}`
279
+ );
280
+ const archivePath = path.join(cacheDir, 'downloads', distribution.filename);
281
+
282
+ // Determine Python executable path based on platform
283
+ const pythonExe =
284
+ process.platform === 'win32'
285
+ ? path.join(pythonDir, 'python.exe')
286
+ : path.join(pythonDir, 'bin', 'python3');
287
+
288
+ // Check if Python is already installed (silent skip per FR-007)
289
+ try {
290
+ await fs.access(pythonExe);
291
+ if (verbose) {
292
+ console.log(`Using cached Python runtime: ${pythonExe}`);
293
+ }
294
+ // Skip both download and extract phases silently
295
+ onProgress?.({ type: 'skip', phaseId: 'download' });
296
+ onProgress?.({ type: 'skip', phaseId: 'extract' });
297
+ return { pythonPath: pythonExe, version: PYTHON_VERSION, isMatch: true };
298
+ } catch {
299
+ // Python not found, proceed with download and extraction
264
300
  }
265
- // Skip download phase silently
266
- onProgress?.({ type: 'skip', phaseId: 'download' });
267
- } catch {
268
- needsDownload = true;
269
- }
270
301
 
271
- if (needsDownload) {
272
- // Start download phase
273
- onProgress?.({ type: 'start', phaseId: 'download' });
302
+ if (!onProgress) {
303
+ console.log(
304
+ `Setting up Python ${PYTHON_VERSION} runtime for ${platformKey}...`
305
+ );
306
+ }
307
+
308
+ // Download if not cached
309
+ let needsDownload = false;
274
310
  try {
311
+ await fs.access(archivePath);
312
+ if (verbose) {
313
+ console.log('Using cached download');
314
+ }
315
+ // Skip download phase silently
316
+ onProgress?.({ type: 'skip', phaseId: 'download' });
317
+ } catch {
318
+ needsDownload = true;
319
+ }
320
+
321
+ if (needsDownload) {
322
+ // Start download phase
323
+ onProgress?.({ type: 'start', phaseId: 'download' });
275
324
  const downloadUrl = `${PBS_BASE_URL}/${distribution.filename}`;
276
325
  await downloadFile(
277
326
  downloadUrl,
@@ -280,29 +329,52 @@ export async function ensurePythonRuntime(verbose = false, onProgress) {
280
329
  onProgress
281
330
  );
282
331
  onProgress?.({ type: 'complete', phaseId: 'download' });
283
- } catch (error) {
284
- onProgress?.({ type: 'error', phaseId: 'download', error });
285
- throw error;
286
332
  }
287
- }
288
333
 
289
- // Extract phase
290
- onProgress?.({ type: 'start', phaseId: 'extract' });
291
- try {
334
+ // Extract phase
335
+ currentPhase = 'extract';
336
+ onProgress?.({ type: 'start', phaseId: 'extract' });
292
337
  await extractTarGz(archivePath, pythonDir, onProgress);
293
338
  onProgress?.({ type: 'complete', phaseId: 'extract' });
294
- } catch (error) {
295
- onProgress?.({ type: 'error', phaseId: 'extract', error });
296
- throw error;
297
- }
298
339
 
299
- // Verify Python executable exists
300
- await fs.access(pythonExe);
340
+ // Verify Python executable exists
341
+ await fs.access(pythonExe);
301
342
 
302
- if (!onProgress) {
303
- console.log(`Python runtime ready: ${pythonExe}`);
343
+ if (!onProgress) {
344
+ console.log(`Python runtime ready: ${pythonExe}`);
345
+ }
346
+ return { pythonPath: pythonExe, version: PYTHON_VERSION, isMatch: true };
347
+ } catch (pbsError) {
348
+ // PBS setup failed (unsupported platform, download, or extract) — attempt PYTHON_PATH fallback
349
+ onProgress?.({ type: 'error', phaseId: currentPhase, error: pbsError });
350
+
351
+ if (verbose) {
352
+ console.error(
353
+ `Python setup failed: ${pbsError.message}. Checking PYTHON_PATH fallback...`
354
+ );
355
+ }
356
+
357
+ try {
358
+ const fallback = await _resolveFallbackPython();
359
+ if (verbose) {
360
+ console.log(
361
+ `Using PYTHON_PATH fallback: ${fallback.pythonPath} (Python ${fallback.version})`
362
+ );
363
+ }
364
+ // Mark extract as skipped since we're using an external Python
365
+ onProgress?.({ type: 'skip', phaseId: 'extract' });
366
+ return fallback;
367
+ } catch (fallbackError) {
368
+ // Neither PBS nor PYTHON_PATH worked
369
+ const err = new Error(
370
+ `Python ${PYTHON_VERSION} could not be set up.\n` +
371
+ ` PBS setup failed: ${pbsError.message}\n` +
372
+ ` PYTHON_PATH fallback: ${fallbackError.message}`
373
+ );
374
+ err.code = 'PYTHON_NOT_FOUND';
375
+ throw err;
376
+ }
304
377
  }
305
- return pythonExe;
306
378
  }
307
379
 
308
380
  /**
@@ -321,9 +321,10 @@ async function writeRequirementsMarker(venvDir, requirementsHash) {
321
321
  export async function ensureVenv(
322
322
  requirementsPath,
323
323
  verbose = false,
324
- onProgress
324
+ onProgress,
325
+ pythonExePath
325
326
  ) {
326
- const pythonExe = await getPythonExecutable();
327
+ const pythonExe = pythonExePath || (await getPythonExecutable());
327
328
  const venvDir = getVenvDir();
328
329
  const requirementsHash = await getRequirementsHash(requirementsPath);
329
330
 
@@ -0,0 +1,268 @@
1
+ import { PYTHON_VERSION } from './python-runtime.js';
2
+
3
+ /**
4
+ * Minimum Python major.minor that is still receiving security updates.
5
+ *
6
+ * This MUST be reviewed against the official Python release schedule
7
+ * (https://devguide.python.org/versions/) when bumping PYTHON_VERSION
8
+ * or whenever a previously supported branch reaches end-of-life.
9
+ *
10
+ * Versions older than this are hard-blocked because they no longer
11
+ * receive security patches and pose a supply-chain risk to users.
12
+ */
13
+ export const MIN_SUPPORTED_PYTHON_MAJOR_MINOR = '3.10';
14
+
15
+ /**
16
+ * Parse a version string into { major, minor }, or null if it cannot
17
+ * be parsed (e.g. the literal string 'unknown').
18
+ * @param {string} version
19
+ * @returns {{ major: number, minor: number } | null}
20
+ */
21
+ function parseMajorMinor(version) {
22
+ const match = String(version).match(/^(\d+)\.(\d+)/);
23
+ if (!match) return null;
24
+ return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10) };
25
+ }
26
+
27
+ /**
28
+ * Returns true when the detected Python version is older than the
29
+ * minimum supported major.minor and is therefore considered EOL.
30
+ *
31
+ * Returns false for unparseable versions (e.g. 'unknown') so the
32
+ * caller's regular version-mismatch flow can handle them.
33
+ *
34
+ * @param {string} version - Detected Python version string (e.g. "3.8.10")
35
+ * @returns {boolean}
36
+ */
37
+ export function _isPythonVersionEol(version) {
38
+ const detected = parseMajorMinor(version);
39
+ if (!detected) return false;
40
+ const min = parseMajorMinor(MIN_SUPPORTED_PYTHON_MAJOR_MINOR);
41
+ if (detected.major < min.major) return true;
42
+ if (detected.major === min.major && detected.minor < min.minor) return true;
43
+ return false;
44
+ }
45
+
46
+ /**
47
+ * Build the user-facing error message for an EOL Python version that
48
+ * has been hard-blocked. EOL versions are blocked unconditionally
49
+ * (no override prompt) because they no longer receive security fixes.
50
+ *
51
+ * @param {string} version - Detected Python version string
52
+ * @returns {string[]} Array of lines to print
53
+ */
54
+ export function _formatEolPythonError(version) {
55
+ return [
56
+ `\nāŒ Python ${version} has reached end-of-life and is not supported.`,
57
+ ' Continuing would expose you to known security vulnerabilities,',
58
+ ' so this version is blocked unconditionally.\n',
59
+ ` Minimum supported version: Python ${MIN_SUPPORTED_PYTHON_MAJOR_MINOR}`,
60
+ ` Recommended version: Python ${PYTHON_VERSION}`,
61
+ ' Python release schedule: https://devguide.python.org/versions/',
62
+ ' Download a supported build: https://www.python.org/downloads/\n',
63
+ ];
64
+ }
65
+
66
+ /**
67
+ * Build the user-facing error message for PYTHON_NOT_FOUND failures.
68
+ * @param {string} platform - process.platform value
69
+ * @returns {string[]} Array of lines to print
70
+ */
71
+ export function _formatPythonNotFoundError(platform) {
72
+ const lines = [
73
+ `\nāŒ Python ${PYTHON_VERSION} is required but could not be found.\n`,
74
+ 'The automatic download failed, and no valid fallback was detected.',
75
+ 'To provide Python manually, set the PYTHON_PATH environment variable:\n',
76
+ ];
77
+
78
+ if (platform === 'win32') {
79
+ lines.push(' set PYTHON_PATH=C:\\path\\to\\python.exe');
80
+ } else {
81
+ lines.push(' export PYTHON_PATH=/path/to/python3');
82
+ }
83
+
84
+ lines.push(
85
+ `\nRequired version: Python ${PYTHON_VERSION}`,
86
+ 'Download: https://www.python.org/downloads/\n'
87
+ );
88
+
89
+ return lines;
90
+ }
91
+
92
+ /**
93
+ * Evaluate what action to take when the resolved Python version doesn't match.
94
+ *
95
+ * EOL versions are hard-rejected even when the user answers "yes" — this
96
+ * defends against the y/n prompt being used to bypass the security gate
97
+ * and is checked here in addition to the upfront check in the CLI.
98
+ *
99
+ * @param {string} version - The detected Python version string
100
+ * @param {{ isInteractive: boolean, promptAnswer?: string }} options
101
+ * @returns {{ action: 'continue' | 'reject', reason: string }}
102
+ */
103
+ export function _evaluateVersionMismatch(
104
+ version,
105
+ { isInteractive, promptAnswer }
106
+ ) {
107
+ if (_isPythonVersionEol(version)) {
108
+ return {
109
+ action: 'reject',
110
+ reason: `Python ${version} has reached end-of-life and cannot be used. Install Python ${MIN_SUPPORTED_PYTHON_MAJOR_MINOR} or newer (Python ${PYTHON_VERSION} recommended).`,
111
+ };
112
+ }
113
+
114
+ if (!isInteractive) {
115
+ return {
116
+ action: 'reject',
117
+ reason: `Python version mismatch (found ${version}). In non-interactive environments, PYTHON_PATH must point to Python ${PYTHON_VERSION}.`,
118
+ };
119
+ }
120
+
121
+ const answer = (promptAnswer || '').trim().toLowerCase();
122
+ if (answer === 'y' || answer === 'yes') {
123
+ return { action: 'continue', reason: 'User accepted version mismatch.' };
124
+ }
125
+
126
+ return {
127
+ action: 'reject',
128
+ reason: `Aborted. Please install Python ${PYTHON_VERSION} or set PYTHON_PATH to a compatible version.`,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Handle a Python version mismatch with side effects routed through
134
+ * injected callbacks so the full decision tree is unit-testable without
135
+ * touching real stdout/stderr, readline, or process.exit.
136
+ *
137
+ * Behaviour mirrors the inline block previously in runevals.js:
138
+ * - EOL versions are hard-blocked (no prompt) regardless of interactivity.
139
+ * - Interactive terminals warn, prompt, and respect the user's answer
140
+ * (subject to the EOL block enforced inside _evaluateVersionMismatch).
141
+ * - Non-interactive environments auto-reject.
142
+ *
143
+ * @param {object} args
144
+ * @param {{ version: string, isMatch: boolean }} args.runtime - Detected runtime info.
145
+ * @param {boolean} args.isInteractive - Whether we are in an interactive terminal.
146
+ * @param {() => Promise<string>} [args.promptForContinue] - Async function that
147
+ * resolves with the user's y/n answer. Required when isInteractive is true.
148
+ * @param {(msg: string) => void} args.warn - Sink for warning messages.
149
+ * @param {(msg: string) => void} args.error - Sink for error messages.
150
+ * @returns {Promise<{ shouldExit: boolean, exitCode?: number }>}
151
+ * When `shouldExit` is true the caller should `process.exit(exitCode)`.
152
+ * When false, initialization may continue with the mismatched runtime.
153
+ */
154
+ export async function _handlePythonVersionMismatch({
155
+ runtime,
156
+ isInteractive,
157
+ promptForContinue,
158
+ warn,
159
+ error,
160
+ }) {
161
+ if (runtime.isMatch) {
162
+ return { shouldExit: false };
163
+ }
164
+
165
+ if (_isPythonVersionEol(runtime.version)) {
166
+ for (const line of _formatEolPythonError(runtime.version)) {
167
+ error(line);
168
+ }
169
+ return { shouldExit: true, exitCode: 1 };
170
+ }
171
+
172
+ if (isInteractive) {
173
+ warn(
174
+ `\nāš ļø Python version mismatch. Expected ${PYTHON_VERSION}, found ${runtime.version}.`
175
+ );
176
+
177
+ const answer = await promptForContinue();
178
+
179
+ const result = _evaluateVersionMismatch(runtime.version, {
180
+ isInteractive: true,
181
+ promptAnswer: answer,
182
+ });
183
+ if (result.action === 'reject') {
184
+ error(`\n${result.reason}`);
185
+ error('Download: https://www.python.org/downloads/\n');
186
+ return { shouldExit: true, exitCode: 1 };
187
+ }
188
+ return { shouldExit: false };
189
+ }
190
+
191
+ // Non-interactive (CI/CD) — auto-reject version mismatch
192
+ const result = _evaluateVersionMismatch(runtime.version, {
193
+ isInteractive: false,
194
+ });
195
+ error(`\nāŒ ${result.reason}`);
196
+ return { shouldExit: true, exitCode: 1 };
197
+ }
198
+
199
+ /**
200
+ * Build the user-facing error guidance lines printed when Python
201
+ * environment initialization fails. Returns the lines as an array so
202
+ * the caller can route them to console.error and the result is testable
203
+ * without intercepting console.
204
+ *
205
+ * If `error.code === 'PYTHON_NOT_FOUND'`, the platform-specific
206
+ * PYTHON_NOT_FOUND guidance is included before the generic troubleshooting
207
+ * tips.
208
+ *
209
+ * @param {{ error: { code?: string }, platform: string }} args
210
+ * @returns {string[]}
211
+ */
212
+ export function _buildInitializationFailureLines({ error, platform }) {
213
+ const lines = [];
214
+
215
+ if (error && error.code === 'PYTHON_NOT_FOUND') {
216
+ for (const line of _formatPythonNotFoundError(platform)) {
217
+ lines.push(line);
218
+ }
219
+ }
220
+
221
+ lines.push(
222
+ 'Troubleshooting:',
223
+ ' - Check your internet connection',
224
+ ' - If behind a proxy, set HTTP_PROXY/HTTPS_PROXY environment variables',
225
+ ' - For SSL issues, set NODE_EXTRA_CA_CERTS or PIP_CERT',
226
+ ' - Run with --log-level debug for detailed output'
227
+ );
228
+
229
+ return lines;
230
+ }
231
+
232
+ /**
233
+ * Prompt the user to continue past a Python version mismatch.
234
+ *
235
+ * The readline factory is injected so this can be unit-tested with a
236
+ * fake interface. The default factory uses Node's built-in readline
237
+ * bound to process.stdin/process.stdout. The interface is always
238
+ * closed via try/finally so a Ctrl+C does not leave the event loop
239
+ * holding stdin open.
240
+ *
241
+ * @param {object} [deps]
242
+ * @param {() => { question: (q: string, cb: (answer: string) => void) => void, close: () => void }} [deps.createInterface]
243
+ * Factory returning a readline-compatible interface.
244
+ * @returns {Promise<string>} The user's raw answer (untrimmed).
245
+ */
246
+ export async function _promptForContinueWithMismatch(deps = {}) {
247
+ const createInterface =
248
+ deps.createInterface ||
249
+ (async () => {
250
+ const readline = await import('readline');
251
+ return readline.createInterface({
252
+ input: process.stdin,
253
+ output: process.stdout,
254
+ });
255
+ });
256
+
257
+ const rl = await createInterface();
258
+ try {
259
+ return await new Promise((resolve) => {
260
+ rl.question(
261
+ 'Would you like to continue with unsupported Python version? (y/n): ',
262
+ resolve
263
+ );
264
+ });
265
+ } finally {
266
+ rl.close();
267
+ }
268
+ }